Files
skills/orchestrator/templates/page-graph3d.js
T
Kevin Veen-BirkenbachandClaude Opus 5.5 d89fb616b8 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>
2026-11-08 21:18:55 +00:00

270 lines
11 KiB
JavaScript

const LAYOUTS = [
["none", "Force", "bi-magnet", "Forces arrange freely: linked nodes attract, everything repels"],
["time", "Time", "bi-clock", "Depth = time: earlier lies behind, later in front"],
["spawn", "Spawn depth", "bi-layers", "Depth = spawn depth: each level one generation"],
];
const KIND_COLORS = {operator: "accent", chair: "accent", session: "info", subagent: "violet", twig: "violet", repo: "warn", branch: "info", worktree: "teal", agent: "accent", artefact: "violet", task: "muted"};
const LINK_COLORS = {spawn: "violet", contains: "line-strong", merge: "grant", overlap: "alarm", direct: "direct", broadcast: "broadcast", alarm: "alarm", security: "security", grant: "grant"};
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: "", picked: null};
const layoutOf = () => space.views.graph.axis;
function loadScript(src) {
return new Promise((resolve, reject) => {
const tag = document.createElement("script");
tag.src = src;
tag.nonce = NONCE;
tag.async = true;
tag.addEventListener("load", resolve);
tag.addEventListener("error", () => reject(new Error(`${src} not loaded`)));
document.head.append(tag);
});
}
function forceGraph() {
if (!g3.lib) {
g3.lib = loadScript("/vendor/3d-force-graph.min.js").then(() => window.ForceGraph3D);
}
return g3.lib;
}
function threeModule() {
if (!g3.three) {
g3.three = import("/vendor/three.module.js").catch(() => null);
}
return g3.three;
}
function layoutTools(tab) {
const group = h("div", {class: "segmented", role: "group", "aria-label": "Layout"}, LAYOUTS.map(([id, label, name, hint]) => h("button", {
type: "button", class: "icon-only", "data-layout": id, ...named(label, `${label}: ${hint}`), "aria-pressed": String(layoutOf() === id), on: {click: () => chooseLayout(tab, id, group)},
}, 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 popup"),
nodeSearch(() => model3d(tab).nodes, (node) => pick3d(tab, node)));
}
function chooseLayout(tab, id, group) {
for (const button of group.querySelectorAll("button")) {
button.setAttribute("aria-pressed", String(button.dataset.layout === id));
}
if (id !== layoutOf()) {
setView("graph", {...space.views.graph, axis: id}, false);
g3.sig = "";
if (ui.screen === tab) {
stage3d(tab, g3.host);
}
}
}
function agentModel() {
const depthOf = {operator: 0, chair: 1, session: 2, subagent: 3};
const born = (node) => ((spawn.subs.get(node.id) || {}).data || {}).since;
const nodes = state.graph.nodes.map((node) => ({
id: node.id, kind: node.kind, label: node.label, depth: depthOf[node.kind], ts: born(node) || (node.timeline[0] || {}).since || state.since,
parent: node.kind === "operator" ? null : node.kind === "chair" ? HUMAN : node.kind === "session" ? chairKey() : node.parent || chairKey(),
}));
for (const node of state.graph.nodes.filter((entry) => entry.kind === "session" || entry.kind === "chair")) {
const walk = (twig, parent, depth) => {
nodes.push({id: twig.id, kind: "twig", label: twig.label, depth, ts: twig.row.since || twig.row.started, parent, session: twig.session, sub: twig.row.id});
twig.children.forEach((child) => walk(child, twig.id, depth + 1));
};
twigs(node.id).forEach((twig) => walk(twig, node.id, depthOf[node.kind] + 1));
}
const ids = new Set(nodes.map((node) => node.id));
const links = nodes.filter((node) => node.parent && ids.has(node.parent)).map((node) => ({source: node.parent, target: node.id, kind: "spawn"}));
for (const link of aggregate().filter((entry) => entry.target !== "*" && ids.has(entry.source) && ids.has(entry.target) && entry.source !== entry.target)) {
links.push({source: link.source, target: link.target, kind: tone(link.cls), count: link.count});
}
return {nodes: SpaceView.notBeforeParent(nodes, SPAWNED), links};
}
function model3d(tab) {
if (tab === "agents") {
return agentModel();
}
const built = tree.model || {nodes: [], links: []};
return {nodes: built.nodes.map((node) => ({id: node.id, kind: node.kind, label: node.label, depth: node.depth, ts: node.ts, parent: node.parent})), links: built.links.map((link) => ({...link}))};
}
function place(nodes, layout) {
const times = nodes.map((node) => node.ts).filter(Number.isFinite);
const [low, high] = [Math.min(...times), Math.max(...times)];
const span = high - low;
for (const node of nodes) {
if (layout === "spawn") {
node.fz = -node.depth * DEPTH_GAP;
} else if (layout === "time") {
node.fz = span > 0 && Number.isFinite(node.ts) ? ((node.ts - low) / span - 0.5) * TIME_DEPTH : 0;
} else {
delete node.fz;
}
}
}
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", "alarm"]);
g3.colors = Object.fromEntries([...roles].map((role) => [role, value(role)]));
return g3.colors;
}
async function drawPlanes(layout, nodes) {
const graph = g3.graph;
const scene = graph.scene();
for (const plane of g3.planes) {
scene.remove(plane);
}
g3.planes = [];
if (layout === "none") {
return;
}
const THREE = await threeModule();
if (!THREE || g3.graph !== graph) {
return;
}
const levels = [...new Set(nodes.map((node) => node.fz))].filter(Number.isFinite).sort((a, b) => a - b);
const shown = layout === "spawn" ? levels : [levels[0], levels[levels.length - 1]].filter(Number.isFinite);
for (const z of new Set(shown)) {
const grid = new THREE.GridHelper(520, 8, g3.colors["line-strong"], g3.colors["line-strong"]);
grid.rotation.x = Math.PI / 2;
grid.position.z = z;
grid.material.transparent = true;
grid.material.opacity = 0.18;
scene.add(grid);
g3.planes.push(grid);
}
}
function pick3d(tab, node) {
if (node.kind === "twig") {
focusSub(node.session, node.sub);
return;
}
(tab === "artifacts" ? artSelect : select)(node.id, anchor3d(node.id), () => mark3d(null));
mark3d(node.id);
}
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) {
return JSON.stringify([tab, layoutOf(), data.nodes.map((node) => [node.id, node.kind, node.depth, node.ts]), data.links.map((link) => [link.source, link.target, link.kind])]);
}
async function stage3d(tab, host) {
if (!host) {
return;
}
if (tab === "agents") {
loadSpawn();
}
const data = model3d(tab);
const sig = signature3d(tab, data);
if (g3.host === host && g3.sig === sig) {
return;
}
if (g3.host !== host) {
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 search above finds every node`}), h("div", {class: "space-note"}, "Loading 3D …"));
}
g3.sig = sig;
const box = host.querySelector(".graph3d");
let Graph = null;
try {
Graph = await forceGraph();
} catch (error) {
g3.failed = String(error.message || error);
}
if (g3.host !== host) {
return;
}
if (!Graph) {
host.replaceChildren(emptyState("3D not loaded", g3.failed || "3d-force-graph is missing."));
return;
}
if (!g3.graph) {
try {
g3.graph = Graph({controlType: "orbit"})(box);
} catch (error) {
host.replaceChildren(emptyState("3D unavailable", `This browser offers no WebGL: ${error.message || error}`));
g3.host = null;
return;
}
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) * (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);
if (calm.matches) {
g3.graph.cooldownTicks(0);
} else {
g3.graph.cooldownTime(4000);
}
}
const layout = layoutOf();
place(data.nodes, layout);
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));
window.requestAnimationFrame(() => g3.graph && g3.host === host && g3.graph.zoomToFit(0, 40));
}
function dispose3d() {
if (g3.graph) {
g3.graph.pauseAnimation();
if (typeof g3.graph._destructor === "function") {
g3.graph._destructor();
}
}
g3.graph = null;
g3.host = null;
g3.sig = "";
g3.planes = [];
}
function refresh3d() {
if (!g3.graph || !g3.host) {
return;
}
if (!g3.host.isConnected) {
dispose3d();
return;
}
colors3d();
const box = g3.host.querySelector(".graph3d");
g3.graph.width(box.clientWidth).height(box.clientHeight).nodeColor(g3.graph.nodeColor()).linkColor(g3.graph.linkColor());
}