@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.test.tsx
CHANGED
|
@@ -1,23 +1,39 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
3
|
import userEvent from "@testing-library/user-event";
|
|
4
4
|
import { Composer } from "./composer";
|
|
5
|
+
import {
|
|
6
|
+
PromptInput,
|
|
7
|
+
PromptInputBody,
|
|
8
|
+
PromptInputFooter,
|
|
9
|
+
PromptInputSubmit,
|
|
10
|
+
PromptInputTextarea,
|
|
11
|
+
PromptInputTools,
|
|
12
|
+
type PromptInputProps,
|
|
13
|
+
} from "./prompt-input";
|
|
5
14
|
|
|
6
15
|
describe("Composer", () => {
|
|
7
|
-
it("renders the default status line
|
|
16
|
+
it("renders the default status line and placeholder, and no model control at all", () => {
|
|
8
17
|
render(<Composer />);
|
|
9
18
|
|
|
10
19
|
expect(screen.getByText("Awaiting your input")).toBeInTheDocument();
|
|
11
20
|
expect(screen.getByPlaceholderText("Ask me anything…")).toBeInTheDocument();
|
|
12
|
-
|
|
21
|
+
// The `model` prop and its hard-coded "Claude Opus 4" pill are GONE: a
|
|
22
|
+
// composer must not show a model name it cannot change. The slot is
|
|
23
|
+
// `modelPicker`, and it renders nothing unless you fill it.
|
|
24
|
+
expect(screen.queryByText("Claude Opus 4")).not.toBeInTheDocument();
|
|
25
|
+
// …and none of the four optional slots renders unless it is asked for.
|
|
26
|
+
expect(document.querySelector('[data-slot="model-picker"]')).toBeNull();
|
|
27
|
+
expect(document.querySelector('[data-slot="prompt-input-mode"]')).toBeNull();
|
|
28
|
+
expect(document.querySelector('[data-slot="prompt-input-effort"]')).toBeNull();
|
|
29
|
+
expect(document.querySelector('[data-slot="prompt-input-slash"]')).toBeNull();
|
|
13
30
|
});
|
|
14
31
|
|
|
15
|
-
it("accepts a custom status and placeholder
|
|
16
|
-
render(<Composer status="Thinking…" placeholder="Ask the agent…"
|
|
32
|
+
it("accepts a custom status and placeholder", () => {
|
|
33
|
+
render(<Composer status="Thinking…" placeholder="Ask the agent…" />);
|
|
17
34
|
|
|
18
35
|
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
|
19
36
|
expect(screen.getByPlaceholderText("Ask the agent…")).toBeInTheDocument();
|
|
20
|
-
expect(screen.queryByText("Claude Opus 4")).not.toBeInTheDocument();
|
|
21
37
|
});
|
|
22
38
|
|
|
23
39
|
it("renders suggestion chips and fires onSuggestionClick with the chosen suggestion", async () => {
|
|
@@ -180,3 +196,338 @@ describe("Composer — icon-only buttons have an accessible name (#356)", () =>
|
|
|
180
196
|
expect(screen.getByRole("button", { name: "Voice" })).toBeInTheDocument();
|
|
181
197
|
});
|
|
182
198
|
});
|
|
199
|
+
|
|
200
|
+
describe("Composer — the four optional control slots (RM-006)", () => {
|
|
201
|
+
const MODES = [
|
|
202
|
+
{ id: "auto", label: "Auto", description: "Acts without asking." },
|
|
203
|
+
{ id: "plan", label: "Plan first", description: "Proposes a plan first." },
|
|
204
|
+
];
|
|
205
|
+
const LEVELS = [
|
|
206
|
+
{ id: "low", label: "Low" },
|
|
207
|
+
{ id: "high", label: "High" },
|
|
208
|
+
];
|
|
209
|
+
const COMMANDS = [
|
|
210
|
+
{ name: "help", description: "Show available commands" },
|
|
211
|
+
{ name: "clear", description: "Clear the conversation" },
|
|
212
|
+
];
|
|
213
|
+
|
|
214
|
+
// jsdom does not implement `Element.prototype.scrollIntoView`, and `cmdk`
|
|
215
|
+
// (which renders the slash palette's list) calls it unconditionally whenever
|
|
216
|
+
// the highlight moves. Same local no-op stub `prompt-input-slash.test.tsx`
|
|
217
|
+
// uses — `vitest.setup.ts` deliberately does not stub it globally.
|
|
218
|
+
let originalScrollIntoView: typeof Element.prototype.scrollIntoView;
|
|
219
|
+
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
originalScrollIntoView = Element.prototype.scrollIntoView;
|
|
222
|
+
Element.prototype.scrollIntoView = vi.fn();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
afterEach(() => {
|
|
226
|
+
Element.prototype.scrollIntoView = originalScrollIntoView;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("renders modelPicker in the tools cluster, before mode and effort", () => {
|
|
230
|
+
render(
|
|
231
|
+
<Composer
|
|
232
|
+
modelPicker={<button type="button">Sonnet 4.5</button>}
|
|
233
|
+
mode={{ modes: MODES }}
|
|
234
|
+
effort={{ levels: LEVELS, "aria-label": "Reasoning effort" }}
|
|
235
|
+
/>,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
// attach · modelPicker · mode · effort — the TerminalComposer arrangement,
|
|
239
|
+
// asserted as real sibling order rather than "all four are somewhere".
|
|
240
|
+
const attach = screen.getByRole("button", { name: "Attach files" });
|
|
241
|
+
const cluster = attach.parentElement!;
|
|
242
|
+
const children = Array.from(cluster.children);
|
|
243
|
+
|
|
244
|
+
expect(children).toHaveLength(4);
|
|
245
|
+
expect(children[0]).toBe(attach);
|
|
246
|
+
expect(children[1]).toBe(screen.getByRole("button", { name: "Sonnet 4.5" }));
|
|
247
|
+
expect(children[2]).toHaveAttribute("data-slot", "prompt-input-mode");
|
|
248
|
+
expect(children[3]).toHaveAttribute("data-slot", "prompt-input-effort");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("mode renders a PromptInputMode whose selection reaches onValueChange", async () => {
|
|
252
|
+
const onValueChange = vi.fn();
|
|
253
|
+
render(<Composer mode={{ modes: MODES, onValueChange }} />);
|
|
254
|
+
|
|
255
|
+
await userEvent.click(screen.getByRole("button", { name: /Auto/ }));
|
|
256
|
+
await userEvent.click(await screen.findByRole("menuitemradio", { name: /Plan first/ }));
|
|
257
|
+
|
|
258
|
+
expect(onValueChange).toHaveBeenCalledWith("plan");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("effort renders a PromptInputEffort whose selection reaches onValueChange", async () => {
|
|
262
|
+
const onValueChange = vi.fn();
|
|
263
|
+
render(
|
|
264
|
+
<Composer effort={{ levels: LEVELS, "aria-label": "Reasoning effort", onValueChange }} />,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// The scale is a real radiogroup, named by the consumer's own vocabulary.
|
|
268
|
+
expect(screen.getByRole("radiogroup", { name: "Reasoning effort" })).toBeInTheDocument();
|
|
269
|
+
await userEvent.click(screen.getByRole("radio", { name: "High" }));
|
|
270
|
+
|
|
271
|
+
expect(onValueChange).toHaveBeenCalledWith("high");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("slashCommands swaps in the palette textarea and fires onSlashCommand on select", async () => {
|
|
275
|
+
const onSlashCommand = vi.fn();
|
|
276
|
+
render(<Composer slashCommands={COMMANDS} onSlashCommand={onSlashCommand} />);
|
|
277
|
+
|
|
278
|
+
const field = screen.getByPlaceholderText("Ask me anything…");
|
|
279
|
+
expect(field).toHaveAttribute("data-slot", "prompt-input-slash-textarea");
|
|
280
|
+
|
|
281
|
+
await userEvent.click(field);
|
|
282
|
+
await userEvent.keyboard("/he");
|
|
283
|
+
expect(await screen.findByRole("listbox")).toBeInTheDocument();
|
|
284
|
+
|
|
285
|
+
await userEvent.keyboard("{Enter}");
|
|
286
|
+
expect(onSlashCommand).toHaveBeenCalledWith(expect.objectContaining({ name: "help" }));
|
|
287
|
+
expect(field).toHaveValue("/help ");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("keeps the submitProps.disabled guard while a slash palette is open", async () => {
|
|
291
|
+
const onSubmit = vi.fn();
|
|
292
|
+
render(
|
|
293
|
+
<Composer onSubmit={onSubmit} slashCommands={COMMANDS} submitProps={{ disabled: true }} />,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
const field = screen.getByPlaceholderText("Ask me anything…");
|
|
297
|
+
await userEvent.click(field);
|
|
298
|
+
await userEvent.keyboard("/he");
|
|
299
|
+
expect(await screen.findByRole("listbox")).toBeInTheDocument();
|
|
300
|
+
|
|
301
|
+
// Enter with the palette open only ever INSERTS the command — it must never
|
|
302
|
+
// reach the form, and the refused send must not destroy the text either.
|
|
303
|
+
await userEvent.keyboard("{Enter}");
|
|
304
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
305
|
+
|
|
306
|
+
// …and Enter once the palette has closed is still refused by the guard.
|
|
307
|
+
await userEvent.keyboard("{Enter}");
|
|
308
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
309
|
+
expect(field).toHaveValue("/help ");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("clears its controlled slash text after an accepted submit", async () => {
|
|
313
|
+
const onSubmit = vi.fn();
|
|
314
|
+
render(<Composer onSubmit={onSubmit} slashCommands={COMMANDS} />);
|
|
315
|
+
|
|
316
|
+
const field = screen.getByPlaceholderText("Ask me anything…");
|
|
317
|
+
await userEvent.type(field, "ship it{Enter}");
|
|
318
|
+
|
|
319
|
+
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
320
|
+
expect(onSubmit).toHaveBeenCalledWith(
|
|
321
|
+
expect.objectContaining({ text: "ship it" }),
|
|
322
|
+
expect.anything(),
|
|
323
|
+
);
|
|
324
|
+
// PromptInput's form.reset() cannot reach React state — Composer clears its
|
|
325
|
+
// own copy, or the sent text would render straight back into the field.
|
|
326
|
+
expect(field).toHaveValue("");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("renders the named slots alongside a `tools` override rather than swallowing them", () => {
|
|
330
|
+
render(
|
|
331
|
+
<Composer
|
|
332
|
+
tools={<button type="button">Web search</button>}
|
|
333
|
+
mode={{ modes: MODES }}
|
|
334
|
+
effort={{ levels: LEVELS, "aria-label": "Reasoning effort" }}
|
|
335
|
+
/>,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
expect(screen.getByRole("button", { name: "Web search" })).toBeInTheDocument();
|
|
339
|
+
expect(screen.queryByRole("button", { name: "Attach files" })).not.toBeInTheDocument();
|
|
340
|
+
expect(document.querySelector('[data-slot="prompt-input-mode"]')).not.toBeNull();
|
|
341
|
+
expect(document.querySelector('[data-slot="prompt-input-effort"]')).not.toBeNull();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ── the port from a hand-rolled PromptInput footer (RM-007, #146) ────────────
|
|
346
|
+
// `registry/blocks/ai-chat-shell/ai-chat.tsx` used to assemble its own
|
|
347
|
+
// `PromptInput` + `PromptInputBody` + `PromptInputFooter` + `PromptInputSubmit`
|
|
348
|
+
// and now renders `<Composer>`. The claim that made the port safe is that
|
|
349
|
+
// `Composer` is a pure re-arrangement of the same primitive: the submit payload,
|
|
350
|
+
// the attachment surface and the stop affordance are unchanged. That is an A/B
|
|
351
|
+
// claim, so it gets an A/B test — the old arrangement is inlined here and driven
|
|
352
|
+
// identically to the new one.
|
|
353
|
+
|
|
354
|
+
describe("Composer — equivalence with a hand-rolled PromptInput footer", () => {
|
|
355
|
+
const HandRolled = ({ onSubmit }: { onSubmit: PromptInputProps["onSubmit"] }) => (
|
|
356
|
+
<PromptInput onSubmit={onSubmit}>
|
|
357
|
+
<PromptInputBody>
|
|
358
|
+
<PromptInputTextarea placeholder="Send a message…" />
|
|
359
|
+
</PromptInputBody>
|
|
360
|
+
<PromptInputFooter>
|
|
361
|
+
<PromptInputTools />
|
|
362
|
+
<PromptInputSubmit />
|
|
363
|
+
</PromptInputFooter>
|
|
364
|
+
</PromptInput>
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
/** Type `text` into whichever arrangement is mounted, then submit with Enter. */
|
|
368
|
+
const drive = async (text: string) => {
|
|
369
|
+
const field = screen.getByPlaceholderText("Send a message…");
|
|
370
|
+
await userEvent.type(field, `${text}{Enter}`);
|
|
371
|
+
return field;
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
it("hands onSubmit the identical PromptInputMessage — text untrimmed, files empty", async () => {
|
|
375
|
+
const before = vi.fn();
|
|
376
|
+
const { unmount } = render(<HandRolled onSubmit={before} />);
|
|
377
|
+
await drive(" what changed overnight? ");
|
|
378
|
+
unmount();
|
|
379
|
+
|
|
380
|
+
const after = vi.fn();
|
|
381
|
+
render(<Composer onSubmit={after} placeholder="Send a message…" />);
|
|
382
|
+
await drive(" what changed overnight? ");
|
|
383
|
+
|
|
384
|
+
expect(before).toHaveBeenCalledTimes(1);
|
|
385
|
+
expect(after).toHaveBeenCalledTimes(1);
|
|
386
|
+
// Deep-equal on the payload, not just "it was called": the block trims the
|
|
387
|
+
// text ITSELF, so an arrangement that silently trimmed (or dropped `files`)
|
|
388
|
+
// would change what a consumer's `send()` receives.
|
|
389
|
+
expect(after.mock.calls[0]![0]).toEqual(before.mock.calls[0]![0]);
|
|
390
|
+
expect(after.mock.calls[0]![0]).toEqual({ text: " what changed overnight? ", files: [] });
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("submits nothing on an empty composer, exactly as the hand-rolled footer did", async () => {
|
|
394
|
+
const before = vi.fn();
|
|
395
|
+
const { unmount } = render(<HandRolled onSubmit={before} />);
|
|
396
|
+
await drive(" ");
|
|
397
|
+
unmount();
|
|
398
|
+
|
|
399
|
+
const after = vi.fn();
|
|
400
|
+
render(<Composer onSubmit={after} placeholder="Send a message…" />);
|
|
401
|
+
await drive(" ");
|
|
402
|
+
|
|
403
|
+
expect(before).not.toHaveBeenCalled();
|
|
404
|
+
expect(after).not.toHaveBeenCalled();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("offers no attachment or dictation affordance when the port switches both off", () => {
|
|
408
|
+
render(<Composer onSubmit={vi.fn()} showAttach={false} showVoice={false} />);
|
|
409
|
+
|
|
410
|
+
expect(screen.queryByRole("button", { name: "Attach files" })).not.toBeInTheDocument();
|
|
411
|
+
expect(screen.queryByRole("button", { name: "Voice" })).not.toBeInTheDocument();
|
|
412
|
+
// The hand-rolled footer rendered an empty `PromptInputTools`; the ported
|
|
413
|
+
// one must be just as empty, or the block would show a dead control.
|
|
414
|
+
expect(screen.getAllByRole("button")).toHaveLength(1); // the send button only
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("never presents a Stop while the block passes no onStop and a settled sendStatus", () => {
|
|
418
|
+
render(<Composer onSubmit={vi.fn()} sendStatus="ready" showAttach={false} showVoice={false} />);
|
|
419
|
+
|
|
420
|
+
const submit = screen.getByRole("button", { name: "Submit" });
|
|
421
|
+
expect(submit).toHaveAttribute("data-action", "send");
|
|
422
|
+
expect(screen.queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ── shortcut hints change with a busy state (#107) ───────────────────────────
|
|
427
|
+
// The acceptance criterion, verbatim: "Composer shortcut hints change with a
|
|
428
|
+
// busy state." `shortcuts` is the always-shown row (opt-in — nothing renders
|
|
429
|
+
// unless it's supplied, matching every other Composer slot); `cancelShortcut`
|
|
430
|
+
// is a SECOND, busy-only hint that appears once the composer is actually
|
|
431
|
+
// generating AND there is a real Stop affordance to describe (`onStop`),
|
|
432
|
+
// mirroring `TerminalComposer`'s `canCancel = busy && Boolean(onStop)` shape.
|
|
433
|
+
describe("Composer — shortcut hints change with a busy state (#107)", () => {
|
|
434
|
+
it("renders no shortcuts row at all when neither prop is supplied", () => {
|
|
435
|
+
render(<Composer />);
|
|
436
|
+
expect(document.querySelector('[data-slot="composer-shortcuts"]')).toBeNull();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("renders the supplied shortcut hints as plain, non-interactive text", () => {
|
|
440
|
+
render(
|
|
441
|
+
<Composer
|
|
442
|
+
shortcuts={[
|
|
443
|
+
{ keys: "Enter", label: "send" },
|
|
444
|
+
{ keys: "Shift+Enter", label: "newline" },
|
|
445
|
+
]}
|
|
446
|
+
/>,
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
expect(screen.getByText("Enter")).toBeInTheDocument();
|
|
450
|
+
expect(screen.getByText("send")).toBeInTheDocument();
|
|
451
|
+
expect(screen.getByText("Shift+Enter")).toBeInTheDocument();
|
|
452
|
+
expect(screen.getByText("newline")).toBeInTheDocument();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("appends the cancel hint only once the composer goes busy, and drops it again at rest", () => {
|
|
456
|
+
const { rerender } = render(
|
|
457
|
+
<Composer
|
|
458
|
+
cancelShortcut={{ keys: "Esc", label: "cancel" }}
|
|
459
|
+
onStop={() => undefined}
|
|
460
|
+
sendStatus="ready"
|
|
461
|
+
shortcuts={[{ keys: "Enter", label: "send" }]}
|
|
462
|
+
/>,
|
|
463
|
+
);
|
|
464
|
+
expect(screen.getByText("Enter")).toBeInTheDocument();
|
|
465
|
+
expect(screen.queryByText("Esc")).not.toBeInTheDocument();
|
|
466
|
+
expect(screen.queryByText("cancel")).not.toBeInTheDocument();
|
|
467
|
+
|
|
468
|
+
rerender(
|
|
469
|
+
<Composer
|
|
470
|
+
cancelShortcut={{ keys: "Esc", label: "cancel" }}
|
|
471
|
+
onStop={() => undefined}
|
|
472
|
+
sendStatus="streaming"
|
|
473
|
+
shortcuts={[{ keys: "Enter", label: "send" }]}
|
|
474
|
+
/>,
|
|
475
|
+
);
|
|
476
|
+
expect(screen.getByText("Enter")).toBeInTheDocument();
|
|
477
|
+
expect(screen.getByText("Esc")).toBeInTheDocument();
|
|
478
|
+
expect(screen.getByText("cancel")).toBeInTheDocument();
|
|
479
|
+
|
|
480
|
+
rerender(
|
|
481
|
+
<Composer
|
|
482
|
+
cancelShortcut={{ keys: "Esc", label: "cancel" }}
|
|
483
|
+
onStop={() => undefined}
|
|
484
|
+
sendStatus="ready"
|
|
485
|
+
shortcuts={[{ keys: "Enter", label: "send" }]}
|
|
486
|
+
/>,
|
|
487
|
+
);
|
|
488
|
+
expect(screen.queryByText("Esc")).not.toBeInTheDocument();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("never shows the cancel hint while busy if there is no onStop to describe", () => {
|
|
492
|
+
render(<Composer cancelShortcut={{ keys: "Esc", label: "cancel" }} sendStatus="streaming" />);
|
|
493
|
+
expect(screen.queryByText("Esc")).not.toBeInTheDocument();
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// The accessible-name trap (#153's failure mode, avoided here by construction):
|
|
497
|
+
// the shortcut row renders as PLAIN SIBLINGS of PromptInputSubmit, never
|
|
498
|
+
// nested inside it, so the Kbd chip cannot concatenate into the button's
|
|
499
|
+
// name.
|
|
500
|
+
//
|
|
501
|
+
// `PromptInputSubmit` sets `aria-label` unconditionally
|
|
502
|
+
// (`prompt-input.tsx`'s `action === "stop" ? t("ai.promptInput.stop") :
|
|
503
|
+
// t("ai.promptInput.submit")`), so an accessible-name assertion alone is
|
|
504
|
+
// VACUOUS here: an explicit `aria-label` wins the accname computation over
|
|
505
|
+
// descendant content, so `toHaveAccessibleName("Stop")` would pass
|
|
506
|
+
// identically whether the Kbd row sat beside the button or was nested
|
|
507
|
+
// inside it (confirmed by mutation-testing this file — nesting a `<span>`
|
|
508
|
+
// child in the button left this assertion green). The property actually
|
|
509
|
+
// being relied on is DOM containment: the shortcut row must never be a
|
|
510
|
+
// descendant of the submit button. Assert that directly.
|
|
511
|
+
it("keeps the shortcut row OUT of the submit button's DOM subtree (#153-style trap)", () => {
|
|
512
|
+
render(
|
|
513
|
+
<Composer
|
|
514
|
+
cancelShortcut={{ keys: "Esc", label: "cancel" }}
|
|
515
|
+
onStop={() => undefined}
|
|
516
|
+
sendStatus="streaming"
|
|
517
|
+
shortcuts={[{ keys: "Enter", label: "send" }]}
|
|
518
|
+
/>,
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
const stopButton = screen.getByRole("button", { name: "Stop" });
|
|
522
|
+
const shortcutRow = document.querySelector<HTMLElement>('[data-slot="composer-shortcuts"]');
|
|
523
|
+
expect(shortcutRow).not.toBeNull();
|
|
524
|
+
// The row renders at all (sanity check the fixture is exercising the
|
|
525
|
+
// real thing, not an empty composer).
|
|
526
|
+
expect(screen.getByText("Esc")).toBeInTheDocument();
|
|
527
|
+
// The actual lock: the button never contains the row (would fail the
|
|
528
|
+
// instant the row moved inside `PromptInputSubmit`), and the row never
|
|
529
|
+
// contains the button (rules out the trap from the other direction).
|
|
530
|
+
expect(stopButton).not.toContainElement(shortcutRow);
|
|
531
|
+
expect(shortcutRow).not.toContainElement(stopButton);
|
|
532
|
+
});
|
|
533
|
+
});
|