diff --git a/canvas/src/components/SidePanel.tsx b/canvas/src/components/SidePanel.tsx
index 2db6c4a1..c05a9c21 100644
--- a/canvas/src/components/SidePanel.tsx
+++ b/canvas/src/components/SidePanel.tsx
@@ -287,7 +287,7 @@ export function SidePanel() {
{panelTab === "config" && }
{panelTab === "schedule" && }
{panelTab === "channels" && }
- {panelTab === "files" && }
+ {panelTab === "files" && }
{panelTab === "memory" && }
{panelTab === "traces" && }
{panelTab === "events" && }
diff --git a/canvas/src/components/tabs/FilesTab.tsx b/canvas/src/components/tabs/FilesTab.tsx
index a3b895b7..c4e73eee 100644
--- a/canvas/src/components/tabs/FilesTab.tsx
+++ b/canvas/src/components/tabs/FilesTab.tsx
@@ -2,9 +2,11 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { showToast } from "../Toaster";
+import type { WorkspaceNodeData } from "@/store/canvas";
import { FilesToolbar } from "./FilesTab/FilesToolbar";
import { FileTree } from "./FilesTab/FileTree";
import { FileEditor } from "./FilesTab/FileEditor";
+import { NotAvailablePanel } from "./FilesTab/NotAvailablePanel";
import { useFilesApi } from "./FilesTab/useFilesApi";
import { buildTree } from "./FilesTab/tree";
@@ -14,9 +16,40 @@ export type { TreeNode } from "./FilesTab/tree";
interface Props {
workspaceId: string;
+ /** Workspace metadata from the canvas store. Optional for back-compat
+ * with any caller that still mounts without
+ * threading data through (legacy tests). When present, runtime gates
+ * the early-return below. Mirrors TerminalTab's prop shape (#2830). */
+ data?: WorkspaceNodeData;
}
-export function FilesTab({ workspaceId }: Props) {
+/** Runtimes whose filesystem the platform doesn't own. The canvas can't
+ * list/read/write files on these — the agent runs on the user's own
+ * hardware (mac laptop, mac mini, hermes-on-home-server) and reaches
+ * the platform via the heartbeat-based polling Phase 30 layer.
+ *
+ * Keep narrow — only add a runtime here when its provisioner genuinely
+ * has no platform-owned filesystem. Otherwise the user loses access to
+ * a real surface (e.g. claude-code SaaS workspaces have files served
+ * by ListFiles via EIC; they belong on the rendering path, not here). */
+const RUNTIMES_WITHOUT_FILES = new Set(["external"]);
+
+export function FilesTab({ workspaceId, data }: Props) {
+ // Early-return for runtimes whose filesystem is not platform-owned.
+ // Skips the whole useFilesApi hook + tree render below — without this,
+ // mounting the tab for an external workspace would issue a GET that
+ // the platform can technically answer (it reads its own DB row, not
+ // the user's machine), but every result row is fictional. Showing
+ // "0 files / No config files yet" reads as a bug. The placeholder
+ // makes the absence intentional and points the user at the right
+ // surface (Chat).
+ if (data && RUNTIMES_WITHOUT_FILES.has(data.runtime)) {
+ return ;
+ }
+ return ;
+}
+
+function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
const [root, setRoot] = useState("/configs");
const [selectedFile, setSelectedFile] = useState(null);
const [fileContent, setFileContent] = useState("");
diff --git a/canvas/src/components/tabs/FilesTab/NotAvailablePanel.tsx b/canvas/src/components/tabs/FilesTab/NotAvailablePanel.tsx
new file mode 100644
index 00000000..5f66d24e
--- /dev/null
+++ b/canvas/src/components/tabs/FilesTab/NotAvailablePanel.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+/**
+ * NotAvailablePanel — full-tab placeholder for runtimes whose filesystem
+ * the platform doesn't own (today: runtime === "external").
+ *
+ * Pre-fix the FilesTab tried to GET /workspaces//files for these
+ * workspaces. The platform answered with [] (no rows in workspace_files
+ * for an external workspace by definition), but the canvas rendered
+ * "0 files / No config files yet" which reads identically to the SaaS
+ * empty-listing bug fixed in PR-A. Showing an explicit placeholder
+ * makes the absence intentional and routes the user toward the
+ * supported surface (Chat) for these workspaces.
+ *
+ * Mirrors the same affordance TerminalTab adopted for runtimes without
+ * a TTY in PR #2830 — uniform "feature-not-applicable" UX across tabs.
+ */
+export function NotAvailablePanel({ runtime }: { runtime: string }) {
+ return (
+
+ {/* Folder-with-slash icon. Custom inline SVG so we don't depend
+ on an icon set being present at canvas build-time (matches
+ TerminalTab's NotAvailablePanel pattern). */}
+
+
Files not available
+
+ This workspace runs the{" "}
+ {runtime} runtime,
+ whose filesystem isn't owned by the platform. Use the Chat tab to
+ interact with the agent directly.
+
+
+ );
+}
diff --git a/canvas/src/components/tabs/__tests__/FilesTab.notAvailable.test.tsx b/canvas/src/components/tabs/__tests__/FilesTab.notAvailable.test.tsx
new file mode 100644
index 00000000..6f383b91
--- /dev/null
+++ b/canvas/src/components/tabs/__tests__/FilesTab.notAvailable.test.tsx
@@ -0,0 +1,119 @@
+// @vitest-environment jsdom
+//
+// Pins the "Files not available" early-return for runtimes whose
+// filesystem the platform doesn't own (today: runtime === "external").
+//
+// Pre-fix: FilesTab issued a GET /workspaces//files for every
+// workspace. The platform's response for an external workspace is
+// always [] (no rows in workspace_files), but the canvas rendered
+// "0 files / No config files yet" — visually identical to the SaaS
+// empty-listing bug fixed in PR-A. The placeholder makes the absence
+// intentional.
+//
+// Pinned branches:
+// 1. external runtime → "Files not available" banner renders,
+// runtime name surfaces in the body so user knows WHY.
+// 2. external runtime → useFilesApi is NOT invoked. Verified by
+// asserting the mocked api.get was never called.
+// 3. claude-code (or any other runtime) → no banner, normal mount
+// proceeds (`/configs` toolbar visible). Pre-fix regression cover.
+// 4. data prop omitted (legacy callers) → no early-return, falls
+// through to normal mount.
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup, waitFor } from "@testing-library/react";
+import React from "react";
+
+afterEach(cleanup);
+
+// Mock the api module so the normal-mount branches don't try to
+// fetch against a real backend — and so we can assert the
+// external-runtime branch never fires a request.
+const apiCalls: string[] = [];
+vi.mock("@/lib/api", () => ({
+ api: {
+ get: vi.fn((path: string) => {
+ apiCalls.push(path);
+ return Promise.resolve([]);
+ }),
+ put: vi.fn(() => Promise.resolve()),
+ del: vi.fn(() => Promise.resolve()),
+ },
+}));
+
+// useCanvasStore is referenced by useFilesApi for the needsRestart
+// flag. The Toaster import inside FilesTab also pulls the store
+// indirectly. Stub minimally to satisfy the import chain.
+vi.mock("@/store/canvas", async () => {
+ const actual = await vi.importActual(
+ "@/store/canvas",
+ );
+ return {
+ ...actual,
+ useCanvasStore: {
+ getState: () => ({
+ updateNodeData: vi.fn(),
+ }),
+ },
+ };
+});
+
+vi.mock("../Toaster", () => ({
+ showToast: vi.fn(),
+}));
+
+beforeEach(() => {
+ apiCalls.length = 0;
+});
+
+import { FilesTab } from "../FilesTab";
+
+const externalData = { runtime: "external", status: "online" } as unknown as Parameters<
+ typeof FilesTab
+>[0]["data"];
+
+const claudeData = { runtime: "claude-code", status: "online" } as unknown as Parameters<
+ typeof FilesTab
+>[0]["data"];
+
+describe("FilesTab not-available early-return for runtimes without platform-owned filesystem", () => {
+ it("external runtime renders the not-available banner with runtime name", () => {
+ render();
+ expect(screen.getByText(/Files not available/i)).not.toBeNull();
+ // Runtime name must surface so the user understands WHY — without
+ // it the placeholder reads as a generic error.
+ expect(screen.getByText(/external/)).not.toBeNull();
+ // Chat tab is the recommended alternative — flagged in copy so the
+ // user knows where to go next instead of bouncing tabs.
+ expect(screen.getByText(/Chat tab/i)).not.toBeNull();
+ });
+
+ it("external runtime does NOT issue any /files API call", async () => {
+ render();
+ // Tolerate one microtask boundary in case useEffect schedules.
+ await new Promise((r) => setTimeout(r, 0));
+ const filesCalls = apiCalls.filter((p) => p.includes("/files"));
+ expect(filesCalls).toEqual([]);
+ });
+
+ it("claude-code runtime does NOT render the banner (normal mount)", async () => {
+ render();
+ // The normal-mount path renders the FilesToolbar with the root
+ // selector. Wait for it (useEffect → loadFiles → setLoading false).
+ await waitFor(() => {
+ expect(screen.queryByText(/Files not available/i)).toBeNull();
+ });
+ // Toolbar's root selector confirms we're on the platform-owned
+ // rendering path, not the placeholder.
+ expect(screen.getByLabelText(/File root directory/i)).not.toBeNull();
+ });
+
+ it("data prop omitted falls through to normal mount (back-compat)", async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.queryByText(/Files not available/i)).toBeNull();
+ });
+ // Without data we can't gate on runtime — must mount normally.
+ expect(screen.getByLabelText(/File root directory/i)).not.toBeNull();
+ });
+});