@datalayer/agent-runtimes 1.3.85 → 1.3.86

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,13 +1,23 @@
1
1
  /**
2
- * A team of two over A2A, as a graph (React Flow): each member is its
3
- * character (`AssistantStage`), with its name and where it runs under it, and
4
- * one edge links the entry to the peer.
2
+ * A team over A2A, as a graph (React Flow): each member is its character
3
+ * (`AssistantStage`), with its name and where it runs under it, and an edge
4
+ * links the entry to each peer it asks — one peer (`peer`, a team of two)
5
+ * or several (`peers`, a team of N; LOOP A-08). The edges are the team's
6
+ * `talks_to` (`links`): a peer that talks to another peer has its edge
7
+ * drawn too, still, since the page runs the entry's asks only.
5
8
  *
6
- * The edge is still until a message travels on it. While the entry asks, it
9
+ * An edge is still until a message travels on it. While the entry asks, it
7
10
  * flows from the entry to the peer; while the peer answers, from the peer to
8
- * the entry ({@link A2ATeamFlow}, from the `A2APeerEvent` phases). A reader
9
- * who asks for no motion sees the same thing without the movement: the arrow
10
- * at the end the message goes to, and the word for it on the edge.
11
+ * the entry ({@link A2ATeamFlow}, from the `A2APeerEvent` phases; one flow
12
+ * per peer, `flows`). A reader who asks for no motion sees the same thing
13
+ * without the movement: the arrow at the end the message goes to, and the
14
+ * word for it on the edge.
15
+ *
16
+ * The members stand where the layout puts them: the entry on the left and
17
+ * the peers to its right, one under another — or where a scene's stage
18
+ * directions say (`positions`, fractions of the box; LOOP A-13): the
19
+ * distinct `x` values are the columns, left to right, the distinct `y`
20
+ * values the rows, top to bottom.
11
21
  *
12
22
  * A member's connections — the MCP servers it reaches, Odoo for Accounting —
13
23
  * hang under it, each a node half a member's size with the server's mark
@@ -108,17 +118,43 @@ export type A2ATeamGraphMember = {
108
118
  /** Its balloon's display was chosen in its menu: the page may keep it. */
109
119
  onBalloonDisplayChange?: (display: BalloonDisplay) => void;
110
120
  };
121
+ /** Who talks to whom over A2A: an edge of the graph. */
122
+ export type A2ATeamGraphLink = {
123
+ from: string;
124
+ to: string;
125
+ };
126
+ /** Where a member stands: fractions of the box, as a scene's stage says. */
127
+ export type A2ATeamGraphPosition = {
128
+ x: number;
129
+ y: number;
130
+ };
111
131
  export type A2ATeamGraphProps = {
112
132
  /** The member that asks: drawn on the left. */
113
133
  entry: A2ATeamGraphMember;
114
- /** The member asked over A2A: drawn on the right. */
115
- peer: A2ATeamGraphMember;
116
- /** Which way the link carries a message now. */
117
- flow: A2ATeamFlow;
118
- /** Whether the peer is reached: the edge is drawn faint until it is. */
119
- connected: boolean;
120
- /** What the edge says at rest: `A2A · <the peer's skill>`. */
134
+ /** The member asked over A2A: drawn on the right. A team of two. */
135
+ peer?: A2ATeamGraphMember;
136
+ /** The members asked over A2A, a team of N: after `peer` when both are given. */
137
+ peers?: readonly A2ATeamGraphMember[];
138
+ /** Which way the link to `peer` carries a message now. */
139
+ flow?: A2ATeamFlow;
140
+ /** Which way each peer's link carries a message now, by the peer's id. */
141
+ flows?: Record<string, A2ATeamFlow>;
142
+ /**
143
+ * Whether the peers are reached: an edge is drawn faint until its peer
144
+ * is. One answer for all, or one per peer id.
145
+ */
146
+ connected: boolean | Record<string, boolean>;
147
+ /** What an edge says at rest: `A2A · <the peer's skill>`. */
121
148
  label?: string;
149
+ /** What each peer's edge says at rest, by the peer's id; `label` unsaid. */
150
+ labels?: Record<string, string>;
151
+ /**
152
+ * The edges: the team's `talks_to` over A2A. Unsaid, the entry to each
153
+ * peer. An edge between two peers is drawn still.
154
+ */
155
+ links?: readonly A2ATeamGraphLink[];
156
+ /** Where each member stands, by its id (a scene's `stage.positions`). */
157
+ positions?: Record<string, A2ATeamGraphPosition>;
122
158
  /** The character's size, in pixels at full scale. */
123
159
  size?: number;
124
160
  /** The tool calls running now, to the members' connections (`useA2ATeam`). */
@@ -130,6 +166,36 @@ export type A2ATeamGraphProps = {
130
166
  */
131
167
  balloonRoom?: number;
132
168
  };
169
+ /**
170
+ * The handles an edge leaves and reaches a member by, named: the middle of
171
+ * its character on each side, chosen by where the other member stands.
172
+ */
173
+ export declare const MEMBER_HANDLES: {
174
+ readonly inLeft: "in-left";
175
+ readonly outRight: "out-right";
176
+ readonly inTop: "in-top";
177
+ readonly outBottom: "out-bottom";
178
+ readonly inRight: "in-right";
179
+ readonly outLeft: "out-left";
180
+ readonly inBottom: "in-bottom";
181
+ readonly outTop: "out-top";
182
+ readonly connections: "connections";
183
+ };
184
+ /**
185
+ * The handles for an edge from one place to another: rightward across,
186
+ * leftward across, or down or up when the other member is mostly below or
187
+ * above.
188
+ */
189
+ export declare function handlesBetween(from: {
190
+ x: number;
191
+ y: number;
192
+ }, to: {
193
+ x: number;
194
+ y: number;
195
+ }): {
196
+ sourceHandle: string;
197
+ targetHandle: string;
198
+ };
133
199
  /** How far a member was moved from its place, in the graph's coordinates. */
134
200
  export type MemberOffset = {
135
201
  dx: number;
@@ -152,6 +218,17 @@ export declare function connectionLabel(connection: Pick<A2ATeamConnection, 'lab
152
218
  export declare function connectionDetails(connection: Pick<A2ATeamConnection, 'name' | 'tools'>, member: string | undefined, calling?: string): string;
153
219
  /** The words on the edge for a flow. */
154
220
  export declare function flowWords(flow: A2ATeamFlow, entry: string, peer: string): string;
221
+ /**
222
+ * An arrowhead at the end a message goes to — the target's when `forward`,
223
+ * the source's otherwise — pointing along the edge.
224
+ */
225
+ export declare function arrowPath(source: {
226
+ x: number;
227
+ y: number;
228
+ }, target: {
229
+ x: number;
230
+ y: number;
231
+ }, forward: boolean): string;
155
232
  type CallData = {
156
233
  /** The tool being called, in words (`list invoices`); empty while none is. */
157
234
  calling: string;
@@ -160,8 +237,26 @@ type CallData = {
160
237
  export declare const CallEdge: import("react").NamedExoticComponent<EdgeProps<Edge<CallData>>>;
161
238
  /** What a member's call says, on its edge and to a screen reader. */
162
239
  export declare function callWords(member: string, connection: A2ATeamConnection, tool: string): string;
163
- /** The viewport that shows the graph's width whole in a box `width` wide, never larger than drawn. */
164
- export declare function teamViewport(width: number): Viewport;
165
- /** Draw a team of two over A2A, the link moving with what travels on it. */
166
- export declare function A2ATeamGraph({ entry, peer, flow, connected, label, size, calls, balloonRoom, }: A2ATeamGraphProps): JSX.Element;
240
+ /** The viewport that shows a graph `drawn` wide whole in a box `width` wide, never larger than drawn. */
241
+ export declare function teamViewport(width: number, drawn?: number): Viewport;
242
+ /** Where a member is laid out: its column and row, from the positions or the default. */
243
+ export type MemberPlace = {
244
+ column: number;
245
+ row: number;
246
+ };
247
+ /**
248
+ * Where each member stands, as columns and rows: from the positions given
249
+ * (the distinct `x` values are the columns left to right, the distinct `y`
250
+ * values the rows top to bottom), or the entry in the first column and the
251
+ * peers one under another in the second.
252
+ */
253
+ export declare function placeMembers(entryId: string, peerIds: readonly string[], positions?: Record<string, A2ATeamGraphPosition>): {
254
+ places: Record<string, MemberPlace>;
255
+ columns: number;
256
+ rows: number;
257
+ };
258
+ /** The one flow the graph reports for all its edges: asking before answering before still. */
259
+ export declare function flowOfAll(flows: readonly A2ATeamFlow[]): A2ATeamFlow;
260
+ /** Draw a team over A2A, each link moving with what travels on it. */
261
+ export declare function A2ATeamGraph({ entry, peer, peers: more, flow, flows, connected, label, labels, links, positions, size, calls, balloonRoom, }: A2ATeamGraphProps): JSX.Element;
167
262
  export default A2ATeamGraph;
@@ -21,6 +21,8 @@ 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
28
  /** Between a member and its connections. */
@@ -37,6 +39,50 @@ const HANDLE = (size) => ({
37
39
  minHeight: 0,
38
40
  border: 0,
39
41
  });
42
+ /**
43
+ * The handles an edge leaves and reaches a member by, named: the middle of
44
+ * its character on each side, chosen by where the other member stands.
45
+ */
46
+ export const MEMBER_HANDLES = {
47
+ inLeft: 'in-left',
48
+ outRight: 'out-right',
49
+ inTop: 'in-top',
50
+ outBottom: 'out-bottom',
51
+ inRight: 'in-right',
52
+ outLeft: 'out-left',
53
+ inBottom: 'in-bottom',
54
+ outTop: 'out-top',
55
+ connections: 'connections',
56
+ };
57
+ /**
58
+ * The handles for an edge from one place to another: rightward across,
59
+ * leftward across, or down or up when the other member is mostly below or
60
+ * above.
61
+ */
62
+ export function handlesBetween(from, to) {
63
+ const dx = to.x - from.x;
64
+ const dy = to.y - from.y;
65
+ if (Math.abs(dx) >= Math.abs(dy)) {
66
+ return dx >= 0
67
+ ? {
68
+ sourceHandle: MEMBER_HANDLES.outRight,
69
+ targetHandle: MEMBER_HANDLES.inLeft,
70
+ }
71
+ : {
72
+ sourceHandle: MEMBER_HANDLES.outLeft,
73
+ targetHandle: MEMBER_HANDLES.inRight,
74
+ };
75
+ }
76
+ return dy >= 0
77
+ ? {
78
+ sourceHandle: MEMBER_HANDLES.outBottom,
79
+ targetHandle: MEMBER_HANDLES.inTop,
80
+ }
81
+ : {
82
+ sourceHandle: MEMBER_HANDLES.outTop,
83
+ targetHandle: MEMBER_HANDLES.inBottom,
84
+ };
85
+ }
40
86
  /**
41
87
  * The members as they are now, by id. Not in the nodes' data: a node whose
42
88
  * object changes is taken by React Flow for a new one, measured again, and
@@ -159,7 +205,7 @@ const MemberNode = memo(function MemberNode({ data, }) {
159
205
  pointerEvents: 'all',
160
206
  }, "data-team-member": member.id, "data-member-balloon": shown, "data-member-moved": offset.dx || offset.dy
161
207
  ? `${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,
208
+ : 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
209
  // In the node, not over the page; the balloon opens toward the
164
210
  // middle of the graph: rightward from the left member.
165
211
  place: side === 'left'
@@ -236,7 +282,7 @@ const MemberNode = memo(function MemberNode({ data, }) {
236
282
  stayPut: true,
237
283
  // Named as the member, not its character: `Sales (in your
238
284
  // 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] }));
285
+ 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.Bottom, style: { ...HANDLE(size), top: 'auto', bottom: 0 }, isConnectable: false })) : null] }));
240
286
  });
241
287
  /** A member's accessible name: its name, and where it runs. */
242
288
  export function memberLabel(member) {
@@ -317,14 +363,29 @@ export function flowWords(flow, entry, peer) {
317
363
  : '';
318
364
  }
319
365
  const ARROW = 9;
366
+ /**
367
+ * An arrowhead at the end a message goes to — the target's when `forward`,
368
+ * the source's otherwise — pointing along the edge.
369
+ */
370
+ export function arrowPath(source, target, forward) {
371
+ const tip = forward ? target : source;
372
+ const from = forward ? source : target;
373
+ const length = Math.hypot(tip.x - from.x, tip.y - from.y) || 1;
374
+ // Along the edge, toward the tip; and across it.
375
+ const ax = (tip.x - from.x) / length;
376
+ const ay = (tip.y - from.y) / length;
377
+ const bx = tip.x - ax * ARROW;
378
+ const by = tip.y - ay * ARROW;
379
+ const px = -ay * (ARROW / 1.6);
380
+ const py = ax * (ARROW / 1.6);
381
+ return `M ${tip.x},${tip.y} L ${bx + px},${by + py} L ${bx - px},${by - py} Z`;
382
+ }
320
383
  const LinkEdge = memo(function LinkEdge({ id, sourceX, sourceY, targetX, targetY, data, }) {
321
384
  const flow = data?.flow ?? 'still';
322
385
  const path = `M ${sourceX},${sourceY} L ${targetX},${targetY}`;
323
- // The arrow at the end the message goes to.
386
+ // The arrow at the end the message goes to, along the edge.
324
387
  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`;
388
+ const arrow = arrowPath({ x: sourceX, y: sourceY }, { x: targetX, y: targetY }, towardPeer);
328
389
  return (_jsxs(_Fragment, { children: [_jsx(BaseEdge, { id: id, path: path, style: {
329
390
  stroke: data?.connected
330
391
  ? 'var(--borderColor-accent-emphasis, #0969da)'
@@ -368,13 +429,60 @@ function connectionsX(memberX, count) {
368
429
  export function callWords(member, connection, tool) {
369
430
  return `${member} calls ${connection.label} · ${toolWords(tool, connection)}`;
370
431
  }
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 };
432
+ /** The viewport that shows a graph `drawn` wide whole in a box `width` wide, never larger than drawn. */
433
+ export function teamViewport(width, drawn = WIDTH) {
434
+ const zoom = Math.min(1, width / drawn);
435
+ return { zoom, x: (width - drawn * zoom) / 2, y: 0 };
375
436
  }
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, }) {
437
+ /**
438
+ * Where each member stands, as columns and rows: from the positions given
439
+ * (the distinct `x` values are the columns left to right, the distinct `y`
440
+ * values the rows top to bottom), or the entry in the first column and the
441
+ * peers one under another in the second.
442
+ */
443
+ export function placeMembers(entryId, peerIds, positions) {
444
+ const ids = [entryId, ...peerIds];
445
+ const placed = positions ?? {};
446
+ if (ids.every(id => placed[id])) {
447
+ const xs = [...new Set(ids.map(id => placed[id].x))].sort((a, b) => a - b);
448
+ const ys = [...new Set(ids.map(id => placed[id].y))].sort((a, b) => a - b);
449
+ return {
450
+ places: Object.fromEntries(ids.map(id => [
451
+ id,
452
+ { column: xs.indexOf(placed[id].x), row: ys.indexOf(placed[id].y) },
453
+ ])),
454
+ columns: xs.length,
455
+ rows: ys.length,
456
+ };
457
+ }
458
+ return {
459
+ places: {
460
+ [entryId]: { column: 0, row: 0 },
461
+ ...Object.fromEntries(peerIds.map((id, at) => [id, { column: 1, row: at }])),
462
+ },
463
+ columns: peerIds.length ? 2 : 1,
464
+ rows: Math.max(1, peerIds.length),
465
+ };
466
+ }
467
+ /** The one flow the graph reports for all its edges: asking before answering before still. */
468
+ export function flowOfAll(flows) {
469
+ return flows.includes('asking')
470
+ ? 'asking'
471
+ : flows.includes('answering')
472
+ ? 'answering'
473
+ : 'still';
474
+ }
475
+ const NO_PEERS = [];
476
+ /** Draw a team over A2A, each link moving with what travels on it. */
477
+ export function A2ATeamGraph({ entry, peer, peers: more = NO_PEERS, flow, flows, connected, label = 'A2A', labels, links, positions, size = 96, calls = [], balloonRoom = BALLOON_ROOM, }) {
478
+ const peers = useMemo(() => [...(peer ? [peer] : []), ...more], [peer, more]);
479
+ const members = useMemo(() => [entry, ...peers], [entry, peers]);
480
+ const memberOf = (id) => members.find(member => member.id === id);
481
+ const flowOf = (peerId) => flows?.[peerId] ??
482
+ (peer && peerId === peer.id ? (flow ?? 'still') : 'still');
483
+ const connectedOf = (peerId) => typeof connected === 'boolean' ? connected : Boolean(connected[peerId]);
484
+ const labelOf = (peerId) => labels?.[peerId] ?? label;
485
+ const edgesWanted = links ?? peers.map(one => ({ from: entry.id, to: one.id }));
378
486
  const box = useRef(null);
379
487
  const [width, setWidth] = useState(0);
380
488
  useEffect(() => {
@@ -391,7 +499,11 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
391
499
  observer.observe(element);
392
500
  return () => observer.disconnect();
393
501
  }, []);
394
- const viewport = useMemo(() => teamViewport(width || WIDTH), [width]);
502
+ // Where the members stand, as columns and rows, and the width that takes.
503
+ const peerIds = peers.map(one => one.id);
504
+ const placing = placeMembers(entry.id, peerIds, positions);
505
+ const drawn = Math.max(WIDTH, placing.columns * NODE_WIDTH + (placing.columns - 1) * GAP);
506
+ const viewport = useMemo(() => teamViewport(width || drawn, drawn), [width, drawn]);
395
507
  // The members' height: the character, its name and where it runs.
396
508
  const memberHeight = size + 56;
397
509
  const connectionSize = Math.round(size / 2);
@@ -399,43 +511,47 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
399
511
  // and room under it before the graph's edge.
400
512
  const connectionHeight = connectionSize + 58;
401
513
  // The connections' ids, member by member: the nodes change only when they do.
402
- const placed = [entry, peer].map(member => ({
514
+ const placed = members.map(member => ({
403
515
  member: member.id,
404
516
  connections: (member.connections ?? []).map(connection => connection.id),
405
517
  }));
406
518
  const placedKey = JSON.stringify(placed);
407
519
  const hasConnections = placed.some(({ connections }) => connections.length);
408
520
  // A notebook in a balloon: more room above the members.
409
- const room = [entry, peer].some(member => member.persona.notebook)
521
+ const room = members.some(member => member.persona.notebook)
410
522
  ? Math.max(NOTEBOOK_BALLOON_ROOM, balloonRoom)
411
523
  : balloonRoom;
412
- const height = Math.ceil((room +
413
- memberHeight +
414
- (hasConnections ? CONNECTION_GAP + connectionHeight : 0)) *
415
- viewport.zoom);
524
+ // Under a member: its connections, when any member has some.
525
+ const below = hasConnections ? CONNECTION_GAP + connectionHeight : 0;
526
+ const rowHeight = memberHeight + below;
527
+ const total = room + placing.rows * rowHeight + (placing.rows - 1) * ROW_GAP;
528
+ const height = Math.ceil(total * viewport.zoom);
529
+ // Where each member is laid out, before it is moved.
530
+ const layoutOf = (id) => {
531
+ const place = placing.places[id] ?? { column: 0, row: 0 };
532
+ return {
533
+ x: place.column * (NODE_WIDTH + GAP),
534
+ y: room + place.row * (rowHeight + ROW_GAP),
535
+ };
536
+ };
416
537
  // Where the members were moved, kept inside the graph's box.
417
- const total = room +
418
- memberHeight +
419
- (hasConnections ? CONNECTION_GAP + connectionHeight : 0);
420
538
  const boundsOf = (id) => {
421
- const layoutX = id === entry.id ? 0 : NODE_WIDTH + GAP;
422
- const below = placed.find(({ member }) => member === id)?.connections.length
539
+ const layout = layoutOf(id);
540
+ const under = placed.find(({ member }) => member === id)?.connections.length
423
541
  ? CONNECTION_GAP + connectionHeight
424
542
  : 0;
425
543
  return {
426
- minDx: -layoutX,
427
- maxDx: WIDTH - NODE_WIDTH - layoutX,
428
- minDy: -room,
429
- maxDy: total - room - memberHeight - below,
544
+ minDx: -layout.x,
545
+ maxDx: drawn - NODE_WIDTH - layout.x,
546
+ minDy: -layout.y,
547
+ maxDy: total - layout.y - memberHeight - under,
430
548
  };
431
549
  };
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
- }));
550
+ const [moved, setMoved] = useState(() => Object.fromEntries(members.map(member => [
551
+ member.id,
552
+ MOVED.get(movedKey(member.id)) ?? AT_PLACE,
553
+ ])));
436
554
  const offsetOf = (id) => clampOffset(moved[id] ?? AT_PLACE, boundsOf(id));
437
- const entryOffset = offsetOf(entry.id);
438
- const peerOffset = offsetOf(peer.id);
439
555
  const movingValue = {
440
556
  zoom: viewport.zoom,
441
557
  offsetOf,
@@ -449,66 +565,55 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
449
565
  setMoved(current => ({ ...current, [id]: AT_PLACE }));
450
566
  },
451
567
  };
452
- const nodes = useMemo(() => [
568
+ // Each member's place and move, and its connections: the nodes are made
569
+ // again only when one of them changes.
570
+ const layout = members.map(member => {
571
+ const at = layoutOf(member.id);
572
+ const offset = offsetOf(member.id);
573
+ return {
574
+ id: member.id,
575
+ x: at.x + offset.dx,
576
+ y: at.y + offset.dy,
577
+ // Its balloon opens toward the middle of the graph.
578
+ side: at.x + NODE_WIDTH / 2 <= drawn / 2
579
+ ? 'left'
580
+ : 'right',
581
+ connections: (member.connections ?? []).map(connection => connection.id),
582
+ };
583
+ });
584
+ const layoutKey = JSON.stringify(layout);
585
+ const nodes = useMemo(() => JSON.parse(layoutKey).flatMap(({ id, x, y, side, connections }) => [
453
586
  {
454
- id: entry.id,
587
+ id,
455
588
  type: 'member',
456
- position: { x: entryOffset.dx, y: room + entryOffset.dy },
589
+ position: { x, y },
457
590
  width: NODE_WIDTH,
458
591
  height: memberHeight,
459
592
  draggable: false,
460
593
  selectable: false,
461
594
  // Above the edge and its label: a balloon overflows its node.
462
595
  zIndex: 10,
463
- data: { id: entry.id, side: 'left', size },
596
+ data: { id, side, size },
464
597
  },
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 },
478
- },
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),
598
+ // A member's connections move with it.
599
+ ...connections.map((connection, at) => {
600
+ const xs = connectionsX(x, connections.length);
601
+ return {
602
+ id: connectionNodeId(id, connection),
485
603
  type: 'connection',
486
604
  position: {
487
605
  x: xs[at],
488
- y: room + memberHeight + CONNECTION_GAP + moveBy.dy,
606
+ y: y + memberHeight + CONNECTION_GAP,
489
607
  },
490
608
  width: CONNECTION_WIDTH,
491
609
  height: connectionHeight,
492
610
  draggable: false,
493
611
  selectable: false,
494
612
  zIndex: 5,
495
- data: { member, connection, size: connectionSize },
496
- }));
613
+ data: { member: id, connection, size: connectionSize },
614
+ };
497
615
  }),
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
- ]);
616
+ ]), [layoutKey, size, memberHeight, connectionSize, connectionHeight]);
512
617
  // The notebook open under the graph runs on a Pyodide kernel in the page:
513
618
  // the entry's sandbox, while it is open.
514
619
  const notebookKernel = useSignalValue(teamNotebookKernel);
@@ -526,16 +631,23 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
526
631
  }
527
632
  : { kind: 'browser', status: 'not running' },
528
633
  }, [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);
634
+ const membersById = useMemo(() => ({
635
+ [entry.id]: entryMember,
636
+ ...Object.fromEntries(peers.map(one => [one.id, one])),
637
+ }), [entry.id, entryMember, peers]);
638
+ // The words for each flow, for the edges and for a screen reader.
639
+ const wordsOf = (link) => link.from === entry.id
640
+ ? flowWords(flowOf(link.to), entry.name, memberOf(link.to)?.name ?? link.to)
641
+ : '';
642
+ const words = edgesWanted.map(wordsOf).filter(Boolean);
531
643
  // The tool each connection is answering now, in words, by its node's id.
532
644
  const busy = useMemo(() => {
533
645
  const byNode = {};
534
646
  const said = [];
535
647
  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) {
648
+ const member = members.find(one => one.id === call.member);
649
+ const connection = member?.connections?.find(known => known.id === call.connection);
650
+ if (!member || !connection) {
539
651
  continue;
540
652
  }
541
653
  const node = connectionNodeId(member.id, connection.id);
@@ -543,28 +655,35 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
543
655
  said.push(callWords(member.name, connection, call.tool));
544
656
  }
545
657
  return { byNode, said: [...new Set(said)] };
546
- }, [calls, entry, peer]);
658
+ }, [calls, members]);
659
+ const linkKey = JSON.stringify(edgesWanted.map(link => ({
660
+ ...link,
661
+ flow: link.from === entry.id ? flowOf(link.to) : 'still',
662
+ connected: connectedOf(link.to),
663
+ label: wordsOf(link)
664
+ ? `${labelOf(link.to)} · ${wordsOf(link)}`
665
+ : labelOf(link.to),
666
+ ...handlesBetween(layoutOf(link.from), layoutOf(link.to)),
667
+ })));
547
668
  const edges = useMemo(() => [
548
- {
549
- id: `${entry.id}->${peer.id}`,
550
- source: entry.id,
551
- target: peer.id,
669
+ ...JSON.parse(linkKey).map(({ from, to, flow: linkFlow, connected: reached, label: said, sourceHandle, targetHandle, }) => ({
670
+ id: `${from}->${to}`,
671
+ source: from,
672
+ sourceHandle,
673
+ target: to,
674
+ targetHandle,
552
675
  type: 'a2a',
553
676
  selectable: false,
554
677
  focusable: false,
555
- data: {
556
- flow,
557
- connected,
558
- label: words ? `${label} · ${words}` : label,
559
- },
560
- },
678
+ data: { flow: linkFlow, connected: reached, label: said },
679
+ })),
561
680
  ...JSON.parse(placedKey).flatMap(({ member, connections }) => connections.map(connection => {
562
681
  const node = connectionNodeId(member, connection);
563
682
  const calling = busy.byNode[node] ?? '';
564
683
  return {
565
684
  id: `${member}->${node}`,
566
685
  source: member,
567
- sourceHandle: 'connections',
686
+ sourceHandle: MEMBER_HANDLES.connections,
568
687
  target: node,
569
688
  type: 'mcp',
570
689
  selectable: false,
@@ -572,9 +691,10 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
572
691
  data: { calling },
573
692
  };
574
693
  })),
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: {
694
+ ], [linkKey, placedKey, busy]);
695
+ const heard = [...words, ...busy.said].filter(Boolean).join('. ');
696
+ const flowNow = flowOfAll(peers.map(one => flowOf(one.id)));
697
+ 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
698
  width: '100%',
579
699
  height,
580
700
  position: 'relative',
@@ -626,8 +746,8 @@ export function A2ATeamGraph({ entry, peer, flow, connected, label = 'A2A', size
626
746
  overflow: 'hidden',
627
747
  clip: 'rect(0 0 0 0)',
628
748
  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' },
749
+ }, 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
750
  // 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` }) }) }) })] }));
751
+ proOptions: { hideAttribution: true }, "aria-label": `${entry.name} and ${peers.map(one => one.name).join(', ')}, over A2A` }) }) }) })] }));
632
752
  }
633
753
  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';