From f93957e982e531afe9e420af67f1bda1c2652a3c Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 5 May 2026 20:21:45 -0700 Subject: [PATCH] ux(canvas/files): "Files not available" banner for external runtimes (#2999 PR-B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Reported by user (issue #2999): external workspaces (mac laptop, mac mini, hermes-on-home-server — runtime="external") render the FilesTab identically to the SaaS empty-listing bug, showing "0 files / No config files yet" even though the platform doesn't actually own the filesystem of these workspaces. Visually indistinguishable from the broken state, reads as a bug. ## Fix Mirror the affordance TerminalTab adopted in PR #2830 for runtimes without a TTY: 1. New `NotAvailablePanel` in `canvas/src/components/tabs/FilesTab/` — folder-with-slash icon + "Files not available" headline + body text that names the runtime and points the user at Chat. 2. `FilesTab` now takes optional `data?: WorkspaceNodeData`. When `data.runtime` is in `RUNTIMES_WITHOUT_FILES` (currently just "external"), early-return the placeholder before mounting the useFilesApi hook. Mirrors TerminalTab's prop shape exactly so the review pattern is uniform across tabs. 3. SidePanel passes `node.data` to FilesTab (matches existing pattern for ChatTab / TerminalTab). ## Test coverage `FilesTab.notAvailable.test.tsx` (4 tests): - external runtime → banner renders with runtime name + Chat-tab guidance copy. - external runtime → NO `/files` API request fires (asserted by inspecting the mocked api.get call log). - claude-code runtime → no banner, normal mount proceeds (toolbar's root selector is the discriminator). - data prop omitted → falls through to normal mount (back-compat with any caller that doesn't thread data through, e.g. legacy tests). Each branch is independent and discriminating — none would pass on a code-deleted version of the early-return. ## Three weakest spots (hostile self-review) 1. `RUNTIMES_WITHOUT_FILES` is a hardcoded set in this file. If a future runtime joins (e.g. a "byok-claude" that runs on user hardware), someone has to remember to add it here. Reviewed alternatives: pull from a runtime-capabilities registry — same shape as `RUNTIMES_WITHOUT_TERMINAL` already in TerminalTab. We chose the parallel pattern over a new abstraction; consolidating into a shared registry can land if/when a third tab grows the same gate (rule of three). Documented inline. 2. The placeholder is a static panel — no retry, no "report bug" link. Same as TerminalTab's. Acceptable because the absence is intentional, not transient. 3. Chat-tab guidance is hardcoded English. No i18n in canvas yet; matches the rest of the codebase. Will move with the i18n migration when that lands. ## Verification - `npx tsc --noEmit` clean - 54/54 canvas tab + SidePanel tests pass - Will be live-verified on staging post-merge: open Files tab on an external workspace (mac laptop) → expect placeholder; open on a platform-owned workspace (Hongming Personal Brand Agent) → expect normal tree (assuming PR-A also lands). Refs #2999. Pairs with PR-A (backend EIC fix) — without PR-A the platform-owned path still shows "0 files" because the backend never returns rows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- canvas/src/components/SidePanel.tsx | 2 +- canvas/src/components/tabs/FilesTab.tsx | 35 +++++- .../tabs/FilesTab/NotAvailablePanel.tsx | 58 +++++++++ .../__tests__/FilesTab.notAvailable.test.tsx | 119 ++++++++++++++++++ 4 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 canvas/src/components/tabs/FilesTab/NotAvailablePanel.tsx create mode 100644 canvas/src/components/tabs/__tests__/FilesTab.notAvailable.test.tsx 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(); + }); +});