From 0f1524253dad9cbd1ab97ec8d341b441fb98ec92 Mon Sep 17 00:00:00 2001 From: brettchien Date: Mon, 17 Aug 2026 09:50:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent-consoles):=20remote=20file=20browser?= =?UTF-8?q?=20=E2=80=94=20read-only,=20MCP-backed=20(slice=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 of the agent-consoles ADR (#49): the remote file editor's read path (Part D). Adds a capability-gated, read-only directory browser + file viewer to the agent console. Per the merged Part D decision (#70), fs is an MCP files server the target agent exposes, reached Studio-brokered via the `oab` reverse-MCP tool — not a bespoke `fs/*` method set on `/acp`. The fs MCP server + the `oab` fs-relay are upstream (openab) and absent today, the same bucket as token streaming / `tool_call`. So this slice ships the browser UI + the source-agnostic read contract now; live fs traffic (MCP-backed read, then write/apply) lands in slice 4 with the server + relay. The earlier draft's bespoke `crates/acp-tunnel` `fs/*` client is dropped (the mechanism #70 rejected); the browser UI it fronted is source- agnostic and unchanged. - console/fileBrowser.ts: capability-gated browser + read-only CodeMirror viewer, mounted per open console, disposed on close/switch. Read-only. - render.ts: pure `fsListingHtml` (dirs-before-files, sizes, open-marking, `data-fs-*` nav hooks) + `fsUnavailableHtml`. - types/source/fixtures: the fs view-models; `fsCapability`/`fsList`/ `fsRead` (Mock = fixture FS; Tauri = honestly unsupported until slice 4). - index.html / styles.css: the Files region (listing + viewer split). Security (Part D): read-only — write/Apply is slice 4, gated at the fs server's tool level (agent-declared roots, `writable` default-off, no `/`-wide default). No orchestrator/kube creds; the `oab` relay stays management-only. Testing: console — `tsc --noEmit` clean · 84 vitest (incl. fs render cases) · `vite build` OK. No Rust change (the bespoke client is removed). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- console/index.html | 10 +++ console/src/agentConsole.ts | 16 ++++ console/src/fileBrowser.ts | 164 ++++++++++++++++++++++++++++++++++++ console/src/fixtures.ts | 38 +++++++++ console/src/render.test.ts | 72 +++++++++++++++- console/src/render.ts | 81 +++++++++++++++++- console/src/source.ts | 47 +++++++++++ console/src/styles.css | 106 +++++++++++++++++++++++ console/src/types.ts | 41 +++++++++ 9 files changed, 573 insertions(+), 2 deletions(-) create mode 100644 console/src/fileBrowser.ts diff --git a/console/index.html b/console/index.html index 1deea95..3d6942b 100644 --- a/console/index.html +++ b/console/index.html @@ -59,6 +59,16 @@
+
+
+ Files + +
+
+
+
+
+
Chat diff --git a/console/src/agentConsole.ts b/console/src/agentConsole.ts index 2502798..09350e8 100644 --- a/console/src/agentConsole.ts +++ b/console/src/agentConsole.ts @@ -13,6 +13,7 @@ import type { Source } from "./source"; import type { AgentEndpointView } from "./types"; import { createChatPanel, type ChatPanel } from "./chatPanel"; +import { createFileBrowser, type FileBrowser } from "./fileBrowser"; import { renderAgentList, agentConsoleHeaderHtml } from "./render"; export interface AgentConsoleConfig { @@ -46,6 +47,9 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole { const send = document.getElementById("ac-chat-send") as HTMLButtonElement | null; const stop = document.getElementById("ac-chat-stop") as HTMLButtonElement | null; const conn = document.getElementById("ac-chat-conn"); + const fbList = document.getElementById("ac-files-list"); + const fbViewer = document.getElementById("ac-files-viewer"); + const fbTitle = document.getElementById("ac-files-title"); const noop: AgentConsole = { refresh: async () => {}, @@ -57,6 +61,7 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole { let agents: AgentEndpointView[] = []; let openName: string | null = null; let panel: ChatPanel | null = null; + let fileBrowser: FileBrowser | null = null; const ac = new AbortController(); const { signal } = ac; @@ -98,6 +103,8 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole { openName = null; panel?.dispose(); panel = null; + fileBrowser?.dispose(); + fileBrowser = null; cfg.panels.delete(name); if (consoleEl) consoleEl.hidden = true; // Fire-and-forget teardown; a failed disconnect is logged, not fatal. @@ -136,6 +143,15 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole { ); cfg.panels.set(name, panel); } + // Mount the read-only file browser for this agent (Part D). It probes fs + // capability itself and shows a "pending the fs MCP files server" placeholder + // when the endpoint has no fs support — which is every real endpoint today. + if (fbList && fbViewer && fbTitle) { + fileBrowser = createFileBrowser( + { list: fbList, viewer: fbViewer, title: fbTitle }, + { agent: name, source: cfg.source, note: cfg.note }, + ); + } try { await cfg.source.remoteConnect(name); cfg.note("info", `agents: opened console for "${name}" — dialing ${a.url}`); diff --git a/console/src/fileBrowser.ts b/console/src/fileBrowser.ts new file mode 100644 index 0000000..fff7804 --- /dev/null +++ b/console/src/fileBrowser.ts @@ -0,0 +1,164 @@ +// The remote file editor's **read** path (ADR agent-consoles Part D): a +// directory browser over an agent's filesystem + a read-only viewer, mounted in +// an open agent console. It is capability-gated — fs is an MCP files server the +// target agent exposes (reached Studio-brokered via the `oab` relay), and that +// server does not exist yet, so on a real endpoint `fsCapability` reports +// unsupported and this renders a "pending the fs MCP files server" placeholder. +// The browser build's mock source serves a fixture filesystem so the surface is +// still demonstrable. +// +// The listing HTML is pure (`render.ts`, unit-tested); this owns the imperative +// shell: the fetch/navigate lifecycle, the delegated click handler, and the +// read-only CodeMirror viewer. The **write** path (Apply) is slice 4. + +import { EditorView, basicSetup } from "codemirror"; +import { EditorState, type Extension } from "@codemirror/state"; +import { StreamLanguage } from "@codemirror/language"; +import { toml } from "@codemirror/legacy-modes/mode/toml"; +import type { Source } from "./source"; +import { fsListingHtml, fsUnavailableHtml } from "./render"; + +export interface FileBrowserElements { + // The listing container (directory rows). + list: HTMLElement; + // The read-only CodeMirror mount. + viewer: HTMLElement; + // The open-file path / status line. + title: HTMLElement; +} + +export interface FileBrowserOptions { + // The registry endpoint name whose filesystem is browsed. + agent: string; + source: Source; + note: (level: "info" | "error", msg: string) => void; +} + +export interface FileBrowser { + dispose(): void; +} + +const UNAVAILABLE = "Remote file editor unavailable — pending the fs MCP files server."; + +function errText(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function dirname(path: string): string { + const cut = path.replace(/\/+$/, "").replace(/\/[^/]+$/, ""); + return cut === "" ? "/" : cut; +} + +export function createFileBrowser( + els: FileBrowserElements, + opts: FileBrowserOptions, +): FileBrowser { + let roots: string[] = []; + let cwd = ""; + let selectedPath: string | null = null; + let view: EditorView | null = null; + const ac = new AbortController(); + const { signal } = ac; + + function destroyViewer(): void { + view?.destroy(); + view = null; + } + + // Show a file's text in a fresh read-only editor. `.toml` gets TOML highlighting + // (the mode already bundled for the config editor); everything else is plain. + function showFile(path: string, text: string, truncated: boolean): void { + destroyViewer(); + const ext: Extension[] = [ + basicSetup, + EditorState.readOnly.of(true), + EditorView.editable.of(false), + ]; + if (path.endsWith(".toml")) ext.push(StreamLanguage.define(toml)); + view = new EditorView({ + parent: els.viewer, + state: EditorState.create({ doc: text, extensions: ext }), + }); + els.title.textContent = truncated ? `${path} · truncated` : path; + } + + // The "up one level" affordance shows while we're below an editable root. + function canGoUp(): boolean { + return !roots.includes(cwd) && cwd !== "/" && cwd !== ""; + } + + function renderList(listing: Parameters[0]): void { + els.list.innerHTML = fsListingHtml(listing, { + selectedPath, + canGoUp: canGoUp(), + }); + } + + async function loadDir(path: string): Promise { + try { + const listing = await opts.source.fsList(path, opts.agent); + cwd = listing.path || path; + renderList(listing); + } catch (e) { + els.list.innerHTML = fsUnavailableHtml(`cannot list ${path} — ${errText(e)}`); + } + } + + async function openFile(path: string): Promise { + try { + const file = await opts.source.fsRead(path, opts.agent); + selectedPath = file.path || path; + showFile(selectedPath, file.text, file.truncated); + // Re-render the current listing so the open row is marked. + await loadDir(cwd); + } catch (e) { + opts.note("error", `files: read ${path} failed — ${errText(e)}`); + els.title.textContent = `${path} · read failed`; + } + } + + async function init(): Promise { + els.title.textContent = "files"; + let cap; + try { + cap = await opts.source.fsCapability(opts.agent); + } catch (e) { + els.list.innerHTML = fsUnavailableHtml(`fs capability check failed — ${errText(e)}`); + return; + } + if (!cap.supported) { + els.list.innerHTML = fsUnavailableHtml(UNAVAILABLE); + return; + } + roots = cap.roots.length ? cap.roots : ["/"]; + await loadDir(roots[0]); + } + + els.list.addEventListener( + "click", + (ev) => { + const t = ev.target as HTMLElement; + const dir = t.closest("[data-fs-dir]"); + if (dir?.dataset.fsDir) { + void loadDir(dir.dataset.fsDir); + return; + } + const file = t.closest("[data-fs-file]"); + if (file?.dataset.fsFile) { + void openFile(file.dataset.fsFile); + return; + } + if (t.closest("[data-fs-up]")) void loadDir(dirname(cwd)); + }, + { signal }, + ); + + void init(); + + return { + dispose: () => { + destroyViewer(); + ac.abort(); + }, + }; +} diff --git a/console/src/fixtures.ts b/console/src/fixtures.ts index 02c43bd..521e2c1 100644 --- a/console/src/fixtures.ts +++ b/console/src/fixtures.ts @@ -3,10 +3,48 @@ import type { Deployment, FleetConfig, RegistryConfig, + FsCapability, + FsEntry, RemoteConfig, RuntimeContext, } from "./types"; +// Stand-in remote filesystem so the browser build can demonstrate the read-only +// file browser without a live gateway (the desktop build shows "pending the fs +// MCP files server" because no real endpoint serves fs yet). A small tree under +// an editable root, read-only. +export const FIXTURE_FS_CAPABILITY: FsCapability = { + supported: true, + roots: ["/home/node"], + writable: false, +}; + +// Directory listings keyed by path (what `fsList` resolves). +export const FIXTURE_FS_DIRS: Record = { + "/home/node": [ + { name: "agent_profiling", path: "/home/node/agent_profiling", kind: "dir" }, + { name: "CLAUDE.md", path: "/home/node/CLAUDE.md", kind: "file", size: 812 }, + { name: "notes.md", path: "/home/node/notes.md", kind: "file", size: 140 }, + ], + "/home/node/agent_profiling": [ + { + name: "identity.md", + path: "/home/node/agent_profiling/identity.md", + kind: "file", + size: 512, + }, + ], +}; + +// File bodies keyed by path (what `fsRead` resolves). +export const FIXTURE_FS_FILES: Record = { + "/home/node/CLAUDE.md": + "# Orca\n\nECS-resident agent. This is a fixture rendering in the browser\nbuild's read-only file browser.\n", + "/home/node/notes.md": "- push early\n- state is ephemeral on Fargate Spot\n", + "/home/node/agent_profiling/identity.md": + "# Identity\n\n- **Name**: Orca\n- **Codename**: ecs-claude\n", +}; + // Stand-in endpoint registry so the browser build renders the agent-console // selector without a core. Mirrors src-tauri's `remote_agents`: one management // entry (backs the top-level console + reverse-MCP grant) plus ordinary agent diff --git a/console/src/render.test.ts b/console/src/render.test.ts index 84257d4..5749a31 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -6,6 +6,8 @@ import { remoteHtml, agentListHtml, agentConsoleHeaderHtml, + fsListingHtml, + fsUnavailableHtml, filterByMembers, serviceName, deploymentKey, @@ -21,6 +23,7 @@ import { AGENT_STATES, type AgentEndpointView, type Deployment, + type FsListing, type RuntimeContext, } from "./types"; @@ -452,7 +455,74 @@ describe("agentConsoleHeaderHtml", () => { expect(html.toLowerCase()).not.toContain("token"); }); - it("notes the read-only editor limitation until the fs/* wire lands", () => { + it("notes the read-only editor limitation until the fs MCP files server lands", () => { expect(agentConsoleHeaderHtml(orca, "disconnected")).toContain("Read-only"); }); }); + +describe("fsListingHtml", () => { + const listing: FsListing = { + path: "/home/node", + entries: [ + { name: "notes.md", path: "/home/node/notes.md", kind: "file", size: 140 }, + { name: "agent_profiling", path: "/home/node/agent_profiling", kind: "dir" }, + { name: "CLAUDE.md", path: "/home/node/CLAUDE.md", kind: "file", size: 2048 }, + ], + }; + + it("sorts directories before files, each alphabetically", () => { + const html = fsListingHtml(listing); + const iDir = html.indexOf("agent_profiling"); + const iClaude = html.indexOf("CLAUDE.md"); + const iNotes = html.indexOf("notes.md"); + expect(iDir).toBeLessThan(iClaude); // dir before any file + expect(iClaude).toBeLessThan(iNotes); // files alphabetical + }); + + it("hooks dirs and files with the right navigation attributes", () => { + const html = fsListingHtml(listing); + expect(html).toContain('data-fs-dir="/home/node/agent_profiling"'); + expect(html).toContain('data-fs-file="/home/node/CLAUDE.md"'); + }); + + it("shows a human-readable size for files only", () => { + const html = fsListingHtml(listing); + expect(html).toContain("2.0 KB"); // CLAUDE.md + expect(html).toContain("140 B"); // notes.md + }); + + it("renders the breadcrumb path", () => { + expect(fsListingHtml(listing)).toContain("/home/node"); + }); + + it("marks the open file", () => { + const html = fsListingHtml(listing, { selectedPath: "/home/node/CLAUDE.md" }); + expect(html).toMatch(/is-open[^>]*data-fs-file="\/home\/node\/CLAUDE\.md"/); + }); + + it("shows an up affordance only when canGoUp", () => { + expect(fsListingHtml(listing, { canGoUp: true })).toContain("data-fs-up"); + expect(fsListingHtml(listing, { canGoUp: false })).not.toContain("data-fs-up"); + }); + + it("renders an empty-directory note when there are no entries and no up", () => { + expect(fsListingHtml({ path: "/x", entries: [] })).toContain("empty directory"); + }); + + it("escapes entry names and paths", () => { + const html = fsListingHtml({ + path: "/x", + entries: [{ name: "