world viz: comm beams + emergent team-shape overlay
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:
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user