diff --git a/docs/requirements/001-agent-orchestrator.md b/docs/requirements/001-agent-orchestrator.md index 02475bc..3bb4998 100644 --- a/docs/requirements/001-agent-orchestrator.md +++ b/docs/requirements/001-agent-orchestrator.md @@ -303,6 +303,16 @@ from three views down to any agent, branch or artefact. time-agent-depth layout. 58. **Steady 3D camera**: the 3D view fits the graph once per build; state refreshes keep the operator's camera. +59. **Radio in the header** (operator 2026-11-10, replaces the toolbar button + of decision 52 and the Radio tab): a radio icon button at the top right, + next to Commands and Settings, opens the radio spot above the main area + of whichever tab is open. The spot holds the composer, the gear, an + icon-only Cancel mayday with its tooltip, who is speaking, the last five + sent messages and the internet radio stations. The Agents toolbar loses + its radio button, the Radio tab and its "Send …" button go away, and an + old `#stream/radio` link opens the Stream with the spot open. Rejected: + keeping the Radio tab, the speaker chips (every Agents view already marks + the speaker). ## Approach @@ -741,6 +751,19 @@ flowchart TD - [x] Two state refreshes after a manual camera move leave the 3D camera where the operator put it. +### Phase 6: radio in the header (S5.4) + +- [ ] The header shows Radio, Commands and Settings as icon buttons; Radio + opens the spot above the main area on Stream, Inbox, Agents, Artifacts + and Tasks, and closes it again. +- [ ] No Radio tab, no "Send …" button and no radio button in the Agents + toolbar remain; `#stream/radio` lands on `#stream` with the spot open + and the message field focused. +- [ ] Cancel mayday shows only its icon, keeps its tooltip, its confirmation + and its passkey signature. +- [ ] The spot shows the speaking caption, the last five sent messages and + the stations with play and stop. + ### Tests - **Unit** @@ -764,6 +787,8 @@ flowchart TD business value signature (forged, replayed, altered), sort orders. - [x] S5.1: regression and velocity trend with their forecast dates, point tooltips, ETA in both modes, radio settings stored from the gear. + - [ ] S5.4: the navigation without the Radio tab, `#stream/radio` + redirecting to `#stream` with the spot flag. - **Integration** - [ ] Two scratch sessions exchange `post` and `call` through the real hook entry points (JSON on stdin): the post appears only on the callee's @@ -792,3 +817,7 @@ flowchart TD - [x] S5.1: radio spot (send, gear, cancel mayday), Day/Night tooltips, one-row Tasks toolbar, trend and point tooltips, ETA column, Tasks 3D and a steady 3D camera, each at 1920, 1366 and 390 px. + - [ ] S5.4: the header radio button opens the spot on every tab, every + class sends from it, the icon-only Cancel mayday, the speaking + caption and the stations inside the spot, in Chromium and Firefox at + 1920, 1366 and 390 px. diff --git a/orchestrator/templates/index.html.j2 b/orchestrator/templates/index.html.j2 index 4ebb1a3..7a459db 100644 --- a/orchestrator/templates/index.html.j2 +++ b/orchestrator/templates/index.html.j2 @@ -420,6 +420,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi

Agent Orchestrator

+
@@ -433,6 +434,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
+
diff --git a/orchestrator/templates/nav.js b/orchestrator/templates/nav.js index 1d9b01a..5550599 100644 --- a/orchestrator/templates/nav.js +++ b/orchestrator/templates/nav.js @@ -1,12 +1,12 @@ const Nav = (() => { "use strict"; const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]]; - const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Firewall"], ["radio", "Radio"]]}; + const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Firewall"]]}; const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]]; const TASK_VIEWS = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"], ["3d", "3D"]]; const OWN = {agents: [["hierarchy", "Hierarchy"]]}; const ICONS = { - stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git", tasks: "bi-list-check", + stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git", tasks: "bi-list-check", orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2", list: "bi-list-task", board: "bi-kanban", rasci: "bi-grid-3x3", gantt: "bi-calendar-range", burn: "bi-graph-down-arrow", }; @@ -24,6 +24,9 @@ const Nav = (() => { function route(hash) { const [head, sub] = decodeURIComponent(String(hash).replace(/^#/, "")).split("/"); + if (head === "stream" && sub === "radio") { + return {screen: "stream", view: null, redirect: "#stream", spot: true}; + } if (Object.hasOwn(OLD, head)) { const [screen, fallback] = OLD[head]; const view = known(screen, sub) ? sub : fallback; diff --git a/orchestrator/templates/page-board.js b/orchestrator/templates/page-board.js index 93f8079..e8cf93d 100644 --- a/orchestrator/templates/page-board.js +++ b/orchestrator/templates/page-board.js @@ -36,70 +36,71 @@ return h("button", {type: "button", class: cls, ...named(label, hint), on: {click: () => confirmAct(action)}}, name ? icon(name) : null, label); } - function openSpot(spot, open) { + let spotDom = null; + + function openSpot(open) { ui.spot = open; - spot.panel.hidden = !open; - spot.toggle.setAttribute("aria-expanded", String(open)); + spotDom.panel.hidden = !open; + spotDom.toggle.setAttribute("aria-expanded", String(open)); window.requestAnimationFrame(refresh3d); if (open) { - spot.text.focus(); + spotDom.text.focus(); } } - function toggleGear(spot) { - const open = spot.gearBox.hidden; - spot.gearBox.replaceChildren(...(open ? [radioSettings(() => toggleGear(spot))] : [])); - spot.gearBox.hidden = !open; - spot.gear.setAttribute("aria-expanded", String(open)); + function toggleGear() { + const open = spotDom.gearBox.hidden; + spotDom.gearBox.replaceChildren(...(open ? [radioSettings(toggleGear)] : [])); + spotDom.gearBox.hidden = !open; + spotDom.gear.setAttribute("aria-expanded", String(open)); window.requestAnimationFrame(refresh3d); } function radioSpot() { const {form, recipient, text} = composerForm(); const gearBox = h("div", {class: "radio-gear", id: "radio-gear", hidden: true}); - const gear = h("button", {type: "button", class: "btn small ghost icon-only spot-toggle", "aria-expanded": "false", "aria-controls": "radio-gear", ...named("Radio settings", "Radio settings: tones, squelch and read aloud")}, icon("bi-gear")); - const clear = actionButton("clear-mayday", "btn small danger", "bi-life-preserver"); + const gear = iconBtn("bi-gear", "Radio settings", "btn small ghost spot-toggle", toggleGear, "Radio settings: tones, squelch and read aloud"); + gear.setAttribute("aria-expanded", "false"); + gear.setAttribute("aria-controls", "radio-gear"); + const [, label, , hint] = actionOf("clear-mayday"); + const clear = iconBtn("bi-life-preserver", label, "btn small danger", () => confirmAct("clear-mayday"), hint); const panel = h("section", {class: "card radio-spot", id: "radio-spot", "aria-label": "Radio", hidden: !ui.spot}, - h("div", {class: "spot-head"}, h("h3", {class: "group-title"}, "Radio"), h("span", {class: "spacer"}), gear, clear), h("div", {class: "spot-body"}, form, gearBox)); - const toggle = h("button", {type: "button", class: "btn small spot-toggle", "aria-expanded": String(ui.spot), "aria-controls": "radio-spot", ...named("Radio", "Radio: post, call or a message to all, with Record and the radio settings")}, icon("bi-broadcast-pin"), caret()); - const spot = {panel, toggle, gear, gearBox, text}; - toggle.addEventListener("click", () => openSpot(spot, !ui.spot)); - gear.addEventListener("click", () => toggleGear(spot)); - return {toggle, panel, clear, recipient}; + h("div", {class: "spot-head"}, h("h3", {class: "group-title"}, "Radio"), h("span", {class: "spacer"}), gear, clear), h("div", {class: "spot-body"}, form, radioSide(), gearBox)); + const toggle = document.getElementById("radio-open"); + toggle.setAttribute("aria-expanded", String(ui.spot)); + toggle.addEventListener("click", () => openSpot(!ui.spot)); + document.getElementById("spot-host").replaceChildren(panel); + spotDom = {panel, toggle, gear, gearBox, text, clear, recipient, names: ""}; } function openRadio() { - ui.spot = true; - go("agents", null); - window.setTimeout(() => { - const field = document.querySelector("#radio-spot textarea"); - if (field) { - field.focus(); - } - }, 50); + openSpot(true); + } + + function drawSpot() { + spotDom.clear.disabled = !state.silence; + const names = agentNames(); + if (JSON.stringify(names) !== spotDom.names) { + spotDom.names = JSON.stringify(names); + const chosen = spotDom.recipient.value; + spotDom.recipient.replaceChildren(...names.map((name) => h("option", {value: name, selected: name === chosen}, name))); + } } function agentsToolbar() { - const spot = radioSpot(); const mode = h("span", {}); const night = h("details", {class: "menu"}, h("summary", named("Day/Night"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode, h("div", {class: "actions"}, ACTIONS.slice(1).map(([id]) => actionButton(id, "btn small"))))); - const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, spot.toggle, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night); - return {bar, spot: spot.panel, clear: spot.clear, recipient: spot.recipient, mode, names: ""}; + const bar = h("div", {class: "toolbar", role: "toolbar", "aria-label": "Controls"}, iconBtn("bi-sort-down", "Priority", "btn small", openAgentOrder, "Priority: agent order in the hierarchy"), night); + return {bar, mode}; } function drawToolbar() { + drawSpot(); const toolbar = graphDom && graphDom.toolbar; if (!toolbar) { return; } const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"]; - toolbar.clear.disabled = !state.silence; toolbar.mode.replaceChildren(pill(mode, tone)); - const names = agentNames(); - if (JSON.stringify(names) !== toolbar.names) { - toolbar.names = JSON.stringify(names); - const chosen = toolbar.recipient.value; - toolbar.recipient.replaceChildren(...names.map((name) => h("option", {value: name, selected: name === chosen}, name))); - } } diff --git a/orchestrator/templates/page-boot.js b/orchestrator/templates/page-boot.js index 5c58a6a..4e85790 100644 --- a/orchestrator/templates/page-boot.js +++ b/orchestrator/templates/page-boot.js @@ -33,6 +33,7 @@ window.addEventListener("resize", refresh3d); window.addEventListener("resize", placePopup); skyPanel(); + radioSpot(); radio.element = radioElement(); drawSky(); musicButton(); diff --git a/orchestrator/templates/page-graph-charts.js b/orchestrator/templates/page-graph-charts.js index 676326b..554138f 100644 --- a/orchestrator/templates/page-graph-charts.js +++ b/orchestrator/templates/page-graph-charts.js @@ -185,5 +185,5 @@ const toolbar = agentsToolbar(); graphDom = {stage: stageBox, pulses: null, toolbar}; const body = view === "tree" ? spawnScreen() : view === "hierarchy" ? hierarchyScreen() : h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()); - return h("div", {class: "graph"}, toolbar.bar, toolbar.spot, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body); + return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body); } diff --git a/orchestrator/templates/page-radioview.js b/orchestrator/templates/page-radioview.js index b0d882a..8ad6a11 100644 --- a/orchestrator/templates/page-radioview.js +++ b/orchestrator/templates/page-radioview.js @@ -55,20 +55,12 @@ : [h("li", {class: "muted"}, "No station yet: add one under ⚙ Settings → Internet radio.")])); } - function speakerChips() { - return Object.entries(people()).sort(([, a], [, b]) => a.now.localeCompare(b.now)).map(([key, entry]) => h("li", {class: "speaker", "data-key": key, title: entry.now}, - h("span", {class: "speaker-face"}, face(key, false)), h("span", {class: "speaker-name"}, entry.now))); - } - function drawSpeaker() { const now = speech.current && speech.current.who ? speech.current : null; for (const art of document.querySelectorAll(".avatar[data-face]")) { art.classList.toggle("speaking", Boolean(now && art.dataset.face === now.who)); } if (radioDom) { - for (const chip of radioDom.speakers.querySelectorAll(".speaker")) { - chip.classList.toggle("speaking", Boolean(now && chip.dataset.key === now.who)); - } radioDom.caption.hidden = !now; radioDom.caption.replaceChildren(...(now ? [ h("span", {class: "speaker-face"}, face(now.who, false)), @@ -95,12 +87,6 @@ if (!radioDom) { return; } - const crew = JSON.stringify(people()); - if (crew !== radioDom.crew) { - radioDom.crew = crew; - radioDom.speakers.replaceChildren(...speakerChips()); - drawSpeaker(); - } const sent = JSON.stringify(state.messages.slice(-5)); if (sent !== radioDom.sent) { radioDom.sent = sent; @@ -109,20 +95,12 @@ drawStations(); } - function radioScreen() { - const speakers = h("ul", {class: "speakers", "aria-label": "Speakers"}); + function radioSide() { const caption = h("div", {class: "radio-caption", role: "status", "aria-live": "polite", hidden: true}); const stations = h("ul", {class: "stations", "aria-label": "Stations"}); const outbox = h("div", {}); - const send = h("button", {type: "button", class: "btn small soft", ...named("Send …", "Send: the radio spot under the Agents toolbar"), on: {click: openRadio}}, icon("bi-broadcast-pin"), "Send …"); - radioDom = {speakers, caption, stations, outbox, crew: "", sent: "", stationsSig: ""}; - return h("div", {class: "stack radio-view"}, - h("h2", {class: "title"}, "Radio"), - h("div", {class: "radio-grid"}, - h("section", {class: "card radio-talk", "aria-label": "Radio"}, h("div", {class: "card-head"}, h("h3", {class: "group-title"}, "Radio"), send), caption, speakers), - h("div", {class: "stack"}, - h("section", {class: "card", "aria-label": "Internet radio"}, - h("div", {class: "card-head"}, h("h3", {class: "group-title"}, "Internet radio"), btn("Edit stations", "btn small ghost", settingsSheet)), - stations), - h("section", {class: "card", "aria-label": "Recently sent"}, h("h3", {class: "group-title"}, "Recently sent"), outbox)))); + radioDom = {caption, stations, outbox, sent: "", stationsSig: ""}; + return h("div", {class: "radio-side"}, caption, + h("section", {"aria-label": "Recently sent"}, h("h3", {class: "group-title"}, "Recently sent"), outbox), + h("section", {"aria-label": "Internet radio"}, h("div", {class: "card-head"}, h("h3", {class: "group-title"}, "Internet radio"), btn("Edit stations", "btn small ghost", settingsSheet)), stations)); } diff --git a/orchestrator/templates/page-screens.js b/orchestrator/templates/page-screens.js index a962485..89a855b 100644 --- a/orchestrator/templates/page-screens.js +++ b/orchestrator/templates/page-screens.js @@ -29,6 +29,9 @@ if (route.redirect) { window.history.replaceState(null, "", route.redirect); } + if (route.spot) { + openSpot(true); + } ui.screen = route.screen; if (route.view) { ui.views[route.screen] = route.view; @@ -48,7 +51,7 @@ } function screenOf() { - return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, radio: radioScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen}[ui.screen](); + return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen}[ui.screen](); } function render(force) { @@ -60,7 +63,6 @@ graphDom = null; stream.dom = null; tree.dom = null; - radioDom = null; inboxDom = null; inboxSig = ""; taskView.dom = null; diff --git a/orchestrator/templates/style-ui.css b/orchestrator/templates/style-ui.css index 5e76ebc..d5a10b3 100644 --- a/orchestrator/templates/style-ui.css +++ b/orchestrator/templates/style-ui.css @@ -3,15 +3,8 @@ .rule-form .field, .settings .grid .field { width: 100%; min-width: 0; } .rule-form [hidden] { display: none; } .rule-form > label.check { display: inline-flex; min-height: 34px; } -.radio-grid { display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); gap: var(--sp-4); align-items: start; } -@media (max-width: 960px) { .radio-grid { grid-template-columns: minmax(0, 1fr); } } -.radio-talk { display: grid; gap: var(--sp-3); } -.speakers { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: 0; padding: 0; list-style: none; } -.speaker { display: inline-flex; align-items: center; gap: 6px; max-width: 220px; padding: 3px 10px 3px 3px; border: 1px solid var(--line); border-radius: 999px; background: var(--panel); font-size: var(--fs-sm); } -.speaker.speaking { border-color: var(--ok); background: var(--ok-soft); color: var(--ok); } -.speaker-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .speaker-face { position: relative; display: inline-grid; flex: none; border-radius: 50%; } -.speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after { content: ""; position: absolute; inset: -4px; border: 2px solid var(--ok); border-radius: 50%; pointer-events: none; animation: speaking 1.3s var(--ease) infinite; } +.radio-caption .speaker-face::after { content: ""; position: absolute; inset: -4px; border: 2px solid var(--ok); border-radius: 50%; pointer-events: none; animation: speaking 1.3s var(--ease) infinite; } @keyframes speaking { from { opacity: .95; transform: scale(1); } to { opacity: 0; transform: scale(1.6); } } .radio-caption { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; padding: var(--sp-2) var(--sp-3); border: 1px solid color-mix(in srgb, var(--ok) 45%, var(--line)); border-radius: var(--radius-sm); background: var(--ok-soft); } .radio-caption[hidden] { display: none; } @@ -23,7 +16,7 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak .speaking .face-art .mouth { animation: talk .3s ease-in-out infinite alternate; } @keyframes talk { to { transform: scale(1.15, 2.2); } } @media (prefers-reduced-motion: reduce) { - .speaker.speaking .speaker-face::after, .radio-caption .speaker-face::after, svg .node.speaking circle, .speaking .face-art .mouth { animation: none; } + .radio-caption .speaker-face::after, svg .node.speaking circle, .speaking .face-art .mouth { animation: none; } } .stations { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; } .station { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) auto auto; align-items: center; gap: var(--sp-2); min-width: 0; padding: 3px 3px 3px 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--sunken); } @@ -38,9 +31,11 @@ svg .node.speaking circle { stroke: var(--ok); stroke-width: 4; animation: speak .station-when { display: none; } } .spot-toggle[aria-expanded="true"] { background: var(--accent-soft); color: var(--accent); } -.radio-spot { display: grid; gap: var(--sp-3); max-height: 46vh; overflow: auto; } +.radio-spot { display: grid; gap: var(--sp-3); max-height: 46vh; margin-bottom: var(--sp-4); overflow: auto; } .radio-spot[hidden], .radio-gear[hidden] { display: none; } +body.fit .spot-host { display: flex; flex: 0 1 auto; flex-direction: column; min-height: 0; } body.fit .radio-spot { flex: 0 1 auto; min-height: 0; } +.radio-side { display: grid; gap: var(--sp-3); align-content: start; min-width: 0; } .spot-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); } .spot-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: var(--sp-4); align-items: start; } .radio-gear { min-width: 0; } diff --git a/tests/e2e/test_orchestrator_e2e.py b/tests/e2e/test_orchestrator_e2e.py index 6889183..b4ad73c 100644 --- a/tests/e2e/test_orchestrator_e2e.py +++ b/tests/e2e/test_orchestrator_e2e.py @@ -35,8 +35,8 @@ OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots")) WIDTHS = ((1920, 1080), (1366, 768), (390, 844)) VIEWS = ("orbit", "chord", "swimlane", "tree", "3d") TASK_VIEWS = ("list", "board", "rasci", "gantt", "burn", "3d") -SCREENS = ["stream", "stream/inbox", "stream/rules", "stream/radio", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy", *(f"tasks/{view}" for view in TASK_VIEWS)] -SUBS = ["Inbox", "Firewall", "Radio"] +SCREENS = ["stream", "stream/inbox", "stream/rules", *(f"{tab}/{view}" for tab in ("agents", "artifacts") for view in VIEWS), "agents/hierarchy", *(f"tasks/{view}" for view in TASK_VIEWS)] +SUBS = ["Inbox", "Firewall"] TABS = ["Stream", "Agents", "Artifacts", "Tasks"] HELPERS = """ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -100,7 +100,7 @@ PROBE = """(async () => {@HELPERS@ const box = document.getElementById("radio-spot"); return box ? {hidden: box.hidden, channels: [...box.querySelector("form.composer select").options].map((option) => option.value)} : null; })(), - composers: all("#main form.composer").map((form) => (form.closest("#radio-spot") ? "spot" : "other")), + composers: all("form.composer").map((form) => (form.closest("#radio-spot") ? "spot" : "other")), inbox: all("#main .inbox-list > li").length, icons: icons.length, unlabelled: icons.map((svg) => svg.parentElement).filter((owner) => !text(owner) && !(owner.getAttribute("title") && owner.getAttribute("aria-label"))).map((owner) => owner.outerHTML.slice(0, 160)), @@ -475,7 +475,7 @@ RADIO_STATE = """(async () => {@HELPERS@@MEDIA@ })()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA) RADIO_VIEW = """(async () => {@HELPERS@@MEDIA@ location.hash = "#stream/radio"; - const row = await until(() => document.querySelector('#main [data-station="0"]'), 8000); + const row = await until(() => all('#radio-spot [data-station="0"]').find((item) => item.getClientRects().length), 8000); if (!row) { const initial = JSON.parse(document.getElementById("state").textContent); return {error: "no station row", log: log(), wrapped: !String(HTMLMediaElement.prototype.play).includes("[native code]"), streams: initial.settings.streams, media: initial.media, now: new Date().toString()}; @@ -493,7 +493,7 @@ RADIO_VIEW = """(async () => {@HELPERS@@MEDIA@ }; })()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA) RADIO_CLICK = """(async () => {@HELPERS@@MEDIA@ - const row = document.querySelector('#main [data-station="0"]'); + const row = document.querySelector('#radio-spot [data-station="0"]'); if (!row) { return {error: "no station row", log: log(), media: await advancing()}; } @@ -504,27 +504,26 @@ RADIO_CLICK = """(async () => {@HELPERS@@MEDIA@ } await until(() => sample() && !sample().paused, 6000); const media = await advancing(); - const now = document.querySelector('#main [data-station="0"]'); + const now = document.querySelector('#radio-spot [data-station="0"]'); return {clicked, state: now.dataset.state, note: text(now.querySelector(".station-note")), media, log: log()}; })()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA) SPEAKING = """ const caption = () => { - const node = document.querySelector("#main .radio-caption"); + const node = document.querySelector("#radio-spot:not([hidden]) .radio-caption"); return node && !node.hidden ? node : null; }; - const ring = (chip) => (chip ? getComputedStyle(chip.querySelector(".speaker-face"), "::after").animationName : null); + const ring = (box) => getComputedStyle(box.querySelector(".speaker-face"), "::after").animationName; const mouth = (art) => (art ? [art.classList.contains("speaking"), getComputedStyle(art.querySelector(".mouth")).animationName] : null); const faces = () => ({ - speaking: mouth(document.querySelector('#main .speakers .avatar[data-face="s2"]')), - silent: mouth(document.querySelector('#main .speakers .avatar[data-face="s1"]')), - keys: [...new Set(all("#main .avatar.speaking").map((art) => art.dataset.face))], + speaking: mouth(document.querySelector('#radio-spot .radio-caption .avatar[data-face="s2"]')), + silent: mouth(document.querySelector('.avatar[data-face]:not([data-face="s2"])')), + keys: [...new Set(all(".avatar.speaking").map((art) => art.dataset.face))], }); const cue = async (word) => { - await until(() => document.querySelector("#main .speakers .speaker"), 8000); + await until(() => document.querySelector("#radio-spot:not([hidden])"), 8000); await fetch("/api/tts", {method: "POST", headers: {"X-Kvb-Orchestrator": "1", "Content-Type": "application/json"}, body: JSON.stringify({text: word, voice: ""}), cache: "no-store"}); const found = await until(caption, 25000); - const chip = document.querySelector("#main .speaker.speaking"); - return found ? {who: text(found.querySelector("strong")), line: text(found.querySelector(".caption-line")), chip: chip ? chip.dataset.key : null, ring: ring(chip), faces: faces()} : {error: "no caption", toasts: all("#toasts .toast").map(text)}; + return found ? {who: text(found.querySelector("strong")), line: text(found.querySelector(".caption-line")), chip: found.querySelector(".avatar").dataset.face, ring: ring(found), faces: faces()} : {error: "no caption", toasts: all("#toasts .toast").map(text)}; }; """ SPEAK = """(async () => {@HELPERS@@SPEAKING@ @@ -542,12 +541,12 @@ SPEAK_GRAPH = """(async () => {@HELPERS@@SPEAKING@ SPEAK_END = """(async () => {@HELPERS@@SPEAKING@ const ended = Boolean(await until(() => !document.querySelector("#main svg .node") || !document.querySelector("#main svg .node.speaking"), 20000)); location.hash = "#stream/radio"; - await until(() => document.querySelector("#main .radio-caption"), 6000); + await until(() => document.querySelector("#radio-spot:not([hidden]) .radio-caption"), 6000); await sleep(300); - return {ended, caption: Boolean(caption()), chips: all("#main .speaker.speaking").length, faces: all("#main .avatar.speaking").length}; + return {ended, caption: Boolean(caption()), faces: all(".avatar.speaking").length}; })()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING) SPOT_OPEN = """ - const spotToggle = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Radio"), 8000); + const spotToggle = document.getElementById("radio-open"); if (spotToggle && spotToggle.getAttribute("aria-expanded") !== "true") { spotToggle.click(); await sleep(300); @@ -588,7 +587,7 @@ COMPOSE = """(async () => {@HELPERS@@SPOT_OPEN@ }; })()""".replace("@HELPERS@", HELPERS).replace("@SPOT_OPEN@", SPOT_OPEN).replace("@CLASHES@", OVERLAP) COMPOSE_SEND = """(async () => {@HELPERS@ - const form = document.querySelector("#main form.composer"); + const form = document.querySelector("#radio-spot form.composer"); const field = form.querySelector("textarea"); field.value = `${field.value} please`; field.dispatchEvent(new Event("input", {bubbles: true})); @@ -627,7 +626,7 @@ CLEAR_MAYDAY = """(async () => {@HELPERS@ accept = true; button.click(); const done = await until(() => fresh().find((toast) => toast.startsWith("Cancel mayday")), 15000); - return {toolbar, title: button.title, declined, accepted: {question: questions.slice(declined.question.length), posted: posted.slice(declined.posted.length)}, done}; + return {toolbar, title: button.title, shown: text(button), declined, accepted: {question: questions.slice(declined.question.length), posted: posted.slice(declined.posted.length)}, done}; })()""".replace("@HELPERS@", HELPERS).replace("@SPOT_OPEN@", SPOT_OPEN) NIGHT_MENU = """(async () => {@HELPERS@ const questions = []; @@ -690,11 +689,9 @@ SPOT_SEND = """(async () => {@HELPERS@ questions.push(question); return true; }; - const toggle = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Radio"), 8000); - if (!toggle) { - return {error: text(document.getElementById("main"))}; - } - const bar = document.querySelector('#main [role="toolbar"]'); + await until(() => document.querySelector('#main [role="toolbar"]'), 8000); + const toggle = document.getElementById("radio-open"); + const main = document.getElementById("main"); const spot = document.getElementById("radio-spot"); const closed = [spot.hidden, toggle.getAttribute("aria-expanded")]; toggle.click(); @@ -705,7 +702,7 @@ SPOT_SEND = """(async () => {@HELPERS@ const found = { closed, open: [spot.hidden, toggle.getAttribute("aria-expanded"), document.activeElement === field], - inline: [getComputedStyle(spot).position, spot.getBoundingClientRect().top >= bar.getBoundingClientRect().bottom, spot.previousElementSibling === bar], + inline: [getComputedStyle(spot).position, spot.getBoundingClientRect().bottom <= main.getBoundingClientRect().top, spot.parentElement.nextElementSibling === main], toolbar: all('#main [role="toolbar"] > *').map((node) => name(node.tagName === "DETAILS" ? node.querySelector("summary") : node)), channels: all("option", channel).map((option) => option.value), record: all("button", form).filter((button) => button.hasAttribute("aria-pressed")).map(text), @@ -733,15 +730,26 @@ SPOT_SEND = """(async () => {@HELPERS@ return found; })()""".replace("@HELPERS@", HELPERS).replace("@FITS@", FITS) RADIO_JUMP = """(async () => {@HELPERS@ - const jump = await until(() => all("#main .radio-view button").find((button) => name(button) === "Send …"), 8000); - if (!jump) { + const spot = await until(() => location.hash === "#stream" && document.querySelector("#radio-spot:not([hidden])"), 8000); + if (!spot) { return {error: text(document.getElementById("main"))}; } - const composers = all("#main form.composer").length; - jump.click(); - const spot = await until(() => location.hash.startsWith("#agents/") && document.querySelector("#radio-spot:not([hidden])"), 4000); await sleep(200); - return {composers, hash: location.hash, open: Boolean(spot), focused: Boolean(spot) && document.activeElement === spot.querySelector("textarea")}; + const focused = document.activeElement === spot.querySelector("textarea"); + const tabs = []; + for (const hash of ["#stream/inbox", "#agents/orbit", "#artifacts/tree", "#tasks/list"]) { + location.hash = hash; + await sleep(500); + const main = document.getElementById("main"); + tabs.push([hash, !spot.hidden, spot.getBoundingClientRect().bottom <= main.getBoundingClientRect().top]); + } + const toggle = document.getElementById("radio-open"); + toggle.click(); + await sleep(200); + return { + hash: "#stream", focused, tabs, closed: [spot.hidden, toggle.getAttribute("aria-expanded")], + header: all("header.top button").map((button) => [button.id, name(button), text(button)]), radioTab: Boolean(document.getElementById("tab-radio")), + }; })()""".replace("@HELPERS@", HELPERS) GEAR_SAVE = """(async () => {@HELPERS@@CLASHES@ const spot = document.getElementById("radio-spot"); @@ -1610,9 +1618,9 @@ class TestOrchestratorE2E(HubCase): with self.subTest(url=step["url"], width=step["width"]): self.assertNotIn("error", probe) self.assertEqual((probe["title"], probe["h1"]), ("(2) Agent Orchestrator", "Agent Orchestrator")) - self.assertEqual(probe["header"], ["palette-open", "settings-open"]) + self.assertEqual(probe["header"], ["radio-open", "palette-open", "settings-open"]) self.assertEqual((probe["tabs"], probe["subs"]), (TABS, SUBS)) - self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-radio": None, "tab-agents": "1", "tab-artifacts": None, "tab-tasks": None}) + self.assertEqual(probe["badges"], {"tab-stream": None, "tab-inbox": "2", "tab-rules": None, "tab-agents": "1", "tab-artifacts": None, "tab-tasks": None}) self.assertEqual(probe["banner"], "MAYDAY · Docs: disk full") self.assertFalse(probe["rail"]) self.assertEqual((probe["time"], probe["replay"]), ("24 h ▾", True)) @@ -1620,7 +1628,7 @@ class TestOrchestratorE2E(HubCase): self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []}) self.assertFalse(probe["wide"], "the page scrolls sideways") self.assertEqual((probe["panel"], probe["nodeChips"]), (0, 0)) - self.assertEqual(probe["composers"], ["spot"] if probe["hash"].startswith("#agents/") else []) + self.assertEqual(probe["composers"], ["spot"]) self.assertEqual([probe["tall"] for probe in probes if probe["hash"] == "#agents/orbit"], [False] * len(WIDTHS)) by_hash = {probe["hash"]: probe for probe in probes} stream = by_hash["#stream"] @@ -1634,7 +1642,7 @@ class TestOrchestratorE2E(HubCase): self.assertTrue(by_hash[f"#{tab}/3d"]["canvas"], by_hash[f"#{tab}/3d"]["note"]) self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ nodes · \d+ edges · Force$") agents = by_hash["#agents/orbit"] - self.assertEqual(agents["toolbar"], ["Radio", "Priority", "Day/Night"]) + self.assertEqual(agents["toolbar"], ["Priority", "Day/Night"]) self.assertEqual(agents["spot"], {"hidden": True, "channels": CHOICE_CHANNELS}) hierarchy = by_hash["#agents/hierarchy"] self.assertEqual([office for office, _ in hierarchy["org"]], ["chair", "identity/oidc", "work/s1", "work/s2"]) @@ -1722,7 +1730,7 @@ class TestOrchestratorE2E(HubCase): self.assertEqual(data["errors"], []) speaking, graph, ended, calm = data["results"] self.talks(speaking, graph) - self.assertEqual(ended, {"ended": True, "caption": False, "chips": 0, "faces": 0}) + self.assertEqual(ended, {"ended": True, "caption": False, "faces": 0}) self.assertEqual((calm["who"], calm["line"], calm["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-2)", "none"), calm) self.assertEqual(calm["faces"]["speaking"], [True, "none"], calm) @@ -1791,8 +1799,8 @@ class TestOrchestratorE2E(HubCase): for result, (width, _) in zip(data["results"], WIDTHS, strict=True): with self.subTest(width=width): self.assertNotIn("error", result) - self.assertEqual(result["toolbar"], ["Radio", "Priority", "Day/Night", "Drain", "Suspend", "Resume"]) - self.assertEqual(result["title"], "Cancel mayday: lift the radio silence so the agents write again; your passkey signs it") + self.assertEqual(result["toolbar"], ["Priority", "Day/Night", "Drain", "Suspend", "Resume"]) + self.assertEqual((result["title"], result["shown"]), ("Cancel mayday: lift the radio silence so the agents write again; your passkey signs it", "")) self.assertEqual(result["declined"], {"question": [question], "posted": [], "toasts": []}) self.assertEqual(result["accepted"], {"question": [question], "posted": ["/api/board/sign", "/api/board/clear-mayday"]}) self.assertTrue((result["done"] or "").startswith("Cancel mayday: done"), result) @@ -1801,7 +1809,7 @@ class TestOrchestratorE2E(HubCase): def test_chromium_sends_every_class_from_the_radio_spot_and_keeps_its_gear_settings_through_a_reload_at_three_widths(self): local = f"http://localhost:{self.port}/" values = {1920: ("0.35", "false", "true"), 1366: ("0.45", "true", "false"), 390: ("0.55", "false", "true")} - steps = [{"width": 1366, "height": 768, "url": f"{local}#stream/radio", "wait": 1200, "probe": RADIO_JUMP, "shot": self.shot("1366-radio-tab-jump-to-spot")}] + steps = [{"width": 1366, "height": 768, "url": f"{local}#stream/radio", "wait": 1200, "probe": RADIO_JUMP, "shot": self.shot("1366-radio-header-spot-every-tab")}] for width, height in WIDTHS: squelch, tone, post = values[width] gear = GEAR_SAVE.replace("@SQUELCH@", squelch).replace("@TONE@", tone).replace("@POST@", post) @@ -1816,7 +1824,10 @@ class TestOrchestratorE2E(HubCase): labels = dict(zip(CHOICE_CHANNELS, ["Post", "Call", "Newsletter", "Broadcast", "Pan-Pan", "Sécurité", "Mayday"], strict=True)) mayday = "Send a mayday to all? All agents pause writing steps until you cancel it." jump, *walked = data["results"] - self.assertEqual(jump, {"composers": 0, "hash": "#agents/orbit", "open": True, "focused": True}) + self.assertEqual(jump, { + "hash": "#stream", "focused": True, "tabs": [[tab, True, True] for tab in ("#stream/inbox", "#agents/orbit", "#artifacts/tree", "#tasks/list")], "closed": [True, "false"], + "header": [["radio-open", "Radio", ""], ["palette-open", "Commands", ""], ["settings-open", "Settings", ""]], "radioTab": False, + }) for index, (width, _) in enumerate(WIDTHS): sent, saved, read = walked[3 * index : 3 * (index + 1)] squelch, tone, post = values[width] @@ -1825,7 +1836,7 @@ class TestOrchestratorE2E(HubCase): self.assertNotIn("error", probe) self.assertEqual((sent["closed"], sent["open"]), ([True, "false"], [False, "true", True])) self.assertEqual(sent["inline"], ["static", True, True]) - self.assertEqual((sent["toolbar"], sent["channels"], sent["record"]), (["Radio", "Priority", "Day/Night"], CHOICE_CHANNELS, ["Record"])) + self.assertEqual((sent["toolbar"], sent["channels"], sent["record"]), (["Priority", "Day/Night"], CHOICE_CHANNELS, ["Record"])) self.assertEqual(sent["heads"], [["Radio settings", "Radio settings: tones, squelch and read aloud"], ["Cancel mayday", "Cancel mayday: lift the radio silence so the agents write again; your passkey signs it"]]) self.assertEqual((sent["tall"], sent["wide"]), (False, False)) self.assertEqual([run["cls"] for run in sent["runs"]], CHOICE_CHANNELS) diff --git a/tests/test_hub_nav.py b/tests/test_hub_nav.py index 0dd9dc5..9987d76 100644 --- a/tests/test_hub_nav.py +++ b/tests/test_hub_nav.py @@ -21,8 +21,8 @@ console.log(JSON.stringify({ tasks: N.viewsOf("tasks").map(([id, label]) => [id, label]), icon: N.ICONS.hierarchy, routes: hashes.map((hash) => N.route(hash)), - hashes: [["stream", null], ["inbox", null], ["rules", null], ["radio", null], ["agents", "chord"], ["artifacts", "3d"], ["tasks", "board"]].map(([screen, view]) => N.hashOf(screen, view)), - tabOf: ["stream", "inbox", "rules", "radio", "agents", "artifacts", "tasks"].map((screen) => N.tabOf(screen)), + hashes: [["stream", null], ["inbox", null], ["rules", null], ["agents", "chord"], ["artifacts", "3d"], ["tasks", "board"]].map(([screen, view]) => N.hashOf(screen, view)), + tabOf: ["stream", "inbox", "rules", "agents", "artifacts", "tasks"].map((screen) => N.tabOf(screen)), })); """ @@ -33,8 +33,8 @@ class TestNavModule(unittest.TestCase): done = subprocess.run(["node", "-e", NAV_PROBE, str(NAV)], capture_output=True, text=True, timeout=60, check=True) cls.data = json.loads(done.stdout) - def test_the_navigation_has_four_entries_and_stream_has_inbox_rules_and_radio(self): - stream = ["stream", "Stream", [["inbox", "Inbox"], ["rules", "Firewall"], ["radio", "Radio"]]] + def test_the_navigation_has_four_entries_and_stream_has_inbox_and_rules(self): + stream = ["stream", "Stream", [["inbox", "Inbox"], ["rules", "Firewall"]]] self.assertEqual(self.data["tabs"], [stream, ["agents", "Agents", []], ["artifacts", "Artifacts", []], ["tasks", "Tasks", []]]) def test_agents_and_artifacts_offer_five_views_in_order_and_tasks_six(self): @@ -71,7 +71,7 @@ class TestNavModule(unittest.TestCase): {"screen": "artifacts", "view": None, "redirect": None}, {"screen": "agents", "view": "hierarchy", "redirect": "#agents/hierarchy"}, {"screen": "artifacts", "view": "tree", "redirect": "#artifacts/tree"}, - {"screen": "radio", "view": None, "redirect": None}, + {"screen": "stream", "view": None, "redirect": "#stream", "spot": True}, stream, {"screen": "tasks", "view": None, "redirect": None}, {"screen": "tasks", "view": "board", "redirect": None}, @@ -86,8 +86,8 @@ class TestNavModule(unittest.TestCase): ]) def test_every_screen_has_its_own_hash_and_tab(self): - self.assertEqual(self.data["hashes"], ["#stream", "#stream/inbox", "#stream/rules", "#stream/radio", "#agents/chord", "#artifacts/3d", "#tasks/board"]) - self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "stream", "agents", "artifacts", "tasks"]) + self.assertEqual(self.data["hashes"], ["#stream", "#stream/inbox", "#stream/rules", "#agents/chord", "#artifacts/3d", "#tasks/board"]) + self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "agents", "artifacts", "tasks"]) if __name__ == "__main__": diff --git a/tests/test_hub_surface.py b/tests/test_hub_surface.py index 0bb5604..c28eb3f 100644 --- a/tests/test_hub_surface.py +++ b/tests/test_hub_surface.py @@ -12,12 +12,12 @@ from kvb_orchestrator import council, events, settings, skill_scripts, view class TestHubSurface(HubCase): - def test_the_header_holds_only_the_title_the_palette_and_the_settings(self): + def test_the_header_holds_only_the_title_the_radio_the_palette_and_the_settings(self): html = self.index() self.assertIn("Agent Orchestrator", html) header = re.search(r'
(.*?)
', html, re.DOTALL).group(1) self.assertIn("

Agent Orchestrator

", header) - self.assertEqual(re.findall(r']*id="([^"]+)"', header), ["palette-open", "settings-open"]) + self.assertEqual(re.findall(r']*id="([^"]+)"', header), ["radio-open", "palette-open", "settings-open"]) self.assertEqual(re.findall(r"<(?:nav|select|input|textarea)\b", header), []) for gone in ('id="rail"', 'id="inbox-bar"', 'id="status"', 'id="board-open"', 'id="rules-open"', 'id="timebar"'): with self.subTest(gone=gone):