@runtypelabs/persona 3.11.0 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/install.ts CHANGED
@@ -22,6 +22,10 @@ interface SiteAgentInstallConfig {
22
22
  previewQueryParam?: string;
23
23
  // Shadow DOM option (defaults to false for better CSS compatibility)
24
24
  useShadowDom?: boolean;
25
+ // Expose the widget handle on window[windowKey] for programmatic access
26
+ windowKey?: string;
27
+ // Called when the widget is initialized and ready for interaction
28
+ onReady?: (handle: any) => void;
25
29
  }
26
30
 
27
31
  declare global {
@@ -55,7 +59,10 @@ declare global {
55
59
  const configJson = script.getAttribute('data-config');
56
60
  if (configJson) {
57
61
  try {
58
- const parsedConfig = JSON.parse(configJson);
62
+ // HTML attributes preserve literal newlines/tabs which are invalid
63
+ // control characters inside JSON string literals — strip them.
64
+ const normalizedJson = configJson.replace(/[\r\n]+\s*/g, '');
65
+ const parsedConfig = JSON.parse(normalizedJson);
59
66
  // If it has nested 'config' property, use it; otherwise treat as widget config
60
67
  if (parsedConfig.config) {
61
68
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -270,12 +277,16 @@ declare global {
270
277
  }
271
278
 
272
279
  try {
273
- window.AgentWidget.initAgentWidget({
280
+ const handle = window.AgentWidget.initAgentWidget({
274
281
  target,
275
282
  config: widgetConfig,
276
283
  // Explicitly disable shadow DOM for better CSS compatibility with host page
277
- useShadowDom: config.useShadowDom ?? false
284
+ useShadowDom: config.useShadowDom ?? false,
285
+ windowKey: config.windowKey
278
286
  });
287
+
288
+ config.onReady?.(handle);
289
+ window.dispatchEvent(new CustomEvent("persona:ready", { detail: handle }));
279
290
  } catch (error) {
280
291
  console.error("Failed to initialize AgentWidget:", error);
281
292
  }
@@ -85,6 +85,138 @@ function createMockController(config?: { launcher?: { enabled?: boolean; autoExp
85
85
  };
86
86
  }
87
87
 
88
+ describe("initAgentWidget windowKey and ready notifications", () => {
89
+ beforeEach(() => {
90
+ document.body.innerHTML = "";
91
+ createAgentExperienceMock.mockReset();
92
+ createAgentExperienceMock.mockImplementation((_mount, config) => createMockController(config));
93
+ });
94
+
95
+ it("assigns the handle to window[windowKey] when windowKey is provided", async () => {
96
+ const { initAgentWidget } = await import("./init");
97
+ document.body.innerHTML = `<div id="target"></div>`;
98
+
99
+ const handle = initAgentWidget({
100
+ target: "#target",
101
+ windowKey: "testWidget",
102
+ config: { launcher: { enabled: false } },
103
+ });
104
+
105
+ expect((window as any).testWidget).toBe(handle);
106
+
107
+ handle.destroy();
108
+ expect((window as any).testWidget).toBeUndefined();
109
+ });
110
+
111
+ it("does not set a window key when windowKey is omitted", async () => {
112
+ const { initAgentWidget } = await import("./init");
113
+ document.body.innerHTML = `<div id="target"></div>`;
114
+
115
+ const handle = initAgentWidget({
116
+ target: "#target",
117
+ config: { launcher: { enabled: false } },
118
+ });
119
+
120
+ // No arbitrary key should have been set
121
+ expect((window as any).testWidget2).toBeUndefined();
122
+ handle.destroy();
123
+ });
124
+
125
+ it("calls onReady after initialization", async () => {
126
+ const { initAgentWidget } = await import("./init");
127
+ document.body.innerHTML = `<div id="target"></div>`;
128
+
129
+ const onReady = vi.fn();
130
+ const handle = initAgentWidget({
131
+ target: "#target",
132
+ onReady,
133
+ config: { launcher: { enabled: false } },
134
+ });
135
+
136
+ expect(onReady).toHaveBeenCalledOnce();
137
+ handle.destroy();
138
+ });
139
+
140
+ it("the window key handle proxies controller methods", async () => {
141
+ const { initAgentWidget } = await import("./init");
142
+ document.body.innerHTML = `<div id="target"></div>`;
143
+
144
+ initAgentWidget({
145
+ target: "#target",
146
+ windowKey: "proxyTest",
147
+ config: { launcher: { enabled: false } },
148
+ });
149
+
150
+ const proxy = (window as any).proxyTest;
151
+ expect(proxy).toBeDefined();
152
+ expect(typeof proxy.open).toBe("function");
153
+ expect(typeof proxy.close).toBe("function");
154
+ expect(typeof proxy.on).toBe("function");
155
+ expect(typeof proxy.destroy).toBe("function");
156
+ expect(typeof proxy.getState).toBe("function");
157
+
158
+ proxy.destroy();
159
+ });
160
+ });
161
+
162
+ describe("install script onReady and persona:ready event", () => {
163
+ beforeEach(() => {
164
+ document.body.innerHTML = "";
165
+ createAgentExperienceMock.mockReset();
166
+ createAgentExperienceMock.mockImplementation((_mount, config) => createMockController(config));
167
+ });
168
+
169
+ it("persona:ready event fires with the handle as detail", async () => {
170
+ const { initAgentWidget } = await import("./init");
171
+ document.body.innerHTML = `<div id="target"></div>`;
172
+
173
+ const eventPromise = new Promise<any>((resolve) => {
174
+ window.addEventListener("persona:ready", (e) => {
175
+ resolve((e as CustomEvent).detail);
176
+ }, { once: true });
177
+ });
178
+
179
+ const handle = initAgentWidget({
180
+ target: "#target",
181
+ windowKey: "eventTest",
182
+ config: { launcher: { enabled: false } },
183
+ });
184
+
185
+ // Simulate what install.ts does after initAgentWidget returns
186
+ window.dispatchEvent(new CustomEvent("persona:ready", { detail: handle }));
187
+
188
+ const detail = await eventPromise;
189
+ expect(detail).toBe(handle);
190
+ expect(typeof detail.open).toBe("function");
191
+ expect(typeof detail.on).toBe("function");
192
+
193
+ handle.destroy();
194
+ });
195
+
196
+ it("persona:ready event listener set up before init receives the handle", async () => {
197
+ const { initAgentWidget } = await import("./init");
198
+ document.body.innerHTML = `<div id="target"></div>`;
199
+
200
+ const received: any[] = [];
201
+ window.addEventListener("persona:ready", (e) => {
202
+ received.push((e as CustomEvent).detail);
203
+ }, { once: true });
204
+
205
+ const handle = initAgentWidget({
206
+ target: "#target",
207
+ config: { launcher: { enabled: false } },
208
+ });
209
+
210
+ // Simulate install.ts dispatching the event
211
+ window.dispatchEvent(new CustomEvent("persona:ready", { detail: handle }));
212
+
213
+ expect(received).toHaveLength(1);
214
+ expect(received[0]).toBe(handle);
215
+
216
+ handle.destroy();
217
+ });
218
+ });
219
+
88
220
  describe("initAgentWidget docked mode", () => {
89
221
  beforeEach(() => {
90
222
  document.body.innerHTML = "";
@@ -218,9 +218,12 @@ export const THEME_TOKEN_DOCS = {
218
218
  'shadow, backgroundColor, borderColor, borderWidth, borderRadius, headerBackgroundColor, headerTextColor, headerPaddingX, headerPaddingY, contentBackgroundColor, contentTextColor, contentPaddingX, contentPaddingY, codeBlockBackgroundColor, codeBlockBorderColor, codeBlockTextColor, toggleTextColor, labelTextColor, activeTextTemplate, completeTextTemplate, loadingAnimationColor, loadingAnimationSecondaryColor, loadingAnimationDuration, renderCollapsedSummary, renderCollapsedPreview, renderGroupedSummary.',
219
219
  },
220
220
  reasoning: {
221
- description: 'Reasoning/thinking row rendering hooks.',
221
+ description:
222
+ 'Reasoning/thinking row rendering hooks, text templates, and loading animations. ' +
223
+ 'Text templates support {duration} placeholder and inline formatting (~dim~, *italic*, **bold**). ' +
224
+ 'renderCollapsedSummary receives elapsed (static string) and createElapsedElement() (live-updating span) in its context.',
222
225
  properties:
223
- 'renderCollapsedSummary, renderCollapsedPreview.',
226
+ 'renderCollapsedSummary, renderCollapsedPreview, activeTextTemplate, completeTextTemplate, loadingAnimationColor, loadingAnimationSecondaryColor, loadingAnimationDuration.',
224
227
  },
225
228
  approval: {
226
229
  description:
@@ -285,7 +288,7 @@ export const THEME_TOKEN_DOCS = {
285
288
  features: {
286
289
  description: 'Feature flags.',
287
290
  properties:
288
- 'showReasoning (AI thinking steps), showToolCalls (tool invocations), toolCallDisplay (collapsedMode, activePreview, activeMinHeight, previewMaxLines, grouped, expandable, loadingAnimation), reasoningDisplay (activePreview, activeMinHeight, previewMaxLines, expandable), artifacts (sidebar config).',
291
+ 'showReasoning (AI thinking steps), showToolCalls (tool invocations), toolCallDisplay (collapsedMode, activePreview, activeMinHeight, previewMaxLines, grouped, expandable, loadingAnimation), reasoningDisplay (activePreview, activeMinHeight, previewMaxLines, expandable, loadingAnimation), artifacts (sidebar config).',
289
292
  },
290
293
  },
291
294
  }
@@ -19,6 +19,7 @@ describe("tool call display defaults", () => {
19
19
  activePreview: false,
20
20
  previewMaxLines: 3,
21
21
  expandable: true,
22
+ loadingAnimation: "none",
22
23
  });
23
24
  });
24
25
  });
package/src/types.ts CHANGED
@@ -656,6 +656,17 @@ export type AgentWidgetReasoningDisplayFeature = {
656
656
  * @default true
657
657
  */
658
658
  expandable?: boolean;
659
+ /**
660
+ * Animation mode applied to the reasoning header text while reasoning is active.
661
+ * Reuses the same modes as tool call animations.
662
+ * - "none" — static text, no animation
663
+ * - "pulse" — opacity pulse on the entire header text
664
+ * - "shimmer" — monochrome opacity sweep per character
665
+ * - "shimmer-color" — color gradient sweep per character
666
+ * - "rainbow" — rainbow color cycle per character
667
+ * @default "none"
668
+ */
669
+ loadingAnimation?: AgentWidgetToolCallLoadingAnimation;
659
670
  };
660
671
 
661
672
  export type AgentWidgetFeatureFlags = {
@@ -1390,6 +1401,14 @@ export type AgentWidgetReasoningConfig = {
1390
1401
  previewText: string;
1391
1402
  isActive: boolean;
1392
1403
  config: AgentWidgetConfig;
1404
+ /** Static elapsed time snapshot, e.g. "2.6s". */
1405
+ elapsed: string;
1406
+ /**
1407
+ * Returns a `<span>` whose text content is automatically updated every
1408
+ * 100ms by the widget's global timer. Place it anywhere in your returned
1409
+ * HTMLElement to get a live-ticking duration display.
1410
+ */
1411
+ createElapsedElement: () => HTMLElement;
1393
1412
  }) => HTMLElement | string | null;
1394
1413
  /**
1395
1414
  * Override the lightweight collapsed preview content shown for active reasoning rows.
@@ -1402,6 +1421,45 @@ export type AgentWidgetReasoningConfig = {
1402
1421
  isActive: boolean;
1403
1422
  config: AgentWidgetConfig;
1404
1423
  }) => HTMLElement | string | null;
1424
+ /**
1425
+ * Template string for the header text while reasoning is active (streaming).
1426
+ *
1427
+ * **Placeholders:** `{duration}` (live-updating elapsed time).
1428
+ *
1429
+ * **Inline formatting:** `~dim~`, `*italic*`, `**bold**` — parsed at render time.
1430
+ *
1431
+ * When not set, falls back to the default "Thinking..." text.
1432
+ * @example "Thinking... ~{duration}~"
1433
+ */
1434
+ activeTextTemplate?: string;
1435
+ /**
1436
+ * Template string for the header text when reasoning is complete.
1437
+ *
1438
+ * **Placeholders:** `{duration}` (final elapsed time).
1439
+ *
1440
+ * **Inline formatting:** `~dim~`, `*italic*`, `**bold**` — same syntax as `activeTextTemplate`.
1441
+ *
1442
+ * When not set, falls back to the default "Thought for X seconds" text.
1443
+ * @example "Thought for ~{duration}~"
1444
+ */
1445
+ completeTextTemplate?: string;
1446
+ /**
1447
+ * Primary color for shimmer-color animation mode.
1448
+ * Defaults to the current text color.
1449
+ */
1450
+ loadingAnimationColor?: string;
1451
+ /**
1452
+ * Secondary/end color for shimmer-color animation mode.
1453
+ * Creates a gradient sweep between `loadingAnimationColor` and this color.
1454
+ * @default "#3b82f6"
1455
+ */
1456
+ loadingAnimationSecondaryColor?: string;
1457
+ /**
1458
+ * Duration of one full animation cycle in milliseconds.
1459
+ * Applies to pulse, shimmer, shimmer-color, and rainbow modes.
1460
+ * @default 2000
1461
+ */
1462
+ loadingAnimationDuration?: number;
1405
1463
  };
1406
1464
 
1407
1465
  export type AgentWidgetSuggestionChipsConfig = {
@@ -620,3 +620,60 @@ describe("CDN Version", () => {
620
620
  expect(VERSION).toMatch(/^\d+\.\d+\.\d+/);
621
621
  });
622
622
  });
623
+
624
+ // =============================================================================
625
+ // windowKey option
626
+ // =============================================================================
627
+
628
+ describe("windowKey option", () => {
629
+ it("script-installer with windowKey nests config and includes windowKey in JSON", () => {
630
+ const code = generateCodeSnippet(minimalConfig, "script-installer", { windowKey: "myWidget" });
631
+
632
+ // Parse the data-config JSON from the output
633
+ const match = code.match(/data-config='([^']*)'/);
634
+ expect(match).not.toBeNull();
635
+ const parsed = JSON.parse(match![1]);
636
+
637
+ expect(parsed.windowKey).toBe("myWidget");
638
+ expect(parsed.config).toBeDefined();
639
+ expect(parsed.config.apiUrl).toBe(minimalConfig.apiUrl);
640
+ });
641
+
642
+ it("script-installer without windowKey uses flat config (no nesting)", () => {
643
+ const code = generateCodeSnippet(minimalConfig, "script-installer");
644
+
645
+ const match = code.match(/data-config='([^']*)'/);
646
+ expect(match).not.toBeNull();
647
+ const parsed = JSON.parse(match![1]);
648
+
649
+ expect(parsed.windowKey).toBeUndefined();
650
+ expect(parsed.config).toBeUndefined();
651
+ expect(parsed.apiUrl).toBe(minimalConfig.apiUrl);
652
+ });
653
+
654
+ it("script-manual with windowKey includes windowKey and captures handle", () => {
655
+ const code = generateCodeSnippet(minimalConfig, "script-manual", { windowKey: "myWidget" });
656
+
657
+ expect(code).toContain("var handle = window.AgentWidget.initAgentWidget(");
658
+ expect(code).toContain("windowKey: 'myWidget'");
659
+ });
660
+
661
+ it("script-manual without windowKey still captures handle but omits windowKey", () => {
662
+ const code = generateCodeSnippet(minimalConfig, "script-manual");
663
+
664
+ expect(code).toContain("var handle = window.AgentWidget.initAgentWidget(");
665
+ expect(code).not.toContain("windowKey");
666
+ });
667
+
668
+ it("script-advanced with windowKey includes windowKey in initAgentWidget call", () => {
669
+ const code = generateCodeSnippet(minimalConfig, "script-advanced", { windowKey: "myWidget" });
670
+
671
+ expect(code).toContain("windowKey: 'myWidget'");
672
+ });
673
+
674
+ it("script-advanced without windowKey omits windowKey", () => {
675
+ const code = generateCodeSnippet(minimalConfig, "script-advanced");
676
+
677
+ expect(code).not.toContain("windowKey");
678
+ });
679
+ });
@@ -134,6 +134,13 @@ export type CodeGeneratorOptions = {
134
134
  * @default true
135
135
  */
136
136
  includeHookComments?: boolean;
137
+
138
+ /**
139
+ * If provided, emits `windowKey` in the generated `initAgentWidget()` call
140
+ * so the widget handle is stored on `window[windowKey]`.
141
+ * Only affects script formats (script-installer, script-manual, script-advanced).
142
+ */
143
+ windowKey?: string;
137
144
  };
138
145
 
139
146
  // Internal type for normalized hooks (always strings)
@@ -551,7 +558,7 @@ export function generateCodeSnippet(
551
558
  if (format === "esm") {
552
559
  return generateESMCode(cleanConfig, normalizedOptions);
553
560
  } else if (format === "script-installer") {
554
- return generateScriptInstallerCode(cleanConfig);
561
+ return generateScriptInstallerCode(cleanConfig, normalizedOptions);
555
562
  } else if (format === "script-advanced") {
556
563
  return generateScriptAdvancedCode(cleanConfig, normalizedOptions);
557
564
  } else if (format === "react-component") {
@@ -1346,12 +1353,18 @@ function buildSerializableConfig(config: any): Record<string, any> {
1346
1353
  return serializableConfig;
1347
1354
  }
1348
1355
 
1349
- function generateScriptInstallerCode(config: any): string {
1356
+ function generateScriptInstallerCode(config: any, options?: CodeGeneratorOptions): string {
1350
1357
  const serializableConfig = buildSerializableConfig(config);
1351
-
1358
+
1359
+ // When windowKey is provided, nest the widget config under `config` so the
1360
+ // install script's parsedConfig.config detection picks it up alongside windowKey.
1361
+ const payload = options?.windowKey
1362
+ ? { config: serializableConfig, windowKey: options.windowKey }
1363
+ : serializableConfig;
1364
+
1352
1365
  // Escape single quotes in JSON for HTML attribute
1353
- const configJson = JSON.stringify(serializableConfig, null, 0).replace(/'/g, "&#39;");
1354
-
1366
+ const configJson = JSON.stringify(payload, null, 0).replace(/'/g, "&#39;");
1367
+
1355
1368
  return `<script src="https://cdn.jsdelivr.net/npm/@runtypelabs/persona@${VERSION}/dist/install.global.js" data-config='${configJson}'></script>`;
1356
1369
  }
1357
1370
 
@@ -1369,8 +1382,9 @@ function generateScriptManualCode(config: any, options?: CodeGeneratorOptions):
1369
1382
  "",
1370
1383
  "<!-- Initialize widget -->",
1371
1384
  "<script>",
1372
- " window.AgentWidget.initAgentWidget({",
1385
+ " var handle = window.AgentWidget.initAgentWidget({",
1373
1386
  " target: 'body',",
1387
+ ...(options?.windowKey ? [` windowKey: '${options.windowKey}',`] : []),
1374
1388
  " config: {"
1375
1389
  ];
1376
1390
 
@@ -1723,6 +1737,7 @@ function generateScriptAdvancedCode(config: any, options?: CodeGeneratorOptions)
1723
1737
  " var handle = agentWidget.initAgentWidget({",
1724
1738
  " target: 'body',",
1725
1739
  " useShadowDom: false,",
1740
+ ...(options?.windowKey ? [` windowKey: '${options.windowKey}',`] : []),
1726
1741
  " config: widgetConfig",
1727
1742
  " });",
1728
1743
  "",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { createJsonStreamParser, parseFormattedTemplate } from "./formatting";
2
+ import { createJsonStreamParser, parseFormattedTemplate, computeReasoningElapsed } from "./formatting";
3
3
 
4
4
  describe("JSON Stream Parser", () => {
5
5
  it("should extract text field incrementally as JSON streams in", () => {
@@ -244,3 +244,27 @@ describe("parseFormattedTemplate", () => {
244
244
  ]);
245
245
  });
246
246
  });
247
+
248
+ describe("computeReasoningElapsed", () => {
249
+ it("uses durationMs when provided", () => {
250
+ const result = computeReasoningElapsed({
251
+ id: "r1", status: "complete", chunks: [], durationMs: 2600,
252
+ });
253
+ expect(result).toBe("2.6s");
254
+ });
255
+
256
+ it("computes from startedAt/completedAt when durationMs is undefined", () => {
257
+ const result = computeReasoningElapsed({
258
+ id: "r2", status: "complete", chunks: [],
259
+ startedAt: 1000, completedAt: 16000,
260
+ });
261
+ expect(result).toBe("15s");
262
+ });
263
+
264
+ it("returns <0.1s for very short durations", () => {
265
+ const result = computeReasoningElapsed({
266
+ id: "r3", status: "complete", chunks: [], durationMs: 50,
267
+ });
268
+ expect(result).toBe("<0.1s");
269
+ });
270
+ });
@@ -115,6 +115,21 @@ export const computeToolElapsed = (tool: AgentWidgetToolCall): string => {
115
115
  return formatElapsedMs(durationMs);
116
116
  };
117
117
 
118
+ /**
119
+ * Computes the current elapsed time string for a reasoning block.
120
+ */
121
+ export const computeReasoningElapsed = (reasoning: AgentWidgetReasoning): string => {
122
+ const durationMs =
123
+ reasoning.durationMs !== undefined
124
+ ? reasoning.durationMs
125
+ : Math.max(
126
+ 0,
127
+ (reasoning.completedAt ?? Date.now()) -
128
+ (reasoning.startedAt ?? reasoning.completedAt ?? Date.now())
129
+ );
130
+ return formatElapsedMs(durationMs);
131
+ };
132
+
118
133
  /**
119
134
  * Resolves a text template with tool call placeholders.
120
135
  * Supported placeholders: {toolName}, {duration}
@@ -90,6 +90,18 @@ describe("computeMessageFingerprint", () => {
90
90
  expect(fp1).not.toBe(fp2);
91
91
  });
92
92
 
93
+ it("changes when toolCall name changes", () => {
94
+ const fp1 = computeMessageFingerprint(
95
+ makeMessage({ toolCall: { status: "running" } }),
96
+ 0
97
+ );
98
+ const fp2 = computeMessageFingerprint(
99
+ makeMessage({ toolCall: { status: "running", name: "UCP Search Catalog" } }),
100
+ 0
101
+ );
102
+ expect(fp1).not.toBe(fp2);
103
+ });
104
+
93
105
  it("changes when toolCall chunks change", () => {
94
106
  const fp1 = computeMessageFingerprint(
95
107
  makeMessage({ toolCall: { status: "running", chunks: ["Loaded tools"] } }),
@@ -53,6 +53,7 @@ export function computeMessageFingerprint(
53
53
  message.llmContent?.length ?? 0,
54
54
  message.approval?.status ?? "",
55
55
  message.toolCall?.status ?? "",
56
+ message.toolCall?.name ?? "",
56
57
  message.toolCall?.chunks?.length ?? 0,
57
58
  message.toolCall?.chunks?.[message.toolCall.chunks.length - 1]?.slice(-32) ?? "",
58
59
  typeof message.toolCall?.args === "string"
@@ -0,0 +1,86 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect } from "vitest";
3
+ import { morphMessages } from "./morph";
4
+
5
+ function makeContainer(html: string): HTMLElement {
6
+ const div = document.createElement("div");
7
+ div.innerHTML = html;
8
+ return div;
9
+ }
10
+
11
+ function makeNewContent(html: string): HTMLElement {
12
+ const div = document.createElement("div");
13
+ div.innerHTML = html;
14
+ return div;
15
+ }
16
+
17
+ describe("morphMessages", () => {
18
+ describe("data-preserve-animation", () => {
19
+ it("preserves animated element when old and new both have data-preserve-animation with same text", () => {
20
+ const container = makeContainer(
21
+ '<span data-preserve-animation="true">Calling tool... 0.1s</span>'
22
+ );
23
+ const oldSpan = container.querySelector("span")!;
24
+
25
+ morphMessages(
26
+ container,
27
+ makeNewContent(
28
+ '<span data-preserve-animation="true">Calling tool... 0.1s</span>'
29
+ )
30
+ );
31
+
32
+ expect(container.querySelector("span")).toBe(oldSpan);
33
+ });
34
+
35
+ it("allows morph when new node drops data-preserve-animation (tool completed)", () => {
36
+ const container = makeContainer(
37
+ '<span data-preserve-animation="true">Calling tool... 0.5s</span>'
38
+ );
39
+
40
+ morphMessages(
41
+ container,
42
+ makeNewContent("<span>Finished tool 0.5s</span>")
43
+ );
44
+
45
+ expect(container.querySelector("span")!.textContent).toBe(
46
+ "Finished tool 0.5s"
47
+ );
48
+ expect(
49
+ container.querySelector("span")!.hasAttribute("data-preserve-animation")
50
+ ).toBe(false);
51
+ });
52
+
53
+ it("allows morph when text content changes despite both having data-preserve-animation", () => {
54
+ const container = makeContainer(
55
+ '<span data-preserve-animation="true">Calling tool... 0.1s</span>'
56
+ );
57
+
58
+ morphMessages(
59
+ container,
60
+ makeNewContent(
61
+ '<span data-preserve-animation="true">Calling UCP Search Catalog... 0.2s</span>'
62
+ )
63
+ );
64
+
65
+ expect(container.querySelector("span")!.textContent).toBe(
66
+ "Calling UCP Search Catalog... 0.2s"
67
+ );
68
+ });
69
+
70
+ it("does not preserve when preserveTypingAnimation is false", () => {
71
+ const container = makeContainer(
72
+ '<span data-preserve-animation="true">Old text</span>'
73
+ );
74
+
75
+ morphMessages(
76
+ container,
77
+ makeNewContent(
78
+ '<span data-preserve-animation="true">New text</span>'
79
+ ),
80
+ { preserveTypingAnimation: false }
81
+ );
82
+
83
+ expect(container.querySelector("span")!.textContent).toBe("New text");
84
+ });
85
+ });
86
+ });
@@ -35,6 +35,14 @@ export const morphMessages = (
35
35
  if (newNode instanceof HTMLElement && !newNode.hasAttribute("data-preserve-animation")) {
36
36
  return;
37
37
  }
38
+ // Allow morph when content has meaningfully changed (e.g. tool name arrived)
39
+ if (newNode instanceof HTMLElement && newNode.hasAttribute("data-preserve-animation")) {
40
+ const oldText = oldNode.textContent ?? "";
41
+ const newText = newNode.textContent ?? "";
42
+ if (oldText !== newText) {
43
+ return;
44
+ }
45
+ }
38
46
  return false;
39
47
  }
40
48
  }