@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
@@ -1,7 +1,12 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, it, expect } from "vitest";
3
- import { createStandardBubble, isSafeImageSrc } from "./message-bubble";
4
- import type { AgentWidgetMessage } from "../types";
3
+ import {
4
+ createStandardBubble,
5
+ isSafeImageSrc,
6
+ resolveStopReasonNoticeText,
7
+ getDefaultStopReasonNoticeCopy,
8
+ } from "./message-bubble";
9
+ import type { AgentWidgetConfig, AgentWidgetMessage } from "../types";
5
10
 
6
11
  const makeMessage = (overrides: Partial<AgentWidgetMessage> = {}): AgentWidgetMessage => ({
7
12
  id: "msg-1",
@@ -95,3 +100,177 @@ describe("createStandardBubble", () => {
95
100
  expect(previewImages[0]?.getAttribute("alt")).toBe("Safe image");
96
101
  });
97
102
  });
103
+
104
+ describe("resolveStopReasonNoticeText", () => {
105
+ it("returns null for natural completions", () => {
106
+ expect(resolveStopReasonNoticeText("end_turn")).toBeNull();
107
+ });
108
+
109
+ it("returns null for unknown reasons", () => {
110
+ expect(resolveStopReasonNoticeText("unknown")).toBeNull();
111
+ });
112
+
113
+ it("returns null when stopReason is undefined", () => {
114
+ expect(resolveStopReasonNoticeText(undefined)).toBeNull();
115
+ });
116
+
117
+ it("returns the default copy for actionable reasons", () => {
118
+ expect(resolveStopReasonNoticeText("max_tool_calls")).toBe(
119
+ getDefaultStopReasonNoticeCopy("max_tool_calls")
120
+ );
121
+ expect(resolveStopReasonNoticeText("length")).toBe(
122
+ getDefaultStopReasonNoticeCopy("length")
123
+ );
124
+ expect(resolveStopReasonNoticeText("content_filter")).toBe(
125
+ getDefaultStopReasonNoticeCopy("content_filter")
126
+ );
127
+ expect(resolveStopReasonNoticeText("error")).toBe(
128
+ getDefaultStopReasonNoticeCopy("error")
129
+ );
130
+ });
131
+
132
+ it("applies overrides on a per-key basis", () => {
133
+ expect(
134
+ resolveStopReasonNoticeText("max_tool_calls", {
135
+ ["max_tool_calls" as const]: "Custom override.",
136
+ })
137
+ ).toBe("Custom override.");
138
+ });
139
+
140
+ it("falls back to defaults for keys not overridden", () => {
141
+ expect(
142
+ resolveStopReasonNoticeText("length", {
143
+ ["max_tool_calls" as const]: "Custom.",
144
+ })
145
+ ).toBe(getDefaultStopReasonNoticeCopy("length"));
146
+ });
147
+
148
+ it("suppresses the notice when override is an empty string", () => {
149
+ expect(
150
+ resolveStopReasonNoticeText("max_tool_calls", {
151
+ ["max_tool_calls" as const]: "",
152
+ })
153
+ ).toBeNull();
154
+ });
155
+ });
156
+
157
+ describe("createStandardBubble — stopReason notice", () => {
158
+ const renderWithStopReason = (
159
+ overrides: Partial<AgentWidgetMessage>,
160
+ widgetConfig?: Partial<AgentWidgetConfig>
161
+ ) =>
162
+ createStandardBubble(
163
+ makeMessage(overrides),
164
+ ({ text }) => text,
165
+ undefined,
166
+ undefined,
167
+ undefined,
168
+ { widgetConfig: widgetConfig as AgentWidgetConfig | undefined }
169
+ );
170
+
171
+ it("renders no notice for end_turn (natural completion)", () => {
172
+ const bubble = renderWithStopReason({
173
+ content: "All done.",
174
+ stopReason: "end_turn",
175
+ });
176
+ expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
177
+ });
178
+
179
+ it("renders no notice when stopReason is absent (backcompat)", () => {
180
+ const bubble = renderWithStopReason({ content: "Hello." });
181
+ expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
182
+ });
183
+
184
+ it("renders no notice for unknown reasons", () => {
185
+ const bubble = renderWithStopReason({
186
+ content: "Hello.",
187
+ stopReason: "unknown",
188
+ });
189
+ expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
190
+ });
191
+
192
+ it("renders the default notice for max_tool_calls", () => {
193
+ const bubble = renderWithStopReason({
194
+ content: "Used a tool.",
195
+ stopReason: "max_tool_calls",
196
+ });
197
+ const notice = bubble.querySelector(".persona-message-stop-reason");
198
+ expect(notice).not.toBeNull();
199
+ expect(notice?.getAttribute("data-stop-reason")).toBe("max_tool_calls");
200
+ expect(notice?.textContent).toBe(getDefaultStopReasonNoticeCopy("max_tool_calls"));
201
+ });
202
+
203
+ it("renders the default notice for length", () => {
204
+ const bubble = renderWithStopReason({
205
+ content: "Long answer cut off.",
206
+ stopReason: "length",
207
+ });
208
+ const notice = bubble.querySelector(".persona-message-stop-reason");
209
+ expect(notice?.getAttribute("data-stop-reason")).toBe("length");
210
+ expect(notice?.textContent).toBe(getDefaultStopReasonNoticeCopy("length"));
211
+ });
212
+
213
+ it("renders the default notice for content_filter", () => {
214
+ const bubble = renderWithStopReason({
215
+ content: "Filtered.",
216
+ stopReason: "content_filter",
217
+ });
218
+ const notice = bubble.querySelector(".persona-message-stop-reason");
219
+ expect(notice?.getAttribute("data-stop-reason")).toBe("content_filter");
220
+ });
221
+
222
+ it("renders the default notice for error", () => {
223
+ const bubble = renderWithStopReason({
224
+ content: "Provider blew up.",
225
+ stopReason: "error",
226
+ });
227
+ const notice = bubble.querySelector(".persona-message-stop-reason");
228
+ expect(notice?.getAttribute("data-stop-reason")).toBe("error");
229
+ });
230
+
231
+ it("applies copy overrides from widgetConfig.copy.stopReasonNotice", () => {
232
+ const bubble = renderWithStopReason(
233
+ { content: "x", stopReason: "max_tool_calls" },
234
+ { copy: { stopReasonNotice: { ["max_tool_calls" as const]: "Custom copy." } } }
235
+ );
236
+ expect(bubble.querySelector(".persona-message-stop-reason")?.textContent).toBe(
237
+ "Custom copy."
238
+ );
239
+ });
240
+
241
+ it("hides the empty content div when content is empty + max_tool_calls", () => {
242
+ // Regression: the empty-bubble symptom the upstream Runtype fix targets.
243
+ // With no content and max_tool_calls, the notice carries the bubble alone;
244
+ // the empty content div must be hidden so we don't render whitespace
245
+ // above the notice.
246
+ const bubble = renderWithStopReason({
247
+ content: "",
248
+ stopReason: "max_tool_calls",
249
+ });
250
+ const contentDiv = bubble.querySelector(".persona-message-content") as HTMLElement | null;
251
+ expect(contentDiv).not.toBeNull();
252
+ expect(contentDiv!.style.display).toBe("none");
253
+ const notice = bubble.querySelector(".persona-message-stop-reason");
254
+ expect(notice).not.toBeNull();
255
+ expect(notice?.getAttribute("data-stop-reason")).toBe("max_tool_calls");
256
+ });
257
+
258
+ it("does not render notice while message is still streaming", () => {
259
+ const bubble = renderWithStopReason({
260
+ content: "partial",
261
+ stopReason: "max_tool_calls",
262
+ streaming: true,
263
+ });
264
+ expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
265
+ });
266
+
267
+ it("does not render notice on user messages", () => {
268
+ const bubble = renderWithStopReason({
269
+ role: "user",
270
+ content: "user msg",
271
+ // stopReason on a user message is nonsense, but guard against it
272
+ stopReason: "max_tool_calls",
273
+ });
274
+ expect(bubble.querySelector(".persona-message-stop-reason")).toBeNull();
275
+ });
276
+ });
@@ -7,10 +7,85 @@ import {
7
7
  AgentWidgetMessageActionsConfig,
8
8
  AgentWidgetMessageFeedback,
9
9
  LoadingIndicatorRenderContext,
10
- ImageContentPart
10
+ ImageContentPart,
11
+ StopReasonKind
11
12
  } from "../types";
12
13
  import { createIconButton } from "../utils/buttons";
13
14
  import { IMAGE_ONLY_MESSAGE_FALLBACK_TEXT } from "../utils/content";
15
+ import {
16
+ applyStreamBuffer,
17
+ createSkeletonPlaceholder,
18
+ createStreamCaret,
19
+ resolveStreamAnimation,
20
+ resolveStreamAnimationPlugin,
21
+ wrapStreamAnimation,
22
+ } from "../utils/stream-animation";
23
+
24
+ /**
25
+ * Default copy for the inline notice rendered when a turn ends with a
26
+ * non-natural stop reason. Deployers override per-reason via
27
+ * `config.copy.stopReasonNotice`. Returns `null` for natural completions
28
+ * (`end_turn`) and uninformative reasons (`unknown`) — those never render
29
+ * an affordance.
30
+ */
31
+ export const getDefaultStopReasonNoticeCopy = (
32
+ stopReason: StopReasonKind
33
+ ): string | null => {
34
+ switch (stopReason) {
35
+ case "max_tool_calls":
36
+ return "Stopped after calling a tool. Send a follow-up to continue.";
37
+ case "length":
38
+ return "Response cut off as max tokens reached. Ask for more to continue.";
39
+ case "content_filter":
40
+ return "The provider filtered this response.";
41
+ case "error":
42
+ return "Something went wrong generating this response.";
43
+ case "end_turn":
44
+ case "unknown":
45
+ default:
46
+ return null;
47
+ }
48
+ };
49
+
50
+ /**
51
+ * Resolve the notice text for a stop reason, applying user overrides on
52
+ * top of the built-in defaults. Returns `null` when the reason does not
53
+ * warrant a notice or when the resolved string is empty (deployers can
54
+ * suppress per-reason by setting an empty override).
55
+ */
56
+ export const resolveStopReasonNoticeText = (
57
+ stopReason: StopReasonKind | undefined,
58
+ overrides?: Partial<Record<StopReasonKind, string>>
59
+ ): string | null => {
60
+ if (!stopReason) return null;
61
+ const fallback = getDefaultStopReasonNoticeCopy(stopReason);
62
+ // Reasons without a default (end_turn, unknown) never render — overrides
63
+ // for those keys are intentionally ignored.
64
+ if (fallback === null) return null;
65
+ const override = overrides?.[stopReason];
66
+ const text = override !== undefined ? override : fallback;
67
+ if (!text) return null;
68
+ return text;
69
+ };
70
+
71
+ /**
72
+ * Build the inline notice element rendered on assistant bubbles whose
73
+ * turn ended with `max_tool_calls`, `length`, `content_filter`, or `error`.
74
+ */
75
+ const createStopReasonNotice = (
76
+ stopReason: StopReasonKind,
77
+ text: string
78
+ ): HTMLElement => {
79
+ const notice = createElement(
80
+ "div",
81
+ "persona-message-stop-reason persona-text-xs persona-mt-2 persona-italic"
82
+ );
83
+ notice.setAttribute("data-stop-reason", stopReason);
84
+ notice.setAttribute("role", "note");
85
+ notice.style.opacity = "0.75";
86
+ notice.textContent = text;
87
+ return notice;
88
+ };
14
89
 
15
90
  /** Validate that an image src URL uses a safe scheme (blocks javascript: and SVG data URIs). */
16
91
  export const isSafeImageSrc = (src: string): boolean => {
@@ -507,24 +582,107 @@ export const createStandardBubble = (
507
582
  imageParts.length > 0 && messageContentText === IMAGE_ONLY_MESSAGE_FALLBACK_TEXT;
508
583
  const shouldHideTextUntilPreviewFails = isImageOnlyFallbackMessage;
509
584
 
585
+ const streamAnimation = resolveStreamAnimation(
586
+ options?.widgetConfig?.features?.streamAnimation
587
+ );
588
+ const streamPluginOverrides =
589
+ options?.widgetConfig?.features?.streamAnimation?.plugins;
590
+ const streamPlugin =
591
+ message.role === "assistant" && streamAnimation.type !== "none"
592
+ ? resolveStreamAnimationPlugin(streamAnimation.type, streamPluginOverrides)
593
+ : null;
594
+ // Stay in "streaming-animated" mode while the plugin reports in-flight
595
+ // work for this message — e.g. glyph-cycle's tick loops still walking
596
+ // through the tail after the last token arrived. Without this, the final
597
+ // non-animated render rips out the cycling spans mid-animation.
598
+ const pluginStillAnimating =
599
+ message.role === "assistant" &&
600
+ streamPlugin?.isAnimating?.(message) === true;
601
+ const streamAnimationActive =
602
+ message.role === "assistant" &&
603
+ streamPlugin !== null &&
604
+ (Boolean(message.streaming) || pluginStillAnimating);
605
+
606
+ if (streamAnimationActive && streamPlugin?.bubbleClass) {
607
+ bubble.classList.add(streamPlugin.bubbleClass);
608
+ }
609
+
510
610
  // Add message content
511
611
  const contentDiv = document.createElement("div");
512
612
  contentDiv.classList.add("persona-message-content");
613
+
614
+ if (streamAnimationActive && streamPlugin) {
615
+ if (streamPlugin.containerClass) {
616
+ contentDiv.classList.add(streamPlugin.containerClass);
617
+ }
618
+ contentDiv.style.setProperty("--persona-stream-step", `${streamAnimation.speed}ms`);
619
+ contentDiv.style.setProperty("--persona-stream-duration", `${streamAnimation.duration}ms`);
620
+ }
621
+
622
+ const bufferedContent = streamAnimationActive
623
+ ? applyStreamBuffer(
624
+ message.content ?? "",
625
+ streamAnimation.buffer,
626
+ streamPlugin,
627
+ message,
628
+ Boolean(message.streaming)
629
+ )
630
+ : (message.content ?? "");
631
+
513
632
  const transformedContent = transform({
514
- text: message.content,
633
+ text: bufferedContent,
515
634
  message,
516
635
  streaming: Boolean(message.streaming),
517
636
  raw: message.rawContent
518
637
  });
638
+
639
+ let animatedContent = transformedContent;
640
+ if (streamAnimationActive && streamPlugin?.wrap === "char") {
641
+ animatedContent = wrapStreamAnimation(transformedContent, "char", message.id, {
642
+ skipTags: streamPlugin.skipTags,
643
+ });
644
+ } else if (streamAnimationActive && streamPlugin?.wrap === "word") {
645
+ animatedContent = wrapStreamAnimation(transformedContent, "word", message.id, {
646
+ skipTags: streamPlugin.skipTags,
647
+ });
648
+ }
649
+
519
650
  let textContentDiv: HTMLElement | null = null;
520
651
 
521
652
  if (shouldHideTextUntilPreviewFails) {
522
653
  textContentDiv = document.createElement("div");
523
- textContentDiv.innerHTML = transformedContent;
654
+ textContentDiv.innerHTML = animatedContent;
524
655
  textContentDiv.style.display = "none";
525
656
  contentDiv.appendChild(textContentDiv);
526
657
  } else {
527
- contentDiv.innerHTML = transformedContent;
658
+ contentDiv.innerHTML = animatedContent;
659
+ }
660
+
661
+ if (
662
+ streamAnimationActive &&
663
+ streamPlugin?.useCaret &&
664
+ !shouldHideTextUntilPreviewFails &&
665
+ messageContentText
666
+ ) {
667
+ const caret = createStreamCaret();
668
+ // Caret must sit on the same line as the final char. Markdown wraps text
669
+ // in block elements (<p>, <li>, <pre>), so appending to contentDiv would
670
+ // drop the caret onto a fresh line. Tuck it after the last char/word span,
671
+ // or fall back to the last block when no spans exist yet.
672
+ const spans = contentDiv.querySelectorAll(
673
+ ".persona-stream-char, .persona-stream-word"
674
+ );
675
+ const lastSpan = spans[spans.length - 1];
676
+ if (lastSpan?.parentNode) {
677
+ lastSpan.parentNode.insertBefore(caret, lastSpan.nextSibling);
678
+ } else {
679
+ const lastChild = contentDiv.lastElementChild;
680
+ if (lastChild) {
681
+ lastChild.appendChild(caret);
682
+ } else {
683
+ contentDiv.appendChild(caret);
684
+ }
685
+ }
528
686
  }
529
687
 
530
688
  // Add inline timestamp if configured
@@ -561,19 +719,56 @@ export const createStandardBubble = (
561
719
  bubble.appendChild(timestamp);
562
720
  }
563
721
 
564
- // Add typing indicator if this is a streaming assistant message
722
+ // Resolve the stop-reason notice (if any). Only assistant messages can
723
+ // carry a stop reason worth surfacing.
724
+ const stopReasonNoticeText =
725
+ message.role === "assistant"
726
+ ? resolveStopReasonNoticeText(
727
+ message.stopReason,
728
+ options?.widgetConfig?.copy?.stopReasonNotice
729
+ )
730
+ : null;
731
+
732
+ // Add typing indicator (or skeleton placeholder) for streaming assistant
733
+ // messages. Check the buffered content — a plugin's `bufferContent` may
734
+ // hold back the first N chars (e.g. glyph-cycle waits for 50 chars), during
735
+ // which the bubble would otherwise appear empty.
736
+ //
737
+ // When the `"line"` buffer strategy is paired with the skeleton placeholder,
738
+ // the skeleton trails below any already-revealed content to hint that more
739
+ // lines are on the way. It disappears on stream completion.
565
740
  if (message.streaming && message.role === "assistant") {
566
- if (!message.content || !message.content.trim()) {
567
- // Use custom renderer if provided, otherwise default
568
- const indicator = renderLoadingIndicatorWithFallback(
569
- 'inline',
570
- options?.loadingIndicatorRenderer,
571
- options?.widgetConfig
572
- );
573
- if (indicator) {
574
- bubble.appendChild(indicator);
741
+ const hasVisibleContent = Boolean(bufferedContent && bufferedContent.trim());
742
+ const skeletonEnabled = streamAnimation.placeholder === "skeleton";
743
+ const trailSkeleton =
744
+ skeletonEnabled && streamAnimation.buffer === "line" && hasVisibleContent;
745
+ if (!hasVisibleContent) {
746
+ if (skeletonEnabled) {
747
+ bubble.appendChild(createSkeletonPlaceholder());
748
+ } else {
749
+ const indicator = renderLoadingIndicatorWithFallback(
750
+ 'inline',
751
+ options?.loadingIndicatorRenderer,
752
+ options?.widgetConfig
753
+ );
754
+ if (indicator) {
755
+ bubble.appendChild(indicator);
756
+ }
575
757
  }
758
+ } else if (trailSkeleton) {
759
+ bubble.appendChild(createSkeletonPlaceholder());
760
+ }
761
+ }
762
+
763
+ // Append the stop-reason notice for non-natural completions. When the
764
+ // assistant produced no text (the `max_tool_calls` empty-bubble symptom),
765
+ // hide the empty content div so the notice carries the entire bubble
766
+ // instead of trailing under a blank space.
767
+ if (stopReasonNoticeText && message.stopReason && !message.streaming) {
768
+ if (!messageContentText) {
769
+ contentDiv.style.display = "none";
576
770
  }
771
+ bubble.appendChild(createStopReasonNotice(message.stopReason, stopReasonNoticeText));
577
772
  }
578
773
 
579
774
  // Add message actions for assistant messages (only when not streaming and has content)
@@ -110,6 +110,8 @@ export interface PanelElements {
110
110
  actionsRow: HTMLElement;
111
111
  leftActions: HTMLElement;
112
112
  rightActions: HTMLElement;
113
+ /** Swap the send button between its send and stop appearances. */
114
+ setSendButtonMode: (mode: "send" | "stop") => void;
113
115
  }
114
116
 
115
117
  export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelElements => {
@@ -230,7 +232,8 @@ export const buildPanel = (config?: AgentWidgetConfig, showClose = true): PanelE
230
232
  // Actions row layout elements
231
233
  actionsRow: composerElements.actionsRow,
232
234
  leftActions: composerElements.leftActions,
233
- rightActions: composerElements.rightActions
235
+ rightActions: composerElements.rightActions,
236
+ setSendButtonMode: composerElements.setSendButtonMode
234
237
  };
235
238
  };
236
239
 
package/src/defaults.ts CHANGED
@@ -43,6 +43,12 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
43
43
  agentIconSize: "40px",
44
44
  headerIconSize: "40px",
45
45
  closeButtonSize: "32px",
46
+ // Zero out browser-default <button> padding so the icon gets the full
47
+ // 32x32 content box, matching clearChat.paddingX/Y below. Without this,
48
+ // UA stylesheets add ~1-2px vertical and ~6px horizontal padding that
49
+ // eats into the border-box width and shrinks the rendered icon.
50
+ closeButtonPaddingX: "0px",
51
+ closeButtonPaddingY: "0px",
46
52
  callToActionIconName: "arrow-up-right",
47
53
  callToActionIconText: "",
48
54
  callToActionIconSize: "32px",
@@ -131,6 +137,12 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
131
137
  expandable: true,
132
138
  loadingAnimation: "none",
133
139
  },
140
+ streamAnimation: {
141
+ type: "none",
142
+ placeholder: "none",
143
+ speed: 120,
144
+ duration: 1800,
145
+ },
134
146
  },
135
147
  suggestionChips: [
136
148
  "What can you help me with?",
@@ -245,6 +257,8 @@ export function mergeWithDefaults(
245
257
  const ca = config.features?.artifacts;
246
258
  const dsb = DEFAULT_WIDGET_CONFIG.features?.scrollToBottom;
247
259
  const csb = config.features?.scrollToBottom;
260
+ const dsa = DEFAULT_WIDGET_CONFIG.features?.streamAnimation;
261
+ const csa = config.features?.streamAnimation;
248
262
  const mergedArtifacts =
249
263
  da === undefined && ca === undefined
250
264
  ? undefined
@@ -263,11 +277,19 @@ export function mergeWithDefaults(
263
277
  ...dsb,
264
278
  ...csb,
265
279
  };
280
+ const mergedStreamAnimation =
281
+ dsa === undefined && csa === undefined
282
+ ? undefined
283
+ : {
284
+ ...dsa,
285
+ ...csa,
286
+ };
266
287
  return {
267
288
  ...DEFAULT_WIDGET_CONFIG.features,
268
289
  ...config.features,
269
290
  ...(mergedScrollToBottom !== undefined ? { scrollToBottom: mergedScrollToBottom } : {}),
270
291
  ...(mergedArtifacts !== undefined ? { artifacts: mergedArtifacts } : {}),
292
+ ...(mergedStreamAnimation !== undefined ? { streamAnimation: mergedStreamAnimation } : {}),
271
293
  };
272
294
  })(),
273
295
  suggestionChips: config.suggestionChips ?? DEFAULT_WIDGET_CONFIG.suggestionChips,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * IIFE entry point — bundled for `<script>` tag consumers.
3
+ *
4
+ * This file re-exports everything from the main entry AND side-imports all
5
+ * built-in subpath animations so they register automatically. Script-tag
6
+ * users who include the global build don't need extra script tags or
7
+ * registration calls — setting `features.streamAnimation.type` to any
8
+ * built-in name just works.
9
+ *
10
+ * npm consumers continue to import from the main entry (`import ... from
11
+ * "@runtypelabs/persona"`) — those animations stay in their subpath
12
+ * modules so bundlers can tree-shake them.
13
+ */
14
+
15
+ // Re-export the full public API.
16
+ export * from "./index";
17
+
18
+ // Side-import the remaining subpath animations so they're available to
19
+ // script-tag consumers without an explicit import. (`letter-rise` and
20
+ // `word-fade` are core built-ins and register automatically.)
21
+ import "./animations/wipe";
22
+ import "./animations/glyph-cycle";
23
+
24
+ // Expose plugin-registration helpers on the global so custom animations
25
+ // can be registered from inline `<script>` blocks or third-party CDN scripts.
26
+ export {
27
+ registerStreamAnimationPlugin,
28
+ unregisterStreamAnimationPlugin,
29
+ listRegisteredStreamAnimations,
30
+ } from "./utils/stream-animation";
31
+ export type { StreamAnimationPlugin, StreamAnimationContext } from "./types";
package/src/index.ts CHANGED
@@ -178,6 +178,24 @@ export type { AgentWidgetInitHandle };
178
178
  export type { AgentWidgetPlugin } from "./plugins/types";
179
179
  export { pluginRegistry } from "./plugins/registry";
180
180
 
181
+ // Stream animation plugin API — lets consumers register custom animations
182
+ // that match the built-in surface (typewriter, pop-bubble) and subpath
183
+ // modules (letter-rise, word-fade, wipe, glyph-cycle).
184
+ export {
185
+ registerStreamAnimationPlugin,
186
+ unregisterStreamAnimationPlugin,
187
+ listRegisteredStreamAnimations,
188
+ } from "./utils/stream-animation";
189
+ export type {
190
+ StreamAnimationPlugin,
191
+ StreamAnimationContext,
192
+ AgentWidgetStreamAnimationBuffer,
193
+ AgentWidgetStreamAnimationBuiltinType,
194
+ AgentWidgetStreamAnimationType,
195
+ AgentWidgetStreamAnimationFeature,
196
+ AgentWidgetStreamAnimationPlaceholder,
197
+ } from "./types";
198
+
181
199
  // Dropdown utility exports
182
200
  export { createDropdownMenu } from "./utils/dropdown";
183
201
  export type { DropdownMenuItem, CreateDropdownOptions, DropdownMenuHandle } from "./utils/dropdown";
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
2
  import { AgentWidgetSession, AgentWidgetSessionStatus } from './session';
3
3
  import { AgentWidgetMessage } from './types';
4
4
 
@@ -245,3 +245,95 @@ describe('AgentWidgetSession - Message Injection', () => {
245
245
  });
246
246
  });
247
247
  });
248
+
249
+ describe('AgentWidgetSession - cancel()', () => {
250
+ const originalFetch = global.fetch;
251
+
252
+ afterEach(() => {
253
+ global.fetch = originalFetch;
254
+ vi.restoreAllMocks();
255
+ });
256
+
257
+ it('aborts the in-flight dispatch and flips streaming/status back to idle', async () => {
258
+ let capturedSignal: AbortSignal | null = null;
259
+ // Fetch returns a promise that only settles when the AbortSignal fires —
260
+ // modeling a dispatch that's still receiving SSE tokens.
261
+ global.fetch = vi.fn().mockImplementation((_url: string, options: any) => {
262
+ capturedSignal = options.signal as AbortSignal;
263
+ return new Promise((_resolve, reject) => {
264
+ options.signal?.addEventListener('abort', () => {
265
+ const err = new Error('aborted');
266
+ err.name = 'AbortError';
267
+ reject(err);
268
+ });
269
+ });
270
+ });
271
+
272
+ let streaming = false;
273
+ let status: AgentWidgetSessionStatus = 'idle';
274
+ const session = new AgentWidgetSession(
275
+ { apiUrl: 'http://example.invalid/chat' },
276
+ {
277
+ onMessagesChanged: () => {},
278
+ onStatusChanged: (s) => { status = s; },
279
+ onStreamingChanged: (s) => { streaming = s; }
280
+ }
281
+ );
282
+
283
+ // Kick off the dispatch but don't await — we want it in-flight when we cancel.
284
+ const dispatchPromise = session.sendMessage('Hello');
285
+ // Let the session set up the AbortController and call fetch.
286
+ await Promise.resolve();
287
+ await Promise.resolve();
288
+
289
+ expect(streaming).toBe(true);
290
+ expect(session.isStreaming()).toBe(true);
291
+ expect(capturedSignal).not.toBeNull();
292
+ expect(capturedSignal!.aborted).toBe(false);
293
+
294
+ session.cancel();
295
+
296
+ expect(session.isStreaming()).toBe(false);
297
+ expect(streaming).toBe(false);
298
+ expect(status).toBe('idle');
299
+ expect(capturedSignal!.aborted).toBe(true);
300
+
301
+ // Drain the dispatch promise so the test doesn't leak a rejection.
302
+ await dispatchPromise;
303
+ });
304
+
305
+ it('is a no-op when not streaming', () => {
306
+ const session = new AgentWidgetSession(
307
+ { apiUrl: 'http://example.invalid/chat' },
308
+ {
309
+ onMessagesChanged: () => {},
310
+ onStatusChanged: () => {},
311
+ onStreamingChanged: () => {}
312
+ }
313
+ );
314
+
315
+ expect(session.isStreaming()).toBe(false);
316
+ expect(() => session.cancel()).not.toThrow();
317
+ expect(session.isStreaming()).toBe(false);
318
+ expect(session.getStatus()).toBe('idle');
319
+ });
320
+
321
+ it('stops in-progress audio playback (TTS + voice provider) on cancel', () => {
322
+ const session = new AgentWidgetSession(
323
+ { apiUrl: 'http://example.invalid/chat' },
324
+ {
325
+ onMessagesChanged: () => {},
326
+ onStatusChanged: () => {},
327
+ onStreamingChanged: () => {}
328
+ }
329
+ );
330
+
331
+ const stopSpeakingSpy = vi.spyOn(session, 'stopSpeaking');
332
+ const stopVoicePlaybackSpy = vi.spyOn(session, 'stopVoicePlayback');
333
+
334
+ session.cancel();
335
+
336
+ expect(stopSpeakingSpy).toHaveBeenCalledTimes(1);
337
+ expect(stopVoicePlaybackSpy).toHaveBeenCalledTimes(1);
338
+ });
339
+ });