@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
@@ -122,6 +122,7 @@ export interface ExplorerBodyProps<T> {
122
122
  readonly columns: readonly ExplorerColumn[];
123
123
  readonly pageEntries: readonly ExplorerEntry<T>[];
124
124
  readonly getItemKey: (item: T) => string;
125
+ readonly selectedItemKey: string | undefined;
125
126
  readonly renderRow: (item: T) => ReactNode;
126
127
  readonly renderCard: (item: T) => ReactNode;
127
128
  readonly openProps: (item: T) => OpenTargetProps;
@@ -134,7 +135,15 @@ export interface ExplorerBodyProps<T> {
134
135
  function CardEntry<T>(
135
136
  props: ExplorerBodyProps<T> & { readonly entry: ExplorerEntry<T> },
136
137
  ): ReactNode {
137
- const { entry, onNavigate, renderFolderActions, getItemKey, renderCard, openProps } = props;
138
+ const {
139
+ entry,
140
+ onNavigate,
141
+ renderFolderActions,
142
+ getItemKey,
143
+ selectedItemKey,
144
+ renderCard,
145
+ openProps,
146
+ } = props;
138
147
  if (entry.kind === 'folder') {
139
148
  return (
140
149
  <div role="listitem" key={`folder:${entry.folder.id}`}>
@@ -148,8 +157,14 @@ function CardEntry<T>(
148
157
  </div>
149
158
  );
150
159
  }
160
+ const key = getItemKey(entry.item);
151
161
  return (
152
- <div role="listitem" key={`item:${getItemKey(entry.item)}`} {...openProps(entry.item)}>
162
+ <div
163
+ role="listitem"
164
+ key={`item:${key}`}
165
+ data-selected={key === selectedItemKey ? 'true' : undefined}
166
+ {...openProps(entry.item)}
167
+ >
153
168
  {renderCard(entry.item)}
154
169
  </div>
155
170
  );
@@ -159,8 +174,16 @@ function CardEntry<T>(
159
174
  function RowEntry<T>(
160
175
  props: ExplorerBodyProps<T> & { readonly entry: ExplorerEntry<T> },
161
176
  ): ReactNode {
162
- const { entry, columns, onNavigate, renderFolderActions, getItemKey, renderRow, openProps } =
163
- props;
177
+ const {
178
+ entry,
179
+ columns,
180
+ onNavigate,
181
+ renderFolderActions,
182
+ getItemKey,
183
+ selectedItemKey,
184
+ renderRow,
185
+ openProps,
186
+ } = props;
164
187
  if (entry.kind === 'folder') {
165
188
  return (
166
189
  <TR key={`folder:${entry.folder.id}`}>
@@ -174,8 +197,9 @@ function RowEntry<T>(
174
197
  </TR>
175
198
  );
176
199
  }
200
+ const key = getItemKey(entry.item);
177
201
  return (
178
- <TR key={`item:${getItemKey(entry.item)}`} {...openProps(entry.item)}>
202
+ <TR key={`item:${key}`} selected={key === selectedItemKey} {...openProps(entry.item)}>
179
203
  {renderRow(entry.item)}
180
204
  </TR>
181
205
  );
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { type ReactNode, useEffect, useState } from 'react';
16
16
 
17
+ import { matchesSearch } from '../search-match';
17
18
  import { ExplorerBody, type ExplorerEntry } from './explorer-body';
18
19
  import { usePageSize } from './explorer-page-size';
19
20
  import { TagFilterRow } from './explorer-tag-filter';
@@ -50,12 +51,13 @@ export interface ExplorerTags<T> {
50
51
  readonly maxVisibleTags?: number;
51
52
  }
52
53
 
53
- /** Controlled free-text search. `matches` owns the item shape and casing; it is
54
- * called only with a non-empty, already-trimmed query. */
54
+ /** Controlled free-text search. The caller names the texts an item is searched by;
55
+ * the explorer matches them with the SDK's one search rule (`matchesSearch`). */
55
56
  export interface ExplorerSearch<T> {
56
57
  readonly value: string;
57
58
  readonly onChange: (value: string) => void;
58
- readonly matches: (item: T, query: string) => boolean;
59
+ /** The texts the reader sees for `item` (its name, display name, description…). */
60
+ readonly texts: (item: T) => readonly string[];
59
61
  /** The input's accessible name. */
60
62
  readonly label: string;
61
63
  readonly placeholder?: string;
@@ -69,10 +71,11 @@ export interface ExplorerEmptyState {
69
71
 
70
72
  /** The folder-aware empty-state ladder — every label caller-supplied. */
71
73
  export interface ExplorerEmptyStates {
72
- /** No items and no folders exist at all. */
74
+ /** No item exists at all. Folders are places for items, so with no item there is
75
+ * nothing to browse: this state shows whatever folders exist. */
73
76
  readonly empty: ExplorerEmptyState;
74
- /** The current folder holds nothing — no items, no subfolders — yet the
75
- * entity set is not globally empty. */
77
+ /** The current folder holds nothing — no items, no subfolders — while items
78
+ * exist elsewhere. */
76
79
  readonly emptyFolder: ExplorerEmptyState;
77
80
  /** The current folder has items but the active tag/search filter excludes
78
81
  * every one, and it has no subfolders to show. */
@@ -86,6 +89,8 @@ export interface ExplorerViewProps<T> {
86
89
  readonly getFolderId: (item: T) => string | null;
87
90
 
88
91
  readonly folders: readonly Folder[];
92
+ /** The open folder. One that is not among `folders` (deleted, or no longer drawn
93
+ * on this screen) shows as the root, and the explorer asks `onNavigate(null)`. */
89
94
  readonly currentFolderId: string | null;
90
95
  readonly onNavigate: (folderId: string | null) => void;
91
96
  readonly rootLabel: string;
@@ -107,6 +112,12 @@ export interface ExplorerViewProps<T> {
107
112
  * originating on a nested control is left to that control. */
108
113
  readonly onOpenItem?: (item: T) => void;
109
114
 
115
+ /** The key (as `getItemKey` gives it) of the item the screen has selected. Its
116
+ * row/card carries `data-selected="true"`, which the stylesheet draws as the one
117
+ * selected look every explorer shares. The screen still marks the item's name
118
+ * link `aria-current`, which is what assistive tech reads. */
119
+ readonly selectedItemKey?: string;
120
+
110
121
  readonly tags?: ExplorerTags<T>;
111
122
  readonly search?: ExplorerSearch<T>;
112
123
 
@@ -183,19 +194,35 @@ function filterInFolder<T>(
183
194
  tags: ExplorerTags<T> | undefined,
184
195
  selectedSet: ReadonlySet<string> | undefined,
185
196
  search: ExplorerSearch<T> | undefined,
186
- query: string,
187
197
  ): readonly T[] {
188
198
  return inFolder.filter((item) => {
189
199
  if (tags !== undefined && selectedSet !== undefined && selectedSet.size > 0) {
190
200
  if (!matchesSelectedTags(tags.getTags(item), selectedSet)) return false;
191
201
  }
192
- if (search !== undefined && query !== '') {
193
- if (!search.matches(item, query)) return false;
194
- }
202
+ if (search !== undefined && !matchesSearch(search.value, ...search.texts(item))) return false;
195
203
  return true;
196
204
  });
197
205
  }
198
206
 
207
+ /**
208
+ * The folder the explorer shows. The open folder can stop being drawn while the user
209
+ * is inside it (deleted, or its last item on this screen filed elsewhere); the
210
+ * explorer then shows the root, so the breadcrumb and the body agree, and hands the
211
+ * root back to the screen's navigation state. One linear pass over the drawn folders.
212
+ */
213
+ function useShownFolderId(
214
+ currentFolderId: string | null,
215
+ folders: readonly Folder[],
216
+ onNavigate: (folderId: string | null) => void,
217
+ ): string | null {
218
+ const currentDrawn =
219
+ currentFolderId === null || folders.some((folder) => folder.id === currentFolderId);
220
+ useEffect(() => {
221
+ if (!currentDrawn) onNavigate(null);
222
+ }, [currentDrawn, onNavigate]);
223
+ return currentDrawn ? currentFolderId : null;
224
+ }
225
+
199
226
  export function ExplorerView<T>({
200
227
  items,
201
228
  getItemKey,
@@ -210,6 +237,7 @@ export function ExplorerView<T>({
210
237
  renderRow,
211
238
  renderCard,
212
239
  onOpenItem,
240
+ selectedItemKey,
213
241
  tags,
214
242
  search,
215
243
  renderFolderActions,
@@ -222,25 +250,27 @@ export function ExplorerView<T>({
222
250
  const query = search ? search.value.trim() : '';
223
251
  const tagKey = tags !== undefined ? tags.selected.join('\0') : '';
224
252
 
253
+ const folderId = useShownFolderId(currentFolderId, folders, onNavigate);
254
+
225
255
  // A new filter view starts on its first page; the clamp below then handles a
226
256
  // set that shrinks under the current page without a reset.
227
257
  useEffect(() => {
228
258
  setPage(1);
229
- }, [currentFolderId, query, tagKey, pageSize]);
259
+ }, [folderId, query, tagKey, pageSize]);
230
260
 
231
261
  if (viewSurface === '') throw new Error('ExplorerView requires a non-empty viewSurface key.');
232
262
  if (columns.length === 0) throw new Error('ExplorerView requires at least one column.');
233
263
 
234
- // Nothing anywhere: no items and no folders. A bare empty state, no controls.
235
- if (items.length === 0 && folders.length === 0) {
264
+ // No item anywhere: a bare empty state, no controls — whatever folders exist.
265
+ if (items.length === 0) {
236
266
  return <EmptyState {...emptyStates.empty} />;
237
267
  }
238
268
 
239
- const subfolders = childFolders(folders, currentFolderId);
240
- const inFolder = items.filter((item) => getFolderId(item) === currentFolderId);
269
+ const subfolders = childFolders(folders, folderId);
270
+ const inFolder = items.filter((item) => getFolderId(item) === folderId);
241
271
 
242
272
  const selectedSet = tags !== undefined ? new Set(tags.selected) : undefined;
243
- const filtered = filterInFolder(inFolder, tags, selectedSet, search, query);
273
+ const filtered = filterInFolder(inFolder, tags, selectedSet, search);
244
274
 
245
275
  const vocabulary =
246
276
  tags !== undefined
@@ -292,7 +322,7 @@ export function ExplorerView<T>({
292
322
 
293
323
  <FolderBreadcrumb
294
324
  folders={folders}
295
- currentFolderId={currentFolderId}
325
+ currentFolderId={folderId}
296
326
  onNavigate={onNavigate}
297
327
  rootLabel={rootLabel}
298
328
  />
@@ -316,6 +346,7 @@ export function ExplorerView<T>({
316
346
  columns={columns}
317
347
  pageEntries={pageEntries}
318
348
  getItemKey={getItemKey}
349
+ selectedItemKey={selectedItemKey}
319
350
  renderRow={renderRow}
320
351
  renderCard={renderCard}
321
352
  openProps={openProps}
@@ -6,10 +6,10 @@
6
6
  * preset/tool extensions wire carries. It is PRESENTATIONAL: the caller fetches the
7
7
  * extension catalog (`available`) and owns the `value`; this component fetches nothing.
8
8
  *
9
- * The surface has two parts:
10
- * - the current combos, each an ordered row of name `Badge`s with a per-combo Edit
11
- * (pull it back into the draft) and Remove;
12
- * - a combo UNDER CONSTRUCTION, authored with the shared `ExtensionPicker` (grouped
9
+ * The surface has two sections:
10
+ * - the current combos (`CommittedComboList`), each an ordered row of name `Badge`s
11
+ * with a per-combo Edit (pull it back into the draft) and Remove;
12
+ * - a combo UNDER CONSTRUCTION (`ComboDraftEditor`), authored with the shared `ExtensionPicker` (grouped
13
13
  * by kind, single-select for the non-stackable `backend` kind). An extension whose
14
14
  * catalog entry declares a `config_schema` is authored as `{ name, config }` through
15
15
  * its settings form; any other name stays a bare element, or the edited combo's own
@@ -38,15 +38,15 @@
38
38
  import type { Extension, PresetExtensionElement } from '@tai42/api-client';
39
39
  import { type ReactNode, useEffect, useState } from 'react';
40
40
 
41
- import { comboElementNames, extensionElementName } from '../extension-combos';
42
- import type { JsonSchema } from '../schema-form/types';
43
41
  import {
44
- ComboDraftEditor,
45
- CommittedComboList,
46
- type ConfigurableExtension,
42
+ comboElementNames,
43
+ extensionElementName,
47
44
  sameNames,
48
45
  unknownNames,
49
- } from './extension-combo-parts';
46
+ } from '../extension-combos';
47
+ import type { JsonSchema } from '../schema-form/types';
48
+ import { ComboDraftEditor, type ConfigurableExtension } from './combo-draft-editor';
49
+ import { CommittedComboList } from './committed-combo-list';
50
50
  import type { ExtensionConfigChange } from './extension-config-editor';
51
51
 
52
52
  type Seeds = Readonly<Record<string, Record<string, unknown> | undefined>>;
@@ -15,7 +15,7 @@
15
15
  import { type ReactNode, useEffect, useRef, useState } from 'react';
16
16
 
17
17
  import { collectEnvRefs } from '../env-markers';
18
- import { SchemaEditor, type SchemaEditorChange } from '../schema-editor';
18
+ import { SchemaEditor, type SchemaEditorDraft } from '../schema-editor';
19
19
  import { defaultValueForSchema } from '../schema-form/default-value';
20
20
  import { JSON_SCHEMA_META_URI } from '../schema-form/json-schema-meta';
21
21
  import { SchemaForm } from '../schema-form/SchemaForm';
@@ -40,7 +40,7 @@ export interface ExtensionConfigEditorProps {
40
40
  readonly idPrefix: string;
41
41
  }
42
42
 
43
- type SchemaSettings = Readonly<Record<string, SchemaEditorChange>>;
43
+ type SchemaSettings = Readonly<Record<string, SchemaEditorDraft>>;
44
44
 
45
45
  const isMetaRef = (node: JsonSchema): boolean => node.$ref === JSON_SCHEMA_META_URI;
46
46
 
@@ -240,7 +240,7 @@ function RenderableSettings({
240
240
  return seeded;
241
241
  });
242
242
  const [schemaSettings, setSchemaSettings] = useState<SchemaSettings>(() => {
243
- const seeded: Record<string, SchemaEditorChange> = {};
243
+ const seeded: Record<string, SchemaEditorDraft> = {};
244
244
  for (const key of schemaKeys) {
245
245
  const stored = value?.[key];
246
246
  seeded[key] = { schema: isRecord(stored) ? stored : null, valid: true };
@@ -13,6 +13,8 @@ import { XCircleIcon } from './icons';
13
13
 
14
14
  interface FieldContextValue {
15
15
  readonly controlId: string | undefined;
16
+ /** The label element's id, published only while `controlId` is (a non-group Field). */
17
+ readonly labelId: string | undefined;
16
18
  readonly describedBy: string | undefined;
17
19
  readonly invalid: boolean;
18
20
  }
@@ -48,6 +50,15 @@ export function useFieldControl(): FieldControlProps {
48
50
  };
49
51
  }
50
52
 
53
+ /**
54
+ * The id of the enclosing Field's label while it names one control, else `undefined`.
55
+ * A control whose popup holds a second labelled surface (a listbox, a dialog) points
56
+ * that surface's `aria-labelledby` at it, so the popup carries the field's name.
57
+ */
58
+ export function useFieldLabelId(): string | undefined {
59
+ return useContext(FieldContext)?.labelId;
60
+ }
61
+
51
62
  export interface FieldProps {
52
63
  readonly label: string;
53
64
  readonly description?: string;
@@ -133,8 +144,13 @@ export function Field({
133
144
  // if the group Field withheld it too, a group Field carrying an `error` would
134
145
  // expose its invalid state NOWHERE in the accessibility tree.
135
146
  const value: FieldContextValue = group
136
- ? { controlId: undefined, describedBy: undefined, invalid: error !== undefined }
137
- : { controlId, describedBy, invalid: error !== undefined };
147
+ ? {
148
+ controlId: undefined,
149
+ labelId: undefined,
150
+ describedBy: undefined,
151
+ invalid: error !== undefined,
152
+ }
153
+ : { controlId, labelId, describedBy, invalid: error !== undefined };
138
154
 
139
155
  // The label keeps its `htmlFor`/`id` wiring either way — `hideLabel` only drops
140
156
  // its PAINT, so the accessible name it gives the control is unchanged.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * The folder WRITES of the tool-organization tree — create, rename, move and delete —
3
+ * shared by every screen that shows the tree, so each offers the same actions over
4
+ * the same client doors (`createFolder` / `renameFolder` / `moveFolder` /
5
+ * `deleteFolder`).
6
+ *
7
+ * The SDK owns no query cache, so every write takes the screen's
8
+ * `onFoldersChanged`: the screen re-reads the overlay under its own key, and the
9
+ * write resolves only after that refresh, so the tree on screen is never stale when
10
+ * a dialog closes. Each dialog owns its own pending and error state, so a failure on
11
+ * one folder never bleeds onto another, and every failure is shown in its dialog.
12
+ *
13
+ * Move offers every folder EXCEPT the folder itself and its descendants (a folder
14
+ * cannot become its own ancestor), with inline create. Delete asks first and lets the
15
+ * server enforce that only an empty folder can go; a refusal is shown in the dialog.
16
+ *
17
+ * Each action is offered only where the caller may make that write
18
+ * ({@link useToolMetaWriteAccess}): its own route, and the tool_meta store not known
19
+ * off. A not-configured refusal is reported, so every screen withdraws its overlay
20
+ * writes; the open dialog stays to show the server's off note.
21
+ */
22
+ import { type ReactNode, useCallback, useState } from 'react';
23
+
24
+ import { errorMessage } from '../errors';
25
+ import { FeatureDisabled, featureDisabledMessage, isFeatureDisabled } from '../feature-disabled';
26
+ import { useApi } from '../hooks/useApi';
27
+ import { ConfirmDialog } from './confirm-dialog';
28
+ import { Dialog } from './dialog';
29
+ import { Field } from './field';
30
+ import { type Folder, FolderPicker } from './folder-nav';
31
+ import { TextInput } from './inputs';
32
+ import { Button, Spinner } from './primitives';
33
+ import {
34
+ ToolMetaWriteError,
35
+ useReportToolMetaRefusal,
36
+ useToolMetaWriteAccess,
37
+ } from './tool-meta-access';
38
+
39
+ /** Re-read the folder tree after a write; the write resolves once this settles. */
40
+ export type FoldersChanged = () => Promise<void>;
41
+
42
+ /** Create a folder under `parentId` and resolve with its id. */
43
+ export type CreateFolder = (name: string, parentId: string | null) => Promise<string>;
44
+
45
+ /**
46
+ * Create a folder through the folder door and refresh the screen's tree, resolving
47
+ * with the new folder's id — the shape {@link FolderPicker}'s `onCreateFolder`
48
+ * takes. `undefined` when the caller may not create a folder, so the picker offers
49
+ * no create. A rejection propagates to the picker, which shows it; a not-configured
50
+ * refusal is also reported, withdrawing every overlay write.
51
+ */
52
+ export function useCreateFolder(onFoldersChanged: FoldersChanged): CreateFolder | undefined {
53
+ const api = useApi();
54
+ const { createFolder: allowed } = useToolMetaWriteAccess();
55
+ const reportRefusal = useReportToolMetaRefusal();
56
+ const create = useCallback(
57
+ async (name: string, parentId: string | null): Promise<string> => {
58
+ let created: { readonly id: string };
59
+ try {
60
+ created = await api.createFolder(name, parentId);
61
+ } catch (error: unknown) {
62
+ reportRefusal(error);
63
+ throw error;
64
+ }
65
+ await onFoldersChanged();
66
+ return created.id;
67
+ },
68
+ [api, onFoldersChanged, reportRefusal],
69
+ );
70
+ return allowed ? create : undefined;
71
+ }
72
+
73
+ /** One write's pending + error state: `run` clears the last error, awaits the write
74
+ * and keeps a rejection for the dialog to show, reporting a not-configured one. */
75
+ function useFolderWrite(write: () => Promise<void>): {
76
+ readonly run: () => void;
77
+ readonly pending: boolean;
78
+ readonly error: unknown;
79
+ } {
80
+ const reportRefusal = useReportToolMetaRefusal();
81
+ const [pending, setPending] = useState(false);
82
+ const [error, setError] = useState<unknown>(null);
83
+ const run = (): void => {
84
+ if (pending) return;
85
+ setPending(true);
86
+ setError(null);
87
+ write().then(
88
+ () => {
89
+ setPending(false);
90
+ },
91
+ (failure: unknown) => {
92
+ setError(failure);
93
+ setPending(false);
94
+ reportRefusal(failure);
95
+ },
96
+ );
97
+ };
98
+ return { run, pending, error };
99
+ }
100
+
101
+ /** The ids of `folder` and every folder nested beneath it — linear in the folder
102
+ * count and safe on a cyclic parent chain. */
103
+ function subtreeIds(folder: Folder, folders: readonly Folder[]): Set<string> {
104
+ const childrenByParent = new Map<string, string[]>();
105
+ for (const candidate of folders) {
106
+ if (candidate.parentId === null) continue;
107
+ const siblings = childrenByParent.get(candidate.parentId) ?? [];
108
+ siblings.push(candidate.id);
109
+ childrenByParent.set(candidate.parentId, siblings);
110
+ }
111
+ const ids = new Set<string>([folder.id]);
112
+ const queue = [folder.id];
113
+ for (let next = queue.pop(); next !== undefined; next = queue.pop()) {
114
+ for (const childId of childrenByParent.get(next) ?? []) {
115
+ if (ids.has(childId)) continue;
116
+ ids.add(childId);
117
+ queue.push(childId);
118
+ }
119
+ }
120
+ return ids;
121
+ }
122
+
123
+ interface FolderDialogProps {
124
+ readonly folder: Folder;
125
+ readonly onFoldersChanged: FoldersChanged;
126
+ readonly onClose: () => void;
127
+ }
128
+
129
+ function RenameFolderDialog({ folder, onFoldersChanged, onClose }: FolderDialogProps): ReactNode {
130
+ const api = useApi();
131
+ const [name, setName] = useState(folder.name);
132
+ const trimmed = name.trim();
133
+ const rename = useFolderWrite(async () => {
134
+ await api.renameFolder(folder.id, trimmed);
135
+ await onFoldersChanged();
136
+ onClose();
137
+ });
138
+ const blocked = trimmed === '' || trimmed === folder.name || rename.pending;
139
+
140
+ return (
141
+ <Dialog
142
+ title="Rename folder"
143
+ open
144
+ onOpenChange={(next) => {
145
+ if (!next) onClose();
146
+ }}
147
+ >
148
+ <Field label="Folder name">
149
+ <TextInput
150
+ value={name}
151
+ autoComplete="off"
152
+ onChange={(event) => {
153
+ setName(event.target.value);
154
+ }}
155
+ onKeyDown={(event) => {
156
+ if (event.key === 'Enter' && !blocked) {
157
+ event.preventDefault();
158
+ rename.run();
159
+ }
160
+ }}
161
+ />
162
+ </Field>
163
+ <ToolMetaWriteError error={rename.error} />
164
+ <div className="tai-dialog-actions">
165
+ <Button type="button" onClick={onClose}>
166
+ Cancel
167
+ </Button>
168
+ <Button type="button" variant="primary" disabled={blocked} onClick={rename.run}>
169
+ {rename.pending ? <Spinner label="Renaming folder" /> : null}
170
+ Rename
171
+ </Button>
172
+ </div>
173
+ </Dialog>
174
+ );
175
+ }
176
+
177
+ function MoveFolderDialog({
178
+ folder,
179
+ folders,
180
+ onFoldersChanged,
181
+ onClose,
182
+ }: FolderDialogProps & { readonly folders: readonly Folder[] }): ReactNode {
183
+ const api = useApi();
184
+ const [parentId, setParentId] = useState<string | null>(folder.parentId);
185
+ const createFolder = useCreateFolder(onFoldersChanged);
186
+ const move = useFolderWrite(async () => {
187
+ await api.moveFolder(folder.id, parentId);
188
+ await onFoldersChanged();
189
+ onClose();
190
+ });
191
+
192
+ const forbidden = subtreeIds(folder, folders);
193
+ const targets = folders.filter((candidate) => !forbidden.has(candidate.id));
194
+
195
+ return (
196
+ <Dialog
197
+ title={`Move ${folder.name}`}
198
+ open
199
+ onOpenChange={(next) => {
200
+ if (!next) onClose();
201
+ }}
202
+ >
203
+ <Field label="Destination folder" description="Choose a new parent, or the root." group>
204
+ <FolderPicker
205
+ value={parentId}
206
+ onChange={setParentId}
207
+ folders={targets}
208
+ onCreateFolder={createFolder}
209
+ unfiledLabel="Root (no folder)"
210
+ />
211
+ </Field>
212
+ <ToolMetaWriteError error={move.error} />
213
+ <div className="tai-dialog-actions">
214
+ <Button type="button" onClick={onClose}>
215
+ Cancel
216
+ </Button>
217
+ <Button
218
+ type="button"
219
+ variant="primary"
220
+ disabled={parentId === folder.parentId || move.pending}
221
+ onClick={move.run}
222
+ >
223
+ {move.pending ? <Spinner label="Moving folder" /> : null}
224
+ Move
225
+ </Button>
226
+ </div>
227
+ </Dialog>
228
+ );
229
+ }
230
+
231
+ function DeleteFolderDialog({ folder, onFoldersChanged, onClose }: FolderDialogProps): ReactNode {
232
+ const api = useApi();
233
+ const remove = useFolderWrite(async () => {
234
+ await api.deleteFolder(folder.id);
235
+ await onFoldersChanged();
236
+ onClose();
237
+ });
238
+ const off = isFeatureDisabled(remove.error);
239
+ return (
240
+ <ConfirmDialog
241
+ title={`Delete ${folder.name}?`}
242
+ confirmLabel="Delete folder"
243
+ pendingLabel="Deleting folder"
244
+ onConfirm={remove.run}
245
+ onClose={onClose}
246
+ isPending={remove.pending}
247
+ error={remove.error !== null && !off ? errorMessage(remove.error) : null}
248
+ disabledNote={
249
+ off ? (
250
+ <FeatureDisabled feature="Tool metadata" message={featureDisabledMessage(remove.error)} />
251
+ ) : undefined
252
+ }
253
+ >
254
+ <p>
255
+ Only an empty folder can be deleted — one that still holds tools (hidden ones count) or
256
+ subfolders cannot be.
257
+ </p>
258
+ </ConfirmDialog>
259
+ );
260
+ }
261
+
262
+ /** Props of {@link FolderActionsMenu}. */
263
+ export interface FolderActionsMenuProps {
264
+ /** The folder the actions act on. */
265
+ readonly folder: Folder;
266
+ /** The FULL folder tree, offered as move destinations. */
267
+ readonly folders: readonly Folder[];
268
+ /** The screen's refresh of its overlay read, awaited after every write. */
269
+ readonly onFoldersChanged: FoldersChanged;
270
+ }
271
+
272
+ /** The Rename, Move and Delete buttons for one folder row/card, each owning its
273
+ * dialog — for an explorer's `renderFolderActions` slot. Each button shows only
274
+ * where the caller may make that write; a dialog already open stays open after its
275
+ * button is withdrawn, so its outcome is still shown. Renders nothing for a caller
276
+ * who may make none of the three writes. */
277
+ export function FolderActionsMenu({
278
+ folder,
279
+ folders,
280
+ onFoldersChanged,
281
+ }: FolderActionsMenuProps): ReactNode {
282
+ const access = useToolMetaWriteAccess();
283
+ const [action, setAction] = useState<'rename' | 'move' | 'delete' | null>(null);
284
+ const close = (): void => {
285
+ setAction(null);
286
+ };
287
+ if (!access.renameFolder && !access.moveFolder && !access.deleteFolder && action === null) {
288
+ return null;
289
+ }
290
+ return (
291
+ <div className="tai-row">
292
+ {access.renameFolder ? (
293
+ <Button
294
+ variant="ghost"
295
+ aria-label={`Rename folder ${folder.name}`}
296
+ onClick={() => {
297
+ setAction('rename');
298
+ }}
299
+ >
300
+ Rename
301
+ </Button>
302
+ ) : null}
303
+ {access.moveFolder ? (
304
+ <Button
305
+ variant="ghost"
306
+ aria-label={`Move folder ${folder.name}`}
307
+ onClick={() => {
308
+ setAction('move');
309
+ }}
310
+ >
311
+ Move
312
+ </Button>
313
+ ) : null}
314
+ {access.deleteFolder ? (
315
+ <Button
316
+ variant="ghost"
317
+ aria-label={`Delete folder ${folder.name}`}
318
+ onClick={() => {
319
+ setAction('delete');
320
+ }}
321
+ >
322
+ Delete
323
+ </Button>
324
+ ) : null}
325
+ {action === 'rename' ? (
326
+ <RenameFolderDialog folder={folder} onFoldersChanged={onFoldersChanged} onClose={close} />
327
+ ) : null}
328
+ {action === 'move' ? (
329
+ <MoveFolderDialog
330
+ folder={folder}
331
+ folders={folders}
332
+ onFoldersChanged={onFoldersChanged}
333
+ onClose={close}
334
+ />
335
+ ) : null}
336
+ {action === 'delete' ? (
337
+ <DeleteFolderDialog folder={folder} onFoldersChanged={onFoldersChanged} onClose={close} />
338
+ ) : null}
339
+ </div>
340
+ );
341
+ }