@datalayer/agent-runtimes 1.3.85 → 1.3.87

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.
@@ -21,11 +21,19 @@ const NOTEBOOK_BALLOON_ROOM = 480;
21
21
  const NOTEBOOK_PREVIEW_HEIGHT = 180;
22
22
  const GAP = 220;
23
23
  const WIDTH = NODE_WIDTH * 2 + GAP;
24
+ /** Between two rows of members. */
25
+ const ROW_GAP = 48;
24
26
  /** A connection's node: half a member's. */
25
27
  const CONNECTION_WIDTH = NODE_WIDTH / 2;
26
- /** Between a member and its connections. */
28
+ /** Between a member and its connections, to its right. */
27
29
  const CONNECTION_GAP = 44;
28
30
  const CONNECTIONS_APART = 16;
31
+ /** Where a member's connections start: right of it. */
32
+ const CONNECTION_X = NODE_WIDTH + CONNECTION_GAP;
33
+ /** Below the character's middle, so the A2A edge leaving it passes above. */
34
+ const CONNECTION_DROP = 12;
35
+ /** What a member's connections take beside it. */
36
+ const CONNECTIONS_BESIDE = CONNECTION_GAP + CONNECTION_WIDTH;
29
37
  /** Where the edge leaves and reaches a member: the middle of its character. */
30
38
  const HANDLE = (size) => ({
31
39
  opacity: 0,
@@ -37,6 +45,50 @@ const HANDLE = (size) => ({
37
45
  minHeight: 0,
38
46
  border: 0,
39
47
  });
48
+ /**
49
+ * The handles an edge leaves and reaches a member by, named: the middle of
50
+ * its character on each side, chosen by where the other member stands.
51
+ */
52
+ export const MEMBER_HANDLES = {
53
+ inLeft: 'in-left',
54
+ outRight: 'out-right',
55
+ inTop: 'in-top',
56
+ outBottom: 'out-bottom',
57
+ inRight: 'in-right',
58
+ outLeft: 'out-left',
59
+ inBottom: 'in-bottom',
60
+ outTop: 'out-top',
61
+ connections: 'connections',
62
+ };
63
+ /**
64
+ * The handles for an edge from one place to another: rightward across,
65
+ * leftward across, or down or up when the other member is mostly below or
66
+ * above.
67
+ */
68
+ export function handlesBetween(from, to) {
69
+ const dx = to.x - from.x;
70
+ const dy = to.y - from.y;
71
+ if (Math.abs(dx) >= Math.abs(dy)) {
72
+ return dx >= 0
73
+ ? {
74
+ sourceHandle: MEMBER_HANDLES.outRight,
75
+ targetHandle: MEMBER_HANDLES.inLeft,
76
+ }
77
+ : {
78
+ sourceHandle: MEMBER_HANDLES.outLeft,
79
+ targetHandle: MEMBER_HANDLES.inRight,
80
+ };
81
+ }
82
+ return dy >= 0
83
+ ? {
84
+ sourceHandle: MEMBER_HANDLES.outBottom,
85
+ targetHandle: MEMBER_HANDLES.inTop,
86
+ }
87
+ : {
88
+ sourceHandle: MEMBER_HANDLES.outTop,
89
+ targetHandle: MEMBER_HANDLES.inBottom,
90
+ };
91
+ }
40
92
  /**
41
93
  * The members as they are now, by id. Not in the nodes' data: a node whose
42
94
  * object changes is taken by React Flow for a new one, measured again, and
@@ -72,7 +124,7 @@ const Moving = createContext({
72
124
  move: () => undefined,
73
125
  reset: () => undefined,
74
126
  });
75
- /** A connection's node id: under its member. */
127
+ /** A connection's node id: beside its member. */
76
128
  const connectionNodeId = (member, connection) => `${member}/${connection}`;
77
129
  const MemberNode = memo(function MemberNode({ data, }) {
78
130
  const { id, side, size } = data;
@@ -159,7 +211,7 @@ const MemberNode = memo(function MemberNode({ data, }) {
159
211
  pointerEvents: 'all',
160
212
  }, "data-team-member": member.id, "data-member-balloon": shown, "data-member-moved": offset.dx || offset.dy
161
213
  ? `${Math.round(offset.dx)},${Math.round(offset.dy)}`
162
- : undefined, "data-member-state": persona.state, children: [_jsx(Handle, { type: "target", position: Position.Left, style: HANDLE(size), isConnectable: false }), _jsx(Box, { sx: { width: size, height: size, position: 'relative' }, children: persona.away ? (_jsxs(Button, { size: "small", sx: { position: 'absolute', top: size / 2 - 16, left: -24 }, onClick: () => member.onAway?.(false), children: ["Call ", member.name, " back"] })) : (_jsx(AssistantStage, { character: member.character, state: persona.state, size: size,
214
+ : undefined, "data-member-state": persona.state, children: [_jsx(Handle, { id: MEMBER_HANDLES.inLeft, type: "target", position: Position.Left, style: HANDLE(size), isConnectable: false }), _jsx(Handle, { id: MEMBER_HANDLES.inRight, type: "target", position: Position.Right, style: HANDLE(size), isConnectable: false }), _jsx(Handle, { id: MEMBER_HANDLES.inTop, type: "target", position: Position.Top, style: { ...HANDLE(size), top: 0 }, isConnectable: false }), _jsx(Handle, { id: MEMBER_HANDLES.inBottom, type: "target", position: Position.Bottom, style: { ...HANDLE(size), top: size }, isConnectable: false }), _jsx(Box, { sx: { width: size, height: size, position: 'relative' }, children: persona.away ? (_jsxs(Button, { size: "small", sx: { position: 'absolute', top: size / 2 - 16, left: -24 }, onClick: () => member.onAway?.(false), children: ["Call ", member.name, " back"] })) : (_jsx(AssistantStage, { character: member.character, state: persona.state, size: size,
163
215
  // In the node, not over the page; the balloon opens toward the
164
216
  // middle of the graph: rightward from the left member.
165
217
  place: side === 'left'
@@ -236,7 +288,7 @@ const MemberNode = memo(function MemberNode({ data, }) {
236
288
  stayPut: true,
237
289
  // Named as the member, not its character: `Sales (in your
238
290
  // browser)`; the character describes it.
239
- agentName: member.name, label: memberLabel(member) })) }), _jsxs(Text, { sx: { fontWeight: 600, textAlign: 'center' }, children: [member.emoji ? `${member.emoji} ` : '', member.name] }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', textAlign: 'center' }, "data-member-where": "", children: member.where }), _jsx(Handle, { type: "source", position: Position.Right, style: HANDLE(size), isConnectable: false }), member.connections?.length ? (_jsx(Handle, { id: "connections", type: "source", position: Position.Bottom, style: { ...HANDLE(size), top: 'auto', bottom: 0 }, isConnectable: false })) : null] }));
291
+ agentName: member.name, label: memberLabel(member) })) }), _jsxs(Text, { sx: { fontWeight: 600, textAlign: 'center' }, children: [member.emoji ? `${member.emoji} ` : '', member.name] }), _jsx(Text, { sx: { fontSize: 0, color: 'fg.muted', textAlign: 'center' }, "data-member-where": "", children: member.where }), _jsx(Handle, { id: MEMBER_HANDLES.outRight, type: "source", position: Position.Right, style: HANDLE(size), isConnectable: false }), _jsx(Handle, { id: MEMBER_HANDLES.outLeft, type: "source", position: Position.Left, style: HANDLE(size), isConnectable: false }), _jsx(Handle, { id: MEMBER_HANDLES.outBottom, type: "source", position: Position.Bottom, style: { ...HANDLE(size), top: size }, isConnectable: false }), _jsx(Handle, { id: MEMBER_HANDLES.outTop, type: "source", position: Position.Top, style: { ...HANDLE(size), top: 0 }, isConnectable: false }), member.connections?.length ? (_jsx(Handle, { id: MEMBER_HANDLES.connections, type: "source", position: Position.Right, style: { ...HANDLE(size), left: 'auto', right: 0 }, isConnectable: false })) : null] }));
240
292
  });
241
293
  /** A member's accessible name: its name, and where it runs. */
242
294
  export function memberLabel(member) {
@@ -268,7 +320,7 @@ const ConnectionNode = memo(function ConnectionNode({ id, data, }) {
268
320
  gap: '2px',
269
321
  cursor: 'default',
270
322
  pointerEvents: 'all',
271
- }, title: connection.name, "data-team-connection": connection.id, "data-connection-busy": tool ? 'true' : 'false', children: [_jsx(Handle, { type: "target", position: Position.Top, style: { ...HANDLE(0), top: 0 }, isConnectable: false }), _jsx(Box, { as: "button", type: "button", className: tool ? 'a2a-team-connection-busy' : undefined, "aria-label": connectionLabel(connection, owner?.name), "aria-expanded": told, "aria-controls": detailsId, "data-connection-figure": "", onClick: () => setTold(shown => !shown), onKeyDown: (event) => {
323
+ }, title: connection.name, "data-team-connection": connection.id, "data-connection-busy": tool ? 'true' : 'false', children: [_jsx(Handle, { type: "target", position: Position.Left, style: { ...HANDLE(size), left: 0 }, isConnectable: false }), _jsx(Box, { as: "button", type: "button", className: tool ? 'a2a-team-connection-busy' : undefined, "aria-label": connectionLabel(connection, owner?.name), "aria-expanded": told, "aria-controls": detailsId, "data-connection-figure": "", onClick: () => setTold(shown => !shown), onKeyDown: (event) => {
272
324
  if (event.key === 'Escape' && told) {
273
325
  event.stopPropagation();
274
326
  setTold(false);
@@ -317,14 +369,29 @@ export function flowWords(flow, entry, peer) {
317
369
  : '';
318
370
  }
319
371
  const ARROW = 9;
372
+ /**
373
+ * An arrowhead at the end a message goes to — the target's when `forward`,
374
+ * the source's otherwise — pointing along the edge.
375
+ */
376
+ export function arrowPath(source, target, forward) {
377
+ const tip = forward ? target : source;
378
+ const from = forward ? source : target;
379
+ const length = Math.hypot(tip.x - from.x, tip.y - from.y) || 1;
380
+ // Along the edge, toward the tip; and across it.
381
+ const ax = (tip.x - from.x) / length;
382
+ const ay = (tip.y - from.y) / length;
383
+ const bx = tip.x - ax * ARROW;
384
+ const by = tip.y - ay * ARROW;
385
+ const px = -ay * (ARROW / 1.6);
386
+ const py = ax * (ARROW / 1.6);
387
+ return `M ${tip.x},${tip.y} L ${bx + px},${by + py} L ${bx - px},${by - py} Z`;
388
+ }
320
389
  const LinkEdge = memo(function LinkEdge({ id, sourceX, sourceY, targetX, targetY, data, }) {
321
390
  const flow = data?.flow ?? 'still';
322
391
  const path = `M ${sourceX},${sourceY} L ${targetX},${targetY}`;
323
- // The arrow at the end the message goes to.
392
+ // The arrow at the end the message goes to, along the edge.
324
393
  const towardPeer = flow === 'asking';
325
- const tipX = towardPeer ? targetX : sourceX;
326
- const back = towardPeer ? -ARROW : ARROW;
327
- const arrow = `M ${tipX},${targetY} L ${tipX + back},${targetY - ARROW / 1.6} L ${tipX + back},${targetY + ARROW / 1.6} Z`;
394
+ const arrow = arrowPath({ x: sourceX, y: sourceY }, { x: targetX, y: targetY }, towardPeer);
328
395
  return (_jsxs(_Fragment, { children: [_jsx(BaseEdge, { id: id, path: path, style: {
329
396
  stroke: data?.connected
330
397
  ? 'var(--borderColor-accent-emphasis, #0969da)'
@@ -358,23 +425,78 @@ export const CallEdge = memo(function CallEdge({ id, sourceX, sourceY, targetX,
358
425
  });
359
426
  const NODE_TYPES = { member: MemberNode, connection: ConnectionNode };
360
427
  const EDGE_TYPES = { a2a: LinkEdge, mcp: CallEdge };
361
- /** Where a member's connections sit: under it, side by side, centred. */
362
- function connectionsX(memberX, count) {
363
- const row = count * CONNECTION_WIDTH + (count - 1) * CONNECTIONS_APART;
364
- const left = memberX + (NODE_WIDTH - row) / 2;
365
- return Array.from({ length: count }, (_, at) => left + at * (CONNECTION_WIDTH + CONNECTIONS_APART));
428
+ /** Where a member's connections sit: right of it, one under another from its character's middle. */
429
+ function connectionsY(memberY, size, count, connectionHeight) {
430
+ const top = memberY + size / 2 + CONNECTION_DROP;
431
+ return Array.from({ length: count }, (_, at) => top + at * (connectionHeight + CONNECTIONS_APART));
432
+ }
433
+ /** How far down a member's connections reach, from its top; none, 0. */
434
+ function connectionsReach(size, count, connectionHeight) {
435
+ return count
436
+ ? size / 2 +
437
+ CONNECTION_DROP +
438
+ count * connectionHeight +
439
+ (count - 1) * CONNECTIONS_APART
440
+ : 0;
366
441
  }
367
442
  /** What a member's call says, on its edge and to a screen reader. */
368
443
  export function callWords(member, connection, tool) {
369
444
  return `${member} calls ${connection.label} · ${toolWords(tool, connection)}`;
370
445
  }
371
- /** The viewport that shows the graph's width whole in a box `width` wide, never larger than drawn. */
372
- export function teamViewport(width) {
373
- const zoom = Math.min(1, width / WIDTH);
374
- return { zoom, x: (width - WIDTH * zoom) / 2, y: 0 };
446
+ /** The viewport that shows a graph `drawn` wide whole in a box `width` wide, never larger than drawn. */
447
+ export function teamViewport(width, drawn = WIDTH) {
448
+ const zoom = Math.min(1, width / drawn);
449
+ return { zoom, x: (width - drawn * zoom) / 2, y: 0 };
450
+ }
451
+ /**
452
+ * Where each member stands, as columns and rows: from the positions given
453
+ * (the distinct `x` values are the columns left to right, the distinct `y`
454
+ * values the rows top to bottom), or the entry in the first column and the
455
+ * peers one under another in the second.
456
+ */
457
+ export function placeMembers(entryId, peerIds, positions) {
458
+ const ids = [entryId, ...peerIds];
459
+ const placed = positions ?? {};
460
+ if (ids.every(id => placed[id])) {
461
+ const xs = [...new Set(ids.map(id => placed[id].x))].sort((a, b) => a - b);
462
+ const ys = [...new Set(ids.map(id => placed[id].y))].sort((a, b) => a - b);
463
+ return {
464
+ places: Object.fromEntries(ids.map(id => [
465
+ id,
466
+ { column: xs.indexOf(placed[id].x), row: ys.indexOf(placed[id].y) },
467
+ ])),
468
+ columns: xs.length,
469
+ rows: ys.length,
470
+ };
471
+ }
472
+ return {
473
+ places: {
474
+ [entryId]: { column: 0, row: 0 },
475
+ ...Object.fromEntries(peerIds.map((id, at) => [id, { column: 1, row: at }])),
476
+ },
477
+ columns: peerIds.length ? 2 : 1,
478
+ rows: Math.max(1, peerIds.length),
479
+ };
480
+ }
481
+ /** The one flow the graph reports for all its edges: asking before answering before still. */
482
+ export function flowOfAll(flows) {
483
+ return flows.includes('asking')
484
+ ? 'asking'
485
+ : flows.includes('answering')
486
+ ? 'answering'
487
+ : 'still';
375
488
  }
376
- /** Draw a team of two over A2A, the link moving with what travels on it. */
377
- export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size = 96, calls = [], balloonRoom = BALLOON_ROOM, }) {
489
+ const NO_PEERS = [];
490
+ /** Draw a team over A2A, each link moving with what travels on it. */
491
+ export function A2ATeamGraph({ entry, peer, peers: more = NO_PEERS, flow, flows, connected, label = 'A2A', labels, links, positions, size = 96, calls = [], balloonRoom = BALLOON_ROOM, }) {
492
+ const peers = useMemo(() => [...(peer ? [peer] : []), ...more], [peer, more]);
493
+ const members = useMemo(() => [entry, ...peers], [entry, peers]);
494
+ const memberOf = (id) => members.find(member => member.id === id);
495
+ const flowOf = (peerId) => flows?.[peerId] ??
496
+ (peer && peerId === peer.id ? (flow ?? 'still') : 'still');
497
+ const connectedOf = (peerId) => typeof connected === 'boolean' ? connected : Boolean(connected[peerId]);
498
+ const labelOf = (peerId) => labels?.[peerId] ?? label;
499
+ const edgesWanted = links ?? peers.map(one => ({ from: entry.id, to: one.id }));
378
500
  const box = useRef(null);
379
501
  const [width, setWidth] = useState(0);
380
502
  useEffect(() => {
@@ -391,7 +513,15 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
391
513
  observer.observe(element);
392
514
  return () => observer.disconnect();
393
515
  }, []);
394
- const viewport = useMemo(() => teamViewport(width || WIDTH), [width]);
516
+ // Where the members stand, as columns and rows, and the width that takes.
517
+ const peerIds = peers.map(one => one.id);
518
+ const placing = placeMembers(entry.id, peerIds, positions);
519
+ const hasConnections = members.some(member => member.connections?.length);
520
+ // The last column's connections sit beside it, inside the graph.
521
+ const drawn = Math.max(WIDTH, placing.columns * NODE_WIDTH +
522
+ (placing.columns - 1) * GAP +
523
+ (hasConnections ? CONNECTIONS_BESIDE : 0));
524
+ const viewport = useMemo(() => teamViewport(width || drawn, drawn), [width, drawn]);
395
525
  // The members' height: the character, its name and where it runs.
396
526
  const memberHeight = size + 56;
397
527
  const connectionSize = Math.round(size / 2);
@@ -399,43 +529,46 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
399
529
  // and room under it before the graph's edge.
400
530
  const connectionHeight = connectionSize + 58;
401
531
  // The connections' ids, member by member: the nodes change only when they do.
402
- const placed = [entry, peer].map(member => ({
532
+ const placed = members.map(member => ({
403
533
  member: member.id,
404
534
  connections: (member.connections ?? []).map(connection => connection.id),
405
535
  }));
406
536
  const placedKey = JSON.stringify(placed);
407
- const hasConnections = placed.some(({ connections }) => connections.length);
408
537
  // A notebook in a balloon: more room above the members.
409
- const room = [entry, peer].some(member => member.persona.notebook)
538
+ const room = members.some(member => member.persona.notebook)
410
539
  ? Math.max(NOTEBOOK_BALLOON_ROOM, balloonRoom)
411
540
  : balloonRoom;
412
- const height = Math.ceil((room +
413
- memberHeight +
414
- (hasConnections ? CONNECTION_GAP + connectionHeight : 0)) *
415
- viewport.zoom);
541
+ // Beside a member, its connections may reach below it: the row takes them.
542
+ const reach = Math.max(0, ...placed.map(({ connections }) => connectionsReach(size, connections.length, connectionHeight)));
543
+ const rowHeight = Math.max(memberHeight, reach);
544
+ const total = room + placing.rows * rowHeight + (placing.rows - 1) * ROW_GAP;
545
+ const height = Math.ceil(total * viewport.zoom);
546
+ // Where each member is laid out, before it is moved.
547
+ const layoutOf = (id) => {
548
+ const place = placing.places[id] ?? { column: 0, row: 0 };
549
+ return {
550
+ x: place.column * (NODE_WIDTH + GAP),
551
+ y: room + place.row * (rowHeight + ROW_GAP),
552
+ };
553
+ };
416
554
  // Where the members were moved, kept inside the graph's box.
417
- const total = room +
418
- memberHeight +
419
- (hasConnections ? CONNECTION_GAP + connectionHeight : 0);
420
555
  const boundsOf = (id) => {
421
- const layoutX = id === entry.id ? 0 : NODE_WIDTH + GAP;
422
- const below = placed.find(({ member }) => member === id)?.connections.length
423
- ? CONNECTION_GAP + connectionHeight
424
- : 0;
556
+ const layout = layoutOf(id);
557
+ const count = placed.find(({ member }) => member === id)?.connections.length ?? 0;
558
+ const beside = count ? CONNECTIONS_BESIDE : 0;
559
+ const tall = Math.max(memberHeight, connectionsReach(size, count, connectionHeight));
425
560
  return {
426
- minDx: -layoutX,
427
- maxDx: WIDTH - NODE_WIDTH - layoutX,
428
- minDy: -room,
429
- maxDy: total - room - memberHeight - below,
561
+ minDx: -layout.x,
562
+ maxDx: drawn - NODE_WIDTH - beside - layout.x,
563
+ minDy: -layout.y,
564
+ maxDy: total - layout.y - tall,
430
565
  };
431
566
  };
432
- const [moved, setMoved] = useState(() => ({
433
- [entry.id]: MOVED.get(movedKey(entry.id)) ?? AT_PLACE,
434
- [peer.id]: MOVED.get(movedKey(peer.id)) ?? AT_PLACE,
435
- }));
567
+ const [moved, setMoved] = useState(() => Object.fromEntries(members.map(member => [
568
+ member.id,
569
+ MOVED.get(movedKey(member.id)) ?? AT_PLACE,
570
+ ])));
436
571
  const offsetOf = (id) => clampOffset(moved[id] ?? AT_PLACE, boundsOf(id));
437
- const entryOffset = offsetOf(entry.id);
438
- const peerOffset = offsetOf(peer.id);
439
572
  const movingValue = {
440
573
  zoom: viewport.zoom,
441
574
  offsetOf,
@@ -449,66 +582,55 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
449
582
  setMoved(current => ({ ...current, [id]: AT_PLACE }));
450
583
  },
451
584
  };
452
- const nodes = useMemo(() => [
585
+ // Each member's place and move, and its connections: the nodes are made
586
+ // again only when one of them changes.
587
+ const layout = members.map(member => {
588
+ const at = layoutOf(member.id);
589
+ const offset = offsetOf(member.id);
590
+ return {
591
+ id: member.id,
592
+ x: at.x + offset.dx,
593
+ y: at.y + offset.dy,
594
+ // Its balloon opens toward the middle of the graph.
595
+ side: at.x + NODE_WIDTH / 2 <= drawn / 2
596
+ ? 'left'
597
+ : 'right',
598
+ connections: (member.connections ?? []).map(connection => connection.id),
599
+ };
600
+ });
601
+ const layoutKey = JSON.stringify(layout);
602
+ const nodes = useMemo(() => JSON.parse(layoutKey).flatMap(({ id, x, y, side, connections }) => [
453
603
  {
454
- id: entry.id,
604
+ id,
455
605
  type: 'member',
456
- position: { x: entryOffset.dx, y: room + entryOffset.dy },
606
+ position: { x, y },
457
607
  width: NODE_WIDTH,
458
608
  height: memberHeight,
459
609
  draggable: false,
460
610
  selectable: false,
461
611
  // Above the edge and its label: a balloon overflows its node.
462
612
  zIndex: 10,
463
- data: { id: entry.id, side: 'left', size },
464
- },
465
- {
466
- id: peer.id,
467
- type: 'member',
468
- position: {
469
- x: NODE_WIDTH + GAP + peerOffset.dx,
470
- y: room + peerOffset.dy,
471
- },
472
- width: NODE_WIDTH,
473
- height: memberHeight,
474
- draggable: false,
475
- selectable: false,
476
- zIndex: 10,
477
- data: { id: peer.id, side: 'right', size },
613
+ data: { id, side, size },
478
614
  },
479
- ...JSON.parse(placedKey).flatMap(({ member, connections }, side) => {
480
- // A member's connections move with it.
481
- const moveBy = side === 0 ? entryOffset : peerOffset;
482
- const xs = connectionsX((side === 0 ? 0 : NODE_WIDTH + GAP) + moveBy.dx, connections.length);
483
- return connections.map((connection, at) => ({
484
- id: connectionNodeId(member, connection),
615
+ // A member's connections move with it.
616
+ ...connections.map((connection, at) => {
617
+ const ys = connectionsY(y, size, connections.length, connectionHeight);
618
+ return {
619
+ id: connectionNodeId(id, connection),
485
620
  type: 'connection',
486
621
  position: {
487
- x: xs[at],
488
- y: room + memberHeight + CONNECTION_GAP + moveBy.dy,
622
+ x: x + CONNECTION_X,
623
+ y: ys[at],
489
624
  },
490
625
  width: CONNECTION_WIDTH,
491
626
  height: connectionHeight,
492
627
  draggable: false,
493
628
  selectable: false,
494
629
  zIndex: 5,
495
- data: { member, connection, size: connectionSize },
496
- }));
630
+ data: { member: id, connection, size: connectionSize },
631
+ };
497
632
  }),
498
- ], [
499
- entry.id,
500
- peer.id,
501
- size,
502
- room,
503
- memberHeight,
504
- placedKey,
505
- connectionSize,
506
- connectionHeight,
507
- entryOffset.dx,
508
- entryOffset.dy,
509
- peerOffset.dx,
510
- peerOffset.dy,
511
- ]);
633
+ ]), [layoutKey, size, memberHeight, connectionSize, connectionHeight]);
512
634
  // The notebook open under the graph runs on a Pyodide kernel in the page:
513
635
  // the entry's sandbox, while it is open.
514
636
  const notebookKernel = useSignalValue(teamNotebookKernel);
@@ -526,16 +648,23 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
526
648
  }
527
649
  : { kind: 'browser', status: 'not running' },
528
650
  }, [entry, notebookKernel]);
529
- const members = useMemo(() => ({ [entry.id]: entryMember, [peer.id]: peer }), [entry.id, entryMember, peer]);
530
- const words = flowWords(flow, entry.name, peer.name);
651
+ const membersById = useMemo(() => ({
652
+ [entry.id]: entryMember,
653
+ ...Object.fromEntries(peers.map(one => [one.id, one])),
654
+ }), [entry.id, entryMember, peers]);
655
+ // The words for each flow, for the edges and for a screen reader.
656
+ const wordsOf = (link) => link.from === entry.id
657
+ ? flowWords(flowOf(link.to), entry.name, memberOf(link.to)?.name ?? link.to)
658
+ : '';
659
+ const words = edgesWanted.map(wordsOf).filter(Boolean);
531
660
  // The tool each connection is answering now, in words, by its node's id.
532
661
  const busy = useMemo(() => {
533
662
  const byNode = {};
534
663
  const said = [];
535
664
  for (const call of calls) {
536
- const member = call.member === entry.id ? entry : peer;
537
- const connection = member.connections?.find(known => known.id === call.connection);
538
- if (!connection || member.id !== call.member) {
665
+ const member = members.find(one => one.id === call.member);
666
+ const connection = member?.connections?.find(known => known.id === call.connection);
667
+ if (!member || !connection) {
539
668
  continue;
540
669
  }
541
670
  const node = connectionNodeId(member.id, connection.id);
@@ -543,28 +672,35 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
543
672
  said.push(callWords(member.name, connection, call.tool));
544
673
  }
545
674
  return { byNode, said: [...new Set(said)] };
546
- }, [calls, entry, peer]);
675
+ }, [calls, members]);
676
+ const linkKey = JSON.stringify(edgesWanted.map(link => ({
677
+ ...link,
678
+ flow: link.from === entry.id ? flowOf(link.to) : 'still',
679
+ connected: connectedOf(link.to),
680
+ label: wordsOf(link)
681
+ ? `${labelOf(link.to)} · ${wordsOf(link)}`
682
+ : labelOf(link.to),
683
+ ...handlesBetween(layoutOf(link.from), layoutOf(link.to)),
684
+ })));
547
685
  const edges = useMemo(() => [
548
- {
549
- id: `${entry.id}->${peer.id}`,
550
- source: entry.id,
551
- target: peer.id,
686
+ ...JSON.parse(linkKey).map(({ from, to, flow: linkFlow, connected: reached, label: said, sourceHandle, targetHandle, }) => ({
687
+ id: `${from}->${to}`,
688
+ source: from,
689
+ sourceHandle,
690
+ target: to,
691
+ targetHandle,
552
692
  type: 'a2a',
553
693
  selectable: false,
554
694
  focusable: false,
555
- data: {
556
- flow,
557
- connected,
558
- label: words ? `${label} · ${words}` : label,
559
- },
560
- },
695
+ data: { flow: linkFlow, connected: reached, label: said },
696
+ })),
561
697
  ...JSON.parse(placedKey).flatMap(({ member, connections }) => connections.map(connection => {
562
698
  const node = connectionNodeId(member, connection);
563
699
  const calling = busy.byNode[node] ?? '';
564
700
  return {
565
701
  id: `${member}->${node}`,
566
702
  source: member,
567
- sourceHandle: 'connections',
703
+ sourceHandle: MEMBER_HANDLES.connections,
568
704
  target: node,
569
705
  type: 'mcp',
570
706
  selectable: false,
@@ -572,9 +708,10 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
572
708
  data: { calling },
573
709
  };
574
710
  })),
575
- ], [entry.id, peer.id, flow, connected, label, words, placedKey, busy]);
576
- const heard = [words, ...busy.said].filter(Boolean).join('. ');
577
- return (_jsxs(Box, { ref: box, "data-a2a-team-graph": "", "data-a2a-flow": flow, "data-a2a-calls": calls.length, sx: {
711
+ ], [linkKey, placedKey, busy]);
712
+ const heard = [...words, ...busy.said].filter(Boolean).join('. ');
713
+ const flowNow = flowOfAll(peers.map(one => flowOf(one.id)));
714
+ return (_jsxs(Box, { ref: box, "data-a2a-team-graph": "", "data-a2a-flow": flowNow, "data-a2a-calls": calls.length, "data-a2a-members": members.length, sx: {
578
715
  width: '100%',
579
716
  height,
580
717
  position: 'relative',
@@ -626,8 +763,8 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
626
763
  overflow: 'hidden',
627
764
  clip: 'rect(0 0 0 0)',
628
765
  whiteSpace: 'nowrap',
629
- }, children: heard }), _jsx(Moving.Provider, { value: movingValue, children: _jsx(Members.Provider, { value: members, children: _jsx(Busy.Provider, { value: busy.byNode, children: _jsx(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: NODE_TYPES, edgeTypes: EDGE_TYPES, viewport: viewport, nodesDraggable: false, nodesConnectable: false, nodesFocusable: false, edgesFocusable: false, elementsSelectable: false, deleteKeyCode: null, selectionKeyCode: null, multiSelectionKeyCode: null, panOnDrag: false, panOnScroll: false, zoomOnScroll: false, zoomOnPinch: false, zoomOnDoubleClick: false, preventScrolling: false, style: { overflow: 'visible', background: 'transparent' },
766
+ }, children: heard }), _jsx(Moving.Provider, { value: movingValue, children: _jsx(Members.Provider, { value: membersById, children: _jsx(Busy.Provider, { value: busy.byNode, children: _jsx(ReactFlow, { nodes: nodes, edges: edges, nodeTypes: NODE_TYPES, edgeTypes: EDGE_TYPES, viewport: viewport, nodesDraggable: false, nodesConnectable: false, nodesFocusable: false, edgesFocusable: false, elementsSelectable: false, deleteKeyCode: null, selectionKeyCode: null, multiSelectionKeyCode: null, panOnDrag: false, panOnScroll: false, zoomOnScroll: false, zoomOnPinch: false, zoomOnDoubleClick: false, preventScrolling: false, style: { overflow: 'visible', background: 'transparent' },
630
767
  // The library is MIT: its credit is not required on the page.
631
- proOptions: { hideAttribution: true }, "aria-label": `${entry.name} and ${peer.name}, over A2A` }) }) }) })] }));
768
+ proOptions: { hideAttribution: true }, "aria-label": `${entry.name} and ${peers.map(one => one.name).join(', ')}, over A2A` }) }) }) })] }));
632
769
  }
633
770
  export default A2ATeamGraph;
@@ -16,3 +16,4 @@ export * from './NotebookPreview';
16
16
  export * from './sceneTranscript';
17
17
  export * from './SceneTranscript';
18
18
  export * from './SceneView';
19
+ export * from './sceneStage';
@@ -20,3 +20,4 @@ export * from './NotebookPreview';
20
20
  export * from './sceneTranscript';
21
21
  export * from './SceneTranscript';
22
22
  export * from './SceneView';
23
+ export * from './sceneStage';
@@ -0,0 +1,120 @@
1
+ /**
2
+ * A scene as a page stages it (LOOP A-13, A-15), read from the scene spec
3
+ * and its team: who is on stage and where each one stands, who asks whom
4
+ * over A2A, what the entry suggests, and where the members on a runtime are
5
+ * reached when the scene plays against local servers.
6
+ *
7
+ * Pure, so the examples app, the landing and the tests read a scene the
8
+ * same way.
9
+ *
10
+ * @module components/teams/sceneStage
11
+ */
12
+ import type { AppSpec } from '../../types/agentspecs';
13
+ import type { SceneCastMemberSpec, SceneSpec } from '../../types/scenes';
14
+ import type { TeamSpec } from '../../types/teams';
15
+ /** Who talks to whom over A2A: an edge of the team's graph. */
16
+ export type A2ATeamLink = {
17
+ /** The member that asks, by its id. */
18
+ from: string;
19
+ /** The member asked. */
20
+ to: string;
21
+ };
22
+ /** A reference without its version: `accounting:0.0.1` is `accounting`. */
23
+ export declare function refId(ref: string): string;
24
+ /** The application a cast member is, from the catalogue, or undefined. */
25
+ export declare function appOfMember(member: Pick<SceneCastMemberSpec, 'app'>): AppSpec | undefined;
26
+ /**
27
+ * The links of a team over A2A, in the order its members say them: each
28
+ * member's `talks_to … over: a2a`. A link to a server (over `mcp`) is a
29
+ * connection, drawn under the member, not an edge between members.
30
+ */
31
+ export declare function teamLinksOf(team: Pick<TeamSpec, 'agents'> | Pick<SceneSpec, 'cast'>): A2ATeamLink[];
32
+ /** A suggestion the entry offers: a label on its chip, and what is sent. */
33
+ export type SceneSuggestion = {
34
+ label: string;
35
+ prompt: string;
36
+ };
37
+ /**
38
+ * The entry's suggestions: the script's cues, each labelled by the starter
39
+ * of the entry's Appspec that says the same words, or by its first words.
40
+ * Without a scene, the starters themselves.
41
+ */
42
+ export declare function sceneCuesOf(scene: Pick<SceneSpec, 'script'> | undefined, starters: readonly {
43
+ label: string;
44
+ message: string;
45
+ }[]): SceneSuggestion[];
46
+ /** One member of a scene, as a page stages it. */
47
+ export type SceneStageMember = {
48
+ /** Its id in the cast. */
49
+ id: string;
50
+ /** The application it is. */
51
+ app: AppSpec;
52
+ cast: SceneCastMemberSpec;
53
+ /** Whether it is the member the audience talks to. */
54
+ entry: boolean;
55
+ /** Where its loop turns. */
56
+ runsIn: 'browser' | 'runtime';
57
+ /** The variable its address is read from, when it runs on a runtime. */
58
+ addressVariable?: string;
59
+ /** Where it stands: fractions of the box, from the stage directions. */
60
+ position?: {
61
+ x: number;
62
+ y: number;
63
+ };
64
+ /** What its application needs that is not set up, in sentences. */
65
+ setup: string[];
66
+ };
67
+ /** A scene as a page stages it. */
68
+ export type SceneStage = {
69
+ scene: SceneSpec;
70
+ entry: SceneStageMember;
71
+ /** The members the entry asks over A2A, in the cast's order. */
72
+ peers: SceneStageMember[];
73
+ /** Every member, the entry first. */
74
+ members: SceneStageMember[];
75
+ /** The edges between members: the team's `talks_to` over A2A. */
76
+ links: A2ATeamLink[];
77
+ /** Where each member stands, by its id. */
78
+ positions: Record<string, {
79
+ x: number;
80
+ y: number;
81
+ }>;
82
+ /** Whose balloon opens first. */
83
+ opensFirst: string;
84
+ /** What the audience is told: the line under the title. */
85
+ assumes: string;
86
+ };
87
+ /**
88
+ * Read a scene's stage: its members with their applications, who the entry
89
+ * is and whom it asks, the links, the positions. Refused when a cast member
90
+ * is an application the catalogue does not have, or the entry is not in the
91
+ * cast: a page cannot stage what it cannot draw.
92
+ */
93
+ export declare function sceneStageOf(scene: SceneSpec): SceneStage;
94
+ /** The port the examples serve Accounting on, as `npm run examples` starts it. */
95
+ export declare const ACCOUNTING_PORT = 8767;
96
+ /** The first port `serve_scenes.py` starts a runtime on for the other members. */
97
+ export declare const SCENE_PORTS_FROM = 8768;
98
+ /**
99
+ * The members on a runtime across the catalogue's scenes, each once, in
100
+ * the catalogue's order (a scene's cast in its order): what
101
+ * `examples/sales-accounting-a2a/serve_scenes.py` serves, in that order.
102
+ */
103
+ export declare function sceneRuntimeMembers(scenes?: readonly SceneSpec[]): string[];
104
+ /**
105
+ * The port each runtime member is served on locally: Accounting on
106
+ * {@link ACCOUNTING_PORT}, the others from {@link SCENE_PORTS_FROM} on, in
107
+ * the order of {@link sceneRuntimeMembers}. `serve_scenes.py` gives the
108
+ * same ports by the same rule.
109
+ */
110
+ export declare function sceneRuntimePorts(scenes?: readonly SceneSpec[]): Record<string, number>;
111
+ /** The variable the examples read a member's local address from: `VITE_A2A_CHANGE_DETECTION_URL`. */
112
+ export declare function sceneAddressVariable(appId: string): string;
113
+ /** Where a member is served over A2A on a local runtime at `port`. */
114
+ export declare function localA2AUrl(appId: string, port: number): string;
115
+ /**
116
+ * Where a member on a runtime is reached by the examples: its variable
117
+ * (`VITE_A2A_<MEMBER>_URL`) when set in `env`, else the local runtime
118
+ * `serve_scenes.py` serves it on.
119
+ */
120
+ export declare function sceneMemberUrl(appId: string, env: Record<string, string | undefined>, ports?: Record<string, number>): string;