@tai42/studio-sdk 30.0.1 → 31.0.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/README.md +98 -10
- package/dist/components/agent-picker.d.ts +1 -1
- package/dist/components/agent-picker.d.ts.map +1 -1
- package/dist/components/agent-picker.js +1 -0
- package/dist/components/agent-picker.js.map +1 -1
- package/dist/components/channel-picker.d.ts +1 -1
- package/dist/components/channel-picker.d.ts.map +1 -1
- package/dist/components/channel-picker.js +1 -0
- package/dist/components/channel-picker.js.map +1 -1
- package/dist/components/components.css +53 -0
- package/dist/components/explorer-body.d.ts +1 -0
- package/dist/components/explorer-body.d.ts.map +1 -1
- package/dist/components/explorer-body.js +6 -4
- package/dist/components/explorer-body.js.map +1 -1
- package/dist/components/explorer-view.d.ts +16 -7
- package/dist/components/explorer-view.d.ts.map +1 -1
- package/dist/components/explorer-view.js +27 -13
- package/dist/components/explorer-view.js.map +1 -1
- package/dist/components/extension-config-editor.js.map +1 -1
- package/dist/components/field.d.ts +6 -0
- package/dist/components/field.d.ts.map +1 -1
- package/dist/components/field.js +15 -2
- package/dist/components/field.js.map +1 -1
- package/dist/components/folder-actions.d.ts +51 -0
- package/dist/components/folder-actions.d.ts.map +1 -0
- package/dist/components/folder-actions.js +173 -0
- package/dist/components/folder-actions.js.map +1 -0
- package/dist/components/folder-filing.d.ts +32 -0
- package/dist/components/folder-filing.d.ts.map +1 -0
- package/dist/components/folder-filing.js +51 -0
- package/dist/components/folder-filing.js.map +1 -0
- package/dist/components/folder-nav.d.ts +9 -4
- package/dist/components/folder-nav.d.ts.map +1 -1
- package/dist/components/folder-nav.js +18 -22
- package/dist/components/folder-nav.js.map +1 -1
- package/dist/components/folder-occupancy.d.ts +27 -0
- package/dist/components/folder-occupancy.d.ts.map +1 -0
- package/dist/components/folder-occupancy.js +54 -0
- package/dist/components/folder-occupancy.js.map +1 -0
- package/dist/components/named-list-picker.d.ts +5 -3
- package/dist/components/named-list-picker.d.ts.map +1 -1
- package/dist/components/named-list-picker.js +7 -7
- package/dist/components/named-list-picker.js.map +1 -1
- package/dist/components/primitives.d.ts +2 -1
- package/dist/components/primitives.d.ts.map +1 -1
- package/dist/components/primitives.js +2 -1
- package/dist/components/primitives.js.map +1 -1
- package/dist/components/searchable-select-model.d.ts +40 -0
- package/dist/components/searchable-select-model.d.ts.map +1 -0
- package/dist/components/searchable-select-model.js +69 -0
- package/dist/components/searchable-select-model.js.map +1 -0
- package/dist/components/searchable-select.d.ts +38 -0
- package/dist/components/searchable-select.d.ts.map +1 -0
- package/dist/components/searchable-select.js +200 -0
- package/dist/components/searchable-select.js.map +1 -0
- package/dist/components/subject-section.d.ts.map +1 -1
- package/dist/components/subject-section.js +2 -2
- package/dist/components/subject-section.js.map +1 -1
- package/dist/components/table.d.ts +7 -1
- package/dist/components/table.d.ts.map +1 -1
- package/dist/components/table.js +2 -2
- package/dist/components/table.js.map +1 -1
- package/dist/components/tool-meta-access.d.ts +28 -0
- package/dist/components/tool-meta-access.d.ts.map +1 -0
- package/dist/components/tool-meta-access.js +65 -0
- package/dist/components/tool-meta-access.js.map +1 -0
- package/dist/components/tool-name-entry.d.ts +26 -0
- package/dist/components/tool-name-entry.d.ts.map +1 -0
- package/dist/components/tool-name-entry.js +43 -0
- package/dist/components/tool-name-entry.js.map +1 -0
- package/dist/components/tool-picker.d.ts +23 -18
- package/dist/components/tool-picker.d.ts.map +1 -1
- package/dist/components/tool-picker.js +51 -33
- package/dist/components/tool-picker.js.map +1 -1
- package/dist/components/tool-select.d.ts +6 -5
- package/dist/components/tool-select.d.ts.map +1 -1
- package/dist/components/tool-select.js +31 -14
- package/dist/components/tool-select.js.map +1 -1
- package/dist/hooks/useApi.d.ts +1 -0
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/hooks/useSystemKinds.d.ts +27 -12
- package/dist/hooks/useSystemKinds.d.ts.map +1 -1
- package/dist/hooks/useSystemKinds.js +56 -16
- package/dist/hooks/useSystemKinds.js.map +1 -1
- package/dist/index.d.ts +51 -49
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -26
- package/dist/index.js.map +1 -1
- package/dist/marketplace-install/use-mount-install.d.ts +6 -6
- package/dist/plugin/version.d.ts +1 -1
- package/dist/plugin/version.js +1 -1
- package/dist/schema-editor/SchemaEditor.d.ts +7 -2
- package/dist/schema-editor/SchemaEditor.d.ts.map +1 -1
- package/dist/schema-editor/SchemaEditor.js +1 -1
- package/dist/schema-editor/SchemaEditor.js.map +1 -1
- package/dist/schema-editor/index.d.ts +2 -2
- package/dist/schema-editor/index.d.ts.map +1 -1
- package/dist/schema-form/array-field.d.ts.map +1 -1
- package/dist/schema-form/array-field.js +6 -2
- package/dist/schema-form/array-field.js.map +1 -1
- package/dist/schema-form/classify.d.ts +4 -1
- package/dist/schema-form/classify.d.ts.map +1 -1
- package/dist/schema-form/classify.js +36 -15
- package/dist/schema-form/classify.js.map +1 -1
- package/dist/schema-form/collapsed-object-field.d.ts +20 -0
- package/dist/schema-form/collapsed-object-field.d.ts.map +1 -0
- package/dist/schema-form/collapsed-object-field.js +14 -0
- package/dist/schema-form/collapsed-object-field.js.map +1 -0
- package/dist/schema-form/context.d.ts +27 -1
- package/dist/schema-form/context.d.ts.map +1 -1
- package/dist/schema-form/context.js +14 -0
- package/dist/schema-form/context.js.map +1 -1
- package/dist/schema-form/default-value.d.ts +21 -6
- package/dist/schema-form/default-value.d.ts.map +1 -1
- package/dist/schema-form/default-value.js +74 -15
- package/dist/schema-form/default-value.js.map +1 -1
- package/dist/schema-form/expression-field.d.ts +48 -0
- package/dist/schema-form/expression-field.d.ts.map +1 -0
- package/dist/schema-form/expression-field.js +88 -0
- package/dist/schema-form/expression-field.js.map +1 -0
- package/dist/schema-form/field-model.d.ts +23 -4
- package/dist/schema-form/field-model.d.ts.map +1 -1
- package/dist/schema-form/field-node.d.ts.map +1 -1
- package/dist/schema-form/field-node.js +16 -3
- package/dist/schema-form/field-node.js.map +1 -1
- package/dist/schema-form/index.d.ts +3 -3
- package/dist/schema-form/index.d.ts.map +1 -1
- package/dist/schema-form/index.js +2 -2
- package/dist/schema-form/index.js.map +1 -1
- package/dist/schema-form/object-fields.d.ts +6 -3
- package/dist/schema-form/object-fields.d.ts.map +1 -1
- package/dist/schema-form/object-fields.js +14 -3
- package/dist/schema-form/object-fields.js.map +1 -1
- package/dist/schema-form/record-field.d.ts.map +1 -1
- package/dist/schema-form/record-field.js +6 -2
- package/dist/schema-form/record-field.js.map +1 -1
- package/dist/schema-form/seeding-action.d.ts +15 -0
- package/dist/schema-form/seeding-action.d.ts.map +1 -0
- package/dist/schema-form/seeding-action.js +35 -0
- package/dist/schema-form/seeding-action.js.map +1 -0
- package/dist/schema-form/string-annotations.d.ts +22 -8
- package/dist/schema-form/string-annotations.d.ts.map +1 -1
- package/dist/schema-form/string-annotations.js +39 -14
- package/dist/schema-form/string-annotations.js.map +1 -1
- package/dist/schema-form/string-field.js +2 -84
- package/dist/schema-form/string-field.js.map +1 -1
- package/dist/schema-form/templated-field.d.ts +13 -0
- package/dist/schema-form/templated-field.d.ts.map +1 -0
- package/dist/schema-form/templated-field.js +43 -0
- package/dist/schema-form/templated-field.js.map +1 -0
- package/dist/schema-form/union-field.d.ts.map +1 -1
- package/dist/schema-form/union-field.js +6 -2
- package/dist/schema-form/union-field.js.map +1 -1
- package/dist/schema-form/union.d.ts +4 -4
- package/dist/schema-form/union.d.ts.map +1 -1
- package/dist/schema-form/union.js +8 -1
- package/dist/schema-form/union.js.map +1 -1
- package/dist/schema-form/validate.d.ts.map +1 -1
- package/dist/schema-form/validate.js +28 -0
- package/dist/schema-form/validate.js.map +1 -1
- package/dist/search-match.d.ts +29 -0
- package/dist/search-match.d.ts.map +1 -0
- package/dist/search-match.js +38 -0
- package/dist/search-match.js.map +1 -0
- package/dist/tool-organization.d.ts +15 -0
- package/dist/tool-organization.d.ts.map +1 -0
- package/dist/tool-organization.js +6 -0
- package/dist/tool-organization.js.map +1 -0
- package/package.json +3 -2
- package/src/components/agent-picker.tsx +2 -1
- package/src/components/channel-picker.tsx +2 -1
- package/src/components/components.css +53 -0
- package/src/components/explorer-body.tsx +29 -5
- package/src/components/explorer-view.tsx +48 -17
- package/src/components/extension-config-editor.tsx +3 -3
- package/src/components/field.tsx +18 -2
- package/src/components/folder-actions.tsx +341 -0
- package/src/components/folder-filing.tsx +109 -0
- package/src/components/folder-nav.tsx +26 -10
- package/src/components/folder-occupancy.ts +83 -0
- package/src/components/named-list-picker.tsx +11 -7
- package/src/components/primitives.tsx +2 -1
- package/src/components/searchable-select-model.ts +105 -0
- package/src/components/searchable-select.tsx +432 -0
- package/src/components/subject-section.tsx +3 -2
- package/src/components/table.tsx +12 -2
- package/src/components/tool-meta-access.tsx +101 -0
- package/src/components/tool-name-entry.tsx +77 -0
- package/src/components/tool-picker.tsx +83 -50
- package/src/components/tool-select.tsx +49 -38
- package/src/hooks/useSystemKinds.tsx +77 -17
- package/src/index.ts +98 -94
- package/src/plugin/version.ts +1 -1
- package/src/schema-editor/SchemaEditor.tsx +9 -3
- package/src/schema-editor/index.ts +2 -2
- package/src/schema-form/array-field.tsx +10 -2
- package/src/schema-form/classify.ts +41 -15
- package/src/schema-form/collapsed-object-field.tsx +56 -0
- package/src/schema-form/context.ts +33 -1
- package/src/schema-form/default-value.ts +89 -15
- package/src/schema-form/expression-field.tsx +162 -0
- package/src/schema-form/field-model.ts +27 -4
- package/src/schema-form/field-node.tsx +41 -2
- package/src/schema-form/index.ts +3 -2
- package/src/schema-form/object-fields.tsx +11 -4
- package/src/schema-form/record-field.tsx +10 -2
- package/src/schema-form/seeding-action.ts +44 -0
- package/src/schema-form/string-annotations.ts +40 -14
- package/src/schema-form/string-field.tsx +2 -147
- package/src/schema-form/templated-field.tsx +124 -0
- package/src/schema-form/union-field.tsx +11 -2
- package/src/schema-form/union.ts +12 -5
- package/src/schema-form/validate.ts +28 -0
- package/src/search-match.ts +41 -0
- package/src/tool-organization.ts +34 -0
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `ToolPicker` — a controlled single-select over the tool names a host offers, built
|
|
3
|
-
* on the DS `
|
|
4
|
-
* a listbox (the same reason `SchemaForm` lives in the SDK). It is
|
|
3
|
+
* on the DS `SearchableSelect` so features share one picker instead of each
|
|
4
|
+
* re-implementing a listbox (the same reason `SchemaForm` lives in the SDK). It is
|
|
5
5
|
* PRESENTATIONAL: the host owns the tool list read and hands it in as `query`, and
|
|
6
6
|
* owns the selected value; this component fetches nothing.
|
|
7
7
|
*
|
|
8
|
+
* Opening the picker opens a search box: typing narrows the options to the tools whose
|
|
9
|
+
* label contains the text, ignoring case. The label carries the display name and the
|
|
10
|
+
* raw name, so either finds the tool.
|
|
11
|
+
*
|
|
8
12
|
* The picker owns its `Field` (with `label`) and draws the read's states in it: pending
|
|
9
|
-
* (a disabled
|
|
13
|
+
* (a disabled select reading "Loading tools…"), error (`ErrorState` with Retry, or the
|
|
10
14
|
* host's `errorNote` line where the same failure is drawn with Retry elsewhere), refused
|
|
11
|
-
* (a 403
|
|
12
|
-
*
|
|
13
|
-
*
|
|
15
|
+
* (a 403 where the host sets no `errorNote`: the session may not list tools, so a text
|
|
16
|
+
* input takes the tool's name, with a note saying why there is no list; the server
|
|
17
|
+
* checks the name on save), and loaded with
|
|
18
|
+
* nothing to offer (the host's `emptyNote`, then its `emptyAction`). The host's `error`
|
|
19
|
+
* shows, and the `Field` claims its control id, exactly while a select or the typed
|
|
20
|
+
* entry renders.
|
|
14
21
|
*
|
|
15
22
|
* Optional extensions, each inert unless supplied:
|
|
16
23
|
* - `excludeNames` removes names from the options (e.g. a preset cannot be
|
|
@@ -30,8 +37,8 @@
|
|
|
30
37
|
* - `displayNames` (from the tool-meta overlay) overlays a human name onto the
|
|
31
38
|
* LABEL: a mapped option reads `Display Name (raw_name)`, an unmapped name (or a
|
|
32
39
|
* mapping equal to the raw name) reads the bare raw name. The value stays the raw
|
|
33
|
-
* name and grouping/filtering/exclusion key off raw names alone
|
|
34
|
-
* name is
|
|
40
|
+
* name and grouping/tag filtering/exclusion key off raw names alone; the display
|
|
41
|
+
* name only labels the option, and so is what the search reads with the raw name.
|
|
35
42
|
* - `badgesByTool` (native ∪ overlay, from the tool-meta merge) surfaces the
|
|
36
43
|
* SELECTED tool's DECLARED capability badges as read-only chips beneath the
|
|
37
44
|
* select. Badges are INFORMATIONAL — the tooltip says so — never a gate, so they
|
|
@@ -40,7 +47,7 @@
|
|
|
40
47
|
* SAFETY: a tool name, a tag, a display name, AND a badge are server-supplied, so
|
|
41
48
|
* every one renders as TEXT (React escapes it) — never an HTML sink. Pinned by a test.
|
|
42
49
|
*/
|
|
43
|
-
import { type ReactNode, useId, useState } from 'react';
|
|
50
|
+
import { type ReactNode, useId, useMemo, useState } from 'react';
|
|
44
51
|
|
|
45
52
|
import { errorMessage, isForbiddenError } from '../errors';
|
|
46
53
|
import { Badge } from './badge';
|
|
@@ -49,6 +56,7 @@ import { ErrorState } from './primitives';
|
|
|
49
56
|
import type { SelectOption } from './select';
|
|
50
57
|
import { SelectedToolBadges } from './selected-tool-badges';
|
|
51
58
|
import { ALL_TAGS, TagFilterField } from './tag-filter-field';
|
|
59
|
+
import { ToolNameEntry } from './tool-name-entry';
|
|
52
60
|
import { ToolSelect } from './tool-select';
|
|
53
61
|
|
|
54
62
|
export { BADGES_NOTE } from './selected-tool-badges';
|
|
@@ -79,12 +87,11 @@ export interface ToolPickerProps {
|
|
|
79
87
|
/** Drawn after `emptyNote` in the empty state (e.g. a link to where tools come from). */
|
|
80
88
|
readonly emptyAction?: ReactNode;
|
|
81
89
|
/**
|
|
82
|
-
* Drawn in place of the `ErrorState` and the control when the read fails,
|
|
83
|
-
* Retry: for a host that already draws the same read's failure
|
|
90
|
+
* Drawn in place of the `ErrorState` and the control when the read fails, a 403
|
|
91
|
+
* included, with no Retry: for a host that already draws the same read's failure
|
|
92
|
+
* elsewhere. Such a host draws no typed entry on a 403.
|
|
84
93
|
*/
|
|
85
94
|
readonly errorNote?: string;
|
|
86
|
-
/** Drawn on a 403 of the read in place of the error state (the no-grant state). */
|
|
87
|
-
readonly forbiddenNote?: string;
|
|
88
95
|
/** Flags a stored value the loaded list does not carry (see the module docs). */
|
|
89
96
|
readonly unlistedLabel?: string;
|
|
90
97
|
readonly disabled?: boolean;
|
|
@@ -94,13 +101,12 @@ export interface ToolPickerProps {
|
|
|
94
101
|
readonly label?: string;
|
|
95
102
|
/** Handed to the picker's `Field` (with `label`). */
|
|
96
103
|
readonly description?: string;
|
|
97
|
-
/** The host's field error, shown under the control while
|
|
104
|
+
/** The host's field error, shown under the control while one renders (with `label`). */
|
|
98
105
|
readonly error?: string;
|
|
99
106
|
/**
|
|
100
|
-
* Accessible name for the
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* otherwise override.
|
|
107
|
+
* Accessible name for the control when `label` renders none and no enclosing
|
|
108
|
+
* `Field` names it. `label` wins where both are given: it names the control from
|
|
109
|
+
* visible text, and the control drops a caller name it would otherwise override.
|
|
104
110
|
*/
|
|
105
111
|
readonly 'aria-label'?: string;
|
|
106
112
|
/** Names removed from the options (the server remains the authority). */
|
|
@@ -129,7 +135,6 @@ type PickerPhase = 'refused' | 'error' | 'unlisted' | 'empty' | 'select';
|
|
|
129
135
|
|
|
130
136
|
interface PickerState {
|
|
131
137
|
readonly phase: PickerPhase;
|
|
132
|
-
readonly available: readonly string[];
|
|
133
138
|
/** The stored value, or `null` for none. */
|
|
134
139
|
readonly stored: string | null;
|
|
135
140
|
readonly pending: boolean;
|
|
@@ -150,23 +155,31 @@ function phaseOf(
|
|
|
150
155
|
return empty ? 'empty' : 'select';
|
|
151
156
|
}
|
|
152
157
|
|
|
158
|
+
/** The loaded names the picker offers: the read's data without `excludeNames`. */
|
|
159
|
+
function availableNames(
|
|
160
|
+
data: readonly string[] | undefined,
|
|
161
|
+
excludeNames: readonly string[] | undefined,
|
|
162
|
+
): readonly string[] {
|
|
163
|
+
const excluded = new Set(excludeNames ?? []);
|
|
164
|
+
return (data ?? []).filter((name) => !excluded.has(name));
|
|
165
|
+
}
|
|
166
|
+
|
|
153
167
|
function pickerState(
|
|
154
168
|
query: ToolListQuery,
|
|
169
|
+
available: readonly string[],
|
|
155
170
|
value: string | null,
|
|
156
|
-
excludeNames: readonly string[] | undefined,
|
|
157
|
-
forbiddenNote: string | undefined,
|
|
158
171
|
unlistedLabel: string | undefined,
|
|
172
|
+
errorNote: string | undefined,
|
|
159
173
|
): PickerState {
|
|
160
|
-
const excluded = new Set(excludeNames ?? []);
|
|
161
|
-
const available = (query.data ?? []).filter((name) => !excluded.has(name));
|
|
162
174
|
const stored = value !== null && value !== '' ? value : null;
|
|
163
175
|
const loaded = query.isSuccess && !query.isError;
|
|
164
176
|
const unlisted =
|
|
165
177
|
unlistedLabel !== undefined && loaded && stored !== null && !available.includes(stored);
|
|
166
178
|
const empty = loaded && available.length === 0;
|
|
167
|
-
|
|
179
|
+
// A host with `errorNote` draws the read's failure itself, a 403 included.
|
|
180
|
+
const refused = errorNote === undefined && query.isError && isForbiddenError(query.error);
|
|
168
181
|
const phase = phaseOf(refused, query.isError, unlisted, empty);
|
|
169
|
-
return { phase,
|
|
182
|
+
return { phase, stored, pending: !loaded && !query.isError, unlisted, empty };
|
|
170
183
|
}
|
|
171
184
|
|
|
172
185
|
/** The distinct tags across `names`, sorted, feeding the filter control. */
|
|
@@ -181,21 +194,31 @@ function tagsAcross(
|
|
|
181
194
|
|
|
182
195
|
/** The tag filter's choices, and the available names the chosen tag keeps. */
|
|
183
196
|
function tagView(
|
|
184
|
-
|
|
197
|
+
available: readonly string[],
|
|
198
|
+
listed: boolean,
|
|
185
199
|
tagsByTool: Readonly<Record<string, readonly string[]>> | undefined,
|
|
186
200
|
tagFilter: string,
|
|
187
201
|
): { readonly allTags: readonly string[]; readonly filtered: readonly string[] } {
|
|
188
|
-
if (tagsByTool === undefined) return { allTags: [], filtered:
|
|
189
|
-
const listed = !state.pending && state.phase !== 'error' && state.phase !== 'refused';
|
|
202
|
+
if (tagsByTool === undefined) return { allTags: [], filtered: available };
|
|
190
203
|
return {
|
|
191
|
-
allTags: listed ? tagsAcross(
|
|
204
|
+
allTags: listed ? tagsAcross(available, tagsByTool) : [],
|
|
192
205
|
filtered:
|
|
193
206
|
tagFilter === ALL_TAGS
|
|
194
|
-
?
|
|
195
|
-
:
|
|
207
|
+
? available
|
|
208
|
+
: available.filter((name) => (tagsByTool[name] ?? []).includes(tagFilter)),
|
|
196
209
|
};
|
|
197
210
|
}
|
|
198
211
|
|
|
212
|
+
/** The option drawn ahead of the list's own: an unlisted value, flagged with `unlistedLabel`. */
|
|
213
|
+
function leadingOption(
|
|
214
|
+
phase: PickerPhase,
|
|
215
|
+
stored: string | null,
|
|
216
|
+
unlistedLabel: string | undefined,
|
|
217
|
+
): SelectOption | undefined {
|
|
218
|
+
if (stored === null || phase !== 'unlisted') return undefined;
|
|
219
|
+
return { value: stored, label: `${stored} (${unlistedLabel ?? ''})` };
|
|
220
|
+
}
|
|
221
|
+
|
|
199
222
|
function MutedLine({ text }: { readonly text: string }): ReactNode {
|
|
200
223
|
return (
|
|
201
224
|
<p role="status" style={MUTED_LINE}>
|
|
@@ -222,7 +245,6 @@ export function ToolPicker({
|
|
|
222
245
|
emptyNote,
|
|
223
246
|
emptyAction,
|
|
224
247
|
errorNote,
|
|
225
|
-
forbiddenNote,
|
|
226
248
|
unlistedLabel,
|
|
227
249
|
disabled,
|
|
228
250
|
placeholder = 'Select a tool…',
|
|
@@ -239,12 +261,26 @@ export function ToolPicker({
|
|
|
239
261
|
}: ToolPickerProps) {
|
|
240
262
|
const [tagFilter, setTagFilter] = useState<string>(ALL_TAGS);
|
|
241
263
|
const unlistedId = useId();
|
|
242
|
-
|
|
264
|
+
// Memoised so a host re-render hands the select the same options, and an open
|
|
265
|
+
// list keeps its rows instead of re-indexing every tool.
|
|
266
|
+
const available = useMemo(
|
|
267
|
+
() => availableNames(query.data, excludeNames),
|
|
268
|
+
[query.data, excludeNames],
|
|
269
|
+
);
|
|
270
|
+
const state = pickerState(query, available, value, unlistedLabel, errorNote);
|
|
243
271
|
const { phase, stored } = state;
|
|
244
272
|
|
|
245
|
-
const
|
|
273
|
+
const listed = !state.pending && phase !== 'error' && phase !== 'refused';
|
|
274
|
+
const { allTags, filtered } = useMemo(
|
|
275
|
+
() => tagView(available, listed, tagsByTool, tagFilter),
|
|
276
|
+
[available, listed, tagsByTool, tagFilter],
|
|
277
|
+
);
|
|
246
278
|
|
|
247
|
-
const
|
|
279
|
+
const leading = useMemo(
|
|
280
|
+
() => leadingOption(phase, stored, unlistedLabel),
|
|
281
|
+
[phase, stored, unlistedLabel],
|
|
282
|
+
);
|
|
283
|
+
const select = (options: readonly string[]): ReactNode => (
|
|
248
284
|
<ToolSelect
|
|
249
285
|
tagsByTool={tagsByTool}
|
|
250
286
|
filtered={options}
|
|
@@ -266,26 +302,23 @@ export function ToolPicker({
|
|
|
266
302
|
</>
|
|
267
303
|
);
|
|
268
304
|
|
|
269
|
-
// Only a rendered
|
|
305
|
+
// Only a rendered control claims the field's control id; with none the host's
|
|
270
306
|
// error is hidden and the note in its place says why the field is empty.
|
|
271
|
-
const
|
|
272
|
-
phase === 'unlisted' || phase === 'select' || (phase === 'refused' && stored !== null);
|
|
307
|
+
const controlRenders = phase === 'unlisted' || phase === 'select' || phase === 'refused';
|
|
273
308
|
const contentByPhase: Record<PickerPhase, () => ReactNode> = {
|
|
274
|
-
// A stored value stays as the sole, unflagged option: without the list the form claims nothing about it.
|
|
275
309
|
refused: () => (
|
|
276
|
-
|
|
277
|
-
{stored
|
|
278
|
-
|
|
279
|
-
|
|
310
|
+
<ToolNameEntry
|
|
311
|
+
value={stored}
|
|
312
|
+
onChange={onChange}
|
|
313
|
+
disabled={disabled === true}
|
|
314
|
+
ariaLabel={ariaLabel}
|
|
315
|
+
/>
|
|
280
316
|
),
|
|
281
317
|
error: () =>
|
|
282
318
|
errorNote !== undefined ? <MutedLine text={errorNote} /> : <ReadError query={query} />,
|
|
283
319
|
unlisted: () => (
|
|
284
320
|
<>
|
|
285
|
-
{select(filtered
|
|
286
|
-
value: stored ?? '',
|
|
287
|
-
label: `${stored ?? ''} (${unlistedLabel ?? ''})`,
|
|
288
|
-
})}
|
|
321
|
+
{select(filtered)}
|
|
289
322
|
{state.empty ? emptyState : null}
|
|
290
323
|
<p id={unlistedId} className="tai-field-hint" style={{ margin: 0 }}>
|
|
291
324
|
<Badge variant="warning">{unlistedLabel}</Badge> {stored} is {unlistedLabel}. Pick
|
|
@@ -294,7 +327,7 @@ export function ToolPicker({
|
|
|
294
327
|
</>
|
|
295
328
|
),
|
|
296
329
|
empty: () => emptyState,
|
|
297
|
-
select: () => select(filtered
|
|
330
|
+
select: () => select(filtered),
|
|
298
331
|
};
|
|
299
332
|
const content = contentByPhase[phase]();
|
|
300
333
|
|
|
@@ -312,8 +345,8 @@ export function ToolPicker({
|
|
|
312
345
|
<Field
|
|
313
346
|
label={label}
|
|
314
347
|
description={description}
|
|
315
|
-
error={
|
|
316
|
-
group={!
|
|
348
|
+
error={controlRenders ? error : undefined}
|
|
349
|
+
group={!controlRenders}
|
|
317
350
|
>
|
|
318
351
|
{content}
|
|
319
352
|
</Field>
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `ToolPicker`'s tool `
|
|
3
|
-
* grouping helpers it renders from.
|
|
2
|
+
* `ToolPicker`'s tool select: a `SearchableSelect`, grouped by tag or flat, with the
|
|
3
|
+
* option-label and grouping helpers it renders from. Its search matches the option
|
|
4
|
+
* label, which carries both the display name and the raw name (`toolDisplayLabel`).
|
|
4
5
|
*/
|
|
5
|
-
import type
|
|
6
|
+
import { type ReactNode, useMemo } from 'react';
|
|
6
7
|
|
|
7
8
|
import { toolDisplayLabel } from '../hooks/useToolDisplayNames';
|
|
8
|
-
import {
|
|
9
|
+
import { SearchableSelect } from './searchable-select';
|
|
10
|
+
import type { SelectGroup, SelectOption } from './select';
|
|
9
11
|
|
|
10
12
|
const NO_DISPLAY_NAMES: Readonly<Record<string, string>> = {};
|
|
11
13
|
const UNTAGGED = 'Untagged';
|
|
14
|
+
const SEARCH_LABEL = 'Search tools';
|
|
12
15
|
|
|
13
16
|
/** The group a tool sorts into: its alphabetically-first tag, else "Untagged". */
|
|
14
17
|
function groupFor(name: string, tagsByTool: Readonly<Record<string, readonly string[]>>): string {
|
|
@@ -73,8 +76,31 @@ interface ToolSelectProps {
|
|
|
73
76
|
readonly ariaDescribedBy: string | undefined;
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
/** The select's options: tag groups when `tagsByTool` is present, else one flat run,
|
|
80
|
+
* with `leading` ahead of either. */
|
|
81
|
+
function toolListing(
|
|
82
|
+
filtered: readonly string[],
|
|
83
|
+
tagsByTool: Readonly<Record<string, readonly string[]>> | undefined,
|
|
84
|
+
displayNames: Readonly<Record<string, string>> | undefined,
|
|
85
|
+
agentToolNames: ReadonlySet<string> | undefined,
|
|
86
|
+
leading: SelectOption | undefined,
|
|
87
|
+
): { readonly groups: readonly SelectGroup[] } | { readonly options: readonly SelectOption[] } {
|
|
88
|
+
if (tagsByTool !== undefined) {
|
|
89
|
+
const groups = buildGroups(filtered, tagsByTool, displayNames, agentToolNames);
|
|
90
|
+
return {
|
|
91
|
+
groups:
|
|
92
|
+
leading === undefined ? groups : [{ label: leading.label, options: [leading] }, ...groups],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const options = filtered.map((name) => ({
|
|
96
|
+
value: name,
|
|
97
|
+
label: optionLabel(name, displayNames, agentToolNames),
|
|
98
|
+
}));
|
|
99
|
+
return { options: leading === undefined ? options : [leading, ...options] };
|
|
100
|
+
}
|
|
101
|
+
|
|
76
102
|
/** The tool select: grouped by tag when `tagsByTool` is present, else a flat list.
|
|
77
|
-
* Either branch is a `
|
|
103
|
+
* Either branch is a `SearchableSelect`, which claims the enclosing `Field`'s control id. */
|
|
78
104
|
export function ToolSelect({
|
|
79
105
|
tagsByTool,
|
|
80
106
|
filtered,
|
|
@@ -88,38 +114,23 @@ export function ToolSelect({
|
|
|
88
114
|
leading,
|
|
89
115
|
ariaDescribedBy,
|
|
90
116
|
}: ToolSelectProps): ReactNode {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
label: optionLabel(name, displayNames, agentToolNames),
|
|
110
|
-
}));
|
|
111
|
-
return (
|
|
112
|
-
<Select
|
|
113
|
-
// Keep Radix controlled even when the caller's value is null: `''` matches no
|
|
114
|
-
// item (placeholder still shows) and avoids the uncontrolled→controlled warning
|
|
115
|
-
// a subsequent selection would otherwise trigger.
|
|
116
|
-
options={leading === undefined ? options : [leading, ...options]}
|
|
117
|
-
value={value ?? ''}
|
|
118
|
-
onValueChange={onChange}
|
|
119
|
-
placeholder={placeholder}
|
|
120
|
-
aria-label={ariaLabel}
|
|
121
|
-
aria-describedby={ariaDescribedBy}
|
|
122
|
-
disabled={disabled}
|
|
123
|
-
/>
|
|
117
|
+
// Memoised so a host re-render keeps the indexed options of an open list.
|
|
118
|
+
const listing = useMemo(
|
|
119
|
+
() => toolListing(filtered, tagsByTool, displayNames, agentToolNames, leading),
|
|
120
|
+
[filtered, tagsByTool, displayNames, agentToolNames, leading],
|
|
121
|
+
);
|
|
122
|
+
const shared = {
|
|
123
|
+
value: value ?? '',
|
|
124
|
+
onValueChange: onChange,
|
|
125
|
+
placeholder,
|
|
126
|
+
'aria-label': ariaLabel,
|
|
127
|
+
'aria-describedby': ariaDescribedBy,
|
|
128
|
+
disabled,
|
|
129
|
+
searchLabel: SEARCH_LABEL,
|
|
130
|
+
};
|
|
131
|
+
return 'groups' in listing ? (
|
|
132
|
+
<SearchableSelect {...shared} groups={listing.groups} />
|
|
133
|
+
) : (
|
|
134
|
+
<SearchableSelect {...shared} options={listing.options} />
|
|
124
135
|
);
|
|
125
136
|
}
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* for it; the kind-status table then reports that kind's `state` as `off` (rather
|
|
8
8
|
* than `active` / `default`). A write affordance for an OFF feature reads this and
|
|
9
9
|
* hides itself BEFORE the user acts, instead of learning OFF only from a write's 501
|
|
10
|
-
* refusal
|
|
11
|
-
*
|
|
10
|
+
* refusal. A write's 501 refusal is the backstop for a stale table: the surface that
|
|
11
|
+
* saw it reports it through `useReportFeatureOff`, and the provider then reads that
|
|
12
|
+
* kind as OFF for every surface in the app, with the refusal's own message.
|
|
12
13
|
*
|
|
13
14
|
* A PLAIN fetch state machine (`loading` / `ready` / `failed`), NOT TanStack Query:
|
|
14
15
|
* the SDK is the shared leaf across the plugin boundary and holds no query context,
|
|
@@ -21,8 +22,10 @@ import {
|
|
|
21
22
|
createContext,
|
|
22
23
|
createElement,
|
|
23
24
|
type ReactNode,
|
|
25
|
+
useCallback,
|
|
24
26
|
useContext,
|
|
25
27
|
useEffect,
|
|
28
|
+
useMemo,
|
|
26
29
|
useState,
|
|
27
30
|
} from 'react';
|
|
28
31
|
|
|
@@ -34,7 +37,14 @@ export type SystemKindsState =
|
|
|
34
37
|
| { readonly status: 'ready'; readonly kinds: readonly KindStatus[] }
|
|
35
38
|
| { readonly status: 'failed'; readonly error: unknown };
|
|
36
39
|
|
|
37
|
-
|
|
40
|
+
interface SystemKindsContextValue {
|
|
41
|
+
readonly state: SystemKindsState;
|
|
42
|
+
/** Kinds a write refusal revealed OFF, each with the refusal's message. */
|
|
43
|
+
readonly refusedOff: ReadonlyMap<string, string>;
|
|
44
|
+
readonly reportOff: (kind: string, message: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SystemKindsContext = createContext<SystemKindsContextValue | null>(null);
|
|
38
48
|
|
|
39
49
|
/**
|
|
40
50
|
* Fetch the kind-status table once the caller is authenticated and hold it as SDK
|
|
@@ -47,8 +57,12 @@ export function SystemKindsProvider({ children }: { children: ReactNode }) {
|
|
|
47
57
|
const { isAuthenticated } = useAuth();
|
|
48
58
|
const api = useApi();
|
|
49
59
|
const [state, setState] = useState<SystemKindsState>({ status: 'loading' });
|
|
60
|
+
const [refusedOff, setRefusedOff] = useState<ReadonlyMap<string, string>>(() => new Map());
|
|
50
61
|
|
|
51
62
|
useEffect(() => {
|
|
63
|
+
// A sign-in, a sign-out or a new API client starts from the table alone: what
|
|
64
|
+
// refusals revealed for an earlier session or another client is not carried over.
|
|
65
|
+
setRefusedOff(new Map());
|
|
52
66
|
if (!isAuthenticated) {
|
|
53
67
|
// No credential → nothing to read. Reset to the neutral `loading` state so the
|
|
54
68
|
// next sign-in re-fetches cleanly; every kind reads not-off meanwhile.
|
|
@@ -70,28 +84,71 @@ export function SystemKindsProvider({ children }: { children: ReactNode }) {
|
|
|
70
84
|
};
|
|
71
85
|
}, [api, isAuthenticated]);
|
|
72
86
|
|
|
73
|
-
|
|
87
|
+
const reportOff = useCallback((kind: string, message: string) => {
|
|
88
|
+
setRefusedOff((current) => {
|
|
89
|
+
if (current.get(kind) === message) return current;
|
|
90
|
+
const next = new Map(current);
|
|
91
|
+
next.set(kind, message);
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
const value = useMemo<SystemKindsContextValue>(
|
|
97
|
+
() => ({ state, refusedOff, reportOff }),
|
|
98
|
+
[state, refusedOff, reportOff],
|
|
99
|
+
);
|
|
100
|
+
return createElement(SystemKindsContext.Provider, { value }, children);
|
|
74
101
|
}
|
|
75
102
|
|
|
103
|
+
const NO_REFUSALS: ReadonlyMap<string, string> = new Map();
|
|
104
|
+
|
|
76
105
|
/**
|
|
77
106
|
* The live kind-status state. Falls back to `loading` when no provider is mounted, so
|
|
78
107
|
* a consumer rendered outside the shell reads every kind as not-off rather than
|
|
79
108
|
* throwing — the fail-open default this proactive-hiding idiom relies on.
|
|
80
109
|
*/
|
|
81
110
|
export function useSystemKinds(): SystemKindsState {
|
|
82
|
-
return useContext(SystemKindsContext) ?? { status: 'loading' };
|
|
111
|
+
return useContext(SystemKindsContext)?.state ?? { status: 'loading' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The kinds a write refusal revealed OFF in this session (none without a provider). */
|
|
115
|
+
function useRefusedOff(): ReadonlyMap<string, string> {
|
|
116
|
+
return useContext(SystemKindsContext)?.refusedOff ?? NO_REFUSALS;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Record that a write refusal revealed `kind` OFF, with the refusal's own `message`,
|
|
121
|
+
* so every surface's `useFeatureOff(kind)` reads OFF from then on. Calling the
|
|
122
|
+
* returned function with no `SystemKindsProvider` mounted throws: the refusal would
|
|
123
|
+
* otherwise reach no other surface.
|
|
124
|
+
*/
|
|
125
|
+
export function useReportFeatureOff(): (kind: string, message: string) => void {
|
|
126
|
+
const context = useContext(SystemKindsContext);
|
|
127
|
+
return useCallback(
|
|
128
|
+
(kind: string, message: string) => {
|
|
129
|
+
if (context === null) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`A ${kind} refusal was reported with no <SystemKindsProvider> mounted to hold it.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
context.reportOff(kind, message);
|
|
135
|
+
},
|
|
136
|
+
[context],
|
|
137
|
+
);
|
|
83
138
|
}
|
|
84
139
|
|
|
85
140
|
/**
|
|
86
|
-
* Whether `kind` is
|
|
87
|
-
*
|
|
88
|
-
* `
|
|
89
|
-
*
|
|
90
|
-
* it is `false` (
|
|
91
|
-
* 501
|
|
141
|
+
* Whether `kind` is OFF — the single predicate a write affordance folds in to hide
|
|
142
|
+
* itself before a doomed write. It is `true` once the table is `ready` AND that kind's
|
|
143
|
+
* row carries `state: 'off'`, or once a write refusal revealed the kind OFF
|
|
144
|
+
* ({@link useReportFeatureOff}). Otherwise — the table loading/failed, no provider
|
|
145
|
+
* mounted, the kind unknown — it is `false` (not-off until known: under-hiding is
|
|
146
|
+
* safe, a write's 501 refusal still reveals a genuinely-off feature).
|
|
92
147
|
*/
|
|
93
148
|
export function useFeatureOff(kind: string): boolean {
|
|
94
149
|
const state = useSystemKinds();
|
|
150
|
+
const refused = useRefusedOff().has(kind);
|
|
151
|
+
if (refused) return true;
|
|
95
152
|
if (state.status !== 'ready') return false;
|
|
96
153
|
return state.kinds.some((row) => row.kind === kind && row.state === 'off');
|
|
97
154
|
}
|
|
@@ -99,13 +156,16 @@ export function useFeatureOff(kind: string): boolean {
|
|
|
99
156
|
/**
|
|
100
157
|
* The OFF row's server `detail` for `kind` — the proactive counterpart to a write
|
|
101
158
|
* refusal's message, so a proactively-hidden surface shows the SERVER's own
|
|
102
|
-
* remediation line rather than a client-composed env-var string
|
|
103
|
-
*
|
|
104
|
-
*
|
|
159
|
+
* remediation line rather than a client-composed env-var string; for a kind only a
|
|
160
|
+
* write refusal revealed OFF, that refusal's message. `null` whenever the kind is not
|
|
161
|
+
* OFF, mirroring `useFeatureOff`'s not-off-until-known default.
|
|
105
162
|
*/
|
|
106
163
|
export function useFeatureOffMessage(kind: string): string | null {
|
|
107
164
|
const state = useSystemKinds();
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
165
|
+
const refused = useRefusedOff().get(kind);
|
|
166
|
+
if (state.status === 'ready') {
|
|
167
|
+
const row = state.kinds.find((entry) => entry.kind === kind && entry.state === 'off');
|
|
168
|
+
if (row) return row.detail;
|
|
169
|
+
}
|
|
170
|
+
return refused ?? null;
|
|
111
171
|
}
|