@elabs-ai/components-ai 4.0.0 → 4.1.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 (128) hide show
  1. package/README.md +61 -11
  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 +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. 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
  },
package/src/reasoning.tsx CHANGED
@@ -1,9 +1,18 @@
1
1
  "use client";
2
2
 
3
3
  import { useControllableState } from "@radix-ui/react-use-controllable-state";
4
- import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@elabs-ai/components-ui";
4
+ import {
5
+ Collapsible,
6
+ CollapsibleContent,
7
+ CollapsibleTrigger,
8
+ useLocale,
9
+ } from "@elabs-ai/components-ui";
5
10
  import { cn } from "@elabs-ai/components-ui/lib/cn";
6
- import { useStreamdownPlugins, useStreamdownTranslations } from "./_streamdown-i18n";
11
+ import {
12
+ useStreamdownMermaidOptions,
13
+ useStreamdownPlugins,
14
+ useStreamdownTranslations,
15
+ } from "./_streamdown-i18n";
7
16
  import { BrainIcon, ChevronDownIcon } from "lucide-react";
8
17
  import type { ComponentProps, ReactNode } from "react";
9
18
  import {
@@ -140,24 +149,24 @@ export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger> &
140
149
  getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
141
150
  };
142
151
 
143
- const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
152
+ const defaultGetThinkingMessage = (
153
+ isStreaming: boolean,
154
+ duration: number | undefined,
155
+ t: (key: string, vars?: Record<string, string | number>) => string,
156
+ ) => {
144
157
  if (isStreaming || duration === 0) {
145
- return <Shimmer duration={1}>Thinking...</Shimmer>;
158
+ return <Shimmer duration={1}>Thinking…</Shimmer>;
146
159
  }
147
160
  if (duration === undefined) {
148
- return <p>Thought for a few seconds</p>;
161
+ return <p>{t("ai.reasoning.thoughtDefault")}</p>;
149
162
  }
150
163
  return <p>Thought for {duration} seconds</p>;
151
164
  };
152
165
 
153
166
  export const ReasoningTrigger = memo(
154
- ({
155
- className,
156
- children,
157
- getThinkingMessage = defaultGetThinkingMessage,
158
- ...props
159
- }: ReasoningTriggerProps) => {
167
+ ({ className, children, getThinkingMessage, ...props }: ReasoningTriggerProps) => {
160
168
  const { isStreaming, isOpen, duration } = useReasoning();
169
+ const { t } = useLocale();
161
170
 
162
171
  return (
163
172
  <CollapsibleTrigger
@@ -170,7 +179,9 @@ export const ReasoningTrigger = memo(
170
179
  {children ?? (
171
180
  <>
172
181
  <BrainIcon className="size-4" />
173
- {getThinkingMessage(isStreaming, duration)}
182
+ {getThinkingMessage
183
+ ? getThinkingMessage(isStreaming, duration)
184
+ : defaultGetThinkingMessage(isStreaming, duration, t)}
174
185
  <ChevronDownIcon
175
186
  className={cn("size-4 transition-transform", isOpen ? "rotate-180" : "rotate-0")}
176
187
  />
@@ -203,6 +214,9 @@ export const ReasoningContent = memo(({ className, children, ...props }: Reasoni
203
214
  // Brand-token-derived `code` plugin, not the package's static github-*
204
215
  // default (#315 follow-up) — re-derives when the active theme changes.
205
216
  const plugins = useStreamdownPlugins();
217
+ // The TOP-LEVEL Streamdown `mermaid` prop — catches a failed diagram
218
+ // render, including a missing `mermaid` optional peer (issue #33).
219
+ const mermaidOptions = useStreamdownMermaidOptions();
206
220
 
207
221
  return (
208
222
  <CollapsibleContent
@@ -214,7 +228,7 @@ export const ReasoningContent = memo(({ className, children, ...props }: Reasoni
214
228
  {...props}
215
229
  >
216
230
  {typeof children === "string" ? (
217
- <Streamdown plugins={plugins} translations={translations}>
231
+ <Streamdown plugins={plugins} translations={translations} mermaid={mermaidOptions}>
218
232
  {children}
219
233
  </Streamdown>
220
234
  ) : (
@@ -13,7 +13,15 @@ import {
13
13
  const meta = {
14
14
  title: "AI/Sandbox",
15
15
  component: Sandbox,
16
- parameters: { layout: "padded" },
16
+ parameters: {
17
+ layout: "padded",
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "A collapsible, tabbed view of a code-execution tool part — code in, output out, read-only. The real Monaco editing surface is `Editor/CodeEditor`; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). `Sandbox` is the only place that knows whether the run body has arrived, so `SandboxContent` renders a layout-shaped skeleton while `loading`.",
22
+ },
23
+ },
24
+ },
17
25
  } satisfies Meta<typeof Sandbox>;
18
26
  export default meta;
19
27
  type Story = StoryObj<typeof meta>;