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",
+ });
+ });
});