world viz: comm beams + emergent team-shape overlay
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 35s
ci / rust (push) Successful in 2m41s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 3m59s

Two mechanics on top of the same signal (agent-to-agent events
that were already flowing over the SSE feed but had nowhere to
land visually):

1. Transient comm beams (option 1)
   - agent.message  → magenta   #ff5eae  ("A said something to B")
   - agent.delegate → coral     #ff8a7a  ("A handed a task to B")
   - room.message   → purple    #c98af0  fan-out from poster to
                                          every other participant
   Each event pushes a Beam { x1..y2, life: 1.4 } into the same
   pipeline world.touch already uses. Life decays at 2.2/sec so a
   burst is ~500ms of bright line + a particle spark at the target.
   Colors are distinct from world.touch (pawn's own color) so a
   crowded scene reads as tool convergence vs teammate chatter at
   a glance.

2. Emergent talk-shape overlay (option 2 without the topology JSON)
   - engine.commEdges: Map<"minId|maxId", CommEdge>. Each call to
     onAgentComm refreshes life to 1.0 (or creates the edge). step()
     decays life at 1/30 per second, so an edge stays visible for
     roughly 30s after the last message.
   - Renderer draws every commEdges entry as a dim cyan line
     between the pawns' current positions BEFORE the transient
     beams, so a bright pulse cleanly overrides the dim shape.
   - The shape isn't declared anywhere — it emerges from actual
     traffic. First few turns fill in a shape (hub_spoke, pipeline,
     mesh, whatever the team actually does); quiet periods let it
     fade so the viz doesn't get stuck showing stale wiring.

Engine changes
   - New interface CommEdge; new commEdges Map on the engine.
   - New onAgentComm(from, to, color) does both jobs — pushes the
     transient beam AND refreshes the persistent edge — so
     WorldCanvas only wires the subscription once per event type.
   - step() decays commEdges alongside beams (separate rate).
   - Uses min/max id as the edge key so A→B and B→A collapse to
     a single line (colors would fight otherwise), with lastSpeaker
     retained for future arrow-hint tinting.

WorldCanvas
   - Three new live.on() calls (agent.message, agent.delegate,
     room.message) route into engine.onAgentComm.
   - Beam render loop pre-appends comm edges before transient
     beams so the same LineSegments material handles both.
   - Dim brightness: 0.18 * edge.life, so a fresh edge starts at
     ~18% cyan and fades from there.

Not yet: option-2's "always-visible topology edges from the graph
JSON" (i.e. drawing the coordinator↔spoke lines before ANY comm
happens). Emergent-only means the shape appears once the team
actually talks — good enough for a running team, wrong for the
"static preview of an unstaffed team" case. That's a follow-up
once we plumb the topology.update event's edges array into
the seed.
This commit is contained in:
Omar Sobh
2026-07-09 11:56:07 -07:00
parent aa941b72a1
commit bce370978d
2 changed files with 83 additions and 0 deletions
@@ -163,6 +163,23 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
live.on("node.activity", (d) => e.onNodeActivity(d)), live.on("node.activity", (d) => e.onNodeActivity(d)),
live.on("agent.reasoning.delta", (d) => e.onReasoning(d.agentId)), live.on("agent.reasoning.delta", (d) => e.onReasoning(d.agentId)),
live.on("agent.memory", (d) => e.onMemory(d)), live.on("agent.memory", (d) => e.onMemory(d)),
// Comm beams — magenta for direct messages, coral for delegations.
// The lifecycle "recent-comm web" also builds from these calls.
live.on("agent.message", (d) => {
if (d.fromAgentId && d.toAgentId) e.onAgentComm(d.fromAgentId, d.toAgentId, "#ff5eae");
}),
live.on("agent.delegate", (d) => {
if (d.fromAgentId && d.toAgentId) e.onAgentComm(d.fromAgentId, d.toAgentId, "#ff8a7a");
}),
// Room chat: fan out from the poster to every other participant.
// Same edge palette so participants see themselves reciprocally
// connected in the emergent shape.
live.on("room.message", (d) => {
if (!d.fromAgentId) return;
for (const pid of d.participantIds ?? []) {
if (pid && pid !== d.fromAgentId) e.onAgentComm(d.fromAgentId, pid, "#c98af0");
}
}),
]; ];
return () => offs.forEach((off) => off()); return () => offs.forEach((off) => off());
}, [live, mode]); }, [live, mode]);
@@ -751,6 +768,23 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
// beams (additive) + a spark burst once per beam // beams (additive) + a spark burst once per beam
const bpos: number[] = []; const bpos: number[] = [];
const bcol: number[] = []; const bcol: number[] = [];
// Persistent comm edges — the "shape of the team" that emerges from
// recent agent-to-agent traffic. Rendered first so bright transient
// beams draw over them; alpha scales with edge.life so a quiet team
// fades to invisible over ~30s.
for (const edge of engine.commEdges.values()) {
const a = engine.pawns.get(edge.from);
const b = engine.pawns.get(edge.to);
if (!a || !b) continue;
// Dim palette for comm shape; brightness proportional to freshness.
// Slight cyan tint since the ephemeral bursts are magenta — the
// two read as "who talks to whom" (dim web) vs "who's talking
// right now" (bright pulse).
col.set("#5ec8d8");
const dim = 0.18 * edge.life;
bpos.push(a.x, a.y, 0.3, b.x, b.y, 0.3);
bcol.push(col.r * dim, col.g * dim, col.b * dim, col.r * dim, col.g * dim, col.b * dim);
}
for (const b of engine.beams) { for (const b of engine.beams) {
col.set(b.color); col.set(b.color);
const l = b.life; const l = b.life;
+49
View File
@@ -76,6 +76,23 @@ export interface Beam {
sparked?: boolean; // renderer fires a particle burst once per beam sparked?: boolean; // renderer fires a particle burst once per beam
} }
/** A pawn↔pawn communication edge. Every agent-to-agent event (delegate,
* direct message, room message) bumps the matching edge to life=1.0;
* step() decays them slowly so a team's recent talk-graph stays visible
* as a faint web between bursts. `dir` records who spoke last so the
* renderer can arrow-hint the freshness. */
export interface CommEdge {
from: string;
to: string;
/** 0..1 — freshness. Decays at DECAY per second. */
life: number;
/** The pawn id that spoke most recently — used for A→B vs B→A tinting. */
lastSpeaker: string;
}
/** How long a comm edge stays faintly visible after the last message
* before it fades to nothing (roughly this many seconds). */
const COMM_EDGE_DECAY_PER_SEC = 1 / 30;
export interface WorldSeed { export interface WorldSeed {
id: string; id: string;
level: string; // "org" | "company" | "team" | "claw" level: string; // "org" | "company" | "team" | "claw"
@@ -99,6 +116,9 @@ export class WorldEngine {
nodes = new Map<string, GNode>(); nodes = new Map<string, GNode>();
pawns = new Map<string, GPawn>(); pawns = new Map<string, GPawn>();
beams: Beam[] = []; beams: Beam[] = [];
/** Persistent (but decaying) map of who's talked to whom. Key = min+max
* pawn ids joined by "|" so A↔B is a single edge regardless of direction. */
commEdges = new Map<string, CommEdge>();
formation: Formation = "live"; formation: Formation = "live";
private homes = new Map<string, string>(); // agentId → teamId private homes = new Map<string, string>(); // agentId → teamId
private now = 0; private now = 0;
@@ -247,6 +267,29 @@ export class WorldEngine {
p.memoryScaleTarget = memoryScaleFromBytes(e.bytes); p.memoryScaleTarget = memoryScaleFromBytes(e.bytes);
} }
} }
/** Any agent-to-agent event — direct message, room mention, delegation —
* fires a transient beam AND refreshes the persistent comm edge so the
* team's recent talk-graph stays visible as a faint web between
* bursts. Called by WorldCanvas for agent.message, agent.delegate, and
* once per recipient of a room.message. */
onAgentComm(fromId: string, toId: string, color: string) {
if (!fromId || !toId || fromId === toId) return;
const a = this.ensurePawn(fromId);
const b = this.ensurePawn(toId);
// Transient burst — same Beam type as world.touch arrivals, but the
// color palette here (magenta / cyan) reads as comms, not tool use.
this.beams.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y, color, life: 1.4 });
// Refresh (or create) the persistent decaying edge.
const key = fromId < toId ? `${fromId}|${toId}` : `${toId}|${fromId}`;
const existing = this.commEdges.get(key);
if (existing) {
existing.life = 1;
existing.lastSpeaker = fromId;
} else {
this.commEdges.set(key, { from: fromId, to: toId, life: 1, lastSpeaker: fromId });
}
}
onNodeActivity(e: TaxonomyEvents["node.activity"]) { onNodeActivity(e: TaxonomyEvents["node.activity"]) {
const node = this.ensureNode(e.nodeId, e.kind === "event" ? "event" : "service", e.label ?? e.nodeId, ROOT, 1); const node = this.ensureNode(e.nodeId, e.kind === "event" ? "event" : "service", e.label ?? e.nodeId, ROOT, 1);
if (e.label) node.label = e.label; if (e.label) node.label = e.label;
@@ -326,6 +369,12 @@ export class WorldEngine {
this.beams[i].life -= dt * 2.2; this.beams[i].life -= dt * 2.2;
if (this.beams[i].life <= 0) this.beams.splice(i, 1); if (this.beams[i].life <= 0) this.beams.splice(i, 1);
} }
// Comm-edge fade — separate rate from beams so the persistent shape
// (dim web) outlasts the transient beams (bright pulses).
for (const [k, e] of this.commEdges) {
e.life -= dt * COMM_EDGE_DECAY_PER_SEC;
if (e.life <= 0) this.commEdges.delete(k);
}
} }
private stepPawns(dt: number) { private stepPawns(dt: number) {