@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
@@ -0,0 +1,541 @@
1
+ "use client";
2
+
3
+ /**
4
+ * PromptInputSlash — a slash-command palette for the chat composer.
5
+ *
6
+ * Reuses the generic trigger machinery `@elabs-ai/components-ui` ships at
7
+ * `lib/trigger-query.ts` (`findTriggerQuery` / `replaceTriggerRun`) with
8
+ * `trigger: "/"` and `boundary: "line-start"` — the same algorithm
9
+ * `MentionInput` runs with `trigger: "@"` and the `"word"` boundary. See
10
+ * docs/decisions/2026-09-01-brainless-adoption-architecture.md § 5: this
11
+ * package NEVER re-implements trigger detection, and never imports the
12
+ * markdown-scoped slash menu in `@elabs-ai/components-editor` (that would be
13
+ * a sideways edge in the one-way dependency graph).
14
+ *
15
+ * Rendering goes through the existing `PromptInputCommand*` parts (thin
16
+ * `cmdk` wrappers), controlled rather than driven by `cmdk`'s own keyboard
17
+ * handling — the caret must stay in the composer's `<textarea>` the whole
18
+ * time, so every keystroke (Arrow/Home/End/Enter/Escape) is intercepted on
19
+ * the textarea itself and only the *highlighted* `value` is pushed into
20
+ * `PromptInputCommand`. `aria-activedescendant` is read back via
21
+ * `PromptInputCommand`'s existing `onActiveItemIdChange` seam (the doc-block
22
+ * on `PromptInputCommandItem` names this exact composition) rather than by
23
+ * guessing a DOM id — `cmdk` assigns each item's `id` internally and
24
+ * overwrites any id a consumer passes.
25
+ *
26
+ * `SlashCommand`, `defaultSlashCommandFilter` and the index-stepping helper
27
+ * (`stepIndex`, formerly this file's private `step`) are promoted to
28
+ * `@elabs-ai/components-ui` (T0 — the terminal CLI look-alike family's own
29
+ * palette, issue #117, reuses the same model; `ui` is the one legal shared
30
+ * home for two layer-2 DAG siblings — see
31
+ * docs/decisions/2026-09-01-brainless-adoption-architecture.md § 4).
32
+ */
33
+ import {
34
+ createContext,
35
+ forwardRef,
36
+ use,
37
+ useCallback,
38
+ useEffect,
39
+ useLayoutEffect,
40
+ useMemo,
41
+ useRef,
42
+ useState,
43
+ type ForwardedRef,
44
+ type HTMLAttributes,
45
+ type KeyboardEvent as ReactKeyboardEvent,
46
+ type ReactNode,
47
+ type RefObject,
48
+ } from "react";
49
+ import {
50
+ Popover,
51
+ PopoverAnchor,
52
+ PopoverContent,
53
+ cn,
54
+ defaultSlashCommandFilter,
55
+ findTriggerQuery,
56
+ replaceTriggerRun,
57
+ stepIndex,
58
+ useLocale,
59
+ type SlashCommand,
60
+ type TriggerBoundary,
61
+ } from "@elabs-ai/components-ui";
62
+
63
+ import {
64
+ PromptInputCommand,
65
+ PromptInputCommandEmpty,
66
+ PromptInputCommandItem,
67
+ PromptInputCommandList,
68
+ PromptInputTextarea,
69
+ type PromptInputTextareaProps,
70
+ } from "./prompt-input";
71
+
72
+ const TRIGGER = "/";
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Small local helpers (deliberately NOT shared — see the doc-comments below)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Merge multiple refs into one callback ref. A private, ~10-line copy rather
80
+ * than a cross-package import: `mergeRefs` lives in `@elabs-ai/components-ui`'s
81
+ * `lib/` but is not exported from its barrel or any subpath today, and adding
82
+ * one is a subpath-export decision this change is not scoped to make (see
83
+ * `.claude/rules/component-api.md` § "Subpath exports"). Ordinary React
84
+ * ref-composition boilerplate, not the trigger algorithm this file exists to
85
+ * avoid re-implementing.
86
+ */
87
+ type MaybeRef<T> = ForwardedRef<T> | undefined;
88
+
89
+ // prettier-ignore
90
+ function mergeRefs<T>(
91
+ ...refs: Array<MaybeRef<T>>
92
+ ) {
93
+ return (node: T | null) => {
94
+ for (const ref of refs) {
95
+ if (typeof ref === "function") ref(node);
96
+ else if (ref) (ref as { current: T | null }).current = node;
97
+ }
98
+ };
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Public types
103
+ // ---------------------------------------------------------------------------
104
+ //
105
+ // `SlashCommand` and `defaultSlashCommandFilter` moved to
106
+ // `@elabs-ai/components-ui` (`lib/slash-command.ts`) — see the promotion
107
+ // note at the top of this file.
108
+
109
+ export interface PromptInputSlashProps extends Omit<
110
+ HTMLAttributes<HTMLDivElement>,
111
+ "onSelect" | "children"
112
+ > {
113
+ /** The registered commands the palette offers. */
114
+ commands: SlashCommand[];
115
+ /** The composer's current text — must match what the wrapped textarea renders. */
116
+ value: string;
117
+ /** Ref to the composer's `<textarea>` (usually the one `PromptInputSlashTextarea` renders). */
118
+ textareaRef: RefObject<HTMLTextAreaElement | null>;
119
+ /**
120
+ * Only trigger at the start of a line (index 0, or right after `"\n"`).
121
+ * `false` falls back to `MentionInput`'s word-boundary rule.
122
+ * @default true
123
+ */
124
+ lineStartOnly?: boolean;
125
+ /** Which commands survive the current query. @default defaultSlashCommandFilter */
126
+ filter?: (command: SlashCommand, query: string) => boolean;
127
+ /**
128
+ * A command was chosen — apply the spliced `{ text, caret }` to your own
129
+ * controlled value (the same shape `replaceTriggerRun` returns). The
130
+ * splice inserts `"/" + command.name + " "` in place of the typed query.
131
+ */
132
+ onValueChange: (next: { text: string; caret: number }) => void;
133
+ /** A command was chosen (fires alongside, not instead of, `onValueChange`). */
134
+ onSelect?: (command: SlashCommand) => void;
135
+ /** The popup opened or closed. */
136
+ onOpenChange?: (open: boolean) => void;
137
+ children: ReactNode;
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Wiring context — private plumbing between the root and its `Textarea` part
142
+ // (component-api.md: "compound components share a context, not props").
143
+ // ---------------------------------------------------------------------------
144
+
145
+ interface PromptInputSlashWiring {
146
+ open: boolean;
147
+ /**
148
+ * The REAL DOM id `cmdk` assigned to the listbox, read back off the
149
+ * rendered node — `cmdk`'s `CommandList` forces its own internally
150
+ * generated `id` after spreading consumer props (the same override
151
+ * `CMDK_OVERRIDDEN_ITEM_PROPS` documents for `CommandItem`), so a
152
+ * self-minted id passed as a prop is silently dropped.
153
+ */
154
+ listDomId?: string;
155
+ /** DOM id of the highlighted `PromptInputCommandItem`, from `onActiveItemIdChange`. */
156
+ activeId?: string;
157
+ textareaRef: RefObject<HTMLTextAreaElement | null>;
158
+ syncCaret: (element: HTMLTextAreaElement) => void;
159
+ onKeyDown: (event: ReactKeyboardEvent<HTMLTextAreaElement>) => void;
160
+ }
161
+
162
+ const PromptInputSlashWiringContext = createContext<PromptInputSlashWiring | null>(null);
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // PromptInputSlash (root — provider + Popover, mirrors MentionInput's shape)
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * PromptInputSlash — wrap the composer's textarea (rendered via
170
+ * `PromptInputSlashTextarea`) to add a `/`-triggered command palette.
171
+ *
172
+ * ```tsx
173
+ * const textareaRef = useRef<HTMLTextAreaElement>(null);
174
+ * const [text, setText] = useState("");
175
+ *
176
+ * <PromptInputSlash
177
+ * commands={commands}
178
+ * value={text}
179
+ * textareaRef={textareaRef}
180
+ * onValueChange={({ text }) => setText(text)}
181
+ * >
182
+ * <PromptInputSlashTextarea value={text} onChange={(e) => setText(e.target.value)} />
183
+ * </PromptInputSlash>
184
+ * ```
185
+ */
186
+ export const PromptInputSlash = forwardRef<HTMLDivElement, PromptInputSlashProps>(
187
+ function PromptInputSlash(
188
+ {
189
+ commands,
190
+ value,
191
+ textareaRef,
192
+ lineStartOnly = true,
193
+ filter = defaultSlashCommandFilter,
194
+ onValueChange,
195
+ onSelect,
196
+ onOpenChange,
197
+ className,
198
+ children,
199
+ ...props
200
+ },
201
+ ref,
202
+ ) {
203
+ const { t } = useLocale();
204
+
205
+ const rootRef = useRef<HTMLDivElement>(null);
206
+ const mergedRootRef = useMemo(() => mergeRefs<HTMLDivElement>(ref, rootRef), [ref]);
207
+
208
+ // ------------------------------------------------------------------
209
+ // Caret — tracked from the wrapped textarea's own DOM events, exactly
210
+ // like `MentionInput`'s `syncCaret`/`applyCaret` pair.
211
+ // ------------------------------------------------------------------
212
+ const [caret, setCaret] = useState(() => value.length);
213
+ const pendingCaretRef = useRef<number | null>(null);
214
+
215
+ const syncCaret = useCallback((element: HTMLTextAreaElement) => {
216
+ setCaret(element.selectionStart ?? 0);
217
+ }, []);
218
+
219
+ // Post-commit pass: only after `value` has actually re-rendered into the
220
+ // DOM can the caret be restored — see MentionInput's identical comment.
221
+ useLayoutEffect(() => {
222
+ const position = pendingCaretRef.current;
223
+ if (position === null) return;
224
+ pendingCaretRef.current = null;
225
+ // `setSelectionRange` fires a native `select` event, which
226
+ // `PromptInputSlashTextarea`'s `onSelect` handler already routes to
227
+ // `syncCaret` — no need to call `setCaret` here too (mirrors
228
+ // `MentionInput`'s identical caret-restore effect).
229
+ textareaRef.current?.setSelectionRange(position, position);
230
+ });
231
+
232
+ // ------------------------------------------------------------------
233
+ // Query / open state
234
+ // ------------------------------------------------------------------
235
+ const boundary: TriggerBoundary = lineStartOnly ? "line-start" : "word";
236
+
237
+ const queryInfo = useMemo(
238
+ () => findTriggerQuery(value, caret, TRIGGER, { boundary }),
239
+ [value, caret, boundary],
240
+ );
241
+
242
+ // Escape (and an outside dismissal) suppress the popup for the run it was
243
+ // dismissed on, without touching the text — same rationale as MentionInput.
244
+ const [dismissedStart, setDismissedStart] = useState<number | null>(null);
245
+ useEffect(() => {
246
+ if (queryInfo === null && dismissedStart !== null) setDismissedStart(null);
247
+ }, [queryInfo, dismissedStart]);
248
+
249
+ const open = queryInfo !== null && dismissedStart !== queryInfo.start;
250
+ const query = open && queryInfo ? queryInfo.query : null;
251
+
252
+ const setOpen = useCallback(
253
+ (next: boolean) => {
254
+ onOpenChange?.(next);
255
+ if (!next) setDismissedStart(queryInfo?.start ?? null);
256
+ },
257
+ [onOpenChange, queryInfo],
258
+ );
259
+
260
+ // ------------------------------------------------------------------
261
+ // Filtering + highlight
262
+ // ------------------------------------------------------------------
263
+ const filtered = useMemo(
264
+ () => (query !== null ? commands.filter((command) => filter(command, query)) : []),
265
+ [query, commands, filter],
266
+ );
267
+
268
+ const [activeIndexState, setActiveIndexState] = useState(0);
269
+
270
+ // Resolved DURING render, never repaired in an effect, so a stale index
271
+ // from before a filter keystroke can never reach the DOM — see the
272
+ // identical rationale in `MentionInput`'s `activeIndex` docblock.
273
+ const activeIndex = useMemo(() => {
274
+ if (filtered.length === 0) return -1;
275
+ if (activeIndexState < 0 || activeIndexState >= filtered.length) return 0;
276
+ return activeIndexState;
277
+ }, [filtered, activeIndexState]);
278
+
279
+ useEffect(() => {
280
+ setActiveIndexState(0);
281
+ }, [query]);
282
+
283
+ const activeCommand = activeIndex >= 0 ? filtered[activeIndex] : undefined;
284
+ const [activeDomId, setActiveDomId] = useState<string | undefined>(undefined);
285
+
286
+ // The listbox lives inside `PopoverContent`, which UNMOUNTS on close — its
287
+ // `onActiveItemIdChange` never fires again to report "no active item", so
288
+ // without this the textarea would keep pointing `aria-activedescendant`
289
+ // at a node that no longer exists.
290
+ useEffect(() => {
291
+ if (!open) setActiveDomId(undefined);
292
+ }, [open]);
293
+
294
+ // The REAL DOM id `cmdk` assigns to the listbox (see `listDomId`'s
295
+ // docblock on `PromptInputSlashWiring`) — read off the rendered node
296
+ // rather than trusted from a prop we pass in, since `cmdk` overwrites it.
297
+ const [listDomId, setListDomId] = useState<string | undefined>(undefined);
298
+ const handleListRef = useCallback((node: HTMLDivElement | null) => {
299
+ setListDomId(node?.id);
300
+ }, []);
301
+
302
+ // `PromptInputCommand` is controlled (`value=`), so `cmdk`'s own pointer
303
+ // hover / mount-time auto-select report back here instead of self-applying
304
+ // — keep mouse and keyboard highlighting as one source of truth.
305
+ const handleActiveValueChange = useCallback(
306
+ (nextValue: string) => {
307
+ const index = filtered.findIndex((command) => command.name === nextValue);
308
+ if (index >= 0) setActiveIndexState(index);
309
+ },
310
+ [filtered],
311
+ );
312
+
313
+ // ------------------------------------------------------------------
314
+ // Actions
315
+ // ------------------------------------------------------------------
316
+ const select = useCallback(
317
+ (command: SlashCommand) => {
318
+ if (!queryInfo) return;
319
+ const insertText = `${TRIGGER}${command.name} `;
320
+ const result = replaceTriggerRun(value, queryInfo.start, caret, insertText);
321
+ pendingCaretRef.current = result.caret;
322
+ onValueChange(result);
323
+ onSelect?.(command);
324
+ },
325
+ [queryInfo, value, caret, onValueChange, onSelect],
326
+ );
327
+
328
+ const close = useCallback(() => setOpen(false), [setOpen]);
329
+
330
+ const handleKeyDown = useCallback(
331
+ (event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
332
+ if (!open) return;
333
+ switch (event.key) {
334
+ case "ArrowDown":
335
+ event.preventDefault();
336
+ setActiveIndexState(stepIndex(filtered.length, activeIndex, 1));
337
+ return;
338
+ case "ArrowUp":
339
+ event.preventDefault();
340
+ setActiveIndexState(stepIndex(filtered.length, activeIndex < 0 ? 0 : activeIndex, -1));
341
+ return;
342
+ case "Home":
343
+ event.preventDefault();
344
+ setActiveIndexState(0);
345
+ return;
346
+ case "End":
347
+ event.preventDefault();
348
+ setActiveIndexState(filtered.length - 1);
349
+ return;
350
+ case "Enter":
351
+ case "Tab": {
352
+ if (activeCommand) {
353
+ event.preventDefault();
354
+ select(activeCommand);
355
+ }
356
+ return;
357
+ }
358
+ case "Escape":
359
+ event.preventDefault();
360
+ close();
361
+ textareaRef.current?.focus();
362
+ return;
363
+ default:
364
+ return;
365
+ }
366
+ },
367
+ [open, filtered.length, activeIndex, activeCommand, select, close, textareaRef],
368
+ );
369
+
370
+ const wiring = useMemo<PromptInputSlashWiring>(
371
+ () => ({
372
+ open,
373
+ listDomId,
374
+ activeId: activeDomId,
375
+ textareaRef,
376
+ syncCaret,
377
+ onKeyDown: handleKeyDown,
378
+ }),
379
+ [open, listDomId, activeDomId, textareaRef, syncCaret, handleKeyDown],
380
+ );
381
+
382
+ return (
383
+ <PromptInputSlashWiringContext value={wiring}>
384
+ <Popover open={open} onOpenChange={(next) => !next && setOpen(false)}>
385
+ <PopoverAnchor asChild>
386
+ <div
387
+ ref={mergedRootRef}
388
+ data-slot="prompt-input-slash"
389
+ className={cn("relative", className)}
390
+ {...props}
391
+ >
392
+ {children}
393
+ </div>
394
+ </PopoverAnchor>
395
+ <PopoverContent
396
+ data-slot="prompt-input-slash-content"
397
+ // A plain container, not a dialog — the listbox inside carries all
398
+ // the semantics (same rationale as `MentionInputContent`).
399
+ role="presentation"
400
+ align="start"
401
+ side="top"
402
+ sideOffset={8}
403
+ className="w-72 p-0"
404
+ onOpenAutoFocus={(event) => event.preventDefault()}
405
+ onCloseAutoFocus={(event) => event.preventDefault()}
406
+ onPointerDownOutside={(event) => {
407
+ // Clicking the field itself only moves the caret; it must not
408
+ // count as a dismissal, or the popup would fight the caret it
409
+ // depends on (same rationale as `MentionInputContent`).
410
+ const target = event.detail.originalEvent.target as Node | null;
411
+ if (target && textareaRef.current?.contains(target)) event.preventDefault();
412
+ }}
413
+ >
414
+ <PromptInputCommand
415
+ shouldFilter={false}
416
+ value={activeCommand?.name ?? ""}
417
+ onValueChange={handleActiveValueChange}
418
+ onActiveItemIdChange={setActiveDomId}
419
+ data-slot="prompt-input-slash-command"
420
+ >
421
+ <PromptInputCommandList
422
+ ref={handleListRef}
423
+ // `cmdk`'s `CommandList` reads its accessible name off its own
424
+ // `label` prop (defaulting to "Suggestions"), not `aria-label`
425
+ // — an `aria-label` prop would land in the spread `cmdk`
426
+ // overwrites, same as the `id` it also owns.
427
+ label={t("ai.promptInputSlash.listLabel")}
428
+ data-slot="prompt-input-slash-list"
429
+ >
430
+ {filtered.length === 0 ? (
431
+ <PromptInputCommandEmpty data-slot="prompt-input-slash-empty">
432
+ {t("ai.promptInputSlash.empty")}
433
+ </PromptInputCommandEmpty>
434
+ ) : (
435
+ filtered.map((command) => (
436
+ <PromptInputCommandItem
437
+ key={command.name}
438
+ value={command.name}
439
+ data-slot="prompt-input-slash-item"
440
+ onSelect={() => select(command)}
441
+ // The field keeps focus — select on mousedown, before a
442
+ // click on a non-focusable row could ever move it.
443
+ onMouseDown={(event) => event.preventDefault()}
444
+ >
445
+ {command.icon ? (
446
+ <span
447
+ aria-hidden="true"
448
+ className="flex size-5 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4"
449
+ >
450
+ {command.icon}
451
+ </span>
452
+ ) : null}
453
+ {/* Fixed-width name column: the selection moving never
454
+ shifts the description text under the user's eyes. */}
455
+ <span className="w-28 shrink-0 truncate text-body">
456
+ {TRIGGER}
457
+ {command.name}
458
+ </span>
459
+ {command.description ? (
460
+ <span className="truncate text-meta text-muted-foreground">
461
+ {command.description}
462
+ </span>
463
+ ) : null}
464
+ </PromptInputCommandItem>
465
+ ))
466
+ )}
467
+ </PromptInputCommandList>
468
+ </PromptInputCommand>
469
+ </PopoverContent>
470
+ </Popover>
471
+ </PromptInputSlashWiringContext>
472
+ );
473
+ },
474
+ );
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // PromptInputSlashTextarea
478
+ // ---------------------------------------------------------------------------
479
+
480
+ export type PromptInputSlashTextareaProps = PromptInputTextareaProps;
481
+
482
+ /**
483
+ * The composer's `<textarea>`, carrying the combobox semantics the palette
484
+ * needs. A thin wrapper around `PromptInputTextarea` — every prop you'd pass
485
+ * that component still works; this one additionally merges in the palette's
486
+ * `ref`, caret tracking and `aria-activedescendant`.
487
+ *
488
+ * Must be rendered inside a `<PromptInputSlash>`.
489
+ */
490
+ export const PromptInputSlashTextarea = forwardRef<
491
+ HTMLTextAreaElement,
492
+ PromptInputSlashTextareaProps
493
+ >(function PromptInputSlashTextarea(
494
+ { onChange, onKeyDown, onSelect, onKeyUp, onClick, className, ...props },
495
+ ref,
496
+ ) {
497
+ const wiring = use(PromptInputSlashWiringContext);
498
+ if (!wiring) {
499
+ throw new Error("PromptInputSlashTextarea must be used inside a <PromptInputSlash>.");
500
+ }
501
+ const mergedRef = useMemo(
502
+ () => mergeRefs<HTMLTextAreaElement>(ref, wiring.textareaRef),
503
+ [ref, wiring.textareaRef],
504
+ );
505
+
506
+ return (
507
+ <PromptInputTextarea
508
+ ref={mergedRef}
509
+ data-slot="prompt-input-slash-textarea"
510
+ aria-autocomplete="list"
511
+ aria-haspopup="listbox"
512
+ // Only while the popup is mounted: the listbox lives in a Radix portal
513
+ // that unmounts on close, and an `aria-controls` pointing at a removed
514
+ // node is a dangling reference (same rationale as `MentionInputTextarea`).
515
+ aria-controls={wiring.open ? wiring.listDomId : undefined}
516
+ aria-activedescendant={wiring.activeId}
517
+ className={cn("relative z-0", className)}
518
+ {...props}
519
+ onChange={(event) => {
520
+ wiring.syncCaret(event.currentTarget);
521
+ onChange?.(event);
522
+ }}
523
+ onKeyDown={(event) => {
524
+ wiring.onKeyDown(event);
525
+ onKeyDown?.(event);
526
+ }}
527
+ onSelect={(event) => {
528
+ wiring.syncCaret(event.currentTarget);
529
+ onSelect?.(event);
530
+ }}
531
+ onKeyUp={(event) => {
532
+ wiring.syncCaret(event.currentTarget);
533
+ onKeyUp?.(event);
534
+ }}
535
+ onClick={(event) => {
536
+ wiring.syncCaret(event.currentTarget);
537
+ onClick?.(event);
538
+ }}
539
+ />
540
+ );
541
+ });
@@ -28,14 +28,14 @@ import {
28
28
  } from "./prompt-input";
29
29
 
30
30
  const meta = {
31
- title: "AI/PromptInput",
31
+ title: "AI/Composer/PromptInput",
32
32
  component: PromptInput,
33
33
  parameters: {
34
34
  layout: "padded",
35
35
  docs: {
36
36
  description: {
37
37
  component:
38
- "The raw composer FORM primitive Composer is built on. `tone` picks the inner well's fill — `surface` (default, muted) or `card` — so it can nest inside an already-tinted outer frame (the double card, #254). The `card` fill is theme-driven, not universally white: raised on light themes, recessed on dark — still a distinct, legible tone against the outer frame in every theme.",
38
+ "A part of the [Composer](?path=/docs/ai-composer--docs) family: the raw composer FORM primitive Composer is built on — compose it yourself only for a bespoke shell. `tone` picks the inner well's fill — `surface` (default, muted) or `card` — so it can nest inside an already-tinted outer frame (the double card, #254). The `card` fill is theme-driven, not universally white: raised on light themes, recessed on dark — still a distinct, legible tone against the outer frame in every theme.",
39
39
  },
40
40
  },
41
41
  },
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { render, screen } from "@testing-library/react";
2
+ import { act, render, screen, fireEvent, waitFor } from "@testing-library/react";
3
3
  import userEvent from "@testing-library/user-event";
4
4
 
5
5
  import {
@@ -107,6 +107,72 @@ describe("PromptInput — empty submissions are blocked", () => {
107
107
  });
108
108
  });
109
109
 
110
+ describe("PromptInput — failed submit restores the composer (#1.9)", () => {
111
+ it("restores the typed text after a rejected async onSubmit", async () => {
112
+ const onSubmit = vi.fn(() => Promise.reject(new Error("network error")));
113
+ render(<Harness onSubmit={onSubmit} />);
114
+
115
+ const textarea = screen.getByPlaceholderText("Ask…") as HTMLTextAreaElement;
116
+ await userEvent.type(textarea, "hello{Enter}");
117
+
118
+ expect(onSubmit).toHaveBeenCalledTimes(1);
119
+ // form.reset() wipes the uncontrolled textarea before onSubmit even runs —
120
+ // a rejected promise must restore exactly what the user had typed.
121
+ await waitFor(() => expect(textarea.value).toBe("hello"));
122
+ });
123
+
124
+ it("restores the typed text after a synchronous onSubmit throw", async () => {
125
+ const onSubmit = vi.fn(() => {
126
+ throw new Error("boom");
127
+ });
128
+ render(<Harness onSubmit={onSubmit} />);
129
+
130
+ const textarea = screen.getByPlaceholderText("Ask…") as HTMLTextAreaElement;
131
+ await userEvent.type(textarea, "hello{Enter}");
132
+
133
+ expect(onSubmit).toHaveBeenCalledTimes(1);
134
+ await waitFor(() => expect(textarea.value).toBe("hello"));
135
+ });
136
+ });
137
+
138
+ describe("PromptInput — in-flight guard blocks a duplicate submit (#1.9)", () => {
139
+ it("does not call onSubmit twice for a double Enter on an attachment-only message before the first submit settles", async () => {
140
+ // Attachments are only cleared once onSubmit resolves, so the submit
141
+ // control stays enabled (canSubmit = files.length > 0) while the first
142
+ // call is still pending — an in-flight ref, not the affordance, must
143
+ // stop a second Enter from re-sending the same attachment.
144
+ let resolveSubmit: (() => void) | undefined;
145
+ const onSubmit = vi.fn(
146
+ () =>
147
+ new Promise<void>((resolve) => {
148
+ resolveSubmit = resolve;
149
+ }),
150
+ );
151
+ render(<Harness onSubmit={onSubmit} />);
152
+
153
+ const file = new File(["hello"], "notes.txt", { type: "text/plain" });
154
+ fireEvent.change(screen.getByLabelText("Upload files"), { target: { files: [file] } });
155
+
156
+ const textarea = screen.getByPlaceholderText("Ask…");
157
+ // Both Enters fire back-to-back, with NO await between them — the guard
158
+ // that matters here is the synchronous `isSubmittingRef` set before
159
+ // `handleSubmit`'s first `await` (the blob→data-URL conversion), not
160
+ // React re-rendering the (still-canSubmit) affordance in between.
161
+ fireEvent.keyDown(textarea, { key: "Enter" });
162
+ fireEvent.keyDown(textarea, { key: "Enter" });
163
+
164
+ // `handleSubmit` is async — `onSubmit` itself isn't called until after the
165
+ // attachment's blob→data-URL conversion microtask, so wait for it rather
166
+ // than asserting synchronously.
167
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
168
+ // Let the pending `onSubmit` promise's resolution (and the resulting
169
+ // `clear()`/state update) settle inside `act` before the test ends.
170
+ await act(async () => {
171
+ resolveSubmit?.();
172
+ });
173
+ });
174
+ });
175
+
110
176
  describe("PromptInputSubmit — disabled affordance mirrors the guard", () => {
111
177
  it("marks itself aria-disabled at rest and clears it after typing", async () => {
112
178
  render(<Harness />);