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(); + }); +});