world viz: repo focus mode — click a file/dir/run to enter the tree
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 41s
ci / rust (push) Successful in 4m1s
ci / e2e (push) Has been skipped
ci / publish (push) Successful in 12m18s

MVP of the repo-detail sub-view. Click any file/dir/run/repo node
in Live and the viz culls to just that node's subtree — you're
now watching agents crawl the repo instead of the whole workspace.
Esc (or the pill button that appears) exits.

Backend
- routes/world.rs: file-op tool events now emit a `file:<path>`
  world.touch IN ADDITION to the existing `tool:<name>` touch.
  The path is pulled from the tool input's path / target / file
  / filename / url keys (same lookup summarize_input uses, but we
  keep the full string so the client can build a real hierarchy).
  Non-file tools are unchanged — they still hit tool:<name> nodes
  as before.

Engine
- onTouch synthesizes a directory hierarchy when the id starts
  with `file:`. Each intermediate path segment gets a `dir:<acc>`
  node (label = the segment), parented at the previous dir; the
  file itself parents at the innermost dir. Ensures the layout
  spring-simulates as a tree naturally, no separate render mode
  needed.
- New pawn fireColor for file: touches: coral #ff8a7a. Reads as
  "file work" vs #5ec8d8 (tool convergence) vs #5fd08a (run
  activity).

WorldCanvas
- Render pass now takes a `visibleNodes: Set<string> | null`.
  When the selected id starts with file: / dir: / run: / repo:,
  we BFS descendants via parentId and hide every non-descendant
  node. Node meshes, glow sprites, hierarchy edges, and labels
  all gate on the set. Pawns stay visible (agents still dart to
  the focused files).
- ESC handler on window: clears the selection by calling
  onSelect("") when a repo-focus id is set.
- Small "REPO FOCUS · <path>" pill lands at top-center with an
  Esc button so the exit is discoverable at a glance without
  learning the shortcut.
- Dashboard.onWorldSelect now treats empty string as "clear
  focus" (setWorldSel(null)) so the same callback handles ESC.

Not yet: the always-on repo:<topic_id> node emitted at run
start when a research topic has a repo bound. Today the focus
works off run:<id> nodes; a repo:<id> anchor would let users
click without waiting for a first file touch. Also skipped:
per-file heat map / call-count visualization tied to touch
weight over time. Both are natural follow-ups on this bones.
This commit is contained in:
Omar Sobh
2026-07-09 12:16:05 -07:00
parent bce370978d
commit f88e8642d9
4 changed files with 168 additions and 4 deletions
+27
View File
@@ -114,6 +114,33 @@ fn normalize_run_event(
out.push(("node.activity", json!({ "nodeId": node_id, "label": tool, "kind": "service", "heat": if file_op { 1.0 } else { 0.9 } }))); out.push(("node.activity", json!({ "nodeId": node_id, "label": tool, "kind": "service", "heat": if file_op { 1.0 } else { 0.9 } })));
// the agent converges on the tool it's using (the Gource beam) // the agent converges on the tool it's using (the Gource beam)
out.push(("world.touch", json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service", "weight": weight }))); out.push(("world.touch", json!({ "agentId": agent_id, "nodeId": node_id, "kind": "service", "weight": weight })));
// File-op tools ALSO emit a `file:<path>` touch so the repo
// detail view can build the tree from real events. The path
// comes from the input's `path` / `target` / `file` / `url`
// keys — same lookup summarize_input does but we keep the
// full string so the client can build the dir hierarchy.
if file_op {
if let Some(input) = payload.get("input") {
for k in ["path", "target", "file", "filename", "url"] {
if let Some(p) = input.get(k).and_then(|v| v.as_str()) {
let cleaned = p.trim().trim_start_matches("./");
if !cleaned.is_empty() {
let file_node = format!("file:{cleaned}");
out.push((
"node.activity",
json!({ "nodeId": file_node, "label": cleaned, "kind": "service", "heat": 1.0 }),
));
out.push((
"world.touch",
json!({ "agentId": agent_id, "nodeId": file_node, "kind": "service", "weight": 1.0 }),
));
break;
}
}
}
}
}
} }
"approval_required" => { "approval_required" => {
let action = payload let action = payload
@@ -410,7 +410,10 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
if (path.length) setExpanded((prev) => new Set([...prev, ...path])); if (path.length) setExpanded((prev) => new Set([...prev, ...path]));
}; };
// Selecting a non-agent node in the graph: highlight + keep the path ids coherent. // Selecting a non-agent node in the graph: highlight + keep the path ids coherent.
// Empty string is treated as "clear the focus" so ESC in WorldCanvas can
// exit repo detail mode without needing a separate callback.
const onWorldSelect = (id: string) => { const onWorldSelect = (id: string) => {
if (!id) { setWorldSel(null); return; }
setWorldSel(id); setWorldSel(id);
const lv = nodeLevel.get(id); const lv = nodeLevel.get(id);
if (lv === "claw") { if (lv === "claw") {
+108 -3
View File
@@ -21,7 +21,7 @@ import { useClawmatesLive, useLiveState } from "@/lib/live/useClawmatesLive";
import { WorldFlow, type WorldItem } from "../dashboard/flow/WorldFlow"; import { WorldFlow, type WorldItem } from "../dashboard/flow/WorldFlow";
import { BRAIN, agentRegion } from "./brain"; import { BRAIN, agentRegion } from "./brain";
import { WorldEngine, type Formation, type WorldSeed } from "./engine"; import { WorldEngine, type Formation, type GNode, type WorldSeed } from "./engine";
type ReplayEvent = { t: number; type: string; data: Record<string, unknown> }; type ReplayEvent = { t: number; type: string; data: Record<string, unknown> };
type ReplayState = { type ReplayState = {
@@ -455,6 +455,22 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
renderer.domElement.addEventListener("pointerdown", onDown); renderer.domElement.addEventListener("pointerdown", onDown);
renderer.domElement.addEventListener("pointermove", onMove); renderer.domElement.addEventListener("pointermove", onMove);
renderer.domElement.addEventListener("pointerup", onUp); renderer.domElement.addEventListener("pointerup", onUp);
// ESC exits repo focus mode by clearing the selection. Dashboard's
// onWorldSelect treats empty string as "clear focus".
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
const sel = selectedRef.current;
if (!sel) return;
if (
sel.startsWith("file:") ||
sel.startsWith("dir:") ||
sel.startsWith("run:") ||
sel.startsWith("repo:")
) {
onSelectRef.current("");
}
};
window.addEventListener("keydown", onKey);
const updateParticles = (dt: number) => { const updateParticles = (dt: number) => {
for (let i = 0; i < MAXP; i++) { for (let i = 0; i < MAXP; i++) {
@@ -631,9 +647,41 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
let maxX = -Infinity; let maxX = -Infinity;
let maxY = -Infinity; let maxY = -Infinity;
// Focus mode: when the user has clicked a file/dir/run/repo node,
// hide every other node so the repo detail view frames just that
// subtree. Descendants are walked via parentId chain; the focus
// root itself + everything below stays visible. Any other selection
// (or none) → visibleNodes is null and the render pass shows all.
const focusRoot = selectedRef.current;
const isRepoFocus =
!!focusRoot &&
(focusRoot.startsWith("file:") ||
focusRoot.startsWith("dir:") ||
focusRoot.startsWith("run:") ||
focusRoot.startsWith("repo:"));
let visibleNodes: Set<string> | null = null;
if (isRepoFocus && focusRoot && engine.nodes.has(focusRoot)) {
visibleNodes = new Set<string>([focusRoot]);
// BFS down parentId — for each node, walk to root and keep only if
// the chain hits focusRoot. O(N * D) with D ~= depth.
for (const n of engine.nodes.values()) {
if (visibleNodes.has(n.id)) continue;
let cur: string | null = n.parentId;
while (cur) {
if (cur === focusRoot) {
visibleNodes.add(n.id);
break;
}
const parent: GNode | undefined = engine.nodes.get(cur);
cur = parent?.parentId ?? null;
}
}
}
// 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()) {
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);
if (!m) { if (!m) {
@@ -699,12 +747,15 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
} }
} }
// edges // edges — parent → child hierarchy. In focus mode only draw the
// subtree so the repo detail view reads as a clean file tree.
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 (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) 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);
} }
edgeGeom.setAttribute("position", new THREE.Float32BufferAttribute(epos, 3)); edgeGeom.setAttribute("position", new THREE.Float32BufferAttribute(epos, 3));
@@ -817,9 +868,11 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
} }
controls.update(); controls.update();
// labels (project to screen) // labels (project to screen). In repo focus mode we skip labels for
// 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 (visibleNodes && !visibleNodes.has(n.id)) continue;
const struct = n.tier === "org" || n.tier === "company" || n.tier === "team"; const struct = n.tier === "org" || n.tier === "company" || n.tier === "team";
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)
@@ -866,6 +919,7 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
renderer.domElement.removeEventListener("pointerdown", onDown); renderer.domElement.removeEventListener("pointerdown", onDown);
renderer.domElement.removeEventListener("pointermove", onMove); renderer.domElement.removeEventListener("pointermove", onMove);
renderer.domElement.removeEventListener("pointerup", onUp); renderer.domElement.removeEventListener("pointerup", onUp);
window.removeEventListener("keydown", onKey);
controls.dispose(); controls.dispose();
labels.forEach((s) => s.remove()); labels.forEach((s) => s.remove());
nodeMeshes.forEach((m) => (m.material as THREE.Material).dispose()); nodeMeshes.forEach((m) => (m.material as THREE.Material).dispose());
@@ -918,6 +972,57 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
<div style={{ position: "absolute", inset: 0 }}> <div style={{ position: "absolute", inset: 0 }}>
<div ref={mountRef} style={{ position: "absolute", inset: 0 }} /> <div ref={mountRef} style={{ position: "absolute", inset: 0 }} />
<div ref={labelLayerRef} style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }} /> <div ref={labelLayerRef} style={{ position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" }} />
{/* Repo focus banner — shows only when a file/dir/run/repo id is
selected. Small dismissable pill in the top-center so it doesn't
fight the formation strip or the panel toggle. */}
{selectedId &&
(selectedId.startsWith("file:") ||
selectedId.startsWith("dir:") ||
selectedId.startsWith("run:") ||
selectedId.startsWith("repo:")) ? (
<div
style={{
position: "absolute",
top: 14,
left: "50%",
transform: "translateX(-50%)",
zIndex: 20,
display: "inline-flex",
alignItems: "center",
gap: 10,
padding: "6px 12px",
borderRadius: 999,
background: "rgba(15,15,20,.85)",
border: "1px solid rgba(255,138,122,.35)",
color: "#f3f3f5",
fontSize: 12,
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
backdropFilter: "blur(6px)",
}}
>
<span style={{ color: "#ff8a7a", letterSpacing: ".08em" }}>REPO FOCUS</span>
<span style={{ color: "#cfcfd5", maxWidth: 340, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{selectedId.replace(/^(file|dir|repo|run):/, "")}
</span>
<button
type="button"
onClick={() => onSelect("")}
aria-label="Exit repo focus"
title="Exit focus (Esc)"
style={{
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
padding: "2px 8px",
borderRadius: 6,
fontSize: 11,
cursor: "pointer",
}}
>
Esc
</button>
</div>
) : null}
{/* Flat = the 2D React-Flow view (overlaid; covers the paused canvas) */} {/* Flat = the 2D React-Flow view (overlaid; covers the paused canvas) */}
{formation === "flat" ? ( {formation === "flat" ? (
<div <div
+30 -1
View File
@@ -239,7 +239,35 @@ export class WorldEngine {
this.ensurePawn(e.agentId, undefined, e.status, this.homes.get(e.agentId) ?? null); this.ensurePawn(e.agentId, undefined, e.status, this.homes.get(e.agentId) ?? null);
} }
onTouch(e: TaxonomyEvents["world.touch"]) { onTouch(e: TaxonomyEvents["world.touch"]) {
const node = this.ensureNode(e.nodeId, e.kind === "event" ? "event" : "service", e.nodeId, ROOT, 1); // file:<path> nodes get a directory hierarchy synthesized on demand
// so the repo detail view builds a real tree as agents crawl the
// repo. dir:<partial> nodes are the parents; the leaf file gets the
// final segment as its label so the layout stays legible when many
// files share a prefix. Non-file nodes fall through unchanged.
let parentId: string = ROOT;
let depth = 1;
let label = e.nodeId;
if (e.nodeId.startsWith("file:")) {
const path = e.nodeId.slice(5);
const segs = path.split("/").filter((s) => s.length > 0);
let acc = "";
for (let i = 0; i < segs.length - 1; i++) {
const seg = segs[i];
acc = acc ? `${acc}/${seg}` : seg;
const dirId = `dir:${acc}`;
this.ensureNode(dirId, "service", seg, parentId, i + 1);
parentId = dirId;
depth = i + 2;
}
label = segs[segs.length - 1] || path;
}
const node = this.ensureNode(
e.nodeId,
e.kind === "event" ? "event" : "service",
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;
@@ -250,6 +278,7 @@ export class WorldEngine {
// activity type → signal colour: file=coral, tool=cyan, run=green // activity type → signal colour: file=coral, tool=cyan, run=green
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";
} }
/** 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. */