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:
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.
|
time-agent-depth layout.
|
||||||
58. **Steady 3D camera**: the 3D view fits the graph once per build; state
|
58. **Steady 3D camera**: the 3D view fits the graph once per build; state
|
||||||
refreshes keep the operator's camera.
|
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
|
## Approach
|
||||||
|
|
||||||
@@ -741,6 +751,19 @@ flowchart TD
|
|||||||
- [x] Two state refreshes after a manual camera move leave the 3D camera
|
- [x] Two state refreshes after a manual camera move leave the 3D camera
|
||||||
where the operator put it.
|
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
|
### Tests
|
||||||
|
|
||||||
- **Unit**
|
- **Unit**
|
||||||
@@ -764,6 +787,8 @@ flowchart TD
|
|||||||
business value signature (forged, replayed, altered), sort orders.
|
business value signature (forged, replayed, altered), sort orders.
|
||||||
- [x] S5.1: regression and velocity trend with their forecast dates, point
|
- [x] S5.1: regression and velocity trend with their forecast dates, point
|
||||||
tooltips, ETA in both modes, radio settings stored from the gear.
|
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**
|
- **Integration**
|
||||||
- [ ] Two scratch sessions exchange `post` and `call` through the real hook
|
- [ ] Two scratch sessions exchange `post` and `call` through the real hook
|
||||||
entry points (JSON on stdin): the post appears only on the callee's
|
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,
|
- [x] S5.1: radio spot (send, gear, cancel mayday), Day/Night tooltips,
|
||||||
one-row Tasks toolbar, trend and point tooltips, ETA column, Tasks
|
one-row Tasks toolbar, trend and point tooltips, ETA column, Tasks
|
||||||
3D and a steady 3D camera, each at 1920, 1366 and 390 px.
|
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.
|
||||||
@@ -420,6 +420,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
|||||||
<header class="top">
|
<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="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">
|
<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="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>
|
<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>
|
</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 class="banner" id="mayday" role="alert" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
|
<div class="spot-host" id="spot-host"></div>
|
||||||
<main id="main"></main>
|
<main id="main"></main>
|
||||||
</div>
|
</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>
|
<dialog class="popup fade" id="popup" aria-label="Details"><svg class="leader" aria-hidden="true"><line></line></svg><div class="popup-box"></div></dialog>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
const Nav = (() => {
|
const Nav = (() => {
|
||||||
"use strict";
|
"use strict";
|
||||||
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
|
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 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 TASK_VIEWS = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"], ["3d", "3D"]];
|
||||||
const OWN = {agents: [["hierarchy", "Hierarchy"]]};
|
const OWN = {agents: [["hierarchy", "Hierarchy"]]};
|
||||||
const ICONS = {
|
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",
|
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",
|
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) {
|
function route(hash) {
|
||||||
const [head, sub] = decodeURIComponent(String(hash).replace(/^#/, "")).split("/");
|
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)) {
|
if (Object.hasOwn(OLD, head)) {
|
||||||
const [screen, fallback] = OLD[head];
|
const [screen, fallback] = OLD[head];
|
||||||
const view = known(screen, sub) ? sub : fallback;
|
const view = known(screen, sub) ? sub : fallback;
|
||||||
|
|||||||
@@ -36,70 +36,71 @@
|
|||||||
return h("button", {type: "button", class: cls, ...named(label, hint), on: {click: () => confirmAct(action)}}, name ? icon(name) : null, label);
|
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;
|
ui.spot = open;
|
||||||
spot.panel.hidden = !open;
|
spotDom.panel.hidden = !open;
|
||||||
spot.toggle.setAttribute("aria-expanded", String(open));
|
spotDom.toggle.setAttribute("aria-expanded", String(open));
|
||||||
window.requestAnimationFrame(refresh3d);
|
window.requestAnimationFrame(refresh3d);
|
||||||
if (open) {
|
if (open) {
|
||||||
spot.text.focus();
|
spotDom.text.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGear(spot) {
|
function toggleGear() {
|
||||||
const open = spot.gearBox.hidden;
|
const open = spotDom.gearBox.hidden;
|
||||||
spot.gearBox.replaceChildren(...(open ? [radioSettings(() => toggleGear(spot))] : []));
|
spotDom.gearBox.replaceChildren(...(open ? [radioSettings(toggleGear)] : []));
|
||||||
spot.gearBox.hidden = !open;
|
spotDom.gearBox.hidden = !open;
|
||||||
spot.gear.setAttribute("aria-expanded", String(open));
|
spotDom.gear.setAttribute("aria-expanded", String(open));
|
||||||
window.requestAnimationFrame(refresh3d);
|
window.requestAnimationFrame(refresh3d);
|
||||||
}
|
}
|
||||||
|
|
||||||
function radioSpot() {
|
function radioSpot() {
|
||||||
const {form, recipient, text} = composerForm();
|
const {form, recipient, text} = composerForm();
|
||||||
const gearBox = h("div", {class: "radio-gear", id: "radio-gear", hidden: true});
|
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 gear = iconBtn("bi-gear", "Radio settings", "btn small ghost spot-toggle", toggleGear, "Radio settings: tones, squelch and read aloud");
|
||||||
const clear = actionButton("clear-mayday", "btn small danger", "bi-life-preserver");
|
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},
|
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));
|
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 = 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 toggle = document.getElementById("radio-open");
|
||||||
const spot = {panel, toggle, gear, gearBox, text};
|
toggle.setAttribute("aria-expanded", String(ui.spot));
|
||||||
toggle.addEventListener("click", () => openSpot(spot, !ui.spot));
|
toggle.addEventListener("click", () => openSpot(!ui.spot));
|
||||||
gear.addEventListener("click", () => toggleGear(spot));
|
document.getElementById("spot-host").replaceChildren(panel);
|
||||||
return {toggle, panel, clear, recipient};
|
spotDom = {panel, toggle, gear, gearBox, text, clear, recipient, names: ""};
|
||||||
}
|
}
|
||||||
|
|
||||||
function openRadio() {
|
function openRadio() {
|
||||||
ui.spot = true;
|
openSpot(true);
|
||||||
go("agents", null);
|
}
|
||||||
window.setTimeout(() => {
|
|
||||||
const field = document.querySelector("#radio-spot textarea");
|
function drawSpot() {
|
||||||
if (field) {
|
spotDom.clear.disabled = !state.silence;
|
||||||
field.focus();
|
const names = agentNames();
|
||||||
}
|
if (JSON.stringify(names) !== spotDom.names) {
|
||||||
}, 50);
|
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() {
|
function agentsToolbar() {
|
||||||
const spot = radioSpot();
|
|
||||||
const mode = h("span", {});
|
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,
|
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")))));
|
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);
|
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, spot: spot.panel, clear: spot.clear, recipient: spot.recipient, mode, names: ""};
|
return {bar, mode};
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawToolbar() {
|
function drawToolbar() {
|
||||||
|
drawSpot();
|
||||||
const toolbar = graphDom && graphDom.toolbar;
|
const toolbar = graphDom && graphDom.toolbar;
|
||||||
if (!toolbar) {
|
if (!toolbar) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"];
|
const [mode, tone] = NIGHT[state.night] || [state.night, "neutral"];
|
||||||
toolbar.clear.disabled = !state.silence;
|
|
||||||
toolbar.mode.replaceChildren(pill(mode, tone));
|
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)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
window.addEventListener("resize", refresh3d);
|
window.addEventListener("resize", refresh3d);
|
||||||
window.addEventListener("resize", placePopup);
|
window.addEventListener("resize", placePopup);
|
||||||
skyPanel();
|
skyPanel();
|
||||||
|
radioSpot();
|
||||||
radio.element = radioElement();
|
radio.element = radioElement();
|
||||||
drawSky();
|
drawSky();
|
||||||
musicButton();
|
musicButton();
|
||||||
|
|||||||
@@ -185,5 +185,5 @@
|
|||||||
const toolbar = agentsToolbar();
|
const toolbar = agentsToolbar();
|
||||||
graphDom = {stage: stageBox, pulses: null, toolbar};
|
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());
|
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);
|
||||||
}
|
}
|
||||||
@@ -55,20 +55,12 @@
|
|||||||
: [h("li", {class: "muted"}, "No station yet: add one under ⚙ Settings → Internet radio.")]));
|
: [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() {
|
function drawSpeaker() {
|
||||||
const now = speech.current && speech.current.who ? speech.current : null;
|
const now = speech.current && speech.current.who ? speech.current : null;
|
||||||
for (const art of document.querySelectorAll(".avatar[data-face]")) {
|
for (const art of document.querySelectorAll(".avatar[data-face]")) {
|
||||||
art.classList.toggle("speaking", Boolean(now && art.dataset.face === now.who));
|
art.classList.toggle("speaking", Boolean(now && art.dataset.face === now.who));
|
||||||
}
|
}
|
||||||
if (radioDom) {
|
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.hidden = !now;
|
||||||
radioDom.caption.replaceChildren(...(now ? [
|
radioDom.caption.replaceChildren(...(now ? [
|
||||||
h("span", {class: "speaker-face"}, face(now.who, false)),
|
h("span", {class: "speaker-face"}, face(now.who, false)),
|
||||||
@@ -95,12 +87,6 @@
|
|||||||
if (!radioDom) {
|
if (!radioDom) {
|
||||||
return;
|
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));
|
const sent = JSON.stringify(state.messages.slice(-5));
|
||||||
if (sent !== radioDom.sent) {
|
if (sent !== radioDom.sent) {
|
||||||
radioDom.sent = sent;
|
radioDom.sent = sent;
|
||||||
@@ -109,20 +95,12 @@
|
|||||||
drawStations();
|
drawStations();
|
||||||
}
|
}
|
||||||
|
|
||||||
function radioScreen() {
|
function radioSide() {
|
||||||
const speakers = h("ul", {class: "speakers", "aria-label": "Speakers"});
|
|
||||||
const caption = h("div", {class: "radio-caption", role: "status", "aria-live": "polite", hidden: true});
|
const caption = h("div", {class: "radio-caption", role: "status", "aria-live": "polite", hidden: true});
|
||||||
const stations = h("ul", {class: "stations", "aria-label": "Stations"});
|
const stations = h("ul", {class: "stations", "aria-label": "Stations"});
|
||||||
const outbox = h("div", {});
|
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 = {caption, stations, outbox, sent: "", stationsSig: ""};
|
||||||
radioDom = {speakers, caption, stations, outbox, crew: "", sent: "", stationsSig: ""};
|
return h("div", {class: "radio-side"}, caption,
|
||||||
return h("div", {class: "stack radio-view"},
|
h("section", {"aria-label": "Recently sent"}, h("h3", {class: "group-title"}, "Recently sent"), outbox),
|
||||||
h("h2", {class: "title"}, "Radio"),
|
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));
|
||||||
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))));
|
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,9 @@
|
|||||||
if (route.redirect) {
|
if (route.redirect) {
|
||||||
window.history.replaceState(null, "", route.redirect);
|
window.history.replaceState(null, "", route.redirect);
|
||||||
}
|
}
|
||||||
|
if (route.spot) {
|
||||||
|
openSpot(true);
|
||||||
|
}
|
||||||
ui.screen = route.screen;
|
ui.screen = route.screen;
|
||||||
if (route.view) {
|
if (route.view) {
|
||||||
ui.views[route.screen] = route.view;
|
ui.views[route.screen] = route.view;
|
||||||
@@ -48,7 +51,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function screenOf() {
|
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) {
|
function render(force) {
|
||||||
@@ -60,7 +63,6 @@
|
|||||||
graphDom = null;
|
graphDom = null;
|
||||||
stream.dom = null;
|
stream.dom = null;
|
||||||
tree.dom = null;
|
tree.dom = null;
|
||||||
radioDom = null;
|
|
||||||
inboxDom = null;
|
inboxDom = null;
|
||||||
inboxSig = "";
|
inboxSig = "";
|
||||||
taskView.dom = null;
|
taskView.dom = null;
|
||||||
|
|||||||
@@ -3,15 +3,8 @@
|
|||||||
.rule-form .field, .settings .grid .field { width: 100%; min-width: 0; }
|
.rule-form .field, .settings .grid .field { width: 100%; min-width: 0; }
|
||||||
.rule-form [hidden] { display: none; }
|
.rule-form [hidden] { display: none; }
|
||||||
.rule-form > label.check { display: inline-flex; min-height: 34px; }
|
.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-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); } }
|
@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 { 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; }
|
.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; }
|
.speaking .face-art .mouth { animation: talk .3s ease-in-out infinite alternate; }
|
||||||
@keyframes talk { to { transform: scale(1.15, 2.2); } }
|
@keyframes talk { to { transform: scale(1.15, 2.2); } }
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@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; }
|
.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); }
|
.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; }
|
.station-when { display: none; }
|
||||||
}
|
}
|
||||||
.spot-toggle[aria-expanded="true"] { background: var(--accent-soft); color: var(--accent); }
|
.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; }
|
.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; }
|
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-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; }
|
.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; }
|
.radio-gear { min-width: 0; }
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ OUT = Path(os.environ.get("E2E_OUT", "/tmp/orchestrator-phase4-shots"))
|
|||||||
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
WIDTHS = ((1920, 1080), (1366, 768), (390, 844))
|
||||||
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
|
VIEWS = ("orbit", "chord", "swimlane", "tree", "3d")
|
||||||
TASK_VIEWS = ("list", "board", "rasci", "gantt", "burn", "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)]
|
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", "Radio"]
|
SUBS = ["Inbox", "Firewall"]
|
||||||
TABS = ["Stream", "Agents", "Artifacts", "Tasks"]
|
TABS = ["Stream", "Agents", "Artifacts", "Tasks"]
|
||||||
HELPERS = """
|
HELPERS = """
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -100,7 +100,7 @@ PROBE = """(async () => {@HELPERS@
|
|||||||
const box = document.getElementById("radio-spot");
|
const box = document.getElementById("radio-spot");
|
||||||
return box ? {hidden: box.hidden, channels: [...box.querySelector("form.composer select").options].map((option) => option.value)} : null;
|
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,
|
inbox: all("#main .inbox-list > li").length,
|
||||||
icons: icons.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)),
|
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)
|
})()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA)
|
||||||
RADIO_VIEW = """(async () => {@HELPERS@@MEDIA@
|
RADIO_VIEW = """(async () => {@HELPERS@@MEDIA@
|
||||||
location.hash = "#stream/radio";
|
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) {
|
if (!row) {
|
||||||
const initial = JSON.parse(document.getElementById("state").textContent);
|
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()};
|
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)
|
})()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA)
|
||||||
RADIO_CLICK = """(async () => {@HELPERS@@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) {
|
if (!row) {
|
||||||
return {error: "no station row", log: log(), media: await advancing()};
|
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);
|
await until(() => sample() && !sample().paused, 6000);
|
||||||
const media = await advancing();
|
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()};
|
return {clicked, state: now.dataset.state, note: text(now.querySelector(".station-note")), media, log: log()};
|
||||||
})()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA)
|
})()""".replace("@HELPERS@", HELPERS).replace("@MEDIA@", MEDIA)
|
||||||
SPEAKING = """
|
SPEAKING = """
|
||||||
const caption = () => {
|
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;
|
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 mouth = (art) => (art ? [art.classList.contains("speaking"), getComputedStyle(art.querySelector(".mouth")).animationName] : null);
|
||||||
const faces = () => ({
|
const faces = () => ({
|
||||||
speaking: mouth(document.querySelector('#main .speakers .avatar[data-face="s2"]')),
|
speaking: mouth(document.querySelector('#radio-spot .radio-caption .avatar[data-face="s2"]')),
|
||||||
silent: mouth(document.querySelector('#main .speakers .avatar[data-face="s1"]')),
|
silent: mouth(document.querySelector('.avatar[data-face]:not([data-face="s2"])')),
|
||||||
keys: [...new Set(all("#main .avatar.speaking").map((art) => art.dataset.face))],
|
keys: [...new Set(all(".avatar.speaking").map((art) => art.dataset.face))],
|
||||||
});
|
});
|
||||||
const cue = async (word) => {
|
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"});
|
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 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: found.querySelector(".avatar").dataset.face, ring: ring(found), 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: chip ? chip.dataset.key : null, ring: ring(chip), faces: faces()} : {error: "no caption", toasts: all("#toasts .toast").map(text)};
|
|
||||||
};
|
};
|
||||||
"""
|
"""
|
||||||
SPEAK = """(async () => {@HELPERS@@SPEAKING@
|
SPEAK = """(async () => {@HELPERS@@SPEAKING@
|
||||||
@@ -542,12 +541,12 @@ SPEAK_GRAPH = """(async () => {@HELPERS@@SPEAKING@
|
|||||||
SPEAK_END = """(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));
|
const ended = Boolean(await until(() => !document.querySelector("#main svg .node") || !document.querySelector("#main svg .node.speaking"), 20000));
|
||||||
location.hash = "#stream/radio";
|
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);
|
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)
|
})()""".replace("@HELPERS@", HELPERS).replace("@SPEAKING@", SPEAKING)
|
||||||
SPOT_OPEN = """
|
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") {
|
if (spotToggle && spotToggle.getAttribute("aria-expanded") !== "true") {
|
||||||
spotToggle.click();
|
spotToggle.click();
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
@@ -588,7 +587,7 @@ COMPOSE = """(async () => {@HELPERS@@SPOT_OPEN@
|
|||||||
};
|
};
|
||||||
})()""".replace("@HELPERS@", HELPERS).replace("@SPOT_OPEN@", SPOT_OPEN).replace("@CLASHES@", OVERLAP)
|
})()""".replace("@HELPERS@", HELPERS).replace("@SPOT_OPEN@", SPOT_OPEN).replace("@CLASHES@", OVERLAP)
|
||||||
COMPOSE_SEND = """(async () => {@HELPERS@
|
COMPOSE_SEND = """(async () => {@HELPERS@
|
||||||
const form = document.querySelector("#main form.composer");
|
const form = document.querySelector("#radio-spot form.composer");
|
||||||
const field = form.querySelector("textarea");
|
const field = form.querySelector("textarea");
|
||||||
field.value = `${field.value} please`;
|
field.value = `${field.value} please`;
|
||||||
field.dispatchEvent(new Event("input", {bubbles: true}));
|
field.dispatchEvent(new Event("input", {bubbles: true}));
|
||||||
@@ -627,7 +626,7 @@ CLEAR_MAYDAY = """(async () => {@HELPERS@
|
|||||||
accept = true;
|
accept = true;
|
||||||
button.click();
|
button.click();
|
||||||
const done = await until(() => fresh().find((toast) => toast.startsWith("Cancel mayday")), 15000);
|
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)
|
})()""".replace("@HELPERS@", HELPERS).replace("@SPOT_OPEN@", SPOT_OPEN)
|
||||||
NIGHT_MENU = """(async () => {@HELPERS@
|
NIGHT_MENU = """(async () => {@HELPERS@
|
||||||
const questions = [];
|
const questions = [];
|
||||||
@@ -690,11 +689,9 @@ SPOT_SEND = """(async () => {@HELPERS@
|
|||||||
questions.push(question);
|
questions.push(question);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const toggle = await until(() => all('#main [role="toolbar"] button').find((node) => name(node) === "Radio"), 8000);
|
await until(() => document.querySelector('#main [role="toolbar"]'), 8000);
|
||||||
if (!toggle) {
|
const toggle = document.getElementById("radio-open");
|
||||||
return {error: text(document.getElementById("main"))};
|
const main = document.getElementById("main");
|
||||||
}
|
|
||||||
const bar = document.querySelector('#main [role="toolbar"]');
|
|
||||||
const spot = document.getElementById("radio-spot");
|
const spot = document.getElementById("radio-spot");
|
||||||
const closed = [spot.hidden, toggle.getAttribute("aria-expanded")];
|
const closed = [spot.hidden, toggle.getAttribute("aria-expanded")];
|
||||||
toggle.click();
|
toggle.click();
|
||||||
@@ -705,7 +702,7 @@ SPOT_SEND = """(async () => {@HELPERS@
|
|||||||
const found = {
|
const found = {
|
||||||
closed,
|
closed,
|
||||||
open: [spot.hidden, toggle.getAttribute("aria-expanded"), document.activeElement === field],
|
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)),
|
toolbar: all('#main [role="toolbar"] > *').map((node) => name(node.tagName === "DETAILS" ? node.querySelector("summary") : node)),
|
||||||
channels: all("option", channel).map((option) => option.value),
|
channels: all("option", channel).map((option) => option.value),
|
||||||
record: all("button", form).filter((button) => button.hasAttribute("aria-pressed")).map(text),
|
record: all("button", form).filter((button) => button.hasAttribute("aria-pressed")).map(text),
|
||||||
@@ -733,15 +730,26 @@ SPOT_SEND = """(async () => {@HELPERS@
|
|||||||
return found;
|
return found;
|
||||||
})()""".replace("@HELPERS@", HELPERS).replace("@FITS@", FITS)
|
})()""".replace("@HELPERS@", HELPERS).replace("@FITS@", FITS)
|
||||||
RADIO_JUMP = """(async () => {@HELPERS@
|
RADIO_JUMP = """(async () => {@HELPERS@
|
||||||
const jump = await until(() => all("#main .radio-view button").find((button) => name(button) === "Send …"), 8000);
|
const spot = await until(() => location.hash === "#stream" && document.querySelector("#radio-spot:not([hidden])"), 8000);
|
||||||
if (!jump) {
|
if (!spot) {
|
||||||
return {error: text(document.getElementById("main"))};
|
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);
|
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)
|
})()""".replace("@HELPERS@", HELPERS)
|
||||||
GEAR_SAVE = """(async () => {@HELPERS@@CLASHES@
|
GEAR_SAVE = """(async () => {@HELPERS@@CLASHES@
|
||||||
const spot = document.getElementById("radio-spot");
|
const spot = document.getElementById("radio-spot");
|
||||||
@@ -1610,9 +1618,9 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
with self.subTest(url=step["url"], width=step["width"]):
|
with self.subTest(url=step["url"], width=step["width"]):
|
||||||
self.assertNotIn("error", probe)
|
self.assertNotIn("error", probe)
|
||||||
self.assertEqual((probe["title"], probe["h1"]), ("(2) Agent Orchestrator", "Agent Orchestrator"))
|
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["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.assertEqual(probe["banner"], "MAYDAY · Docs: disk full")
|
||||||
self.assertFalse(probe["rail"])
|
self.assertFalse(probe["rail"])
|
||||||
self.assertEqual((probe["time"], probe["replay"]), ("24 h ▾", True))
|
self.assertEqual((probe["time"], probe["replay"]), ("24 h ▾", True))
|
||||||
@@ -1620,7 +1628,7 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
|
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
|
||||||
self.assertFalse(probe["wide"], "the page scrolls sideways")
|
self.assertFalse(probe["wide"], "the page scrolls sideways")
|
||||||
self.assertEqual((probe["panel"], probe["nodeChips"]), (0, 0))
|
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))
|
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}
|
by_hash = {probe["hash"]: probe for probe in probes}
|
||||||
stream = by_hash["#stream"]
|
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.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$")
|
self.assertRegex(by_hash[f"#{tab}/3d"]["note"], r"^\d+ nodes · \d+ edges · Force$")
|
||||||
agents = by_hash["#agents/orbit"]
|
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})
|
self.assertEqual(agents["spot"], {"hidden": True, "channels": CHOICE_CHANNELS})
|
||||||
hierarchy = by_hash["#agents/hierarchy"]
|
hierarchy = by_hash["#agents/hierarchy"]
|
||||||
self.assertEqual([office for office, _ in hierarchy["org"]], ["chair", "identity/oidc", "work/s1", "work/s2"])
|
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"], [])
|
self.assertEqual(data["errors"], [])
|
||||||
speaking, graph, ended, calm = data["results"]
|
speaking, graph, ended, calm = data["results"]
|
||||||
self.talks(speaking, graph)
|
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["who"], calm["line"], calm["ring"]), ("Stage speaking", "Disk almost full (e2e-cue-2)", "none"), calm)
|
||||||
self.assertEqual(calm["faces"]["speaking"], [True, "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):
|
for result, (width, _) in zip(data["results"], WIDTHS, strict=True):
|
||||||
with self.subTest(width=width):
|
with self.subTest(width=width):
|
||||||
self.assertNotIn("error", result)
|
self.assertNotIn("error", result)
|
||||||
self.assertEqual(result["toolbar"], ["Radio", "Priority", "Day/Night", "Drain", "Suspend", "Resume"])
|
self.assertEqual(result["toolbar"], ["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["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["declined"], {"question": [question], "posted": [], "toasts": []})
|
||||||
self.assertEqual(result["accepted"], {"question": [question], "posted": ["/api/board/sign", "/api/board/clear-mayday"]})
|
self.assertEqual(result["accepted"], {"question": [question], "posted": ["/api/board/sign", "/api/board/clear-mayday"]})
|
||||||
self.assertTrue((result["done"] or "").startswith("Cancel mayday: done"), result)
|
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):
|
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}/"
|
local = f"http://localhost:{self.port}/"
|
||||||
values = {1920: ("0.35", "false", "true"), 1366: ("0.45", "true", "false"), 390: ("0.55", "false", "true")}
|
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:
|
for width, height in WIDTHS:
|
||||||
squelch, tone, post = values[width]
|
squelch, tone, post = values[width]
|
||||||
gear = GEAR_SAVE.replace("@SQUELCH@", squelch).replace("@TONE@", tone).replace("@POST@", post)
|
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))
|
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."
|
mayday = "Send a mayday to all? All agents pause writing steps until you cancel it."
|
||||||
jump, *walked = data["results"]
|
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):
|
for index, (width, _) in enumerate(WIDTHS):
|
||||||
sent, saved, read = walked[3 * index : 3 * (index + 1)]
|
sent, saved, read = walked[3 * index : 3 * (index + 1)]
|
||||||
squelch, tone, post = values[width]
|
squelch, tone, post = values[width]
|
||||||
@@ -1825,7 +1836,7 @@ class TestOrchestratorE2E(HubCase):
|
|||||||
self.assertNotIn("error", probe)
|
self.assertNotIn("error", probe)
|
||||||
self.assertEqual((sent["closed"], sent["open"]), ([True, "false"], [False, "true", True]))
|
self.assertEqual((sent["closed"], sent["open"]), ([True, "false"], [False, "true", True]))
|
||||||
self.assertEqual(sent["inline"], ["static", 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["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((sent["tall"], sent["wide"]), (False, False))
|
||||||
self.assertEqual([run["cls"] for run in sent["runs"]], CHOICE_CHANNELS)
|
self.assertEqual([run["cls"] for run in sent["runs"]], CHOICE_CHANNELS)
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ console.log(JSON.stringify({
|
|||||||
tasks: N.viewsOf("tasks").map(([id, label]) => [id, label]),
|
tasks: N.viewsOf("tasks").map(([id, label]) => [id, label]),
|
||||||
icon: N.ICONS.hierarchy,
|
icon: N.ICONS.hierarchy,
|
||||||
routes: hashes.map((hash) => N.route(hash)),
|
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)),
|
hashes: [["stream", null], ["inbox", null], ["rules", 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)),
|
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)
|
done = subprocess.run(["node", "-e", NAV_PROBE, str(NAV)], capture_output=True, text=True, timeout=60, check=True)
|
||||||
cls.data = json.loads(done.stdout)
|
cls.data = json.loads(done.stdout)
|
||||||
|
|
||||||
def test_the_navigation_has_four_entries_and_stream_has_inbox_rules_and_radio(self):
|
def test_the_navigation_has_four_entries_and_stream_has_inbox_and_rules(self):
|
||||||
stream = ["stream", "Stream", [["inbox", "Inbox"], ["rules", "Firewall"], ["radio", "Radio"]]]
|
stream = ["stream", "Stream", [["inbox", "Inbox"], ["rules", "Firewall"]]]
|
||||||
self.assertEqual(self.data["tabs"], [stream, ["agents", "Agents", []], ["artifacts", "Artifacts", []], ["tasks", "Tasks", []]])
|
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):
|
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": "artifacts", "view": None, "redirect": None},
|
||||||
{"screen": "agents", "view": "hierarchy", "redirect": "#agents/hierarchy"},
|
{"screen": "agents", "view": "hierarchy", "redirect": "#agents/hierarchy"},
|
||||||
{"screen": "artifacts", "view": "tree", "redirect": "#artifacts/tree"},
|
{"screen": "artifacts", "view": "tree", "redirect": "#artifacts/tree"},
|
||||||
{"screen": "radio", "view": None, "redirect": None},
|
{"screen": "stream", "view": None, "redirect": "#stream", "spot": True},
|
||||||
stream,
|
stream,
|
||||||
{"screen": "tasks", "view": None, "redirect": None},
|
{"screen": "tasks", "view": None, "redirect": None},
|
||||||
{"screen": "tasks", "view": "board", "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):
|
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["hashes"], ["#stream", "#stream/inbox", "#stream/rules", "#agents/chord", "#artifacts/3d", "#tasks/board"])
|
||||||
self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "stream", "agents", "artifacts", "tasks"])
|
self.assertEqual(self.data["tabOf"], ["stream", "stream", "stream", "agents", "artifacts", "tasks"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ from kvb_orchestrator import council, events, settings, skill_scripts, view
|
|||||||
|
|
||||||
|
|
||||||
class TestHubSurface(HubCase):
|
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()
|
html = self.index()
|
||||||
self.assertIn("<title>Agent Orchestrator</title>", html)
|
self.assertIn("<title>Agent Orchestrator</title>", html)
|
||||||
header = re.search(r'<header class="top">(.*?)</header>', html, re.DOTALL).group(1)
|
header = re.search(r'<header class="top">(.*?)</header>', html, re.DOTALL).group(1)
|
||||||
self.assertIn("<h1>Agent Orchestrator</h1>", header)
|
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), [])
|
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"'):
|
for gone in ('id="rail"', 'id="inbox-bar"', 'id="status"', 'id="board-open"', 'id="rules-open"', 'id="timebar"'):
|
||||||
with self.subTest(gone=gone):
|
with self.subTest(gone=gone):
|
||||||
|
|||||||
Reference in new issue
Block a user