@tangle-network/sandbox-ui 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,12 @@
1
1
  import { t as MOTION_CONTROL } from "./motion-Bn7ESHrj.js";
2
- import { d as quietActivity, f as syncAmbient, g as useRunNow, h as useMotionAllowed, i as fmtCost, m as useAttention, o as fmtEventTime, p as tokenMilliseconds, s as fmtTokenTotal, u as ambientStyle } from "./AgentGraph-Dd1EBlhu.js";
3
- import { t as LatestEvents } from "./latest-events-D1i5Ny3N.js";
4
- import { P as shortModel, m as edgeColor, o as RESTING_BORDER, r as NodeMark, s as RESTING_SHADOW, t as KIND_ACCENT } from "./node-ui-DWv_oLvy.js";
5
- import { i as loadElk, r as useColorMode, t as SELECTION_OUTLINE } from "./canvas-theme-DGd0fm1H.js";
2
+ import { d as quietActivity, f as syncAmbient, g as useRunNow, h as useMotionAllowed, i as fmtCost, m as useAttention, o as fmtEventTime, p as tokenMilliseconds, s as fmtTokenTotal, u as ambientStyle } from "./AgentGraph-YuBa5O8m.js";
3
+ import { t as LatestEvents } from "./latest-events-CWvZr8Ut.js";
4
+ import { P as shortModel, m as edgeColor, o as RESTING_BORDER, r as NodeMark, s as RESTING_SHADOW, t as KIND_ACCENT } from "./node-ui-Dst2R2M5.js";
5
+ import { i as loadElk, r as useColorMode, t as SELECTION_OUTLINE } from "./canvas-theme-aqWjGbfu.js";
6
6
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
8
- import { Users, Wrench } from "lucide-react";
7
+ import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
8
+ import { createPortal } from "react-dom";
9
+ import { ChevronDown, Wrench } from "lucide-react";
9
10
  import { RunStatusIcon, statusMeta } from "@tangle-network/ui/run";
10
11
  import { Background, BaseEdge, Controls, EdgeLabelRenderer, Handle, MarkerType, MiniMap, Position, ReactFlow, useInternalNode } from "@xyflow/react";
11
12
  import "@xyflow/react/dist/style.css";
@@ -17,12 +18,11 @@ const AGENT_CARD = {
17
18
  };
18
19
  /** A live card: room for the header, a two-line thinking line and the last three events. Fixed for the same reason. */
19
20
  const LIVE_AGENT_CARD = {
20
- width: 304,
21
+ width: 296,
21
22
  height: 164
22
23
  };
23
24
  const GRID_GAP = 10;
24
25
  const FRAME_PAD = 12;
25
- const MAX_GRID_COLUMNS = 6;
26
26
  const LAYER_GAP = 56;
27
27
  const SIBLING_GAP = 24;
28
28
  function buildAgentTopology(nodes, edges) {
@@ -71,27 +71,32 @@ function buildAgentTopology(nodes, edges) {
71
71
  groupOf
72
72
  };
73
73
  }
74
- function gridSize(members, columns, card) {
74
+ function gridSize(members, columns, card, collapsed = false) {
75
+ if (collapsed) return {
76
+ width: card.width + 24,
77
+ height: 80
78
+ };
75
79
  const cols = Math.max(1, Math.min(columns, members));
76
80
  const rows = Math.ceil(members / cols);
77
81
  return {
78
82
  width: cols * card.width + (cols - 1) * GRID_GAP + 24,
79
- height: 26 + rows * card.height + (rows - 1) * GRID_GAP + FRAME_PAD
83
+ height: 80 + rows * card.height + (rows - 1) * GRID_GAP + FRAME_PAD
80
84
  };
81
85
  }
82
86
  /** The boxes the layouter places: each ungrouped agent, and each grid as one block. */
83
- function layoutUnits(topology, columns, card) {
87
+ function layoutUnits(topology, columns, card, collapsed) {
84
88
  const units = [];
85
- for (const id of topology.agentIds) if (!topology.groupOf.has(id)) units.push({
89
+ const ownerGroup = new Map(topology.groups.map((group) => [group.parentId, group.id]));
90
+ for (const id of topology.agentIds) if (!topology.groupOf.has(id) && !ownerGroup.has(id)) units.push({
86
91
  id,
87
92
  width: card.width,
88
93
  height: card.height
89
94
  });
90
95
  for (const g of topology.groups) units.push({
91
96
  id: g.id,
92
- ...gridSize(g.memberIds.length, columns, card)
97
+ ...gridSize(g.memberIds.length, columns, card, collapsed.has(g.id))
93
98
  });
94
- const unitOf = (id) => topology.groupOf.get(id) ?? id;
99
+ const unitOf = (id) => topology.groupOf.get(id) ?? ownerGroup.get(id) ?? id;
95
100
  const seen = /* @__PURE__ */ new Set();
96
101
  const links = [];
97
102
  for (const s of topology.spawns) {
@@ -190,8 +195,8 @@ function placeByDepth(units, links, direction) {
190
195
  return at;
191
196
  }
192
197
  /** Lay the run out with every grid `columns` wide. */
193
- async function layoutAgents(topology, direction, columns, card = AGENT_CARD) {
194
- const { units, links } = layoutUnits(topology, columns, card);
198
+ async function layoutAgents(topology, direction, columns, card = AGENT_CARD, collapsed = /* @__PURE__ */ new Set()) {
199
+ const { units, links } = layoutUnits(topology, columns, card, collapsed);
195
200
  const placed = await placeWithElk(units, links, direction) ?? placeByDepth(units, links, direction);
196
201
  const positions = /* @__PURE__ */ new Map();
197
202
  const frames = [];
@@ -212,17 +217,24 @@ async function layoutAgents(topology, direction, columns, card = AGENT_CARD) {
212
217
  y: 0
213
218
  };
214
219
  positions.delete(g.id);
215
- const size = gridSize(g.memberIds.length, columns, card);
220
+ const closed = collapsed.has(g.id);
221
+ const size = gridSize(g.memberIds.length, columns, card, closed);
216
222
  frames.push({
217
223
  group: g,
218
224
  ...origin,
219
- ...size
225
+ ...size,
226
+ collapsed: closed
227
+ });
228
+ positions.set(g.parentId, {
229
+ x: origin.x + FRAME_PAD,
230
+ y: origin.y + 8
220
231
  });
232
+ if (closed) continue;
221
233
  const cols = Math.max(1, Math.min(columns, g.memberIds.length));
222
234
  g.memberIds.forEach((id, i) => {
223
235
  positions.set(id, {
224
236
  x: origin.x + FRAME_PAD + i % cols * (card.width + GRID_GAP),
225
- y: origin.y + 26 + Math.floor(i / cols) * (card.height + GRID_GAP)
237
+ y: origin.y + 80 + Math.floor(i / cols) * (card.height + GRID_GAP)
226
238
  });
227
239
  });
228
240
  }
@@ -234,34 +246,6 @@ async function layoutAgents(topology, direction, columns, card = AGENT_CARD) {
234
246
  height
235
247
  };
236
248
  }
237
- /** The grid widths worth trying for this run: one per column count a grid can use. */
238
- function candidateColumns(topology) {
239
- const largest = Math.max(0, ...topology.groups.map((g) => g.memberIds.length));
240
- const top = Math.min(MAX_GRID_COLUMNS, largest);
241
- return top <= 1 ? [1] : Array.from({ length: top }, (_, i) => i + 1);
242
- }
243
- /**
244
- * The layout that shows the run largest in this canvas: the highest zoom at
245
- * which it fits whole, capped at the cards' designed size. Layouts that all
246
- * reach that cap tie, and the tie goes to the one whose shape is closest to the
247
- * canvas's, so a wide canvas gets a wide grid and a phone a tall one.
248
- */
249
- function pickAgentLayout(layouts, canvas, padding) {
250
- let best = null;
251
- let bestZoom = -1;
252
- let bestShape = Number.POSITIVE_INFINITY;
253
- const aspect = canvas.width / Math.max(1, canvas.height);
254
- for (const l of layouts) {
255
- const zoom = Math.min(1, (canvas.width - 2 * padding) / Math.max(1, l.width), (canvas.height - 2 * padding) / Math.max(1, l.height));
256
- const shape = Math.abs(Math.log(l.width / Math.max(1, l.height) / aspect));
257
- if (zoom > bestZoom + .02 || Math.abs(zoom - bestZoom) <= .02 && shape < bestShape) {
258
- best = l;
259
- bestZoom = zoom;
260
- bestShape = shape;
261
- }
262
- }
263
- return best;
264
- }
265
249
  // src/workflows/agent-graph-parts.tsx
266
250
  /**
267
251
  * What `AgentGraph` draws: an agent's card, the frame around a grid of leaf
@@ -364,7 +348,7 @@ function AgentCardView({ agent, selected, direction, now: fixed, onOpen, parentL
364
348
  })
365
349
  }),
366
350
  /* @__PURE__ */ jsxs("span", {
367
- className: "min-w-0 flex-1 truncate text-[13px] font-semibold leading-[18px]",
351
+ className: "min-w-0 flex-1 truncate text-sm font-semibold leading-[18px]",
368
352
  children: [parentLabel && /* @__PURE__ */ jsxs("span", {
369
353
  className: "ag-parent-label",
370
354
  children: [parentLabel, " / "]
@@ -410,62 +394,28 @@ function AgentCard({ data }) {
410
394
  handles: true
411
395
  });
412
396
  }
413
- /** The statuses a grid caption counts: the ones a reader looks for. */
414
- const CAPTION_STATUSES = [
415
- "needs-you",
416
- "failed",
417
- "quiet",
418
- "waiting",
419
- "running"
420
- ];
421
397
  function AgentFrameBox({ data }) {
422
- const { frame, statuses, direction } = data;
423
- const counts = CAPTION_STATUSES.map((status) => ({
424
- status,
425
- n: status === "quiet" ? data.quietCount ?? 0 : statuses.filter((s) => s === status).length
426
- })).filter((c) => c.n > 0);
427
- const words = [`${frame.group.memberIds.length} agents`, ...counts.map((c) => `${c.n} ${c.status === "quiet" ? "quiet" : agentStatus(c.status).label.toLowerCase()}`)].join(", ");
398
+ const { frame, direction } = data;
428
399
  return /* @__PURE__ */ jsxs("div", {
429
400
  className: "relative h-full w-full rounded-2xl border",
430
401
  style: {
431
402
  background: "color-mix(in srgb, hsl(var(--muted-foreground)) 7%, transparent)",
432
403
  borderColor: "hsl(var(--foreground) / 0.08)"
433
404
  },
434
- children: [/* @__PURE__ */ jsx(Handles, {
435
- direction,
436
- source: false
437
- }), /* @__PURE__ */ jsxs("div", {
438
- className: "flex h-[26px] items-center gap-2.5 px-3 text-[11px] text-muted-foreground leading-4",
439
- children: [
440
- /* @__PURE__ */ jsx("span", {
441
- className: "sr-only",
442
- children: words
443
- }),
444
- /* @__PURE__ */ jsxs("span", {
445
- "aria-hidden": true,
446
- className: "flex items-center gap-1.5 font-medium",
447
- children: [
448
- /* @__PURE__ */ jsx(Users, {
449
- size: 12,
450
- strokeWidth: 2
451
- }),
452
- frame.group.memberIds.length,
453
- " agents"
454
- ]
455
- }),
456
- counts.map((c) => /* @__PURE__ */ jsxs("span", {
457
- "aria-hidden": true,
458
- className: "flex items-center gap-1 tabular-nums",
459
- children: [
460
- c.status !== "quiet" && /* @__PURE__ */ jsx(StatusGlyph, {
461
- status: c.status,
462
- size: 11
463
- }),
464
- c.n,
465
- c.status === "quiet" ? " quiet" : ""
466
- ]
467
- }, c.status))
468
- ]
405
+ children: [/* @__PURE__ */ jsx(Handles, { direction }), /* @__PURE__ */ jsxs("button", {
406
+ type: "button",
407
+ className: "nodrag nopan absolute right-3 top-2 flex h-16 w-8 flex-col items-center justify-center gap-1 rounded text-sm text-muted-foreground hover:bg-muted focus-visible:outline",
408
+ "aria-label": `${frame.collapsed ? "Expand" : "Collapse"} ${data.label} team`,
409
+ "aria-expanded": !frame.collapsed,
410
+ title: `${data.label} · ${frame.group.memberIds.length} agents`,
411
+ onClick: (event) => {
412
+ event.stopPropagation();
413
+ data.onToggle();
414
+ },
415
+ children: [/* @__PURE__ */ jsx(ChevronDown, {
416
+ size: 16,
417
+ className: frame.collapsed ? "-rotate-90" : void 0
418
+ }), /* @__PURE__ */ jsx("span", { children: frame.group.memberIds.length })]
469
419
  })]
470
420
  });
471
421
  }
@@ -574,48 +524,13 @@ function LiveAgentCardView({ agent, selected, direction = "TB", now: fixed, hand
574
524
  const quiet = quietActivity(agent, now);
575
525
  const s = agentStatus(agent.status);
576
526
  const meta = [agent.model ? shortModel(agent.model) : void 0, agent.effort].filter(Boolean).join(" · ");
577
- const [events, setEvents] = useState(() => (agent.events ?? []).slice(-4));
527
+ const events = (agent.events ?? []).slice(-3);
578
528
  const cost = Number.isFinite(agent.costUsd) ? fmtCost(agent.costUsd) : void 0;
579
529
  const metrics = [
580
530
  cost,
581
531
  !failed && agent.startedAt != null ? `${fmtEventTime(agent.startedAt, now, false)} elapsed` : void 0,
582
532
  Number.isFinite(agent.commits) ? `${agent.commits} ${agent.commits === 1 ? "commit" : "commits"}` : void 0
583
533
  ].filter(Boolean).join(" · ") || "—";
584
- const tail = useRef(null);
585
- const motion = useMotionAllowed();
586
- const previous = useRef(events.at(-1)?.id);
587
- const pending = useRef(void 0);
588
- const latest = useRef(agent.events);
589
- latest.current = agent.events;
590
- useEffect(() => {
591
- if (pending.current !== void 0) return;
592
- pending.current = setTimeout(() => {
593
- setEvents((latest.current ?? []).slice(-4));
594
- pending.current = void 0;
595
- }, tokenMilliseconds("--duration-ambient", ref.current) * .15);
596
- }, [agent.events]);
597
- useEffect(() => () => clearTimeout(pending.current), []);
598
- const tailAnimation = useRef(null);
599
- useEffect(() => () => tailAnimation.current?.cancel(), []);
600
- useLayoutEffect(() => {
601
- const last = events.at(-1)?.id;
602
- const old = previous.current;
603
- previous.current = last;
604
- const element = tail.current;
605
- if (!motion) {
606
- tailAnimation.current?.cancel();
607
- return;
608
- }
609
- if (!element || !old || old === last) return;
610
- const queued = events.findIndex((event) => event.id === old);
611
- const offset = new DOMMatrix(getComputedStyle(element).transform).m42 + 16;
612
- tailAnimation.current?.cancel();
613
- const frames = queued < 0 ? [{ opacity: 0 }, { opacity: 1 }] : [{ transform: `translateY(${offset}px)` }, { transform: "translateY(-16px)" }];
614
- tailAnimation.current = element.animate(frames, {
615
- duration: tokenMilliseconds("--duration-stream", element),
616
- easing: getComputedStyle(element).getPropertyValue("--ease-standard").trim()
617
- });
618
- }, [events, motion]);
619
534
  return /* @__PURE__ */ jsxs("div", {
620
535
  className: "ag-live-slot",
621
536
  children: [handles && /* @__PURE__ */ jsx(Handles, { direction }), /* @__PURE__ */ jsxs("div", {
@@ -722,7 +637,6 @@ function LiveAgentCardView({ agent, selected, direction = "TB", now: fixed, hand
722
637
  }), events.length > 0 && /* @__PURE__ */ jsx("div", {
723
638
  className: "ag-live-events-clip",
724
639
  children: /* @__PURE__ */ jsx("ol", {
725
- ref: tail,
726
640
  className: "ag-live-events",
727
641
  "aria-label": "Latest events",
728
642
  children: events.map((event) => /* @__PURE__ */ jsxs("li", {
@@ -765,7 +679,7 @@ const NODE_TYPES = {
765
679
  const EDGE_TYPES = { agentMessage: AgentMessageEdgeView };
766
680
  /** The fit's margin, in screen pixels. */
767
681
  const FIT_PADDING = 16;
768
- const MINIMAP_MIN_NODES = 40;
682
+ const PHONE_PAGE = 24;
769
683
  /** The canvas's size, once it has one. */
770
684
  function useCanvasSize() {
771
685
  const [size, setSize] = useState(null);
@@ -794,7 +708,7 @@ function revealSelection(inst, wrapper, id) {
794
708
  const width = node?.measured.width ?? node?.width, height = node?.measured.height ?? node?.height;
795
709
  if (!node || !box || !width || !height) return;
796
710
  const current = inst.getViewport(), position = node.internals.positionAbsolute;
797
- const zoom = Math.max(.02, Math.min(current.zoom, (box.width - 32) / width, (box.height - 32) / height));
711
+ const zoom = Math.max(.02, Math.min(1, (box.width - 32) / width, (box.height - 32) / height));
798
712
  const x = Math.max(FIT_PADDING - position.x * zoom, Math.min(box.width - FIT_PADDING - (position.x + width) * zoom, current.x));
799
713
  const y = Math.max(FIT_PADDING - position.y * zoom, Math.min(box.height - FIT_PADDING - (position.y + height) * zoom, current.y));
800
714
  if (x !== current.x || y !== current.y || zoom !== current.zoom) inst.setViewport({
@@ -807,7 +721,23 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
807
721
  const card = live ? LIVE_AGENT_CARD : AGENT_CARD;
808
722
  const clock = useRunNow(now);
809
723
  const animateMotion = useMotionAllowed();
724
+ const detailsId = useId();
725
+ const hoverTimer = useRef(void 0);
726
+ const hoverTarget = useRef(null);
727
+ const returningFocus = useRef(false);
728
+ const keepDetails = () => clearTimeout(hoverTimer.current);
729
+ const hideDetails = () => {
730
+ keepDetails();
731
+ setHovered(null);
732
+ };
733
+ const leaveDetails = () => {
734
+ keepDetails();
735
+ hoverTimer.current = setTimeout(() => setHovered(null), tokenMilliseconds("--duration-fast", wrapperRef.current));
736
+ };
737
+ useEffect(() => () => clearTimeout(hoverTimer.current), []);
810
738
  const [hovered, setHovered] = useState(null);
739
+ const [collapsed, setCollapsed] = useState(/* @__PURE__ */ new Set());
740
+ const [phonePage, setPhonePage] = useState(0);
811
741
  const wrapperRef = useRef(null);
812
742
  const [canvas, measureCanvas] = useCanvasSize();
813
743
  const colorMode = useColorMode(wrapperRef, nodes.length > 0);
@@ -816,37 +746,44 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
816
746
  const topologyRef = useRef(nextTopology);
817
747
  if (topologyRef.current.key !== nextTopology.key) topologyRef.current = nextTopology;
818
748
  const topology = topologyRef.current;
819
- const [layouts, setLayouts] = useState(null);
820
- const layoutKey = `${direction}:${live ? "live" : "cards"}:${topology.key}`;
749
+ const columnKey = `${direction}:${live}:${topology.key}`;
750
+ const columnRef = useRef(null);
751
+ if (canvas && !narrow && columnRef.current?.key !== columnKey) columnRef.current = {
752
+ key: columnKey,
753
+ columns: Math.max(1, Math.min(6, Math.floor((canvas.width - 32) / (card.width + 10))))
754
+ };
755
+ const columns = columnRef.current?.columns ?? 1;
756
+ const layoutKey = JSON.stringify([
757
+ direction,
758
+ live,
759
+ topology.key,
760
+ columns,
761
+ [...collapsed]
762
+ ]);
763
+ const [placed, setPlaced] = useState(null);
821
764
  useEffect(() => {
822
765
  if (narrow) return;
823
- let live = true;
824
- Promise.all(candidateColumns(topology).map((c) => layoutAgents(topology, direction, c, card))).then((all) => {
825
- if (live) setLayouts({
766
+ let active = true;
767
+ layoutAgents(topology, direction, columns, card, collapsed).then((layout) => {
768
+ if (active) setPlaced({
826
769
  key: layoutKey,
827
- all
770
+ layout
828
771
  });
829
772
  });
830
773
  return () => {
831
- live = false;
774
+ active = false;
832
775
  };
833
- }, [
834
- topology,
835
- direction,
836
- layoutKey,
837
- card,
838
- narrow
839
- ]);
840
- const priorLayout = useRef(null);
841
- const layout = useMemo(() => {
842
- if (!layouts || layouts.key !== layoutKey || !canvas) return null;
843
- return priorLayout.current = selectedId && priorLayout.current && layouts.all.includes(priorLayout.current) ? priorLayout.current : pickAgentLayout(layouts.all, canvas, FIT_PADDING);
844
- }, [
845
- layouts,
846
- layoutKey,
847
- canvas,
848
- selectedId
849
- ]);
776
+ }, [layoutKey, narrow]);
777
+ const layout = placed?.key === layoutKey ? placed.layout : null;
778
+ useEffect(() => {
779
+ const group = selectedId && topology.groupOf.get(selectedId);
780
+ if (group) setCollapsed((current) => {
781
+ if (!current.has(group)) return current;
782
+ const next = new Set(current);
783
+ next.delete(group);
784
+ return next;
785
+ });
786
+ }, [selectedId, topology.key]);
850
787
  const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
851
788
  const rfRef = useRef(null);
852
789
  const flowNodes = useMemo(() => {
@@ -868,8 +805,15 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
868
805
  data: {
869
806
  frame,
870
807
  direction,
871
- quietCount: frame.group.memberIds.filter((id) => byId.get(id) && quietActivity(byId.get(id), clock)).length,
872
- statuses: frame.group.memberIds.map((id) => byId.get(id)?.status ?? "unknown")
808
+ label: byId.get(frame.group.parentId)?.label ?? "Agent",
809
+ onToggle: () => {
810
+ if (!frame.collapsed && selectedId && frame.group.memberIds.includes(selectedId)) onSelect?.(frame.group.parentId);
811
+ setCollapsed((current) => {
812
+ const next = new Set(current);
813
+ next.has(frame.group.id) ? next.delete(frame.group.id) : next.add(frame.group.id);
814
+ return next;
815
+ });
816
+ }
873
817
  }
874
818
  }));
875
819
  const cards = [];
@@ -877,19 +821,24 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
877
821
  const agent = byId.get(id);
878
822
  const position = layout.positions.get(id);
879
823
  if (!agent || !position) continue;
880
- const selected = selectedId === id;
824
+ const selected = selectedId === id, owned = layout.frames.find((frame) => frame.group.parentId === id);
825
+ const width = owned ? owned.width - 64 : card.width, height = owned ? 64 : card.height;
826
+ const measured = rfRef.current?.getInternalNode(id)?.measured;
881
827
  cards.push({
882
828
  id,
883
829
  type: live ? "liveAgent" : "agent",
884
830
  position,
885
- width: card.width,
886
- height: card.height,
887
- measured: rfRef.current?.getInternalNode(id)?.measured,
888
- className: AGENT_NODE_CLASS,
831
+ width,
832
+ height,
833
+ measured: measured?.width === width && measured.height === height ? measured : void 0,
834
+ className: `${AGENT_NODE_CLASS}${owned ? " ag-frame-owner" : ""}`,
889
835
  draggable: false,
890
836
  ariaRole: "button",
891
837
  ariaLabel: agentSummary(agent),
892
- domAttributes: { "aria-pressed": selected },
838
+ domAttributes: {
839
+ "aria-pressed": selected,
840
+ "aria-describedby": hovered?.id === id ? detailsId : void 0
841
+ },
893
842
  data: {
894
843
  agent,
895
844
  selected,
@@ -911,20 +860,22 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
911
860
  card,
912
861
  clock,
913
862
  onSelect,
914
- renderEventIcon
863
+ renderEventIcon,
864
+ hovered
915
865
  ]);
916
866
  const flowEdges = useMemo(() => {
917
867
  if (!layout) return [];
918
868
  const out = [];
919
869
  const seen = /* @__PURE__ */ new Set();
920
870
  for (const s of topology.spawns) {
921
- const target = topology.groupOf.get(s.to) ?? s.to;
922
- const id = `spawn:${s.from}:${target}`;
871
+ const source = topology.groups.find((group) => group.parentId === s.from)?.id ?? s.from, target = topology.groupOf.get(s.to) ?? s.to;
872
+ if (source === target) continue;
873
+ const id = `spawn:${source}:${target}`;
923
874
  if (seen.has(id)) continue;
924
875
  seen.add(id);
925
876
  out.push({
926
877
  id,
927
- source: s.from,
878
+ source,
928
879
  target,
929
880
  type: "smoothstep",
930
881
  pathOptions: { borderRadius: 14 },
@@ -936,11 +887,11 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
936
887
  }
937
888
  });
938
889
  }
939
- const ids = new Set(topology.agentIds);
890
+ const ids = new Set(layout.positions.keys());
940
891
  const messages = /* @__PURE__ */ new Map();
941
892
  for (const e of edges) {
942
893
  if (e.kind === "spawn" || e.from === e.to || !ids.has(e.from) || !ids.has(e.to)) continue;
943
- if (![selectedId, hovered].some((id) => id === e.from || id === e.to)) continue;
894
+ if (![selectedId, hovered?.id].some((id) => id === e.from || id === e.to)) continue;
944
895
  const id = `${e.kind}:${e.from}:${e.to}`;
945
896
  const prior = messages.get(id);
946
897
  const count = (prior?.data?.count ?? 0) + Math.max(1, e.count ?? 1);
@@ -978,7 +929,6 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
978
929
  ]);
979
930
  const [ready, setReady] = useState(false);
980
931
  const [shown, setShown] = useState(false);
981
- const readerMoved = useRef(false);
982
932
  const selection = useRef(selectedId);
983
933
  selection.current = selectedId;
984
934
  const framed = useRef(null);
@@ -996,19 +946,22 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
996
946
  const inst = rfRef.current;
997
947
  if (!ready || !inst || !layoutIdentity || !canvas) return;
998
948
  const relayout = framed.current !== layoutIdentity;
999
- const preserveView = !relayout && readerMoved.current;
949
+ const preserveView = !relayout;
1000
950
  if (preserveView && !selection.current) return;
1001
951
  const animate = framed.current !== null && relayout && animateMotion;
1002
952
  framed.current = layoutIdentity;
1003
- readerMoved.current = preserveView;
1004
953
  const frame = requestAnimationFrame(() => {
1005
954
  if (preserveView) revealSelection(inst, wrapperRef.current, selection.current);
1006
- else inst.fitView({
1007
- padding: `${FIT_PADDING}px`,
1008
- maxZoom: 1,
1009
- minZoom,
1010
- duration: animate ? tokenMilliseconds("--duration-base", wrapperRef.current) : 0
1011
- });
955
+ else {
956
+ const id = selection.current ?? topology.agentIds[0], position = layout?.positions.get(id), width = inst.getInternalNode(id)?.width ?? card.width;
957
+ const x = layout.width + 32 <= canvas.width ? (canvas.width - layout.width) / 2 : position ? Math.max(FIT_PADDING - position.x, canvas.width / 2 - position.x - width / 2) : FIT_PADDING;
958
+ const y = layout.height + 32 <= canvas.height ? FIT_PADDING : FIT_PADDING - (position?.y ?? 0);
959
+ inst.setViewport({
960
+ x,
961
+ y,
962
+ zoom: 1
963
+ }, { duration: animate ? tokenMilliseconds("--duration-base", wrapperRef.current) : 0 });
964
+ }
1012
965
  setShown(true);
1013
966
  });
1014
967
  return () => cancelAnimationFrame(frame);
@@ -1017,7 +970,8 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
1017
970
  layoutIdentity,
1018
971
  canvas,
1019
972
  minZoom,
1020
- animateMotion
973
+ animateMotion,
974
+ selectedId
1021
975
  ]);
1022
976
  const select = useCallback((id) => {
1023
977
  if (byId.has(id)) {
@@ -1054,12 +1008,12 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
1054
1008
  if (!target) return;
1055
1009
  event.preventDefault();
1056
1010
  event.stopPropagation();
1057
- const position = layout?.positions.get(target);
1058
- wrapperRef.current?.querySelector(`.react-flow__node[data-id="${CSS.escape(target)}"]`)?.focus();
1059
- if (position) rfRef.current?.setCenter(position.x + card.width / 2, position.y + card.height / 2, {
1060
- zoom: rfRef.current.getZoom(),
1061
- duration: animateMotion ? tokenMilliseconds("--duration-base", wrapperRef.current) : 0
1062
- });
1011
+ const position = layout?.positions.get(target), size = rfRef.current?.getInternalNode(target)?.measured;
1012
+ if (position) rfRef.current?.setCenter(position.x + (size?.width ?? card.width) / 2, position.y + (size?.height ?? card.height) / 2, { zoom: 1 });
1013
+ const focus = () => wrapperRef.current?.querySelector(`.react-flow__node[data-id="${CSS.escape(target)}"]`);
1014
+ const visible = focus();
1015
+ if (visible) visible.focus();
1016
+ else requestAnimationFrame(() => requestAnimationFrame(() => focus()?.focus()));
1063
1017
  }, [
1064
1018
  byId,
1065
1019
  select,
@@ -1080,6 +1034,23 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
1080
1034
  }
1081
1035
  return depth;
1082
1036
  };
1037
+ const readingOrder = useRef([]), known = new Set(readingOrder.current);
1038
+ readingOrder.current = [...readingOrder.current.filter((id) => byId.has(id)), ...nodes.filter((agent) => !known.has(agent.id)).sort((a, b) => activityRank(a) - activityRank(b)).map((agent) => agent.id)];
1039
+ const phoneNodes = readingOrder.current.map((id) => byId.get(id));
1040
+ useEffect(() => {
1041
+ const at = phoneNodes.findIndex((node) => node.id === selectedId);
1042
+ if (at >= 0) setPhonePage(Math.floor(at / PHONE_PAGE));
1043
+ }, [selectedId]);
1044
+ useEffect(() => {
1045
+ if (!narrow || !selectedId) return;
1046
+ const frame = requestAnimationFrame(() => wrapperRef.current?.querySelector(`[data-agent-id="${CSS.escape(selectedId)}"]`)?.scrollIntoView({ block: "nearest" }));
1047
+ return () => cancelAnimationFrame(frame);
1048
+ }, [
1049
+ selectedId,
1050
+ phonePage,
1051
+ narrow
1052
+ ]);
1053
+ const first = Math.min(phonePage * PHONE_PAGE, Math.max(0, Math.ceil(nodes.length / PHONE_PAGE) - 1) * PHONE_PAGE);
1083
1054
  const counts = [
1084
1055
  "needs-you",
1085
1056
  "failed",
@@ -1106,8 +1077,8 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
1106
1077
  const index = group.matches.findIndex((agent) => agent.id === selectedId);
1107
1078
  const next = group.matches[(index + 1) % group.matches.length];
1108
1079
  select(next.id);
1109
- const p = layout?.positions.get(next.id);
1110
- if (p) rfRef.current?.setCenter(p.x + card.width / 2, p.y + card.height / 2, {
1080
+ const p = layout?.positions.get(next.id), size = rfRef.current?.getInternalNode(next.id)?.measured;
1081
+ if (p) rfRef.current?.setCenter(p.x + (size?.width ?? card.width) / 2, p.y + (size?.height ?? card.height) / 2, {
1111
1082
  zoom: 1,
1112
1083
  duration: animateMotion ? tokenMilliseconds("--duration-base", wrapperRef.current) : 0
1113
1084
  });
@@ -1139,7 +1110,35 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
1139
1110
  },
1140
1111
  className: `agent-graph ag-live relative flex flex-col ${className ?? ""}`,
1141
1112
  tabIndex: -1,
1142
- onKeyDownCapture: handleKeyDown,
1113
+ onKeyDownCapture: (event) => {
1114
+ if (event.target instanceof HTMLElement && event.target.closest("[data-agent-metrics]")) return;
1115
+ if (event.key === "Tab" && !event.shiftKey && hovered) {
1116
+ event.preventDefault();
1117
+ document.getElementById(detailsId)?.focus();
1118
+ return;
1119
+ }
1120
+ if (event.key === "Escape") hideDetails();
1121
+ handleKeyDown(event);
1122
+ },
1123
+ onFocusCapture: (event) => {
1124
+ if (!(event.target instanceof HTMLElement) || returningFocus.current) return;
1125
+ keepDetails();
1126
+ if (event.target.closest("[data-agent-metrics]")) return;
1127
+ if (!event.target.matches(":focus-visible")) {
1128
+ hideDetails();
1129
+ return;
1130
+ }
1131
+ const node = event.target.closest(".react-flow__node, [data-agent-id]");
1132
+ const id = node?.dataset.id ?? node?.dataset.agentId;
1133
+ hoverTarget.current = node ?? null;
1134
+ setHovered(id && byId.has(id) ? {
1135
+ id,
1136
+ rect: node.getBoundingClientRect()
1137
+ } : null);
1138
+ },
1139
+ onBlurCapture: (event) => {
1140
+ if (!event.currentTarget.contains(event.relatedTarget) && event.relatedTarget?.id !== detailsId) hideDetails();
1141
+ },
1143
1142
  "data-layout": narrow ? "list" : layout && shown ? "ready" : "pending",
1144
1143
  "data-columns": layout?.columns,
1145
1144
  children: [
@@ -1151,10 +1150,10 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
1151
1150
  selectedId,
1152
1151
  onSelect: onSelect ? select : void 0
1153
1152
  }),
1154
- narrow ? /* @__PURE__ */ jsx("ul", {
1153
+ narrow ? /* @__PURE__ */ jsxs("ul", {
1155
1154
  className: "ag-graph-viewport ag-phone-list overflow-auto",
1156
1155
  "aria-label": "Agents by attention",
1157
- children: [...nodes].sort((a, b) => activityRank(a) - activityRank(b)).map((agent) => /* @__PURE__ */ jsx("li", {
1156
+ children: [phoneNodes.slice(first, first + PHONE_PAGE).map((agent) => /* @__PURE__ */ jsx("li", {
1158
1157
  style: { ["--depth"]: depthOf(agent.id) },
1159
1158
  children: /* @__PURE__ */ jsx("div", {
1160
1159
  role: "button",
@@ -1186,7 +1185,39 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
1186
1185
  onOpen: onSelect ? () => select(agent.id) : void 0
1187
1186
  })
1188
1187
  })
1189
- }, agent.id))
1188
+ }, agent.id)), nodes.length > PHONE_PAGE && /* @__PURE__ */ jsxs("li", {
1189
+ className: "flex items-center justify-between gap-2 border-t border-border pt-3 text-sm",
1190
+ "aria-label": "Agent pages",
1191
+ children: [
1192
+ /* @__PURE__ */ jsx("button", {
1193
+ type: "button",
1194
+ className: "rounded px-2 py-1 hover:bg-muted disabled:opacity-40 focus-visible:outline",
1195
+ disabled: !first,
1196
+ onClick: () => {
1197
+ setPhonePage((page) => page - 1);
1198
+ wrapperRef.current?.querySelector(".ag-phone-list")?.scrollTo({ top: 0 });
1199
+ },
1200
+ children: "Previous"
1201
+ }),
1202
+ /* @__PURE__ */ jsxs("span", { children: [
1203
+ first + 1,
1204
+ "–",
1205
+ Math.min(nodes.length, first + PHONE_PAGE),
1206
+ " of ",
1207
+ nodes.length
1208
+ ] }),
1209
+ /* @__PURE__ */ jsx("button", {
1210
+ type: "button",
1211
+ className: "rounded px-2 py-1 hover:bg-muted disabled:opacity-40 focus-visible:outline",
1212
+ disabled: first + PHONE_PAGE >= nodes.length,
1213
+ onClick: () => {
1214
+ setPhonePage((page) => page + 1);
1215
+ wrapperRef.current?.querySelector(".ag-phone-list")?.scrollTo({ top: 0 });
1216
+ },
1217
+ children: "Next"
1218
+ })
1219
+ ]
1220
+ })]
1190
1221
  }) : /* @__PURE__ */ jsx("div", {
1191
1222
  className: "ag-graph-viewport min-h-0 flex-1",
1192
1223
  style: { opacity: shown ? 1 : 0 },
@@ -1200,13 +1231,18 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
1200
1231
  rfRef.current = inst;
1201
1232
  setReady(true);
1202
1233
  },
1203
- onMoveStart: (event) => {
1204
- if (event) readerMoved.current = true;
1205
- },
1206
1234
  onNodeClick: handleNodeClick,
1207
- onNodeMouseEnter: (_, node) => setHovered(node.id),
1208
- onNodeMouseLeave: () => setHovered(null),
1235
+ onNodeMouseEnter: (event, node) => {
1236
+ keepDetails();
1237
+ hoverTarget.current = event.target.closest(".react-flow__node");
1238
+ if (hoverTarget.current) setHovered({
1239
+ id: node.id,
1240
+ rect: hoverTarget.current.getBoundingClientRect()
1241
+ });
1242
+ },
1243
+ onNodeMouseLeave: leaveDetails,
1209
1244
  proOptions: { hideAttribution: true },
1245
+ onlyRenderVisibleElements: true,
1210
1246
  nodesDraggable: false,
1211
1247
  nodesConnectable: false,
1212
1248
  elementsSelectable: false,
@@ -1228,7 +1264,7 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
1228
1264
  size: 1,
1229
1265
  color: "hsl(var(--foreground) / 0.06)"
1230
1266
  }),
1231
- nodes.length > MINIMAP_MIN_NODES && /* @__PURE__ */ jsx(MiniMap, {
1267
+ layout && canvas && (layout.width > canvas.width || layout.height > canvas.height) && /* @__PURE__ */ jsx(MiniMap, {
1232
1268
  pannable: true,
1233
1269
  zoomable: true,
1234
1270
  position: "bottom-left",
@@ -1249,8 +1285,119 @@ function AgentGraphCanvas({ nodes, edges, selectedId, onSelect, direction = "TB"
1249
1285
  })
1250
1286
  ]
1251
1287
  })
1288
+ }),
1289
+ hovered && byId.has(hovered.id) && /* @__PURE__ */ jsx(AgentMetrics, {
1290
+ anchor: wrapperRef.current,
1291
+ mode: colorMode,
1292
+ id: detailsId,
1293
+ agent: byId.get(hovered.id),
1294
+ rect: hovered.rect,
1295
+ onEnter: keepDetails,
1296
+ onLeave: leaveDetails,
1297
+ onClose: () => {
1298
+ hideDetails();
1299
+ returningFocus.current = true;
1300
+ hoverTarget.current?.focus({ preventScroll: true });
1301
+ returningFocus.current = false;
1302
+ }
1252
1303
  })
1253
1304
  ]
1254
1305
  });
1255
1306
  }
1307
+ /** Metrics stay at reading size outside the zoomed canvas and its clipping container. */
1308
+ function AgentMetrics({ anchor, mode, id, agent, rect, onEnter, onLeave, onClose }) {
1309
+ const [theme, setTheme] = useState({});
1310
+ useLayoutEffect(() => {
1311
+ if (!anchor) return;
1312
+ const read = () => {
1313
+ const css = getComputedStyle(anchor);
1314
+ setTheme(Object.fromEntries(Array.from(css).filter((name) => name.startsWith("--") && !name.startsWith("--tw-")).map((name) => [name, css.getPropertyValue(name)])));
1315
+ };
1316
+ read();
1317
+ const observer = new MutationObserver(read);
1318
+ for (let el = anchor; el; el = el.parentElement) observer.observe(el, {
1319
+ attributes: true,
1320
+ attributeFilter: [
1321
+ "class",
1322
+ "style",
1323
+ "data-theme",
1324
+ "data-sandbox-theme"
1325
+ ]
1326
+ });
1327
+ return () => observer.disconnect();
1328
+ }, [anchor]);
1329
+ const facts = new Map([
1330
+ {
1331
+ label: "Tokens",
1332
+ value: fmtTokenTotal(agent.tokens) ?? "Not recorded"
1333
+ },
1334
+ {
1335
+ label: "Cost",
1336
+ value: fmtCost(agent.costUsd ?? void 0) ?? "Not recorded"
1337
+ },
1338
+ ...agent.facts ?? []
1339
+ ].map((fact) => [fact.label.toLowerCase(), fact]));
1340
+ const width = Math.min(420, window.innerWidth - 24), height = Math.min(560, window.innerHeight - 24);
1341
+ const left = Math.max(12, Math.min(rect.right + 12 + width <= window.innerWidth ? rect.right + 12 : rect.left - width - 12, window.innerWidth - width - 12));
1342
+ return createPortal(/* @__PURE__ */ jsxs("div", {
1343
+ id,
1344
+ "data-agent-metrics": true,
1345
+ "data-theme": anchor?.closest("[data-theme]")?.getAttribute("data-theme") ?? mode,
1346
+ role: "tooltip",
1347
+ tabIndex: 0,
1348
+ onMouseEnter: onEnter,
1349
+ onMouseLeave: onLeave,
1350
+ onWheel: (event) => event.stopPropagation(),
1351
+ onBlur: onLeave,
1352
+ onKeyDown: (event) => {
1353
+ if (event.key === "Escape") {
1354
+ event.preventDefault();
1355
+ onClose();
1356
+ }
1357
+ },
1358
+ className: `${mode} rounded-xl border border-border bg-card p-4 text-sm text-foreground shadow-xl`,
1359
+ style: {
1360
+ ...theme,
1361
+ colorScheme: mode,
1362
+ position: "fixed",
1363
+ zIndex: 1e3,
1364
+ width,
1365
+ maxHeight: height,
1366
+ overflow: "auto",
1367
+ left,
1368
+ top: Math.max(12, Math.min(rect.top, window.innerHeight - height - 12))
1369
+ },
1370
+ children: [
1371
+ /* @__PURE__ */ jsxs("div", {
1372
+ className: "mb-2 flex items-center justify-between gap-3",
1373
+ children: [/* @__PURE__ */ jsx("strong", { children: agent.label }), /* @__PURE__ */ jsx("span", { children: agentStatus(agent.status).label })]
1374
+ }),
1375
+ /* @__PURE__ */ jsx("p", {
1376
+ className: "mb-3 text-muted-foreground",
1377
+ children: [
1378
+ agent.model,
1379
+ agent.harness,
1380
+ agent.role
1381
+ ].filter(Boolean).join(" · ")
1382
+ }),
1383
+ /* @__PURE__ */ jsx("dl", {
1384
+ className: "grid grid-cols-[minmax(0,1fr)_auto] gap-x-5 gap-y-1.5",
1385
+ children: [...facts.values()].map((fact) => /* @__PURE__ */ jsxs("div", {
1386
+ className: "contents",
1387
+ children: [/* @__PURE__ */ jsx("dt", {
1388
+ className: "min-w-0 break-words text-muted-foreground",
1389
+ children: fact.label
1390
+ }), /* @__PURE__ */ jsx("dd", {
1391
+ className: "m-0 min-w-0 max-w-64 whitespace-normal break-words text-right tabular-nums",
1392
+ children: fact.value
1393
+ })]
1394
+ }, fact.label))
1395
+ }),
1396
+ agent.status === "failed" && /* @__PURE__ */ jsx("p", {
1397
+ className: "mt-3 whitespace-pre-wrap break-words text-[var(--surface-danger-text)]",
1398
+ children: agent.result ?? agent.turn ?? "Failure cause not recorded"
1399
+ })
1400
+ ]
1401
+ }), document.body);
1402
+ }
1256
1403
  export { AgentGraphCanvas };