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.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx
index b739d120da63..25bb82b8ad79 100644
--- a/apps/web/src/components/files/FilePreviewPanel.tsx
+++ b/apps/web/src/components/files/FilePreviewPanel.tsx
@@ -995,12 +995,20 @@ 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. Mutation
+ // refresh stays on so the surface notices if the path becomes a file. A host
+ // path cannot be revealed in the workspace tree, so it keeps the read error.
+ const isDirectory = file.isNotFile && !isHostFile;
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(
@@ -1027,12 +1035,13 @@ export default function FilePreviewPanel({
(handledReveal?.path === relativePath && handledReveal.requestId === revealRequestId);
const renderMarkdown = isMarkdown && renderMarkdownPreferred && revealHandled;
const renderBrowserFile = isPdf || (isHtml && renderBrowserFilePreferred && revealHandled);
- const canToggleRendered = attachment === undefined && (isMarkdown || isHtml);
+ const canToggleRendered = !isDirectory && attachment === undefined && (isMarkdown || isHtml);
const rendered = isMarkdown ? renderMarkdown : renderBrowserFile;
const setRenderedPreferred = isMarkdown
? setRenderMarkdownPreferred
: setRenderBrowserFilePreferred;
const canOpenInBrowser =
+ !isDirectory &&
relativePath !== null &&
attachment === undefined &&
!isVideo &&
@@ -1190,7 +1199,7 @@ export default function FilePreviewPanel({
Open file in preview browser
) : null}
- {!isHostFile ? (
+ {!isHostFile && !isDirectory ? (
{relativePath && attachment ? (
@@ -1332,7 +1341,7 @@ export default function FilePreviewPanel({
diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx
index 8edf982a030a..3de418d065e8 100644
--- a/apps/web/src/components/files/projectFilesQueryState.test.tsx
+++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx
@@ -1,6 +1,7 @@
import {
EnvironmentId,
type ProjectListEntriesResult,
+ ProjectReadFileError,
type ProjectReadFileResult,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
@@ -250,4 +251,33 @@ describe("project query refresh", () => {
atomHooks.registry = null;
}
});
+
+ it("reports a directory read as not a file", async () => {
+ const readAtom = Atom.make(
+ Effect.fail(
+ new ProjectReadFileError({
+ cwd: "/repo",
+ relativePath: ".agents/skills",
+ failure: "path_not_file",
+ }),
+ ),
+ );
+ const registry = AtomRegistry.make();
+ const unmount = registry.mount(readAtom);
+ projectMocks.readFile.mockReturnValue(readAtom);
+ projectMocks.optimisticFile.mockReturnValue(Atom.make(null));
+ atomHooks.registry = registry;
+
+ try {
+ await flushEffects();
+ reactHooks.beginRender();
+ const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills");
+ expect(query.isNotFile).toBe(true);
+ expect(query.data).toBeNull();
+ } finally {
+ unmount();
+ registry.dispose();
+ atomHooks.registry = null;
+ }
+ });
});
diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts
index a12772920956..ad075d3283a6 100644
--- a/apps/web/src/components/files/projectFilesQueryState.ts
+++ b/apps/web/src/components/files/projectFilesQueryState.ts
@@ -1,8 +1,9 @@
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
-import type {
- EnvironmentId,
- ProjectListEntriesResult,
- ProjectReadFileResult,
+import {
+ type EnvironmentId,
+ type ProjectListEntriesResult,
+ ProjectReadFileError,
+ type ProjectReadFileResult,
} from "@t3tools/contracts";
import {
isWorkspaceImagePreviewPath,
@@ -10,6 +11,7 @@ import {
} from "@t3tools/shared/filePreview";
import * as Cause from "effect/Cause";
import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { useCallback } from "react";
@@ -33,6 +35,11 @@ interface ProjectQueryState {
readonly refresh: () => void;
}
+interface ProjectFileQueryState extends ProjectQueryState {
+ /** The path exists but is not a regular file, typically a directory. */
+ readonly isNotFile: boolean;
+}
+
function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) {
return projectEnvironment.listEntries({ environmentId, input: { cwd } });
}
@@ -125,6 +132,14 @@ function errorMessage(result: AsyncResult.AsyncResult): string |
return cause instanceof Error ? cause.message : "Workspace query failed.";
}
+const isProjectReadFileError = Schema.is(ProjectReadFileError);
+
+function isNotFileFailure(result: AsyncResult.AsyncResult): boolean {
+ if (result._tag !== "Failure") return false;
+ const cause = Cause.squash(result.cause);
+ return isProjectReadFileError(cause) && cause.failure === "path_not_file";
+}
+
export function useProjectEntriesQuery(
environmentId: EnvironmentId,
cwd: string,
@@ -181,7 +196,7 @@ export function useProjectFileQuery(
cwd: string,
relativePath: string | null,
enabled = true,
-): ProjectQueryState {
+): ProjectFileQueryState {
const isMedia =
relativePath !== null &&
(isWorkspaceImagePreviewPath(relativePath) || isWorkspaceVideoPreviewPath(relativePath));
@@ -201,6 +216,7 @@ export function useProjectFileQuery(
return {
data: optimisticFile?.data ?? data,
error: errorMessage(result),
+ isNotFile: isNotFileFailure(result),
isPending: result.waiting,
refresh,
};
diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts
index 1719ae77725c..cb860a472b8b 100644
--- a/apps/web/src/rightPanelStore.ts
+++ b/apps/web/src/rightPanelStore.ts
@@ -487,9 +487,11 @@ export const useRightPanelStore = create()(
: next;
}),
),
- openFile: (ref, relativePath, line) =>
+ openFile: (ref, requestedPath, line) =>
set((state) =>
userAction(state, scopedThreadKey(ref), (current) => {
+ // A folder link may end in a slash; the tree keys folders without it.
+ const relativePath = requestedPath.replace(/\/+$/, "") || requestedPath;
const withoutStandaloneExplorer = current.surfaces.filter(
(surface) => surface.kind !== "files",
);