@runtypelabs/persona 3.10.1 → 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.
@@ -1,11 +1,12 @@
1
1
  import { createElement } from "../utils/dom";
2
2
  import { AgentWidgetMessage, AgentWidgetConfig } from "../types";
3
- import { formatUnknownValue, describeToolTitle } from "../utils/formatting";
3
+ import { formatUnknownValue, describeToolTitle, resolveToolHeaderText, computeToolElapsed, parseFormattedTemplate } from "../utils/formatting";
4
4
  import { renderLucideIcon } from "../utils/icons";
5
5
 
6
6
  // Expansion state per widget instance
7
7
  export const toolExpansionState = new Set<string>();
8
8
 
9
+
9
10
  const appendRenderedValue = (
10
11
  container: HTMLElement,
11
12
  value: HTMLElement | string | null | undefined
@@ -59,6 +60,7 @@ const getToolSummaryText = (
59
60
  }
60
61
 
61
62
  const isActive = tool.status !== "complete";
63
+ const toolCallConfig = config?.toolCall ?? {};
62
64
  let summary = defaultSummary;
63
65
  if (collapsedMode === "tool-name") {
64
66
  summary = tool.name?.trim() || defaultSummary;
@@ -66,6 +68,13 @@ const getToolSummaryText = (
66
68
  summary = previewText;
67
69
  }
68
70
 
71
+ // Apply text templates if configured
72
+ if (isActive && toolCallConfig.activeTextTemplate) {
73
+ summary = resolveToolHeaderText(tool, toolCallConfig.activeTextTemplate, summary);
74
+ } else if (!isActive && toolCallConfig.completeTextTemplate) {
75
+ summary = resolveToolHeaderText(tool, toolCallConfig.completeTextTemplate, summary);
76
+ }
77
+
69
78
  return { summary, previewText, isActive };
70
79
  };
71
80
 
@@ -151,6 +160,7 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
151
160
  const expandable = toolDisplayConfig.expandable !== false;
152
161
  let expanded = expandable && toolExpansionState.has(message.id);
153
162
  const { summary, previewText, isActive } = getToolSummaryText(message, config);
163
+
154
164
  const header = createElement(
155
165
  "button",
156
166
  expandable
@@ -182,6 +192,18 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
182
192
  if (toolCallConfig.headerTextColor) {
183
193
  title.style.color = toolCallConfig.headerTextColor;
184
194
  }
195
+
196
+ // Elapsed helpers — defined early so they're available to renderCollapsedSummary
197
+ const startedAt = String(tool.startedAt ?? Date.now());
198
+
199
+ // Helper: build a <span data-tool-elapsed> that the global timer in ui.ts updates
200
+ const createElapsedSpan = (): HTMLElement => {
201
+ const span = createElement("span", "");
202
+ span.setAttribute("data-tool-elapsed", startedAt);
203
+ span.textContent = computeToolElapsed(tool);
204
+ return span;
205
+ };
206
+
185
207
  const customSummary = toolCallConfig.renderCollapsedSummary?.({
186
208
  message,
187
209
  toolCall: tool,
@@ -190,6 +212,8 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
190
212
  collapsedMode: toolDisplayConfig.collapsedMode ?? "tool-call",
191
213
  isActive,
192
214
  config: config ?? {},
215
+ elapsed: computeToolElapsed(tool),
216
+ createElapsedElement: createElapsedSpan,
193
217
  });
194
218
  if (typeof customSummary === "string" && customSummary.trim()) {
195
219
  title.textContent = customSummary;
@@ -201,6 +225,102 @@ export const createToolBubble = (message: AgentWidgetMessage, config?: AgentWidg
201
225
  headerContent.appendChild(title);
202
226
  }
203
227
 
228
+ // Apply loading animation when tool is active and no custom HTMLElement was provided
229
+ const loadingAnimation = toolDisplayConfig.loadingAnimation ?? "none";
230
+ const activeTemplate = toolCallConfig.activeTextTemplate;
231
+ const completeTemplate = toolCallConfig.completeTextTemplate;
232
+ const currentTemplate = isActive ? activeTemplate : completeTemplate;
233
+ const skipCustomElement = customSummary instanceof HTMLElement;
234
+
235
+ // Helper: append text as individual animated character spans
236
+ const appendCharSpans = (container: HTMLElement, text: string, startIndex: number): number => {
237
+ let idx = startIndex;
238
+ for (const char of text) {
239
+ const span = createElement("span", "persona-tool-char");
240
+ span.style.setProperty("--char-index", String(idx));
241
+ span.textContent = char === " " ? "\u00A0" : char;
242
+ container.appendChild(span);
243
+ idx++;
244
+ }
245
+ return idx;
246
+ };
247
+
248
+ /**
249
+ * Renders a template into the title element, handling:
250
+ * - Inline formatting markers: **bold**, *italic*, ~dim~
251
+ * - {duration} as a live-updating elapsed span (active) or static text (complete)
252
+ * - Character-by-character animation wrapping when `animated` is true
253
+ */
254
+ const renderFormattedTitle = (template: string, animated: boolean) => {
255
+ title.textContent = "";
256
+ const toolName = tool.name?.trim() || "tool";
257
+ const segments = parseFormattedTemplate(template, toolName);
258
+ let charIndex = 0;
259
+
260
+ for (const seg of segments) {
261
+ // Determine parent: wrap in a styled span if formatting is present
262
+ const parent = seg.styles.length > 0
263
+ ? (() => {
264
+ const w = createElement("span", seg.styles.map(s => `persona-tool-text-${s}`).join(" "));
265
+ title.appendChild(w);
266
+ return w;
267
+ })()
268
+ : title;
269
+
270
+ if (seg.isDuration && isActive) {
271
+ // Live-updating elapsed span for active tools
272
+ parent.appendChild(createElapsedSpan());
273
+ } else {
274
+ // Static text (or resolved duration for completed tools)
275
+ const text = seg.isDuration ? computeToolElapsed(tool) : seg.text;
276
+ if (animated) {
277
+ charIndex = appendCharSpans(parent, text, charIndex);
278
+ } else {
279
+ parent.appendChild(document.createTextNode(text));
280
+ }
281
+ }
282
+ }
283
+ };
284
+
285
+ if (!skipCustomElement) {
286
+ if (isActive && loadingAnimation !== "none") {
287
+ const animDuration = toolCallConfig.loadingAnimationDuration ?? 2000;
288
+ title.setAttribute("data-preserve-animation", "true");
289
+
290
+ if (loadingAnimation === "pulse") {
291
+ title.classList.add("persona-tool-loading-pulse");
292
+ title.style.setProperty("--persona-tool-anim-duration", `${animDuration}ms`);
293
+ if (currentTemplate) {
294
+ renderFormattedTitle(currentTemplate, false);
295
+ }
296
+ } else {
297
+ // Character-by-character modes: shimmer, shimmer-color, rainbow
298
+ title.classList.add(`persona-tool-loading-${loadingAnimation}`);
299
+ title.style.setProperty("--persona-tool-anim-duration", `${animDuration}ms`);
300
+
301
+ if (loadingAnimation === "shimmer-color") {
302
+ if (toolCallConfig.loadingAnimationColor) {
303
+ title.style.setProperty("--persona-tool-anim-color", toolCallConfig.loadingAnimationColor);
304
+ }
305
+ if (toolCallConfig.loadingAnimationSecondaryColor) {
306
+ title.style.setProperty("--persona-tool-anim-secondary-color", toolCallConfig.loadingAnimationSecondaryColor);
307
+ }
308
+ }
309
+
310
+ if (currentTemplate) {
311
+ renderFormattedTitle(currentTemplate, true);
312
+ } else {
313
+ const text = title.textContent || summary;
314
+ title.textContent = "";
315
+ appendCharSpans(title, text, 0);
316
+ }
317
+ }
318
+ } else if (currentTemplate) {
319
+ // Template with formatting but no animation (or completed tool)
320
+ renderFormattedTitle(currentTemplate, false);
321
+ }
322
+ }
323
+
204
324
  let toggleIcon: HTMLElement | null = null;
205
325
  if (expandable) {
206
326
  toggleIcon = createElement("div", "persona-flex persona-items-center");
package/src/defaults.ts CHANGED
@@ -123,6 +123,7 @@ export const DEFAULT_WIDGET_CONFIG: Partial<AgentWidgetConfig> = {
123
123
  grouped: false,
124
124
  previewMaxLines: 3,
125
125
  expandable: true,
126
+ loadingAnimation: "none",
126
127
  },
127
128
  reasoningDisplay: {
128
129
  activePreview: false,
@@ -1220,6 +1220,86 @@
1220
1220
  box-shadow: var(--persona-tool-bubble-shadow, 0 5px 15px rgba(15, 23, 42, 0.08));
1221
1221
  }
1222
1222
 
1223
+ /* ==============================
1224
+ Tool call loading animations
1225
+ ============================== */
1226
+
1227
+ /* Inline formatting classes for template text */
1228
+ [data-persona-root] .persona-tool-text-dim {
1229
+ opacity: 0.5;
1230
+ }
1231
+ [data-persona-root] .persona-tool-text-bold {
1232
+ font-weight: 600;
1233
+ }
1234
+ [data-persona-root] .persona-tool-text-italic {
1235
+ font-style: italic;
1236
+ }
1237
+
1238
+ /* Pulse mode: entire text element pulses opacity */
1239
+ @keyframes persona-tool-loading-pulse {
1240
+ 0%, 100% { opacity: 1; }
1241
+ 50% { opacity: 0.4; }
1242
+ }
1243
+
1244
+ [data-persona-root] .persona-tool-loading-pulse {
1245
+ animation: persona-tool-loading-pulse var(--persona-tool-anim-duration, 2s) ease-in-out infinite;
1246
+ }
1247
+
1248
+ /* Shimmer mode: monochrome brightness sweep per character */
1249
+ @keyframes persona-tool-loading-shimmer {
1250
+ 0%, 100% { opacity: 0.4; }
1251
+ 50% { opacity: 1; }
1252
+ }
1253
+
1254
+ [data-persona-root] .persona-tool-loading-shimmer .persona-tool-char {
1255
+ display: inline-block;
1256
+ animation: persona-tool-loading-shimmer var(--persona-tool-anim-duration, 2s) ease-in-out infinite;
1257
+ animation-delay: calc(var(--char-index, 0) * 60ms);
1258
+ }
1259
+
1260
+ /* Shimmer-color mode: color gradient sweep per character */
1261
+ @keyframes persona-tool-loading-shimmer-color {
1262
+ 0%, 100% {
1263
+ color: var(--persona-tool-anim-color, currentColor);
1264
+ }
1265
+ 50% {
1266
+ color: var(--persona-tool-anim-secondary-color, #3b82f6);
1267
+ }
1268
+ }
1269
+
1270
+ [data-persona-root] .persona-tool-loading-shimmer-color .persona-tool-char {
1271
+ display: inline-block;
1272
+ animation: persona-tool-loading-shimmer-color var(--persona-tool-anim-duration, 2s) ease-in-out infinite;
1273
+ animation-delay: calc(var(--char-index, 0) * 60ms);
1274
+ }
1275
+
1276
+ /* Rainbow mode: hue rotation per character */
1277
+ @keyframes persona-tool-loading-rainbow {
1278
+ 0% { color: #ef4444; }
1279
+ 16% { color: #f59e0b; }
1280
+ 33% { color: #22c55e; }
1281
+ 50% { color: #06b6d4; }
1282
+ 66% { color: #3b82f6; }
1283
+ 83% { color: #8b5cf6; }
1284
+ 100% { color: #ef4444; }
1285
+ }
1286
+
1287
+ [data-persona-root] .persona-tool-loading-rainbow .persona-tool-char {
1288
+ display: inline-block;
1289
+ animation: persona-tool-loading-rainbow var(--persona-tool-anim-duration, 2s) linear infinite;
1290
+ animation-delay: calc(var(--char-index, 0) * 80ms);
1291
+ }
1292
+
1293
+ /* Prefers-reduced-motion: disable all tool loading animations */
1294
+ @media (prefers-reduced-motion: reduce) {
1295
+ [data-persona-root] .persona-tool-loading-pulse,
1296
+ [data-persona-root] .persona-tool-loading-shimmer .persona-tool-char,
1297
+ [data-persona-root] .persona-tool-loading-shimmer-color .persona-tool-char,
1298
+ [data-persona-root] .persona-tool-loading-rainbow .persona-tool-char {
1299
+ animation: none !important;
1300
+ }
1301
+ }
1302
+
1223
1303
  [data-persona-root] .persona-reasoning-bubble.persona-shadow-sm {
1224
1304
  box-shadow: var(--persona-reasoning-bubble-shadow, 0 5px 15px rgba(15, 23, 42, 0.08));
1225
1305
  }
@@ -210,9 +210,12 @@ export const THEME_TOKEN_DOCS = {
210
210
  'features.scrollToBottom.enabled, features.scrollToBottom.iconName, features.scrollToBottom.label (empty string renders icon-only). Defaults: enabled=true, iconName="arrow-down", label="".',
211
211
  },
212
212
  toolCall: {
213
- description: 'Tool call display styling and collapsed/grouped rendering hooks.',
213
+ description:
214
+ 'Tool call display styling, text templates, loading animations, and rendering hooks. ' +
215
+ 'Text templates support placeholders ({toolName}, {duration}) and inline formatting (~dim~, *italic*, **bold**). ' +
216
+ 'renderCollapsedSummary receives elapsed (static string) and createElapsedElement() (live-updating span) in its context.',
214
217
  properties:
215
- 'shadow, backgroundColor, borderColor, borderWidth, borderRadius, headerBackgroundColor, headerTextColor, headerPaddingX, headerPaddingY, contentBackgroundColor, contentTextColor, contentPaddingX, contentPaddingY, codeBlockBackgroundColor, codeBlockBorderColor, codeBlockTextColor, toggleTextColor, labelTextColor, renderCollapsedSummary, renderCollapsedPreview, renderGroupedSummary.',
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.',
216
219
  },
217
220
  reasoning: {
218
221
  description: 'Reasoning/thinking row rendering hooks.',
@@ -282,7 +285,7 @@ export const THEME_TOKEN_DOCS = {
282
285
  features: {
283
286
  description: 'Feature flags.',
284
287
  properties:
285
- 'showReasoning (AI thinking steps), showToolCalls (tool invocations), toolCallDisplay (collapsedMode, activePreview, activeMinHeight, previewMaxLines, grouped), reasoningDisplay (activePreview, activeMinHeight, previewMaxLines), artifacts (sidebar config).',
288
+ 'showReasoning (AI thinking steps), showToolCalls (tool invocations), toolCallDisplay (collapsedMode, activePreview, activeMinHeight, previewMaxLines, grouped, expandable, loadingAnimation), reasoningDisplay (activePreview, activeMinHeight, previewMaxLines, expandable), artifacts (sidebar config).',
286
289
  },
287
290
  },
288
291
  }
@@ -10,6 +10,7 @@ describe("tool call display defaults", () => {
10
10
  grouped: false,
11
11
  previewMaxLines: 3,
12
12
  expandable: true,
13
+ loadingAnimation: "none",
13
14
  });
14
15
  });
15
16
 
package/src/types.ts CHANGED
@@ -577,6 +577,19 @@ export type AgentWidgetToolCallCollapsedMode =
577
577
  | "tool-name"
578
578
  | "tool-preview";
579
579
 
580
+ /**
581
+ * Animation mode applied to tool call header text while the tool is running.
582
+ * Character-by-character modes (`shimmer`, `shimmer-color`, `rainbow`) wrap each
583
+ * character in a span with staggered `animation-delay`. `pulse` applies to the
584
+ * entire text container. Honors `prefers-reduced-motion`.
585
+ */
586
+ export type AgentWidgetToolCallLoadingAnimation =
587
+ | "none"
588
+ | "pulse"
589
+ | "shimmer"
590
+ | "shimmer-color"
591
+ | "rainbow";
592
+
580
593
  export type AgentWidgetToolCallDisplayFeature = {
581
594
  /**
582
595
  * Controls what collapsed tool call rows show in their header/summary area.
@@ -590,6 +603,8 @@ export type AgentWidgetToolCallDisplayFeature = {
590
603
  activePreview?: boolean;
591
604
  /**
592
605
  * Optional CSS min-height applied to active collapsed tool call rows.
606
+ * @default undefined (no min-height)
607
+ * @example "100px"
593
608
  */
594
609
  activeMinHeight?: string;
595
610
  /**
@@ -608,6 +623,16 @@ export type AgentWidgetToolCallDisplayFeature = {
608
623
  * @default true
609
624
  */
610
625
  expandable?: boolean;
626
+ /**
627
+ * Animation mode applied to the tool call header text while the tool is active.
628
+ * - "none" — static text, no animation
629
+ * - "pulse" — opacity pulse on the entire header text
630
+ * - "shimmer" — monochrome opacity sweep per character
631
+ * - "shimmer-color" — color gradient sweep per character
632
+ * - "rainbow" — rainbow color cycle per character
633
+ * @default "none"
634
+ */
635
+ loadingAnimation?: AgentWidgetToolCallLoadingAnimation;
611
636
  };
612
637
 
613
638
  export type AgentWidgetReasoningDisplayFeature = {
@@ -1234,22 +1259,39 @@ export type AgentWidgetApprovalConfig = {
1234
1259
  export type AgentWidgetToolCallConfig = {
1235
1260
  /** Box-shadow for tool-call bubbles; overrides `theme.toolBubbleShadow` when set. */
1236
1261
  shadow?: string;
1262
+ /** Background color of the tool call bubble container. */
1237
1263
  backgroundColor?: string;
1264
+ /** Border color of the tool call bubble container. */
1238
1265
  borderColor?: string;
1266
+ /** Border width of the tool call bubble container (CSS value, e.g. `"1px"`). */
1239
1267
  borderWidth?: string;
1268
+ /** Border radius of the tool call bubble container (CSS value, e.g. `"12px"`). */
1240
1269
  borderRadius?: string;
1270
+ /** Background color of the collapsed header row. */
1241
1271
  headerBackgroundColor?: string;
1272
+ /** Text color of the collapsed header row (tool name / summary). */
1242
1273
  headerTextColor?: string;
1274
+ /** Horizontal padding of the collapsed header row (CSS value). */
1243
1275
  headerPaddingX?: string;
1276
+ /** Vertical padding of the collapsed header row (CSS value). */
1244
1277
  headerPaddingY?: string;
1278
+ /** Background color of the expanded content area. */
1245
1279
  contentBackgroundColor?: string;
1280
+ /** Text color of the expanded content area. */
1246
1281
  contentTextColor?: string;
1282
+ /** Horizontal padding of the expanded content area (CSS value). */
1247
1283
  contentPaddingX?: string;
1284
+ /** Vertical padding of the expanded content area (CSS value). */
1248
1285
  contentPaddingY?: string;
1286
+ /** Background color of code blocks (arguments / result) in the expanded area. */
1249
1287
  codeBlockBackgroundColor?: string;
1288
+ /** Border color of code blocks in the expanded area. */
1250
1289
  codeBlockBorderColor?: string;
1290
+ /** Text color of code blocks in the expanded area. */
1251
1291
  codeBlockTextColor?: string;
1292
+ /** Color of the expand/collapse toggle icon. */
1252
1293
  toggleTextColor?: string;
1294
+ /** Color of section labels ("Arguments", "Result", "Activity") in the expanded area. */
1253
1295
  labelTextColor?: string;
1254
1296
  /**
1255
1297
  * Override the collapsed summary row content for a tool call bubble.
@@ -1263,6 +1305,14 @@ export type AgentWidgetToolCallConfig = {
1263
1305
  collapsedMode: AgentWidgetToolCallCollapsedMode;
1264
1306
  isActive: boolean;
1265
1307
  config: AgentWidgetConfig;
1308
+ /** Static elapsed time snapshot, e.g. "2.6s". */
1309
+ elapsed: string;
1310
+ /**
1311
+ * Returns a `<span>` whose text content is automatically updated every
1312
+ * 100ms by the widget's global timer. Place it anywhere in your returned
1313
+ * HTMLElement to get a live-ticking duration display.
1314
+ */
1315
+ createElapsedElement: () => HTMLElement;
1266
1316
  }) => HTMLElement | string | null;
1267
1317
  /**
1268
1318
  * Override the lightweight collapsed preview content shown for active tool rows.
@@ -1285,6 +1335,47 @@ export type AgentWidgetToolCallConfig = {
1285
1335
  defaultSummary: string;
1286
1336
  config: AgentWidgetConfig;
1287
1337
  }) => HTMLElement | string | null;
1338
+ /**
1339
+ * Template string for the header text while a tool call is active (running).
1340
+ *
1341
+ * **Placeholders:** `{toolName}` (tool name), `{duration}` (live-updating elapsed time).
1342
+ *
1343
+ * **Inline formatting:** `~dim~`, `*italic*`, `**bold**` — parsed at render time and
1344
+ * applied as styled `<span>` elements. Works with all animation modes.
1345
+ *
1346
+ * When not set, falls back to the current `collapsedMode` behavior.
1347
+ * @example "Calling {toolName}... ~{duration}~"
1348
+ * @example "**Searching** *{toolName}*..."
1349
+ */
1350
+ activeTextTemplate?: string;
1351
+ /**
1352
+ * Template string for the header text when a tool call is complete.
1353
+ *
1354
+ * **Placeholders:** `{toolName}` (tool name), `{duration}` (final elapsed time).
1355
+ *
1356
+ * **Inline formatting:** `~dim~`, `*italic*`, `**bold**` — same syntax as `activeTextTemplate`.
1357
+ *
1358
+ * When not set, falls back to the existing "Used tool for X seconds" text.
1359
+ * @example "Finished {toolName} ~{duration}~"
1360
+ */
1361
+ completeTextTemplate?: string;
1362
+ /**
1363
+ * Primary color for shimmer-color animation mode.
1364
+ * Defaults to the current text color.
1365
+ */
1366
+ loadingAnimationColor?: string;
1367
+ /**
1368
+ * Secondary/end color for shimmer-color animation mode.
1369
+ * Creates a gradient sweep between `loadingAnimationColor` and this color.
1370
+ * @default "#3b82f6"
1371
+ */
1372
+ loadingAnimationSecondaryColor?: string;
1373
+ /**
1374
+ * Duration of one full animation cycle in milliseconds.
1375
+ * Applies to pulse, shimmer, shimmer-color, and rainbow modes.
1376
+ * @default 2000
1377
+ */
1378
+ loadingAnimationDuration?: number;
1288
1379
  };
1289
1380
 
1290
1381
  export type AgentWidgetReasoningConfig = {
@@ -34,6 +34,16 @@ const installRafMock = () => {
34
34
  });
35
35
 
36
36
  return {
37
+ step(frameCount = 1) {
38
+ let frames = 0;
39
+ while (callbacks.size > 0 && frames < frameCount) {
40
+ const pending = [...callbacks.entries()];
41
+ callbacks.clear();
42
+ frames += 1;
43
+ now += 16;
44
+ pending.forEach(([, callback]) => callback(now));
45
+ }
46
+ },
37
47
  flush(maxFrames = 80) {
38
48
  let frames = 0;
39
49
  while (callbacks.size > 0 && frames < maxFrames) {
@@ -215,14 +225,14 @@ describe("createAgentExperience streaming scroll", () => {
215
225
 
216
226
  expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
217
227
 
218
- metrics.setScrollTop(metrics.getBottomScrollTop() - 3);
228
+ metrics.setScrollTop(metrics.getBottomScrollTop() - 6);
219
229
  scrollContainer!.dispatchEvent(new Event("scroll"));
220
230
 
221
231
  metrics.setScrollHeight(1040);
222
232
  emitStreamingMessage(controller, "Second chunk");
223
233
  raf.flush();
224
234
 
225
- expect(metrics.getScrollTop()).toBe(597);
235
+ expect(metrics.getScrollTop()).toBe(594);
226
236
 
227
237
  controller.destroy();
228
238
  });
@@ -364,6 +374,39 @@ describe("createAgentExperience streaming scroll", () => {
364
374
  controller.destroy();
365
375
  });
366
376
 
377
+ it("catches up immediately when a streamed update lands far behind", () => {
378
+ const raf = installRafMock();
379
+ const mount = createMount();
380
+ const controller = createAgentExperience(mount, {
381
+ apiUrl: "https://api.example.com/chat",
382
+ launcher: { enabled: false }
383
+ });
384
+
385
+ const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
386
+ expect(scrollContainer).not.toBeNull();
387
+
388
+ const metrics = installScrollMetrics(scrollContainer!, {
389
+ scrollHeight: 900,
390
+ clientHeight: 400
391
+ });
392
+
393
+ emitStreamingStatus(controller);
394
+ emitStreamingMessage(controller, "Chunk one");
395
+ raf.flush();
396
+
397
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
398
+
399
+ metrics.setScrollHeight(1080);
400
+ emitStreamingMessage(controller, "Chunk two");
401
+
402
+ // Only run the scheduled auto-scroll frame, not the whole animation.
403
+ raf.step(1);
404
+
405
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
406
+
407
+ controller.destroy();
408
+ });
409
+
367
410
  it("lets the user break away during reasoning streaming", () => {
368
411
  const raf = installRafMock();
369
412
  const mount = createMount();
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";
@@ -1989,8 +1990,13 @@ export const createAgentExperience = (
1989
1990
  let isAutoScrolling = false;
1990
1991
  let hasPendingAutoScroll = false;
1991
1992
 
1992
- const USER_SCROLL_THRESHOLD = 1;
1993
- const BOTTOM_THRESHOLD = 8;
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;
1994
2000
  const messageState = new Map<
1995
2001
  string,
1996
2002
  { streaming?: boolean; role: AgentWidgetMessage["role"] }
@@ -2177,6 +2183,18 @@ export const createAgentExperience = (
2177
2183
  return;
2178
2184
  }
2179
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
+
2180
2198
  // Cancel any ongoing smooth scroll animation
2181
2199
  cancelSmoothScroll();
2182
2200
 
@@ -2960,9 +2978,33 @@ export const createAgentExperience = (
2960
2978
  };
2961
2979
  }
2962
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
+
2963
3003
  session = new AgentWidgetSession(config, {
2964
3004
  onMessagesChanged(messages) {
2965
3005
  renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
3006
+ // Start elapsed timer if any active tool has a live duration span
3007
+ ensureToolElapsedTimer();
2966
3008
  // Re-render suggestions to hide them after first user message
2967
3009
  // Pass messages directly to avoid calling session.getMessages() during construction
2968
3010
  if (session) {
@@ -5716,6 +5758,10 @@ export const createAgentExperience = (
5716
5758
  return session.submitNPSFeedback(rating, comment);
5717
5759
  },
5718
5760
  destroy() {
5761
+ if (toolElapsedTimerId != null) {
5762
+ clearInterval(toolElapsedTimerId);
5763
+ toolElapsedTimerId = null;
5764
+ }
5719
5765
  destroyCallbacks.forEach((cb) => cb());
5720
5766
  wrapper.remove();
5721
5767
  launcherButtonInstance?.destroy();