diff --git a/console/index.html b/console/index.html index 5f82f25..f4fab92 100644 --- a/console/index.html +++ b/console/index.html @@ -36,6 +36,45 @@ + + + agent consoles + select an agent to open its console — chat + read-only config + + + + + agent console + + Close + + + + + Files + + + + + + + + + + Chat + disconnected + + + + + + Stop + Send + + + + + @@ -64,45 +103,6 @@ - - - agent consoles - select an agent to open its console — chat + read-only config - - - - - agent console - - Close - - - - - Files - - - - - - - - - - Chat - disconnected - - - - - - Stop - Send - - - - - Activity diff --git a/console/src/main.ts b/console/src/main.ts index eac09e6..5352414 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -272,15 +272,23 @@ async function refreshRemote(): Promise { // The console's top-level drill-down (ADR #83 Part A): Fleets ↔ Fleet detail. // `activeFleet === null` shows the Fleets screen (the `#config` list); a -// selected fleet shows Fleet detail (breadcrumb header + the members roster) -// instead. This is screen 7.2 vs 7.3 — slice 2's scope; the further drill into -// an Agent console (7.4) is slice 3. +// selected fleet shows Fleet detail (breadcrumb header + the members roster, +// with each member drilling further into its Agent console — slice 3) instead. function updateScreen(): void { if (configEl) configEl.hidden = activeFleet !== null; if (fleetDetailEl) fleetDetailEl.hidden = activeFleet === null; if (activeFleet && fdHeaderEl) renderFleetDetailHeader(fdHeaderEl, activeFleet); } +// Leaving the fleet (back to Fleets, or switching to another one) also leaves +// any open Agent console — reuses the console's own wired Close button (see +// `openAgentForRow`'s note on why: `agentConsole.ts` stays untouched). +function closeOpenAgentConsole(): void { + document + .querySelector('#agent-console [data-action="close-console"]') + ?.click(); +} + // Switch the active fleet by identity (name): re-point every read at its cluster // (and thus its bound credential), filter the roster to its members, and refresh // immediately — so "switch fleet" == "switch managing account + roster" the ADR @@ -290,6 +298,7 @@ function selectFleet(name: string): void { if (!name || name === activeFleet) return; const fleet = fleetConfig?.fleets.find((f) => f.name === name); if (!fleet) return; + closeOpenAgentConsole(); activeFleet = name; activeCluster = fleet.cluster; activeMembers = fleet.members; @@ -305,6 +314,7 @@ function selectFleet(name: string): void { // unfiltered roster (whole default cluster) until another fleet is picked. function deselectFleet(): void { if (!activeFleet) return; + closeOpenAgentConsole(); activeFleet = null; activeCluster = DEFAULT_CLUSTER; activeMembers = []; @@ -316,6 +326,23 @@ function deselectFleet(): void { void tick(); } +// Fleet detail shows either the members roster or the open Agent console, never +// both (Part A: "the only navigation model," no tab-peer surfaces) — mirror +// `#agent-console`'s own `hidden` state (owned by `agentConsole.ts`, untouched) +// onto the roster rather than adding a second source of truth for what's open. +(function watchAgentConsoleVisibility(): void { + const consoleEl = document.getElementById("agent-console"); + if (!consoleEl || !roster) return; + const sync = (): void => { + roster.hidden = !consoleEl.hidden; + }; + new MutationObserver(sync).observe(consoleEl, { + attributes: true, + attributeFilter: ["hidden"], + }); + sync(); +})(); + // ---- TOML editor (fleets.toml + remote.toml) --------------------------------- // One CodeMirror TOML editor, shared by both config files (which one is set by // `editorTarget`). Kept imperative (CM owns real DOM) and separate from the @@ -604,6 +631,31 @@ async function scale( } } +// Drill into a roster row's Agent console (ADR #83 slice 3: mockup 7.4). The +// agent-console shell (`agentConsole.ts`) is untouched — its selector +// (`#agent-list`, now hidden via CSS in favor of this roster-row entry point) +// already opens-on-click for a `[data-agent]` button, so reuse that exact, +// already-tested path via a synthetic click rather than duplicating the +// open/dial/teardown logic here. Tries the service-name form first, then the +// short name — the same precedence `filterByMembers` uses for fleet members. +function openAgentForRow(svc: string, alt: string): void { + const btn = + document.querySelector( + `#agent-list [data-agent="${CSS.escape(svc)}"]`, + ) ?? + document.querySelector( + `#agent-list [data-agent="${CSS.escape(alt)}"]`, + ); + if (btn) { + btn.click(); + } else { + note( + "info", + `agents: no agent console registered for "${svc}" — add it to agents.toml to open one`, + ); + } +} + // One delegated listener on the roster. Start executes on click; Stop is // disruptive (kills the running instance, though reversible), so it arms on the // first click and only executes on a confirming second click within 3s — a @@ -611,7 +663,14 @@ async function scale( // roster and would reset an armed button on its own; the 3s timer is tighter. if (roster) { roster.addEventListener("click", (ev) => { - const btn = (ev.target as HTMLElement).closest("button.act"); + const target = ev.target as HTMLElement; + const openBtn = target.closest("button.row-open"); + if (openBtn) { + const { openAgent, openAgentAlt } = openBtn.dataset; + if (openAgent) openAgentForRow(openAgent, openAgentAlt ?? openAgent); + return; + } + const btn = target.closest("button.act"); if (!btn) return; const action = btn.dataset.action; const { name, namespace } = btn.dataset; diff --git a/console/src/render.test.ts b/console/src/render.test.ts index 9dbb61e..913e58c 100644 --- a/console/src/render.test.ts +++ b/console/src/render.test.ts @@ -140,6 +140,19 @@ describe("rosterHtml", () => { expect(html).not.toContain("act-pending"); expect(html).toContain('data-action="stop"'); }); + + it("makes each row's name a button carrying both agent-lookup identities (ADR #83 slice 3)", () => { + const html = rosterHtml([dep({ name: "orca", namespace: "prod" })]); + expect(html).toContain('class="row-open"'); + expect(html).toContain('data-open-agent="oab-prod-orca"'); + expect(html).toContain('data-open-agent-alt="orca"'); + }); + + it("escapes the row-open data attributes", () => { + const html = rosterHtml([dep({ name: '"x', namespace: "n" })]); + expect(html).toContain(""x"); + expect(html).not.toContain('data-open-agent-alt=""x"'); + }); }); describe("deploymentKey", () => { diff --git a/console/src/render.ts b/console/src/render.ts index 42a324e..a59b0e7 100644 --- a/console/src/render.ts +++ b/console/src/render.ts @@ -57,14 +57,21 @@ function actionButton(d: Deployment, pending: boolean): string { return `${label}`; } +// The row's name cell is a button, not plain text — clicking a member drills +// into its Agent console (ADR #83 slice 3: mockup 7.4), if one is registered. +// `data-open-agent`/`data-open-agent-alt` carry both candidate identities +// (service name and short name) the delegated handler tries against the +// registry, same precedence as `filterByMembers`'s member match. function rowHtml(d: Deployment, pending: ReadonlySet): string { const phases = d.instances.length ? d.instances.map((i) => badge(i.state)).join(" ") : `—`; const health = d.ready === d.desired ? "ok" : "warn"; const name = escapeHtml(`${d.namespace}/${d.name}`); + const svc = escapeHtml(serviceName(d)); + const shortName = escapeHtml(d.name); return ` - ${name} + ${name} ${d.ready}/${d.desired} · cur ${d.current} ${phases} ${actionButton(d, pending.has(deploymentKey(d)))} diff --git a/console/src/styles.css b/console/src/styles.css index cc63d11..77f82ce 100644 --- a/console/src/styles.css +++ b/console/src/styles.css @@ -312,6 +312,23 @@ td.name { font-weight: 600; font-variant-numeric: tabular-nums; } +/* A roster row's name is a button (ADR #83 slice 3) — drills into that + member's Agent console. Reset button chrome so it still reads as a name. */ +button.row-open { + appearance: none; + cursor: pointer; + border: 0; + background: transparent; + color: var(--text); + font: inherit; + font-weight: 600; + font-variant-numeric: tabular-nums; + padding: 0; +} +button.row-open:hover { + color: var(--s-starting); + text-decoration: underline; +} td.counts { font-variant-numeric: tabular-nums; } @@ -1207,15 +1224,20 @@ button.act:disabled { font: inherit; } -/* ---- Agent consoles (ADR agent-consoles Parts B/C) ------------------------ */ +/* ---- Agent consoles (ADR agent-consoles Parts B/C; ADR #83 slice 3: reached + by drilling into a roster row instead) ------------------------------------ */ .agents-wrap { margin: 0 0 12px; } +/* The flat endpoint selector is superseded as a reachability path by roster + rows (`button.row-open`, main.ts's `openAgentForRow`) — kept in the DOM + (still rendered by `agentConsole.ts`, untouched) since that's the synthetic + click target the roster-row handler reuses; just not shown. */ .agents-head { - display: flex; - align-items: baseline; - gap: 10px; - margin-bottom: 8px; + display: none; +} +#agent-list { + display: none; } .agents-label { font-weight: 600;