From ec931b09acde6fd3cc7035ae7b70fe3d22aeb7fb Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 9 Sep 2026 07:55:38 +0000 Subject: [PATCH 1/8] fix(web): folder links from chat reveal the folder in the file tree A folder mentioned by the agent opened as a file surface, which asked the server to read a directory and showed 'Failed to read workspace file'. The file surface now recognizes a directory from the workspace listing, skips the preview pane, and expands and selects the folder in the tree. Co-Authored-By: Claude Fable 5.1 --- .../src/components/files/FileBrowserPanel.tsx | 17 ++++++++++++---- .../components/files/FilePreviewPanel.test.ts | 20 +++++++++++++++++++ .../src/components/files/FilePreviewPanel.tsx | 15 ++++++++++++-- .../src/components/files/filePreviewMode.ts | 13 ++++++++++++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 49894db3c8cf..1803d327290f 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -30,7 +30,7 @@ interface FileBrowserPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; - /** File currently open in the preview pane; revealed and selected in the tree. */ + /** Entry currently open in the surface; revealed and selected in the tree. A directory is expanded. */ selectedPath: string | null; /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ selectedPathRevealId: number; @@ -299,8 +299,13 @@ export default function FileBrowserPanel({ ) { return; } - if (entryKinds.get(selectedPath) !== "file") return; - const selectedItem = model.getItem(selectedPath); + const selectedKind = entryKinds.get(selectedPath); + if (selectedKind === undefined) return; + // Directory rows are registered with a trailing slash (see treePath). + const selectedItem = + selectedKind === "directory" + ? model.getItem(`${selectedPath}/`) + : model.getItem(selectedPath); if (!selectedItem) return; // A selection that originated inside the tree (clicking a row, possibly @@ -334,8 +339,12 @@ export default function FileBrowserPanel({ if (item && "expand" in item) item.expand(); } + if (selectedKind === "directory" && "expand" in selectedItem) selectedItem.expand(); selectedItem.select(); - model.scrollToPath(selectedPath, { focus: true, offset: "center" }); + model.scrollToPath(selectedKind === "directory" ? `${selectedPath}/` : selectedPath, { + focus: true, + offset: "center", + }); queueMicrotask(() => { syncingSelectionRef.current = false; }); diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index 5ef590847c4b..f65c9b03ace5 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -6,6 +6,7 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { + isDirectoryEntry, isMarkdownPreviewFile, setMarkdownTaskChecked, shouldShowFileExplorer, @@ -120,3 +121,22 @@ describe("setMarkdownTaskChecked", () => { expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown); }); }); + +describe("isDirectoryEntry", () => { + const entries = [ + { kind: "directory" as const, path: ".agents" }, + { kind: "directory" as const, path: ".agents/skills" }, + { kind: "file" as const, path: ".agents/skills/SKILL.md" }, + ]; + + it("recognizes a listed directory with or without a trailing slash", () => { + expect(isDirectoryEntry(entries, ".agents/skills")).toBe(true); + expect(isDirectoryEntry(entries, ".agents/skills/")).toBe(true); + }); + + it("does not treat files or unknown paths as directories", () => { + expect(isDirectoryEntry(entries, ".agents/skills/SKILL.md")).toBe(false); + expect(isDirectoryEntry(entries, "missing")).toBe(false); + expect(isDirectoryEntry(undefined, ".agents")).toBe(false); + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index b739d120da63..767b0f7536c7 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -68,6 +68,7 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { + isDirectoryEntry, isMarkdownPreviewFile, setMarkdownTaskChecked, shouldShowFileExplorer, @@ -76,6 +77,7 @@ import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; import { getOptimisticProjectFileQueryData, setProjectFileQueryData, + useProjectEntriesQuery, useProjectFileQuery, } from "./projectFilesQueryState"; @@ -956,7 +958,7 @@ export default function FilePreviewPanel({ environmentId, cwd, projectName, - relativePath, + relativePath: selectedPath, attachment, threadRef, composerDraftTarget, @@ -980,6 +982,15 @@ export default function FilePreviewPanel({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); + // A chat link cannot tell a folder from a file, so a folder arrives as a file + // surface. The tree already knows every entry; a folder is revealed there + // and gets no preview pane instead of a read error. + const entries = useProjectEntriesQuery(environmentId, cwd); + const isDirectory = + selectedPath !== null && + attachment === undefined && + isDirectoryEntry(entries.data?.entries, selectedPath); + const relativePath = isDirectory ? null : selectedPath; const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); const isMedia = isImage || isVideo; @@ -1342,7 +1353,7 @@ export default function FilePreviewPanel({ environmentId={environmentId} cwd={cwd} projectName={projectName} - selectedPath={relativePath} + selectedPath={selectedPath} selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} workspaceMutationId={workspaceMutationId} diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 9770d36fa2c3..f8b249d8fe07 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -13,6 +13,19 @@ export function shouldShowFileExplorer(input: { return input.explorerOpen || input.relativePath === null; } +/** Whether a workspace path names a directory in the listed entries, with or without a trailing slash. */ +export function isDirectoryEntry( + entries: + | ReadonlyArray<{ readonly kind: "file" | "directory"; readonly path: string }> + | undefined, + relativePath: string, +): boolean { + const normalizedPath = relativePath.replace(/\/+$/, ""); + return ( + entries?.some((entry) => entry.kind === "directory" && entry.path === normalizedPath) ?? false + ); +} + export function setMarkdownTaskChecked( markdown: string, markerOffset: number, From 7ae0ea06f6624869cec115d260f1774879ad821a Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Wed, 9 Sep 2026 08:45:48 +0000 Subject: [PATCH 2/8] fix(web): detect folder links from the read failure, not the listing The workspace listing skips hidden folders and truncates large roots, so a folder like ~/.agents/skills never appeared in it and still showed the read error. The server already answers a directory read with path_not_file; the file surface now uses that signal, keeps the folder breadcrumbs, and lets the tree fill the surface. Co-Authored-By: Claude Fable 5.1 --- .../components/files/FilePreviewPanel.test.ts | 20 ---------- .../src/components/files/FilePreviewPanel.tsx | 38 +++++++++---------- .../src/components/files/filePreviewMode.ts | 13 ------- .../files/projectFilesQueryState.test.tsx | 30 +++++++++++++++ .../files/projectFilesQueryState.ts | 26 ++++++++++--- 5 files changed, 68 insertions(+), 59 deletions(-) diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts index f65c9b03ace5..5ef590847c4b 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.ts +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -6,7 +6,6 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { - isDirectoryEntry, isMarkdownPreviewFile, setMarkdownTaskChecked, shouldShowFileExplorer, @@ -121,22 +120,3 @@ describe("setMarkdownTaskChecked", () => { expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown); }); }); - -describe("isDirectoryEntry", () => { - const entries = [ - { kind: "directory" as const, path: ".agents" }, - { kind: "directory" as const, path: ".agents/skills" }, - { kind: "file" as const, path: ".agents/skills/SKILL.md" }, - ]; - - it("recognizes a listed directory with or without a trailing slash", () => { - expect(isDirectoryEntry(entries, ".agents/skills")).toBe(true); - expect(isDirectoryEntry(entries, ".agents/skills/")).toBe(true); - }); - - it("does not treat files or unknown paths as directories", () => { - expect(isDirectoryEntry(entries, ".agents/skills/SKILL.md")).toBe(false); - expect(isDirectoryEntry(entries, "missing")).toBe(false); - expect(isDirectoryEntry(undefined, ".agents")).toBe(false); - }); -}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 767b0f7536c7..08452e489871 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -68,7 +68,6 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { - isDirectoryEntry, isMarkdownPreviewFile, setMarkdownTaskChecked, shouldShowFileExplorer, @@ -77,7 +76,6 @@ import { useFileSaveCoordinator } from "./useFileSaveCoordinator"; import { getOptimisticProjectFileQueryData, setProjectFileQueryData, - useProjectEntriesQuery, useProjectFileQuery, } from "./projectFilesQueryState"; @@ -958,7 +956,7 @@ export default function FilePreviewPanel({ environmentId, cwd, projectName, - relativePath: selectedPath, + relativePath, attachment, threadRef, composerDraftTarget, @@ -982,15 +980,6 @@ export default function FilePreviewPanel({ const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false, }); - // A chat link cannot tell a folder from a file, so a folder arrives as a file - // surface. The tree already knows every entry; a folder is revealed there - // and gets no preview pane instead of a read error. - const entries = useProjectEntriesQuery(environmentId, cwd); - const isDirectory = - selectedPath !== null && - attachment === undefined && - isDirectoryEntry(entries.data?.entries, selectedPath); - const relativePath = isDirectory ? null : selectedPath; const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); const isMedia = isImage || isVideo; @@ -1006,12 +995,18 @@ export default function FilePreviewPanel({ relativePath, attachment === undefined && !isMedia && !isPdf, ); + // A chat link cannot tell a folder from a file, so a folder arrives here as + // a file surface and the read fails. Keep the breadcrumbs, drop the preview + // pane, and let the tree fill the surface with the folder revealed. + const isDirectory = file.isNotFile; const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); - const showExplorer = shouldShowFileExplorer({ - relativePath, - explorerOpen, - attachmentOpen: attachment !== undefined, - }); + const showExplorer = + isDirectory || + shouldShowFileExplorer({ + relativePath, + explorerOpen, + attachmentOpen: attachment !== undefined, + }); // Reading markdown rendered is a preference, not a property of one file. Keeping // it on the panel meant a thread switch dropped it and forced source back. const [renderMarkdownPreferred, setRenderMarkdownPreferred] = useLocalStorage( @@ -1058,6 +1053,7 @@ export default function FilePreviewPanel({ relativePath !== null && !isMedia && !isPdf && + !isDirectory && !selectedFilePending, mutationId: workspaceMutationId, refresh: file.refresh, @@ -1233,7 +1229,7 @@ export default function FilePreviewPanel({
{relativePath && attachment ? ( @@ -1343,7 +1339,7 @@ export default function FilePreviewPanel({