@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.
Files changed (128) hide show
  1. package/README.md +61 -11
  2. package/dist/{_audio-player-media-chrome-KA5DY54G.js → _audio-player-media-chrome-T3XVXWRZ.js} +8 -4
  3. package/dist/_audio-player-media-chrome-T3XVXWRZ.js.map +1 -0
  4. package/dist/{_flow-boundary-D63PJ65S.js → _flow-boundary-SHNWLQG5.js} +32 -43
  5. package/dist/_flow-boundary-SHNWLQG5.js.map +1 -0
  6. package/dist/{_persona-rive-RFR2EUWP.js → _persona-rive-JEG44YHX.js} +9 -5
  7. package/dist/_persona-rive-JEG44YHX.js.map +1 -0
  8. package/dist/index.d.ts +1109 -202
  9. package/dist/index.js +4867 -2878
  10. package/dist/index.js.map +1 -1
  11. package/package.json +31 -16
  12. package/src/_audio-player-media-chrome.tsx +102 -15
  13. package/src/_flow-boundary.tsx +68 -49
  14. package/src/_lazy-boundary-conformance.ts +38 -0
  15. package/src/_lazy-engine-boundary.tsx +61 -0
  16. package/src/_lazy-mermaid-absent.test.ts +53 -0
  17. package/src/_lazy-mermaid.test.ts +15 -0
  18. package/src/_lazy-mermaid.ts +24 -1
  19. package/src/_mermaid-error-panel.test.tsx +50 -0
  20. package/src/_mermaid-error-panel.tsx +66 -0
  21. package/src/_persona-rive.tsx +62 -10
  22. package/src/_streamdown-i18n.ts +21 -1
  23. package/src/_streamdown-safety.ts +170 -0
  24. package/src/agent-event.stories.tsx +97 -0
  25. package/src/agent-event.test.tsx +145 -0
  26. package/src/agent-event.tsx +187 -0
  27. package/src/agent-timeline.stories.tsx +9 -1
  28. package/src/agent.test.tsx +47 -0
  29. package/src/agent.tsx +28 -14
  30. package/src/agentic-workspace.stories.tsx +27 -20
  31. package/src/artifact.tsx +15 -12
  32. package/src/audio-player.test.tsx +78 -0
  33. package/src/audio-player.tsx +243 -56
  34. package/src/audio-visualizer.stories.tsx +126 -0
  35. package/src/audio-visualizer.test.tsx +438 -0
  36. package/src/audio-visualizer.tsx +367 -0
  37. package/src/canvas.stories.tsx +150 -1
  38. package/src/chat-shell.stories.tsx +18 -3
  39. package/src/chat.stories.tsx +16 -2
  40. package/src/code-block.stories.tsx +9 -1
  41. package/src/composer.stories.tsx +372 -9
  42. package/src/composer.test.tsx +357 -6
  43. package/src/composer.tsx +217 -35
  44. package/src/confirmation.stories.tsx +72 -1
  45. package/src/confirmation.test.tsx +216 -2
  46. package/src/confirmation.tsx +263 -3
  47. package/src/context-panel.stories.tsx +9 -1
  48. package/src/context-panel.tsx +2 -1
  49. package/src/conversation.stories.tsx +63 -2
  50. package/src/conversation.test.tsx +13 -0
  51. package/src/conversation.tsx +20 -2
  52. package/src/diff-view.stories.tsx +196 -0
  53. package/src/diff-view.test.tsx +188 -0
  54. package/src/diff-view.tsx +642 -0
  55. package/src/gallery.tsx +4 -2
  56. package/src/index.ts +14 -4
  57. package/src/jsx-preview.stories.tsx +2 -2
  58. package/src/markdown-view.stories.tsx +92 -1
  59. package/src/markdown-view.test.tsx +232 -1
  60. package/src/markdown-view.tsx +150 -6
  61. package/src/message-compare.stories.tsx +175 -0
  62. package/src/message-compare.test.tsx +207 -0
  63. package/src/message-compare.tsx +453 -0
  64. package/src/message-form.stories.tsx +29 -1
  65. package/src/message.stories.tsx +9 -1
  66. package/src/message.test.tsx +176 -0
  67. package/src/message.tsx +90 -4
  68. package/src/microcopy.test.tsx +40 -0
  69. package/src/{model-selector.stories.tsx → model-provider-logo.stories.tsx} +17 -8
  70. package/src/{model-selector.test.tsx → model-provider-logo.test.tsx} +10 -10
  71. package/src/model-provider-logo.tsx +149 -0
  72. package/src/permission-mode-select.stories.tsx +82 -0
  73. package/src/permission-mode-select.test.tsx +100 -0
  74. package/src/permission-mode-select.tsx +137 -0
  75. package/src/persona-missing-peer.test.tsx +54 -0
  76. package/src/persona.tsx +68 -22
  77. package/src/plan.stories.tsx +166 -0
  78. package/src/plan.test.tsx +267 -0
  79. package/src/plan.tsx +182 -20
  80. package/src/prompt-input-effort.stories.tsx +123 -0
  81. package/src/prompt-input-effort.test.tsx +83 -0
  82. package/src/prompt-input-effort.tsx +136 -0
  83. package/src/prompt-input-mode.stories.tsx +108 -0
  84. package/src/prompt-input-mode.test.tsx +99 -0
  85. package/src/prompt-input-mode.tsx +169 -0
  86. package/src/prompt-input-slash.stories.tsx +211 -0
  87. package/src/prompt-input-slash.test.tsx +262 -0
  88. package/src/prompt-input-slash.tsx +541 -0
  89. package/src/prompt-input.stories.tsx +2 -2
  90. package/src/reasoning.tsx +27 -13
  91. package/src/sandbox.stories.tsx +9 -1
  92. package/src/schema-display.tsx +5 -2
  93. package/src/selection-toolbar.stories.tsx +9 -1
  94. package/src/session-header.stories.tsx +128 -0
  95. package/src/session-header.test.tsx +138 -0
  96. package/src/session-header.tsx +243 -0
  97. package/src/session-status-bar.stories.tsx +73 -0
  98. package/src/session-status-bar.test.tsx +94 -0
  99. package/src/session-status-bar.tsx +165 -0
  100. package/src/snippet.stories.tsx +9 -1
  101. package/src/stack-trace.tsx +6 -2
  102. package/src/streamdown-i18n.test.tsx +1 -1
  103. package/src/task.stories.tsx +13 -3
  104. package/src/templates-ai-assistant.stories.tsx +21 -1
  105. package/src/token-usage.stories.tsx +24 -0
  106. package/src/token-usage.test.tsx +92 -0
  107. package/src/{context.tsx → token-usage.tsx} +67 -53
  108. package/src/tool-result-card.stories.tsx +9 -1
  109. package/src/tool.stories.tsx +13 -3
  110. package/src/tool.tsx +15 -8
  111. package/src/turn-status.stories.tsx +124 -0
  112. package/src/turn-status.test.tsx +74 -0
  113. package/src/turn-status.tsx +174 -0
  114. package/src/use-audio-level.ts +104 -0
  115. package/src/web-preview.tsx +8 -3
  116. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +0 -1
  117. package/dist/_flow-boundary-D63PJ65S.js.map +0 -1
  118. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +0 -11
  119. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +0 -1
  120. package/dist/_persona-rive-RFR2EUWP.js.map +0 -1
  121. package/src/_interactive-terminal-xterm.ts +0 -32
  122. package/src/blocks-ai-composer.stories.tsx +0 -83
  123. package/src/context.stories.tsx +0 -16
  124. package/src/interactive-terminal.stories.tsx +0 -165
  125. package/src/interactive-terminal.test.tsx +0 -448
  126. package/src/interactive-terminal.tsx +0 -444
  127. package/src/model-selector.tsx +0 -225
  128. package/src/terminal.tsx +0 -244
@@ -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, placeholder, and model pill", () => {
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
- expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
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, and hides the model pill when model is null", () => {
16
- render(<Composer status="Thinking…" placeholder="Ask the agent…" model={null} />);
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
+ });