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