Files
skills/orchestrator/templates/page-choices.js
T
Kevin Veen-BirkenbachandClaude Opus 5.5 57ab29eb0d
🧪 Test / 🧪 Lock + lint (push) Canceled after 0s
feat(orchestrator): S5.1 radio spot, compact menus, trend, ETA and Tasks 3D
Requirement 001 decisions 52 to 58 (operator 2026-11-09):

- Radio spot: the Agents toolbar radio button expands inline with the one
  composer (every channel, post and call recipients, Record, Send), a gear
  with the radio settings (tones, squelch, read aloud, moved out of the
  general settings) and Cancel mayday, which leaves the toolbar; the Radio
  tab keeps speakers, stations and the log plus a jump to the spot.
- Day/Night menu: the badge and Drain, Suspend and Resume only; each
  explanation is the button's tooltip.
- Tasks toolbar: views, filter, estimation, sort and business value in one
  wrapping row with a Request choices dropdown; the status chips stay the
  second row.
- Burndown and burnup: a trend selector (off, linear regression, velocity
  over N days) stored in the hub settings, the trend lines and the forecast
  date on the chart, and a tooltip per point with the work packages that
  started, ended or joined there.
- ETA: every list row shows its ETA (end when done, else start or now plus
  the forecast length behind its predecessors), sharing one predecessor
  walk with the Gantt.
- Tasks 3D: a sixth view on the 3D engine with the dependency edges in the
  Gantt colours, layouts Force and Time.
- Steady 3D camera: the view fits once per graph build, refreshes keep the
  operator's camera.
- e2e: Firefox runs with its profile as cwd, so its crashreporter no longer
  drops core dumps into the repository.
- A forecast beyond the last second a Date can hold is no forecast, so a
  nearly flat regression no longer aborts the Tasks render.

Evidence: make test Ran 717 OK, ruff clean, markdownlint 0 errors; e2e 30 of
30 OK in Chromium and Firefox at 1920, 1366 and 390 px; MEDUSA 0 findings
after one skeptic-accepted lexical EXA006.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 08:10:51 +00:00

74 lines
3.3 KiB
JavaScript

const CHOICE_ICONS = {post: "bi-envelope", call: "bi-telephone", newsletter: "bi-newspaper", broadcast: "bi-broadcast", "pan-pan": "bi-exclamation-triangle", security: "bi-shield-exclamation", mayday: "bi-life-preserver"};
const choiceAsk = {to: new Set(), dom: null, names: ""};
async function requestChoices(cls) {
const direct = TO_ONE.some(([id]) => id === cls);
const to = direct ? [...choiceAsk.to].sort((a, b) => a.localeCompare(b)) : ["*"];
if ((direct && !to.length) || (cls === "mayday" && !confirmMayday())) {
return;
}
const label = `Choice request (${CLASSES[cls]}) to ${direct ? to.join(", ") : "all"}`;
const signed = await signedProof("/api/choices/sign", {class: cls, to}, label);
if (!signed) {
return;
}
const result = await sendJson("/api/choices", {class: cls, to, proof: signed.proof});
if (!result.ok || !result.data) {
toast(`${label} failed`, fail(result), false);
return;
}
toast(`${label}: ${result.data.ok ? "sent" : "stored, sending failed"}`, [...result.data.ids.map((id) => `kvb-orchestrator msg ${id}`), ...runLines(result.data)], result.data.ok);
await load();
}
function choiceMenu() {
const picks = h("div", {class: "choice-agents", role: "group", "aria-label": "Post or call to"});
const count = counter(0);
const button = ([id, label]) => h("button", {type: "button", class: "btn small", "data-channel": id, ...named(label, `${label}: ask for every open decision as a choice, signed with your passkey. ${CHANNEL_HINTS[id]}`), on: {click: () => requestChoices(id)}},
icon(CHOICE_ICONS[id]), label);
const direct = TO_ONE.map(button);
choiceAsk.dom = {picks, count, direct};
choiceAsk.names = "";
return h("details", {class: "menu choice-request", id: "choice-request"},
h("summary", named("Request choices", "Request choices: every open decision comes back as a choice, signed with your passkey"), icon("bi-ui-checks"), "Request choices", caret()),
h("div", {class: "menu-box end", role: "group", "aria-label": "Choice requests"},
h("h3", {}, "Post or call to", count), picks, h("div", {class: "choice-buttons"}, direct),
h("h3", {}, "To all"), h("div", {class: "choice-buttons"}, TO_ALL.map(button))));
}
function syncChoices() {
choiceAsk.dom.count.textContent = String(choiceAsk.to.size);
for (const button of choiceAsk.dom.direct) {
button.disabled = !choiceAsk.to.size;
}
}
function pickChoice(name, on) {
if (on) {
choiceAsk.to.add(name);
} else {
choiceAsk.to.delete(name);
}
syncChoices();
}
function drawChoices() {
const dom = choiceAsk.dom;
if (!dom || !dom.picks.isConnected) {
return;
}
const names = agentNames();
for (const name of [...choiceAsk.to]) {
if (!names.includes(name)) {
choiceAsk.to.delete(name);
}
}
const sig = JSON.stringify(names);
if (sig !== choiceAsk.names) {
choiceAsk.names = sig;
dom.picks.replaceChildren(...(names.length ? names.map((name) => h("label", {class: "check"},
h("input", {type: "checkbox", value: name, checked: choiceAsk.to.has(name), on: {change: (event) => pickChoice(name, event.target.checked)}}), name)) : [h("p", {class: "muted"}, "No agent known yet.")]));
}
syncChoices();
}