From b7c2366f6c9f55c7bf12d47f45e68de03c516ceb Mon Sep 17 00:00:00 2001 From: Logan Rupe Date: Thu, 1 Oct 2026 10:10:14 +1000 Subject: [PATCH] fix(web): chat file links and the files panel refresh follow the workspace folder list Chat file links now resolve against the same roots the files panel lists, so a link into a nested folder, a folder outside the workspace folder, or an isolated run's worktree is highlighted in the tree. The panel's refresh button re-reads the .code-workspace, and the tree waits for the first read instead of showing the fallback roots. --- apps/web/src/components/ChatView.tsx | 22 ++++++++--- .../src/components/files/FileBrowserPanel.tsx | 4 ++ .../src/components/files/FilePreviewPanel.tsx | 39 ++++++++++++------- apps/web/src/lib/projectFileRoots.test.ts | 27 +++++++++++++ 4 files changed, 71 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5afd6d8d4e0b..8c10ab23d43e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3673,9 +3673,9 @@ export default function ChatView(props: ChatViewProps) { : null, [multiRepoStatusRoots, gitStatusCwd], ); - // The files panel and @-mention search span the folders a `.code-workspace` - // lists, while git surfaces stay on `repoRoots`. Null searches the - // worktree-aware cwd alone. + // The files panel, @-mention search and chat file links span the folders a + // `.code-workspace` lists, while git surfaces stay on `repoRoots`. Null + // searches the worktree-aware cwd alone. const workspaceFileQuery = useEnvironmentQuery( projectWorkspaceFile === null ? null @@ -3685,6 +3685,12 @@ export default function ChatView(props: ChatViewProps) { }), ); const workspaceFolders = workspaceFileQuery.data?.folders ?? null; + // The first read of the folder list has not settled. The files panel holds + // its tree until then, since the fallback roots can show unlisted folders. + const projectFileRootsPending = + projectWorkspaceFile !== null && + workspaceFileQuery.data === null && + workspaceFileQuery.error === null; const projectFileRoots = useMemo( () => resolveProjectFileRoots({ @@ -3694,6 +3700,10 @@ export default function ChatView(props: ChatViewProps) { }), [workspaceFolders, activeProject?.repoRoots, threadWorktrees], ); + const fileLinkRoots = useMemo( + () => projectFileRoots?.map(({ root }) => root), + [projectFileRoots], + ); // Single-repo git query scoped to the anchor root, backing the branch-sync, // PR-checkout, and refresh flows; the multi-repo status/diff surfaces use // `vcsStatusGroups` below. @@ -9787,6 +9797,8 @@ export default function ChatView(props: ChatViewProps) { keybindings={keybindings} availableEditors={availableEditors} roots={projectFileRoots ?? undefined} + rootsPending={projectFileRootsPending} + onRefreshRoots={workspaceFileQuery.refresh} relativePath={ renderedRightPanelSurface.kind === "file" ? renderedRightPanelSurface.relativePath @@ -10003,9 +10015,7 @@ export default function ChatView(props: ChatViewProps) { ? (heldPaintContext?.markdownCwd ?? undefined) : (gitCwd ?? undefined) } - markdownRepoRoots={ - !paintOnlyDisplayedTimeline && isMultiRepo ? activeProject?.repoRoots : undefined - } + markdownRepoRoots={paintOnlyDisplayedTimeline ? undefined : fileLinkRoots} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={ diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index aad4dc69243b..8f2b131a234f 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -46,6 +46,8 @@ interface FileBrowserPanelProps { roots?: readonly ProjectFileRoot[] | undefined; onOpenFile: (relativePath: string, root?: string) => void; onRefreshSelectedFile?: () => void; + /** Re-resolves `roots`, so a refresh also picks up a changed root list. */ + onRefreshRoots?: () => void; workspaceMutationId: string | null; } @@ -117,6 +119,7 @@ export default function FileBrowserPanel({ roots, onOpenFile, onRefreshSelectedFile, + onRefreshRoots, workspaceMutationId, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); @@ -439,6 +442,7 @@ export default function FileBrowserPanel({ refresh(); if (query.trim()) pathSearch.refresh(); onRefreshSelectedFile?.(); + onRefreshRoots?.(); }; useWorkspaceMutationRefresh({ mutationId: workspaceMutationId, diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 544b3a14580a..8688d5f943ae 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -107,6 +107,10 @@ interface FilePreviewPanelProps { revealRequestId: number; // Multi-root projects (#923): roots to list/group in the file tree. roots?: readonly ProjectFileRoot[] | undefined; + // True while `roots` is still being resolved; the file tree waits for it. + rootsPending?: boolean | undefined; + // Re-resolves `roots` from their source when the user refreshes the tree. + onRefreshRoots?: (() => void) | undefined; // Owning root of the currently-open file. Reads/writes resolve against this // root (it may be a different repo than the anchor `cwd`). Null = anchor. fileRoot?: string | null | undefined; @@ -929,6 +933,8 @@ export default function FilePreviewPanel({ revealLine, revealRequestId, roots, + rootsPending, + onRefreshRoots, fileRoot, onOpenFile, onPendingChange, @@ -1320,21 +1326,24 @@ export default function FilePreviewPanel({ > {/* The tree caches folders by label, so a root that changes path under the same label must remount it rather than show the old contents. */} - + {rootsPending ? null : ( + + )} ) : null} diff --git a/apps/web/src/lib/projectFileRoots.test.ts b/apps/web/src/lib/projectFileRoots.test.ts index 742eabb1f88f..7ce4224334f1 100644 --- a/apps/web/src/lib/projectFileRoots.test.ts +++ b/apps/web/src/lib/projectFileRoots.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; +import { resolveMarkdownFileLinkMeta } from "../markdown-links"; import { resolveProjectFileRoots } from "./projectFileRoots"; const folder = (absolutePath: string, exists = true) => ({ absolutePath, exists }); @@ -78,4 +79,30 @@ describe("resolveProjectFileRoots", () => { }); expect(roots?.map((entry) => entry.label)).toEqual(["a/docs", "b/docs"]); }); + + // Chat file links are matched against these roots, so a link has to land on + // the root the panel lists the file under or the tree cannot highlight it. + it("claims chat links for nested, outside and worktree roots", () => { + const roots = paths( + resolveProjectFileRoots({ + workspaceFolders: [folder("/ws/api"), folder("/ws/notes/2026"), folder("/elsewhere/notes")], + repoRoots: ["/ws/api"], + worktrees: [{ repoRoot: "/ws/api", worktreePath: "/wt/feature/api" }], + }), + ); + const link = (path: string) => + resolveMarkdownFileLinkMeta(path, "/ws", undefined, roots ?? undefined); + expect(link("/ws/notes/2026/plan.md")).toMatchObject({ + fileRoot: "/ws/notes/2026", + workspaceRelativePath: "plan.md", + }); + expect(link("/elsewhere/notes/todo.md")).toMatchObject({ + fileRoot: "/elsewhere/notes", + workspaceRelativePath: "todo.md", + }); + expect(link("/wt/feature/api/src/main.ts")).toMatchObject({ + fileRoot: "/wt/feature/api", + workspaceRelativePath: "src/main.ts", + }); + }); });