@runtypelabs/persona 4.32.2 → 4.34.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 (215) hide show
  1. package/dist/activity-ui.d.cts +3 -15
  2. package/dist/activity-ui.d.ts +3 -15
  3. package/dist/animations/glyph-cycle.d.cts +1 -3
  4. package/dist/animations/glyph-cycle.d.ts +1 -3
  5. package/dist/animations/wipe.d.cts +1 -3
  6. package/dist/animations/wipe.d.ts +1 -3
  7. package/dist/approval-ui.cjs +2 -2
  8. package/dist/approval-ui.d.cts +3 -15
  9. package/dist/approval-ui.d.ts +3 -15
  10. package/dist/approval-ui.js +1 -1
  11. package/dist/artifacts-ui.cjs +1 -1
  12. package/dist/artifacts-ui.d.cts +3 -21
  13. package/dist/artifacts-ui.d.ts +3 -21
  14. package/dist/background-runtime.d.cts +2 -3
  15. package/dist/background-runtime.d.ts +2 -3
  16. package/dist/{chunk-BbXc58OA.js → chunk-6CUrH_jm.js} +1 -1
  17. package/dist/{chunk-B_H0l3u2.js → chunk-BRM3bHcJ.js} +1 -1
  18. package/dist/{chunk-DNdeIFJ6.js → chunk-Brdx4NCz.js} +1 -1
  19. package/dist/chunk-C-rux8Q9.js +1 -0
  20. package/dist/{chunk-Ds_twaZn.js → chunk-C4v-HNhS.js} +1 -1
  21. package/dist/{chunk-Bkma5hIU.js → chunk-CE4seJxF.js} +1 -1
  22. package/dist/chunk-CEPM3FOp.js +1 -0
  23. package/dist/{chunk-BpjtKnGI.js → chunk-CV8EPIhT.js} +1 -1
  24. package/dist/{chunk-DVZ8ri1z.js → chunk-CWzHNuVK.js} +1 -1
  25. package/dist/{chunk-B3l7TOOv.js → chunk-CYpt7PsO.js} +1 -1
  26. package/dist/{chunk-2FnmZ9AA.js → chunk-Csr59G5V.js} +1 -1
  27. package/dist/{chunk-B9Zu-wrk.js → chunk-CxNPSY-b.js} +1 -1
  28. package/dist/{chunk-UWMfWW2a.js → chunk-D1G8tgYc.js} +1 -1
  29. package/dist/{chunk-DTGvDx8o.js → chunk-Dj2a9f9w.js} +1 -1
  30. package/dist/chunk-Dqa_GOcj.js +1 -0
  31. package/dist/chunk-DsWv3EGF.js +1 -0
  32. package/dist/{chunk-C7-m0EmI.js → chunk-NUNdcnAZ.js} +1 -1
  33. package/dist/{chunk-D0tcIZJM.js → chunk-YMkMvAsa.js} +2 -2
  34. package/dist/{chunk-C-EHtpbB.js → chunk-h5aOI_Pa.js} +1 -1
  35. package/dist/chunk-i4qGOJAL.js +1 -0
  36. package/dist/chunk-kizhspe5.js +1 -0
  37. package/dist/client-dispatch.js +1 -1
  38. package/dist/client-history.js +1 -1
  39. package/dist/client-stream.js +1 -1
  40. package/dist/{code-generators-DqauYyfX.d.cts → code-generators-DPAqTcAz.d.cts} +0 -2
  41. package/dist/{code-generators-DqauYyfX.d.ts → code-generators-DPAqTcAz.d.ts} +0 -2
  42. package/dist/codegen.cjs +1 -1
  43. package/dist/codegen.d.cts +1 -1
  44. package/dist/codegen.d.ts +1 -1
  45. package/dist/codegen.js +1 -1
  46. package/dist/composer-actions-runtime.cjs +1 -1
  47. package/dist/composer-actions-runtime.d.cts +4 -20
  48. package/dist/composer-actions-runtime.d.ts +4 -20
  49. package/dist/composer-actions-runtime.js +1 -1
  50. package/dist/{composer-input-dwg_o9Z-.d.ts → composer-input-i_FiUp6J.d.cts} +1 -7
  51. package/dist/{composer-input-dwg_o9Z-.d.cts → composer-input-i_FiUp6J.d.ts} +1 -7
  52. package/dist/{composer-store-Dw7Zbqxr.d.ts → composer-store-DhGNgG7Z.d.cts} +1 -3
  53. package/dist/{composer-store-Dw7Zbqxr.d.cts → composer-store-DhGNgG7Z.d.ts} +1 -3
  54. package/dist/config-diagnostics.d.cts +0 -2
  55. package/dist/config-diagnostics.d.ts +0 -2
  56. package/dist/{context-mention-manager-D2p0-JH-.d.ts → context-mention-manager-DL-xwGfL.d.cts} +2 -6
  57. package/dist/{context-mention-manager-D2p0-JH-.d.cts → context-mention-manager-DL-xwGfL.d.ts} +2 -6
  58. package/dist/context-mentions-inline.cjs +1 -1
  59. package/dist/context-mentions-inline.d.cts +2 -4
  60. package/dist/context-mentions-inline.d.ts +2 -4
  61. package/dist/context-mentions-inline.js +1 -1
  62. package/dist/context-mentions.cjs +1 -1
  63. package/dist/context-mentions.d.cts +3 -9
  64. package/dist/context-mentions.d.ts +3 -9
  65. package/dist/{defaults-CjX0kKzb.d.ts → defaults-Bga2Xrsp.d.cts} +2 -5
  66. package/dist/{defaults-CjX0kKzb.d.cts → defaults-Bga2Xrsp.d.ts} +2 -5
  67. package/dist/dictation-runtime.cjs +1 -1
  68. package/dist/dictation-runtime.d.cts +4 -13
  69. package/dist/dictation-runtime.d.ts +4 -13
  70. package/dist/dictation-runtime.js +1 -1
  71. package/dist/{dom-context-DHTabIn7.d.ts → dom-context-Bw-_9Evh.d.cts} +0 -2
  72. package/dist/{dom-context-DHTabIn7.d.cts → dom-context-Bw-_9Evh.d.ts} +0 -2
  73. package/dist/event-stream-view.cjs +1 -1
  74. package/dist/event-stream-view.d.cts +3 -16
  75. package/dist/event-stream-view.d.ts +3 -16
  76. package/dist/{fallback-speech-engine-DmF7MczI.d.ts → fallback-speech-engine-kLdyPcmk.d.cts} +1 -3
  77. package/dist/{fallback-speech-engine-DmF7MczI.d.cts → fallback-speech-engine-kLdyPcmk.d.ts} +1 -3
  78. package/dist/{feature-preferences-CVFDt9Xf.d.ts → feature-preferences-CmwZGKrf.d.cts} +1 -5
  79. package/dist/{feature-preferences-CVFDt9Xf.d.cts → feature-preferences-CmwZGKrf.d.ts} +1 -5
  80. package/dist/forms-ui.d.cts +2 -4
  81. package/dist/forms-ui.d.ts +2 -4
  82. package/dist/{history-confirm-dialog-DJEXsTyE.d.cts → history-confirm-dialog-i46-lPN_.d.cts} +2 -14
  83. package/dist/{history-confirm-dialog-DJEXsTyE.d.ts → history-confirm-dialog-i46-lPN_.d.ts} +2 -14
  84. package/dist/history-shell.cjs +1 -1
  85. package/dist/history-shell.d.cts +8 -27
  86. package/dist/history-shell.d.ts +8 -27
  87. package/dist/history-shell.js +1 -1
  88. package/dist/history-view.d.cts +1 -1
  89. package/dist/history-view.d.ts +1 -1
  90. package/dist/icons-extra.cjs +1 -1
  91. package/dist/icons-extra.d.cts +0 -2
  92. package/dist/icons-extra.d.ts +0 -2
  93. package/dist/index.cjs +44 -44
  94. package/dist/index.cjs.map +1 -1
  95. package/dist/index.d.cts +11 -11
  96. package/dist/index.d.ts +11 -11
  97. package/dist/index.global.js +1 -1
  98. package/dist/index.global.js.map +1 -1
  99. package/dist/index.js +46 -46
  100. package/dist/index.js.map +1 -1
  101. package/dist/install.global.js +1 -1
  102. package/dist/install.global.js.map +1 -1
  103. package/dist/launcher.global.js +2 -2
  104. package/dist/launcher.global.js.map +1 -1
  105. package/dist/{loading-label-runtime-loader-CPxnoaoi.d.ts → loading-label-runtime-loader-Ccr3ETd3.d.cts} +1 -3
  106. package/dist/{loading-label-runtime-loader-CPxnoaoi.d.cts → loading-label-runtime-loader-Ccr3ETd3.d.ts} +1 -3
  107. package/dist/loading-label-runtime.cjs +1 -1
  108. package/dist/loading-label-runtime.d.cts +1 -1
  109. package/dist/loading-label-runtime.d.ts +1 -1
  110. package/dist/loading-label-runtime.js +1 -1
  111. package/dist/markdown-parsers.js +1 -1
  112. package/dist/{message-bubble-CkZGlCLC.d.ts → message-bubble-C6AVaH7f.d.cts} +21 -4
  113. package/dist/{message-bubble-CkZGlCLC.d.cts → message-bubble-C6AVaH7f.d.ts} +21 -4
  114. package/dist/{plugin-kit-C1mRjLgW.d.cts → plugin-kit-BVggqE3C.d.cts} +0 -2
  115. package/dist/{plugin-kit-C1mRjLgW.d.ts → plugin-kit-BVggqE3C.d.ts} +0 -2
  116. package/dist/plugin-kit.d.cts +1 -1
  117. package/dist/plugin-kit.d.ts +1 -1
  118. package/dist/preferences-runtime.cjs +1 -1
  119. package/dist/preferences-runtime.d.cts +1 -1
  120. package/dist/preferences-runtime.d.ts +1 -1
  121. package/dist/preferences-runtime.js +1 -1
  122. package/dist/{preview-DdUvoLb4.d.cts → preview-BMdRffo4.d.cts} +2 -6
  123. package/dist/{preview-DdUvoLb4.d.ts → preview-BMdRffo4.d.ts} +2 -6
  124. package/dist/{registry-Dz4iHNST.d.ts → registry-B16l8s1b.d.cts} +1 -8
  125. package/dist/{registry-Dz4iHNST.d.cts → registry-B16l8s1b.d.ts} +1 -8
  126. package/dist/{session-Baxp9iRK.d.cts → session-CtZHe7fc.d.cts} +10 -93
  127. package/dist/{session-Baxp9iRK.d.ts → session-CtZHe7fc.d.ts} +10 -93
  128. package/dist/session-reconnect.d.cts +2 -4
  129. package/dist/session-reconnect.d.ts +2 -4
  130. package/dist/smart-dom-reader.d.cts +2 -8
  131. package/dist/smart-dom-reader.d.ts +2 -8
  132. package/dist/testing.d.cts +0 -2
  133. package/dist/testing.d.ts +0 -2
  134. package/dist/theme-editor-preview.cjs +42 -42
  135. package/dist/theme-editor-preview.d.cts +1 -1
  136. package/dist/theme-editor-preview.d.ts +1 -1
  137. package/dist/theme-editor-preview.js +25 -25
  138. package/dist/theme-editor.cjs +25 -5
  139. package/dist/theme-editor.d.cts +2 -22
  140. package/dist/theme-editor.d.ts +2 -22
  141. package/dist/theme-editor.js +1 -1
  142. package/dist/theme-reference.cjs +1 -1
  143. package/dist/theme-reference.d.cts +4 -4
  144. package/dist/theme-reference.d.ts +4 -4
  145. package/dist/theme-reference.js +1 -1
  146. package/dist/{tool-loading-animation-un3kA5Ur.d.ts → tool-loading-animation-D2cyFvmM.d.cts} +1 -7
  147. package/dist/{tool-loading-animation-un3kA5Ur.d.cts → tool-loading-animation-D2cyFvmM.d.ts} +1 -7
  148. package/dist/{tooltip-BkW-8MnD.d.cts → tooltip-DbXkAuJL.d.cts} +0 -2
  149. package/dist/{tooltip-BkW-8MnD.d.ts → tooltip-DbXkAuJL.d.ts} +0 -2
  150. package/dist/{turn-activity-ui-BGNhtMcH.d.cts → turn-activity-ui-Do1Nd-Ow.d.cts} +2 -12
  151. package/dist/{turn-activity-ui-BGNhtMcH.d.ts → turn-activity-ui-Do1Nd-Ow.d.ts} +2 -12
  152. package/dist/turn-activity-ui.d.cts +1 -1
  153. package/dist/turn-activity-ui.d.ts +1 -1
  154. package/dist/{types-BOZHQIk0.d.cts → types-BC7MRxO5.d.cts} +167 -36
  155. package/dist/{types-BOZHQIk0.d.ts → types-BC7MRxO5.d.ts} +167 -36
  156. package/dist/ui-extras.js +3 -3
  157. package/dist/voice-client-tools.d.cts +1 -3
  158. package/dist/voice-client-tools.d.ts +1 -3
  159. package/dist/voice-runtime.cjs +1 -1
  160. package/dist/voice-runtime.d.cts +2 -12
  161. package/dist/voice-runtime.d.ts +2 -12
  162. package/dist/voice-runtime.js +2 -2
  163. package/dist/voice-worklet-player.d.cts +2 -6
  164. package/dist/voice-worklet-player.d.ts +2 -6
  165. package/dist/{webmcp-runtime-entry-DuAG1_yF.d.ts → webmcp-runtime-entry-B8g46vS0.d.cts} +1 -5
  166. package/dist/{webmcp-runtime-entry-DuAG1_yF.d.cts → webmcp-runtime-entry-B8g46vS0.d.ts} +1 -5
  167. package/dist/webmcp-runtime.d.cts +1 -1
  168. package/dist/webmcp-runtime.d.ts +1 -1
  169. package/dist/widget.css +1 -1
  170. package/package.json +4 -4
  171. package/src/client-dispatch.ts +69 -16
  172. package/src/client.operator-role.test.ts +217 -0
  173. package/src/components/event-stream-view.test.ts +5 -0
  174. package/src/components/message-bubble.test.ts +169 -0
  175. package/src/components/message-bubble.ts +123 -34
  176. package/src/components/scroll-to-bottom.test.ts +135 -0
  177. package/src/components/scroll-to-bottom.ts +50 -13
  178. package/src/components/user-message-collapse.ts +117 -0
  179. package/src/defaults-parity.test.ts +14 -0
  180. package/src/defaults.ts +6 -0
  181. package/src/index-core.ts +7 -0
  182. package/src/session.resubmit-from.test.ts +27 -0
  183. package/src/session.test.ts +10 -0
  184. package/src/session.ts +8 -5
  185. package/src/styles/widget.css +79 -1
  186. package/src/theme-reference.ts +5 -3
  187. package/src/types/theme.ts +50 -0
  188. package/src/types.ts +120 -11
  189. package/src/ui.ask-user-question-plugin.test.ts +154 -0
  190. package/src/ui.operator-role.test.ts +336 -0
  191. package/src/ui.scroll-additive.test.ts +219 -117
  192. package/src/ui.scroll.test.ts +176 -13
  193. package/src/ui.ts +267 -119
  194. package/src/ui.turn-activity.test.ts +7 -7
  195. package/src/ui.user-message-collapse.test.ts +540 -0
  196. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +236 -0
  197. package/src/utils/auto-follow.test.ts +0 -38
  198. package/src/utils/auto-follow.ts +0 -35
  199. package/src/utils/brand.test.ts +14 -0
  200. package/src/utils/brand.ts +8 -4
  201. package/src/utils/history-messages.test.ts +30 -0
  202. package/src/utils/history-messages.ts +26 -8
  203. package/src/utils/message-fingerprint.ts +4 -0
  204. package/src/utils/resubmit-turn.test.ts +13 -0
  205. package/src/utils/resubmit-turn.ts +12 -1
  206. package/src/utils/theme.test.ts +23 -0
  207. package/src/utils/tokens.ts +47 -0
  208. package/src/welcome.test.ts +16 -0
  209. package/src/welcome.ts +10 -2
  210. package/dist/chunk-BNE40cEG.js +0 -1
  211. package/dist/chunk-Bz9jAgba.js +0 -1
  212. package/dist/chunk-CVMIncEY.js +0 -1
  213. package/dist/chunk-CdpVP6LZ.js +0 -1
  214. package/dist/chunk-CjkechxR.js +0 -1
  215. package/dist/chunk-DgZZqy14.js +0 -1
@@ -1115,6 +1115,103 @@ describe("createAgentExperience streaming scroll", () => {
1115
1115
  controller.destroy();
1116
1116
  });
1117
1117
 
1118
+ describe("approval waiting below an anchored turn", () => {
1119
+ const approvalMessage = (id: string, status: "pending" | "approved" = "pending") => ({
1120
+ type: "message" as const, message: {
1121
+ id, role: "assistant" as const, content: "", createdAt: STREAM_CREATED_AT,
1122
+ variant: "approval" as const, approval: { id, agentId: "agent", executionId: "execution", status, toolName: "Search", description: "Search documentation", parameters: { query: "docs" } },
1123
+ },
1124
+ });
1125
+ // `approvals` maps approval ids to card tops; each Allow button sits 40px
1126
+ // into its card (jsdom has no layout, so the geometry is mocked).
1127
+ const setup = (approvals: Record<string, number>, scrollHeight = 1600) => {
1128
+ const raf = installRafMock();
1129
+ const mount = createMount();
1130
+ const controller = createAgentExperience(mount, {
1131
+ apiUrl: "/test", future: { v5Defaults: true }, launcher: { enabled: false },
1132
+ approval: { keyboardShortcuts: "focused" },
1133
+ features: { scrollBehavior: { mode: "anchor-top", anchorTopOffset: 16 } },
1134
+ } as any);
1135
+ const body = mount.querySelector<HTMLElement>("#persona-scroll-container")!;
1136
+ const metrics = installScrollMetrics(body, { scrollHeight, clientHeight: 400 });
1137
+ const tops: Record<string, number> = { "user-approve": 100, ...approvals };
1138
+ vi.spyOn(HTMLElement.prototype, "offsetTop", "get").mockImplementation(function (this: HTMLElement) {
1139
+ const id = this.getAttribute("data-message-id") ?? this.getAttribute("data-wrapper-id");
1140
+ if (id && id in tops) return tops[id];
1141
+ const card = this.matches('[data-action="allow"]') ? this.closest("[data-message-id]")?.getAttribute("data-message-id") : null;
1142
+ return card && card in tops ? tops[card] + 40 : 0;
1143
+ });
1144
+ emitStreamingStatus(controller);
1145
+ emitUserMessage(controller, "user-approve", "Long question ".repeat(80));
1146
+ raf.flush();
1147
+ expect(metrics.getScrollTop()).toBe(84);
1148
+ const composer = mount.querySelector<HTMLTextAreaElement>("textarea")!;
1149
+ composer.focus();
1150
+ for (const id of Object.keys(approvals)) controller.injectTestMessage(approvalMessage(id));
1151
+ raf.flush();
1152
+ const allow = (id: string) => mount.querySelector<HTMLButtonElement>(`[data-message-id="${id}"] [data-action="allow"]`);
1153
+ return { raf, controller, metrics, composer, allow, jump: getScrollToBottomButton(mount)! };
1154
+ };
1155
+
1156
+ it("names it instead of scrolling or moving focus, then focuses it after the jump", () => {
1157
+ const { raf, controller, metrics, composer, allow, jump } = setup({ "approval-below": 1400 });
1158
+ expect(allow("approval-below")).not.toBeNull();
1159
+ // The anchor holds, and Enter-to-allow never lands on an unseen card.
1160
+ expect(metrics.getScrollTop()).toBe(84);
1161
+ expect(document.activeElement).toBe(composer);
1162
+ expect(jump.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(true);
1163
+ expect(jump.textContent).toBe("Approval needed");
1164
+ expect(jump.getAttribute("aria-label")).toBe("Approval needed below. Jump to latest");
1165
+
1166
+ jump.click();
1167
+ raf.flush();
1168
+ // Bottom is 1200: the card at 1400 and its Allow button at 1440 are in view.
1169
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
1170
+ expect(document.activeElement).toBe(allow("approval-below"));
1171
+ expect(jump.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(false);
1172
+ controller.destroy();
1173
+ });
1174
+
1175
+ it("after the jump, focuses only an approval whose action is in view", () => {
1176
+ // The older card ends up above the viewport after the jump; the newer
1177
+ // one is in view. Focus must go to the visible one, never the older.
1178
+ const { raf, controller, composer, allow, jump } = setup({ "approval-old": 700, "approval-new": 1400 });
1179
+ jump.click();
1180
+ raf.flush();
1181
+ expect(document.activeElement).not.toBe(allow("approval-old"));
1182
+ expect(document.activeElement).toBe(allow("approval-new"));
1183
+ expect(composer).not.toBe(document.activeElement);
1184
+ controller.destroy();
1185
+ });
1186
+
1187
+ it("focuses nothing after the jump when no target's action is in view", () => {
1188
+ // The only card lands above the jump's viewport (bottom is 1200).
1189
+ const { raf, controller, allow, jump } = setup({ "approval-above": 700 });
1190
+ expect(jump.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(true);
1191
+ jump.click();
1192
+ raf.flush();
1193
+ expect(document.activeElement).not.toBe(allow("approval-above"));
1194
+ controller.destroy();
1195
+ });
1196
+
1197
+ it("leaves a visible approval to the card itself", () => {
1198
+ const { controller, metrics, allow, jump } = setup({ "approval-below": 300 });
1199
+ expect(metrics.getScrollTop()).toBe(84);
1200
+ expect(jump.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(false);
1201
+ expect(document.activeElement).toBe(allow("approval-below"));
1202
+ controller.destroy();
1203
+ });
1204
+
1205
+ it("drops the label once the approval resolves", () => {
1206
+ const { raf, controller, jump } = setup({ "approval-below": 1400 });
1207
+ expect(jump.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(true);
1208
+ controller.injectTestMessage(approvalMessage("approval-below", "approved"));
1209
+ raf.flush();
1210
+ expect(jump.hasAttribute("data-persona-scroll-to-bottom-approval")).toBe(false);
1211
+ controller.destroy();
1212
+ });
1213
+ });
1214
+
1118
1215
  it("does not yank a paused reader when content grows without a render event", () => {
1119
1216
  const raf = installRafMock();
1120
1217
  const resize = installResizeObserverMock();
@@ -1366,6 +1463,19 @@ describe("createAgentExperience streaming scroll", () => {
1366
1463
  expect(spacer?.style.height).toBe("0px");
1367
1464
  expect(spacer?.style.display).toBe("none");
1368
1465
  expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
1466
+ const releasedAt = metrics.getScrollTop();
1467
+
1468
+ // Later streaming, resizes, and stream end must not reinstate the anchor
1469
+ // or its spacer.
1470
+ metrics.setScrollHeight(1500);
1471
+ emitStreamingMessage(controller, "Streaming response, now longer still");
1472
+ raf.flush();
1473
+ resize.trigger();
1474
+ controller.injectTestMessage({ type: "status", status: "idle" });
1475
+ raf.flush();
1476
+ expect(spacer?.style.height).toBe("0px");
1477
+ expect(spacer?.style.display).toBe("none");
1478
+ expect(metrics.getScrollTop()).toBe(releasedAt);
1369
1479
 
1370
1480
  controller.destroy();
1371
1481
  });
@@ -1504,9 +1614,9 @@ describe("createAgentExperience streaming scroll", () => {
1504
1614
  }
1505
1615
  });
1506
1616
 
1507
- it("anchor-top hands off to the first unread block only when it starts below the fold", () => {
1617
+ it("anchor-top keeps a user message taller than the viewport anchored as the reply arrives", () => {
1508
1618
  const raf = installRafMock();
1509
- installResizeObserverMock();
1619
+ const resize = installResizeObserverMock();
1510
1620
  const mount = createMount();
1511
1621
  const controller = createAgentExperience(mount, {
1512
1622
  apiUrl: "https://api.example.com/chat",
@@ -1517,34 +1627,87 @@ describe("createAgentExperience streaming scroll", () => {
1517
1627
  } as any);
1518
1628
 
1519
1629
  const scrollContainer = mount.querySelector<HTMLElement>("#persona-scroll-container");
1630
+ // Earlier history fills 0..100; the new question spans 100..1082, more
1631
+ // than twice the 400px viewport.
1520
1632
  const metrics = installScrollMetrics(scrollContainer!, {
1521
- scrollHeight: 1600,
1633
+ scrollHeight: 1100,
1522
1634
  clientHeight: 400
1523
1635
  });
1524
1636
 
1525
1637
  emitStreamingStatus(controller);
1526
- emitUserMessage(controller, "user-strand", "Very long question");
1638
+ emitUserMessage(controller, "user-tall", "Very long question ".repeat(200));
1527
1639
  const bubble = scrollContainer!.querySelector<HTMLElement>(
1528
- '[data-message-id="user-strand"]'
1640
+ '[data-message-id="user-tall"]'
1529
1641
  );
1530
1642
  Object.defineProperty(bubble!, "offsetTop", { value: 100 });
1643
+ Object.defineProperty(bubble!, "offsetHeight", { value: 982 });
1531
1644
  raf.flush();
1532
1645
  expect(metrics.getScrollTop()).toBe(84);
1533
1646
 
1534
- // The reply's first block starts 500px below the user message: with the
1535
- // user message pinned it would sit past the fold (16 + 500 > 400 - 96),
1536
- // so the anchor hands off to the block: target = 600 - 16 = 584. This is
1537
- // the stranded case the handoff exists for (tall send / tool prelude).
1647
+ // The reply's first block starts 1000px below the question's top, entirely
1648
+ // below the fold. The question keeps the anchor: the reader can finish
1649
+ // reading it, and the jump control says there is output below.
1538
1650
  emitStreamingMessage(controller, "Streaming response");
1539
- // Streaming updates render on the next animation frame.
1540
1651
  raf.step(1);
1541
1652
  const streamBubble = scrollContainer!.querySelector<HTMLElement>(
1542
1653
  `[data-message-id="${STREAM_MESSAGE_ID}"]`
1543
1654
  );
1544
- Object.defineProperty(streamBubble!, "offsetTop", { value: 600 });
1545
- emitStreamingMessage(controller, "Streaming response, longer");
1655
+ Object.defineProperty(streamBubble!, "offsetTop", { value: 1100 });
1656
+ for (const [height, text] of [
1657
+ [1300, "Streaming response, longer"],
1658
+ [1600, "Streaming response, longer still"],
1659
+ [2000, "Streaming response, longer still, with more detail"]
1660
+ ] as const) {
1661
+ metrics.setScrollHeight(height);
1662
+ emitStreamingMessage(controller, text);
1663
+ raf.flush();
1664
+ resize.trigger();
1665
+ expect(metrics.getScrollTop()).toBe(84);
1666
+ }
1667
+
1668
+ const jump = getScrollToBottomButton(mount)!;
1669
+ expect(jump.style.display).toBe("");
1670
+ expect(jump.getAttribute("data-persona-scroll-to-bottom-visible")).toBe("true");
1671
+
1672
+ // Completion does not hand off either.
1673
+ controller.injectTestMessage({
1674
+ type: "message",
1675
+ message: {
1676
+ id: STREAM_MESSAGE_ID,
1677
+ role: "assistant",
1678
+ content: "Streaming response, complete",
1679
+ createdAt: STREAM_CREATED_AT,
1680
+ streaming: false
1681
+ }
1682
+ });
1683
+ controller.injectTestMessage({ type: "status", status: "idle" });
1684
+ raf.flush();
1685
+ expect(metrics.getScrollTop()).toBe(84);
1686
+
1687
+ // The jump control reveals the answer and releases the anchor.
1688
+ jump.click();
1689
+ raf.flush();
1690
+ expect(metrics.getScrollTop()).toBe(metrics.getBottomScrollTop());
1691
+ const releasedAt = metrics.getScrollTop();
1692
+
1693
+ // Later renders and resizes never pull the reader back to the question.
1694
+ emitStreamingStatus(controller);
1695
+ controller.injectTestMessage({
1696
+ type: "message",
1697
+ message: {
1698
+ id: "ast-follow-up",
1699
+ role: "assistant",
1700
+ content: "A follow-up block",
1701
+ createdAt: STREAM_CREATED_AT,
1702
+ streaming: true
1703
+ }
1704
+ });
1705
+ metrics.setScrollHeight(2300);
1706
+ raf.flush();
1707
+ resize.trigger();
1708
+ controller.injectTestMessage({ type: "status", status: "idle" });
1546
1709
  raf.flush();
1547
- expect(metrics.getScrollTop()).toBe(584);
1710
+ expect(metrics.getScrollTop()).toBe(releasedAt);
1548
1711
 
1549
1712
  controller.destroy();
1550
1713
  });