@tai42/studio-sdk 30.0.0 → 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.
Files changed (252) hide show
  1. package/README.md +98 -10
  2. package/dist/components/agent-picker.d.ts +1 -1
  3. package/dist/components/agent-picker.d.ts.map +1 -1
  4. package/dist/components/agent-picker.js +1 -0
  5. package/dist/components/agent-picker.js.map +1 -1
  6. package/dist/components/channel-picker.d.ts +1 -1
  7. package/dist/components/channel-picker.d.ts.map +1 -1
  8. package/dist/components/channel-picker.js +1 -0
  9. package/dist/components/channel-picker.js.map +1 -1
  10. package/dist/components/combo-draft-editor.d.ts +31 -0
  11. package/dist/components/combo-draft-editor.d.ts.map +1 -0
  12. package/dist/components/combo-draft-editor.js +16 -0
  13. package/dist/components/combo-draft-editor.js.map +1 -0
  14. package/dist/components/committed-combo-list.d.ts +17 -0
  15. package/dist/components/committed-combo-list.d.ts.map +1 -0
  16. package/dist/components/{extension-combo-parts.js → committed-combo-list.js} +2 -22
  17. package/dist/components/committed-combo-list.js.map +1 -0
  18. package/dist/components/components.css +53 -0
  19. package/dist/components/explorer-body.d.ts +1 -0
  20. package/dist/components/explorer-body.d.ts.map +1 -1
  21. package/dist/components/explorer-body.js +6 -4
  22. package/dist/components/explorer-body.js.map +1 -1
  23. package/dist/components/explorer-view.d.ts +16 -7
  24. package/dist/components/explorer-view.d.ts.map +1 -1
  25. package/dist/components/explorer-view.js +27 -13
  26. package/dist/components/explorer-view.js.map +1 -1
  27. package/dist/components/extension-combo-builder.d.ts +4 -4
  28. package/dist/components/extension-combo-builder.js +3 -2
  29. package/dist/components/extension-combo-builder.js.map +1 -1
  30. package/dist/components/extension-config-editor.js.map +1 -1
  31. package/dist/components/field.d.ts +6 -0
  32. package/dist/components/field.d.ts.map +1 -1
  33. package/dist/components/field.js +15 -2
  34. package/dist/components/field.js.map +1 -1
  35. package/dist/components/folder-actions.d.ts +51 -0
  36. package/dist/components/folder-actions.d.ts.map +1 -0
  37. package/dist/components/folder-actions.js +173 -0
  38. package/dist/components/folder-actions.js.map +1 -0
  39. package/dist/components/folder-filing.d.ts +32 -0
  40. package/dist/components/folder-filing.d.ts.map +1 -0
  41. package/dist/components/folder-filing.js +51 -0
  42. package/dist/components/folder-filing.js.map +1 -0
  43. package/dist/components/folder-nav.d.ts +9 -4
  44. package/dist/components/folder-nav.d.ts.map +1 -1
  45. package/dist/components/folder-nav.js +18 -22
  46. package/dist/components/folder-nav.js.map +1 -1
  47. package/dist/components/folder-occupancy.d.ts +27 -0
  48. package/dist/components/folder-occupancy.d.ts.map +1 -0
  49. package/dist/components/folder-occupancy.js +54 -0
  50. package/dist/components/folder-occupancy.js.map +1 -0
  51. package/dist/components/named-list-picker.d.ts +5 -3
  52. package/dist/components/named-list-picker.d.ts.map +1 -1
  53. package/dist/components/named-list-picker.js +7 -7
  54. package/dist/components/named-list-picker.js.map +1 -1
  55. package/dist/components/primitives.d.ts +2 -1
  56. package/dist/components/primitives.d.ts.map +1 -1
  57. package/dist/components/primitives.js +2 -1
  58. package/dist/components/primitives.js.map +1 -1
  59. package/dist/components/searchable-select-model.d.ts +40 -0
  60. package/dist/components/searchable-select-model.d.ts.map +1 -0
  61. package/dist/components/searchable-select-model.js +69 -0
  62. package/dist/components/searchable-select-model.js.map +1 -0
  63. package/dist/components/searchable-select.d.ts +38 -0
  64. package/dist/components/searchable-select.d.ts.map +1 -0
  65. package/dist/components/searchable-select.js +200 -0
  66. package/dist/components/searchable-select.js.map +1 -0
  67. package/dist/components/selected-tool-badges.d.ts +13 -0
  68. package/dist/components/selected-tool-badges.d.ts.map +1 -0
  69. package/dist/components/selected-tool-badges.js +14 -0
  70. package/dist/components/selected-tool-badges.js.map +1 -0
  71. package/dist/components/subject-section.d.ts.map +1 -1
  72. package/dist/components/subject-section.js +2 -2
  73. package/dist/components/subject-section.js.map +1 -1
  74. package/dist/components/table.d.ts +7 -1
  75. package/dist/components/table.d.ts.map +1 -1
  76. package/dist/components/table.js +2 -2
  77. package/dist/components/table.js.map +1 -1
  78. package/dist/components/tag-filter-field.d.ts +11 -0
  79. package/dist/components/tag-filter-field.d.ts.map +1 -0
  80. package/dist/components/tag-filter-field.js +15 -0
  81. package/dist/components/tag-filter-field.js.map +1 -0
  82. package/dist/components/tool-meta-access.d.ts +28 -0
  83. package/dist/components/tool-meta-access.d.ts.map +1 -0
  84. package/dist/components/tool-meta-access.js +65 -0
  85. package/dist/components/tool-meta-access.js.map +1 -0
  86. package/dist/components/tool-name-entry.d.ts +26 -0
  87. package/dist/components/tool-name-entry.d.ts.map +1 -0
  88. package/dist/components/tool-name-entry.js +43 -0
  89. package/dist/components/tool-name-entry.js.map +1 -0
  90. package/dist/components/tool-picker.d.ts +24 -19
  91. package/dist/components/tool-picker.d.ts.map +1 -1
  92. package/dist/components/tool-picker.js +55 -35
  93. package/dist/components/tool-picker.js.map +1 -1
  94. package/dist/components/tool-select.d.ts +26 -0
  95. package/dist/components/tool-select.d.ts.map +1 -0
  96. package/dist/components/tool-select.js +82 -0
  97. package/dist/components/tool-select.js.map +1 -0
  98. package/dist/extension-combos.d.ts +6 -1
  99. package/dist/extension-combos.d.ts.map +1 -1
  100. package/dist/extension-combos.js +8 -0
  101. package/dist/extension-combos.js.map +1 -1
  102. package/dist/hooks/useApi.d.ts +1 -0
  103. package/dist/hooks/useApi.d.ts.map +1 -1
  104. package/dist/hooks/useSystemKinds.d.ts +27 -12
  105. package/dist/hooks/useSystemKinds.d.ts.map +1 -1
  106. package/dist/hooks/useSystemKinds.js +56 -16
  107. package/dist/hooks/useSystemKinds.js.map +1 -1
  108. package/dist/index.d.ts +51 -49
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +34 -26
  111. package/dist/index.js.map +1 -1
  112. package/dist/marketplace-install/use-mount-install.d.ts +6 -6
  113. package/dist/plugin/version.d.ts +1 -1
  114. package/dist/plugin/version.js +1 -1
  115. package/dist/schema-editor/SchemaEditor.d.ts +7 -2
  116. package/dist/schema-editor/SchemaEditor.d.ts.map +1 -1
  117. package/dist/schema-editor/SchemaEditor.js +1 -1
  118. package/dist/schema-editor/SchemaEditor.js.map +1 -1
  119. package/dist/schema-editor/index.d.ts +2 -2
  120. package/dist/schema-editor/index.d.ts.map +1 -1
  121. package/dist/schema-form/array-field.d.ts.map +1 -1
  122. package/dist/schema-form/array-field.js +6 -2
  123. package/dist/schema-form/array-field.js.map +1 -1
  124. package/dist/schema-form/classify.d.ts +4 -1
  125. package/dist/schema-form/classify.d.ts.map +1 -1
  126. package/dist/schema-form/classify.js +36 -15
  127. package/dist/schema-form/classify.js.map +1 -1
  128. package/dist/schema-form/collapsed-object-field.d.ts +20 -0
  129. package/dist/schema-form/collapsed-object-field.d.ts.map +1 -0
  130. package/dist/schema-form/collapsed-object-field.js +14 -0
  131. package/dist/schema-form/collapsed-object-field.js.map +1 -0
  132. package/dist/schema-form/context.d.ts +27 -1
  133. package/dist/schema-form/context.d.ts.map +1 -1
  134. package/dist/schema-form/context.js +14 -0
  135. package/dist/schema-form/context.js.map +1 -1
  136. package/dist/schema-form/default-value.d.ts +21 -6
  137. package/dist/schema-form/default-value.d.ts.map +1 -1
  138. package/dist/schema-form/default-value.js +74 -15
  139. package/dist/schema-form/default-value.js.map +1 -1
  140. package/dist/schema-form/expression-field.d.ts +48 -0
  141. package/dist/schema-form/expression-field.d.ts.map +1 -0
  142. package/dist/schema-form/expression-field.js +88 -0
  143. package/dist/schema-form/expression-field.js.map +1 -0
  144. package/dist/schema-form/field-model.d.ts +23 -4
  145. package/dist/schema-form/field-model.d.ts.map +1 -1
  146. package/dist/schema-form/field-node.d.ts.map +1 -1
  147. package/dist/schema-form/field-node.js +16 -3
  148. package/dist/schema-form/field-node.js.map +1 -1
  149. package/dist/schema-form/index.d.ts +3 -3
  150. package/dist/schema-form/index.d.ts.map +1 -1
  151. package/dist/schema-form/index.js +2 -2
  152. package/dist/schema-form/index.js.map +1 -1
  153. package/dist/schema-form/object-fields.d.ts +6 -3
  154. package/dist/schema-form/object-fields.d.ts.map +1 -1
  155. package/dist/schema-form/object-fields.js +14 -3
  156. package/dist/schema-form/object-fields.js.map +1 -1
  157. package/dist/schema-form/record-field.d.ts.map +1 -1
  158. package/dist/schema-form/record-field.js +6 -2
  159. package/dist/schema-form/record-field.js.map +1 -1
  160. package/dist/schema-form/seeding-action.d.ts +15 -0
  161. package/dist/schema-form/seeding-action.d.ts.map +1 -0
  162. package/dist/schema-form/seeding-action.js +35 -0
  163. package/dist/schema-form/seeding-action.js.map +1 -0
  164. package/dist/schema-form/string-annotations.d.ts +22 -8
  165. package/dist/schema-form/string-annotations.d.ts.map +1 -1
  166. package/dist/schema-form/string-annotations.js +39 -14
  167. package/dist/schema-form/string-annotations.js.map +1 -1
  168. package/dist/schema-form/string-field.js +2 -84
  169. package/dist/schema-form/string-field.js.map +1 -1
  170. package/dist/schema-form/templated-field.d.ts +13 -0
  171. package/dist/schema-form/templated-field.d.ts.map +1 -0
  172. package/dist/schema-form/templated-field.js +43 -0
  173. package/dist/schema-form/templated-field.js.map +1 -0
  174. package/dist/schema-form/union-field.d.ts.map +1 -1
  175. package/dist/schema-form/union-field.js +6 -2
  176. package/dist/schema-form/union-field.js.map +1 -1
  177. package/dist/schema-form/union.d.ts +4 -4
  178. package/dist/schema-form/union.d.ts.map +1 -1
  179. package/dist/schema-form/union.js +8 -1
  180. package/dist/schema-form/union.js.map +1 -1
  181. package/dist/schema-form/validate.d.ts.map +1 -1
  182. package/dist/schema-form/validate.js +28 -0
  183. package/dist/schema-form/validate.js.map +1 -1
  184. package/dist/search-match.d.ts +29 -0
  185. package/dist/search-match.d.ts.map +1 -0
  186. package/dist/search-match.js +38 -0
  187. package/dist/search-match.js.map +1 -0
  188. package/dist/tool-organization.d.ts +15 -0
  189. package/dist/tool-organization.d.ts.map +1 -0
  190. package/dist/tool-organization.js +6 -0
  191. package/dist/tool-organization.js.map +1 -0
  192. package/package.json +3 -2
  193. package/src/components/agent-picker.tsx +2 -1
  194. package/src/components/channel-picker.tsx +2 -1
  195. package/src/components/combo-draft-editor.tsx +96 -0
  196. package/src/components/{extension-combo-parts.tsx → committed-combo-list.tsx} +4 -105
  197. package/src/components/components.css +53 -0
  198. package/src/components/explorer-body.tsx +29 -5
  199. package/src/components/explorer-view.tsx +48 -17
  200. package/src/components/extension-combo-builder.tsx +10 -10
  201. package/src/components/extension-config-editor.tsx +3 -3
  202. package/src/components/field.tsx +18 -2
  203. package/src/components/folder-actions.tsx +341 -0
  204. package/src/components/folder-filing.tsx +109 -0
  205. package/src/components/folder-nav.tsx +26 -10
  206. package/src/components/folder-occupancy.ts +83 -0
  207. package/src/components/named-list-picker.tsx +11 -7
  208. package/src/components/primitives.tsx +2 -1
  209. package/src/components/searchable-select-model.ts +105 -0
  210. package/src/components/searchable-select.tsx +432 -0
  211. package/src/components/selected-tool-badges.tsx +41 -0
  212. package/src/components/subject-section.tsx +3 -2
  213. package/src/components/table.tsx +12 -2
  214. package/src/components/tag-filter-field.tsx +37 -0
  215. package/src/components/tool-meta-access.tsx +101 -0
  216. package/src/components/tool-name-entry.tsx +77 -0
  217. package/src/components/tool-picker.tsx +87 -52
  218. package/src/components/tool-select.tsx +136 -0
  219. package/src/extension-combos.ts +15 -1
  220. package/src/hooks/useSystemKinds.tsx +77 -17
  221. package/src/index.ts +98 -94
  222. package/src/plugin/version.ts +1 -1
  223. package/src/schema-editor/SchemaEditor.tsx +9 -3
  224. package/src/schema-editor/index.ts +2 -2
  225. package/src/schema-form/array-field.tsx +10 -2
  226. package/src/schema-form/classify.ts +41 -15
  227. package/src/schema-form/collapsed-object-field.tsx +56 -0
  228. package/src/schema-form/context.ts +33 -1
  229. package/src/schema-form/default-value.ts +89 -15
  230. package/src/schema-form/expression-field.tsx +162 -0
  231. package/src/schema-form/field-model.ts +27 -4
  232. package/src/schema-form/field-node.tsx +41 -2
  233. package/src/schema-form/index.ts +3 -2
  234. package/src/schema-form/object-fields.tsx +11 -4
  235. package/src/schema-form/record-field.tsx +10 -2
  236. package/src/schema-form/seeding-action.ts +44 -0
  237. package/src/schema-form/string-annotations.ts +40 -14
  238. package/src/schema-form/string-field.tsx +2 -147
  239. package/src/schema-form/templated-field.tsx +124 -0
  240. package/src/schema-form/union-field.tsx +11 -2
  241. package/src/schema-form/union.ts +12 -5
  242. package/src/schema-form/validate.ts +28 -0
  243. package/src/search-match.ts +41 -0
  244. package/src/tool-organization.ts +34 -0
  245. package/dist/components/extension-combo-parts.d.ts +0 -46
  246. package/dist/components/extension-combo-parts.d.ts.map +0 -1
  247. package/dist/components/extension-combo-parts.js.map +0 -1
  248. package/dist/components/tool-picker-parts.d.ts +0 -43
  249. package/dist/components/tool-picker-parts.d.ts.map +0 -1
  250. package/dist/components/tool-picker-parts.js +0 -89
  251. package/dist/components/tool-picker-parts.js.map +0 -1
  252. package/src/components/tool-picker-parts.tsx +0 -195
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `ToolPicker`'s typed entry: the control it draws when the session may not list tools
3
+ * (the read answered 403). The server checks a named tool on save, so a reader who
4
+ * knows the name can still fill the field; the note under the input says why there is
5
+ * no list.
6
+ *
7
+ * The input holds a draft while the reader types and hands the trimmed name to
8
+ * `onChange` on blur or Enter, so a host that acts on every change (an "add" control
9
+ * that appends each emitted name) receives one whole name, never each keystroke. Enter
10
+ * commits instead of submitting the enclosing form, since the form would otherwise read
11
+ * the value from before the commit. A blank draft emits nothing: the input shows the
12
+ * stored value again.
13
+ */
14
+ import { type KeyboardEvent, type ReactNode, useId, useState } from 'react';
15
+
16
+ import { useFieldControl } from './field';
17
+ import { TextInput } from './inputs';
18
+
19
+ /** The sentence under the typed entry, naming why there is no list and what saving does. */
20
+ export const TOOL_LIST_REFUSED_NOTE =
21
+ "This session cannot list tools, so type the tool's exact name. Saving checks that the deployment serves it.";
22
+
23
+ const NOTE_STYLE = { margin: 0, color: 'var(--tai-color-text-muted)' } as const;
24
+
25
+ export interface ToolNameEntryProps {
26
+ /** The stored name, or `null` for none. */
27
+ readonly value: string | null;
28
+ readonly onChange: (toolName: string) => void;
29
+ readonly disabled: boolean;
30
+ /** Names the input when no enclosing `Field` labels it; a `Field`'s label wins. */
31
+ readonly ariaLabel: string | undefined;
32
+ }
33
+
34
+ export function ToolNameEntry({
35
+ value,
36
+ onChange,
37
+ disabled,
38
+ ariaLabel,
39
+ }: ToolNameEntryProps): ReactNode {
40
+ const noteId = useId();
41
+ const labelled = useFieldControl().id !== undefined;
42
+ const [draft, setDraft] = useState<string | undefined>(undefined);
43
+
44
+ const commit = (): void => {
45
+ if (draft === undefined) return;
46
+ const name = draft.trim();
47
+ setDraft(undefined);
48
+ if (name !== '' && name !== value) onChange(name);
49
+ };
50
+
51
+ const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
52
+ if (event.key !== 'Enter') return;
53
+ event.preventDefault();
54
+ commit();
55
+ };
56
+
57
+ return (
58
+ <>
59
+ <TextInput
60
+ value={draft ?? value ?? ''}
61
+ onChange={(event) => {
62
+ setDraft(event.target.value);
63
+ }}
64
+ onBlur={commit}
65
+ onKeyDown={onKeyDown}
66
+ disabled={disabled}
67
+ aria-label={labelled ? undefined : ariaLabel}
68
+ aria-describedby={noteId}
69
+ autoComplete="off"
70
+ spellCheck={false}
71
+ />
72
+ <p id={noteId} role="status" style={NOTE_STYLE}>
73
+ {TOOL_LIST_REFUSED_NOTE}
74
+ </p>
75
+ </>
76
+ );
77
+ }
@@ -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 `Select` so features share one picker instead of each re-implementing
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 `Select` reading "Loading tools…"), error (`ErrorState` with Retry, or the
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 with `forbiddenNote` set), and loaded with nothing to offer (the host's
12
- * `emptyNote`, then its `emptyAction`). The host's `error` shows, and the `Field` claims
13
- * its control id, exactly while a `Select` renders.
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, so a display
34
- * name is purely cosmetic.
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,16 +47,19 @@
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';
47
54
  import { Field } from './field';
48
55
  import { ErrorState } from './primitives';
49
56
  import type { SelectOption } from './select';
50
- import { ALL_TAGS, SelectedToolBadges, TagFilterField, ToolSelect } from './tool-picker-parts';
57
+ import { SelectedToolBadges } from './selected-tool-badges';
58
+ import { ALL_TAGS, TagFilterField } from './tag-filter-field';
59
+ import { ToolNameEntry } from './tool-name-entry';
60
+ import { ToolSelect } from './tool-select';
51
61
 
52
- export { BADGES_NOTE } from './tool-picker-parts';
62
+ export { BADGES_NOTE } from './selected-tool-badges';
53
63
 
54
64
  const MUTED_LINE = { margin: 0, color: 'var(--tai-color-text-muted)' } as const;
55
65
 
@@ -77,12 +87,11 @@ export interface ToolPickerProps {
77
87
  /** Drawn after `emptyNote` in the empty state (e.g. a link to where tools come from). */
78
88
  readonly emptyAction?: ReactNode;
79
89
  /**
80
- * Drawn in place of the `ErrorState` and the control when the read fails, with no
81
- * Retry: for a host that already draws the same read's failure with Retry elsewhere.
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.
82
93
  */
83
94
  readonly errorNote?: string;
84
- /** Drawn on a 403 of the read in place of the error state (the no-grant state). */
85
- readonly forbiddenNote?: string;
86
95
  /** Flags a stored value the loaded list does not carry (see the module docs). */
87
96
  readonly unlistedLabel?: string;
88
97
  readonly disabled?: boolean;
@@ -92,13 +101,12 @@ export interface ToolPickerProps {
92
101
  readonly label?: string;
93
102
  /** Handed to the picker's `Field` (with `label`). */
94
103
  readonly description?: string;
95
- /** The host's field error, shown under the control while a `Select` renders (with `label`). */
104
+ /** The host's field error, shown under the control while one renders (with `label`). */
96
105
  readonly error?: string;
97
106
  /**
98
- * Accessible name for the tool listbox when `label` renders none and no
99
- * enclosing `Field` names it. `label` wins where both are given: it names the
100
- * control from visible text, and the `Select` drops a caller name it would
101
- * 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.
102
110
  */
103
111
  readonly 'aria-label'?: string;
104
112
  /** Names removed from the options (the server remains the authority). */
@@ -127,7 +135,6 @@ type PickerPhase = 'refused' | 'error' | 'unlisted' | 'empty' | 'select';
127
135
 
128
136
  interface PickerState {
129
137
  readonly phase: PickerPhase;
130
- readonly available: readonly string[];
131
138
  /** The stored value, or `null` for none. */
132
139
  readonly stored: string | null;
133
140
  readonly pending: boolean;
@@ -148,23 +155,31 @@ function phaseOf(
148
155
  return empty ? 'empty' : 'select';
149
156
  }
150
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
+
151
167
  function pickerState(
152
168
  query: ToolListQuery,
169
+ available: readonly string[],
153
170
  value: string | null,
154
- excludeNames: readonly string[] | undefined,
155
- forbiddenNote: string | undefined,
156
171
  unlistedLabel: string | undefined,
172
+ errorNote: string | undefined,
157
173
  ): PickerState {
158
- const excluded = new Set(excludeNames ?? []);
159
- const available = (query.data ?? []).filter((name) => !excluded.has(name));
160
174
  const stored = value !== null && value !== '' ? value : null;
161
175
  const loaded = query.isSuccess && !query.isError;
162
176
  const unlisted =
163
177
  unlistedLabel !== undefined && loaded && stored !== null && !available.includes(stored);
164
178
  const empty = loaded && available.length === 0;
165
- const refused = forbiddenNote !== undefined && query.isError && isForbiddenError(query.error);
179
+ // A host with `errorNote` draws the read's failure itself, a 403 included.
180
+ const refused = errorNote === undefined && query.isError && isForbiddenError(query.error);
166
181
  const phase = phaseOf(refused, query.isError, unlisted, empty);
167
- return { phase, available, stored, pending: !loaded && !query.isError, unlisted, empty };
182
+ return { phase, stored, pending: !loaded && !query.isError, unlisted, empty };
168
183
  }
169
184
 
170
185
  /** The distinct tags across `names`, sorted, feeding the filter control. */
@@ -179,21 +194,31 @@ function tagsAcross(
179
194
 
180
195
  /** The tag filter's choices, and the available names the chosen tag keeps. */
181
196
  function tagView(
182
- state: PickerState,
197
+ available: readonly string[],
198
+ listed: boolean,
183
199
  tagsByTool: Readonly<Record<string, readonly string[]>> | undefined,
184
200
  tagFilter: string,
185
201
  ): { readonly allTags: readonly string[]; readonly filtered: readonly string[] } {
186
- if (tagsByTool === undefined) return { allTags: [], filtered: state.available };
187
- const listed = !state.pending && state.phase !== 'error' && state.phase !== 'refused';
202
+ if (tagsByTool === undefined) return { allTags: [], filtered: available };
188
203
  return {
189
- allTags: listed ? tagsAcross(state.available, tagsByTool) : [],
204
+ allTags: listed ? tagsAcross(available, tagsByTool) : [],
190
205
  filtered:
191
206
  tagFilter === ALL_TAGS
192
- ? state.available
193
- : state.available.filter((name) => (tagsByTool[name] ?? []).includes(tagFilter)),
207
+ ? available
208
+ : available.filter((name) => (tagsByTool[name] ?? []).includes(tagFilter)),
194
209
  };
195
210
  }
196
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
+
197
222
  function MutedLine({ text }: { readonly text: string }): ReactNode {
198
223
  return (
199
224
  <p role="status" style={MUTED_LINE}>
@@ -220,7 +245,6 @@ export function ToolPicker({
220
245
  emptyNote,
221
246
  emptyAction,
222
247
  errorNote,
223
- forbiddenNote,
224
248
  unlistedLabel,
225
249
  disabled,
226
250
  placeholder = 'Select a tool…',
@@ -237,12 +261,26 @@ export function ToolPicker({
237
261
  }: ToolPickerProps) {
238
262
  const [tagFilter, setTagFilter] = useState<string>(ALL_TAGS);
239
263
  const unlistedId = useId();
240
- const state = pickerState(query, value, excludeNames, forbiddenNote, unlistedLabel);
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);
241
271
  const { phase, stored } = state;
242
272
 
243
- const { allTags, filtered } = tagView(state, tagsByTool, tagFilter);
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
+ );
244
278
 
245
- const select = (options: readonly string[], leading: SelectOption | undefined): ReactNode => (
279
+ const leading = useMemo(
280
+ () => leadingOption(phase, stored, unlistedLabel),
281
+ [phase, stored, unlistedLabel],
282
+ );
283
+ const select = (options: readonly string[]): ReactNode => (
246
284
  <ToolSelect
247
285
  tagsByTool={tagsByTool}
248
286
  filtered={options}
@@ -264,26 +302,23 @@ export function ToolPicker({
264
302
  </>
265
303
  );
266
304
 
267
- // Only a rendered `Select` claims the field's control id; with none the host's
305
+ // Only a rendered control claims the field's control id; with none the host's
268
306
  // error is hidden and the note in its place says why the field is empty.
269
- const selectRenders =
270
- phase === 'unlisted' || phase === 'select' || (phase === 'refused' && stored !== null);
307
+ const controlRenders = phase === 'unlisted' || phase === 'select' || phase === 'refused';
271
308
  const contentByPhase: Record<PickerPhase, () => ReactNode> = {
272
- // A stored value stays as the sole, unflagged option: without the list the form claims nothing about it.
273
309
  refused: () => (
274
- <>
275
- {stored !== null ? select([], { value: stored, label: stored }) : null}
276
- <MutedLine text={forbiddenNote ?? ''} />
277
- </>
310
+ <ToolNameEntry
311
+ value={stored}
312
+ onChange={onChange}
313
+ disabled={disabled === true}
314
+ ariaLabel={ariaLabel}
315
+ />
278
316
  ),
279
317
  error: () =>
280
318
  errorNote !== undefined ? <MutedLine text={errorNote} /> : <ReadError query={query} />,
281
319
  unlisted: () => (
282
320
  <>
283
- {select(filtered, {
284
- value: stored ?? '',
285
- label: `${stored ?? ''} (${unlistedLabel ?? ''})`,
286
- })}
321
+ {select(filtered)}
287
322
  {state.empty ? emptyState : null}
288
323
  <p id={unlistedId} className="tai-field-hint" style={{ margin: 0 }}>
289
324
  <Badge variant="warning">{unlistedLabel}</Badge> {stored} is {unlistedLabel}. Pick
@@ -292,7 +327,7 @@ export function ToolPicker({
292
327
  </>
293
328
  ),
294
329
  empty: () => emptyState,
295
- select: () => select(filtered, undefined),
330
+ select: () => select(filtered),
296
331
  };
297
332
  const content = contentByPhase[phase]();
298
333
 
@@ -310,8 +345,8 @@ export function ToolPicker({
310
345
  <Field
311
346
  label={label}
312
347
  description={description}
313
- error={selectRenders ? error : undefined}
314
- group={!selectRenders}
348
+ error={controlRenders ? error : undefined}
349
+ group={!controlRenders}
315
350
  >
316
351
  {content}
317
352
  </Field>
@@ -0,0 +1,136 @@
1
+ /**
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`).
5
+ */
6
+ import { type ReactNode, useMemo } from 'react';
7
+
8
+ import { toolDisplayLabel } from '../hooks/useToolDisplayNames';
9
+ import { SearchableSelect } from './searchable-select';
10
+ import type { SelectGroup, SelectOption } from './select';
11
+
12
+ const NO_DISPLAY_NAMES: Readonly<Record<string, string>> = {};
13
+ const UNTAGGED = 'Untagged';
14
+ const SEARCH_LABEL = 'Search tools';
15
+
16
+ /** The group a tool sorts into: its alphabetically-first tag, else "Untagged". */
17
+ function groupFor(name: string, tagsByTool: Readonly<Record<string, readonly string[]>>): string {
18
+ const tags = [...(tagsByTool[name] ?? [])].sort((a, b) => a.localeCompare(b));
19
+ return tags[0] ?? UNTAGGED;
20
+ }
21
+
22
+ /**
23
+ * The option label: `Display Name (raw)` when a distinct non-empty display name maps
24
+ * the name, else the bare name; either way suffixed " (agent)" for an agent run tool.
25
+ */
26
+ function optionLabel(
27
+ name: string,
28
+ displayNames: Readonly<Record<string, string>> | undefined,
29
+ agentToolNames: ReadonlySet<string> | undefined,
30
+ ): string {
31
+ const base = toolDisplayLabel(displayNames ?? NO_DISPLAY_NAMES, name);
32
+ return agentToolNames?.has(name) === true ? `${base} (agent)` : base;
33
+ }
34
+
35
+ /** Build the grouped option clusters (sorted group labels, "Untagged" last). */
36
+ function buildGroups(
37
+ names: readonly string[],
38
+ tagsByTool: Readonly<Record<string, readonly string[]>>,
39
+ displayNames: Readonly<Record<string, string>> | undefined,
40
+ agentToolNames: ReadonlySet<string> | undefined,
41
+ ): SelectGroup[] {
42
+ const byGroup = new Map<string, string[]>();
43
+ for (const name of names) {
44
+ const group = groupFor(name, tagsByTool);
45
+ const bucket = byGroup.get(group) ?? [];
46
+ bucket.push(name);
47
+ byGroup.set(group, bucket);
48
+ }
49
+ return [...byGroup.entries()]
50
+ .sort(([a], [b]) => {
51
+ if (a === UNTAGGED) return 1;
52
+ if (b === UNTAGGED) return -1;
53
+ return a.localeCompare(b);
54
+ })
55
+ .map(([label, groupNames]) => ({
56
+ label,
57
+ options: groupNames.map((name) => ({
58
+ value: name,
59
+ label: optionLabel(name, displayNames, agentToolNames),
60
+ })),
61
+ }));
62
+ }
63
+
64
+ interface ToolSelectProps {
65
+ readonly tagsByTool: Readonly<Record<string, readonly string[]>> | undefined;
66
+ readonly filtered: readonly string[];
67
+ readonly value: string | null;
68
+ readonly onChange: (toolName: string) => void;
69
+ readonly placeholder: string;
70
+ readonly ariaLabel: string | undefined;
71
+ readonly disabled: boolean | undefined;
72
+ readonly displayNames: Readonly<Record<string, string>> | undefined;
73
+ readonly agentToolNames: ReadonlySet<string> | undefined;
74
+ /** An option drawn ahead of the list's own (a stored value the list does not carry). */
75
+ readonly leading: SelectOption | undefined;
76
+ readonly ariaDescribedBy: string | undefined;
77
+ }
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
+
102
+ /** The tool select: grouped by tag when `tagsByTool` is present, else a flat list.
103
+ * Either branch is a `SearchableSelect`, which claims the enclosing `Field`'s control id. */
104
+ export function ToolSelect({
105
+ tagsByTool,
106
+ filtered,
107
+ value,
108
+ onChange,
109
+ placeholder,
110
+ ariaLabel,
111
+ disabled,
112
+ displayNames,
113
+ agentToolNames,
114
+ leading,
115
+ ariaDescribedBy,
116
+ }: ToolSelectProps): ReactNode {
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} />
135
+ );
136
+ }
@@ -3,7 +3,8 @@
3
3
  * author `config` (`{ name, config }`) alongside bare-name elements. The editors
4
4
  * hold the full config-bearing elements as their value, so config rides through an
5
5
  * edit directly; these helpers project a combo to its bare extension names where a
6
- * name-only view is needed (a picker's selection, a branch-tool name, a badge row).
6
+ * name-only view is needed (a picker's selection, a branch-tool name, a badge row)
7
+ * and compare or check combos by those names.
7
8
  */
8
9
  import type { PresetExtensionElement } from '@tai42/api-client';
9
10
 
@@ -16,3 +17,16 @@ export function extensionElementName(element: PresetExtensionElement): string {
16
17
  export function comboElementNames(combo: readonly PresetExtensionElement[]): string[] {
17
18
  return combo.map(extensionElementName);
18
19
  }
20
+
21
+ /** Two combos are equal when their NAME sequences match (config is not part of identity). */
22
+ export function sameNames(a: readonly string[], b: readonly string[]): boolean {
23
+ return a.length === b.length && a.every((name, index) => name === b[index]);
24
+ }
25
+
26
+ /** The element names in a combo that are absent from the known catalog. */
27
+ export function unknownNames(
28
+ combo: readonly PresetExtensionElement[],
29
+ availableNames: ReadonlySet<string>,
30
+ ): string[] {
31
+ return comboElementNames(combo).filter((name) => !availableNames.has(name));
32
+ }
@@ -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 — the reactive `isFeatureDisabled` swallow stays as the backstop for a
11
- * stale table.
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
- const SystemKindsContext = createContext<SystemKindsState | null>(null);
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
- return createElement(SystemKindsContext.Provider, { value: state }, children);
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 reported OFF by the kind-status table — the single proactive
87
- * predicate a write affordance folds in to hide itself before a doomed write. It is
88
- * `true` ONLY once the table is `ready` AND that kind's row carries `state: 'off'`;
89
- * while the table is loading/failed, no provider is mounted, or the kind is unknown,
90
- * it is `false` (treat as not-off until known — under-hiding is safe, the reactive
91
- * 501 backstop still catches a genuinely-off feature).
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. `null` whenever the
103
- * kind is not reported OFF (table loading/failed, no provider, unknown kind, or the
104
- * kind is on), mirroring `useFeatureOff`'s not-off-until-known default.
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
- if (state.status !== 'ready') return null;
109
- const row = state.kinds.find((entry) => entry.kind === kind && entry.state === 'off');
110
- return row ? row.detail : null;
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
  }