@runtypelabs/persona 4.33.0 → 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 (186) 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-gu34lr-P.js → chunk-Brdx4NCz.js} +1 -1
  17. package/dist/{chunk-dLJWKfOb.js → chunk-C-rux8Q9.js} +1 -1
  18. package/dist/{chunk-Ds_twaZn.js → chunk-C4v-HNhS.js} +1 -1
  19. package/dist/{chunk-Bkma5hIU.js → chunk-CE4seJxF.js} +1 -1
  20. package/dist/{chunk-D6U0E82p.js → chunk-CV8EPIhT.js} +1 -1
  21. package/dist/{chunk-ql5N2I_t.js → chunk-CWzHNuVK.js} +1 -1
  22. package/dist/{chunk-DbGLjU2P.js → chunk-CYpt7PsO.js} +1 -1
  23. package/dist/{chunk-DOZmcve8.js → chunk-Csr59G5V.js} +1 -1
  24. package/dist/{chunk-DW7Vg_l2.js → chunk-CxNPSY-b.js} +1 -1
  25. package/dist/chunk-Dqa_GOcj.js +1 -0
  26. package/dist/chunk-DsWv3EGF.js +1 -0
  27. package/dist/{chunk-C7-m0EmI.js → chunk-NUNdcnAZ.js} +1 -1
  28. package/dist/{chunk-CXQJG7pb.js → chunk-YMkMvAsa.js} +2 -2
  29. package/dist/{chunk-C-EHtpbB.js → chunk-h5aOI_Pa.js} +1 -1
  30. package/dist/chunk-kizhspe5.js +1 -0
  31. package/dist/client-dispatch.js +1 -1
  32. package/dist/client-history.js +1 -1
  33. package/dist/client-stream.js +1 -1
  34. package/dist/{code-generators-DqauYyfX.d.cts → code-generators-DPAqTcAz.d.cts} +0 -2
  35. package/dist/{code-generators-DqauYyfX.d.ts → code-generators-DPAqTcAz.d.ts} +0 -2
  36. package/dist/codegen.cjs +1 -1
  37. package/dist/codegen.d.cts +1 -1
  38. package/dist/codegen.d.ts +1 -1
  39. package/dist/codegen.js +1 -1
  40. package/dist/composer-actions-runtime.cjs +1 -1
  41. package/dist/composer-actions-runtime.d.cts +4 -20
  42. package/dist/composer-actions-runtime.d.ts +4 -20
  43. package/dist/composer-actions-runtime.js +1 -1
  44. package/dist/{composer-input-DnqGCXZz.d.ts → composer-input-i_FiUp6J.d.cts} +1 -7
  45. package/dist/{composer-input-DnqGCXZz.d.cts → composer-input-i_FiUp6J.d.ts} +1 -7
  46. package/dist/{composer-store-6Iwy6WLI.d.ts → composer-store-DhGNgG7Z.d.cts} +1 -3
  47. package/dist/{composer-store-6Iwy6WLI.d.cts → composer-store-DhGNgG7Z.d.ts} +1 -3
  48. package/dist/config-diagnostics.d.cts +0 -2
  49. package/dist/config-diagnostics.d.ts +0 -2
  50. package/dist/{context-mention-manager-B-rHF98n.d.ts → context-mention-manager-DL-xwGfL.d.cts} +2 -6
  51. package/dist/{context-mention-manager-B-rHF98n.d.cts → context-mention-manager-DL-xwGfL.d.ts} +2 -6
  52. package/dist/context-mentions-inline.cjs +1 -1
  53. package/dist/context-mentions-inline.d.cts +2 -4
  54. package/dist/context-mentions-inline.d.ts +2 -4
  55. package/dist/context-mentions-inline.js +1 -1
  56. package/dist/context-mentions.cjs +1 -1
  57. package/dist/context-mentions.d.cts +3 -9
  58. package/dist/context-mentions.d.ts +3 -9
  59. package/dist/{defaults-BuOjqUAG.d.ts → defaults-Bga2Xrsp.d.cts} +1 -5
  60. package/dist/{defaults-BuOjqUAG.d.cts → defaults-Bga2Xrsp.d.ts} +1 -5
  61. package/dist/dictation-runtime.cjs +1 -1
  62. package/dist/dictation-runtime.d.cts +4 -13
  63. package/dist/dictation-runtime.d.ts +4 -13
  64. package/dist/dictation-runtime.js +1 -1
  65. package/dist/{dom-context-DHTabIn7.d.cts → dom-context-Bw-_9Evh.d.cts} +0 -2
  66. package/dist/{dom-context-DHTabIn7.d.ts → dom-context-Bw-_9Evh.d.ts} +0 -2
  67. package/dist/event-stream-view.cjs +1 -1
  68. package/dist/event-stream-view.d.cts +3 -16
  69. package/dist/event-stream-view.d.ts +3 -16
  70. package/dist/{fallback-speech-engine-SqEfkGda.d.ts → fallback-speech-engine-kLdyPcmk.d.cts} +1 -3
  71. package/dist/{fallback-speech-engine-SqEfkGda.d.cts → fallback-speech-engine-kLdyPcmk.d.ts} +1 -3
  72. package/dist/{feature-preferences-DqAmje-2.d.ts → feature-preferences-CmwZGKrf.d.cts} +1 -5
  73. package/dist/{feature-preferences-DqAmje-2.d.cts → feature-preferences-CmwZGKrf.d.ts} +1 -5
  74. package/dist/forms-ui.d.cts +2 -4
  75. package/dist/forms-ui.d.ts +2 -4
  76. package/dist/{history-confirm-dialog-B3L7QNcO.d.cts → history-confirm-dialog-i46-lPN_.d.cts} +2 -14
  77. package/dist/{history-confirm-dialog-B3L7QNcO.d.ts → history-confirm-dialog-i46-lPN_.d.ts} +2 -14
  78. package/dist/history-shell.cjs +1 -1
  79. package/dist/history-shell.d.cts +8 -27
  80. package/dist/history-shell.d.ts +8 -27
  81. package/dist/history-shell.js +1 -1
  82. package/dist/history-view.d.cts +1 -1
  83. package/dist/history-view.d.ts +1 -1
  84. package/dist/icons-extra.cjs +1 -1
  85. package/dist/icons-extra.d.cts +0 -2
  86. package/dist/icons-extra.d.ts +0 -2
  87. package/dist/index.cjs +45 -45
  88. package/dist/index.cjs.map +1 -1
  89. package/dist/index.d.cts +10 -10
  90. package/dist/index.d.ts +10 -10
  91. package/dist/index.global.js +1 -1
  92. package/dist/index.global.js.map +1 -1
  93. package/dist/index.js +49 -49
  94. package/dist/index.js.map +1 -1
  95. package/dist/install.global.js +1 -1
  96. package/dist/install.global.js.map +1 -1
  97. package/dist/launcher.global.js +2 -2
  98. package/dist/launcher.global.js.map +1 -1
  99. package/dist/{loading-label-runtime-loader-JecjOfJS.d.ts → loading-label-runtime-loader-Ccr3ETd3.d.cts} +1 -3
  100. package/dist/{loading-label-runtime-loader-JecjOfJS.d.cts → loading-label-runtime-loader-Ccr3ETd3.d.ts} +1 -3
  101. package/dist/loading-label-runtime.cjs +1 -1
  102. package/dist/loading-label-runtime.d.cts +1 -1
  103. package/dist/loading-label-runtime.d.ts +1 -1
  104. package/dist/loading-label-runtime.js +1 -1
  105. package/dist/markdown-parsers.js +1 -1
  106. package/dist/{message-bubble-CBYtpDsT.d.ts → message-bubble-C6AVaH7f.d.cts} +20 -3
  107. package/dist/{message-bubble-CBYtpDsT.d.cts → message-bubble-C6AVaH7f.d.ts} +20 -3
  108. package/dist/{plugin-kit-C1mRjLgW.d.cts → plugin-kit-BVggqE3C.d.cts} +0 -2
  109. package/dist/{plugin-kit-C1mRjLgW.d.ts → plugin-kit-BVggqE3C.d.ts} +0 -2
  110. package/dist/plugin-kit.d.cts +1 -1
  111. package/dist/plugin-kit.d.ts +1 -1
  112. package/dist/preferences-runtime.cjs +1 -1
  113. package/dist/preferences-runtime.d.cts +1 -1
  114. package/dist/preferences-runtime.d.ts +1 -1
  115. package/dist/preferences-runtime.js +1 -1
  116. package/dist/{preview-_UJwwt2L.d.cts → preview-BMdRffo4.d.cts} +2 -6
  117. package/dist/{preview-_UJwwt2L.d.ts → preview-BMdRffo4.d.ts} +2 -6
  118. package/dist/{registry-B5ZV3K-h.d.ts → registry-B16l8s1b.d.cts} +1 -8
  119. package/dist/{registry-B5ZV3K-h.d.cts → registry-B16l8s1b.d.ts} +1 -8
  120. package/dist/{session-Bys8_XuB.d.cts → session-CtZHe7fc.d.cts} +8 -92
  121. package/dist/{session-Bys8_XuB.d.ts → session-CtZHe7fc.d.ts} +8 -92
  122. package/dist/session-reconnect.d.cts +2 -4
  123. package/dist/session-reconnect.d.ts +2 -4
  124. package/dist/smart-dom-reader.d.cts +2 -8
  125. package/dist/smart-dom-reader.d.ts +2 -8
  126. package/dist/testing.d.cts +0 -2
  127. package/dist/testing.d.ts +0 -2
  128. package/dist/theme-editor-preview.cjs +42 -42
  129. package/dist/theme-editor-preview.d.cts +1 -1
  130. package/dist/theme-editor-preview.d.ts +1 -1
  131. package/dist/theme-editor-preview.js +25 -25
  132. package/dist/theme-editor.cjs +25 -5
  133. package/dist/theme-editor.d.cts +2 -22
  134. package/dist/theme-editor.d.ts +2 -22
  135. package/dist/theme-editor.js +1 -1
  136. package/dist/theme-reference.cjs +1 -1
  137. package/dist/theme-reference.d.cts +2 -4
  138. package/dist/theme-reference.d.ts +2 -4
  139. package/dist/theme-reference.js +1 -1
  140. package/dist/{tool-loading-animation-d5qAQcwv.d.ts → tool-loading-animation-D2cyFvmM.d.cts} +1 -7
  141. package/dist/{tool-loading-animation-d5qAQcwv.d.cts → tool-loading-animation-D2cyFvmM.d.ts} +1 -7
  142. package/dist/{tooltip-BkW-8MnD.d.cts → tooltip-DbXkAuJL.d.cts} +0 -2
  143. package/dist/{tooltip-BkW-8MnD.d.ts → tooltip-DbXkAuJL.d.ts} +0 -2
  144. package/dist/{turn-activity-ui-q6fHVnx-.d.cts → turn-activity-ui-Do1Nd-Ow.d.cts} +2 -12
  145. package/dist/{turn-activity-ui-q6fHVnx-.d.ts → turn-activity-ui-Do1Nd-Ow.d.ts} +2 -12
  146. package/dist/turn-activity-ui.d.cts +1 -1
  147. package/dist/turn-activity-ui.d.ts +1 -1
  148. package/dist/{types-B_mj21jg.d.cts → types-BC7MRxO5.d.cts} +76 -29
  149. package/dist/{types-B_mj21jg.d.ts → types-BC7MRxO5.d.ts} +76 -29
  150. package/dist/ui-extras.js +3 -3
  151. package/dist/voice-client-tools.d.cts +1 -3
  152. package/dist/voice-client-tools.d.ts +1 -3
  153. package/dist/voice-runtime.cjs +1 -1
  154. package/dist/voice-runtime.d.cts +2 -12
  155. package/dist/voice-runtime.d.ts +2 -12
  156. package/dist/voice-runtime.js +2 -2
  157. package/dist/voice-worklet-player.d.cts +2 -6
  158. package/dist/voice-worklet-player.d.ts +2 -6
  159. package/dist/{webmcp-runtime-entry-ClEO3qdI.d.ts → webmcp-runtime-entry-B8g46vS0.d.cts} +1 -5
  160. package/dist/{webmcp-runtime-entry-ClEO3qdI.d.cts → webmcp-runtime-entry-B8g46vS0.d.ts} +1 -5
  161. package/dist/webmcp-runtime.d.cts +1 -1
  162. package/dist/webmcp-runtime.d.ts +1 -1
  163. package/dist/widget.css +1 -1
  164. package/package.json +4 -4
  165. package/src/components/event-stream-view.test.ts +5 -0
  166. package/src/components/message-bubble.ts +26 -6
  167. package/src/components/scroll-to-bottom.test.ts +135 -0
  168. package/src/components/scroll-to-bottom.ts +50 -13
  169. package/src/components/user-message-collapse.ts +117 -0
  170. package/src/defaults.ts +6 -0
  171. package/src/styles/widget.css +56 -0
  172. package/src/theme-reference.ts +2 -2
  173. package/src/types/theme.ts +29 -0
  174. package/src/types.ts +49 -5
  175. package/src/ui.scroll-additive.test.ts +219 -117
  176. package/src/ui.scroll.test.ts +176 -13
  177. package/src/ui.ts +230 -107
  178. package/src/ui.turn-activity.test.ts +7 -7
  179. package/src/ui.user-message-collapse.test.ts +540 -0
  180. package/src/utils/auto-follow.test.ts +0 -38
  181. package/src/utils/auto-follow.ts +0 -35
  182. package/src/utils/theme.test.ts +23 -0
  183. package/src/utils/tokens.ts +16 -0
  184. package/dist/chunk-CVMIncEY.js +0 -1
  185. package/dist/chunk-DVgzoVCB.js +0 -1
  186. package/dist/chunk-d54BJdKd.js +0 -1
package/src/ui.ts CHANGED
@@ -203,7 +203,6 @@ import {
203
203
  createFollowStateController,
204
204
  getScrollBottomOffset,
205
205
  hasSelectionWithin,
206
- isAttentionWorthyMessage,
207
206
  isElementNearBottom,
208
207
  resolveFollowStateFromScroll,
209
208
  resolveFollowStateFromWheel
@@ -272,6 +271,17 @@ import { buildHeaderWithLayout } from "./components/header-layouts";
272
271
  import { positionMap } from "./utils/positioning";
273
272
  import type { HeaderElements as _HeaderElements, ComposerElements as _ComposerElements } from "./components/panel";
274
273
  import { MessageTransform, MessageActionCallbacks, MessageActionEligibility } from "./components/message-bubble";
274
+ import {
275
+ applyUserCollapseState,
276
+ isUserMessageCollapseEnabled,
277
+ resolveUserMessageCollapseLabels,
278
+ resolveUserMessageCollapseMaxHeight,
279
+ userMessageOverflows,
280
+ USER_COLLAPSE_REGION_ATTR,
281
+ USER_COLLAPSE_TOGGLE_ATTR,
282
+ type UserMessageCollapseRender,
283
+ type UserMessageCollapseState,
284
+ } from "./components/user-message-collapse";
275
285
  import {
276
286
  createStandardBubble,
277
287
  createTypingIndicator,
@@ -972,6 +982,31 @@ export const createAgentExperience = (
972
982
  const toolExpansionState = new Set<string>();
973
983
  const reasoningExpansionState = new Set<string>();
974
984
  const approvalDetailsExpansionState = new Map<string, boolean>();
985
+ // Long user message collapse, per message id: the measured overflow and the
986
+ // reader's own toggle (true = expanded). Render state derives from both, so
987
+ // re-renders and morphs never re-collapse or re-expand a toggled bubble.
988
+ const userCollapseOverflow = new Map<string, boolean>();
989
+ const userCollapseToggled = new Map<string, boolean>();
990
+ // Voice transcripts rendered while interim. Their final render stays
991
+ // expanded ("settling") so the post-morph sync applies the collapse with
992
+ // the viewport held, rather than the morph collapsing it underneath.
993
+ const userCollapseLive = new Set<string>();
994
+ const resolveUserCollapse = (message: AgentWidgetMessage): UserMessageCollapseRender | undefined => {
995
+ if (message.role !== "user" || !isUserMessageCollapseEnabled(config)) return undefined;
996
+ if (message.voiceProcessing) userCollapseLive.add(message.id);
997
+ const state: UserMessageCollapseState = message.voiceProcessing
998
+ ? "live"
999
+ : userCollapseLive.has(message.id)
1000
+ ? "settling"
1001
+ : !userCollapseOverflow.get(message.id)
1002
+ ? "fit"
1003
+ : userCollapseToggled.get(message.id) ? "expanded" : "collapsed";
1004
+ return {
1005
+ state,
1006
+ maxHeight: resolveUserMessageCollapseMaxHeight(config),
1007
+ ...resolveUserMessageCollapseLabels(config),
1008
+ };
1009
+ };
975
1010
  const applyTooltipTiming = (): void => {
976
1011
  configureTooltipTiming({
977
1012
  delayMs: config.tooltip?.delayMs ?? DEFAULT_TOOLTIP_DELAY_MS,
@@ -2864,6 +2899,40 @@ export const createAgentExperience = (
2864
2899
  }
2865
2900
  }, { capture: true });
2866
2901
 
2902
+ // "Show more" / "Show less" on a long user message. Toggling is reading
2903
+ // intent like a tool disclosure: it pauses follow and never re-anchors.
2904
+ const setUserCollapseExpanded = (bubble: HTMLElement, expanded: boolean) => {
2905
+ const region = bubble.querySelector<HTMLElement>(`[${USER_COLLAPSE_REGION_ATTR}]`);
2906
+ const messageId = bubble.getAttribute("data-message-id");
2907
+ if (!region || !messageId) return;
2908
+ markReaderEngaged();
2909
+ pauseAutoScroll();
2910
+ userCollapseToggled.set(messageId, expanded);
2911
+ applyUserCollapseChange(bubble, region, messageId, {
2912
+ state: expanded ? "expanded" : "collapsed",
2913
+ maxHeight: resolveUserMessageCollapseMaxHeight(config),
2914
+ ...resolveUserMessageCollapseLabels(config),
2915
+ }, "toggle");
2916
+ };
2917
+ messagesWrapper.addEventListener("click", (event) => {
2918
+ const target = event.target;
2919
+ if (!(target instanceof Element)) return;
2920
+ const toggle = target.closest<HTMLButtonElement>(`button[${USER_COLLAPSE_TOGGLE_ATTR}]`);
2921
+ const bubble = toggle?.closest<HTMLElement>("[data-message-id]");
2922
+ if (!toggle || !bubble) return;
2923
+ event.preventDefault();
2924
+ setUserCollapseExpanded(bubble, toggle.getAttribute("aria-expanded") !== "true");
2925
+ });
2926
+ // Keyboard focus landing on a link or control inside the clipped part
2927
+ // expands the message, so a focused element is never hidden behind the clip.
2928
+ messagesWrapper.addEventListener("focusin", (event) => {
2929
+ const target = event.target;
2930
+ if (!(target instanceof Element)) return;
2931
+ const region = target.closest(`[${USER_COLLAPSE_REGION_ATTR}="collapsed"]`);
2932
+ const bubble = region?.closest<HTMLElement>("[data-message-id]");
2933
+ if (bubble) setUserCollapseExpanded(bubble, true);
2934
+ });
2935
+
2867
2936
  // Add event delegation for reasoning and tool bubble expansion
2868
2937
  // This handles clicks even after idiomorph morphs the DOM
2869
2938
  const handleBubbleExpansion = (event: Event) => {
@@ -5120,14 +5189,13 @@ export const createAgentExperience = (
5120
5189
  // pinned and never re-arms the fallback, so a late-loading embed can't yank
5121
5190
  // the viewport down to the bottom.
5122
5191
  let currentTurnAnchored = false;
5123
- // Which message the anchor is currently parked on. Starts as the sent user
5124
- // message and moves once to the turn's first attention-worthy block (see
5125
- // `maybeAnchorToFirstUnread`).
5192
+ // The sent user message the anchor is parked on. It holds for the whole
5193
+ // reply, however tall the message or its tool prelude: the jump-to-latest
5194
+ // control is how the reader reaches output below the fold.
5126
5195
  let anchoredMessageId: string | null = null;
5127
- // Has the reader taken over positioning this turn? First-unread anchoring is
5128
- // a guess about where someone who ISN'T watching wants to land; the moment
5129
- // they demonstrably are watching, the guess stands down and never moves the
5130
- // viewport under them. Reset on each user send (an explicit "take me along").
5196
+ // Has the reader taken over positioning this turn? Layout re-pins (a host
5197
+ // move) stand down once they demonstrably are, and never move the viewport
5198
+ // under them. Reset on each user send (an explicit "take me along").
5131
5199
  //
5132
5200
  // Driven by real INPUT (wheel, touch drag, pointer press, transcript
5133
5201
  // keyboard nav, focus, selection) rather than by scroll deltas: streaming
@@ -5488,9 +5556,9 @@ export const createAgentExperience = (
5488
5556
  (getScrollMode() !== "anchor-top" || isActivityWhilePinnedEnabled());
5489
5557
 
5490
5558
  const updateScrollToBottomCountBadge = () => {
5491
- scrollToBottomControl.update(config,
5492
- scrollToBottomButton.getAttribute("data-persona-scroll-to-bottom-visible") === "true",
5493
- isStreamingOutOfView(), newMessagesSincePause);
5559
+ const visible = scrollToBottomButton.getAttribute("data-persona-scroll-to-bottom-visible") === "true";
5560
+ scrollToBottomControl.update(config, visible, isStreamingOutOfView(), newMessagesSincePause,
5561
+ visible && isApprovalBelow());
5494
5562
  };
5495
5563
 
5496
5564
  const resetNewMessagesCount = () => {
@@ -5502,14 +5570,42 @@ export const createAgentExperience = (
5502
5570
  // Spacer air and composer reserve are scrollable, but aren't unread content.
5503
5571
  // Compare the real content bottom with the readable viewport instead. An
5504
5572
  // overlay footer clips that viewport; a block footer sits below it.
5505
- const isNearLatestContent = () => {
5506
- const contentHeight = measureContentHeight(anchorState?.spacerHeight ?? 0, false);
5573
+ const readableViewportHeight = () => {
5507
5574
  const bodyRect = body.getBoundingClientRect();
5508
5575
  const footerRect = footer.getBoundingClientRect();
5509
- const readableHeight = footerRect.height > 0 && footerRect.bottom > bodyRect.top
5576
+ return footerRect.height > 0 && footerRect.bottom > bodyRect.top
5510
5577
  ? Math.min(body.clientHeight, Math.max(0, footerRect.top - bodyRect.top - body.clientTop))
5511
5578
  : body.clientHeight;
5512
- return contentHeight - body.scrollTop - readableHeight <= BOTTOM_THRESHOLD;
5579
+ };
5580
+ const isNearLatestContent = () => {
5581
+ const contentHeight = measureContentHeight(anchorState?.spacerHeight ?? 0, false);
5582
+ return contentHeight - body.scrollTop - readableViewportHeight() <= BOTTOM_THRESHOLD;
5583
+ };
5584
+
5585
+ // A pending approval whose card starts below the readable viewport. The
5586
+ // anchor never scrolls the reader to it, so the jump control names it.
5587
+ const isBelowFold = (element: HTMLElement) =>
5588
+ offsetTopWithinBody(element) > body.scrollTop + readableViewportHeight() - BOTTOM_THRESHOLD;
5589
+ const pendingApprovalBubbles = () => (session?.getMessages() ?? [])
5590
+ .filter((message) => message.variant === "approval" && message.approval?.status === "pending")
5591
+ .map((message) => queryMessageBubble(message.id))
5592
+ .filter((bubble): bubble is HTMLElement => Boolean(bubble?.isConnected));
5593
+ const isApprovalBelow = () => pendingApprovalBubbles().some(isBelowFold);
5594
+ const isFullyInView = (element: HTMLElement) => {
5595
+ const top = offsetTopWithinBody(element);
5596
+ return top >= body.scrollTop && top + element.offsetHeight <= body.scrollTop + readableViewportHeight();
5597
+ };
5598
+ // The approvals a jump from the "Approval needed" control was taken for.
5599
+ // Once it lands, focus moves to the first of them whose primary action is
5600
+ // fully in view, and never to one the reader can't see.
5601
+ let approvalJumpTargets: string[] = [];
5602
+ const focusApprovalJumpTarget = () => {
5603
+ const targets = approvalJumpTargets;
5604
+ approvalJumpTargets = [];
5605
+ for (const id of targets) {
5606
+ const allow = queryMessageBubble(id)?.querySelector<HTMLButtonElement>('[data-action="allow"]');
5607
+ if (allow && isFullyInView(allow)) { allow.focus({ preventScroll: true }); return; }
5608
+ }
5513
5609
  };
5514
5610
 
5515
5611
  // Whether the user is currently away from the latest content: drives both
@@ -5541,7 +5637,7 @@ export const createAgentExperience = (
5541
5637
  const hasOverflow = getScrollBottomOffset(body) > 0;
5542
5638
  const show = !latestScroll.active && hasOverflow && isAwayFromLatest() && transcriptHasMessages();
5543
5639
  if (!show) newMessagesSincePause = 0;
5544
- scrollToBottomControl.update(config, show, isStreamingOutOfView(), newMessagesSincePause);
5640
+ scrollToBottomControl.update(config, show, isStreamingOutOfView(), newMessagesSincePause, show && isApprovalBelow());
5545
5641
  };
5546
5642
 
5547
5643
  const pauseAutoScroll = () => {
@@ -5611,8 +5707,8 @@ export const createAgentExperience = (
5611
5707
 
5612
5708
  const latestScroll = createScrollToLatest(body, container,
5613
5709
  () => { lastScrollTop = body.scrollTop; },
5614
- () => { resumeAutoScroll(); scheduleAutoScroll(true); syncScrollToBottomButton(); },
5615
- () => { autoFollow.pause(); syncScrollToBottomButton(); });
5710
+ () => { resumeAutoScroll(); scheduleAutoScroll(true); syncScrollToBottomButton(); focusApprovalJumpTarget(); },
5711
+ () => { approvalJumpTargets = []; autoFollow.pause(); syncScrollToBottomButton(); });
5616
5712
 
5617
5713
  // Custom smooth scroll animation with easing
5618
5714
  const smoothScrollToBottom = (element: HTMLElement, duration = 500) => {
@@ -5659,9 +5755,6 @@ export const createAgentExperience = (
5659
5755
  syncScrollToBottomButton();
5660
5756
  };
5661
5757
 
5662
- // Defined here (rather than beside the scroll listeners) because
5663
- // `maybeAnchorToFirstUnread` runs on every render and would otherwise hit
5664
- // these consts in their temporal dead zone on a synchronous first paint.
5665
5758
  const getTranscriptSelection = (): Selection | null => {
5666
5759
  // Selections inside a shadow root are not always reflected by
5667
5760
  // document.getSelection(); prefer the shadow root's view when available
@@ -5795,14 +5888,6 @@ export const createAgentExperience = (
5795
5888
  `[data-message-id="${escapeMessageId(messageId)}"]`
5796
5889
  );
5797
5890
 
5798
- // Progress inside a turn disclosure belongs to its visible header. Pinning
5799
- // the nested prose instead clips that header, and leaves a hidden anchor
5800
- // when the completed turn folds away. Keep ordinary message lookups intact.
5801
- const queryScrollAnchor = (messageId: string): HTMLElement | null => {
5802
- const bubble = queryMessageBubble(messageId);
5803
- return bubble?.closest<HTMLElement>("[data-persona-turn-activity]") ?? bubble;
5804
- };
5805
-
5806
5891
  // Anchor-top mode: scroll `messageId` to rest `anchorTopOffset` px below the
5807
5892
  // viewport top and hold it there while the response streams in beneath it.
5808
5893
  // Deferred one frame so the message bubble has been rendered and laid out.
@@ -5812,7 +5897,7 @@ export const createAgentExperience = (
5812
5897
  }
5813
5898
  anchorRAF = requestAnimationFrame(() => {
5814
5899
  anchorRAF = null;
5815
- const bubble = queryScrollAnchor(messageId);
5900
+ const bubble = queryMessageBubble(messageId);
5816
5901
  if (!bubble) return;
5817
5902
 
5818
5903
  // Bubble top relative to the scroll content. offsetTop is used instead
@@ -5866,7 +5951,7 @@ export const createAgentExperience = (
5866
5951
  const repinAnchoredMessage = () => {
5867
5952
  if (getScrollMode() !== "anchor-top") return;
5868
5953
  if (!anchorState || !anchoredMessageId) return;
5869
- const bubble = queryScrollAnchor(anchoredMessageId);
5954
+ const bubble = queryMessageBubble(anchoredMessageId);
5870
5955
  if (!bubble) return;
5871
5956
  cancelSmoothScroll();
5872
5957
  const previousSpacerHeight = anchorState.spacerHeight;
@@ -5920,6 +6005,94 @@ export const createAgentExperience = (
5920
6005
  syncScrollToBottomButton();
5921
6006
  };
5922
6007
 
6008
+ // Re-applies a user bubble's collapse state in place, holding the viewport.
6009
+ // A toggle holds the bubble's top (a collapse whose top scrolled out of view
6010
+ // brings it back to the anchor offset, so the toggle stays in view). A measurement
6011
+ // change holds what the reader sees: the top when it is in view, otherwise
6012
+ // the bubble's bottom edge, so the content below never moves. While
6013
+ // anchored, the spacer is re-reserved first so a shrink can't clamp
6014
+ // scrollTop, then trimmed to the held position with a fresh drain baseline.
6015
+ const applyUserCollapseChange = (
6016
+ bubble: HTMLElement,
6017
+ region: HTMLElement,
6018
+ messageId: string,
6019
+ render: UserMessageCollapseRender,
6020
+ intent: "toggle" | "measure"
6021
+ ) => {
6022
+ messageCache.delete(messageId);
6023
+ const holdViewport = intent === "toggle" || !(isFollowEffective() && autoFollow.isFollowing());
6024
+ if (!holdViewport) {
6025
+ applyUserCollapseState(bubble, region, messageId, render);
6026
+ return;
6027
+ }
6028
+ const bodyTop = body.getBoundingClientRect().top;
6029
+ const before = bubble.getBoundingClientRect();
6030
+ const anchor = anchorState;
6031
+ if (anchor) {
6032
+ cancelSmoothScroll();
6033
+ setAnchorSpacerHeight(anchor.spacerHeight + bubble.offsetHeight);
6034
+ }
6035
+ applyUserCollapseState(bubble, region, messageId, render);
6036
+ const after = bubble.getBoundingClientRect();
6037
+ const prevTop = before.top - bodyTop;
6038
+ const shift = intent === "toggle"
6039
+ ? after.top - bodyTop - (render.state === "collapsed" && prevTop < 0 ? getAnchorTopOffset() : prevTop)
6040
+ : prevTop >= 0 ? after.top - before.top : after.bottom - before.bottom;
6041
+ const target = Math.max(0, body.scrollTop + shift);
6042
+ if (anchor && anchorState === anchor) {
6043
+ setAnchorSpacerHeight(target + body.clientHeight + BOTTOM_THRESHOLD - measureContentHeight(anchor.spacerHeight));
6044
+ anchor.initialSpacerHeight = anchor.spacerHeight;
6045
+ anchor.contentHeightAtAnchor = body.scrollHeight - anchor.spacerHeight;
6046
+ }
6047
+ if (Math.abs(target - body.scrollTop) >= 1) {
6048
+ isAutoScrolling = true;
6049
+ body.scrollTop = target;
6050
+ lastScrollTop = body.scrollTop;
6051
+ isAutoScrolling = false;
6052
+ }
6053
+ syncScrollToBottomButton();
6054
+ };
6055
+
6056
+ // Measures every built-in user bubble's real rendered height (markdown,
6057
+ // code, wrapping, attachments) after a morph or resize and collapses the
6058
+ // ones that overflow. Unlaid regions (a closed panel, jsdom) measure 0 and
6059
+ // keep their state until a later pass can measure them.
6060
+ const syncUserMessageCollapse = () => {
6061
+ if (!isUserMessageCollapseEnabled(config)) return;
6062
+ const maxHeight = resolveUserMessageCollapseMaxHeight(config);
6063
+ for (const region of Array.from(
6064
+ messagesWrapper.querySelectorAll<HTMLElement>(`[${USER_COLLAPSE_REGION_ATTR}]`)
6065
+ )) {
6066
+ const bubble = region.closest<HTMLElement>("[data-message-id]");
6067
+ const messageId = bubble?.getAttribute("data-message-id");
6068
+ if (!bubble || !messageId) continue;
6069
+ const naturalHeight = region.scrollHeight;
6070
+ if (naturalHeight > 0) {
6071
+ userCollapseOverflow.set(messageId, userMessageOverflows(naturalHeight, maxHeight));
6072
+ }
6073
+ const overflows = userCollapseOverflow.get(messageId) ?? false;
6074
+ const current = region.getAttribute(USER_COLLAPSE_REGION_ATTR);
6075
+ // An interim voice transcript stays expanded so the speaker can watch it.
6076
+ if (current === "live") continue;
6077
+ if (current === "settling") userCollapseLive.delete(messageId);
6078
+ const state: UserMessageCollapseState = !overflows
6079
+ ? "fit"
6080
+ : userCollapseToggled.get(messageId) ? "expanded" : "collapsed";
6081
+ if (state === current) continue;
6082
+ applyUserCollapseChange(bubble, region, messageId, {
6083
+ state, maxHeight, ...resolveUserMessageCollapseLabels(config),
6084
+ }, "measure");
6085
+ }
6086
+ };
6087
+
6088
+ const pruneUserMessageCollapse = (messages: AgentWidgetMessage[]) => {
6089
+ if (userCollapseOverflow.size === 0 && userCollapseToggled.size === 0 && userCollapseLive.size === 0) return;
6090
+ const ids = new Set(messages.map((message) => message.id));
6091
+ for (const map of [userCollapseOverflow, userCollapseToggled, userCollapseLive]) {
6092
+ for (const id of map.keys()) if (!ids.has(id)) map.delete(id);
6093
+ }
6094
+ };
6095
+
5923
6096
  // Reacts to a user message the user just sent (seeded so restored history
5924
6097
  // never triggers it). Follow mode re-sticks to the bottom even if the user
5925
6098
  // had scrolled up: sending is an unambiguous "take me to the latest"
@@ -5935,8 +6108,8 @@ export const createAgentExperience = (
5935
6108
  // next user send.
5936
6109
  followFallbackActive = false;
5937
6110
  currentTurnAnchored = true;
5938
- // A send is an explicit "take me along": it re-arms first-unread
5939
- // positioning even if the reader had scrolled away during the last turn.
6111
+ // A send is an explicit "take me along": it re-arms anchoring even if
6112
+ // the reader had scrolled away during the last turn.
5940
6113
  readerEngagedThisTurn = false;
5941
6114
  anchoredMessageId = messageId;
5942
6115
  scheduleAnchorToMessage(messageId);
@@ -5962,75 +6135,6 @@ export const createAgentExperience = (
5962
6135
  scheduleAutoScroll(true);
5963
6136
  };
5964
6137
 
5965
- // Anchor-top positioning: park on the turn's FIRST UNREAD attention-worthy
5966
- // block.
5967
- //
5968
- // The read boundary is the last user send — the reader wrote it, so it (and
5969
- // everything above) is read; every attention-worthy block after it is not.
5970
- // Tool calls and reasoning are chrome and never targeted, so they scroll
5971
- // past underneath rather than parking the reader on progress output.
5972
- //
5973
- // The policy assumes the reader is NOT watching the stream, and picks the
5974
- // best place for them to land when they look back: the start of what they
5975
- // haven't seen. `readerEngagedThisTurn` is the escape hatch — once someone
5976
- // is demonstrably driving, their position wins and this stands down until
5977
- // the next send.
5978
- //
5979
- // Anchoring to the first unread (not the newest) block means this fires at
5980
- // most once per turn in practice, and later blocks stream in below the
5981
- // anchor exactly as they do today.
5982
-
5983
- // Minimum readable slice of the first unread block that must fit under the
5984
- // pinned user message for the anchor to stay put. Below this, the block is
5985
- // "stranded" and the anchor hands off to it.
5986
- const ANCHOR_HANDOFF_MIN_VISIBLE = 96;
5987
-
5988
- // Market pattern (ChatGPT/Claude/Gemini): the sent user message stays
5989
- // pinned at the top as the visible header of the streaming reply. The
5990
- // handoff to the first unread block exists for the stranded case — a long
5991
- // user message or a tall tool-call prelude pushing the reply below the
5992
- // fold — so it only fires when the block's start is not readably within
5993
- // the viewport while the user message holds the anchor.
5994
- const isFirstUnreadStranded = (messageId: string): boolean => {
5995
- if (lastSentUserMessageId === null) return true;
5996
- const userBubble = queryMessageBubble(lastSentUserMessageId);
5997
- const blockBubble = queryScrollAnchor(messageId);
5998
- // Unmeasurable geometry: fall back to handing off (pre-conditional
5999
- // behavior) rather than risking an answer parked below the fold.
6000
- if (!userBubble || !blockBubble) return true;
6001
- const delta =
6002
- offsetTopWithinBody(blockBubble) - offsetTopWithinBody(userBubble);
6003
- return (
6004
- getAnchorTopOffset() + delta >
6005
- body.clientHeight - ANCHOR_HANDOFF_MIN_VISIBLE
6006
- );
6007
- };
6008
-
6009
- const maybeAnchorToFirstUnread = (messages: AgentWidgetMessage[]) => {
6010
- if (getScrollMode() !== "anchor-top") return;
6011
- if (!currentTurnAnchored) return;
6012
- if (readerEngagedThisTurn) return;
6013
- if (hasActiveTranscriptSelection()) return;
6014
-
6015
- let pastReadBoundary = lastSentUserMessageId === null;
6016
- for (const message of messages) {
6017
- if (!pastReadBoundary) {
6018
- if (message.id === lastSentUserMessageId) pastReadBoundary = true;
6019
- continue;
6020
- }
6021
- if (!isAttentionWorthyMessage(message)) continue;
6022
- // Already parked on it: hold still while it streams.
6023
- if (message.id === anchoredMessageId) return;
6024
- // Block starts within view under the pinned user message: keep the
6025
- // user message anchored. Re-evaluated every render, so a tool prelude
6026
- // that later pushes the block below the fold still hands off.
6027
- if (!isFirstUnreadStranded(message.id)) return;
6028
- anchoredMessageId = message.id;
6029
- scheduleAnchorToMessage(message.id);
6030
- return;
6031
- }
6032
- };
6033
-
6034
6138
  const trackMessages = (messages: AgentWidgetMessage[]) => {
6035
6139
  const nextState = new Map<
6036
6140
  string,
@@ -6392,8 +6496,10 @@ export const createAgentExperience = (
6392
6496
  ? { toolCallId: linkedTool.id, status: linkedTool.status === "running"
6393
6497
  ? "running" : linkedTool.success === false || linkedTool.error ? "failed" : "completed" }
6394
6498
  : undefined;
6499
+ const userCollapse = resolveUserCollapse(message);
6395
6500
  const fingerprint =
6396
6501
  computeMessageFingerprint(message, configVersion) + askMeta + `:${eligibilityKey}` +
6502
+ (userCollapse ? `:uc-${userCollapse.state}` : "") +
6397
6503
  (approvalWithPlugin ? `:${message.approval?.toolCallId ?? ""}:${JSON.stringify(execution)}` : "");
6398
6504
  const cachedWrapper = (askWithPlugin || approvalWithPlugin || hasDirectiveBubble ||
6399
6505
  (message.dispatchError && config.errorDisplay === "block" && !approvalUi))
@@ -6442,7 +6548,8 @@ export const createAgentExperience = (
6442
6548
  // Keep the detail readable even while the secondary UI chunk loads.
6443
6549
  const bubble = createStandardBubble(message, transform, config.layout?.messages,
6444
6550
  config.messageActions, messageActionCallbacks, {
6445
- loadingIndicatorRenderer: inlineLoadingRenderer, widgetConfig: config, actionEligibility
6551
+ loadingIndicatorRenderer: inlineLoadingRenderer, widgetConfig: config, actionEligibility,
6552
+ userCollapse
6446
6553
  });
6447
6554
  bubble.dataset.personaDefaultMessage = "true";
6448
6555
  return bubble;
@@ -7227,7 +7334,12 @@ export const createAgentExperience = (
7227
7334
  wrapper.replaceChildren(bubble);
7228
7335
  wrapper.setAttribute("data-bubble-fp", fingerprint);
7229
7336
  lastApprovalBubbleFingerprint.set(messageId, fingerprint);
7230
- if (focusDefault) bubble.querySelector<HTMLButtonElement>('[data-action="allow"]')?.focus();
7337
+ // Never park focus (and Enter-to-allow) on a card the reader can't
7338
+ // see: a paused reader below the fold gets the "Approval needed" jump.
7339
+ const following = isFollowEffective() && autoFollow.isFollowing();
7340
+ if (focusDefault && (following || !isBelowFold(wrapper))) {
7341
+ bubble.querySelector<HTMLButtonElement>('[data-action="allow"]')?.focus();
7342
+ }
7231
7343
  }
7232
7344
  }
7233
7345
 
@@ -7255,6 +7367,8 @@ export const createAgentExperience = (
7255
7367
  ) => {
7256
7368
  if (messages.length > 0) warmClientStream();
7257
7369
  renderMessagesWithPluginsImpl(container, messages, transform);
7370
+ pruneUserMessageCollapse(messages);
7371
+ syncUserMessageCollapse();
7258
7372
  refreshReadAloudButtons();
7259
7373
  if (revealedToolCalls.size > 0) {
7260
7374
  for (const [id, timer] of revealedToolCalls) {
@@ -8705,7 +8819,6 @@ export const createAgentExperience = (
8705
8819
  handleAssistantTurnStarted();
8706
8820
  }
8707
8821
  if (lastAssistantMessage) lastHandledAssistantId = lastAssistantMessage.id;
8708
- maybeAnchorToFirstUnread(messages);
8709
8822
 
8710
8823
  const prevLastUserMessageId = voiceState.lastUserMessageId;
8711
8824
  if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
@@ -11170,6 +11283,8 @@ export const createAgentExperience = (
11170
11283
  // (viewport resize) so the pin survives all of them.
11171
11284
  if (typeof ResizeObserver !== "undefined") {
11172
11285
  const contentResizeObserver = new ResizeObserver(() => {
11286
+ // Width changes rewrap text and late images grow attachments.
11287
+ syncUserMessageCollapse();
11173
11288
  handleContentResize();
11174
11289
  syncTranscriptEdgeFades();
11175
11290
  // A resize can start or stop a table overflowing without a render or a
@@ -11215,7 +11330,7 @@ export const createAgentExperience = (
11215
11330
  ]);
11216
11331
  const handleTranscriptKeydown = (event: KeyboardEvent) => {
11217
11332
  if (!NAV_KEYS.has(event.key)) return;
11218
- // Reader intent for first-unread anchoring is tracked unconditionally;
11333
+ // Reader intent for anchor re-pins is tracked unconditionally;
11219
11334
  // only the follow-mode *pause* below stays behind the opt-in flag.
11220
11335
  markReaderEngaged();
11221
11336
  if (!isPauseOnInteractionEnabled()) return;
@@ -11269,10 +11384,13 @@ export const createAgentExperience = (
11269
11384
  });
11270
11385
  destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
11271
11386
  scrollToBottomButton.addEventListener("click", () => {
11387
+ const targets = pendingApprovalBubbles().filter(isBelowFold)
11388
+ .map((bubble) => bubble.getAttribute("data-message-id")!);
11272
11389
  // Jumping to the latest abandons the current anchor: drop the spacer
11273
11390
  // first so "bottom" means the real end of content, not spacer padding
11274
11391
  // that would keep shrinking underneath the reader.
11275
11392
  resetAnchorState();
11393
+ approvalJumpTargets = targets;
11276
11394
  cancelAutoScroll();
11277
11395
  autoFollow.pause();
11278
11396
  latestScroll.start(config);
@@ -11810,6 +11928,10 @@ export const createAgentExperience = (
11810
11928
  const previousShowToolCalls = config.features?.showToolCalls;
11811
11929
  const previousToolCallDisplay = config.features?.toolCallDisplay;
11812
11930
  const previousReasoningDisplay = config.features?.reasoningDisplay;
11931
+ const userCollapseConfigKey = () => JSON.stringify([
11932
+ config.features?.userMessageCollapse, config.copy?.showMoreLabel, config.copy?.showLessLabel,
11933
+ ]);
11934
+ const previousUserCollapseConfig = userCollapseConfigKey();
11813
11935
  const previousStreamAnimationType = config.features?.streamAnimation?.type;
11814
11936
  // History provider identity: enablement, eligibility, and advertised
11815
11937
  // scopes are all resolved at construction, so a change rebuilds it.
@@ -12067,7 +12189,8 @@ export const createAgentExperience = (
12067
12189
  const featuresChanged = (config.features?.showReasoning ?? true) !== (previousShowReasoning ?? true)
12068
12190
  || (config.features?.showToolCalls ?? true) !== (previousShowToolCalls ?? true)
12069
12191
  || JSON.stringify(config.features?.toolCallDisplay) !== JSON.stringify(previousToolCallDisplay)
12070
- || JSON.stringify(config.features?.reasoningDisplay) !== JSON.stringify(previousReasoningDisplay);
12192
+ || JSON.stringify(config.features?.reasoningDisplay) !== JSON.stringify(previousReasoningDisplay)
12193
+ || userCollapseConfigKey() !== previousUserCollapseConfig;
12071
12194
  const messagesConfigChanged = (config.future?.v5Defaults === true) !== previousDefaultsVersion
12072
12195
  || "toolCall" in nextConfig || messageActionsChanged || layoutMessagesChanged
12073
12196
  || "loadingIndicator" in nextConfig || iterationDisplayChanged || featuresChanged
@@ -72,7 +72,7 @@ it("keeps a manual close/open choice through streaming and completion", async ()
72
72
 
73
73
  it.each([
74
74
  ["instant", false], ["height", false], ["instant", true], ["height", true],
75
- ] as const)("anchors the turn header through %s collapse (reader scrolled: %s)", async (animation, readerScrolled) => {
75
+ ] as const)("keeps a long user prompt anchored through %s collapse (reader scrolled: %s)", async (animation, readerScrolled) => {
76
76
  // Vitest's global performance and JSDOM frame timestamps can have different
77
77
  // origins. Match the browser's shared clock so the 220ms ease stays bounded.
78
78
  const requestFrame = globalThis.requestAnimationFrame;
@@ -83,9 +83,9 @@ it.each([
83
83
  scrollHeight: { configurable: true, get: () => 3000 },
84
84
  clientHeight: { configurable: true, get: () => 400 },
85
85
  });
86
- // A long user prompt strands the first progress block below the viewport.
87
- // Its text is inset from the disclosure header by 44px. Anchoring the text
88
- // would put the header 28px above the viewport (644 - 16 vs 600).
86
+ // A long user prompt puts the first progress block below the viewport. The
87
+ // prompt keeps the anchor (100 - 16): the disclosure streaming in and
88
+ // folding away below the fold never moves the reader to its header.
89
89
  vi.spyOn(HTMLElement.prototype, "offsetTop", "get").mockImplementation(function (this: HTMLElement) {
90
90
  if (this.hasAttribute("data-persona-turn-activity")) return 600;
91
91
  if (this.closest("[data-persona-turn-activity]")) return 644;
@@ -93,7 +93,7 @@ it.each([
93
93
  return 0;
94
94
  });
95
95
  await start(); await text("p", "Investigating the issue.");
96
- await vi.waitFor(() => expect(body.scrollTop).toBe(584));
96
+ await vi.waitFor(() => expect(body.scrollTop).toBe(84));
97
97
  if (readerScrolled) {
98
98
  body.dispatchEvent(new WheelEvent("wheel", { deltaY: 120, bubbles: true }));
99
99
  body.scrollTop = 800;
@@ -101,9 +101,9 @@ it.each([
101
101
  await text("answer", "The answer stays below the header.", "final");
102
102
  push("execution_complete", { kind: "agent", success: true }); close();
103
103
  await vi.waitFor(() => expect(header().getAttribute("aria-expanded")).toBe("false"));
104
- expect(body.scrollTop).toBe(readerScrolled ? 800 : 584);
104
+ expect(body.scrollTop).toBe(readerScrolled ? 800 : 84);
105
105
 
106
- // Later render/resize passes must not pull a reader back to the header.
106
+ // Later render/resize passes must not pull a reader back to the prompt.
107
107
  body.dispatchEvent(new WheelEvent("wheel", { deltaY: 120, bubbles: true }));
108
108
  body.scrollTop = 800;
109
109
  controller.update({ expansion: { animation, duration: 120 } });