@principal-ai/subsystems-react 0.25.1 → 0.26.1

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.
Files changed (33) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.d.ts +5 -0
  6. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.d.ts.map +1 -0
  7. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.js +4 -0
  8. package/dist/stories/Subsystem/ComponentGraph/aggregateViewFixture.js.map +1 -0
  9. package/dist/subsystem/SubsystemAggregateGraph.d.ts +70 -0
  10. package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -0
  11. package/dist/subsystem/SubsystemAggregateGraph.js +386 -0
  12. package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -0
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
  14. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  15. package/dist/subsystem/SubsystemComponentGraph.js +25 -4
  16. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  17. package/dist/subsystem/graphChrome.d.ts +27 -0
  18. package/dist/subsystem/graphChrome.d.ts.map +1 -0
  19. package/dist/subsystem/graphChrome.js +30 -0
  20. package/dist/subsystem/graphChrome.js.map +1 -0
  21. package/dist/subsystem/model.d.ts +7 -0
  22. package/dist/subsystem/model.d.ts.map +1 -1
  23. package/dist/subsystem/model.js +35 -0
  24. package/dist/subsystem/model.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/index.ts +8 -0
  27. package/src/stories/Subsystem/ComponentGraph/AggregateFrameGraph.stories.tsx +61 -0
  28. package/src/stories/Subsystem/ComponentGraph/aggregateViewFixture.ts +8 -0
  29. package/src/subsystem/SubsystemAggregateGraph.tsx +610 -0
  30. package/src/subsystem/SubsystemComponentGraph.tsx +29 -18
  31. package/src/subsystem/graphChrome.tsx +41 -0
  32. package/src/subsystem/model.test.ts +17 -0
  33. package/src/subsystem/model.ts +34 -0
@@ -0,0 +1,610 @@
1
+ /**
2
+ * SubsystemAggregateGraph — frame-level overview canvas.
3
+ *
4
+ * Same stack and interaction language as SubsystemComponentGraph
5
+ * (React Flow + ELK + custom nodes + select/hover), but purpose-built for
6
+ * composed views: nodes are process / module frames (plus one ungrouped
7
+ * bucket), edges are the deduped walkthrough flows between them.
8
+ *
9
+ * Deliberately leaner than the component graph: fixed node sizes (no
10
+ * measurement passes), flat ELK layout (no compound groups, so the
11
+ * double-parent failure mode is structurally impossible), no edge labels,
12
+ * no walkthrough playback, no drawers. Frame detail (members, models)
13
+ * renders in a side panel.
14
+ */
15
+
16
+ import { useCallback, useEffect, useMemo, useState } from 'react';
17
+ import {
18
+ Edge,
19
+ Handle,
20
+ MarkerType,
21
+ Node,
22
+ NodeProps,
23
+ Position,
24
+ ReactFlow,
25
+ ReactFlowProvider,
26
+ useReactFlow,
27
+ } from '@xyflow/react';
28
+ import { useTheme } from '@principal-ade/industry-theme';
29
+ import { computeElkLayout } from '../utils/elkLayout';
30
+ import { describeConstructBreakdown, MECHANISM_COLOR } from './model';
31
+ import { GRAPH_NAV_PROPS, GraphChrome } from './graphChrome';
32
+
33
+ export interface AggregateFrameMember {
34
+ alias: string;
35
+ name: string;
36
+ file: string;
37
+ construct: string;
38
+ models: string[];
39
+ }
40
+
41
+ export interface AggregateFrameNode {
42
+ id: string;
43
+ kind: 'module' | 'process' | 'external' | 'ungrouped';
44
+ key: string;
45
+ label: string;
46
+ /**
47
+ * Enclosing boundary: process code frames nest in their process frame,
48
+ * every external frame in the single external boundary. Absent =
49
+ * root-level (module with no process, ungrouped).
50
+ */
51
+ group?: { key: string; kind: 'process' | 'external' };
52
+ members: AggregateFrameMember[];
53
+ models: string[];
54
+ }
55
+
56
+ export interface AggregateFrameEdge {
57
+ from: string;
58
+ to: string;
59
+ mechanisms: string[];
60
+ walkthroughIds: string[];
61
+ steps: number;
62
+ /** Real member endpoint behind a hub-routed edge (see aggregate). */
63
+ source?: string;
64
+ target?: string;
65
+ }
66
+
67
+ /** Intake/outtake hub for a multi-frame boundary. */
68
+ export interface AggregateHub {
69
+ id: string;
70
+ group: { key: string; kind: 'process' | 'external' };
71
+ kind: 'intake' | 'outtake';
72
+ label: string;
73
+ }
74
+
75
+ export interface SubsystemAggregateGraphProps {
76
+ frames: AggregateFrameNode[];
77
+ edges: AggregateFrameEdge[];
78
+ /** Intake/outtake hubs; rendered as compact pills inside their boundary. */
79
+ hubs?: AggregateHub[];
80
+ title?: string;
81
+ onSelectFrame?: (id: string | null) => void;
82
+ onOpenFile?: (path: string) => void;
83
+ }
84
+
85
+ const NODE_W = 232;
86
+ const NODE_H = 78;
87
+
88
+ function edgeColor(mechanism: string | undefined, fallback: string): string {
89
+ if (!mechanism) return fallback;
90
+ return (MECHANISM_COLOR as Record<string, string>)[mechanism] ?? fallback;
91
+ }
92
+
93
+ function AggregateFrameGroupView(
94
+ props: NodeProps<Node<{ label: string; kind: 'process' | 'external' }>>,
95
+ ) {
96
+ const { theme } = useTheme();
97
+ const external = props.data.kind === 'external';
98
+ const border = external
99
+ ? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
100
+ : (theme.colors.border ?? '#333');
101
+ return (
102
+ <div
103
+ style={{
104
+ width: '100%',
105
+ height: '100%',
106
+ boxSizing: 'border-box',
107
+ borderRadius: 12,
108
+ border: `2px ${external ? 'solid' : 'dashed'} ${border}`,
109
+ background: 'transparent',
110
+ pointerEvents: 'none',
111
+ }}
112
+ >
113
+ <span
114
+ style={{
115
+ position: 'absolute',
116
+ top: -11,
117
+ left: 12,
118
+ fontFamily: theme.fonts.monospace,
119
+ fontSize: theme.fontSizes[1],
120
+ letterSpacing: 0.5,
121
+ textTransform: 'uppercase',
122
+ color: external
123
+ ? border
124
+ : (theme.colors.textMuted ?? theme.colors.textSecondary),
125
+ background: theme.colors.background,
126
+ padding: '0 8px',
127
+ whiteSpace: 'nowrap',
128
+ }}
129
+ >
130
+ {props.data.label}
131
+ </span>
132
+ </div>
133
+ );
134
+ }
135
+
136
+ const HUB_W = 96;
137
+ const HUB_H = 30;
138
+
139
+ function AggregateHubNodeView(
140
+ props: NodeProps<Node<{ hub: AggregateHub }>>,
141
+ ) {
142
+ const { theme } = useTheme();
143
+ const { hub } = props.data;
144
+ const color =
145
+ hub.group.kind === 'external'
146
+ ? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
147
+ : (theme.colors.border ?? '#333');
148
+ return (
149
+ <div
150
+ style={{
151
+ width: HUB_W,
152
+ height: HUB_H,
153
+ boxSizing: 'border-box',
154
+ display: 'flex',
155
+ alignItems: 'center',
156
+ justifyContent: 'center',
157
+ borderRadius: HUB_H / 2,
158
+ border: `1px solid ${color}`,
159
+ background: theme.colors.background,
160
+ color: theme.colors.textMuted ?? theme.colors.textSecondary,
161
+ fontFamily: theme.fonts.monospace,
162
+ fontSize: theme.fontSizes[0],
163
+ letterSpacing: 0.4,
164
+ whiteSpace: 'nowrap',
165
+ overflow: 'hidden',
166
+ textOverflow: 'ellipsis',
167
+ }}
168
+ title={hub.label}
169
+ >
170
+ {hub.kind === 'intake' ? '▸ in' : 'out ▸'}
171
+ <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
172
+ <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
173
+ </div>
174
+ );
175
+ }
176
+
177
+ function AggregateFrameNodeView(
178
+ props: NodeProps<
179
+ Node<{ frame: AggregateFrameNode; selected: boolean; dimmed: boolean; nested?: boolean }>
180
+ >,
181
+ ) {
182
+ const { theme } = useTheme();
183
+ const { data } = props;
184
+ const f = data.frame;
185
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
186
+ // Process buckets read as sub-regions, not entities: dashed boundary
187
+ // chrome and a flat fill, mirroring the group frames. Externals are real
188
+ // systems — solid chrome like other nodes, just without a module tag.
189
+ // Module/ungrouped boxes keep solid node chrome.
190
+ const boundaryStyled = f.kind === 'process';
191
+ // A process box nested inside its own process boundary would otherwise
192
+ // repeat the boundary's name — it holds that process's module-less
193
+ // members, so it says so. Root-level boxes keep their key for context.
194
+ // External boxes never nest in a same-named frame (their boundary is the
195
+ // shared `external` one), so they always keep their system name.
196
+ const title = f.kind === 'process' && data.nested ? 'unframed' : f.label;
197
+ const fullTitle =
198
+ f.kind === 'process' && data.nested ? `${f.label} · unframed members` : f.label;
199
+ return (
200
+ <div
201
+ style={{
202
+ width: NODE_W,
203
+ height: NODE_H,
204
+ boxSizing: 'border-box',
205
+ display: 'flex',
206
+ flexDirection: 'column',
207
+ justifyContent: 'center',
208
+ gap: 2,
209
+ padding: '8px 12px',
210
+ borderRadius: 8,
211
+ // The unframed bucket paints the canvas background rather than
212
+ // staying transparent, so routed edges don't show through its label.
213
+ background: boundaryStyled
214
+ ? theme.colors.background
215
+ : (theme.colors.backgroundSecondary ?? theme.colors.background),
216
+ border: `2px ${boundaryStyled ? 'dashed' : 'solid'} ${
217
+ data.selected ? theme.colors.primary : (theme.colors.border ?? '#333')
218
+ }`,
219
+ boxShadow: boundaryStyled ? 'none' : '0 1px 4px rgba(0,0,0,0.25)',
220
+ opacity: data.dimmed ? 0.35 : 1,
221
+ transition: 'opacity 120ms ease, border-color 120ms ease',
222
+ cursor: 'pointer',
223
+ fontFamily: theme.fonts.body,
224
+ }}
225
+ >
226
+ {f.kind === 'module' && (
227
+ <span
228
+ style={{
229
+ fontFamily: theme.fonts.monospace,
230
+ fontSize: theme.fontSizes[0],
231
+ letterSpacing: 0.5,
232
+ textTransform: 'uppercase',
233
+ color: theme.colors.accent ?? theme.colors.info,
234
+ whiteSpace: 'nowrap',
235
+ overflow: 'hidden',
236
+ textOverflow: 'ellipsis',
237
+ }}
238
+ >
239
+ module
240
+ </span>
241
+ )}
242
+ <span
243
+ style={{
244
+ fontWeight: 600,
245
+ fontSize: theme.fontSizes[2],
246
+ color: theme.colors.text,
247
+ whiteSpace: 'nowrap',
248
+ overflow: 'hidden',
249
+ textOverflow: 'ellipsis',
250
+ }}
251
+ title={fullTitle}
252
+ >
253
+ {title}
254
+ </span>
255
+ <span
256
+ style={{
257
+ fontFamily: theme.fonts.monospace,
258
+ fontSize: theme.fontSizes[0],
259
+ color: muted,
260
+ whiteSpace: 'nowrap',
261
+ overflow: 'hidden',
262
+ textOverflow: 'ellipsis',
263
+ }}
264
+ >
265
+ {/* Boxes describe their contents (constructs), not their container —
266
+ the frame boundary already says which process/module this is. */}
267
+ {describeConstructBreakdown(f.members.map((m) => m.construct))}
268
+ {f.models.length > 0 ? ` · ${f.models.length} model${f.models.length === 1 ? '' : 's'}` : ''}
269
+ </span>
270
+ <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
271
+ <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
272
+ </div>
273
+ );
274
+ }
275
+
276
+ const nodeTypes = {
277
+ 'aggregate-frame': AggregateFrameNodeView,
278
+ 'aggregate-frame-group': AggregateFrameGroupView,
279
+ 'aggregate-hub': AggregateHubNodeView,
280
+ };
281
+
282
+ function Inner({
283
+ frames,
284
+ edges,
285
+ hubs = [],
286
+ title,
287
+ onSelectFrame,
288
+ onOpenFile,
289
+ }: SubsystemAggregateGraphProps) {
290
+ const { theme } = useTheme();
291
+ const { fitView } = useReactFlow();
292
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
293
+ const [nodes, setNodes] = useState<Node[]>([]);
294
+ const [rfEdges, setRfEdges] = useState<Edge[]>([]);
295
+ const [ready, setReady] = useState(false);
296
+ const [selectedId, setSelectedId] = useState<string | null>(null);
297
+ const [hoveredId, setHoveredId] = useState<string | null>(null);
298
+
299
+ const layoutKey = useMemo(
300
+ () =>
301
+ [
302
+ ...frames.map((f) => f.id).sort(),
303
+ ...edges.map((e) => `${e.from}\0${e.to}\0${e.steps}`).sort(),
304
+ ].join('\n'),
305
+ [frames, edges, hubs],
306
+ );
307
+
308
+ useEffect(() => {
309
+ let alive = true;
310
+ setReady(false);
311
+ setSelectedId(null);
312
+ // Compound process frames: each box is parented to exactly one process
313
+ // group (its majority process); process member-boxes to their own
314
+ // process; the rest stays root-level. Exclusive assignment by
315
+ // construction — the double-parent failure mode cannot occur here.
316
+ const groupIdFor = (kind: 'process' | 'external', key: string) =>
317
+ `frame:${kind}:${key}`;
318
+ const groupDefs = new Map<string, { id: string; kind: 'process' | 'external'; key: string }>();
319
+ for (const f of frames) {
320
+ if (!f.group) continue;
321
+ const id = groupIdFor(f.group.kind, f.group.key);
322
+ if (!groupDefs.has(id)) groupDefs.set(id, { id, ...f.group });
323
+ }
324
+ const rfNodes: Node[] = [
325
+ ...frames.map((f, i) => ({
326
+ id: f.id,
327
+ type: 'aggregate-frame',
328
+ // Pre-ELK grid slot; replaced by ELK positions below.
329
+ position: { x: (i % 4) * (NODE_W + 60), y: Math.floor(i / 4) * (NODE_H + 40) },
330
+ width: NODE_W,
331
+ height: NODE_H,
332
+ ...(f.group ? { parentId: groupIdFor(f.group.kind, f.group.key) } : {}),
333
+ data: { frame: f, selected: false, dimmed: false },
334
+ })),
335
+ ...hubs.map((h) => ({
336
+ id: h.id,
337
+ type: 'aggregate-hub',
338
+ position: { x: 0, y: 0 },
339
+ width: HUB_W,
340
+ height: HUB_H,
341
+ parentId: groupIdFor(h.group.kind, h.group.key),
342
+ data: { hub: h },
343
+ })),
344
+ ];
345
+ const groups = [...groupDefs.values()].map((g) => ({
346
+ id: g.id,
347
+ memberIds: [
348
+ ...frames
349
+ .filter((f) => f.group?.kind === g.kind && f.group?.key === g.key)
350
+ .map((f) => f.id),
351
+ ...hubs
352
+ .filter((h) => h.group.kind === g.kind && h.group.key === g.key)
353
+ .map((h) => h.id),
354
+ ],
355
+ }));
356
+ const rfEdges: Edge[] = edges
357
+ .filter((e) => e.from !== e.to)
358
+ .map((e) => ({
359
+ id: `${e.from}\0${e.to}`,
360
+ source: e.from,
361
+ target: e.to,
362
+ style: {
363
+ stroke: edgeColor(e.mechanisms[0], muted),
364
+ strokeWidth: 1 + Math.min(e.steps, 6) * 0.5,
365
+ },
366
+ markerEnd: {
367
+ type: MarkerType.ArrowClosed,
368
+ color: edgeColor(e.mechanisms[0], muted),
369
+ width: 20,
370
+ height: 20,
371
+ },
372
+ data: { edge: e },
373
+ }));
374
+ void computeElkLayout(rfNodes, rfEdges, {
375
+ direction: 'RIGHT',
376
+ // Reposition (never keep the placeholder grid) and lay out compound
377
+ // process frames.
378
+ preserveNodePositions: false,
379
+ groups,
380
+ })
381
+ .then((result) => {
382
+ if (!alive) return;
383
+ const positioned = new Map(result.nodes.map((n) => [n.id, n]));
384
+ const builtGroups = new Set(result.groupBounds.keys());
385
+ const shells: Node[] = [];
386
+ for (const [id, b] of result.groupBounds) {
387
+ const def = groupDefs.get(id);
388
+ shells.push({
389
+ id,
390
+ type: 'aggregate-frame-group',
391
+ position: { x: b.x, y: b.y },
392
+ width: Math.max(200, b.width),
393
+ height: Math.max(120, b.height),
394
+ draggable: false,
395
+ selectable: false,
396
+ data: {
397
+ label: def?.key ?? id,
398
+ kind: def?.kind ?? 'process',
399
+ },
400
+ });
401
+ }
402
+ // Clear leaf parentIds that point at groups ELK dropped.
403
+ const leaves = rfNodes.map((n) => {
404
+ const p = positioned.get(n.id);
405
+ const node = p ? { ...n, position: { x: p.position.x, y: p.position.y } } : n;
406
+ const parentId = (node as { parentId?: string }).parentId;
407
+ if (parentId && !builtGroups.has(parentId)) {
408
+ const { parentId: _drop, ...rest } = node as { parentId?: string } & Node;
409
+ return rest as Node;
410
+ }
411
+ return node;
412
+ });
413
+ setNodes([...shells, ...leaves]);
414
+ setRfEdges(rfEdges);
415
+ setReady(true);
416
+ requestAnimationFrame(() => fitView({ padding: 0.15 }));
417
+ })
418
+ .catch((err) => {
419
+ if (!alive) return;
420
+ console.warn('[subsystem-aggregate-graph] ELK layout failed, using grid:', err);
421
+ setNodes(rfNodes);
422
+ setRfEdges(rfEdges);
423
+ setReady(true);
424
+ requestAnimationFrame(() => fitView({ padding: 0.15 }));
425
+ });
426
+ return () => {
427
+ alive = false;
428
+ };
429
+ // eslint-disable-next-line react-hooks/exhaustive-deps
430
+ }, [layoutKey]);
431
+
432
+ const neighborIds = useMemo(() => {
433
+ const focus = hoveredId ?? selectedId;
434
+ if (!focus) return null;
435
+ const set = new Set<string>([focus]);
436
+ for (const e of edges) {
437
+ if (e.from === focus) set.add(e.to);
438
+ if (e.to === focus) set.add(e.from);
439
+ }
440
+ return set;
441
+ }, [hoveredId, selectedId, edges]);
442
+
443
+ const selected = selectedId ? (frames.find((f) => f.id === selectedId) ?? null) : null;
444
+
445
+ const onNodeClick = useCallback(
446
+ (_e: unknown, node: Node) => {
447
+ const next = selectedId === node.id ? null : node.id;
448
+ setSelectedId(next);
449
+ onSelectFrame?.(next);
450
+ },
451
+ [selectedId, onSelectFrame],
452
+ );
453
+
454
+ const displayNodes = useMemo(
455
+ () =>
456
+ nodes.map((n) => ({
457
+ ...n,
458
+ data: {
459
+ ...(n.data as object),
460
+ selected: selectedId === n.id,
461
+ dimmed: neighborIds ? !neighborIds.has(n.id) : false,
462
+ // Surviving parentId means ELK built this node's process frame —
463
+ // the node renders nested, so a process box drops its redundant name.
464
+ nested: (n as { parentId?: string }).parentId != null,
465
+ },
466
+ })),
467
+ [nodes, selectedId, neighborIds],
468
+ );
469
+
470
+ const displayEdges = useMemo(
471
+ () =>
472
+ rfEdges.map((e) => {
473
+ const dimmed =
474
+ neighborIds != null &&
475
+ !(neighborIds.has(e.source) && neighborIds.has(e.target));
476
+ return {
477
+ ...e,
478
+ style: { ...(e.style as object), opacity: dimmed ? 0.12 : 0.9 },
479
+ };
480
+ }),
481
+ [rfEdges, neighborIds],
482
+ );
483
+
484
+ return (
485
+ <div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
486
+ <ReactFlow
487
+ nodes={displayNodes}
488
+ edges={displayEdges}
489
+ nodeTypes={nodeTypes}
490
+ {...GRAPH_NAV_PROPS}
491
+ onNodeClick={onNodeClick}
492
+ onNodeMouseEnter={(_e, node) => setHoveredId(node.id)}
493
+ onNodeMouseLeave={() => setHoveredId(null)}
494
+ onPaneClick={() => {
495
+ setSelectedId(null);
496
+ onSelectFrame?.(null);
497
+ }}
498
+ proOptions={{ hideAttribution: true }}
499
+ >
500
+ <GraphChrome />
501
+ </ReactFlow>
502
+ {!ready && (
503
+ <div
504
+ style={{
505
+ position: 'absolute',
506
+ inset: 0,
507
+ display: 'flex',
508
+ alignItems: 'center',
509
+ justifyContent: 'center',
510
+ background: theme.colors.background,
511
+ color: muted,
512
+ fontFamily: theme.fonts.monospace,
513
+ fontSize: theme.fontSizes[0],
514
+ }}
515
+ >
516
+ Laying out frames…
517
+ </div>
518
+ )}
519
+ {title && (
520
+ <div
521
+ style={{
522
+ position: 'absolute',
523
+ top: 12,
524
+ left: 12,
525
+ fontSize: theme.fontSizes[2],
526
+ fontWeight: 600,
527
+ color: theme.colors.text,
528
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
529
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
530
+ borderRadius: 6,
531
+ padding: '4px 10px',
532
+ }}
533
+ >
534
+ {title}
535
+ </div>
536
+ )}
537
+ {selected && (
538
+ <div
539
+ style={{
540
+ position: 'absolute',
541
+ top: 12,
542
+ right: 12,
543
+ width: 280,
544
+ maxHeight: 'calc(100% - 24px)',
545
+ overflowY: 'auto',
546
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
547
+ border: `1px solid ${theme.colors.border ?? '#333'}`,
548
+ borderRadius: 8,
549
+ padding: '10px 12px',
550
+ }}
551
+ >
552
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
553
+ <span style={{ fontWeight: 600, color: theme.colors.text, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
554
+ {selected.label}
555
+ </span>
556
+ <button
557
+ type="button"
558
+ onClick={() => {
559
+ setSelectedId(null);
560
+ onSelectFrame?.(null);
561
+ }}
562
+ aria-label="Clear selection"
563
+ style={{ border: 'none', background: 'transparent', color: muted, cursor: 'pointer', fontSize: theme.fontSizes[2], lineHeight: 1 }}
564
+ >
565
+ ×
566
+ </button>
567
+ </div>
568
+ <div style={{ fontFamily: theme.fonts.monospace, fontSize: theme.fontSizes[0], color: muted, margin: '4px 0 8px' }}>
569
+ {selected.kind} · {selected.members.length} member{selected.members.length === 1 ? '' : 's'}
570
+ {selected.models.length > 0 ? ` · ${selected.models.length} model${selected.models.length === 1 ? '' : 's'}` : ''}
571
+ </div>
572
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
573
+ {selected.members.map((m) => (
574
+ <button
575
+ key={m.alias}
576
+ type="button"
577
+ title={`${m.alias}${m.file ? ` — ${m.file}` : ''}`}
578
+ disabled={!m.file || !onOpenFile}
579
+ onClick={() => m.file && onOpenFile?.(m.file)}
580
+ style={{
581
+ border: 'none',
582
+ background: 'transparent',
583
+ color: m.file ? theme.colors.text : muted,
584
+ cursor: m.file && onOpenFile ? 'pointer' : 'default',
585
+ fontFamily: theme.fonts.monospace,
586
+ fontSize: theme.fontSizes[0],
587
+ textAlign: 'left',
588
+ padding: '2px 0',
589
+ overflow: 'hidden',
590
+ textOverflow: 'ellipsis',
591
+ whiteSpace: 'nowrap',
592
+ }}
593
+ >
594
+ {m.alias}
595
+ </button>
596
+ ))}
597
+ </div>
598
+ </div>
599
+ )}
600
+ </div>
601
+ );
602
+ }
603
+
604
+ export function SubsystemAggregateGraph(props: SubsystemAggregateGraphProps) {
605
+ return (
606
+ <ReactFlowProvider>
607
+ <Inner {...props} />
608
+ </ReactFlowProvider>
609
+ );
610
+ }