@runtypelabs/persona 3.10.0 → 3.11.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/ui.ts CHANGED
@@ -54,6 +54,7 @@ import { MessageTransform, MessageActionCallbacks, LoadingIndicatorRenderer } fr
54
54
  import { createStandardBubble, createTypingIndicator } from "./components/message-bubble";
55
55
  import { createReasoningBubble, reasoningExpansionState, updateReasoningBubbleUI } from "./components/reasoning-bubble";
56
56
  import { createToolBubble, toolExpansionState, updateToolBubbleUI } from "./components/tool-bubble";
57
+ import { formatElapsedMs } from "./utils/formatting";
57
58
  import { createApprovalBubble } from "./components/approval-bubble";
58
59
  import { createSuggestions } from "./components/suggestions";
59
60
  import { EventStreamBuffer } from "./utils/event-stream-buffer";
@@ -147,6 +148,19 @@ function getClipboardImageFiles(clipboardData: DataTransfer | null): File[] {
147
148
  return imageFiles;
148
149
  }
149
150
 
151
+ function dataTransferHasFiles(
152
+ dataTransfer: DataTransfer | null
153
+ ): dataTransfer is DataTransfer {
154
+ if (!dataTransfer) return false;
155
+ const types = dataTransfer.types;
156
+ if (!types) return false;
157
+ // Real browsers return DOMStringList which has .contains(); test polyfills use plain arrays.
158
+ if (typeof (types as unknown as { contains?: unknown }).contains === "function") {
159
+ return (types as unknown as DOMStringList).contains("Files");
160
+ }
161
+ return Array.from(types).includes("Files");
162
+ }
163
+
150
164
  // ============================================================================
151
165
  // PERSIST STATE HELPERS
152
166
  // ============================================================================
@@ -389,11 +403,43 @@ const buildPostprocessor = (
389
403
  };
390
404
  };
391
405
 
406
+ function buildDropOverlay(
407
+ dropCfg?: NonNullable<AgentWidgetConfig["attachments"]>["dropOverlay"]
408
+ ): HTMLElement {
409
+ const overlay = createElement("div", "persona-attachment-drop-overlay");
410
+ if (dropCfg?.background) overlay.style.setProperty("--persona-drop-overlay-bg", dropCfg.background);
411
+ if (dropCfg?.backdropBlur !== undefined) overlay.style.setProperty("--persona-drop-overlay-blur", dropCfg.backdropBlur);
412
+ if (dropCfg?.border) overlay.style.setProperty("--persona-drop-overlay-border", dropCfg.border);
413
+ if (dropCfg?.borderRadius) overlay.style.setProperty("--persona-drop-overlay-radius", dropCfg.borderRadius);
414
+ if (dropCfg?.inset) overlay.style.setProperty("--persona-drop-overlay-inset", dropCfg.inset);
415
+ if (dropCfg?.labelSize) overlay.style.setProperty("--persona-drop-overlay-label-size", dropCfg.labelSize);
416
+ if (dropCfg?.labelColor) overlay.style.setProperty("--persona-drop-overlay-label-color", dropCfg.labelColor);
417
+
418
+ const iconName = dropCfg?.iconName ?? "upload";
419
+ const iconSize = dropCfg?.iconSize ?? "48px";
420
+ const iconColor = dropCfg?.iconColor ?? "rgba(59, 130, 246, 0.6)";
421
+ const iconStrokeWidth = dropCfg?.iconStrokeWidth ?? 0.5;
422
+ const iconSvg = renderLucideIcon(iconName, iconSize, iconColor, iconStrokeWidth);
423
+ if (iconSvg) overlay.appendChild(iconSvg);
424
+
425
+ if (dropCfg?.label) {
426
+ const labelEl = createElement("span", "persona-drop-overlay-label");
427
+ labelEl.textContent = dropCfg.label;
428
+ overlay.appendChild(labelEl);
429
+ }
430
+ return overlay;
431
+ }
432
+
392
433
  export const createAgentExperience = (
393
434
  mount: HTMLElement,
394
435
  initialConfig?: AgentWidgetConfig,
395
436
  runtimeOptions?: { debugTools?: boolean }
396
437
  ): Controller => {
438
+ if (mount == null) {
439
+ throw new Error(
440
+ "createAgentExperience: mount must be a non-null HTMLElement (e.g. pass document.getElementById(\"my-root\") after the node exists)."
441
+ );
442
+ }
397
443
  // Preserve original mount id as data attribute for window event instance scoping
398
444
  if (mount.id && !mount.getAttribute("data-persona-instance")) {
399
445
  mount.setAttribute("data-persona-instance", mount.id);
@@ -870,8 +916,21 @@ export const createAgentExperience = (
870
916
  return composerElements.footer;
871
917
  },
872
918
  onSubmit: (text: string) => {
873
- if (session && !session.isStreaming()) {
874
- session.sendMessage(text);
919
+ if (!session || session.isStreaming()) return;
920
+ const value = text.trim();
921
+ const hasAttachments = attachmentManager?.hasAttachments() ?? false;
922
+ if (!value && !hasAttachments) return;
923
+ let contentParts: ContentPart[] | undefined;
924
+ if (hasAttachments) {
925
+ contentParts = [];
926
+ contentParts.push(...attachmentManager!.getContentParts());
927
+ if (value) {
928
+ contentParts.push(createTextPart(value));
929
+ }
930
+ }
931
+ session.sendMessage(value, { contentParts });
932
+ if (hasAttachments) {
933
+ attachmentManager!.clearAttachments();
875
934
  }
876
935
  },
877
936
  streaming: false,
@@ -959,6 +1018,10 @@ export const createAgentExperience = (
959
1018
  attachmentManager?.handleFileSelect(target.files);
960
1019
  target.value = "";
961
1020
  });
1021
+
1022
+ const dropCfg = config.attachments.dropOverlay;
1023
+ const overlay = buildDropOverlay(dropCfg);
1024
+ container.appendChild(overlay);
962
1025
  }
963
1026
 
964
1027
  // Slot system: allow custom content injection into specific regions
@@ -1925,9 +1988,15 @@ export const createAgentExperience = (
1925
1988
  let lastScrollTop = 0;
1926
1989
  let scrollRAF: number | null = null;
1927
1990
  let isAutoScrolling = false;
1928
-
1929
- const USER_SCROLL_THRESHOLD = 1;
1930
- const BOTTOM_THRESHOLD = 8;
1991
+ let hasPendingAutoScroll = false;
1992
+
1993
+ // Scroll events caused by layout, scroll anchoring, and smooth-scroll
1994
+ // easing can easily move by a couple pixels. Keep manual wheel intent
1995
+ // responsive, but require a slightly larger raw scroll delta before we
1996
+ // treat a plain scroll event as the user breaking away.
1997
+ const USER_SCROLL_THRESHOLD = 4;
1998
+ const BOTTOM_THRESHOLD = 24;
1999
+ const AUTO_SCROLL_SNAP_THRESHOLD = 80;
1931
2000
  const messageState = new Map<
1932
2001
  string,
1933
2002
  { streaming?: boolean; role: AgentWidgetMessage["role"] }
@@ -2041,6 +2110,7 @@ export const createAgentExperience = (
2041
2110
  cancelAnimationFrame(scrollRAF);
2042
2111
  scrollRAF = null;
2043
2112
  }
2113
+ hasPendingAutoScroll = false;
2044
2114
  cancelSmoothScroll();
2045
2115
  };
2046
2116
 
@@ -2076,10 +2146,25 @@ export const createAgentExperience = (
2076
2146
 
2077
2147
  if (!force && !isStreaming) return;
2078
2148
 
2079
- cancelAutoScroll();
2149
+ // Only cancel the pending schedule rAF — keep the ongoing smooth scroll
2150
+ // animation alive so isAutoScrolling stays true. This prevents scroll
2151
+ // events fired by DOM morphing (between cancel and the next rAF) from
2152
+ // being misinterpreted as user-initiated upward scrolls that would
2153
+ // permanently pause auto-follow during streaming.
2154
+ // smoothScrollToBottom() already calls cancelSmoothScroll() internally
2155
+ // before starting its new animation.
2156
+ if (scrollRAF !== null) {
2157
+ cancelAnimationFrame(scrollRAF);
2158
+ scrollRAF = null;
2159
+ }
2080
2160
 
2161
+ // Treat the render -> next-rAF window as programmatic scrolling too.
2162
+ // This prevents layout/scroll-anchoring scroll events fired before the
2163
+ // actual smooth scroll starts from being misread as user intent.
2164
+ hasPendingAutoScroll = true;
2081
2165
  scrollRAF = requestAnimationFrame(() => {
2082
2166
  scrollRAF = null;
2167
+ hasPendingAutoScroll = false;
2083
2168
  if (!autoFollow.isFollowing()) return;
2084
2169
  smoothScrollToBottom(getScrollableContainer(), force ? 220 : 140);
2085
2170
  });
@@ -2098,6 +2183,18 @@ export const createAgentExperience = (
2098
2183
  return;
2099
2184
  }
2100
2185
 
2186
+ // If the transcript has fallen noticeably behind, catch up immediately
2187
+ // instead of easing over multiple frames. This keeps fast streaming /
2188
+ // bursty tool and reasoning updates pinned to the bottom.
2189
+ if (Math.abs(distance) >= AUTO_SCROLL_SNAP_THRESHOLD) {
2190
+ cancelSmoothScroll();
2191
+ isAutoScrolling = true;
2192
+ element.scrollTop = target;
2193
+ lastScrollTop = element.scrollTop;
2194
+ isAutoScrolling = false;
2195
+ return;
2196
+ }
2197
+
2101
2198
  // Cancel any ongoing smooth scroll animation
2102
2199
  cancelSmoothScroll();
2103
2200
 
@@ -2881,9 +2978,33 @@ export const createAgentExperience = (
2881
2978
  };
2882
2979
  }
2883
2980
 
2981
+ // Global timer for live-updating tool elapsed time spans.
2982
+ // Runs at 100ms while any [data-tool-elapsed] span exists in the message area,
2983
+ // auto-stops when none remain. Operates on real DOM after morph, not temp elements.
2984
+ let toolElapsedTimerId: ReturnType<typeof setInterval> | null = null;
2985
+ const ensureToolElapsedTimer = () => {
2986
+ if (toolElapsedTimerId != null) return;
2987
+ toolElapsedTimerId = setInterval(() => {
2988
+ const spans = messagesWrapper.querySelectorAll<HTMLElement>("[data-tool-elapsed]");
2989
+ if (spans.length === 0) {
2990
+ clearInterval(toolElapsedTimerId!);
2991
+ toolElapsedTimerId = null;
2992
+ return;
2993
+ }
2994
+ const now = Date.now();
2995
+ spans.forEach((span) => {
2996
+ const startedAt = Number(span.getAttribute("data-tool-elapsed"));
2997
+ if (!startedAt) return;
2998
+ span.textContent = formatElapsedMs(now - startedAt);
2999
+ });
3000
+ }, 100);
3001
+ };
3002
+
2884
3003
  session = new AgentWidgetSession(config, {
2885
3004
  onMessagesChanged(messages) {
2886
3005
  renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
3006
+ // Start elapsed timer if any active tool has a live duration span
3007
+ ensureToolElapsedTimer();
2887
3008
  // Re-render suggestions to hide them after first user message
2888
3009
  // Pass messages directly to avoid calling session.getMessages() during construction
2889
3010
  if (session) {
@@ -3776,7 +3897,7 @@ export const createAgentExperience = (
3776
3897
  lastScrollTop,
3777
3898
  nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
3778
3899
  userScrollThreshold: USER_SCROLL_THRESHOLD,
3779
- isAutoScrolling,
3900
+ isAutoScrolling: isAutoScrolling || hasPendingAutoScroll,
3780
3901
  pauseOnUpwardScroll: true,
3781
3902
  pauseWhenAwayFromBottom: false,
3782
3903
  resumeRequiresDownwardScroll: true
@@ -3915,6 +4036,78 @@ export const createAgentExperience = (
3915
4036
  textarea?.addEventListener("keydown", handleInputEnter);
3916
4037
  textarea?.addEventListener("paste", handleInputPaste);
3917
4038
 
4039
+ const ATTACHMENT_DROP_ACTIVE_CLASS = "persona-attachment-drop-active";
4040
+ let attachmentFileDragDepth = 0;
4041
+
4042
+ const clearAttachmentDropVisual = () => {
4043
+ attachmentFileDragDepth = 0;
4044
+ container.classList.remove(ATTACHMENT_DROP_ACTIVE_CLASS);
4045
+ };
4046
+
4047
+ const attachmentDropHandlingActive = (): boolean =>
4048
+ config.attachments?.enabled === true && attachmentManager !== null;
4049
+
4050
+ // Visual highlight tracked on `container` (the chat column).
4051
+ const handleAttachmentDragEnterCapture = (e: DragEvent) => {
4052
+ if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
4053
+ attachmentFileDragDepth++;
4054
+ if (attachmentFileDragDepth === 1) {
4055
+ container.classList.add(ATTACHMENT_DROP_ACTIVE_CLASS);
4056
+ }
4057
+ };
4058
+
4059
+ const handleAttachmentDragLeaveCapture = (e: DragEvent) => {
4060
+ if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
4061
+ attachmentFileDragDepth--;
4062
+ if (attachmentFileDragDepth <= 0) {
4063
+ clearAttachmentDropVisual();
4064
+ }
4065
+ };
4066
+
4067
+ // dragover + drop registered on `mount` so the browser default (open file)
4068
+ // is suppressed across the entire widget surface (artifact pane, gaps, etc.).
4069
+ const handleAttachmentDragOverCapture = (e: DragEvent) => {
4070
+ if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
4071
+ e.preventDefault();
4072
+ e.dataTransfer.dropEffect = "copy";
4073
+ };
4074
+
4075
+ const handleAttachmentDropCapture = (e: DragEvent) => {
4076
+ if (!dataTransferHasFiles(e.dataTransfer) || !attachmentDropHandlingActive()) return;
4077
+ e.preventDefault();
4078
+ e.stopPropagation();
4079
+ clearAttachmentDropVisual();
4080
+ const files = Array.from(e.dataTransfer.files ?? []);
4081
+ if (files.length === 0) return;
4082
+ void attachmentManager!.handleFiles(files);
4083
+ };
4084
+
4085
+ const attachmentDropCapture = true;
4086
+ container.addEventListener("dragenter", handleAttachmentDragEnterCapture, attachmentDropCapture);
4087
+ container.addEventListener("dragleave", handleAttachmentDragLeaveCapture, attachmentDropCapture);
4088
+ mount.addEventListener("dragover", handleAttachmentDragOverCapture, attachmentDropCapture);
4089
+ mount.addEventListener("drop", handleAttachmentDropCapture, attachmentDropCapture);
4090
+
4091
+ // Prevent the browser from navigating to/opening a dropped file anywhere on
4092
+ // the page while this widget instance has attachments enabled. These guards
4093
+ // intentionally skip the `dataTransferHasFiles` check because real OS drags
4094
+ // may expose `dataTransfer.types` as a DOMStringList or restrict access
4095
+ // during certain drag phases. The cost is minimal: we suppress the native
4096
+ // "open file" default for ALL drag-overs while the widget is alive and
4097
+ // attachments are on — text drags into the textarea still work because
4098
+ // element-level handlers are unaffected (we don't stopPropagation here).
4099
+ const ownerDoc = mount.ownerDocument;
4100
+ const handleDocDragOver = (e: DragEvent) => {
4101
+ if (!attachmentDropHandlingActive()) return;
4102
+ e.preventDefault();
4103
+ };
4104
+ const handleDocDrop = (e: DragEvent) => {
4105
+ if (!attachmentDropHandlingActive()) return;
4106
+ e.preventDefault();
4107
+ };
4108
+ ownerDoc.addEventListener("dragover", handleDocDragOver);
4109
+ ownerDoc.addEventListener("drop", handleDocDrop);
4110
+
3918
4111
  destroyCallbacks.push(() => {
3919
4112
  if (composerForm) {
3920
4113
  composerForm.removeEventListener("submit", handleSubmit);
@@ -3923,6 +4116,16 @@ export const createAgentExperience = (
3923
4116
  textarea?.removeEventListener("paste", handleInputPaste);
3924
4117
  });
3925
4118
 
4119
+ destroyCallbacks.push(() => {
4120
+ container.removeEventListener("dragenter", handleAttachmentDragEnterCapture, attachmentDropCapture);
4121
+ container.removeEventListener("dragleave", handleAttachmentDragLeaveCapture, attachmentDropCapture);
4122
+ mount.removeEventListener("dragover", handleAttachmentDragOverCapture, attachmentDropCapture);
4123
+ mount.removeEventListener("drop", handleAttachmentDropCapture, attachmentDropCapture);
4124
+ ownerDoc.removeEventListener("dragover", handleDocDragOver);
4125
+ ownerDoc.removeEventListener("drop", handleDocDrop);
4126
+ clearAttachmentDropVisual();
4127
+ });
4128
+
3926
4129
  destroyCallbacks.push(() => {
3927
4130
  session.cancel();
3928
4131
  });
@@ -4958,6 +5161,11 @@ export const createAgentExperience = (
4958
5161
  }
4959
5162
  });
4960
5163
  }
5164
+
5165
+ // Create drop overlay if missing
5166
+ if (!container.querySelector(".persona-attachment-drop-overlay")) {
5167
+ container.appendChild(buildDropOverlay(attachmentsConfig.dropOverlay));
5168
+ }
4961
5169
  } else {
4962
5170
  // Show existing attachment button and update config
4963
5171
  attachmentButtonWrapper.style.display = "";
@@ -4987,6 +5195,8 @@ export const createAgentExperience = (
4987
5195
  if (attachmentManager) {
4988
5196
  attachmentManager.clearAttachments();
4989
5197
  }
5198
+ // Remove drop overlay
5199
+ container.querySelector(".persona-attachment-drop-overlay")?.remove();
4990
5200
  }
4991
5201
 
4992
5202
  // Update send button styling
@@ -5548,6 +5758,10 @@ export const createAgentExperience = (
5548
5758
  return session.submitNPSFeedback(rating, comment);
5549
5759
  },
5550
5760
  destroy() {
5761
+ if (toolElapsedTimerId != null) {
5762
+ clearInterval(toolElapsedTimerId);
5763
+ toolElapsedTimerId = null;
5764
+ }
5551
5765
  destroyCallbacks.forEach((cb) => cb());
5552
5766
  wrapper.remove();
5553
5767
  launcherButtonInstance?.destroy();
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { createJsonStreamParser } from "./formatting";
2
+ import { createJsonStreamParser, parseFormattedTemplate } from "./formatting";
3
3
 
4
4
  describe("JSON Stream Parser", () => {
5
5
  it("should extract text field incrementally as JSON streams in", () => {
@@ -170,3 +170,77 @@ describe("JSON Stream Parser", () => {
170
170
  expect(finalResult).toBe("You're welcome! Enjoy your browsing, and I'm here if you need anything!");
171
171
  });
172
172
  });
173
+
174
+ describe("parseFormattedTemplate", () => {
175
+ it("returns plain text segments when no formatting markers are present", () => {
176
+ const segments = parseFormattedTemplate("Calling {toolName}...", "Get Weather");
177
+ expect(segments).toEqual([
178
+ { text: "Calling Get Weather...", styles: [] },
179
+ ]);
180
+ });
181
+
182
+ it("resolves {toolName} placeholder", () => {
183
+ const segments = parseFormattedTemplate("{toolName} running", "Search Catalog");
184
+ expect(segments).toEqual([
185
+ { text: "Search Catalog running", styles: [] },
186
+ ]);
187
+ });
188
+
189
+ it("parses ~dim~ markers", () => {
190
+ const segments = parseFormattedTemplate("Finished {toolName} ~{duration}~", "Get Weather");
191
+ expect(segments).toEqual([
192
+ { text: "Finished Get Weather ", styles: [] },
193
+ { text: "{duration}", styles: ["dim"], isDuration: true },
194
+ ]);
195
+ });
196
+
197
+ it("parses *italic* markers", () => {
198
+ const segments = parseFormattedTemplate("*{toolName}* completed", "Search");
199
+ expect(segments).toEqual([
200
+ { text: "Search", styles: ["italic"] },
201
+ { text: " completed", styles: [] },
202
+ ]);
203
+ });
204
+
205
+ it("parses **bold** markers", () => {
206
+ const segments = parseFormattedTemplate("**Calling** {toolName}", "Lookup");
207
+ expect(segments).toEqual([
208
+ { text: "Calling", styles: ["bold"] },
209
+ { text: " Lookup", styles: [] },
210
+ ]);
211
+ });
212
+
213
+ it("handles multiple formatting markers in one template", () => {
214
+ const segments = parseFormattedTemplate("**Done** *{toolName}* ~{duration}~", "API");
215
+ expect(segments).toEqual([
216
+ { text: "Done", styles: ["bold"] },
217
+ { text: " ", styles: [] },
218
+ { text: "API", styles: ["italic"] },
219
+ { text: " ", styles: [] },
220
+ { text: "{duration}", styles: ["dim"], isDuration: true },
221
+ ]);
222
+ });
223
+
224
+ it("handles {duration} without formatting markers", () => {
225
+ const segments = parseFormattedTemplate("Ran for {duration}", "Tool");
226
+ expect(segments).toEqual([
227
+ { text: "Ran for ", styles: [] },
228
+ { text: "{duration}", styles: [], isDuration: true },
229
+ ]);
230
+ });
231
+
232
+ it("handles template with no placeholders", () => {
233
+ const segments = parseFormattedTemplate("Running...", "Ignored");
234
+ expect(segments).toEqual([
235
+ { text: "Running...", styles: [] },
236
+ ]);
237
+ });
238
+
239
+ it("handles empty tool name fallback in template", () => {
240
+ const segments = parseFormattedTemplate("{toolName}", " ");
241
+ // toolName is resolved before parsing, so whitespace stays
242
+ expect(segments).toEqual([
243
+ { text: " ", styles: [] },
244
+ ]);
245
+ });
246
+ });
@@ -87,6 +87,136 @@ export const describeToolTitle = (tool: AgentWidgetToolCall) => {
87
87
  return "Using tool...";
88
88
  };
89
89
 
90
+ /**
91
+ * Formats a millisecond duration as a short human-readable string.
92
+ * Returns "2.3s", "15s", or "<0.1s".
93
+ */
94
+ export const formatElapsedMs = (ms: number): string => {
95
+ const seconds = ms / 1000;
96
+ if (seconds < 0.1) return "<0.1s";
97
+ if (seconds >= 10) return `${Math.round(seconds)}s`;
98
+ return `${seconds.toFixed(1).replace(/\.0$/, "")}s`;
99
+ };
100
+
101
+ /**
102
+ * Computes the current elapsed time string for a tool call.
103
+ */
104
+ export const computeToolElapsed = (tool: AgentWidgetToolCall): string => {
105
+ const durationMs =
106
+ typeof tool.duration === "number"
107
+ ? tool.duration
108
+ : typeof tool.durationMs === "number"
109
+ ? tool.durationMs
110
+ : Math.max(
111
+ 0,
112
+ (tool.completedAt ?? Date.now()) -
113
+ (tool.startedAt ?? tool.completedAt ?? Date.now())
114
+ );
115
+ return formatElapsedMs(durationMs);
116
+ };
117
+
118
+ /**
119
+ * Resolves a text template with tool call placeholders.
120
+ * Supported placeholders: {toolName}, {duration}
121
+ * Returns the fallback if template is undefined.
122
+ */
123
+ export const resolveToolHeaderText = (
124
+ tool: AgentWidgetToolCall,
125
+ template: string | undefined,
126
+ fallback: string
127
+ ): string => {
128
+ if (!template) return fallback;
129
+
130
+ const toolName = tool.name?.trim() || "tool";
131
+ const duration = computeToolElapsed(tool);
132
+
133
+ return template
134
+ .replace(/\{toolName\}/g, toolName)
135
+ .replace(/\{duration\}/g, duration);
136
+ };
137
+
138
+ /**
139
+ * A segment of parsed template text with optional inline formatting.
140
+ */
141
+ export interface TemplateSegment {
142
+ /** The text content (or "{duration}" for duration placeholders) */
143
+ text: string;
144
+ /** CSS modifier names to apply: "dim", "bold", "italic" */
145
+ styles: string[];
146
+ /** True when this segment represents a {duration} placeholder */
147
+ isDuration?: boolean;
148
+ }
149
+
150
+ /**
151
+ * Parses a template string with inline formatting markers into segments.
152
+ *
153
+ * Supported markers (Markdown-like):
154
+ * - `**text**` → bold
155
+ * - `*text*` → italic
156
+ * - `~text~` → dim / muted
157
+ *
158
+ * Placeholders `{toolName}` are resolved; `{duration}` is preserved as a
159
+ * typed segment so the caller can render it as a live-updating DOM node.
160
+ *
161
+ * @example
162
+ * parseFormattedTemplate("Finished {toolName} ~{duration}~", "Get Weather")
163
+ * // → [
164
+ * // { text: "Finished Get Weather ", styles: [] },
165
+ * // { text: "{duration}", styles: ["dim"], isDuration: true }
166
+ * // ]
167
+ */
168
+ export const parseFormattedTemplate = (
169
+ template: string,
170
+ toolName: string
171
+ ): TemplateSegment[] => {
172
+ const resolved = template.replace(/\{toolName\}/g, toolName);
173
+ const segments: TemplateSegment[] = [];
174
+ // Order matters: ** must match before *
175
+ const regex = /\*\*(.+?)\*\*|\*(.+?)\*|~(.+?)~/g;
176
+
177
+ let lastIndex = 0;
178
+ let match;
179
+
180
+ while ((match = regex.exec(resolved)) !== null) {
181
+ if (match.index > lastIndex) {
182
+ pushSegments(segments, resolved.slice(lastIndex, match.index), []);
183
+ }
184
+
185
+ if (match[1] !== undefined) {
186
+ pushSegments(segments, match[1], ["bold"]);
187
+ } else if (match[2] !== undefined) {
188
+ pushSegments(segments, match[2], ["italic"]);
189
+ } else if (match[3] !== undefined) {
190
+ pushSegments(segments, match[3], ["dim"]);
191
+ }
192
+
193
+ lastIndex = match.index + match[0].length;
194
+ }
195
+
196
+ if (lastIndex < resolved.length) {
197
+ pushSegments(segments, resolved.slice(lastIndex), []);
198
+ }
199
+
200
+ return segments;
201
+ };
202
+
203
+ /** Splits text on {duration} and pushes typed segments. */
204
+ const pushSegments = (
205
+ segments: TemplateSegment[],
206
+ text: string,
207
+ styles: string[]
208
+ ): void => {
209
+ const parts = text.split("{duration}");
210
+ for (let i = 0; i < parts.length; i++) {
211
+ if (parts[i]) {
212
+ segments.push({ text: parts[i], styles });
213
+ }
214
+ if (i < parts.length - 1) {
215
+ segments.push({ text: "{duration}", styles, isDuration: true });
216
+ }
217
+ }
218
+ };
219
+
90
220
  /**
91
221
  * Creates a regex-based parser for extracting text from JSON streams.
92
222
  * This is a simpler alternative to schema-stream that uses regex to extract
@@ -21,14 +21,20 @@ export const morphMessages = (
21
21
  Idiomorph.morph(container, newContent.innerHTML, {
22
22
  morphStyle: "innerHTML",
23
23
  callbacks: {
24
- beforeNodeMorphed(oldNode: Node, _newNode: Node): boolean | void {
24
+ beforeNodeMorphed(oldNode: Node, newNode: Node): boolean | void {
25
25
  if (!(oldNode instanceof HTMLElement)) return;
26
26
 
27
27
  // Preserve typing indicator dots to maintain animation continuity
28
28
  // Also preserve elements with data-preserve-animation attribute for custom loading indicators
29
29
  if (preserveTypingAnimation) {
30
- if (oldNode.classList.contains("persona-animate-typing") ||
31
- oldNode.hasAttribute("data-preserve-animation")) {
30
+ if (oldNode.classList.contains("persona-animate-typing")) {
31
+ return false;
32
+ }
33
+ if (oldNode.hasAttribute("data-preserve-animation")) {
34
+ // Allow morph when the new node drops the attribute (e.g. tool completed)
35
+ if (newNode instanceof HTMLElement && !newNode.hasAttribute("data-preserve-animation")) {
36
+ return;
37
+ }
32
38
  return false;
33
39
  }
34
40
  }