@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
package/src/commit.tsx CHANGED
@@ -3,11 +3,11 @@
3
3
  import { Avatar, AvatarFallback } from "@elabs-ai/components-ui";
4
4
  import { Button } from "@elabs-ai/components-ui";
5
5
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
6
- import { useLocale } from "@elabs-ai/components-ui";
6
+ import { useCopyToClipboard, useLocale } from "@elabs-ai/components-ui";
7
7
  import { cn } from "@elabs-ai/components-ui/lib/cn";
8
8
  import { CheckIcon, CopyIcon, FileIcon, GitCommitIcon, MinusIcon, PlusIcon } from "lucide-react";
9
9
  import type { ComponentProps, HTMLAttributes } from "react";
10
- import { useCallback, useEffect, useRef, useState } from "react";
10
+ import { useCallback, useEffect, useState } from "react";
11
11
 
12
12
  export type CommitProps = ComponentProps<typeof Collapsible>;
13
13
 
@@ -35,7 +35,7 @@ export const CommitHeader = ({ className, children, ...props }: CommitHeaderProp
35
35
  export type CommitHashProps = HTMLAttributes<HTMLSpanElement>;
36
36
 
37
37
  export const CommitHash = ({ className, children, ...props }: CommitHashProps) => (
38
- <span className={cn("font-mono text-xs", className)} {...props}>
38
+ <span className={cn("font-mono text-meta", className)} {...props}>
39
39
  <GitCommitIcon className="me-1 inline-block size-3" />
40
40
  {children}
41
41
  </span>
@@ -44,7 +44,7 @@ export const CommitHash = ({ className, children, ...props }: CommitHashProps) =
44
44
  export type CommitMessageProps = HTMLAttributes<HTMLSpanElement>;
45
45
 
46
46
  export const CommitMessage = ({ className, children, ...props }: CommitMessageProps) => (
47
- <span className={cn("font-medium text-sm", className)} {...props}>
47
+ <span className={cn("font-medium text-body", className)} {...props}>
48
48
  {children}
49
49
  </span>
50
50
  );
@@ -53,7 +53,7 @@ export type CommitMetadataProps = HTMLAttributes<HTMLDivElement>;
53
53
 
54
54
  export const CommitMetadata = ({ className, children, ...props }: CommitMetadataProps) => (
55
55
  <div
56
- className={cn("flex items-center gap-2 text-muted-foreground text-xs", className)}
56
+ className={cn("flex items-center gap-2 text-muted-foreground text-meta", className)}
57
57
  {...props}
58
58
  >
59
59
  {children}
@@ -90,7 +90,7 @@ export type CommitAuthorAvatarProps = ComponentProps<typeof Avatar> & {
90
90
 
91
91
  export const CommitAuthorAvatar = ({ initials, className, ...props }: CommitAuthorAvatarProps) => (
92
92
  <Avatar className={cn("size-8", className)} {...props}>
93
- <AvatarFallback className="text-xs">{initials}</AvatarFallback>
93
+ <AvatarFallback className="text-meta">{initials}</AvatarFallback>
94
94
  </Avatar>
95
95
  );
96
96
 
@@ -116,7 +116,7 @@ export const CommitTimestamp = ({ date, className, children, ...props }: CommitT
116
116
  }, [updateFormatted]);
117
117
 
118
118
  return (
119
- <time className={cn("text-xs", className)} dateTime={date.toISOString()} {...props}>
119
+ <time className={cn("text-meta", className)} dateTime={date.toISOString()} {...props}>
120
120
  {children ?? formatted}
121
121
  </time>
122
122
  );
@@ -150,43 +150,32 @@ export const CommitCopyButton = ({
150
150
  hash,
151
151
  onCopy,
152
152
  onError,
153
- timeout = 2000,
153
+ timeout,
154
154
  children,
155
155
  className,
156
+ "aria-label": ariaLabel,
156
157
  ...props
157
158
  }: CommitCopyButtonProps) => {
158
- const [isCopied, setIsCopied] = useState(false);
159
- const timeoutRef = useRef<number>(0);
159
+ const { t } = useLocale();
160
+ // Shared implementation (`@elabs-ai/components-ui`) instead of a private
161
+ // copy of the same copy-to-clipboard state machine (issue-workflow.md
162
+ // dedupe finding) — see `CodeBlockCopyButton` for the reference usage.
163
+ const { copied: isCopied, copy } = useCopyToClipboard({ resetAfterMs: timeout ?? 2000 });
160
164
 
161
165
  const copyToClipboard = useCallback(async () => {
162
- if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
166
+ const ok = await copy(hash);
167
+ if (ok) {
168
+ onCopy?.();
169
+ } else {
163
170
  onError?.(new Error("Clipboard API not available"));
164
- return;
165
171
  }
166
-
167
- try {
168
- if (!isCopied) {
169
- await navigator.clipboard.writeText(hash);
170
- setIsCopied(true);
171
- onCopy?.();
172
- timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
173
- }
174
- } catch (error) {
175
- onError?.(error as Error);
176
- }
177
- }, [hash, onCopy, onError, timeout, isCopied]);
178
-
179
- useEffect(
180
- () => () => {
181
- window.clearTimeout(timeoutRef.current);
182
- },
183
- [],
184
- );
172
+ }, [copy, hash, onCopy, onError]);
185
173
 
186
174
  const Icon = isCopied ? CheckIcon : CopyIcon;
187
175
 
188
176
  return (
189
177
  <Button
178
+ aria-label={ariaLabel ?? t("copy")}
190
179
  className={cn("size-7 shrink-0", className)}
191
180
  onClick={copyToClipboard}
192
181
  size="icon"
@@ -219,7 +208,7 @@ export type CommitFileProps = HTMLAttributes<HTMLDivElement>;
219
208
  export const CommitFile = ({ className, children, ...props }: CommitFileProps) => (
220
209
  <div
221
210
  className={cn(
222
- "flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50",
211
+ "flex items-center justify-between gap-2 rounded px-2 py-1 text-body hover:bg-muted/50",
223
212
  className,
224
213
  )}
225
214
  {...props}
@@ -237,10 +226,10 @@ export const CommitFileInfo = ({ className, children, ...props }: CommitFileInfo
237
226
  );
238
227
 
239
228
  const fileStatusStyles = {
240
- added: "text-green-600 dark:text-green-400",
241
- deleted: "text-red-600 dark:text-red-400",
242
- modified: "text-yellow-600 dark:text-yellow-400",
243
- renamed: "text-blue-600 dark:text-blue-400",
229
+ added: "text-success-text",
230
+ deleted: "text-destructive-text",
231
+ modified: "text-warning-text",
232
+ renamed: "text-info-text",
244
233
  };
245
234
 
246
235
  const fileStatusLabels = {
@@ -261,7 +250,7 @@ export const CommitFileStatus = ({
261
250
  ...props
262
251
  }: CommitFileStatusProps) => (
263
252
  <span
264
- className={cn("font-medium font-mono text-xs", fileStatusStyles[status], className)}
253
+ className={cn("font-medium font-mono text-meta", fileStatusStyles[status], className)}
265
254
  {...props}
266
255
  >
267
256
  {children ?? fileStatusLabels[status]}
@@ -277,7 +266,7 @@ export const CommitFileIcon = ({ className, ...props }: CommitFileIconProps) =>
277
266
  export type CommitFilePathProps = HTMLAttributes<HTMLSpanElement>;
278
267
 
279
268
  export const CommitFilePath = ({ className, children, ...props }: CommitFilePathProps) => (
280
- <span className={cn("truncate font-mono text-xs", className)} {...props}>
269
+ <span className={cn("truncate font-mono text-meta", className)} {...props}>
281
270
  {children}
282
271
  </span>
283
272
  );
@@ -285,7 +274,7 @@ export const CommitFilePath = ({ className, children, ...props }: CommitFilePath
285
274
  export type CommitFileChangesProps = HTMLAttributes<HTMLDivElement>;
286
275
 
287
276
  export const CommitFileChanges = ({ className, children, ...props }: CommitFileChangesProps) => (
288
- <div className={cn("flex shrink-0 items-center gap-1 font-mono text-xs", className)} {...props}>
277
+ <div className={cn("flex shrink-0 items-center gap-1 font-mono text-meta", className)} {...props}>
289
278
  {children}
290
279
  </div>
291
280
  );
@@ -305,7 +294,7 @@ export const CommitFileAdditions = ({
305
294
  }
306
295
 
307
296
  return (
308
- <span className={cn("text-green-600 dark:text-green-400", className)} {...props}>
297
+ <span className={cn("text-success-text", className)} {...props}>
309
298
  {children ?? (
310
299
  <>
311
300
  <PlusIcon className="inline-block size-3" />
@@ -331,7 +320,7 @@ export const CommitFileDeletions = ({
331
320
  }
332
321
 
333
322
  return (
334
- <span className={cn("text-red-600 dark:text-red-400", className)} {...props}>
323
+ <span className={cn("text-destructive-text", className)} {...props}>
335
324
  {children ?? (
336
325
  <>
337
326
  <MinusIcon className="inline-block size-3" />
@@ -3,19 +3,134 @@
3
3
  *
4
4
  * The canonical two-tone "double card": an outer `bg-card` frame with a muted
5
5
  * status strip wrapping a recessed `PromptInput` well (sharp top, theme-rounded
6
- * bottom), a model pill, voice, and a circular send. This is THE chat input —
7
- * reach for `<Composer />` instead of hand-rolling a `PromptInput` footer, in a
8
- * `ChatShell` footer or as a standalone empty-state composer. Semantic tokens
9
- * only; theme-aware radii; reads in every theme.
6
+ * bottom), a tools cluster, voice, and a circular send.
7
+ *
8
+ * **Composer is the chat input. Every control the `PromptInput` family ships is
9
+ * reachable from a `Composer` prop; drop to `PromptInput` only for a bespoke
10
+ * shell.** The four slots that make that true — `modelPicker`, `mode`, `effort`
11
+ * and `slashCommands` — are the stories below; the footer order is
12
+ * `attach · modelPicker · mode · effort │ voice · send`, the same arrangement
13
+ * `TerminalComposer` uses, so the chat and console skins agree.
14
+ *
15
+ * ### Anatomy — and what the pages nested under this one are
16
+ *
17
+ * A `Composer` **is** a `PromptInput` form: a muted status strip above the
18
+ * well, the well itself, and a tools cluster in the footer. The sub-pages under
19
+ * `AI/Composer` are those parts, not siblings of it:
20
+ *
21
+ * - **`PromptInput`** — the raw composer FORM primitive everything here is
22
+ * built on. Compose it yourself only for a bespoke shell.
23
+ * - **`PromptInputMode`**, **`PromptInputEffort`**, **`PromptInputSlash`** —
24
+ * the three controls the `mode`, `effort` and `slashCommands` slots render.
25
+ * The fourth slot, `modelPicker`, takes a `ModelPicker` from
26
+ * `@elabs-ai/components-ui`, which is why it has no page of its own here.
27
+ * - **`WithMentionInput`** — the one documented bespoke shell: a mention
28
+ * roster has to WRAP the textarea, which is the seam `Composer` owns, so
29
+ * that page drops to `PromptInput` on purpose.
30
+ *
31
+ * `Terminal/TerminalComposer` is the console skin of this same family — same
32
+ * footer order, different surface.
33
+ *
34
+ * Semantic tokens only; theme-aware radii; reads in every theme.
10
35
  */
36
+ import { useState } from "react";
11
37
  import type { Meta, StoryObj } from "@storybook/react-vite";
12
- import { expect, fn, waitFor } from "storybook/test";
38
+ import { expect, fn, waitFor, within } from "storybook/test";
39
+ import { Bot, PencilLine, ShieldCheck } from "lucide-react";
40
+ import {
41
+ ModelPicker,
42
+ type EffortLevel,
43
+ type ModelPickerGroup,
44
+ type ModelPickerItem,
45
+ type OperatingMode,
46
+ type SlashCommand,
47
+ } from "@elabs-ai/components-ui";
13
48
 
14
49
  import { ChatGreeting } from "./chat-greeting";
15
50
  import { Composer } from "./composer";
16
51
 
52
+ /** The model picker is a ReactNode slot, so its spy lives here rather than in `args`. */
53
+ const onModelSelect = fn();
54
+
17
55
  const SUGGESTIONS = ["Summary", "Code", "Design", "Research"];
18
56
 
57
+ /** Demo vocabulary. brand-ui ships none of this — every list below is app-supplied. */
58
+ const MODES: OperatingMode[] = [
59
+ {
60
+ id: "auto",
61
+ label: "Auto",
62
+ description: "Acts without asking, within its usual limits.",
63
+ icon: <Bot className="size-4" aria-hidden="true" />,
64
+ },
65
+ {
66
+ id: "plan",
67
+ label: "Plan first",
68
+ description: "Proposes a plan and waits for approval before acting.",
69
+ keyHint: "⇧ Tab",
70
+ icon: <PencilLine className="size-4" aria-hidden="true" />,
71
+ },
72
+ {
73
+ id: "review",
74
+ label: "Review edits",
75
+ description: "Acts, but pauses on every file edit for a quick review.",
76
+ icon: <ShieldCheck className="size-4" aria-hidden="true" />,
77
+ },
78
+ ];
79
+
80
+ const LEVELS: EffortLevel[] = [
81
+ { id: "low", label: "Low" },
82
+ { id: "medium", label: "Medium" },
83
+ { id: "high", label: "High" },
84
+ { id: "max", label: "Max" },
85
+ ];
86
+
87
+ const COMMANDS: SlashCommand[] = [
88
+ { name: "help", description: "Show available commands" },
89
+ { name: "history", description: "Show recent conversation history" },
90
+ { name: "clear", description: "Clear the conversation" },
91
+ { name: "settings", description: "Open workspace settings" },
92
+ ];
93
+
94
+ const MODEL_GROUPS: ModelPickerGroup[] = [
95
+ {
96
+ key: "frontier",
97
+ label: "Frontier",
98
+ items: [
99
+ { id: "opus", label: "Opus", description: "Deepest reasoning, slowest." },
100
+ { id: "sonnet", label: "Sonnet", description: "The everyday balance." },
101
+ ],
102
+ },
103
+ {
104
+ key: "fast",
105
+ label: "Fast",
106
+ items: [{ id: "haiku", label: "Haiku", description: "Cheapest and quickest." }],
107
+ },
108
+ ];
109
+
110
+ /**
111
+ * A `ModelPicker` wired to local state — what a real app passes to
112
+ * `modelPicker`. The picker is the consumer's, not Composer's: Composer only
113
+ * owns the slot and its position in the footer.
114
+ */
115
+ function DemoModelPicker({ onSelect }: { onSelect?: (item: ModelPickerItem) => void }) {
116
+ const [picked, setPicked] = useState("sonnet");
117
+ const label =
118
+ MODEL_GROUPS.flatMap((group) => group.items).find((item) => item.id === picked)?.label ??
119
+ "Model";
120
+ return (
121
+ <ModelPicker
122
+ groups={MODEL_GROUPS}
123
+ value={picked}
124
+ triggerLabel={label}
125
+ aria-label="Model"
126
+ onSelect={(item) => {
127
+ setPicked(item.id);
128
+ onSelect?.(item);
129
+ }}
130
+ />
131
+ );
132
+ }
133
+
19
134
  const meta = {
20
135
  title: "AI/Composer",
21
136
  component: Composer,
@@ -24,7 +139,16 @@ const meta = {
24
139
  docs: {
25
140
  description: {
26
141
  component:
27
- "The standard brand-ui AI chat input: a rounded two-tone double card (outer bg-card frame + muted status strip around a recessed PromptInput well with a sharp top and theme-rounded bottom), a model pill, voice, and a circular send. Built on the real @elabs-ai/components-ai PromptInput. Use it as the chat input everywhere a ChatShell footer or a standalone empty-state composer. Semantic tokens only; reads in all themes.",
142
+ "The CHAT composer; the console skin is `Terminal/TerminalComposer`see " +
143
+ "[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
144
+ "Composer is the chat input. Every control the PromptInput family ships is reachable from a Composer prop; drop to PromptInput only for a bespoke shell. A rounded two-tone double card (outer bg-card frame + muted status strip around a recessed PromptInput well with a sharp top and theme-rounded bottom), a tools cluster, voice, and a circular send — plus four optional slots: modelPicker (pass a ModelPicker), mode (PromptInputMode), effort (PromptInputEffort) and slashCommands (PromptInputSlash). Footer order: attach · modelPicker · mode · effort │ voice · send, mirroring TerminalComposer. Built on the real @elabs-ai/components-ai PromptInput; semantic tokens only; reads in all themes.\n\n" +
145
+ "**Anatomy — and what the pages nested under this one are.** A Composer IS a PromptInput form: a muted status strip above the well, the well itself, and a tools cluster in the footer. The sub-pages under `AI/Composer` are those parts, not siblings of it — " +
146
+ "[PromptInput](?path=/docs/ai-composer-promptinput--docs) is the raw composer FORM primitive everything here is built on; " +
147
+ "[PromptInputMode](?path=/docs/ai-composer-promptinputmode--docs), " +
148
+ "[PromptInputEffort](?path=/docs/ai-composer-promptinputeffort--docs) and " +
149
+ "[PromptInputSlash](?path=/docs/ai-composer-promptinputslash--docs) are the three controls the `mode`, `effort` and `slashCommands` slots render (the fourth slot, `modelPicker`, takes a `ModelPicker` from @elabs-ai/components-ui, which is why it has no page of its own); and " +
150
+ "[WithMentionInput](?path=/docs/ai-composer-withmentioninput--docs) is the one documented bespoke shell — a mention roster has to WRAP the textarea, the seam Composer owns, so that page drops to PromptInput on purpose. " +
151
+ "The scaffold that puts this composer under a transcript is [Patterns/Blocks/AI Chat Shell](?path=/docs/patterns-blocks-ai-chat-shell--docs); the console skin of the same family is [Terminal/TerminalComposer](?path=/docs/terminal-terminalcomposer--docs).",
28
152
  },
29
153
  },
30
154
  },
@@ -85,6 +209,60 @@ export const Streaming: Story = {
85
209
  ),
86
210
  };
87
211
 
212
+ /**
213
+ * `shortcuts` — a row of keyboard-shortcut hints beneath the well (#107).
214
+ * Opt-in: nothing renders unless you pass at least one. At rest only the
215
+ * always-shown hints appear.
216
+ */
217
+ export const WithShortcuts: Story = {
218
+ args: {
219
+ shortcuts: [
220
+ { keys: "Enter", label: "send" },
221
+ { keys: "Shift+Enter", label: "newline" },
222
+ ],
223
+ },
224
+ render: (args) => (
225
+ <div className="mx-auto max-w-2xl">
226
+ <Composer {...args} />
227
+ </div>
228
+ ),
229
+ };
230
+
231
+ /**
232
+ * The hints CHANGE with a busy state (#107's acceptance criterion, verbatim):
233
+ * `cancelShortcut` joins the row only once the composer is actually
234
+ * generating (`sendStatus="streaming"`) AND a real `onStop` is set — a hint
235
+ * for an affordance that isn't there is never shown.
236
+ */
237
+ export const WithShortcutsBusy: Story = {
238
+ args: {
239
+ status: "Generating…",
240
+ sendStatus: "streaming",
241
+ onStop: fn(),
242
+ shortcuts: [{ keys: "Enter", label: "send" }],
243
+ cancelShortcut: { keys: "Esc", label: "cancel" },
244
+ },
245
+ render: (args) => (
246
+ <div className="mx-auto max-w-2xl">
247
+ <Composer {...args} />
248
+ </div>
249
+ ),
250
+ play: async ({ canvas, canvasElement }) => {
251
+ await expect(canvas.getByText("Esc")).toBeInTheDocument();
252
+ await expect(canvas.getByText("cancel")).toBeInTheDocument();
253
+ // The row is a plain sibling, never nested in the Stop button. Note this
254
+ // is NOT provable via accessible name: `PromptInputSubmit` sets
255
+ // `aria-label` unconditionally, so `toHaveAccessibleName("Stop")` would
256
+ // pass even if the row were wrongly nested inside the button (an
257
+ // explicit `aria-label` wins over descendant content in the accname
258
+ // computation). The real #153-style trap check is DOM containment.
259
+ const stopButton = canvas.getByRole("button", { name: "Stop" });
260
+ const shortcutRow = canvasElement.querySelector('[data-slot="composer-shortcuts"]');
261
+ await expect(shortcutRow).not.toBeNull();
262
+ await expect(stopButton).not.toContainElement(shortcutRow);
263
+ },
264
+ };
265
+
88
266
  /**
89
267
  * Running, but the user has already typed a follow-up (#351's P0 fix): the
90
268
  * control flips back to the circular ArrowUp `sendIcon` — never the Stop
@@ -147,9 +325,9 @@ export const SendStates: Story = {
147
325
  ),
148
326
  };
149
327
 
150
- /** Trimmed: no model pill, no voice — just attach + send. */
328
+ /** Trimmed to the bone: no status strip, no voice — just attach + send. */
151
329
  export const Minimal: Story = {
152
- args: { model: null, showVoice: false, status: null },
330
+ args: { showVoice: false, status: null },
153
331
  render: (args) => (
154
332
  <div className="mx-auto max-w-2xl">
155
333
  <Composer {...args} />
@@ -175,7 +353,6 @@ export const Minimal: Story = {
175
353
  */
176
354
  export const RefusedSubmit: Story = {
177
355
  args: {
178
- model: null,
179
356
  status: "Setting up the conversation…",
180
357
  suggestions: undefined,
181
358
  submitProps: { disabled: true, "aria-label": "Send (unavailable until setup completes)" },
@@ -186,3 +363,189 @@ export const RefusedSubmit: Story = {
186
363
  </div>
187
364
  ),
188
365
  };
366
+
367
+ /**
368
+ * `mode` — an app-defined operating mode (how autonomously the agent may act
369
+ * on the next turn), rendered as a `PromptInputMode` in the tools cluster.
370
+ * brand-ui ships no mode vocabulary; `modes` is entirely yours.
371
+ */
372
+ export const WithMode: Story = {
373
+ args: { mode: { modes: MODES, onValueChange: fn() } },
374
+ render: (args) => (
375
+ <div className="mx-auto max-w-2xl">
376
+ <Composer {...args} />
377
+ </div>
378
+ ),
379
+ play: async ({ canvas, args, userEvent, step }) => {
380
+ await step("the trigger shows the current mode", async () => {
381
+ await expect(canvas.getByRole("button", { name: /Auto/ })).toBeInTheDocument();
382
+ });
383
+
384
+ await step("opening it and picking a mode fires onValueChange", async () => {
385
+ await userEvent.click(canvas.getByRole("button", { name: /Auto/ }));
386
+ // Radix portals the menu to document.body, outside the story canvas.
387
+ const menu = within(document.body);
388
+ await userEvent.click(await menu.findByRole("menuitemradio", { name: /Plan first/ }));
389
+ await waitFor(() => expect(args.mode?.onValueChange).toHaveBeenCalledWith("plan"));
390
+ });
391
+ },
392
+ };
393
+
394
+ /**
395
+ * `effort` — an ORDERED reasoning-effort scale, rendered as a
396
+ * `PromptInputEffort`. The squares grow low → high and fill up to the current
397
+ * level, so the level survives a greyscale render; the level's name is also
398
+ * rendered as text. `aria-label` names the scale and is required — the name of
399
+ * the scale is as much your vocabulary as its rungs are.
400
+ */
401
+ export const WithEffort: Story = {
402
+ args: {
403
+ effort: { levels: LEVELS, "aria-label": "Reasoning effort", onValueChange: fn() },
404
+ },
405
+ render: (args) => (
406
+ <div className="mx-auto max-w-2xl">
407
+ <Composer {...args} />
408
+ </div>
409
+ ),
410
+ play: async ({ canvas, args, userEvent, step }) => {
411
+ await step("the scale is a named radiogroup starting at the lowest rung", async () => {
412
+ await expect(
413
+ canvas.getByRole("radiogroup", { name: "Reasoning effort" }),
414
+ ).toBeInTheDocument();
415
+ await expect(canvas.getByRole("radio", { name: "Low", checked: true })).toBeInTheDocument();
416
+ });
417
+
418
+ await step("picking a rung fires onValueChange and moves the fill", async () => {
419
+ await userEvent.click(canvas.getByRole("radio", { name: "High" }));
420
+ await waitFor(() => expect(args.effort?.onValueChange).toHaveBeenCalledWith("high"));
421
+ await expect(canvas.getByRole("radio", { name: "High", checked: true })).toBeInTheDocument();
422
+ });
423
+ },
424
+ };
425
+
426
+ /**
427
+ * `slashCommands` — typing `/` at the start of a line opens a filtered command
428
+ * palette without focus ever leaving the field. Setting the prop swaps the
429
+ * textarea for a `PromptInputSlashTextarea` inside the same body; nothing else
430
+ * about the composer changes.
431
+ */
432
+ export const WithSlashCommands: Story = {
433
+ args: { slashCommands: COMMANDS, onSlashCommand: fn() },
434
+ render: (args) => (
435
+ <div className="mx-auto max-w-2xl">
436
+ <Composer {...args} />
437
+ </div>
438
+ ),
439
+ play: async ({ canvas, args, userEvent, step }) => {
440
+ const field = canvas.getByPlaceholderText("Ask me anything…");
441
+
442
+ await step("typing '/' at the start of the line opens the palette", async () => {
443
+ await userEvent.click(field);
444
+ await userEvent.keyboard("/h");
445
+ await expect(await within(document.body).findByRole("listbox")).toBeInTheDocument();
446
+ await expect(field).toHaveFocus();
447
+ });
448
+
449
+ await step("Enter inserts the highlighted command and fires onSlashCommand", async () => {
450
+ await userEvent.keyboard("{Enter}");
451
+ await waitFor(() =>
452
+ expect(args.onSlashCommand).toHaveBeenCalledWith(expect.objectContaining({ name: "help" })),
453
+ );
454
+ await expect(field).toHaveValue("/help ");
455
+ // Radix keeps the popover mounted until its exit transition finishes, so
456
+ // the unmount is asynchronous — wait for it before the story's axe pass
457
+ // (a closing, empty cmdk listbox is the pre-existing
458
+ // `aria-required-children` shape every cmdk combobox in this repo shares).
459
+ await waitFor(() =>
460
+ expect(within(document.body).queryByRole("listbox")).not.toBeInTheDocument(),
461
+ );
462
+ });
463
+ },
464
+ };
465
+
466
+ /**
467
+ * `modelPicker` — the slot that replaced the old static `model` pill. Pass a
468
+ * `ModelPicker` from `@elabs-ai/components-ui` (it is sized to sit in a
469
+ * composer footer) wired to your own state; Composer owns only the slot and its
470
+ * position. A composer that shows a model name it cannot change is a lie about
471
+ * what the control does, which is why nothing renders here by default.
472
+ */
473
+ export const WithModelPicker: Story = {
474
+ render: (args) => (
475
+ <div className="mx-auto max-w-2xl">
476
+ <Composer {...args} modelPicker={<DemoModelPicker onSelect={onModelSelect} />} />
477
+ </div>
478
+ ),
479
+ play: async ({ canvas, userEvent, step }) => {
480
+ onModelSelect.mockClear();
481
+
482
+ await step("the trigger shows the currently pinned model", async () => {
483
+ await expect(canvas.getByRole("combobox", { name: "Model" })).toHaveTextContent("Sonnet");
484
+ });
485
+
486
+ await step("opening it and picking a model fires onSelect", async () => {
487
+ await userEvent.click(canvas.getByRole("combobox", { name: "Model" }));
488
+ const panel = within(document.body);
489
+ await userEvent.click(await panel.findByRole("option", { name: /Haiku/ }));
490
+ await waitFor(() =>
491
+ expect(onModelSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "haiku" })),
492
+ );
493
+ await waitFor(() =>
494
+ expect(canvas.getByRole("combobox", { name: "Model" })).toHaveTextContent("Haiku"),
495
+ );
496
+ });
497
+ },
498
+ };
499
+
500
+ /**
501
+ * Everything at once — the full footer in its canonical order:
502
+ * `attach · modelPicker · mode · effort │ voice · send`, the same left-to-right
503
+ * arrangement `TerminalComposer` uses. The cluster wraps rather than overflowing,
504
+ * so check this one at a narrow width and in both themes.
505
+ */
506
+ export const Everything: Story = {
507
+ args: {
508
+ status: "Ready when you are",
509
+ mode: { modes: MODES, onValueChange: fn() },
510
+ effort: { levels: LEVELS, "aria-label": "Reasoning effort", onValueChange: fn() },
511
+ slashCommands: COMMANDS,
512
+ onSlashCommand: fn(),
513
+ suggestions: SUGGESTIONS,
514
+ },
515
+ render: (args) => (
516
+ <div className="mx-auto max-w-2xl">
517
+ <Composer {...args} modelPicker={<DemoModelPicker />} />
518
+ </div>
519
+ ),
520
+ play: async ({ canvas, args, userEvent, step }) => {
521
+ await step("every control the PromptInput family ships is on screen", async () => {
522
+ await expect(canvas.getByRole("button", { name: "Attach files" })).toBeInTheDocument();
523
+ await expect(canvas.getByRole("combobox", { name: "Model" })).toBeInTheDocument();
524
+ await expect(canvas.getByRole("button", { name: /Auto/ })).toBeInTheDocument();
525
+ await expect(
526
+ canvas.getByRole("radiogroup", { name: "Reasoning effort" }),
527
+ ).toBeInTheDocument();
528
+ await expect(canvas.getByRole("button", { name: "Voice" })).toBeInTheDocument();
529
+ await expect(canvas.getByRole("button", { name: "Submit" })).toBeInTheDocument();
530
+ });
531
+
532
+ await step("and they are still live in combination", async () => {
533
+ await userEvent.click(canvas.getByRole("radio", { name: "Max" }));
534
+ await waitFor(() => expect(args.effort?.onValueChange).toHaveBeenCalledWith("max"));
535
+
536
+ await userEvent.click(canvas.getByPlaceholderText("Ask me anything…"));
537
+ await userEvent.keyboard("/cl");
538
+ await userEvent.keyboard("{Enter}");
539
+ await waitFor(() =>
540
+ expect(args.onSlashCommand).toHaveBeenCalledWith(
541
+ expect.objectContaining({ name: "clear" }),
542
+ ),
543
+ );
544
+ // See WithSlashCommands: wait out the popover's exit transition so axe
545
+ // never measures a closing, empty cmdk listbox.
546
+ await waitFor(() =>
547
+ expect(within(document.body).queryByRole("listbox")).not.toBeInTheDocument(),
548
+ );
549
+ });
550
+ },
551
+ };