@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
package/src/session.ts CHANGED
@@ -971,6 +971,11 @@ export class AgentWidgetSession {
971
971
  public cancel() {
972
972
  this.abortController?.abort();
973
973
  this.abortController = null;
974
+ // Stop any in-progress audio too — when the user hits "stop", they want
975
+ // the assistant to actually stop talking, not just stop generating tokens.
976
+ // Both helpers are safe no-ops when audio isn't configured.
977
+ this.stopSpeaking();
978
+ this.stopVoicePlayback();
974
979
  this.setStreaming(false);
975
980
  this.setStatus("idle");
976
981
  }
@@ -2824,3 +2824,136 @@
2824
2824
  transform: translateX(0);
2825
2825
  }
2826
2826
  }
2827
+
2828
+ /* ============================================================
2829
+ Stream animations — reveal effects for assistant message text
2830
+ while streaming. Opt-in via `features.streamAnimation.type`.
2831
+ Units are staggered via `--char-index` / `--word-index`
2832
+ (set inline on each wrapper span). Timing is configured via
2833
+ `--persona-stream-step` and `--persona-stream-duration` on
2834
+ the `.persona-message-content` container.
2835
+ ============================================================ */
2836
+
2837
+ /* Per-char/per-word spans need to be inline-block so `transform` works for
2838
+ the rise/fade animations. Each span animates from the moment it is first
2839
+ added to the DOM — streaming itself provides the visible stagger, so the
2840
+ CSS animation has no per-index delay. An index-based delay would compound
2841
+ with the stream's arrival cadence and leave later chars permanently hidden. */
2842
+ [data-persona-root] .persona-stream-char,
2843
+ [data-persona-root] .persona-stream-word {
2844
+ display: inline-block;
2845
+ will-change: opacity, transform, filter;
2846
+ }
2847
+
2848
+ /* Group chars belonging to the same word so the browser treats the word as a
2849
+ single break unit. Without this, every inline-block char introduces a break
2850
+ opportunity and words get split mid-letter during streaming, then snap back
2851
+ when the final content replaces the wrapped spans. */
2852
+ [data-persona-root] .persona-stream-word-group {
2853
+ white-space: nowrap;
2854
+ }
2855
+
2856
+ /* ---------- typewriter: fade per arriving char + blinking caret ---------- */
2857
+ @keyframes persona-stream-typewriter-in {
2858
+ from { opacity: 0; }
2859
+ to { opacity: 1; }
2860
+ }
2861
+ [data-persona-root] .persona-stream-typewriter .persona-stream-char {
2862
+ animation: persona-stream-typewriter-in var(--persona-stream-step, 120ms) ease-out both;
2863
+ }
2864
+
2865
+ /* ---------- letter-rise: per-char translateY + fade ---------- */
2866
+ @keyframes persona-stream-letter-rise {
2867
+ from { opacity: 0; transform: translateY(8px); }
2868
+ to { opacity: 1; transform: translateY(0); }
2869
+ }
2870
+ [data-persona-root] .persona-stream-letter-rise .persona-stream-char {
2871
+ animation: persona-stream-letter-rise calc(var(--persona-stream-step, 120ms) * 2)
2872
+ ease-out both;
2873
+ }
2874
+
2875
+ /* ---------- word-fade: per-word blur + translateY fade-in ---------- */
2876
+ @keyframes persona-stream-word-fade {
2877
+ from { opacity: 0; filter: blur(4px); transform: translateY(3px); }
2878
+ to { opacity: 1; filter: blur(0); transform: translateY(0); }
2879
+ }
2880
+ [data-persona-root] .persona-stream-word-fade .persona-stream-word {
2881
+ animation: persona-stream-word-fade calc(var(--persona-stream-step, 120ms) * 3)
2882
+ ease-out both;
2883
+ }
2884
+
2885
+ /* The following animations live in subpath plugin modules — their CSS is
2886
+ injected by the plugin when activated, not by the core stylesheet:
2887
+ - `wipe` → @runtypelabs/persona/animations/wipe
2888
+ - `glyph-cycle` → @runtypelabs/persona/animations/glyph-cycle */
2889
+
2890
+ /* ---------- pop-bubble: scale + opacity entrance on the bubble ---------- */
2891
+ @keyframes persona-stream-pop-in {
2892
+ 0% { transform: scale(0.6); opacity: 0; }
2893
+ 100% { transform: scale(1); opacity: 1; }
2894
+ }
2895
+ [data-persona-root] .persona-stream-pop {
2896
+ transform-origin: bottom left;
2897
+ animation: persona-stream-pop-in 400ms cubic-bezier(0.2, 0.9, 0.3, 1.4) both;
2898
+ }
2899
+
2900
+ /* ---------- caret used by typewriter ---------- */
2901
+ @keyframes persona-stream-blink {
2902
+ 0%, 50% { opacity: 1; }
2903
+ 50.01%, 100% { opacity: 0; }
2904
+ }
2905
+ [data-persona-root] .persona-stream-caret {
2906
+ display: inline-block;
2907
+ width: 2px;
2908
+ height: 1em;
2909
+ margin-left: 1px;
2910
+ vertical-align: -2px;
2911
+ background: currentColor;
2912
+ animation: persona-stream-blink 1s steps(1) infinite;
2913
+ }
2914
+
2915
+ /* ---------- skeleton placeholder (pre-first-token) ---------- */
2916
+ @keyframes persona-stream-skeleton-shimmer {
2917
+ 0% { background-position: 200% 0; }
2918
+ 100% { background-position: -200% 0; }
2919
+ }
2920
+ [data-persona-root] .persona-stream-skeleton {
2921
+ padding: 2px 0;
2922
+ /* The assistant bubble sizes to content. Give the skeleton an intrinsic
2923
+ width so the bubble expands; the bubble's own `max-width: 85%` clamps
2924
+ the upper bound. */
2925
+ width: 260px;
2926
+ max-width: 100%;
2927
+ }
2928
+ [data-persona-root] .persona-stream-skeleton-line {
2929
+ width: 100%;
2930
+ height: 10px;
2931
+ border-radius: 3px;
2932
+ background: linear-gradient(
2933
+ 90deg,
2934
+ color-mix(in srgb, currentColor 12%, transparent) 0%,
2935
+ color-mix(in srgb, currentColor 22%, transparent) 50%,
2936
+ color-mix(in srgb, currentColor 12%, transparent) 100%
2937
+ );
2938
+ background-size: 200% 100%;
2939
+ animation: persona-stream-skeleton-shimmer 1.4s linear infinite;
2940
+ }
2941
+
2942
+ /* ---------- reduced-motion: disable per-unit and container animations ---------- */
2943
+ @media (prefers-reduced-motion: reduce) {
2944
+ [data-persona-root] .persona-stream-typewriter .persona-stream-char,
2945
+ [data-persona-root] .persona-stream-letter-rise .persona-stream-char,
2946
+ [data-persona-root] .persona-stream-word-fade .persona-stream-word,
2947
+ [data-persona-root] .persona-stream-pop,
2948
+ [data-persona-root] .persona-stream-caret,
2949
+ [data-persona-root] .persona-stream-skeleton-line {
2950
+ animation: none !important;
2951
+ opacity: 1 !important;
2952
+ filter: none !important;
2953
+ transform: none !important;
2954
+ color: inherit !important;
2955
+ background: none !important;
2956
+ -webkit-background-clip: border-box !important;
2957
+ background-clip: border-box !important;
2958
+ }
2959
+ }
@@ -0,0 +1,11 @@
1
+ /** @runtypelabs/persona/testing — Helpers for mocking SSE streams in demos, previews, and tests. */
2
+
3
+ export {
4
+ createMockSSEStream,
5
+ createMockSSEResponse,
6
+ buildAssistantTurnFrames,
7
+ type MockSSEFrame,
8
+ type CreateMockSSEStreamOptions,
9
+ type MockSSEResponseOptions,
10
+ type AssistantTurnFramesOptions,
11
+ } from "./mock-stream";
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ buildAssistantTurnFrames,
5
+ createMockSSEResponse,
6
+ createMockSSEStream,
7
+ } from "./mock-stream";
8
+
9
+ async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
10
+ const reader = stream.getReader();
11
+ const decoder = new TextDecoder();
12
+ let out = "";
13
+ for (;;) {
14
+ const { done, value } = await reader.read();
15
+ if (done) break;
16
+ out += decoder.decode(value);
17
+ }
18
+ return out;
19
+ }
20
+
21
+ describe("createMockSSEStream", () => {
22
+ it("emits bare `data:` frames by default", async () => {
23
+ const frames = [
24
+ { type: "agent_turn_start", executionId: "e-1", turnId: "t-1" },
25
+ { type: "agent_turn_delta", executionId: "e-1", turnId: "t-1", delta: "hi" },
26
+ { type: "agent_turn_complete", executionId: "e-1", turnId: "t-1" },
27
+ ];
28
+ const text = await readAll(createMockSSEStream(frames, { delayMs: 0 }));
29
+
30
+ expect(text).not.toContain("event:");
31
+ expect(text.split("\n\n").filter(Boolean)).toHaveLength(3);
32
+ expect(text).toContain('"type":"agent_turn_delta"');
33
+ expect(text).toContain('"delta":"hi"');
34
+ });
35
+
36
+ it("emits named event frames when eventName is set", async () => {
37
+ const text = await readAll(
38
+ createMockSSEStream([{ type: "ping" }], { delayMs: 0, eventName: "message" })
39
+ );
40
+ expect(text.startsWith("event: message\n")).toBe(true);
41
+ });
42
+ });
43
+
44
+ describe("buildAssistantTurnFrames", () => {
45
+ it("chunks text into delta frames bracketed by start/complete", () => {
46
+ const frames = buildAssistantTurnFrames({
47
+ executionId: "exec-1",
48
+ turnId: "turn-1",
49
+ text: "abcdefghij",
50
+ chunkSize: 4,
51
+ });
52
+
53
+ expect(frames[0]).toEqual({ type: "agent_turn_start", executionId: "exec-1", turnId: "turn-1" });
54
+ expect(frames[frames.length - 1]).toEqual({
55
+ type: "agent_turn_complete",
56
+ executionId: "exec-1",
57
+ turnId: "turn-1",
58
+ });
59
+
60
+ const deltas = frames.filter((f) => f.type === "agent_turn_delta");
61
+ expect(deltas.map((f) => f.delta)).toEqual(["abcd", "efgh", "ij"]);
62
+ expect(deltas.every((f) => f.executionId === "exec-1" && f.turnId === "turn-1")).toBe(true);
63
+ });
64
+
65
+ it("defaults turnId and chunkSize", () => {
66
+ const frames = buildAssistantTurnFrames({ executionId: "exec-2", text: "hello" });
67
+ expect(frames[0].turnId).toBe("turn-1");
68
+ const deltaCount = frames.filter((f) => f.type === "agent_turn_delta").length;
69
+ expect(deltaCount).toBeGreaterThanOrEqual(1);
70
+ });
71
+ });
72
+
73
+ describe("createMockSSEResponse", () => {
74
+ it("wraps the stream in a text/event-stream Response", async () => {
75
+ const res = createMockSSEResponse([{ type: "ping" }], { delayMs: 0 });
76
+ expect(res.status).toBe(200);
77
+ expect(res.headers.get("Content-Type")).toBe("text/event-stream");
78
+ expect(await res.text()).toContain('"type":"ping"');
79
+ });
80
+ });
@@ -0,0 +1,94 @@
1
+ /** Shared helpers for mocking SSE streams in demos, previews, and tests. */
2
+
3
+ export interface MockSSEFrame {
4
+ type: string;
5
+ [key: string]: unknown;
6
+ }
7
+
8
+ export interface CreateMockSSEStreamOptions {
9
+ /** Delay in ms between emitted frames. Default: 100. */
10
+ delayMs?: number;
11
+ /**
12
+ * Named event name. When set, each frame is emitted as `event: <name>\ndata: ...\n\n`.
13
+ * Omit for bare `data: ...\n\n` form (both are valid SSE and the widget parser accepts either).
14
+ */
15
+ eventName?: string;
16
+ }
17
+
18
+ const encoder = new TextEncoder();
19
+
20
+ export function createMockSSEStream(
21
+ frames: ReadonlyArray<MockSSEFrame>,
22
+ options?: CreateMockSSEStreamOptions
23
+ ): ReadableStream<Uint8Array> {
24
+ const delayMs = options?.delayMs ?? 100;
25
+ const prefix = options?.eventName ? `event: ${options.eventName}\n` : "";
26
+ let index = 0;
27
+
28
+ return new ReadableStream<Uint8Array>({
29
+ async pull(controller) {
30
+ if (index >= frames.length) {
31
+ controller.close();
32
+ return;
33
+ }
34
+ await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
35
+ const payload = JSON.stringify(frames[index]);
36
+ controller.enqueue(encoder.encode(`${prefix}data: ${payload}\n\n`));
37
+ index += 1;
38
+ },
39
+ });
40
+ }
41
+
42
+ export interface AssistantTurnFramesOptions {
43
+ /** Execution id shared across the turn's frames. */
44
+ executionId: string;
45
+ /** Turn id. Default: `turn-1`. */
46
+ turnId?: string;
47
+ /** Assistant text content to stream. */
48
+ text: string;
49
+ /** Approximate characters per `agent_turn_delta` frame. Default: 32. */
50
+ chunkSize?: number;
51
+ }
52
+
53
+ /**
54
+ * Builds the standard `agent_turn_start` → many `agent_turn_delta` → `agent_turn_complete`
55
+ * frame sequence for simulating a streaming assistant reply. The frames drive the same
56
+ * client pipeline as real SSE, so stream animations (typewriter, word-fade, etc.) engage.
57
+ */
58
+ export function buildAssistantTurnFrames(options: AssistantTurnFramesOptions): MockSSEFrame[] {
59
+ const { executionId, text } = options;
60
+ const turnId = options.turnId ?? "turn-1";
61
+ const chunkSize = Math.max(1, options.chunkSize ?? 32);
62
+
63
+ const frames: MockSSEFrame[] = [{ type: "agent_turn_start", executionId, turnId }];
64
+ for (let i = 0; i < text.length; i += chunkSize) {
65
+ frames.push({
66
+ type: "agent_turn_delta",
67
+ executionId,
68
+ turnId,
69
+ delta: text.slice(i, i + chunkSize),
70
+ });
71
+ }
72
+ frames.push({ type: "agent_turn_complete", executionId, turnId });
73
+ return frames;
74
+ }
75
+
76
+ export interface MockSSEResponseOptions extends CreateMockSSEStreamOptions {
77
+ status?: number;
78
+ headers?: Record<string, string>;
79
+ }
80
+
81
+ /** Convenience wrapper: returns a `Response` ready to hand back from a `customFetch` implementation. */
82
+ export function createMockSSEResponse(
83
+ frames: ReadonlyArray<MockSSEFrame>,
84
+ options?: MockSSEResponseOptions
85
+ ): Response {
86
+ const stream = createMockSSEStream(frames, options);
87
+ return new Response(stream, {
88
+ status: options?.status ?? 200,
89
+ headers: {
90
+ "Content-Type": "text/event-stream",
91
+ ...options?.headers,
92
+ },
93
+ });
94
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Entry point for @runtypelabs/persona/testing */
2
+ export * from "./testing/index";
@@ -84,6 +84,8 @@ export {
84
84
  getPreviewTranscriptPresetLabel,
85
85
  createPreviewTranscriptEntry,
86
86
  appendPreviewTranscriptEntry,
87
+ presetStreamsText,
88
+ buildTranscriptStreamFrames,
87
89
  createPreviewMessages,
88
90
  applySceneConfig,
89
91
  buildPreviewConfig,
@@ -94,6 +96,8 @@ export type {
94
96
  PreviewTranscriptEntryPreset,
95
97
  PreviewShellPalette,
96
98
  PreviewConfigOptions,
99
+ TranscriptStreamFrame,
100
+ BuildTranscriptStreamFramesOptions,
97
101
  } from './preview-utils';
98
102
 
99
103
  // Role mappings (Interface Roles editor)
@@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  appendPreviewTranscriptEntry,
5
5
  buildPreviewConfig,
6
+ buildTranscriptStreamFrames,
6
7
  createPreviewTranscriptEntry,
8
+ presetStreamsText,
7
9
  } from "./preview-utils";
8
10
 
9
11
  describe("theme editor preview demo data", () => {
@@ -55,4 +57,62 @@ describe("theme editor preview demo data", () => {
55
57
  expect(updated[0].variant).toBe("tool");
56
58
  expect(updated[1].variant).toBe("reasoning");
57
59
  });
60
+
61
+ it("creates assistant code-block, table, and image presets with markdown content", () => {
62
+ const code = createPreviewTranscriptEntry("assistant-code-block", 1);
63
+ const table = createPreviewTranscriptEntry("assistant-markdown-table", 2);
64
+ const image = createPreviewTranscriptEntry("assistant-image", 3);
65
+
66
+ for (const message of [code, table, image]) {
67
+ expect(message.role).toBe("assistant");
68
+ expect(message.variant).toBeUndefined();
69
+ expect(typeof message.content).toBe("string");
70
+ }
71
+
72
+ expect(code.content).toContain("```ts");
73
+ expect(table.content).toContain("| Preset");
74
+ expect(image.content).toContain("![");
75
+ });
76
+
77
+ it("flags assistant text presets as streaming and non-text presets as not", () => {
78
+ expect(presetStreamsText("assistant-message")).toBe(true);
79
+ expect(presetStreamsText("assistant-code-block")).toBe(true);
80
+ expect(presetStreamsText("assistant-markdown-table")).toBe(true);
81
+ expect(presetStreamsText("assistant-image")).toBe(true);
82
+ expect(presetStreamsText("user-message")).toBe(false);
83
+ expect(presetStreamsText("reasoning-streaming")).toBe(false);
84
+ expect(presetStreamsText("tool-running")).toBe(false);
85
+ });
86
+
87
+ it("builds a single done frame for non-streaming presets", () => {
88
+ const frames = buildTranscriptStreamFrames("user-message", 0);
89
+ expect(frames).toHaveLength(1);
90
+ expect(frames[0].done).toBe(true);
91
+ expect(frames[0].delayMs).toBe(0);
92
+ expect(frames[0].message.role).toBe("user");
93
+ });
94
+
95
+ it("builds progressive snapshots for assistant text presets ending with streaming:false", () => {
96
+ const frames = buildTranscriptStreamFrames("assistant-message", 0, { chunkSize: 10, delayMs: 5 });
97
+ expect(frames.length).toBeGreaterThan(1);
98
+
99
+ const first = frames[0];
100
+ expect(first.message.content).toBe("");
101
+ expect(first.message.streaming).toBe(true);
102
+ expect(first.delayMs).toBe(0);
103
+ expect(first.done).toBe(false);
104
+
105
+ for (let i = 1; i < frames.length - 1; i += 1) {
106
+ expect(frames[i].message.streaming).toBe(true);
107
+ expect(frames[i].done).toBe(false);
108
+ expect(frames[i].delayMs).toBe(5);
109
+ }
110
+
111
+ const last = frames[frames.length - 1];
112
+ expect(last.message.streaming).toBe(false);
113
+ expect(last.done).toBe(true);
114
+ const completed = createPreviewTranscriptEntry("assistant-message", 0);
115
+ expect(last.message.content).toBe(completed.content);
116
+ expect(last.message.id).toBe(completed.id);
117
+ });
58
118
  });
@@ -200,6 +200,9 @@ export type PreviewScene = 'home' | 'conversation' | 'minimized' | 'artifact';
200
200
  export type PreviewTranscriptEntryPreset =
201
201
  | 'user-message'
202
202
  | 'assistant-message'
203
+ | 'assistant-code-block'
204
+ | 'assistant-markdown-table'
205
+ | 'assistant-image'
203
206
  | 'reasoning-streaming'
204
207
  | 'reasoning-complete'
205
208
  | 'tool-running'
@@ -208,6 +211,9 @@ export type PreviewTranscriptEntryPreset =
208
211
  const PREVIEW_TRANSCRIPT_PRESET_LABELS: Record<PreviewTranscriptEntryPreset, string> = {
209
212
  'user-message': 'User message',
210
213
  'assistant-message': 'Assistant message',
214
+ 'assistant-code-block': 'Assistant — code block',
215
+ 'assistant-markdown-table': 'Assistant — markdown table',
216
+ 'assistant-image': 'Assistant — image',
211
217
  'reasoning-streaming': 'Reasoning (streaming)',
212
218
  'reasoning-complete': 'Reasoning (complete)',
213
219
  'tool-running': 'Tool call (running)',
@@ -240,6 +246,56 @@ export function createPreviewTranscriptEntry(
240
246
  content: 'Absolutely. I can keep going and explain what happens next.',
241
247
  createdAt,
242
248
  };
249
+ case 'assistant-code-block':
250
+ return {
251
+ id: `preview-seq-assistant-code-${suffix}`,
252
+ role: 'assistant',
253
+ content: [
254
+ "Here's how you'd wire up a streaming animation:",
255
+ '',
256
+ '```ts',
257
+ "import { createAgentExperience } from '@runtypelabs/persona';",
258
+ '',
259
+ 'createAgentExperience(el, {',
260
+ ' features: {',
261
+ ' streamAnimation: { type: "letter-rise", speed: 120 },',
262
+ ' },',
263
+ '});',
264
+ '```',
265
+ '',
266
+ 'Swap the `type` value to try the other presets.',
267
+ ].join('\n'),
268
+ createdAt,
269
+ };
270
+ case 'assistant-markdown-table':
271
+ return {
272
+ id: `preview-seq-assistant-table-${suffix}`,
273
+ role: 'assistant',
274
+ content: [
275
+ 'Here are the built-in streaming animations at a glance:',
276
+ '',
277
+ '| Preset | Wrap unit | Best for |',
278
+ '| ------------ | --------- | --------------------------- |',
279
+ '| Typewriter | Character | Classic terminal feel |',
280
+ '| Letter rise | Character | Soft, staggered entrance |',
281
+ '| Word fade | Word | Longer-form assistant replies |',
282
+ '| Pop bubble | Bubble | Short, punchy affirmations |',
283
+ ].join('\n'),
284
+ createdAt,
285
+ };
286
+ case 'assistant-image':
287
+ return {
288
+ id: `preview-seq-assistant-image-${suffix}`,
289
+ role: 'assistant',
290
+ content: [
291
+ "Here's the reference diagram you asked for — let me know if you'd like a different view:",
292
+ '',
293
+ '![Stream animation reference](https://placehold.co/320x200/png?text=Stream+Animation)',
294
+ '',
295
+ 'The gradient shows how per-unit delays stagger across the reply.',
296
+ ].join('\n'),
297
+ createdAt,
298
+ };
243
299
  case 'reasoning-streaming':
244
300
  return {
245
301
  id: `preview-seq-reasoning-stream-${suffix}`,
@@ -312,6 +368,79 @@ export function appendPreviewTranscriptEntry(
312
368
  return [...messages, createPreviewTranscriptEntry(preset, messages.length)];
313
369
  }
314
370
 
371
+ /** Presets whose assistant content should stream in so Stream Animation settings engage. */
372
+ export function presetStreamsText(preset: PreviewTranscriptEntryPreset): boolean {
373
+ return (
374
+ preset === 'assistant-message' ||
375
+ preset === 'assistant-code-block' ||
376
+ preset === 'assistant-markdown-table' ||
377
+ preset === 'assistant-image'
378
+ );
379
+ }
380
+
381
+ export interface TranscriptStreamFrame {
382
+ /** Message to upsert into the session. */
383
+ message: AgentWidgetMessage;
384
+ /** Delay from the previous frame in ms. The first frame uses 0. */
385
+ delayMs: number;
386
+ /** True when this is the final frame (message is no longer streaming). */
387
+ done: boolean;
388
+ }
389
+
390
+ export interface BuildTranscriptStreamFramesOptions {
391
+ /** Characters per progressive chunk. Default: 24. */
392
+ chunkSize?: number;
393
+ /** Delay between chunks in ms. Default: 42. */
394
+ delayMs?: number;
395
+ }
396
+
397
+ /**
398
+ * Builds progressive snapshots for a transcript preset suitable for feeding into
399
+ * `injectTestMessage({ type: 'message', message })` on a timer. Each frame upserts
400
+ * the same message id with more content, ending with `streaming: false`.
401
+ *
402
+ * - Streaming-capable presets (assistant text) yield many frames.
403
+ * - All other presets yield a single `done` frame matching `createPreviewTranscriptEntry`.
404
+ */
405
+ export function buildTranscriptStreamFrames(
406
+ preset: PreviewTranscriptEntryPreset,
407
+ suffix: number,
408
+ options?: BuildTranscriptStreamFramesOptions
409
+ ): TranscriptStreamFrame[] {
410
+ const completed = createPreviewTranscriptEntry(preset, suffix);
411
+ if (!presetStreamsText(preset) || typeof completed.content !== 'string') {
412
+ return [{ message: completed, delayMs: 0, done: true }];
413
+ }
414
+
415
+ const chunkSize = Math.max(1, options?.chunkSize ?? 24);
416
+ const delayMs = Math.max(0, options?.delayMs ?? 42);
417
+ const fullText = completed.content;
418
+ const frames: TranscriptStreamFrame[] = [];
419
+
420
+ // Seed with an empty streaming bubble so the animation plugin can attach from the first tick.
421
+ frames.push({
422
+ message: { ...completed, content: '', streaming: true },
423
+ delayMs: 0,
424
+ done: false,
425
+ });
426
+
427
+ for (let i = chunkSize; i < fullText.length; i += chunkSize) {
428
+ frames.push({
429
+ message: { ...completed, content: fullText.slice(0, i), streaming: true },
430
+ delayMs,
431
+ done: false,
432
+ });
433
+ }
434
+
435
+ frames.push({
436
+ message: { ...completed, content: fullText, streaming: false },
437
+ delayMs,
438
+ done: true,
439
+ });
440
+
441
+ return frames;
442
+ }
443
+
315
444
  const createAdvancedTranscriptPreviewMessages = (): AgentWidgetMessage[] => [
316
445
  {
317
446
  id: "preview-adv-1",
@@ -60,4 +60,23 @@ describe("theme editor scroll-to-bottom controls", () => {
60
60
  expect(debugSection?.fields.some((field) => field.path === "features.reasoningDisplay.previewMaxLines")).toBe(true);
61
61
  expect(debugSection?.fields.some((field) => field.path === "features.reasoningDisplay.activeMinHeight")).toBe(true);
62
62
  });
63
+
64
+ it("exposes stream animation controls", () => {
65
+ const section = CONFIGURE_SECTIONS.find((entry) => entry.id === "stream-animation");
66
+
67
+ expect(section).toBeDefined();
68
+ const paths = section?.fields.map((field) => field.path) ?? [];
69
+ expect(paths).toEqual(
70
+ expect.arrayContaining([
71
+ "features.streamAnimation.type",
72
+ "features.streamAnimation.placeholder",
73
+ "features.streamAnimation.buffer",
74
+ "features.streamAnimation.speed",
75
+ "features.streamAnimation.duration",
76
+ ])
77
+ );
78
+
79
+ const speedField = section?.fields.find((field) => field.path === "features.streamAnimation.speed");
80
+ expect(speedField?.parseValue?.("240")).toBe(240);
81
+ });
63
82
  });