@runtypelabs/persona 3.30.0 → 3.31.1

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 (50) hide show
  1. package/README.md +15 -2602
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-B_Qazlm4.d.cts → types-quh7NmYD.d.cts} +9 -0
  5. package/dist/animations/{types-B_Qazlm4.d.ts → types-quh7NmYD.d.ts} +9 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +4 -4
  9. package/dist/codegen.js +3 -3
  10. package/dist/index.cjs +46 -46
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +52 -0
  13. package/dist/index.d.ts +52 -0
  14. package/dist/index.global.js +77 -80
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +45 -45
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js +2 -2
  19. package/dist/launcher.global.js.map +1 -1
  20. package/dist/smart-dom-reader.d.cts +50 -0
  21. package/dist/smart-dom-reader.d.ts +50 -0
  22. package/dist/theme-editor.cjs +39 -39
  23. package/dist/theme-editor.d.cts +50 -0
  24. package/dist/theme-editor.d.ts +50 -0
  25. package/dist/theme-editor.js +39 -39
  26. package/dist/theme-reference.cjs +1 -1
  27. package/dist/theme-reference.js +1 -1
  28. package/dist/webmcp-polyfill.js +4 -0
  29. package/dist/widget.css +19 -0
  30. package/package.json +4 -3
  31. package/src/client.ts +6 -0
  32. package/src/components/approval-bubble.test.ts +52 -0
  33. package/src/components/approval-bubble.ts +20 -0
  34. package/src/components/panel.ts +7 -1
  35. package/src/defaults.ts +14 -0
  36. package/src/generated/runtype-openapi-contract.ts +4 -0
  37. package/src/index-global.ts +39 -0
  38. package/src/session.test.ts +62 -0
  39. package/src/session.ts +27 -0
  40. package/src/styles/widget.css +19 -0
  41. package/src/theme-reference.ts +1 -1
  42. package/src/types.ts +50 -0
  43. package/src/ui.scroll.test.ts +383 -0
  44. package/src/ui.ts +390 -40
  45. package/src/utils/auto-follow.test.ts +91 -0
  46. package/src/utils/auto-follow.ts +68 -0
  47. package/src/version.ts +5 -2
  48. package/src/webmcp-bridge.test.ts +60 -0
  49. package/src/webmcp-bridge.ts +34 -1
  50. package/src/webmcp-polyfill.ts +16 -0
package/src/ui.ts CHANGED
@@ -41,8 +41,11 @@ import {
41
41
  } from "./utils/composer-history";
42
42
  import { computeMessageFingerprint, createMessageCache, getCachedWrapper, setCachedWrapper, pruneCache } from "./utils/message-fingerprint";
43
43
  import {
44
+ computeAnchorScrollState,
45
+ computeShrunkSpacerHeight,
44
46
  createFollowStateController,
45
47
  getScrollBottomOffset,
48
+ hasSelectionWithin,
46
49
  isElementNearBottom,
47
50
  resolveFollowStateFromScroll,
48
51
  resolveFollowStateFromWheel
@@ -672,6 +675,7 @@ export const createAgentExperience = (
672
675
  let showToolCalls = config.features?.showToolCalls ?? true;
673
676
  let showEventStreamToggle = config.features?.showEventStreamToggle ?? false;
674
677
  let scrollToBottomFeature = config.features?.scrollToBottom ?? {};
678
+ let scrollBehaviorFeature = config.features?.scrollBehavior ?? {};
675
679
  const persistKeyPrefix = (typeof config.persistState === 'object' ? config.persistState?.keyPrefix : undefined) ?? "persona-";
676
680
  const eventStreamDbName = `${persistKeyPrefix}event-stream`;
677
681
  let eventStreamStore = showEventStreamToggle ? new EventStreamStore(eventStreamDbName) : null;
@@ -792,6 +796,8 @@ export const createAgentExperience = (
792
796
  const getScrollToBottomLabel = () => scrollToBottomFeature.label ?? "";
793
797
  const getScrollToBottomIconName = () => scrollToBottomFeature.iconName ?? "arrow-down";
794
798
  const isScrollToBottomEnabled = () => scrollToBottomFeature.enabled !== false;
799
+ const getScrollMode = () => scrollBehaviorFeature.mode ?? "follow";
800
+ const getAnchorTopOffset = () => scrollBehaviorFeature.anchorTopOffset ?? 16;
795
801
  const scrollToBottomButton = createElement(
796
802
  "button",
797
803
  "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"
@@ -801,9 +807,28 @@ export const createAgentExperience = (
801
807
  scrollToBottomButton.setAttribute("data-persona-scroll-to-bottom", "true");
802
808
  const scrollToBottomIcon = createElement("span", "persona-flex persona-items-center");
803
809
  const scrollToBottomLabel = createElement("span", "");
804
- scrollToBottomButton.append(scrollToBottomIcon, scrollToBottomLabel);
810
+ // Count of messages that arrived while auto-follow was paused (or, in
811
+ // non-follow scroll modes, while the user was away from the bottom).
812
+ // Rendered as a small badge on the scroll-to-bottom affordance, mirroring
813
+ // the event stream view's "Jump to latest (N)" indicator.
814
+ const scrollToBottomCount = createElement("span", "");
815
+ scrollToBottomCount.setAttribute("data-persona-scroll-to-bottom-count", "");
816
+ scrollToBottomCount.style.display = "none";
817
+ scrollToBottomButton.append(scrollToBottomIcon, scrollToBottomLabel, scrollToBottomCount);
805
818
  container.appendChild(scrollToBottomButton);
806
819
 
820
+ // Anchor-top scroll mode: zero-height spacer kept after the messages
821
+ // wrapper. Sized on send so the just-sent user message can be scrolled to
822
+ // the top of the viewport before the streamed response is tall enough to
823
+ // make that position reachable; shrinks as real content fills the space.
824
+ const anchorSpacer = createElement("div", "persona-stream-anchor-spacer");
825
+ anchorSpacer.setAttribute("aria-hidden", "true");
826
+ anchorSpacer.setAttribute("data-persona-anchor-spacer", "");
827
+ anchorSpacer.style.flexShrink = "0";
828
+ anchorSpacer.style.pointerEvents = "none";
829
+ anchorSpacer.style.height = "0px";
830
+ body.appendChild(anchorSpacer);
831
+
807
832
  const updateScrollToBottomButtonOffset = () => {
808
833
  const footerHidden = footer.style.display === "none";
809
834
  const footerHeight = footerHidden ? 0 : footer.offsetHeight;
@@ -2625,6 +2650,20 @@ export const createAgentExperience = (
2625
2650
  let scrollRAF: number | null = null;
2626
2651
  let isAutoScrolling = false;
2627
2652
  let hasPendingAutoScroll = false;
2653
+ // Messages that arrived while the user was away from the latest content;
2654
+ // shown as a badge on the scroll-to-bottom affordance.
2655
+ let newMessagesSincePause = 0;
2656
+ // Live anchor-top state for the current turn (null when not anchored).
2657
+ let anchorState: {
2658
+ initialSpacerHeight: number;
2659
+ contentHeightAtAnchor: number;
2660
+ spacerHeight: number;
2661
+ } | null = null;
2662
+ let anchorRAF: number | null = null;
2663
+ // Seeded send-detection so restored history doesn't read as a fresh send.
2664
+ let scrollSendSeeded = false;
2665
+ let suppressScrollSend = false;
2666
+ let lastSentUserMessageId: string | null = null;
2628
2667
 
2629
2668
  // Scroll events caused by layout, scroll anchoring, and smooth-scroll
2630
2669
  // easing can easily move by a couple pixels. Keep manual wheel intent
@@ -2752,6 +2791,39 @@ export const createAgentExperience = (
2752
2791
  cancelSmoothScroll();
2753
2792
  };
2754
2793
 
2794
+ const updateScrollToBottomCountBadge = () => {
2795
+ if (newMessagesSincePause > 0) {
2796
+ scrollToBottomCount.textContent = String(newMessagesSincePause);
2797
+ scrollToBottomCount.style.display = "";
2798
+ scrollToBottomButton.setAttribute(
2799
+ "aria-label",
2800
+ `${getScrollToBottomLabel() || "Jump to latest"} (${newMessagesSincePause} new)`
2801
+ );
2802
+ } else {
2803
+ scrollToBottomCount.textContent = "";
2804
+ scrollToBottomCount.style.display = "none";
2805
+ scrollToBottomButton.setAttribute(
2806
+ "aria-label",
2807
+ getScrollToBottomLabel() || "Jump to latest"
2808
+ );
2809
+ }
2810
+ };
2811
+
2812
+ const resetNewMessagesCount = () => {
2813
+ if (newMessagesSincePause === 0) return;
2814
+ newMessagesSincePause = 0;
2815
+ updateScrollToBottomCountBadge();
2816
+ };
2817
+
2818
+ // Whether the user is currently away from the latest content — drives both
2819
+ // the scroll-to-bottom affordance and the new-messages badge. In follow
2820
+ // mode that's "auto-follow paused"; in anchor-top/none modes (where there
2821
+ // is no follow state) it's simply "not near the bottom".
2822
+ const isAwayFromLatest = () =>
2823
+ getScrollMode() === "follow"
2824
+ ? !autoFollow.isFollowing()
2825
+ : !isElementNearBottom(body, BOTTOM_THRESHOLD);
2826
+
2755
2827
  const syncScrollToBottomButton = () => {
2756
2828
  if (!isScrollToBottomEnabled() || eventStreamVisible) {
2757
2829
  if (scrollToBottomButton.parentNode) {
@@ -2765,7 +2837,11 @@ export const createAgentExperience = (
2765
2837
  }
2766
2838
  updateScrollToBottomButtonOffset();
2767
2839
  const hasOverflow = getScrollBottomOffset(body) > 0;
2768
- scrollToBottomButton.style.display = (autoFollow.isFollowing() || !hasOverflow) ? "none" : "";
2840
+ const show = hasOverflow && isAwayFromLatest();
2841
+ if (!show) {
2842
+ resetNewMessagesCount();
2843
+ }
2844
+ scrollToBottomButton.style.display = show ? "" : "none";
2769
2845
  };
2770
2846
 
2771
2847
  const pauseAutoScroll = () => {
@@ -2776,10 +2852,15 @@ export const createAgentExperience = (
2776
2852
 
2777
2853
  const resumeAutoScroll = () => {
2778
2854
  autoFollow.resume();
2855
+ resetNewMessagesCount();
2779
2856
  syncScrollToBottomButton();
2780
2857
  };
2781
2858
 
2782
2859
  const scheduleAutoScroll = (force = false) => {
2860
+ // Auto-follow only applies in "follow" mode; anchor-top and none never
2861
+ // chase the bottom during streaming.
2862
+ if (getScrollMode() !== "follow") return;
2863
+
2783
2864
  if (!autoFollow.isFollowing()) return;
2784
2865
 
2785
2866
  if (!force && !isStreaming) return;
@@ -2808,31 +2889,21 @@ export const createAgentExperience = (
2808
2889
  });
2809
2890
  };
2810
2891
 
2811
- // Custom smooth scroll animation with easing
2812
- const smoothScrollToBottom = (element: HTMLElement, duration = 500) => {
2892
+ // Generic eased scroll animation. `resolveTarget` is re-read every frame so
2893
+ // a moving target (the bottom of a streaming transcript) stays accurate;
2894
+ // `shouldContinue` lets the caller cancel mid-flight (e.g. when auto-follow
2895
+ // pauses). Scroll events emitted by the animation are masked from the
2896
+ // user-intent detector via `isAutoScrolling`.
2897
+ const animateScrollTo = (
2898
+ element: HTMLElement,
2899
+ resolveTarget: () => number,
2900
+ duration: number,
2901
+ shouldContinue: () => boolean = () => true
2902
+ ) => {
2813
2903
  const start = element.scrollTop;
2814
- // Recalculate target dynamically to handle layout changes
2815
- let target = getScrollBottomOffset(element);
2904
+ let target = resolveTarget();
2816
2905
  let distance = target - start;
2817
2906
 
2818
- // If already at bottom or very close, skip animation to prevent glitch
2819
- if (Math.abs(distance) < 1) {
2820
- lastScrollTop = element.scrollTop;
2821
- return;
2822
- }
2823
-
2824
- // If the transcript has fallen noticeably behind, catch up immediately
2825
- // instead of easing over multiple frames. This keeps fast streaming /
2826
- // bursty tool and reasoning updates pinned to the bottom.
2827
- if (Math.abs(distance) >= AUTO_SCROLL_SNAP_THRESHOLD) {
2828
- cancelSmoothScroll();
2829
- isAutoScrolling = true;
2830
- element.scrollTop = target;
2831
- lastScrollTop = element.scrollTop;
2832
- isAutoScrolling = false;
2833
- return;
2834
- }
2835
-
2836
2907
  // Cancel any ongoing smooth scroll animation
2837
2908
  cancelSmoothScroll();
2838
2909
 
@@ -2845,13 +2916,13 @@ export const createAgentExperience = (
2845
2916
  };
2846
2917
 
2847
2918
  const animate = (currentTime: number) => {
2848
- if (!autoFollow.isFollowing()) {
2919
+ if (!shouldContinue()) {
2849
2920
  cancelSmoothScroll();
2850
2921
  return;
2851
2922
  }
2852
2923
 
2853
2924
  // Recalculate target each frame in case scrollHeight changed
2854
- const currentTarget = getScrollBottomOffset(element);
2925
+ const currentTarget = resolveTarget();
2855
2926
  if (currentTarget !== target) {
2856
2927
  target = currentTarget;
2857
2928
  distance = target - start;
@@ -2860,7 +2931,7 @@ export const createAgentExperience = (
2860
2931
  const elapsed = currentTime - startTime;
2861
2932
  const progress = Math.min(elapsed / duration, 1);
2862
2933
  const eased = easeOutCubic(progress);
2863
-
2934
+
2864
2935
  const currentScroll = start + distance * eased;
2865
2936
  element.scrollTop = currentScroll;
2866
2937
  lastScrollTop = element.scrollTop;
@@ -2879,6 +2950,157 @@ export const createAgentExperience = (
2879
2950
  smoothScrollRAF = requestAnimationFrame(animate);
2880
2951
  };
2881
2952
 
2953
+ // Custom smooth scroll animation with easing
2954
+ const smoothScrollToBottom = (element: HTMLElement, duration = 500) => {
2955
+ const distance = getScrollBottomOffset(element) - element.scrollTop;
2956
+
2957
+ // If already at bottom or very close, skip animation to prevent glitch
2958
+ if (Math.abs(distance) < 1) {
2959
+ lastScrollTop = element.scrollTop;
2960
+ return;
2961
+ }
2962
+
2963
+ // If the transcript has fallen noticeably behind, catch up immediately
2964
+ // instead of easing over multiple frames. This keeps fast streaming /
2965
+ // bursty tool and reasoning updates pinned to the bottom.
2966
+ if (Math.abs(distance) >= AUTO_SCROLL_SNAP_THRESHOLD) {
2967
+ cancelSmoothScroll();
2968
+ isAutoScrolling = true;
2969
+ element.scrollTop = getScrollBottomOffset(element);
2970
+ lastScrollTop = element.scrollTop;
2971
+ isAutoScrolling = false;
2972
+ return;
2973
+ }
2974
+
2975
+ animateScrollTo(
2976
+ element,
2977
+ () => getScrollBottomOffset(element),
2978
+ duration,
2979
+ () => autoFollow.isFollowing()
2980
+ );
2981
+ };
2982
+
2983
+ // Instant jump used for initial mount / panel open in non-follow scroll
2984
+ // modes (where scheduleAutoScroll is inert).
2985
+ const jumpToBottomInstant = () => {
2986
+ const element = getScrollableContainer();
2987
+ isAutoScrolling = true;
2988
+ element.scrollTop = getScrollBottomOffset(element);
2989
+ lastScrollTop = element.scrollTop;
2990
+ isAutoScrolling = false;
2991
+ syncScrollToBottomButton();
2992
+ };
2993
+
2994
+ const setAnchorSpacerHeight = (height: number) => {
2995
+ anchorSpacer.style.height = `${Math.max(0, Math.round(height))}px`;
2996
+ if (anchorState) {
2997
+ anchorState.spacerHeight = Math.max(0, height);
2998
+ }
2999
+ };
3000
+
3001
+ const resetAnchorState = () => {
3002
+ if (anchorRAF !== null) {
3003
+ cancelAnimationFrame(anchorRAF);
3004
+ anchorRAF = null;
3005
+ }
3006
+ // Also stop an in-flight anchor scroll animation — otherwise its
3007
+ // remaining frames keep easing scrollTop toward the stale anchor target
3008
+ // after a jump-to-latest, chat clear, or scroll-mode change.
3009
+ cancelSmoothScroll();
3010
+ anchorState = null;
3011
+ anchorSpacer.style.height = "0px";
3012
+ };
3013
+
3014
+ // Anchor-top mode: scroll the just-sent user message to rest
3015
+ // `anchorTopOffset` px below the viewport top and hold it there while the
3016
+ // response streams in beneath it. Deferred one frame so the message bubble
3017
+ // has been rendered and laid out.
3018
+ const scheduleAnchorToUserMessage = (messageId: string) => {
3019
+ if (anchorRAF !== null) {
3020
+ cancelAnimationFrame(anchorRAF);
3021
+ }
3022
+ anchorRAF = requestAnimationFrame(() => {
3023
+ anchorRAF = null;
3024
+ const escapedId =
3025
+ typeof CSS !== "undefined" && typeof CSS.escape === "function"
3026
+ ? CSS.escape(messageId)
3027
+ : messageId.replace(/"/g, '\\"');
3028
+ const bubble = body.querySelector<HTMLElement>(
3029
+ `[data-message-id="${escapedId}"]`
3030
+ );
3031
+ if (!bubble) return;
3032
+
3033
+ // Bubble top relative to the scroll content. `body` is the positioned
3034
+ // ancestor, so walking offsetParents terminates there. offsetTop is
3035
+ // used instead of getBoundingClientRect so in-flight entrance
3036
+ // animations (transforms) can't skew the target.
3037
+ let anchorOffsetTop = 0;
3038
+ let node: HTMLElement | null = bubble;
3039
+ while (node && node !== body) {
3040
+ anchorOffsetTop += node.offsetTop;
3041
+ node = node.offsetParent as HTMLElement | null;
3042
+ }
3043
+
3044
+ const previousSpacerHeight = anchorState?.spacerHeight ?? 0;
3045
+ const contentHeight = body.scrollHeight - previousSpacerHeight;
3046
+ const { targetScrollTop, spacerHeight } = computeAnchorScrollState({
3047
+ anchorOffsetTop,
3048
+ topOffset: getAnchorTopOffset(),
3049
+ viewportHeight: body.clientHeight,
3050
+ contentHeight
3051
+ });
3052
+
3053
+ anchorState = {
3054
+ initialSpacerHeight: spacerHeight,
3055
+ contentHeightAtAnchor: contentHeight,
3056
+ spacerHeight
3057
+ };
3058
+ setAnchorSpacerHeight(spacerHeight);
3059
+ animateScrollTo(body, () => targetScrollTop, 220);
3060
+ });
3061
+ };
3062
+
3063
+ // Content growth handler (ResizeObserver-driven). In follow mode this is
3064
+ // what keeps the transcript pinned when content grows *without* a render
3065
+ // event — images/embeds finishing loading mid-stream, fonts swapping,
3066
+ // the panel or composer resizing. In anchor-top mode it gives spacer room
3067
+ // back as the streamed response grows (shrink-only, so total scroll height
3068
+ // stays constant and nothing jumps).
3069
+ const handleContentResize = () => {
3070
+ if (getScrollMode() === "follow") {
3071
+ if (!autoFollow.isFollowing()) return;
3072
+ if (isElementNearBottom(body, 1)) return;
3073
+ scheduleAutoScroll(!isStreaming);
3074
+ return;
3075
+ }
3076
+ if (anchorState && anchorState.initialSpacerHeight > 0) {
3077
+ const currentContentHeight = body.scrollHeight - anchorState.spacerHeight;
3078
+ const next = computeShrunkSpacerHeight({
3079
+ initialSpacerHeight: anchorState.initialSpacerHeight,
3080
+ contentHeightAtAnchor: anchorState.contentHeightAtAnchor,
3081
+ currentContentHeight
3082
+ });
3083
+ if (next !== anchorState.spacerHeight) {
3084
+ setAnchorSpacerHeight(next);
3085
+ }
3086
+ }
3087
+ syncScrollToBottomButton();
3088
+ };
3089
+
3090
+ // Reacts to a user message the user just sent (seeded so restored history
3091
+ // never triggers it). Follow mode re-sticks to the bottom even if the user
3092
+ // had scrolled up — sending is an unambiguous "take me to the latest"
3093
+ // signal. Anchor-top mode pins the sent message near the viewport top.
3094
+ const handleUserMessageSent = (messageId: string) => {
3095
+ const mode = getScrollMode();
3096
+ if (mode === "follow") {
3097
+ resumeAutoScroll();
3098
+ scheduleAutoScroll(true);
3099
+ } else if (mode === "anchor-top") {
3100
+ scheduleAnchorToUserMessage(messageId);
3101
+ }
3102
+ };
3103
+
2882
3104
  const trackMessages = (messages: AgentWidgetMessage[]) => {
2883
3105
  const nextState = new Map<
2884
3106
  string,
@@ -2894,6 +3116,19 @@ export const createAgentExperience = (
2894
3116
 
2895
3117
  if (!previous && message.role === "assistant") {
2896
3118
  eventBus.emit("assistant:message", message);
3119
+ // Count messages the user hasn't seen for the scroll-to-bottom badge.
3120
+ // Skipped in anchor-top (the user is already reading the latest turn
3121
+ // from its top, so a "new" count would mislead) and during history
3122
+ // hydration (restored messages aren't "missed").
3123
+ if (
3124
+ !suppressScrollSend &&
3125
+ getScrollMode() !== "anchor-top" &&
3126
+ isAwayFromLatest()
3127
+ ) {
3128
+ newMessagesSincePause += 1;
3129
+ updateScrollToBottomCountBadge();
3130
+ syncScrollToBottomButton();
3131
+ }
2897
3132
  }
2898
3133
 
2899
3134
  if (
@@ -4380,7 +4615,13 @@ export const createAgentExperience = (
4380
4615
 
4381
4616
  if (open) {
4382
4617
  recalcPanelHeight();
4383
- scheduleAutoScroll(true);
4618
+ if (getScrollMode() === "follow") {
4619
+ scheduleAutoScroll(true);
4620
+ } else {
4621
+ // Non-follow modes still start at the latest content when the panel
4622
+ // opens; they just never chase it during streaming.
4623
+ jumpToBottomInstant();
4624
+ }
4384
4625
  }
4385
4626
 
4386
4627
  // Emit widget state events
@@ -4536,6 +4777,20 @@ export const createAgentExperience = (
4536
4777
  .reverse()
4537
4778
  .find((msg) => msg.role === "user");
4538
4779
 
4780
+ // Scroll-on-send / anchor-top. Seeded so restored history (constructor
4781
+ // initialMessages and async storage hydration) never reads as a fresh
4782
+ // send; clearing the chat resets any anchor spacer.
4783
+ if (messages.length === 0) {
4784
+ resetAnchorState();
4785
+ }
4786
+ if (!scrollSendSeeded || suppressScrollSend) {
4787
+ scrollSendSeeded = true;
4788
+ lastSentUserMessageId = lastUserMessage?.id ?? null;
4789
+ } else if (lastUserMessage && lastUserMessage.id !== lastSentUserMessageId) {
4790
+ lastSentUserMessageId = lastUserMessage.id;
4791
+ handleUserMessageSent(lastUserMessage.id);
4792
+ }
4793
+
4539
4794
  // Emit user:message event when a new user message is detected
4540
4795
  const prevLastUserMessageId = voiceState.lastUserMessageId;
4541
4796
  if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
@@ -4615,6 +4870,11 @@ export const createAgentExperience = (
4615
4870
 
4616
4871
  sessionRef.current = session;
4617
4872
 
4873
+ // The constructor only emits onMessagesChanged when it has initial
4874
+ // messages, so seed send-detection explicitly for the empty-session case —
4875
+ // otherwise the user's very first send would be mistaken for the seed.
4876
+ scrollSendSeeded = true;
4877
+
4618
4878
  // Setup Runtype voice provider when configured (connects WebSocket for server-side STT)
4619
4879
  if (config.voiceRecognition?.provider?.type === 'runtype') {
4620
4880
  try {
@@ -4661,7 +4921,14 @@ export const createAgentExperience = (
4661
4921
  actionManager.syncFromMetadata();
4662
4922
  }
4663
4923
  if (state.messages?.length) {
4664
- session.hydrateMessages(state.messages);
4924
+ // Restored history must not read as a fresh send (scroll-on-send /
4925
+ // anchor-top would fire for the last restored user message).
4926
+ suppressScrollSend = true;
4927
+ try {
4928
+ session.hydrateMessages(state.messages);
4929
+ } finally {
4930
+ suppressScrollSend = false;
4931
+ }
4665
4932
  }
4666
4933
  if (state.artifacts?.length) {
4667
4934
  session.hydrateArtifacts(
@@ -5434,7 +5701,11 @@ export const createAgentExperience = (
5434
5701
  suggestionsManager.render(config.suggestionChips, session, textarea, undefined, config.suggestionChipsConfig);
5435
5702
  updateCopy();
5436
5703
  setComposerDisabled(session.isStreaming());
5437
- scheduleAutoScroll(true);
5704
+ if (getScrollMode() === "follow") {
5705
+ scheduleAutoScroll(true);
5706
+ } else {
5707
+ jumpToBottomInstant();
5708
+ }
5438
5709
  maybeRestoreVoiceFromMetadata();
5439
5710
 
5440
5711
  if (autoFocusInput) {
@@ -5558,18 +5829,44 @@ export const createAgentExperience = (
5558
5829
  }
5559
5830
 
5560
5831
  lastScrollTop = body.scrollTop;
5561
- let lastScrollHeight = body.scrollHeight;
5832
+ let lastBottomOffset = getScrollBottomOffset(body);
5833
+
5834
+ const getTranscriptSelection = (): Selection | null => {
5835
+ // Selections inside a shadow root are not always reflected by
5836
+ // document.getSelection(); prefer the shadow root's view when available
5837
+ // (non-standard but supported where it matters).
5838
+ const root = body.getRootNode();
5839
+ const shadowSelection =
5840
+ typeof (root as ShadowRoot & { getSelection?: () => Selection | null })
5841
+ .getSelection === "function"
5842
+ ? (root as ShadowRoot & { getSelection: () => Selection | null }).getSelection()
5843
+ : null;
5844
+ return shadowSelection ?? body.ownerDocument.getSelection();
5845
+ };
5846
+ const hasActiveTranscriptSelection = () =>
5847
+ hasSelectionWithin(getTranscriptSelection(), body);
5562
5848
 
5563
5849
  const handleScroll = () => {
5564
5850
  const scrollTop = body.scrollTop;
5565
- // When content mutates (e.g. stream-animation plugins re-rendering text),
5566
- // scrollHeight can shrink and force the browser to clamp scrollTop downward.
5851
+ // When content mutates (e.g. stream-animation plugins re-rendering text)
5852
+ // or the viewport grows (composer shrinking back), the maximum scroll
5853
+ // position can shrink and force the browser to clamp scrollTop downward.
5567
5854
  // That emits a scroll event with a negative delta that would otherwise be
5568
5855
  // misread as the user scrolling up, pausing auto-follow and flashing the
5569
- // scroll-to-bottom button. Treat those as non-user events.
5570
- const currentScrollHeight = body.scrollHeight;
5571
- const scrollHeightShrank = currentScrollHeight < lastScrollHeight;
5572
- lastScrollHeight = currentScrollHeight;
5856
+ // scroll-to-bottom button. Treat those as non-user events. Tracking the
5857
+ // bottom offset (scrollHeight - clientHeight) rather than scrollHeight
5858
+ // alone also covers clientHeight-driven clamps.
5859
+ const currentBottomOffset = getScrollBottomOffset(body);
5860
+ const bottomOffsetShrank = currentBottomOffset < lastBottomOffset;
5861
+ lastBottomOffset = currentBottomOffset;
5862
+
5863
+ if (getScrollMode() !== "follow") {
5864
+ // No follow state to manage — just keep the scroll-to-bottom
5865
+ // affordance in sync with the user's position.
5866
+ lastScrollTop = scrollTop;
5867
+ syncScrollToBottomButton();
5868
+ return;
5869
+ }
5573
5870
 
5574
5871
  const { action, nextLastScrollTop } = resolveFollowStateFromScroll({
5575
5872
  following: autoFollow.isFollowing(),
@@ -5577,7 +5874,7 @@ export const createAgentExperience = (
5577
5874
  lastScrollTop,
5578
5875
  nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
5579
5876
  userScrollThreshold: USER_SCROLL_THRESHOLD,
5580
- isAutoScrolling: isAutoScrolling || hasPendingAutoScroll || scrollHeightShrank,
5877
+ isAutoScrolling: isAutoScrolling || hasPendingAutoScroll || bottomOffsetShrank,
5581
5878
  pauseOnUpwardScroll: true,
5582
5879
  pauseWhenAwayFromBottom: false,
5583
5880
  resumeRequiresDownwardScroll: true
@@ -5585,7 +5882,12 @@ export const createAgentExperience = (
5585
5882
  lastScrollTop = nextLastScrollTop;
5586
5883
 
5587
5884
  if (action === "resume") {
5588
- resumeAutoScroll();
5885
+ // Drag-selecting downward near the bottom edge auto-scrolls down and
5886
+ // would otherwise read as a resume gesture; keep follow paused while a
5887
+ // transcript selection is active so it isn't yanked mid-drag.
5888
+ if (!hasActiveTranscriptSelection()) {
5889
+ resumeAutoScroll();
5890
+ }
5589
5891
  return;
5590
5892
  }
5591
5893
 
@@ -5596,7 +5898,41 @@ export const createAgentExperience = (
5596
5898
 
5597
5899
  body.addEventListener("scroll", handleScroll, { passive: true });
5598
5900
  destroyCallbacks.push(() => body.removeEventListener("scroll", handleScroll));
5901
+
5902
+ // Content-growth follow. Render events already schedule auto-scroll, but
5903
+ // content can also grow without one: images/embeds finishing loading
5904
+ // mid-stream, web fonts swapping, the panel or composer resizing. Observe
5905
+ // the messages wrapper (content growth) and the scroll container itself
5906
+ // (viewport resize) so the pin survives all of them.
5907
+ if (typeof ResizeObserver !== "undefined") {
5908
+ const contentResizeObserver = new ResizeObserver(() => {
5909
+ handleContentResize();
5910
+ });
5911
+ contentResizeObserver.observe(messagesWrapper);
5912
+ contentResizeObserver.observe(body);
5913
+ destroyCallbacks.push(() => contentResizeObserver.disconnect());
5914
+ }
5915
+
5916
+ // Pause auto-follow while the user selects transcript text so the
5917
+ // streaming scroll doesn't move content out from under the selection.
5918
+ // Driven purely by selectionchange (no pointer gating) so keyboard
5919
+ // selection (Shift+arrows, select-all) pauses too; a stale selection
5920
+ // left in the transcript fires no further events, so it can't re-pause
5921
+ // after the user resumes following.
5922
+ const handleSelectionChange = () => {
5923
+ if (getScrollMode() !== "follow") return;
5924
+ if (!autoFollow.isFollowing()) return;
5925
+ if (hasActiveTranscriptSelection()) {
5926
+ pauseAutoScroll();
5927
+ }
5928
+ };
5929
+ const selectionDocument = body.ownerDocument;
5930
+ selectionDocument.addEventListener("selectionchange", handleSelectionChange);
5931
+ destroyCallbacks.push(() => {
5932
+ selectionDocument.removeEventListener("selectionchange", handleSelectionChange);
5933
+ });
5599
5934
  const handleWheel = (event: WheelEvent) => {
5935
+ if (getScrollMode() !== "follow") return;
5600
5936
  const action = resolveFollowStateFromWheel({
5601
5937
  following: autoFollow.isFollowing(),
5602
5938
  deltaY: event.deltaY,
@@ -5606,21 +5942,27 @@ export const createAgentExperience = (
5606
5942
 
5607
5943
  if (action === "pause") {
5608
5944
  pauseAutoScroll();
5609
- } else if (action === "resume") {
5945
+ } else if (action === "resume" && !hasActiveTranscriptSelection()) {
5610
5946
  resumeAutoScroll();
5611
5947
  }
5612
5948
  };
5613
5949
  body.addEventListener("wheel", handleWheel, { passive: true });
5614
5950
  destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
5615
5951
  scrollToBottomButton.addEventListener("click", () => {
5952
+ // Jumping to the latest abandons the current anchor: drop the spacer
5953
+ // first so "bottom" means the real end of content, not spacer padding
5954
+ // that would keep shrinking underneath the reader.
5955
+ resetAnchorState();
5616
5956
  body.scrollTop = body.scrollHeight;
5617
5957
  lastScrollTop = body.scrollTop;
5618
5958
  resumeAutoScroll();
5619
5959
  scheduleAutoScroll(true);
5960
+ syncScrollToBottomButton();
5620
5961
  });
5621
5962
  destroyCallbacks.push(() => scrollToBottomButton.remove());
5622
5963
  destroyCallbacks.push(() => {
5623
5964
  cancelAutoScroll();
5965
+ resetAnchorState();
5624
5966
  });
5625
5967
 
5626
5968
  const refreshCloseButton = () => {
@@ -5865,6 +6207,14 @@ export const createAgentExperience = (
5865
6207
  showReasoning = config.features?.showReasoning ?? true;
5866
6208
  showToolCalls = config.features?.showToolCalls ?? true;
5867
6209
  scrollToBottomFeature = config.features?.scrollToBottom ?? {};
6210
+ const prevScrollMode = getScrollMode();
6211
+ scrollBehaviorFeature = config.features?.scrollBehavior ?? {};
6212
+ if (prevScrollMode !== getScrollMode()) {
6213
+ // Leaving anchor-top drops any live spacer; entering a new mode
6214
+ // starts from a clean follow state.
6215
+ resetAnchorState();
6216
+ resumeAutoScroll();
6217
+ }
5868
6218
  renderScrollToBottomButton();
5869
6219
  syncScrollToBottomButton();
5870
6220
  const prevShowEventStreamToggle = showEventStreamToggle;