@runtypelabs/persona 3.15.1 → 3.17.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 (60) hide show
  1. package/dist/animations/glyph-cycle.cjs +279 -0
  2. package/dist/animations/glyph-cycle.d.cts +5 -0
  3. package/dist/animations/glyph-cycle.d.ts +5 -0
  4. package/dist/animations/glyph-cycle.js +252 -0
  5. package/dist/animations/types-HPZY7oAI.d.cts +282 -0
  6. package/dist/animations/types-HPZY7oAI.d.ts +282 -0
  7. package/dist/animations/wipe.cjs +107 -0
  8. package/dist/animations/wipe.d.cts +5 -0
  9. package/dist/animations/wipe.d.ts +5 -0
  10. package/dist/animations/wipe.js +80 -0
  11. package/dist/index.cjs +49 -48
  12. package/dist/index.cjs.map +1 -1
  13. package/dist/index.d.cts +216 -1
  14. package/dist/index.d.ts +216 -1
  15. package/dist/index.global.js +137 -82
  16. package/dist/index.global.js.map +1 -1
  17. package/dist/index.js +49 -48
  18. package/dist/index.js.map +1 -1
  19. package/dist/testing.cjs +85 -0
  20. package/dist/testing.d.cts +39 -0
  21. package/dist/testing.d.ts +39 -0
  22. package/dist/testing.js +56 -0
  23. package/dist/theme-editor.cjs +847 -127
  24. package/dist/theme-editor.d.cts +225 -2
  25. package/dist/theme-editor.d.ts +225 -2
  26. package/dist/theme-editor.js +845 -127
  27. package/dist/widget.css +133 -0
  28. package/package.json +20 -3
  29. package/src/animations/glyph-cycle.ts +332 -0
  30. package/src/animations/wipe.ts +66 -0
  31. package/src/client.test.ts +141 -0
  32. package/src/client.ts +197 -2
  33. package/src/components/composer-builder.ts +61 -10
  34. package/src/components/header-builder.ts +18 -7
  35. package/src/components/header-layouts.ts +3 -1
  36. package/src/components/message-bubble.test.ts +181 -2
  37. package/src/components/message-bubble.ts +209 -14
  38. package/src/components/panel.ts +4 -1
  39. package/src/defaults.ts +22 -0
  40. package/src/index-global.ts +31 -0
  41. package/src/index.ts +18 -0
  42. package/src/session.test.ts +93 -1
  43. package/src/session.ts +5 -0
  44. package/src/styles/widget.css +133 -0
  45. package/src/testing/index.ts +11 -0
  46. package/src/testing/mock-stream.test.ts +80 -0
  47. package/src/testing/mock-stream.ts +94 -0
  48. package/src/testing.ts +2 -0
  49. package/src/theme-editor/index.ts +4 -0
  50. package/src/theme-editor/preview-utils.test.ts +60 -0
  51. package/src/theme-editor/preview-utils.ts +129 -0
  52. package/src/theme-editor/sections.test.ts +19 -0
  53. package/src/theme-editor/sections.ts +84 -1
  54. package/src/types.ts +221 -0
  55. package/src/ui.stop-button.test.ts +165 -0
  56. package/src/ui.ts +79 -8
  57. package/src/utils/message-fingerprint.ts +2 -0
  58. package/src/utils/morph.ts +7 -0
  59. package/src/utils/stream-animation.test.ts +417 -0
  60. package/src/utils/stream-animation.ts +449 -0
@@ -697,6 +697,89 @@ const featuresSectionDef: SectionDef = {
697
697
  ],
698
698
  };
699
699
 
700
+ const streamAnimationSectionDef: SectionDef = {
701
+ id: 'stream-animation', title: 'Stream Animation', description: 'Control how assistant text appears while streaming.', collapsed: true,
702
+ fields: [
703
+ {
704
+ id: 'stream-anim-type',
705
+ label: 'Animation',
706
+ description: 'Reveal effect applied to each assistant reply as it streams.',
707
+ type: 'select',
708
+ path: 'features.streamAnimation.type',
709
+ defaultValue: 'none',
710
+ options: [
711
+ { value: 'none', label: 'None' },
712
+ { value: 'typewriter', label: 'Typewriter' },
713
+ { value: 'word-fade', label: 'Word fade' },
714
+ { value: 'letter-rise', label: 'Letter rise' },
715
+ { value: 'glyph-cycle', label: 'Glyph cycle' },
716
+ { value: 'wipe', label: 'Wipe' },
717
+ { value: 'pop-bubble', label: 'Pop bubble' },
718
+ ],
719
+ },
720
+ {
721
+ id: 'stream-anim-placeholder',
722
+ label: 'Pre-first-token Placeholder',
723
+ description: 'What to show before the first token arrives.',
724
+ type: 'select',
725
+ path: 'features.streamAnimation.placeholder',
726
+ defaultValue: 'none',
727
+ options: [
728
+ { value: 'none', label: 'Typing indicator (default)' },
729
+ { value: 'skeleton', label: 'Skeleton shimmer' },
730
+ ],
731
+ },
732
+ {
733
+ id: 'stream-anim-buffer',
734
+ label: 'Content Buffering',
735
+ description: 'Trim in-progress units so only complete words/lines reveal.',
736
+ type: 'select',
737
+ path: 'features.streamAnimation.buffer',
738
+ defaultValue: 'none',
739
+ options: [
740
+ { value: 'none', label: 'None — stream every character' },
741
+ { value: 'word', label: 'Word — hold until whitespace' },
742
+ { value: 'line', label: 'Line — hold until newline' },
743
+ ],
744
+ },
745
+ {
746
+ id: 'stream-anim-speed',
747
+ label: 'Per-unit Duration (ms)',
748
+ description: 'Animation length for each character or word.',
749
+ type: 'select',
750
+ path: 'features.streamAnimation.speed',
751
+ defaultValue: 120,
752
+ options: [
753
+ { value: '40', label: '40ms — snappy' },
754
+ { value: '80', label: '80ms' },
755
+ { value: '120', label: '120ms (default)' },
756
+ { value: '200', label: '200ms' },
757
+ { value: '320', label: '320ms' },
758
+ { value: '480', label: '480ms — slow' },
759
+ ],
760
+ formatValue: (v: unknown) => String(v ?? 120),
761
+ parseValue: (v: unknown) => Number(v),
762
+ },
763
+ {
764
+ id: 'stream-anim-duration',
765
+ label: 'Container Duration (ms)',
766
+ description: 'Length of container-level effects (pop-bubble, custom plugins).',
767
+ type: 'select',
768
+ path: 'features.streamAnimation.duration',
769
+ defaultValue: 1800,
770
+ options: [
771
+ { value: '600', label: '600ms' },
772
+ { value: '1200', label: '1200ms' },
773
+ { value: '1800', label: '1800ms (default)' },
774
+ { value: '2400', label: '2400ms' },
775
+ { value: '3600', label: '3600ms — slow' },
776
+ ],
777
+ formatValue: (v: unknown) => String(v ?? 1800),
778
+ parseValue: (v: unknown) => Number(v),
779
+ },
780
+ ],
781
+ };
782
+
700
783
  const attachmentsSectionDef: SectionDef = {
701
784
  id: 'attachments-config', title: 'Attachments', collapsed: true,
702
785
  fields: [
@@ -773,7 +856,7 @@ export const CONFIGURE_SUB_GROUPS: SubGroupDef[] = [
773
856
  { label: 'Content', sections: [copySectionDef, suggestionsSectionDef] },
774
857
  { label: 'Layout', sections: [generalLayoutSectionDef, headerLayoutSectionDef, messagesLayoutSectionDef, messageActionsSectionDef] },
775
858
  { label: 'Widget', sections: [launcherBasicsSectionDef, launcherAdvancedSectionDef, sendButtonSectionDef, closeButtonSectionDef, clearChatSectionDef, statusIndicatorSectionDef] },
776
- { label: 'Features', sections: [featuresSectionDef, attachmentsSectionDef, artifactsSectionDef, artifactCustomizationSectionDef] },
859
+ { label: 'Features', sections: [featuresSectionDef, streamAnimationSectionDef, attachmentsSectionDef, artifactsSectionDef, artifactCustomizationSectionDef] },
777
860
  { label: 'Developer', collapsedByDefault: true, sections: [apiIntegrationSectionDef, debugSectionDef, markdownSectionDef] },
778
861
  ];
779
862
 
package/src/types.ts CHANGED
@@ -194,6 +194,17 @@ export type AgentMessageMetadata = {
194
194
  iteration?: number;
195
195
  turnId?: string;
196
196
  agentName?: string;
197
+ /**
198
+ * When this message was produced by a step inside a nested flow executed
199
+ * as a tool, identifies the parent tool call id. Enables renderers to
200
+ * visually group or indent nested-flow output under its parent tool.
201
+ */
202
+ parentToolId?: string;
203
+ /**
204
+ * Nested flow step id that produced this message (e.g. a `send-stream`
205
+ * or `prompt` step inside the nested flow). Stable key for that step.
206
+ */
207
+ parentStepId?: string;
197
208
  };
198
209
 
199
210
  export type AgentWidgetRequestMiddlewareContext = {
@@ -669,6 +680,167 @@ export type AgentWidgetReasoningDisplayFeature = {
669
680
  loadingAnimation?: AgentWidgetToolCallLoadingAnimation;
670
681
  };
671
682
 
683
+ /**
684
+ * Reveal animation applied to assistant message text while it is streaming.
685
+ *
686
+ * Built-in types always available:
687
+ * - `none` — text appears as tokens arrive (default).
688
+ * - `typewriter` — characters fade in with a blinking caret.
689
+ * - `pop-bubble` — the bubble scales in; text streams normally afterward.
690
+ * - `letter-rise` — per-char translateY + fade reveal.
691
+ * - `word-fade` — per-word blur + translateY fade-in.
692
+ *
693
+ * Subpath plugins (import from `@runtypelabs/persona/animations/*` to register):
694
+ * - `wipe`, `glyph-cycle`.
695
+ *
696
+ * Custom types are allowed — register a plugin with any string name and
697
+ * reference it by that name in `type`.
698
+ */
699
+ export type AgentWidgetStreamAnimationBuiltinType =
700
+ | "none"
701
+ | "typewriter"
702
+ | "word-fade"
703
+ | "letter-rise"
704
+ | "glyph-cycle"
705
+ | "wipe"
706
+ | "pop-bubble";
707
+
708
+ export type AgentWidgetStreamAnimationType =
709
+ | AgentWidgetStreamAnimationBuiltinType
710
+ | (string & {});
711
+
712
+ /**
713
+ * Placeholder shown inside a streaming assistant bubble before the first token arrives.
714
+ * - `none` — use the default typing-dots indicator (existing behavior).
715
+ * - `skeleton` — shimmer bars, replaced by streaming content once it starts.
716
+ */
717
+ export type AgentWidgetStreamAnimationPlaceholder = "none" | "skeleton";
718
+
719
+ /**
720
+ * How much of the accumulated streaming content to display while tokens are
721
+ * still arriving. Trimming to a boundary means in-progress words or lines
722
+ * stay hidden until they complete — useful for animations that benefit from
723
+ * unit-complete reveals (e.g. wipe, glyph-cycle).
724
+ * - `none` — show every character as it arrives (default).
725
+ * - `word` — trim to the last whitespace boundary.
726
+ * - `line` — trim to the last newline boundary.
727
+ */
728
+ export type AgentWidgetStreamAnimationBuffer = "none" | "word" | "line";
729
+
730
+ /**
731
+ * Context passed to plugin lifecycle hooks. Carries the live DOM references
732
+ * and resolved animation settings for the currently-streaming message.
733
+ */
734
+ export type StreamAnimationContext = {
735
+ /** The `.persona-message-content` element owning the streamed text. */
736
+ container: HTMLElement;
737
+ /** The outer message bubble element. */
738
+ bubble: HTMLElement;
739
+ /** ID of the streaming message. */
740
+ messageId: string;
741
+ /** Read-only reference to the message being streamed. */
742
+ message: AgentWidgetMessage;
743
+ /** Effective `speed` from `streamAnimation.speed`. */
744
+ speed: number;
745
+ /** Effective `duration` from `streamAnimation.duration`. */
746
+ duration: number;
747
+ };
748
+
749
+ /**
750
+ * Pluggable stream animation. Third-party packages and inline registrations
751
+ * implement this interface to add custom reveal effects.
752
+ *
753
+ * Lifecycle:
754
+ * - When the widget mounts and detects a plugin (either passed via config or
755
+ * auto-registered in the IIFE bundle), it injects `styles` once into the
756
+ * widget's style host.
757
+ * - For each streaming assistant message whose `type` matches `name`, the
758
+ * widget applies `containerClass` / `bubbleClass`, wraps text per `wrap`,
759
+ * and — if `useCaret` is true — appends a blinking caret.
760
+ * - Hooks fire after the live DOM is morphed; plugins use stable element IDs
761
+ * and `data-preserve-animation` to safely mutate per-char or per-word spans
762
+ * without idiomorph clobbering in-flight work.
763
+ */
764
+ export type StreamAnimationPlugin = {
765
+ /** Plugin identifier. Matches the `type` field in `streamAnimation`. */
766
+ name: string;
767
+ /** Class added to `.persona-message-content` while streaming. */
768
+ containerClass?: string;
769
+ /** Class added to the bubble element (e.g. a one-shot scale animation). */
770
+ bubbleClass?: string;
771
+ /** Wrap mode applied to text nodes during streaming. @default "none" */
772
+ wrap?: "none" | "char" | "word";
773
+ /**
774
+ * HTML tags whose descendant text is skipped during wrapping. Defaults to
775
+ * `["pre", "code", "a", "script", "style"]` — useful for keeping code
776
+ * blocks legible and link click-targets intact. Plugins that want to
777
+ * animate characters inside inline code (e.g. `glyph-cycle`) can narrow
778
+ * the list.
779
+ */
780
+ skipTags?: string[];
781
+ /** Append a blinking caret after the last rendered char/word. */
782
+ useCaret?: boolean;
783
+ /** CSS string injected into the widget style host on first activation. */
784
+ styles?: string;
785
+ /**
786
+ * Optional custom buffering strategy. Returns the portion of `content`
787
+ * that should be rendered during streaming. Use this for buffering
788
+ * schemes beyond the built-in `word` / `line` strategies.
789
+ */
790
+ bufferContent?: (content: string, message: AgentWidgetMessage) => string;
791
+ /**
792
+ * Fires once when the plugin is first activated inside a widget instance.
793
+ * Use this to set up MutationObservers or other long-lived listeners.
794
+ * Return an optional cleanup function that runs on widget destroy.
795
+ */
796
+ onAttach?: (root: HTMLElement | ShadowRoot) => (() => void) | void;
797
+ /** Fires after each render that reaches the live DOM. */
798
+ onAfterRender?: (ctx: StreamAnimationContext) => void;
799
+ /** Fires when a streamed message's `streaming` flag flips to false. */
800
+ onStreamComplete?: (ctx: StreamAnimationContext) => void;
801
+ /**
802
+ * Report whether the plugin still has in-flight animation work for a
803
+ * message. When `true`, the widget keeps rendering the message in its
804
+ * "streaming-animated" mode even after `message.streaming` flips false —
805
+ * preventing the final non-animated render from yanking the rug out from
806
+ * under unfinished per-char cycles or reveals.
807
+ */
808
+ isAnimating?: (message: AgentWidgetMessage) => boolean;
809
+ };
810
+
811
+ export type AgentWidgetStreamAnimationFeature = {
812
+ /** Reveal animation to apply while streaming. @default "none" */
813
+ type?: AgentWidgetStreamAnimationType;
814
+ /** Pre-first-token placeholder. @default "none" */
815
+ placeholder?: AgentWidgetStreamAnimationPlaceholder;
816
+ /**
817
+ * Per-unit animation duration (ms) for `typewriter`, `letter-rise`, `word-fade`,
818
+ * and per-unit plugin animations. Each arriving character/word animates from
819
+ * invisible to visible over this duration, independent of its position — the
820
+ * streaming cadence itself provides the visible stagger.
821
+ * @default 120
822
+ */
823
+ speed?: number;
824
+ /**
825
+ * Total duration of container-level animations (`pop-bubble` and custom
826
+ * plugin animations), in milliseconds.
827
+ * @default 1800
828
+ */
829
+ duration?: number;
830
+ /**
831
+ * Trim the accumulated streaming content to a word or line boundary before
832
+ * rendering. Hides in-progress units until they complete.
833
+ * @default "none"
834
+ */
835
+ buffer?: AgentWidgetStreamAnimationBuffer;
836
+ /**
837
+ * Extra animation plugins available to this widget instance. Keys are
838
+ * plugin names; the matching plugin activates when `type` is set to that
839
+ * name. Built-in types (`typewriter`, `pop-bubble`) are always registered.
840
+ */
841
+ plugins?: Record<string, StreamAnimationPlugin>;
842
+ };
843
+
672
844
  export type AgentWidgetFeatureFlags = {
673
845
  showReasoning?: boolean;
674
846
  showToolCalls?: boolean;
@@ -683,6 +855,8 @@ export type AgentWidgetFeatureFlags = {
683
855
  eventStream?: EventStreamConfig;
684
856
  /** Optional artifact sidebar (split pane / mobile drawer) */
685
857
  artifacts?: AgentWidgetArtifactsFeature;
858
+ /** Reveal animation for streaming assistant text. */
859
+ streamAnimation?: AgentWidgetStreamAnimationFeature;
686
860
  };
687
861
 
688
862
  export type SSEEventRecord = {
@@ -985,6 +1159,10 @@ export type AgentWidgetSendButtonConfig = {
985
1159
  backgroundColor?: string;
986
1160
  textColor?: string;
987
1161
  size?: string;
1162
+ /** Lucide icon name shown while a response is streaming. Clicking the button in this state aborts the stream. Default: "square". */
1163
+ stopIconName?: string;
1164
+ /** Tooltip text shown while streaming. Default: "Stop generating". */
1165
+ stopTooltipText?: string;
988
1166
  };
989
1167
 
990
1168
  /** Optional composer UI state for custom `renderComposer` implementations. */
@@ -2597,11 +2775,21 @@ export type AgentWidgetConfig = {
2597
2775
  welcomeSubtitle?: string;
2598
2776
  inputPlaceholder?: string;
2599
2777
  sendButtonLabel?: string;
2778
+ /** Button label shown in text mode while a response is streaming. Default: "Stop". */
2779
+ stopButtonLabel?: string;
2600
2780
  /**
2601
2781
  * When false, the welcome / intro card is not shown above the message list.
2602
2782
  * @default true
2603
2783
  */
2604
2784
  showWelcomeCard?: boolean;
2785
+ /**
2786
+ * Per-stop-reason copy for the inline notice rendered on assistant
2787
+ * bubbles when the runtime reports a non-natural stop (e.g. the agent
2788
+ * loop hit `max_tool_calls` and was cut off mid-loop). Each key is
2789
+ * optional — keys you omit fall back to the built-in defaults. Set a
2790
+ * key to an empty string to suppress the notice for that reason.
2791
+ */
2792
+ stopReasonNotice?: Partial<Record<StopReasonKind, string>>;
2605
2793
  };
2606
2794
  /**
2607
2795
  * Semantic design tokens (`palette`, `semantic`, `components`).
@@ -3110,6 +3298,28 @@ export type AgentWidgetApproval = {
3110
3298
 
3111
3299
  export type AgentWidgetMessageVariant = "assistant" | "reasoning" | "tool" | "approval";
3112
3300
 
3301
+ /**
3302
+ * Per-turn / per-step stop reason emitted by the runtime on
3303
+ * `agent_turn_complete` and `step_complete` SSE events. The vocabulary is
3304
+ * owned by the upstream Runtype API — do not extend without coordination.
3305
+ *
3306
+ * - `end_turn` — natural completion (no affordance needed)
3307
+ * - `max_tool_calls` — agent loop tripped the configured tool-call ceiling
3308
+ * - `length` — provider hit max output tokens
3309
+ * - `content_filter` — provider content filter intervened
3310
+ * - `error` — provider/runtime error (prefer existing error rendering)
3311
+ * - `unknown` — explicitly reported but uninformative
3312
+ *
3313
+ * Absent (`undefined`) means "not reported" — distinct from `'unknown'`.
3314
+ */
3315
+ export type StopReasonKind =
3316
+ | 'end_turn'
3317
+ | 'max_tool_calls'
3318
+ | 'length'
3319
+ | 'content_filter'
3320
+ | 'error'
3321
+ | 'unknown';
3322
+
3113
3323
  /**
3114
3324
  * Represents a message in the chat conversation.
3115
3325
  *
@@ -3196,6 +3406,17 @@ export type AgentWidgetMessage = {
3196
3406
  * Contains execution context like iteration number and turn ID.
3197
3407
  */
3198
3408
  agentMetadata?: AgentMessageMetadata;
3409
+ /**
3410
+ * Per-turn stop reason reported by the runtime on `agent_turn_complete`
3411
+ * (agent-loop path) or the last `step_complete` for a prompt step
3412
+ * (dispatch / flow path). Absent when the API did not report a value.
3413
+ *
3414
+ * When set to a non-natural value (`max_tool_calls`, `length`,
3415
+ * `content_filter`, `error`), the widget renders an inline notice on
3416
+ * the assistant bubble. See `config.copy.stopReasonNotice` to override
3417
+ * the default copy.
3418
+ */
3419
+ stopReason?: StopReasonKind;
3199
3420
  };
3200
3421
 
3201
3422
  // ============================================================================
@@ -0,0 +1,165 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { createAgentExperience } from "./ui";
6
+
7
+ const createMount = () => {
8
+ const mount = document.createElement("div");
9
+ document.body.appendChild(mount);
10
+ return mount;
11
+ };
12
+
13
+ const flush = async (times = 4) => {
14
+ for (let i = 0; i < times; i += 1) {
15
+ // eslint-disable-next-line no-await-in-loop
16
+ await Promise.resolve();
17
+ }
18
+ };
19
+
20
+ describe("createAgentExperience stop-streaming submit button", () => {
21
+ const originalFetch = global.fetch;
22
+ let capturedSignals: AbortSignal[] = [];
23
+
24
+ beforeEach(() => {
25
+ capturedSignals = [];
26
+ vi.stubGlobal("requestAnimationFrame", (cb: (time: number) => void) => {
27
+ cb(0);
28
+ return 1;
29
+ });
30
+ vi.stubGlobal("cancelAnimationFrame", () => {});
31
+ window.scrollTo = vi.fn();
32
+
33
+ // Fetch hangs until the caller aborts the signal — models an in-flight
34
+ // SSE stream so the widget stays in the "streaming" state.
35
+ global.fetch = vi.fn().mockImplementation((_url: string, options: any) => {
36
+ const signal = options.signal as AbortSignal;
37
+ capturedSignals.push(signal);
38
+ return new Promise((_resolve, reject) => {
39
+ signal.addEventListener("abort", () => {
40
+ const err = new Error("aborted");
41
+ err.name = "AbortError";
42
+ reject(err);
43
+ });
44
+ });
45
+ }) as any;
46
+ });
47
+
48
+ afterEach(() => {
49
+ document.body.innerHTML = "";
50
+ global.fetch = originalFetch;
51
+ vi.restoreAllMocks();
52
+ });
53
+
54
+ it("keeps the submit button enabled while streaming and clicking it cancels the stream", async () => {
55
+ const mount = createMount();
56
+ const controller = createAgentExperience(mount, {
57
+ apiUrl: "https://api.example.com/chat",
58
+ launcher: { enabled: false },
59
+ });
60
+
61
+ const submit = mount.querySelector<HTMLButtonElement>(
62
+ "[data-persona-composer-submit]"
63
+ );
64
+ expect(submit).not.toBeNull();
65
+
66
+ const textarea = mount.querySelector<HTMLTextAreaElement>(
67
+ "[data-persona-composer-input]"
68
+ )!;
69
+ textarea.value = "Hello";
70
+ submit!.click();
71
+
72
+ await flush();
73
+
74
+ // Streaming is active: the button must stay enabled so it can be clicked
75
+ // again to stop the response.
76
+ expect(controller.getState().streaming).toBe(true);
77
+ expect(submit!.disabled).toBe(false);
78
+ expect(capturedSignals).toHaveLength(1);
79
+ expect(capturedSignals[0].aborted).toBe(false);
80
+
81
+ // Second click — acts as "stop generating".
82
+ submit!.click();
83
+
84
+ await flush();
85
+
86
+ expect(controller.getState().streaming).toBe(false);
87
+ expect(capturedSignals[0].aborted).toBe(true);
88
+ // No new request should have been fired by the stop click.
89
+ expect(capturedSignals).toHaveLength(1);
90
+ // Typed text is preserved so the user can resend after stopping.
91
+ expect(textarea.value).toBe("");
92
+ // (The textarea was cleared on the *first* submit, not by the stop click —
93
+ // that's fine because after cancel the user can keep typing.)
94
+
95
+ controller.destroy();
96
+ });
97
+
98
+ it("swaps to the stop icon while streaming and back to the send icon after cancel (icon mode)", async () => {
99
+ const mount = createMount();
100
+ const controller = createAgentExperience(mount, {
101
+ apiUrl: "https://api.example.com/chat",
102
+ launcher: { enabled: false },
103
+ sendButton: { useIcon: true, iconName: "arrow-up" },
104
+ });
105
+
106
+ const submit = mount.querySelector<HTMLButtonElement>(
107
+ "[data-persona-composer-submit]"
108
+ )!;
109
+ const textarea = mount.querySelector<HTMLTextAreaElement>(
110
+ "[data-persona-composer-input]"
111
+ )!;
112
+
113
+ // Initial state: send icon (aria-label tracks tooltip default).
114
+ expect(submit.getAttribute("aria-label")).toBe("Send message");
115
+
116
+ textarea.value = "Hi";
117
+ submit.click();
118
+ await flush();
119
+
120
+ expect(controller.getState().streaming).toBe(true);
121
+ expect(submit.getAttribute("aria-label")).toBe("Stop generating");
122
+
123
+ submit.click();
124
+ await flush();
125
+
126
+ expect(controller.getState().streaming).toBe(false);
127
+ expect(submit.getAttribute("aria-label")).toBe("Send message");
128
+
129
+ controller.destroy();
130
+ });
131
+
132
+ it("swaps the text label in text mode", async () => {
133
+ const mount = createMount();
134
+ const controller = createAgentExperience(mount, {
135
+ apiUrl: "https://api.example.com/chat",
136
+ launcher: { enabled: false },
137
+ sendButton: { useIcon: false },
138
+ copy: { sendButtonLabel: "Send", stopButtonLabel: "Stop" },
139
+ });
140
+
141
+ const submit = mount.querySelector<HTMLButtonElement>(
142
+ "[data-persona-composer-submit]"
143
+ )!;
144
+ const textarea = mount.querySelector<HTMLTextAreaElement>(
145
+ "[data-persona-composer-input]"
146
+ )!;
147
+
148
+ expect(submit.textContent).toBe("Send");
149
+
150
+ textarea.value = "Hi";
151
+ submit.click();
152
+ await flush();
153
+
154
+ expect(controller.getState().streaming).toBe(true);
155
+ expect(submit.textContent).toBe("Stop");
156
+
157
+ submit.click();
158
+ await flush();
159
+
160
+ expect(controller.getState().streaming).toBe(false);
161
+ expect(submit.textContent).toBe("Send");
162
+
163
+ controller.destroy();
164
+ });
165
+ });