@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.
- package/README.md +15 -2547
- 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 +6 -6
- package/dist/codegen.js +4 -4
- package/dist/index.cjs +50 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +164 -7
- package/dist/index.d.ts +164 -7
- package/dist/index.global.js +60 -60
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -49
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -0
- package/dist/plugin-kit.d.cts +98 -0
- package/dist/plugin-kit.d.ts +98 -0
- package/dist/plugin-kit.js +1 -0
- package/dist/smart-dom-reader.d.cts +157 -4
- package/dist/smart-dom-reader.d.ts +157 -4
- package/dist/theme-editor.cjs +40 -40
- package/dist/theme-editor.d.cts +161 -6
- package/dist/theme-editor.d.ts +161 -6
- package/dist/theme-editor.js +40 -40
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +2 -0
- package/dist/theme-reference.d.ts +2 -0
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +19 -0
- package/package.json +8 -2
- package/src/client.ts +6 -0
- package/src/components/approval-bubble.test.ts +320 -0
- package/src/components/approval-bubble.ts +190 -20
- package/src/components/launcher.ts +6 -3
- package/src/components/panel.ts +7 -1
- package/src/components/tool-bubble.test.ts +39 -0
- package/src/components/tool-bubble.ts +4 -0
- package/src/defaults.ts +14 -0
- package/src/generated/runtype-openapi-contract.ts +4 -0
- package/src/index-core.ts +1 -0
- package/src/plugin-kit.test.ts +230 -0
- package/src/plugin-kit.ts +294 -0
- package/src/plugins/types.ts +49 -2
- package/src/session.test.ts +161 -0
- package/src/session.ts +41 -3
- package/src/styles/widget.css +19 -0
- package/src/theme-editor/preview-utils.test.ts +10 -0
- package/src/theme-editor/preview-utils.ts +29 -1
- package/src/theme-editor/sections.test.ts +17 -0
- package/src/theme-editor/sections.ts +31 -0
- package/src/theme-reference.ts +2 -2
- package/src/types/theme.ts +2 -0
- package/src/types.ts +109 -2
- package/src/ui.approval-plugin.test.ts +204 -0
- package/src/ui.scroll.test.ts +383 -0
- package/src/ui.ts +539 -56
- package/src/utils/auto-follow.test.ts +91 -0
- package/src/utils/auto-follow.ts +68 -0
- package/src/utils/theme.test.ts +6 -2
- package/src/utils/theme.ts +0 -8
- package/src/utils/tokens.ts +6 -1
- package/src/webmcp-bridge.test.ts +66 -0
- 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: (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2792
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 =
|
|
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
|
-
|
|
3047
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
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 ||
|
|
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
|
-
|
|
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(
|
|
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();
|