@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/composer.tsx CHANGED
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
- import type { ComponentProps, ReactNode } from "react";
4
- import { ArrowUp, ChevronDown, Globe, Mic, Paperclip, Sparkles } from "lucide-react";
5
- import { cn, useLocale } from "@elabs-ai/components-ui";
3
+ import { useCallback, useRef, useState, type ComponentProps, type ReactNode } from "react";
4
+ import { ArrowUp, Mic, Paperclip, Sparkles } from "lucide-react";
5
+ import { cn, Kbd, useLocale } from "@elabs-ai/components-ui";
6
6
 
7
7
  import {
8
8
  PromptInput,
@@ -14,8 +14,54 @@ import {
14
14
  PromptInputTools,
15
15
  type PromptInputProps,
16
16
  } from "./prompt-input";
17
+ import { PromptInputEffort, type PromptInputEffortProps } from "./prompt-input-effort";
18
+ import { PromptInputMode, type PromptInputModeProps } from "./prompt-input-mode";
19
+ import {
20
+ PromptInputSlash,
21
+ PromptInputSlashTextarea,
22
+ type PromptInputSlashProps,
23
+ } from "./prompt-input-slash";
17
24
  import { Suggestion, Suggestions } from "./suggestion";
18
25
 
26
+ /**
27
+ * The slice of `PromptInputModeProps` a `Composer` owns — the vocabulary and
28
+ * the controlled/uncontrolled seam, never the DOM props of the wrapper div.
29
+ *
30
+ * `defaultValue` is deliberately absent: `PromptInputMode` has no uncontrolled
31
+ * initial-value seam (its uncontrolled default is `modes[0]`), and the
32
+ * `defaultValue` it inherits from `HTMLAttributes<HTMLDivElement>` is the DOM
33
+ * attribute, which would type-check and then do nothing. Omit `value` for the
34
+ * uncontrolled default; pass `value` + `onValueChange` to drive it.
35
+ */
36
+ export type ComposerModeProps = Pick<
37
+ PromptInputModeProps,
38
+ "modes" | "value" | "onValueChange" | "aria-label"
39
+ >;
40
+
41
+ /**
42
+ * The slice of `PromptInputEffortProps` a `Composer` owns. `aria-label` stays
43
+ * REQUIRED, exactly as on the primitive: the name of the scale ("Reasoning
44
+ * effort", "Thinking budget") is as much the consumer's vocabulary as its
45
+ * levels are, so `Composer` has no default to invent either. `defaultValue` is
46
+ * absent for the same reason it is on `ComposerModeProps`.
47
+ */
48
+ export type ComposerEffortProps = Pick<
49
+ PromptInputEffortProps,
50
+ "levels" | "value" | "onValueChange" | "aria-label"
51
+ >;
52
+
53
+ /**
54
+ * One shortcut-hint row entry: the physical key(s) plus what they do, shown
55
+ * as a `Kbd` chip beside a plain-text label — never inside a control's own
56
+ * name (see `ComposerProps.shortcuts`).
57
+ */
58
+ export interface ComposerShortcut {
59
+ /** Rendered inside a `Kbd` chip, e.g. `"Enter"`, `"Shift+Enter"`. */
60
+ keys: string;
61
+ /** What the key does, in a word or two, e.g. `"send"`, `"newline"`. */
62
+ label: string;
63
+ }
64
+
19
65
  export interface ComposerProps {
20
66
  /** Submit handler — receives the assembled message (text + attachments). */
21
67
  onSubmit?: PromptInputProps["onSubmit"];
@@ -27,18 +73,72 @@ export interface ComposerProps {
27
73
  * `null` to hide the strip entirely.
28
74
  */
29
75
  status?: ReactNode;
30
- /** Model-selector pill label. Default `"Claude Opus 4"`; pass `null` to hide it. */
31
- model?: ReactNode;
32
76
  /**
33
- * Override the left-hand tool cluster (attach + model pill) — e.g. to add a
34
- * web-search or connect-data control. Render `PromptInputButton`s /
35
- * `PromptInputActionMenu` here; when set, `showAttach`/`model` are ignored.
77
+ * The model control, rendered first in the tools cluster after `attach`.
78
+ * Pass a `<ModelPicker>` from `@elabs-ai/components-ui` (it is sized to sit
79
+ * in a composer footer) — or anything else. Default: nothing renders.
80
+ *
81
+ * This replaces the old `model` prop, which rendered a static, non-interactive
82
+ * pill defaulted to a hard-coded model name. A composer that shows a model
83
+ * name it cannot change is a lie about what the control does; the slot now
84
+ * takes the real picker instead.
85
+ */
86
+ modelPicker?: ReactNode;
87
+ /**
88
+ * App-defined operating mode control — renders a `PromptInputMode` in the
89
+ * tools cluster. Omitted, nothing renders. `brand-ui` ships no mode
90
+ * vocabulary: `modes` is entirely yours.
91
+ */
92
+ mode?: ComposerModeProps;
93
+ /**
94
+ * Ordered reasoning-effort control — renders a `PromptInputEffort` in the
95
+ * tools cluster. Omitted, nothing renders. `levels` is ordered low → high
96
+ * and is entirely yours; `aria-label` names the scale.
97
+ */
98
+ effort?: ComposerEffortProps;
99
+ /**
100
+ * Slash-command palette. When set (and non-empty) the textarea becomes a
101
+ * `PromptInputSlashTextarea` wrapped in a `PromptInputSlash`, so typing `/`
102
+ * at the start of a line opens a filtered command list — nothing else about
103
+ * the composer changes.
104
+ *
105
+ * Note this makes the textarea REACT-CONTROLLED (the palette needs to read
106
+ * and splice the text), which is why `Composer` clears its own copy of the
107
+ * text on submit: `PromptInput`'s `form.reset()` reaches the DOM node, not
108
+ * React state.
109
+ */
110
+ slashCommands?: PromptInputSlashProps["commands"];
111
+ /** Fires with the chosen slash command (alongside the text splice). */
112
+ onSlashCommand?: PromptInputSlashProps["onSelect"];
113
+ /**
114
+ * Override the DEFAULT tool buttons (today: the attach button) — e.g. to add
115
+ * a web-search or connect-data control. Render `PromptInputButton`s /
116
+ * `PromptInputActionMenu` here; when set, `showAttach` is ignored.
117
+ *
118
+ * The explicit `modelPicker` / `mode` / `effort` slots are NOT part of this
119
+ * override — they render after whatever this puts in the cluster, so passing
120
+ * `tools` can never silently swallow a control you asked for by name.
36
121
  */
37
122
  tools?: ReactNode;
38
123
  /** Send-button state, forwarded to `PromptInputSubmit` (ready/submitted/streaming/error). */
39
124
  sendStatus?: ComponentProps<typeof PromptInputSubmit>["status"];
40
125
  /** Stop handler used while the send button shows the generating state. */
41
126
  onStop?: ComponentProps<typeof PromptInputSubmit>["onStop"];
127
+ /**
128
+ * Keyboard-shortcut hints shown as a row beneath the input well — plain
129
+ * `Kbd` + label pairs, e.g. `[{ keys: "Enter", label: "send" }]`. Omitted,
130
+ * nothing renders (opt-in, like every other Composer slot); the row is
131
+ * never invented, since the real bindings are the host app's.
132
+ */
133
+ shortcuts?: ComposerShortcut[];
134
+ /**
135
+ * A second, busy-only hint appended to `shortcuts` (#107) — shown ONLY
136
+ * once the composer is actually generating (`sendStatus` is `"submitted"`
137
+ * or `"streaming"`) AND a real `onStop` handler is set, so the hint never
138
+ * describes an affordance that isn't there. Mirrors
139
+ * `TerminalComposer`'s `canCancel = busy && Boolean(onStop)` shape.
140
+ */
141
+ cancelShortcut?: ComposerShortcut;
42
142
  /**
43
143
  * Extra props spread onto the send button — `disabled`, `aria-label`,
44
144
  * `variant`, `id`, `className`. `status` and `onStop` are excluded because
@@ -54,7 +154,8 @@ export interface ComposerProps {
54
154
  * user's text destroyed, with nothing to restore from. Disabling the control
55
155
  * is what actually prevents it: `PromptInputTextarea`'s Enter handler checks
56
156
  * `submitControl?.disabled` and bails before `requestSubmit()`, so the Enter
57
- * path is closed too, not just the click.
157
+ * path is closed too, not just the click. It holds with a slash palette open
158
+ * as well — the palette's own Enter handling only ever inserts a command.
58
159
  *
59
160
  * Leave it UNSET rather than passing `false` when you have no opinion —
60
161
  * `PromptInputSubmit` resolves `disabled ?? autoDisabled`, so a literal
@@ -88,9 +189,9 @@ export interface ComposerProps {
88
189
  * Composer — the standard brand-ui AI chat input.
89
190
  *
90
191
  * A rounded two-tone "double card": a status strip wrapping a recessed
91
- * `PromptInput` well (sharp top, theme-rounded bottom), a model pill, voice,
92
- * and a circular send. Built on the real `PromptInput`, so it drops into a
93
- * `ChatShell` footer or stands alone as an empty-state composer. `tone`
192
+ * `PromptInput` well (sharp top, theme-rounded bottom), a tools cluster,
193
+ * voice, and a circular send. Built on the real `PromptInput`, so it drops
194
+ * into a `ChatShell` footer or stands alone as an empty-state composer. `tone`
94
195
  * (default `"surface"`, unchanged) picks the arrangement: `"surface"` keeps
95
196
  * the original outer `bg-card` frame around the standard muted well;
96
197
  * `"card"` (#254) swaps to an outer `bg-surface-muted` frame around a
@@ -99,18 +200,30 @@ export interface ComposerProps {
99
200
  * `tone` prop doc on `PromptInput`. Semantic tokens only; theme-aware radii
100
201
  * (`rounded-xl` frame / `rounded-b-lg` well); reads in every theme.
101
202
  *
102
- * This is the canonical chat input — reach for it instead of hand-rolling a
103
- * `PromptInput` footer.
203
+ * **Composer is the chat input. Every control the `PromptInput` family ships
204
+ * is reachable from a `Composer` prop; drop to `PromptInput` only for a
205
+ * bespoke shell.** `modelPicker`, `mode`, `effort` and `slashCommands` are the
206
+ * four slots that make that true — they render `ModelPicker`,
207
+ * `PromptInputMode`, `PromptInputEffort` and `PromptInputSlash` respectively,
208
+ * in the footer order `attach · modelPicker · mode · effort │ voice · send`
209
+ * (the same left-to-right arrangement `TerminalComposer` uses, so the chat and
210
+ * console skins agree).
104
211
  */
105
212
  export function Composer({
106
213
  onSubmit,
107
214
  placeholder,
108
215
  status = "Awaiting your input",
109
- model = "Claude Opus 4",
216
+ modelPicker,
217
+ mode,
218
+ effort,
219
+ slashCommands,
220
+ onSlashCommand,
110
221
  tools,
111
222
  sendStatus,
112
223
  onStop,
113
224
  submitProps,
225
+ shortcuts,
226
+ cancelShortcut,
114
227
  suggestions,
115
228
  onSuggestionClick,
116
229
  showVoice = true,
@@ -120,6 +233,38 @@ export function Composer({
120
233
  }: ComposerProps) {
121
234
  const { t } = useLocale();
122
235
 
236
+ // Only read when `slashCommands` is set — the palette needs the live text and
237
+ // a handle on the field to do its caret math. Without it the textarea stays
238
+ // uncontrolled, exactly as before.
239
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
240
+ const [text, setText] = useState("");
241
+ const hasSlash = slashCommands !== undefined && slashCommands.length > 0;
242
+
243
+ // #107: "Composer shortcut hints change with a busy state" — derived from
244
+ // the existing canonical `sendStatus`, never a second boolean prop. The
245
+ // cancel hint only ever joins the row once there is a real Stop affordance
246
+ // it can describe (mirrors `TerminalComposer`'s `canCancel` derivation).
247
+ const busy = sendStatus === "submitted" || sendStatus === "streaming";
248
+ const canCancel = busy && Boolean(onStop);
249
+ const allShortcuts = [
250
+ ...(shortcuts ?? []),
251
+ ...(canCancel && cancelShortcut ? [cancelShortcut] : []),
252
+ ];
253
+
254
+ const handleSubmit = useCallback<PromptInputProps["onSubmit"]>(
255
+ (message, event) => {
256
+ // `PromptInput.handleSubmit` calls `form.reset()`, which clears the DOM
257
+ // node but cannot touch React state — so the slash-mode textarea would
258
+ // keep rendering the sent text straight back. Clearing here is a no-op in
259
+ // the uncontrolled (non-slash) arrangement.
260
+ setText("");
261
+ return onSubmit?.(message, event);
262
+ },
263
+ [onSubmit],
264
+ );
265
+
266
+ const resolvedPlaceholder = placeholder ?? t("ai.composer.placeholder");
267
+
123
268
  return (
124
269
  <div className="w-full">
125
270
  <div
@@ -139,33 +284,46 @@ export function Composer({
139
284
 
140
285
  {/* Inner well — sharp top (nests under the strip), theme-rounded bottom. */}
141
286
  <PromptInput
142
- onSubmit={onSubmit ?? (() => undefined)}
287
+ onSubmit={handleSubmit}
143
288
  surfaceClassName="rounded-t-none rounded-b-lg"
144
289
  tone={tone}
145
290
  >
146
291
  <PromptInputBody>
147
- <PromptInputTextarea placeholder={placeholder ?? t("ai.composer.placeholder")} />
292
+ {hasSlash && slashCommands ? (
293
+ <PromptInputSlash
294
+ commands={slashCommands}
295
+ value={text}
296
+ textareaRef={textareaRef}
297
+ onValueChange={(next) => setText(next.text)}
298
+ onSelect={onSlashCommand}
299
+ >
300
+ {/* No `ref` here on purpose: PromptInputSlashTextarea merges the
301
+ palette's own `textareaRef` in for us. */}
302
+ <PromptInputSlashTextarea
303
+ value={text}
304
+ onChange={(event) => setText(event.target.value)}
305
+ placeholder={resolvedPlaceholder}
306
+ />
307
+ </PromptInputSlash>
308
+ ) : (
309
+ <PromptInputTextarea placeholder={resolvedPlaceholder} />
310
+ )}
148
311
  </PromptInputBody>
149
312
  <PromptInputFooter>
150
- <PromptInputTools>
151
- {tools ?? (
152
- <>
153
- {showAttach ? (
154
- <PromptInputButton tooltip="Attach files">
155
- <Paperclip className="size-4" />
156
- </PromptInputButton>
157
- ) : null}
158
- {model != null ? (
159
- <PromptInputButton variant="ghost" className="gap-1.5 rounded-full">
160
- <Globe className="size-4" />
161
- <span>{model}</span>
162
- <ChevronDown className="size-4 opacity-60" />
163
- </PromptInputButton>
164
- ) : null}
165
- </>
166
- )}
313
+ {/* `flex-wrap` so a full cluster (attach + picker + mode + effort)
314
+ wraps instead of overflowing a narrow composer. */}
315
+ <PromptInputTools className="flex-wrap gap-y-1">
316
+ {tools ??
317
+ (showAttach ? (
318
+ <PromptInputButton tooltip="Attach files">
319
+ <Paperclip className="size-4" />
320
+ </PromptInputButton>
321
+ ) : null)}
322
+ {modelPicker}
323
+ {mode ? <PromptInputMode {...mode} /> : null}
324
+ {effort ? <PromptInputEffort {...effort} /> : null}
167
325
  </PromptInputTools>
168
- <div className="flex items-center gap-1">
326
+ <div className="flex shrink-0 items-center gap-1">
169
327
  {showVoice ? (
170
328
  <PromptInputButton tooltip="Voice">
171
329
  <Mic className="size-4" />
@@ -188,6 +346,30 @@ export function Composer({
188
346
  </div>
189
347
  </PromptInputFooter>
190
348
  </PromptInput>
349
+
350
+ {/* #107: the hint row itself — plain SIBLING `Kbd` + label pairs, never
351
+ nested inside `PromptInputSubmit` (which already names itself via its
352
+ own `aria-label`), so the row cannot pollute any control's accessible
353
+ name (the #153-style trap). */}
354
+ {allShortcuts.length > 0 ? (
355
+ <div
356
+ className="flex flex-wrap items-center gap-3 px-3 pt-1.5 text-meta text-muted-foreground"
357
+ data-slot="composer-shortcuts"
358
+ >
359
+ {allShortcuts.map((shortcut, index) => (
360
+ <span
361
+ className="inline-flex items-center gap-1.5"
362
+ // Shortcut rows are a fixed, non-reorderable list per render —
363
+ // `keys` alone isn't guaranteed unique (a caller could offer the
364
+ // same key for two purposes), so index is a stable, honest key.
365
+ key={index}
366
+ >
367
+ <Kbd>{shortcut.keys}</Kbd>
368
+ <span>{shortcut.label}</span>
369
+ </span>
370
+ ))}
371
+ </div>
372
+ ) : null}
191
373
  </div>
192
374
 
193
375
  {suggestions && suggestions.length > 0 ? (
@@ -1,4 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { expect, fn } from "storybook/test";
3
+ import { type ApprovalOption } from "@elabs-ai/components-ui";
2
4
  import {
3
5
  ApprovalCard,
4
6
  ApprovalCardAccepted,
@@ -6,15 +8,26 @@ import {
6
8
  ApprovalCardApprove,
7
9
  ApprovalCardDeny,
8
10
  ApprovalCardDescription,
11
+ ApprovalCardOptions,
12
+ ApprovalCardReason,
9
13
  ApprovalCardRejected,
10
14
  ApprovalCardRequest,
15
+ ApprovalCardTarget,
11
16
  ApprovalCardTitle,
12
17
  } from "./confirmation";
13
18
  const meta = {
14
19
  title: "AI/ApprovalCard",
15
20
  component: ApprovalCard,
16
21
  tags: ["autodocs"],
17
- parameters: { layout: "padded" },
22
+ parameters: {
23
+ layout: "padded",
24
+ docs: {
25
+ description: {
26
+ component:
27
+ "The CHAT approval gate; the console skin is `Terminal/TerminalPermission`, and both read the same `ApprovalScope` / `ApprovalOption` model promoted into `@elabs-ai/components-ui` — see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). Pending is the attention treatment: a warning wash plus a hue-independent structural rail and a lift, never colour alone.",
28
+ },
29
+ },
30
+ },
18
31
  } satisfies Meta<typeof ApprovalCard>;
19
32
  export default meta;
20
33
  type Story = StoryObj<typeof meta>;
@@ -59,3 +72,61 @@ export const Denied: Story = {
59
72
  </ApprovalCard>
60
73
  ),
61
74
  };
75
+
76
+ // N-option, scoped decision (#103) — real coding-agent permission prompts are
77
+ // rarely a plain yes/no. `ApprovalCardTarget` previews WHAT is being
78
+ // approved; `ApprovalCardOptions` renders the scoped choices through a real
79
+ // Radix radiogroup; `ApprovalCardReason` carries an optional explanation that
80
+ // travels with whichever option is chosen.
81
+ const scopedOptions: ApprovalOption[] = [
82
+ { id: "once", label: "Yes", scope: "once" },
83
+ {
84
+ id: "session",
85
+ label: "Yes, and keep approving this command for the session",
86
+ scope: "session",
87
+ },
88
+ { id: "deny", label: "No", description: "Denies the deploy.", scope: "deny" },
89
+ ];
90
+
91
+ export const ScopedOptions: Story = {
92
+ render: () => (
93
+ <ApprovalCard approval={{ id: "call-2" }} state="approval-requested">
94
+ <ApprovalCardRequest>
95
+ <ApprovalCardTitle>Run the production deploy script?</ApprovalCardTitle>
96
+ <ApprovalCardTarget>
97
+ <code>./scripts/deploy.sh --env production</code>
98
+ </ApprovalCardTarget>
99
+ <ApprovalCardReason />
100
+ <ApprovalCardOptions options={scopedOptions} />
101
+ </ApprovalCardRequest>
102
+ </ApprovalCard>
103
+ ),
104
+ };
105
+
106
+ const scopedOptionsOnConfirm = fn();
107
+
108
+ // Types a reason FIRST, then chooses "No" — `ApprovalCardOptions` reports the
109
+ // chosen option together with whatever reason text is already held by the
110
+ // sibling `ApprovalCardReason` at the moment the option is selected.
111
+ export const ScopedOptionsDenyWithReason: Story = {
112
+ render: () => (
113
+ <ApprovalCard approval={{ id: "call-3" }} state="approval-requested">
114
+ <ApprovalCardRequest>
115
+ <ApprovalCardTitle>Run the production deploy script?</ApprovalCardTitle>
116
+ <ApprovalCardTarget>
117
+ <code>./scripts/deploy.sh --env production</code>
118
+ </ApprovalCardTarget>
119
+ <ApprovalCardReason />
120
+ <ApprovalCardOptions options={scopedOptions} onConfirm={scopedOptionsOnConfirm} />
121
+ </ApprovalCardRequest>
122
+ </ApprovalCard>
123
+ ),
124
+ play: async ({ canvas, userEvent }) => {
125
+ await userEvent.type(canvas.getByRole("textbox"), "Not ready for production yet");
126
+ await userEvent.click(canvas.getByRole("radio", { name: /^no/i }));
127
+ await expect(scopedOptionsOnConfirm).toHaveBeenCalledWith(
128
+ scopedOptions[2],
129
+ "Not ready for production yet",
130
+ );
131
+ },
132
+ };
@@ -1,12 +1,18 @@
1
- import { describe, expect, it } from "vitest";
2
- import { render, screen } from "@testing-library/react";
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { type ApprovalOption } from "@elabs-ai/components-ui";
5
+ import * as confirmation from "./confirmation";
3
6
  import {
4
7
  ApprovalCard,
5
8
  ApprovalCardActions,
6
9
  ApprovalCardApprove,
7
10
  ApprovalCardDeny,
8
11
  ApprovalCardDescription,
12
+ ApprovalCardOptions,
13
+ ApprovalCardReason,
9
14
  ApprovalCardRequest,
15
+ ApprovalCardTarget,
10
16
  ApprovalCardTitle,
11
17
  Confirmation,
12
18
  ConfirmationAccepted,
@@ -134,3 +140,211 @@ describe("ApprovalCard alias", () => {
134
140
  expect(screen.getByText("Approve").className).toContain("bg-primary");
135
141
  });
136
142
  });
143
+
144
+ /**
145
+ * The `Confirmation*` family is a CLOSED compatibility surface mirroring the
146
+ * AI-Elements shape (docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4).
147
+ * New parts are `ApprovalCard*` only. Adding a name here is a decision, not a fix.
148
+ */
149
+ const FROZEN_CONFIRMATION_EXPORTS = [
150
+ "Confirmation",
151
+ "ConfirmationTitle",
152
+ "ConfirmationDescription",
153
+ "ConfirmationRequest",
154
+ "ConfirmationAccepted",
155
+ "ConfirmationRejected",
156
+ "ConfirmationActions",
157
+ "ConfirmationAction",
158
+ "ConfirmationApprove",
159
+ "ConfirmationDeny",
160
+ ].sort();
161
+
162
+ it("does not grow the Confirmation compatibility family", () => {
163
+ const actual = Object.keys(confirmation)
164
+ .filter((k) => k.startsWith("Confirmation"))
165
+ .sort();
166
+ expect(actual).toEqual(FROZEN_CONFIRMATION_EXPORTS);
167
+ });
168
+
169
+ describe("Confirmation binary preset is unaffected by the N-option API (#103)", () => {
170
+ it("still renders exactly two actions and the same rail class with no `options` prop", () => {
171
+ render(
172
+ <Confirmation state="approval-requested" approval={{ id: "t" }}>
173
+ <ConfirmationRequest>
174
+ <ConfirmationTitle>Proceed?</ConfirmationTitle>
175
+ <ConfirmationActions>
176
+ <ConfirmationDeny>Deny</ConfirmationDeny>
177
+ <ConfirmationApprove>Approve</ConfirmationApprove>
178
+ </ConfirmationActions>
179
+ </ConfirmationRequest>
180
+ </Confirmation>,
181
+ );
182
+ expect(screen.getAllByRole("button")).toHaveLength(2);
183
+ expect(screen.getByRole("group").className).toContain("border-s-border-strong");
184
+ expect(screen.queryByRole("radiogroup")).not.toBeInTheDocument();
185
+ expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
186
+ });
187
+ });
188
+
189
+ describe("ApprovalCardOptions (#103) — N-option, scoped decision", () => {
190
+ const options: ApprovalOption[] = [
191
+ { id: "once", label: "Yes", scope: "once" },
192
+ { id: "session", label: "Yes, and don't ask again this session", scope: "session" },
193
+ { id: "deny", label: "No", description: "Denies this action.", scope: "deny" },
194
+ ];
195
+
196
+ it("renders a real radiogroup via Radix, never a hand-rolled roving group", () => {
197
+ render(
198
+ <Confirmation state="approval-requested" approval={{ id: "t" }}>
199
+ <ConfirmationRequest>
200
+ <ConfirmationTitle>Run the deploy script?</ConfirmationTitle>
201
+ <ApprovalCardOptions options={options} />
202
+ </ConfirmationRequest>
203
+ </Confirmation>,
204
+ );
205
+ expect(screen.getByRole("radiogroup")).toBeInTheDocument();
206
+ expect(screen.getAllByRole("radio")).toHaveLength(3);
207
+ });
208
+
209
+ it("ArrowDown from the last option wraps to the first, and the selected option is reported to onConfirm", async () => {
210
+ const onConfirm = vi.fn();
211
+ render(
212
+ <Confirmation state="approval-requested" approval={{ id: "t" }}>
213
+ <ConfirmationRequest>
214
+ <ConfirmationTitle>Run the deploy script?</ConfirmationTitle>
215
+ <ApprovalCardOptions options={options} onConfirm={onConfirm} />
216
+ </ConfirmationRequest>
217
+ </Confirmation>,
218
+ );
219
+
220
+ const radios = screen.getAllByRole("radio");
221
+ const [firstRadio, , lastRadio] = radios;
222
+ lastRadio!.focus();
223
+ await userEvent.keyboard("{ArrowDown}");
224
+
225
+ // Radix's `RovingFocusGroup` wraps focus from the last item back to the
226
+ // first — this is the roving-tabindex behavior we get for free by using
227
+ // Radix instead of a hand-rolled `parentElement.children[i]` walk.
228
+ expect(firstRadio).toHaveFocus();
229
+ // Committing a focused native radio via Space is standard browser
230
+ // behavior (independent of Radix's own auto-select-on-arrow-move, which
231
+ // relies on a real document-level focus/click ordering that jsdom does
232
+ // not reproduce faithfully). This still proves the acceptance criterion:
233
+ // the option focus wrapped to is the one reported to onConfirm.
234
+ await userEvent.keyboard(" ");
235
+
236
+ await waitFor(() => expect(firstRadio).toBeChecked());
237
+ expect(onConfirm).toHaveBeenLastCalledWith(options[0], undefined);
238
+ });
239
+
240
+ it("a denial with typed reason text calls onConfirm with both the option and the reason", async () => {
241
+ const onConfirm = vi.fn();
242
+ render(
243
+ <Confirmation state="approval-requested" approval={{ id: "t" }}>
244
+ <ConfirmationRequest>
245
+ <ConfirmationTitle>Delete the release branch?</ConfirmationTitle>
246
+ <ApprovalCardReason />
247
+ <ApprovalCardOptions options={options} onConfirm={onConfirm} />
248
+ </ConfirmationRequest>
249
+ </Confirmation>,
250
+ );
251
+
252
+ await userEvent.type(screen.getByRole("textbox"), "Not ready yet");
253
+ await userEvent.click(screen.getByRole("radio", { name: /^no/i }));
254
+
255
+ expect(onConfirm).toHaveBeenCalledWith(options[2], "Not ready yet");
256
+ });
257
+
258
+ it("falls back to a scope-derived description when the caller supplies none — never colour/data-* alone", () => {
259
+ render(
260
+ <Confirmation state="approval-requested" approval={{ id: "t" }}>
261
+ <ConfirmationRequest>
262
+ <ConfirmationTitle>Install dependencies?</ConfirmationTitle>
263
+ <ApprovalCardOptions
264
+ options={[{ id: "always", label: "Yes, always", scope: "always" }]}
265
+ />
266
+ </ConfirmationRequest>
267
+ </Confirmation>,
268
+ );
269
+ // No LocaleProvider is mounted, so `t()` falls back to the shipped English
270
+ // default — real, visible text tied to the option via `aria-describedby`,
271
+ // not a colour or a bare `data-*` attribute. Asserting the SENTENCE (not
272
+ // the key) is what proves the central catalogue actually carries it.
273
+ const radio = screen.getByRole("radio", { name: "Yes, always" });
274
+ const describedBy = radio.getAttribute("aria-describedby");
275
+ expect(describedBy).toBeTruthy();
276
+ expect(document.getElementById(describedBy!)?.textContent).toBe(
277
+ "Applies to actions like this from now on.",
278
+ );
279
+ });
280
+
281
+ it("prefers a caller-supplied description over the derived scope text", () => {
282
+ render(
283
+ <Confirmation state="approval-requested" approval={{ id: "t" }}>
284
+ <ConfirmationRequest>
285
+ <ConfirmationTitle>Install dependencies?</ConfirmationTitle>
286
+ <ApprovalCardOptions options={options} />
287
+ </ConfirmationRequest>
288
+ </Confirmation>,
289
+ );
290
+ expect(screen.getByText("Denies this action.")).toBeInTheDocument();
291
+ });
292
+
293
+ it("is uncontrolled by default and controlled when `value` is supplied", async () => {
294
+ const onValueChange = vi.fn();
295
+ const { rerender } = render(
296
+ <Confirmation state="approval-requested" approval={{ id: "t" }}>
297
+ <ConfirmationRequest>
298
+ <ConfirmationTitle>Run the deploy script?</ConfirmationTitle>
299
+ <ApprovalCardOptions options={options} value="once" onValueChange={onValueChange} />
300
+ </ConfirmationRequest>
301
+ </Confirmation>,
302
+ );
303
+ const sessionRadio = screen.getByRole("radio", {
304
+ name: "Yes, and don't ask again this session",
305
+ });
306
+ await userEvent.click(sessionRadio);
307
+ expect(onValueChange).toHaveBeenCalledWith("session");
308
+ // Controlled: the caller did not update `value` in response, so the
309
+ // checked state stays pinned to the `value` prop, not the click.
310
+ expect(screen.getByRole("radio", { name: "Yes" })).toBeChecked();
311
+ expect(sessionRadio).not.toBeChecked();
312
+
313
+ rerender(
314
+ <Confirmation state="approval-requested" approval={{ id: "t" }}>
315
+ <ConfirmationRequest>
316
+ <ConfirmationTitle>Run the deploy script?</ConfirmationTitle>
317
+ <ApprovalCardOptions options={options} value="session" onValueChange={onValueChange} />
318
+ </ConfirmationRequest>
319
+ </Confirmation>,
320
+ );
321
+ expect(
322
+ screen.getByRole("radio", { name: "Yes, and don't ask again this session" }),
323
+ ).toBeChecked();
324
+ });
325
+ });
326
+
327
+ describe("ApprovalCardTarget (#103)", () => {
328
+ it("accepts arbitrary children — e.g. a command or diff preview", () => {
329
+ render(
330
+ <Confirmation state="approval-requested" approval={{ id: "t" }}>
331
+ <ConfirmationRequest>
332
+ <ConfirmationTitle>Apply this change?</ConfirmationTitle>
333
+ <ApprovalCardTarget>
334
+ <pre>rm -rf build/</pre>
335
+ </ApprovalCardTarget>
336
+ </ConfirmationRequest>
337
+ </Confirmation>,
338
+ );
339
+ expect(screen.getByText("rm -rf build/")).toBeInTheDocument();
340
+ });
341
+
342
+ it("stays visible after the decision resolves (not gated by state)", () => {
343
+ render(
344
+ <Confirmation state="approval-responded" approval={{ id: "t", approved: true }}>
345
+ <ApprovalCardTarget>rm -rf build/</ApprovalCardTarget>
346
+ </Confirmation>,
347
+ );
348
+ expect(screen.getByText("rm -rf build/")).toBeInTheDocument();
349
+ });
350
+ });