@runtypelabs/persona 4.31.0 → 4.32.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 (120) hide show
  1. package/dist/activity-ui.cjs +2 -2
  2. package/dist/activity-ui.d.cts +11 -3
  3. package/dist/activity-ui.d.ts +11 -3
  4. package/dist/activity-ui.js +1 -1
  5. package/dist/animations/glyph-cycle.d.cts +1 -1
  6. package/dist/animations/glyph-cycle.d.ts +1 -1
  7. package/dist/animations/wipe.d.cts +1 -1
  8. package/dist/animations/wipe.d.ts +1 -1
  9. package/dist/approval-ui.d.cts +2 -2
  10. package/dist/approval-ui.d.ts +2 -2
  11. package/dist/artifacts-ui.d.cts +3 -3
  12. package/dist/artifacts-ui.d.ts +3 -3
  13. package/dist/background-runtime.d.cts +1 -1
  14. package/dist/background-runtime.d.ts +1 -1
  15. package/dist/chunk-CkE90FtA.js +2 -0
  16. package/dist/{chunk-Co4LmTFv.js → chunk-CzZBmTLd.js} +1 -1
  17. package/dist/chunk-DDAwu89-.js +1 -0
  18. package/dist/{chunk-WA9UucGf.js → chunk-DZg9bFK6.js} +1 -1
  19. package/dist/{chunk-BBtVdcKU.js → chunk-FEzOcZof.js} +1 -1
  20. package/dist/client-dispatch.js +1 -1
  21. package/dist/client-history.js +1 -1
  22. package/dist/codegen.cjs +1 -1
  23. package/dist/codegen.js +1 -1
  24. package/dist/composer-actions-runtime.d.cts +2 -2
  25. package/dist/composer-actions-runtime.d.ts +2 -2
  26. package/dist/{composer-input-C2_WizXV.d.cts → composer-input-dwg_o9Z-.d.cts} +1 -1
  27. package/dist/{composer-input-C2_WizXV.d.ts → composer-input-dwg_o9Z-.d.ts} +1 -1
  28. package/dist/{composer-store-BV6kTlKv.d.cts → composer-store-Dw7Zbqxr.d.cts} +1 -1
  29. package/dist/{composer-store-BV6kTlKv.d.ts → composer-store-Dw7Zbqxr.d.ts} +1 -1
  30. package/dist/{context-mention-manager-BfZzcydV.d.cts → context-mention-manager-D2p0-JH-.d.cts} +2 -2
  31. package/dist/{context-mention-manager-BfZzcydV.d.ts → context-mention-manager-D2p0-JH-.d.ts} +2 -2
  32. package/dist/context-mentions-inline.d.cts +2 -2
  33. package/dist/context-mentions-inline.d.ts +2 -2
  34. package/dist/context-mentions.d.cts +3 -3
  35. package/dist/context-mentions.d.ts +3 -3
  36. package/dist/{defaults-DiTeXyr3.d.cts → defaults-CjX0kKzb.d.cts} +1 -1
  37. package/dist/{defaults-DiTeXyr3.d.ts → defaults-CjX0kKzb.d.ts} +1 -1
  38. package/dist/dictation-runtime.d.cts +2 -2
  39. package/dist/dictation-runtime.d.ts +2 -2
  40. package/dist/event-stream-view.d.cts +1 -1
  41. package/dist/event-stream-view.d.ts +1 -1
  42. package/dist/{fallback-speech-engine-DZ6Of7VU.d.cts → fallback-speech-engine-DmF7MczI.d.cts} +1 -1
  43. package/dist/{fallback-speech-engine-DZ6Of7VU.d.ts → fallback-speech-engine-DmF7MczI.d.ts} +1 -1
  44. package/dist/{feature-preferences-BT549D_E.d.cts → feature-preferences-CVFDt9Xf.d.cts} +1 -1
  45. package/dist/{feature-preferences-BT549D_E.d.ts → feature-preferences-CVFDt9Xf.d.ts} +1 -1
  46. package/dist/forms-ui.d.cts +2 -2
  47. package/dist/forms-ui.d.ts +2 -2
  48. package/dist/{history-confirm-dialog-Cm4oK4am.d.cts → history-confirm-dialog-DJEXsTyE.d.cts} +1 -1
  49. package/dist/{history-confirm-dialog-Cm4oK4am.d.ts → history-confirm-dialog-DJEXsTyE.d.ts} +1 -1
  50. package/dist/history-shell.d.cts +6 -6
  51. package/dist/history-shell.d.ts +6 -6
  52. package/dist/history-view.d.cts +1 -1
  53. package/dist/history-view.d.ts +1 -1
  54. package/dist/index.cjs +10 -9
  55. package/dist/index.cjs.map +1 -1
  56. package/dist/index.d.cts +9 -9
  57. package/dist/index.d.ts +9 -9
  58. package/dist/index.global.js +1 -1
  59. package/dist/index.global.js.map +1 -1
  60. package/dist/index.js +23 -22
  61. package/dist/index.js.map +1 -1
  62. package/dist/{loading-label-runtime-loader-DmiPDkBu.d.cts → loading-label-runtime-loader-CPxnoaoi.d.cts} +1 -1
  63. package/dist/{loading-label-runtime-loader-DmiPDkBu.d.ts → loading-label-runtime-loader-CPxnoaoi.d.ts} +1 -1
  64. package/dist/loading-label-runtime.d.cts +1 -1
  65. package/dist/loading-label-runtime.d.ts +1 -1
  66. package/dist/{message-bubble-D0rosKuK.d.cts → message-bubble-CkZGlCLC.d.cts} +1 -1
  67. package/dist/{message-bubble-D0rosKuK.d.ts → message-bubble-CkZGlCLC.d.ts} +1 -1
  68. package/dist/preferences-runtime.d.cts +1 -1
  69. package/dist/preferences-runtime.d.ts +1 -1
  70. package/dist/{preview-DRU27q-X.d.cts → preview-DdUvoLb4.d.cts} +2 -2
  71. package/dist/{preview-DRU27q-X.d.ts → preview-DdUvoLb4.d.ts} +2 -2
  72. package/dist/{registry-DRk3VMFc.d.cts → registry-Dz4iHNST.d.cts} +1 -1
  73. package/dist/{registry-DRk3VMFc.d.ts → registry-Dz4iHNST.d.ts} +1 -1
  74. package/dist/{session-Dj4bOm5y.d.cts → session-Baxp9iRK.d.cts} +14 -8
  75. package/dist/{session-Dj4bOm5y.d.ts → session-Baxp9iRK.d.ts} +14 -8
  76. package/dist/session-reconnect.d.cts +2 -2
  77. package/dist/session-reconnect.d.ts +2 -2
  78. package/dist/smart-dom-reader.d.cts +1 -1
  79. package/dist/smart-dom-reader.d.ts +1 -1
  80. package/dist/theme-editor-preview.cjs +46 -45
  81. package/dist/theme-editor-preview.d.cts +1 -1
  82. package/dist/theme-editor-preview.d.ts +1 -1
  83. package/dist/theme-editor-preview.js +19 -18
  84. package/dist/theme-editor.d.cts +2 -2
  85. package/dist/theme-editor.d.ts +2 -2
  86. package/dist/theme-reference.d.cts +2 -2
  87. package/dist/theme-reference.d.ts +2 -2
  88. package/dist/{tool-loading-animation-D1MS5Ace.d.cts → tool-loading-animation-un3kA5Ur.d.cts} +1 -1
  89. package/dist/{tool-loading-animation-D1MS5Ace.d.ts → tool-loading-animation-un3kA5Ur.d.ts} +1 -1
  90. package/dist/{turn-activity-ui-CorwdlI0.d.cts → turn-activity-ui-BGNhtMcH.d.cts} +2 -2
  91. package/dist/{turn-activity-ui-CorwdlI0.d.ts → turn-activity-ui-BGNhtMcH.d.ts} +2 -2
  92. package/dist/turn-activity-ui.d.cts +1 -1
  93. package/dist/turn-activity-ui.d.ts +1 -1
  94. package/dist/{types-CSXHl5cN.d.cts → types-BOZHQIk0.d.cts} +8 -1
  95. package/dist/{types-CSXHl5cN.d.ts → types-BOZHQIk0.d.ts} +8 -1
  96. package/dist/voice-client-tools.d.cts +1 -1
  97. package/dist/voice-client-tools.d.ts +1 -1
  98. package/dist/voice-runtime.cjs +1 -1
  99. package/dist/voice-runtime.d.cts +2 -2
  100. package/dist/voice-runtime.d.ts +2 -2
  101. package/dist/voice-runtime.js +1 -1
  102. package/dist/voice-worklet-player.d.cts +2 -2
  103. package/dist/voice-worklet-player.d.ts +2 -2
  104. package/dist/{webmcp-runtime-entry-DpDN6k-6.d.cts → webmcp-runtime-entry-DuAG1_yF.d.cts} +1 -1
  105. package/dist/{webmcp-runtime-entry-DpDN6k-6.d.ts → webmcp-runtime-entry-DuAG1_yF.d.ts} +1 -1
  106. package/dist/webmcp-runtime.d.cts +1 -1
  107. package/dist/webmcp-runtime.d.ts +1 -1
  108. package/dist/widget.css +1 -1
  109. package/package.json +1 -1
  110. package/src/activity-ui.ts +23 -0
  111. package/src/components/disclosure-animation.ts +5 -0
  112. package/src/components/tool-bubble.ts +1 -0
  113. package/src/index-core.ts +1 -0
  114. package/src/styles/widget.css +6 -0
  115. package/src/types.ts +8 -0
  116. package/src/ui.footer-visibility.test.ts +54 -0
  117. package/src/ui.reveal-tool-call.test.ts +258 -0
  118. package/src/ui.ts +127 -5
  119. package/dist/chunk-B3OrH9Ng.js +0 -2
  120. package/dist/chunk-CjhA_5Jk.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.31.0",
3
+ "version": "4.32.0",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -8,6 +8,29 @@ import { updateToolBubbleUI } from "./components/tool-bubble";
8
8
  import { updateReasoningBubbleUI } from "./components/reasoning-bubble";
9
9
  import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
10
10
  import { createActivityOverflow } from "./utils/activity-overflow";
11
+ import { afterDisclosureAnimation } from "./components/disclosure-animation";
12
+
13
+ type DisclosureAnchor = { spacerHeight: number; initialSpacerHeight: number; contentHeightAtAnchor: number; pending: number };
14
+
15
+ /** Keep scroll reserve throughout manual disclosure animations and reversals. */
16
+ export function holdScroll(
17
+ bubble: HTMLElement, body: HTMLElement, anchor: DisclosureAnchor,
18
+ measure: (spacer: number) => number, setSpacer: (height: number) => void,
19
+ slack: number, isCurrent: () => boolean
20
+ ): () => void {
21
+ // Reserve the whole possible collapse before changing display or starting an
22
+ // animation. ResizeObserver must not drain it during intermediate frames.
23
+ setSpacer(anchor.spacerHeight + bubble.offsetHeight);
24
+ anchor.pending++;
25
+ return () => afterDisclosureAnimation(bubble, () => {
26
+ if (--anchor.pending || !isCurrent()) return;
27
+ // Respect scrolling that the reader did while the animation was running.
28
+ setSpacer(body.scrollTop + body.clientHeight + slack - measure(anchor.spacerHeight));
29
+ anchor.initialSpacerHeight = anchor.spacerHeight;
30
+ // Match the resize drain, including the flex gap before the spacer.
31
+ anchor.contentHeightAtAnchor = body.scrollHeight - anchor.spacerHeight;
32
+ });
33
+ }
11
34
 
12
35
  /** Keep automatic disclosure updates in the optional activity chunk. */
13
36
  export function createActivityLifecycle(
@@ -7,6 +7,11 @@ type Settings = { animation: "instant" | "height"; duration: number; easing: str
7
7
  type Running = { effect: Animation; expanded: boolean; settings: string; finish: () => void };
8
8
  const running = new WeakMap<HTMLElement, Running>();
9
9
 
10
+ /** Observe this disclosure's height effect without waiting for looping children. */
11
+ export function afterDisclosureAnimation(bubble: HTMLElement, finish: () => void): void {
12
+ running.get(bubble)?.effect.finished?.then(finish, finish) ?? finish();
13
+ }
14
+
10
15
  export function disclosureSettings(config?: AgentWidgetConfig): Settings {
11
16
  const options = config?.expansion;
12
17
  return {
@@ -131,6 +131,7 @@ export const createToolBubble = (
131
131
  if (!tool) {
132
132
  return applyActivityRow(bubble, message, config, "tool");
133
133
  }
134
+ if (tool.id) bubble.dataset.toolCallId = tool.id;
134
135
 
135
136
  const toolDisplayConfig = config.features!.toolCallDisplay!;
136
137
  const expandable = toolDisplayConfig.expandable ?? DEFAULT_TOOL_CALL_DISPLAY.expandable;
package/src/index-core.ts CHANGED
@@ -226,6 +226,7 @@ export type {
226
226
  // Visitor conversation history (client-token mode) types
227
227
  AgentWidgetHistoryFeature,
228
228
  AgentWidgetOpenConversationOptions,
229
+ AgentWidgetRevealToolCallOptions,
229
230
  BackgroundConversationAdapter,
230
231
  ConversationExecutionState,
231
232
  AgentWidgetHistoryListAction,
@@ -6773,6 +6773,12 @@
6773
6773
  outline-offset: 2px;
6774
6774
  }
6775
6775
 
6776
+ /* revealToolCall() marks the revealed tool briefly. */
6777
+ .persona-tool-bubble[data-persona-revealed] {
6778
+ outline: 2px solid var(--persona-focus-ring, var(--persona-primary, #171717));
6779
+ outline-offset: 2px;
6780
+ }
6781
+
6776
6782
  /* Compact approval disclosures share the tool detail surfaces. */
6777
6783
  .persona-approval-row .persona-approval-details {
6778
6784
  display: grid;
package/src/types.ts CHANGED
@@ -3568,6 +3568,14 @@ export type AgentWidgetOpenConversationOptions = {
3568
3568
  busy?: "reject" | "stop" | "background";
3569
3569
  };
3570
3570
 
3571
+ /** Options for `controller.revealToolCall()`. */
3572
+ export type AgentWidgetRevealToolCallOptions = {
3573
+ /** Expand the tool's details. Default true. */
3574
+ expand?: boolean;
3575
+ /** How long the bubble keeps `data-persona-revealed`, in ms; 0 skips the mark. Default 2000. */
3576
+ highlightMs?: number;
3577
+ };
3578
+
3571
3579
  /** The single in-flight history operation, or `null` when idle. */
3572
3580
  export type HistoryPendingAction =
3573
3581
  | { kind: "refresh" | "load-more" | "start-new" | "clear" | "reset" }
@@ -0,0 +1,54 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { initAgentWidget } from "./runtime/init";
5
+ import { createAgentExperience, type AgentWidgetController } from "./ui";
6
+
7
+ let mount: HTMLElement;
8
+ let controller: { destroy: () => void } | undefined;
9
+
10
+ const footer = () => mount.querySelector<HTMLElement>(".persona-widget-footer");
11
+ const settle = async () => {
12
+ await vi.dynamicImportSettled();
13
+ await new Promise((resolve) => setTimeout(resolve, 0));
14
+ };
15
+
16
+ beforeEach(() => {
17
+ window.localStorage.clear();
18
+ window.scrollTo = vi.fn();
19
+ mount = document.createElement("div");
20
+ mount.id = "chat";
21
+ document.body.appendChild(mount);
22
+ });
23
+
24
+ afterEach(() => {
25
+ controller?.destroy();
26
+ controller = undefined;
27
+ document.body.replaceChildren();
28
+ });
29
+
30
+ describe.each([false, true])("layout.showFooter: false (V5 defaults: %s)", (v5Defaults) => {
31
+ const config = {
32
+ apiUrl: "/test",
33
+ persistState: false,
34
+ future: { v5Defaults },
35
+ launcher: { enabled: false },
36
+ layout: { embedded: true, showFooter: false },
37
+ initialMessages: [{ id: "m1", role: "user" as const, content: "Hi", createdAt: "2026-01-01T00:00:00Z" }],
38
+ };
39
+
40
+ it("keeps the footer hidden after the widget is ready", async () => {
41
+ controller = initAgentWidget({ target: "#chat", useShadowDom: false, config });
42
+ await settle();
43
+ expect(footer()?.style.display).toBe("none");
44
+ });
45
+
46
+ it("keeps the footer hidden when an update rebuilds the composer", async () => {
47
+ const experience: AgentWidgetController = createAgentExperience(mount, config);
48
+ controller = experience;
49
+ await settle();
50
+ experience.update({ contextMentions: { enabled: false } });
51
+ await settle();
52
+ expect(footer()?.style.display).toBe("none");
53
+ });
54
+ });
@@ -0,0 +1,258 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { createAgentExperience, type AgentWidgetController } from "./ui";
5
+
6
+ let controller: AgentWidgetController;
7
+ let mount: HTMLElement;
8
+
9
+ const injectTool = (id: string, sequence: number, status: "running" | "complete" = "complete", chunks = ["Tool details"]) =>
10
+ controller.injectTestMessage({
11
+ type: "message",
12
+ message: {
13
+ id: `msg-${id}`,
14
+ role: "assistant",
15
+ content: "",
16
+ createdAt: "2026-01-01T00:00:00Z",
17
+ sequence,
18
+ streaming: status === "running",
19
+ variant: "tool",
20
+ toolCall: { id, name: "Search", status, chunks },
21
+ },
22
+ });
23
+
24
+ const setup = (grouped: boolean, v5Defaults = true) => {
25
+ controller = createAgentExperience(mount, {
26
+ apiUrl: "/test",
27
+ persistState: false,
28
+ future: { v5Defaults },
29
+ launcher: { enabled: false },
30
+ features: {
31
+ toolCallDisplay: { grouped, groupedMode: "collapsible", autoExpand: false, autoCollapseDelay: false },
32
+ },
33
+ });
34
+ injectTool("call-1", 1);
35
+ injectTool("call-2", 2);
36
+ };
37
+
38
+ const toolBubble = (toolCallId: string) =>
39
+ mount.querySelector<HTMLElement>(`.persona-tool-bubble[data-tool-call-id="${toolCallId}"]`);
40
+ const ownHeader = (bubble: HTMLElement) =>
41
+ Array.from(bubble.querySelectorAll<HTMLElement>('button[data-expand-header="true"]'))
42
+ .find((header) => header.closest(".persona-tool-bubble") === bubble)!;
43
+
44
+ const installScrollMetrics = () => {
45
+ const body = mount.querySelector<HTMLElement>(".persona-widget-body")!;
46
+ let height = 1000;
47
+ Object.defineProperties(body, {
48
+ clientHeight: { configurable: true, get: () => 400 },
49
+ scrollHeight: { configurable: true, get: () => height },
50
+ });
51
+ body.scrollTo = (options?: ScrollToOptions | number) => {
52
+ if (options && typeof options === "object") body.scrollTop = options.top ?? 0;
53
+ };
54
+ for (const [id, top] of [["call-1", 100], ["call-2", 900]] as const) {
55
+ Object.defineProperties(ownHeader(toolBubble(id)!), {
56
+ offsetTop: { configurable: true, get: () => top },
57
+ offsetParent: { configurable: true, get: () => body },
58
+ });
59
+ }
60
+ return { body, grow: () => { height += 400; } };
61
+ };
62
+
63
+ beforeEach(() => {
64
+ vi.useFakeTimers();
65
+ window.localStorage.clear();
66
+ window.scrollTo = vi.fn();
67
+ mount = document.createElement("div");
68
+ document.body.appendChild(mount);
69
+ });
70
+
71
+ afterEach(() => {
72
+ controller?.destroy();
73
+ document.body.replaceChildren();
74
+ vi.unstubAllGlobals();
75
+ vi.useRealTimers();
76
+ });
77
+
78
+ describe("revealToolCall", () => {
79
+ it("stamps each tool bubble with its tool call id", () => {
80
+ setup(false);
81
+ expect(toolBubble("call-1")?.dataset.messageId).toBe("msg-call-1");
82
+ expect(toolBubble("call-2")?.dataset.messageId).toBe("msg-call-2");
83
+ });
84
+
85
+ it("expands a standalone tool and marks it until the highlight expires", () => {
86
+ setup(false);
87
+ const bubble = toolBubble("call-2")!;
88
+ expect(ownHeader(bubble).getAttribute("aria-expanded")).toBe("false");
89
+
90
+ expect(controller.revealToolCall("call-2")).toBe(true);
91
+ expect(ownHeader(toolBubble("call-2")!).getAttribute("aria-expanded")).toBe("true");
92
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(true);
93
+ expect(ownHeader(toolBubble("call-1")!).getAttribute("aria-expanded")).toBe("false");
94
+
95
+ vi.advanceTimersByTime(2000);
96
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(false);
97
+ });
98
+
99
+ it("opens the collapsed group that holds the tool before expanding it", () => {
100
+ setup(true);
101
+ const group = mount.querySelector<HTMLElement>('[data-message-id="tool-group-msg-call-1"]')!;
102
+ expect(ownHeader(group).getAttribute("aria-expanded")).toBe("false");
103
+
104
+ expect(controller.revealToolCall("call-2")).toBe(true);
105
+ expect(ownHeader(group).getAttribute("aria-expanded")).toBe("true");
106
+ expect(ownHeader(toolBubble("call-2")!).getAttribute("aria-expanded")).toBe("true");
107
+ });
108
+
109
+ it.each([[false, false], [false, true], [true, false], [true, true]])(
110
+ "keeps the highlight through streaming renders (V5: %s, grouped: %s)",
111
+ (v5Defaults, grouped) => {
112
+ setup(grouped, v5Defaults);
113
+ injectTool("call-2", 2, "running", ["Searching"]);
114
+ controller.revealToolCall("call-2");
115
+
116
+ vi.advanceTimersByTime(1000);
117
+ injectTool("call-2", 2, "running", ["Searching", "Found a page"]);
118
+ expect(toolBubble("call-2")!.textContent).toContain("Found a page");
119
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(true);
120
+
121
+ injectTool("call-2", 2, "complete", ["Search complete"]);
122
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(true);
123
+ vi.advanceTimersByTime(999);
124
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(true);
125
+ vi.advanceTimersByTime(1);
126
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(false);
127
+ injectTool("call-2", 2, "complete", ["Updated result"]);
128
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(false);
129
+ },
130
+ );
131
+
132
+ it("restarts the highlight duration when revealing the same tool again", () => {
133
+ setup(false);
134
+ controller.revealToolCall("call-2");
135
+ vi.advanceTimersByTime(1000);
136
+ controller.revealToolCall("call-2");
137
+ vi.advanceTimersByTime(1000);
138
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(true);
139
+ vi.advanceTimersByTime(1000);
140
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(false);
141
+ });
142
+
143
+ it("keeps the highlight when regrouping the transcript", () => {
144
+ setup(false);
145
+ controller.revealToolCall("call-2");
146
+ controller.update({ features: { toolCallDisplay: { grouped: true } } });
147
+ expect(toolBubble("call-2")!.parentElement?.closest("[data-persona-tool-group]")).not.toBeNull();
148
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(true);
149
+ vi.advanceTimersByTime(2000);
150
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(false);
151
+ });
152
+
153
+ it("does not carry a highlight into a cleared conversation with the same tool ids", () => {
154
+ setup(false);
155
+ controller.revealToolCall("call-2");
156
+ controller.clearChat();
157
+ injectTool("call-2", 2);
158
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(false);
159
+ });
160
+
161
+ it.each([false, true])("keeps following paused when centering reaches the bottom (reduced motion: %s)", (reduced) => {
162
+ vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: reduced, addEventListener: vi.fn(), removeEventListener: vi.fn() })));
163
+ setup(false);
164
+ controller.update({ features: { scrollBehavior: { mode: "follow" } } });
165
+ const { body, grow } = installScrollMetrics();
166
+ controller.revealToolCall("call-2", { expand: false, highlightMs: 0 });
167
+ vi.advanceTimersByTime(500);
168
+ expect(body.scrollTop).toBe(600);
169
+ body.dispatchEvent(new Event("scroll"));
170
+ vi.advanceTimersByTime(200);
171
+ body.dispatchEvent(new Event("scroll"));
172
+ grow();
173
+ injectTool("call-2", 2, "running", ["More streamed output"]);
174
+ vi.advanceTimersByTime(500);
175
+ expect(body.scrollTop).toBe(600);
176
+ });
177
+
178
+ it("cancels an earlier jump to latest and keeps following paused", () => {
179
+ setup(false);
180
+ controller.update({ features: {
181
+ scrollBehavior: { mode: "follow" },
182
+ scrollToBottom: { scrollBehavior: "smooth", scrollDurationMs: 800 },
183
+ } });
184
+ vi.advanceTimersByTime(1000);
185
+ const { body, grow } = installScrollMetrics();
186
+ body.scrollTop = 0;
187
+ body.dispatchEvent(new Event("scroll"));
188
+ mount.querySelector<HTMLElement>("[data-persona-scroll-to-bottom]")!.dispatchEvent(new MouseEvent("click"));
189
+ vi.advanceTimersByTime(80);
190
+ expect(body.scrollTop).toBeGreaterThan(0);
191
+ controller.revealToolCall("call-1", { expand: false });
192
+ vi.advanceTimersByTime(1000);
193
+ expect(body.scrollTop).toBe(0);
194
+ grow();
195
+ injectTool("call-2", 2, "running", ["More streamed output"]);
196
+ vi.advanceTimersByTime(500);
197
+ expect(body.scrollTop).toBe(0);
198
+ });
199
+
200
+ it.each([
201
+ ["wheel", 80, false],
202
+ ["wheel", 250, false],
203
+ ["keydown", 80, false],
204
+ ["touchmove", 80, false],
205
+ ["pointerdown", 80, false],
206
+ ["wheel", 80, true],
207
+ ] as const)("yields to %s at %sms (reduced motion: %s)", (input, elapsed, reduced) => {
208
+ vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: reduced, addEventListener: vi.fn(), removeEventListener: vi.fn() })));
209
+ setup(false);
210
+ controller.update({ features: { scrollBehavior: { mode: "follow" } } });
211
+ const { body } = installScrollMetrics();
212
+ controller.revealToolCall("call-2", { expand: false });
213
+ if (elapsed > 200) {
214
+ vi.advanceTimersByTime(180);
215
+ // A disclosure transition moves the target before the settle pass.
216
+ Object.defineProperty(ownHeader(toolBubble("call-2")!), "offsetTop", { configurable: true, get: () => 750 });
217
+ vi.advanceTimersByTime(elapsed - 180);
218
+ } else vi.advanceTimersByTime(elapsed);
219
+ body.dispatchEvent(input === "wheel" ? new WheelEvent("wheel", { deltaY: -100 })
220
+ : input === "keydown" ? new KeyboardEvent("keydown", { key: "ArrowUp" }) : new Event(input));
221
+ body.scrollTop = 123;
222
+ body.dispatchEvent(new Event("scroll"));
223
+ vi.advanceTimersByTime(20);
224
+ expect(body.scrollTop).toBe(123);
225
+ vi.advanceTimersByTime(480);
226
+ expect(body.scrollTop).toBe(123);
227
+ expect(toolBubble("call-2")!.hasAttribute("data-persona-revealed")).toBe(true);
228
+ controller.revealToolCall("call-2", { expand: false });
229
+ vi.advanceTimersByTime(500);
230
+ expect(body.scrollTop).toBe(elapsed > 200 ? 550 : 600);
231
+ });
232
+
233
+ it("cancels the previous reveal's delayed centering", () => {
234
+ vi.stubGlobal("matchMedia", vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })));
235
+ setup(false);
236
+ const { body } = installScrollMetrics();
237
+ controller.revealToolCall("call-1", { expand: false, highlightMs: 0 });
238
+ vi.advanceTimersByTime(100);
239
+ controller.revealToolCall("call-2", { expand: false, highlightMs: 0 });
240
+ expect(body.scrollTop).toBe(600);
241
+ vi.advanceTimersByTime(150);
242
+ expect(body.scrollTop).toBe(600);
243
+ vi.advanceTimersByTime(50);
244
+ expect(body.scrollTop).toBe(600);
245
+ });
246
+
247
+ it("leaves details closed and skips the mark when asked", () => {
248
+ setup(false);
249
+ expect(controller.revealToolCall("call-1", { expand: false, highlightMs: 0 })).toBe(true);
250
+ expect(ownHeader(toolBubble("call-1")!).getAttribute("aria-expanded")).toBe("false");
251
+ expect(toolBubble("call-1")!.hasAttribute("data-persona-revealed")).toBe(false);
252
+ });
253
+
254
+ it("returns false for an unknown tool call id", () => {
255
+ setup(false);
256
+ expect(controller.revealToolCall("missing")).toBe(false);
257
+ });
258
+ });