authorgravatar for 134572227+pc-style@users.noreply.github.compcstyle <134572227+pc-style@users.noreply.github.com> 2026-09-17 06:48:22+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 23:48:22-05:00
log886c83450797c46ee84ae28fc16c2208cafa965e
tree212155b11fa4787be58965afdd4691420788cee9
parent6d1d549441be84f19696ab59ed7e2fbf305280d4
signature Signed by PGP key B5690EEEBB952194

fix(web): folder links from chat open the file tree instead of a broken preview (#10909)

Co-authored-by: exe.dev user <exedev@ropeway-swimming.exe.xyz> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Yash Singh <saiansh2525@gmail.com>

6 files changed, 167 insertions(+), 50 deletions(-)

apps/web/src/components/files/FileBrowserPanel.tsx+13-4
......@@ -31,7 +31,7 @@ interface FileBrowserPanelProps {
3131 environmentId: EnvironmentId;
3232 cwd: string;
3333 projectName: string;
34 /** File currently open in the preview pane; revealed and selected in the tree. */
34 /** Entry currently open in the surface; revealed and selected in the tree. A directory is expanded. */
3535 selectedPath: string | null;
3636 /** Bumped when the same path should be revealed again (e.g. re-opened from search). */
3737 selectedPathRevealId: number;
......@@ -376,7 +376,10 @@ export default function FileBrowserPanel({
376376 handledRevealRef.current = null;
377377 return;
378378 }
379 if (entryKinds.get(selectedPath) !== "file") {
379 const selectedKind = entryKinds.get(selectedPath);
380 // An unloaded entry has no row to reveal yet; folders do, and chat links can
381 // point at them.
382 if (selectedKind === undefined) {
380383 handledRevealRef.current = null;
381384 return;
382385 }
......@@ -390,7 +393,9 @@ export default function FileBrowserPanel({
390393 ) {
391394 return;
392395 }
393 const selectedItem = model.getItem(selectedPath);
396 // Directory rows are registered with a trailing slash (see treePath).
397 const selectedTreePath = selectedKind === "directory" ? `${selectedPath}/` : selectedPath;
398 const selectedItem = model.getItem(selectedTreePath);
394399 if (!selectedItem) return;
395400
396401 // A selection that originated inside the tree (clicking a row, possibly
......@@ -425,8 +430,12 @@ export default function FileBrowserPanel({
425430 if (item && "expand" in item) item.expand();
426431 }
427432
433 if ("expand" in selectedItem) selectedItem.expand();
428434 selectedItem.select();
429 model.scrollToPath(selectedPath, { focus: true, offset: "center" });
435 model.scrollToPath(selectedTreePath, {
436 focus: true,
437 offset: "center",
438 });
430439 queueMicrotask(() => {
431440 syncingSelectionRef.current = false;
432441 });
apps/web/src/components/files/FilePreviewPanel.tsx+31-24
......@@ -941,15 +941,22 @@ export default function FilePreviewPanel({
941941 // A file outside the workspace (an absolute path) is shown, never edited.
942942 const isHostFile =
943943 attachment !== undefined || (relativePath !== null && isAbsolutePath(relativePath));
944 const file = useProjectFileQuery(
945 environmentId,
946 cwd,
947 relativePath,
948 attachment === undefined && !isMedia && !isPdf,
949 );
944 // Media and PDFs render from their absolute path, so their contents are never
945 // shown. The read still runs: a folder named `assets.png` is only knowable as a
946 // folder from the read failure, and the server stats before reading, so a folder
947 // costs an open and a stat and returns no body.
948 const file = useProjectFileQuery(environmentId, cwd, relativePath, attachment === undefined);
949 // A chat link cannot tell a folder from a file, so a folder arrives here as
950 // a file surface and the read fails. Keep the breadcrumbs, drop the preview
951 // pane, and let the tree fill the surface with the folder revealed. Mutation
952 // refresh stays on so the surface notices if the path becomes a file. A host
953 // path cannot be revealed in the workspace tree, so it keeps the read error.
954 const isDirectory = file.isNotFile && !isHostFile;
955 // Everything preview-related keys off previewPath; a folder has no preview.
956 const previewPath = isDirectory ? null : relativePath;
950957 const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen);
951958 const showExplorer = shouldShowFileExplorer({
952 relativePath,
959 relativePath: previewPath,
953960 explorerOpen,
954961 attachmentOpen: attachment !== undefined,
955962 });
......@@ -977,9 +984,9 @@ export default function FilePreviewPanel({
977984 null,
978985 );
979986 const breadcrumbRef = useRef<HTMLDivElement>(null);
980 const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false;
987 const isMarkdown = previewPath ? isMarkdownPreviewFile(previewPath) : false;
981988 const tableDelimiter =
982 relativePath && attachment === undefined ? filePreviewDelimiter({ name: relativePath }) : null;
989 previewPath && attachment === undefined ? filePreviewDelimiter({ name: previewPath }) : null;
983990 // A reveal still wins over the preference: the line only exists in the source.
984991 const revealHandled =
985992 revealLine === null ||
......@@ -994,12 +1001,13 @@ export default function FilePreviewPanel({
9941001 : isHtml
9951002 ? ("html" as const)
9961003 : null;
997 const canToggleRendered = attachment === undefined && renderedMode !== null;
1004 const canToggleRendered =
1005 previewPath !== null && attachment === undefined && renderedMode !== null;
9981006 const updateClientSettings = useUpdateClientSettings();
9991007 // Word wrap only reaches the text bodies. A rendered Markdown document, a table and the
10001008 // browser frame all lay themselves out, so the toggle stays hidden rather than inert.
10011009 const showsRawText =
1002 relativePath !== null &&
1010 previewPath !== null &&
10031011 file.data !== null &&
10041012 !(isMarkdown && renderMarkdown) &&
10051013 !(tableDelimiter && renderTable) &&
......@@ -1011,11 +1019,11 @@ export default function FilePreviewPanel({
10111019 ? setRenderTablePreferred
10121020 : setRenderBrowserFilePreferred;
10131021 const canOpenInBrowser =
1014 relativePath !== null &&
1022 previewPath !== null &&
10151023 attachment === undefined &&
10161024 !isVideo &&
10171025 isPreviewSupportedInRuntime() &&
1018 isBrowserPreviewFile(relativePath);
1026 isBrowserPreviewFile(previewPath);
10191027 const absolutePath =
10201028 relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null;
10211029 const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId);
......@@ -1023,8 +1031,10 @@ export default function FilePreviewPanel({
10231031 enabled:
10241032 attachment === undefined &&
10251033 relativePath !== null &&
1026 !isMedia &&
1027 !isPdf &&
1034 // Media and PDFs never show their contents, so re-reading them on every
1035 // workspace mutation is waste. A folder named like one still re-reads, so
1036 // it notices when the path becomes a file.
1037 (isDirectory || (!isMedia && !isPdf)) &&
10281038 !selectedFilePending,
10291039 mutationId: workspaceMutationId,
10301040 refresh: file.refresh,
......@@ -1145,7 +1155,7 @@ export default function FilePreviewPanel({
11451155 <Globe2 className="size-3.5" />
11461156 </FileSurfaceAction>
11471157 ) : null}
1148 {!isHostFile ? (
1158 {!isHostFile && previewPath !== null ? (
11491159 <FileSurfaceAction
11501160 label={explorerOpen ? "Hide file explorer" : "Show file explorer"}
11511161 pressed={explorerOpen}
......@@ -1156,7 +1166,7 @@ export default function FilePreviewPanel({
11561166 ) : null}
11571167 </div>
11581168 ) : null}
1159 {relativePath &&
1169 {previewPath &&
11601170 attachment === undefined &&
11611171 !isMedia &&
11621172 !renderBrowserFile &&
......@@ -1167,12 +1177,9 @@ export default function FilePreviewPanel({
11671177 ) : null}
11681178 <div className="flex min-h-0 flex-1 overflow-hidden">
11691179 <div
1170 className={cn(
1171 "min-w-0 flex-1 flex-col overflow-hidden",
1172 relativePath ? "flex" : "hidden",
1173 )}
1180 className={cn("min-w-0 flex-1 flex-col overflow-hidden", previewPath ? "flex" : "hidden")}
11741181 >
1175 {relativePath && attachment ? (
1182 {isDirectory ? null : relativePath && attachment ? (
11761183 <AttachmentFilePreview
11771184 key={`${environmentId}:${attachment.id}`}
11781185 name={attachment.name}
......@@ -1279,7 +1286,7 @@ export default function FilePreviewPanel({
12791286 <aside
12801287 className={cn(
12811288 "flex min-h-0 shrink-0 bg-background",
1282 relativePath
1289 previewPath
12831290 ? "w-[min(22rem,46%)] min-w-64 border-l border-border/60"
12841291 : "min-w-0 flex-1",
12851292 )}
......@@ -1293,7 +1300,7 @@ export default function FilePreviewPanel({
12931300 selectedPathRevealId={revealRequestId}
12941301 onOpenFile={onOpenFile}
12951302 workspaceMutationId={workspaceMutationId}
1296 {...(relativePath && !isMedia && !isPdf
1303 {...(previewPath && !isMedia && !isPdf
12971304 ? { onRefreshSelectedFile: file.refresh }
12981305 : {})}
12991306 />
apps/web/src/components/files/projectFilesQueryState.test.tsx+59
......@@ -1,6 +1,7 @@
11import {
22 EnvironmentId,
33 type ProjectListEntriesResult,
4 ProjectReadFileError,
45 type ProjectReadFileResult,
56} from "@t3tools/contracts";
67import * as Effect from "effect/Effect";
......@@ -250,4 +251,62 @@ describe("project query refresh", () => {
250251 atomHooks.registry = null;
251252 }
252253 });
254
255 it("reports a directory named like an image as not a file", async () => {
256 const readAtom = Atom.make(
257 Effect.fail(
258 new ProjectReadFileError({
259 cwd: "/repo",
260 relativePath: "assets.png",
261 failure: "path_not_file",
262 }),
263 ),
264 );
265 const registry = AtomRegistry.make();
266 const unmount = registry.mount(readAtom);
267 projectMocks.readFile.mockReturnValue(readAtom);
268 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
269 atomHooks.registry = registry;
270
271 try {
272 await flushEffects();
273 reactHooks.beginRender();
274 const query = useProjectFileQuery(environmentId, "/repo", "assets.png");
275 expect(query.isNotFile).toBe(true);
276 expect(query.data).toBeNull();
277 } finally {
278 unmount();
279 registry.dispose();
280 atomHooks.registry = null;
281 }
282 });
283
284 it("reports a directory read as not a file", async () => {
285 const readAtom = Atom.make(
286 Effect.fail(
287 new ProjectReadFileError({
288 cwd: "/repo",
289 relativePath: ".agents/skills",
290 failure: "path_not_file",
291 }),
292 ),
293 );
294 const registry = AtomRegistry.make();
295 const unmount = registry.mount(readAtom);
296 projectMocks.readFile.mockReturnValue(readAtom);
297 projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
298 atomHooks.registry = registry;
299
300 try {
301 await flushEffects();
302 reactHooks.beginRender();
303 const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills");
304 expect(query.isNotFile).toBe(true);
305 expect(query.data).toBeNull();
306 } finally {
307 unmount();
308 registry.dispose();
309 atomHooks.registry = null;
310 }
311 });
253312});
apps/web/src/components/files/projectFilesQueryState.ts+29-21
......@@ -1,15 +1,13 @@
11import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
2import type {
3 EnvironmentId,
4 ProjectListEntriesResult,
5 ProjectReadFileResult,
6} from "@t3tools/contracts";
72import {
8 isWorkspaceImagePreviewPath,
9 isWorkspaceVideoPreviewPath,
10} from "@t3tools/shared/filePreview";
3 type EnvironmentId,
4 type ProjectListEntriesResult,
5 ProjectReadFileError,
6 type ProjectReadFileResult,
7} from "@t3tools/contracts";
118import * as Cause from "effect/Cause";
129import * as Option from "effect/Option";
10import * as Schema from "effect/Schema";
1311import { AsyncResult, Atom } from "effect/unstable/reactivity";
1412import { useCallback } from "react";
1513
......@@ -33,6 +31,11 @@ interface ProjectQueryState<A> {
3331 readonly refresh: () => void;
3432}
3533
34interface ProjectFileQueryState extends ProjectQueryState<ProjectReadFileResult> {
35 /** The path exists but is not a regular file, typically a directory. */
36 readonly isNotFile: boolean;
37}
38
3639function getProjectEntriesQueryAtom(
3740 environmentId: EnvironmentId,
3841 cwd: string,
......@@ -126,12 +129,17 @@ export function clearProjectFileQueryData(
126129 appAtomRegistry.set(optimisticFileAtom(environmentId, cwd, relativePath), null);
127130}
128131
129function errorMessage<A>(result: AsyncResult.AsyncResult<A, unknown>): string | null {
130 if (result._tag !== "Failure") return null;
131 const cause = Cause.squash(result.cause);
132function failureCause<A>(result: AsyncResult.AsyncResult<A, unknown>): unknown {
133 return result._tag === "Failure" ? Cause.squash(result.cause) : null;
134}
135
136function errorMessage(cause: unknown): string | null {
137 if (cause === null) return null;
132138 return cause instanceof Error ? cause.message : "Workspace query failed.";
133139}
134140
141const isProjectReadFileError = Schema.is(ProjectReadFileError);
142
135143export function useProjectEntriesQuery(
136144 environmentId: EnvironmentId,
137145 cwd: string,
......@@ -143,7 +151,7 @@ export function useProjectEntriesQuery(
143151 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
144152 return {
145153 data: Option.getOrNull(AsyncResult.value(result)),
146 error: errorMessage(result),
154 error: errorMessage(failureCause(result)),
147155 isPending: result.waiting,
148156 refresh,
149157 };
......@@ -189,14 +197,12 @@ export function useProjectFileQuery(
189197 cwd: string,
190198 relativePath: string | null,
191199 enabled = true,
192): ProjectQueryState<ProjectReadFileResult> {
193 const isMedia =
194 relativePath !== null &&
195 (isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath));
196 const atom =
197 enabled && !isMedia
198 ? getProjectFileQueryAtom(environmentId, cwd, relativePath)
199 : EMPTY_PROJECT_FILE_QUERY_ATOM;
200): ProjectFileQueryState {
201 // The caller decides what to read. A media path is not skipped here: a folder
202 // named `assets.png` is only knowable as a folder from the read failure.
203 const atom = enabled
204 ? getProjectFileQueryAtom(environmentId, cwd, relativePath)
205 : EMPTY_PROJECT_FILE_QUERY_ATOM;
200206 const result = useAtomValue(atom);
201207 const refreshAtom = useAtomRefresh(atom);
202208 const refresh = useCallback(() => refreshAtom(), [refreshAtom]);
......@@ -205,10 +211,12 @@ export function useProjectFileQuery(
205211 optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH),
206212 );
207213 const optimisticFile = relativePath === null ? null : optimisticResult;
214 const cause = failureCause(result);
208215
209216 return {
210217 data: optimisticFile?.data ?? data,
211 error: errorMessage(result),
218 error: errorMessage(cause),
219 isNotFile: isProjectReadFileError(cause) && cause.failure === "path_not_file",
212220 isPending: result.waiting,
213221 refresh,
214222 };
apps/web/src/rightPanelStore.test.ts+30
......@@ -475,6 +475,36 @@ describe("rightPanelStore", () => {
475475 });
476476 });
477477
478 it.each([
479 ["generated\\", "generated"],
480 ["notes/meeting ", "notes/meeting"],
481 [" notes/meeting", "notes/meeting"],
482 ])("keeps %j and %j in separate file tabs", (firstPath, secondPath) => {
483 useRightPanelStore.getState().openFile(refA, firstPath);
484 useRightPanelStore.getState().openFile(refA, secondPath);
485
486 expect(
487 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
488 ).toMatchObject([
489 { id: `file:${firstPath}`, relativePath: firstPath },
490 { id: `file:${secondPath}`, relativePath: secondPath },
491 ]);
492 });
493
494 it.each([
495 ["docs/", "docs"],
496 ["docs///", "docs"],
497 ["/", "/"],
498 ["C:/", "C:/"],
499 ])("reuses the folder tab for %j and %j", (linkPath, treePath) => {
500 useRightPanelStore.getState().openFile(refA, linkPath);
501 useRightPanelStore.getState().openFile(refA, treePath);
502
503 expect(
504 selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
505 ).toMatchObject([{ id: `file:${treePath}`, relativePath: treePath, revealRequestId: 2 }]);
506 });
507
478508 it("opens an attachment as a file surface without the standalone explorer", () => {
479509 const attachment = {
480510 type: "file" as const,
apps/web/src/rightPanelStore.ts+5-1
......@@ -575,9 +575,13 @@ export const useRightPanelStore = create<RightPanelStoreState>()(
575575 : next;
576576 }),
577577 ),
578 openFile: (ref, relativePath, line) =>
578 openFile: (ref, requestedPath, line) =>
579579 set((state) =>
580580 userAction(state, scopedThreadKey(ref), (current) => {
581 // Workspace entry paths use '/', including on Windows.
582 const relativePath = /^[A-Za-z]:\/+$/.test(requestedPath)
583 ? requestedPath
584 : requestedPath.replace(/\/+$/, "") || requestedPath;
581585 const withoutStandaloneExplorer = current.surfaces.filter(
582586 (surface) => surface.kind !== "files",
583587 );