world viz: repo focus mode — click a file/dir/run to enter the tree
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:
@@ -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") {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user