@principal-ai/principal-view-react 0.16.21 → 0.16.23
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.
- package/dist/components/GraphRenderer.js.map +1 -1
- package/dist/components/session-events/SessionEventFeed.d.ts.map +1 -1
- package/dist/components/session-events/SessionEventFeed.js +10 -3
- package/dist/components/session-events/SessionEventFeed.js.map +1 -1
- package/dist/graphify/consolidated.d.ts +170 -0
- package/dist/graphify/consolidated.d.ts.map +1 -0
- package/dist/graphify/consolidated.js +11 -0
- package/dist/graphify/consolidated.js.map +1 -0
- package/dist/graphify/index.d.ts +9 -0
- package/dist/graphify/index.d.ts.map +1 -0
- package/dist/graphify/index.js +8 -0
- package/dist/graphify/index.js.map +1 -0
- package/dist/graphify/types.d.ts +141 -0
- package/dist/graphify/types.d.ts.map +1 -0
- package/dist/graphify/types.js +8 -0
- package/dist/graphify/types.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +28 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.js +365 -0
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -0
- package/dist/subsystem/model.d.ts +131 -0
- package/dist/subsystem/model.d.ts.map +1 -0
- package/dist/subsystem/model.js +296 -0
- package/dist/subsystem/model.js.map +1 -0
- package/dist/subsystem/nodes.d.ts +28 -0
- package/dist/subsystem/nodes.d.ts.map +1 -0
- package/dist/subsystem/nodes.js +230 -0
- package/dist/subsystem/nodes.js.map +1 -0
- package/dist/utils/elkLayout.d.ts +19 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +72 -9
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +3 -3
- package/src/components/GraphRenderer.tsx +2 -2
- package/src/components/session-events/SessionEventFeed.tsx +20 -3
- package/src/graphify/consolidated.ts +202 -0
- package/src/graphify/index.ts +36 -0
- package/src/graphify/types.ts +164 -0
- package/src/index.ts +31 -0
- package/src/stories/SubsystemComponentGraph.stories.tsx +761 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +585 -0
- package/src/subsystem/model.test.ts +81 -0
- package/src/subsystem/model.ts +434 -0
- package/src/subsystem/nodes.tsx +353 -0
- package/src/utils/elkLayout.ts +98 -9
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubsystemComponentGraph — a clickable, read-only React Flow component graph
|
|
3
|
+
* for a subsystem snapshot.
|
|
4
|
+
*
|
|
5
|
+
* Nodes are positioned with ELK auto-layout (layered, minimized crossings) and
|
|
6
|
+
* colored by package. Only cross-package edges leave a package region. Clicking
|
|
7
|
+
* a component invokes `onSelect`. (Package frames are deferred — nodes are
|
|
8
|
+
* color-coded by package for now.)
|
|
9
|
+
*
|
|
10
|
+
* This is a focused fork of the package's `GraphRenderer` pipeline (same ELK
|
|
11
|
+
* edge routing, delayed fitView, Background/Controls/MiniMap, node/edge type
|
|
12
|
+
* injection, onNodeClick) adapted to the subsystem model — read-only.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
|
+
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
|
|
17
|
+
import {
|
|
18
|
+
ReactFlow,
|
|
19
|
+
ReactFlowProvider,
|
|
20
|
+
Background,
|
|
21
|
+
BackgroundVariant,
|
|
22
|
+
Controls,
|
|
23
|
+
useReactFlow,
|
|
24
|
+
useViewport,
|
|
25
|
+
type NodeTypes,
|
|
26
|
+
type EdgeTypes,
|
|
27
|
+
type Node,
|
|
28
|
+
type Edge,
|
|
29
|
+
type NodeMouseHandler,
|
|
30
|
+
type NodeChange,
|
|
31
|
+
type EdgeChange,
|
|
32
|
+
applyNodeChanges,
|
|
33
|
+
} from '@xyflow/react';
|
|
34
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
35
|
+
import {
|
|
36
|
+
buildSubsystemGraph,
|
|
37
|
+
KIND_COLOR,
|
|
38
|
+
MECHANISM_COLOR,
|
|
39
|
+
deriveNameFromSymbol,
|
|
40
|
+
type SubsystemComponentEdge,
|
|
41
|
+
type SubsystemComponent,
|
|
42
|
+
type SubsystemEdgeMechanism,
|
|
43
|
+
} from './model';
|
|
44
|
+
import type { GraphifyComponentDetail } from '../graphify';
|
|
45
|
+
import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
|
|
46
|
+
|
|
47
|
+
export interface SubsystemComponentGraphProps {
|
|
48
|
+
components: SubsystemComponent[];
|
|
49
|
+
edges: SubsystemComponentEdge[];
|
|
50
|
+
onSelect?: (componentId: string) => void;
|
|
51
|
+
/** Called when an edge is clicked (relationship / mechanism + refs seam). */
|
|
52
|
+
onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
|
|
53
|
+
/** Upper bound for node width in px; nodes grow with content up to this,
|
|
54
|
+
* then the name wraps. Defaults to 300 for components (320 for packages). */
|
|
55
|
+
maxNodeWidth?: number;
|
|
56
|
+
/** Show edge labels (mechanism names) on the graph. @default true */
|
|
57
|
+
showEdgeLabels?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const nodeTypes: NodeTypes = {
|
|
61
|
+
'subsystem-component': SubsystemComponentNode,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const edgeTypes: EdgeTypes = {
|
|
65
|
+
'subsystem-edge': SubsystemEdge,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
interface InnerProps extends SubsystemComponentGraphProps {
|
|
69
|
+
measured: { w: number; h: number } | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function Inner({ components, edges, onSelect, onEdgeSelect, measured, maxNodeWidth, showEdgeLabels }: InnerProps) {
|
|
73
|
+
const { theme } = useTheme();
|
|
74
|
+
const { fitView } = useReactFlow();
|
|
75
|
+
const viewport = useViewport();
|
|
76
|
+
const [built, setBuilt] = useState<{ nodes: Node[]; edges: Edge[] }>({
|
|
77
|
+
nodes: [],
|
|
78
|
+
edges: [],
|
|
79
|
+
});
|
|
80
|
+
const [layoutReady, setLayoutReady] = useState(false);
|
|
81
|
+
const [selected, setSelected] = useState<SubsystemComponent | null>(null);
|
|
82
|
+
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
83
|
+
// closure over the effect deps) can toggle without a stale value.
|
|
84
|
+
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
85
|
+
selectedRef.current = selected;
|
|
86
|
+
|
|
87
|
+
// Pass 1: build with estimated widths so React Flow can measure the DOM.
|
|
88
|
+
// The pane stays hidden until Pass 2 completes.
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
let alive = true;
|
|
91
|
+
pass2DoneRef.current = false;
|
|
92
|
+
measuredDimsRef.current = new Map();
|
|
93
|
+
prevMeasuredSigRef.current = '';
|
|
94
|
+
void buildSubsystemGraph({ components, edges }, { maxNodeWidth, showEdgeLabels }).then(({ nodes, edges: e }) => {
|
|
95
|
+
if (!alive) return;
|
|
96
|
+
setBuilt({ nodes, edges: e as Edge[] });
|
|
97
|
+
setLayoutReady(false);
|
|
98
|
+
});
|
|
99
|
+
return () => { alive = false; };
|
|
100
|
+
}, [components, edges]);
|
|
101
|
+
|
|
102
|
+
// Track measured dimensions from React Flow's dimension changes.
|
|
103
|
+
// These arrive as { type: 'dimensions', id, dimensions } in onNodesChange.
|
|
104
|
+
const measuredDimsRef = useRef(new Map<string, { width: number; height: number }>());
|
|
105
|
+
const pendingMeasuredRef = useRef(false);
|
|
106
|
+
|
|
107
|
+
// Pass 2: once every node has a measured dimension, re-run ELK.
|
|
108
|
+
const prevMeasuredSigRef = useRef('');
|
|
109
|
+
const pass2DoneRef = useRef(false);
|
|
110
|
+
const triggerPass2 = useCallback(() => {
|
|
111
|
+
if (pass2DoneRef.current) return;
|
|
112
|
+
const dims = measuredDimsRef.current;
|
|
113
|
+
if (dims.size < built.nodes.length) return;
|
|
114
|
+
const sig = built.nodes.map((n) => `${n.id}:${dims.get(n.id)?.width ?? '?'}`).join(',');
|
|
115
|
+
if (sig.includes('?:')) return;
|
|
116
|
+
if (sig === prevMeasuredSigRef.current) return;
|
|
117
|
+
prevMeasuredSigRef.current = sig;
|
|
118
|
+
pendingMeasuredRef.current = false;
|
|
119
|
+
pass2DoneRef.current = true;
|
|
120
|
+
|
|
121
|
+
const measuredWidths = new Map(built.nodes.map((n) => [n.id, dims.get(n.id)!.width]));
|
|
122
|
+
const measuredHeights = new Map(built.nodes.map((n) => [n.id, dims.get(n.id)!.height]));
|
|
123
|
+
let alive = true;
|
|
124
|
+
void buildSubsystemGraph(
|
|
125
|
+
{ components, edges },
|
|
126
|
+
{ maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights },
|
|
127
|
+
).then(({ nodes, edges: e }) => {
|
|
128
|
+
if (!alive) return;
|
|
129
|
+
setBuilt({ nodes, edges: e as Edge[] });
|
|
130
|
+
setLayoutReady(true);
|
|
131
|
+
});
|
|
132
|
+
return () => { alive = false; };
|
|
133
|
+
}, [built.nodes, components, edges, maxNodeWidth, showEdgeLabels]);
|
|
134
|
+
|
|
135
|
+
// Node components call SUBSYSTEM_CALLBACKS.onSelect on click (their inner
|
|
136
|
+
// onClick stops React Flow propagation), so wire selection + width through it.
|
|
137
|
+
// Lookup from edge id → the authored SubsystemComponentEdge (for onEdgeSelect).
|
|
138
|
+
const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
139
|
+
|
|
140
|
+
// Shared edge-selection logic used by both React Flow's onEdgeClick and the
|
|
141
|
+
// clickable edge label (SUBSYSTEM_CALLBACKS.onEdgeSelect).
|
|
142
|
+
const selectEdge = useCallback(
|
|
143
|
+
(edgeId: string) => {
|
|
144
|
+
if (selectedEdgeIdRef.current === edgeId) {
|
|
145
|
+
setSelectedEdgeId(null);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const src = edgeById.get(edgeId);
|
|
149
|
+
setSelectedEdgeId(edgeId);
|
|
150
|
+
if (src) onEdgeSelect?.(src);
|
|
151
|
+
},
|
|
152
|
+
[edgeById, onEdgeSelect],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
SUBSYSTEM_CALLBACKS.onSelect = (id: string) => {
|
|
157
|
+
const comp = components.find((c) => c.id === id);
|
|
158
|
+
if (comp) {
|
|
159
|
+
// Clicking the already-selected node unselects it (toggle off).
|
|
160
|
+
if (selectedRef.current?.id === comp.id) {
|
|
161
|
+
setSelected(null);
|
|
162
|
+
setSelectedEdgeId(null);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
setSelected(comp);
|
|
166
|
+
setSelectedEdgeId(null);
|
|
167
|
+
onSelect?.(id);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
SUBSYSTEM_CALLBACKS.onEdgeSelect = selectEdge;
|
|
171
|
+
SUBSYSTEM_CALLBACKS.maxNodeWidth = maxNodeWidth;
|
|
172
|
+
return () => {
|
|
173
|
+
SUBSYSTEM_CALLBACKS.onSelect = undefined;
|
|
174
|
+
SUBSYSTEM_CALLBACKS.onEdgeSelect = undefined;
|
|
175
|
+
SUBSYSTEM_CALLBACKS.maxNodeWidth = undefined;
|
|
176
|
+
};
|
|
177
|
+
}, [components, onSelect, maxNodeWidth, selectEdge]);
|
|
178
|
+
|
|
179
|
+
const { nodes, edges: convertedEdges } = built;
|
|
180
|
+
const xyflowNodesBase = nodes as Node[];
|
|
181
|
+
const baseEdges = convertedEdges as Edge[];
|
|
182
|
+
|
|
183
|
+
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
184
|
+
const baseEdgesKey = useMemo(
|
|
185
|
+
() => convertedEdges.map((e) => e.id).sort().join(','),
|
|
186
|
+
[convertedEdges],
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// When an edge is selected, dim every other edge + its label to focus it.
|
|
190
|
+
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
191
|
+
// Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
|
|
192
|
+
// without a stale closure value.
|
|
193
|
+
const selectedEdgeIdRef = useRef<string | null>(null);
|
|
194
|
+
selectedEdgeIdRef.current = selectedEdgeId;
|
|
195
|
+
const dispEdges = useMemo(() => {
|
|
196
|
+
if (!selectedEdgeId) return baseEdges;
|
|
197
|
+
return baseEdges.map((e) => ({
|
|
198
|
+
...e,
|
|
199
|
+
data: {
|
|
200
|
+
...(e.data as object),
|
|
201
|
+
dimmed: e.id !== selectedEdgeId,
|
|
202
|
+
},
|
|
203
|
+
}));
|
|
204
|
+
}, [baseEdges, selectedEdgeId]);
|
|
205
|
+
|
|
206
|
+
const onNodesChange = useCallback(
|
|
207
|
+
(changes: NodeChange[]) => {
|
|
208
|
+
// Capture dimension changes (React Flow's measurement callback).
|
|
209
|
+
for (const ch of changes) {
|
|
210
|
+
if (ch.type === 'dimensions' && ch.dimensions) {
|
|
211
|
+
measuredDimsRef.current.set(ch.id, ch.dimensions);
|
|
212
|
+
pendingMeasuredRef.current = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const result = applyNodeChanges(changes, xyflowNodesBase);
|
|
216
|
+
// After applying changes, check if we should trigger pass 2.
|
|
217
|
+
if (pendingMeasuredRef.current) {
|
|
218
|
+
// Use microtask so the state update from applyNodeChanges commits first.
|
|
219
|
+
queueMicrotask(() => triggerPass2());
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
},
|
|
223
|
+
[xyflowNodesBase, triggerPass2],
|
|
224
|
+
);
|
|
225
|
+
const onEdgesChange = useCallback(
|
|
226
|
+
(_changes: EdgeChange[]) => dispEdges,
|
|
227
|
+
[dispEdges],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
const t = setTimeout(() => {
|
|
232
|
+
fitView({ padding: 0.1, includeHiddenNodes: false, minZoom: 0.05, maxZoom: 2, duration: 200 });
|
|
233
|
+
}, 200);
|
|
234
|
+
return () => clearTimeout(t);
|
|
235
|
+
}, [baseNodesKey, baseEdgesKey, fitView]);
|
|
236
|
+
|
|
237
|
+
const onNodeClick: NodeMouseHandler = useCallback(
|
|
238
|
+
(_e, node: Node) => {
|
|
239
|
+
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
240
|
+
setSelectedEdgeId(null);
|
|
241
|
+
if (node.type === 'subsystem-component' && comp) {
|
|
242
|
+
// Clicking the already-selected node unselects it (toggle off).
|
|
243
|
+
if (selected?.id === comp.id) {
|
|
244
|
+
setSelected(null);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
setSelected(comp);
|
|
248
|
+
if (comp.id) onSelect?.(comp.id);
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
[onSelect, selected],
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// React Flow's edge click → the same shared selection logic as the label.
|
|
255
|
+
const onEdgeClick = useCallback(
|
|
256
|
+
(_e: ReactMouseEvent, edge: Edge) => {
|
|
257
|
+
selectEdge(edge.id);
|
|
258
|
+
},
|
|
259
|
+
[selectEdge],
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const onPaneClick = useCallback(() => {
|
|
263
|
+
setSelected(null);
|
|
264
|
+
setSelectedEdgeId(null);
|
|
265
|
+
}, []);
|
|
266
|
+
|
|
267
|
+
// Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
|
|
268
|
+
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
269
|
+
// the actual edge path (not node-center approximations).
|
|
270
|
+
const edgeLabels = useMemo(() => {
|
|
271
|
+
return dispEdges.map((e) => {
|
|
272
|
+
const d = e.data as { mechanism?: string; dimmed?: boolean; labelX?: number; labelY?: number } | undefined;
|
|
273
|
+
return {
|
|
274
|
+
id: e.id,
|
|
275
|
+
mechanism: d?.mechanism ?? 'imports',
|
|
276
|
+
dimmed: d?.dimmed === true,
|
|
277
|
+
midX: d?.labelX ?? 0,
|
|
278
|
+
midY: d?.labelY ?? 0,
|
|
279
|
+
};
|
|
280
|
+
});
|
|
281
|
+
}, [dispEdges]);
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div style={{ visibility: layoutReady ? 'visible' : 'hidden', width: '100%', height: '100%' }}>
|
|
287
|
+
{/* Edge-label overlay — sits above the ReactFlow pane so clicks land. */}
|
|
288
|
+
{showEdgeLabels !== false && (
|
|
289
|
+
<div
|
|
290
|
+
style={{
|
|
291
|
+
position: 'absolute',
|
|
292
|
+
inset: 0,
|
|
293
|
+
overflow: 'hidden',
|
|
294
|
+
pointerEvents: 'none',
|
|
295
|
+
zIndex: 5,
|
|
296
|
+
}}
|
|
297
|
+
>
|
|
298
|
+
{edgeLabels.map((lbl) => {
|
|
299
|
+
const color = MECHANISM_COLOR[lbl.mechanism as SubsystemEdgeMechanism] ?? '#888';
|
|
300
|
+
const screenX = lbl.midX * viewport.zoom + viewport.x;
|
|
301
|
+
const screenY = lbl.midY * viewport.zoom + viewport.y;
|
|
302
|
+
return (
|
|
303
|
+
<div
|
|
304
|
+
key={lbl.id}
|
|
305
|
+
data-edge-label={lbl.id}
|
|
306
|
+
onClick={(e) => {
|
|
307
|
+
e.stopPropagation();
|
|
308
|
+
selectEdge(lbl.id);
|
|
309
|
+
}}
|
|
310
|
+
style={{
|
|
311
|
+
position: 'absolute',
|
|
312
|
+
left: screenX,
|
|
313
|
+
top: screenY,
|
|
314
|
+
fontSize: 10,
|
|
315
|
+
fontFamily: theme.fonts.monospace,
|
|
316
|
+
fontWeight: 500,
|
|
317
|
+
color,
|
|
318
|
+
background: 'rgba(21,21,21,0.9)',
|
|
319
|
+
border: `0.5px solid ${color}`,
|
|
320
|
+
borderRadius: 4,
|
|
321
|
+
padding: '1px 5px',
|
|
322
|
+
cursor: 'pointer',
|
|
323
|
+
pointerEvents: 'auto',
|
|
324
|
+
opacity: lbl.dimmed ? 0.15 : 1,
|
|
325
|
+
whiteSpace: 'nowrap',
|
|
326
|
+
}}
|
|
327
|
+
>
|
|
328
|
+
{lbl.mechanism}
|
|
329
|
+
</div>
|
|
330
|
+
);
|
|
331
|
+
})}
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
<ReactFlow
|
|
335
|
+
key={`${baseNodesKey}-${baseEdgesKey}`}
|
|
336
|
+
nodes={xyflowNodesBase}
|
|
337
|
+
edges={dispEdges}
|
|
338
|
+
nodeTypes={nodeTypes}
|
|
339
|
+
edgeTypes={edgeTypes}
|
|
340
|
+
minZoom={0.05}
|
|
341
|
+
maxZoom={4}
|
|
342
|
+
onNodeClick={onNodeClick}
|
|
343
|
+
onEdgeClick={onEdgeClick}
|
|
344
|
+
onNodesChange={onNodesChange}
|
|
345
|
+
onEdgesChange={onEdgesChange}
|
|
346
|
+
proOptions={{ hideAttribution: true }}
|
|
347
|
+
nodesDraggable={false}
|
|
348
|
+
elementsSelectable
|
|
349
|
+
selectNodesOnDrag={false}
|
|
350
|
+
nodesConnectable={false}
|
|
351
|
+
edgesReconnectable={false}
|
|
352
|
+
onPaneClick={onPaneClick}
|
|
353
|
+
panOnDrag
|
|
354
|
+
panOnScroll
|
|
355
|
+
zoomOnScroll={false}
|
|
356
|
+
zoomOnPinch
|
|
357
|
+
zoomOnDoubleClick={false}
|
|
358
|
+
style={{
|
|
359
|
+
width: measured ? measured.w : '100%',
|
|
360
|
+
height: measured ? measured.h : '100%',
|
|
361
|
+
flex: 1,
|
|
362
|
+
minHeight: 0,
|
|
363
|
+
background: theme.colors.background,
|
|
364
|
+
}}
|
|
365
|
+
>
|
|
366
|
+
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
|
367
|
+
<Controls showZoom showFitView showInteractive />
|
|
368
|
+
</ReactFlow>
|
|
369
|
+
{selected && <ComponentDetail component={selected} />}
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Detail panel for the selected component — the verifiable identity + sources
|
|
375
|
+
* moved off the canvas so nodes stay minimal. */
|
|
376
|
+
function ComponentDetail({ component }: { component: SubsystemComponent }) {
|
|
377
|
+
const { theme } = useTheme();
|
|
378
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
379
|
+
const color = KIND_COLOR[component.kind] ?? '#888';
|
|
380
|
+
const displayName = deriveNameFromSymbol(component.symbol, component.kind, component.name, component.file);
|
|
381
|
+
const rows: Array<[string, string]> = [];
|
|
382
|
+
if (component.symbol) rows.push(['Symbol', component.symbol]);
|
|
383
|
+
if (component.file) rows.push(['File', component.file]);
|
|
384
|
+
if (component.purpose) rows.push(['Purpose', component.purpose]);
|
|
385
|
+
rows.push(['PURL', component.purl]);
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<div
|
|
389
|
+
style={{
|
|
390
|
+
borderTop: `1px solid ${theme.colors.border}`,
|
|
391
|
+
background: theme.colors.background,
|
|
392
|
+
padding: '10px 12px',
|
|
393
|
+
fontFamily: theme.fonts.body,
|
|
394
|
+
}}
|
|
395
|
+
>
|
|
396
|
+
<div
|
|
397
|
+
style={{
|
|
398
|
+
display: 'flex',
|
|
399
|
+
alignItems: 'center',
|
|
400
|
+
gap: 8,
|
|
401
|
+
fontSize: theme.fontSizes[1],
|
|
402
|
+
fontWeight: 600,
|
|
403
|
+
marginBottom: 6,
|
|
404
|
+
}}
|
|
405
|
+
>
|
|
406
|
+
<span style={{ color }}>{displayName}</span>
|
|
407
|
+
<span
|
|
408
|
+
style={{
|
|
409
|
+
fontSize: theme.fontSizes[0] * 0.8,
|
|
410
|
+
fontFamily: theme.fonts.monospace,
|
|
411
|
+
textTransform: 'uppercase',
|
|
412
|
+
color,
|
|
413
|
+
}}
|
|
414
|
+
>
|
|
415
|
+
{component.kind}
|
|
416
|
+
</span>
|
|
417
|
+
</div>
|
|
418
|
+
{rows.map(([k, v]) => (
|
|
419
|
+
<div
|
|
420
|
+
key={k}
|
|
421
|
+
style={{ display: 'flex', gap: 8, fontSize: theme.fontSizes[0], lineHeight: 1.5 }}
|
|
422
|
+
>
|
|
423
|
+
<span
|
|
424
|
+
style={{
|
|
425
|
+
width: 64,
|
|
426
|
+
flexShrink: 0,
|
|
427
|
+
color: muted,
|
|
428
|
+
fontFamily: theme.fonts.monospace,
|
|
429
|
+
textTransform: 'uppercase',
|
|
430
|
+
fontSize: theme.fontSizes[0] * 0.8,
|
|
431
|
+
}}
|
|
432
|
+
>
|
|
433
|
+
{k}
|
|
434
|
+
</span>
|
|
435
|
+
<span style={{ color: theme.colors.text, fontFamily: theme.fonts.monospace }}>
|
|
436
|
+
{v}
|
|
437
|
+
</span>
|
|
438
|
+
</div>
|
|
439
|
+
))}
|
|
440
|
+
{component.detail && <GraphifyDetailSections detail={component.detail} />}
|
|
441
|
+
</div>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Renders the graphify drill-down payload for a component. */
|
|
446
|
+
function GraphifyDetailSections({ detail }: { detail: GraphifyComponentDetail }) {
|
|
447
|
+
const { theme } = useTheme();
|
|
448
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
449
|
+
|
|
450
|
+
const label = (k: string) => (
|
|
451
|
+
<span
|
|
452
|
+
style={{
|
|
453
|
+
width: 64,
|
|
454
|
+
flexShrink: 0,
|
|
455
|
+
color: muted,
|
|
456
|
+
fontFamily: theme.fonts.monospace,
|
|
457
|
+
textTransform: 'uppercase',
|
|
458
|
+
fontSize: theme.fontSizes[0] * 0.8,
|
|
459
|
+
}}
|
|
460
|
+
>
|
|
461
|
+
{k}
|
|
462
|
+
</span>
|
|
463
|
+
);
|
|
464
|
+
const chip = (name: string, meta?: string, key?: string | number) => (
|
|
465
|
+
<span
|
|
466
|
+
key={key}
|
|
467
|
+
style={{
|
|
468
|
+
fontFamily: theme.fonts.monospace,
|
|
469
|
+
fontSize: theme.fontSizes[0],
|
|
470
|
+
color: theme.colors.text,
|
|
471
|
+
border: `1px solid ${theme.colors.border}`,
|
|
472
|
+
borderRadius: 4,
|
|
473
|
+
padding: '1px 6px',
|
|
474
|
+
marginRight: 6,
|
|
475
|
+
marginBottom: 4,
|
|
476
|
+
display: 'inline-block',
|
|
477
|
+
}}
|
|
478
|
+
>
|
|
479
|
+
{name}
|
|
480
|
+
{meta ? <span style={{ color: muted }}> {meta}</span> : ''}
|
|
481
|
+
</span>
|
|
482
|
+
);
|
|
483
|
+
const row = (k: string, children: ReactNode) => (
|
|
484
|
+
<div
|
|
485
|
+
style={{ display: 'flex', gap: 8, fontSize: theme.fontSizes[0], lineHeight: 1.5, marginBottom: 4 }}
|
|
486
|
+
>
|
|
487
|
+
{label(k)}
|
|
488
|
+
<div style={{ minWidth: 0 }}>{children}</div>
|
|
489
|
+
</div>
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
switch (detail.kind) {
|
|
493
|
+
case 'class':
|
|
494
|
+
return (
|
|
495
|
+
<>
|
|
496
|
+
{detail.methods.length > 0 &&
|
|
497
|
+
row('methods', detail.methods.map((m, i) => chip(m.name, m.returnType, i)))}
|
|
498
|
+
{detail.properties.length > 0 &&
|
|
499
|
+
row('props', detail.properties.map((p, i) => chip(p.name, p.type, i)))}
|
|
500
|
+
{detail.extends.length > 0 && row('extends', detail.extends.map((e, i) => chip(e, undefined, i)))}
|
|
501
|
+
{detail.implements.length > 0 && row('impl', detail.implements.map((e, i) => chip(e, undefined, i)))}
|
|
502
|
+
{detail.instantiations.length > 0 &&
|
|
503
|
+
row('insts', detail.instantiations.map((i, idx) => chip(i.name, undefined, idx)))}
|
|
504
|
+
{detail.references.length > 0 &&
|
|
505
|
+
row('refs', detail.references.map((r, idx) => chip(r.name, undefined, idx)))}
|
|
506
|
+
</>
|
|
507
|
+
);
|
|
508
|
+
case 'function':
|
|
509
|
+
return (
|
|
510
|
+
<>
|
|
511
|
+
{detail.parameters.length > 0 &&
|
|
512
|
+
row('params', detail.parameters.map((p, i) => chip(p.type, undefined, i)))}
|
|
513
|
+
{detail.returnType && row('return', <span>{detail.returnType}</span>)}
|
|
514
|
+
{detail.callers.length > 0 &&
|
|
515
|
+
row('callers', detail.callers.map((c, i) => chip(c.name, undefined, i)))}
|
|
516
|
+
{detail.callees.length > 0 &&
|
|
517
|
+
row('callees', detail.callees.map((c, i) => chip(c.name, undefined, i)))}
|
|
518
|
+
</>
|
|
519
|
+
);
|
|
520
|
+
case 'type':
|
|
521
|
+
return (
|
|
522
|
+
<>
|
|
523
|
+
{detail.properties.length > 0 &&
|
|
524
|
+
row('props', detail.properties.map((p, i) => chip(p.name, p.type, i)))}
|
|
525
|
+
{detail.usedBy.length > 0 &&
|
|
526
|
+
row('used by', detail.usedBy.map((u, i) => chip(u.name, undefined, i)))}
|
|
527
|
+
{detail.implementors.length > 0 &&
|
|
528
|
+
row('impl', detail.implementors.map((e, i) => chip(e, undefined, i)))}
|
|
529
|
+
</>
|
|
530
|
+
);
|
|
531
|
+
case 'module':
|
|
532
|
+
return (
|
|
533
|
+
<>
|
|
534
|
+
{detail.exports.length > 0 &&
|
|
535
|
+
row('exports', detail.exports.map((e, i) => chip(e, undefined, i)))}
|
|
536
|
+
{detail.imports.length > 0 &&
|
|
537
|
+
row('imports', detail.imports.map((i, idx) => chip(i.name, undefined, idx)))}
|
|
538
|
+
{detail.symbols.length > 0 &&
|
|
539
|
+
row('symbols', detail.symbols.map((s, i) => chip(s, undefined, i)))}
|
|
540
|
+
</>
|
|
541
|
+
);
|
|
542
|
+
case 'external':
|
|
543
|
+
return row('source', <span>{detail.label}</span>);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
|
|
548
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
549
|
+
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
|
|
550
|
+
|
|
551
|
+
// Measure the container so React Flow always has a real, non-zero pixel size
|
|
552
|
+
// (responsive to the parent; avoids the %→0 blank canvas).
|
|
553
|
+
useEffect(() => {
|
|
554
|
+
const el = wrapRef.current;
|
|
555
|
+
if (!el) return;
|
|
556
|
+
const update = () => {
|
|
557
|
+
const rect = el.getBoundingClientRect();
|
|
558
|
+
if (rect.width > 0 && rect.height > 0) setSize({ w: rect.width, h: rect.height });
|
|
559
|
+
};
|
|
560
|
+
update();
|
|
561
|
+
const ro = new ResizeObserver(update);
|
|
562
|
+
ro.observe(el);
|
|
563
|
+
return () => ro.disconnect();
|
|
564
|
+
}, []);
|
|
565
|
+
|
|
566
|
+
return (
|
|
567
|
+
<div
|
|
568
|
+
ref={wrapRef}
|
|
569
|
+
style={{
|
|
570
|
+
position: 'relative',
|
|
571
|
+
width: '100%',
|
|
572
|
+
height: '100%',
|
|
573
|
+
minHeight: 0,
|
|
574
|
+
display: 'flex',
|
|
575
|
+
flexDirection: 'column',
|
|
576
|
+
}}
|
|
577
|
+
>
|
|
578
|
+
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
579
|
+
<ReactFlowProvider>
|
|
580
|
+
<Inner {...props} measured={size} />
|
|
581
|
+
</ReactFlowProvider>
|
|
582
|
+
</div>
|
|
583
|
+
</div>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
convertSubsystemToNodes,
|
|
4
|
+
convertSubsystemToEdges,
|
|
5
|
+
buildSubsystemGraph,
|
|
6
|
+
deriveNameFromSymbol,
|
|
7
|
+
packageColor,
|
|
8
|
+
} from './model';
|
|
9
|
+
import type { SubsystemComponent, SubsystemComponentEdge } from './model';
|
|
10
|
+
|
|
11
|
+
const comps: SubsystemComponent[] = [
|
|
12
|
+
{ id: 'reader', name: 'SessionReader', kind: 'class', file: 'SessionReader.ts', purl: 'pkg:github/principal-ai/agent-monitoring' },
|
|
13
|
+
{ id: 'transcript', name: 'transcript', kind: 'module', file: 'transcript.ts', purl: 'pkg:github/principal-ai/agent-monitoring' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const edges: SubsystemComponentEdge[] = [
|
|
17
|
+
{ id: 'e1', from: 'transcript', to: 'reader', mechanism: 'imports' },
|
|
18
|
+
// 'host' is NOT a component — this is the cross-package external case.
|
|
19
|
+
{ id: 'e2', from: 'reader', to: 'host', mechanism: 'imports', refs: ['bun/index.ts'] },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
describe('subsystem graph model', () => {
|
|
23
|
+
test('converts all components to flat component nodes', () => {
|
|
24
|
+
const nodes = convertSubsystemToNodes({ components: comps, edges });
|
|
25
|
+
const components = nodes.filter((n) => n.type === 'subsystem-component');
|
|
26
|
+
expect(components).toHaveLength(comps.length);
|
|
27
|
+
expect(components.every((n) => n.type === 'subsystem-component')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('converts edges with directed markers', () => {
|
|
31
|
+
const converted = convertSubsystemToEdges({ components: comps, edges });
|
|
32
|
+
expect(converted).toHaveLength(2);
|
|
33
|
+
expect(converted[0].source).toBe('transcript');
|
|
34
|
+
expect(converted[0].target).toBe('reader');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('buildSubsystemGraph creates external stub nodes for non-component targets', async () => {
|
|
38
|
+
const { nodes, edges: gEdges } = await buildSubsystemGraph({ components: comps, edges });
|
|
39
|
+
const external = nodes.find((n) => n.id === 'external:host');
|
|
40
|
+
expect(external).toBeDefined();
|
|
41
|
+
expect(external!.data.component.kind).toBe('consumer');
|
|
42
|
+
const crossEdge = gEdges.find((e) => e.target === 'external:host');
|
|
43
|
+
expect(crossEdge).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('buildSubsystemGraph positions nodes via ELK (non-zero coords)', async () => {
|
|
47
|
+
const { nodes } = await buildSubsystemGraph({ components: comps, edges });
|
|
48
|
+
const placed = nodes.filter((n) => n.type === 'subsystem-component' && n.position);
|
|
49
|
+
expect(placed.length).toBeGreaterThan(0);
|
|
50
|
+
// ELK (or the grid fallback) gives finite coordinates.
|
|
51
|
+
for (const n of placed) {
|
|
52
|
+
expect(typeof n.position!.x).toBe('number');
|
|
53
|
+
expect(typeof n.position!.y).toBe('number');
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('packageColor is deterministic', () => {
|
|
58
|
+
expect(packageColor('agent-monitoring')).toBe(packageColor('agent-monitoring'));
|
|
59
|
+
expect(packageColor('a')).not.toBe(packageColor('b'));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('deriveNameFromSymbol is consistent per kind', () => {
|
|
63
|
+
// class/type/module/function use the symbol as-is.
|
|
64
|
+
expect(deriveNameFromSymbol('SessionReader', 'class')).toBe('SessionReader');
|
|
65
|
+
expect(deriveNameFromSymbol('SessionRecord', 'type')).toBe('SessionRecord');
|
|
66
|
+
// method uses the last dotted segment.
|
|
67
|
+
expect(deriveNameFromSymbol('SessionReader.normalize', 'method')).toBe('normalize');
|
|
68
|
+
// falls back to existing name when no symbol.
|
|
69
|
+
expect(deriveNameFromSymbol(undefined, 'class', 'SessionReader')).toBe('SessionReader');
|
|
70
|
+
expect(deriveNameFromSymbol('', 'consumer', 'trail-viewer-host')).toBe('trail-viewer-host');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('deriveNameFromSymbol falls back to file basename for modules', () => {
|
|
74
|
+
expect(deriveNameFromSymbol(undefined, 'module', undefined, 'transcript.ts')).toBe('transcript');
|
|
75
|
+
expect(deriveNameFromSymbol(undefined, 'module', undefined, 'src/event-processors/index.ts')).toBe('index');
|
|
76
|
+
// Symbol wins over file basename.
|
|
77
|
+
expect(deriveNameFromSymbol('CodexRolloutRecord', 'module', undefined, 'transcript.ts')).toBe('CodexRolloutRecord');
|
|
78
|
+
// File basename wins over a supplied name for modules (derivation precedence).
|
|
79
|
+
expect(deriveNameFromSymbol(undefined, 'module', 'MyModule', 'x.ts')).toBe('x');
|
|
80
|
+
});
|
|
81
|
+
});
|