diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx
index 503f9b60c1b5d..604889da5bb68 100644
--- a/apps/web/src/components/DiffPanel.browser.test.tsx
+++ b/apps/web/src/components/DiffPanel.browser.test.tsx
@@ -94,6 +94,7 @@ vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null
import DiffPanel from "./DiffPanel";
import { useDiffPanelSessionStore, useDiffPanelStore } from "~/diffPanelStore";
+import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore";
const cwd = "/project-worktree";
const errorMessage = "Could not find a common commit between 'origin/orphan' and HEAD.";
@@ -126,6 +127,7 @@ function setup(failedBaseRef: string | null) {
branchBaseRefByThreadKey: {},
});
useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} });
+ useRightPanelStore.setState({ byThreadKey: {}, threadPanelVisibilityByThreadKey: {} });
};
reset();
state.diffFilesCollapsed = false;
@@ -233,6 +235,36 @@ it.each([
},
);
+it("opens a repository-relative file from a nested project without a remote", async () => {
+ using context = setup("origin/orphan");
+ state.thread = { ...state.thread!, worktreePath: null };
+ const path = "project/back\\slash.txt";
+ const metadata = preview();
+ const source = {
+ ...metadata.sources[0]!,
+ files: [{ path, previousPath: null, additions: 1, deletions: 0 }],
+ };
+ state.previews.set("main", Atom.make(AsyncResult.success({ ...metadata, sources: [source] })));
+ state.status = Atom.make(
+ AsyncResult.success({
+ ...AsyncResult.getOrElse(context.registry.get(state.status), () => {
+ throw new Error("Missing status");
+ }),
+ workingTree: { rootPath: "/", files: [], insertions: 0, deletions: 0 },
+ }),
+ );
+ useDiffPanelStore.getState().selectBranchBaseRef(context.threadRef, "main");
+ await using screen = await render(
+
+
+ ,
+ );
+ await userEvent.click(screen.getByText(path, { exact: true }));
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, context.threadRef),
+ ).toMatchObject({ activeSurfaceId: "file:back\\slash.txt" });
+});
+
it.each([false, true])(
"expands an unloaded file on the first click with default collapse %s",
async (collapsed) => {
diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx
index 71c197b6ce363..2ec6d51997ace 100644
--- a/apps/web/src/components/DiffPanel.tsx
+++ b/apps/web/src/components/DiffPanel.tsx
@@ -254,9 +254,6 @@ export default function DiffPanel({
: null,
);
const activeCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot;
- const activeRepositoryRoot = activeThread?.worktreePath
- ? undefined
- : activeProject?.repositoryIdentity?.rootPath;
const serverConfig = useAtomValue(
serverEnvironment.configValueAtom(activeThread?.environmentId ?? null),
);
@@ -274,6 +271,9 @@ export default function DiffPanel({
})
: null,
);
+ const activeRepositoryRoot =
+ gitStatusQuery.data?.workingTree.rootPath ??
+ (activeThread?.worktreePath ? undefined : activeProject?.repositoryIdentity?.rootPath);
const diffSelection = useDiffPanelStore((state) =>
selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef),
);
diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx
index 1d03161e83014..b62f6f8e4b10f 100644
--- a/apps/web/src/components/chat/TimelineAssistantRows.tsx
+++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx
@@ -32,6 +32,11 @@ import { readThreadTurnItems } from "../../state/entities";
import { useV2ItemSupport } from "../../state/v2ItemSupport";
import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext";
import { MESSAGE_HEADING_LEVEL, MessageAuthorHeading } from "./TimelineRowParts";
+import { useAtomValue } from "@effect/atom-react";
+import { AsyncResult, Atom } from "effect/reactivity";
+import { vcsEnvironment } from "../../state/vcs";
+
+const NO_VCS_STATUS = Atom.make(AsyncResult.initial());
export function AssistantTimelineRow({ row }: { row: Extract }) {
const ctx = use(TimelineRowCtx);
@@ -460,6 +465,15 @@ function AssistantChangedFilesSectionInner({
: null,
);
const onFileContextMenu = useFileContextMenuHandler(ctx.activeThreadEnvironmentId);
+ const repositoryRoot = useAtomValue(
+ ctx.workspaceRoot
+ ? vcsEnvironment.status({
+ environmentId: ctx.activeThreadEnvironmentId,
+ input: { cwd: ctx.workspaceRoot },
+ })
+ : NO_VCS_STATUS,
+ (status) => AsyncResult.getOrElse(status, () => null)?.workingTree.rootPath,
+ );
return (
{
).toBe("frontend/Dockerfile");
});
- it("handles Windows roots and mixed diff separators", () => {
- expect(
- resolveDiffPathForWorkspace({
- filePath: "Frontend/src\\index.ts",
- workspaceRoot: "C:\\repo\\frontend",
- repositoryRoot: "C:\\repo",
- }),
- ).toBe("src/index.ts");
- });
+ it.each([
+ {
+ filePath: "Frontend/src\\index.ts",
+ workspaceRoot: "C:\\repo\\frontend",
+ repositoryRoot: "C:\\repo",
+ expected: "src/index.ts",
+ },
+ {
+ filePath: "Frontend\\src\\index.ts",
+ workspaceRoot: "C:\\repo\\frontend",
+ repositoryRoot: "C:\\repo",
+ expected: "src/index.ts",
+ },
+ {
+ filePath: "frontend/back\\slash.txt",
+ workspaceRoot: "/repo/frontend",
+ repositoryRoot: "/repo",
+ expected: "back\\slash.txt",
+ },
+ {
+ filePath: "back/slash/a.ts",
+ workspaceRoot: "/repo/back\\slash",
+ repositoryRoot: "/repo",
+ expected: null,
+ },
+ ])(
+ "resolves $filePath in $workspaceRoot",
+ ({ filePath, workspaceRoot, repositoryRoot, expected }) => {
+ expect(resolveDiffPathForWorkspace({ filePath, workspaceRoot, repositoryRoot })).toBe(
+ expected,
+ );
+ },
+ );
// regression: #6174 opening diff files broke for repositories at a filesystem root
it.each([
@@ -89,6 +113,7 @@ describe("openDiffFilePrimaryAction", () => {
"backend/server.ts",
"frontend2/app.ts",
"frontend/../secret.ts",
+ "frontend/..\\secret.ts",
"C:secret.ts",
"/repo/backend/server.ts",
"/repo/frontend/../backend/server.ts",
diff --git a/apps/web/src/diffFileActions.ts b/apps/web/src/diffFileActions.ts
index c6a3fa3d436fc..046baa4ce5b64 100644
--- a/apps/web/src/diffFileActions.ts
+++ b/apps/web/src/diffFileActions.ts
@@ -17,16 +17,22 @@ interface OpenDiffFilePrimaryActionInput {
readonly openInEditor: (targetPath: string) => void;
}
-function normalizedRelativePathSegments(filePath: string): ReadonlyArray | null {
+function normalizedRelativePathSegments(
+ filePath: string,
+ windowsRoot: boolean,
+): ReadonlyArray | null {
if (filePath.startsWith("/") || isWindowsAbsolutePath(filePath) || /^[a-zA-Z]:/.test(filePath)) {
return null;
}
- const segments = filePath
- .replaceAll("\\", "/")
- .split("/")
- .filter((segment) => segment.length > 0 && segment !== ".");
- if (segments.length === 0 || segments.includes("..")) return null;
+ // On POSIX a backslash is part of a file name; Windows treats it as a separator
+ // (tool-call patches can name files with either), so reject ".." either way.
+ const parts = filePath.split(/[\\/]/);
+ if (parts.includes("..")) return null;
+ const segments = (windowsRoot ? parts : filePath.split("/")).filter(
+ (segment) => segment.length > 0 && segment !== ".",
+ );
+ if (segments.length === 0) return null;
return segments;
}
@@ -40,16 +46,13 @@ function repositoryRelativeWorkspaceSegments(
const normalizedRepositoryRoot = normalizeProjectPathForComparison(repositoryRoot);
if (normalizedWorkspaceRoot === normalizedRepositoryRoot) return [];
- const separator = normalizedRepositoryRoot.includes("\\") ? "\\" : "/";
+ const separator = isWindowsAbsolutePath(normalizedRepositoryRoot) ? "\\" : "/";
const repositoryPrefix = normalizedRepositoryRoot.endsWith(separator)
? normalizedRepositoryRoot
: `${normalizedRepositoryRoot}${separator}`;
if (!normalizedWorkspaceRoot.startsWith(repositoryPrefix)) return null;
- return normalizedWorkspaceRoot
- .slice(repositoryPrefix.length)
- .split(/[\\/]+/)
- .filter(Boolean);
+ return normalizedWorkspaceRoot.slice(repositoryPrefix.length).split(separator).filter(Boolean);
}
export function resolveDiffPathForWorkspace(input: {
@@ -57,14 +60,18 @@ export function resolveDiffPathForWorkspace(input: {
readonly workspaceRoot: string | undefined;
readonly repositoryRoot: string | undefined;
}): string | null {
+ const windowsRoot = [input.workspaceRoot, input.repositoryRoot].some(
+ (root) => root !== undefined && isWindowsAbsolutePath(root),
+ );
// Claude and ACP tool-call diffs name files by absolute path.
if (isAbsolutePath(input.filePath)) {
const relativePath = workspaceRelativeFilePath(input.filePath, input.workspaceRoot);
- const segments = relativePath === null ? null : normalizedRelativePathSegments(relativePath);
+ const segments =
+ relativePath === null ? null : normalizedRelativePathSegments(relativePath, windowsRoot);
return segments ? segments.join("/") : null;
}
- const fileSegments = normalizedRelativePathSegments(input.filePath);
+ const fileSegments = normalizedRelativePathSegments(input.filePath, windowsRoot);
if (!fileSegments) return null;
const workspaceSegments = repositoryRelativeWorkspaceSegments(