@runtypelabs/persona 4.4.2 → 4.5.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.
package/src/ui.ts CHANGED
@@ -842,8 +842,27 @@ export const createAgentExperience = (
842
842
  const getScrollToBottomLabel = () => scrollToBottomFeature.label ?? "";
843
843
  const getScrollToBottomIconName = () => scrollToBottomFeature.iconName ?? "arrow-down";
844
844
  const isScrollToBottomEnabled = () => scrollToBottomFeature.enabled !== false;
845
- const getScrollMode = () => scrollBehaviorFeature.mode ?? "follow";
845
+ // Default is "anchor-top" (see DEFAULT_WIDGET_CONFIG). This `??` only applies
846
+ // when a partial config sets `scrollBehavior.mode` to undefined explicitly;
847
+ // it must agree with the declared default.
848
+ const getScrollMode = () => scrollBehaviorFeature.mode ?? "anchor-top";
849
+ // "Effectively following the bottom" for streaming auto-scroll: true in
850
+ // follow mode, and in anchor-top when the current turn has no anchor (the
851
+ // no-anchor fallback). Drives `scheduleAutoScroll`, `handleContentResize`,
852
+ // and `isAwayFromLatest` so a no-anchor anchor-top turn behaves like follow.
853
+ const isFollowEffective = () =>
854
+ getScrollMode() === "follow" ||
855
+ (getScrollMode() === "anchor-top" && followFallbackActive);
846
856
  const getAnchorTopOffset = () => scrollBehaviorFeature.anchorTopOffset ?? 16;
857
+ const getScrollRestorePosition = () =>
858
+ scrollBehaviorFeature.restorePosition ?? "bottom";
859
+ const isPauseOnInteractionEnabled = () =>
860
+ scrollBehaviorFeature.pauseOnInteraction === true;
861
+ // Defaults on alongside the anchor-top default so the pinned-turn UX keeps the
862
+ // unread count + "streaming below" hint; opt out with `false`.
863
+ const isActivityWhilePinnedEnabled = () =>
864
+ scrollBehaviorFeature.showActivityWhilePinned !== false;
865
+ const isAnnounceEnabled = () => scrollBehaviorFeature.announce === true;
847
866
  const scrollToBottomButton = createElement(
848
867
  "button",
849
868
  "persona-scroll-to-bottom-indicator persona-absolute persona-bottom-3 persona-left-1/2 persona-z-10 persona-flex persona-items-center persona-gap-1 persona-text-xs persona-transform persona--translate-x-1/2 persona-cursor-pointer"
@@ -875,6 +894,47 @@ export const createAgentExperience = (
875
894
  anchorSpacer.style.height = "0px";
876
895
  body.appendChild(anchorSpacer);
877
896
 
897
+ // Visually-hidden polite live region for screen-reader announcements
898
+ // (Principle 15: announce important events at a comfortable pace, never
899
+ // token-by-token). Created unconditionally but only written to when
900
+ // `features.scrollBehavior.announce` is opted in.
901
+ const liveRegion = createElement("div", "persona-sr-only");
902
+ liveRegion.setAttribute("aria-live", "polite");
903
+ liveRegion.setAttribute("aria-atomic", "true");
904
+ liveRegion.setAttribute("role", "status");
905
+ liveRegion.setAttribute("data-persona-live-region", "");
906
+ Object.assign(liveRegion.style, {
907
+ position: "absolute",
908
+ width: "1px",
909
+ height: "1px",
910
+ margin: "-1px",
911
+ padding: "0",
912
+ overflow: "hidden",
913
+ clip: "rect(0 0 0 0)",
914
+ clipPath: "inset(50%)",
915
+ whiteSpace: "nowrap",
916
+ border: "0",
917
+ } satisfies Partial<CSSStyleDeclaration>);
918
+ container.appendChild(liveRegion);
919
+ // Debounce announcements so a fast event sequence (e.g. several messages
920
+ // landing while away) collapses into one calm spoken update.
921
+ let announceTimer: ReturnType<typeof setTimeout> | null = null;
922
+ let pendingAnnouncement: string | null = null;
923
+ const announce = (message: string) => {
924
+ if (!isAnnounceEnabled() || !message) return;
925
+ pendingAnnouncement = message;
926
+ if (announceTimer !== null) return;
927
+ announceTimer = setTimeout(() => {
928
+ announceTimer = null;
929
+ // Re-check: `update()` may have disabled `announce` within the debounce
930
+ // window, and a stale message must not slip through to the live region.
931
+ if (pendingAnnouncement && isAnnounceEnabled()) {
932
+ liveRegion.textContent = pendingAnnouncement;
933
+ }
934
+ pendingAnnouncement = null;
935
+ }, 400);
936
+ };
937
+
878
938
  const updateScrollToBottomButtonOffset = () => {
879
939
  const footerHidden = footer.style.display === "none";
880
940
  const footerHeight = footerHidden ? 0 : footer.offsetHeight;
@@ -2690,6 +2750,10 @@ export const createAgentExperience = (
2690
2750
  destroyCallbacks.push(() => {
2691
2751
  document.removeEventListener("keydown", handleAskUserDigitKey);
2692
2752
  });
2753
+ // Clear any pending live-region announcement timer on teardown.
2754
+ destroyCallbacks.push(() => {
2755
+ if (announceTimer !== null) clearTimeout(announceTimer);
2756
+ });
2693
2757
 
2694
2758
  let teardownHostStacking: (() => void) | null = null;
2695
2759
  let releaseScrollLock: (() => void) | null = null;
@@ -2865,6 +2929,22 @@ export const createAgentExperience = (
2865
2929
  let scrollSendSeeded = false;
2866
2930
  let suppressScrollSend = false;
2867
2931
  let lastSentUserMessageId: string | null = null;
2932
+ // anchor-top no-anchor fallback: anchor-top pins on a USER send. An assistant
2933
+ // message that streams when NO user send has anchored the conversation yet
2934
+ // (first-load / proactive-first streaming) has nothing to anchor to, so it
2935
+ // falls back to follow-to-bottom — otherwise its content streams in
2936
+ // off-screen. `true` by default (nothing anchored yet); a user send clears it
2937
+ // and the anchor takes over. Inert in follow/none mode (see
2938
+ // `isFollowEffective`).
2939
+ let followFallbackActive = true;
2940
+ // True once a user send has anchored the current conversation (until the chat
2941
+ // is cleared). While anchored, follow-on assistant content — the response, a
2942
+ // multi-part reply, an injected embed (tweet/image), a tool result — stays
2943
+ // pinned and never re-arms the fallback, so a late-loading embed can't yank
2944
+ // the viewport down to the bottom.
2945
+ let currentTurnAnchored = false;
2946
+ // Dedupes assistant-turn detection across token-by-token re-renders.
2947
+ let lastHandledAssistantId: string | null = null;
2868
2948
 
2869
2949
  // Scroll events caused by layout, scroll anchoring, and smooth-scroll
2870
2950
  // easing can easily move by a couple pixels. Keep manual wheel intent
@@ -2992,20 +3072,36 @@ export const createAgentExperience = (
2992
3072
  cancelSmoothScroll();
2993
3073
  };
2994
3074
 
3075
+ // True when a response is streaming in below the reader's current position,
3076
+ // i.e. content is arriving out of view. Drives the "still streaming" hint on
3077
+ // the scroll-to-bottom affordance (Principle 8: show what's happening out of
3078
+ // view). In anchor-top mode this is gated behind `showActivityWhilePinned`
3079
+ // so the historical "silent while pinned" behavior is preserved by default.
3080
+ const isStreamingOutOfView = () =>
3081
+ isStreaming &&
3082
+ isAwayFromLatest() &&
3083
+ (getScrollMode() !== "anchor-top" || isActivityWhilePinnedEnabled());
3084
+
2995
3085
  const updateScrollToBottomCountBadge = () => {
3086
+ const base = getScrollToBottomLabel() || "Jump to latest";
3087
+ const streamingBelow = isStreamingOutOfView();
3088
+ scrollToBottomButton.toggleAttribute(
3089
+ "data-persona-scroll-to-bottom-streaming",
3090
+ streamingBelow
3091
+ );
2996
3092
  if (newMessagesSincePause > 0) {
2997
3093
  scrollToBottomCount.textContent = String(newMessagesSincePause);
2998
3094
  scrollToBottomCount.style.display = "";
2999
3095
  scrollToBottomButton.setAttribute(
3000
3096
  "aria-label",
3001
- `${getScrollToBottomLabel() || "Jump to latest"} (${newMessagesSincePause} new)`
3097
+ `${base} (${newMessagesSincePause} new)`
3002
3098
  );
3003
3099
  } else {
3004
3100
  scrollToBottomCount.textContent = "";
3005
3101
  scrollToBottomCount.style.display = "none";
3006
3102
  scrollToBottomButton.setAttribute(
3007
3103
  "aria-label",
3008
- getScrollToBottomLabel() || "Jump to latest"
3104
+ streamingBelow ? `${base} (response streaming below)` : base
3009
3105
  );
3010
3106
  }
3011
3107
  };
@@ -3017,11 +3113,11 @@ export const createAgentExperience = (
3017
3113
  };
3018
3114
 
3019
3115
  // Whether the user is currently away from the latest content: drives both
3020
- // the scroll-to-bottom affordance and the new-messages badge. In follow
3021
- // mode that's "auto-follow paused"; in anchor-top/none modes (where there
3022
- // is no follow state) it's simply "not near the bottom".
3116
+ // the scroll-to-bottom affordance and the new-messages badge. When following
3117
+ // the bottom (follow mode, or a no-anchor anchor-top fallback turn) that's
3118
+ // "auto-follow paused"; otherwise it's simply "not near the bottom".
3023
3119
  const isAwayFromLatest = () =>
3024
- getScrollMode() === "follow"
3120
+ isFollowEffective()
3025
3121
  ? !autoFollow.isFollowing()
3026
3122
  : !isElementNearBottom(body, BOTTOM_THRESHOLD);
3027
3123
 
@@ -3041,6 +3137,9 @@ export const createAgentExperience = (
3041
3137
  const show = hasOverflow && isAwayFromLatest();
3042
3138
  if (!show) {
3043
3139
  resetNewMessagesCount();
3140
+ } else {
3141
+ // Refresh the streaming-below hint while the affordance is visible.
3142
+ updateScrollToBottomCountBadge();
3044
3143
  }
3045
3144
  scrollToBottomButton.style.display = show ? "" : "none";
3046
3145
  };
@@ -3058,9 +3157,10 @@ export const createAgentExperience = (
3058
3157
  };
3059
3158
 
3060
3159
  const scheduleAutoScroll = (force = false) => {
3061
- // Auto-follow only applies in "follow" mode; anchor-top and none never
3062
- // chase the bottom during streaming.
3063
- if (getScrollMode() !== "follow") return;
3160
+ // Auto-follow applies in "follow" mode, and in anchor-top only for a
3161
+ // no-anchor fallback turn (see `isFollowEffective`). Anchored anchor-top
3162
+ // turns and "none" never chase the bottom during streaming.
3163
+ if (!isFollowEffective()) return;
3064
3164
 
3065
3165
  if (!autoFollow.isFollowing()) return;
3066
3166
 
@@ -3108,6 +3208,17 @@ export const createAgentExperience = (
3108
3208
  // Cancel any ongoing smooth scroll animation
3109
3209
  cancelSmoothScroll();
3110
3210
 
3211
+ // Nothing to scroll: land exactly on target and skip the rAF loop. Avoids a
3212
+ // no-op animation when already in place (e.g. anchoring with zero overflow),
3213
+ // which also keeps environments with a synchronous rAF from spinning.
3214
+ if (Math.abs(distance) < 1) {
3215
+ isAutoScrolling = true;
3216
+ element.scrollTop = target;
3217
+ lastScrollTop = element.scrollTop;
3218
+ isAutoScrolling = false;
3219
+ return;
3220
+ }
3221
+
3111
3222
  const startTime = performance.now();
3112
3223
  isAutoScrolling = true;
3113
3224
 
@@ -3192,6 +3303,61 @@ export const createAgentExperience = (
3192
3303
  syncScrollToBottomButton();
3193
3304
  };
3194
3305
 
3306
+ // Walk offsetParents up to `body` (the positioned scroll ancestor) to get a
3307
+ // node's top relative to the scroll content. offsetTop avoids skew from any
3308
+ // in-flight entrance transforms. Mirrors the anchor-top geometry.
3309
+ const offsetTopWithinBody = (el: HTMLElement): number => {
3310
+ let top = 0;
3311
+ let node: HTMLElement | null = el;
3312
+ while (node && node !== body) {
3313
+ top += node.offsetTop;
3314
+ node = node.offsetParent as HTMLElement | null;
3315
+ }
3316
+ return top;
3317
+ };
3318
+
3319
+ // Principle 11: reopen where the reader left off. When `restorePosition` is
3320
+ // "last-user-turn" and there is pre-existing history, land with the last user
3321
+ // message pinned near the top of the viewport instead of jumping to the
3322
+ // absolute bottom. Returns true when it handled positioning. Opt-in; the
3323
+ // default ("bottom") returns false so callers fall back to jump-to-bottom.
3324
+ const restoreScrollPosition = (): boolean => {
3325
+ if (getScrollRestorePosition() !== "last-user-turn") return false;
3326
+ const messages = session?.getMessages() ?? [];
3327
+ // A *restore* only makes sense when reopening existing history; a fresh
3328
+ // (empty or single-turn) conversation should still start at the latest.
3329
+ if (messages.length < 2) return false;
3330
+ const lastUser = [...messages].reverse().find((m) => m.role === "user");
3331
+ if (!lastUser) return false;
3332
+ const escapedId =
3333
+ typeof CSS !== "undefined" && typeof CSS.escape === "function"
3334
+ ? CSS.escape(lastUser.id)
3335
+ : lastUser.id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
3336
+ const bubble = body.querySelector<HTMLElement>(
3337
+ `[data-message-id="${escapedId}"]`
3338
+ );
3339
+ if (!bubble) return false;
3340
+ const target = Math.min(
3341
+ Math.max(0, offsetTopWithinBody(bubble) - getAnchorTopOffset()),
3342
+ getScrollBottomOffset(body)
3343
+ );
3344
+ isAutoScrolling = true;
3345
+ body.scrollTop = target;
3346
+ lastScrollTop = body.scrollTop;
3347
+ isAutoScrolling = false;
3348
+ // In follow mode, deliberately landing above the bottom means we are not
3349
+ // following; pause so the first streamed token doesn't yank the reader
3350
+ // down. (In anchor-top/none there is no follow state to manage.)
3351
+ if (
3352
+ getScrollMode() === "follow" &&
3353
+ !isElementNearBottom(body, BOTTOM_THRESHOLD)
3354
+ ) {
3355
+ autoFollow.pause();
3356
+ }
3357
+ syncScrollToBottomButton();
3358
+ return true;
3359
+ };
3360
+
3195
3361
  const setAnchorSpacerHeight = (height: number) => {
3196
3362
  anchorSpacer.style.height = `${Math.max(0, Math.round(height))}px`;
3197
3363
  if (anchorState) {
@@ -3225,22 +3391,16 @@ export const createAgentExperience = (
3225
3391
  const escapedId =
3226
3392
  typeof CSS !== "undefined" && typeof CSS.escape === "function"
3227
3393
  ? CSS.escape(messageId)
3228
- : messageId.replace(/"/g, '\\"');
3394
+ : messageId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
3229
3395
  const bubble = body.querySelector<HTMLElement>(
3230
3396
  `[data-message-id="${escapedId}"]`
3231
3397
  );
3232
3398
  if (!bubble) return;
3233
3399
 
3234
- // Bubble top relative to the scroll content. `body` is the positioned
3235
- // ancestor, so walking offsetParents terminates there. offsetTop is
3236
- // used instead of getBoundingClientRect so in-flight entrance
3237
- // animations (transforms) can't skew the target.
3238
- let anchorOffsetTop = 0;
3239
- let node: HTMLElement | null = bubble;
3240
- while (node && node !== body) {
3241
- anchorOffsetTop += node.offsetTop;
3242
- node = node.offsetParent as HTMLElement | null;
3243
- }
3400
+ // Bubble top relative to the scroll content. offsetTop is used instead
3401
+ // of getBoundingClientRect so in-flight entrance animations (transforms)
3402
+ // can't skew the target.
3403
+ const anchorOffsetTop = offsetTopWithinBody(bubble);
3244
3404
 
3245
3405
  const previousSpacerHeight = anchorState?.spacerHeight ?? 0;
3246
3406
  const contentHeight = body.scrollHeight - previousSpacerHeight;
@@ -3268,7 +3428,7 @@ export const createAgentExperience = (
3268
3428
  // back as the streamed response grows (shrink-only, so total scroll height
3269
3429
  // stays constant and nothing jumps).
3270
3430
  const handleContentResize = () => {
3271
- if (getScrollMode() === "follow") {
3431
+ if (isFollowEffective()) {
3272
3432
  if (!autoFollow.isFollowing()) return;
3273
3433
  if (isElementNearBottom(body, 1)) return;
3274
3434
  scheduleAutoScroll(!isStreaming);
@@ -3298,10 +3458,34 @@ export const createAgentExperience = (
3298
3458
  resumeAutoScroll();
3299
3459
  scheduleAutoScroll(true);
3300
3460
  } else if (mode === "anchor-top") {
3461
+ // A real anchor now drives the conversation: disarm the no-anchor
3462
+ // fallback. Every follow-on assistant message stays anchored until the
3463
+ // next user send.
3464
+ followFallbackActive = false;
3465
+ currentTurnAnchored = true;
3301
3466
  scheduleAnchorToUserMessage(messageId);
3302
3467
  }
3303
3468
  };
3304
3469
 
3470
+ // Reacts to a new assistant message that arrived without a fresh user send.
3471
+ // Only meaningful in anchor-top. While the conversation is anchored (a user
3472
+ // has sent at least once), follow-on assistant content — the response, a
3473
+ // multi-part reply, an injected embed, a tool result — keeps the anchor so a
3474
+ // late-loading embed never yanks the viewport. Only when nothing has anchored
3475
+ // yet (first-load / proactive-first streaming) does it fall back to
3476
+ // follow-to-bottom so the content isn't stranded off-screen.
3477
+ const handleAssistantTurnStarted = () => {
3478
+ if (getScrollMode() !== "anchor-top") return;
3479
+ if (currentTurnAnchored) {
3480
+ followFallbackActive = false;
3481
+ return;
3482
+ }
3483
+ followFallbackActive = true;
3484
+ resetAnchorState();
3485
+ resumeAutoScroll();
3486
+ scheduleAutoScroll(true);
3487
+ };
3488
+
3305
3489
  const trackMessages = (messages: AgentWidgetMessage[]) => {
3306
3490
  const nextState = new Map<
3307
3491
  string,
@@ -3319,16 +3503,23 @@ export const createAgentExperience = (
3319
3503
  eventBus.emit("assistant:message", message);
3320
3504
  // Count messages the user hasn't seen for the scroll-to-bottom badge.
3321
3505
  // Skipped in anchor-top (the user is already reading the latest turn
3322
- // from its top, so a "new" count would mislead) and during history
3323
- // hydration (restored messages aren't "missed").
3506
+ // from its top, so a "new" count would normally mislead) and during
3507
+ // history hydration (restored messages aren't "missed"). When
3508
+ // `showActivityWhilePinned` is opted in, anchor-top *does* count so the
3509
+ // reader is told content is arriving offscreen below (Principle 8).
3324
3510
  if (
3325
3511
  !suppressScrollSend &&
3326
- getScrollMode() !== "anchor-top" &&
3512
+ (getScrollMode() !== "anchor-top" || isActivityWhilePinnedEnabled()) &&
3327
3513
  isAwayFromLatest()
3328
3514
  ) {
3329
3515
  newMessagesSincePause += 1;
3330
3516
  updateScrollToBottomCountBadge();
3331
3517
  syncScrollToBottomButton();
3518
+ announce(
3519
+ newMessagesSincePause === 1
3520
+ ? "1 new message below."
3521
+ : `${newMessagesSincePause} new messages below.`
3522
+ );
3332
3523
  }
3333
3524
  }
3334
3525
 
@@ -4841,12 +5032,16 @@ export const createAgentExperience = (
4841
5032
 
4842
5033
  if (open) {
4843
5034
  recalcPanelHeight();
4844
- if (getScrollMode() === "follow") {
4845
- scheduleAutoScroll(true);
4846
- } else {
4847
- // Non-follow modes still start at the latest content when the panel
4848
- // opens; they just never chase it during streaming.
4849
- jumpToBottomInstant();
5035
+ // Reopen-where-left-off takes precedence when opted in (Principle 11);
5036
+ // otherwise fall back to the historical per-mode positioning.
5037
+ if (!restoreScrollPosition()) {
5038
+ if (getScrollMode() === "follow") {
5039
+ scheduleAutoScroll(true);
5040
+ } else {
5041
+ // Non-follow modes still start at the latest content when the panel
5042
+ // opens; they just never chase it during streaming.
5043
+ jumpToBottomInstant();
5044
+ }
4850
5045
  }
4851
5046
  }
4852
5047
 
@@ -4993,19 +5188,38 @@ export const createAgentExperience = (
4993
5188
  const lastUserMessage = [...messages]
4994
5189
  .reverse()
4995
5190
  .find((msg) => msg.role === "user");
5191
+ const lastAssistantMessage = [...messages]
5192
+ .reverse()
5193
+ .find((msg) => msg.role === "assistant");
4996
5194
 
4997
5195
  // Scroll-on-send / anchor-top. Seeded so restored history (constructor
4998
5196
  // initialMessages and async storage hydration) never reads as a fresh
4999
5197
  // send; clearing the chat resets any anchor spacer.
5000
5198
  if (messages.length === 0) {
5001
5199
  resetAnchorState();
5200
+ // Cleared: nothing anchored, so re-arm the no-anchor follow fallback.
5201
+ followFallbackActive = true;
5202
+ currentTurnAnchored = false;
5002
5203
  }
5003
5204
  if (!scrollSendSeeded || suppressScrollSend) {
5004
5205
  scrollSendSeeded = true;
5005
5206
  lastSentUserMessageId = lastUserMessage?.id ?? null;
5207
+ // Seed assistant-turn tracking too, so restored history doesn't read
5208
+ // as a fresh assistant turn and trigger the no-anchor fallback.
5209
+ lastHandledAssistantId = lastAssistantMessage?.id ?? null;
5006
5210
  } else if (lastUserMessage && lastUserMessage.id !== lastSentUserMessageId) {
5007
5211
  lastSentUserMessageId = lastUserMessage.id;
5008
5212
  handleUserMessageSent(lastUserMessage.id);
5213
+ } else if (
5214
+ lastAssistantMessage &&
5215
+ lastAssistantMessage.id !== lastHandledAssistantId
5216
+ ) {
5217
+ // A new assistant turn with no fresh user send: the anchor-top
5218
+ // no-anchor fallback (proactive/injected/resubmit/first-load streaming).
5219
+ handleAssistantTurnStarted();
5220
+ }
5221
+ if (lastAssistantMessage) {
5222
+ lastHandledAssistantId = lastAssistantMessage.id;
5009
5223
  }
5010
5224
 
5011
5225
  // Emit user:message event when a new user message is detected
@@ -5043,6 +5257,10 @@ export const createAgentExperience = (
5043
5257
  if (!streaming) {
5044
5258
  scheduleAutoScroll(true);
5045
5259
  }
5260
+ // Keep the "streaming below" hint and its announcement in sync with the
5261
+ // streaming lifecycle (Principles 8 + 15).
5262
+ syncScrollToBottomButton();
5263
+ announce(streaming ? "Responding…" : "Response complete.");
5046
5264
  // Composer-bar peek: streaming state is one of the two visibility
5047
5265
  // triggers (the other is composer hover), so re-evaluate now.
5048
5266
  syncComposerBarPeek();
@@ -5940,10 +6158,14 @@ export const createAgentExperience = (
5940
6158
  renderSuggestions();
5941
6159
  updateCopy();
5942
6160
  setComposerDisabled(session.isStreaming());
5943
- if (getScrollMode() === "follow") {
5944
- scheduleAutoScroll(true);
5945
- } else {
5946
- jumpToBottomInstant();
6161
+ // Reopen-where-left-off takes precedence when opted in (Principle 11);
6162
+ // otherwise fall back to the historical per-mode positioning.
6163
+ if (!restoreScrollPosition()) {
6164
+ if (getScrollMode() === "follow") {
6165
+ scheduleAutoScroll(true);
6166
+ } else {
6167
+ jumpToBottomInstant();
6168
+ }
5947
6169
  }
5948
6170
  maybeRestoreVoiceFromMetadata();
5949
6171
 
@@ -6099,9 +6321,9 @@ export const createAgentExperience = (
6099
6321
  const bottomOffsetShrank = currentBottomOffset < lastBottomOffset;
6100
6322
  lastBottomOffset = currentBottomOffset;
6101
6323
 
6102
- if (getScrollMode() !== "follow") {
6103
- // No follow state to manage: just keep the scroll-to-bottom
6104
- // affordance in sync with the user's position.
6324
+ if (!isFollowEffective()) {
6325
+ // No follow state to manage (anchored anchor-top / none): just keep the
6326
+ // scroll-to-bottom affordance in sync with the user's position.
6105
6327
  lastScrollTop = scrollTop;
6106
6328
  syncScrollToBottomButton();
6107
6329
  return;
@@ -6159,7 +6381,7 @@ export const createAgentExperience = (
6159
6381
  // left in the transcript fires no further events, so it can't re-pause
6160
6382
  // after the user resumes following.
6161
6383
  const handleSelectionChange = () => {
6162
- if (getScrollMode() !== "follow") return;
6384
+ if (!isFollowEffective()) return;
6163
6385
  if (!autoFollow.isFollowing()) return;
6164
6386
  if (hasActiveTranscriptSelection()) {
6165
6387
  pauseAutoScroll();
@@ -6170,8 +6392,46 @@ export const createAgentExperience = (
6170
6392
  destroyCallbacks.push(() => {
6171
6393
  selectionDocument.removeEventListener("selectionchange", handleSelectionChange);
6172
6394
  });
6395
+
6396
+ // Principle 3: every interaction is intent. Beyond wheel/scroll/selection,
6397
+ // opting into `pauseOnInteraction` also treats keyboard navigation within the
6398
+ // transcript and focusing an interactive element (a link, button, etc.) as
6399
+ // "the reader is doing something here" — pause auto-follow so the stream
6400
+ // doesn't move content out from under them. Opt-in; off by default.
6401
+ const NAV_KEYS = new Set([
6402
+ "PageUp",
6403
+ "PageDown",
6404
+ "Home",
6405
+ "End",
6406
+ "ArrowUp",
6407
+ "ArrowDown",
6408
+ ]);
6409
+ const handleTranscriptKeydown = (event: KeyboardEvent) => {
6410
+ if (!isPauseOnInteractionEnabled()) return;
6411
+ if (!isFollowEffective()) return;
6412
+ if (!autoFollow.isFollowing()) return;
6413
+ if (NAV_KEYS.has(event.key)) {
6414
+ pauseAutoScroll();
6415
+ }
6416
+ };
6417
+ const handleTranscriptFocusIn = (event: FocusEvent) => {
6418
+ if (!isPauseOnInteractionEnabled()) return;
6419
+ if (!isFollowEffective()) return;
6420
+ if (!autoFollow.isFollowing()) return;
6421
+ const target = event.target as Element | null;
6422
+ if (target && target.closest("a, button, [tabindex], input, textarea, select")) {
6423
+ pauseAutoScroll();
6424
+ }
6425
+ };
6426
+ body.addEventListener("keydown", handleTranscriptKeydown);
6427
+ body.addEventListener("focusin", handleTranscriptFocusIn);
6428
+ destroyCallbacks.push(() => {
6429
+ body.removeEventListener("keydown", handleTranscriptKeydown);
6430
+ body.removeEventListener("focusin", handleTranscriptFocusIn);
6431
+ });
6432
+
6173
6433
  const handleWheel = (event: WheelEvent) => {
6174
- if (getScrollMode() !== "follow") return;
6434
+ if (!isFollowEffective()) return;
6175
6435
  const action = resolveFollowStateFromWheel({
6176
6436
  following: autoFollow.isFollowing(),
6177
6437
  deltaY: event.deltaY,