@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.stories.tsx
CHANGED
|
@@ -3,19 +3,134 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The canonical two-tone "double card": an outer `bg-card` frame with a muted
|
|
5
5
|
* status strip wrapping a recessed `PromptInput` well (sharp top, theme-rounded
|
|
6
|
-
* bottom), a
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* bottom), a tools cluster, voice, and a circular send.
|
|
7
|
+
*
|
|
8
|
+
* **Composer is the chat input. Every control the `PromptInput` family ships is
|
|
9
|
+
* reachable from a `Composer` prop; drop to `PromptInput` only for a bespoke
|
|
10
|
+
* shell.** The four slots that make that true — `modelPicker`, `mode`, `effort`
|
|
11
|
+
* and `slashCommands` — are the stories below; the footer order is
|
|
12
|
+
* `attach · modelPicker · mode · effort │ voice · send`, the same arrangement
|
|
13
|
+
* `TerminalComposer` uses, so the chat and console skins agree.
|
|
14
|
+
*
|
|
15
|
+
* ### Anatomy — and what the pages nested under this one are
|
|
16
|
+
*
|
|
17
|
+
* A `Composer` **is** a `PromptInput` form: a muted status strip above the
|
|
18
|
+
* well, the well itself, and a tools cluster in the footer. The sub-pages under
|
|
19
|
+
* `AI/Composer` are those parts, not siblings of it:
|
|
20
|
+
*
|
|
21
|
+
* - **`PromptInput`** — the raw composer FORM primitive everything here is
|
|
22
|
+
* built on. Compose it yourself only for a bespoke shell.
|
|
23
|
+
* - **`PromptInputMode`**, **`PromptInputEffort`**, **`PromptInputSlash`** —
|
|
24
|
+
* the three controls the `mode`, `effort` and `slashCommands` slots render.
|
|
25
|
+
* The fourth slot, `modelPicker`, takes a `ModelPicker` from
|
|
26
|
+
* `@elabs-ai/components-ui`, which is why it has no page of its own here.
|
|
27
|
+
* - **`WithMentionInput`** — the one documented bespoke shell: a mention
|
|
28
|
+
* roster has to WRAP the textarea, which is the seam `Composer` owns, so
|
|
29
|
+
* that page drops to `PromptInput` on purpose.
|
|
30
|
+
*
|
|
31
|
+
* `Terminal/TerminalComposer` is the console skin of this same family — same
|
|
32
|
+
* footer order, different surface.
|
|
33
|
+
*
|
|
34
|
+
* Semantic tokens only; theme-aware radii; reads in every theme.
|
|
10
35
|
*/
|
|
36
|
+
import { useState } from "react";
|
|
11
37
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
12
|
-
import { expect, fn, waitFor } from "storybook/test";
|
|
38
|
+
import { expect, fn, waitFor, within } from "storybook/test";
|
|
39
|
+
import { Bot, PencilLine, ShieldCheck } from "lucide-react";
|
|
40
|
+
import {
|
|
41
|
+
ModelPicker,
|
|
42
|
+
type EffortLevel,
|
|
43
|
+
type ModelPickerGroup,
|
|
44
|
+
type ModelPickerItem,
|
|
45
|
+
type OperatingMode,
|
|
46
|
+
type SlashCommand,
|
|
47
|
+
} from "@elabs-ai/components-ui";
|
|
13
48
|
|
|
14
49
|
import { ChatGreeting } from "./chat-greeting";
|
|
15
50
|
import { Composer } from "./composer";
|
|
16
51
|
|
|
52
|
+
/** The model picker is a ReactNode slot, so its spy lives here rather than in `args`. */
|
|
53
|
+
const onModelSelect = fn();
|
|
54
|
+
|
|
17
55
|
const SUGGESTIONS = ["Summary", "Code", "Design", "Research"];
|
|
18
56
|
|
|
57
|
+
/** Demo vocabulary. brand-ui ships none of this — every list below is app-supplied. */
|
|
58
|
+
const MODES: OperatingMode[] = [
|
|
59
|
+
{
|
|
60
|
+
id: "auto",
|
|
61
|
+
label: "Auto",
|
|
62
|
+
description: "Acts without asking, within its usual limits.",
|
|
63
|
+
icon: <Bot className="size-4" aria-hidden="true" />,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "plan",
|
|
67
|
+
label: "Plan first",
|
|
68
|
+
description: "Proposes a plan and waits for approval before acting.",
|
|
69
|
+
keyHint: "⇧ Tab",
|
|
70
|
+
icon: <PencilLine className="size-4" aria-hidden="true" />,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: "review",
|
|
74
|
+
label: "Review edits",
|
|
75
|
+
description: "Acts, but pauses on every file edit for a quick review.",
|
|
76
|
+
icon: <ShieldCheck className="size-4" aria-hidden="true" />,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const LEVELS: EffortLevel[] = [
|
|
81
|
+
{ id: "low", label: "Low" },
|
|
82
|
+
{ id: "medium", label: "Medium" },
|
|
83
|
+
{ id: "high", label: "High" },
|
|
84
|
+
{ id: "max", label: "Max" },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const COMMANDS: SlashCommand[] = [
|
|
88
|
+
{ name: "help", description: "Show available commands" },
|
|
89
|
+
{ name: "history", description: "Show recent conversation history" },
|
|
90
|
+
{ name: "clear", description: "Clear the conversation" },
|
|
91
|
+
{ name: "settings", description: "Open workspace settings" },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
const MODEL_GROUPS: ModelPickerGroup[] = [
|
|
95
|
+
{
|
|
96
|
+
key: "frontier",
|
|
97
|
+
label: "Frontier",
|
|
98
|
+
items: [
|
|
99
|
+
{ id: "opus", label: "Opus", description: "Deepest reasoning, slowest." },
|
|
100
|
+
{ id: "sonnet", label: "Sonnet", description: "The everyday balance." },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: "fast",
|
|
105
|
+
label: "Fast",
|
|
106
|
+
items: [{ id: "haiku", label: "Haiku", description: "Cheapest and quickest." }],
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A `ModelPicker` wired to local state — what a real app passes to
|
|
112
|
+
* `modelPicker`. The picker is the consumer's, not Composer's: Composer only
|
|
113
|
+
* owns the slot and its position in the footer.
|
|
114
|
+
*/
|
|
115
|
+
function DemoModelPicker({ onSelect }: { onSelect?: (item: ModelPickerItem) => void }) {
|
|
116
|
+
const [picked, setPicked] = useState("sonnet");
|
|
117
|
+
const label =
|
|
118
|
+
MODEL_GROUPS.flatMap((group) => group.items).find((item) => item.id === picked)?.label ??
|
|
119
|
+
"Model";
|
|
120
|
+
return (
|
|
121
|
+
<ModelPicker
|
|
122
|
+
groups={MODEL_GROUPS}
|
|
123
|
+
value={picked}
|
|
124
|
+
triggerLabel={label}
|
|
125
|
+
aria-label="Model"
|
|
126
|
+
onSelect={(item) => {
|
|
127
|
+
setPicked(item.id);
|
|
128
|
+
onSelect?.(item);
|
|
129
|
+
}}
|
|
130
|
+
/>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
19
134
|
const meta = {
|
|
20
135
|
title: "AI/Composer",
|
|
21
136
|
component: Composer,
|
|
@@ -24,7 +139,16 @@ const meta = {
|
|
|
24
139
|
docs: {
|
|
25
140
|
description: {
|
|
26
141
|
component:
|
|
27
|
-
"The
|
|
142
|
+
"The CHAT composer; the console skin is `Terminal/TerminalComposer` — see " +
|
|
143
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
144
|
+
"Composer is the chat input. Every control the PromptInput family ships is reachable from a Composer prop; drop to PromptInput only for a bespoke shell. A rounded two-tone double card (outer bg-card frame + muted status strip around a recessed PromptInput well with a sharp top and theme-rounded bottom), a tools cluster, voice, and a circular send — plus four optional slots: modelPicker (pass a ModelPicker), mode (PromptInputMode), effort (PromptInputEffort) and slashCommands (PromptInputSlash). Footer order: attach · modelPicker · mode · effort │ voice · send, mirroring TerminalComposer. Built on the real @elabs-ai/components-ai PromptInput; semantic tokens only; reads in all themes.\n\n" +
|
|
145
|
+
"**Anatomy — and what the pages nested under this one are.** A Composer IS a PromptInput form: a muted status strip above the well, the well itself, and a tools cluster in the footer. The sub-pages under `AI/Composer` are those parts, not siblings of it — " +
|
|
146
|
+
"[PromptInput](?path=/docs/ai-composer-promptinput--docs) is the raw composer FORM primitive everything here is built on; " +
|
|
147
|
+
"[PromptInputMode](?path=/docs/ai-composer-promptinputmode--docs), " +
|
|
148
|
+
"[PromptInputEffort](?path=/docs/ai-composer-promptinputeffort--docs) and " +
|
|
149
|
+
"[PromptInputSlash](?path=/docs/ai-composer-promptinputslash--docs) are the three controls the `mode`, `effort` and `slashCommands` slots render (the fourth slot, `modelPicker`, takes a `ModelPicker` from @elabs-ai/components-ui, which is why it has no page of its own); and " +
|
|
150
|
+
"[WithMentionInput](?path=/docs/ai-composer-withmentioninput--docs) is the one documented bespoke shell — a mention roster has to WRAP the textarea, the seam Composer owns, so that page drops to PromptInput on purpose. " +
|
|
151
|
+
"The scaffold that puts this composer under a transcript is [Patterns/Blocks/AI Chat Shell](?path=/docs/patterns-blocks-ai-chat-shell--docs); the console skin of the same family is [Terminal/TerminalComposer](?path=/docs/terminal-terminalcomposer--docs).",
|
|
28
152
|
},
|
|
29
153
|
},
|
|
30
154
|
},
|
|
@@ -85,6 +209,60 @@ export const Streaming: Story = {
|
|
|
85
209
|
),
|
|
86
210
|
};
|
|
87
211
|
|
|
212
|
+
/**
|
|
213
|
+
* `shortcuts` — a row of keyboard-shortcut hints beneath the well (#107).
|
|
214
|
+
* Opt-in: nothing renders unless you pass at least one. At rest only the
|
|
215
|
+
* always-shown hints appear.
|
|
216
|
+
*/
|
|
217
|
+
export const WithShortcuts: Story = {
|
|
218
|
+
args: {
|
|
219
|
+
shortcuts: [
|
|
220
|
+
{ keys: "Enter", label: "send" },
|
|
221
|
+
{ keys: "Shift+Enter", label: "newline" },
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
render: (args) => (
|
|
225
|
+
<div className="mx-auto max-w-2xl">
|
|
226
|
+
<Composer {...args} />
|
|
227
|
+
</div>
|
|
228
|
+
),
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The hints CHANGE with a busy state (#107's acceptance criterion, verbatim):
|
|
233
|
+
* `cancelShortcut` joins the row only once the composer is actually
|
|
234
|
+
* generating (`sendStatus="streaming"`) AND a real `onStop` is set — a hint
|
|
235
|
+
* for an affordance that isn't there is never shown.
|
|
236
|
+
*/
|
|
237
|
+
export const WithShortcutsBusy: Story = {
|
|
238
|
+
args: {
|
|
239
|
+
status: "Generating…",
|
|
240
|
+
sendStatus: "streaming",
|
|
241
|
+
onStop: fn(),
|
|
242
|
+
shortcuts: [{ keys: "Enter", label: "send" }],
|
|
243
|
+
cancelShortcut: { keys: "Esc", label: "cancel" },
|
|
244
|
+
},
|
|
245
|
+
render: (args) => (
|
|
246
|
+
<div className="mx-auto max-w-2xl">
|
|
247
|
+
<Composer {...args} />
|
|
248
|
+
</div>
|
|
249
|
+
),
|
|
250
|
+
play: async ({ canvas, canvasElement }) => {
|
|
251
|
+
await expect(canvas.getByText("Esc")).toBeInTheDocument();
|
|
252
|
+
await expect(canvas.getByText("cancel")).toBeInTheDocument();
|
|
253
|
+
// The row is a plain sibling, never nested in the Stop button. Note this
|
|
254
|
+
// is NOT provable via accessible name: `PromptInputSubmit` sets
|
|
255
|
+
// `aria-label` unconditionally, so `toHaveAccessibleName("Stop")` would
|
|
256
|
+
// pass even if the row were wrongly nested inside the button (an
|
|
257
|
+
// explicit `aria-label` wins over descendant content in the accname
|
|
258
|
+
// computation). The real #153-style trap check is DOM containment.
|
|
259
|
+
const stopButton = canvas.getByRole("button", { name: "Stop" });
|
|
260
|
+
const shortcutRow = canvasElement.querySelector('[data-slot="composer-shortcuts"]');
|
|
261
|
+
await expect(shortcutRow).not.toBeNull();
|
|
262
|
+
await expect(stopButton).not.toContainElement(shortcutRow);
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
|
|
88
266
|
/**
|
|
89
267
|
* Running, but the user has already typed a follow-up (#351's P0 fix): the
|
|
90
268
|
* control flips back to the circular ArrowUp `sendIcon` — never the Stop
|
|
@@ -147,9 +325,9 @@ export const SendStates: Story = {
|
|
|
147
325
|
),
|
|
148
326
|
};
|
|
149
327
|
|
|
150
|
-
/** Trimmed: no
|
|
328
|
+
/** Trimmed to the bone: no status strip, no voice — just attach + send. */
|
|
151
329
|
export const Minimal: Story = {
|
|
152
|
-
args: {
|
|
330
|
+
args: { showVoice: false, status: null },
|
|
153
331
|
render: (args) => (
|
|
154
332
|
<div className="mx-auto max-w-2xl">
|
|
155
333
|
<Composer {...args} />
|
|
@@ -175,7 +353,6 @@ export const Minimal: Story = {
|
|
|
175
353
|
*/
|
|
176
354
|
export const RefusedSubmit: Story = {
|
|
177
355
|
args: {
|
|
178
|
-
model: null,
|
|
179
356
|
status: "Setting up the conversation…",
|
|
180
357
|
suggestions: undefined,
|
|
181
358
|
submitProps: { disabled: true, "aria-label": "Send (unavailable until setup completes)" },
|
|
@@ -186,3 +363,189 @@ export const RefusedSubmit: Story = {
|
|
|
186
363
|
</div>
|
|
187
364
|
),
|
|
188
365
|
};
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* `mode` — an app-defined operating mode (how autonomously the agent may act
|
|
369
|
+
* on the next turn), rendered as a `PromptInputMode` in the tools cluster.
|
|
370
|
+
* brand-ui ships no mode vocabulary; `modes` is entirely yours.
|
|
371
|
+
*/
|
|
372
|
+
export const WithMode: Story = {
|
|
373
|
+
args: { mode: { modes: MODES, onValueChange: fn() } },
|
|
374
|
+
render: (args) => (
|
|
375
|
+
<div className="mx-auto max-w-2xl">
|
|
376
|
+
<Composer {...args} />
|
|
377
|
+
</div>
|
|
378
|
+
),
|
|
379
|
+
play: async ({ canvas, args, userEvent, step }) => {
|
|
380
|
+
await step("the trigger shows the current mode", async () => {
|
|
381
|
+
await expect(canvas.getByRole("button", { name: /Auto/ })).toBeInTheDocument();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await step("opening it and picking a mode fires onValueChange", async () => {
|
|
385
|
+
await userEvent.click(canvas.getByRole("button", { name: /Auto/ }));
|
|
386
|
+
// Radix portals the menu to document.body, outside the story canvas.
|
|
387
|
+
const menu = within(document.body);
|
|
388
|
+
await userEvent.click(await menu.findByRole("menuitemradio", { name: /Plan first/ }));
|
|
389
|
+
await waitFor(() => expect(args.mode?.onValueChange).toHaveBeenCalledWith("plan"));
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* `effort` — an ORDERED reasoning-effort scale, rendered as a
|
|
396
|
+
* `PromptInputEffort`. The squares grow low → high and fill up to the current
|
|
397
|
+
* level, so the level survives a greyscale render; the level's name is also
|
|
398
|
+
* rendered as text. `aria-label` names the scale and is required — the name of
|
|
399
|
+
* the scale is as much your vocabulary as its rungs are.
|
|
400
|
+
*/
|
|
401
|
+
export const WithEffort: Story = {
|
|
402
|
+
args: {
|
|
403
|
+
effort: { levels: LEVELS, "aria-label": "Reasoning effort", onValueChange: fn() },
|
|
404
|
+
},
|
|
405
|
+
render: (args) => (
|
|
406
|
+
<div className="mx-auto max-w-2xl">
|
|
407
|
+
<Composer {...args} />
|
|
408
|
+
</div>
|
|
409
|
+
),
|
|
410
|
+
play: async ({ canvas, args, userEvent, step }) => {
|
|
411
|
+
await step("the scale is a named radiogroup starting at the lowest rung", async () => {
|
|
412
|
+
await expect(
|
|
413
|
+
canvas.getByRole("radiogroup", { name: "Reasoning effort" }),
|
|
414
|
+
).toBeInTheDocument();
|
|
415
|
+
await expect(canvas.getByRole("radio", { name: "Low", checked: true })).toBeInTheDocument();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await step("picking a rung fires onValueChange and moves the fill", async () => {
|
|
419
|
+
await userEvent.click(canvas.getByRole("radio", { name: "High" }));
|
|
420
|
+
await waitFor(() => expect(args.effort?.onValueChange).toHaveBeenCalledWith("high"));
|
|
421
|
+
await expect(canvas.getByRole("radio", { name: "High", checked: true })).toBeInTheDocument();
|
|
422
|
+
});
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* `slashCommands` — typing `/` at the start of a line opens a filtered command
|
|
428
|
+
* palette without focus ever leaving the field. Setting the prop swaps the
|
|
429
|
+
* textarea for a `PromptInputSlashTextarea` inside the same body; nothing else
|
|
430
|
+
* about the composer changes.
|
|
431
|
+
*/
|
|
432
|
+
export const WithSlashCommands: Story = {
|
|
433
|
+
args: { slashCommands: COMMANDS, onSlashCommand: fn() },
|
|
434
|
+
render: (args) => (
|
|
435
|
+
<div className="mx-auto max-w-2xl">
|
|
436
|
+
<Composer {...args} />
|
|
437
|
+
</div>
|
|
438
|
+
),
|
|
439
|
+
play: async ({ canvas, args, userEvent, step }) => {
|
|
440
|
+
const field = canvas.getByPlaceholderText("Ask me anything…");
|
|
441
|
+
|
|
442
|
+
await step("typing '/' at the start of the line opens the palette", async () => {
|
|
443
|
+
await userEvent.click(field);
|
|
444
|
+
await userEvent.keyboard("/h");
|
|
445
|
+
await expect(await within(document.body).findByRole("listbox")).toBeInTheDocument();
|
|
446
|
+
await expect(field).toHaveFocus();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
await step("Enter inserts the highlighted command and fires onSlashCommand", async () => {
|
|
450
|
+
await userEvent.keyboard("{Enter}");
|
|
451
|
+
await waitFor(() =>
|
|
452
|
+
expect(args.onSlashCommand).toHaveBeenCalledWith(expect.objectContaining({ name: "help" })),
|
|
453
|
+
);
|
|
454
|
+
await expect(field).toHaveValue("/help ");
|
|
455
|
+
// Radix keeps the popover mounted until its exit transition finishes, so
|
|
456
|
+
// the unmount is asynchronous — wait for it before the story's axe pass
|
|
457
|
+
// (a closing, empty cmdk listbox is the pre-existing
|
|
458
|
+
// `aria-required-children` shape every cmdk combobox in this repo shares).
|
|
459
|
+
await waitFor(() =>
|
|
460
|
+
expect(within(document.body).queryByRole("listbox")).not.toBeInTheDocument(),
|
|
461
|
+
);
|
|
462
|
+
});
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* `modelPicker` — the slot that replaced the old static `model` pill. Pass a
|
|
468
|
+
* `ModelPicker` from `@elabs-ai/components-ui` (it is sized to sit in a
|
|
469
|
+
* composer footer) wired to your own state; Composer owns only the slot and its
|
|
470
|
+
* position. A composer that shows a model name it cannot change is a lie about
|
|
471
|
+
* what the control does, which is why nothing renders here by default.
|
|
472
|
+
*/
|
|
473
|
+
export const WithModelPicker: Story = {
|
|
474
|
+
render: (args) => (
|
|
475
|
+
<div className="mx-auto max-w-2xl">
|
|
476
|
+
<Composer {...args} modelPicker={<DemoModelPicker onSelect={onModelSelect} />} />
|
|
477
|
+
</div>
|
|
478
|
+
),
|
|
479
|
+
play: async ({ canvas, userEvent, step }) => {
|
|
480
|
+
onModelSelect.mockClear();
|
|
481
|
+
|
|
482
|
+
await step("the trigger shows the currently pinned model", async () => {
|
|
483
|
+
await expect(canvas.getByRole("combobox", { name: "Model" })).toHaveTextContent("Sonnet");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
await step("opening it and picking a model fires onSelect", async () => {
|
|
487
|
+
await userEvent.click(canvas.getByRole("combobox", { name: "Model" }));
|
|
488
|
+
const panel = within(document.body);
|
|
489
|
+
await userEvent.click(await panel.findByRole("option", { name: /Haiku/ }));
|
|
490
|
+
await waitFor(() =>
|
|
491
|
+
expect(onModelSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "haiku" })),
|
|
492
|
+
);
|
|
493
|
+
await waitFor(() =>
|
|
494
|
+
expect(canvas.getByRole("combobox", { name: "Model" })).toHaveTextContent("Haiku"),
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Everything at once — the full footer in its canonical order:
|
|
502
|
+
* `attach · modelPicker · mode · effort │ voice · send`, the same left-to-right
|
|
503
|
+
* arrangement `TerminalComposer` uses. The cluster wraps rather than overflowing,
|
|
504
|
+
* so check this one at a narrow width and in both themes.
|
|
505
|
+
*/
|
|
506
|
+
export const Everything: Story = {
|
|
507
|
+
args: {
|
|
508
|
+
status: "Ready when you are",
|
|
509
|
+
mode: { modes: MODES, onValueChange: fn() },
|
|
510
|
+
effort: { levels: LEVELS, "aria-label": "Reasoning effort", onValueChange: fn() },
|
|
511
|
+
slashCommands: COMMANDS,
|
|
512
|
+
onSlashCommand: fn(),
|
|
513
|
+
suggestions: SUGGESTIONS,
|
|
514
|
+
},
|
|
515
|
+
render: (args) => (
|
|
516
|
+
<div className="mx-auto max-w-2xl">
|
|
517
|
+
<Composer {...args} modelPicker={<DemoModelPicker />} />
|
|
518
|
+
</div>
|
|
519
|
+
),
|
|
520
|
+
play: async ({ canvas, args, userEvent, step }) => {
|
|
521
|
+
await step("every control the PromptInput family ships is on screen", async () => {
|
|
522
|
+
await expect(canvas.getByRole("button", { name: "Attach files" })).toBeInTheDocument();
|
|
523
|
+
await expect(canvas.getByRole("combobox", { name: "Model" })).toBeInTheDocument();
|
|
524
|
+
await expect(canvas.getByRole("button", { name: /Auto/ })).toBeInTheDocument();
|
|
525
|
+
await expect(
|
|
526
|
+
canvas.getByRole("radiogroup", { name: "Reasoning effort" }),
|
|
527
|
+
).toBeInTheDocument();
|
|
528
|
+
await expect(canvas.getByRole("button", { name: "Voice" })).toBeInTheDocument();
|
|
529
|
+
await expect(canvas.getByRole("button", { name: "Submit" })).toBeInTheDocument();
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
await step("and they are still live in combination", async () => {
|
|
533
|
+
await userEvent.click(canvas.getByRole("radio", { name: "Max" }));
|
|
534
|
+
await waitFor(() => expect(args.effort?.onValueChange).toHaveBeenCalledWith("max"));
|
|
535
|
+
|
|
536
|
+
await userEvent.click(canvas.getByPlaceholderText("Ask me anything…"));
|
|
537
|
+
await userEvent.keyboard("/cl");
|
|
538
|
+
await userEvent.keyboard("{Enter}");
|
|
539
|
+
await waitFor(() =>
|
|
540
|
+
expect(args.onSlashCommand).toHaveBeenCalledWith(
|
|
541
|
+
expect.objectContaining({ name: "clear" }),
|
|
542
|
+
),
|
|
543
|
+
);
|
|
544
|
+
// See WithSlashCommands: wait out the popover's exit transition so axe
|
|
545
|
+
// never measures a closing, empty cmdk listbox.
|
|
546
|
+
await waitFor(() =>
|
|
547
|
+
expect(within(document.body).queryByRole("listbox")).not.toBeInTheDocument(),
|
|
548
|
+
);
|
|
549
|
+
});
|
|
550
|
+
},
|
|
551
|
+
};
|