@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.
- package/dist/components/components.css +1 -1
- package/dist/components/entity-card-grid.d.ts +15 -0
- package/dist/components/entity-card-grid.d.ts.map +1 -0
- package/dist/components/entity-card-grid.js +5 -0
- package/dist/components/entity-card-grid.js.map +1 -0
- package/dist/components/folder-nav.d.ts +63 -0
- package/dist/components/folder-nav.d.ts.map +1 -0
- package/dist/components/folder-nav.js +123 -0
- package/dist/components/folder-nav.js.map +1 -0
- package/dist/components/icons.d.ts +4 -0
- package/dist/components/icons.d.ts.map +1 -1
- package/dist/components/icons.js +4 -0
- package/dist/components/icons.js.map +1 -1
- package/dist/components/overlay-details-fields.d.ts +50 -0
- package/dist/components/overlay-details-fields.d.ts.map +1 -0
- package/dist/components/overlay-details-fields.js +17 -0
- package/dist/components/overlay-details-fields.js.map +1 -0
- package/dist/components/view-toggle.d.ts +31 -0
- package/dist/components/view-toggle.d.ts.map +1 -0
- package/dist/components/view-toggle.js +59 -0
- package/dist/components/view-toggle.js.map +1 -0
- package/dist/hooks/useApi.d.ts +46 -7
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/index.d.ts +13 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/navigation/context.d.ts +8 -0
- package/dist/navigation/context.d.ts.map +1 -1
- package/dist/navigation/context.js +11 -0
- package/dist/navigation/context.js.map +1 -1
- package/dist/navigation/index.d.ts +2 -2
- package/dist/navigation/index.d.ts.map +1 -1
- package/dist/navigation/index.js +1 -1
- package/dist/navigation/index.js.map +1 -1
- package/dist/navigation/types.d.ts +14 -0
- package/dist/navigation/types.d.ts.map +1 -1
- package/dist/plugin/types.d.ts +37 -1
- package/dist/plugin/types.d.ts.map +1 -1
- package/dist/tool-visibility.d.ts +32 -0
- package/dist/tool-visibility.d.ts.map +1 -0
- package/dist/tool-visibility.js +33 -0
- package/dist/tool-visibility.js.map +1 -0
- package/package.json +2 -2
- package/src/components/components.css +1 -1
- package/src/components/entity-card-grid.tsx +25 -0
- package/src/components/folder-nav.tsx +255 -0
- package/src/components/icons.tsx +17 -0
- package/src/components/overlay-details-fields.tsx +102 -0
- package/src/components/view-toggle.tsx +92 -0
- package/src/index.ts +36 -1
- package/src/navigation/context.tsx +15 -0
- package/src/navigation/index.ts +8 -1
- package/src/navigation/types.ts +25 -0
- package/src/plugin/types.ts +38 -1
- 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
|
+
}
|
package/src/components/icons.tsx
CHANGED
|
@@ -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 {
|
|
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>;
|
package/src/navigation/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
export {
|
|
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';
|
package/src/navigation/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/plugin/types.ts
CHANGED
|
@@ -45,9 +45,39 @@ export interface ToolPanelProps {
|
|
|
45
45
|
readonly run: (args: Record<string, unknown>) => Promise<unknown>;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/**
|
|
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
|
/**
|