@elabs-ai/components-ai 4.0.0 → 4.2.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/README.md +62 -12
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1152 -218
  9. package/dist/index.js +5601 -3241
  10. package/dist/index.js.map +1 -1
  11. package/package.json +30 -15
  12. package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
  13. package/src/__contract__/chat-shell.contract.test.tsx +49 -0
  14. package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
  15. package/src/__contract__/image.contract.test.tsx +49 -0
  16. package/src/__contract__/markdown-view.contract.test.tsx +49 -0
  17. package/src/__contract__/message-feedback.contract.test.tsx +49 -0
  18. package/src/__contract__/message-form.contract.test.tsx +49 -0
  19. package/src/__contract__/message-table.contract.test.tsx +49 -0
  20. package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
  21. package/src/__contract__/persona.contract.test.tsx +49 -0
  22. package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
  23. package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
  24. package/src/_audio-player-media-chrome.tsx +102 -15
  25. package/src/_chat-shell-rail.tsx +2 -2
  26. package/src/_flow-boundary.tsx +68 -49
  27. package/src/_lazy-boundary-conformance.ts +38 -0
  28. package/src/_lazy-cjk.test.ts +43 -0
  29. package/src/_lazy-cjk.ts +73 -0
  30. package/src/_lazy-engine-boundary.tsx +61 -0
  31. package/src/_lazy-math.test.ts +63 -0
  32. package/src/_lazy-math.ts +90 -0
  33. package/src/_lazy-mermaid-absent.test.ts +53 -0
  34. package/src/_lazy-mermaid.test.ts +15 -0
  35. package/src/_lazy-mermaid.ts +24 -1
  36. package/src/_mermaid-error-panel.test.tsx +50 -0
  37. package/src/_mermaid-error-panel.tsx +66 -0
  38. package/src/_persona-rive.tsx +62 -10
  39. package/src/_streamdown-i18n.ts +94 -22
  40. package/src/_streamdown-safety.ts +170 -0
  41. package/src/_theme-scope-store.test.ts +83 -0
  42. package/src/_theme-scope-store.ts +103 -0
  43. package/src/agent-event.stories.tsx +97 -0
  44. package/src/agent-event.test.tsx +145 -0
  45. package/src/agent-event.tsx +187 -0
  46. package/src/agent-timeline.stories.tsx +9 -1
  47. package/src/agent.test.tsx +47 -0
  48. package/src/agent.tsx +43 -24
  49. package/src/agentic-workspace.stories.tsx +27 -20
  50. package/src/artifact.tsx +17 -14
  51. package/src/asset-preview.test.tsx +40 -0
  52. package/src/asset-preview.tsx +83 -12
  53. package/src/attachments.tsx +7 -4
  54. package/src/audio-player.test.tsx +78 -0
  55. package/src/audio-player.tsx +243 -56
  56. package/src/audio-visualizer.stories.tsx +126 -0
  57. package/src/audio-visualizer.test.tsx +438 -0
  58. package/src/audio-visualizer.tsx +367 -0
  59. package/src/canvas.stories.tsx +150 -1
  60. package/src/chat-shell.stories.tsx +18 -3
  61. package/src/chat.stories.tsx +16 -2
  62. package/src/code-block.stories.tsx +9 -1
  63. package/src/code-block.test.tsx +100 -1
  64. package/src/code-block.tsx +166 -103
  65. package/src/commit.tsx +30 -41
  66. package/src/composer.stories.tsx +372 -9
  67. package/src/composer.test.tsx +357 -6
  68. package/src/composer.tsx +217 -35
  69. package/src/confirmation.stories.tsx +72 -1
  70. package/src/confirmation.test.tsx +216 -2
  71. package/src/confirmation.tsx +264 -4
  72. package/src/context-panel.stories.tsx +9 -1
  73. package/src/context-panel.tsx +3 -2
  74. package/src/conversation.stories.tsx +86 -2
  75. package/src/conversation.test.tsx +66 -0
  76. package/src/conversation.tsx +52 -9
  77. package/src/diff-view.stories.tsx +196 -0
  78. package/src/diff-view.test.tsx +238 -0
  79. package/src/diff-view.tsx +697 -0
  80. package/src/environment-variables.tsx +20 -37
  81. package/src/file-tree.test.tsx +21 -0
  82. package/src/file-tree.tsx +12 -2
  83. package/src/gallery.tsx +4 -2
  84. package/src/index.ts +14 -4
  85. package/src/inline-citation.tsx +5 -5
  86. package/src/jsx-preview.stories.tsx +2 -2
  87. package/src/jsx-preview.tsx +151 -43
  88. package/src/markdown-view.stories.tsx +92 -1
  89. package/src/markdown-view.test.tsx +237 -2
  90. package/src/markdown-view.tsx +157 -6
  91. package/src/message-compare.stories.tsx +175 -0
  92. package/src/message-compare.test.tsx +207 -0
  93. package/src/message-compare.tsx +453 -0
  94. package/src/message-form.stories.tsx +65 -4
  95. package/src/message-form.test.tsx +8 -2
  96. package/src/message-form.tsx +15 -7
  97. package/src/message-table.stories.tsx +2 -2
  98. package/src/message-table.test.tsx +7 -0
  99. package/src/message-table.tsx +8 -4
  100. package/src/message.stories.tsx +9 -1
  101. package/src/message.test.tsx +247 -1
  102. package/src/message.tsx +125 -15
  103. package/src/microcopy.test.tsx +40 -0
  104. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  105. package/src/model-provider-logo.test.tsx +109 -0
  106. package/src/model-provider-logo.tsx +183 -0
  107. package/src/open-in-chat.tsx +50 -29
  108. package/src/package-info.tsx +12 -12
  109. package/src/permission-mode-select.stories.tsx +82 -0
  110. package/src/permission-mode-select.test.tsx +100 -0
  111. package/src/permission-mode-select.tsx +137 -0
  112. package/src/persona-missing-peer.test.tsx +54 -0
  113. package/src/persona.tsx +68 -22
  114. package/src/plan.stories.tsx +166 -0
  115. package/src/plan.test.tsx +267 -0
  116. package/src/plan.tsx +182 -20
  117. package/src/prompt-input-effort.stories.tsx +123 -0
  118. package/src/prompt-input-effort.test.tsx +83 -0
  119. package/src/prompt-input-effort.tsx +136 -0
  120. package/src/prompt-input-mode.stories.tsx +108 -0
  121. package/src/prompt-input-mode.test.tsx +99 -0
  122. package/src/prompt-input-mode.tsx +169 -0
  123. package/src/prompt-input-slash.stories.tsx +211 -0
  124. package/src/prompt-input-slash.test.tsx +262 -0
  125. package/src/prompt-input-slash.tsx +541 -0
  126. package/src/prompt-input.stories.tsx +2 -2
  127. package/src/prompt-input.test.tsx +67 -1
  128. package/src/prompt-input.tsx +42 -4
  129. package/src/queue.tsx +4 -4
  130. package/src/reasoning.tsx +42 -17
  131. package/src/sandbox.stories.tsx +9 -1
  132. package/src/sandbox.tsx +3 -3
  133. package/src/schema-display.test.tsx +56 -0
  134. package/src/schema-display.tsx +71 -37
  135. package/src/selection-toolbar.stories.tsx +9 -1
  136. package/src/session-header.stories.tsx +128 -0
  137. package/src/session-header.test.tsx +138 -0
  138. package/src/session-header.tsx +243 -0
  139. package/src/session-status-bar.stories.tsx +73 -0
  140. package/src/session-status-bar.test.tsx +94 -0
  141. package/src/session-status-bar.tsx +165 -0
  142. package/src/snippet.stories.tsx +9 -1
  143. package/src/snippet.test.tsx +6 -2
  144. package/src/snippet.tsx +14 -34
  145. package/src/speech-input.test.tsx +109 -0
  146. package/src/speech-input.tsx +31 -3
  147. package/src/stack-trace.tsx +24 -36
  148. package/src/streamdown-i18n.test.tsx +24 -1
  149. package/src/task.stories.tsx +13 -3
  150. package/src/templates-ai-assistant.stories.tsx +21 -1
  151. package/src/test-results.tsx +47 -27
  152. package/src/token-usage.stories.tsx +24 -0
  153. package/src/token-usage.test.tsx +92 -0
  154. package/src/{context.tsx → token-usage.tsx} +73 -59
  155. package/src/tool-result-card.stories.tsx +9 -1
  156. package/src/tool.stories.tsx +13 -3
  157. package/src/tool.test.tsx +65 -0
  158. package/src/tool.tsx +73 -30
  159. package/src/transcription.tsx +1 -1
  160. package/src/turn-status.stories.tsx +124 -0
  161. package/src/turn-status.test.tsx +74 -0
  162. package/src/turn-status.tsx +174 -0
  163. package/src/use-audio-level.ts +104 -0
  164. package/src/voice-selector.tsx +5 -5
  165. package/src/web-preview.test.tsx +51 -1
  166. package/src/web-preview.tsx +46 -10
  167. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  168. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  169. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  170. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  171. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  172. package/src/_interactive-terminal-xterm.ts +0 -32
  173. package/src/blocks-ai-composer.stories.tsx +0 -83
  174. package/src/context.stories.tsx +0 -16
  175. package/src/interactive-terminal.stories.tsx +0 -165
  176. package/src/interactive-terminal.test.tsx +0 -448
  177. package/src/interactive-terminal.tsx +0 -444
  178. package/src/model-selector.test.tsx +0 -55
  179. package/src/model-selector.tsx +0 -225
  180. package/src/terminal.tsx +0 -244
@@ -1,11 +1,24 @@
1
1
  "use client";
2
2
 
3
- import { Alert, AlertDescription, AlertTitle, StatusBadge } from "@elabs-ai/components-ui";
3
+ import {
4
+ Alert,
5
+ AlertDescription,
6
+ AlertTitle,
7
+ APPROVAL_SCOPE_DESCRIPTION_KEYS,
8
+ Kbd,
9
+ Label,
10
+ RadioGroup,
11
+ RadioGroupItem,
12
+ StatusBadge,
13
+ Textarea,
14
+ useLocale,
15
+ type ApprovalOption,
16
+ } from "@elabs-ai/components-ui";
4
17
  import { Button } from "@elabs-ai/components-ui";
5
18
  import { cn } from "@elabs-ai/components-ui/lib/cn";
6
19
  import type { ToolUIPart } from "ai";
7
20
  import type { ComponentProps, ReactNode } from "react";
8
- import { createContext, useContext, useId, useMemo } from "react";
21
+ import { createContext, forwardRef, useContext, useId, useMemo, useState } from "react";
9
22
 
10
23
  type ToolUIPartApproval =
11
24
  | {
@@ -35,6 +48,15 @@ interface ConfirmationContextValue {
35
48
  state: ToolUIPart["state"];
36
49
  /** Default id wired from the card's `aria-labelledby` to `ConfirmationTitle`. */
37
50
  titleId: string;
51
+ /**
52
+ * Shared draft reason text (#103) — written by `ApprovalCardReason`, read by
53
+ * `ApprovalCardOptions` at the moment a decision commits, so a typed
54
+ * explanation reaches `onConfirm`'s second argument without threading a
55
+ * prop between two sibling compound parts. Unused by the pre-existing
56
+ * binary `Confirmation*` parts, so their rendered output is unaffected.
57
+ */
58
+ reason: string;
59
+ setReason: (reason: string) => void;
38
60
  }
39
61
 
40
62
  const ConfirmationContext = createContext<ConfirmationContextValue | null>(null);
@@ -64,7 +86,11 @@ export const Confirmation = ({
64
86
  ...props
65
87
  }: ConfirmationProps) => {
66
88
  const titleId = useId();
67
- const contextValue = useMemo(() => ({ approval, state, titleId }), [approval, state, titleId]);
89
+ const [reason, setReason] = useState("");
90
+ const contextValue = useMemo(
91
+ () => ({ approval, state, titleId, reason, setReason }),
92
+ [approval, state, titleId, reason],
93
+ );
68
94
 
69
95
  if (!approval || state === "input-streaming" || state === "input-available") {
70
96
  return null;
@@ -225,7 +251,7 @@ export const ConfirmationActions = ({ className, ...props }: ConfirmationActions
225
251
  export type ConfirmationActionProps = ComponentProps<typeof Button>;
226
252
 
227
253
  export const ConfirmationAction = ({ className, ...props }: ConfirmationActionProps) => (
228
- <Button className={cn("h-8 px-3 text-sm", className)} type="button" {...props} />
254
+ <Button className={cn("h-8 px-3 text-body", className)} type="button" {...props} />
229
255
  );
230
256
 
231
257
  export type ConfirmationApproveProps = ConfirmationActionProps;
@@ -250,6 +276,12 @@ export const ConfirmationDeny = (props: ConfirmationDenyProps) => (
250
276
  * interaction (research 11 §B.3). Same components, clearer name; the
251
277
  * `Confirmation*` exports stay for the AI-Elements-shaped API (non-breaking).
252
278
  * New code reaches for `ApprovalCard`.
279
+ *
280
+ * The `Confirmation*` family below is CLOSED — frozen at exactly the ten
281
+ * names aliased here. New parts (#103 and beyond) are declared as real
282
+ * `ApprovalCard*` implementations, never added to this alias block. See
283
+ * `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 4 and the
284
+ * `FROZEN_CONFIRMATION_EXPORTS` lock in `confirmation.test.tsx`.
253
285
  */
254
286
  export const ApprovalCard = Confirmation;
255
287
  export type ApprovalCardProps = ConfirmationProps;
@@ -271,3 +303,231 @@ export const ApprovalCardApprove = ConfirmationApprove;
271
303
  export type ApprovalCardApproveProps = ConfirmationApproveProps;
272
304
  export const ApprovalCardDeny = ConfirmationDeny;
273
305
  export type ApprovalCardDenyProps = ConfirmationDenyProps;
306
+
307
+ /*
308
+ * ---------------------------------------------------------------------------
309
+ * N-option, scoped approval (#103)
310
+ * ---------------------------------------------------------------------------
311
+ * Real coding-agent permission prompts are rarely a plain yes/no — they ask
312
+ * an N-option question whose options encode SCOPE: "Yes", "Yes, and don't ask
313
+ * again this session", "No, and tell the agent what to do instead". The
314
+ * binary `Confirmation`/`ApprovalCard` pair above cannot express the middle
315
+ * option; these three parts extend it additively, composed as CHILDREN of the
316
+ * existing card (`ApprovalCard` / `ApprovalCardRequest`), never as a
317
+ * replacement for it. Every new export here is a real `ApprovalCard*`
318
+ * implementation — see the closed-family note above the alias block.
319
+ *
320
+ * Vocabulary note: this is the PER-CALL decision surface ("may I run this
321
+ * command?"). `PermissionModeSelect` (#104) is the separate, standing POLICY
322
+ * surface ("how much may you do without asking?") — the two compose rather
323
+ * than merge into one boolean-flagged component, per
324
+ * `.claude/rules/component-api.md` ("Avoid boolean-prop proliferation").
325
+ */
326
+
327
+ /**
328
+ * `ApprovalScope`, `ApprovalOption` and `APPROVAL_SCOPE_DESCRIPTION_KEYS`
329
+ * moved to `@elabs-ai/components-ui` (`lib/approval-option.ts`) — the
330
+ * terminal CLI look-alike family's own permission row (issue #117) reuses
331
+ * the same model, and `@elabs-ai/components-ai`/`@elabs-ai/components-terminal`
332
+ * are layer-2 DAG siblings that may not import each other (T0; see
333
+ * docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4). Imported
334
+ * above.
335
+ */
336
+
337
+ export interface ApprovalCardOptionsProps extends ComponentProps<"div"> {
338
+ options: ApprovalOption[];
339
+ value?: string;
340
+ defaultValue?: string;
341
+ onValueChange?: (id: string) => void;
342
+ /**
343
+ * Fires when an option is selected — by click, or by ANY keyboard move
344
+ * within the radio group (Home/End/arrow keys select immediately, per the
345
+ * native/ARIA radiogroup pattern Radix implements; there is no separate
346
+ * "highlight vs commit" step). Reports the full resolved `ApprovalOption`
347
+ * plus whatever reason text is currently held by a sibling
348
+ * `ApprovalCardReason` — type a reason BEFORE choosing an option to have it
349
+ * included in this call.
350
+ */
351
+ onConfirm?: (option: ApprovalOption, reason?: string) => void;
352
+ }
353
+
354
+ /**
355
+ * ApprovalCardOptions — the N-option, scoped decision (#103). Renders through
356
+ * `@elabs-ai/components-ui`'s `RadioGroup` (Radix) for roving focus, arrow-key
357
+ * wrap, Home/End and announcement — never a hand-rolled
358
+ * `parentElement.children[i]` focus walk. Composed inside `ApprovalCardRequest`,
359
+ * alongside the existing `ApprovalCardTitle`/`ApprovalCardDescription`.
360
+ */
361
+ export const ApprovalCardOptions = forwardRef<HTMLDivElement, ApprovalCardOptionsProps>(
362
+ function ApprovalCardOptions(
363
+ {
364
+ options,
365
+ value,
366
+ defaultValue,
367
+ onValueChange,
368
+ onConfirm,
369
+ className,
370
+ dir,
371
+ "aria-labelledby": ariaLabelledBy,
372
+ ...props
373
+ },
374
+ ref,
375
+ ) {
376
+ const { t } = useLocale();
377
+ const { titleId, reason } = useConfirmation();
378
+ const baseId = useId();
379
+
380
+ // Controlled/uncontrolled mirrors the platform (and `PermissionModeSelect`,
381
+ // #104): `value`/`onValueChange` for a controlled caller, `defaultValue`
382
+ // for an uncontrolled starting point — Radix's own `RadioGroup` already
383
+ // implements both correctly. Hand-rolling a parallel `internalValue`
384
+ // state here and always passing a (possibly `undefined`) `value` down
385
+ // makes Radix's controllable-state hook see a spurious
386
+ // uncontrolled-to-controlled transition the first time a selection is
387
+ // made, which is exactly the warning this avoids.
388
+ const handleValueChange = (id: string) => {
389
+ onValueChange?.(id);
390
+ const option = options.find((candidate) => candidate.id === id);
391
+ if (option) onConfirm?.(option, reason.trim() ? reason : undefined);
392
+ };
393
+
394
+ return (
395
+ <RadioGroup
396
+ ref={ref}
397
+ // `dir` on a plain <div> is a free-form HTML string; Radix's
398
+ // RadioGroup narrows it to "ltr" | "rtl". Cast at this single call
399
+ // site, matching `PermissionModeSelect` (#104), rather than widening
400
+ // (and so weakening) the public prop type.
401
+ dir={dir as "ltr" | "rtl" | undefined}
402
+ defaultValue={defaultValue}
403
+ value={value}
404
+ onValueChange={handleValueChange}
405
+ aria-labelledby={ariaLabelledBy ?? titleId}
406
+ data-slot="approval-card-options"
407
+ className={cn("grid gap-2", className)}
408
+ {...props}
409
+ >
410
+ {options.map((option) => {
411
+ const itemId = `${baseId}-${option.id}`;
412
+ const descriptionId = `${itemId}-description`;
413
+ const description =
414
+ option.description ?? t(APPROVAL_SCOPE_DESCRIPTION_KEYS[option.scope]);
415
+
416
+ return (
417
+ <div
418
+ key={option.id}
419
+ data-slot="approval-card-option"
420
+ data-scope={option.scope}
421
+ className={cn(
422
+ "flex items-start gap-3 rounded-lg border border-border bg-card p-3",
423
+ "has-[[data-state=checked]]:border-primary",
424
+ )}
425
+ >
426
+ <RadioGroupItem
427
+ aria-describedby={descriptionId}
428
+ className="mt-1"
429
+ data-slot="approval-card-option-input"
430
+ id={itemId}
431
+ value={option.id}
432
+ />
433
+ <div className="grid flex-1 gap-1">
434
+ <div className="flex items-center justify-between gap-2">
435
+ <Label
436
+ className="text-body flex items-center gap-2 font-medium"
437
+ data-slot="approval-card-option-label"
438
+ htmlFor={itemId}
439
+ >
440
+ {option.label}
441
+ </Label>
442
+ {option.keyHint ? (
443
+ <Kbd data-slot="approval-card-option-key-hint">{option.keyHint}</Kbd>
444
+ ) : null}
445
+ </div>
446
+ <p
447
+ className="text-meta text-muted-foreground"
448
+ data-slot="approval-card-option-description"
449
+ id={descriptionId}
450
+ >
451
+ {description}
452
+ </p>
453
+ </div>
454
+ </div>
455
+ );
456
+ })}
457
+ </RadioGroup>
458
+ );
459
+ },
460
+ );
461
+
462
+ ApprovalCardOptions.displayName = "ApprovalCardOptions";
463
+
464
+ export type ApprovalCardTargetProps = ComponentProps<"div">;
465
+
466
+ /**
467
+ * ApprovalCardTarget — the preview slot for WHAT is being approved: a
468
+ * command, a path, or a richer node such as a `DiffView` (#102). Accepts
469
+ * arbitrary children — this file never imports `DiffView`, the composition
470
+ * happens entirely in the consumer's tree. Ungated by state (unlike
471
+ * `ApprovalCardRequest`/`Accepted`/`Rejected`): the subject of a decision
472
+ * stays visible after it resolves, not only while pending.
473
+ */
474
+ export const ApprovalCardTarget = forwardRef<HTMLDivElement, ApprovalCardTargetProps>(
475
+ function ApprovalCardTarget({ className, ...props }, ref) {
476
+ return (
477
+ <div
478
+ ref={ref}
479
+ data-slot="approval-card-target"
480
+ className={cn("text-body rounded-md bg-muted/50 p-3", className)}
481
+ {...props}
482
+ />
483
+ );
484
+ },
485
+ );
486
+
487
+ ApprovalCardTarget.displayName = "ApprovalCardTarget";
488
+
489
+ export type ApprovalCardReasonProps = Omit<
490
+ ComponentProps<typeof Textarea>,
491
+ "value" | "defaultValue" | "onChange"
492
+ >;
493
+
494
+ /**
495
+ * ApprovalCardReason — the deny-with-reason input, wired to the `reason`
496
+ * field that already exists on `ToolUIPartApproval` and was previously
497
+ * unreachable from the UI. Shares its value with `ApprovalCardOptions`
498
+ * through `Confirmation`'s own context (not a prop threaded between the two
499
+ * sibling parts) — type here BEFORE choosing an option to have the text
500
+ * included in that option's `onConfirm` call.
501
+ */
502
+ export const ApprovalCardReason = forwardRef<HTMLTextAreaElement, ApprovalCardReasonProps>(
503
+ function ApprovalCardReason({ className, id, placeholder, ...props }, ref) {
504
+ const { t } = useLocale();
505
+ const { reason, setReason } = useConfirmation();
506
+ const generatedId = useId();
507
+ const textareaId = id ?? generatedId;
508
+
509
+ return (
510
+ <div className="grid gap-1.5" data-slot="approval-card-reason">
511
+ <Label
512
+ className="text-meta text-muted-foreground"
513
+ data-slot="approval-card-reason-label"
514
+ htmlFor={textareaId}
515
+ >
516
+ {t("ai.approvalCard.reasonLabel")}
517
+ </Label>
518
+ <Textarea
519
+ ref={ref}
520
+ id={textareaId}
521
+ placeholder={placeholder ?? t("ai.approvalCard.reasonPlaceholder")}
522
+ value={reason}
523
+ onChange={(event) => setReason(event.target.value)}
524
+ data-slot="approval-card-reason-input"
525
+ className={cn("min-h-16 text-body", className)}
526
+ {...props}
527
+ />
528
+ </div>
529
+ );
530
+ },
531
+ );
532
+
533
+ ApprovalCardReason.displayName = "ApprovalCardReason";
@@ -18,7 +18,15 @@ const meta = {
18
18
  title: "AI/ContextPanel",
19
19
  component: ContextPanel,
20
20
  tags: ["autodocs"],
21
- parameters: { layout: "padded" },
21
+ parameters: {
22
+ layout: "padded",
23
+ docs: {
24
+ description: {
25
+ component:
26
+ "The chat workspace’s right context RAIL: produced assets, a preview drill-in, and an always-mounted width tween so an external `ContextPanelTrigger` can drive it from anywhere inside `ContextPanelProvider`. Nothing to do with `AI/TokenUsage`, the context-WINDOW usage ring, which carried the name `Context` until it was renamed for exactly this confusion; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). Below the mobile breakpoint the rail degrades to a right-side `Sheet` driven by the same state.",
27
+ },
28
+ },
29
+ },
22
30
  } satisfies Meta<typeof ContextPanel>;
23
31
  export default meta;
24
32
  type Story = StoryObj<typeof meta>;
@@ -355,6 +355,7 @@ export const ContextPanel = forwardRef<HTMLDivElement, ContextPanelProps>(functi
355
355
  ) {
356
356
  const { state, actions, meta } = useContextPanel();
357
357
  const { panelId } = useContextPanelInternal();
358
+ const { t } = useLocale();
358
359
 
359
360
  const panel = useCollapsiblePanel({
360
361
  side: "right",
@@ -380,8 +381,8 @@ export const ContextPanel = forwardRef<HTMLDivElement, ContextPanelProps>(functi
380
381
  style={{ "--context-panel-width": CONTEXT_PANEL_WIDTH_MOBILE } as CSSProperties}
381
382
  >
382
383
  <SheetHeader className="sr-only">
383
- <SheetTitle>Context panel</SheetTitle>
384
- <SheetDescription>Displays the chat context panel.</SheetDescription>
384
+ <SheetTitle>{t("ai.contextPanel.title")}</SheetTitle>
385
+ <SheetDescription>{t("ai.contextPanel.description")}</SheetDescription>
385
386
  </SheetHeader>
386
387
  <div className="flex h-full w-full flex-col">{children}</div>
387
388
  </SheetContent>
@@ -1,10 +1,19 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
- import { Conversation, ConversationContent } from "./conversation";
2
+ import { expect, within } from "storybook/test";
3
+ import { Conversation, ConversationContent, ConversationEmptyState } from "./conversation";
3
4
  import { Message, MessageContent } from "./message";
4
5
  const meta = {
5
6
  title: "AI/Conversation",
6
7
  component: Conversation,
7
- parameters: { layout: "padded" },
8
+ parameters: {
9
+ layout: "padded",
10
+ docs: {
11
+ description: {
12
+ component:
13
+ "The CHAT scrolling transcript region — stick-to-bottom, with a scroll-to-bottom button once the reader leaves the end. The console counterpart is `Terminal/TerminalSurface`, and the frame around either is `AI/ChatShell` or `Terminal/TerminalConsole`. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
14
+ },
15
+ },
16
+ },
8
17
  } satisfies Meta<typeof Conversation>;
9
18
  export default meta;
10
19
  type Story = StoryObj<typeof meta>;
@@ -24,3 +33,78 @@ export const Default: Story = {
24
33
  </div>
25
34
  ),
26
35
  };
36
+
37
+ /**
38
+ * `isStreaming` suppresses the live-region chatter while tokens are still
39
+ * arriving (`aria-live="off"` + `aria-busy`) — see the prop's doc on
40
+ * `ConversationProps` for why a `"polite"` region would otherwise announce
41
+ * every incoming token.
42
+ */
43
+ export const Streaming: Story = {
44
+ render: () => (
45
+ <div className="flex h-72 flex-col rounded-lg border">
46
+ <Conversation isStreaming>
47
+ <ConversationContent>
48
+ <Message from="user">
49
+ <MessageContent>Summarize the deploy log.</MessageContent>
50
+ </Message>
51
+ <Message from="assistant">
52
+ <MessageContent>Deploying to production</MessageContent>
53
+ </Message>
54
+ </ConversationContent>
55
+ </Conversation>
56
+ </div>
57
+ ),
58
+ };
59
+
60
+ /**
61
+ * **Layout lock (#72).** With zero messages, `ConversationEmptyState` must render
62
+ * vertically centred in the conversation canvas — not pinned to the top with a
63
+ * gap of empty space below (the original bug: `ConversationContent` never grew
64
+ * past its shrink-wrapped content size, so the canvas's extra height sat unused
65
+ * below it).
66
+ *
67
+ * This can only be verified with real CSS layout (`getBoundingClientRect`), never
68
+ * a class-string assertion — jsdom performs no layout, and asserting a class name
69
+ * would have passed on the buggy `size-full` markup verbatim (`size-full` was
70
+ * always present; it just had nothing to fill). Per
71
+ * `.claude/rules/component-api.md` § "Regression locks with observable side
72
+ * effects", this runs in the ordinary full-suite invocation, not only isolated.
73
+ *
74
+ * We deliberately measure `ConversationEmptyState`'s OWN rendered box against the
75
+ * outer canvas (`role="log"`'s parent), not against `ConversationContent`'s box —
76
+ * the empty state is `size-full` inside `ConversationContent`, so comparing it to
77
+ * its immediate parent is tautological (it always exactly fills it, bug or not).
78
+ * Comparing against the fixed-height canvas is what actually distinguishes
79
+ * "pinned to the top of a tall canvas" from "filling — and centred within — the
80
+ * canvas".
81
+ */
82
+ export const EmptyStateCentering: Story = {
83
+ render: () => (
84
+ <div className="flex h-72 flex-col rounded-lg border">
85
+ <Conversation>
86
+ <ConversationContent>
87
+ <ConversationEmptyState
88
+ title="Start the conversation"
89
+ description="Ask anything to begin."
90
+ />
91
+ </ConversationContent>
92
+ </Conversation>
93
+ </div>
94
+ ),
95
+ play: async ({ canvasElement }) => {
96
+ const log = within(canvasElement).getByRole("log");
97
+ const canvas = log.parentElement as HTMLElement;
98
+ const heading = within(canvasElement).getByRole("heading", { name: "Start the conversation" });
99
+ // heading (h3) -> the "space-y-1" title/description group -> ConversationEmptyState's own root div.
100
+ const emptyState = heading.parentElement!.parentElement as HTMLElement;
101
+
102
+ const canvasRect = canvas.getBoundingClientRect();
103
+ const emptyRect = emptyState.getBoundingClientRect();
104
+
105
+ const gapAbove = emptyRect.top - canvasRect.top;
106
+ const gapBelow = canvasRect.bottom - emptyRect.bottom;
107
+
108
+ await expect(Math.abs(gapAbove - gapBelow)).toBeLessThan(8);
109
+ },
110
+ };
@@ -16,6 +16,7 @@ import type { UIMessage } from "ai";
16
16
  import {
17
17
  Conversation,
18
18
  ConversationContent,
19
+ ConversationDownload,
19
20
  ConversationEmptyState,
20
21
  messagesToMarkdown,
21
22
  } from "./conversation";
@@ -69,6 +70,46 @@ describe("Conversation — transcript region", () => {
69
70
  expect(screen.getByRole("log")).toHaveClass("extra");
70
71
  expect(screen.getByRole("log")).toHaveClass("flex-1");
71
72
  });
73
+
74
+ it('defaults to aria-live="polite" and no aria-busy (not streaming)', () => {
75
+ render(
76
+ <Conversation>
77
+ <ConversationContent>x</ConversationContent>
78
+ </Conversation>,
79
+ );
80
+ const log = screen.getByRole("log");
81
+ expect(log).toHaveAttribute("aria-live", "polite");
82
+ expect(log).not.toHaveAttribute("aria-busy");
83
+ });
84
+
85
+ it('suppresses aria-live and sets aria-busy while isStreaming (perf review §2 — role="log" must not announce every token)', () => {
86
+ render(
87
+ <Conversation isStreaming>
88
+ <ConversationContent>x</ConversationContent>
89
+ </Conversation>,
90
+ );
91
+ const log = screen.getByRole("log");
92
+ expect(log).toHaveAttribute("aria-live", "off");
93
+ expect(log).toHaveAttribute("aria-busy", "true");
94
+ });
95
+
96
+ it("returns to aria-live=polite once streaming settles, so the final content is announced", () => {
97
+ const { rerender } = render(
98
+ <Conversation isStreaming>
99
+ <ConversationContent>partial</ConversationContent>
100
+ </Conversation>,
101
+ );
102
+ expect(screen.getByRole("log")).toHaveAttribute("aria-live", "off");
103
+
104
+ rerender(
105
+ <Conversation isStreaming={false}>
106
+ <ConversationContent>final</ConversationContent>
107
+ </Conversation>,
108
+ );
109
+ const log = screen.getByRole("log");
110
+ expect(log).toHaveAttribute("aria-live", "polite");
111
+ expect(log).not.toHaveAttribute("aria-busy");
112
+ });
72
113
  });
73
114
 
74
115
  describe("ConversationEmptyState", () => {
@@ -93,6 +134,31 @@ describe("ConversationEmptyState", () => {
93
134
  expect(screen.getByText("custom")).toBeInTheDocument();
94
135
  expect(screen.queryByRole("heading", { name: "No messages yet" })).toBeNull();
95
136
  });
137
+
138
+ // API lock (#72): `actions` completes the EmptyState anatomy (illustration/icon
139
+ // + title + one sentence + one action, per .claude/rules/design-first.md) —
140
+ // without it, ConversationEmptyState could not offer a next step at all.
141
+ it("renders a passed actions node", () => {
142
+ render(<ConversationEmptyState actions={<button type="button">Ask something</button>} />);
143
+ expect(screen.getByRole("button", { name: "Ask something" })).toBeInTheDocument();
144
+ });
145
+
146
+ it("omits the actions wrapper entirely when no actions are passed", () => {
147
+ const { container } = render(<ConversationEmptyState />);
148
+ expect(container.querySelectorAll("button")).toHaveLength(0);
149
+ });
150
+ });
151
+
152
+ describe("ConversationDownload — accessible name (accessibility.md)", () => {
153
+ it("names the icon-only download control", () => {
154
+ render(<ConversationDownload messages={messages} />);
155
+ expect(screen.getByRole("button", { name: "Download conversation" })).toBeInTheDocument();
156
+ });
157
+
158
+ it("lets a caller override the accessible name", () => {
159
+ render(<ConversationDownload aria-label="Export chat" messages={messages} />);
160
+ expect(screen.getByRole("button", { name: "Export chat" })).toBeInTheDocument();
161
+ });
96
162
  });
97
163
 
98
164
  describe("messagesToMarkdown — the download serializer", () => {
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { Button, downloadBlob } from "@elabs-ai/components-ui";
3
+ import { Button, downloadBlob, useLocale } from "@elabs-ai/components-ui";
4
4
  import { cn } from "@elabs-ai/components-ui/lib/cn";
5
5
  import type { UIMessage } from "ai";
6
6
  import { ArrowDownIcon, DownloadIcon } from "lucide-react";
@@ -8,28 +8,57 @@ import type { ComponentProps } from "react";
8
8
  import { useCallback } from "react";
9
9
  import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
10
10
 
11
- export type ConversationProps = ComponentProps<typeof StickToBottom>;
11
+ export type ConversationProps = ComponentProps<typeof StickToBottom> & {
12
+ /**
13
+ * The assistant response is still arriving (loading-states.md
14
+ * `isStreaming`). `role="log"` otherwise announces every incoming token to
15
+ * assistive tech as it streams in — deafening. While `true`, `aria-live`
16
+ * is suppressed (`"off"`) and `aria-busy` is set; once streaming ends,
17
+ * `aria-live` returns to `"polite"` so the settled content is what
18
+ * actually gets announced.
19
+ * @default false
20
+ */
21
+ isStreaming?: boolean;
22
+ };
12
23
 
13
- export const Conversation = ({ className, ...props }: ConversationProps) => (
24
+ export const Conversation = ({ className, isStreaming = false, ...props }: ConversationProps) => (
14
25
  <StickToBottom
15
26
  className={cn("relative flex-1 overflow-y-hidden", className)}
16
27
  initial="smooth"
17
28
  resize="smooth"
18
29
  role="log"
30
+ aria-live={isStreaming ? "off" : "polite"}
31
+ aria-busy={isStreaming || undefined}
19
32
  {...props}
20
33
  />
21
34
  );
22
35
 
23
36
  export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
24
37
 
25
- export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
26
- <StickToBottom.Content className={cn("flex flex-col gap-8 p-4", className)} {...props} />
38
+ export const ConversationContent = ({
39
+ className,
40
+ scrollClassName,
41
+ ...props
42
+ }: ConversationContentProps) => (
43
+ <StickToBottom.Content
44
+ // `use-stick-to-bottom` renders a scroll viewport (`scrollClassName`)
45
+ // wrapping a content div (`className`, via `...props`). The viewport must
46
+ // be a flex column so its height is a DEFINITE main size the content div
47
+ // can consume via `flex-1` — a bare `height:100%` on the content div
48
+ // can't resolve against a block-level parent whose own height is
49
+ // `min-height`-only (the #72 empty-state-pinned-to-the-top bug).
50
+ scrollClassName={cn("flex flex-col", scrollClassName)}
51
+ className={cn("flex flex-1 min-h-0 flex-col gap-8 p-4", className)}
52
+ {...props}
53
+ />
27
54
  );
28
55
 
29
56
  export type ConversationEmptyStateProps = ComponentProps<"div"> & {
30
57
  title?: string;
31
58
  description?: string;
32
59
  icon?: React.ReactNode;
60
+ /** Primary / secondary action(s) below the description (e.g. suggested prompts). */
61
+ actions?: React.ReactNode;
33
62
  };
34
63
 
35
64
  export const ConversationEmptyState = ({
@@ -37,6 +66,7 @@ export const ConversationEmptyState = ({
37
66
  title = "No messages yet",
38
67
  description = "Start a conversation to see messages here",
39
68
  icon,
69
+ actions,
40
70
  children,
41
71
  ...props
42
72
  }: ConversationEmptyStateProps) => (
@@ -51,9 +81,10 @@ export const ConversationEmptyState = ({
51
81
  <>
52
82
  {icon && <div className="text-muted-foreground">{icon}</div>}
53
83
  <div className="space-y-1">
54
- <h3 className="font-medium text-sm">{title}</h3>
55
- {description && <p className="text-muted-foreground text-sm">{description}</p>}
84
+ <h3 className="font-medium text-body">{title}</h3>
85
+ {description && <p className="text-muted-foreground text-body">{description}</p>}
56
86
  </div>
87
+ {actions ? <div className="mt-1 flex items-center gap-2">{actions}</div> : null}
57
88
  </>
58
89
  )}
59
90
  </div>
@@ -66,6 +97,7 @@ export const ConversationScrollButton = ({
66
97
  ...props
67
98
  }: ConversationScrollButtonProps) => {
68
99
  const { isAtBottom, scrollToBottom } = useStickToBottomContext();
100
+ const { t } = useLocale();
69
101
 
70
102
  const handleScrollToBottom = useCallback(() => {
71
103
  scrollToBottom();
@@ -75,9 +107,16 @@ export const ConversationScrollButton = ({
75
107
  !isAtBottom && (
76
108
  <Button
77
109
  className={cn(
78
- "absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
110
+ // Was `dark:bg-background dark:hover:bg-muted` — a hardcoded-dark
111
+ // branch invisible to any other registered theme (styling-and-tokens.md).
112
+ // `bg-background` already matches the `outline` variant's own default;
113
+ // the quieter `hover:bg-muted` (in place of `outline`'s default
114
+ // `hover:bg-accent`) now applies in every theme, not only the two
115
+ // shipped ones.
116
+ "absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full bg-background hover:bg-muted",
79
117
  className,
80
118
  )}
119
+ aria-label={t("ai.turnStatus.scrollToBottom")}
81
120
  onClick={handleScrollToBottom}
82
121
  size="icon"
83
122
  type="button"
@@ -120,6 +159,7 @@ export const ConversationDownload = ({
120
159
  children,
121
160
  ...props
122
161
  }: ConversationDownloadProps) => {
162
+ const { t } = useLocale();
123
163
  const handleDownload = useCallback(() => {
124
164
  const markdown = messagesToMarkdown(messages, formatMessage);
125
165
  downloadBlob(new Blob([markdown], { type: "text/markdown" }), filename);
@@ -128,9 +168,12 @@ export const ConversationDownload = ({
128
168
  return (
129
169
  <Button
130
170
  className={cn(
131
- "absolute top-4 end-4 rounded-full dark:bg-background dark:hover:bg-muted",
171
+ // See `ConversationScrollButton` — was a hardcoded-dark-only branch;
172
+ // `bg-background hover:bg-muted` now applies in every theme.
173
+ "absolute top-4 end-4 rounded-full bg-background hover:bg-muted",
132
174
  className,
133
175
  )}
176
+ aria-label={t("ai.conversation.download")}
134
177
  onClick={handleDownload}
135
178
  size="icon"
136
179
  type="button"