@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
package/src/ui.ts CHANGED
@@ -112,6 +112,7 @@ import {
112
112
  type MessageEditEditor
113
113
  } from "./components/message-edit-editor";
114
114
  import {
115
+ canResubmitUserMessage,
115
116
  findPrecedingUserMessageId,
116
117
  findRetryableAssistantMessageId,
117
118
  isEditableUserMessage
@@ -202,7 +203,6 @@ import {
202
203
  createFollowStateController,
203
204
  getScrollBottomOffset,
204
205
  hasSelectionWithin,
205
- isAttentionWorthyMessage,
206
206
  isElementNearBottom,
207
207
  resolveFollowStateFromScroll,
208
208
  resolveFollowStateFromWheel
@@ -271,6 +271,17 @@ import { buildHeaderWithLayout } from "./components/header-layouts";
271
271
  import { positionMap } from "./utils/positioning";
272
272
  import type { HeaderElements as _HeaderElements, ComposerElements as _ComposerElements } from "./components/panel";
273
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";
274
285
  import {
275
286
  createStandardBubble,
276
287
  createTypingIndicator,
@@ -971,6 +982,31 @@ export const createAgentExperience = (
971
982
  const toolExpansionState = new Set<string>();
972
983
  const reasoningExpansionState = new Set<string>();
973
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
+ };
974
1010
  const applyTooltipTiming = (): void => {
975
1011
  configureTooltipTiming({
976
1012
  delayMs: config.tooltip?.delayMs ?? DEFAULT_TOOLTIP_DELAY_MS,
@@ -1408,8 +1444,9 @@ export const createAgentExperience = (
1408
1444
  const messageActionCallbacks: MessageActionCallbacks = {
1409
1445
  onCopy: (message: AgentWidgetMessage) => {
1410
1446
  eventBus.emit("message:copy", message);
1411
- // Send copy feedback to API if in client token mode
1412
- if (session?.isClientTokenMode()) {
1447
+ // Send copy feedback to API if in client token mode. Operator messages
1448
+ // are host-injected and unknown to the API, so they never report.
1449
+ if (session?.isClientTokenMode() && message.role !== "operator") {
1413
1450
  session.submitMessageFeedback(message.id, 'copy').catch((error) => {
1414
1451
  if (config.debug) {
1415
1452
  // eslint-disable-next-line no-console
@@ -2862,6 +2899,40 @@ export const createAgentExperience = (
2862
2899
  }
2863
2900
  }, { capture: true });
2864
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
+
2865
2936
  // Add event delegation for reasoning and tool bubble expansion
2866
2937
  // This handles clicks even after idiomorph morphs the DOM
2867
2938
  const handleBubbleExpansion = (event: Event) => {
@@ -5118,14 +5189,13 @@ export const createAgentExperience = (
5118
5189
  // pinned and never re-arms the fallback, so a late-loading embed can't yank
5119
5190
  // the viewport down to the bottom.
5120
5191
  let currentTurnAnchored = false;
5121
- // Which message the anchor is currently parked on. Starts as the sent user
5122
- // message and moves once to the turn's first attention-worthy block (see
5123
- // `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.
5124
5195
  let anchoredMessageId: string | null = null;
5125
- // Has the reader taken over positioning this turn? First-unread anchoring is
5126
- // a guess about where someone who ISN'T watching wants to land; the moment
5127
- // they demonstrably are watching, the guess stands down and never moves the
5128
- // 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").
5129
5199
  //
5130
5200
  // Driven by real INPUT (wheel, touch drag, pointer press, transcript
5131
5201
  // keyboard nav, focus, selection) rather than by scroll deltas: streaming
@@ -5486,9 +5556,9 @@ export const createAgentExperience = (
5486
5556
  (getScrollMode() !== "anchor-top" || isActivityWhilePinnedEnabled());
5487
5557
 
5488
5558
  const updateScrollToBottomCountBadge = () => {
5489
- scrollToBottomControl.update(config,
5490
- scrollToBottomButton.getAttribute("data-persona-scroll-to-bottom-visible") === "true",
5491
- isStreamingOutOfView(), newMessagesSincePause);
5559
+ const visible = scrollToBottomButton.getAttribute("data-persona-scroll-to-bottom-visible") === "true";
5560
+ scrollToBottomControl.update(config, visible, isStreamingOutOfView(), newMessagesSincePause,
5561
+ visible && isApprovalBelow());
5492
5562
  };
5493
5563
 
5494
5564
  const resetNewMessagesCount = () => {
@@ -5500,14 +5570,42 @@ export const createAgentExperience = (
5500
5570
  // Spacer air and composer reserve are scrollable, but aren't unread content.
5501
5571
  // Compare the real content bottom with the readable viewport instead. An
5502
5572
  // overlay footer clips that viewport; a block footer sits below it.
5503
- const isNearLatestContent = () => {
5504
- const contentHeight = measureContentHeight(anchorState?.spacerHeight ?? 0, false);
5573
+ const readableViewportHeight = () => {
5505
5574
  const bodyRect = body.getBoundingClientRect();
5506
5575
  const footerRect = footer.getBoundingClientRect();
5507
- const readableHeight = footerRect.height > 0 && footerRect.bottom > bodyRect.top
5576
+ return footerRect.height > 0 && footerRect.bottom > bodyRect.top
5508
5577
  ? Math.min(body.clientHeight, Math.max(0, footerRect.top - bodyRect.top - body.clientTop))
5509
5578
  : body.clientHeight;
5510
- 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
+ }
5511
5609
  };
5512
5610
 
5513
5611
  // Whether the user is currently away from the latest content: drives both
@@ -5539,7 +5637,7 @@ export const createAgentExperience = (
5539
5637
  const hasOverflow = getScrollBottomOffset(body) > 0;
5540
5638
  const show = !latestScroll.active && hasOverflow && isAwayFromLatest() && transcriptHasMessages();
5541
5639
  if (!show) newMessagesSincePause = 0;
5542
- scrollToBottomControl.update(config, show, isStreamingOutOfView(), newMessagesSincePause);
5640
+ scrollToBottomControl.update(config, show, isStreamingOutOfView(), newMessagesSincePause, show && isApprovalBelow());
5543
5641
  };
5544
5642
 
5545
5643
  const pauseAutoScroll = () => {
@@ -5609,8 +5707,8 @@ export const createAgentExperience = (
5609
5707
 
5610
5708
  const latestScroll = createScrollToLatest(body, container,
5611
5709
  () => { lastScrollTop = body.scrollTop; },
5612
- () => { resumeAutoScroll(); scheduleAutoScroll(true); syncScrollToBottomButton(); },
5613
- () => { autoFollow.pause(); syncScrollToBottomButton(); });
5710
+ () => { resumeAutoScroll(); scheduleAutoScroll(true); syncScrollToBottomButton(); focusApprovalJumpTarget(); },
5711
+ () => { approvalJumpTargets = []; autoFollow.pause(); syncScrollToBottomButton(); });
5614
5712
 
5615
5713
  // Custom smooth scroll animation with easing
5616
5714
  const smoothScrollToBottom = (element: HTMLElement, duration = 500) => {
@@ -5657,9 +5755,6 @@ export const createAgentExperience = (
5657
5755
  syncScrollToBottomButton();
5658
5756
  };
5659
5757
 
5660
- // Defined here (rather than beside the scroll listeners) because
5661
- // `maybeAnchorToFirstUnread` runs on every render and would otherwise hit
5662
- // these consts in their temporal dead zone on a synchronous first paint.
5663
5758
  const getTranscriptSelection = (): Selection | null => {
5664
5759
  // Selections inside a shadow root are not always reflected by
5665
5760
  // document.getSelection(); prefer the shadow root's view when available
@@ -5793,14 +5888,6 @@ export const createAgentExperience = (
5793
5888
  `[data-message-id="${escapeMessageId(messageId)}"]`
5794
5889
  );
5795
5890
 
5796
- // Progress inside a turn disclosure belongs to its visible header. Pinning
5797
- // the nested prose instead clips that header, and leaves a hidden anchor
5798
- // when the completed turn folds away. Keep ordinary message lookups intact.
5799
- const queryScrollAnchor = (messageId: string): HTMLElement | null => {
5800
- const bubble = queryMessageBubble(messageId);
5801
- return bubble?.closest<HTMLElement>("[data-persona-turn-activity]") ?? bubble;
5802
- };
5803
-
5804
5891
  // Anchor-top mode: scroll `messageId` to rest `anchorTopOffset` px below the
5805
5892
  // viewport top and hold it there while the response streams in beneath it.
5806
5893
  // Deferred one frame so the message bubble has been rendered and laid out.
@@ -5810,7 +5897,7 @@ export const createAgentExperience = (
5810
5897
  }
5811
5898
  anchorRAF = requestAnimationFrame(() => {
5812
5899
  anchorRAF = null;
5813
- const bubble = queryScrollAnchor(messageId);
5900
+ const bubble = queryMessageBubble(messageId);
5814
5901
  if (!bubble) return;
5815
5902
 
5816
5903
  // Bubble top relative to the scroll content. offsetTop is used instead
@@ -5864,7 +5951,7 @@ export const createAgentExperience = (
5864
5951
  const repinAnchoredMessage = () => {
5865
5952
  if (getScrollMode() !== "anchor-top") return;
5866
5953
  if (!anchorState || !anchoredMessageId) return;
5867
- const bubble = queryScrollAnchor(anchoredMessageId);
5954
+ const bubble = queryMessageBubble(anchoredMessageId);
5868
5955
  if (!bubble) return;
5869
5956
  cancelSmoothScroll();
5870
5957
  const previousSpacerHeight = anchorState.spacerHeight;
@@ -5918,6 +6005,94 @@ export const createAgentExperience = (
5918
6005
  syncScrollToBottomButton();
5919
6006
  };
5920
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
+
5921
6096
  // Reacts to a user message the user just sent (seeded so restored history
5922
6097
  // never triggers it). Follow mode re-sticks to the bottom even if the user
5923
6098
  // had scrolled up: sending is an unambiguous "take me to the latest"
@@ -5933,8 +6108,8 @@ export const createAgentExperience = (
5933
6108
  // next user send.
5934
6109
  followFallbackActive = false;
5935
6110
  currentTurnAnchored = true;
5936
- // A send is an explicit "take me along": it re-arms first-unread
5937
- // 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.
5938
6113
  readerEngagedThisTurn = false;
5939
6114
  anchoredMessageId = messageId;
5940
6115
  scheduleAnchorToMessage(messageId);
@@ -5960,75 +6135,6 @@ export const createAgentExperience = (
5960
6135
  scheduleAutoScroll(true);
5961
6136
  };
5962
6137
 
5963
- // Anchor-top positioning: park on the turn's FIRST UNREAD attention-worthy
5964
- // block.
5965
- //
5966
- // The read boundary is the last user send — the reader wrote it, so it (and
5967
- // everything above) is read; every attention-worthy block after it is not.
5968
- // Tool calls and reasoning are chrome and never targeted, so they scroll
5969
- // past underneath rather than parking the reader on progress output.
5970
- //
5971
- // The policy assumes the reader is NOT watching the stream, and picks the
5972
- // best place for them to land when they look back: the start of what they
5973
- // haven't seen. `readerEngagedThisTurn` is the escape hatch — once someone
5974
- // is demonstrably driving, their position wins and this stands down until
5975
- // the next send.
5976
- //
5977
- // Anchoring to the first unread (not the newest) block means this fires at
5978
- // most once per turn in practice, and later blocks stream in below the
5979
- // anchor exactly as they do today.
5980
-
5981
- // Minimum readable slice of the first unread block that must fit under the
5982
- // pinned user message for the anchor to stay put. Below this, the block is
5983
- // "stranded" and the anchor hands off to it.
5984
- const ANCHOR_HANDOFF_MIN_VISIBLE = 96;
5985
-
5986
- // Market pattern (ChatGPT/Claude/Gemini): the sent user message stays
5987
- // pinned at the top as the visible header of the streaming reply. The
5988
- // handoff to the first unread block exists for the stranded case — a long
5989
- // user message or a tall tool-call prelude pushing the reply below the
5990
- // fold — so it only fires when the block's start is not readably within
5991
- // the viewport while the user message holds the anchor.
5992
- const isFirstUnreadStranded = (messageId: string): boolean => {
5993
- if (lastSentUserMessageId === null) return true;
5994
- const userBubble = queryMessageBubble(lastSentUserMessageId);
5995
- const blockBubble = queryScrollAnchor(messageId);
5996
- // Unmeasurable geometry: fall back to handing off (pre-conditional
5997
- // behavior) rather than risking an answer parked below the fold.
5998
- if (!userBubble || !blockBubble) return true;
5999
- const delta =
6000
- offsetTopWithinBody(blockBubble) - offsetTopWithinBody(userBubble);
6001
- return (
6002
- getAnchorTopOffset() + delta >
6003
- body.clientHeight - ANCHOR_HANDOFF_MIN_VISIBLE
6004
- );
6005
- };
6006
-
6007
- const maybeAnchorToFirstUnread = (messages: AgentWidgetMessage[]) => {
6008
- if (getScrollMode() !== "anchor-top") return;
6009
- if (!currentTurnAnchored) return;
6010
- if (readerEngagedThisTurn) return;
6011
- if (hasActiveTranscriptSelection()) return;
6012
-
6013
- let pastReadBoundary = lastSentUserMessageId === null;
6014
- for (const message of messages) {
6015
- if (!pastReadBoundary) {
6016
- if (message.id === lastSentUserMessageId) pastReadBoundary = true;
6017
- continue;
6018
- }
6019
- if (!isAttentionWorthyMessage(message)) continue;
6020
- // Already parked on it: hold still while it streams.
6021
- if (message.id === anchoredMessageId) return;
6022
- // Block starts within view under the pinned user message: keep the
6023
- // user message anchored. Re-evaluated every render, so a tool prelude
6024
- // that later pushes the block below the fold still hands off.
6025
- if (!isFirstUnreadStranded(message.id)) return;
6026
- anchoredMessageId = message.id;
6027
- scheduleAnchorToMessage(message.id);
6028
- return;
6029
- }
6030
- };
6031
-
6032
6138
  const trackMessages = (messages: AgentWidgetMessage[]) => {
6033
6139
  const nextState = new Map<
6034
6140
  string,
@@ -6097,21 +6203,26 @@ export const createAgentExperience = (
6097
6203
  wrapper: HTMLElement,
6098
6204
  role: AgentWidgetMessage["role"]
6099
6205
  ): void => {
6206
+ // Operator rows sit on the assistant's side and share its max width, but
6207
+ // always shrink-wrap: a full-column operator bubble reads as an input.
6100
6208
  const sizingRole = role === "user" ? "user" : "assistant";
6101
6209
  const roleLayout = config.layout?.messages?.[sizingRole];
6102
- const width = roleLayout?.width ?? "content";
6210
+ const configuredWidth = roleLayout?.width ?? "content";
6211
+ const width = role === "operator" ? "content" : configuredWidth;
6103
6212
  // Only an explicit config value is stamped inline; the 85%/100% defaults
6104
6213
  // live in widget.css so `components.message.<role>.maxWidth` can win.
6214
+ const fullMaxWidth = role === "operator"
6215
+ ? "var(--persona-message-operator-max-width, var(--persona-message-assistant-max-width, 100%))"
6216
+ : `var(--persona-message-${sizingRole}-max-width, 100%)`;
6105
6217
  const maxWidth =
6106
- roleLayout?.maxWidth ?? (width === "full"
6107
- ? `var(--persona-message-${sizingRole}-max-width, 100%)`
6108
- : undefined);
6218
+ roleLayout?.maxWidth ?? (configuredWidth === "full" ? fullMaxWidth : undefined);
6109
6219
 
6110
6220
  wrapper.classList.add("persona-message-row");
6111
6221
  wrapper.classList.remove(
6112
6222
  "persona-message-row-user",
6113
6223
  "persona-message-row-assistant",
6114
6224
  "persona-message-row-system",
6225
+ "persona-message-row-operator",
6115
6226
  "persona-message-width-content",
6116
6227
  "persona-message-width-full"
6117
6228
  );
@@ -6248,6 +6359,10 @@ export const createAgentExperience = (
6248
6359
  ? findRetryableAssistantMessageId(messages)
6249
6360
  : null;
6250
6361
  const editEnabled = actionsCfg?.showEdit === true && actionsCfg.enabled !== false;
6362
+ let lastOperatorIndex = -1;
6363
+ messages.forEach((message, index) => {
6364
+ if (message.role === "operator") lastOperatorIndex = index;
6365
+ });
6251
6366
  const quoteEnabled = actionsCfg?.showQuote === true && actionsCfg.enabled !== false;
6252
6367
  /** The live editor, re-grafted into its morphed wrapper after the pass. */
6253
6368
  let editHydrate: { messageId: string; element: HTMLElement } | null = null;
@@ -6289,7 +6404,7 @@ export const createAgentExperience = (
6289
6404
  );
6290
6405
  };
6291
6406
 
6292
- messages.forEach((message) => {
6407
+ messages.forEach((message, messageIndex) => {
6293
6408
  if (isDroppedCompletedRow(message)) {
6294
6409
  return;
6295
6410
  }
@@ -6356,7 +6471,7 @@ export const createAgentExperience = (
6356
6471
  isLastAssistant: message.id === lastAssistantId,
6357
6472
  canRegenerate: retryableAssistantId === message.id &&
6358
6473
  (actionsCfg?.showRegenerate === true || message.dispatchError === true),
6359
- canEdit: editEnabled && isEditableUserMessage(message),
6474
+ canEdit: editEnabled && messageIndex > lastOperatorIndex && isEditableUserMessage(message),
6360
6475
  canQuote:
6361
6476
  quoteEnabled && !message.variant && (message.content?.trim().length ?? 0) > 0,
6362
6477
  };
@@ -6381,8 +6496,10 @@ export const createAgentExperience = (
6381
6496
  ? { toolCallId: linkedTool.id, status: linkedTool.status === "running"
6382
6497
  ? "running" : linkedTool.success === false || linkedTool.error ? "failed" : "completed" }
6383
6498
  : undefined;
6499
+ const userCollapse = resolveUserCollapse(message);
6384
6500
  const fingerprint =
6385
6501
  computeMessageFingerprint(message, configVersion) + askMeta + `:${eligibilityKey}` +
6502
+ (userCollapse ? `:uc-${userCollapse.state}` : "") +
6386
6503
  (approvalWithPlugin ? `:${message.approval?.toolCallId ?? ""}:${JSON.stringify(execution)}` : "");
6387
6504
  const cachedWrapper = (askWithPlugin || approvalWithPlugin || hasDirectiveBubble ||
6388
6505
  (message.dispatchError && config.errorDisplay === "block" && !approvalUi))
@@ -6431,7 +6548,8 @@ export const createAgentExperience = (
6431
6548
  // Keep the detail readable even while the secondary UI chunk loads.
6432
6549
  const bubble = createStandardBubble(message, transform, config.layout?.messages,
6433
6550
  config.messageActions, messageActionCallbacks, {
6434
- loadingIndicatorRenderer: inlineLoadingRenderer, widgetConfig: config, actionEligibility
6551
+ loadingIndicatorRenderer: inlineLoadingRenderer, widgetConfig: config, actionEligibility,
6552
+ userCollapse
6435
6553
  });
6436
6554
  bubble.dataset.personaDefaultMessage = "true";
6437
6555
  return bubble;
@@ -6957,10 +7075,23 @@ export const createAgentExperience = (
6957
7075
  // "the assistant is already visibly responding": otherwise the transcript
6958
7076
  // goes blank for the whole tool phase. When those rows are shown, their own
6959
7077
  // bubble carries the loading animation and the standalone dots stay hidden.
7078
+ //
7079
+ // A pending `ask_user_question` call is visible only once its question has
7080
+ // rendered: a plugin bubble mounted for it this pass, or the built-in sheet
7081
+ // mounted for its tool call. Until then (arguments still streaming, a plugin
7082
+ // returning null for a partial payload, the sheet waiting on the pause) it
7083
+ // renders nothing, so it must not hide the dots either.
7084
+ const isUnrenderedAskRow = (msg: AgentWidgetMessage): boolean =>
7085
+ !(msg.toolCall?.id && liveAskToolIds.has(msg.toolCall.id)) &&
7086
+ !askPluginHydrate.some((entry) => entry.messageId === msg.id);
6960
7087
  const isHiddenActivityRow = (msg: AgentWidgetMessage): boolean =>
6961
- (msg.variant === "tool" && !showToolCalls) ||
6962
- (msg.variant === "reasoning" && !showReasoning) ||
6963
- isDroppedCompletedRow(msg);
7088
+ isAskUserQuestionMessage(msg) &&
7089
+ msg.agentMetadata?.askUserQuestionAnswered !== true &&
7090
+ config.features?.askUserQuestion?.enabled !== false
7091
+ ? isUnrenderedAskRow(msg)
7092
+ : (msg.variant === "tool" && !showToolCalls) ||
7093
+ (msg.variant === "reasoning" && !showReasoning) ||
7094
+ isDroppedCompletedRow(msg);
6964
7095
  const hasStreamingAssistantMessage = messages.some(
6965
7096
  (msg) => msg.role === "assistant" && msg.streaming && !isHiddenActivityRow(msg)
6966
7097
  );
@@ -7203,7 +7334,12 @@ export const createAgentExperience = (
7203
7334
  wrapper.replaceChildren(bubble);
7204
7335
  wrapper.setAttribute("data-bubble-fp", fingerprint);
7205
7336
  lastApprovalBubbleFingerprint.set(messageId, fingerprint);
7206
- 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
+ }
7207
7343
  }
7208
7344
  }
7209
7345
 
@@ -7231,6 +7367,8 @@ export const createAgentExperience = (
7231
7367
  ) => {
7232
7368
  if (messages.length > 0) warmClientStream();
7233
7369
  renderMessagesWithPluginsImpl(container, messages, transform);
7370
+ pruneUserMessageCollapse(messages);
7371
+ syncUserMessageCollapse();
7234
7372
  refreshReadAloudButtons();
7235
7373
  if (revealedToolCalls.size > 0) {
7236
7374
  for (const [id, timer] of revealedToolCalls) {
@@ -8681,7 +8819,6 @@ export const createAgentExperience = (
8681
8819
  handleAssistantTurnStarted();
8682
8820
  }
8683
8821
  if (lastAssistantMessage) lastHandledAssistantId = lastAssistantMessage.id;
8684
- maybeAnchorToFirstUnread(messages);
8685
8822
 
8686
8823
  const prevLastUserMessageId = voiceState.lastUserMessageId;
8687
8824
  if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
@@ -10053,7 +10190,8 @@ export const createAgentExperience = (
10053
10190
  function beginMessageEdit(messageId: string): void {
10054
10191
  if (config.messageActions?.showEdit !== true) return;
10055
10192
  const message = session.getMessages().find((m) => m.id === messageId);
10056
- if (!message || !isEditableUserMessage(message)) return;
10193
+ if (!message || !isEditableUserMessage(message) ||
10194
+ !canResubmitUserMessage(session.getMessages(), messageId)) return;
10057
10195
  if (editingMessageId === messageId) return;
10058
10196
  cancelMessageEdit();
10059
10197
  editingMessageId = messageId;
@@ -11145,6 +11283,8 @@ export const createAgentExperience = (
11145
11283
  // (viewport resize) so the pin survives all of them.
11146
11284
  if (typeof ResizeObserver !== "undefined") {
11147
11285
  const contentResizeObserver = new ResizeObserver(() => {
11286
+ // Width changes rewrap text and late images grow attachments.
11287
+ syncUserMessageCollapse();
11148
11288
  handleContentResize();
11149
11289
  syncTranscriptEdgeFades();
11150
11290
  // A resize can start or stop a table overflowing without a render or a
@@ -11190,7 +11330,7 @@ export const createAgentExperience = (
11190
11330
  ]);
11191
11331
  const handleTranscriptKeydown = (event: KeyboardEvent) => {
11192
11332
  if (!NAV_KEYS.has(event.key)) return;
11193
- // Reader intent for first-unread anchoring is tracked unconditionally;
11333
+ // Reader intent for anchor re-pins is tracked unconditionally;
11194
11334
  // only the follow-mode *pause* below stays behind the opt-in flag.
11195
11335
  markReaderEngaged();
11196
11336
  if (!isPauseOnInteractionEnabled()) return;
@@ -11244,10 +11384,13 @@ export const createAgentExperience = (
11244
11384
  });
11245
11385
  destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
11246
11386
  scrollToBottomButton.addEventListener("click", () => {
11387
+ const targets = pendingApprovalBubbles().filter(isBelowFold)
11388
+ .map((bubble) => bubble.getAttribute("data-message-id")!);
11247
11389
  // Jumping to the latest abandons the current anchor: drop the spacer
11248
11390
  // first so "bottom" means the real end of content, not spacer padding
11249
11391
  // that would keep shrinking underneath the reader.
11250
11392
  resetAnchorState();
11393
+ approvalJumpTargets = targets;
11251
11394
  cancelAutoScroll();
11252
11395
  autoFollow.pause();
11253
11396
  latestScroll.start(config);
@@ -11785,6 +11928,10 @@ export const createAgentExperience = (
11785
11928
  const previousShowToolCalls = config.features?.showToolCalls;
11786
11929
  const previousToolCallDisplay = config.features?.toolCallDisplay;
11787
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();
11788
11935
  const previousStreamAnimationType = config.features?.streamAnimation?.type;
11789
11936
  // History provider identity: enablement, eligibility, and advertised
11790
11937
  // scopes are all resolved at construction, so a change rebuilds it.
@@ -12042,7 +12189,8 @@ export const createAgentExperience = (
12042
12189
  const featuresChanged = (config.features?.showReasoning ?? true) !== (previousShowReasoning ?? true)
12043
12190
  || (config.features?.showToolCalls ?? true) !== (previousShowToolCalls ?? true)
12044
12191
  || JSON.stringify(config.features?.toolCallDisplay) !== JSON.stringify(previousToolCallDisplay)
12045
- || JSON.stringify(config.features?.reasoningDisplay) !== JSON.stringify(previousReasoningDisplay);
12192
+ || JSON.stringify(config.features?.reasoningDisplay) !== JSON.stringify(previousReasoningDisplay)
12193
+ || userCollapseConfigKey() !== previousUserCollapseConfig;
12046
12194
  const messagesConfigChanged = (config.future?.v5Defaults === true) !== previousDefaultsVersion
12047
12195
  || "toolCall" in nextConfig || messageActionsChanged || layoutMessagesChanged
12048
12196
  || "loadingIndicator" in nextConfig || iterationDisplayChanged || featuresChanged