feat(orchestrator): radio icon in the header opens the radio spot on every tab

The header radio button opens the radio spot above the main area of the current tab. The Send button, the toolbar radio button and the Radio tab are gone, the mayday clears inside the spot without a Cancel mayday label, and the radio station probe waits for a visible row.

🎯 Fix: 92 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: 100 %
🛡️ Security: 100 %
🏆 Score: 88
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-10 21:10:20 +00:00
1 parent 57ab29eb0d
commit 78f3d953e7
12 files changed
+149 -127

No files matched your search

@@ -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.
+2
View File
@@ -420,6 +420,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
<header class="top">
<div class="brand"><span class="logo" aria-hidden="true"></span><div class="brand-text"><h1>Agent Orchestrator</h1><span class="sky-note" id="sky-note"></span></div></div>
<div class="tools">
<button type="button" class="btn small ghost icon-only spot-toggle" id="radio-open" title="Radio: post, call or a message to all, with Record, the radio settings and the stations" aria-label="Radio" aria-controls="radio-spot" aria-expanded="false"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-broadcast-pin"></use></svg></button>
<button type="button" class="btn small ghost icon-only" id="palette-open" title="Commands (Ctrl+K)" aria-label="Commands"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-command"></use></svg></button>
<button type="button" class="btn small ghost icon-only" id="settings-open" title="Settings and ambience" aria-label="Settings"><svg class="bi" aria-hidden="true" focusable="false"><use href="#bi-gear"></use></svg></button>
</div>
@@ -433,6 +434,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
<div class="banner" id="mayday" role="alert" hidden></div>
</div>
<div class="shell">
<div class="spot-host" id="spot-host"></div>
<main id="main"></main>
</div>
<dialog class="popup fade" id="popup" aria-label="Details"><svg class="leader" aria-hidden="true"><line></line></svg><div class="popup-box"></div></dialog>
+5 -2
View File
@@ -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;
+36 -35
View File
@@ -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)));
}
}
+1
View File
@@ -33,6 +33,7 @@
window.addEventListener("resize", refresh3d);
window.addEventListener("resize", placePopup);
skyPanel();
radioSpot();
radio.element = radioElement();
drawSky();
musicButton();
+1 -1
View File
@@ -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);
}
+5 -27
View File
@@ -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));
}
+4 -2
View File
@@ -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;
+5 -10
View File
@@ -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; }
+52 -41
View File
@@ -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)
+7 -7
View File
@@ -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__":
+2 -2
View File
@@ -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("<title>Agent Orchestrator</title>", html)
header = re.search(r'<header class="top">(.*?)</header>', html, re.DOTALL).group(1)
self.assertIn("<h1>Agent Orchestrator</h1>", header)
self.assertEqual(re.findall(r'<button[^>]*id="([^"]+)"', header), ["palette-open", "settings-open"])
self.assertEqual(re.findall(r'<button[^>]*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):