@runtypelabs/persona 3.30.0 → 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.
- package/README.md +15 -2602
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-B_Qazlm4.d.cts → types-quh7NmYD.d.cts} +9 -0
- package/dist/animations/{types-B_Qazlm4.d.ts → types-quh7NmYD.d.ts} +9 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +46 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +52 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.global.js +55 -55
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +45 -45
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +50 -0
- package/dist/smart-dom-reader.d.ts +50 -0
- package/dist/theme-editor.cjs +39 -39
- package/dist/theme-editor.d.cts +50 -0
- package/dist/theme-editor.d.ts +50 -0
- package/dist/theme-editor.js +39 -39
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +19 -0
- package/package.json +1 -1
- package/src/client.ts +6 -0
- package/src/components/approval-bubble.test.ts +52 -0
- package/src/components/approval-bubble.ts +20 -0
- package/src/components/panel.ts +7 -1
- package/src/defaults.ts +14 -0
- package/src/generated/runtype-openapi-contract.ts +4 -0
- package/src/session.test.ts +62 -0
- package/src/session.ts +27 -0
- package/src/styles/widget.css +19 -0
- package/src/theme-reference.ts +1 -1
- package/src/types.ts +50 -0
- package/src/ui.scroll.test.ts +383 -0
- package/src/ui.ts +390 -40
- package/src/utils/auto-follow.test.ts +91 -0
- package/src/utils/auto-follow.ts +68 -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2812
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
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 ||
|
|
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
|
-
|
|
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;
|