@tai42/studio-sdk 25.0.0 → 26.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 (262) hide show
  1. package/README.md +143 -26
  2. package/dist/components/action-menu.d.ts +30 -0
  3. package/dist/components/action-menu.d.ts.map +1 -0
  4. package/dist/components/action-menu.js +101 -0
  5. package/dist/components/action-menu.js.map +1 -0
  6. package/dist/components/agent-picker.d.ts +22 -0
  7. package/dist/components/agent-picker.d.ts.map +1 -0
  8. package/dist/components/agent-picker.js +17 -0
  9. package/dist/components/agent-picker.js.map +1 -0
  10. package/dist/components/channel-picker.d.ts +27 -0
  11. package/dist/components/channel-picker.d.ts.map +1 -0
  12. package/dist/components/channel-picker.js +26 -0
  13. package/dist/components/channel-picker.js.map +1 -0
  14. package/dist/components/components.css +226 -8
  15. package/dist/components/confirm-dialog.d.ts +3 -1
  16. package/dist/components/confirm-dialog.d.ts.map +1 -1
  17. package/dist/components/confirm-dialog.js +2 -2
  18. package/dist/components/confirm-dialog.js.map +1 -1
  19. package/dist/components/execution-key-picker.d.ts +26 -10
  20. package/dist/components/execution-key-picker.d.ts.map +1 -1
  21. package/dist/components/execution-key-picker.js +134 -15
  22. package/dist/components/execution-key-picker.js.map +1 -1
  23. package/dist/components/extension-combo-builder.d.ts +7 -7
  24. package/dist/components/extension-combo-builder.d.ts.map +1 -1
  25. package/dist/components/extension-combo-builder.js +42 -16
  26. package/dist/components/extension-combo-builder.js.map +1 -1
  27. package/dist/components/extension-combo-parts.d.ts +13 -11
  28. package/dist/components/extension-combo-parts.d.ts.map +1 -1
  29. package/dist/components/extension-combo-parts.js +10 -19
  30. package/dist/components/extension-combo-parts.js.map +1 -1
  31. package/dist/components/extension-config-editor.d.ts +33 -0
  32. package/dist/components/extension-config-editor.d.ts.map +1 -0
  33. package/dist/components/extension-config-editor.js +190 -0
  34. package/dist/components/extension-config-editor.js.map +1 -0
  35. package/dist/components/extension-grouping.d.ts +4 -41
  36. package/dist/components/extension-grouping.d.ts.map +1 -1
  37. package/dist/components/extension-grouping.js +1 -48
  38. package/dist/components/extension-grouping.js.map +1 -1
  39. package/dist/components/extension-picker.d.ts +4 -3
  40. package/dist/components/extension-picker.d.ts.map +1 -1
  41. package/dist/components/extension-picker.js +6 -6
  42. package/dist/components/extension-picker.js.map +1 -1
  43. package/dist/components/fleet-report.d.ts +8 -3
  44. package/dist/components/fleet-report.d.ts.map +1 -1
  45. package/dist/components/fleet-report.js +12 -2
  46. package/dist/components/fleet-report.js.map +1 -1
  47. package/dist/components/holder-kinds.d.ts +18 -0
  48. package/dist/components/holder-kinds.d.ts.map +1 -0
  49. package/dist/components/holder-kinds.js +41 -0
  50. package/dist/components/holder-kinds.js.map +1 -0
  51. package/dist/components/holder-list.d.ts +58 -0
  52. package/dist/components/holder-list.d.ts.map +1 -0
  53. package/dist/components/holder-list.js +77 -0
  54. package/dist/components/holder-list.js.map +1 -0
  55. package/dist/components/icons/index.d.ts +1 -1
  56. package/dist/components/icons/index.d.ts.map +1 -1
  57. package/dist/components/icons/index.js +1 -1
  58. package/dist/components/icons/index.js.map +1 -1
  59. package/dist/components/icons/nav-map.d.ts.map +1 -1
  60. package/dist/components/icons/nav-map.js +1 -3
  61. package/dist/components/icons/nav-map.js.map +1 -1
  62. package/dist/components/icons/nav-marks.d.ts +0 -4
  63. package/dist/components/icons/nav-marks.d.ts.map +1 -1
  64. package/dist/components/icons/nav-marks.js +0 -4
  65. package/dist/components/icons/nav-marks.js.map +1 -1
  66. package/dist/components/named-list-picker.d.ts +39 -0
  67. package/dist/components/named-list-picker.d.ts.map +1 -0
  68. package/dist/components/named-list-picker.js +61 -0
  69. package/dist/components/named-list-picker.js.map +1 -0
  70. package/dist/components/named-list-test-utils.d.ts +23 -0
  71. package/dist/components/named-list-test-utils.d.ts.map +1 -0
  72. package/dist/components/named-list-test-utils.js +33 -0
  73. package/dist/components/named-list-test-utils.js.map +1 -0
  74. package/dist/components/recycle-roll.d.ts +23 -0
  75. package/dist/components/recycle-roll.d.ts.map +1 -0
  76. package/dist/components/recycle-roll.js +47 -0
  77. package/dist/components/recycle-roll.js.map +1 -0
  78. package/dist/components/stepper.d.ts +28 -0
  79. package/dist/components/stepper.d.ts.map +1 -0
  80. package/dist/components/stepper.js +33 -0
  81. package/dist/components/stepper.js.map +1 -0
  82. package/dist/components/subject-section.d.ts +2 -1
  83. package/dist/components/subject-section.d.ts.map +1 -1
  84. package/dist/components/subject-section.js +10 -5
  85. package/dist/components/subject-section.js.map +1 -1
  86. package/dist/components/tokens.css +31 -41
  87. package/dist/components/tokens.d.ts +3 -4
  88. package/dist/components/tokens.d.ts.map +1 -1
  89. package/dist/components/tokens.js +5 -13
  90. package/dist/components/tokens.js.map +1 -1
  91. package/dist/components/tool-picker-parts.d.ts +5 -1
  92. package/dist/components/tool-picker-parts.d.ts.map +1 -1
  93. package/dist/components/tool-picker-parts.js +8 -6
  94. package/dist/components/tool-picker-parts.js.map +1 -1
  95. package/dist/components/tool-picker.d.ts +77 -2
  96. package/dist/components/tool-picker.d.ts.map +1 -1
  97. package/dist/components/tool-picker.js +87 -29
  98. package/dist/components/tool-picker.js.map +1 -1
  99. package/dist/components/version-history-panel.d.ts +7 -1
  100. package/dist/components/version-history-panel.d.ts.map +1 -1
  101. package/dist/components/version-history-panel.js +2 -2
  102. package/dist/components/version-history-panel.js.map +1 -1
  103. package/dist/components/version-history-rows.d.ts +4 -2
  104. package/dist/components/version-history-rows.d.ts.map +1 -1
  105. package/dist/components/version-history-rows.js +14 -2
  106. package/dist/components/version-history-rows.js.map +1 -1
  107. package/dist/errors.d.ts +5 -0
  108. package/dist/errors.d.ts.map +1 -1
  109. package/dist/errors.js +7 -0
  110. package/dist/errors.js.map +1 -1
  111. package/dist/hooks/useApi.d.ts +524 -197
  112. package/dist/hooks/useApi.d.ts.map +1 -1
  113. package/dist/hooks/useCapabilities.d.ts +43 -10
  114. package/dist/hooks/useCapabilities.d.ts.map +1 -1
  115. package/dist/hooks/useCapabilities.js +39 -10
  116. package/dist/hooks/useCapabilities.js.map +1 -1
  117. package/dist/index.d.ts +25 -12
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +14 -6
  120. package/dist/index.js.map +1 -1
  121. package/dist/marketplace-install/env-var-fields.d.ts +17 -0
  122. package/dist/marketplace-install/env-var-fields.d.ts.map +1 -0
  123. package/dist/marketplace-install/env-var-fields.js +22 -0
  124. package/dist/marketplace-install/env-var-fields.js.map +1 -0
  125. package/dist/marketplace-install/index.d.ts +18 -0
  126. package/dist/marketplace-install/index.d.ts.map +1 -0
  127. package/dist/marketplace-install/index.js +8 -0
  128. package/dist/marketplace-install/index.js.map +1 -0
  129. package/dist/marketplace-install/install-env.d.ts +21 -0
  130. package/dist/marketplace-install/install-env.d.ts.map +1 -0
  131. package/dist/marketplace-install/install-env.js +22 -0
  132. package/dist/marketplace-install/install-env.js.map +1 -0
  133. package/dist/marketplace-install/install-mount.d.ts +42 -0
  134. package/dist/marketplace-install/install-mount.d.ts.map +1 -0
  135. package/dist/marketplace-install/install-mount.js +62 -0
  136. package/dist/marketplace-install/install-mount.js.map +1 -0
  137. package/dist/marketplace-install/install-report.d.ts +14 -0
  138. package/dist/marketplace-install/install-report.d.ts.map +1 -0
  139. package/dist/marketplace-install/install-report.js +19 -0
  140. package/dist/marketplace-install/install-report.js.map +1 -0
  141. package/dist/marketplace-install/install-shell.d.ts +34 -0
  142. package/dist/marketplace-install/install-shell.d.ts.map +1 -0
  143. package/dist/marketplace-install/install-shell.js +32 -0
  144. package/dist/marketplace-install/install-shell.js.map +1 -0
  145. package/dist/marketplace-install/mount-install.d.ts +41 -0
  146. package/dist/marketplace-install/mount-install.d.ts.map +1 -0
  147. package/dist/marketplace-install/mount-install.js +90 -0
  148. package/dist/marketplace-install/mount-install.js.map +1 -0
  149. package/dist/marketplace-install/plugin-detail.d.ts +17 -0
  150. package/dist/marketplace-install/plugin-detail.d.ts.map +1 -0
  151. package/dist/marketplace-install/plugin-detail.js +41 -0
  152. package/dist/marketplace-install/plugin-detail.js.map +1 -0
  153. package/dist/marketplace-install/plugin-install.d.ts +27 -0
  154. package/dist/marketplace-install/plugin-install.d.ts.map +1 -0
  155. package/dist/marketplace-install/plugin-install.js +89 -0
  156. package/dist/marketplace-install/plugin-install.js.map +1 -0
  157. package/dist/marketplace-install/route-items.d.ts +14 -0
  158. package/dist/marketplace-install/route-items.d.ts.map +1 -0
  159. package/dist/marketplace-install/route-items.js +11 -0
  160. package/dist/marketplace-install/route-items.js.map +1 -0
  161. package/dist/marketplace-install/use-debounced-value.d.ts +6 -0
  162. package/dist/marketplace-install/use-debounced-value.d.ts.map +1 -0
  163. package/dist/marketplace-install/use-debounced-value.js +18 -0
  164. package/dist/marketplace-install/use-debounced-value.js.map +1 -0
  165. package/dist/marketplace-install/use-install-action.d.ts +26 -0
  166. package/dist/marketplace-install/use-install-action.d.ts.map +1 -0
  167. package/dist/marketplace-install/use-install-action.js +35 -0
  168. package/dist/marketplace-install/use-install-action.js.map +1 -0
  169. package/dist/marketplace-install/use-install-preview.d.ts +33 -0
  170. package/dist/marketplace-install/use-install-preview.d.ts.map +1 -0
  171. package/dist/marketplace-install/use-install-preview.js +44 -0
  172. package/dist/marketplace-install/use-install-preview.js.map +1 -0
  173. package/dist/marketplace-install/use-mount-install.d.ts +80 -0
  174. package/dist/marketplace-install/use-mount-install.d.ts.map +1 -0
  175. package/dist/marketplace-install/use-mount-install.js +75 -0
  176. package/dist/marketplace-install/use-mount-install.js.map +1 -0
  177. package/dist/navigation/index.d.ts +1 -1
  178. package/dist/navigation/index.d.ts.map +1 -1
  179. package/dist/navigation/index.js.map +1 -1
  180. package/dist/navigation/types.d.ts +23 -7
  181. package/dist/navigation/types.d.ts.map +1 -1
  182. package/dist/plugin/registry.d.ts +0 -8
  183. package/dist/plugin/registry.d.ts.map +1 -1
  184. package/dist/plugin/registry.js +25 -1
  185. package/dist/plugin/registry.js.map +1 -1
  186. package/dist/plugin/types.d.ts +36 -12
  187. package/dist/plugin/types.d.ts.map +1 -1
  188. package/dist/plugin/version.d.ts +6 -4
  189. package/dist/plugin/version.d.ts.map +1 -1
  190. package/dist/plugin/version.js +6 -4
  191. package/dist/plugin/version.js.map +1 -1
  192. package/dist/query-keys.d.ts +45 -4
  193. package/dist/query-keys.d.ts.map +1 -1
  194. package/dist/query-keys.js +52 -4
  195. package/dist/query-keys.js.map +1 -1
  196. package/dist/schema-form/json-schema-meta.d.ts +3 -0
  197. package/dist/schema-form/json-schema-meta.d.ts.map +1 -0
  198. package/dist/schema-form/json-schema-meta.js +3 -0
  199. package/dist/schema-form/json-schema-meta.js.map +1 -0
  200. package/dist/testing.d.ts +2 -0
  201. package/dist/testing.d.ts.map +1 -1
  202. package/dist/testing.js +2 -0
  203. package/dist/testing.js.map +1 -1
  204. package/dist/token-rules.d.ts +26 -0
  205. package/dist/token-rules.d.ts.map +1 -0
  206. package/dist/token-rules.js +64 -0
  207. package/dist/token-rules.js.map +1 -0
  208. package/package.json +7 -7
  209. package/src/components/action-menu.tsx +160 -0
  210. package/src/components/agent-picker.tsx +53 -0
  211. package/src/components/channel-picker.tsx +68 -0
  212. package/src/components/components.css +226 -8
  213. package/src/components/confirm-dialog.tsx +4 -1
  214. package/src/components/execution-key-picker.tsx +252 -36
  215. package/src/components/extension-combo-builder.tsx +61 -29
  216. package/src/components/extension-combo-parts.tsx +28 -38
  217. package/src/components/extension-config-editor.tsx +307 -0
  218. package/src/components/extension-grouping.ts +4 -73
  219. package/src/components/extension-picker.tsx +13 -13
  220. package/src/components/fleet-report.tsx +38 -4
  221. package/src/components/holder-kinds.ts +60 -0
  222. package/src/components/holder-list.tsx +163 -0
  223. package/src/components/icons/index.ts +0 -2
  224. package/src/components/icons/nav-map.ts +0 -4
  225. package/src/components/icons/nav-marks.tsx +0 -15
  226. package/src/components/named-list-picker.tsx +182 -0
  227. package/src/components/named-list-test-utils.tsx +47 -0
  228. package/src/components/recycle-roll.tsx +120 -0
  229. package/src/components/stepper.tsx +111 -0
  230. package/src/components/subject-section.tsx +10 -5
  231. package/src/components/tokens.css +31 -41
  232. package/src/components/tokens.ts +5 -13
  233. package/src/components/tool-picker-parts.tsx +17 -6
  234. package/src/components/tool-picker.tsx +215 -41
  235. package/src/components/version-history-panel.tsx +8 -0
  236. package/src/components/version-history-rows.tsx +14 -2
  237. package/src/errors.ts +10 -0
  238. package/src/hooks/useCapabilities.ts +59 -10
  239. package/src/index.ts +54 -14
  240. package/src/marketplace-install/env-var-fields.tsx +59 -0
  241. package/src/marketplace-install/index.ts +17 -0
  242. package/src/marketplace-install/install-env.ts +39 -0
  243. package/src/marketplace-install/install-mount.ts +96 -0
  244. package/src/marketplace-install/install-report.tsx +54 -0
  245. package/src/marketplace-install/install-shell.tsx +99 -0
  246. package/src/marketplace-install/mount-install.tsx +290 -0
  247. package/src/marketplace-install/plugin-detail.ts +60 -0
  248. package/src/marketplace-install/plugin-install.tsx +221 -0
  249. package/src/marketplace-install/route-items.ts +20 -0
  250. package/src/marketplace-install/use-debounced-value.ts +18 -0
  251. package/src/marketplace-install/use-install-action.ts +60 -0
  252. package/src/marketplace-install/use-install-preview.ts +74 -0
  253. package/src/marketplace-install/use-mount-install.ts +116 -0
  254. package/src/navigation/index.ts +1 -0
  255. package/src/navigation/types.ts +27 -7
  256. package/src/plugin/registry.ts +19 -1
  257. package/src/plugin/types.ts +40 -12
  258. package/src/plugin/version.ts +6 -4
  259. package/src/query-keys.ts +71 -4
  260. package/src/schema-form/json-schema-meta.ts +2 -0
  261. package/src/testing.ts +2 -0
  262. package/src/token-rules.ts +66 -0
@@ -47,6 +47,8 @@ export interface ConfirmDialogProps {
47
47
  * the confirm button — the action can only refuse, so there is nothing to retry.
48
48
  */
49
49
  readonly disabledNote?: ReactNode;
50
+ /** Hold the confirm button disabled while the caller knows the action cannot pass. */
51
+ readonly confirmDisabled?: boolean;
50
52
  readonly confirmVariant?: 'primary' | 'danger';
51
53
  /** The cancel button's label; defaults to `Cancel`. Set it when the negative
52
54
  * choice reads better as a specific verb (e.g. `Keep existing`). */
@@ -64,6 +66,7 @@ export function ConfirmDialog({
64
66
  isPending,
65
67
  error,
66
68
  disabledNote,
69
+ confirmDisabled = false,
67
70
  confirmVariant = 'danger',
68
71
  cancelLabel = 'Cancel',
69
72
  children,
@@ -85,7 +88,7 @@ export function ConfirmDialog({
85
88
  <Button
86
89
  type="button"
87
90
  variant={confirmVariant}
88
- disabled={isPending || disabledNote != null}
91
+ disabled={isPending || disabledNote != null || confirmDisabled}
89
92
  onClick={onConfirm}
90
93
  >
91
94
  {isPending ? <Spinner label={pendingLabel} /> : null}
@@ -1,20 +1,25 @@
1
1
  /**
2
- * Picks the api key a fire / recurring run RUNS AS. Shared by every fire-path form
3
- * (hooks, trigger links, schedules). Presentational and prop-driven — the host
4
- * feature owns the api-key list query and hands it in, so this component (and the
5
- * SDK) stay free of a data-fetching library, exactly as {@link ToolPicker} does.
2
+ * Picks the api key a stored record (a route, a hook, a trigger link, a schedule)
3
+ * RUNS AS. Presentational and prop-driven — the host feature owns the api-key list
4
+ * query and hands it in, so this component (and the SDK) stay free of a
5
+ * data-fetching library, exactly as {@link ToolPicker} does.
6
6
  *
7
- * The server decides authorization; this only lists the pickable keys and renders
8
- * the read's loading / empty / error states. The host form renders the server's
9
- * refusal verbatim elsewhere.
7
+ * The `binding` decides what renders: nothing with access control off, a pending
8
+ * control while the session's projection loads, and otherwise the key list plus the
9
+ * session's own identity when it is an api key the list does not hold. A stored key
10
+ * the loaded list does not hold is flagged with what the server will make of it,
11
+ * compared with the session's identity. The server decides authorization; the host
12
+ * form renders its refusal verbatim elsewhere.
10
13
  */
11
14
  import type { TokensPayload } from '@tai42/api-client';
12
- import type { ReactNode } from 'react';
15
+ import { type ReactNode, useId } from 'react';
13
16
 
14
- import { errorMessage } from '../errors';
17
+ import { errorMessage, isForbiddenError } from '../errors';
18
+ import type { ExecutionKeyBinding, OwnIdentity } from '../hooks/useCapabilities';
19
+ import { Badge } from './badge';
15
20
  import { Field } from './field';
16
21
  import { ErrorState } from './primitives';
17
- import { Select } from './select';
22
+ import { Select, type SelectOption } from './select';
18
23
 
19
24
  /**
20
25
  * The subset of the api-key list query this picker reads. A caller's TanStack
@@ -61,56 +66,267 @@ function pickableKeys(keys: TokensPayload): TokensPayload {
61
66
  });
62
67
  }
63
68
 
69
+ /**
70
+ * Whether the form has a key to send: always with access control off, never while the
71
+ * projection is pending, and otherwise once the list read has answered (loaded, or
72
+ * refused with a 403) and the picker holds a value, lists a key, or offers a key
73
+ * session's own identity. Under a checked binding the picker renders its `Select`
74
+ * exactly while the list read is pending or this holds.
75
+ */
76
+ export function canHoldExecutionKey(
77
+ query: ExecutionKeyQuery,
78
+ binding: ExecutionKeyBinding,
79
+ value: string,
80
+ ): boolean {
81
+ if (binding.mode === 'unchecked') return true;
82
+ if (binding.mode === 'pending') return false;
83
+ return (
84
+ (query.isSuccess || isForbiddenError(query.error)) &&
85
+ (value !== '' ||
86
+ (query.isSuccess && !isExecutionKeyListEmpty(query)) ||
87
+ binding.own.kind === 'key')
88
+ );
89
+ }
90
+
91
+ /** The unlisted line's sentence for a stored key the key list does not hold. */
92
+ function unlistedKeyHint(value: string, own: OwnIdentity): string {
93
+ if (own.admin) {
94
+ return `${value} is not a listed key. As an admin you may bind another identity; the server checks it on save.`;
95
+ }
96
+ if (own.kind === 'account') {
97
+ return `${value} is not a listed key. The server accepts only an api key you own; any other value is refused on save.`;
98
+ }
99
+ return `${value} is not a listed key. The server accepts an unlisted key only when it is your own identity; any other value is refused on save.`;
100
+ }
101
+
102
+ /** The second line: what the comparison of the value with the session's identity shows, or undefined when it shows
103
+ * nothing to say (a key session's own identity). */
104
+ function unlistedKeyNote(
105
+ value: string,
106
+ own: OwnIdentity,
107
+ ): { readonly badge: string; readonly text: string } | undefined {
108
+ if (value === own.userId) {
109
+ return own.kind === 'account'
110
+ ? {
111
+ badge: 'not an api key',
112
+ text: `${value} is this session's account, not an api key, so the server will refuse it on save.`,
113
+ }
114
+ : undefined;
115
+ }
116
+ return {
117
+ badge: 'not your identity',
118
+ text: own.admin
119
+ ? `${value} is not this session's identity (${own.userId}).`
120
+ : `${value} is not this session's identity (${own.userId}), so the server will refuse it on save.`,
121
+ };
122
+ }
123
+
124
+ const DEFAULT_DESCRIPTION =
125
+ "The api key this fire runs AS. Every tool call it makes is authorized against that key's live grants — prefer a least-privilege service key over a human's broad one.";
126
+ const FORBIDDEN_NOTE =
127
+ 'This session cannot list api keys. Picking one needs read access to GET /api/auth/tokens-payload; ask an administrator to grant it.';
128
+ const KEY_EMPTY_NOTE =
129
+ 'No api keys of yours are listed. Mint one on the settings API-keys tab to run as a narrower key.';
130
+ const ACCOUNT_EMPTY_NOTE =
131
+ 'No api keys of yours are listed. You are signed in as an account, not an api key, so your own identity cannot be picked here. Mint a key on the settings API-keys tab first.';
132
+ const MUTED_LINE = { margin: 0, color: 'var(--tai-color-text-muted)' } as const;
133
+
134
+ function MutedLine({ text }: { readonly text: string }): ReactNode {
135
+ return (
136
+ <p role="status" style={MUTED_LINE}>
137
+ {text}
138
+ </p>
139
+ );
140
+ }
141
+
64
142
  export interface ExecutionKeyPickerProps {
65
143
  /** The api-key list read the host feature owns and hands in. */
66
144
  readonly query: ExecutionKeyQuery;
145
+ /** How the host form binds its key (`useExecutionKeyBinding()`). */
146
+ readonly binding: ExecutionKeyBinding;
67
147
  readonly value: string;
68
148
  readonly onValueChange: (value: string) => void;
69
- /** The host form's required-field error, shown under the control. */
149
+ /** The host form's required-field error, shown under the control while a `Select` renders. */
70
150
  readonly error?: string | undefined;
151
+ /** The field's description; the default speaks of a fire. */
152
+ readonly description?: string;
153
+ }
154
+
155
+ /** What the checked picker draws, derived from the list read, the value and the session's identity. */
156
+ interface CheckedView {
157
+ readonly options: readonly SelectOption[];
158
+ /** A `Select` renders (a pending list, or something to hold). */
159
+ readonly selectRenders: boolean;
160
+ /** The muted note beside or in place of the `Select`. */
161
+ readonly note: string | undefined;
162
+ /** The stored value no listed option carries, once the list loaded. */
163
+ readonly unlisted: boolean;
164
+ /** The session's own identity is offered as an option. */
165
+ readonly ownOffered: boolean;
166
+ }
167
+
168
+ /** The view while the binding is pending: a disabled `Select` and nothing else. */
169
+ const PENDING_VIEW: CheckedView = {
170
+ options: [],
171
+ selectRenders: true,
172
+ note: undefined,
173
+ unlisted: false,
174
+ ownOffered: false,
175
+ };
176
+
177
+ /** The `Select`'s options: the stored value no listed option carries (flagged once the list
178
+ * loaded), the session's own identity when offered, then the listed keys. */
179
+ function checkedOptions(
180
+ stored: string | null,
181
+ unlisted: boolean,
182
+ forbidden: boolean,
183
+ ownOffered: string | null,
184
+ listed: readonly TokensPayload[number][],
185
+ ): SelectOption[] {
186
+ const options: SelectOption[] = [];
187
+ if (stored !== null && (unlisted || forbidden)) {
188
+ options.push({ value: stored, label: unlisted ? `${stored} (not a listed key)` : stored });
189
+ }
190
+ if (ownOffered !== null) {
191
+ options.push({ value: ownOffered, label: `${ownOffered} — this session's identity` });
192
+ }
193
+ for (const key of listed) options.push({ value: key.user_id, label: keyLabel(key) });
194
+ return options;
195
+ }
196
+
197
+ /** The muted note: the refused read's, or the empty list's for the session's kind. */
198
+ function checkedNote(query: ExecutionKeyQuery, own: OwnIdentity): string | undefined {
199
+ if (isForbiddenError(query.error)) return FORBIDDEN_NOTE;
200
+ if (!isExecutionKeyListEmpty(query)) return undefined;
201
+ return own.kind === 'key' ? KEY_EMPTY_NOTE : ACCOUNT_EMPTY_NOTE;
202
+ }
203
+
204
+ function checkedView(query: ExecutionKeyQuery, own: OwnIdentity, value: string): CheckedView {
205
+ const forbidden = isForbiddenError(query.error);
206
+ const listed = query.isSuccess ? pickableKeys(query.data ?? []) : [];
207
+ const answered = query.isSuccess || forbidden;
208
+ const ownOffered =
209
+ own.kind === 'key' && answered && !listed.some((key) => key.user_id === own.userId);
210
+ const stored = value !== '' && !(ownOffered && value === own.userId) ? value : null;
211
+ const unlisted =
212
+ query.isSuccess && stored !== null && !listed.some((key) => key.user_id === stored);
213
+ const options = checkedOptions(
214
+ stored,
215
+ unlisted,
216
+ forbidden,
217
+ ownOffered ? own.userId : null,
218
+ listed,
219
+ );
220
+ const selectRenders =
221
+ (query.isPending && !query.isError) ||
222
+ canHoldExecutionKey(query, { mode: 'checked', own }, value);
223
+ return { options, selectRenders, note: checkedNote(query, own), unlisted, ownOffered };
224
+ }
225
+
226
+ /** The lines under the `Select`: the unlisted hint, the own-identity hint and the comparison note. */
227
+ function KeyHints({
228
+ own,
229
+ view,
230
+ value,
231
+ hintId,
232
+ noteId,
233
+ }: {
234
+ readonly own: OwnIdentity | undefined;
235
+ readonly view: CheckedView;
236
+ readonly value: string;
237
+ readonly hintId: string;
238
+ readonly noteId: string;
239
+ }): ReactNode {
240
+ if (own === undefined) return null;
241
+ const keyNote = view.unlisted ? unlistedKeyNote(value, own) : undefined;
242
+ return (
243
+ <>
244
+ {view.unlisted ? (
245
+ <p id={hintId} className="tai-field-hint" style={{ margin: 0 }}>
246
+ <Badge variant="warning">not a listed key</Badge> {unlistedKeyHint(value, own)}
247
+ </p>
248
+ ) : null}
249
+ {view.ownOffered && value === own.userId ? (
250
+ <p id={hintId} className="tai-field-hint" style={{ margin: 0 }}>
251
+ {`This runs as this session's own identity, ${own.userId}, with its live grants.`}
252
+ </p>
253
+ ) : null}
254
+ {keyNote !== undefined ? (
255
+ <p id={noteId} role="status" className="tai-field-hint" style={{ margin: 0 }}>
256
+ <Badge variant="warning">{keyNote.badge}</Badge> {keyNote.text}
257
+ </p>
258
+ ) : null}
259
+ </>
260
+ );
261
+ }
262
+
263
+ /** The ids the `Select` is described by: the hint while one shows, the note while one shows. */
264
+ function describedByIds(
265
+ own: OwnIdentity | undefined,
266
+ view: CheckedView,
267
+ value: string,
268
+ hintId: string,
269
+ noteId: string,
270
+ ): string | undefined {
271
+ if (own === undefined) return undefined;
272
+ const hinted = view.unlisted || (view.ownOffered && value === own.userId);
273
+ const noted = view.unlisted && unlistedKeyNote(value, own) !== undefined;
274
+ const ids = [hinted ? hintId : undefined, noted ? noteId : undefined].filter(
275
+ (id) => id !== undefined,
276
+ );
277
+ return ids.length === 0 ? undefined : ids.join(' ');
71
278
  }
72
279
 
73
280
  export function ExecutionKeyPicker({
74
281
  query,
282
+ binding,
75
283
  value,
76
284
  onValueChange,
77
285
  error,
286
+ description = DEFAULT_DESCRIPTION,
78
287
  }: ExecutionKeyPickerProps): ReactNode {
79
- const listEmpty = isExecutionKeyListEmpty(query);
80
-
81
- return (
82
- <Field
83
- label="Execution key"
84
- description="The api key this fire runs AS. Every tool call it makes is authorized against that key's live grants — prefer a least-privilege service key over a human's broad one."
85
- // Suppressed: the empty note / the ErrorState already say it.
86
- error={listEmpty || query.isError ? undefined : error}
87
- // Only the Select branch claims the field's control id; the error and
88
- // empty branches render no labelable element, so `for` would dangle.
89
- group={query.isError || listEmpty}
90
- >
91
- {query.isError ? (
288
+ const hintId = useId();
289
+ const noteId = useId();
290
+ if (binding.mode === 'unchecked') return null;
291
+ if (binding.mode === 'checked' && query.isError && !isForbiddenError(query.error)) {
292
+ return (
293
+ <Field label="Execution key" description={description} group>
92
294
  <ErrorState
93
295
  message={errorMessage(query.error)}
94
296
  onRetry={() => {
95
297
  query.refetch();
96
298
  }}
97
299
  />
98
- ) : listEmpty ? (
99
- <p role="status" style={{ margin: 0, color: 'var(--tai-color-text-muted)' }}>
100
- No api keys available to run as. Mint one on the settings API-keys tab first.
101
- </p>
102
- ) : (
300
+ </Field>
301
+ );
302
+ }
303
+ // A pending binding renders the same tree as a pending list, so the control is not
304
+ // remounted when the binding resolves.
305
+ const own = binding.mode === 'checked' ? binding.own : undefined;
306
+ const view = own === undefined ? PENDING_VIEW : checkedView(query, own, value);
307
+ const loading = own === undefined || query.isPending;
308
+
309
+ return (
310
+ <Field
311
+ label="Execution key"
312
+ description={description}
313
+ error={view.selectRenders ? error : undefined}
314
+ // Only the Select claims the field's control id; with nothing to hold no
315
+ // labelable element renders, so `for` would dangle.
316
+ group={!view.selectRenders}
317
+ >
318
+ {view.selectRenders ? (
103
319
  <Select
104
- options={pickableKeys(query.data ?? []).map((key) => ({
105
- value: key.user_id,
106
- label: keyLabel(key),
107
- }))}
320
+ options={view.options}
108
321
  value={value}
109
- placeholder={query.isPending ? 'Loading keys…' : 'Select an execution key'}
110
- disabled={query.isPending}
322
+ placeholder={loading ? 'Loading keys…' : 'Select an execution key'}
323
+ disabled={loading}
111
324
  onValueChange={onValueChange}
325
+ aria-describedby={describedByIds(own, view, value, hintId, noteId)}
112
326
  />
113
- )}
327
+ ) : null}
328
+ {view.note !== undefined ? <MutedLine text={view.note} /> : null}
329
+ <KeyHints own={own} view={view} value={value} hintId={hintId} noteId={noteId} />
114
330
  </Field>
115
331
  );
116
332
  }
@@ -10,16 +10,16 @@
10
10
  * - the current combos, each an ordered row of name `Badge`s with a per-combo Edit
11
11
  * (pull it back into the draft) and Remove;
12
12
  * - a combo UNDER CONSTRUCTION, authored with the shared `ExtensionPicker` (grouped
13
- * by kind, single-select for the non-stackable `backend` kind). When the draft
14
- * includes the `output_schema` extension, its config is authored inline with a
15
- * `SchemaEditor` (no title required); an "Add combo" / "Update combo" action
16
- * composes the elements — `output_schema` as `{ name, config: { schema } }`, every
17
- * other extension as a bare name — and writes it into the value.
13
+ * by kind, single-select for the non-stackable `backend` kind). An extension whose
14
+ * catalog entry declares a `config_schema` is authored as `{ name, config }` through
15
+ * its settings form; any other name stays a bare element, or the edited combo's own
16
+ * element when it survives the edit. An "Add extension set" / "Update extension
17
+ * set" action composes the elements and writes the combo into the value.
18
18
  *
19
19
  * The builder only blocks the client-trivial invalids: an EMPTY combo (Add is
20
20
  * disabled until the draft has ≥1 member), a DUPLICATE of a combo already in the
21
- * value (by name sequence; flagged inline, Add blocked), an INVALID `output_schema`
22
- * config (the `SchemaEditor` shows the parse/lint error and Add is blocked), and an
21
+ * value (by name sequence; flagged inline, Add blocked), an INVALID or not yet
22
+ * reported settings form of a drafted extension (Add is blocked), and an
23
23
  * UNKNOWN extension name in an existing combo (a name absent from `available`, which
24
24
  * happens when a plugin is removed after a preset was authored) — flagged inline on
25
25
  * that combo row and reported through `onValidityChange` so the caller can block
@@ -39,15 +39,17 @@ import type { Extension, PresetExtensionElement } from '@tai42/api-client';
39
39
  import { type ReactNode, useEffect, useState } from 'react';
40
40
 
41
41
  import { comboElementNames, extensionElementName } from '../extension-combos';
42
- import { type SchemaEditorChange } from '../schema-editor';
42
+ import type { JsonSchema } from '../schema-form/types';
43
43
  import {
44
44
  ComboDraftEditor,
45
45
  CommittedComboList,
46
- OUTPUT_SCHEMA,
47
- outputSchemaConfig,
46
+ type ConfigurableExtension,
48
47
  sameNames,
49
48
  unknownNames,
50
49
  } from './extension-combo-parts';
50
+ import type { ExtensionConfigChange } from './extension-config-editor';
51
+
52
+ type Seeds = Readonly<Record<string, Record<string, unknown> | undefined>>;
51
53
 
52
54
  export interface ExtensionComboBuilderProps {
53
55
  /** The extension catalog the combos are drawn from (caller-fetched). */
@@ -84,15 +86,29 @@ export function ExtensionComboBuilder({
84
86
  availableReady = true,
85
87
  }: ExtensionComboBuilderProps): ReactNode {
86
88
  const [draft, setDraft] = useState<string[]>([]);
87
- const [draftConfig, setDraftConfig] = useState<SchemaEditorChange>({ schema: null, valid: true });
89
+ // The settings each drafted configurable extension's form last reported.
90
+ const [draftConfigs, setDraftConfigs] = useState<Readonly<Record<string, ExtensionConfigChange>>>(
91
+ {},
92
+ );
93
+ // The stored settings the forms seed from when a combo is pulled in for edit.
94
+ const [draftSeeds, setDraftSeeds] = useState<Seeds>({});
88
95
  // The index of the combo being edited (pulled into the draft), or `null` for a new one.
89
96
  const [editing, setEditing] = useState<number | null>(null);
90
97
 
91
- const draftHasOutputSchema = draft.includes(OUTPUT_SCHEMA);
98
+ const settingsSchemas = new Map<string, JsonSchema>();
99
+ for (const extension of available) {
100
+ if (extension.config_schema !== null) {
101
+ settingsSchemas.set(extension.name, extension.config_schema);
102
+ }
103
+ }
104
+ const configurable: ConfigurableExtension[] = draft.flatMap((name) => {
105
+ const schema = settingsSchemas.get(name);
106
+ return schema === undefined ? [] : [{ name, schema }];
107
+ });
92
108
  const isDuplicate =
93
109
  draft.length > 0 &&
94
110
  value.some((combo, index) => index !== editing && sameNames(comboElementNames(combo), draft));
95
- const configInvalid = draftHasOutputSchema && !draftConfig.valid;
111
+ const configInvalid = configurable.some(({ name }) => draftConfigs[name]?.valid !== true);
96
112
  const canAdd = !disabled && draft.length > 0 && !isDuplicate && !configInvalid;
97
113
 
98
114
  // Unknown-name validation over the EXISTING combos. Until the catalog is ready the
@@ -108,16 +124,16 @@ export function ExtensionComboBuilder({
108
124
 
109
125
  const resetDraft = (): void => {
110
126
  setDraft([]);
111
- setDraftConfig({ schema: null, valid: true });
127
+ setDraftConfigs({});
128
+ setDraftSeeds({});
112
129
  setEditing(null);
113
130
  };
114
131
 
115
132
  /**
116
- * Compose the draft names into config-bearing elements. `output_schema` takes the
117
- * inline-authored config; every other name keeps the edited combo's existing
118
- * element verbatim when it survives — preserving any author `config` (e.g. an
119
- * `ask_external` verifier) the builder does not itself author — and a newly-added
120
- * name becomes a bare-name element.
133
+ * Compose the draft names into elements. An extension whose catalog entry declares
134
+ * a `config_schema` is authored as `{ name, config }` through its settings form;
135
+ * any other name stays a bare element, or the edited combo's own element when it
136
+ * survives the edit.
121
137
  */
122
138
  const composeCombo = (): PresetExtensionElement[] => {
123
139
  const source = editing === null ? undefined : value[editing];
@@ -125,11 +141,10 @@ export function ExtensionComboBuilder({
125
141
  if (source !== undefined) {
126
142
  for (const element of source) byName.set(extensionElementName(element), element);
127
143
  }
128
- return draft.map((name) =>
129
- name === OUTPUT_SCHEMA
130
- ? { name, config: { schema: draftConfig.schema ?? {} } }
131
- : (byName.get(name) ?? name),
132
- );
144
+ return draft.map((name) => {
145
+ const report = settingsSchemas.has(name) ? draftConfigs[name] : undefined;
146
+ return report === undefined ? (byName.get(name) ?? name) : { name, config: report.config };
147
+ });
133
148
  };
134
149
 
135
150
  const commitCombo = (): void => {
@@ -146,11 +161,28 @@ export function ExtensionComboBuilder({
146
161
  const startEdit = (index: number): void => {
147
162
  const combo = value[index];
148
163
  if (combo === undefined) return;
164
+ const seeds: Record<string, Record<string, unknown>> = {};
165
+ for (const element of combo) {
166
+ if (typeof element !== 'string') seeds[element.name] = element.config;
167
+ }
149
168
  setDraft(comboElementNames(combo));
150
- setDraftConfig({ schema: outputSchemaConfig(combo), valid: true });
169
+ setDraftConfigs({});
170
+ setDraftSeeds(seeds);
151
171
  setEditing(index);
152
172
  };
153
173
 
174
+ const changeDraft = (next: string[]): void => {
175
+ setDraft(next);
176
+ // An unticked extension's settings report goes with it.
177
+ setDraftConfigs((current) =>
178
+ Object.fromEntries(Object.entries(current).filter(([name]) => next.includes(name))),
179
+ );
180
+ };
181
+
182
+ const changeDraftConfig = (name: string, change: ExtensionConfigChange): void => {
183
+ setDraftConfigs((current) => ({ ...current, [name]: change }));
184
+ };
185
+
154
186
  const removeCombo = (index: number): void => {
155
187
  onChange(value.filter((_, i) => i !== index).map((combo) => [...combo]));
156
188
  // Removing the row under edit (or one before it) invalidates the draft target.
@@ -172,10 +204,10 @@ export function ExtensionComboBuilder({
172
204
  <ComboDraftEditor
173
205
  available={available}
174
206
  draft={draft}
175
- onDraftChange={setDraft}
176
- draftHasOutputSchema={draftHasOutputSchema}
177
- draftConfig={draftConfig}
178
- onDraftConfigChange={setDraftConfig}
207
+ onDraftChange={changeDraft}
208
+ configurable={configurable}
209
+ draftConfigs={draftSeeds}
210
+ onDraftConfigChange={changeDraftConfig}
179
211
  isDuplicate={isDuplicate}
180
212
  canAdd={canAdd}
181
213
  editing={editing}
@@ -7,35 +7,18 @@ import type { Extension, PresetExtensionElement } from '@tai42/api-client';
7
7
  import { Fragment, type ReactNode } from 'react';
8
8
 
9
9
  import { comboElementNames } from '../extension-combos';
10
- import { SchemaEditor, type SchemaEditorChange } from '../schema-editor';
10
+ import type { JsonSchema } from '../schema-form/types';
11
11
  import { Badge } from './badge';
12
+ import { type ExtensionConfigChange, ExtensionConfigEditor } from './extension-config-editor';
12
13
  import { ExtensionPicker } from './extension-picker';
13
14
  import { CloseIcon, XCircleIcon } from './icons';
14
15
  import { Button } from './primitives';
15
16
 
16
- /** The one config-taking extension this builder authors inline. */
17
- export const OUTPUT_SCHEMA = 'output_schema';
18
-
19
17
  /** Two combos are equal when their NAME sequences match (config is not part of identity). */
20
18
  export function sameNames(a: readonly string[], b: readonly string[]): boolean {
21
19
  return a.length === b.length && a.every((name, index) => name === b[index]);
22
20
  }
23
21
 
24
- /** The stored `output_schema` config schema for a combo, if it carries one. */
25
- export function outputSchemaConfig(
26
- combo: readonly PresetExtensionElement[],
27
- ): Record<string, unknown> | null {
28
- for (const element of combo) {
29
- if (typeof element !== 'string' && element.name === OUTPUT_SCHEMA) {
30
- const schema = element.config.schema;
31
- return typeof schema === 'object' && schema !== null && !Array.isArray(schema)
32
- ? (schema as Record<string, unknown>)
33
- : {};
34
- }
35
- }
36
- return null;
37
- }
38
-
39
22
  /** The element names in a combo that are absent from the known catalog. */
40
23
  export function unknownNames(
41
24
  combo: readonly PresetExtensionElement[],
@@ -158,14 +141,20 @@ export function CommittedComboList({
158
141
  );
159
142
  }
160
143
 
161
- /** The combo under construction: the extension picker, the inline output-schema
162
- * editor when present, the duplicate note, and the Add/Update + Cancel actions. */
144
+ /** A drafted extension whose catalog entry declares a settings schema. */
145
+ export interface ConfigurableExtension {
146
+ readonly name: string;
147
+ readonly schema: JsonSchema;
148
+ }
149
+
150
+ /** The combo under construction: the extension picker, one settings form per drafted
151
+ * configurable extension, the duplicate note, and the Add/Update + Cancel actions. */
163
152
  export function ComboDraftEditor({
164
153
  available,
165
154
  draft,
166
155
  onDraftChange,
167
- draftHasOutputSchema,
168
- draftConfig,
156
+ configurable,
157
+ draftConfigs,
169
158
  onDraftConfigChange,
170
159
  isDuplicate,
171
160
  canAdd,
@@ -178,9 +167,9 @@ export function ComboDraftEditor({
178
167
  readonly available: readonly Extension[];
179
168
  readonly draft: string[];
180
169
  readonly onDraftChange: (draft: string[]) => void;
181
- readonly draftHasOutputSchema: boolean;
182
- readonly draftConfig: SchemaEditorChange;
183
- readonly onDraftConfigChange: (change: SchemaEditorChange) => void;
170
+ readonly configurable: readonly ConfigurableExtension[];
171
+ readonly draftConfigs: Readonly<Record<string, Record<string, unknown> | undefined>>;
172
+ readonly onDraftConfigChange: (name: string, change: ExtensionConfigChange) => void;
184
173
  readonly isDuplicate: boolean;
185
174
  readonly canAdd: boolean;
186
175
  readonly editing: number | null;
@@ -199,20 +188,21 @@ export function ComboDraftEditor({
199
188
  idPrefix={`${idPrefix}-draft`}
200
189
  />
201
190
 
202
- {draftHasOutputSchema ? (
203
- <SchemaEditor
204
- value={draftConfig.schema}
205
- onChange={onDraftConfigChange}
206
- requireTitle={false}
207
- label="Output schema"
208
- description="The JSON Schema the output_schema extension enforces on this extension set's result."
209
- disabled={disabled}
210
- idPrefix={`${idPrefix}-output-schema`}
211
- // Re-seed the editor when the draft target changes (a new draft vs a
191
+ {configurable.map(({ name, schema }) => (
192
+ <ExtensionConfigEditor
193
+ // Re-seed the form when the draft target changes (a new draft vs a
212
194
  // specific combo pulled in for edit).
213
- key={editing === null ? 'new' : `edit-${String(editing)}`}
195
+ key={`${editing === null ? 'new' : String(editing)}-${name}`}
196
+ name={name}
197
+ schema={schema}
198
+ value={draftConfigs[name]}
199
+ onChange={(change) => {
200
+ onDraftConfigChange(name, change);
201
+ }}
202
+ disabled={disabled}
203
+ idPrefix={`${idPrefix}-settings-${name}`}
214
204
  />
215
- ) : null}
205
+ ))}
216
206
 
217
207
  {isDuplicate ? (
218
208
  <p role="alert" className="tai-field-error">