feat(orchestrator): S2 agent and node popups with leader line, node search
Requirement 001 phase 6, S2: - Agent popup: a click on an agent opens one fading <dialog> with face, name, earlier names, offices, tasks, claims, subagents, history and the composer; Escape or a click outside closes it. The agent drawer and the Details side panel are gone. - Node popup: a click on a node in a graph view enlarges it and marks it red, and an SVG leader line ties it to the translucent popup; a click elsewhere restores the node. popup.js places the popup right, left, below or above the node, caps its height to the room there and returns the line. - Search: in the 3D views a search field with suggestions replaces the list of every node, one chip per distinct label. - Fit: body.fit now applies at every width; without the side panel the orbit view otherwise grew a page scrollbar at 390 px. - Two page modules had silently shadowed each other's functions of the same name while this was built; test_no_two_page_modules_declare_the_same_function now guards that. Artifact ids carry NUL, which CSS.escape turns into U+FFFD, so the node lookup compares dataset values. - Composer: a message goes to the agent whose popup it was written in. deliver() read the global ui.agent after awaiting the upload, so closing the popup or opening another agent during an upload sent the signed message to null or to the other agent (found by the security skeptic, present before, easier now that a backdrop click closes). Limits: the 3D leader line is placed when the popup opens and does not follow the camera while the layout settles; the search keeps the first node per label, so a second repository's main branch is found in the graph, not through the search. Evidence: make test 667 OK, ruff and markdownlint clean (/tmp/s2-make-test-1.log); e2e 19 OK in Chromium and Firefox at 1920, 1366 and 390 px, none skipped, rerun after the composer fix (/tmp/s2-e2e-4.log); screenshots in /tmp/orchestrator-phase4-shots/; MEDUSA 22 files, 6 findings (a "PLA" pattern in "templates", the UI names openAgent and drawAgent) accepted after an independent skeptic that also found no XSS or selector injection in the popup code. 🎯 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>
This commit is contained in:
1 parent
c035373518
commit
d89fb616b8
22 files changed
+596
-161
No files matched your search
@@ -28,7 +28,7 @@ body { min-height: 100vh; margin: 0; background: transparent; color: var(--text)
|
||||
button, input, select { font: inherit; color: inherit; }
|
||||
code, pre { font-family: var(--mono); font-size: var(--fs-sm); }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 22%, transparent); }
|
||||
.top, .navbar, main, .drawer, .sheet-box, .palette-box, .toast, .sky-panel, .menu-box { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); }
|
||||
.top, .navbar, main, .popup-box, .sheet-box, .palette-box, .toast, .sky-panel, .menu-box { -webkit-backdrop-filter: var(--blur); backdrop-filter: var(--blur); transition: background-color .8s var(--ease), border-color .8s var(--ease); }
|
||||
::view-transition-old(root), ::view-transition-new(root) { animation-duration: .22s; animation-timing-function: var(--ease); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition-duration: 0s !important; animation-duration: 0s !important; scroll-behavior: auto !important; }
|
||||
@@ -140,7 +140,7 @@ pre.notes { max-height: 260px; margin: 12px 0 0; padding: 10px 12px; overflow: a
|
||||
.chip.preset { font-weight: 650; }
|
||||
.chip .x { padding: 0 2px; border: 0; background: none; color: inherit; cursor: pointer; }
|
||||
.split { display: grid; grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); gap: 14px; align-items: start; }
|
||||
@media (max-width: 860px) { .split, .graph-body { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 860px) { .split { grid-template-columns: minmax(0, 1fr); } }
|
||||
.tree { position: sticky; top: 0; max-height: 70vh; padding: 10px 6px; overflow: auto; }
|
||||
.tree-title { margin: 6px 10px 4px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.tree-list { margin: 0; padding: 0 0 0 10px; list-style: none; }
|
||||
@@ -206,24 +206,17 @@ table.diff tr.more td { padding: 8px 16px; color: var(--muted); font-style: ital
|
||||
.replay { display: flex; flex: 1; align-items: center; gap: 10px; min-width: 280px; }
|
||||
.replay input[type="range"] { flex: 1; accent-color: var(--accent); }
|
||||
.window-label { color: var(--muted); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.graph-body { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 14px; align-items: start; }
|
||||
.stage-card { padding: 8px; }
|
||||
.stage svg { display: block; width: 100%; height: auto; max-height: calc(100vh - var(--head) - 120px); }
|
||||
@media (min-width: 861px) {
|
||||
body.fit { display: flex; flex-direction: column; height: 100vh; }
|
||||
body.fit .shell, body.fit main, body.fit .graph, body.fit .stage-card, body.fit .stage { display: flex; flex: 1; flex-direction: column; min-height: 0; }
|
||||
body.fit main { padding-bottom: var(--sp-5); }
|
||||
body.fit .graph-body { flex: 1; align-items: stretch; min-height: 0; }
|
||||
body.fit .stage svg, body.fit .stage canvas.space, body.fit .graph3d { flex: 1; height: auto; min-height: 0; max-height: none; }
|
||||
body.fit .panel { position: static; max-height: none; min-height: 0; overflow: auto; }
|
||||
}
|
||||
body.fit { display: flex; flex-direction: column; height: 100vh; }
|
||||
body.fit .shell, body.fit main, body.fit .graph, body.fit .stage-card, body.fit .stage { display: flex; flex: 1; flex-direction: column; min-height: 0; }
|
||||
body.fit main { padding-bottom: var(--sp-5); }
|
||||
body.fit .stage svg, body.fit .stage canvas.space, body.fit .graph3d { flex: 1; height: auto; min-height: 0; max-height: none; }
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 6px 10px 4px; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.swatch { display: inline-block; width: 18px; height: 4px; border-radius: 2px; background: var(--c); }
|
||||
.panel { position: sticky; top: calc(var(--head) + 12px); max-height: calc(100vh - var(--head) - 24px); overflow: auto; }
|
||||
.panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
|
||||
.panel-title { margin: 0; font-size: var(--fs-xl); font-weight: 650; overflow-wrap: anywhere; }
|
||||
.panel h3, .drawer-body h3 { display: flex; align-items: center; gap: 8px; margin: 18px 0 8px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.popup-box h3 { display: flex; align-items: center; gap: 8px; margin: 18px 0 8px; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.mini { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
||||
.mini li { display: grid; gap: 3px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); font-size: var(--fs-sm); overflow-wrap: anywhere; }
|
||||
.mini .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
@@ -248,7 +241,6 @@ svg .node.k-operator circle { fill: var(--accent); stroke: color-mix(in srgb, va
|
||||
svg .node.k-chair circle { fill: var(--accent-soft); stroke: var(--accent); stroke-width: 2; }
|
||||
svg .node.k-subagent circle { fill: var(--violet-soft); stroke: var(--violet); }
|
||||
svg .node.claimed circle { stroke: var(--ok); stroke-width: 2.2; }
|
||||
svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; }
|
||||
svg .label { fill: var(--text); font-size: var(--fs-sm); font-weight: 560; paint-order: stroke; stroke: var(--panel); stroke-width: 4px; stroke-linejoin: round; }
|
||||
svg .initial { fill: var(--muted); font-size: var(--fs-sm); font-weight: 700; pointer-events: none; }
|
||||
svg .k-operator .initial { fill: var(--on-accent); font-size: var(--fs-xl); }
|
||||
@@ -278,7 +270,9 @@ svg .node.tone-warn circle { fill: var(--warn-soft); stroke: var(--warn); }
|
||||
svg .node.tone-bad circle { fill: var(--bad-soft); stroke: var(--bad); }
|
||||
svg .node.tone-info circle { fill: var(--info-soft); stroke: var(--info); }
|
||||
svg .node.tone-violet circle { fill: var(--violet-soft); stroke: var(--violet); }
|
||||
svg .node.selected circle { stroke: var(--accent); stroke-width: 3.5; }
|
||||
svg .node.picked circle { fill: var(--alarm); stroke: var(--alarm); transform: scale(1.6); transform-box: fill-box; transform-origin: center; }
|
||||
svg .lane-label.picked { fill: var(--alarm); font-size: var(--fs-lg); font-weight: 750; }
|
||||
.linkish.picked { color: var(--alarm); transform: scale(1.15); }
|
||||
svg .edge.merge { stroke-dasharray: 5 5; }
|
||||
svg .lane-mark { stroke-width: 1.2; }
|
||||
svg .lane-mark.tone-ok { fill: var(--ok); stroke: var(--ok); }
|
||||
@@ -291,11 +285,16 @@ input[type="datetime-local"] { padding: 3px 6px; border: 1px solid var(--line-st
|
||||
.avatar.big { width: 52px; height: 52px; font-size: var(--fs-3xl); }
|
||||
.linkish { padding: 0; border: 0; background: none; color: inherit; font-weight: 650; text-align: left; cursor: pointer; overflow-wrap: anywhere; }
|
||||
.linkish:hover { color: var(--accent); text-decoration: underline; }
|
||||
.drawer { position: fixed; top: 0; right: 0; bottom: 0; z-index: 20; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; width: min(480px, 100vw); background: var(--glass-card); border-left: 1px solid var(--line); box-shadow: var(--shadow-lift); }
|
||||
.drawer[hidden] { display: none; }
|
||||
.drawer-head { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--line); }
|
||||
.drawer-body { overflow: auto; padding: 4px 18px 12px; }
|
||||
.drawer-form { display: grid; gap: 8px; padding: 12px 18px 18px; border-top: 1px solid var(--line); }
|
||||
.popup { position: fixed; inset: 0; z-index: 25; width: auto; height: auto; max-width: none; max-height: none; margin: 0; padding: 0; overflow: hidden; border: 0; background: transparent; color: var(--text); }
|
||||
.fade { animation: fade .2s var(--ease); }
|
||||
.leader { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.leader line { stroke: var(--alarm); stroke-width: 2; stroke-linecap: round; }
|
||||
.popup-box { position: absolute; display: flex; flex-direction: column; gap: var(--sp-2); width: min(440px, calc(100% - 2 * var(--sp-4))); max-height: calc(100% - 2 * var(--sp-4)); padding: var(--sp-4); overflow: auto; background: var(--glass-card); border: 1px solid var(--line-strong); border-radius: var(--radius); box-shadow: var(--shadow-lift); opacity: .94; }
|
||||
.popup-head { display: flex; flex: none; align-items: center; gap: 12px; }
|
||||
.popup-body { flex: 0 1 auto; min-height: 7em; overflow: auto; }
|
||||
.popup-form { display: grid; flex: none; gap: 8px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.node-search { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.node-search .chips { margin-bottom: 0; }
|
||||
textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid var(--line-strong); border-radius: 10px; background: var(--panel); color: var(--text); font: inherit; resize: vertical; }
|
||||
.bubble { display: grid; gap: 3px; margin: 0 0 8px; padding: 8px 11px; border-radius: 12px; background: var(--sunken); border: 1px solid var(--line); font-size: var(--fs-sm); overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.bubble.mine { margin-left: 34px; background: var(--accent-soft); border-color: transparent; }
|
||||
@@ -379,8 +378,6 @@ textarea { width: 100%; min-height: 84px; padding: 8px 10px; border: 1px solid v
|
||||
.stage canvas.space { display: block; width: 100%; height: clamp(360px, 68vh, 820px); border-radius: var(--radius-sm); cursor: grab; touch-action: none; }
|
||||
.stage canvas.space:active { cursor: grabbing; }
|
||||
.space-note { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; padding: 8px 10px 0; color: var(--muted); font-size: var(--fs-sm); }
|
||||
.space-list { padding: 6px 10px 2px; }
|
||||
.space-list summary { margin: 4px 0; color: var(--muted); font-size: var(--fs-sm); cursor: pointer; }
|
||||
body.sky-look .shell { visibility: hidden; }
|
||||
body.sky-look .sky { z-index: 5; pointer-events: auto; cursor: grab; touch-action: none; }
|
||||
body.sky-look .sky:active { cursor: grabbing; }
|
||||
@@ -441,7 +438,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
<div class="shell">
|
||||
<main id="main"></main>
|
||||
</div>
|
||||
<aside class="drawer" id="drawer" aria-label="Agent" hidden></aside>
|
||||
<dialog class="popup fade" id="popup" aria-label="Details"><svg class="leader" aria-hidden="true"><line></line></svg><div class="popup-box"></div></dialog>
|
||||
<div class="sheet" id="sheet" hidden></div>
|
||||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||
<div class="palette" id="palette" hidden></div>
|
||||
@@ -454,6 +451,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
{% include "voice.js" %}
|
||||
|
||||
{% include "nav.js" %}
|
||||
|
||||
{% include "popup.js" %}
|
||||
</script>
|
||||
<script nonce="{{ nonce }}">
|
||||
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
|
||||
@@ -550,6 +549,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
|
||||
|
||||
{% include "page-keyboard.js" %}
|
||||
|
||||
{% include "page-popup.js" %}
|
||||
|
||||
{% include "page-agent.js" %}
|
||||
|
||||
{% include "page-palette.js" %}
|
||||
|
||||
@@ -7,14 +7,15 @@
|
||||
return [...relayed, ...sent].sort((a, b) => a.ts - b.ts).slice(-80);
|
||||
}
|
||||
|
||||
function drawDrawer() {
|
||||
if (!drawerDom) {
|
||||
function drawAgent() {
|
||||
if (!agentDom) {
|
||||
return;
|
||||
}
|
||||
const node = state.graph.nodes.find((entry) => entry.id === ui.agent);
|
||||
const tasks = node ? node.tasks : [];
|
||||
const claims = node ? node.claims : [];
|
||||
drawerDom.info.replaceChildren(
|
||||
agentDom.info.replaceChildren(
|
||||
h("p", {class: "muted"}, claims.length ? `Offices: ${claims.map((claim) => claim.office).join(", ")}` : "No office"),
|
||||
h("h3", {}, "Tasks", counter(tasks.length)),
|
||||
tasks.length ? h("ul", {class: "mini"}, tasks.map((task) => h("li", {},
|
||||
h("div", {class: "row"}, h("code", {}, task.task), status(task.status)),
|
||||
@@ -26,9 +27,10 @@
|
||||
h("span", {}, dash(claim.work)),
|
||||
h("span", {class: "muted"}, `${dash(claim.runtime)} · since ${dash(claim.since)}`)))) : h("p", {class: "muted"}, "No claims."));
|
||||
const rows = history(ui.agent);
|
||||
drawerDom.history.replaceChildren(rows.length
|
||||
agentDom.history.replaceChildren(rows.length
|
||||
? h("div", {}, rows.map((row) => h("div", {class: `bubble ${row.mine ? "mine" : "theirs"}`}, h("span", {class: "muted"}, `${row.who} · `, when(row.ts)), row.text, voiceReplay(row.voice))))
|
||||
: h("p", {class: "muted"}, "No history in the chosen time range."));
|
||||
placePopup();
|
||||
}
|
||||
|
||||
async function loadSubagents(key, holder, life) {
|
||||
@@ -56,9 +58,10 @@
|
||||
item.style.marginLeft = `${(row.depth - 1) * 18}px`;
|
||||
return item;
|
||||
})) : h("p", {class: "muted"}, "No subagents."));
|
||||
placePopup();
|
||||
}
|
||||
|
||||
async function deliver(text, file, cls) {
|
||||
async function deliver(key, text, file, cls) {
|
||||
let upload = "";
|
||||
if (file) {
|
||||
if (file.size > UPLOAD_LIMIT) {
|
||||
@@ -72,23 +75,21 @@
|
||||
}
|
||||
upload = stored.data.id;
|
||||
}
|
||||
return message(nameOf(ui.agent), text, upload, cls);
|
||||
return message(nameOf(key), text, upload, cls);
|
||||
}
|
||||
|
||||
function message(to, text, upload, cls) {
|
||||
return radioSend({to, text, upload, cls, voice: ""});
|
||||
}
|
||||
|
||||
function openAgent(who) {
|
||||
const drawer = document.getElementById("drawer");
|
||||
function openAgent(who, anchor, closed) {
|
||||
const key = agentKey(who);
|
||||
const name = nameOf(key);
|
||||
ui.agent = key;
|
||||
const text = h("textarea", {"aria-label": `Message to ${name}`, placeholder: `Message to ${name} …`});
|
||||
const file = h("input", {type: "file", class: "field", "aria-label": "Attach file"});
|
||||
const button = h("button", {type: "submit", class: "btn primary icon-only", ...named("Send")}, icon("bi-send"));
|
||||
const classes = choice("to-one", TO_ONE, "post");
|
||||
const form = h("form", {class: "drawer-form"}, text, file, classes,
|
||||
const form = h("form", {class: "popup-form"}, text, file, classes,
|
||||
h("p", {class: "muted"}, "Post arrives at the end of the turn, Call at once with a read receipt; your passkey signs, verifiable with operator_message."),
|
||||
h("div", {class: "actions"}, h("span", {class: "spacer"}), micButton(text), button));
|
||||
form.addEventListener("submit", async (event) => {
|
||||
@@ -97,7 +98,7 @@
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
if (await deliver(text.value, file.files[0] || null, picked(classes))) {
|
||||
if (await deliver(key, text.value, file.files[0] || null, picked(classes))) {
|
||||
text.value = "";
|
||||
file.value = "";
|
||||
}
|
||||
@@ -108,24 +109,24 @@
|
||||
const log = h("div", {"aria-live": "polite"});
|
||||
const life = h("span", {class: "muted"});
|
||||
const former = (people()[key] || {names: []}).names.filter((entry) => entry !== name);
|
||||
drawer.replaceChildren(
|
||||
h("div", {class: "drawer-head"}, face(name, true), life, h("div", {}, h("h2", {class: "panel-title"}, name), h("span", {class: "muted"}, former.length ? `Agent · formerly ${former.join(", ")}` : "Agent")), h("span", {class: "spacer"}),
|
||||
iconBtn("bi-x-lg", "Close", "btn small ghost", closeAgent)),
|
||||
h("div", {class: "drawer-body"}, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log),
|
||||
form);
|
||||
drawer.hidden = false;
|
||||
drawerDom = {info, history: log};
|
||||
drawDrawer();
|
||||
openPopup(name, [
|
||||
popupHead(face(name, true), name, h("span", {class: "muted"}, former.length ? `Agent · formerly ${former.join(", ")}` : "Agent"), " ", life),
|
||||
h("div", {class: "popup-body"}, info, h("h3", {}, "Subagents"), subs, h("h3", {}, "History"), log),
|
||||
form,
|
||||
], anchor, () => {
|
||||
agentDom = null;
|
||||
ui.agent = null;
|
||||
if (closed) {
|
||||
closed();
|
||||
}
|
||||
});
|
||||
ui.agent = key;
|
||||
agentDom = {info, history: log};
|
||||
drawAgent();
|
||||
loadSubagents(key, subs, life);
|
||||
text.focus();
|
||||
}
|
||||
|
||||
function closeAgent() {
|
||||
document.getElementById("drawer").hidden = true;
|
||||
drawerDom = null;
|
||||
ui.agent = null;
|
||||
}
|
||||
|
||||
function agentNames() {
|
||||
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.agent_order]);
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const art = {repo: "", node: null};
|
||||
const art = {repo: ""};
|
||||
const ART_KINDS = {repo: ["Repository", "bi-git"], branch: ["Branch", "bi-diagram-2"], worktree: ["Worktree", "bi-folder2-open"], agent: ["Agent", "bi-robot"], artefact: ["Artifact", "bi-box-seam"], task: ["Task", "bi-card-checklist"]};
|
||||
const ARTEFACT_ICONS = {image: "bi-box-seam", volume: "bi-hdd", log: "bi-file-text", dir: "bi-folder"};
|
||||
const LANE_MARKS = {commit: ["Commit", "bi-record-circle"], review: ["Review", "bi-eye"], gate: ["Gate", "bi-shield-check"], push: ["Push", "bi-cloud-upload"]};
|
||||
@@ -71,16 +71,23 @@
|
||||
return found;
|
||||
}
|
||||
|
||||
function artSelect(id) {
|
||||
art.node = art.node === id ? null : id;
|
||||
drawArtifacts();
|
||||
function artSelect(id, anchor, closed) {
|
||||
const node = tree.model && tree.model.byId.get(id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (node.kind === "agent") {
|
||||
openAgent(node.data.name, anchor || pickOf(id), closed);
|
||||
} else {
|
||||
openPopup(node.label, artPopup(node), anchor || pickOf(id), closed);
|
||||
}
|
||||
}
|
||||
|
||||
function artMark(node, at, radius, extra, caption) {
|
||||
const [kindName] = ART_KINDS[node.kind];
|
||||
const classes = ["node", `a-${node.kind}`, extra, art.node === node.id ? "selected" : ""].filter(Boolean).join(" ");
|
||||
const classes = ["node", `a-${node.kind}`, extra].filter(Boolean).join(" ");
|
||||
return s("g", {
|
||||
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${kindName}`,
|
||||
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${kindName}`, "data-pick": node.id,
|
||||
on: {
|
||||
click: () => artSelect(node.id),
|
||||
keydown: (event) => {
|
||||
@@ -178,7 +185,7 @@
|
||||
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": `Swimlane: commits, reviews, gates and pushes per branch of ${repo.name}`}, defs());
|
||||
lanes.forEach((branch, index) => {
|
||||
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
|
||||
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", on: {click: () => artSelect(`branch:${repo.path}\u0000${branch.name}`)}}, short(branch.name).slice(0, 26)));
|
||||
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "data-pick": `branch:${repo.path}\u0000${branch.name}`, on: {click: () => artSelect(`branch:${repo.path}\u0000${branch.name}`)}}, short(branch.name).slice(0, 26)));
|
||||
});
|
||||
for (let index = 0; index <= 6; index += 1) {
|
||||
const ts = begin + ((end - begin) * index) / 6;
|
||||
@@ -209,11 +216,7 @@
|
||||
return h("div", {class: "legend"}, marks, h("span", {class: "muted"}, words[view]));
|
||||
}
|
||||
|
||||
function artPanel(model) {
|
||||
const node = model.byId.get(art.node);
|
||||
if (!node) {
|
||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Click a repository, a branch, a worktree, an agent or an artifact.")];
|
||||
}
|
||||
function artPopup(node) {
|
||||
const [kindName, kindIcon] = ART_KINDS[node.kind];
|
||||
const data = node.data;
|
||||
const body = [];
|
||||
@@ -229,31 +232,23 @@
|
||||
flowDetails(data.flow, data.branch),
|
||||
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "No agent works here."),
|
||||
data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path)));
|
||||
} else if (node.kind === "agent") {
|
||||
body.push(h("div", {class: "actions"}, btn("Open agent", "btn small soft", () => openAgent(data.name))));
|
||||
} else if (node.kind === "artefact") {
|
||||
body.push(meta([["Ref", data.ref], ["Kind", data.kind], ["Task", data.task], ["Since", data.added]]), data.ended ? pill("Task ended: clean up", "warn") : null);
|
||||
} else {
|
||||
body.push(meta([["Title", data.title], ["Status", data.status]]));
|
||||
}
|
||||
return [
|
||||
h("div", {class: "panel-head"}, h("div", {}, h("h2", {class: "panel-title"}, icon(kindIcon), " ", node.label), h("span", {class: "muted"}, kindName)),
|
||||
iconBtn("bi-x-lg", "Close", "btn small ghost", () => artSelect(node.id))),
|
||||
...body,
|
||||
];
|
||||
return [popupHead(icon(kindIcon), node.label, h("span", {class: "muted"}, kindName)), h("div", {class: "popup-body"}, body)];
|
||||
}
|
||||
|
||||
function artifactsScreen() {
|
||||
const view = ui.views.artifacts;
|
||||
const picker = h("select", {class: "field", "aria-label": "Repository", title: "Repository", on: {change: (event) => {
|
||||
art.repo = event.target.value;
|
||||
art.node = null;
|
||||
drawArtifacts();
|
||||
}}});
|
||||
const count = h("span", {class: "muted"});
|
||||
const reload = iconBtn("bi-arrow-clockwise", "Reload", "btn small soft", fetchTree, "Reload: read the tree state from pkgmgr");
|
||||
const stageBox = h("div", {class: "stage"});
|
||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||
const legendBox = h("div", {});
|
||||
const list = h("div", {class: "repos"});
|
||||
const search = view === "tree" ? h("input", {type: "search", placeholder: "Filter repo, branch, path or agent …", value: tree.q, "aria-label": "Filter tree", on: {input: (event) => {
|
||||
@@ -261,14 +256,14 @@
|
||||
drawArtifacts();
|
||||
}}}) : null;
|
||||
const removeAll = view === "tree" ? h("span", {class: "delete-all"}) : null;
|
||||
tree.dom = {view, picker, count, stage: stageBox, aside, legend: legendBox, list, removeAll};
|
||||
tree.dom = {view, picker, count, stage: stageBox, legend: legendBox, list, removeAll};
|
||||
window.setTimeout(() => {
|
||||
drawArtifacts();
|
||||
fetchTree();
|
||||
}, 0);
|
||||
const controls = h("div", {class: "card controls"}, viewSwitch("artifacts"), view === "3d" ? layoutTools("artifacts") : view === "tree" ? null : picker,
|
||||
h("div", {class: "toolbar", role: "toolbar", "aria-label": "Artifacts"}, search, reload, removeAll), count);
|
||||
const body = view === "tree" ? list : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legendBox), aside);
|
||||
const body = view === "tree" ? list : h("div", {class: "card stage-card"}, stageBox, legendBox);
|
||||
return h("div", {class: "graph"}, controls, body);
|
||||
}
|
||||
|
||||
@@ -305,14 +300,9 @@
|
||||
tree.dom.count.textContent = empty ? "" : `${tree.data.repos.length} repositories · as of ${clock.format(stampOf(tree.data.scanned))}`;
|
||||
if (empty) {
|
||||
tree.dom.stage.replaceChildren(emptyState(tree.busy || !tree.data ? "Reading repositories …" : "No tree", tree.data ? tree.data.error : "pkgmgr writes the tree every minute while this page is open."));
|
||||
tree.dom.aside.replaceChildren();
|
||||
return;
|
||||
}
|
||||
const model = tree.model;
|
||||
if (art.node !== null && !model.byId.has(art.node)) {
|
||||
art.node = null;
|
||||
}
|
||||
tree.dom.aside.replaceChildren(...artPanel(model));
|
||||
if (tree.dom.view === "3d") {
|
||||
stage3d("artifacts", tree.dom.stage);
|
||||
return;
|
||||
@@ -321,4 +311,5 @@
|
||||
tree.dom.picker.replaceChildren(...tree.data.repos.map((entry) => h("option", {value: entry.path, selected: entry.path === art.repo}, `${entry.name} (${entry.branches.length})`)));
|
||||
tree.dom.legend.replaceChildren(artLegend(tree.dom.view));
|
||||
tree.dom.stage.replaceChildren(repo ? {orbit: artOrbit, chord: artChord, swimlane: artSwimlane}[tree.dom.view](repo, model) : emptyState("No repository", "pkgmgr names no repository."));
|
||||
placePopup();
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
closeSheet();
|
||||
}
|
||||
});
|
||||
popupDialog().addEventListener("click", (event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closePopup();
|
||||
}
|
||||
});
|
||||
document.getElementById("palette-open").addEventListener("click", openPalette);
|
||||
document.getElementById("settings-open").addEventListener("click", settingsSheet);
|
||||
document.addEventListener("click", (event) => closeMenus(event.target instanceof Element ? event.target.closest("details.menu") : null));
|
||||
@@ -26,6 +31,7 @@
|
||||
window.addEventListener("resize", measure);
|
||||
window.addEventListener("resize", redrawSky);
|
||||
window.addEventListener("resize", refresh3d);
|
||||
window.addEventListener("resize", placePopup);
|
||||
skyPanel();
|
||||
radio.element = radioElement();
|
||||
drawSky();
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": "Swimlane: messages over time"}, defs());
|
||||
lanes.forEach((node, index) => {
|
||||
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
|
||||
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", on: {click: () => select(node.id)}}, node.label));
|
||||
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", "data-pick": node.id, on: {click: () => select(node.id)}}, node.label));
|
||||
});
|
||||
for (let index = 0; index <= 6; index += 1) {
|
||||
const ts = begin + ((end - begin) * index) / 6;
|
||||
@@ -161,21 +161,15 @@
|
||||
h("span", {class: "muted"}, "Line width = count · closeness = recent collaboration"));
|
||||
}
|
||||
|
||||
function side() {
|
||||
const node = state.graph.nodes.find((entry) => entry.id === ui.node);
|
||||
if (!node) {
|
||||
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, "Click a node: its panel shows tasks, claims, subagents and history.")];
|
||||
}
|
||||
function nodePopup(node) {
|
||||
const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse();
|
||||
return [
|
||||
h("div", {class: "panel-head"},
|
||||
h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` of ${labelOf(node.parent)}` : ""}`)),
|
||||
iconBtn("bi-x-lg", "Close", "btn small ghost", () => select(node.id))),
|
||||
h("h3", {}, "Latest messages", counter(messages.length)),
|
||||
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
|
||||
h("div", {class: "row"}, klass(row.class), when(row.ts)),
|
||||
h("span", {class: "muted"}, `${dash(row.from)} → ${targetsOf(row)}`),
|
||||
h("span", {}, row.text)))) : h("p", {class: "muted"}, "No messages in the chosen time range."),
|
||||
popupHead(null, node.label, h("span", {class: "muted"}, NODE_KINDS[node.kind])),
|
||||
h("div", {class: "popup-body"}, h("h3", {}, "Latest messages", counter(messages.length)),
|
||||
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
|
||||
h("div", {class: "row"}, klass(row.class), when(row.ts)),
|
||||
h("span", {class: "muted"}, `${dash(row.from)} → ${targetsOf(row)}`),
|
||||
h("span", {}, row.text)))) : h("p", {class: "muted"}, "No messages in the chosen time range.")),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -188,9 +182,8 @@
|
||||
function agentsScreen() {
|
||||
const view = ui.views.agents;
|
||||
const stageBox = h("div", {class: "stage"});
|
||||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||||
const toolbar = agentsToolbar();
|
||||
graphDom = {stage: stageBox, aside, pulses: null, toolbar};
|
||||
const body = view === "tree" ? spawnScreen() : view === "hierarchy" ? hierarchyScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
|
||||
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, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
|
||||
}
|
||||
@@ -107,22 +107,24 @@
|
||||
return s("text", {class: "label", x: fixed(cos * gap), y: fixed(sin * (gap + 3)), dy: "0.35em", "text-anchor": anchor}, node.label);
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
ui.node = ui.node === id ? null : id;
|
||||
stage();
|
||||
drawSide();
|
||||
function select(id, anchor, closed) {
|
||||
const node = state.graph.nodes.find((entry) => entry.id === id);
|
||||
if (ui.node !== null && node && node.kind !== "operator") {
|
||||
openAgent(node.kind === "subagent" ? node.parent : id);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (node.kind === "operator") {
|
||||
openPopup(node.label, nodePopup(node), anchor || pickOf(id), closed);
|
||||
} else {
|
||||
openAgent(node.kind === "subagent" ? node.parent : id, anchor || pickOf(id), closed);
|
||||
}
|
||||
}
|
||||
|
||||
function nodeMark(node, at, gap) {
|
||||
const r = RADIUS[node.kind];
|
||||
const classes = ["node", `k-${node.kind}`, ui.node === node.id ? "selected" : "", node.claims.length ? "claimed" : "", speech.current && speech.current.who === node.id ? "speaking" : ""].filter(Boolean).join(" ");
|
||||
const classes = ["node", `k-${node.kind}`, node.claims.length ? "claimed" : "", speech.current && speech.current.who === node.id ? "speaking" : ""].filter(Boolean).join(" ");
|
||||
const tasks = node.tasks.length ? ` · ${node.tasks.length} tasks` : "";
|
||||
return s("g", {
|
||||
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${NODE_KINDS[node.kind]}`,
|
||||
class: classes, transform: `translate(${fixed(at.x)} ${fixed(at.y)})`, tabindex: 0, role: "button", "aria-label": `${node.label}, ${NODE_KINDS[node.kind]}`, "data-pick": node.id,
|
||||
on: {
|
||||
click: () => select(node.id),
|
||||
keydown: (event) => {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
const SPAWNED = new Set(["twig", "subagent"]);
|
||||
const DEPTH_GAP = 90;
|
||||
const TIME_DEPTH = 640;
|
||||
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: ""};
|
||||
const g3 = {lib: null, three: null, graph: null, host: null, tab: "", sig: "", planes: [], colors: null, failed: "", picked: null};
|
||||
|
||||
const layoutOf = () => space.views.graph.axis;
|
||||
|
||||
@@ -44,7 +44,8 @@
|
||||
}, icon(name))));
|
||||
return h("div", {class: "space-tools"}, group,
|
||||
iconBtn("bi-arrow-counterclockwise", "Reset view", "btn small ghost", () => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 600, 40)),
|
||||
h("span", {class: "muted"}, "Drag turns · right drag pans · wheel zooms · click opens the panel"));
|
||||
h("span", {class: "muted"}, "Drag turns · right drag pans · wheel zooms · click opens the popup"),
|
||||
nodeSearch(() => model3d(tab).nodes, (node) => pick3d(tab, node)));
|
||||
}
|
||||
|
||||
function chooseLayout(tab, id, group) {
|
||||
@@ -108,7 +109,7 @@
|
||||
function colors3d() {
|
||||
const css = window.getComputedStyle(document.documentElement);
|
||||
const value = (name) => css.getPropertyValue(`--${name}`).trim() || "#888";
|
||||
const roles = new Set([...Object.values(KIND_COLORS), ...Object.values(LINK_COLORS), "text"]);
|
||||
const roles = new Set([...Object.values(KIND_COLORS), ...Object.values(LINK_COLORS), "text", "alarm"]);
|
||||
g3.colors = Object.fromEntries([...roles].map((role) => [role, value(role)]));
|
||||
return g3.colors;
|
||||
}
|
||||
@@ -141,19 +142,37 @@
|
||||
}
|
||||
|
||||
function pick3d(tab, node) {
|
||||
if (tab === "artifacts") {
|
||||
artSelect(node.id);
|
||||
} else if (node.kind === "twig") {
|
||||
if (node.kind === "twig") {
|
||||
focusSub(node.session, node.sub);
|
||||
} else {
|
||||
select(node.id);
|
||||
return;
|
||||
}
|
||||
(tab === "artifacts" ? artSelect : select)(node.id, anchor3d(node.id), () => mark3d(null));
|
||||
mark3d(node.id);
|
||||
}
|
||||
|
||||
function list3d(tab, data) {
|
||||
const pickable = data.nodes.filter((node) => node.kind !== "operator");
|
||||
return h("details", {class: "space-list"}, h("summary", {}, `${pickable.length} nodes as a list`),
|
||||
h("div", {class: "chips", role: "group", "aria-label": "Nodes"}, pickable.slice(0, 400).map((node) => h("button", {type: "button", class: "chip", title: node.label, "data-node": node.id, on: {click: () => pick3d(tab, node)}}, node.label))));
|
||||
function anchor3d(id) {
|
||||
return () => {
|
||||
const node = g3.graph && g3.host && g3.host.isConnected ? g3.graph.graphData().nodes.find((entry) => entry.id === id) : null;
|
||||
if (!node || !Number.isFinite(node.x)) {
|
||||
return null;
|
||||
}
|
||||
const at = g3.graph.graph2ScreenCoords(node.x, node.y, node.z);
|
||||
const box = g3.host.querySelector(".graph3d").getBoundingClientRect();
|
||||
return {left: box.left + at.x - 8, top: box.top + at.y - 8, width: 16, height: 16};
|
||||
};
|
||||
}
|
||||
|
||||
function mark3d(id) {
|
||||
g3.picked = id;
|
||||
if (g3.graph) {
|
||||
g3.graph.nodeColor(g3.graph.nodeColor()).nodeVal(g3.graph.nodeVal());
|
||||
}
|
||||
const box = g3.host && g3.host.querySelector(".graph3d");
|
||||
if (box && id) {
|
||||
box.dataset.picked = id;
|
||||
} else if (box) {
|
||||
delete box.dataset.picked;
|
||||
}
|
||||
}
|
||||
|
||||
function signature3d(tab, data) {
|
||||
@@ -176,7 +195,7 @@
|
||||
dispose3d();
|
||||
g3.host = host;
|
||||
g3.tab = tab;
|
||||
host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D graph of the ${tab === "agents" ? "agents" : "artifacts"}; the nodes are also listed below`}), h("div", {class: "space-note"}, "Loading 3D …"));
|
||||
host.replaceChildren(h("div", {class: "graph3d", role: "img", "aria-label": `3D graph of the ${tab === "agents" ? "agents" : "artifacts"}; the search above finds every node`}), h("div", {class: "space-note"}, "Loading 3D …"));
|
||||
}
|
||||
g3.sig = sig;
|
||||
const box = host.querySelector(".graph3d");
|
||||
@@ -203,8 +222,8 @@
|
||||
}
|
||||
const colors = colors3d();
|
||||
g3.graph.backgroundColor("rgba(0,0,0,0)").showNavInfo(false).nodeRelSize(5).nodeOpacity(0.92).linkOpacity(0.45)
|
||||
.nodeLabel((node) => h("span", {}, node.label)).nodeVal((node) => ({operator: 6, chair: 4, repo: 6, branch: 3, session: 2.5}[node.kind] || 1))
|
||||
.nodeColor((node) => g3.colors[KIND_COLORS[node.kind]] || colors.text).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
|
||||
.nodeLabel((node) => h("span", {}, node.label)).nodeVal((node) => ({operator: 6, chair: 4, repo: 6, branch: 3, session: 2.5}[node.kind] || 1) * (node.id === g3.picked ? 4 : 1))
|
||||
.nodeColor((node) => (node.id === g3.picked ? g3.colors.alarm : g3.colors[KIND_COLORS[node.kind]] || colors.text)).linkColor((link) => g3.colors[LINK_COLORS[link.kind]] || colors.text)
|
||||
.linkWidth((link) => (link.kind === "contains" || link.kind === "spawn" ? 0.6 : Math.min(0.8 + Math.log2(1 + (link.count || 1)), 4)))
|
||||
.onNodeClick((node) => pick3d(g3.tab, node)).onEngineStop(() => g3.graph && g3.graph.zoomToFit(calm.matches ? 0 : 400, 40));
|
||||
g3.graph.warmupTicks(calm.matches ? 160 : 80);
|
||||
@@ -219,7 +238,7 @@
|
||||
g3.graph.width(box.clientWidth).height(box.clientHeight).graphData(data);
|
||||
drawPlanes(layout, data.nodes);
|
||||
const words = `${data.nodes.length} nodes · ${data.links.length} edges · ${LAYOUTS.find(([id]) => id === layout)[1]}${calm.matches ? " · without motion (prefers-reduced-motion)" : ""}`;
|
||||
host.replaceChildren(box, h("div", {class: "space-note"}, words), list3d(tab, data));
|
||||
host.replaceChildren(box, h("div", {class: "space-note"}, words));
|
||||
window.requestAnimationFrame(() => g3.graph && g3.host === host && g3.graph.zoomToFit(0, 40));
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
const active = claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT;
|
||||
return h("div", {class: "org-row", "data-office": claim.office},
|
||||
h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)),
|
||||
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `Open ${who}`, on: {click: () => openAgent(who)}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
|
||||
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `Open ${who}`, on: {click: () => openAgent(who, near(`#main [data-focus="agent:${CSS.escape(claim.office)}"]`))}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
|
||||
active ? chip("bi-play-circle", "active", "ok") : chip("bi-pause-circle", "no heartbeat", "neutral"),
|
||||
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
|
||||
h("span", {class: "org-actions"},
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
closeSheet();
|
||||
} else if (space.look) {
|
||||
skyLook(false);
|
||||
} else if (ui.agent !== null) {
|
||||
closeAgent();
|
||||
} else if (popupDialog().open) {
|
||||
closePopup();
|
||||
} else {
|
||||
closeMenus();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
announce();
|
||||
render(false);
|
||||
drawTime();
|
||||
drawDrawer();
|
||||
drawAgent();
|
||||
drawStream();
|
||||
if (motion.current.size === 0 || ui.asOf !== motion.asOf) {
|
||||
const first = motion.current.size === 0;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
const pop = {anchor: null, marked: null, closed: null};
|
||||
const HITS = 8;
|
||||
const popupDialog = () => document.getElementById("popup");
|
||||
const popupBox = () => popupDialog().querySelector(".popup-box");
|
||||
const near = (selector) => () => document.querySelector(selector);
|
||||
// Trip-wire: artifact ids hold NUL, which CSS.escape turns into U+FFFD, so an attribute selector never matches them.
|
||||
const pickOf = (id) => () => [...document.querySelectorAll("#main [data-pick]")].find((node) => node.dataset.pick === id) || null;
|
||||
const popupHead = (lead, title, ...sub) => h("div", {class: "popup-head"}, lead, h("div", {}, h("h2", {class: "panel-title"}, title), sub), h("span", {class: "spacer"}), iconBtn("bi-x-lg", "Close", "btn small ghost", closePopup));
|
||||
|
||||
function openPopup(label, body, anchor, closed) {
|
||||
closePopup();
|
||||
pop.anchor = anchor || null;
|
||||
pop.closed = closed || null;
|
||||
popupDialog().setAttribute("aria-label", label);
|
||||
popupBox().replaceChildren(...body);
|
||||
popupDialog().show();
|
||||
placePopup();
|
||||
}
|
||||
|
||||
function closePopup() {
|
||||
if (!popupDialog().open) {
|
||||
return;
|
||||
}
|
||||
popupDialog().close();
|
||||
markPick(null);
|
||||
const closed = pop.closed;
|
||||
pop.anchor = null;
|
||||
pop.closed = null;
|
||||
if (closed) {
|
||||
closed();
|
||||
}
|
||||
}
|
||||
|
||||
function markPick(target) {
|
||||
if (pop.marked && pop.marked !== target) {
|
||||
pop.marked.classList.remove("picked");
|
||||
}
|
||||
pop.marked = target;
|
||||
if (target) {
|
||||
target.classList.add("picked");
|
||||
}
|
||||
}
|
||||
|
||||
function placePopup() {
|
||||
const dialog = popupDialog();
|
||||
if (!dialog.open) {
|
||||
return;
|
||||
}
|
||||
const found = pop.anchor ? pop.anchor() : null;
|
||||
const target = found instanceof Element && found.isConnected ? found : null;
|
||||
markPick(target);
|
||||
const shape = target ? target.querySelector("circle") || target : null;
|
||||
const box = popupBox();
|
||||
box.style.maxHeight = "";
|
||||
const at = Popup.place(shape ? shape.getBoundingClientRect() : found instanceof Element ? null : found, {width: box.offsetWidth, height: box.offsetHeight}, {width: dialog.clientWidth, height: dialog.clientHeight});
|
||||
box.style.left = `${at.left}px`;
|
||||
box.style.top = `${at.top}px`;
|
||||
box.style.maxHeight = at.height === null ? "" : `${at.height}px`;
|
||||
const line = dialog.querySelector(".leader line");
|
||||
line.setAttribute("display", at.line ? "inline" : "none");
|
||||
for (const [key, value] of Object.entries(at.line || {})) {
|
||||
line.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function nodeSearch(nodes, pick) {
|
||||
const list = h("div", {class: "chips hits", role: "group", "aria-label": "Search hits"});
|
||||
const field = h("input", {type: "search", class: "field", placeholder: "Find a node …", "aria-label": "Find a node", on: {
|
||||
input: () => list.replaceChildren(...Popup.hits(nodes(), field.value, HITS).map((node) => h("button", {type: "button", class: "chip", title: node.label, "data-hit": node.id, on: {click: () => pick(node)}}, node.label))),
|
||||
keydown: (event) => {
|
||||
const [first] = event.key === "Enter" ? Popup.hits(nodes(), field.value, 1) : [];
|
||||
if (first) {
|
||||
event.preventDefault();
|
||||
pick(first);
|
||||
}
|
||||
},
|
||||
}});
|
||||
return h("div", {class: "node-search"}, field, list);
|
||||
}
|
||||
@@ -52,9 +52,6 @@
|
||||
}
|
||||
|
||||
function render(force) {
|
||||
if (ui.node !== null && !state.graph.nodes.some((node) => node.id === ui.node)) {
|
||||
ui.node = null;
|
||||
}
|
||||
for (const [id, {button}] of navButtons) {
|
||||
button.setAttribute("aria-current", ui.screen === id ? "page" : "false");
|
||||
}
|
||||
@@ -78,7 +75,6 @@
|
||||
}
|
||||
}
|
||||
stage();
|
||||
drawSide();
|
||||
drawSpawn();
|
||||
drawHierarchy(force);
|
||||
drawToolbar();
|
||||
@@ -87,6 +83,7 @@
|
||||
drawTasks();
|
||||
drawStatus();
|
||||
tick();
|
||||
placePopup();
|
||||
}
|
||||
|
||||
function drawStatus() {
|
||||
|
||||
@@ -8,12 +8,7 @@
|
||||
return;
|
||||
}
|
||||
graphDom.stage.replaceChildren({orbit, chord, swimlane}[ui.views.agents]());
|
||||
}
|
||||
|
||||
function drawSide() {
|
||||
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree" && ui.views.agents !== "hierarchy") {
|
||||
graphDom.aside.replaceChildren(...side());
|
||||
}
|
||||
placePopup();
|
||||
}
|
||||
|
||||
function seenView(kind) {
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
function spawnItem(entry) {
|
||||
const running = entry.running === null ? null : entry.running ? chip("bi-play-circle", "running", "ok") : chip("bi-check-circle", "finished", "neutral");
|
||||
const open = entry.kind === "twig" ? () => focusSub(entry.session, entry.row.id) : entry.kind === "operator" ? null : () => select(entry.id);
|
||||
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Show transcript in the stream" : `Open ${entry.label}`, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
|
||||
const name = open ? h("button", {type: "button", class: "linkish", title: entry.kind === "twig" ? "Show transcript in the stream" : `Open ${entry.label}`, "data-pick": entry.kind === "twig" ? null : entry.id, on: {click: open}}, entry.label) : h("strong", {}, entry.label);
|
||||
const meta = entry.kind === "twig" ? h("span", {class: "muted"}, `Depth ${entry.row.depth} · `, when(entry.row.started)) : h("span", {class: "muted"}, NODE_KINDS[entry.kind]);
|
||||
return h("li", {"data-kind": entry.kind, "data-running": entry.running === null ? null : String(entry.running)},
|
||||
h("div", {class: "spawn-row"}, h("span", {class: "state", role: "img", ...named(NODE_KINDS[entry.kind] || "Subagent")}, icon(SPAWN_KINDS[entry.kind])), name, running, meta),
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
|
||||
let state = JSON.parse(document.getElementById("state").textContent);
|
||||
const KINDS = Object.entries(state.kinds);
|
||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
|
||||
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
|
||||
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
|
||||
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
|
||||
@@ -48,7 +48,7 @@
|
||||
let userViews = validViews(stored(STORE.views, stored("kvb-hub.views", [])));
|
||||
let settings = state.settings;
|
||||
let settingsSig = JSON.stringify(settings);
|
||||
let drawerDom = null;
|
||||
let agentDom = null;
|
||||
let timeDom = null;
|
||||
let graphDom = null;
|
||||
let reviewDom = null;
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
filter.waiting = false;
|
||||
filter.session = session;
|
||||
filter.sub = sub;
|
||||
closeAgent();
|
||||
closePopup();
|
||||
if (ui.screen === "stream" && stream.dom) {
|
||||
refilter();
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
const Popup = (() => {
|
||||
"use strict";
|
||||
const GAP = 16;
|
||||
const REACH = 40;
|
||||
const MIN = 160;
|
||||
const clamp = (value, low, high) => Math.max(low, Math.min(value, high));
|
||||
const fixed = (value) => Math.round(value * 10) / 10;
|
||||
|
||||
function leader(anchor, box) {
|
||||
const cx = anchor.left + anchor.width / 2;
|
||||
const cy = anchor.top + anchor.height / 2;
|
||||
const x2 = clamp(cx, box.left, box.left + box.width);
|
||||
const y2 = clamp(cy, box.top, box.top + box.height);
|
||||
const dx = x2 - cx;
|
||||
const dy = y2 - cy;
|
||||
const share = Math.min(dx ? anchor.width / 2 / Math.abs(dx) : Infinity, dy ? anchor.height / 2 / Math.abs(dy) : Infinity);
|
||||
return share >= 1 ? null : {x1: fixed(cx + dx * share), y1: fixed(cy + dy * share), x2: fixed(x2), y2: fixed(y2)};
|
||||
}
|
||||
|
||||
function vertical(anchor, size, view, left) {
|
||||
const below = view.height - GAP - (anchor.top + anchor.height + REACH);
|
||||
const above = anchor.top - REACH - GAP;
|
||||
const down = below >= size.height || (above < size.height && below >= above);
|
||||
const room = down ? below : above;
|
||||
if (room < Math.min(size.height, MIN)) {
|
||||
return {left, top: clamp(anchor.top + anchor.height + REACH, GAP, view.height - size.height - GAP), height: null};
|
||||
}
|
||||
const height = Math.min(size.height, room);
|
||||
return {left, top: down ? anchor.top + anchor.height + REACH : anchor.top - REACH - height, height: height < size.height ? height : null};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param anchor - the picked node's viewport box {left, top, width, height}, or null for a popup without a node.
|
||||
* @param size - the popup's natural {width, height}.
|
||||
* @param view - the viewport's {width, height}.
|
||||
* @returns {left, top, height, line}: the popup's corner right or left of the node, else below or above it with
|
||||
* height capping it to the room there (null: no cap), else below and clamped into the viewport; line {x1, y1, x2,
|
||||
* y2} runs from the node's edge to the nearest point of the popup, null without a node or when the popup covers it.
|
||||
*/
|
||||
function place(anchor, size, view) {
|
||||
if (!anchor) {
|
||||
return {left: fixed(Math.max(GAP, (view.width - size.width) / 2)), top: fixed(Math.max(GAP, (view.height - size.height) / 2)), height: null, line: null};
|
||||
}
|
||||
const top = clamp(anchor.top + anchor.height / 2 - size.height / 2, GAP, view.height - size.height - GAP);
|
||||
const left = clamp(anchor.left + anchor.width / 2 - size.width / 2, GAP, view.width - size.width - GAP);
|
||||
const right = anchor.left + anchor.width + REACH;
|
||||
const before = anchor.left - REACH - size.width;
|
||||
const spot = right + size.width <= view.width - GAP ? {left: right, top, height: null} : before >= GAP ? {left: before, top, height: null} : vertical(anchor, size, view, left);
|
||||
const box = {left: spot.left, top: spot.top, width: size.width, height: spot.height === null ? size.height : spot.height};
|
||||
return {left: fixed(spot.left), top: fixed(spot.top), height: spot.height === null ? null : fixed(spot.height), line: leader(anchor, box)};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param nodes - {id, label} records.
|
||||
* @param query - the typed search text; blank finds nothing.
|
||||
* @param limit - the most hits returned.
|
||||
* @returns the first node of every distinct label containing the query, case-insensitive, labels starting with it
|
||||
* first, otherwise in node order.
|
||||
*/
|
||||
function hits(nodes, query, limit) {
|
||||
const words = String(query).trim().toLowerCase();
|
||||
if (!words) {
|
||||
return [];
|
||||
}
|
||||
const found = new Map();
|
||||
for (const node of nodes) {
|
||||
const at = String(node.label).toLowerCase().indexOf(words);
|
||||
if (at >= 0 && !found.has(node.label)) {
|
||||
found.set(node.label, {node, first: at === 0});
|
||||
}
|
||||
}
|
||||
return [...found.values()].sort((a, b) => Number(b.first) - Number(a.first)).slice(0, limit).map((entry) => entry.node);
|
||||
}
|
||||
|
||||
return {GAP, REACH, MIN, place, hits};
|
||||
})();
|
||||
if (typeof module === "object" && module.exports) {
|
||||
module.exports = Popup;
|
||||
}
|
||||
Reference in new issue
Block a user