@principal-ai/subsystems-react 0.28.3 → 0.28.5
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/pierre/PierreFileView.d.ts.map +1 -1
- package/dist/pierre/PierreFileView.js +11 -1
- package/dist/pierre/PierreFileView.js.map +1 -1
- package/dist/pierre/PierreWalkthroughCodeView.d.ts +6 -1
- package/dist/pierre/PierreWalkthroughCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreWalkthroughCodeView.js +83 -30
- package/dist/pierre/PierreWalkthroughCodeView.js.map +1 -1
- package/dist/pierre/fileAvailability.d.ts +17 -0
- package/dist/pierre/fileAvailability.d.ts.map +1 -0
- package/dist/pierre/fileAvailability.js +31 -0
- package/dist/pierre/fileAvailability.js.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts +12 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +70 -11
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/package.json +1 -1
- package/src/pierre/PierreFileView.tsx +22 -0
- package/src/pierre/PierreWalkthroughCodeView.tsx +125 -39
- package/src/pierre/fileAvailability.test.ts +40 -0
- package/src/pierre/fileAvailability.ts +37 -0
- package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +131 -16
- package/src/subsystem/SubsystemComponentGraph.tsx +100 -9
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { fileUnavailableNotice, isFileUnavailableError } from './fileAvailability';
|
|
3
|
+
|
|
4
|
+
describe('isFileUnavailableError', () => {
|
|
5
|
+
test('matches the host "no local checkout" family', () => {
|
|
6
|
+
expect(
|
|
7
|
+
isFileUnavailableError(
|
|
8
|
+
'file not found in graph repos: packages/subsystems-studio/src/bun/subsystem-model-runs.ts',
|
|
9
|
+
),
|
|
10
|
+
).toBe(true);
|
|
11
|
+
expect(isFileUnavailableError('graph has no local root for this file')).toBe(
|
|
12
|
+
true,
|
|
13
|
+
);
|
|
14
|
+
expect(isFileUnavailableError('no local checkout for this repo')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('leaves genuine I/O errors alone', () => {
|
|
18
|
+
expect(isFileUnavailableError('EACCES: permission denied')).toBe(false);
|
|
19
|
+
expect(isFileUnavailableError('')).toBe(false);
|
|
20
|
+
expect(isFileUnavailableError(null)).toBe(false);
|
|
21
|
+
expect(isFileUnavailableError(undefined)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('fileUnavailableNotice', () => {
|
|
26
|
+
test('names proposed work as planned, not missing', () => {
|
|
27
|
+
expect(
|
|
28
|
+
fileUnavailableNotice('src/a.ts', true),
|
|
29
|
+
).toBe("Proposed — src/a.ts isn't in the local checkout yet.");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('falls back to a plain not-in-checkout notice', () => {
|
|
33
|
+
expect(fileUnavailableNotice('src/a.ts')).toBe(
|
|
34
|
+
'Not in the local checkout: src/a.ts',
|
|
35
|
+
);
|
|
36
|
+
expect(fileUnavailableNotice('src/a.ts', false)).toBe(
|
|
37
|
+
'Not in the local checkout: src/a.ts',
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host file reads fail with a small, stable set of messages when a path can't
|
|
3
|
+
* be served from any local checkout — most often a *proposed* seam whose file
|
|
4
|
+
* isn't implemented yet. These helpers detect that class of failure so the
|
|
5
|
+
* Pierre views can show a proposed / not-yet-available notice instead of a raw
|
|
6
|
+
* host error.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Substrings the host uses when a read can't resolve to a local file. */
|
|
10
|
+
const UNAVAILABLE_FRAGMENTS = [
|
|
11
|
+
'not found in graph repos',
|
|
12
|
+
'graph has no local root',
|
|
13
|
+
'no local checkout',
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
/** True when a read failed because the file has no local checkout, not because
|
|
17
|
+
* of an I/O or permission error. */
|
|
18
|
+
export function isFileUnavailableError(
|
|
19
|
+
message: string | null | undefined,
|
|
20
|
+
): boolean {
|
|
21
|
+
if (!message) return false;
|
|
22
|
+
return UNAVAILABLE_FRAGMENTS.some((fragment) => message.includes(fragment));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One-line notice shown in place of a snippet whose file can't be read. When
|
|
27
|
+
* the step touches a proposed component the wording names that, so a proposed
|
|
28
|
+
* seam reads as "planned" rather than "missing".
|
|
29
|
+
*/
|
|
30
|
+
export function fileUnavailableNotice(
|
|
31
|
+
path: string,
|
|
32
|
+
proposed?: boolean,
|
|
33
|
+
): string {
|
|
34
|
+
return proposed
|
|
35
|
+
? `Proposed — ${path} isn't in the local checkout yet.`
|
|
36
|
+
: `Not in the local checkout: ${path}`;
|
|
37
|
+
}
|
|
@@ -54,7 +54,11 @@ const storyFiles: Record<string, string> = {
|
|
|
54
54
|
function readStoryFile(path: string): Promise<string> {
|
|
55
55
|
const content = storyFiles[path];
|
|
56
56
|
if (content == null) {
|
|
57
|
-
|
|
57
|
+
// Mirror the host's wording so the Pierre views exercise their
|
|
58
|
+
// "not in the local checkout" handling, not just any rejection.
|
|
59
|
+
return Promise.reject(
|
|
60
|
+
new Error(`file not found in graph repos: ${path}`),
|
|
61
|
+
);
|
|
58
62
|
}
|
|
59
63
|
return Promise.resolve(content);
|
|
60
64
|
}
|
|
@@ -122,50 +126,59 @@ const drawingComponents: SubsystemComponent[] = [
|
|
|
122
126
|
},
|
|
123
127
|
];
|
|
124
128
|
|
|
129
|
+
const STORY_PURL = 'pkg:github/principal-ai/desktop-app';
|
|
130
|
+
const stepPurl = (file: string) => `${STORY_PURL}#${file}`;
|
|
131
|
+
|
|
125
132
|
const drawingWalkthroughs: SubsystemWalkthrough[] = [
|
|
126
133
|
{
|
|
127
134
|
id: 'tl-open-drawing',
|
|
128
135
|
title: 'Open drawing',
|
|
129
136
|
steps: [
|
|
130
|
-
{ from: 'panel', to: 'storage', mechanism: 'calls', file: 'src/panels/DrawingsLeftPanel.tsx', line: 56, symbol: 'DrawingsLeftPanel.scan', annotation: 'Lists the drawings directory; each row feeds an open intent.' },
|
|
131
|
-
{ from: 'panel', to: 'host', mechanism: 'produces', file: 'src/panels/DrawingsLeftPanel.tsx', line: 85, symbol: 'DrawingsLeftPanel.openDrawing', annotation: 'Emits the open intent up to the host.' },
|
|
132
|
-
{ from: 'host', to: 'storage', mechanism: 'calls', file: 'src/hooks/useDrawingsHost.ts', line: 45, symbol: 'useDrawingsHost.openDrawing', annotation: 'Host resolves the path and delegates to storage.' },
|
|
133
|
-
{ from: 'host', to: 'shell', mechanism: 'produces', file: 'src/workspace/WorkspaceShell.tsx', line: 369, symbol: 'WorkspaceShell.openTab', annotation: 'Asks the shell to surface the drawing as a tab.' },
|
|
134
|
-
{ from: 'shell', to: 'tab', mechanism: 'feeds', file: 'src/workspace/WorkspaceShell.tsx', line: 372, symbol: 'WorkspaceShell.mountTab' },
|
|
135
|
-
{ from: 'storage', to: 'fs', mechanism: 'calls', file: 'src/storage/drawingsStorage.ts', line: 90, symbol: 'DrawingsStorage.read', annotation: 'Reads the file through the sandboxed fs service.' },
|
|
136
|
-
{ from: 'tab', to: 'fs', mechanism: 'calls', file: 'src/components/DrawingTabContent.tsx', line: 83, symbol: 'DrawingTabContent.load' },
|
|
137
|
+
{ from: 'panel', to: 'storage', mechanism: 'calls', file: 'src/panels/DrawingsLeftPanel.tsx', line: 56, purl: stepPurl('src/panels/DrawingsLeftPanel.tsx'), symbol: 'DrawingsLeftPanel.scan', annotation: 'Lists the drawings directory; each row feeds an open intent.' },
|
|
138
|
+
{ from: 'panel', to: 'host', mechanism: 'produces', file: 'src/panels/DrawingsLeftPanel.tsx', line: 85, purl: stepPurl('src/panels/DrawingsLeftPanel.tsx'), symbol: 'DrawingsLeftPanel.openDrawing', annotation: 'Emits the open intent up to the host.' },
|
|
139
|
+
{ from: 'host', to: 'storage', mechanism: 'calls', file: 'src/hooks/useDrawingsHost.ts', line: 45, purl: stepPurl('src/hooks/useDrawingsHost.ts'), symbol: 'useDrawingsHost.openDrawing', annotation: 'Host resolves the path and delegates to storage.' },
|
|
140
|
+
{ from: 'host', to: 'shell', mechanism: 'produces', file: 'src/workspace/WorkspaceShell.tsx', line: 369, purl: stepPurl('src/workspace/WorkspaceShell.tsx'), symbol: 'WorkspaceShell.openTab', annotation: 'Asks the shell to surface the drawing as a tab.' },
|
|
141
|
+
{ from: 'shell', to: 'tab', mechanism: 'feeds', file: 'src/workspace/WorkspaceShell.tsx', line: 372, purl: stepPurl('src/workspace/WorkspaceShell.tsx'), symbol: 'WorkspaceShell.mountTab' },
|
|
142
|
+
{ from: 'storage', to: 'fs', mechanism: 'calls', file: 'src/storage/drawingsStorage.ts', line: 90, purl: stepPurl('src/storage/drawingsStorage.ts'), symbol: 'DrawingsStorage.read', annotation: 'Reads the file through the sandboxed fs service.' },
|
|
143
|
+
{ from: 'tab', to: 'fs', mechanism: 'calls', file: 'src/components/DrawingTabContent.tsx', line: 83, purl: stepPurl('src/components/DrawingTabContent.tsx'), symbol: 'DrawingTabContent.load' },
|
|
137
144
|
],
|
|
138
145
|
},
|
|
139
146
|
{
|
|
140
147
|
id: 'tl-save-drawing',
|
|
141
148
|
title: 'Save drawing',
|
|
142
149
|
steps: [
|
|
143
|
-
{ from: 'tab', to: 'fs', mechanism: 'calls', file: 'src/components/DrawingTabContent.tsx', line: 112, symbol: 'DrawingTabContent.save' },
|
|
144
|
-
{ from: 'tab', to: 'host', mechanism: 'produces', file: 'src/components/DrawingTabContent.tsx', line: 121, symbol: 'DrawingTabContent.emitSaved' },
|
|
145
|
-
{ from: 'host', to: 'panel', mechanism: 'produces', file: 'src/hooks/useDrawingsHost.ts', line: 64, symbol: 'useDrawingsHost.onSaved' },
|
|
146
|
-
{ from: 'panel', to: 'storage', mechanism: 'calls', file: 'src/panels/DrawingsLeftPanel.tsx', line: 56, symbol: 'DrawingsLeftPanel.scan' },
|
|
150
|
+
{ from: 'tab', to: 'fs', mechanism: 'calls', file: 'src/components/DrawingTabContent.tsx', line: 112, purl: stepPurl('src/components/DrawingTabContent.tsx'), symbol: 'DrawingTabContent.save' },
|
|
151
|
+
{ from: 'tab', to: 'host', mechanism: 'produces', file: 'src/components/DrawingTabContent.tsx', line: 121, purl: stepPurl('src/components/DrawingTabContent.tsx'), symbol: 'DrawingTabContent.emitSaved' },
|
|
152
|
+
{ from: 'host', to: 'panel', mechanism: 'produces', file: 'src/hooks/useDrawingsHost.ts', line: 64, purl: stepPurl('src/hooks/useDrawingsHost.ts'), symbol: 'useDrawingsHost.onSaved' },
|
|
153
|
+
{ from: 'panel', to: 'storage', mechanism: 'calls', file: 'src/panels/DrawingsLeftPanel.tsx', line: 56, purl: stepPurl('src/panels/DrawingsLeftPanel.tsx'), symbol: 'DrawingsLeftPanel.scan' },
|
|
147
154
|
],
|
|
148
155
|
},
|
|
149
156
|
{
|
|
150
157
|
id: 'tl-delete-drawing',
|
|
151
158
|
title: 'Delete drawing',
|
|
152
159
|
steps: [
|
|
153
|
-
{ from: 'host', to: 'fs', mechanism: 'calls', file: 'src/hooks/useDrawingsHost.ts', line: 64, symbol: 'useDrawingsHost.deleteDrawing' },
|
|
154
|
-
{ from: 'host', to: 'panel', mechanism: 'produces', file: 'src/hooks/useDrawingsHost.ts', line: 66, symbol: 'useDrawingsHost.refreshList' },
|
|
155
|
-
{ from: 'panel', to: 'storage', mechanism: 'calls', file: 'src/panels/DrawingsLeftPanel.tsx', line: 56, symbol: 'DrawingsLeftPanel.scan' },
|
|
160
|
+
{ from: 'host', to: 'fs', mechanism: 'calls', file: 'src/hooks/useDrawingsHost.ts', line: 64, purl: stepPurl('src/hooks/useDrawingsHost.ts'), symbol: 'useDrawingsHost.deleteDrawing' },
|
|
161
|
+
{ from: 'host', to: 'panel', mechanism: 'produces', file: 'src/hooks/useDrawingsHost.ts', line: 66, purl: stepPurl('src/hooks/useDrawingsHost.ts'), symbol: 'useDrawingsHost.refreshList' },
|
|
162
|
+
{ from: 'panel', to: 'storage', mechanism: 'calls', file: 'src/panels/DrawingsLeftPanel.tsx', line: 56, purl: stepPurl('src/panels/DrawingsLeftPanel.tsx'), symbol: 'DrawingsLeftPanel.scan' },
|
|
156
163
|
],
|
|
157
164
|
},
|
|
158
165
|
];
|
|
159
166
|
|
|
160
167
|
function FlowsDemo() {
|
|
161
168
|
const renderWalkthroughViewer = useCallback(
|
|
162
|
-
({
|
|
169
|
+
({
|
|
170
|
+
walkthrough,
|
|
171
|
+
stepIndex,
|
|
172
|
+
onOpenFile,
|
|
173
|
+
proposedAliases,
|
|
174
|
+
}: WalkthroughViewerContext) => (
|
|
163
175
|
<PierreWalkthroughCodeView
|
|
164
176
|
walkthrough={walkthrough}
|
|
165
177
|
stepIndex={stepIndex}
|
|
166
178
|
readFile={readStoryFile}
|
|
167
179
|
contextLines={4}
|
|
168
180
|
onOpenFile={onOpenFile}
|
|
181
|
+
proposedAliases={proposedAliases}
|
|
169
182
|
/>
|
|
170
183
|
),
|
|
171
184
|
[],
|
|
@@ -204,3 +217,105 @@ function FlowsDemo() {
|
|
|
204
217
|
export const ThreeFlows: Story = {
|
|
205
218
|
render: () => <FlowsDemo />,
|
|
206
219
|
};
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The proposed-seam failure mode: a walkthrough hop whose component is
|
|
223
|
+
* `proposed` points at a file that isn't in the checkout yet (no story
|
|
224
|
+
* fixture). The hop must render an inline "Proposed — … isn't in the local
|
|
225
|
+
* checkout yet." placeholder with no Open-file affordance, while the live hops
|
|
226
|
+
* around it still render their snippets — one bad step must not blank the flow.
|
|
227
|
+
*/
|
|
228
|
+
const proposedComponents: SubsystemComponent[] = [
|
|
229
|
+
...drawingComponents,
|
|
230
|
+
{
|
|
231
|
+
alias: 'runs',
|
|
232
|
+
name: 'SubsystemModelRunStore',
|
|
233
|
+
construct: 'store',
|
|
234
|
+
file: 'src/runs/subsystemModelRuns.ts',
|
|
235
|
+
purl: 'pkg:github/principal-ai/desktop-app',
|
|
236
|
+
symbol: 'SubsystemModelRunStore',
|
|
237
|
+
purpose: 'proposed store associating maintain runs with a model',
|
|
238
|
+
proposed: true,
|
|
239
|
+
process: 'draw-host',
|
|
240
|
+
},
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
const proposedWalkthroughs: SubsystemWalkthrough[] = [
|
|
244
|
+
{
|
|
245
|
+
id: 'tl-associate-run',
|
|
246
|
+
title: 'Associate a finished run with its model (proposed)',
|
|
247
|
+
steps: [
|
|
248
|
+
{
|
|
249
|
+
from: 'host',
|
|
250
|
+
to: 'storage',
|
|
251
|
+
mechanism: 'calls',
|
|
252
|
+
file: 'src/hooks/useDrawingsHost.ts',
|
|
253
|
+
line: 45,
|
|
254
|
+
purl: stepPurl('src/hooks/useDrawingsHost.ts'),
|
|
255
|
+
symbol: 'useDrawingsHost.openDrawing',
|
|
256
|
+
annotation: 'Live hop — its snippet still renders.',
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
from: 'storage',
|
|
260
|
+
to: 'runs',
|
|
261
|
+
mechanism: 'writes',
|
|
262
|
+
file: 'src/runs/subsystemModelRuns.ts',
|
|
263
|
+
line: 1,
|
|
264
|
+
purl: stepPurl('src/runs/subsystemModelRuns.ts'),
|
|
265
|
+
symbol: 'SubsystemModelRunStore.write',
|
|
266
|
+
annotation: 'Proposed seam — the file is not in the checkout yet.',
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
function ProposedMissingStepDemo() {
|
|
273
|
+
const renderWalkthroughViewer = useCallback(
|
|
274
|
+
({
|
|
275
|
+
walkthrough,
|
|
276
|
+
stepIndex,
|
|
277
|
+
onOpenFile,
|
|
278
|
+
proposedAliases,
|
|
279
|
+
}: WalkthroughViewerContext) => (
|
|
280
|
+
<PierreWalkthroughCodeView
|
|
281
|
+
walkthrough={walkthrough}
|
|
282
|
+
stepIndex={stepIndex}
|
|
283
|
+
readFile={readStoryFile}
|
|
284
|
+
contextLines={4}
|
|
285
|
+
onOpenFile={onOpenFile}
|
|
286
|
+
proposedAliases={proposedAliases}
|
|
287
|
+
/>
|
|
288
|
+
),
|
|
289
|
+
[],
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
294
|
+
<SubsystemComponentGraph
|
|
295
|
+
components={proposedComponents}
|
|
296
|
+
relations={[]}
|
|
297
|
+
walkthroughs={proposedWalkthroughs}
|
|
298
|
+
title="proposed seam with a missing file"
|
|
299
|
+
description="Expand **Associate a finished run with its model** and click either step. The proposed hop has no file in the checkout, so its snippet shows an inline *Proposed — … isn't in the local checkout yet.* placeholder instead of failing the whole flow."
|
|
300
|
+
renderWalkthroughViewer={renderWalkthroughViewer}
|
|
301
|
+
renderFileViewer={(file) => (
|
|
302
|
+
<div
|
|
303
|
+
style={{
|
|
304
|
+
padding: 12,
|
|
305
|
+
fontFamily: 'monospace',
|
|
306
|
+
fontSize: 12,
|
|
307
|
+
color: '#bbb',
|
|
308
|
+
whiteSpace: 'pre',
|
|
309
|
+
}}
|
|
310
|
+
>
|
|
311
|
+
{`// ${file}\n …`}
|
|
312
|
+
</div>
|
|
313
|
+
)}
|
|
314
|
+
/>
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export const ProposedMissingStep: Story = {
|
|
320
|
+
render: () => <ProposedMissingStepDemo />,
|
|
321
|
+
};
|
|
@@ -78,12 +78,59 @@ export interface WalkthroughViewerContext {
|
|
|
78
78
|
stepIndex: number | null;
|
|
79
79
|
/** Open a step's full source file over the walkthrough drawer (keeps snippets mounted). */
|
|
80
80
|
onOpenFile: (path: string, opts?: SubsystemOpenFileOptions) => void;
|
|
81
|
+
/**
|
|
82
|
+
* Aliases of components marked `proposed`. A step whose file can't be read
|
|
83
|
+
* but whose endpoint is proposed can be labelled as planned, not missing.
|
|
84
|
+
*/
|
|
85
|
+
proposedAliases: ReadonlySet<string>;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
type DrawerTarget =
|
|
84
89
|
| { kind: 'file'; file: string; startLine?: number }
|
|
85
90
|
| { kind: 'walkthrough'; walkthroughId: string; stepIndex: number | null };
|
|
86
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Per-model UI state persisted to `localStorage`, keyed by `persistKey`.
|
|
94
|
+
* Only the walkthrough working set is stored — which flows are expanded and
|
|
95
|
+
* which flow/step is selected — so tabbing away from a model and back lands
|
|
96
|
+
* you where you left off. Transient things (hover, drag, camera) are not saved.
|
|
97
|
+
*/
|
|
98
|
+
interface PersistedViewState {
|
|
99
|
+
expandedWalkthroughs?: string[];
|
|
100
|
+
focusedWalkthroughId?: string | null;
|
|
101
|
+
focusedStepIndex?: number | null;
|
|
102
|
+
sidebarWidth?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const VIEW_STATE_PREFIX = 'principal.subsystems.viewState.';
|
|
106
|
+
|
|
107
|
+
function readViewState(key: string | undefined): PersistedViewState {
|
|
108
|
+
if (!key || typeof window === 'undefined') return {};
|
|
109
|
+
try {
|
|
110
|
+
const raw = window.localStorage.getItem(VIEW_STATE_PREFIX + key);
|
|
111
|
+
if (!raw) return {};
|
|
112
|
+
const parsed: unknown = JSON.parse(raw);
|
|
113
|
+
return parsed && typeof parsed === 'object'
|
|
114
|
+
? (parsed as PersistedViewState)
|
|
115
|
+
: {};
|
|
116
|
+
} catch {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeViewState(
|
|
122
|
+
key: string | undefined,
|
|
123
|
+
patch: PersistedViewState,
|
|
124
|
+
): void {
|
|
125
|
+
if (!key || typeof window === 'undefined') return;
|
|
126
|
+
try {
|
|
127
|
+
const merged = { ...readViewState(key), ...patch };
|
|
128
|
+
window.localStorage.setItem(VIEW_STATE_PREFIX + key, JSON.stringify(merged));
|
|
129
|
+
} catch {
|
|
130
|
+
// Best-effort: private mode / quota — view state is non-critical.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
87
134
|
export interface SubsystemComponentGraphProps {
|
|
88
135
|
components: SubsystemComponent[];
|
|
89
136
|
/** Topology relations (structural / module / type). Display edges are derived with walkthrough hops. */
|
|
@@ -232,6 +279,13 @@ export interface SubsystemComponentGraphProps {
|
|
|
232
279
|
onVerifyComponent?: (componentAlias: string) => void;
|
|
233
280
|
/** Live verification status for the selected component. */
|
|
234
281
|
componentVerification?: ComponentVerificationState | null;
|
|
282
|
+
/**
|
|
283
|
+
* When set, the graph's walkthrough working set — expanded flows and the
|
|
284
|
+
* selected flow/step — is persisted to `localStorage` under this key and
|
|
285
|
+
* restored on mount. Hosts key it by the model id so each model remembers
|
|
286
|
+
* where the user left off. Omit to keep the state purely in-memory.
|
|
287
|
+
*/
|
|
288
|
+
persistKey?: string;
|
|
235
289
|
}
|
|
236
290
|
|
|
237
291
|
const nodeTypes: NodeTypes = {
|
|
@@ -306,23 +360,28 @@ const WalkthroughDrawerContent = memo(function WalkthroughDrawerContent({
|
|
|
306
360
|
walkthrough,
|
|
307
361
|
stepIndex,
|
|
308
362
|
onOpenFile,
|
|
363
|
+
proposedAliases,
|
|
309
364
|
}: {
|
|
310
365
|
render: (ctx: WalkthroughViewerContext) => ReactNode;
|
|
311
366
|
walkthrough: SubsystemWalkthrough;
|
|
312
367
|
stepIndex: number | null;
|
|
313
368
|
onOpenFile: (path: string, opts?: SubsystemOpenFileOptions) => void;
|
|
369
|
+
proposedAliases: ReadonlySet<string>;
|
|
314
370
|
}) {
|
|
315
|
-
return <>{render({ walkthrough, stepIndex, onOpenFile })}</>;
|
|
371
|
+
return <>{render({ walkthrough, stepIndex, onOpenFile, proposedAliases })}</>;
|
|
316
372
|
});
|
|
317
373
|
|
|
318
374
|
interface InnerProps extends SubsystemComponentGraphProps {
|
|
319
375
|
measured: { w: number; h: number } | null;
|
|
320
376
|
}
|
|
321
377
|
|
|
322
|
-
function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
|
|
378
|
+
function Inner({ components, relations, walkthroughs, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, onVerifyComponent, componentVerification, persistKey }: InnerProps) {
|
|
323
379
|
const { theme } = useTheme();
|
|
324
380
|
const { fitView } = useReactFlow();
|
|
325
381
|
const viewport = useViewport();
|
|
382
|
+
// Restored once per mount from `localStorage` (see `readViewState`). Each
|
|
383
|
+
// graph is its own tab/mount, so `persistKey` is stable for a mount.
|
|
384
|
+
const persisted = useMemo(() => readViewState(persistKey), [persistKey]);
|
|
326
385
|
const graphEdges = useMemo(
|
|
327
386
|
() => deriveGraphEdges({ relations, walkthroughs }),
|
|
328
387
|
[relations, walkthroughs],
|
|
@@ -350,9 +409,14 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
350
409
|
const [hoveredComponentAlias, setHoveredComponentId] = useState<string | null>(null);
|
|
351
410
|
// Walkthrough focus — selected flow (or step) is full strength; other
|
|
352
411
|
// opened-flow members stay visible but dimmed; everything else is hidden.
|
|
353
|
-
|
|
412
|
+
// Restored from the persisted view state when a `persistKey` is set.
|
|
413
|
+
const [focusedWalkthroughId, setFocusedWalkthroughId] = useState<string | null>(
|
|
414
|
+
persisted.focusedWalkthroughId ?? null,
|
|
415
|
+
);
|
|
354
416
|
// `null` = whole flow focused; a number = that single step's edge focused.
|
|
355
|
-
const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(
|
|
417
|
+
const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(
|
|
418
|
+
persisted.focusedStepIndex ?? null,
|
|
419
|
+
);
|
|
356
420
|
// Hovered walkthrough in the flows panel: dims every canvas node/edge not
|
|
357
421
|
// involved in the hover preview (or selected ∪ hovered when a step is
|
|
358
422
|
// focused). `stepIndex: null` = whole flow (collapsed title hover);
|
|
@@ -381,7 +445,10 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
381
445
|
: 'relations');
|
|
382
446
|
// Walkthrough flows the user has expanded (via the title row). Closed by
|
|
383
447
|
// default so a graph with several flows doesn't dump every step list at once.
|
|
384
|
-
|
|
448
|
+
// Restored from the persisted view state when a `persistKey` is set.
|
|
449
|
+
const [expandedWalkthroughs, setExpandedWalkthroughs] = useState<Set<string>>(
|
|
450
|
+
() => new Set(persisted.expandedWalkthroughs ?? []),
|
|
451
|
+
);
|
|
385
452
|
// Sidebar description visibility. Hidden by default so the files/flows
|
|
386
453
|
// panel gets the vertical room; the title-row toggle reveals it.
|
|
387
454
|
const [descriptionVisible, setDescriptionVisible] = useState(false);
|
|
@@ -389,11 +456,16 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
389
456
|
// `true` only when a description exists AND the user opened it.
|
|
390
457
|
const showDesc = !!description && descriptionVisible;
|
|
391
458
|
// Sidebar width (px). Draggable via the resize handle between the sidebar
|
|
392
|
-
// and the graph canvas; clamps to sensible bounds while dragging.
|
|
393
|
-
|
|
459
|
+
// and the graph canvas; clamps to sensible bounds while dragging. Restored
|
|
460
|
+
// from the persisted view state when a `persistKey` is set.
|
|
461
|
+
const [sidebarWidth, setSidebarWidth] = useState(persisted.sidebarWidth ?? 450);
|
|
394
462
|
const [sidebarDrag, setSidebarDrag] = useState(false);
|
|
395
463
|
const sidebarDragStartX = useRef(0);
|
|
396
|
-
const sidebarDragStartWidth = useRef(
|
|
464
|
+
const sidebarDragStartWidth = useRef(450);
|
|
465
|
+
// Mirror of `sidebarWidth` for the drag-end persist (the mouseup listener
|
|
466
|
+
// closes over the drag-start render).
|
|
467
|
+
const sidebarWidthRef = useRef(sidebarWidth);
|
|
468
|
+
sidebarWidthRef.current = sidebarWidth;
|
|
397
469
|
const sidebarMinWidth = 240;
|
|
398
470
|
const sidebarMaxWidth = useMemo(
|
|
399
471
|
() => Math.max(Math.min((_measured?.w ?? 680) * 0.5, 600), sidebarMinWidth),
|
|
@@ -422,6 +494,8 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
422
494
|
};
|
|
423
495
|
const onUp = () => {
|
|
424
496
|
setSidebarDrag(false);
|
|
497
|
+
// Persist only on drag end — not on every mousemove.
|
|
498
|
+
writeViewState(persistKey, { sidebarWidth: sidebarWidthRef.current });
|
|
425
499
|
// Re-fit the canvas so the graph re-centers in the new available space.
|
|
426
500
|
requestAnimationFrame(() => fitView());
|
|
427
501
|
};
|
|
@@ -431,7 +505,23 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
431
505
|
document.removeEventListener('mousemove', onMove);
|
|
432
506
|
document.removeEventListener('mouseup', onUp);
|
|
433
507
|
};
|
|
434
|
-
}, [sidebarDrag, sidebarMaxWidth, fitView]);
|
|
508
|
+
}, [sidebarDrag, sidebarMaxWidth, fitView, persistKey]);
|
|
509
|
+
// Persist the walkthrough working set per model. Each write merges into the
|
|
510
|
+
// stored blob, so the three fields never clobber one another.
|
|
511
|
+
useEffect(() => {
|
|
512
|
+
if (!persistKey) return;
|
|
513
|
+
writeViewState(persistKey, {
|
|
514
|
+
expandedWalkthroughs: Array.from(expandedWalkthroughs),
|
|
515
|
+
});
|
|
516
|
+
}, [persistKey, expandedWalkthroughs]);
|
|
517
|
+
useEffect(() => {
|
|
518
|
+
if (!persistKey) return;
|
|
519
|
+
writeViewState(persistKey, { focusedWalkthroughId });
|
|
520
|
+
}, [persistKey, focusedWalkthroughId]);
|
|
521
|
+
useEffect(() => {
|
|
522
|
+
if (!persistKey) return;
|
|
523
|
+
writeViewState(persistKey, { focusedStepIndex });
|
|
524
|
+
}, [persistKey, focusedStepIndex]);
|
|
435
525
|
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
436
526
|
// closure over the effect deps) can toggle without a stale value.
|
|
437
527
|
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
@@ -2069,6 +2159,7 @@ function Inner({ components, relations, walkthroughs, initialWalkthroughId, onRe
|
|
|
2069
2159
|
walkthrough={focusedWalkthrough}
|
|
2070
2160
|
stepIndex={drawerTarget.stepIndex}
|
|
2071
2161
|
onOpenFile={onOpenFileFromWalkthrough}
|
|
2162
|
+
proposedAliases={proposedAliases}
|
|
2072
2163
|
/>
|
|
2073
2164
|
) : drawerTarget?.kind === 'file' ? (
|
|
2074
2165
|
<FileDrawerContent
|