feat(viz): the World shows one mission, not every mission at once
The visualization page carried the org -> company -> team -> agent forest in its sidebar — the hierarchy the agents page stopped rendering — so the two pages disagreed about the shape of the workspace, and there was no way to ask the World to show a single mission. Everything ran together in one clump. Same sidebar as the agents page now: My Workforce, missions under it, agents under those. Selecting a mission scopes the scene to that mission's crew. Scoping had to happen at the FEED, not the seed. `WorldEngine.ensurePawn` materialises a pawn for any agentId an event mentions, so seeding the engine with one crew would have left every other mission's agents streaming in anyway — the view would have looked filtered for a frame and then re-clumped. `focusAgents` gates every agent-bearing event, `focusMissionId` keeps other missions' landmark orbs out, and comm beams require BOTH ends in focus or a delegation would drag an outside agent onto the stage. The engine also re-seeds when the focus changes. It was seeded once on mount, which was right when the World only ever showed everything; now a stale engine would keep the previous mission's pawns on stage, and the feed filter cannot remove what is already there. Keyed on focusMissionId rather than on `roots` identity — `roots` is rebuilt every Dashboard render, so depending on it would throw the scene away continuously. The HUD says which mission is being shown when scoped. A filtered world and an idle world look identical otherwise, and that difference is the whole question a viewer is asking. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d3a398716b
commit
44079eb8b4
@@ -21,6 +21,7 @@ import { topologyById } from "@/lib/topologies";
|
|||||||
import { panelParsers, type DeviceSize } from "@/lib/url/panel-params";
|
import { panelParsers, type DeviceSize } from "@/lib/url/panel-params";
|
||||||
import { type FlowItem } from "./flow/TopologyFlow";
|
import { type FlowItem } from "./flow/TopologyFlow";
|
||||||
import { WorldCanvas } from "../world/WorldCanvas";
|
import { WorldCanvas } from "../world/WorldCanvas";
|
||||||
|
import type { WorldSeed } from "../world/engine";
|
||||||
import { ObservePanel } from "../observe/ObservePanel";
|
import { ObservePanel } from "../observe/ObservePanel";
|
||||||
import { StructureTree, orgNode, type TreeItem, clawNode } from "./StructureTree";
|
import { StructureTree, orgNode, type TreeItem, clawNode } from "./StructureTree";
|
||||||
import { MissionsList } from "./MissionsList";
|
import { MissionsList } from "./MissionsList";
|
||||||
@@ -471,13 +472,20 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
const onWorldSelect = (id: string) => {
|
const onWorldSelect = (id: string) => {
|
||||||
if (!id) { setWorldSel(null); return; }
|
if (!id) { setWorldSel(null); return; }
|
||||||
setWorldSel(id);
|
setWorldSel(id);
|
||||||
|
// A mission grouping row carries no level and is not a row in any of the
|
||||||
|
// org tables — it only sets the focus, which `focusedMissionId` derives
|
||||||
|
// from `worldSel`.
|
||||||
|
if (isGroupingRow(id)) return;
|
||||||
const lv = nodeLevel.get(id);
|
const lv = nodeLevel.get(id);
|
||||||
if (lv === "claw") {
|
if (lv === "claw") {
|
||||||
// Agents open a summary in the side panel (no redirect — the panel's robot
|
// Agents open a summary in the side panel (no redirect — the panel's robot
|
||||||
// button drills in). Keep the path ids coherent for the summary + drill.
|
// button drills in). Keep the path ids coherent for the summary + drill.
|
||||||
const loc = locateAgent(id);
|
// The tree id may be `<missionId>:<clawId>`; every consumer below wants
|
||||||
|
// the claw id alone.
|
||||||
|
const clawId = clawIdOf(id);
|
||||||
|
const loc = locateAgent(clawId);
|
||||||
if (loc) { setOrgId(loc.org.id); setCompanyId(loc.company.id); setTeamId(loc.team.id); }
|
if (loc) { setOrgId(loc.org.id); setCompanyId(loc.company.id); setTeamId(loc.team.id); }
|
||||||
setAgentId(id);
|
setAgentId(clawId);
|
||||||
setWorldPanelOpen(true);
|
setWorldPanelOpen(true);
|
||||||
} else if (lv === "org") selectOrg(id);
|
} else if (lv === "org") selectOrg(id);
|
||||||
else if (lv === "company") selectCompany(id);
|
else if (lv === "company") selectCompany(id);
|
||||||
@@ -500,8 +508,12 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
// is nothing else to do with it.
|
// is nothing else to do with it.
|
||||||
if (ORPHAN_CONTAINER_IDS.has(item.id)) { setOrphanDialogOpen(true); return; }
|
if (ORPHAN_CONTAINER_IDS.has(item.id)) { setOrphanDialogOpen(true); return; }
|
||||||
if (SYNTHETIC_TREE_IDS.has(item.id)) return;
|
if (SYNTHETIC_TREE_IDS.has(item.id)) return;
|
||||||
// A mission grouping row is a heading, like the workforce root: the row
|
// On the World tier a mission grouping row is the whole point: selecting it
|
||||||
// click has already toggled the branch. Falling through would hand a
|
// focuses the scene on that mission. `worldSel` is the single source of
|
||||||
|
// that focus, so it is set here rather than in a second state.
|
||||||
|
if (isWorld && isGroupingRow(item.id)) { onWorldSelect(item.id); return; }
|
||||||
|
// Everywhere else a grouping row is a heading, like the workforce root: the
|
||||||
|
// row click has already toggled the branch. Falling through would hand a
|
||||||
// MISSION uuid to selectTeam, which is a real-looking id for the wrong
|
// MISSION uuid to selectTeam, which is a real-looking id for the wrong
|
||||||
// table.
|
// table.
|
||||||
if (isGroupingRow(item.id)) return;
|
if (isGroupingRow(item.id)) return;
|
||||||
@@ -718,11 +730,55 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
meta: `${distinctAgentCount} agent${distinctAgentCount === 1 ? "" : "s"}`,
|
meta: `${distinctAgentCount} agent${distinctAgentCount === 1 ? "" : "s"}`,
|
||||||
children: useGrouped ? groupedChildren : allAgents.map(clawNode),
|
children: useGrouped ? groupedChildren : allAgents.map(clawNode),
|
||||||
};
|
};
|
||||||
const treeRoots: TreeItem[] = isWorld ? worldRoots : [workforceRoot];
|
// The World tier gets the SAME workforce sidebar as the agents page.
|
||||||
|
//
|
||||||
|
// It used to show the org → company → team → agent forest, which is the
|
||||||
|
// hierarchy the agents page stopped rendering — so the two pages disagreed
|
||||||
|
// about what the workspace looks like, and the World offered no way to ask
|
||||||
|
// "show me just this mission". Missions are the unit people think in, so the
|
||||||
|
// sidebar is missions here too, and picking one scopes the scene.
|
||||||
|
const treeRoots: TreeItem[] = [workforceRoot];
|
||||||
const treeActiveId = isClaw ? agentId : worldSel;
|
const treeActiveId = isClaw ? agentId : worldSel;
|
||||||
// Open by default. A workforce collapsed behind one disclosure is a workforce
|
// Open by default. A workforce collapsed behind one disclosure is a workforce
|
||||||
// the user has to discover they own.
|
// the user has to discover they own.
|
||||||
const treeAutoExpand: string[] = isWorld ? [] : ["my-workforce"];
|
const treeAutoExpand: string[] = ["my-workforce"];
|
||||||
|
|
||||||
|
// Which mission the World is focused on, derived from the tree selection so
|
||||||
|
// there is ONE selection state rather than a second one to keep in sync.
|
||||||
|
// Selecting a mission group focuses it; selecting an agent inside a group
|
||||||
|
// keeps that mission focused, which is what makes clicking around inside a
|
||||||
|
// mission feel stable.
|
||||||
|
const focusedMissionId: string | null = (() => {
|
||||||
|
if (!isWorld || !worldSel) return null;
|
||||||
|
if (worldSel.startsWith("mission-group:")) return worldSel.slice("mission-group:".length);
|
||||||
|
const grouped = (workforce?.missions ?? []).find((m) =>
|
||||||
|
m.agents.some((a) => `${m.mission_id}:${a.id}` === worldSel),
|
||||||
|
);
|
||||||
|
return grouped?.mission_id ?? null;
|
||||||
|
})();
|
||||||
|
const focusedMission = (workforce?.missions ?? []).find((m) => m.mission_id === focusedMissionId) ?? null;
|
||||||
|
const focusAgentIds: Set<string> | null = focusedMission
|
||||||
|
? new Set(focusedMission.agents.map((a) => a.id))
|
||||||
|
: null;
|
||||||
|
// Seed the scene with just this mission's crew when one is focused. The seed
|
||||||
|
// alone does not scope the view (the engine materialises any agent an event
|
||||||
|
// mentions) — WorldCanvas filters the feed — but it means the mission's own
|
||||||
|
// people are on stage immediately rather than fading in with their first
|
||||||
|
// event.
|
||||||
|
const worldSeedRoots: WorldSeed[] = focusedMission
|
||||||
|
? [{
|
||||||
|
id: `mission:${focusedMission.mission_id}`,
|
||||||
|
level: "team",
|
||||||
|
label: focusedMission.title?.trim() || "Untitled mission",
|
||||||
|
status: focusedMission.status,
|
||||||
|
children: focusedMission.agents.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
level: "claw",
|
||||||
|
label: a.name,
|
||||||
|
status: a.status,
|
||||||
|
})),
|
||||||
|
}]
|
||||||
|
: worldCanvasRoots;
|
||||||
|
|
||||||
// Every node in the active tree (flattened) — the wrench multi-select looks up
|
// Every node in the active tree (flattened) — the wrench multi-select looks up
|
||||||
// selected nodes here regardless of depth, and derives the delete kind from the
|
// selected nodes here regardless of depth, and derives the delete kind from the
|
||||||
@@ -1009,7 +1065,17 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
<>
|
<>
|
||||||
{/* Graph stage — condensed by the right slide-out's width. */}
|
{/* Graph stage — condensed by the right slide-out's width. */}
|
||||||
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: "var(--world-width, 0px)", background: "radial-gradient(120% 90% at 55% 38%, #0e0e13 0%, #08080a 70%)", transition: "right var(--duration-normal) var(--ease-app)" }}>
|
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: "var(--world-width, 0px)", background: "radial-gradient(120% 90% at 55% 38%, #0e0e13 0%, #08080a 70%)", transition: "right var(--duration-normal) var(--ease-app)" }}>
|
||||||
<WorldCanvas roots={worldCanvasRoots} expanded={expanded} onToggleExpand={toggleExpand} selectedId={worldSel} onSelect={onWorldSelect} onOpenRuns={() => setRunsOpen(true)} />
|
<WorldCanvas
|
||||||
|
roots={worldSeedRoots}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggleExpand={toggleExpand}
|
||||||
|
selectedId={worldSel}
|
||||||
|
onSelect={onWorldSelect}
|
||||||
|
onOpenRuns={() => setRunsOpen(true)}
|
||||||
|
focusAgents={focusAgentIds}
|
||||||
|
focusMissionId={focusedMissionId}
|
||||||
|
focusLabel={focusedMission?.title ?? null}
|
||||||
|
/>
|
||||||
{/* Open the slide-out (top-right) when it's closed. */}
|
{/* Open the slide-out (top-right) when it's closed. */}
|
||||||
{!worldPanelOpen ? (
|
{!worldPanelOpen ? (
|
||||||
<button type="button" aria-label="Open panel" title="Panel" onClick={() => setWorldPanelOpen(true)} style={{ position: "absolute", top: 14, right: 16, zIndex: 50, width: 38, height: 38, borderRadius: "50%", border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff6f61", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><PanelRight aria-hidden size={19} /></button>
|
<button type="button" aria-label="Open panel" title="Panel" onClick={() => setWorldPanelOpen(true)} style={{ position: "absolute", top: 14, right: 16, zIndex: 50, width: 38, height: 38, borderRadius: "50%", border: "1px solid rgba(255,111,97,.4)", background: "rgba(255,111,97,.08)", color: "#ff6f61", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><PanelRight aria-hidden size={19} /></button>
|
||||||
@@ -1026,8 +1092,12 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
<button type="button" aria-label="Close panel" onClick={() => setWorldPanelOpen(false)} style={{ width: 30, height: 30, flex: "none", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#cfcfd5", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><X aria-hidden size={16} /></button>
|
<button type="button" aria-label="Close panel" onClick={() => setWorldPanelOpen(false)} style={{ width: 30, height: 30, flex: "none", borderRadius: 8, border: "1px solid rgba(255,255,255,.12)", background: "transparent", color: "#cfcfd5", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}><X aria-hidden size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const loc = locateAgent(worldSel);
|
// `worldSel` may be a mission-group id or a composite
|
||||||
if (!loc) return <ObservePanel focusAgent={worldSel} onClearFocus={() => setWorldSel(null)} />;
|
// `<missionId>:<clawId>`; the panel wants a claw id, and a
|
||||||
|
// grouping row has none.
|
||||||
|
const selClawId = worldSel && !isGroupingRow(worldSel) ? clawIdOf(worldSel) : null;
|
||||||
|
const loc = locateAgent(selClawId);
|
||||||
|
if (!loc) return <ObservePanel focusAgent={selClawId} onClearFocus={() => setWorldSel(null)} />;
|
||||||
const { org: o, company: co, team: t, agent: a } = loc;
|
const { org: o, company: co, team: t, agent: a } = loc;
|
||||||
const statusCol = a.status === "running" ? "#5ec8d8" : a.status === "online" ? "#5fd08a" : "#5a5a62";
|
const statusCol = a.status === "running" ? "#5ec8d8" : a.status === "online" ? "#5fd08a" : "#5a5a62";
|
||||||
const chip = (text: string, col = "#9a9aa2") => <span style={{ fontFamily: mono, fontSize: 10, color: col, padding: "3px 9px", borderRadius: 999, background: `${col}1a`, border: `1px solid ${col}33` }}>{text}</span>;
|
const chip = (text: string, col = "#9a9aa2") => <span style={{ fontFamily: mono, fontSize: 10, color: col, padding: "3px 9px", borderRadius: 999, background: `${col}1a`, border: `1px solid ${col}33` }}>{text}</span>;
|
||||||
|
|||||||
@@ -77,6 +77,15 @@ interface WorldCanvasProps {
|
|||||||
expanded?: Set<string>;
|
expanded?: Set<string>;
|
||||||
onToggleExpand?: (id: string) => void;
|
onToggleExpand?: (id: string) => void;
|
||||||
onOpenRuns?: () => void;
|
onOpenRuns?: () => void;
|
||||||
|
/// Agent ids to render. Empty or absent means the whole workspace, which is
|
||||||
|
/// what the World showed before missions could be focused.
|
||||||
|
focusAgents?: Set<string> | null;
|
||||||
|
/// The mission being focused, used to keep OTHER missions' landmark orbs out
|
||||||
|
/// of the scene. Separate from `focusAgents` because a mission node carries
|
||||||
|
/// no agent id.
|
||||||
|
focusMissionId?: string | null;
|
||||||
|
/// Shown in the HUD so a filtered view never looks like an empty world.
|
||||||
|
focusLabel?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORMATIONS: { id: Formation; label: string }[] = [
|
const FORMATIONS: { id: Formation; label: string }[] = [
|
||||||
@@ -98,7 +107,7 @@ const VIEW_LEGEND: Record<Formation, string> = {
|
|||||||
hierarchy: "agents are neurons in a mesh; pathways fire as they work",
|
hierarchy: "agents are neurons in a mesh; pathways fire as they work",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExpand, onOpenRuns }: WorldCanvasProps) {
|
export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExpand, onOpenRuns, focusAgents, focusMissionId, focusLabel }: WorldCanvasProps) {
|
||||||
const mountRef = useRef<HTMLDivElement>(null);
|
const mountRef = useRef<HTMLDivElement>(null);
|
||||||
const labelLayerRef = useRef<HTMLDivElement>(null);
|
const labelLayerRef = useRef<HTMLDivElement>(null);
|
||||||
const live = useClawmatesLive();
|
const live = useClawmatesLive();
|
||||||
@@ -132,13 +141,25 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
|
|||||||
speedRef.current = speedMult;
|
speedRef.current = speedMult;
|
||||||
});
|
});
|
||||||
|
|
||||||
// seed the engine once from the structure roots
|
// Seed the engine from the structure roots, and RE-seed when the focus
|
||||||
|
// changes.
|
||||||
|
//
|
||||||
|
// This used to run once on mount, which was right when the World only ever
|
||||||
|
// showed the whole workspace. Now that a mission can be focused, keeping the
|
||||||
|
// old engine would leave the previous mission's pawns on stage — the live
|
||||||
|
// filter stops NEW events for them but cannot remove what is already there.
|
||||||
|
// A fresh engine is the honest reset, and it is cheap: the scene rebuilds
|
||||||
|
// from the feed within a frame or two.
|
||||||
|
//
|
||||||
|
// Keyed on the focus rather than on `roots` identity: `roots` is rebuilt on
|
||||||
|
// every Dashboard render, so depending on it directly would throw the scene
|
||||||
|
// away continuously.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const engine = new WorldEngine();
|
const engine = new WorldEngine();
|
||||||
engine.seed(roots);
|
engine.seed(roots);
|
||||||
engineRef.current = engine;
|
engineRef.current = engine;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [focusMissionId]);
|
||||||
|
|
||||||
// poll the engine for the HUD counts (agents + currently working)
|
// poll the engine for the HUD counts (agents + currently working)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,33 +177,58 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const e = engineRef.current;
|
const e = engineRef.current;
|
||||||
if (!e || mode !== "live") return;
|
if (!e || mode !== "live") return;
|
||||||
|
// Scope the feed to one mission's crew.
|
||||||
|
//
|
||||||
|
// Seeding the engine with only that crew is NOT enough: `ensurePawn`
|
||||||
|
// creates a pawn for any agentId an event mentions, so every other
|
||||||
|
// mission's agents would materialise anyway and the view would be the
|
||||||
|
// same clump it was before. The filter has to sit here, between the feed
|
||||||
|
// and the engine.
|
||||||
|
//
|
||||||
|
// `focusAgents` empty/absent means the whole world, which is the previous
|
||||||
|
// behaviour and the default.
|
||||||
|
const inFocus = (agentId?: string | null) =>
|
||||||
|
!focusAgents || focusAgents.size === 0 || (!!agentId && focusAgents.has(agentId));
|
||||||
|
// A node is in focus when it is THIS mission's landmark, or when it is not
|
||||||
|
// a mission landmark at all (files, runs and tools are reached via a
|
||||||
|
// touch, whose agent has already been filtered).
|
||||||
|
const nodeInFocus = (nodeId?: string | null) => {
|
||||||
|
if (!focusMissionId || !nodeId) return true;
|
||||||
|
if (!nodeId.startsWith("mission:")) return true;
|
||||||
|
return nodeId === `mission:${focusMissionId}`;
|
||||||
|
};
|
||||||
const offs = [
|
const offs = [
|
||||||
live.on("topology.update", (d) => e.onTopology(d)),
|
live.on("topology.update", (d) => e.onTopology(d)),
|
||||||
live.on("agent.status", (d) => e.onStatus(d)),
|
live.on("agent.status", (d) => { if (inFocus(d.agentId)) e.onStatus(d); }),
|
||||||
live.on("world.touch", (d) => e.onTouch(d)),
|
live.on("world.touch", (d) => { if (inFocus(d.agentId) && nodeInFocus(d.nodeId)) e.onTouch(d); }),
|
||||||
live.on("node.activity", (d) => e.onNodeActivity(d)),
|
live.on("node.activity", (d) => { if (nodeInFocus(d.nodeId)) e.onNodeActivity(d); }),
|
||||||
live.on("agent.reasoning.delta", (d) => e.onReasoning(d.agentId)),
|
live.on("agent.reasoning.delta", (d) => { if (inFocus(d.agentId)) e.onReasoning(d.agentId); }),
|
||||||
live.on("agent.memory", (d) => e.onMemory(d)),
|
live.on("agent.memory", (d) => { if (inFocus(d.agentId)) e.onMemory(d); }),
|
||||||
// Comm beams — magenta for direct messages, coral for delegations.
|
// Comm beams — magenta for direct messages, coral for delegations.
|
||||||
// The lifecycle "recent-comm web" also builds from these calls.
|
// The lifecycle "recent-comm web" also builds from these calls.
|
||||||
|
// Both ends must be in focus, or a beam would drag an outside agent in.
|
||||||
live.on("agent.message", (d) => {
|
live.on("agent.message", (d) => {
|
||||||
if (d.fromAgentId && d.toAgentId) e.onAgentComm(d.fromAgentId, d.toAgentId, "#ff5eae");
|
if (d.fromAgentId && d.toAgentId && inFocus(d.fromAgentId) && inFocus(d.toAgentId)) {
|
||||||
|
e.onAgentComm(d.fromAgentId, d.toAgentId, "#ff5eae");
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
live.on("agent.delegate", (d) => {
|
live.on("agent.delegate", (d) => {
|
||||||
if (d.fromAgentId && d.toAgentId) e.onAgentComm(d.fromAgentId, d.toAgentId, "#ff8a7a");
|
if (d.fromAgentId && d.toAgentId && inFocus(d.fromAgentId) && inFocus(d.toAgentId)) {
|
||||||
|
e.onAgentComm(d.fromAgentId, d.toAgentId, "#ff8a7a");
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
// Room chat: fan out from the poster to every other participant.
|
// Room chat: fan out from the poster to every other participant.
|
||||||
// Same edge palette so participants see themselves reciprocally
|
// Same edge palette so participants see themselves reciprocally
|
||||||
// connected in the emergent shape.
|
// connected in the emergent shape.
|
||||||
live.on("room.message", (d) => {
|
live.on("room.message", (d) => {
|
||||||
if (!d.fromAgentId) return;
|
if (!d.fromAgentId || !inFocus(d.fromAgentId)) return;
|
||||||
for (const pid of d.participantIds ?? []) {
|
for (const pid of d.participantIds ?? []) {
|
||||||
if (pid && pid !== d.fromAgentId) e.onAgentComm(d.fromAgentId, pid, "#c98af0");
|
if (pid && pid !== d.fromAgentId && inFocus(pid)) e.onAgentComm(d.fromAgentId, pid, "#c98af0");
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
return () => offs.forEach((off) => off());
|
return () => offs.forEach((off) => off());
|
||||||
}, [live, mode]);
|
}, [live, mode, focusAgents, focusMissionId]);
|
||||||
|
|
||||||
// replay mode: fetch the run-history timeline and feed it back through the engine
|
// replay mode: fetch the run-history timeline and feed it back through the engine
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1156,6 +1202,18 @@ export function WorldCanvas({ roots, selectedId, onSelect, expanded, onToggleExp
|
|||||||
>
|
>
|
||||||
<div style={{ fontSize: 11, letterSpacing: ".08em", color: "#ff8a7a", fontWeight: 700 }}>{VIEW_TITLE[formation]}</div>
|
<div style={{ fontSize: 11, letterSpacing: ".08em", color: "#ff8a7a", fontWeight: 700 }}>{VIEW_TITLE[formation]}</div>
|
||||||
<div style={{ fontSize: 10, color: "#8a8a92", marginTop: 3, lineHeight: 1.5 }}>{VIEW_LEGEND[formation]}</div>
|
<div style={{ fontSize: 10, color: "#8a8a92", marginTop: 3, lineHeight: 1.5 }}>{VIEW_LEGEND[formation]}</div>
|
||||||
|
{/* Say what is being shown when the scene is scoped. A filtered world
|
||||||
|
and a quiet world look identical, and the difference matters: one
|
||||||
|
is "this mission is idle", the other is "you are watching one
|
||||||
|
mission". */}
|
||||||
|
{focusMissionId ? (
|
||||||
|
<div style={{ fontSize: 10, color: "#6fd0c0", marginTop: 5, display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#6fd0c0", flex: "none" }} />
|
||||||
|
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 230 }}>
|
||||||
|
{focusLabel?.trim() || "this mission"} only
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div style={{ fontSize: 10.5, color: "#cfcfd5", marginTop: 6, display: "flex", gap: 10, flexWrap: "wrap" }}>
|
<div style={{ fontSize: 10.5, color: "#cfcfd5", marginTop: 6, display: "flex", gap: 10, flexWrap: "wrap" }}>
|
||||||
<span>
|
<span>
|
||||||
{hud.agents} agent{hud.agents === 1 ? "" : "s"}
|
{hud.agents} agent{hud.agents === 1 ? "" : "s"}
|
||||||
|
|||||||
Reference in New Issue
Block a user