@runtypelabs/persona 3.29.1 → 3.31.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 (68) hide show
  1. package/README.md +15 -2547
  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 +6 -6
  9. package/dist/codegen.js +4 -4
  10. package/dist/index.cjs +50 -50
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +164 -7
  13. package/dist/index.d.ts +164 -7
  14. package/dist/index.global.js +60 -60
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +49 -49
  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/plugin-kit.cjs +1 -0
  21. package/dist/plugin-kit.d.cts +98 -0
  22. package/dist/plugin-kit.d.ts +98 -0
  23. package/dist/plugin-kit.js +1 -0
  24. package/dist/smart-dom-reader.d.cts +157 -4
  25. package/dist/smart-dom-reader.d.ts +157 -4
  26. package/dist/theme-editor.cjs +40 -40
  27. package/dist/theme-editor.d.cts +161 -6
  28. package/dist/theme-editor.d.ts +161 -6
  29. package/dist/theme-editor.js +40 -40
  30. package/dist/theme-reference.cjs +1 -1
  31. package/dist/theme-reference.d.cts +2 -0
  32. package/dist/theme-reference.d.ts +2 -0
  33. package/dist/theme-reference.js +1 -1
  34. package/dist/widget.css +19 -0
  35. package/package.json +8 -2
  36. package/src/client.ts +6 -0
  37. package/src/components/approval-bubble.test.ts +320 -0
  38. package/src/components/approval-bubble.ts +190 -20
  39. package/src/components/launcher.ts +6 -3
  40. package/src/components/panel.ts +7 -1
  41. package/src/components/tool-bubble.test.ts +39 -0
  42. package/src/components/tool-bubble.ts +4 -0
  43. package/src/defaults.ts +14 -0
  44. package/src/generated/runtype-openapi-contract.ts +4 -0
  45. package/src/index-core.ts +1 -0
  46. package/src/plugin-kit.test.ts +230 -0
  47. package/src/plugin-kit.ts +294 -0
  48. package/src/plugins/types.ts +49 -2
  49. package/src/session.test.ts +161 -0
  50. package/src/session.ts +41 -3
  51. package/src/styles/widget.css +19 -0
  52. package/src/theme-editor/preview-utils.test.ts +10 -0
  53. package/src/theme-editor/preview-utils.ts +29 -1
  54. package/src/theme-editor/sections.test.ts +17 -0
  55. package/src/theme-editor/sections.ts +31 -0
  56. package/src/theme-reference.ts +2 -2
  57. package/src/types/theme.ts +2 -0
  58. package/src/types.ts +109 -2
  59. package/src/ui.approval-plugin.test.ts +204 -0
  60. package/src/ui.scroll.test.ts +383 -0
  61. package/src/ui.ts +539 -56
  62. package/src/utils/auto-follow.test.ts +91 -0
  63. package/src/utils/auto-follow.ts +68 -0
  64. package/src/utils/theme.test.ts +6 -2
  65. package/src/utils/theme.ts +0 -8
  66. package/src/utils/tokens.ts +6 -1
  67. package/src/webmcp-bridge.test.ts +66 -0
  68. package/src/webmcp-bridge.ts +49 -0
package/src/ui.ts CHANGED
@@ -3,6 +3,7 @@ import { resolveSanitizer } from "./utils/sanitize";
3
3
  import { AgentWidgetSession, AgentWidgetSessionStatus } from "./session";
4
4
  import {
5
5
  AgentWidgetConfig,
6
+ AgentWidgetApprovalDecisionOptions,
6
7
  AgentWidgetMessage,
7
8
  AgentWidgetEvent,
8
9
  AgentWidgetStorageAdapter,
@@ -40,8 +41,11 @@ import {
40
41
  } from "./utils/composer-history";
41
42
  import { computeMessageFingerprint, createMessageCache, getCachedWrapper, setCachedWrapper, pruneCache } from "./utils/message-fingerprint";
42
43
  import {
44
+ computeAnchorScrollState,
45
+ computeShrunkSpacerHeight,
43
46
  createFollowStateController,
44
47
  getScrollBottomOffset,
48
+ hasSelectionWithin,
45
49
  isElementNearBottom,
46
50
  resolveFollowStateFromScroll,
47
51
  resolveFollowStateFromWheel
@@ -85,7 +89,7 @@ import {
85
89
  setCurrentAnswer,
86
90
  } from "./components/ask-user-question-bubble";
87
91
  import { formatElapsedMs } from "./utils/formatting";
88
- import { createApprovalBubble } from "./components/approval-bubble";
92
+ import { approvalDetailsExpansionState, createApprovalBubble, updateApprovalDetailsUI } from "./components/approval-bubble";
89
93
  import { createSuggestions } from "./components/suggestions";
90
94
  import { EventStreamBuffer } from "./utils/event-stream-buffer";
91
95
  import { EventStreamStore } from "./utils/event-stream-store";
@@ -375,8 +379,14 @@ type Controller = {
375
379
  * Programmatically resolve a pending approval.
376
380
  * @param approvalId - The approval ID to resolve
377
381
  * @param decision - "approved" or "denied"
382
+ * @param options - Optional decision context (e.g. `{ remember: true }`),
383
+ * forwarded to `config.approval.onDecision`.
378
384
  */
379
- resolveApproval: (approvalId: string, decision: 'approved' | 'denied') => Promise<void>;
385
+ resolveApproval: (
386
+ approvalId: string,
387
+ decision: 'approved' | 'denied',
388
+ options?: AgentWidgetApprovalDecisionOptions
389
+ ) => Promise<void>;
380
390
  };
381
391
 
382
392
  const buildPostprocessor = (
@@ -665,6 +675,7 @@ export const createAgentExperience = (
665
675
  let showToolCalls = config.features?.showToolCalls ?? true;
666
676
  let showEventStreamToggle = config.features?.showEventStreamToggle ?? false;
667
677
  let scrollToBottomFeature = config.features?.scrollToBottom ?? {};
678
+ let scrollBehaviorFeature = config.features?.scrollBehavior ?? {};
668
679
  const persistKeyPrefix = (typeof config.persistState === 'object' ? config.persistState?.keyPrefix : undefined) ?? "persona-";
669
680
  const eventStreamDbName = `${persistKeyPrefix}event-stream`;
670
681
  let eventStreamStore = showEventStreamToggle ? new EventStreamStore(eventStreamDbName) : null;
@@ -785,6 +796,8 @@ export const createAgentExperience = (
785
796
  const getScrollToBottomLabel = () => scrollToBottomFeature.label ?? "";
786
797
  const getScrollToBottomIconName = () => scrollToBottomFeature.iconName ?? "arrow-down";
787
798
  const isScrollToBottomEnabled = () => scrollToBottomFeature.enabled !== false;
799
+ const getScrollMode = () => scrollBehaviorFeature.mode ?? "follow";
800
+ const getAnchorTopOffset = () => scrollBehaviorFeature.anchorTopOffset ?? 16;
788
801
  const scrollToBottomButton = createElement(
789
802
  "button",
790
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"
@@ -794,9 +807,28 @@ export const createAgentExperience = (
794
807
  scrollToBottomButton.setAttribute("data-persona-scroll-to-bottom", "true");
795
808
  const scrollToBottomIcon = createElement("span", "persona-flex persona-items-center");
796
809
  const scrollToBottomLabel = createElement("span", "");
797
- 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);
798
818
  container.appendChild(scrollToBottomButton);
799
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
+
800
832
  const updateScrollToBottomButtonOffset = () => {
801
833
  const footerHidden = footer.style.display === "none";
802
834
  const footerHeight = footerHidden ? 0 : footer.offsetHeight;
@@ -1218,7 +1250,7 @@ export const createAgentExperience = (
1218
1250
  if (!headerButton) return;
1219
1251
 
1220
1252
  // Find the parent bubble element
1221
- const bubble = headerButton.closest('.persona-reasoning-bubble, .persona-tool-bubble') as HTMLElement;
1253
+ const bubble = headerButton.closest('.persona-reasoning-bubble, .persona-tool-bubble, .persona-approval-bubble') as HTMLElement;
1222
1254
  if (!bubble) return;
1223
1255
 
1224
1256
  // Get message ID from bubble
@@ -1242,6 +1274,12 @@ export const createAgentExperience = (
1242
1274
  toolExpansionState.add(messageId);
1243
1275
  }
1244
1276
  updateToolBubbleUI(messageId, bubble, config);
1277
+ } else if (bubbleType === 'approval') {
1278
+ const approvalConfig = config.approval !== false ? config.approval : undefined;
1279
+ const defaultExpanded = (approvalConfig?.detailsDisplay ?? 'collapsed') === 'expanded';
1280
+ const expanded = approvalDetailsExpansionState.get(messageId) ?? defaultExpanded;
1281
+ approvalDetailsExpansionState.set(messageId, !expanded);
1282
+ updateApprovalDetailsUI(messageId, bubble, config);
1245
1283
  }
1246
1284
  // Invalidate cached wrapper so next render rebuilds with current expansion state
1247
1285
  messageCache.delete(messageId);
@@ -2599,12 +2637,33 @@ export const createAgentExperience = (
2599
2637
  // expensive rebuild on fingerprint change so user state inside the rendered
2600
2638
  // component (e.g. partially-filled form inputs) is not wiped on every pass.
2601
2639
  const lastComponentDirectiveFingerprint = new Map<string, string>();
2640
+ // Same idea for plugin-rendered approval bubbles (`renderApproval`). The
2641
+ // custom element is injected into the live DOM post-morph so its event
2642
+ // listeners (Approve/Deny, an expandable parameters accordion, etc.) survive;
2643
+ // this map gates the rebuild on fingerprint change so interactive state (e.g.
2644
+ // a collapsed accordion) is not reset on every pass while the approval is
2645
+ // pending.
2646
+ const lastApprovalBubbleFingerprint = new Map<string, string>();
2602
2647
  let configVersion = 0;
2603
2648
  const autoFollow = createFollowStateController();
2604
2649
  let lastScrollTop = 0;
2605
2650
  let scrollRAF: number | null = null;
2606
2651
  let isAutoScrolling = false;
2607
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;
2608
2667
 
2609
2668
  // Scroll events caused by layout, scroll anchoring, and smooth-scroll
2610
2669
  // easing can easily move by a couple pixels. Keep manual wheel intent
@@ -2732,6 +2791,39 @@ export const createAgentExperience = (
2732
2791
  cancelSmoothScroll();
2733
2792
  };
2734
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
+
2735
2827
  const syncScrollToBottomButton = () => {
2736
2828
  if (!isScrollToBottomEnabled() || eventStreamVisible) {
2737
2829
  if (scrollToBottomButton.parentNode) {
@@ -2745,7 +2837,11 @@ export const createAgentExperience = (
2745
2837
  }
2746
2838
  updateScrollToBottomButtonOffset();
2747
2839
  const hasOverflow = getScrollBottomOffset(body) > 0;
2748
- scrollToBottomButton.style.display = (autoFollow.isFollowing() || !hasOverflow) ? "none" : "";
2840
+ const show = hasOverflow && isAwayFromLatest();
2841
+ if (!show) {
2842
+ resetNewMessagesCount();
2843
+ }
2844
+ scrollToBottomButton.style.display = show ? "" : "none";
2749
2845
  };
2750
2846
 
2751
2847
  const pauseAutoScroll = () => {
@@ -2756,10 +2852,15 @@ export const createAgentExperience = (
2756
2852
 
2757
2853
  const resumeAutoScroll = () => {
2758
2854
  autoFollow.resume();
2855
+ resetNewMessagesCount();
2759
2856
  syncScrollToBottomButton();
2760
2857
  };
2761
2858
 
2762
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
+
2763
2864
  if (!autoFollow.isFollowing()) return;
2764
2865
 
2765
2866
  if (!force && !isStreaming) return;
@@ -2788,31 +2889,21 @@ export const createAgentExperience = (
2788
2889
  });
2789
2890
  };
2790
2891
 
2791
- // Custom smooth scroll animation with easing
2792
- 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
+ ) => {
2793
2903
  const start = element.scrollTop;
2794
- // Recalculate target dynamically to handle layout changes
2795
- let target = getScrollBottomOffset(element);
2904
+ let target = resolveTarget();
2796
2905
  let distance = target - start;
2797
2906
 
2798
- // If already at bottom or very close, skip animation to prevent glitch
2799
- if (Math.abs(distance) < 1) {
2800
- lastScrollTop = element.scrollTop;
2801
- return;
2802
- }
2803
-
2804
- // If the transcript has fallen noticeably behind, catch up immediately
2805
- // instead of easing over multiple frames. This keeps fast streaming /
2806
- // bursty tool and reasoning updates pinned to the bottom.
2807
- if (Math.abs(distance) >= AUTO_SCROLL_SNAP_THRESHOLD) {
2808
- cancelSmoothScroll();
2809
- isAutoScrolling = true;
2810
- element.scrollTop = target;
2811
- lastScrollTop = element.scrollTop;
2812
- isAutoScrolling = false;
2813
- return;
2814
- }
2815
-
2816
2907
  // Cancel any ongoing smooth scroll animation
2817
2908
  cancelSmoothScroll();
2818
2909
 
@@ -2825,13 +2916,13 @@ export const createAgentExperience = (
2825
2916
  };
2826
2917
 
2827
2918
  const animate = (currentTime: number) => {
2828
- if (!autoFollow.isFollowing()) {
2919
+ if (!shouldContinue()) {
2829
2920
  cancelSmoothScroll();
2830
2921
  return;
2831
2922
  }
2832
2923
 
2833
2924
  // Recalculate target each frame in case scrollHeight changed
2834
- const currentTarget = getScrollBottomOffset(element);
2925
+ const currentTarget = resolveTarget();
2835
2926
  if (currentTarget !== target) {
2836
2927
  target = currentTarget;
2837
2928
  distance = target - start;
@@ -2840,7 +2931,7 @@ export const createAgentExperience = (
2840
2931
  const elapsed = currentTime - startTime;
2841
2932
  const progress = Math.min(elapsed / duration, 1);
2842
2933
  const eased = easeOutCubic(progress);
2843
-
2934
+
2844
2935
  const currentScroll = start + distance * eased;
2845
2936
  element.scrollTop = currentScroll;
2846
2937
  lastScrollTop = element.scrollTop;
@@ -2859,6 +2950,157 @@ export const createAgentExperience = (
2859
2950
  smoothScrollRAF = requestAnimationFrame(animate);
2860
2951
  };
2861
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
+
2862
3104
  const trackMessages = (messages: AgentWidgetMessage[]) => {
2863
3105
  const nextState = new Map<
2864
3106
  string,
@@ -2874,6 +3116,19 @@ export const createAgentExperience = (
2874
3116
 
2875
3117
  if (!previous && message.role === "assistant") {
2876
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
+ }
2877
3132
  }
2878
3133
 
2879
3134
  if (
@@ -2977,10 +3232,26 @@ export const createAgentExperience = (
2977
3232
  const componentDirectiveHydrate: ComponentDirectiveHydrate[] = [];
2978
3233
  const componentStreamingEnabled = config.enableComponentStreaming !== false;
2979
3234
 
3235
+ // Plugin-rendered approval bubbles use the same stub-and-hydrate pattern:
3236
+ // `renderApproval` may attach listeners (the built-in bubble resolves via
3237
+ // delegation on `messagesWrapper`, but a custom element owns its own
3238
+ // interactivity), and idiomorph imports nodes via `document.importNode`,
3239
+ // which strips them. So we build the live element, append a stub during
3240
+ // morph, and inject the live element afterward.
3241
+ const hasApprovalPlugin = plugins.some((p) => p.renderApproval) && config.approval !== false;
3242
+ type ApprovalPluginHydrate = {
3243
+ messageId: string;
3244
+ fingerprint: string;
3245
+ bubble: HTMLElement | null;
3246
+ };
3247
+ const approvalPluginHydrate: ApprovalPluginHydrate[] = [];
3248
+
2980
3249
  messages.forEach((message) => {
2981
3250
  activeMessageIds.add(message.id);
2982
3251
 
2983
3252
  const askWithPlugin = hasAskPlugin && isAskUserQuestionMessage(message);
3253
+ const approvalWithPlugin =
3254
+ hasApprovalPlugin && message.variant === "approval" && !!message.approval;
2984
3255
  const hasDirectiveBubble =
2985
3256
  !askWithPlugin &&
2986
3257
  message.role === "assistant" &&
@@ -2988,6 +3259,14 @@ export const createAgentExperience = (
2988
3259
  componentStreamingEnabled &&
2989
3260
  hasComponentDirective(message);
2990
3261
 
3262
+ // If a message stops being an approval-plugin bubble, strip
3263
+ // `data-preserve-runtime` so the next morph can replace the live wrapper.
3264
+ if (!approvalWithPlugin && lastApprovalBubbleFingerprint.has(message.id)) {
3265
+ const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
3266
+ existing?.removeAttribute("data-preserve-runtime");
3267
+ lastApprovalBubbleFingerprint.delete(message.id);
3268
+ }
3269
+
2991
3270
  // If a message previously rendered as a directive bubble but no longer
2992
3271
  // does (e.g. content was rewritten), strip `data-preserve-runtime` from
2993
3272
  // the live wrapper so the next morph can replace it.
@@ -3009,7 +3288,7 @@ export const createAgentExperience = (
3009
3288
  }`
3010
3289
  : "";
3011
3290
  const fingerprint = computeMessageFingerprint(message, configVersion) + askMeta;
3012
- const cachedWrapper = (askWithPlugin || hasDirectiveBubble)
3291
+ const cachedWrapper = (askWithPlugin || approvalWithPlugin || hasDirectiveBubble)
3013
3292
  ? null
3014
3293
  : getCachedWrapper(messageCache, message.id, fingerprint);
3015
3294
  if (cachedWrapper) {
@@ -3043,9 +3322,9 @@ export const createAgentExperience = (
3043
3322
  if (message.variant === "tool" && p.renderToolCall) {
3044
3323
  return true;
3045
3324
  }
3046
- if (message.variant === "approval" && p.renderApproval) {
3047
- return true;
3048
- }
3325
+ // Approval plugins are handled via the stub-and-hydrate path below
3326
+ // (see `approvalWithPlugin`), not this inline morph path, so their
3327
+ // listeners survive — so they are intentionally excluded here.
3049
3328
  if (!message.variant && p.renderMessage) {
3050
3329
  return true;
3051
3330
  }
@@ -3159,6 +3438,74 @@ export const createAgentExperience = (
3159
3438
  }
3160
3439
  return;
3161
3440
  }
3441
+ } else if (approvalWithPlugin) {
3442
+ // Plugin-rendered approval bubble. Build the live element with its
3443
+ // listeners, append a stub for the morph pass, and hydrate the live
3444
+ // element into the morphed wrapper afterward (same trick as
3445
+ // `renderAskUserQuestion` / component directives) so Approve/Deny and
3446
+ // any accordion listeners survive idiomorph's `importNode`. Gate the
3447
+ // rebuild on fingerprint so interactive state (e.g. a collapsed
3448
+ // accordion) is preserved while the approval stays pending.
3449
+ const approvalPlugin = plugins.find((p) => typeof p.renderApproval === "function");
3450
+ const lastFp = lastApprovalBubbleFingerprint.get(message.id);
3451
+ const needsRebuild = lastFp !== fingerprint;
3452
+ let liveBubble: HTMLElement | null = null;
3453
+
3454
+ if (needsRebuild && approvalPlugin?.renderApproval) {
3455
+ // Re-find the live message at decision time so we resolve against
3456
+ // current state, and route WebMCP gate approvals to the local
3457
+ // resolver — mirroring the built-in delegated handler.
3458
+ const approvalMessageId = message.id;
3459
+ const resolveDecision = (
3460
+ decision: "approved" | "denied",
3461
+ options?: AgentWidgetApprovalDecisionOptions
3462
+ ): void => {
3463
+ const live = sessionRef.current
3464
+ ?.getMessages()
3465
+ .find((m) => m.id === approvalMessageId);
3466
+ if (!live?.approval) return;
3467
+ if (live.approval.toolType === "webmcp") {
3468
+ sessionRef.current?.resolveWebMcpApproval(live.id, decision);
3469
+ } else {
3470
+ sessionRef.current?.resolveApproval(live.approval, decision, options);
3471
+ }
3472
+ };
3473
+ liveBubble = approvalPlugin.renderApproval({
3474
+ message,
3475
+ defaultRenderer: () => createApprovalBubble(message, config),
3476
+ config,
3477
+ approve: (options) => resolveDecision("approved", options),
3478
+ deny: (options) => resolveDecision("denied", options)
3479
+ });
3480
+ }
3481
+
3482
+ if (needsRebuild && liveBubble === null) {
3483
+ // Plugin opted out for this state (e.g. a resolved approval, where the
3484
+ // demo plugin defers to the built-in approved/denied bubble). Render
3485
+ // the built-in bubble — it resolves via the delegated `messagesWrapper`
3486
+ // handler and morphs normally — and drop any preserved live wrapper so
3487
+ // morph can replace the now-stale pending bubble.
3488
+ const existing = container.querySelector<HTMLElement>(`#wrapper-${message.id}`);
3489
+ existing?.removeAttribute("data-preserve-runtime");
3490
+ lastApprovalBubbleFingerprint.delete(message.id);
3491
+ bubble = createApprovalBubble(message, config);
3492
+ } else {
3493
+ // A fresh live bubble to hydrate (needsRebuild), or fingerprint
3494
+ // unchanged so we reuse the preserved live wrapper (`bubble: null`).
3495
+ const stub = document.createElement("div");
3496
+ stub.className = "persona-flex";
3497
+ stub.id = `wrapper-${message.id}`;
3498
+ stub.setAttribute("data-wrapper-id", message.id);
3499
+ stub.setAttribute("data-approval-plugin-stub", "true");
3500
+ stub.setAttribute("data-preserve-runtime", "true");
3501
+ tempContainer.appendChild(stub);
3502
+ approvalPluginHydrate.push({
3503
+ messageId: message.id,
3504
+ fingerprint,
3505
+ bubble: liveBubble
3506
+ });
3507
+ return;
3508
+ }
3162
3509
  } else if (matchingPlugin) {
3163
3510
  if (message.variant === "reasoning" && message.reasoning && matchingPlugin.renderReasoning) {
3164
3511
  if (!showReasoning) return;
@@ -3174,13 +3521,6 @@ export const createAgentExperience = (
3174
3521
  defaultRenderer: () => createToolBubble(message, config),
3175
3522
  config
3176
3523
  });
3177
- } else if (message.variant === "approval" && message.approval && matchingPlugin.renderApproval) {
3178
- if (config.approval === false) return;
3179
- bubble = matchingPlugin.renderApproval({
3180
- message,
3181
- defaultRenderer: () => createApprovalBubble(message, config),
3182
- config
3183
- });
3184
3524
  } else if (matchingPlugin.renderMessage) {
3185
3525
  bubble = matchingPlugin.renderMessage({
3186
3526
  message,
@@ -3663,6 +4003,30 @@ export const createAgentExperience = (
3663
4003
  if (!activeMessageIds.has(id)) lastComponentDirectiveFingerprint.delete(id);
3664
4004
  }
3665
4005
  }
4006
+
4007
+ // Hydrate plugin-rendered approval bubbles into their stub wrappers,
4008
+ // mirroring the ask-question / component-directive hydration above.
4009
+ if (approvalPluginHydrate.length > 0) {
4010
+ for (const { messageId, fingerprint, bubble } of approvalPluginHydrate) {
4011
+ const wrapper = container.querySelector(`#wrapper-${messageId}`);
4012
+ if (!wrapper) continue;
4013
+ if (bubble === null) {
4014
+ // Fingerprint matched the previous pass (or the plugin opted out
4015
+ // after a prior render) — the live wrapper, kept alive by
4016
+ // `data-preserve-runtime`, still holds the listener-bearing bubble.
4017
+ continue;
4018
+ }
4019
+ wrapper.replaceChildren(bubble);
4020
+ wrapper.setAttribute("data-bubble-fp", fingerprint);
4021
+ lastApprovalBubbleFingerprint.set(messageId, fingerprint);
4022
+ }
4023
+ }
4024
+
4025
+ if (lastApprovalBubbleFingerprint.size > 0) {
4026
+ for (const id of lastApprovalBubbleFingerprint.keys()) {
4027
+ if (!activeMessageIds.has(id)) lastApprovalBubbleFingerprint.delete(id);
4028
+ }
4029
+ }
3666
4030
  };
3667
4031
 
3668
4032
  // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
@@ -4251,7 +4615,13 @@ export const createAgentExperience = (
4251
4615
 
4252
4616
  if (open) {
4253
4617
  recalcPanelHeight();
4254
- 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
+ }
4255
4625
  }
4256
4626
 
4257
4627
  // Emit widget state events
@@ -4407,6 +4777,20 @@ export const createAgentExperience = (
4407
4777
  .reverse()
4408
4778
  .find((msg) => msg.role === "user");
4409
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
+
4410
4794
  // Emit user:message event when a new user message is detected
4411
4795
  const prevLastUserMessageId = voiceState.lastUserMessageId;
4412
4796
  if (lastUserMessage && lastUserMessage.id !== prevLastUserMessageId) {
@@ -4486,6 +4870,11 @@ export const createAgentExperience = (
4486
4870
 
4487
4871
  sessionRef.current = session;
4488
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
+
4489
4878
  // Setup Runtype voice provider when configured (connects WebSocket for server-side STT)
4490
4879
  if (config.voiceRecognition?.provider?.type === 'runtype') {
4491
4880
  try {
@@ -4532,7 +4921,14 @@ export const createAgentExperience = (
4532
4921
  actionManager.syncFromMetadata();
4533
4922
  }
4534
4923
  if (state.messages?.length) {
4535
- 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
+ }
4536
4932
  }
4537
4933
  if (state.artifacts?.length) {
4538
4934
  session.hydrateArtifacts(
@@ -5305,7 +5701,11 @@ export const createAgentExperience = (
5305
5701
  suggestionsManager.render(config.suggestionChips, session, textarea, undefined, config.suggestionChipsConfig);
5306
5702
  updateCopy();
5307
5703
  setComposerDisabled(session.isStreaming());
5308
- scheduleAutoScroll(true);
5704
+ if (getScrollMode() === "follow") {
5705
+ scheduleAutoScroll(true);
5706
+ } else {
5707
+ jumpToBottomInstant();
5708
+ }
5309
5709
  maybeRestoreVoiceFromMetadata();
5310
5710
 
5311
5711
  if (autoFocusInput) {
@@ -5429,18 +5829,44 @@ export const createAgentExperience = (
5429
5829
  }
5430
5830
 
5431
5831
  lastScrollTop = body.scrollTop;
5432
- 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);
5433
5848
 
5434
5849
  const handleScroll = () => {
5435
5850
  const scrollTop = body.scrollTop;
5436
- // When content mutates (e.g. stream-animation plugins re-rendering text),
5437
- // 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.
5438
5854
  // That emits a scroll event with a negative delta that would otherwise be
5439
5855
  // misread as the user scrolling up, pausing auto-follow and flashing the
5440
- // scroll-to-bottom button. Treat those as non-user events.
5441
- const currentScrollHeight = body.scrollHeight;
5442
- const scrollHeightShrank = currentScrollHeight < lastScrollHeight;
5443
- 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
+ }
5444
5870
 
5445
5871
  const { action, nextLastScrollTop } = resolveFollowStateFromScroll({
5446
5872
  following: autoFollow.isFollowing(),
@@ -5448,7 +5874,7 @@ export const createAgentExperience = (
5448
5874
  lastScrollTop,
5449
5875
  nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
5450
5876
  userScrollThreshold: USER_SCROLL_THRESHOLD,
5451
- isAutoScrolling: isAutoScrolling || hasPendingAutoScroll || scrollHeightShrank,
5877
+ isAutoScrolling: isAutoScrolling || hasPendingAutoScroll || bottomOffsetShrank,
5452
5878
  pauseOnUpwardScroll: true,
5453
5879
  pauseWhenAwayFromBottom: false,
5454
5880
  resumeRequiresDownwardScroll: true
@@ -5456,7 +5882,12 @@ export const createAgentExperience = (
5456
5882
  lastScrollTop = nextLastScrollTop;
5457
5883
 
5458
5884
  if (action === "resume") {
5459
- 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
+ }
5460
5891
  return;
5461
5892
  }
5462
5893
 
@@ -5467,7 +5898,41 @@ export const createAgentExperience = (
5467
5898
 
5468
5899
  body.addEventListener("scroll", handleScroll, { passive: true });
5469
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
+ });
5470
5934
  const handleWheel = (event: WheelEvent) => {
5935
+ if (getScrollMode() !== "follow") return;
5471
5936
  const action = resolveFollowStateFromWheel({
5472
5937
  following: autoFollow.isFollowing(),
5473
5938
  deltaY: event.deltaY,
@@ -5477,21 +5942,27 @@ export const createAgentExperience = (
5477
5942
 
5478
5943
  if (action === "pause") {
5479
5944
  pauseAutoScroll();
5480
- } else if (action === "resume") {
5945
+ } else if (action === "resume" && !hasActiveTranscriptSelection()) {
5481
5946
  resumeAutoScroll();
5482
5947
  }
5483
5948
  };
5484
5949
  body.addEventListener("wheel", handleWheel, { passive: true });
5485
5950
  destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
5486
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();
5487
5956
  body.scrollTop = body.scrollHeight;
5488
5957
  lastScrollTop = body.scrollTop;
5489
5958
  resumeAutoScroll();
5490
5959
  scheduleAutoScroll(true);
5960
+ syncScrollToBottomButton();
5491
5961
  });
5492
5962
  destroyCallbacks.push(() => scrollToBottomButton.remove());
5493
5963
  destroyCallbacks.push(() => {
5494
5964
  cancelAutoScroll();
5965
+ resetAnchorState();
5495
5966
  });
5496
5967
 
5497
5968
  const refreshCloseButton = () => {
@@ -5736,6 +6207,14 @@ export const createAgentExperience = (
5736
6207
  showReasoning = config.features?.showReasoning ?? true;
5737
6208
  showToolCalls = config.features?.showToolCalls ?? true;
5738
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
+ }
5739
6218
  renderScrollToBottomButton();
5740
6219
  syncScrollToBottomButton();
5741
6220
  const prevShowEventStreamToggle = showEventStreamToggle;
@@ -7258,7 +7737,11 @@ export const createAgentExperience = (
7258
7737
  textarea.focus();
7259
7738
  return true;
7260
7739
  },
7261
- async resolveApproval(approvalId: string, decision: 'approved' | 'denied'): Promise<void> {
7740
+ async resolveApproval(
7741
+ approvalId: string,
7742
+ decision: 'approved' | 'denied',
7743
+ options?: AgentWidgetApprovalDecisionOptions
7744
+ ): Promise<void> {
7262
7745
  const messages = session.getMessages();
7263
7746
  const approvalMessage = messages.find(
7264
7747
  m => m.variant === "approval" && m.approval?.id === approvalId
@@ -7274,7 +7757,7 @@ export const createAgentExperience = (
7274
7757
  session.resolveWebMcpApproval(approvalMessage.id, decision);
7275
7758
  return;
7276
7759
  }
7277
- return session.resolveApproval(approvalMessage.approval, decision);
7760
+ return session.resolveApproval(approvalMessage.approval, decision, options);
7278
7761
  },
7279
7762
  getMessages() {
7280
7763
  return session.getMessages();