@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.
- package/README.md +61 -11
- package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
- package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
- package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
- package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
- package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
- package/dist/_persona-rive-JEG44YHX.js.map +1 -0
- package/dist/index.d.ts +1109 -202
- package/dist/index.js +4867 -2878
- package/dist/index.js.map +1 -1
- package/package.json +31 -16
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-mermaid-absent.test.ts +53 -0
- package/src/_lazy-mermaid.test.ts +15 -0
- package/src/_lazy-mermaid.ts +24 -1
- package/src/_mermaid-error-panel.test.tsx +50 -0
- package/src/_mermaid-error-panel.tsx +66 -0
- package/src/_persona-rive.tsx +62 -10
- package/src/_streamdown-i18n.ts +21 -1
- package/src/_streamdown-safety.ts +170 -0
- package/src/agent-event.stories.tsx +97 -0
- package/src/agent-event.test.tsx +145 -0
- package/src/agent-event.tsx +187 -0
- package/src/agent-timeline.stories.tsx +9 -1
- package/src/agent.test.tsx +47 -0
- package/src/agent.tsx +28 -14
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +15 -12
- package/src/audio-player.test.tsx +78 -0
- package/src/audio-player.tsx +243 -56
- package/src/audio-visualizer.stories.tsx +126 -0
- package/src/audio-visualizer.test.tsx +438 -0
- package/src/audio-visualizer.tsx +367 -0
- package/src/canvas.stories.tsx +150 -1
- package/src/chat-shell.stories.tsx +18 -3
- package/src/chat.stories.tsx +16 -2
- package/src/code-block.stories.tsx +9 -1
- package/src/composer.stories.tsx +372 -9
- package/src/composer.test.tsx +357 -6
- package/src/composer.tsx +217 -35
- package/src/confirmation.stories.tsx +72 -1
- package/src/confirmation.test.tsx +216 -2
- package/src/confirmation.tsx +263 -3
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +2 -1
- package/src/conversation.stories.tsx +63 -2
- package/src/conversation.test.tsx +13 -0
- package/src/conversation.tsx +20 -2
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +188 -0
- package/src/diff-view.tsx +642 -0
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +232 -1
- package/src/markdown-view.tsx +150 -6
- package/src/message-compare.stories.tsx +175 -0
- package/src/message-compare.test.tsx +207 -0
- package/src/message-compare.tsx +453 -0
- package/src/message-form.stories.tsx +29 -1
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +176 -0
- package/src/message.tsx +90 -4
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
- package/src/model-provider-logo.tsx +149 -0
- package/src/permission-mode-select.stories.tsx +82 -0
- package/src/permission-mode-select.test.tsx +100 -0
- package/src/permission-mode-select.tsx +137 -0
- package/src/persona-missing-peer.test.tsx +54 -0
- package/src/persona.tsx +68 -22
- package/src/plan.stories.tsx +166 -0
- package/src/plan.test.tsx +267 -0
- package/src/plan.tsx +182 -20
- package/src/prompt-input-effort.stories.tsx +123 -0
- package/src/prompt-input-effort.test.tsx +83 -0
- package/src/prompt-input-effort.tsx +136 -0
- package/src/prompt-input-mode.stories.tsx +108 -0
- package/src/prompt-input-mode.test.tsx +99 -0
- package/src/prompt-input-mode.tsx +169 -0
- package/src/prompt-input-slash.stories.tsx +211 -0
- package/src/prompt-input-slash.test.tsx +262 -0
- package/src/prompt-input-slash.tsx +541 -0
- package/src/prompt-input.stories.tsx +2 -2
- package/src/reasoning.tsx +27 -13
- package/src/sandbox.stories.tsx +9 -1
- package/src/schema-display.tsx +5 -2
- package/src/selection-toolbar.stories.tsx +9 -1
- package/src/session-header.stories.tsx +128 -0
- package/src/session-header.test.tsx +138 -0
- package/src/session-header.tsx +243 -0
- package/src/session-status-bar.stories.tsx +73 -0
- package/src/session-status-bar.test.tsx +94 -0
- package/src/session-status-bar.tsx +165 -0
- package/src/snippet.stories.tsx +9 -1
- package/src/stack-trace.tsx +6 -2
- package/src/streamdown-i18n.test.tsx +1 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +67 -53
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.tsx +15 -8
- package/src/turn-status.stories.tsx +124 -0
- package/src/turn-status.test.tsx +74 -0
- package/src/turn-status.tsx +174 -0
- package/src/use-audio-level.ts +104 -0
- package/src/web-preview.tsx +8 -3
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
- package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
- package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
- package/src/_interactive-terminal-xterm.ts +0 -32
- package/src/blocks-ai-composer.stories.tsx +0 -83
- package/src/context.stories.tsx +0 -16
- package/src/interactive-terminal.stories.tsx +0 -165
- package/src/interactive-terminal.test.tsx +0 -448
- package/src/interactive-terminal.tsx +0 -444
- package/src/model-selector.tsx +0 -225
- 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
|
-
"
|
|
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 {
|
|
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 {
|
|
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 = (
|
|
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
|
|
158
|
+
return <Shimmer duration={1}>Thinking…</Shimmer>;
|
|
146
159
|
}
|
|
147
160
|
if (duration === undefined) {
|
|
148
|
-
return <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
|
|
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
|
) : (
|
package/src/sandbox.stories.tsx
CHANGED
|
@@ -13,7 +13,15 @@ import {
|
|
|
13
13
|
const meta = {
|
|
14
14
|
title: "AI/Sandbox",
|
|
15
15
|
component: Sandbox,
|
|
16
|
-
parameters: {
|
|
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>;
|