@tai42/studio-sdk 0.2.0 → 0.4.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 (60) 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/extension-combo-builder.d.ts.map +1 -1
  7. package/dist/components/extension-combo-builder.js +1 -1
  8. package/dist/components/extension-combo-builder.js.map +1 -1
  9. package/dist/components/folder-nav.d.ts +63 -0
  10. package/dist/components/folder-nav.d.ts.map +1 -0
  11. package/dist/components/folder-nav.js +123 -0
  12. package/dist/components/folder-nav.js.map +1 -0
  13. package/dist/components/icons.d.ts +4 -0
  14. package/dist/components/icons.d.ts.map +1 -1
  15. package/dist/components/icons.js +4 -0
  16. package/dist/components/icons.js.map +1 -1
  17. package/dist/components/overlay-details-fields.d.ts +50 -0
  18. package/dist/components/overlay-details-fields.d.ts.map +1 -0
  19. package/dist/components/overlay-details-fields.js +17 -0
  20. package/dist/components/overlay-details-fields.js.map +1 -0
  21. package/dist/components/view-toggle.d.ts +31 -0
  22. package/dist/components/view-toggle.d.ts.map +1 -0
  23. package/dist/components/view-toggle.js +59 -0
  24. package/dist/components/view-toggle.js.map +1 -0
  25. package/dist/hooks/useApi.d.ts +72 -15
  26. package/dist/hooks/useApi.d.ts.map +1 -1
  27. package/dist/index.d.ts +13 -4
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +8 -2
  30. package/dist/index.js.map +1 -1
  31. package/dist/navigation/context.d.ts +8 -0
  32. package/dist/navigation/context.d.ts.map +1 -1
  33. package/dist/navigation/context.js +11 -0
  34. package/dist/navigation/context.js.map +1 -1
  35. package/dist/navigation/index.d.ts +2 -2
  36. package/dist/navigation/index.d.ts.map +1 -1
  37. package/dist/navigation/index.js +1 -1
  38. package/dist/navigation/index.js.map +1 -1
  39. package/dist/navigation/types.d.ts +14 -0
  40. package/dist/navigation/types.d.ts.map +1 -1
  41. package/dist/plugin/types.d.ts +51 -1
  42. package/dist/plugin/types.d.ts.map +1 -1
  43. package/dist/tool-visibility.d.ts +32 -0
  44. package/dist/tool-visibility.d.ts.map +1 -0
  45. package/dist/tool-visibility.js +33 -0
  46. package/dist/tool-visibility.js.map +1 -0
  47. package/package.json +2 -2
  48. package/src/components/components.css +1 -1
  49. package/src/components/entity-card-grid.tsx +25 -0
  50. package/src/components/extension-combo-builder.tsx +3 -1
  51. package/src/components/folder-nav.tsx +255 -0
  52. package/src/components/icons.tsx +17 -0
  53. package/src/components/overlay-details-fields.tsx +102 -0
  54. package/src/components/view-toggle.tsx +92 -0
  55. package/src/index.ts +37 -1
  56. package/src/navigation/context.tsx +15 -0
  57. package/src/navigation/index.ts +8 -1
  58. package/src/navigation/types.ts +25 -0
  59. package/src/plugin/types.ts +54 -1
  60. package/src/tool-visibility.ts +53 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tai42/studio-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "The Studio plugin contract, shared UI components, and hooks that every feature builds on.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "tai42 <oss@tai42.ai>",
@@ -89,7 +89,7 @@
89
89
  "@radix-ui/react-select": "^2.3.2",
90
90
  "@radix-ui/react-tabs": "^1.1.16",
91
91
  "@radix-ui/react-tooltip": "^1.2.11",
92
- "@tai42/api-client": "0.2.0"
92
+ "@tai42/api-client": "0.4.0"
93
93
  },
94
94
  "scripts": {
95
95
  "build": "tsc -b tsconfig.build.json && node scripts/copy-assets.mjs",
@@ -1245,7 +1245,7 @@
1245
1245
 
1246
1246
  .tai-table td {
1247
1247
  padding: var(--tai-space-3);
1248
- vertical-align: top;
1248
+ vertical-align: middle;
1249
1249
  border-bottom: 1px solid var(--tai-color-border);
1250
1250
  }
1251
1251
 
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `EntityCardGrid` — the card-view container every entity screen shares (the card
3
+ * half of {@link ViewToggle}). It lays its children out on the design system's
4
+ * responsive `.tai-grid-cards` track (three columns, dropping to two then one down
5
+ * the responsive bands); each child is expected to be an interactive `Card`. Living
6
+ * in the SDK keeps the tools screen and PLAN_4's flows page on one identical grid.
7
+ */
8
+ import type { ReactNode } from 'react';
9
+
10
+ export interface EntityCardGridProps {
11
+ readonly children: ReactNode;
12
+ /** The grid's accessible name (e.g. "Tools"). Rendered as a `list` for AT. */
13
+ readonly 'aria-label': string;
14
+ }
15
+
16
+ export function EntityCardGrid({
17
+ children,
18
+ 'aria-label': ariaLabel,
19
+ }: EntityCardGridProps): ReactNode {
20
+ return (
21
+ <div className="tai-grid-cards" role="list" aria-label={ariaLabel}>
22
+ {children}
23
+ </div>
24
+ );
25
+ }
@@ -203,7 +203,9 @@ export function ExtensionComboBuilder({
203
203
  // of its own — so an index key is stable and cannot collide.
204
204
  <li key={index} className="tai-card tai-stack tai-stack-2">
205
205
  <div className="tai-row">
206
- <span className="tai-row">
206
+ {/* The badges take the row's spare width so the Edit/Remove pair
207
+ pins to a stable trailing edge across combos of varied length. */}
208
+ <span className="tai-row" style={{ flex: 1 }}>
207
209
  {names.map((name, position) => (
208
210
  <Fragment key={`${name}-${String(position)}`}>
209
211
  {position > 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,
@@ -39,6 +40,7 @@ export type {
39
40
  SettingsTabContribution,
40
41
  RegisteredSettingsTab,
41
42
  NavEntryContribution,
43
+ NavEntrySection,
42
44
  RegisteredNavEntry,
43
45
  PluginContributions,
44
46
  RequiredCapabilities,
@@ -70,7 +72,13 @@ export { useInteractionsStream } from './hooks/useSse';
70
72
  export type { InteractionsStreamState, StreamInteraction } from './hooks/useSse';
71
73
 
72
74
  // -- Navigation (shell ⇄ feature route-token contract) -----------------------
73
- export { NavigationProvider, AppLink, useAppNavigate, useResolvePath } from './navigation';
75
+ export {
76
+ NavigationProvider,
77
+ AppLink,
78
+ useAppNavigate,
79
+ useResolvePath,
80
+ usePluginNavigation,
81
+ } from './navigation';
74
82
  export type {
75
83
  AppLinkProps,
76
84
  RouteToken,
@@ -78,6 +86,7 @@ export type {
78
86
  RouteSearchByToken,
79
87
  PageProps,
80
88
  NavigationContextValue,
89
+ PluginSearch,
81
90
  } from './navigation';
82
91
 
83
92
  // -- Utilities ---------------------------------------------------------------
@@ -90,6 +99,9 @@ export { toolsListKey, extensionsQueryKey, subMcpKey, tokensPayloadKey } from '.
90
99
  // -- Extension-combo editing helpers -----------------------------------------
91
100
  export { comboElementNames, extensionElementName } from './extension-combos';
92
101
 
102
+ // -- Tool-visibility tri-state -----------------------------------------------
103
+ export { effectiveHidden, hiddenToolNames } from './tool-visibility';
104
+
93
105
  // -- Design system -----------------------------------------------------------
94
106
  export { Button, Card, Skeleton, EmptyState, ErrorState, Spinner } from './components/primitives';
95
107
  export type {
@@ -154,6 +166,28 @@ export { JsonDiff, diffJson } from './components/json-diff';
154
166
  export type { JsonDiffProps, JsonDiffRow } from './components/json-diff';
155
167
  export { TagChips, TagsInput } from './components/tags';
156
168
  export type { TagChipsProps, TagsInputProps } from './components/tags';
169
+ export { OverlayDetailsFields, overlayDetailsPatch } from './components/overlay-details-fields';
170
+ export type {
171
+ OverlayDetails,
172
+ OverlayDetailsFieldsProps,
173
+ } from './components/overlay-details-fields';
174
+ export { ViewToggle, useViewMode } from './components/view-toggle';
175
+ export type { ViewMode, ViewToggleProps } from './components/view-toggle';
176
+ export { EntityCardGrid } from './components/entity-card-grid';
177
+ export type { EntityCardGridProps } from './components/entity-card-grid';
178
+ export {
179
+ FolderBreadcrumb,
180
+ FolderRow,
181
+ FolderPicker,
182
+ childFolders,
183
+ folderPathTo,
184
+ } from './components/folder-nav';
185
+ export type {
186
+ Folder,
187
+ FolderBreadcrumbProps,
188
+ FolderRowProps,
189
+ FolderPickerProps,
190
+ } from './components/folder-nav';
157
191
  export { CodeBlock } from './components/code-block';
158
192
  export type { CodeBlockProps } from './components/code-block';
159
193
  export { FleetReport } from './components/fleet-report';
@@ -212,6 +246,8 @@ export {
212
246
  SignOutIcon,
213
247
  MenuIcon,
214
248
  CloseIcon,
249
+ FolderIcon,
250
+ GridIcon,
215
251
  FilterIcon,
216
252
  ArrowLeftIcon,
217
253
  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';