diff --git a/console/index.html b/console/index.html index 7b71010..4df21cb 100644 --- a/console/index.html +++ b/console/index.html @@ -55,16 +55,6 @@
-
-
- Files - -
-
-
-
-
-
Chat @@ -82,92 +72,98 @@
-
-
-
- Agent chat - disconnected -
-
-
- -
- - + + -
- - + +
+
+
+
+
+ Agent chat + disconnected
- +
+
+ +
+ + +
+
+
-
+ -
-
+
Compose template ⊕ overlay authoring — golden bundle library diff --git a/console/src/agentConsole.ts b/console/src/agentConsole.ts index 09350e8..69b4c08 100644 --- a/console/src/agentConsole.ts +++ b/console/src/agentConsole.ts @@ -7,13 +7,12 @@ // It stays out of the transcript/turn machinery — that is `chatPanel.ts`. Its // job is selection, the dial/teardown, the read-only config header, and // registering the mounted panel in the shared event-router map so `main.ts` can -// route this agent's `agent-update` / `remote-status` events to it. The remote -// file editor (view/edit/apply) is a later slice; config is read-only here. +// route this agent's `agent-update` / `remote-status` events to it. Config is +// read-only here. 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 { @@ -27,6 +26,13 @@ export interface AgentConsoleConfig { // The management endpoint's name (resolved async in `main.ts`) — read lazily so // the console never tries to re-key a name the management console owns. managementName: () => string | null; + // The persistent management connection's active dial target (`remote.toml`'s + // `url`, only while it's connecting/connected/erroring — `null` once fully + // disconnected). `agents.toml`'s own `management` flag and this url are two + // independent sources of truth; a roster entry can reach the exact same + // physical agent as the management connection without being flagged, so both + // checks are needed to avoid dialing a second ACP session against it. + managementUrl: () => string | null; } export interface AgentConsole { @@ -47,9 +53,6 @@ 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 () => {}, @@ -61,7 +64,6 @@ 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; @@ -74,7 +76,7 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole { } function renderList(): void { - if (listEl) renderAgentList(listEl, agents, openName); + if (listEl) renderAgentList(listEl, agents, openName, cfg.managementUrl()); } function renderHeader(status: string): void { @@ -103,8 +105,6 @@ 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. @@ -118,12 +118,23 @@ export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole { // Open a console for a named endpoint: teardown any current one, mount a chat // panel bound to this agent, render its read-only config, and dial the // endpoint. The management endpoint is never opened here (it has its own - // console); unconfigured endpoints are not dialable. + // console) — by name (agents.toml's own flag) or by url (the same physical + // agent reached under an un-flagged roster entry, e.g. this very fleet's + // management agent also showing up as a plain roster member); unconfigured + // endpoints are not dialable. async function open(name: string): Promise { if (name === openName) return; - if (name === cfg.managementName()) return; // has its own top-level console + if (name === cfg.managementName()) return; const a = find(name); if (!a || !a.configured) return; + const mgmtUrl = cfg.managementUrl(); + if (mgmtUrl && a.url === mgmtUrl) { + cfg.note( + "info", + `agents: "${name}" is the management agent's own endpoint — use Agent chat above instead of a second console`, + ); + return; + } close(); openName = name; if (consoleEl) consoleEl.hidden = false; @@ -143,15 +154,6 @@ 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/deploy.ts b/console/src/deploy.ts index 8f40fc9..0e181bf 100644 --- a/console/src/deploy.ts +++ b/console/src/deploy.ts @@ -52,6 +52,9 @@ export interface DeployedInfo { export interface DeployPanelDeps { source: Source; onDeployed(info: DeployedInfo): void | Promise; + // Re-run the #config/#fleet-detail visibility logic on close — main.ts owns + // which of the two `activeFleet` selects, this panel doesn't need to know. + restoreScreen(): void; } export interface DeployPanelHandle { @@ -59,11 +62,15 @@ export interface DeployPanelHandle { close(): void; } -// Wires the `#deploy-wrap` panel declared in index.html. `null` if the DOM -// isn't present (mirrors the rest of the console's init* functions). +// Wires the `#deploy-wrap` panel declared in index.html. It lives inside +// `.drilldown-main` (a sibling of `#config`/`#fleet-detail`) so it takes over +// just the main column while open — the persistent side column (identity + +// Agent chat) stays put, same as every other depth of the drill-down. `null` +// if the DOM isn't present (mirrors the rest of the console's init* functions). export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null { const wrap = document.getElementById("deploy-wrap"); - const drilldownRow = document.getElementById("drilldown-row"); + const configEl = document.getElementById("config"); + const fleetDetailEl = document.getElementById("fleet-detail"); const titleEl = document.getElementById("deploy-title"); const cancelBtn = document.getElementById("deploy-cancel") as HTMLButtonElement | null; const identityForm = document.getElementById("deploy-identity-form") as HTMLFormElement | null; @@ -154,7 +161,8 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null composeHeading.textContent = m.kind === "new-fleet" ? "Step 2 — first instance" : "Compose"; } - if (drilldownRow) drilldownRow.hidden = true; + if (configEl) configEl.hidden = true; + if (fleetDetailEl) fleetDetailEl.hidden = true; wrap.hidden = false; void loadLibraryAndPickers(); }; @@ -162,7 +170,7 @@ export function initDeployPanel(deps: DeployPanelDeps): DeployPanelHandle | null const close = (): void => { mode = null; wrap.hidden = true; - if (drilldownRow) drilldownRow.hidden = false; + deps.restoreScreen(); reset(); }; diff --git a/console/src/fileBrowser.ts b/console/src/fileBrowser.ts deleted file mode 100644 index fff7804..0000000 --- a/console/src/fileBrowser.ts +++ /dev/null @@ -1,164 +0,0 @@ -// 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/main.ts b/console/src/main.ts index ed19700..2330012 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -21,6 +21,7 @@ import { initAgentConsole, type AgentConsole } from "./agentConsole"; import { initDeployPanel, type DeployPanelHandle, type DeployedInfo } from "./deploy"; import { createPane, bindBackend, type Level } from "./log"; import { initThemeToggle } from "./theme"; +import { initSplitPane } from "./splitPane"; import { EditorView, basicSetup } from "codemirror"; import { EditorState } from "@codemirror/state"; import { StreamLanguage } from "@codemirror/language"; @@ -51,6 +52,7 @@ const configEl = document.getElementById("config"); const fleetDetailEl = document.getElementById("fleet-detail"); const fdHeaderEl = document.getElementById("fd-header"); const remoteEl = document.getElementById("remote"); +const composeStandaloneEl = document.getElementById("compose-standalone"); const editorSection = document.getElementById("config-editor"); const editorMount = document.getElementById("cfg-editor-mount"); const editorError = document.getElementById("cfg-editor-error"); @@ -296,9 +298,14 @@ async function refreshRemote(): Promise { // `activeFleet === null` shows the Fleets screen (the `#config` list); a // selected fleet shows Fleet detail (breadcrumb header + the members roster, // with each member drilling further into its Agent console — slice 3) instead. +// Remote (management-connection setup) and Compose (template/bundle authoring) +// are Fleets-screen concerns — not relevant once drilled into a fleet/agent, so +// they hide alongside it. function updateScreen(): void { if (configEl) configEl.hidden = activeFleet !== null; if (fleetDetailEl) fleetDetailEl.hidden = activeFleet === null; + if (remoteEl) remoteEl.hidden = activeFleet !== null; + if (composeStandaloneEl) composeStandaloneEl.hidden = activeFleet !== null; if (activeFleet && fdHeaderEl) renderFleetDetailHeader(fdHeaderEl, activeFleet); } @@ -373,6 +380,12 @@ async function handleDeployed(info: DeployedInfo): Promise { const deployPanel: DeployPanelHandle | null = initDeployPanel({ source, onDeployed: handleDeployed, + // Deploy replaces whichever of #config/#fleet-detail is showing in the main + // column while open (Brett: it read as a stray extra panel when it instead + // rendered full-width below an unrelated-looking roster+chat row); closing + // it just re-runs the same screen logic used everywhere else `activeFleet` + // changes, rather than duplicating "which one was showing" here. + restoreScreen: updateScreen, }); // Fleet detail shows either the members roster or the open Agent console, never @@ -906,6 +919,8 @@ async function boot(): Promise { note, panels: chatPanels, managementName: () => managementName, + managementUrl: () => + remoteConfig && remoteConfig.status !== "disconnected" ? remoteConfig.url : null, }); if (clusterLabel) clusterLabel.textContent = activeCluster; note("info", `app: polling cluster "${activeCluster}" every ${POLL_MS / 1000}s`); @@ -913,6 +928,26 @@ async function boot(): Promise { // prefers-color-scheme default. const themeBtn = document.getElementById("theme-btn"); if (themeBtn) initThemeToggle(themeBtn as HTMLButtonElement); + // Drag-resize between the drill-down column and the persistent Agent chat + // side (`.drilldown-side`) — widening the side is how the operator makes + // the chat panel bigger. + const drilldownResizer = document.getElementById("drilldown-resizer"); + const drilldownSide = document.getElementById("drilldown-side"); + if (drilldownResizer && drilldownSide) initSplitPane(drilldownResizer, drilldownSide); + // The Debug drawer anchors below the topbar (`--topbar-h`, styles.css) so it + // slides in without hiding the topbar or the drawer's own header underneath + // it. Tracked live — the topbar's height isn't hard-coded. + const topbarEl = document.querySelector(".topbar"); + if (topbarEl) { + const syncTopbarHeight = (): void => { + document.documentElement.style.setProperty( + "--topbar-h", + `${topbarEl.getBoundingClientRect().height}px`, + ); + }; + syncTopbarHeight(); + new ResizeObserver(syncTopbarHeight).observe(topbarEl); + } setupUpdater(); await startCore(); // Debug drawer's Config tab: pin the oab-mcp target (cluster/profile/region → diff --git a/console/src/render.test.ts b/console/src/render.test.ts index 96b5a10..22e5f16 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -7,8 +7,6 @@ import { remoteHtml, agentListHtml, agentConsoleHeaderHtml, - fsListingHtml, - fsUnavailableHtml, filterByMembers, serviceName, deploymentKey, @@ -24,7 +22,6 @@ import { AGENT_STATES, type AgentEndpointView, type Deployment, - type FsListing, type RuntimeContext, } from "./types"; @@ -462,41 +459,56 @@ describe("agentListHtml", () => { } it("renders an empty-state pointing at agents.toml when the registry is empty", () => { - const html = agentListHtml([], null); + const html = agentListHtml([], null, null); expect(html).toContain("ag-empty"); expect(html).toContain("agents.toml"); }); it("makes an ordinary configured endpoint an openable button", () => { - const html = agentListHtml([ep({ name: "mira" })], null); + const html = agentListHtml([ep({ name: "mira" })], null, null); expect(html).toContain('data-agent="mira"'); expect(html).toContain(" { - const html = agentListHtml([ep({ name: "falcon", configured: false, url: "" })], null); + const html = agentListHtml( + [ep({ name: "falcon", configured: false, url: "" })], + null, + null, + ); expect(html).toContain('data-agent="falcon"'); expect(html).toContain("disabled"); expect(html).toContain("not configured"); }); it("shows the management endpoint but does not make it openable", () => { - const html = agentListHtml([ep({ name: "orca", management: true })], null); + const html = agentListHtml([ep({ name: "orca", management: true })], null, null); // no data-agent hook → the delegated open handler can't fire for it expect(html).not.toContain('data-agent="orca"'); expect(html).toContain("management"); expect(html).toContain("console above"); }); + it("also treats an un-flagged endpoint as management when its url matches the active management connection", () => { + const html = agentListHtml( + [ep({ name: "orca", management: false, url: "wss://orca-acp.example/acp" })], + null, + "wss://orca-acp.example/acp", + ); + expect(html).not.toContain('data-agent="orca"'); + expect(html).toContain("management"); + expect(html).toContain("console above"); + }); + it("marks the currently open console as pressed", () => { - const html = agentListHtml([ep({ name: "mira" })], "mira"); + const html = agentListHtml([ep({ name: "mira" })], "mira", null); expect(html).toContain('aria-pressed="true"'); expect(html).toContain("is-open"); }); it("renders every fixture endpoint", () => { - const html = agentListHtml(FIXTURE_AGENTS, null); + const html = agentListHtml(FIXTURE_AGENTS, null, null); for (const a of FIXTURE_AGENTS) expect(html).toContain(a.name); }); @@ -504,6 +516,7 @@ describe("agentListHtml", () => { const html = agentListHtml( [ep({ name: "a" })], null, + null, ); expect(html).not.toContain("