@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.
- package/README.md +62 -12
- 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 +1152 -218
- package/dist/index.js +5601 -3241
- package/dist/index.js.map +1 -1
- package/package.json +30 -15
- package/src/__contract__/audio-visualizer.contract.test.tsx +49 -0
- package/src/__contract__/chat-shell.contract.test.tsx +49 -0
- package/src/__contract__/grouped-parts.contract.test.tsx +49 -0
- package/src/__contract__/image.contract.test.tsx +49 -0
- package/src/__contract__/markdown-view.contract.test.tsx +49 -0
- package/src/__contract__/message-feedback.contract.test.tsx +49 -0
- package/src/__contract__/message-form.contract.test.tsx +49 -0
- package/src/__contract__/message-table.contract.test.tsx +49 -0
- package/src/__contract__/model-provider-logo.contract.test.tsx +49 -0
- package/src/__contract__/persona.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-effort.contract.test.tsx +49 -0
- package/src/__contract__/prompt-input-mode.contract.test.tsx +49 -0
- package/src/_audio-player-media-chrome.tsx +102 -15
- package/src/_chat-shell-rail.tsx +2 -2
- package/src/_flow-boundary.tsx +68 -49
- package/src/_lazy-boundary-conformance.ts +38 -0
- package/src/_lazy-cjk.test.ts +43 -0
- package/src/_lazy-cjk.ts +73 -0
- package/src/_lazy-engine-boundary.tsx +61 -0
- package/src/_lazy-math.test.ts +63 -0
- package/src/_lazy-math.ts +90 -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 +94 -22
- package/src/_streamdown-safety.ts +170 -0
- package/src/_theme-scope-store.test.ts +83 -0
- package/src/_theme-scope-store.ts +103 -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 +43 -24
- package/src/agentic-workspace.stories.tsx +27 -20
- package/src/artifact.tsx +17 -14
- package/src/asset-preview.test.tsx +40 -0
- package/src/asset-preview.tsx +83 -12
- package/src/attachments.tsx +7 -4
- 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/code-block.test.tsx +100 -1
- package/src/code-block.tsx +166 -103
- package/src/commit.tsx +30 -41
- 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 +264 -4
- package/src/context-panel.stories.tsx +9 -1
- package/src/context-panel.tsx +3 -2
- package/src/conversation.stories.tsx +86 -2
- package/src/conversation.test.tsx +66 -0
- package/src/conversation.tsx +52 -9
- package/src/diff-view.stories.tsx +196 -0
- package/src/diff-view.test.tsx +238 -0
- package/src/diff-view.tsx +697 -0
- package/src/environment-variables.tsx +20 -37
- package/src/file-tree.test.tsx +21 -0
- package/src/file-tree.tsx +12 -2
- package/src/gallery.tsx +4 -2
- package/src/index.ts +14 -4
- package/src/inline-citation.tsx +5 -5
- package/src/jsx-preview.stories.tsx +2 -2
- package/src/jsx-preview.tsx +151 -43
- package/src/markdown-view.stories.tsx +92 -1
- package/src/markdown-view.test.tsx +237 -2
- package/src/markdown-view.tsx +157 -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 +65 -4
- package/src/message-form.test.tsx +8 -2
- package/src/message-form.tsx +15 -7
- package/src/message-table.stories.tsx +2 -2
- package/src/message-table.test.tsx +7 -0
- package/src/message-table.tsx +8 -4
- package/src/message.stories.tsx +9 -1
- package/src/message.test.tsx +247 -1
- package/src/message.tsx +125 -15
- package/src/microcopy.test.tsx +40 -0
- package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
- package/src/model-provider-logo.test.tsx +109 -0
- package/src/model-provider-logo.tsx +183 -0
- package/src/open-in-chat.tsx +50 -29
- package/src/package-info.tsx +12 -12
- 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/prompt-input.test.tsx +67 -1
- package/src/prompt-input.tsx +42 -4
- package/src/queue.tsx +4 -4
- package/src/reasoning.tsx +42 -17
- package/src/sandbox.stories.tsx +9 -1
- package/src/sandbox.tsx +3 -3
- package/src/schema-display.test.tsx +56 -0
- package/src/schema-display.tsx +71 -37
- 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/snippet.test.tsx +6 -2
- package/src/snippet.tsx +14 -34
- package/src/speech-input.test.tsx +109 -0
- package/src/speech-input.tsx +31 -3
- package/src/stack-trace.tsx +24 -36
- package/src/streamdown-i18n.test.tsx +24 -1
- package/src/task.stories.tsx +13 -3
- package/src/templates-ai-assistant.stories.tsx +21 -1
- package/src/test-results.tsx +47 -27
- package/src/token-usage.stories.tsx +24 -0
- package/src/token-usage.test.tsx +92 -0
- package/src/{context.tsx → token-usage.tsx} +73 -59
- package/src/tool-result-card.stories.tsx +9 -1
- package/src/tool.stories.tsx +13 -3
- package/src/tool.test.tsx +65 -0
- package/src/tool.tsx +73 -30
- package/src/transcription.tsx +1 -1
- 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/voice-selector.tsx +5 -5
- package/src/web-preview.test.tsx +51 -1
- package/src/web-preview.tsx +46 -10
- 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.test.tsx +0 -55
- 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
|
},
|
|
@@ -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 />);
|