@principal-ai/subsystems-react 0.36.1 → 0.37.0

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.
@@ -171,6 +171,8 @@ function FlowsDemo() {
171
171
  stepIndex,
172
172
  onOpenFile,
173
173
  proposedAliases,
174
+ resolveSymbol,
175
+ onSymbolClick,
174
176
  }: WalkthroughViewerContext) => (
175
177
  <PierreWalkthroughCodeView
176
178
  walkthrough={walkthrough}
@@ -179,6 +181,8 @@ function FlowsDemo() {
179
181
  contextLines={4}
180
182
  onOpenFile={onOpenFile}
181
183
  proposedAliases={proposedAliases}
184
+ resolveSymbol={resolveSymbol}
185
+ onSymbolClick={onSymbolClick}
182
186
  />
183
187
  ),
184
188
  [],
@@ -276,6 +280,8 @@ function ProposedMissingStepDemo() {
276
280
  stepIndex,
277
281
  onOpenFile,
278
282
  proposedAliases,
283
+ resolveSymbol,
284
+ onSymbolClick,
279
285
  }: WalkthroughViewerContext) => (
280
286
  <PierreWalkthroughCodeView
281
287
  walkthrough={walkthrough}
@@ -284,6 +290,8 @@ function ProposedMissingStepDemo() {
284
290
  contextLines={4}
285
291
  onOpenFile={onOpenFile}
286
292
  proposedAliases={proposedAliases}
293
+ resolveSymbol={resolveSymbol}
294
+ onSymbolClick={onSymbolClick}
287
295
  />
288
296
  ),
289
297
  [],
@@ -319,3 +327,217 @@ function ProposedMissingStepDemo() {
319
327
  export const ProposedMissingStep: Story = {
320
328
  render: () => <ProposedMissingStepDemo />,
321
329
  };
330
+
331
+ // --- Clickable constructs -------------------------------------------------
332
+ //
333
+ // A walkthrough step's line is an edge between two constructs. When the host
334
+ // supplies `resolveSymbol`/`onSymbolClick` (the graph derives them from the
335
+ // step's `from`/`to` components), a token in the snippet that names either
336
+ // endpoint becomes clickable — clicking it opens that construct's file at its
337
+ // declaration line in the bottom drawer. The second endpoint here is a
338
+ // `method` construct (`DrawingStore.list`), so the bare `list` token is
339
+ // clickable and jumps to the method's declaration.
340
+
341
+ const clickableComponents: SubsystemComponent[] = [
342
+ {
343
+ alias: 'load-panel',
344
+ name: 'LoadPanel',
345
+ construct: 'function',
346
+ file: 'src/load/LoadPanel.tsx',
347
+ purl: 'pkg:github/principal-ai/desktop-app',
348
+ symbol: 'LoadPanel',
349
+ purpose: 'loads the drawing list and hands rows to the render surface',
350
+ process: 'draw-list',
351
+ declarationRef: {
352
+ file: 'src/load/LoadPanel.tsx',
353
+ startLine: 3,
354
+ lineHash: 'story',
355
+ capturedAt: '2026-01-01T00:00:00.000Z',
356
+ },
357
+ declaration: {
358
+ kind: 'function',
359
+ parameters: [],
360
+ returnType: 'JSX.Element',
361
+ callers: [],
362
+ callees: [],
363
+ },
364
+ declarationProvenance: 'authored',
365
+ },
366
+ {
367
+ alias: 'drawing-store-list',
368
+ name: 'list',
369
+ construct: 'method',
370
+ file: 'src/store/DrawingStore.ts',
371
+ purl: 'pkg:github/principal-ai/desktop-app',
372
+ symbol: 'DrawingStore.list',
373
+ purpose: 'lists stored drawings for the panel',
374
+ process: 'draw-list',
375
+ declarationRef: {
376
+ file: 'src/store/DrawingStore.ts',
377
+ startLine: 4,
378
+ lineHash: 'story',
379
+ capturedAt: '2026-01-01T00:00:00.000Z',
380
+ },
381
+ declaration: {
382
+ kind: 'method',
383
+ hostClass: 'DrawingStore',
384
+ parameters: [],
385
+ returnType: 'Drawing[]',
386
+ },
387
+ declarationProvenance: 'authored',
388
+ },
389
+ {
390
+ alias: 'render-surface',
391
+ name: 'RenderSurface',
392
+ construct: 'class',
393
+ file: 'src/render/RenderSurface.ts',
394
+ purl: 'pkg:github/principal-ai/desktop-app',
395
+ symbol: 'RenderSurface',
396
+ purpose: 'paints a drawing into a surface',
397
+ process: 'draw-host',
398
+ declarationRef: {
399
+ file: 'src/render/RenderSurface.ts',
400
+ startLine: 3,
401
+ lineHash: 'story',
402
+ capturedAt: '2026-01-01T00:00:00.000Z',
403
+ },
404
+ declaration: {
405
+ kind: 'class',
406
+ methods: [{ nodeId: 'RenderSurface.render', name: 'render' }],
407
+ properties: [],
408
+ extends: [],
409
+ implements: [],
410
+ instantiations: [],
411
+ references: [],
412
+ },
413
+ declarationProvenance: 'authored',
414
+ },
415
+ ];
416
+
417
+ const clickableFiles: Record<string, string> = {
418
+ 'src/load/LoadPanel.tsx': [
419
+ '// src/load/LoadPanel.tsx',
420
+ '',
421
+ 'export function LoadPanel() {',
422
+ ' const rows = DrawingStore.list();',
423
+ ' return rows.map(RenderSurface.render);',
424
+ '}',
425
+ ].join('\n'),
426
+ 'src/store/DrawingStore.ts': [
427
+ '// src/store/DrawingStore.ts',
428
+ '',
429
+ 'export class DrawingStore {',
430
+ ' static list() {',
431
+ ' return RenderSurface.read();',
432
+ ' }',
433
+ '}',
434
+ ].join('\n'),
435
+ };
436
+
437
+ function readClickableFile(path: string): Promise<string> {
438
+ const content = clickableFiles[path];
439
+ if (content == null) {
440
+ return Promise.reject(new Error(`file not found in graph repos: ${path}`));
441
+ }
442
+ return Promise.resolve(content);
443
+ }
444
+
445
+ const clickableWalkthroughs: SubsystemWalkthrough[] = [
446
+ {
447
+ id: 'tl-load',
448
+ title: 'Load drawings',
449
+ steps: [
450
+ {
451
+ from: 'load-panel',
452
+ to: 'drawing-store-list',
453
+ mechanism: 'calls',
454
+ file: 'src/load/LoadPanel.tsx',
455
+ line: 4,
456
+ purl: stepPurl('src/load/LoadPanel.tsx'),
457
+ symbol: 'LoadPanel.load',
458
+ annotation:
459
+ 'LoadPanel calls DrawingStore.list — click the `list` (or `DrawingStore`) token to jump to the method.',
460
+ },
461
+ {
462
+ from: 'drawing-store-list',
463
+ to: 'render-surface',
464
+ mechanism: 'calls',
465
+ file: 'src/store/DrawingStore.ts',
466
+ line: 5,
467
+ purl: stepPurl('src/store/DrawingStore.ts'),
468
+ symbol: 'DrawingStore.list',
469
+ annotation: 'list reads through RenderSurface — click RenderSurface.',
470
+ },
471
+ ],
472
+ },
473
+ ];
474
+
475
+ function ClickableConstructsDemo() {
476
+ const [opened, setOpened] = React.useState<string | null>(null);
477
+ const renderWalkthroughViewer = useCallback(
478
+ ({
479
+ walkthrough,
480
+ stepIndex,
481
+ onOpenFile,
482
+ proposedAliases,
483
+ resolveSymbol,
484
+ onSymbolClick,
485
+ }: WalkthroughViewerContext) => (
486
+ <PierreWalkthroughCodeView
487
+ walkthrough={walkthrough}
488
+ stepIndex={stepIndex}
489
+ readFile={readClickableFile}
490
+ contextLines={4}
491
+ onOpenFile={onOpenFile}
492
+ proposedAliases={proposedAliases}
493
+ resolveSymbol={resolveSymbol}
494
+ onSymbolClick={onSymbolClick}
495
+ />
496
+ ),
497
+ [],
498
+ );
499
+
500
+ return (
501
+ <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
502
+ <div
503
+ style={{
504
+ padding: '8px 14px',
505
+ fontFamily: 'monospace',
506
+ fontSize: 12,
507
+ borderBottom: '1px solid #333',
508
+ background: '#141414',
509
+ color: '#ddd',
510
+ }}
511
+ >
512
+ Click the <strong>Load drawings</strong> step, then click the dotted-underlined{' '}
513
+ <code>list</code> <em>(a method)</em> or <code>RenderSurface</code> token in the
514
+ snippet. It opens that construct's file at its declaration line in the bottom drawer
515
+ — <code>list</code> jumps to <code>DrawingStore.ts:4</code>.
516
+ <span style={{ marginLeft: 8, color: '#8fd' }}>opened: {opened ?? '—'}</span>
517
+ </div>
518
+ <div style={{ flex: 1, minHeight: 0 }}>
519
+ <SubsystemComponentGraph
520
+ components={clickableComponents}
521
+ relations={[]}
522
+ walkthroughs={clickableWalkthroughs}
523
+ initialWalkthroughId="tl-load"
524
+ title="clickable constructs"
525
+ description="A walkthrough step's line is an edge to a construct. Tokens that name the step's `from`/`to` components are clickable and open that construct's declaration line in the file drawer — including a **method** endpoint (click `list` to jump to `DrawingStore.list`)."
526
+ renderWalkthroughViewer={renderWalkthroughViewer}
527
+ onFileSelect={setOpened}
528
+ renderFileViewer={(file, opts) => (
529
+ <div style={{ padding: 12, fontFamily: 'monospace', fontSize: 12, color: '#bbb' }}>
530
+ {`// ${file}`}
531
+ {opts?.startLine != null ? `\n // → focus line ${opts.startLine}` : ''}
532
+ {'\n …'}
533
+ </div>
534
+ )}
535
+ />
536
+ </div>
537
+ </div>
538
+ );
539
+ }
540
+
541
+ export const ClickableConstructs: Story = {
542
+ render: () => <ClickableConstructsDemo />,
543
+ };
@@ -0,0 +1,137 @@
1
+ import '@xyflow/react/dist/style.css';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
4
+ import { SubsystemComponentGraph } from '../../../subsystem/SubsystemComponentGraph';
5
+ import type { SubsystemComponent } from '../../../subsystem/model';
6
+ import { graphSpecFromEdges } from './fixtures';
7
+
8
+ const meta = {
9
+ title: 'Subsystem/ComponentGraph/WorkspacePackages',
10
+ component: SubsystemComponentGraph,
11
+ parameters: { layout: 'fullscreen' },
12
+ tags: ['autodocs'],
13
+ decorators: [
14
+ (Story) => (
15
+ <ThemeProvider theme={defaultEditorTheme}>
16
+ <Story />
17
+ </ThemeProvider>
18
+ ),
19
+ ],
20
+ } satisfies Meta<typeof SubsystemComponentGraph>;
21
+
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ const STUDIO = 'pkg:npm/@principal-ai/subsystems-studio';
26
+ const REACT = 'pkg:npm/@principal-ai/subsystems-react';
27
+ const RENDERER = 'principal-studio/renderer';
28
+
29
+ /**
30
+ * Workspace-package preview — one repo, two packages.
31
+ *
32
+ * `purl` carries the workspace-package identity (npm purls here), so package
33
+ * frames draw per package instead of one frame per repo. The app package owns
34
+ * the runtime process: only `subsystems-studio` code sits inside
35
+ * `principal-studio/renderer`. The consumed library (`subsystems-react`) is a
36
+ * dependency, not a deployment unit, so it is housed in its own package frame
37
+ * outside that process — reached by cross-package edges instead of nesting.
38
+ *
39
+ * Tree: package → process → module → leaves, with the library package beside
40
+ * the process rather than inside it.
41
+ */
42
+ const components: SubsystemComponent[] = [
43
+ // --- packages/subsystems-studio · inside process principal-studio/renderer
44
+ {
45
+ alias: 'model-view',
46
+ name: 'SubsystemModelView',
47
+ construct: 'function',
48
+ symbol: 'SubsystemModelView',
49
+ file: 'packages/subsystems-studio/src/mainview/views/SubsystemModelView.tsx',
50
+ module: 'packages/subsystems-studio/src/mainview/views/SubsystemModelView.tsx',
51
+ process: RENDERER,
52
+ purl: STUDIO,
53
+ purpose: 'renderer view that opens a subsystem model',
54
+ layer: 0,
55
+ },
56
+ {
57
+ alias: 'read-file',
58
+ name: 'readSubsystemFile',
59
+ construct: 'function',
60
+ symbol: 'readSubsystemFile',
61
+ file: 'packages/subsystems-studio/src/mainview/views/SubsystemModelView.tsx',
62
+ module: 'packages/subsystems-studio/src/mainview/views/SubsystemModelView.tsx',
63
+ process: RENDERER,
64
+ purl: STUDIO,
65
+ purpose: 'request a file slice for the drawer',
66
+ layer: 1,
67
+ },
68
+ {
69
+ alias: 'get-model',
70
+ name: 'getSubsystemModel',
71
+ construct: 'function',
72
+ symbol: 'getSubsystemModel',
73
+ file: 'packages/subsystems-studio/src/bun/subsystem-model-store.ts',
74
+ module: 'packages/subsystems-studio/src/bun/subsystem-model-store.ts',
75
+ process: RENDERER,
76
+ purl: STUDIO,
77
+ purpose: 'load a model document for the view',
78
+ layer: 2,
79
+ },
80
+
81
+ // --- packages/subsystems-react · consumed library, housed outside the process
82
+ {
83
+ alias: 'component-graph',
84
+ name: 'SubsystemComponentGraph',
85
+ construct: 'function',
86
+ symbol: 'SubsystemComponentGraph',
87
+ file: 'packages/subsystems-react/src/subsystem/SubsystemComponentGraph.tsx',
88
+ module: 'packages/subsystems-react/src/subsystem/SubsystemComponentGraph.tsx',
89
+ purl: REACT,
90
+ purpose: 'the graph canvas the view embeds',
91
+ layer: 1,
92
+ },
93
+ {
94
+ alias: 'file-drawer',
95
+ name: 'FileDrawer',
96
+ construct: 'function',
97
+ symbol: 'FileDrawer',
98
+ file: 'packages/subsystems-react/src/subsystem/FileDrawer.tsx',
99
+ module: 'packages/subsystems-react/src/subsystem/FileDrawer.tsx',
100
+ purl: REACT,
101
+ purpose: 'pierre-backed source drawer',
102
+ layer: 2,
103
+ },
104
+ {
105
+ alias: 'pierre-file-view',
106
+ name: 'PierreFileView',
107
+ construct: 'function',
108
+ symbol: 'PierreFileView',
109
+ file: 'packages/subsystems-react/src/pierre/PierreFileView.tsx',
110
+ module: 'packages/subsystems-react/src/pierre/PierreFileView.tsx',
111
+ purl: REACT,
112
+ purpose: 'renders one file slice',
113
+ layer: 3,
114
+ },
115
+ ];
116
+
117
+ const spec = graphSpecFromEdges([
118
+ ['read-file', 'get-model', 'calls'],
119
+ ['model-view', 'component-graph', 'uses'],
120
+ ['component-graph', 'file-drawer', 'calls'],
121
+ ['file-drawer', 'pierre-file-view', 'uses'],
122
+ ]);
123
+
124
+ export const PackageOwnedProcess: Story = {
125
+ name: 'Package-owned process · library outside',
126
+ args: {
127
+ components,
128
+ relations: spec.relations,
129
+ walkthroughs: spec.walkthroughs,
130
+ graphTitle: 'Opening a file — app package owns the process',
131
+ },
132
+ render: (args) => (
133
+ <div style={{ width: '100vw', height: '100vh' }}>
134
+ <SubsystemComponentGraph {...args} />
135
+ </div>
136
+ ),
137
+ };
@@ -53,6 +53,7 @@ import {
53
53
  } from './model';
54
54
  import { ConstructsCatalog } from './ConstructsCatalog';
55
55
  import type { SubsystemOpenFileOptions } from './declarationRef';
56
+ import type { WalkthroughSymbolQuery } from '../pierre/PierreWalkthroughCodeView';
56
57
  import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
57
58
  import { SubsystemDiagnosticToggle, type SubsystemDiagnostic } from './DiagnosticToggle';
58
59
  import {
@@ -88,6 +89,28 @@ export interface WalkthroughViewerContext {
88
89
  * but whose endpoint is proposed can be labelled as planned, not missing.
89
90
  */
90
91
  proposedAliases: ReadonlySet<string>;
92
+ /**
93
+ * Resolve a token in a step's snippet to a construct the step touches (its
94
+ * `from`/`to` component), returning that component's alias. `null` when the
95
+ * token names no touched construct. Forward to the code view so constructs
96
+ * read as clickable.
97
+ */
98
+ resolveSymbol?: (query: WalkthroughSymbolQuery) => string | null;
99
+ /** A clicked construct token — open that construct's declaration line. */
100
+ onSymbolClick?: (symbol: string, query: WalkthroughSymbolQuery) => void;
101
+ }
102
+
103
+ /** Identifiers a token could match to name this component as a construct. */
104
+ function constructIdentifiers(comp: SubsystemComponent): string[] {
105
+ const ids = new Set<string>();
106
+ if (comp.name) ids.add(comp.name);
107
+ if (comp.symbol) {
108
+ ids.add(comp.symbol);
109
+ for (const part of comp.symbol.split('.')) {
110
+ if (part) ids.add(part);
111
+ }
112
+ }
113
+ return [...ids];
91
114
  }
92
115
 
93
116
  type DrawerTarget =
@@ -390,14 +413,29 @@ const WalkthroughDrawerContent = memo(function WalkthroughDrawerContent({
390
413
  stepIndex,
391
414
  onOpenFile,
392
415
  proposedAliases,
416
+ resolveSymbol,
417
+ onSymbolClick,
393
418
  }: {
394
419
  render: (ctx: WalkthroughViewerContext) => ReactNode;
395
420
  walkthrough: SubsystemWalkthrough;
396
421
  stepIndex: number | null;
397
422
  onOpenFile: (path: string, opts?: SubsystemOpenFileOptions) => void;
398
423
  proposedAliases: ReadonlySet<string>;
424
+ resolveSymbol?: (query: WalkthroughSymbolQuery) => string | null;
425
+ onSymbolClick?: (symbol: string, query: WalkthroughSymbolQuery) => void;
399
426
  }) {
400
- return <>{render({ walkthrough, stepIndex, onOpenFile, proposedAliases })}</>;
427
+ return (
428
+ <>
429
+ {render({
430
+ walkthrough,
431
+ stepIndex,
432
+ onOpenFile,
433
+ proposedAliases,
434
+ resolveSymbol,
435
+ onSymbolClick,
436
+ })}
437
+ </>
438
+ );
401
439
  });
402
440
 
403
441
  interface InnerProps extends SubsystemComponentGraphProps {
@@ -570,6 +608,10 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
570
608
  if (drawerTarget?.kind !== 'walkthrough' || !walkthroughs) return null;
571
609
  return walkthroughs.find((t) => t.id === drawerTarget.walkthroughId) ?? null;
572
610
  }, [drawerTarget, walkthroughs]);
611
+ // Ref mirror of the drawer's walkthrough id so the symbol resolver stays
612
+ // stable across graph re-renders (the drawer is memoized on callback identity).
613
+ const drawerWalkthroughIdRef = useRef<string | null>(null);
614
+ drawerWalkthroughIdRef.current = focusedWalkthrough?.id ?? null;
573
615
 
574
616
  // Walkthrough shown on the canvas title chip (focus or hover/autoplay highlight).
575
617
  const overlayWalkthroughTitle = useMemo(() => {
@@ -1576,6 +1618,60 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
1576
1618
  [components, onSelect],
1577
1619
  );
1578
1620
 
1621
+ // Construct tokens inside a walkthrough snippet. A step's line is an edge
1622
+ // between its `from`/`to` components, so a token naming either of those
1623
+ // constructs should navigate to that construct's declaration. Index the
1624
+ // matchable identifiers per step (`walkthroughId:index`).
1625
+ const walkthroughSymbolIndex = useMemo(() => {
1626
+ const byAlias = new Map(components.map((c) => [c.alias, c]));
1627
+ const index = new Map<string, Map<string, string>>();
1628
+ for (const wt of walkthroughs ?? []) {
1629
+ wt.steps.forEach((step, i) => {
1630
+ const tokens = new Map<string, string>();
1631
+ for (const alias of [step.from, step.to]) {
1632
+ const comp = byAlias.get(alias);
1633
+ if (!comp) continue;
1634
+ for (const ident of constructIdentifiers(comp)) tokens.set(ident, alias);
1635
+ }
1636
+ index.set(`${wt.id}:${i}`, tokens);
1637
+ });
1638
+ }
1639
+ return index;
1640
+ }, [components, walkthroughs]);
1641
+ const walkthroughSymbolIndexRef = useRef(walkthroughSymbolIndex);
1642
+ walkthroughSymbolIndexRef.current = walkthroughSymbolIndex;
1643
+
1644
+ // Stable resolver: reads the live index + focused walkthrough from refs so
1645
+ // the memoized walkthrough drawer isn't rebuilt on every graph render.
1646
+ const resolveWalkthroughSymbol = useCallback(
1647
+ (query: WalkthroughSymbolQuery): string | null => {
1648
+ const walkthroughId = drawerWalkthroughIdRef.current;
1649
+ if (walkthroughId == null) return null;
1650
+ return (
1651
+ walkthroughSymbolIndexRef.current
1652
+ .get(`${walkthroughId}:${query.stepIndex}`)
1653
+ ?.get(query.tokenText) ?? null
1654
+ );
1655
+ },
1656
+ [],
1657
+ );
1658
+
1659
+ // A construct token click navigates to that construct's declaration: open
1660
+ // its file at the anchored declaration line (same path as the declaration
1661
+ // panel's file link), falling back to the file top when unanchored.
1662
+ const openConstructDeclaration = useCallback(
1663
+ (alias: string) => {
1664
+ const comp = components.find((c) => c.alias === alias);
1665
+ if (!comp?.file) return;
1666
+ const startLine = comp.declarationRef?.startLine;
1667
+ onOpenDeclarationFile(
1668
+ comp.file,
1669
+ startLine != null ? { startLine } : undefined,
1670
+ );
1671
+ },
1672
+ [components, onOpenDeclarationFile],
1673
+ );
1674
+
1579
1675
  // Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
1580
1676
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
1581
1677
  // the actual edge path (not node-center approximations).
@@ -2210,6 +2306,8 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
2210
2306
  stepIndex={drawerTarget.stepIndex}
2211
2307
  onOpenFile={onOpenFileFromWalkthrough}
2212
2308
  proposedAliases={proposedAliases}
2309
+ resolveSymbol={resolveWalkthroughSymbol}
2310
+ onSymbolClick={openConstructDeclaration}
2213
2311
  />
2214
2312
  ) : drawerTarget?.kind === 'file' ? (
2215
2313
  <FileDrawerContent