@principal-ai/subsystems-react 0.56.10 → 0.56.12

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 (34) hide show
  1. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
  2. package/dist/subsystem/DiagnosticToggle.js +2 -1
  3. package/dist/subsystem/DiagnosticToggle.js.map +1 -1
  4. package/dist/subsystem/SubsystemComponentGraph.d.ts +2 -1
  5. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  6. package/dist/subsystem/SubsystemComponentGraph.js +64 -27
  7. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  8. package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
  9. package/dist/subsystem/SubsystemFileTree.js +2 -1
  10. package/dist/subsystem/SubsystemFileTree.js.map +1 -1
  11. package/dist/subsystem/TrailStepsNav.d.ts +3 -1
  12. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
  13. package/dist/subsystem/TrailStepsNav.js +13 -10
  14. package/dist/subsystem/TrailStepsNav.js.map +1 -1
  15. package/dist/subsystem/TrailsPanel.d.ts +5 -3
  16. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  17. package/dist/subsystem/TrailsPanel.js +44 -68
  18. package/dist/subsystem/TrailsPanel.js.map +1 -1
  19. package/dist/subsystem/trailSidebarScope.d.ts +11 -0
  20. package/dist/subsystem/trailSidebarScope.d.ts.map +1 -0
  21. package/dist/subsystem/trailSidebarScope.js +47 -0
  22. package/dist/subsystem/trailSidebarScope.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/stories/Canvases/Subsystem/TrailSidebarViews.stories.tsx +68 -0
  25. package/src/subsystem/DiagnosticToggle.tsx +2 -1
  26. package/src/subsystem/SubsystemComponentGraph.sidebar.test.tsx +106 -5
  27. package/src/subsystem/SubsystemComponentGraph.tsx +87 -41
  28. package/src/subsystem/SubsystemFileTree.tsx +2 -1
  29. package/src/subsystem/TrailStepsNav.test.tsx +60 -0
  30. package/src/subsystem/TrailStepsNav.tsx +16 -10
  31. package/src/subsystem/TrailsPanel.test.tsx +99 -35
  32. package/src/subsystem/TrailsPanel.tsx +43 -86
  33. package/src/subsystem/trailSidebarScope.test.ts +89 -0
  34. package/src/subsystem/trailSidebarScope.ts +62 -0
@@ -0,0 +1,47 @@
1
+ import { buildTreePathMapping, purlOwnerName, purlRepoKey } from './paths';
2
+ /** Scope sidebar content to a trail's endpoints and authored source sites. */
3
+ export function buildTrailSidebarScope(components, trails, issues, trailId) {
4
+ const trail = trails?.find((item) => item.id === trailId) ?? null;
5
+ if (!trail) {
6
+ return { trail: null, treePaths: buildTreePathMapping(components), issues: issues ?? [] };
7
+ }
8
+ const aliases = new Set(trail.steps.flatMap((step) => [step.from, step.to]));
9
+ const participants = components.filter((component) => aliases.has(component.alias));
10
+ const files = [...participants];
11
+ for (const step of trail.steps) {
12
+ files.push(step);
13
+ if (step.target)
14
+ files.push(step.target);
15
+ }
16
+ const modules = new Set(participants.map((component) => component.module?.trim()).filter(Boolean));
17
+ const processes = new Set(participants.map((component) => component.process).filter(Boolean));
18
+ const repos = new Set(files.flatMap((file) => [purlRepoKey(file.purl), purlOwnerName(file.purl)].filter((key) => !!key)));
19
+ const scopedIssues = (issues ?? []).filter((issue) => {
20
+ const target = issue.target;
21
+ if (!target)
22
+ return false;
23
+ const key = target.id ?? target.label;
24
+ switch (target.kind) {
25
+ case 'component': {
26
+ const component = components.find((candidate) => candidate.alias === target.id || candidate.alias === target.label ||
27
+ candidate.name === target.label || candidate.symbol === target.label);
28
+ return component != null && aliases.has(component.alias);
29
+ }
30
+ case 'trail':
31
+ return target.id != null ? target.id === trail.id : target.label === trail.title;
32
+ case 'step':
33
+ return target.id === trail.id && target.stepIndex != null &&
34
+ Number.isInteger(target.stepIndex) && trail.steps[target.stepIndex] != null;
35
+ case 'module':
36
+ return modules.has(key);
37
+ case 'process':
38
+ return processes.has(key);
39
+ case 'repo':
40
+ return repos.has(purlRepoKey(key) ?? key);
41
+ default:
42
+ return false;
43
+ }
44
+ });
45
+ return { trail, treePaths: buildTreePathMapping(files), issues: scopedIssues };
46
+ }
47
+ //# sourceMappingURL=trailSidebarScope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trailSidebarScope.js","sourceRoot":"","sources":["../../src/subsystem/trailSidebarScope.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@principal-ai/subsystems-react",
3
- "version": "0.56.10",
3
+ "version": "0.56.12",
4
4
  "description": "React UI for Subsystem Models — graph, Pierre code views, and session-event feeds",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,68 @@
1
+ import { useState } from 'react';
2
+ import '@xyflow/react/dist/style.css';
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
5
+ import { SubsystemComponentGraph } from '../../../subsystem/SubsystemComponentGraph';
6
+ import type { SubsystemComponent, SubsystemTrail } from '../../../subsystem/model';
7
+ import type { SubsystemIssue } from '../../../subsystem/IssueList';
8
+
9
+ const components: SubsystemComponent[] = [
10
+ { alias: 'checkout', name: 'Checkout', construct: 'function', file: 'src/checkout.ts', purl: 'pkg:github/acme/app' },
11
+ { alias: 'store', name: 'Store', construct: 'function', file: 'src/store.ts', purl: 'pkg:github/acme/app' },
12
+ { alias: 'report', name: 'Report', construct: 'function', file: 'src/report.ts', purl: 'pkg:github/acme/app' },
13
+ { alias: 'mail', name: 'Mail', construct: 'function', file: 'src/mail.ts', purl: 'pkg:github/acme/app' },
14
+ ];
15
+ const trails: SubsystemTrail[] = [
16
+ { id: 'checkout', title: 'Validate the checkout and persist the customer’s order with all selected items', steps: [
17
+ { from: 'checkout', to: 'store', mechanism: 'calls', file: 'src/checkout.ts',
18
+ purl: 'pkg:github/acme/app', symbol: 'saveCheckout', annotation: 'Persist the checkout.' },
19
+ ] },
20
+ { id: 'report', title: 'Generate the monthly account activity report and deliver it to every subscribed recipient', steps: [
21
+ { from: 'report', to: 'mail', mechanism: 'calls', file: 'src/report.ts',
22
+ purl: 'pkg:github/acme/app', symbol: 'sendReport' },
23
+ ] },
24
+ ];
25
+ const issues: SubsystemIssue[] = [
26
+ { id: 'checkout-symbol', severity: 'error', kind: 'symbol_unmatched',
27
+ message: 'Checkout declaration needs verification.',
28
+ target: { kind: 'component', id: 'checkout', label: 'Checkout' } },
29
+ { id: 'checkout-step', severity: 'error', kind: 'step_span_unconfirmed',
30
+ message: 'Capture the checkout seam.',
31
+ target: { kind: 'step', id: 'checkout', stepIndex: 0, label: 'Checkout → Store' } },
32
+ { id: 'report-symbol', severity: 'error', kind: 'symbol_unmatched',
33
+ message: 'Report declaration needs verification.',
34
+ target: { kind: 'component', id: 'report', label: 'Report' } },
35
+ ];
36
+
37
+ function Demo() {
38
+ const [showIssues, setShowIssues] = useState(false);
39
+ return (
40
+ <div style={{ height: '100vh' }}>
41
+ <SubsystemComponentGraph
42
+ title="Trail-scoped sidebar"
43
+ description="Select a trail, then switch between Trails, Files, and Issues. Its header stays visible and each panel shows only that trail’s information. Back clears the trail context."
44
+ components={components}
45
+ trails={trails}
46
+ issues={issues}
47
+ showIssues={showIssues}
48
+ diagnostic={{ status: 'issues', issueCount: issues.length,
49
+ onToggle: () => setShowIssues((value) => !value) }}
50
+ renderFileViewer={(file) => (
51
+ <pre style={{ padding: 16 }}>Source preview: {file}</pre>
52
+ )}
53
+ />
54
+ </div>
55
+ );
56
+ }
57
+
58
+ const meta = {
59
+ title: 'Canvases/Subsystem/TrailSidebarViews',
60
+ component: SubsystemComponentGraph,
61
+ parameters: { layout: 'fullscreen' },
62
+ decorators: [(Story) => (
63
+ <ThemeProvider theme={defaultEditorTheme}><Story /></ThemeProvider>
64
+ )],
65
+ } satisfies Meta<typeof SubsystemComponentGraph>;
66
+ export default meta;
67
+ type Story = StoryObj<typeof meta>;
68
+ export const ScopedPanels: Story = { render: () => <Demo /> };
@@ -155,7 +155,7 @@ export function SubsystemDiagnosticToggle({
155
155
  justifyContent: bordered || segmented ? 'center' : undefined,
156
156
  gap: 4,
157
157
  height: bordered || segmented ? 28 : 22,
158
- padding: bordered || segmented ? '4px 7px' : showCount ? '0 7px 0 6px' : '0 5px',
158
+ padding: segmented ? '4px 16px' : bordered ? '4px 7px' : showCount ? '0 7px 0 6px' : '0 5px',
159
159
  border: bordered ? `1px solid ${theme.colors.border}` : 'none',
160
160
  borderRadius: 4,
161
161
  background,
@@ -227,6 +227,7 @@ export function SubsystemDiagnosticToggle({
227
227
  fontFamily: theme.fonts.monospace,
228
228
  fontSize: theme.fontSizes[0],
229
229
  fontWeight: 600,
230
+ marginLeft: segmented && labelText ? 4 : undefined,
230
231
  lineHeight: 1,
231
232
  }}
232
233
  >
@@ -73,15 +73,51 @@ describe('subsystem graph sidebar', () => {
73
73
  });
74
74
 
75
75
  test('groups Trails, Files, and Issues into one switch with Trails selected by default', () => {
76
- const body = renderGraph();
76
+ const body = renderGraph({ diagnostic: { status: 'issues', issueCount: 1 } });
77
77
  const group = body.querySelector('header [role="group"][aria-label="Sidebar view"]');
78
78
  expect(group?.querySelectorAll('button').length).toBe(3);
79
79
  expect(group?.querySelectorAll('button[aria-pressed="true"]').length).toBe(1);
80
- expect(group?.querySelector('button[aria-label="Trails"]')?.getAttribute('aria-pressed')).toBe('true');
81
- expect(group?.querySelector('button[aria-label^="Issues:"]')?.textContent).toBe('Issues');
80
+ expect(group?.querySelector('button[aria-label^="Trails"]')?.getAttribute('aria-pressed')).toBe('true');
81
+ expect(group?.querySelector('button[aria-label^="Trails"]')?.textContent).toBe('Trails1');
82
+ expect(group?.querySelector('button[aria-label^="Issues:"]')?.textContent).toBe('Issues1');
82
83
  expect((group as HTMLElement).style.borderStyle).toBe('solid');
84
+ expect((group as HTMLElement).style.gridAutoColumns).toBe('1fr');
85
+ for (const button of group!.querySelectorAll('button')) {
86
+ expect((button as HTMLElement).style.paddingLeft).toBe('16px');
87
+ expect((button as HTMLElement).style.paddingRight).toBe('16px');
88
+ expect((button.lastElementChild as HTMLElement).style.marginLeft).toBe('4px');
89
+ }
83
90
  });
84
91
 
92
+ for (const count of [0, 1, 3]) {
93
+ test(`shows ${count} in the plural Trails option`, () => {
94
+ const body = renderGraph({
95
+ trails: Array.from({ length: count }, (_, index) => ({ ...trails![0], id: `trail-${index}` })),
96
+ graphifyRelations,
97
+ });
98
+ const button = body.querySelector('header button[aria-label^="Trails"]');
99
+ expect(button?.textContent).toBe(`Trails${count}`);
100
+ expect(button?.getAttribute('aria-label')).toBe(`Trails (${count} ${count === 1 ? 'trail' : 'trails'})`);
101
+ });
102
+ }
103
+
104
+ for (const props of [
105
+ {},
106
+ { diagnostic: { status: 'ok' as const, issueCount: 0 } },
107
+ { issues: [], diagnostic: { status: 'issues' as const, issueCount: 3 } },
108
+ { issues: [], showIssues: true },
109
+ ]) {
110
+ test(`hides Issues and keeps Trails active for an empty scope: ${JSON.stringify(props)}`, () => {
111
+ const body = renderGraph(props);
112
+ const group = body.querySelector('header [aria-label="Sidebar view"]');
113
+ expect(group?.querySelectorAll('button').length).toBe(2);
114
+ expect(group?.querySelector('button[aria-label^="Issues:"]')).toBeNull();
115
+ expect(group?.querySelector('button[aria-label^="Trails"]')?.getAttribute('aria-pressed')).toBe('true');
116
+ expect(body.textContent).toContain('Request flow');
117
+ expect((group as HTMLElement).style.gridAutoColumns).toBe('1fr');
118
+ });
119
+ }
120
+
85
121
  test('omits the Files button when no components have files', () => {
86
122
  const body = renderGraph({
87
123
  components: components.map((component) => ({ ...component, file: '' })),
@@ -110,7 +146,7 @@ describe('subsystem graph sidebar', () => {
110
146
  expect(toggle?.getAttribute('aria-pressed')).toBe(String(active));
111
147
  expect(header?.querySelectorAll('button').length).toBe(3);
112
148
  expect(header?.querySelectorAll('button[aria-pressed="true"]').length).toBe(1);
113
- expect(header?.querySelector('button[aria-label="Trails"]')?.getAttribute('aria-pressed')).toBe(String(!active));
149
+ expect(header?.querySelector('button[aria-label^="Trails"]')?.getAttribute('aria-pressed')).toBe(String(!active));
114
150
  expect(body.querySelectorAll('button[aria-label^="Issues:"]').length).toBe(1);
115
151
  expect(header?.nextElementSibling?.querySelector('button[aria-label^="Issues:"]')).toBeNull();
116
152
  expect(body.textContent).toContain('Always visible description.');
@@ -188,6 +224,8 @@ describe('subsystem graph sidebar', () => {
188
224
  globalThis.window = dom as unknown as typeof window;
189
225
  try {
190
226
  const body = renderGraph({ persistKey: 'trail-nav-test' });
227
+ expect(body.querySelector('header button[aria-label="Trail"]')?.textContent).toBe('Trail');
228
+ expect(body.querySelector('header button[aria-label^="Trails"]')).toBeNull();
191
229
  expect(body.querySelector('[aria-hidden="false"] button[aria-label="Back to trails"]')).not.toBeNull();
192
230
  expect(body.querySelector('button[aria-label^="Play "]')).toBeNull();
193
231
  expect(body.querySelector('button[aria-label^="Focus layout"]')).toBeNull();
@@ -197,6 +235,66 @@ describe('subsystem graph sidebar', () => {
197
235
  }
198
236
  });
199
237
 
238
+ test('keeps the trail header and scoped counts in Issues after graph selection is cleared', () => {
239
+ const previousWindow = globalThis.window;
240
+ const dom = new Window();
241
+ dom.localStorage.setItem('principal.subsystems.viewState.scoped-sidebar-test', JSON.stringify({
242
+ sidebarTrailId: 'request',
243
+ focusedTrailId: null,
244
+ }));
245
+ globalThis.window = dom as unknown as typeof window;
246
+ try {
247
+ const body = renderGraph({
248
+ persistKey: 'scoped-sidebar-test',
249
+ showIssues: true,
250
+ components: [...components, {
251
+ alias: 'other', name: 'Other', construct: 'function', file: 'src/other.ts', purl: 'pkg:github/test/app',
252
+ }],
253
+ issues: [
254
+ { id: 'related', severity: 'error', kind: 'missing_file', message: 'Related finding',
255
+ target: { kind: 'component', id: 'entry', label: 'Entry' } },
256
+ { id: 'unrelated', severity: 'error', kind: 'missing_file', message: 'Unrelated finding',
257
+ target: { kind: 'component', id: 'other', label: 'Other' } },
258
+ ],
259
+ diagnostic: { status: 'issues', issueCount: 2 },
260
+ });
261
+ expect(body.querySelector('[aria-hidden="false"] button[aria-label="Back to trails"]')).not.toBeNull();
262
+ expect(body.querySelector('[aria-hidden="false"]')?.textContent).toContain('Request flow');
263
+ expect(body.querySelector('header button[aria-label^="Files"]')?.textContent).toBe('Files2');
264
+ expect(body.querySelector('header button[aria-label^="Issues:"]')?.textContent).toBe('Issues1');
265
+ expect(body.querySelector('header button[aria-label="Trail"]')?.textContent).toBe('Trail');
266
+ expect(body.querySelector('[aria-hidden="false"]')?.textContent).not.toContain('Unrelated finding');
267
+ } finally {
268
+ globalThis.window = previousWindow;
269
+ }
270
+ });
271
+
272
+ test('hides Issues for a selected trail with no findings even when other trails have issues', () => {
273
+ const previousWindow = globalThis.window;
274
+ const dom = new Window();
275
+ dom.localStorage.setItem('principal.subsystems.viewState.empty-trail-issues-test', JSON.stringify({
276
+ sidebarTrailId: 'request',
277
+ }));
278
+ globalThis.window = dom as unknown as typeof window;
279
+ try {
280
+ const body = renderGraph({
281
+ persistKey: 'empty-trail-issues-test',
282
+ showIssues: true,
283
+ issues: [{
284
+ id: 'unrelated', severity: 'error', kind: 'missing_file', message: 'Unrelated finding',
285
+ target: { kind: 'component', id: 'other', label: 'Other' },
286
+ }],
287
+ diagnostic: { status: 'issues', issueCount: 1 },
288
+ });
289
+ expect(body.querySelector('header button[aria-label^="Issues:"]')).toBeNull();
290
+ expect(body.querySelector('header button[aria-label="Trail"]')?.getAttribute('aria-pressed')).toBe('true');
291
+ expect(body.querySelector('[aria-hidden="false"] button[aria-label="Back to trails"]')).not.toBeNull();
292
+ expect(body.querySelector('[aria-hidden="false"]')?.textContent).toContain('Entry');
293
+ } finally {
294
+ globalThis.window = previousWindow;
295
+ }
296
+ });
297
+
200
298
  test('static graphs keep their description but do not show a file panel', () => {
201
299
  const body = renderGraph({ trails: undefined, graphifyRelations });
202
300
  expect(body.textContent).toContain('Always visible description.');
@@ -217,7 +315,10 @@ describe('subsystem graph sidebar', () => {
217
315
  });
218
316
 
219
317
  test('the description stays visible while diagnostics replace the trails', () => {
220
- const body = renderGraph({ showIssues: true, issues: [] });
318
+ const body = renderGraph({ showIssues: true, issues: [{
319
+ id: 'related', severity: 'error', kind: 'missing_file', message: 'Related finding',
320
+ target: { kind: 'component', id: 'entry', label: 'Entry' },
321
+ }] });
221
322
  expect(body.textContent).toContain('Always visible description.');
222
323
  expect(body.textContent).not.toContain('Request flow');
223
324
  expect(body.querySelector('[aria-label="Resize sidebar"]')).not.toBeNull();