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

+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; }