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