From f6bef73f005b9f69d0b497844c5311b191e03656 Mon Sep 17 00:00:00 2001 From: Entrpi Date: Sun, 8 Nov 2026 08:32:32 +1100 Subject: [PATCH 1/5] fix(web): a file opened from a diff in a project folder inside a repository opens that file A project can be a folder inside its repository, such as apps/web. Turn and git diffs name files from the repository root (apps/web/src/a.ts), so opening one has to know that root, while a tool call's diff names a file as its agent recorded it, absolute or relative to the project. Two cases opened the wrong file or nothing: - No remote: the diff panel and the turn's changed files card took the root from the project's repository identity, which exists only when the repository has a remote. The path was taken as relative to the project, and the Files panel opened apps/web/apps/web/src/a.ts, which doesn't exist. - With a remote: a tool call's diff with a relative path (Claude's Edit can record src/a.ts) was read as repository-relative, didn't start with apps/web, and clicking or right-clicking its header did nothing. The diff panel and the card now take the root from git status (`workingTree.rootPath`), which the diff panel and the chat view already load and which needs no remote; for a worktree thread it is the worktree, as before. A tool call's diff resolves its paths against the project only. - DiffPanel: header click, and right-click Open, Reveal and Open with. - Changed files card: right-click on a file. MessagesTimeline takes an optional `repositoryRoot`, which the chat view passes from its git status. - Mobile has no open-file action on diffs, so it needs no change. Tests: DiffPanel.browser.test.tsx "opens a file from a diff in a project inside its repository" (main opens apps/web/src/a.ts inside the project) and "opens a file from a tool call's diff by the path its agent recorded" (main opens nothing). Both open src/a.ts with this change. User story: When my project is one folder of a bigger repository, clicking a file in any diff opens that file, whether or not the repository has a remote. --- apps/web/src/components/ChatView.tsx | 3 + .../src/components/DiffPanel.browser.test.tsx | 94 ++++++++++++++++++- apps/web/src/components/DiffPanel.tsx | 12 ++- .../src/components/chat/MessagesTimeline.tsx | 4 + .../components/chat/TimelineAssistantRows.tsx | 13 +-- .../src/components/chat/timelineRowContext.ts | 1 + 6 files changed, 109 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ff4a642b5c31d..0fb94712bd9a9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1712,6 +1712,8 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; + // Git status knows the root without a remote, unlike the project's repository identity. + const activeRepositoryRoot = gitStatusQuery.data?.workingTree.rootPath; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; const liveIsGitRepo = gitStatusQuery.data?.isRepo; @@ -3507,6 +3509,7 @@ export default function ChatView(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} + repositoryRoot={activeRepositoryRoot} skills={timelineSkills} anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReadyStable} diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 874118daf306f..4a79d3317eebe 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -5,6 +5,7 @@ import { EnvironmentId, ProjectId, ThreadId, + TurnItemId, type ClientSettings, type ReviewBranchCommitsResult, type ReviewDiffPreviewResult, @@ -31,6 +32,8 @@ const state = vi.hoisted(() => ({ patch: null as unknown as ResultAtom, diffFilesCollapsed: false, config: null as unknown as Atom.Atom, + project: { workspaceRoot: "/project", repositoryIdentity: null as { rootPath: string } | null }, + toolCallItem: null as unknown, status: null as unknown as ResultAtom, localRefs: null as unknown as ResultAtom, remoteRefs: null as unknown as ResultAtom, @@ -58,7 +61,7 @@ vi.mock("~/state/vcs", () => ({ })); vi.mock("~/state/entities", () => ({ useQuietThreadShell: () => state.thread, - useProject: () => ({ workspaceRoot: "/project", repositoryIdentity: null }), + useProject: () => state.project, useThreadCheckpointSummaries: () => [], useQuietThreadVisibleTurnItems: () => [], })); @@ -78,7 +81,11 @@ vi.mock("~/hooks/useTheme", () => ({ })); vi.mock("~/state/queries", () => ({ useCheckpointDiff: () => ({ data: null, error: null, isPending: false }), - useTurnItemDetail: () => ({ data: null, error: null, isPending: false }), + useTurnItemDetail: () => ({ + data: state.toolCallItem ? { item: state.toolCallItem } : null, + error: null, + isPending: false, + }), useDebouncedValue: (value: A) => value, })); vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => {} })); @@ -91,6 +98,7 @@ vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null import DiffPanel from "./DiffPanel"; import { useDiffPanelSessionStore, useDiffPanelStore } from "~/diffPanelStore"; +import { useRightPanelStore } from "~/rightPanelStore"; const cwd = "/project-worktree"; const errorMessage = "Could not find a common commit between 'origin/orphan' and HEAD."; @@ -124,9 +132,12 @@ function setup(failedBaseRef: string | null) { keepSelectionOnOpenThreadKey: null, }); useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} }); + useRightPanelStore.setState({ byThreadKey: {} }); }; reset(); state.diffFilesCollapsed = false; + state.project = { workspaceRoot: "/project", repositoryIdentity: null }; + state.toolCallItem = null; const patch = Atom.make>( AsyncResult.initial(), ); @@ -268,3 +279,82 @@ it.each([false, true])( await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); }, ); + +// A local thread in a project that is the repository's apps/web folder. +function inRepositoryFolder(context: ReturnType, repositoryIdentity: boolean) { + state.thread = { ...state.thread!, worktreePath: null }; + state.project = { + workspaceRoot: "/repo/apps/web", + repositoryIdentity: repositoryIdentity ? { rootPath: "/repo" } : null, + }; + const status = AsyncResult.getOrThrow(context.registry.get(state.status)); + state.status = Atom.make( + AsyncResult.success({ ...status, workingTree: { ...status.workingTree, rootPath: "/repo" } }), + ); +} + +function openedFiles(threadRef: ScopedThreadRef) { + return ( + useRightPanelStore + .getState() + .byThreadKey[scopedThreadKey(threadRef)]?.surfaces.map((surface) => surface.id) ?? [] + ); +} + +it("opens a file from a diff in a project inside its repository", async () => { + using context = setup("origin/orphan"); + // Without a remote the project has no repository identity. + inRepositoryFolder(context, false); + const path = "apps/web/src/a.ts"; + const metadata = preview(); + const source = { + ...metadata.sources[0]!, + files: [{ path, previousPath: null, additions: 1, deletions: 1 }], + }; + state.previews.set("main", Atom.make(AsyncResult.success({ ...metadata, sources: [source] }))); + useDiffPanelStore.getState().selectBranchBaseRef(context.threadRef, "main"); + await using screen = await render( + +
+ +
+
, + ); + await userEvent.click(screen.getByRole("button", { name: `Expand ${path}`, exact: true })); + await act(() => + context.registry.set( + context.patch, + AsyncResult.success({ + ...source, + diff: `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -1 +1 @@\n-before\n+after\n`, + }), + ), + ); + await userEvent.click(screen.getByText(path, { exact: true })); + await vi.waitFor(() => expect(openedFiles(context.threadRef)).toContain("file:src/a.ts")); +}); + +it("opens a file from a tool call's diff by the path its agent recorded", async () => { + using context = setup("origin/orphan"); + inRepositoryFolder(context, true); + // Claude's Edit can record a path relative to the workspace, unlike the repository's diffs. + state.toolCallItem = { + type: "file_change", + fileName: "src/a.ts", + diffStr: "--- src/a.ts\n+++ src/a.ts\n@@ -1 +1 @@\n-before\n+after\n", + }; + useDiffPanelStore.getState().selectToolCall(context.threadRef, { + threadId: context.threadRef.threadId, + itemId: TurnItemId.make("edit"), + revision: "1", + }); + await using screen = await render( + +
+ +
+
, + ); + await userEvent.click(screen.getByText("src/a.ts", { exact: true })); + await vi.waitFor(() => expect(openedFiles(context.threadRef)).toContain("file:src/a.ts")); +}); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index e7e6d614ef77f..ac491ac64e9e9 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -257,9 +257,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), ); @@ -277,6 +274,8 @@ export default function DiffPanel({ }) : null, ); + // Git status knows the root without a remote, unlike the project's repository identity. + const activeRepositoryRoot = gitStatusQuery.data?.workingTree.rootPath; const diffSelection = useDiffPanelStore((state) => selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); @@ -284,6 +283,9 @@ export default function DiffPanel({ selectThreadBranchBaseRef(state, routeThreadRef), ); const toolCallSelection = diffSelection.kind === "tool-call" ? diffSelection : null; + // Turn and git diffs name files from the repository root; a tool call's diff names them as + // its provider recorded them, absolute or relative to the workspace. + const diffPathRepositoryRoot = toolCallSelection ? undefined : activeRepositoryRoot; const isGitSelection = diffSelection.kind === "branch" || diffSelection.kind === "unstaged" || @@ -727,7 +729,7 @@ export default function DiffPanel({ threadRef: routeThreadRef, filePath, activeCwd, - repositoryRoot: activeRepositoryRoot, + repositoryRoot: diffPathRepositoryRoot, openInEditor: (targetPath) => { void (async () => { const result = await openInPreferredEditor(targetPath); @@ -1417,7 +1419,7 @@ export default function DiffPanel({ environmentId: activeThread?.environmentId ?? null, filePath, workspaceRoot: activeCwd, - repositoryRoot: activeRepositoryRoot, + repositoryRoot: diffPathRepositoryRoot, }, event, ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 28d3299a7bd6d..d548a7cd36b15 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -292,6 +292,8 @@ interface MessagesTimelineProps { resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; + /** Diff paths are named from it; omitted, they are taken as workspace-relative. */ + repositoryRoot?: string | undefined; skills?: ReadonlyArray; runs: ReadonlyArray; anchorMessageId: MessageId | null; @@ -391,6 +393,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ resolvedTheme, timestampFormat, workspaceRoot, + repositoryRoot, skills = EMPTY_TIMELINE_SKILLS, runs: runsProp, anchorMessageId, @@ -1031,6 +1034,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ markdownCwd, resolvedTheme, workspaceRoot, + repositoryRoot, skills, runs, activeThreadEnvironmentId, diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 458d84b9e982b..4f5f898a696e3 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -12,7 +12,7 @@ import { McpAppFrame } from "./McpAppFrame"; import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { useFileContextMenuHandler } from "../../fileContextMenu"; -import { useAssistantMessageText, useProject, useThreadShell } from "../../state/entities"; +import { useAssistantMessageText } from "../../state/entities"; import { MessageCopyButton } from "./MessageCopyButton"; import { AssistantCitationSource } from "./AssistantCitationSource"; import { resolveAssistantMessageCopyState } from "./MessagesTimeline.logic"; @@ -342,12 +342,6 @@ function AssistantChangedFilesSectionInner({ const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const allDirectoriesExpanded = persistedExpanded ?? false; - const thread = useThreadShell(ctx.threadRef); - const activeProject = useProject( - thread && thread.projectId - ? { environmentId: thread.environmentId, projectId: thread.projectId } - : null, - ); const onFileContextMenu = useFileContextMenuHandler(ctx.activeThreadEnvironmentId); return ( @@ -367,10 +361,7 @@ function AssistantChangedFilesSectionInner({ environmentId: ctx.activeThreadEnvironmentId, filePath, workspaceRoot: ctx.workspaceRoot, - repositoryRoot: - thread?.worktreePath == null - ? activeProject?.repositoryIdentity?.rootPath - : undefined, + repositoryRoot: ctx.repositoryRoot, }, event, ) diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index 1a0c3aa014ad0..0c2f34f652dbf 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -57,6 +57,7 @@ export interface TimelineRowSharedState { markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; + repositoryRoot: string | undefined; skills: ReadonlyArray; runs: ReadonlyArray; activeThreadEnvironmentId: EnvironmentId; From 717ebef125ba50763f88f03caa2cf23ba4a1196d Mon Sep 17 00:00:00 2001 From: Entrpi Date: Sun, 8 Nov 2026 09:19:37 +1100 Subject: [PATCH 2/5] fix(web): diffs keep the repository identity's root while git status loads The diff panel and the changed files card now take the repository root from git status. Until status loads, or if it fails, that root was missing, so a repository-relative path in a project folder was read as relative to the project, a case the repository identity used to cover when the repository has a remote. The identity's root is now the fallback for a thread that isn't in a worktree, as before this branch. User story: When I click a file in a diff right after opening a thread, it opens that file even if git status hasn't loaded yet. --- apps/web/src/components/ChatView.tsx | 7 +++++-- apps/web/src/components/DiffPanel.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0fb94712bd9a9..442f223a70b13 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1712,8 +1712,11 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; - // Git status knows the root without a remote, unlike the project's repository identity. - const activeRepositoryRoot = gitStatusQuery.data?.workingTree.rootPath; + // Git status knows the root without a remote; the repository identity covers it while + // status loads or fails. A worktree thread's root is its cwd. + const activeRepositoryRoot = + gitStatusQuery.data?.workingTree.rootPath ?? + (activeThreadWorktreePath ? undefined : activeProject?.repositoryIdentity?.rootPath); const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; const liveIsGitRepo = gitStatusQuery.data?.isRepo; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index ac491ac64e9e9..84040e4a2a2ec 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -274,8 +274,11 @@ export default function DiffPanel({ }) : null, ); - // Git status knows the root without a remote, unlike the project's repository identity. - const activeRepositoryRoot = gitStatusQuery.data?.workingTree.rootPath; + // Git status knows the root without a remote; the repository identity covers it while + // status loads or fails. A worktree thread's root is its cwd. + const activeRepositoryRoot = + gitStatusQuery.data?.workingTree.rootPath ?? + (activeThread?.worktreePath ? undefined : activeProject?.repositoryIdentity?.rootPath); const diffSelection = useDiffPanelStore((state) => selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); From c1853a1e9846a7108b70c4bd9506170754e83971 Mon Sep 17 00:00:00 2001 From: Entrpi Date: Sat, 7 Nov 2026 22:03:39 +1100 Subject: [PATCH 3/5] fix(web): a tool call's diff names its files relative to the workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude records an edit with an absolute path or one relative to the workspace. With an absolute one, its patch starts "--- /Users/…/docs/plan.md", and the diff panel showed that full path in the file header. `toolCallDiff` takes the workspace root and rewrites paths under it in patch header lines (diff --git, ---, +++, rename from/to) to relative ones, the same names the agent's relative paths already have. Hunk content is untouched, and paths outside the workspace stay as recorded. The diff panel passes the thread's worktree or project root, and resolves a tool call's paths against it when a header is clicked, so the header still opens its file. Builds on "a file opened from a diff in a project folder inside a repository opens that file": before it, a relative header in a project folder of a repository with a remote opened nothing. Tests: toolCallDiff.test.ts "names files under the workspace relative to it, as recorded relative paths are" (main shows /Users/me/project/docs/plan.md), and the DiffPanel.browser.test.tsx row for an absolute path, whose header now reads src/a.ts and opens it. User story: When I open the diff of one edit, its file header reads docs/plan.md, not my home directory, and clicking it opens the file. --- .../src/components/DiffPanel.browser.test.tsx | 11 +++++---- apps/web/src/components/DiffPanel.tsx | 4 +++- apps/web/src/lib/toolCallDiff.test.ts | 24 +++++++++++++++++++ apps/web/src/lib/toolCallDiff.ts | 23 +++++++++++++++++- 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 4a79d3317eebe..413e4448b4665 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -334,14 +334,17 @@ it("opens a file from a diff in a project inside its repository", async () => { await vi.waitFor(() => expect(openedFiles(context.threadRef)).toContain("file:src/a.ts")); }); -it("opens a file from a tool call's diff by the path its agent recorded", async () => { +it.each([ + { name: "relative to the workspace", path: "src/a.ts" }, + { name: "absolute", path: "/repo/apps/web/src/a.ts" }, +])("opens a file from a tool call's diff that recorded it $name", async ({ path }) => { using context = setup("origin/orphan"); inRepositoryFolder(context, true); - // Claude's Edit can record a path relative to the workspace, unlike the repository's diffs. + // Claude's Edit records either; the header names both relative to the workspace. state.toolCallItem = { type: "file_change", - fileName: "src/a.ts", - diffStr: "--- src/a.ts\n+++ src/a.ts\n@@ -1 +1 @@\n-before\n+after\n", + fileName: path, + diffStr: `--- ${path}\n+++ ${path}\n@@ -1 +1 @@\n-before\n+after\n`, }; useDiffPanelStore.getState().selectToolCall(context.threadRef, { threadId: context.threadRef.threadId, diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 84040e4a2a2ec..ad5635ac64003 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -475,7 +475,9 @@ export default function DiffPanel({ const gitDiff = selectedGitSource?.diff; const toolCallPreview = - toolCallSelection && toolCallItem?.type === "file_change" ? toolCallDiff(toolCallItem) : null; + toolCallSelection && toolCallItem?.type === "file_change" + ? toolCallDiff(toolCallItem, activeCwd) + : null; const selectedPatch = toolCallSelection ? toolCallPreview?.patch : selectedTurn diff --git a/apps/web/src/lib/toolCallDiff.test.ts b/apps/web/src/lib/toolCallDiff.test.ts index 8efd08ddfa38e..a03246cf9286b 100644 --- a/apps/web/src/lib/toolCallDiff.test.ts +++ b/apps/web/src/lib/toolCallDiff.test.ts @@ -104,6 +104,30 @@ describe("toolCallDiff", () => { }); }); + it("names files under the workspace relative to it, as recorded relative paths are", () => { + const root = "/Users/me/project"; + const paths = (patch: string | undefined) => { + const rendered = getRenderablePatch(patch); + if (rendered?.kind !== "files") throw new Error("Expected a file diff"); + return rendered.files.map((file) => resolveFileDiffPath(file)); + }; + const recorded = `--- ${root}/docs/plan.md\n+++ ${root}/docs/plan.md\n@@ -1 +1 @@\n-a ${root}/x\n+b\n`; + const { patch } = toolCallDiff({ fileName: `${root}/docs/plan.md`, diffStr: recorded }, root); + expect(paths(patch)).toEqual(["docs/plan.md"]); + expect(patch).toContain(`-a ${root}/x`); + expect( + paths( + toolCallDiff({ fileName: `${root}/src/a.ts`, diffStr: "@@ -1 +1 @@\n-x\n+y\n" }, `${root}/`) + .patch, + ), + ).toEqual(["src/a.ts"]); + expect( + paths( + toolCallDiff({ fileName: "/elsewhere/b.ts", diffStr: "@@ -1 +1 @@\n-x\n+y\n" }, root).patch, + ), + ).toEqual(["/elsewhere/b.ts"]); + }); + it("preserves complete patches and unsupported provider output", () => { for (const operation of ["add", "delete"]) { const diffStr = [ diff --git a/apps/web/src/lib/toolCallDiff.ts b/apps/web/src/lib/toolCallDiff.ts index 2224702601d74..621da1db788cc 100644 --- a/apps/web/src/lib/toolCallDiff.ts +++ b/apps/web/src/lib/toolCallDiff.ts @@ -50,6 +50,23 @@ function toolCallPatch(item: Pick + HEADER_LINE.test(line) + ? prefixes.reduce((header, prefix) => header.replaceAll(prefix, ""), line) + : line, + ) + .join("\n"); +} + function recordedText(value: string | undefined) { const suffix = "\n… output truncated for transport"; const truncated = value !== undefined && value.endsWith(suffix); @@ -58,6 +75,7 @@ function recordedText(value: string | undefined) { export function toolCallDiff( item: Pick, + workspaceRoot?: string | null, ) { const diff = recordedText(item.diffStr); const operation = item.changes?.[0]?.operation; @@ -65,7 +83,10 @@ export function toolCallDiff( const before = recordedText(item.oldStr ?? (operation === "delete" ? emptyContent : undefined)); const after = recordedText(item.newStr ?? (operation === "add" ? emptyContent : undefined)); return { - patch: toolCallPatch({ ...item, ...(diff.text === undefined ? {} : { diffStr: diff.text }) }), + patch: relativeHeaderPaths( + toolCallPatch({ ...item, ...(diff.text === undefined ? {} : { diffStr: diff.text }) }), + workspaceRoot, + ), content: diff.text?.trim() ? [] : [ From 245d53eb524788e0af0a8e758d0cbe43c0230a72 Mon Sep 17 00:00:00 2001 From: Entrpi Date: Sun, 8 Nov 2026 09:47:46 +1100 Subject: [PATCH 4/5] fix(web): a tool call's header paths lose the workspace root only where they start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rewrite removed the workspace root anywhere in a header line, and matched hunk lines that look like headers, so `--- /backup//file.ts` became `--- /backup/file.ts`, and a removed line whose text is `-- /x` lost its root. Windows paths, which a quoted header escapes as `C:\\repo\\…`, never matched a root normalized to forward slashes. The root now comes off only where a header's path starts, after an optional quote and git's a/ or b/ prefix, compared segment by segment with /, \ or an escaped \\ between them, ignoring case for a drive-letter root. Hunk lines are counted from each @@ header and left alone, so a second file's headers after a hunk are still rewritten. User story: When an edit's diff shows a file outside my project, or a line that happens to start like a header, it shows them as recorded, and on Windows the header reads src\a.ts instead of the full path. --- apps/web/src/lib/toolCallDiff.test.ts | 26 +++++++++++++ apps/web/src/lib/toolCallDiff.ts | 56 +++++++++++++++++++++++---- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lib/toolCallDiff.test.ts b/apps/web/src/lib/toolCallDiff.test.ts index a03246cf9286b..99a44ae611b66 100644 --- a/apps/web/src/lib/toolCallDiff.test.ts +++ b/apps/web/src/lib/toolCallDiff.test.ts @@ -126,6 +126,32 @@ describe("toolCallDiff", () => { toolCallDiff({ fileName: "/elsewhere/b.ts", diffStr: "@@ -1 +1 @@\n-x\n+y\n" }, root).patch, ), ).toEqual(["/elsewhere/b.ts"]); + + // Only where a header's path starts: hunk lines that look like headers, and a path that + // merely contains the root, keep it, in this file and the next. + const twoFiles = [ + `--- ${root}/a.ts`, + `+++ ${root}/a.ts`, + "@@ -1 +1 @@", + `--- ${root}/x`, + `+++ ${root}/x`, + `--- /backup${root}/b.ts`, + `+++ /backup${root}/b.ts`, + "@@ -1 +1 @@", + "-b", + "+c", + "", + ].join("\n"); + const multiple = toolCallDiff({ fileName: `${root}/a.ts`, diffStr: twoFiles }, root).patch; + expect(paths(multiple)).toEqual(["a.ts", `/backup${root}/b.ts`]); + expect(multiple).toContain(`\n--- ${root}/x\n+++ ${root}/x\n`); + + // Windows paths, which a quoted header escapes. + const windows = toolCallDiff( + { fileName: "C:\\repo\\src\\a.ts", diffStr: "@@ -1 +1 @@\n-x\n+y\n" }, + "c:\\repo", + ).patch; + expect(paths(windows)).toEqual(["src\\a.ts"]); }); it("preserves complete patches and unsupported provider output", () => { diff --git a/apps/web/src/lib/toolCallDiff.ts b/apps/web/src/lib/toolCallDiff.ts index 621da1db788cc..7e5195f352644 100644 --- a/apps/web/src/lib/toolCallDiff.ts +++ b/apps/web/src/lib/toolCallDiff.ts @@ -51,19 +51,59 @@ function toolCallPatch(item: Pick a.toLowerCase() === b.toLowerCase() + : (a: string, b: string) => a === b; + // Removes the root only where a path starts: after an optional quote and git's a/ or b/. + const stripAt = (line: string, start: number) => { + const kept = start + /^"?(?:[ab]\/)?/.exec(line.slice(start))![0].length; + const separator = (at: number) => SEPARATOR.exec(line.slice(at))?.[0].length ?? 0; + let at = kept + separator(kept); + for (const segment of root) { + if (!sameSegment(line.slice(at, at + segment.length), segment)) return line; + at += segment.length; + const width = separator(at); + if (width === 0) return line; + at += width; + } + return line.slice(0, kept) + line.slice(at); + }; + let oldLines = 0; + let newLines = 0; return patch .split("\n") - .map((line) => - HEADER_LINE.test(line) - ? prefixes.reduce((header, prefix) => header.replaceAll(prefix, ""), line) - : line, - ) + .map((line) => { + // Hunk lines can start like headers ("--- " is a removed "-- "); count them out. + if (oldLines > 0 || newLines > 0) { + if (line.startsWith("-")) oldLines -= 1; + else if (line.startsWith("+")) newLines -= 1; + else if (!line.startsWith("\\")) { + oldLines -= 1; + newLines -= 1; + } + return line; + } + const hunk = HUNK_HEADER.exec(line); + if (hunk) { + oldLines = Number(hunk[1] ?? 1); + newLines = Number(hunk[2] ?? 1); + return line; + } + const keyword = HEADER_LINE.exec(line)?.[0]; + if (keyword === undefined) return line; + if (keyword !== "diff --git ") return stripAt(line, keyword.length); + const second = Math.max(line.lastIndexOf(' "b/'), line.lastIndexOf(" b/")); + const both = second > keyword.length ? stripAt(line, second + 1) : line; + return stripAt(both, keyword.length); + }) .join("\n"); } From 2b38aa820b06a1e11ba5139b61680700bca89738 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 04:09:21 +0000 Subject: [PATCH 5/5] fix(web): chat view reads the checkout root from its narrowed git status --- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/state/vcs.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a16f4750c3fed..70326aa1a0bbc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1691,7 +1691,7 @@ export default function ChatView(props: ChatViewProps) { // Git status knows the root without a remote; the repository identity covers it while // status loads or fails. A worktree thread's root is its cwd. const activeRepositoryRoot = - gitStatusQuery.data?.workingTree.rootPath ?? + gitStatusQuery.data?.rootPath ?? (activeThreadWorktreePath ? undefined : activeProject?.repositoryIdentity?.rootPath); const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; diff --git a/apps/web/src/state/vcs.ts b/apps/web/src/state/vcs.ts index dafacf53c27f3..1a85f070dd10e 100644 --- a/apps/web/src/state/vcs.ts +++ b/apps/web/src/state/vcs.ts @@ -18,7 +18,7 @@ export const chatGitStatusAtom = Atom.family((status: ReturnType null); if (data === null) return null; const { isRepo, refName, hasWorkingTreeChanges, pr } = data; - return { isRepo, refName, hasWorkingTreeChanges, pr }; + return { isRepo, refName, hasWorkingTreeChanges, pr, rootPath: data.workingTree.rootPath }; }).pipe(Atom.withEquality(Equal.equals)), ); export const NO_CHAT_GIT_STATUS_ATOM: ReturnType = Atom.make(null);