Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Comment on lines 3687 to +3693

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one. Keeping the last list that read successfully is what I want when a refresh fails: the fallback shows unlisted folders, so dropping to it on a transient relay error would be worse than a stale list. I've corrected the PR description, which only described the first-read failure.

const projectFileRoots = useMemo(
() =>
resolveProjectFileRoots({
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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={
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/files/FileBrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -117,6 +119,7 @@ export default function FileBrowserPanel({
roots,
onOpenFile,
onRefreshSelectedFile,
onRefreshRoots,
workspaceMutationId,
}: FileBrowserPanelProps) {
const { resolvedTheme } = useTheme();
Expand Down Expand Up @@ -439,6 +442,7 @@ export default function FileBrowserPanel({
refresh();
if (query.trim()) pathSearch.refresh();
onRefreshSelectedFile?.();
onRefreshRoots?.();
};
useWorkspaceMutationRefresh({
mutationId: workspaceMutationId,
Expand Down
39 changes: 24 additions & 15 deletions apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -929,6 +933,8 @@ export default function FilePreviewPanel({
revealLine,
revealRequestId,
roots,
rootsPending,
onRefreshRoots,
fileRoot,
onOpenFile,
onPendingChange,
Expand Down Expand Up @@ -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. */}
<FileBrowserPanel
key={`${environmentId}:${cwd}:${projectFileRootsKey(roots)}`}
environmentId={environmentId}
cwd={cwd}
projectName={projectName}
selectedPath={relativePath}
selectedRoot={fileRoot ?? undefined}
selectedPathRevealId={revealRequestId}
roots={roots}
onOpenFile={onOpenFile}
workspaceMutationId={workspaceMutationId}
{...(previewPath && !isMedia && !isPdf
? { onRefreshSelectedFile: file.refresh }
: {})}
/>
{rootsPending ? null : (
<FileBrowserPanel
key={`${environmentId}:${cwd}:${projectFileRootsKey(roots)}`}
environmentId={environmentId}
cwd={cwd}
projectName={projectName}
selectedPath={relativePath}
selectedRoot={fileRoot ?? undefined}
selectedPathRevealId={revealRequestId}
roots={roots}
onOpenFile={onOpenFile}
workspaceMutationId={workspaceMutationId}
{...(onRefreshRoots ? { onRefreshRoots } : {})}
{...(previewPath && !isMedia && !isPdf
? { onRefreshSelectedFile: file.refresh }
: {})}
/>
)}
</aside>
) : null}
</div>
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/lib/projectFileRoots.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
Expand Down Expand Up @@ -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",
});
});
});
Loading