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: "