@elabs-ai/components-ai 4.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 (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,845 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MessageForm — a model-emittable form rendered inside a chat message.
5
+ *
6
+ * The model emits a serializable `FormSpec` (see `message-form-spec.ts`); the
7
+ * user fills it; the app receives structured `{ formName, values }` on submit.
8
+ *
9
+ * Design bar (mirrors AutoChart + ChangeReview):
10
+ * - Spec-driven, zod-validated. The model is the author; it never chooses look.
11
+ * - Never throws on model output. A malformed spec → `MessageFormFallback`.
12
+ * - Streaming-tolerant. Half-arrived fields are dropped (not fatal); an empty
13
+ * spec while streaming shows a skeleton, never a crash.
14
+ * - Compound + lifted state, controlled AND uncontrolled (the ChangeReview
15
+ * pattern): `MessageFormProvider` owns the values; the parts read a context.
16
+ * - Tokens only; keyboard-operable; inline errors; focus the first error on
17
+ * submit; a submitted form renders inert with its values visible (a chat
18
+ * message is a historical record).
19
+ *
20
+ * Composes `@elabs-ai/components-ui` inputs — it does NOT re-invent field primitives.
21
+ *
22
+ * Compound structure (named exports, the Card/CardHeader convention):
23
+ * <MessageFormProvider> — lifted state (values + errors + actions)
24
+ * <MessageFormRoot> — the <form> element (never nest inside another form)
25
+ * <MessageFormFields> — every field, or place <MessageFormField> yourself
26
+ * <MessageFormSubmit> — submit button / submitting spinner / submitted note
27
+ */
28
+
29
+ import {
30
+ createContext,
31
+ forwardRef,
32
+ use,
33
+ useCallback,
34
+ useId,
35
+ useMemo,
36
+ useState,
37
+ type FormEvent,
38
+ type HTMLAttributes,
39
+ type ReactNode,
40
+ } from "react";
41
+ import { Check } from "lucide-react";
42
+ import {
43
+ Badge,
44
+ Button,
45
+ Checkbox,
46
+ Input,
47
+ Label,
48
+ NumberInput,
49
+ Select,
50
+ SelectContent,
51
+ SelectItem,
52
+ SelectTrigger,
53
+ SelectValue,
54
+ Skeleton,
55
+ Spinner,
56
+ Textarea,
57
+ } from "@elabs-ai/components-ui";
58
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
59
+ import { useLocale } from "@elabs-ai/components-ui";
60
+
61
+ import {
62
+ fieldLabel,
63
+ initialFormValues,
64
+ normalizeFormSpec,
65
+ optionLabel,
66
+ optionValue,
67
+ validateForm,
68
+ type FieldSpec,
69
+ type FormSpec,
70
+ type FormSubmitState,
71
+ type FormValue,
72
+ type FormValues,
73
+ type NormalizedFormSpec,
74
+ } from "./message-form-spec";
75
+
76
+ // ─── Context ────────────────────────────────────────────────────────────────
77
+
78
+ interface MessageFormContextValue {
79
+ spec: NormalizedFormSpec;
80
+ values: FormValues;
81
+ /** Per-field error text, only populated after a submit attempt. */
82
+ errors: Record<string, string | null>;
83
+ setValue: (name: string, value: FormValue) => void;
84
+ submit: () => void;
85
+ reset: () => void;
86
+ /** True once submit has been attempted (drives error visibility). */
87
+ attempted: boolean;
88
+ submitted: boolean;
89
+ submitting: boolean;
90
+ disabled: boolean;
91
+ streaming: boolean;
92
+ formId: string;
93
+ headingId: string;
94
+ }
95
+
96
+ const MessageFormContext = createContext<MessageFormContextValue | null>(null);
97
+
98
+ function useMessageFormContext(): MessageFormContextValue {
99
+ const ctx = use(MessageFormContext);
100
+ if (!ctx) {
101
+ throw new Error("MessageForm sub-components must be rendered inside <MessageFormProvider>.");
102
+ }
103
+ return ctx;
104
+ }
105
+
106
+ /** Stable DOM id for a field's primary control (used for label + focus). */
107
+ function controlId(formId: string, name: string): string {
108
+ return `${formId}-field-${name}`;
109
+ }
110
+ function descId(formId: string, name: string): string {
111
+ return `${formId}-desc-${name}`;
112
+ }
113
+ function errorId(formId: string, name: string): string {
114
+ return `${formId}-error-${name}`;
115
+ }
116
+
117
+ /** The effective value for a field (state value, else its default). */
118
+ function effectiveValue(field: FieldSpec, values: FormValues): FormValue {
119
+ const v = values[field.name];
120
+ if (v !== undefined) return v;
121
+ if (field.default !== undefined) return field.default;
122
+ if (field.type === "boolean") return false;
123
+ if (field.type === "multi-enum") return [];
124
+ return undefined;
125
+ }
126
+
127
+ // ─── Provider ───────────────────────────────────────────────────────────────
128
+
129
+ export interface MessageFormProviderProps {
130
+ /** A validated/normalized spec (a plain `FormSpec` also satisfies this). */
131
+ spec: NormalizedFormSpec;
132
+ /**
133
+ * Controlled values. When provided the component is controlled and `onChange`
134
+ * is the only way to update state.
135
+ */
136
+ values?: FormValues;
137
+ /** Called with the next full values object on any field change. */
138
+ onChange?: (values: FormValues) => void;
139
+ /** Called with `{ formName, values }` when a valid form is submitted. */
140
+ onSubmit?: (state: FormSubmitState) => void;
141
+ /** Disable every control (form is not interactable). */
142
+ disabled?: boolean;
143
+ /** Terminal submitted state: controls are inert, values visible, no submit. */
144
+ submitted?: boolean;
145
+ /** In-flight submit: controls disabled, the submit button shows a spinner. */
146
+ submitting?: boolean;
147
+ /** The spec is still streaming in (renders a skeleton when no fields yet). */
148
+ streaming?: boolean;
149
+ children: ReactNode;
150
+ }
151
+
152
+ /**
153
+ * Lifts the form values. Controlled (pass `values`) or uncontrolled.
154
+ * Derives `isControlled = values !== undefined` and never flips modes.
155
+ */
156
+ export function MessageFormProvider({
157
+ spec,
158
+ values: valuesProp,
159
+ onChange,
160
+ onSubmit,
161
+ disabled = false,
162
+ submitted = false,
163
+ submitting = false,
164
+ streaming = false,
165
+ children,
166
+ }: MessageFormProviderProps) {
167
+ const formId = useId();
168
+ const headingId = `${formId}-title`;
169
+
170
+ const isControlled = valuesProp !== undefined;
171
+ const [internalValues, setInternalValues] = useState<FormValues>(() =>
172
+ initialFormValues(spec.fields),
173
+ );
174
+ const [attempted, setAttempted] = useState(false);
175
+
176
+ const resolvedValues = isControlled ? (valuesProp as FormValues) : internalValues;
177
+
178
+ const setValue = useCallback(
179
+ (name: string, value: FormValue) => {
180
+ const base = isControlled ? (valuesProp as FormValues) : internalValues;
181
+ const next: FormValues = { ...base, [name]: value };
182
+ if (!isControlled) setInternalValues(next);
183
+ onChange?.(next);
184
+ },
185
+ [isControlled, valuesProp, internalValues, onChange],
186
+ );
187
+
188
+ const submit = useCallback(() => {
189
+ setAttempted(true);
190
+ // Build the effective values (defaults folded in) for validation + payload.
191
+ const merged: FormValues = { ...resolvedValues };
192
+ for (const field of spec.fields) merged[field.name] = effectiveValue(field, resolvedValues);
193
+
194
+ const errs = validateForm(spec.fields, merged);
195
+ const firstInvalid = spec.fields.find((f) => errs[f.name]);
196
+ if (firstInvalid) {
197
+ // Focus the first field with an error (a11y: don't leave the user hunting).
198
+ if (typeof document !== "undefined") {
199
+ const el = document.getElementById(controlId(formId, firstInvalid.name));
200
+ el?.focus();
201
+ }
202
+ return;
203
+ }
204
+ onSubmit?.({ formName: spec.formName, values: merged });
205
+ }, [resolvedValues, spec.fields, spec.formName, formId, onSubmit]);
206
+
207
+ const reset = useCallback(() => {
208
+ setAttempted(false);
209
+ const seeded = initialFormValues(spec.fields);
210
+ if (!isControlled) setInternalValues(seeded);
211
+ onChange?.(seeded);
212
+ }, [isControlled, spec.fields, onChange]);
213
+
214
+ // Errors only surface after a submit attempt; then they live-update on change.
215
+ const errors = useMemo<Record<string, string | null>>(() => {
216
+ if (!attempted) return {};
217
+ const merged: FormValues = { ...resolvedValues };
218
+ for (const field of spec.fields) merged[field.name] = effectiveValue(field, resolvedValues);
219
+ return validateForm(spec.fields, merged);
220
+ }, [attempted, resolvedValues, spec.fields]);
221
+
222
+ const value = useMemo<MessageFormContextValue>(
223
+ () => ({
224
+ spec,
225
+ values: resolvedValues,
226
+ errors,
227
+ setValue,
228
+ submit,
229
+ reset,
230
+ attempted,
231
+ submitted,
232
+ submitting,
233
+ disabled,
234
+ streaming,
235
+ formId,
236
+ headingId,
237
+ }),
238
+ [
239
+ spec,
240
+ resolvedValues,
241
+ errors,
242
+ setValue,
243
+ submit,
244
+ reset,
245
+ attempted,
246
+ submitted,
247
+ submitting,
248
+ disabled,
249
+ streaming,
250
+ formId,
251
+ headingId,
252
+ ],
253
+ );
254
+
255
+ return <MessageFormContext.Provider value={value}>{children}</MessageFormContext.Provider>;
256
+ }
257
+
258
+ // ─── Field control renderers ──────────────────────────────────────────────────
259
+
260
+ interface FieldControlProps {
261
+ field: FieldSpec;
262
+ value: FormValue;
263
+ invalid: boolean;
264
+ disabled: boolean;
265
+ readOnly: boolean;
266
+ id: string;
267
+ /** Id of the field's visible label (used by grouped controls' aria-labelledby). */
268
+ labelId: string;
269
+ describedBy: string | undefined;
270
+ setValue: (name: string, value: FormValue) => void;
271
+ }
272
+
273
+ function StringControl({
274
+ field,
275
+ value,
276
+ invalid,
277
+ disabled,
278
+ readOnly,
279
+ id,
280
+ describedBy,
281
+ setValue,
282
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "string" }> }) {
283
+ const text = value === undefined ? "" : String(value);
284
+ const commonAria = {
285
+ id,
286
+ "aria-invalid": invalid || undefined,
287
+ "aria-describedby": describedBy,
288
+ "aria-required": field.required || undefined,
289
+ required: field.required,
290
+ } as const;
291
+
292
+ if (field.multiline) {
293
+ return (
294
+ <Textarea
295
+ {...commonAria}
296
+ name={field.name}
297
+ value={text}
298
+ disabled={disabled}
299
+ readOnly={readOnly}
300
+ maxLength={field.maxLength}
301
+ onChange={(e) => setValue(field.name, e.target.value)}
302
+ />
303
+ );
304
+ }
305
+
306
+ // Map the format hint → input type + autofill/keyboard hints.
307
+ const inputType =
308
+ field.format === "email"
309
+ ? "email"
310
+ : field.format === "uri"
311
+ ? "url"
312
+ : field.format === "date"
313
+ ? "date"
314
+ : field.format === "date-time"
315
+ ? "datetime-local"
316
+ : "text";
317
+ const spellCheck = field.format === "email" || field.format === "uri" ? false : undefined;
318
+ const inputMode = field.format === "email" ? "email" : field.format === "uri" ? "url" : undefined;
319
+
320
+ return (
321
+ <Input
322
+ {...commonAria}
323
+ type={inputType}
324
+ name={field.name}
325
+ value={text}
326
+ disabled={disabled}
327
+ readOnly={readOnly}
328
+ minLength={field.minLength}
329
+ maxLength={field.maxLength}
330
+ spellCheck={spellCheck}
331
+ inputMode={inputMode}
332
+ onChange={(e) => setValue(field.name, e.target.value)}
333
+ />
334
+ );
335
+ }
336
+
337
+ function NumberControl({
338
+ field,
339
+ value,
340
+ invalid,
341
+ disabled,
342
+ readOnly,
343
+ id,
344
+ describedBy,
345
+ setValue,
346
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "number" | "integer" }> }) {
347
+ const num = typeof value === "number" ? value : null;
348
+ return (
349
+ <NumberInput
350
+ id={id}
351
+ name={field.name}
352
+ value={num}
353
+ min={field.min}
354
+ max={field.max}
355
+ step={field.type === "integer" ? 1 : undefined}
356
+ disabled={disabled}
357
+ readOnly={readOnly}
358
+ aria-invalid={invalid || undefined}
359
+ aria-describedby={describedBy}
360
+ aria-required={field.required || undefined}
361
+ onValueChange={(next) => setValue(field.name, next ?? undefined)}
362
+ />
363
+ );
364
+ }
365
+
366
+ function BooleanControl({
367
+ field,
368
+ value,
369
+ disabled,
370
+ id,
371
+ describedBy,
372
+ setValue,
373
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "boolean" }> }) {
374
+ // Checkbox + clickable label share ONE hit target (no dead zone).
375
+ return (
376
+ <div className="flex items-center gap-2">
377
+ <Checkbox
378
+ id={id}
379
+ name={field.name}
380
+ checked={value === true}
381
+ disabled={disabled}
382
+ aria-describedby={describedBy}
383
+ aria-required={field.required || undefined}
384
+ onCheckedChange={(checked) => setValue(field.name, checked === true)}
385
+ />
386
+ <Label htmlFor={id} className="flex items-center gap-1 font-normal">
387
+ {fieldLabel(field)}
388
+ {field.required && (
389
+ <span aria-hidden="true" className="text-destructive-text">
390
+ *
391
+ </span>
392
+ )}
393
+ </Label>
394
+ </div>
395
+ );
396
+ }
397
+
398
+ function EnumControl({
399
+ field,
400
+ value,
401
+ invalid,
402
+ disabled,
403
+ id,
404
+ describedBy,
405
+ setValue,
406
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "enum" }> }) {
407
+ const { t } = useLocale();
408
+ const current = typeof value === "string" && value.length > 0 ? value : undefined;
409
+ return (
410
+ <Select value={current} disabled={disabled} onValueChange={(v) => setValue(field.name, v)}>
411
+ <SelectTrigger
412
+ id={id}
413
+ aria-invalid={invalid || undefined}
414
+ aria-describedby={describedBy}
415
+ aria-required={field.required || undefined}
416
+ >
417
+ <SelectValue placeholder={t("ai.messageForm.selectPlaceholder")} />
418
+ </SelectTrigger>
419
+ <SelectContent>
420
+ {field.options.map((option) => (
421
+ <SelectItem key={optionValue(option)} value={optionValue(option)}>
422
+ {optionLabel(option)}
423
+ </SelectItem>
424
+ ))}
425
+ </SelectContent>
426
+ </Select>
427
+ );
428
+ }
429
+
430
+ function MultiEnumControl({
431
+ field,
432
+ value,
433
+ disabled,
434
+ id,
435
+ labelId,
436
+ describedBy,
437
+ setValue,
438
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "multi-enum" }> }) {
439
+ const selected = Array.isArray(value) ? value : [];
440
+ const toggle = (optValue: string, checked: boolean) => {
441
+ const next = checked ? [...selected, optValue] : selected.filter((v) => v !== optValue);
442
+ setValue(field.name, next);
443
+ };
444
+ return (
445
+ // The group is the focus-first-error target (id + tabIndex=-1). It is named
446
+ // by the field's visible label via aria-labelledby — NOT aria-label — so no
447
+ // single checkbox ends up with two <label for> associations.
448
+ <div
449
+ role="group"
450
+ id={id}
451
+ tabIndex={-1}
452
+ aria-labelledby={labelId}
453
+ aria-describedby={describedBy}
454
+ className="flex flex-col gap-2 focus:outline-none"
455
+ >
456
+ {field.options.map((option) => {
457
+ const optValue = optionValue(option);
458
+ const optionId = `${id}-${optValue}`;
459
+ return (
460
+ <div key={optValue} className="flex items-center gap-2">
461
+ <Checkbox
462
+ id={optionId}
463
+ checked={selected.includes(optValue)}
464
+ disabled={disabled}
465
+ onCheckedChange={(checked) => toggle(optValue, checked === true)}
466
+ />
467
+ <Label htmlFor={optionId} className="font-normal">
468
+ {optionLabel(option)}
469
+ </Label>
470
+ </div>
471
+ );
472
+ })}
473
+ </div>
474
+ );
475
+ }
476
+
477
+ // ─── Field ────────────────────────────────────────────────────────────────────
478
+
479
+ export interface MessageFormFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
480
+ /** The field's `name` (its key in the spec + values). */
481
+ name: string;
482
+ }
483
+
484
+ /**
485
+ * Renders one field by name: label, control, description, inline error.
486
+ * Boolean fields render their own inline label (checkbox + label), so the
487
+ * standalone `<Label>` above is suppressed for them.
488
+ */
489
+ export const MessageFormField = forwardRef<HTMLDivElement, MessageFormFieldProps>(
490
+ function MessageFormField({ name, className, ...props }, ref) {
491
+ const ctx = useMessageFormContext();
492
+ const field = ctx.spec.fields.find((f) => f.name === name);
493
+ if (!field) return null;
494
+
495
+ const id = controlId(ctx.formId, name);
496
+ const labelId = `${ctx.formId}-label-${name}`;
497
+ const value = effectiveValue(field, ctx.values);
498
+ const error = ctx.errors[name] ?? null;
499
+ const invalid = Boolean(error);
500
+ const controlDisabled = ctx.disabled || ctx.submitted || ctx.submitting;
501
+ const readOnly = ctx.submitted;
502
+ const description = field.description;
503
+ const hasDesc = Boolean(description);
504
+ const describedBy =
505
+ [hasDesc ? descId(ctx.formId, name) : null, invalid ? errorId(ctx.formId, name) : null]
506
+ .filter(Boolean)
507
+ .join(" ") || undefined;
508
+
509
+ const controlProps: FieldControlProps = {
510
+ field,
511
+ value,
512
+ invalid,
513
+ disabled: controlDisabled,
514
+ readOnly,
515
+ id,
516
+ labelId,
517
+ describedBy,
518
+ setValue: ctx.setValue,
519
+ };
520
+
521
+ const isBoolean = field.type === "boolean";
522
+ // A multi-enum renders a checkbox group named via aria-labelledby, so its
523
+ // label must NOT use htmlFor (which would associate with a single control).
524
+ const isGroupField = field.type === "multi-enum";
525
+
526
+ return (
527
+ <div ref={ref} className={cn("flex flex-col gap-1.5", className)} {...props}>
528
+ {!isBoolean && (
529
+ <Label
530
+ id={labelId}
531
+ htmlFor={isGroupField ? undefined : id}
532
+ className="flex items-center gap-1"
533
+ >
534
+ {fieldLabel(field)}
535
+ {field.required && (
536
+ <span aria-hidden="true" className="text-destructive-text">
537
+ *
538
+ </span>
539
+ )}
540
+ </Label>
541
+ )}
542
+
543
+ {field.type === "string" && <StringControl {...controlProps} field={field} />}
544
+ {(field.type === "number" || field.type === "integer") && (
545
+ <NumberControl {...controlProps} field={field} />
546
+ )}
547
+ {field.type === "boolean" && <BooleanControl {...controlProps} field={field} />}
548
+ {field.type === "enum" && <EnumControl {...controlProps} field={field} />}
549
+ {field.type === "multi-enum" && <MultiEnumControl {...controlProps} field={field} />}
550
+
551
+ {hasDesc && (
552
+ <p id={descId(ctx.formId, name)} className="text-caption text-muted-foreground">
553
+ {description}
554
+ </p>
555
+ )}
556
+ {invalid && (
557
+ <p id={errorId(ctx.formId, name)} className="text-caption text-destructive-text">
558
+ {error}
559
+ </p>
560
+ )}
561
+ </div>
562
+ );
563
+ },
564
+ );
565
+
566
+ // ─── Fields (all) ─────────────────────────────────────────────────────────────
567
+
568
+ export type MessageFormFieldsProps = HTMLAttributes<HTMLDivElement>;
569
+
570
+ /** Renders every field in the spec, in order. A skeleton while streaming empty. */
571
+ export const MessageFormFields = forwardRef<HTMLDivElement, MessageFormFieldsProps>(
572
+ function MessageFormFields({ className, ...props }, ref) {
573
+ const { spec, streaming } = useMessageFormContext();
574
+
575
+ if (spec.fields.length === 0 && streaming) {
576
+ return (
577
+ <div ref={ref} className={cn("flex flex-col gap-4", className)} {...props}>
578
+ <span className="sr-only" role="status" aria-live="polite">
579
+ Loading form…
580
+ </span>
581
+ <div aria-hidden="true" className="flex flex-col gap-4">
582
+ {[0, 1, 2].map((i) => (
583
+ <div key={i} className="flex flex-col gap-1.5">
584
+ <Skeleton className="h-4 w-24" />
585
+ <Skeleton className="h-9 w-full" />
586
+ </div>
587
+ ))}
588
+ </div>
589
+ </div>
590
+ );
591
+ }
592
+
593
+ return (
594
+ <div ref={ref} className={cn("flex flex-col gap-4", className)} {...props}>
595
+ {spec.fields.map((field) => (
596
+ <MessageFormField key={field.name} name={field.name} />
597
+ ))}
598
+ </div>
599
+ );
600
+ },
601
+ );
602
+
603
+ // ─── Submit ───────────────────────────────────────────────────────────────────
604
+
605
+ export interface MessageFormSubmitProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
606
+ /** Submit button label (overrides `spec.submitLabel`). @default "Submit" */
607
+ label?: string;
608
+ }
609
+
610
+ /**
611
+ * The submit affordance. Enabled until the request starts (validation runs on
612
+ * click), then a spinner. A submitted form shows an inert "Submitted" note.
613
+ */
614
+ export const MessageFormSubmit = forwardRef<HTMLDivElement, MessageFormSubmitProps>(
615
+ function MessageFormSubmit({ label, className, ...props }, ref) {
616
+ const { spec, submitting, submitted, disabled } = useMessageFormContext();
617
+
618
+ if (submitted) {
619
+ return (
620
+ <div ref={ref} className={cn("flex items-center", className)} {...props}>
621
+ <Badge variant="success" aria-live="polite">
622
+ <Check aria-hidden="true" className="size-3" />
623
+ Submitted
624
+ </Badge>
625
+ </div>
626
+ );
627
+ }
628
+
629
+ const text = label ?? spec.submitLabel ?? "Submit";
630
+ return (
631
+ <div ref={ref} className={cn("flex items-center", className)} {...props}>
632
+ <Button type="submit" disabled={disabled || submitting} aria-busy={submitting || undefined}>
633
+ {submitting && <Spinner aria-hidden="true" className="text-current" />}
634
+ {submitting ? "Submitting…" : text}
635
+ </Button>
636
+ </div>
637
+ );
638
+ },
639
+ );
640
+
641
+ // ─── Root (the <form> element) ────────────────────────────────────────────────
642
+
643
+ export type MessageFormRootProps = Omit<HTMLAttributes<HTMLFormElement>, "onSubmit">;
644
+
645
+ /**
646
+ * The `<form>` element wired to the context's `submit`. Never nest inside
647
+ * another `<form>` — compose it as its own message-body block.
648
+ */
649
+ export const MessageFormRoot = forwardRef<HTMLFormElement, MessageFormRootProps>(
650
+ function MessageFormRoot({ className, children, ...props }, ref) {
651
+ const { t } = useLocale();
652
+ const { submit, headingId, spec, disabled, submitting } = useMessageFormContext();
653
+ const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
654
+ e.preventDefault();
655
+ if (disabled || submitting) return;
656
+ submit();
657
+ };
658
+ return (
659
+ <form
660
+ ref={ref}
661
+ noValidate
662
+ onSubmit={handleSubmit}
663
+ aria-labelledby={spec.title ? headingId : undefined}
664
+ aria-label={spec.title ? undefined : spec.formName || t("ai.messageForm.label")}
665
+ className={cn("flex w-full flex-col gap-4", className)}
666
+ {...props}
667
+ >
668
+ {children}
669
+ </form>
670
+ );
671
+ },
672
+ );
673
+
674
+ // ─── Title + Description ──────────────────────────────────────────────────────
675
+
676
+ export type MessageFormTitleProps = HTMLAttributes<HTMLParagraphElement>;
677
+
678
+ /** The form heading. Its id is the `<form>`'s `aria-labelledby` target. */
679
+ export const MessageFormTitle = forwardRef<HTMLParagraphElement, MessageFormTitleProps>(
680
+ function MessageFormTitle({ className, children, ...props }, ref) {
681
+ const { headingId, spec } = useMessageFormContext();
682
+ const content = children ?? spec.title;
683
+ if (!content) return null;
684
+ return (
685
+ <p
686
+ ref={ref}
687
+ id={headingId}
688
+ className={cn("text-subtitle font-semibold text-foreground text-balance", className)}
689
+ {...props}
690
+ >
691
+ {content}
692
+ </p>
693
+ );
694
+ },
695
+ );
696
+
697
+ export type MessageFormDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
698
+
699
+ /** Supplemental description under the title. */
700
+ export const MessageFormDescription = forwardRef<HTMLParagraphElement, MessageFormDescriptionProps>(
701
+ function MessageFormDescription({ className, children, ...props }, ref) {
702
+ const { spec } = useMessageFormContext();
703
+ const content = children ?? spec.description;
704
+ if (!content) return null;
705
+ return (
706
+ <p
707
+ ref={ref}
708
+ className={cn("text-body text-muted-foreground text-pretty", className)}
709
+ {...props}
710
+ >
711
+ {content}
712
+ </p>
713
+ );
714
+ },
715
+ );
716
+
717
+ // ─── Fallback ─────────────────────────────────────────────────────────────────
718
+
719
+ export interface MessageFormFallbackProps extends HTMLAttributes<HTMLDivElement> {
720
+ /** Short human reason the form could not render. */
721
+ message?: string;
722
+ }
723
+
724
+ /**
725
+ * Shown when the spec is unusable. Mirrors `ChartFallback`: a calm, bordered
726
+ * status box with a short reason — never a thrown error.
727
+ */
728
+ export const MessageFormFallback = forwardRef<HTMLDivElement, MessageFormFallbackProps>(
729
+ function MessageFormFallback(
730
+ { message = "This form could not be displayed.", className, ...props },
731
+ ref,
732
+ ) {
733
+ return (
734
+ <div
735
+ ref={ref}
736
+ role="status"
737
+ aria-live="polite"
738
+ className={cn(
739
+ // Sole structural cue is the border (no fill) — reads in every theme.
740
+ "flex items-center justify-center rounded-md border border-border-strong px-4 py-6 text-center text-body text-muted-foreground",
741
+ className,
742
+ )}
743
+ {...props}
744
+ >
745
+ {message}
746
+ </div>
747
+ );
748
+ },
749
+ );
750
+
751
+ // ─── Root convenience component ────────────────────────────────────────────────
752
+
753
+ export interface MessageFormProps extends Omit<
754
+ HTMLAttributes<HTMLDivElement>,
755
+ "onSubmit" | "onChange" | "title"
756
+ > {
757
+ /** The serializable form specification produced by an LLM tool-call. */
758
+ spec: FormSpec | unknown;
759
+ /** Controlled values (`{ [fieldName]: value }`). Omit for uncontrolled. */
760
+ values?: FormValues;
761
+ /** Called with the next full values object on any change. */
762
+ onChange?: (values: FormValues) => void;
763
+ /** Called with `{ formName, values }` on a valid submit. */
764
+ onSubmit?: (state: FormSubmitState) => void;
765
+ /** Submit button label (overrides `spec.submitLabel`). */
766
+ submitLabel?: string;
767
+ /** Disable every control. */
768
+ disabled?: boolean;
769
+ /** Terminal submitted state: inert, values visible, submit replaced. */
770
+ submitted?: boolean;
771
+ /** In-flight submit: controls disabled, spinner on submit. */
772
+ submitting?: boolean;
773
+ /** The spec is still streaming (renders a skeleton when no fields yet). */
774
+ streaming?: boolean;
775
+ }
776
+
777
+ /**
778
+ * Convenience composition: `Provider → Root(form) → title/description → Fields →
779
+ * Submit`. For a custom layout, compose `MessageFormProvider` + the parts.
780
+ * A malformed spec renders `MessageFormFallback` (never throws).
781
+ */
782
+ export const MessageForm = forwardRef<HTMLDivElement, MessageFormProps>(function MessageForm(
783
+ {
784
+ spec,
785
+ values,
786
+ onChange,
787
+ onSubmit,
788
+ submitLabel,
789
+ disabled,
790
+ submitted,
791
+ submitting,
792
+ streaming,
793
+ className,
794
+ ...props
795
+ },
796
+ ref,
797
+ ) {
798
+ const result = normalizeFormSpec(spec);
799
+ if (!result.ok) {
800
+ return (
801
+ <MessageFormFallback ref={ref} message={result.reason} className={className} {...props} />
802
+ );
803
+ }
804
+
805
+ const normalized = result.spec;
806
+ const empty = normalized.fields.length === 0;
807
+
808
+ // Not streaming + no fields → the spec is structurally valid but useless.
809
+ if (empty && !streaming) {
810
+ return (
811
+ <MessageFormFallback
812
+ ref={ref}
813
+ message="This form has no fields to fill in."
814
+ className={className}
815
+ {...props}
816
+ />
817
+ );
818
+ }
819
+
820
+ return (
821
+ <MessageFormProvider
822
+ spec={normalized}
823
+ values={values}
824
+ onChange={onChange}
825
+ onSubmit={onSubmit}
826
+ disabled={disabled}
827
+ submitted={submitted}
828
+ submitting={submitting}
829
+ streaming={streaming}
830
+ >
831
+ <div ref={ref} className={cn("w-full", className)} {...props}>
832
+ <MessageFormRoot>
833
+ {(normalized.title || normalized.description) && (
834
+ <div className="flex flex-col gap-1">
835
+ <MessageFormTitle />
836
+ <MessageFormDescription />
837
+ </div>
838
+ )}
839
+ <MessageFormFields />
840
+ <MessageFormSubmit label={submitLabel} />
841
+ </MessageFormRoot>
842
+ </div>
843
+ </MessageFormProvider>
844
+ );
845
+ });