@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,109 @@
1
+ /**
2
+ * Filing ONE tool into a folder of the tool-organization tree — for a screen whose
3
+ * rows have no edit dialog of their own to carry a folder field. The folder is part
4
+ * of the tool's overlay row, so the write is a single `upsertToolMeta` merge-patch of
5
+ * `folder_id`; the tool's display name, tags and visibility are left untouched.
6
+ *
7
+ * The picker offers the FULL tree with inline create. The write resolves only after
8
+ * the screen's `onFoldersChanged` refresh, and a failure is shown in the dialog — a
9
+ * not-configured refusal as the server's off note, reported so every screen withdraws
10
+ * its overlay writes.
11
+ */
12
+ import { type ReactNode, useState } from 'react';
13
+
14
+ import { useApi } from '../hooks/useApi';
15
+ import { Dialog } from './dialog';
16
+ import { Field } from './field';
17
+ import { type FoldersChanged, useCreateFolder } from './folder-actions';
18
+ import { type Folder, FolderPicker } from './folder-nav';
19
+ import { Button, Spinner } from './primitives';
20
+ import { ToolMetaWriteError, useReportToolMetaRefusal } from './tool-meta-access';
21
+
22
+ /** Props of {@link MoveToFolderDialog}. */
23
+ export interface MoveToFolderDialogProps {
24
+ /** The tool whose overlay row is filed (its registered tool name). */
25
+ readonly toolName: string;
26
+ /** What the dialog title calls the item (e.g. the agent's name). */
27
+ readonly label: string;
28
+ /** The tool's current folder (`null` = unfiled). */
29
+ readonly currentFolderId: string | null;
30
+ /** The FULL folder tree, offered as destinations. */
31
+ readonly folders: readonly Folder[];
32
+ /** The screen's refresh of its overlay read, awaited after the write. */
33
+ readonly onFoldersChanged: FoldersChanged;
34
+ /** Close the dialog (Cancel, dismiss, or after a successful move). */
35
+ readonly onClose: () => void;
36
+ }
37
+
38
+ /** A dialog that files one tool into a folder (or unfiles it). Mount while open. */
39
+ export function MoveToFolderDialog({
40
+ toolName,
41
+ label,
42
+ currentFolderId,
43
+ folders,
44
+ onFoldersChanged,
45
+ onClose,
46
+ }: MoveToFolderDialogProps): ReactNode {
47
+ const api = useApi();
48
+ const createFolder = useCreateFolder(onFoldersChanged);
49
+ const reportRefusal = useReportToolMetaRefusal();
50
+ const [folderId, setFolderId] = useState<string | null>(currentFolderId);
51
+ const [pending, setPending] = useState(false);
52
+ const [error, setError] = useState<unknown>(null);
53
+
54
+ const save = (): void => {
55
+ if (pending) return;
56
+ setPending(true);
57
+ setError(null);
58
+ api
59
+ .upsertToolMeta(toolName, { folder_id: folderId })
60
+ .then(() => onFoldersChanged())
61
+ .then(
62
+ () => {
63
+ setPending(false);
64
+ onClose();
65
+ },
66
+ (failure: unknown) => {
67
+ setError(failure);
68
+ setPending(false);
69
+ reportRefusal(failure);
70
+ },
71
+ );
72
+ };
73
+
74
+ return (
75
+ <Dialog
76
+ title={`Move ${label} to a folder`}
77
+ open
78
+ onOpenChange={(next) => {
79
+ if (!next) onClose();
80
+ }}
81
+ >
82
+ <Field label="Destination folder" description="Choose a folder, or no folder." group>
83
+ <FolderPicker
84
+ value={folderId}
85
+ onChange={setFolderId}
86
+ folders={folders}
87
+ onCreateFolder={createFolder}
88
+ unfiledLabel="No folder"
89
+ disabled={pending}
90
+ />
91
+ </Field>
92
+ <ToolMetaWriteError error={error} />
93
+ <div className="tai-dialog-actions">
94
+ <Button type="button" onClick={onClose}>
95
+ Cancel
96
+ </Button>
97
+ <Button
98
+ type="button"
99
+ variant="primary"
100
+ disabled={folderId === currentFolderId || pending}
101
+ onClick={save}
102
+ >
103
+ {pending ? <Spinner label="Moving" /> : null}
104
+ Move
105
+ </Button>
106
+ </div>
107
+ </Dialog>
108
+ );
109
+ }
@@ -11,14 +11,17 @@
11
11
  *
12
12
  * `FolderPicker` carries inline new-folder creation, so a folder can be created from
13
13
  * EVERY consuming screen through this one component — never one screen's own
14
- * affordance.
14
+ * affordance. A failed creation is shown loudly under the draft by the picker
15
+ * itself, so every screen that files through it surfaces the failure the same way.
15
16
  */
17
+ import type { FolderRecord } from '@tai42/api-client';
16
18
  import { type ReactNode, useState } from 'react';
17
19
 
18
20
  import { ChevronRightIcon, FolderIcon } from './icons';
19
21
  import { TextInput } from './inputs';
20
22
  import { Button } from './primitives';
21
23
  import { Select } from './select';
24
+ import { ToolMetaWriteError } from './tool-meta-access';
22
25
 
23
26
  /** A folder in the tree, in the SDK's camelCase UI shape (`parentId`, `null` = root). */
24
27
  export interface Folder {
@@ -27,6 +30,15 @@ export interface Folder {
27
30
  readonly parentId: string | null;
28
31
  }
29
32
 
33
+ /** Map the overlay's wire folder records into the UI {@link Folder} shape. */
34
+ export function toFolders(records: readonly FolderRecord[]): Folder[] {
35
+ return records.map((record) => ({
36
+ id: record.id,
37
+ name: record.name,
38
+ parentId: record.parent_id,
39
+ }));
40
+ }
41
+
30
42
  /** The subfolders directly under `parentId` (`null` = the root directory), name-sorted. */
31
43
  export function childFolders(folders: readonly Folder[], parentId: string | null): Folder[] {
32
44
  return folders
@@ -143,10 +155,11 @@ export interface FolderPickerProps {
143
155
  readonly folders: readonly Folder[];
144
156
  /**
145
157
  * 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.
158
+ * id; the picker selects it. On a rejection the picker shows the error and keeps
159
+ * the draft in place so the operator can retry. Without it the picker offers no
160
+ * create — the caller may not create folders.
148
161
  */
149
- readonly onCreateFolder: (name: string, parentId: string | null) => Promise<string>;
162
+ readonly onCreateFolder?: (name: string, parentId: string | null) => Promise<string>;
150
163
  readonly disabled?: boolean;
151
164
  /** The unfiled option's label (e.g. "No folder"). */
152
165
  readonly unfiledLabel: string;
@@ -164,22 +177,23 @@ export function FolderPicker({
164
177
  const [creating, setCreating] = useState(false);
165
178
  const [draft, setDraft] = useState('');
166
179
  const [busy, setBusy] = useState(false);
180
+ const [createError, setCreateError] = useState<unknown>(null);
167
181
 
168
182
  const options = [{ value: UNFILED_VALUE, label: unfiledLabel }, ...folderOptions(folders)];
169
183
 
170
184
  const create = async (): Promise<void> => {
171
185
  const name = draft.trim();
172
- if (name === '' || busy) return;
186
+ if (name === '' || busy || onCreateFolder === undefined) return;
173
187
  setBusy(true);
188
+ setCreateError(null);
174
189
  try {
175
190
  const id = await onCreateFolder(name, value);
176
191
  onChange(id);
177
192
  setDraft('');
178
193
  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.
194
+ } catch (error: unknown) {
195
+ // Shown under the draft, which stays OPEN with its text for a retry.
196
+ setCreateError(error);
183
197
  } finally {
184
198
  setBusy(false);
185
199
  }
@@ -196,7 +210,7 @@ export function FolderPicker({
196
210
  }}
197
211
  disabled={disabled ?? busy}
198
212
  />
199
- {creating ? (
213
+ {onCreateFolder === undefined ? null : creating ? (
200
214
  <div className="tai-row">
201
215
  <div style={{ flex: 1 }}>
202
216
  <TextInput
@@ -231,6 +245,7 @@ export function FolderPicker({
231
245
  onClick={() => {
232
246
  setCreating(false);
233
247
  setDraft('');
248
+ setCreateError(null);
234
249
  }}
235
250
  disabled={busy}
236
251
  >
@@ -250,6 +265,7 @@ export function FolderPicker({
250
265
  <span>New folder</span>
251
266
  </Button>
252
267
  )}
268
+ <ToolMetaWriteError error={createError} />
253
269
  </div>
254
270
  );
255
271
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Which folders of the ONE tool-organization tree a screen draws. Every tool has one
3
+ * folder; a screen lists only its own population (a subset of the tools), so it draws
4
+ * only the part of the tree that concerns that population:
5
+ *
6
+ * - a folder whose subtree holds at least one of the screen's items is drawn;
7
+ * - a folder whose subtree holds no tool at all is drawn (a fresh folder belongs to
8
+ * every screen until something is filed in it);
9
+ * - a folder whose subtree holds only other tools is not drawn;
10
+ * - the ancestors of a drawn folder are drawn, so every drawn folder stays reachable
11
+ * from the root.
12
+ *
13
+ * Occupancy counts EVERY overlay row with a `folder_id` — hidden tools and rows of
14
+ * tools no longer registered included — so a folder never reads as empty while it
15
+ * still holds something. A screen whose population is every tool draws the whole tree.
16
+ */
17
+ import type { ToolMetaRecord } from '@tai42/api-client';
18
+
19
+ import type { Folder } from './folder-nav';
20
+
21
+ /** What a folder's subtree holds, and whether the folder is drawn. */
22
+ interface SubtreeState {
23
+ readonly own: boolean;
24
+ readonly any: boolean;
25
+ readonly drawn: boolean;
26
+ }
27
+
28
+ const NOTHING: SubtreeState = { own: false, any: false, drawn: false };
29
+
30
+ /**
31
+ * The folders a screen draws, in the input order.
32
+ *
33
+ * `rows` are the overlay rows (each tool's `folder_id`), `population` the tool names
34
+ * the screen lists. Linear in folders + rows; the subtree walk is cycle-guarded, so a
35
+ * malformed parent chain yields a bounded result rather than looping.
36
+ */
37
+ export function foldersForView(
38
+ folders: readonly Folder[],
39
+ rows: readonly Pick<ToolMetaRecord, 'tool_name' | 'folder_id'>[],
40
+ population: ReadonlySet<string>,
41
+ ): Folder[] {
42
+ const direct = new Map<string, { own: boolean; any: boolean }>();
43
+ for (const row of rows) {
44
+ if (row.folder_id === null) continue;
45
+ const entry = direct.get(row.folder_id) ?? { own: false, any: false };
46
+ entry.any = true;
47
+ if (population.has(row.tool_name)) entry.own = true;
48
+ direct.set(row.folder_id, entry);
49
+ }
50
+
51
+ const childrenByParent = new Map<string, string[]>();
52
+ for (const folder of folders) {
53
+ if (folder.parentId === null) continue;
54
+ const siblings = childrenByParent.get(folder.parentId) ?? [];
55
+ siblings.push(folder.id);
56
+ childrenByParent.set(folder.parentId, siblings);
57
+ }
58
+
59
+ const settled = new Map<string, SubtreeState>();
60
+ const visiting = new Set<string>();
61
+ const stateOf = (id: string): SubtreeState => {
62
+ const cached = settled.get(id);
63
+ if (cached !== undefined) return cached;
64
+ if (visiting.has(id)) return NOTHING;
65
+ visiting.add(id);
66
+ const held = direct.get(id);
67
+ let own = held?.own ?? false;
68
+ let any = held?.any ?? false;
69
+ let childDrawn = false;
70
+ for (const childId of childrenByParent.get(id) ?? []) {
71
+ const child = stateOf(childId);
72
+ own = own || child.own;
73
+ any = any || child.any;
74
+ childDrawn = childDrawn || child.drawn;
75
+ }
76
+ visiting.delete(id);
77
+ const state = { own, any, drawn: own || !any || childDrawn };
78
+ settled.set(id, state);
79
+ return state;
80
+ };
81
+
82
+ return folders.filter((folder) => stateOf(folder.id).drawn);
83
+ }
@@ -1,10 +1,10 @@
1
1
  /**
2
- * The single-select both `AgentPicker` and `ChannelPicker` render: one list read of
3
- * names, drawn in its own `Field` with the read's states (pending, error with Retry,
2
+ * The searchable single-select both `AgentPicker` and `ChannelPicker` render: one list
3
+ * read of names, drawn in its own `Field` with the read's states (pending, error with Retry,
4
4
  * refused on a 403, loaded and empty) and a stored value the loaded list does not
5
5
  * hold flagged with the picker's unlisted label. Each picker hands in its own copy.
6
6
  * The host's `error` shows, and the `Field` claims its control id, exactly while a
7
- * `Select` renders.
7
+ * select renders.
8
8
  */
9
9
  import { type ReactNode, useId } from 'react';
10
10
 
@@ -12,7 +12,8 @@ import { errorMessage, isForbiddenError } from '../errors';
12
12
  import { Badge } from './badge';
13
13
  import { Field } from './field';
14
14
  import { ErrorState } from './primitives';
15
- import { Select, type SelectOption } from './select';
15
+ import { SearchableSelect } from './searchable-select';
16
+ import type { SelectOption } from './select';
16
17
 
17
18
  const MUTED_LINE = { margin: 0, color: 'var(--tai-color-text-muted)' } as const;
18
19
 
@@ -35,6 +36,8 @@ export interface NamedListCopy {
35
36
  readonly emptyNote: string;
36
37
  readonly emptyAction: ReactNode;
37
38
  readonly unlistedLabel: string;
39
+ /** Accessible name of the select's search box. */
40
+ readonly searchLabel: string;
38
41
  }
39
42
 
40
43
  export interface NamedListPickerProps {
@@ -107,7 +110,7 @@ function namedListView(
107
110
  return { refused, failed, pending, unlisted, empty, options, selectRenders };
108
111
  }
109
112
 
110
- /** The loaded, pending or refused body: the `Select` while one renders, then its notes. */
113
+ /** The loaded, pending or refused body: the select while one renders, then its notes. */
111
114
  function NamedListBody({
112
115
  view,
113
116
  value,
@@ -123,13 +126,14 @@ function NamedListBody({
123
126
  return (
124
127
  <>
125
128
  {view.selectRenders ? (
126
- <Select
129
+ <SearchableSelect
127
130
  options={view.options}
128
131
  value={value}
129
132
  placeholder={view.pending ? copy.loadingPlaceholder : copy.placeholder}
130
133
  disabled={view.pending}
131
134
  onValueChange={onValueChange}
132
135
  aria-describedby={view.unlisted ? unlistedId : undefined}
136
+ searchLabel={copy.searchLabel}
133
137
  />
134
138
  ) : null}
135
139
  {view.refused ? <MutedLine text={copy.forbiddenNote} /> : null}
@@ -163,7 +167,7 @@ export function NamedListPicker({
163
167
  label={copy.label}
164
168
  description={copy.description}
165
169
  error={view.selectRenders ? error : undefined}
166
- // Only the Select claims the field's control id; with nothing to hold no
170
+ // Only the select claims the field's control id; with nothing to hold no
167
171
  // labelable element renders, so `for` would dangle.
168
172
  group={!view.selectRenders}
169
173
  >
@@ -68,7 +68,8 @@ function normalizeHref(href: string): string {
68
68
  * evil.com.
69
69
  *
70
70
  * Callers render the returned string rather than their input, so the URL that was
71
- * judged is always the URL that is navigated.
71
+ * judged is always the URL that is navigated. `isSafeHttpUrl` gives the yes/no
72
+ * answer for a caller that does not need the URL itself.
72
73
  */
73
74
  export function safeHttpUrl(href: string): string | undefined {
74
75
  const normalized = normalizeHref(href);
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The option model behind `SearchableSelect`: the caller's options indexed once for
3
+ * search, the sections a query leaves, and the keyboard's movement over them. Pure,
4
+ * so the component re-derives nothing per row and the filter is testable alone.
5
+ */
6
+ import { searchNeedle, textHasNeedle } from '../search-match';
7
+ import type { SelectGroup, SelectOption } from './select';
8
+
9
+ /** One option, with its position in the full list and its label in search form. */
10
+ export interface SearchEntry {
11
+ readonly option: SelectOption;
12
+ /** The option's position across every section; stable while the reader filters. */
13
+ readonly position: number;
14
+ readonly loweredLabel: string;
15
+ }
16
+
17
+ /** A run of entries under one group label, or under none for a flat list. */
18
+ export interface SearchSection {
19
+ readonly label: string | undefined;
20
+ readonly entries: readonly SearchEntry[];
21
+ }
22
+
23
+ /** What a query leaves: the non-empty sections and their entries in reading order. */
24
+ export interface VisibleOptions {
25
+ readonly sections: readonly SearchSection[];
26
+ readonly entries: readonly SearchEntry[];
27
+ }
28
+
29
+ /** Index the caller's options (a flat run, or labelled groups) for search. */
30
+ export function indexSections(
31
+ options: readonly SelectOption[] | undefined,
32
+ groups: readonly SelectGroup[] | undefined,
33
+ ): SearchSection[] {
34
+ const runs: { label: string | undefined; options: readonly SelectOption[] }[] =
35
+ groups !== undefined
36
+ ? groups.map((group) => ({ label: group.label, options: group.options }))
37
+ : [{ label: undefined, options: options ?? [] }];
38
+ let position = 0;
39
+ return runs.map((run) => ({
40
+ label: run.label,
41
+ entries: run.options.map((option) => ({
42
+ option,
43
+ position: position++,
44
+ loweredLabel: option.label.toLowerCase(),
45
+ })),
46
+ }));
47
+ }
48
+
49
+ /**
50
+ * The sections and entries whose label contains `query`. A blank query returns every
51
+ * section as indexed; either way each kept entry is the indexed object itself, so a
52
+ * row memoised on its entry does not re-render because the reader typed.
53
+ */
54
+ export function visibleOptions(sections: readonly SearchSection[], query: string): VisibleOptions {
55
+ const needle = searchNeedle(query);
56
+ const kept =
57
+ needle === ''
58
+ ? sections
59
+ : sections.map((section) => ({
60
+ label: section.label,
61
+ entries: section.entries.filter((entry) => textHasNeedle(entry.loweredLabel, needle)),
62
+ }));
63
+ const nonEmpty = kept.filter((section) => section.entries.length > 0);
64
+ return { sections: nonEmpty, entries: nonEmpty.flatMap((section) => section.entries) };
65
+ }
66
+
67
+ function enabled(entry: SearchEntry): boolean {
68
+ return entry.option.disabled !== true;
69
+ }
70
+
71
+ /**
72
+ * The entry the keyboard highlight moves to from `current` by one step in
73
+ * `direction`, skipping disabled entries and stopping at either end. With no current
74
+ * highlight among `entries`, a step lands on the first (down) or last (up) one.
75
+ */
76
+ export function stepEntry(
77
+ entries: readonly SearchEntry[],
78
+ current: SearchEntry | undefined,
79
+ direction: 1 | -1,
80
+ ): SearchEntry | undefined {
81
+ const at = current === undefined ? -1 : entries.indexOf(current);
82
+ if (at === -1) return direction === 1 ? entries.find(enabled) : entries.findLast(enabled);
83
+ for (let index = at + direction; index >= 0 && index < entries.length; index += direction) {
84
+ const entry = entries[index];
85
+ if (entry !== undefined && enabled(entry)) return entry;
86
+ }
87
+ return current;
88
+ }
89
+
90
+ /**
91
+ * The entry to draw highlighted: the choosable visible entry whose value is `wanted`,
92
+ * else the first choosable one. Keyed by value, so the highlight stays on its option
93
+ * when the list around it changes, and a query that filters it away moves it to the
94
+ * top of what is left instead of to nothing.
95
+ */
96
+ export function shownEntry(
97
+ entries: readonly SearchEntry[],
98
+ wanted: string | undefined,
99
+ ): SearchEntry | undefined {
100
+ const kept =
101
+ wanted === undefined
102
+ ? undefined
103
+ : entries.find((entry) => entry.option.value === wanted && enabled(entry));
104
+ return kept ?? entries.find(enabled);
105
+ }