@tai42/studio-sdk 0.2.0 → 0.3.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.
Files changed (56) hide show
  1. package/dist/components/components.css +1 -1
  2. package/dist/components/entity-card-grid.d.ts +15 -0
  3. package/dist/components/entity-card-grid.d.ts.map +1 -0
  4. package/dist/components/entity-card-grid.js +5 -0
  5. package/dist/components/entity-card-grid.js.map +1 -0
  6. package/dist/components/folder-nav.d.ts +63 -0
  7. package/dist/components/folder-nav.d.ts.map +1 -0
  8. package/dist/components/folder-nav.js +123 -0
  9. package/dist/components/folder-nav.js.map +1 -0
  10. package/dist/components/icons.d.ts +4 -0
  11. package/dist/components/icons.d.ts.map +1 -1
  12. package/dist/components/icons.js +4 -0
  13. package/dist/components/icons.js.map +1 -1
  14. package/dist/components/overlay-details-fields.d.ts +50 -0
  15. package/dist/components/overlay-details-fields.d.ts.map +1 -0
  16. package/dist/components/overlay-details-fields.js +17 -0
  17. package/dist/components/overlay-details-fields.js.map +1 -0
  18. package/dist/components/view-toggle.d.ts +31 -0
  19. package/dist/components/view-toggle.d.ts.map +1 -0
  20. package/dist/components/view-toggle.js +59 -0
  21. package/dist/components/view-toggle.js.map +1 -0
  22. package/dist/hooks/useApi.d.ts +46 -7
  23. package/dist/hooks/useApi.d.ts.map +1 -1
  24. package/dist/index.d.ts +13 -4
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/navigation/context.d.ts +8 -0
  29. package/dist/navigation/context.d.ts.map +1 -1
  30. package/dist/navigation/context.js +11 -0
  31. package/dist/navigation/context.js.map +1 -1
  32. package/dist/navigation/index.d.ts +2 -2
  33. package/dist/navigation/index.d.ts.map +1 -1
  34. package/dist/navigation/index.js +1 -1
  35. package/dist/navigation/index.js.map +1 -1
  36. package/dist/navigation/types.d.ts +14 -0
  37. package/dist/navigation/types.d.ts.map +1 -1
  38. package/dist/plugin/types.d.ts +37 -1
  39. package/dist/plugin/types.d.ts.map +1 -1
  40. package/dist/tool-visibility.d.ts +32 -0
  41. package/dist/tool-visibility.d.ts.map +1 -0
  42. package/dist/tool-visibility.js +33 -0
  43. package/dist/tool-visibility.js.map +1 -0
  44. package/package.json +2 -2
  45. package/src/components/components.css +1 -1
  46. package/src/components/entity-card-grid.tsx +25 -0
  47. package/src/components/folder-nav.tsx +255 -0
  48. package/src/components/icons.tsx +17 -0
  49. package/src/components/overlay-details-fields.tsx +102 -0
  50. package/src/components/view-toggle.tsx +92 -0
  51. package/src/index.ts +36 -1
  52. package/src/navigation/context.tsx +15 -0
  53. package/src/navigation/index.ts +8 -1
  54. package/src/navigation/types.ts +25 -0
  55. package/src/plugin/types.ts +38 -1
  56. package/src/tool-visibility.ts +53 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Folder navigation primitives for the tool-organization tree — shared by the tools
3
+ * screen and PLAN_4's flows page so both explore, breadcrumb, and re-file against
4
+ * one identical set of controls.
5
+ *
6
+ * The tree is passed FLAT (a {@link Folder} list keyed by `id`, each naming its
7
+ * `parentId`); these helpers derive the current directory, the breadcrumb path, and
8
+ * the indented picker options from it. Every derivation is cycle-SAFE: a malformed
9
+ * parent chain (a loop, a dangling parent) is bounded by the folder count rather than
10
+ * looping forever — bad data degrades to a truncated path, never a hang.
11
+ *
12
+ * `FolderPicker` carries inline new-folder creation, so a folder can be created from
13
+ * BOTH consuming screens through this one component — never a flows-page-only
14
+ * affordance.
15
+ */
16
+ import { useState, type ReactNode } from 'react';
17
+
18
+ import { ChevronRightIcon, FolderIcon } from './icons';
19
+ import { Button } from './primitives';
20
+ import { Select } from './select';
21
+ import { TextInput } from './inputs';
22
+
23
+ /** A folder in the tree, in the SDK's camelCase UI shape (`parentId`, `null` = root). */
24
+ export interface Folder {
25
+ readonly id: string;
26
+ readonly name: string;
27
+ readonly parentId: string | null;
28
+ }
29
+
30
+ /** The subfolders directly under `parentId` (`null` = the root directory), name-sorted. */
31
+ export function childFolders(folders: readonly Folder[], parentId: string | null): Folder[] {
32
+ return folders
33
+ .filter((folder) => folder.parentId === parentId)
34
+ .sort((a, b) => a.name.localeCompare(b.name));
35
+ }
36
+
37
+ /**
38
+ * The ordered ancestor path from the root down to (and including) `folderId`, for a
39
+ * breadcrumb. `null` is the root (an empty path). The walk is bounded by the folder
40
+ * count, so a cyclic or dangling chain truncates instead of looping.
41
+ */
42
+ export function folderPathTo(folders: readonly Folder[], folderId: string | null): Folder[] {
43
+ if (folderId === null) return [];
44
+ const byId = new Map(folders.map((folder) => [folder.id, folder]));
45
+ const path: Folder[] = [];
46
+ const seen = new Set<string>();
47
+ let current: string | null = folderId;
48
+ while (current !== null && !seen.has(current)) {
49
+ seen.add(current);
50
+ const folder = byId.get(current);
51
+ if (folder === undefined) break;
52
+ path.unshift(folder);
53
+ current = folder.parentId;
54
+ }
55
+ return path;
56
+ }
57
+
58
+ export interface FolderBreadcrumbProps {
59
+ readonly folders: readonly Folder[];
60
+ readonly currentFolderId: string | null;
61
+ readonly onNavigate: (folderId: string | null) => void;
62
+ /** The root crumb's label (e.g. "All tools"). */
63
+ readonly rootLabel: string;
64
+ }
65
+
66
+ /** The root → … → current path; each crumb navigates to that level. */
67
+ export function FolderBreadcrumb({
68
+ folders,
69
+ currentFolderId,
70
+ onNavigate,
71
+ rootLabel,
72
+ }: FolderBreadcrumbProps): ReactNode {
73
+ const path = folderPathTo(folders, currentFolderId);
74
+ return (
75
+ <nav className="tai-row" aria-label="Folder path">
76
+ <Button
77
+ variant="ghost"
78
+ onClick={() => {
79
+ onNavigate(null);
80
+ }}
81
+ aria-current={currentFolderId === null ? 'page' : undefined}
82
+ >
83
+ {rootLabel}
84
+ </Button>
85
+ {path.map((folder) => (
86
+ <span key={folder.id} className="tai-row">
87
+ <ChevronRightIcon />
88
+ <Button
89
+ variant="ghost"
90
+ onClick={() => {
91
+ onNavigate(folder.id);
92
+ }}
93
+ aria-current={folder.id === currentFolderId ? 'page' : undefined}
94
+ >
95
+ {folder.name}
96
+ </Button>
97
+ </span>
98
+ ))}
99
+ </nav>
100
+ );
101
+ }
102
+
103
+ export interface FolderRowProps {
104
+ readonly folder: Folder;
105
+ readonly onOpen: (folderId: string) => void;
106
+ }
107
+
108
+ /** One subfolder in the current-directory explorer; opening it navigates inward. */
109
+ export function FolderRow({ folder, onOpen }: FolderRowProps): ReactNode {
110
+ return (
111
+ <Button
112
+ variant="ghost"
113
+ onClick={() => {
114
+ onOpen(folder.id);
115
+ }}
116
+ >
117
+ <FolderIcon />
118
+ <span>{folder.name}</span>
119
+ </Button>
120
+ );
121
+ }
122
+
123
+ /** The sentinel option value for "no folder" — folder ids are UUIDs, so it never collides. */
124
+ const UNFILED_VALUE = '__unfiled__';
125
+
126
+ /** Flatten the tree into indented `Select` options, root first, depth-first. */
127
+ function folderOptions(folders: readonly Folder[]): { value: string; label: string }[] {
128
+ const options: { value: string; label: string }[] = [];
129
+ const walk = (parentId: string | null, depth: number): void => {
130
+ for (const folder of childFolders(folders, parentId)) {
131
+ options.push({ value: folder.id, label: `${'  '.repeat(depth)}${folder.name}` });
132
+ walk(folder.id, depth + 1);
133
+ }
134
+ };
135
+ walk(null, 0);
136
+ return options;
137
+ }
138
+
139
+ export interface FolderPickerProps {
140
+ /** The chosen folder id, or `null` for unfiled. */
141
+ readonly value: string | null;
142
+ readonly onChange: (folderId: string | null) => void;
143
+ readonly folders: readonly Folder[];
144
+ /**
145
+ * Create a folder under `parentId` (the current selection) and resolve with its
146
+ * id; the picker selects it. Rejection is the caller's to surface — the picker
147
+ * leaves the draft in place so the operator can retry.
148
+ */
149
+ readonly onCreateFolder: (name: string, parentId: string | null) => Promise<string>;
150
+ readonly disabled?: boolean;
151
+ /** The unfiled option's label (e.g. "No folder"). */
152
+ readonly unfiledLabel: string;
153
+ }
154
+
155
+ /** A folder chooser with inline new-folder creation, for the edit / move dialogs. */
156
+ export function FolderPicker({
157
+ value,
158
+ onChange,
159
+ folders,
160
+ onCreateFolder,
161
+ disabled,
162
+ unfiledLabel,
163
+ }: FolderPickerProps): ReactNode {
164
+ const [creating, setCreating] = useState(false);
165
+ const [draft, setDraft] = useState('');
166
+ const [busy, setBusy] = useState(false);
167
+
168
+ const options = [{ value: UNFILED_VALUE, label: unfiledLabel }, ...folderOptions(folders)];
169
+
170
+ const create = async (): Promise<void> => {
171
+ const name = draft.trim();
172
+ if (name === '' || busy) return;
173
+ setBusy(true);
174
+ try {
175
+ const id = await onCreateFolder(name, value);
176
+ onChange(id);
177
+ setDraft('');
178
+ setCreating(false);
179
+ } catch {
180
+ // The caller owns surfacing the failure (its `onCreateFolder` is the loud
181
+ // surface); the picker's part is to keep the draft OPEN with its text so the
182
+ // operator can retry — never to clear it or select nothing.
183
+ } finally {
184
+ setBusy(false);
185
+ }
186
+ };
187
+
188
+ return (
189
+ <div className="tai-stack tai-stack-2">
190
+ <Select
191
+ aria-label="Folder"
192
+ options={options}
193
+ value={value ?? UNFILED_VALUE}
194
+ onValueChange={(next) => {
195
+ onChange(next === UNFILED_VALUE ? null : next);
196
+ }}
197
+ disabled={disabled ?? busy}
198
+ />
199
+ {creating ? (
200
+ <div className="tai-row">
201
+ <div style={{ flex: 1 }}>
202
+ <TextInput
203
+ value={draft}
204
+ onChange={(event) => {
205
+ setDraft(event.target.value);
206
+ }}
207
+ onKeyDown={(event) => {
208
+ if (event.key === 'Enter') {
209
+ event.preventDefault();
210
+ void create();
211
+ }
212
+ }}
213
+ placeholder="New folder name…"
214
+ aria-label="New folder name"
215
+ disabled={busy}
216
+ />
217
+ </div>
218
+ <Button
219
+ type="button"
220
+ variant="primary"
221
+ onClick={() => {
222
+ void create();
223
+ }}
224
+ disabled={busy || draft.trim() === ''}
225
+ >
226
+ Create
227
+ </Button>
228
+ <Button
229
+ type="button"
230
+ variant="ghost"
231
+ onClick={() => {
232
+ setCreating(false);
233
+ setDraft('');
234
+ }}
235
+ disabled={busy}
236
+ >
237
+ Cancel
238
+ </Button>
239
+ </div>
240
+ ) : (
241
+ <Button
242
+ type="button"
243
+ variant="ghost"
244
+ onClick={() => {
245
+ setCreating(true);
246
+ }}
247
+ disabled={disabled}
248
+ >
249
+ <FolderIcon />
250
+ <span>New folder</span>
251
+ </Button>
252
+ )}
253
+ </div>
254
+ );
255
+ }
@@ -477,6 +477,23 @@ export const PendingIcon: IconComponent = (props) => (
477
477
  </Icon>
478
478
  );
479
479
 
480
+ /** Folder: a tabbed folder, for the tool-organization tree and breadcrumbs. */
481
+ export const FolderIcon: IconComponent = (props) => (
482
+ <Icon {...props}>
483
+ <path d="M3 6.5a2 2 0 0 1 2-2h4l2 2.5h6a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
484
+ </Icon>
485
+ );
486
+
487
+ /** Grid: a 2x2 tile array — the card-view half of the list/card toggle. */
488
+ export const GridIcon: IconComponent = (props) => (
489
+ <Icon {...props}>
490
+ <rect x="4" y="4" width="7" height="7" rx="1.5" />
491
+ <rect x="13" y="4" width="7" height="7" rx="1.5" />
492
+ <rect x="4" y="13" width="7" height="7" rx="1.5" />
493
+ <rect x="13" y="13" width="7" height="7" rx="1.5" />
494
+ </Icon>
495
+ );
496
+
480
497
  // -- Route mapping -----------------------------------------------------------
481
498
 
482
499
  /**
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `OverlayDetailsFields` — the display-name + user-tags editor shared by EVERY
3
+ * tool_meta edit surface (the tools-screen edit dialog, the presets screen, and
4
+ * PLAN_4's flows page). One canonical control means the merge-patch write semantics
5
+ * exist exactly once.
6
+ *
7
+ * Controlled: the caller owns `value` (prefilled from the tool's overlay row) and
8
+ * receives every keystroke through `onChange`. This component edits ONLY the two
9
+ * overlay fields it shows; the write it feeds ({@link overlayDetailsPatch}) sends
10
+ * ONLY `display_name` and `tags`, so the merge-patch API leaves `folder_id`/`hidden`
11
+ * untouched — never re-sent from a baseline.
12
+ *
13
+ * Display-name mapping is PINNED (the API rejects `""` with a 422): a blank or
14
+ * whitespace-only input maps to `display_name: null` (clear the override); trimmed
15
+ * non-empty text is sent as-is. That rule lives in {@link overlayDetailsPatch}, so
16
+ * every consumer clears identically.
17
+ *
18
+ * `nativeTags` (optional) renders the tool's read-only plugin-native tags as
19
+ * visually DISTINCT chips beside the editable input — the one place native and
20
+ * overlay tags are shown apart (every other surface merges them).
21
+ */
22
+ import type { ReactNode } from 'react';
23
+
24
+ import { Field } from './field';
25
+ import { TextInput } from './inputs';
26
+ import { TagChips, TagsInput } from './tags';
27
+
28
+ /** The two overlay fields this control edits. */
29
+ export interface OverlayDetails {
30
+ /** The raw display-name draft (unblank-normalized only at write time). */
31
+ readonly displayName: string;
32
+ readonly tags: readonly string[];
33
+ }
34
+
35
+ export interface OverlayDetailsFieldsProps {
36
+ readonly value: OverlayDetails;
37
+ readonly onChange: (next: OverlayDetails) => void;
38
+ readonly disabled?: boolean;
39
+ /**
40
+ * The tool's real name, shown as the display-name input's placeholder so an empty
41
+ * override reads as "falls back to <name>".
42
+ */
43
+ readonly namePlaceholder?: string;
44
+ /**
45
+ * Read-only plugin-native tags, rendered as distinct chips. Present only in the
46
+ * edit surfaces that want native and overlay tags shown apart.
47
+ */
48
+ readonly nativeTags?: readonly string[];
49
+ }
50
+
51
+ /** The merge-patch a display-name + tags write sends: ONLY these two fields. */
52
+ export function overlayDetailsPatch(value: OverlayDetails): {
53
+ display_name: string | null;
54
+ tags: string[];
55
+ } {
56
+ const trimmed = value.displayName.trim();
57
+ return { display_name: trimmed === '' ? null : trimmed, tags: [...value.tags] };
58
+ }
59
+
60
+ export function OverlayDetailsFields({
61
+ value,
62
+ onChange,
63
+ disabled,
64
+ namePlaceholder,
65
+ nativeTags,
66
+ }: OverlayDetailsFieldsProps): ReactNode {
67
+ return (
68
+ <div className="tai-stack tai-stack-3">
69
+ <Field
70
+ label="Display name"
71
+ description="Shown instead of the tool's real name. Leave blank to use the real name."
72
+ >
73
+ <TextInput
74
+ value={value.displayName}
75
+ onChange={(event) => {
76
+ onChange({ ...value, displayName: event.target.value });
77
+ }}
78
+ placeholder={namePlaceholder}
79
+ disabled={disabled}
80
+ />
81
+ </Field>
82
+ <Field label="Tags" description="Your own labels for grouping and filtering." group>
83
+ <div className="tai-stack tai-stack-2">
84
+ {nativeTags !== undefined && nativeTags.length > 0 ? (
85
+ <div className="tai-stack tai-stack-2">
86
+ <span className="tai-muted">Native tags (read-only)</span>
87
+ <TagChips tags={nativeTags} />
88
+ </div>
89
+ ) : null}
90
+ <TagsInput
91
+ value={value.tags}
92
+ onChange={(next) => {
93
+ onChange({ ...value, tags: next });
94
+ }}
95
+ disabled={disabled}
96
+ aria-label="Tags"
97
+ />
98
+ </div>
99
+ </Field>
100
+ </div>
101
+ );
102
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `ViewToggle` + `useViewMode` — the list/card switch every entity screen shares.
3
+ *
4
+ * The control is a `RadioGroup variant="segmented"` with ICON-ONLY options: each
5
+ * option's text label is rendered for assistive tech only (`visuallyHiddenLabel`),
6
+ * so the segment shows just its glyph while keeping a real accessible name. A single
7
+ * screen's choice persists across sessions in `localStorage`, keyed per SURFACE —
8
+ * the tools screen and the flows page each remember their own view — via
9
+ * {@link useViewMode}. The two live in the SDK so every entity screen (and PLAN_4's
10
+ * flows page) toggles identically; `EntityCardGrid` renders the card half.
11
+ */
12
+ import { useCallback, useState, type ReactNode } from 'react';
13
+
14
+ import { GridIcon, MenuIcon } from './icons';
15
+ import { RadioGroup } from './radio-group';
16
+
17
+ /** The two entity-screen layouts. */
18
+ export type ViewMode = 'list' | 'cards';
19
+
20
+ const OPTIONS = [
21
+ { value: 'list', label: 'List view', icon: <MenuIcon />, visuallyHiddenLabel: true },
22
+ { value: 'cards', label: 'Card view', icon: <GridIcon />, visuallyHiddenLabel: true },
23
+ ] as const;
24
+
25
+ export interface ViewToggleProps {
26
+ readonly value: ViewMode;
27
+ readonly onValueChange: (mode: ViewMode) => void;
28
+ /** The control's accessible group name (e.g. "Tools view"). */
29
+ readonly 'aria-label': string;
30
+ readonly disabled?: boolean;
31
+ }
32
+
33
+ /** The icon-only segmented switch. Controlled: pair it with {@link useViewMode}. */
34
+ export function ViewToggle({
35
+ value,
36
+ onValueChange,
37
+ 'aria-label': ariaLabel,
38
+ disabled,
39
+ }: ViewToggleProps): ReactNode {
40
+ return (
41
+ <RadioGroup
42
+ variant="segmented"
43
+ orientation="horizontal"
44
+ aria-label={ariaLabel}
45
+ options={OPTIONS}
46
+ value={value}
47
+ onValueChange={(next) => {
48
+ // The option values are a closed pair, so the widening back to `ViewMode`
49
+ // is exact — a stray value could only come from a mismatched options list.
50
+ onValueChange(next as ViewMode);
51
+ }}
52
+ disabled={disabled}
53
+ />
54
+ );
55
+ }
56
+
57
+ const STORAGE_PREFIX = 'tai-studio.view-mode.';
58
+
59
+ function readStoredMode(surface: string, fallback: ViewMode): ViewMode {
60
+ try {
61
+ const raw = globalThis.localStorage.getItem(STORAGE_PREFIX + surface);
62
+ if (raw === 'list' || raw === 'cards') return raw;
63
+ } catch {
64
+ // No storage (private mode / non-browser) — fall back to the caller's default.
65
+ }
66
+ return fallback;
67
+ }
68
+
69
+ /**
70
+ * The persisted view mode for one `surface` (a stable key like `'tools'`). Returns
71
+ * the stored choice or `fallback` when none is set, and a setter that writes through
72
+ * to `localStorage`. Storage failures degrade to in-memory only — never a thrown
73
+ * boot, matching the theme preference's defensive reads/writes.
74
+ */
75
+ export function useViewMode(
76
+ surface: string,
77
+ fallback: ViewMode = 'list',
78
+ ): readonly [ViewMode, (mode: ViewMode) => void] {
79
+ const [mode, setModeState] = useState<ViewMode>(() => readStoredMode(surface, fallback));
80
+ const setMode = useCallback(
81
+ (next: ViewMode) => {
82
+ setModeState(next);
83
+ try {
84
+ globalThis.localStorage.setItem(STORAGE_PREFIX + surface, next);
85
+ } catch {
86
+ // Storage unavailable — the choice still applies for this session.
87
+ }
88
+ },
89
+ [surface],
90
+ );
91
+ return [mode, setMode];
92
+ }
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export type {
32
32
  PluginEntry,
33
33
  ToolPanelProps,
34
34
  PluginPageProps,
35
+ PluginPageParamsSchema,
35
36
  SettingsTabProps,
36
37
  ToolPanelContribution,
37
38
  PageContribution,
@@ -70,7 +71,13 @@ export { useInteractionsStream } from './hooks/useSse';
70
71
  export type { InteractionsStreamState, StreamInteraction } from './hooks/useSse';
71
72
 
72
73
  // -- Navigation (shell ⇄ feature route-token contract) -----------------------
73
- export { NavigationProvider, AppLink, useAppNavigate, useResolvePath } from './navigation';
74
+ export {
75
+ NavigationProvider,
76
+ AppLink,
77
+ useAppNavigate,
78
+ useResolvePath,
79
+ usePluginNavigation,
80
+ } from './navigation';
74
81
  export type {
75
82
  AppLinkProps,
76
83
  RouteToken,
@@ -78,6 +85,7 @@ export type {
78
85
  RouteSearchByToken,
79
86
  PageProps,
80
87
  NavigationContextValue,
88
+ PluginSearch,
81
89
  } from './navigation';
82
90
 
83
91
  // -- Utilities ---------------------------------------------------------------
@@ -90,6 +98,9 @@ export { toolsListKey, extensionsQueryKey, subMcpKey, tokensPayloadKey } from '.
90
98
  // -- Extension-combo editing helpers -----------------------------------------
91
99
  export { comboElementNames, extensionElementName } from './extension-combos';
92
100
 
101
+ // -- Tool-visibility tri-state -----------------------------------------------
102
+ export { effectiveHidden, hiddenToolNames } from './tool-visibility';
103
+
93
104
  // -- Design system -----------------------------------------------------------
94
105
  export { Button, Card, Skeleton, EmptyState, ErrorState, Spinner } from './components/primitives';
95
106
  export type {
@@ -154,6 +165,28 @@ export { JsonDiff, diffJson } from './components/json-diff';
154
165
  export type { JsonDiffProps, JsonDiffRow } from './components/json-diff';
155
166
  export { TagChips, TagsInput } from './components/tags';
156
167
  export type { TagChipsProps, TagsInputProps } from './components/tags';
168
+ export { OverlayDetailsFields, overlayDetailsPatch } from './components/overlay-details-fields';
169
+ export type {
170
+ OverlayDetails,
171
+ OverlayDetailsFieldsProps,
172
+ } from './components/overlay-details-fields';
173
+ export { ViewToggle, useViewMode } from './components/view-toggle';
174
+ export type { ViewMode, ViewToggleProps } from './components/view-toggle';
175
+ export { EntityCardGrid } from './components/entity-card-grid';
176
+ export type { EntityCardGridProps } from './components/entity-card-grid';
177
+ export {
178
+ FolderBreadcrumb,
179
+ FolderRow,
180
+ FolderPicker,
181
+ childFolders,
182
+ folderPathTo,
183
+ } from './components/folder-nav';
184
+ export type {
185
+ Folder,
186
+ FolderBreadcrumbProps,
187
+ FolderRowProps,
188
+ FolderPickerProps,
189
+ } from './components/folder-nav';
157
190
  export { CodeBlock } from './components/code-block';
158
191
  export type { CodeBlockProps } from './components/code-block';
159
192
  export { FleetReport } from './components/fleet-report';
@@ -212,6 +245,8 @@ export {
212
245
  SignOutIcon,
213
246
  MenuIcon,
214
247
  CloseIcon,
248
+ FolderIcon,
249
+ GridIcon,
215
250
  FilterIcon,
216
251
  ArrowLeftIcon,
217
252
  ArrowUpIcon,
@@ -48,6 +48,21 @@ export function useResolvePath(): NavigationContextValue['resolvePath'] {
48
48
  return useNavigation().resolvePath;
49
49
  }
50
50
 
51
+ /**
52
+ * Plugin-page navigation: `navigatePlugin` drives a client-side transition to a
53
+ * runtime plugin path, `resolvePluginPath` yields its href. These are the ONLY
54
+ * navigation methods a plugin page uses to reach its own deep-linkable sub-paths —
55
+ * the token-typed {@link useAppNavigate}/{@link useResolvePath} address the shell's
56
+ * compile-time routes and know nothing of plugin paths.
57
+ */
58
+ export function usePluginNavigation(): Pick<
59
+ NavigationContextValue,
60
+ 'navigatePlugin' | 'resolvePluginPath'
61
+ > {
62
+ const { navigatePlugin, resolvePluginPath } = useNavigation();
63
+ return { navigatePlugin, resolvePluginPath };
64
+ }
65
+
51
66
  export interface AppLinkProps<T extends RouteToken> {
52
67
  to: T;
53
68
  search?: RouteSearch<T>;
@@ -1,4 +1,10 @@
1
- export { NavigationProvider, AppLink, useAppNavigate, useResolvePath } from './context';
1
+ export {
2
+ NavigationProvider,
3
+ AppLink,
4
+ useAppNavigate,
5
+ useResolvePath,
6
+ usePluginNavigation,
7
+ } from './context';
2
8
  export type { AppLinkProps } from './context';
3
9
  export type {
4
10
  RouteToken,
@@ -6,4 +12,5 @@ export type {
6
12
  RouteSearchByToken,
7
13
  PageProps,
8
14
  NavigationContextValue,
15
+ PluginSearch,
9
16
  } from './types';
@@ -77,14 +77,39 @@ export interface PageProps<T extends RouteToken> {
77
77
  search: RouteSearch<T>;
78
78
  }
79
79
 
80
+ /**
81
+ * A plugin page's deep-link search object. Plugin paths are RUNTIME contributions
82
+ * outside the compile-time {@link RouteToken} map, so their search is an open bag of
83
+ * serializable values (the page's own {@link PluginPageParamsSchema} validates it);
84
+ * this is deliberately NOT a `RouteSearch`.
85
+ */
86
+ export type PluginSearch = Record<string, unknown>;
87
+
80
88
  /**
81
89
  * The runtime navigation surface the shell provides through
82
90
  * {@link NavigationProvider}. `navigate` performs a client-side transition;
83
91
  * `resolvePath` produces the href a link should point at (so an AppLink is a real
84
92
  * anchor — middle-click / open-in-new-tab work — while still driving a
85
93
  * client-side transition on plain click).
94
+ *
95
+ * `navigatePlugin`/`resolvePluginPath` are the plugin-page twins: they target a
96
+ * runtime plugin path (`/plugins/{pluginId}/{pagePath}` plus an optional validated
97
+ * sub-path `params` and `search`), NOT a route token — `routes.ts` never learns
98
+ * plugin paths. Plugins reach these two via the {@link usePluginNavigation} hook.
86
99
  */
87
100
  export interface NavigationContextValue {
88
101
  navigate: <T extends RouteToken>(token: T, search?: RouteSearch<T>) => void;
89
102
  resolvePath: <T extends RouteToken>(token: T, search?: RouteSearch<T>) => string;
103
+ navigatePlugin: (
104
+ pluginId: string,
105
+ pagePath: string,
106
+ params?: string,
107
+ search?: PluginSearch,
108
+ ) => void;
109
+ resolvePluginPath: (
110
+ pluginId: string,
111
+ pagePath: string,
112
+ params?: string,
113
+ search?: PluginSearch,
114
+ ) => string;
90
115
  }
@@ -45,9 +45,39 @@ export interface ToolPanelProps {
45
45
  readonly run: (args: Record<string, unknown>) => Promise<unknown>;
46
46
  }
47
47
 
48
- /** Props a contributed full page receives. Navigation is via shell route tokens. */
48
+ /**
49
+ * Props a contributed full page receives. A page is deep-linkable: the shell mounts
50
+ * it at `/plugins/{pluginId}/{path}` and, when the contribution declares a
51
+ * {@link PluginPageParamsSchema}, forwards the VALIDATED sub-path remainder as
52
+ * `params` and the VALIDATED search object as `search`. Both are OPTIONAL — a page
53
+ * that declares no schema receives neither (and accepts no sub-path), so this stays
54
+ * a non-breaking addition to every existing contribution and the plugin API version
55
+ * does not move. Navigate between plugin pages with `usePluginNavigation`.
56
+ */
49
57
  export interface PluginPageProps {
50
58
  readonly pluginId: string;
59
+ /** The validated sub-path remainder (present only when the page declared a schema). */
60
+ readonly params?: Record<string, unknown>;
61
+ /** The validated search object (present only when the page declared a schema). */
62
+ readonly search?: Record<string, unknown>;
63
+ }
64
+
65
+ /**
66
+ * A page's optional deep-link schema. The shell resolves a URL to a page by
67
+ * LONGEST registered `path` prefix; the remainder of the URL after the matched
68
+ * prefix is handed to `parseParams`, and the raw search object to `parseSearch`.
69
+ * Each parser VALIDATES and shapes its input, RAISING on anything it rejects — the
70
+ * shell renders that throw as a loud error card, never a blank or partial view. A
71
+ * contribution that omits the schema entirely accepts no sub-path (the pre-deep-link
72
+ * behavior, unchanged). Omitting just one parser leaves that half unvalidated:
73
+ * absent `parseParams` means the page still matches only its exact `path`; absent
74
+ * `parseSearch` means `search` is not forwarded.
75
+ */
76
+ export interface PluginPageParamsSchema {
77
+ /** Validate + shape the sub-path remainder after this page's prefix; throws to reject. */
78
+ readonly parseParams?: (remainder: string) => Record<string, unknown>;
79
+ /** Validate + shape the raw search object; throws to reject. */
80
+ readonly parseSearch?: (raw: Record<string, unknown>) => Record<string, unknown>;
51
81
  }
52
82
 
53
83
  /** Props a contributed settings tab receives. */
@@ -67,6 +97,13 @@ export interface PageContribution {
67
97
  readonly component: ComponentType<PluginPageProps>;
68
98
  /** Capability gate (see {@link RequiredCapabilities}); absent ⇒ full-only. */
69
99
  readonly requiredCapabilities?: RequiredCapabilities;
100
+ /**
101
+ * Deep-link schema (see {@link PluginPageParamsSchema}). When present the page is
102
+ * addressable under its `path` PREFIX, and the shell validates the sub-path
103
+ * remainder + search before rendering. Absent ⇒ the page matches only its exact
104
+ * `path` and receives no `params`/`search` (unchanged behavior).
105
+ */
106
+ readonly params?: PluginPageParamsSchema;
70
107
  }
71
108
 
72
109
  /**