diff --git a/console/index.html b/console/index.html index ce892e2..1deea95 100644 --- a/console/index.html +++ b/console/index.html @@ -46,6 +46,35 @@ + + + agent consoles + select an agent to open its console — chat + read-only config + + + + + agent console + + Close + + + + + Chat + disconnected + + + + + + Stop + Send + + + + + Activity diff --git a/console/src/agentConsole.ts b/console/src/agentConsole.ts new file mode 100644 index 0000000..2502798 --- /dev/null +++ b/console/src/agentConsole.ts @@ -0,0 +1,185 @@ +// The agent-console shell (ADR agent-consoles, Part C): an endpoint selector + +// a per-agent console that dials the endpoint, shows its read-only config, and +// mounts the reusable chat primitive bound to that agent. The operator opens one +// console at a time (ADR §7 bounded fan-out), so this owns a single open console +// and a clean open→dial / close→teardown lifecycle. +// +// 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. + +import type { Source } from "./source"; +import type { AgentEndpointView } from "./types"; +import { createChatPanel, type ChatPanel } from "./chatPanel"; +import { renderAgentList, agentConsoleHeaderHtml } from "./render"; + +export interface AgentConsoleConfig { + source: Source; + // Browser build (no live agent) → the mounted panel drives a canned reply. + mock: boolean; + note: (level: "info" | "error", msg: string) => void; + // The shared event-router map (keyed by endpoint name) `main.ts` dispatches + // backend events through. The open console registers/unregisters its panel here. + panels: Map; + // 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; +} + +export interface AgentConsole { + // Re-fetch the registry and re-render the selector. + refresh(): Promise; + // Reflect a live `remote-status` on the open console's badge (no-op otherwise). + onStatus(agent: string, status: string): void; + dispose(): void; +} + +export function initAgentConsole(cfg: AgentConsoleConfig): AgentConsole { + const listEl = document.getElementById("agent-list"); + const consoleEl = document.getElementById("agent-console"); + const configEl = document.getElementById("ac-config"); + const log = document.getElementById("ac-chat-log"); + const form = document.getElementById("ac-chat-form") as HTMLFormElement | null; + const text = document.getElementById("ac-chat-text") as HTMLTextAreaElement | null; + 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 noop: AgentConsole = { + refresh: async () => {}, + onStatus: () => {}, + dispose: () => {}, + }; + if (!listEl || !consoleEl) return noop; + + let agents: AgentEndpointView[] = []; + let openName: string | null = null; + let panel: ChatPanel | null = null; + const ac = new AbortController(); + const { signal } = ac; + + function errText(e: unknown): string { + return e instanceof Error ? e.message : String(e); + } + + function find(name: string): AgentEndpointView | undefined { + return agents.find((a) => a.name === name); + } + + function renderList(): void { + if (listEl) renderAgentList(listEl, agents, openName); + } + + function renderHeader(status: string): void { + const a = openName ? find(openName) : undefined; + if (!configEl || !a) return; + configEl.innerHTML = agentConsoleHeaderHtml(a, status); + } + + async function refresh(): Promise { + try { + agents = await cfg.source.remoteAgents(); + } catch (e) { + agents = []; + cfg.note("error", `agents: registry load failed — ${errText(e)}`); + } + renderList(); + // Keep an open console's header status in sync with the refreshed registry. + if (openName) renderHeader(find(openName)?.status ?? "disconnected"); + } + + // Tear down the open console: dispose its panel, drop it from the router, hide + // the section, and disconnect the endpoint. Safe to call with nothing open. + function close(): void { + if (!openName) return; + const name = openName; + openName = null; + panel?.dispose(); + panel = null; + cfg.panels.delete(name); + if (consoleEl) consoleEl.hidden = true; + // Fire-and-forget teardown; a failed disconnect is logged, not fatal. + void cfg.source.remoteDisconnect(name).catch((e) => { + cfg.note("error", `agents: disconnect ${name} failed — ${errText(e)}`); + }); + cfg.note("info", `agents: closed console for "${name}"`); + renderList(); + } + + // 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. + async function open(name: string): Promise { + if (name === openName) return; + if (name === cfg.managementName()) return; // has its own top-level console + const a = find(name); + if (!a || !a.configured) return; + close(); + openName = name; + if (consoleEl) consoleEl.hidden = false; + // Optimistic "connecting" until the first `remote-status` (or ready in mock). + renderHeader(cfg.mock ? "connected" : "connecting"); + renderList(); + if (log && form && text && send && stop && conn) { + panel = createChatPanel( + { log, form, text, send, stop, conn }, + { + agent: name, + source: cfg.source, + mock: cfg.mock, + note: cfg.note, + notReadyLabel: "connecting to the agent…", + }, + ); + cfg.panels.set(name, panel); + } + try { + await cfg.source.remoteConnect(name); + cfg.note("info", `agents: opened console for "${name}" — dialing ${a.url}`); + } catch (e) { + cfg.note("error", `agents: connect ${name} failed — ${errText(e)}`); + renderHeader(`error: ${errText(e)}`); + } + } + + function onStatus(agent: string, status: string): void { + // Update the cached entry so the selector badge reflects it too. + const a = find(agent); + if (a) a.status = status; + renderList(); + if (agent === openName) renderHeader(status); + } + + // Delegated: a selector row opens its endpoint; the console's Close tears down. + listEl.addEventListener( + "click", + (ev) => { + const btn = (ev.target as HTMLElement).closest("[data-agent]"); + if (btn?.dataset.agent) void open(btn.dataset.agent); + }, + { signal }, + ); + consoleEl.addEventListener( + "click", + (ev) => { + if ((ev.target as HTMLElement).closest('[data-action="close-console"]')) { + close(); + } + }, + { signal }, + ); + + void refresh(); + + return { + refresh, + onStatus, + dispose: () => { + close(); + ac.abort(); + }, + }; +} diff --git a/console/src/chatPanel.ts b/console/src/chatPanel.ts new file mode 100644 index 0000000..3511b9f --- /dev/null +++ b/console/src/chatPanel.ts @@ -0,0 +1,269 @@ +// The chat panel as a **reusable primitive** (ADR agent-consoles Part A: "the +// chat panel is one component, instantiated against a chosen endpoint"). Both +// the management console and each per-agent console mount one of these; the only +// difference is the endpoint the turns are sent to (`opts.agent`) and the DOM it +// binds. All the turn machinery — the transcript, the one-turn-at-a-time queue, +// the streaming spinner, stop/retry, copy — lives here so it is written once. +// +// The pure transcript reducers and HTML stay in `chat.ts` (unit-tested without a +// DOM); this module owns the imperative shell: DOM writes, the ``/keydown +// listeners, and the queue. It does **not** subscribe to backend events itself — +// the caller (`main.ts`) owns the single `agent-update` / `remote-status` +// subscription and routes each event to the matching panel by endpoint name via +// `onChunk` / `onTurnEnd` / `setConnected`. That keeps one listener for N panels +// and makes routing explicit. + +import DOMPurify from "dompurify"; +import { + transcriptHtml, + mdToHtml, + appendUser, + appendChunk, + endTurn, + type ChatTurn, +} from "./chat"; +import type { Source } from "./source"; + +// The DOM a panel drives. The management console and each agent console pass +// their own set of these (same roles, different nodes). +export interface ChatPanelElements { + log: HTMLElement; + form: HTMLFormElement; + text: HTMLTextAreaElement; + send: HTMLButtonElement; + stop: HTMLButtonElement; + conn: HTMLElement; +} + +export interface ChatPanelOptions { + // The registry endpoint name turns are sent to (`agentPrompt`/`agentCancel` + // pass it through). Omitted ⇒ the management endpoint (legacy single-console + // commands). Event routing keys off this same name in `main.ts`. + agent?: string; + source: Source; + // True in the browser build (no live agent): drive a canned reply locally so + // the panel stays demonstrable without a gateway. + mock: boolean; + note: (level: "info" | "error", msg: string) => void; + // Shown on the connection pill when chat isn't usable yet. Defaults to the + // management console's wording; an agent console overrides it (it auto-dials). + notReadyLabel?: string; +} + +// The imperative handle the caller drives. `onChunk`/`onTurnEnd` feed streamed +// backend events in; `setConnected` reflects the live transport state; `dispose` +// tears down the DOM listeners (an agent console re-binds a panel per selection). +export interface ChatPanel { + readonly agent?: string; + onChunk(text: string): void; + onTurnEnd(stopReason: string): void; + setConnected(connected: boolean): void; + isConnected(): boolean; + render(): void; + dispose(): void; +} + +// Untrusted agent markdown → HTML: markdown-it escapes raw HTML and blocks +// dangerous link protocols; DOMPurify is the second layer (ADR: markdown-it + +// DOMPurify). Only the agent-markdown body takes this — user text and the +// panel's own chrome are escaped/trusted in `chat.ts`. +function renderAgentBody(text: string): string { + return DOMPurify.sanitize(mdToHtml(text)); +} + +export function createChatPanel( + els: ChatPanelElements, + opts: ChatPanelOptions, +): ChatPanel { + const notReady = opts.notReadyLabel ?? "activate the remote connection to chat"; + // Per-panel transcript state (was module-global in main.ts's single console). + let turns: ChatTurn[] = []; + let turnActive = false; + const queue: string[] = []; + let seq = 0; + let connected = false; + // DOM listeners are scoped to this controller so `dispose()` removes them all + // at once — an agent console mounts a fresh panel each time it opens. + const ac = new AbortController(); + const { signal } = ac; + + function errText(e: unknown): string { + return e instanceof Error ? e.message : String(e); + } + + // Chat is usable once the connection is live (or always, in the mock). + function ready(): boolean { + return opts.mock || connected; + } + + function render(): void { + els.log.innerHTML = transcriptHtml(turns, renderAgentBody); + els.log.scrollTop = els.log.scrollHeight; // keep the latest turn in view + } + + function updateControls(): void { + const r = ready(); + els.send.disabled = !r; + els.text.disabled = !r; + els.stop.hidden = !turnActive; + const label = !r ? notReady : turnActive ? "agent is responding…" : "connected"; + els.conn.textContent = label; + els.conn.classList.toggle("is-connected", r && !turnActive); + els.conn.classList.toggle("is-error", false); + } + + // Enqueue a prompt and try to release it. `flush` is the single choke point + // that enforces one-turn-at-a-time; typing mid-turn just grows the queue. + function submit(text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + queue.push(trimmed); + void flush(); + } + + async function flush(): Promise { + if (turnActive) return; // a turn is in flight — wait for its `turn_end` + const next = queue.shift(); + if (next === undefined) return; + turnActive = true; + seq += 1; + turns = appendUser(turns, seq, next); + render(); + updateControls(); + try { + await opts.source.agentPrompt(next, opts.agent); + if (opts.mock) mockReply(next); // browser preview: synthesize the reply + } catch (e) { + // Send failed (not connected / socket just closed): close the turn with an + // error, surface it, release the queue so a later prompt can still go. + turnActive = false; + opts.note("error", `chat: ${errText(e)}`); + seq += 1; + turns = endTurn(turns, seq, "error"); + render(); + updateControls(); + void flush(); + } + } + + // A streamed `chunk`: open the agent turn on the first one (stable id for its + // copy button), append thereafter. + function onChunk(text: string): void { + const last = turns[turns.length - 1]; + const open = last?.role === "agent" && last.streaming; + const id = open ? (last as ChatTurn).id : (seq += 1); + turns = appendChunk(turns, id, text); + render(); + } + + // `turn_end`: finalize the open agent turn (markdown render), free the gate, + // and release any queued prompt. + function onTurnEnd(stopReason: string): void { + seq += 1; + turns = endTurn(turns, seq, stopReason); + turnActive = false; + render(); + updateControls(); + void flush(); + } + + async function stopTurn(): Promise { + if (!turnActive) return; + try { + await opts.source.agentCancel(opts.agent); + opts.note("info", "chat: cancel sent"); + } catch (e) { + opts.note("error", `chat: cancel failed — ${errText(e)}`); + } + // The backend still emits a `turn_end` (stopReason `cancelled`), which clears + // `turnActive` and flushes the queue — no local state change needed here. + } + + // Browser preview only: stream a short canned markdown reply so the chunk → + // turn_end → markdown path is visible without a live gateway. + function mockReply(prompt: string): void { + const parts = [ + `You said: **${prompt}**.\n\n`, + "Here's what the panel renders:\n\n", + "- streamed *chunks*\n- then final `markdown`\n\n", + "```\ncode stays monospaced\n```", + ]; + let i = 0; + const step = (): void => { + if (i < parts.length) { + onChunk(parts[i]); + i += 1; + window.setTimeout(step, 130); + } else { + onTurnEnd("end_turn"); + } + }; + window.setTimeout(step, 150); + } + + function setConnected(next: boolean): void { + connected = next; + // If the socket drops mid-turn, no `turn_end` will arrive — close the open + // turn so the panel doesn't hang on a spinner. + if (!connected && turnActive) onTurnEnd("disconnected"); + updateControls(); + } + + // ---- wiring --------------------------------------------------------------- + els.form.addEventListener( + "submit", + (ev) => { + ev.preventDefault(); + submit(els.text.value); + els.text.value = ""; + }, + { signal }, + ); + // Enter sends; Shift+Enter inserts a newline. + els.text.addEventListener( + "keydown", + (ev) => { + if (ev.key === "Enter" && !ev.shiftKey) { + ev.preventDefault(); + els.form.requestSubmit(); + } + }, + { signal }, + ); + els.stop.addEventListener("click", () => void stopTurn(), { signal }); + // Delegated copy: copy the raw turn text (not the rendered HTML). + els.log.addEventListener( + "click", + (ev) => { + const btn = (ev.target as HTMLElement).closest( + "button.chat-copy", + ); + if (!btn) return; + const turn = turns.find((t) => t.id === Number(btn.dataset.copy)); + if (!turn) return; + void navigator.clipboard?.writeText(turn.text).then( + () => { + btn.textContent = "Copied"; + window.setTimeout(() => { + if (btn.isConnected) btn.textContent = "Copy"; + }, 1500); + }, + () => opts.note("error", "chat: copy failed"), + ); + }, + { signal }, + ); + + render(); + updateControls(); + + return { + agent: opts.agent, + onChunk, + onTurnEnd, + setConnected, + isConnected: () => connected, + render, + dispose: () => ac.abort(), + }; +} diff --git a/console/src/fixtures.ts b/console/src/fixtures.ts index f7c4500..673f031 100644 --- a/console/src/fixtures.ts +++ b/console/src/fixtures.ts @@ -1,10 +1,42 @@ import type { + AgentEndpointView, Deployment, FleetConfig, RemoteConfig, RuntimeContext, } from "./types"; +// 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 +// consoles. Tokens are never present — only whether each entry is configured. +export const FIXTURE_AGENTS: AgentEndpointView[] = [ + { + name: "orca", + url: "wss://orca-acp.example/acp", + cwd: "/home/node", + management: true, + configured: true, + status: "disconnected", + }, + { + name: "mira", + url: "wss://mira-acp.example/acp", + cwd: "/home/node", + management: false, + configured: true, + status: "disconnected", + }, + { + name: "falcon", + url: "", + cwd: "/home/node", + management: false, + configured: false, + status: "disconnected", + }, +]; + // Stand-in remote-connection view so the browser build renders the remote panel // without a core. A configured-but-disconnected example — the shape the panel // shows before the operator hits "Activate". diff --git a/console/src/main.ts b/console/src/main.ts index 8847741..2ef0b4c 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -10,15 +10,8 @@ import { deploymentKey, } from "./render"; import type { Deployment, FleetConfig, RemoteConfig } from "./types"; -import { - transcriptHtml, - mdToHtml, - appendUser, - appendChunk, - endTurn, - type ChatTurn, -} from "./chat"; -import DOMPurify from "dompurify"; +import { createChatPanel, type ChatPanel } from "./chatPanel"; +import { initAgentConsole, type AgentConsole } from "./agentConsole"; import { createPane, bindBackend, type Level } from "./log"; import { EditorView, basicSetup } from "codemirror"; import { EditorState } from "@codemirror/state"; @@ -39,6 +32,7 @@ let activeCluster = DEFAULT_CLUSTER; let activeMembers: string[] = []; let fleetConfig: FleetConfig | null = null; let remoteConfig: RemoteConfig | null = null; +let agentConsole: AgentConsole | null = null; const roster = document.getElementById("roster"); const identityEl = document.getElementById("identity"); @@ -362,187 +356,83 @@ if (remoteEl) { }); } -// ---- chat panel (Part C) ----------------------------------------------------- -// The backend serves ONE turn at a time over the live `/acp` session (Part B): -// `agent_prompt` sends a turn, and the reply streams back as `agent-update` -// events (`chunk` → `turn_end`). `turnActive` gates sends; a prompt typed -// mid-turn is queued and `flushQueue` releases the next only once the current -// turn ends (the katashiro turn model). This UI-level gate means two turns never -// overlap — so the backend's in-flight guard is a safety net, not the norm. -let chatTurns: ChatTurn[] = []; -let turnActive = false; -const promptQueue: string[] = []; -let chatSeq = 0; -let remoteConnected = false; - -// True in the browser build (no Tauri shell): there is no live agent, so we drive -// a canned reply locally to keep the panel demonstrable. +// ---- chat panels (Part C, reusable primitive) -------------------------------- +// The chat panel is one component (`chatPanel.ts`) instantiated per endpoint. The +// management console mounts one against the management binding; each agent +// console mounts one against its agent (`agentConsole.ts`). This module owns the +// single `agent-update` / `remote-status` subscription and routes each event to +// the matching panel by endpoint name — the backend tags every event with the +// agent it belongs to, so N panels share one listener. +const chatPanels = new Map(); + +// True in the browser build (no Tauri shell): there is no live agent, so panels +// drive a canned reply locally to stay demonstrable. function isMock(): boolean { return tauriInvoke() === undefined; } -// Chat is usable once the remote connection is live (or always, in the mock). -function chatReady(): boolean { - return isMock() || remoteConnected; -} - -// Untrusted agent markdown → HTML: markdown-it escapes raw HTML and blocks -// dangerous link protocols; DOMPurify is the second layer (ADR: markdown-it + -// DOMPurify). Only the agent-markdown body takes this — user text and the -// panel's own chrome are escaped/trusted in `chat.ts`. -function renderAgentBody(text: string): string { - return DOMPurify.sanitize(mdToHtml(text)); -} - -function renderChat(): void { - if (!chatLogEl) return; - chatLogEl.innerHTML = transcriptHtml(chatTurns, renderAgentBody); - chatLogEl.scrollTop = chatLogEl.scrollHeight; // keep the latest turn in view -} - -function updateChatControls(): void { - const ready = chatReady(); - if (chatSendEl) chatSendEl.disabled = !ready; - if (chatTextEl) chatTextEl.disabled = !ready; - if (chatStopEl) chatStopEl.hidden = !turnActive; - if (chatConnEl) { - const label = !ready - ? "activate the remote connection to chat" - : turnActive - ? "agent is responding…" - : "connected"; - chatConnEl.textContent = label; - chatConnEl.classList.toggle("is-connected", ready && !turnActive); - chatConnEl.classList.toggle("is-error", false); - } +// The management console's chat panel + the endpoint name its events carry. The +// panel is built at boot; its name is learned from the registry (the +// `management: true` entry) so events tagged with that name route here. `agent: +// undefined` ⇒ the legacy single-console commands (no `agent` arg). +let managementPanel: ChatPanel | null = null; +let managementName: string | null = null; + +function buildManagementPanel(): void { + if ( + !chatLogEl || + !chatFormEl || + !chatTextEl || + !chatSendEl || + !chatStopEl || + !chatConnEl + ) + return; + managementPanel = createChatPanel( + { + log: chatLogEl, + form: chatFormEl, + text: chatTextEl, + send: chatSendEl, + stop: chatStopEl, + conn: chatConnEl, + }, + { source, mock: isMock(), note }, + ); } -// Enqueue a prompt and try to release it. `flushQueue` is the single choke point -// that enforces one-turn-at-a-time; typing mid-turn just grows the queue. -function submitPrompt(text: string): void { - const trimmed = text.trim(); - if (!trimmed) return; - promptQueue.push(trimmed); - void flushQueue(); -} - -async function flushQueue(): Promise { - if (turnActive) return; // a turn is in flight — wait for its `turn_end` - const next = promptQueue.shift(); - if (next === undefined) return; - turnActive = true; - chatSeq += 1; - chatTurns = appendUser(chatTurns, chatSeq, next); - renderChat(); - updateChatControls(); +// Key the management panel under its endpoint name (from the registry) so its +// `agent-update` / `remote-status` events route to it. The legacy `remote.toml` +// setup is adopted as a `management` entry named "management". +async function registerManagementPanel(): Promise { + if (!managementPanel) return; try { - await source.agentPrompt(next); - if (isMock()) mockReply(next); // browser preview: synthesize the reply - } catch (e) { - // Send failed (not connected / socket just closed): don't leave the panel - // hanging on a spinner — close the turn with an error, surface it, release - // the queue so a later (connected) prompt can still go. - turnActive = false; - note("error", `chat: ${errText(e)}`); - chatSeq += 1; - chatTurns = endTurn(chatTurns, chatSeq, "error"); - renderChat(); - updateChatControls(); - void flushQueue(); + const agents = await source.remoteAgents(); + managementName = agents.find((a) => a.management)?.name ?? "management"; + } catch { + managementName = "management"; } + chatPanels.set(managementName, managementPanel); } -// A streamed `chunk`: open the agent turn on the first one (stable id for its -// copy button), append thereafter. -function onAgentChunk(text: string): void { - const last = chatTurns[chatTurns.length - 1]; - const open = last?.role === "agent" && last.streaming; - const id = open ? (last as ChatTurn).id : (chatSeq += 1); - chatTurns = appendChunk(chatTurns, id, text); - renderChat(); +// Route a backend event to the panel that owns the endpoint. Unknown names (a +// console that was closed, or an agent with no open panel) are dropped. +function routeChunk(agent: string, text: string): void { + chatPanels.get(agent)?.onChunk(text); } - -// `turn_end`: finalize the open agent turn (markdown render), free the gate, and -// release any queued prompt. -function onAgentTurnEnd(stopReason: string): void { - chatSeq += 1; - chatTurns = endTurn(chatTurns, chatSeq, stopReason); - turnActive = false; - renderChat(); - updateChatControls(); - void flushQueue(); +function routeTurnEnd(agent: string, stopReason: string): void { + chatPanels.get(agent)?.onTurnEnd(stopReason); +} +function routeStatus(agent: string, status: string): void { + chatPanels.get(agent)?.setConnected(status === "connected"); + // Also reflect it on the agent console's read-only status badge (no-op when + // the event isn't for the currently open console). + agentConsole?.onStatus(agent, status); } -async function stopTurn(): Promise { - if (!turnActive) return; - try { - await source.agentCancel(); - note("info", "chat: cancel sent"); - } catch (e) { - note("error", `chat: cancel failed — ${errText(e)}`); - } - // The backend still emits a `turn_end` (stopReason `cancelled`), which clears - // `turnActive` and flushes the queue — no local state change needed here. -} - -// Browser preview only: stream a short canned markdown reply so the chunk → -// turn_end → markdown path is visible without a live gateway. -function mockReply(prompt: string): void { - const parts = [ - `You said: **${prompt}**.\n\n`, - "Here's what the panel renders:\n\n", - "- streamed *chunks*\n- then final `markdown`\n\n", - "```\ncode stays monospaced\n```", - ]; - let i = 0; - const step = (): void => { - if (i < parts.length) { - onAgentChunk(parts[i]); - i += 1; - window.setTimeout(step, 130); - } else { - onAgentTurnEnd("end_turn"); - } - }; - window.setTimeout(step, 150); -} - -chatFormEl?.addEventListener("submit", (ev) => { - ev.preventDefault(); - if (!chatTextEl) return; - submitPrompt(chatTextEl.value); - chatTextEl.value = ""; -}); -// Enter sends; Shift+Enter inserts a newline. -chatTextEl?.addEventListener("keydown", (ev) => { - if (ev.key === "Enter" && !ev.shiftKey) { - ev.preventDefault(); - chatFormEl?.requestSubmit(); - } -}); -chatStopEl?.addEventListener("click", () => void stopTurn()); -// Delegated copy: copy the raw turn text (not the rendered HTML). -chatLogEl?.addEventListener("click", (ev) => { - const btn = (ev.target as HTMLElement).closest( - "button.chat-copy", - ); - if (!btn) return; - const turn = chatTurns.find((t) => t.id === Number(btn.dataset.copy)); - if (!turn) return; - void navigator.clipboard?.writeText(turn.text).then( - () => { - btn.textContent = "Copied"; - window.setTimeout(() => { - if (btn.isConnected) btn.textContent = "Copy"; - }, 1500); - }, - () => note("error", "chat: copy failed"), - ); -}); - -// Subscribe to the backend's streamed chat updates (desktop only). `chunk` and -// `turn_end` drive the transcript; the browser build has no bridge and uses the -// mock reply path instead. +// Subscribe to the backend's streamed chat updates (desktop only). Each event is +// tagged with the `agent` endpoint it belongs to; route it to that panel. The +// browser build has no bridge and drives the mock reply path per panel instead. async function bindAgentUpdates(): Promise { const listen = ( globalThis as { @@ -557,14 +447,17 @@ async function bindAgentUpdates(): Promise { } ).__TAURI__?.event?.listen; if (!listen) return; - await listen<{ kind?: string; text?: string; stopReason?: string }>( - "agent-update", - (e) => { - const p = e.payload; - if (p.kind === "chunk") onAgentChunk(p.text ?? ""); - else if (p.kind === "turn_end") onAgentTurnEnd(p.stopReason ?? "end_turn"); - }, - ); + await listen<{ + agent?: string; + kind?: string; + text?: string; + stopReason?: string; + }>("agent-update", (e) => { + const p = e.payload; + const agent = p.agent ?? managementName ?? "management"; + if (p.kind === "chunk") routeChunk(agent, p.text ?? ""); + else if (p.kind === "turn_end") routeTurnEnd(agent, p.stopReason ?? "end_turn"); + }); } // ---- start / stop (ADR-2 write model: stop = scale→0, start = scale→1) ------- @@ -725,17 +618,18 @@ async function bindRemoteStatus(): Promise { const listen = (globalThis as { __TAURI__?: EventGlobal }).__TAURI__?.event ?.listen; if (!listen) return; - await listen<{ status: string }>("remote-status", (e) => { + await listen<{ agent?: string; status: string }>("remote-status", (e) => { const status = e.payload?.status ?? "disconnected"; - if (remoteConfig) { + const agent = e.payload?.agent ?? managementName ?? "management"; + // The legacy remote panel shows only the management connection's status. + if (agent === managementName && remoteConfig) { remoteConfig = { ...remoteConfig, status }; if (remoteEl) renderRemote(remoteEl, remoteConfig); } - remoteConnected = status === "connected"; - // If the socket drops mid-turn, no `turn_end` will arrive — close the open - // turn so the panel doesn't hang on a spinner. - if (!remoteConnected && turnActive) onAgentTurnEnd(status); - updateChatControls(); + // Route the live state to the owning chat panel — it re-enables its input on + // `connected` and, on a mid-turn drop, closes the open turn so it doesn't + // hang on a spinner (handled inside the panel's `setConnected`). + routeStatus(agent, status); }); } @@ -744,10 +638,21 @@ async function bindRemoteStatus(): Promise { async function boot(): Promise { note("info", `OAB Studio ${BUILD} (built ${__BUILD_TIME__})`); if (activity && mcp) await bindBackend(activity, mcp); + // Mount the management chat panel and learn its endpoint name before binding + // the event listeners, so status/chat events route to it from the first tick. + buildManagementPanel(); + await registerManagementPanel(); await bindRemoteStatus(); await bindAgentUpdates(); - renderChat(); - updateChatControls(); + // The agent-console shell: the endpoint selector + a per-agent console (dial + + // read-only config + chat) sharing the same event router (`chatPanels`). + agentConsole = initAgentConsole({ + source, + mock: isMock(), + note, + panels: chatPanels, + managementName: () => managementName, + }); if (clusterLabel) clusterLabel.textContent = activeCluster; note("info", `app: polling cluster "${activeCluster}" every ${POLL_MS / 1000}s`); setupUpdater(); diff --git a/console/src/render.test.ts b/console/src/render.test.ts index e2fa253..84257d4 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -4,17 +4,25 @@ import { identityHtml, fleetConfigHtml, remoteHtml, + agentListHtml, + agentConsoleHeaderHtml, filterByMembers, serviceName, deploymentKey, } from "./render"; import { + FIXTURE_AGENTS, FIXTURE_DEPLOYMENTS, FIXTURE_FLEET_CONFIG, FIXTURE_REMOTE_CONFIG, FIXTURE_RUNTIME_CONTEXT, } from "./fixtures"; -import { AGENT_STATES, type Deployment, type RuntimeContext } from "./types"; +import { + AGENT_STATES, + type AgentEndpointView, + type Deployment, + type RuntimeContext, +} from "./types"; function ctx(partial: Partial): RuntimeContext { return { ...structuredClone(FIXTURE_RUNTIME_CONTEXT), ...partial }; @@ -365,3 +373,86 @@ describe("filterByMembers", () => { expect(serviceName({ ...FIXTURE_DEPLOYMENTS[0] })).toBe("oab-prod-orca"); }); }); + +describe("agentListHtml", () => { + function ep(partial: Partial): AgentEndpointView { + return { + name: "x", + url: "wss://x.example/acp", + cwd: "/home/node", + management: false, + configured: true, + status: "disconnected", + ...partial, + }; + } + + it("renders an empty-state pointing at agents.toml when the registry is empty", () => { + const html = agentListHtml([], 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); + expect(html).toContain('data-agent="mira"'); + expect(html).toContain(" { + const html = agentListHtml([ep({ name: "falcon", configured: false, url: "" })], 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); + // 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("marks the currently open console as pressed", () => { + const html = agentListHtml([ep({ name: "mira" })], "mira"); + expect(html).toContain('aria-pressed="true"'); + expect(html).toContain("is-open"); + }); + + it("renders every fixture endpoint", () => { + const html = agentListHtml(FIXTURE_AGENTS, null); + for (const a of FIXTURE_AGENTS) expect(html).toContain(a.name); + }); + + it("escapes endpoint names and urls", () => { + const html = agentListHtml( + [ep({ name: "a" })], + null, + ); + expect(html).not.toContain("