diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts
index ebebf510994bc1bfef081ba16abb1010b7f8b313..84fb69e79cf7b91f005046bb06e45c8ad63db885 100644
--- a/apps/web/src/components/files/FilePreviewPanel.test.ts
+++ b/apps/web/src/components/files/FilePreviewPanel.test.ts
@@ -1,3 +1,5 @@
+import { ProjectReadFileError } from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
import { describe, expect, it } from "vite-plus/test";
import {
@@ -6,12 +8,57 @@ import {
remapFileCommentAnnotations,
} from "./fileCommentAnnotations";
import {
+ filePreviewReadErrorMessage,
isMarkdownPreviewFile,
resolveFilePreviewPath,
setMarkdownTaskChecked,
shouldShowFileExplorer,
} from "./filePreviewMode";
+const decodeReadError = Schema.decodeSync(ProjectReadFileError);
+
+describe("file preview read errors", () => {
+ it.each([
+ ["path_not_file", "The path is a directory or special file, not a regular file."],
+ ["binary_file", "The file is binary and cannot be displayed as text."],
+ ["workspace_path_outside_root", "The requested path is outside the workspace."],
+ ["resolved_path_outside_root", "The path resolves to a location outside the workspace."],
+ [
+ "operation_failed",
+ "The file could not be accessed or read. It may be missing or inaccessible.",
+ ],
+ ] as const)("describes %s without revealing the platform cause", (failure, message) => {
+ const error = new ProjectReadFileError({
+ cwd: "/workspace",
+ relativePath: "workspace/outline.md",
+ failure,
+ operation: "realpath-target",
+ resolvedPath: "/workspace/workspace/outline.md",
+ cause: new Error("EACCES: sensitive platform detail"),
+ });
+ expect(filePreviewReadErrorMessage(error)).toBe(message);
+ });
+
+ it("distinguishes an inaccessible workspace from an inaccessible file", () => {
+ const error = new ProjectReadFileError({
+ cwd: "/workspace",
+ relativePath: "outline.md",
+ failure: "operation_failed",
+ operation: "realpath-workspace-root",
+ operationPath: "/workspace",
+ });
+ expect(filePreviewReadErrorMessage(error)).toBe("The workspace folder could not be accessed.");
+ });
+
+ it("preserves the public message from older servers", () => {
+ const error = decodeReadError({
+ _tag: "ProjectReadFileError",
+ message: "Legacy file read failure.",
+ });
+ expect(filePreviewReadErrorMessage(error)).toBe("Legacy file read failure.");
+ });
+});
+
describe("file comment annotations", () => {
it("normalizes and formats selected line ranges", () => {
expect(normalizeFileCommentRange({ start: 16, end: 7 })).toEqual({
diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx
index 560c9a1336a5327f60ba3855508e623e61bec550..5ae0753429fd1cca73bdec7297ec42cec889194f 100644
--- a/apps/web/src/components/files/FilePreviewPanel.tsx
+++ b/apps/web/src/components/files/FilePreviewPanel.tsx
@@ -82,6 +82,7 @@ import { resolveCenteredFileLineScrollTop } from "./fileLineReveal";
import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision";
import {
+ filePreviewReadErrorMessage,
isMarkdownPreviewFile,
resolveFilePreviewPath,
setMarkdownTaskChecked,
@@ -957,6 +958,7 @@ export default function FilePreviewPanel({
relativePath,
attachment === undefined && relativePath !== null,
);
+ const attemptedPath = file.readError?.resolvedPath ?? file.readError?.operationPath;
// 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
@@ -1236,8 +1238,29 @@ export default function FilePreviewPanel({
workspaceMutationId={workspaceMutationId}
/>
) : relativePath && file.error && file.data === null ? (
-
- {file.error}
+
+
+
+ {file.readError ? filePreviewReadErrorMessage(file.readError) : file.error}
+
+ {attemptedPath ? (
+
+ Attempted path
+
+ {attemptedPath}
+
+
+ ) : null}
+ {!isHostFile ? (
+
+ Workspace folder:{" "}
+
+ {file.readError?.cwd ?? cwd}
+
+ . Check the link's path or locate the file in Files.
+
+ ) : null}
+
) : relativePath && file.data === null ? (
diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts
index 12a3eb95a3e81b48ce11baed22e3001ef80ed868..764d44bfd83d4d927037050289eeda7d160ecb71 100644
--- a/apps/web/src/components/files/filePreviewMode.ts
+++ b/apps/web/src/components/files/filePreviewMode.ts
@@ -1,4 +1,5 @@
import { workspaceRelativeFilePath } from "@t3tools/client-runtime/markdown-links";
+import type { ProjectReadFileError } from "@t3tools/contracts";
import { isAbsolutePath } from "~/terminal-links";
/** Resolve workspace links before choosing between the explorer and a file preview. */
@@ -9,6 +10,27 @@ export function resolveFilePreviewPath(path: string | null, cwd: string): string
export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path);
+/** Describe existing failure codes without exposing the underlying platform cause. */
+export function filePreviewReadErrorMessage(error: ProjectReadFileError): string {
+ switch (error.failure) {
+ case "path_not_file":
+ return "The path is a directory or special file, not a regular file.";
+ case "binary_file":
+ return "The file is binary and cannot be displayed as text.";
+ case "workspace_path_outside_root":
+ return "The requested path is outside the workspace.";
+ case "resolved_path_outside_root":
+ return "The path resolves to a location outside the workspace.";
+ case "operation_failed":
+ // A realpath failure can mean a missing path, permissions, or another I/O error.
+ return error.operation === "realpath-workspace-root"
+ ? "The workspace folder could not be accessed."
+ : "The file could not be accessed or read. It may be missing or inaccessible.";
+ default:
+ return error.message;
+ }
+}
+
export function shouldShowFileExplorer(input: {
readonly relativePath: string | null;
readonly explorerOpen: boolean;
diff --git a/apps/web/src/components/files/projectFilesQueryState.test.tsx b/apps/web/src/components/files/projectFilesQueryState.test.tsx
index a33465f6cb19e8a045373c5c180acc78ed0892b5..49bade395980d1cc3552786ae127ab1e3b0d872a 100644
--- a/apps/web/src/components/files/projectFilesQueryState.test.tsx
+++ b/apps/web/src/components/files/projectFilesQueryState.test.tsx
@@ -5,6 +5,7 @@ import {
type ProjectReadFileResult,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
import { Atom, AtomRegistry } from "effect/reactivity";
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
@@ -79,6 +80,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh
import { useProjectEntriesQuery, useProjectFileQuery } from "./projectFilesQueryState";
const environmentId = EnvironmentId.make("environment-1");
+const decodeReadError = Schema.decodeSync(ProjectReadFileError);
function deferred
() {
let resolve!: (value: A) => void;
@@ -137,6 +139,7 @@ describe("project query refresh", () => {
const render = (mutationId: string | null) => {
reactHooks.beginRender();
const query = useProjectFileQuery(environmentId, "/repo", "src/preview.ts");
+ expect(query.readError).toBeNull();
renderedContents = query.data?.contents ?? null;
useWorkspaceMutationRefresh({
mutationId,
@@ -245,6 +248,7 @@ describe("project query refresh", () => {
await flushEffects();
expect(projectMocks.readFile).not.toHaveBeenCalled();
+ expect(query.readError).toBeNull();
expect(requests).toHaveLength(0);
} finally {
registry.dispose();
@@ -273,6 +277,7 @@ describe("project query refresh", () => {
reactHooks.beginRender();
const query = useProjectFileQuery(environmentId, "/repo", "assets.png");
expect(query.isNotFile).toBe(true);
+ expect(query.readError?.failure).toBe("path_not_file");
expect(query.data).toBeNull();
} finally {
unmount();
@@ -302,6 +307,40 @@ describe("project query refresh", () => {
reactHooks.beginRender();
const query = useProjectFileQuery(environmentId, "/repo", ".agents/skills");
expect(query.isNotFile).toBe(true);
+ expect(query.readError?.failure).toBe("path_not_file");
+ expect(query.data).toBeNull();
+ } finally {
+ unmount();
+ registry.dispose();
+ atomHooks.registry = null;
+ }
+ });
+
+ it("retains decoded read failure context for the connected environment", async () => {
+ const error = decodeReadError({
+ _tag: "ProjectReadFileError",
+ cwd: "C:\\workspace",
+ relativePath: "workspace/outline.md",
+ failure: "operation_failed",
+ operation: "realpath-target",
+ operationPath: "C:\\workspace\\workspace\\outline.md",
+ resolvedPath: "C:\\workspace\\workspace\\outline.md",
+ message: "Failed to read workspace file.",
+ });
+ const readAtom = Atom.make(Effect.fail(error));
+ 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, "C:\\workspace", "workspace/outline.md");
+ expect(query.readError).toBe(error);
+ expect(query.error).toBe(error.message);
+ expect(query.isNotFile).toBe(false);
expect(query.data).toBeNull();
} finally {
unmount();
diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts
index fa4657ca815f1d03ffd95996cebefd81533f3f66..0b3ca2040e88c081d29da8ec896a92f9a2e8ed35 100644
--- a/apps/web/src/components/files/projectFilesQueryState.ts
+++ b/apps/web/src/components/files/projectFilesQueryState.ts
@@ -37,6 +37,7 @@ interface ProjectQueryState {
}
interface ProjectFileQueryState extends ProjectQueryState {
+ readonly readError: ProjectReadFileError | null;
/** The path exists but is not a regular file, typically a directory. */
readonly isNotFile: boolean;
}
@@ -217,11 +218,13 @@ export function useProjectFileQuery(
);
const optimisticFile = relativePath === null ? null : optimisticResult;
const cause = failureCause(result);
+ const readError = isProjectReadFileError(cause) ? cause : null;
return {
data: optimisticFile?.data ?? data,
error: errorMessage(cause),
- isNotFile: isProjectReadFileError(cause) && cause.failure === "path_not_file",
+ readError,
+ isNotFile: readError?.failure === "path_not_file",
isPending: result.waiting,
refresh,
};