From bfb3173531e20f8c3f5d77543d20cd6fe2226745 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 24 May 2026 23:15:42 +0100 Subject: [PATCH] Replace swarm graph with cockpit UI --- docs/voidbot-swarm-web-interface.md | 2 +- scripts/render-voidbot-swarm-dashboard.mjs | 1106 +++----------------- state/evidence.jsonl | 1 + 3 files changed, 136 insertions(+), 973 deletions(-) diff --git a/docs/voidbot-swarm-web-interface.md b/docs/voidbot-swarm-web-interface.md index 9cefa6a..8e09611 100644 --- a/docs/voidbot-swarm-web-interface.md +++ b/docs/voidbot-swarm-web-interface.md @@ -37,7 +37,7 @@ Open the LAN URL printed by the command on the iPad. The server refreshes the sn The CTB strip is intentionally compact: avatar icons show upcoming Face turns in scheduler order. It occupies the long side of the viewport: top edge in landscape, left edge in portrait. A direct mention should appear as a shuffle in the mirrored snapshot once the heartbeat state records the pending obligation or queued turn. -The graph surface is the interface, not decoration. Face nodes represent scheduler participants. UI facts and controls are graph nodes too: the cadence node contains the slider, the manual-turn node contains the selector/button, status/count nodes show their live values, and the selected-Face node carries the currently focused participant facts. The SPA starts from a deterministic force-style layout, then runs a DOM-aware settle pass so spring-attached UI nodes stay near the viewport center while avoiding rendered graph content. +The main viewport is a direct cockpit: summary, controls, participant list, selected-Face detail, and recent mesh events. The screen should be readable first and clever never. For a one-shot render without serving: diff --git a/scripts/render-voidbot-swarm-dashboard.mjs b/scripts/render-voidbot-swarm-dashboard.mjs index 56d63c5..b732050 100644 --- a/scripts/render-voidbot-swarm-dashboard.mjs +++ b/scripts/render-voidbot-swarm-dashboard.mjs @@ -426,11 +426,10 @@ function renderHtml(snapshot) { :root { color-scheme: dark; --bg: #03070d; - --field: #07110f; - --panel: rgba(5, 13, 17, 0.78); - --panel-strong: rgba(8, 19, 24, 0.92); + --panel: rgba(6, 15, 19, 0.9); + --panel-soft: rgba(142, 223, 176, 0.07); --line: rgba(142, 223, 176, 0.18); - --line-strong: rgba(142, 223, 176, 0.38); + --line-strong: rgba(142, 223, 176, 0.4); --text: #effcf8; --muted: rgba(239, 252, 248, 0.66); --green: #8edfb0; @@ -447,17 +446,18 @@ function renderHtml(snapshot) { html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; } body { background: - radial-gradient(circle at 72% 12%, rgba(255, 174, 88, 0.16), transparent 24%), - radial-gradient(circle at 10% 18%, rgba(142, 223, 176, 0.16), transparent 28%), + radial-gradient(circle at 72% 12%, rgba(255, 174, 88, 0.14), transparent 24%), + radial-gradient(circle at 10% 18%, rgba(142, 223, 176, 0.14), transparent 28%), linear-gradient(180deg, #03070d, #07110f 52%, #091712); color: var(--text); font: 15px/1.45 var(--font); letter-spacing: 0; } + h1, h2, h3, p { margin: 0; } button, select, input { font: inherit; } button, select { - min-height: 38px; + min-height: 40px; border: 1px solid var(--line); border-radius: 8px; background: rgba(142, 223, 176, 0.09); @@ -468,7 +468,6 @@ function renderHtml(snapshot) { button:hover, select:hover { border-color: var(--amber); } select { min-width: 0; padding: 0 10px; } input[type="range"] { width: 100%; accent-color: var(--cyan); } - h1, h2, h3, p { margin: 0; } .shell { position: fixed; @@ -484,9 +483,8 @@ function renderHtml(snapshot) { gap: 8px; padding: 10px; border-color: var(--line); - background: rgba(3, 7, 13, 0.84); + background: rgba(3, 7, 13, 0.86); backdrop-filter: blur(18px); - z-index: 4; overflow: auto; scrollbar-width: thin; scrollbar-color: rgba(142, 223, 176, 0.58) rgba(3, 7, 13, 0.32); @@ -503,12 +501,11 @@ function renderHtml(snapshot) { padding: 7px 6px; border: 1px solid var(--line); border-radius: 8px; - background: rgba(8, 19, 24, 0.88); + background: rgba(8, 19, 24, 0.9); color: var(--text); text-align: center; - box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24); } - .turn-card.active-turn { border-color: var(--cyan); box-shadow: 0 0 24px rgba(105, 226, 239, 0.24); } + .turn-card.active-turn { border-color: var(--cyan); box-shadow: 0 0 24px rgba(105, 226, 239, 0.22); } .turn-card.mention-turn { border-color: var(--amber); } .turn-card strong, .turn-card span { width: 100%; @@ -535,95 +532,84 @@ function renderHtml(snapshot) { } .avatar img { width: 100%; height: 100%; object-fit: cover; display: block; } - .graph-stage { - position: relative; + .workspace { min-width: 0; min-height: 0; - overflow: hidden; - } - - .graph-svg { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - pointer-events: none; - } - .edge { stroke: rgba(142, 223, 176, 0.28); stroke-width: 1.5; } - .edge.hot { stroke: rgba(255, 174, 88, 0.52); stroke-width: 2.4; } - .hub-core { fill: rgba(142, 223, 176, 0.16); stroke: rgba(142, 223, 176, 0.56); stroke-width: 2; } - - .graph-node-layer { - position: absolute; - inset: 0; - z-index: 2; - } - - .graph-node { - position: absolute; - display: grid; display: grid; gap: 12px; - min-width: 0; - width: var(--node-width, 190px); - min-height: var(--node-height, 96px); padding: 12px; + overflow: hidden; + grid-template-columns: minmax(280px, 0.88fr) minmax(360px, 1.4fr) minmax(280px, 0.92fr); + grid-template-rows: auto minmax(0, 1fr); + grid-template-areas: + "header header controls" + "queue detail events"; + } + + .pane { + min-width: 0; + min-height: 0; + display: grid; + gap: 12px; + padding: 14px; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); - box-shadow: 0 24px 80px rgba(0, 0, 0, 0.34); - backdrop-filter: blur(18px); - opacity: 0.9; - transform: translate(-50%, -50%); - transition: opacity 0.16s ease, transform 0.16s ease, border-color 0.16s ease; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28); + overflow: hidden; } - .graph-node.spring-node { z-index: 3; } - .graph-node.graph-content-node { z-index: 2; } - .graph-node:hover, .graph-node:focus-within, .graph-node.selected { - opacity: 1; - transform: translate(-50%, calc(-50% - 2px)); - border-color: var(--line-strong); - } - .graph-node.hot { border-color: rgba(255, 174, 88, 0.58); } - .graph-node.face-node { --node-width: 110px; --node-height: 100px; justify-items: center; text-align: center; } - .graph-node.active-focus { box-shadow: 0 0 38px rgba(105, 226, 239, 0.28), 0 24px 80px rgba(0, 0, 0, 0.34); } - .graph-node.system-node { --node-width: 210px; --node-height: 112px; border-color: rgba(142, 223, 176, 0.46); background: rgba(5, 13, 17, 0.86); } - .graph-node.control-node { --node-width: 220px; } - .graph-node.detail-node { --node-width: 238px; } - .graph-node.event-node { --node-width: 238px; } - .graph-node.metric-node { --node-width: 130px; --node-height: 78px; align-content: space-between; } - .graph-node.metric-node strong { font-size: 1.25rem; line-height: 1; } - .node-title { display: grid; min-width: 0; gap: 2px; } - .node-title h2, .node-title h3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .face-node .avatar { width: 44px; height: 44px; } - .face-node strong, .face-node span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .face-node strong { font-size: 0.92rem; line-height: 1.05; } - .face-node span { color: var(--muted); font: 0.72rem/1.1 var(--mono); } + .pane-scroll { overflow: auto; } + .header-pane { grid-area: header; align-content: center; } + .controls-pane { grid-area: controls; align-content: center; } + .queue-pane { grid-area: queue; } + .detail-pane { grid-area: detail; } + .events-pane { grid-area: events; } - .system-node h1 { font-size: clamp(1.3rem, 3vw, 2rem); line-height: 0.95; } - .system-node p.muted { max-height: 2.8em; overflow: hidden; } .kicker { color: var(--green); font-size: 0.74rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; } + h1 { font-size: clamp(1.8rem, 5vw, 4rem); line-height: 0.95; } + h2 { font-size: 1rem; text-transform: uppercase; } .muted { color: var(--muted); } .mono { font-family: var(--mono); } + .metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; } + .metric, .fact { + min-width: 0; + padding: 10px; + border: 1px solid rgba(142, 223, 176, 0.12); + border-radius: 8px; + background: var(--panel-soft); + } + .metric span, .fact span { display: block; color: var(--muted); font-size: 0.68rem; font-weight: 800; text-transform: uppercase; } + .metric strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 1.12rem; } + .fact strong { display: block; overflow-wrap: anywhere; font: 0.9rem/1.15 var(--mono); } + .control-grid { display: grid; gap: 10px; } .control-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; } .force-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; } - .details-head { display: grid; grid-template-columns: 54px minmax(0, 1fr); gap: 10px; align-items: center; } - .details-head h2 { overflow-wrap: anywhere; font-size: 1.22rem; } - .facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } - .fact { - min-width: 0; - padding: 6px; - border: 1px solid rgba(142, 223, 176, 0.12); + .queue-list, .event-list { display: grid; gap: 8px; overflow: auto; } + .agent-row { + display: grid; + grid-template-columns: 42px minmax(0, 1fr) auto; + gap: 9px; + align-items: center; + width: 100%; + min-height: 58px; + padding: 8px; + border: 1px solid var(--line); border-radius: 8px; background: rgba(142, 223, 176, 0.06); + color: var(--text); + text-align: left; } - .fact span { display: block; color: var(--muted); font-size: 0.68rem; font-weight: 800; text-transform: uppercase; } - .fact strong { display: block; overflow-wrap: anywhere; font: 0.82rem/1.1 var(--mono); } - .event-list { display: grid; gap: 6px; } - .event-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; color: var(--muted); font-size: 0.82rem; } + .agent-row.selected { border-color: var(--amber); } + .agent-row strong, .agent-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .agent-row span { color: var(--muted); font: 0.72rem/1.1 var(--mono); } + + .details-head { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 12px; align-items: center; } + .details-head .avatar { width: 58px; height: 58px; } + .facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } + .event-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 8px; border: 1px solid rgba(142, 223, 176, 0.1); border-radius: 8px; background: rgba(142, 223, 176, 0.05); color: var(--muted); font-size: 0.82rem; } .badge { display: inline-flex; @@ -652,7 +638,7 @@ function renderHtml(snapshot) { align-items: center; border-bottom: 1px solid var(--line); } - .graph-stage { grid-row: 2; grid-column: 1; } + .workspace { grid-row: 2; grid-column: 1; } .turn-card { width: 76px; height: 96px; } } @@ -666,17 +652,28 @@ function renderHtml(snapshot) { align-items: center; border-right: 1px solid var(--line); } - .graph-stage { grid-row: 1; grid-column: 2; } + .workspace { + grid-row: 1; + grid-column: 2; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr) minmax(0, 0.88fr); + grid-template-areas: + "header" + "controls" + "detail" + "queue"; + } + .events-pane { display: none; } + .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .facts { grid-template-columns: repeat(2, minmax(0, 1fr)); } .turn-card { width: 72px; height: 96px; } - .graph-node { --node-width: 168px; } - .graph-node.face-node { --node-width: 112px; --node-height: 108px; padding: 9px; } - .graph-node.system-node { --node-width: 230px; } - .graph-node.control-node, .graph-node.detail-node, .graph-node.event-node { --node-width: 220px; } } @media (max-width: 760px) { - .system-node p { display: none; } - .facts { grid-template-columns: 1fr 1fr; } + .workspace { padding: 10px; gap: 10px; } + .pane { padding: 12px; } + .header-pane .muted { display: none; } + .controls-pane { align-content: start; } } @@ -717,14 +714,17 @@ function renderHtml(snapshot) { app.innerHTML = [ "
", renderCtb(snapshot.upcomingTurns || []), - "
", - renderGraph(snapshot, participants, selected), + "
", + renderHeader(snapshot), + renderControls(snapshot, participants), + renderQueue(participants), + renderDetails(selected), + renderEvents(snapshot), "
", "
", ].join(""); attachControls(); - attachGraphSelection(); - window.requestAnimationFrame(settleGraphNodes); + attachSelection(); } function sortedParticipants(snapshot) { @@ -744,306 +744,56 @@ function renderHtml(snapshot) { }).join("") + ""; } - function renderGraph(snapshot, participants, selectedAgent) { - const nodes = graphNodes(snapshot, participants, selectedAgent); - const hub = nodes.find((node) => node.id === "system") || { x: 50, y: 50 }; - const edges = nodes.map((node) => { - if (node.kind === "system") return ""; - return ""; - }).join(""); - return "" + - "" + - edges + - "
" + nodes.map(renderGraphNode).join("") + "
"; - } - - function graphNodes(snapshot, participants, selectedAgent) { + function renderHeader(snapshot) { const summary = snapshot.summary || {}; + return "

VoidBot Swarm Control

CTB Cockpit

CultMesh mirror and scheduler command surface. State comes from the heartbeat snapshot.

" + + metric("State", badge(summary.state)) + + metric("Participants", number(summary.participantCount)) + + metric("Ready", number(summary.readyNowCount)) + + metric("Mentions", number(summary.pendingMentionCount)) + + metric("Shuffle", summary.lastShuffle ? summary.lastShuffle.kind.replace(/_/g, " ") : "none") + + "
"; + } + + function renderControls(snapshot, participants) { const controls = snapshot.controls || {}; - const latestRequest = (controls.manualTurnRequests || [])[0]; - const activeAgent = participants.find((agent) => agent.activeJobId) || selectedAgent || participants[0] || null; - const focus = focusPointForAgent(participants, activeAgent); - const nodes = [ - { - id: "system", - kind: "system", - spring: true, - anchorX: 50, - anchorY: 50, - selected: false, - hot: summary.state === "running" || summary.state === "paused", - html: "

VoidBot Swarm Control

CTB Mesh

CultMesh mirror. The graph is made of interface facts; each node carries its own affordance.

" + badge(summary.state) + " " + esc(relative(snapshot.generatedAt)) + "
", - }, - metricNode("participants", 34, 25, "Participants", number(summary.participantCount), "metric-node"), - metricNode("pending", 66, 25, "Pending Mentions", number(summary.pendingMentionCount), summary.pendingMentionCount > 0 ? "metric-node hot" : "metric-node"), - metricNode("ready", 34, 75, "Ready Now", number(summary.readyNowCount), summary.readyNowCount > 0 ? "metric-node hot" : "metric-node"), - metricNode("shuffle", 66, 75, "Last Shuffle", summary.lastShuffle ? summary.lastShuffle.kind.replace(/_/g, " ") : "none", summary.lastShuffle ? "metric-node hot" : "metric-node"), - { - id: "cadence-control", - kind: "control", - spring: true, - anchorX: 70, - anchorY: 45, - selected: false, - hot: controls.cadenceMultiplier !== 1, - html: "

Cadence

x" + esc(number(controls.cadenceMultiplier || 1)) + "
", - }, - { - id: "force-control", - kind: "control", - spring: true, - anchorX: 70, - anchorY: 55, - selected: false, - hot: Boolean(latestRequest && latestRequest.status !== "consumed"), - html: "

Manual Next Turn

" + esc(latestRequest?.identityId || "none") + " " + esc(latestRequest?.status || "") + "
", - }, - detailNode(selectedAgent), - eventNode(snapshot), - ]; - const count = Math.max(participants.length, 1); - for (const [index, agent] of participants.entries()) { - const angle = -Math.PI / 2 + (index / count) * Math.PI * 2; - const isFocus = agent.identityId === focus.identityId; - const heat = Math.max(0, Math.min(1, (agent.heat || 0) / 4)); - const radiusX = isFocus ? 6 : 20 + heat * 3; - const radiusY = isFocus ? 5 : 20 + heat * 2; - nodes.push({ - id: "face-" + agent.identityId, - kind: "face", - graphContent: true, - agent, - focus: isFocus, - selected: agent.identityId === selectedIdentity, - hot: Boolean(agent.activeJobId || agent.pendingMentionCount > 0), - x: Math.round((focus.x + Math.cos(angle) * radiusX) * 10) / 10, - y: Math.round((focus.y + Math.sin(angle) * radiusY) * 10) / 10, - width: 9, - height: 17, - html: faceNodeHtml(agent), - }); - } - return forceLayout(nodes, focus); + const latest = (controls.manualTurnRequests || [])[0]; + return "

Controls

" + + "
Heartbeat cadence
x" + esc(number(controls.cadenceMultiplier || 1)) + "
" + + "
Manual next turn
" + + "
Latest request: " + esc(latest?.identityId || "none") + " " + esc(latest?.status || "") + "
" + + "
"; } - function metricNode(id, x, y, label, value, klass) { - return { - id, - kind: klass || "metric-node", - spring: true, - anchorX: x, - anchorY: y, - selected: false, - hot: String(klass || "").includes("hot"), - html: "

" + esc(label) + "

" + esc(value) + "", - }; + function renderQueue(participants) { + return "

Participants

" + participants.map((agent) => { + const klass = "agent-row" + (agent.identityId === selectedIdentity ? " selected" : ""); + return ""; + }).join("") + "
"; } - function focusPointForAgent(participants, activeAgent) { - const index = Math.max(0, participants.findIndex((agent) => agent.identityId === activeAgent?.identityId)); - const count = Math.max(participants.length, 1); - const angle = -Math.PI / 2 + (index / count) * Math.PI * 2; - return { - identityId: activeAgent?.identityId || null, - x: Math.round((50 + Math.cos(angle) * 24) * 10) / 10, - y: Math.round((50 + Math.sin(angle) * 20) * 10) / 10, - }; + function renderDetails(agent) { + if (!agent) return "

Selected Face

No participant state.

"; + return "
" + avatarHtml(agent) + "

Selected Face

" + esc(agent.displayName) + "

" + esc(agent.identityId) + " / " + esc(agent.repoName) + "

" + badge(agent.activeJobId ? "running" : agent.status) + " " + (agent.pendingMentionCount > 0 ? badge("warning", "mention") : "") + "
" + + fact("Next", minutes(agent.nextTurnInMinutes)) + + fact("Load", number(agent.currentLoad)) + + fact("Speed", number(agent.effectiveSpeed)) + + fact("Heat", number(agent.heat)) + + fact("Queued", number(agent.queuedCount)) + + fact("Mentions", number(agent.pendingMentionCount)) + + fact("Channels", number(agent.channelCount)) + + fact("Last queued", relative(agent.lastQueuedAt)) + + fact("Job", agent.activeJobId || "none") + + "
"; } - function forceLayout(nodes, focus) { - const laidOut = nodes.map((node) => ({ - ...node, - x: node.x ?? node.anchorX ?? 50, - y: node.y ?? node.anchorY ?? 50, - vx: 0, - vy: 0, - width: node.width ?? widthForKind(node.kind), - height: node.height ?? heightForKind(node.kind), - })); - for (let tick = 0; tick < 160; tick += 1) { - for (const node of laidOut) { - if (node.spring) { - node.vx += ((node.anchorX ?? 50) - node.x) * 0.01; - node.vy += ((node.anchorY ?? 50) - node.y) * 0.01; - } else if (node.graphContent && node.focus) { - node.vx += (focus.x - node.x) * 0.035; - node.vy += (focus.y - node.y) * 0.035; - } else if (node.graphContent) { - node.vx += (focus.x - node.x) * 0.004; - node.vy += (focus.y - node.y) * 0.004; - } - } - for (let i = 0; i < laidOut.length; i += 1) { - for (let j = i + 1; j < laidOut.length; j += 1) { - repelIfOverlapping(laidOut[i], laidOut[j]); - } - } - for (const node of laidOut) { - node.vx *= 0.72; - node.vy *= 0.72; - node.x = clampNumber(node.x + node.vx, node.width / 2 + 1, 99 - node.width / 2); - node.y = clampNumber(node.y + node.vy, node.height / 2 + 1, 99 - node.height / 2); - } - } - return laidOut.map((node) => ({ - ...node, - x: Math.round(node.x * 10) / 10, - y: Math.round(node.y * 10) / 10, - })); + function renderEvents(snapshot) { + const events = (snapshot.recentEvents || []).slice(0, 18); + return "

Mesh Events

" + (events.length ? events.map((event) => "
" + esc(event.type) + " " + esc(event.identityId || "") + "" + esc(relative(event.observedAt)) + "
").join("") : "

No recent events.

") + "
"; } - function repelIfOverlapping(left, right) { - const dx = right.x - left.x || 0.1; - const dy = right.y - left.y || 0.1; - const minX = (left.width + right.width) / 2 + 2; - const minY = (left.height + right.height) / 2 + 2; - const overlapX = minX - Math.abs(dx); - const overlapY = minY - Math.abs(dy); - if (overlapX <= 0 || overlapY <= 0) return; - const force = Math.min(8, Math.min(overlapX, overlapY) * 0.28); - const length = Math.max(1, Math.hypot(dx, dy)); - const nx = dx / length; - const ny = dy / length; - const leftWeight = left.spring && right.graphContent ? 3.8 : 1.1; - const rightWeight = right.spring && left.graphContent ? 3.8 : 1.1; - left.vx -= nx * force * leftWeight; - left.vy -= ny * force * leftWeight; - right.vx += nx * force * rightWeight; - right.vy += ny * force * rightWeight; - } - - function widthForKind(kind) { - if (kind === "system") return 20; - if (kind === "control") return 18; - if (kind === "detail" || kind === "event") return 20; - if (String(kind).includes("metric")) return 11; - return 9; - } - - function heightForKind(kind) { - if (kind === "system") return 24; - if (kind === "control") return 22; - if (kind === "detail") return 32; - if (kind === "event") return 26; - if (String(kind).includes("metric")) return 15; - return 17; - } - - function clampNumber(value, min, max) { - return Math.max(min, Math.min(max, value)); - } - - function detailNode(agent) { - if (!agent) { - return { id: "details", kind: "detail", spring: true, anchorX: 28, anchorY: 60, selected: false, hot: false, html: "

Selected Face

No participant state.

" }; - } - return { - id: "details", - kind: "detail", - spring: true, - anchorX: 28, - anchorY: 60, - selected: true, - hot: Boolean(agent.activeJobId || agent.pendingMentionCount > 0), - html: "
" + avatarHtml(agent) + "

Selected Face

" + esc(agent.displayName) + "

" + esc(agent.identityId) + "

" + fact("Next", minutes(agent.nextTurnInMinutes)) + fact("Heat", number(agent.heat)) + fact("Speed", number(agent.effectiveSpeed)) + fact("Mentions", number(agent.pendingMentionCount)) + "
", - }; - } - - function eventNode(snapshot) { - const events = (snapshot.recentEvents || []).slice(0, 4); - return { - id: "events", - kind: "event", - spring: true, - anchorX: 28, - anchorY: 40, - selected: false, - hot: Boolean(snapshot.summary?.lastShuffle), - html: "

Mesh Events

" + (events.length ? events.map((event) => "
" + esc(event.type) + " " + esc(event.identityId || "") + "" + esc(relative(event.observedAt)) + "
").join("") : "

No recent events.

") + "
", - }; - } - - function faceNodeHtml(agent) { - return avatarHtml(agent) + "" + esc(agent.displayName) + "" + esc(minutes(agent.nextTurnInMinutes)) + "" + (agent.pendingMentionCount > 0 ? "mention" : agent.activeJobId ? "active" : ""); - } - - function renderGraphNode(node) { - const kindClass = node.kind.includes("metric-node") ? "metric-node" : node.kind + "-node"; - const className = [ - "graph-node", - kindClass, - node.spring ? "spring-node" : "graph-content-node", - node.focus ? "active-focus" : "", - node.hot ? "hot" : "", - node.selected ? "selected" : "", - ].filter(Boolean).join(" "); - const selectable = node.agent ? " data-select=\\"" + esc(node.agent.identityId) + "\\" role=\\"button\\" tabindex=\\"0\\"" : ""; - return "
" + node.html + "
"; - } - - function settleGraphNodes() { - const stage = document.querySelector(".graph-stage"); - if (!stage) return; - const stageRect = stage.getBoundingClientRect(); - const nodes = [...document.querySelectorAll(".graph-node")].map((element) => { - const rect = element.getBoundingClientRect(); - return { - element, - spring: element.dataset.spring === "true", - content: element.dataset.content === "true", - x: ((rect.left + rect.width / 2 - stageRect.left) / stageRect.width) * 100, - y: ((rect.top + rect.height / 2 - stageRect.top) / stageRect.height) * 100, - anchorX: Number(element.dataset.anchorX || "50"), - anchorY: Number(element.dataset.anchorY || "50"), - vx: 0, - vy: 0, - w: (rect.width / stageRect.width) * 100, - h: (rect.height / stageRect.height) * 100, - }; - }); - for (let tick = 0; tick < 140; tick += 1) { - for (const node of nodes) { - if (node.spring) { - node.vx += (node.anchorX - node.x) * 0.008; - node.vy += (node.anchorY - node.y) * 0.008; - } - } - for (let i = 0; i < nodes.length; i += 1) { - for (let j = i + 1; j < nodes.length; j += 1) { - repelDomNodes(nodes[i], nodes[j]); - } - } - for (const node of nodes) { - node.vx *= 0.7; - node.vy *= 0.7; - node.x = clampNumber(node.x + node.vx, node.w / 2 + 0.5, 99.5 - node.w / 2); - node.y = clampNumber(node.y + node.vy, node.h / 2 + 0.5, 99.5 - node.h / 2); - } - } - for (const node of nodes) { - node.element.style.left = node.x.toFixed(2) + "%"; - node.element.style.top = node.y.toFixed(2) + "%"; - } - } - - function repelDomNodes(left, right) { - const dx = right.x - left.x || 0.1; - const dy = right.y - left.y || 0.1; - const minX = (left.w + right.w) / 2 + 1.2; - const minY = (left.h + right.h) / 2 + 1.2; - const overlapX = minX - Math.abs(dx); - const overlapY = minY - Math.abs(dy); - if (overlapX <= 0 || overlapY <= 0) return; - const force = Math.min(6, Math.min(overlapX, overlapY) * 0.22); - const length = Math.max(1, Math.hypot(dx, dy)); - const nx = dx / length; - const ny = dy / length; - const springContent = (left.spring && right.content) || (right.spring && left.content); - const weight = springContent ? 3.5 : 1.2; - left.vx -= nx * force * weight; - left.vy -= ny * force * weight; - right.vx += nx * force * weight; - right.vy += ny * force * weight; + function metric(label, value) { + return "
" + esc(label) + "" + value + "
"; } function fact(label, value) { @@ -1056,19 +806,12 @@ function renderHtml(snapshot) { : "" + esc(initials(agent.displayName)) + ""; } - function attachGraphSelection() { + function attachSelection() { document.querySelectorAll("[data-select]").forEach((element) => { element.addEventListener("click", () => { selectedIdentity = element.getAttribute("data-select"); refresh(); }); - element.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - selectedIdentity = element.getAttribute("data-select"); - refresh(); - } - }); }); } @@ -1131,587 +874,6 @@ function renderHtml(snapshot) { `; } -function renderLegacyHtml(snapshot) { - return ` - - - - - VoidBot Swarm State - - - -
- - - - -`; -} - function redactSnapshot(snapshot) { const redacted = JSON.parse(JSON.stringify(snapshot)); redacted.visibility = "public_redacted"; diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 9544029..1f6d690 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -326,3 +326,4 @@ {"ts":"2026-05-24T21:27:15Z","type":"implementation","status":"ok","note":"Added read-only VoidBot swarm web exporter: renders .voidbot/status/swarm-dashboard.html and swarm-state.json from heartbeat/orchestrator status, writes a CultCache snapshot for CultMesh at .voidbot/status/cultmesh/voidbot-swarm-state.cc, and supports --public redaction for future voidbot.gamecult.org hosting."} {"ts":"2026-05-24T21:42:11Z","type":"implementation","status":"ok","note":"Swarm dashboard now acts as local CTB control surface: heartbeat state carries scheduler-owned controls for cadenceMultiplier and manualTurnRequests, the heartbeat runner consumes those controls on pulse, and the CultMesh snapshot mirrors compact avatar-based upcoming turn order plus direct-mention shuffle evidence."} {"ts":"2026-05-24T22:07:44Z","type":"implementation","status":"ok","note":"Swarm dashboard UI changed from stacked report to fullscreen single-viewport graph SPA: CTB rail stays on the long viewport side, Face state and UI facts/controls are graph nodes, center-spring affordance nodes use a DOM-aware settle pass to avoid rendered Face graph content, and active/selected agent state pulls the graph focus."} +{"ts":"2026-05-24T22:14:01Z","type":"implementation","status":"ok","note":"Swarm dashboard graph view was cut after proving awkward in use. The served SPA is now a direct cockpit: long-edge CTB rail plus summary, controls, participant list, selected-Face details, and recent mesh events, with scheduler/CultMesh ownership unchanged."}