@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.
- 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/extension-combo-builder.d.ts.map +1 -1
- package/dist/components/extension-combo-builder.js +1 -1
- package/dist/components/extension-combo-builder.js.map +1 -1
- 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 +72 -15
- 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 +51 -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/extension-combo-builder.tsx +3 -1
- 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 +37 -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 +54 -1
- 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.
|
|
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.
|
|
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",
|
|
@@ -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
|
-
|
|
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
|
+
}
|
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,
|
|
@@ -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 {
|
|
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>;
|
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';
|