Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@
<button class="ac-close" type="button" data-action="close-console">Close</button>
</div>
<div id="ac-config" class="ac-config"></div>
<div class="ac-files">
<div class="ac-files-head">
<span class="ac-files-label">Files</span>
<span id="ac-files-title" class="ac-files-title muted"></span>
</div>
<div class="ac-files-body">
<div id="ac-files-list" class="ac-files-list"></div>
<div id="ac-files-viewer" class="ac-files-viewer"></div>
</div>
</div>
<div class="ac-chat chat-wrap">
<div class="chat-head">
<span class="chat-title">Chat</span>
Expand Down
16 changes: 16 additions & 0 deletions console/src/agentConsole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 () => {},
Expand All @@ -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;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}`);
Expand Down
164 changes: 164 additions & 0 deletions console/src/fileBrowser.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fsListingHtml>[0]): void {
els.list.innerHTML = fsListingHtml(listing, {
selectedPath,
canGoUp: canGoUp(),
});
}

async function loadDir(path: string): Promise<void> {
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<void> {
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<void> {
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<HTMLElement>("[data-fs-dir]");
if (dir?.dataset.fsDir) {
void loadDir(dir.dataset.fsDir);
return;
}
const file = t.closest<HTMLElement>("[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();
},
};
}
38 changes: 38 additions & 0 deletions console/src/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, FsEntry[]> = {
"/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<string, string> = {
"/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
Expand Down
72 changes: 71 additions & 1 deletion console/src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
remoteHtml,
agentListHtml,
agentConsoleHeaderHtml,
fsListingHtml,
fsUnavailableHtml,
filterByMembers,
serviceName,
deploymentKey,
Expand All @@ -21,6 +23,7 @@ import {
AGENT_STATES,
type AgentEndpointView,
type Deployment,
type FsListing,
type RuntimeContext,
} from "./types";

Expand Down Expand Up @@ -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: "<script>", path: "/x/<script>", kind: "file" }],
});
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
});
});

describe("fsUnavailableHtml", () => {
it("renders the pending reason, escaped", () => {
const html = fsUnavailableHtml("pending the fs MCP files server");
expect(html).toContain("fs-unavailable");
expect(html).toContain("pending the fs MCP files server");
});
});
Loading
Loading