world: solid team + project orbs, hide ROOT, per-topic repo landmarks
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:
@@ -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)]);
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user