world: solid team + project orbs, hide ROOT, per-topic repo landmarks
ci / gates (push) Successful in 7s
ci / rust (push) Failing after 13s
ci / frontend (push) Successful in 28s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Three fixes to the Live viz per user feedback:

1) Hide the ROOT sentinel — it was rendering at the origin as a red disc
   with no label. It's a physics anchor, not a real orb. Skipping it in
   nodes/edges/labels loops removes the mystery red circle.

2) Emit `repo:<topic_id>` project orbs for active research topics from
   the world SSE loop. Labeled with the topic title, tier "repo" gets its
   own soft sky-blue palette and a landmark radius (r=11 between company
   and team). Assigned agents get a low-weight (0.15) convergence touch
   so pawns cluster around their project's orb even at rest — no wait
   for a file op to see the affiliation.

3) Solid at rest, bloom on interaction. Two engine changes:
   - Structural + repo orbs now have a soft 0.08 glow at heat=0 (down
     from 0.22 ambient) so the disc reads as solid until agents heat it.
   - `world.touch` heat is now weight-scaled (`+w*0.6`) instead of a
     flat `+0.5` regardless of intent. Soft convergence stays soft;
     file ops still explode.

Click a `repo:` orb → the existing Commit F focus mode already treats
that prefix as a subtree root, so users drop straight into the
Gource-style repo detail view with only the files their agents are
touching.

Follow-ups queued: teardown of repo orbs when a topic reaches 'published'
(currently they persist until the SSE loop's status filter drops them,
which is correct); loops equivalent (loop:<id> landmark orbs).
This commit is contained in:
Omar Sobh
2026-07-09 13:22:27 -07:00
parent f88e8642d9
commit 708f45d09b
3 changed files with 101 additions and 21 deletions
+52
View File
@@ -61,6 +61,37 @@ async fn active_runs(pool: &PgPool, ws: WorkspaceId) -> Vec<(String, String)> {
.collect() .collect()
} }
/// Active research topics + their assigned agents. Each returned row is a
/// `(topic_id, title, agent_id)` — one row per (topic, agent) pair. Emitted
/// from the SSE loop as `repo:<topic_id>` project orbs so the World shows a
/// clickable, labeled landmark for every in-flight R&D initiative — no need
/// for a file touch to land first.
async fn active_research_topics(
pool: &PgPool,
ws: WorkspaceId,
) -> Vec<(String, String, String)> {
let rows = sqlx::query(
"SELECT t.id::text AS topic_id, t.title AS title, ra.agent_id::text AS agent_id
FROM research_topics t
JOIN research_topic_agents ra ON ra.topic_id = t.id
WHERE t.workspace_id = $1
AND t.status IN ('processing', 'reviewing', 'publishing')",
)
.bind(ws.as_uuid())
.fetch_all(pool)
.await
.unwrap_or_default();
rows.into_iter()
.map(|r| {
(
r.get::<String, _>("topic_id"),
r.get::<String, _>("title"),
r.get::<String, _>("agent_id"),
)
})
.collect()
}
/// A short human label for a tool's input (for the tool-call target). /// A short human label for a tool's input (for the tool-call target).
fn summarize_input(input: &Value) -> String { fn summarize_input(input: &Value) -> String {
for k in ["target", "path", "url", "query", "name", "file", "command"] { for k in ["target", "path", "url", "query", "name", "file", "command"] {
@@ -361,6 +392,27 @@ pub async fn world_live(State(state): State<AppState>, Authed(user): Authed) ->
} }
} }
// Active research topics → landmark project orbs. One `repo:<id>`
// per topic, labeled with the topic title so users can click it
// and drop into the repo-focus (Gource) view before any files are
// touched. Assigned agents gently converge on their topic's orb
// so the affinity is visible even in idle windows.
let research = active_research_topics(&pool, ws).await;
let mut seen_topics = std::collections::HashSet::new();
for (topic_id, title, agent_id) in &research {
let node_id = format!("repo:{topic_id}");
if seen_topics.insert(topic_id.clone()) {
yield sse(
"node.activity",
json!({ "nodeId": node_id, "label": title, "kind": "service", "heat": 0.0 }),
);
}
yield sse(
"world.touch",
json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service", "weight": 0.15 }),
);
}
// Real convergence: each running agent beams toward its active-run node. // Real convergence: each running agent beams toward its active-run node.
for (run_id, agent_id) in &runs { for (run_id, agent_id) in &runs {
let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]); let node_id = format!("run:{}", &run_id[..run_id.len().min(8)]);
+22 -9
View File
@@ -681,6 +681,9 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
// nodes (solid disc + additive glow sprite) // nodes (solid disc + additive glow sprite)
const liveNodeIds = new Set<string>(); const liveNodeIds = new Set<string>();
for (const n of engine.nodes.values()) { for (const n of engine.nodes.values()) {
// The physics-anchor ROOT sentinel is never a real orb — hiding it
// removes the mysterious unlabeled red disc at the origin.
if (n.tier === "root") continue;
if (visibleNodes && !visibleNodes.has(n.id)) continue; if (visibleNodes && !visibleNodes.has(n.id)) continue;
liveNodeIds.add(n.id); liveNodeIds.add(n.id);
let m = nodeMeshes.get(n.id); let m = nodeMeshes.get(n.id);
@@ -710,7 +713,12 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
g.scale.set(gs, gs, 1); g.scale.set(gs, gs, 1);
const gm = g.material as THREE.SpriteMaterial; const gm = g.material as THREE.SpriteMaterial;
gm.color.set(n.color); gm.color.set(n.color);
gm.opacity = (n.tier === "root" ? 0.5 : 0.22 + n.heat * 0.8) * n.alpha; // Structural + project landmarks: solid at rest (soft 0.08 halo)
// and bloom on heat. Services/events keep their permanent 0.22
// ambient glow since they *are* activity signals.
const struct =
n.tier === "org" || n.tier === "company" || n.tier === "team" || n.tier === "repo";
gm.opacity = (struct ? 0.08 + n.heat * 0.9 : 0.22 + n.heat * 0.8) * n.alpha;
if (effects && n.heat > 0.35 && Math.random() < n.heat * dt * 8) { if (effects && n.heat > 0.35 && Math.random() < n.heat * dt * 8) {
const a = Math.random() * Math.PI * 2; const a = Math.random() * Math.PI * 2;
const sp = 18 + Math.random() * 30; const sp = 18 + Math.random() * 30;
@@ -726,12 +734,10 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
} }
n.burst = 0; n.burst = 0;
} }
if (n.tier !== "root") { minX = Math.min(minX, n.x - r);
minX = Math.min(minX, n.x - r); minY = Math.min(minY, n.y - r);
minY = Math.min(minY, n.y - r); maxX = Math.max(maxX, n.x + r);
maxX = Math.max(maxX, n.x + r); maxY = Math.max(maxY, n.y + r);
maxY = Math.max(maxY, n.y + r);
}
} }
for (const [id, m] of nodeMeshes) { for (const [id, m] of nodeMeshes) {
if (!liveNodeIds.has(id)) { if (!liveNodeIds.has(id)) {
@@ -752,9 +758,10 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
const epos: number[] = []; const epos: number[] = [];
for (const n of engine.nodes.values()) { for (const n of engine.nodes.values()) {
if (!n.parentId) continue; if (!n.parentId) continue;
if (n.tier === "root") continue;
if (visibleNodes && !visibleNodes.has(n.id)) continue; if (visibleNodes && !visibleNodes.has(n.id)) continue;
const p = engine.nodes.get(n.parentId); const p = engine.nodes.get(n.parentId);
if (!p) continue; if (!p || p.tier === "root") continue; // no dangling edges into ROOT
if (visibleNodes && !visibleNodes.has(p.id)) continue; if (visibleNodes && !visibleNodes.has(p.id)) continue;
epos.push(n.x, n.y, 0, p.x, p.y, 0); epos.push(n.x, n.y, 0, p.x, p.y, 0);
} }
@@ -872,8 +879,14 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
// hidden nodes so they don't leak past the culling. // hidden nodes so they don't leak past the culling.
const wantLabels = new Map<string, { x: number; y: number; text: string; color: string; big: boolean }>(); const wantLabels = new Map<string, { x: number; y: number; text: string; color: string; big: boolean }>();
for (const n of engine.nodes.values()) { for (const n of engine.nodes.values()) {
if (n.tier === "root") continue;
if (visibleNodes && !visibleNodes.has(n.id)) continue; if (visibleNodes && !visibleNodes.has(n.id)) continue;
const struct = n.tier === "org" || n.tier === "company" || n.tier === "team"; // Structural + repo landmarks are ALWAYS labeled — the team's name
// and the project's title should read at rest, before any activity
// heats them up. Services/events (tool: file:) only label once they
// actually see traffic or are selected.
const struct =
n.tier === "org" || n.tier === "company" || n.tier === "team" || n.tier === "repo";
const hot = (n.tier === "service" || n.tier === "event") && (n.heat > 0.12 || n.id === selectedRef.current); const hot = (n.tier === "service" || n.tier === "event") && (n.heat > 0.12 || n.id === selectedRef.current);
if (struct || hot) if (struct || hot)
wantLabels.set(n.id, { x: n.x, y: n.y - n.r - 6, text: n.label, color: n.color, big: n.tier === "org" }); wantLabels.set(n.id, { x: n.x, y: n.y - n.r - 6, text: n.label, color: n.color, big: n.tier === "org" });
+27 -12
View File
@@ -9,7 +9,7 @@
import type { TaxonomyEvents } from "@/lib/live/taxonomy"; import type { TaxonomyEvents } from "@/lib/live/taxonomy";
export type Formation = "hierarchy" | "flat" | "live"; export type Formation = "hierarchy" | "flat" | "live";
export type Tier = "root" | "org" | "company" | "team" | "service" | "event"; export type Tier = "root" | "org" | "company" | "team" | "repo" | "service" | "event";
export interface GNode { export interface GNode {
id: string; id: string;
@@ -105,6 +105,7 @@ const LEVEL_COLOR: Record<string, string> = {
org: "#c98af0", org: "#c98af0",
company: "#8a9af0", company: "#8a9af0",
team: "#6fd0c0", team: "#6fd0c0",
repo: "#8ec5ff", // R&D projects — soft sky-blue, distinct landmark tone
service: "#5ec8d8", service: "#5ec8d8",
event: "#ff8a7a", event: "#ff8a7a",
root: "#ff6f61", root: "#ff6f61",
@@ -175,7 +176,7 @@ export class WorldEngine {
y: (parent?.y ?? 0) + Math.sin(ang) * rad * 0.4, y: (parent?.y ?? 0) + Math.sin(ang) * rad * 0.4,
vx: 0, vx: 0,
vy: 0, vy: 0,
r: tier === "org" ? 15 : tier === "company" ? 12 : tier === "team" ? 9 : 7, r: tier === "org" ? 15 : tier === "company" ? 12 : tier === "repo" ? 11 : tier === "team" ? 9 : 7,
color: LEVEL_COLOR[tier] ?? "#9a9aa2", color: LEVEL_COLOR[tier] ?? "#9a9aa2",
heat: 0, heat: 0,
burst: 0, burst: 0,
@@ -261,24 +262,30 @@ export class WorldEngine {
} }
label = segs[segs.length - 1] || path; label = segs[segs.length - 1] || path;
} }
const node = this.ensureNode( // Repo landmarks keep their `repo` tier so we can render them as solid
e.nodeId, // project orbs (see LEVEL_COLOR and the label rules in WorldCanvas).
e.kind === "event" ? "event" : "service", const tier: Tier = e.nodeId.startsWith("repo:")
label, ? "repo"
parentId, : e.kind === "event"
depth, ? "event"
); : "service";
const node = this.ensureNode(e.nodeId, tier, label, parentId, depth);
const p = this.ensurePawn(e.agentId); const p = this.ensurePawn(e.agentId);
p.targetId = e.nodeId; p.targetId = e.nodeId;
p.idle = 0; p.idle = 0;
p.retime = 1.2 + Math.random() * 1.6; p.retime = 1.2 + Math.random() * 1.6;
const w = e.weight ?? 0.5; const w = e.weight ?? 0.5;
node.heat = Math.min(1, node.heat + 0.5); // Heat is now weight-scaled: a soft convergence touch (w=0.15) barely
// lifts the orb; a file op (w=1.0) blooms it. Old behavior was a hard
// +0.5 per touch which turned every repeatedly-touched node into a
// permanent hot spot regardless of intent.
node.heat = Math.min(1, node.heat + w * 0.6);
node.burst = Math.max(node.burst, w); // file ops (weight 1) → explosive burst node.burst = Math.max(node.burst, w); // file ops (weight 1) → explosive burst
// activity type → signal colour: file=coral, tool=cyan, run=green // activity type → signal colour: file=coral, tool=cyan, run=green, repo=sky
if (e.nodeId.startsWith("tool:")) p.fireColor = w >= 0.9 ? "#ff6f61" : "#5ec8d8"; if (e.nodeId.startsWith("tool:")) p.fireColor = w >= 0.9 ? "#ff6f61" : "#5ec8d8";
else if (e.nodeId.startsWith("run:")) p.fireColor = "#5fd08a"; else if (e.nodeId.startsWith("run:")) p.fireColor = "#5fd08a";
else if (e.nodeId.startsWith("file:")) p.fireColor = "#ff8a7a"; else if (e.nodeId.startsWith("file:")) p.fireColor = "#ff8a7a";
else if (e.nodeId.startsWith("repo:")) p.fireColor = "#8ec5ff";
} }
/** A reasoning token from an agent — marks it active and tints its signals. */ /** A reasoning token from an agent — marks it active and tints its signals. */
@@ -320,7 +327,15 @@ export class WorldEngine {
} }
} }
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); // Preserve the `repo` tier when the SSE stream marks a landmark project
// orb — otherwise everything collapses back to service/event and the
// World loses the distinct color+radius treatment for R&D projects.
const tier: Tier = e.nodeId.startsWith("repo:")
? "repo"
: e.kind === "event"
? "event"
: "service";
const node = this.ensureNode(e.nodeId, tier, e.label ?? e.nodeId, ROOT, 1);
if (e.label) node.label = e.label; if (e.label) node.label = e.label;
if (e.heat != null) node.heat = Math.max(node.heat, e.heat); if (e.heat != null) node.heat = Math.max(node.heat, e.heat);
} }