@runtypelabs/persona 3.6.0 → 3.8.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 (50) hide show
  1. package/dist/index.cjs +40 -40
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +73 -4
  4. package/dist/index.d.ts +73 -4
  5. package/dist/index.global.js +69 -69
  6. package/dist/index.global.js.map +1 -1
  7. package/dist/index.js +40 -40
  8. package/dist/index.js.map +1 -1
  9. package/dist/theme-editor.cjs +704 -243
  10. package/dist/theme-editor.d.cts +75 -5
  11. package/dist/theme-editor.d.ts +75 -5
  12. package/dist/theme-editor.js +703 -243
  13. package/dist/theme-reference.cjs +1 -1
  14. package/dist/theme-reference.d.cts +53 -0
  15. package/dist/theme-reference.d.ts +53 -0
  16. package/dist/theme-reference.js +1 -1
  17. package/dist/widget.css +44 -0
  18. package/package.json +1 -1
  19. package/src/components/artifact-card.ts +1 -1
  20. package/src/components/demo-carousel.ts +1 -1
  21. package/src/components/event-stream-view.test.ts +142 -0
  22. package/src/components/event-stream-view.ts +67 -28
  23. package/src/components/header-builder.ts +3 -0
  24. package/src/components/launcher.ts +7 -2
  25. package/src/components/panel.ts +3 -1
  26. package/src/defaults.ts +15 -0
  27. package/src/runtime/host-layout.test.ts +1 -1
  28. package/src/runtime/host-layout.ts +2 -1
  29. package/src/scroll-to-bottom-defaults.test.ts +13 -0
  30. package/src/styles/widget.css +44 -0
  31. package/src/theme-editor/index.ts +1 -0
  32. package/src/theme-editor/role-mappings.ts +12 -0
  33. package/src/theme-editor/sections.test.ts +43 -0
  34. package/src/theme-editor/sections.ts +42 -0
  35. package/src/theme-reference.ts +8 -0
  36. package/src/types/theme.ts +45 -0
  37. package/src/types.ts +31 -4
  38. package/src/ui.overlay-z-index.test.ts +34 -2
  39. package/src/ui.scroll.test.ts +554 -0
  40. package/src/ui.ts +264 -90
  41. package/src/utils/auto-follow.test.ts +110 -0
  42. package/src/utils/auto-follow.ts +112 -0
  43. package/src/utils/constants.ts +13 -0
  44. package/src/utils/dropdown.ts +2 -1
  45. package/src/utils/overlay-host-stacking.test.ts +61 -0
  46. package/src/utils/overlay-host-stacking.ts +38 -0
  47. package/src/utils/scroll-lock.test.ts +64 -0
  48. package/src/utils/scroll-lock.ts +62 -0
  49. package/src/utils/theme.test.ts +34 -0
  50. package/src/utils/tokens.ts +112 -0
package/src/ui.ts CHANGED
@@ -33,7 +33,16 @@ import { renderLucideIcon } from "./utils/icons";
33
33
  import { createElement, createElementInDocument } from "./utils/dom";
34
34
  import { morphMessages } from "./utils/morph";
35
35
  import { computeMessageFingerprint, createMessageCache, getCachedWrapper, setCachedWrapper, pruneCache } from "./utils/message-fingerprint";
36
- import { statusCopy } from "./utils/constants";
36
+ import {
37
+ createFollowStateController,
38
+ getScrollBottomOffset,
39
+ isElementNearBottom,
40
+ resolveFollowStateFromScroll,
41
+ resolveFollowStateFromWheel
42
+ } from "./utils/auto-follow";
43
+ import { statusCopy, DEFAULT_OVERLAY_Z_INDEX, PORTALED_OVERLAY_Z_INDEX } from "./utils/constants";
44
+ import { syncOverlayHostStacking } from "./utils/overlay-host-stacking";
45
+ import { acquireScrollLock } from "./utils/scroll-lock";
37
46
  import { isDockedMountMode, resolveDockConfig } from "./utils/dock";
38
47
  import { createLauncherButton } from "./components/launcher";
39
48
  import { createWrapper, buildPanel, buildHeader, buildComposer, attachHeaderToContainer } from "./components/panel";
@@ -545,6 +554,7 @@ export const createAgentExperience = (
545
554
  let showReasoning = config.features?.showReasoning ?? true;
546
555
  let showToolCalls = config.features?.showToolCalls ?? true;
547
556
  let showEventStreamToggle = config.features?.showEventStreamToggle ?? false;
557
+ let scrollToBottomFeature = config.features?.scrollToBottom ?? {};
548
558
  const persistKeyPrefix = (typeof config.persistState === 'object' ? config.persistState?.keyPrefix : undefined) ?? "persona-";
549
559
  const eventStreamDbName = `${persistKeyPrefix}event-stream`;
550
560
  let eventStreamStore = showEventStreamToggle ? new EventStreamStore(eventStreamDbName) : null;
@@ -655,6 +665,49 @@ export const createAgentExperience = (
655
665
  let attachmentButtonWrapper: HTMLElement | null = panelElements.attachmentButtonWrapper;
656
666
  let attachmentInput: HTMLInputElement | null = panelElements.attachmentInput;
657
667
  let attachmentPreviewsContainer: HTMLElement | null = panelElements.attachmentPreviewsContainer;
668
+ container.classList.add("persona-relative");
669
+ body.classList.add("persona-relative");
670
+ const SCROLL_TO_BOTTOM_EDGE_OFFSET = 12;
671
+
672
+ const getScrollToBottomLabel = () => scrollToBottomFeature.label ?? "";
673
+ const getScrollToBottomIconName = () => scrollToBottomFeature.iconName ?? "arrow-down";
674
+ const isScrollToBottomEnabled = () => scrollToBottomFeature.enabled !== false;
675
+ const scrollToBottomButton = createElement(
676
+ "button",
677
+ "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"
678
+ ) as HTMLButtonElement;
679
+ scrollToBottomButton.type = "button";
680
+ scrollToBottomButton.style.display = "none";
681
+ scrollToBottomButton.setAttribute("data-persona-scroll-to-bottom", "true");
682
+ const scrollToBottomIcon = createElement("span", "persona-flex persona-items-center");
683
+ const scrollToBottomLabel = createElement("span", "");
684
+ scrollToBottomButton.append(scrollToBottomIcon, scrollToBottomLabel);
685
+ container.appendChild(scrollToBottomButton);
686
+
687
+ const updateScrollToBottomButtonOffset = () => {
688
+ const footerHidden = footer.style.display === "none";
689
+ const footerHeight = footerHidden ? 0 : footer.offsetHeight;
690
+ scrollToBottomButton.style.bottom = `${footerHeight + SCROLL_TO_BOTTOM_EDGE_OFFSET}px`;
691
+ };
692
+ updateScrollToBottomButtonOffset();
693
+
694
+ const renderScrollToBottomButton = () => {
695
+ const hasLabel = Boolean(getScrollToBottomLabel());
696
+ scrollToBottomButton.setAttribute("aria-label", getScrollToBottomLabel() || "Jump to latest");
697
+ scrollToBottomButton.title = getScrollToBottomLabel();
698
+ scrollToBottomButton.setAttribute("data-persona-scroll-to-bottom-has-label", hasLabel ? "true" : "false");
699
+ scrollToBottomIcon.innerHTML = "";
700
+ const icon = renderLucideIcon(getScrollToBottomIconName(), "14px", "currentColor", 2);
701
+ if (icon) {
702
+ scrollToBottomIcon.appendChild(icon);
703
+ scrollToBottomIcon.style.display = "";
704
+ } else {
705
+ scrollToBottomIcon.style.display = "none";
706
+ }
707
+ scrollToBottomLabel.textContent = getScrollToBottomLabel();
708
+ scrollToBottomLabel.style.display = hasLabel ? "" : "none";
709
+ };
710
+ renderScrollToBottomButton();
658
711
 
659
712
  // Initialized after composer plugins rebind footer DOM (see `bindComposerRefsFromFooter`)
660
713
  let attachmentManager: AttachmentManager | null = null;
@@ -719,6 +772,7 @@ export const createAgentExperience = (
719
772
  };
720
773
  eventStreamLastUpdate = 0;
721
774
  eventStreamRAF = requestAnimationFrame(rafLoop);
775
+ syncScrollToBottomButton();
722
776
  eventBus.emit("eventStream:opened", { timestamp: Date.now() });
723
777
  };
724
778
 
@@ -739,6 +793,7 @@ export const createAgentExperience = (
739
793
  cancelAnimationFrame(eventStreamRAF);
740
794
  eventStreamRAF = null;
741
795
  }
796
+ syncScrollToBottomButton();
742
797
  eventBus.emit("eventStream:closed", { timestamp: Date.now() });
743
798
  };
744
799
 
@@ -1493,7 +1548,7 @@ export const createAgentExperience = (
1493
1548
  // Determine panel styling based on mode, with theme overrides
1494
1549
  const position = config.launcher?.position ?? 'bottom-left';
1495
1550
  const isLeftSidebar = position === 'bottom-left' || position === 'top-left';
1496
- const overlayZIndex = config.launcher?.zIndex ?? 9999;
1551
+ const overlayZIndex = config.launcher?.zIndex ?? DEFAULT_OVERLAY_Z_INDEX;
1497
1552
 
1498
1553
  // Default values based on mode
1499
1554
  let defaultPanelBorder = (sidebarMode || shouldGoFullscreen) ? 'none' : '1px solid var(--persona-border)';
@@ -1766,9 +1821,8 @@ export const createAgentExperience = (
1766
1821
  if (!isInlineEmbed && !dockedMode) {
1767
1822
  const maxHeightStyles = 'max-height: -moz-available !important; max-height: stretch !important;';
1768
1823
  const paddingStyles = sidebarMode ? '' : 'padding-top: 1.25em !important;';
1769
- // Override z-index only when explicitly configured; otherwise the persona-z-50 class applies
1770
- const zIndexStyles = !sidebarMode && config.launcher?.zIndex != null
1771
- ? `z-index: ${config.launcher.zIndex} !important;`
1824
+ const zIndexStyles = !sidebarMode
1825
+ ? `z-index: ${config.launcher?.zIndex ?? DEFAULT_OVERLAY_Z_INDEX} !important;`
1772
1826
  : '';
1773
1827
  wrapper.style.cssText += maxHeightStyles + paddingStyles + zIndexStyles;
1774
1828
  }
@@ -1781,6 +1835,16 @@ export const createAgentExperience = (
1781
1835
 
1782
1836
  const destroyCallbacks: Array<() => void> = [];
1783
1837
 
1838
+ let teardownHostStacking: (() => void) | null = null;
1839
+ let releaseScrollLock: (() => void) | null = null;
1840
+
1841
+ destroyCallbacks.push(() => {
1842
+ teardownHostStacking?.();
1843
+ teardownHostStacking = null;
1844
+ releaseScrollLock?.();
1845
+ releaseScrollLock = null;
1846
+ });
1847
+
1784
1848
  if (artifactPanelResizeObs) {
1785
1849
  destroyCallbacks.push(() => {
1786
1850
  artifactPanelResizeObs?.disconnect();
@@ -1845,18 +1909,13 @@ export const createAgentExperience = (
1845
1909
  let isStreaming = false;
1846
1910
  const messageCache = createMessageCache();
1847
1911
  let configVersion = 0;
1848
- let shouldAutoScroll = true;
1912
+ const autoFollow = createFollowStateController();
1849
1913
  let lastScrollTop = 0;
1850
- let lastAutoScrollTime = 0;
1851
1914
  let scrollRAF: number | null = null;
1852
- let isAutoScrollBlocked = false;
1853
- let blockUntilTime = 0;
1854
1915
  let isAutoScrolling = false;
1855
1916
 
1856
- const AUTO_SCROLL_THROTTLE = 125;
1857
- const AUTO_SCROLL_BLOCK_TIME = 2000;
1858
- const USER_SCROLL_THRESHOLD = 5;
1859
- const BOTTOM_THRESHOLD = 50;
1917
+ const USER_SCROLL_THRESHOLD = 1;
1918
+ const BOTTOM_THRESHOLD = 8;
1860
1919
  const messageState = new Map<
1861
1920
  string,
1862
1921
  { streaming?: boolean; role: AgentWidgetMessage["role"] }
@@ -1946,75 +2005,92 @@ export const createAgentExperience = (
1946
2005
  }
1947
2006
  }
1948
2007
 
1949
- const scheduleAutoScroll = (force = false) => {
1950
- if (!shouldAutoScroll) return;
2008
+ // Track ongoing smooth scroll animation
2009
+ let smoothScrollRAF: number | null = null;
1951
2010
 
1952
- const now = Date.now();
2011
+ // Get the scrollable container using its unique ID
2012
+ const getScrollableContainer = (): HTMLElement => {
2013
+ // Use the unique ID for reliable selection
2014
+ const scrollable = wrapper.querySelector('#persona-scroll-container') as HTMLElement;
2015
+ // Fallback to body if ID not found (shouldn't happen, but safe fallback)
2016
+ return scrollable || body;
2017
+ };
1953
2018
 
1954
- if (isAutoScrollBlocked && now < blockUntilTime) {
1955
- if (!force) return;
2019
+ const cancelSmoothScroll = () => {
2020
+ if (smoothScrollRAF !== null) {
2021
+ cancelAnimationFrame(smoothScrollRAF);
2022
+ smoothScrollRAF = null;
1956
2023
  }
2024
+ isAutoScrolling = false;
2025
+ };
1957
2026
 
1958
- if (isAutoScrollBlocked && now >= blockUntilTime) {
1959
- isAutoScrollBlocked = false;
2027
+ const cancelAutoScroll = () => {
2028
+ if (scrollRAF !== null) {
2029
+ cancelAnimationFrame(scrollRAF);
2030
+ scrollRAF = null;
1960
2031
  }
2032
+ cancelSmoothScroll();
2033
+ };
1961
2034
 
1962
- if (!force && !isStreaming) return;
2035
+ const syncScrollToBottomButton = () => {
2036
+ if (!isScrollToBottomEnabled() || eventStreamVisible) {
2037
+ if (scrollToBottomButton.parentNode) {
2038
+ scrollToBottomButton.remove();
2039
+ }
2040
+ scrollToBottomButton.style.display = "none";
2041
+ return;
2042
+ }
2043
+ if (scrollToBottomButton.parentNode !== container) {
2044
+ container.appendChild(scrollToBottomButton);
2045
+ }
2046
+ updateScrollToBottomButtonOffset();
2047
+ const hasOverflow = getScrollBottomOffset(body) > 0;
2048
+ scrollToBottomButton.style.display = (autoFollow.isFollowing() || !hasOverflow) ? "none" : "";
2049
+ };
1963
2050
 
1964
- if (now - lastAutoScrollTime < AUTO_SCROLL_THROTTLE) return;
1965
- lastAutoScrollTime = now;
2051
+ const pauseAutoScroll = () => {
2052
+ if (!autoFollow.pause()) return;
2053
+ cancelAutoScroll();
2054
+ syncScrollToBottomButton();
2055
+ };
1966
2056
 
1967
- if (scrollRAF) {
1968
- cancelAnimationFrame(scrollRAF);
1969
- }
2057
+ const resumeAutoScroll = () => {
2058
+ autoFollow.resume();
2059
+ syncScrollToBottomButton();
2060
+ };
2061
+
2062
+ const scheduleAutoScroll = (force = false) => {
2063
+ if (!autoFollow.isFollowing()) return;
2064
+
2065
+ if (!force && !isStreaming) return;
2066
+
2067
+ cancelAutoScroll();
1970
2068
 
1971
2069
  scrollRAF = requestAnimationFrame(() => {
1972
- if (isAutoScrollBlocked || !shouldAutoScroll) return;
1973
- isAutoScrolling = true;
1974
- body.scrollTop = body.scrollHeight;
1975
- lastScrollTop = body.scrollTop;
1976
- requestAnimationFrame(() => {
1977
- isAutoScrolling = false;
1978
- });
1979
2070
  scrollRAF = null;
2071
+ if (!autoFollow.isFollowing()) return;
2072
+ smoothScrollToBottom(getScrollableContainer(), force ? 220 : 140);
1980
2073
  });
1981
2074
  };
1982
2075
 
1983
- // Track ongoing smooth scroll animation
1984
- let smoothScrollRAF: number | null = null;
1985
-
1986
- // Get the scrollable container using its unique ID
1987
- const getScrollableContainer = (): HTMLElement => {
1988
- // Use the unique ID for reliable selection
1989
- const scrollable = wrapper.querySelector('#persona-scroll-container') as HTMLElement;
1990
- // Fallback to body if ID not found (shouldn't happen, but safe fallback)
1991
- return scrollable || body;
1992
- };
1993
-
1994
2076
  // Custom smooth scroll animation with easing
1995
2077
  const smoothScrollToBottom = (element: HTMLElement, duration = 500) => {
1996
2078
  const start = element.scrollTop;
1997
- const clientHeight = element.clientHeight;
1998
2079
  // Recalculate target dynamically to handle layout changes
1999
- let target = element.scrollHeight;
2080
+ let target = getScrollBottomOffset(element);
2000
2081
  let distance = target - start;
2001
2082
 
2002
- // Check if already at bottom: scrollTop + clientHeight should be >= scrollHeight
2003
- // Add a small threshold (2px) to account for rounding/subpixel differences
2004
- const isAtBottom = start + clientHeight >= target - 2;
2005
-
2006
2083
  // If already at bottom or very close, skip animation to prevent glitch
2007
- if (isAtBottom || Math.abs(distance) < 5) {
2084
+ if (Math.abs(distance) < 1) {
2085
+ lastScrollTop = element.scrollTop;
2008
2086
  return;
2009
2087
  }
2010
2088
 
2011
2089
  // Cancel any ongoing smooth scroll animation
2012
- if (smoothScrollRAF !== null) {
2013
- cancelAnimationFrame(smoothScrollRAF);
2014
- smoothScrollRAF = null;
2015
- }
2090
+ cancelSmoothScroll();
2016
2091
 
2017
2092
  const startTime = performance.now();
2093
+ isAutoScrolling = true;
2018
2094
 
2019
2095
  // Easing function: ease-out cubic for smooth deceleration
2020
2096
  const easeOutCubic = (t: number): number => {
@@ -2022,8 +2098,13 @@ export const createAgentExperience = (
2022
2098
  };
2023
2099
 
2024
2100
  const animate = (currentTime: number) => {
2101
+ if (!autoFollow.isFollowing()) {
2102
+ cancelSmoothScroll();
2103
+ return;
2104
+ }
2105
+
2025
2106
  // Recalculate target each frame in case scrollHeight changed
2026
- const currentTarget = element.scrollHeight;
2107
+ const currentTarget = getScrollBottomOffset(element);
2027
2108
  if (currentTarget !== target) {
2028
2109
  target = currentTarget;
2029
2110
  distance = target - start;
@@ -2035,13 +2116,16 @@ export const createAgentExperience = (
2035
2116
 
2036
2117
  const currentScroll = start + distance * eased;
2037
2118
  element.scrollTop = currentScroll;
2119
+ lastScrollTop = element.scrollTop;
2038
2120
 
2039
2121
  if (progress < 1) {
2040
2122
  smoothScrollRAF = requestAnimationFrame(animate);
2041
2123
  } else {
2042
2124
  // Ensure we end exactly at the target
2043
- element.scrollTop = element.scrollHeight;
2125
+ element.scrollTop = target;
2126
+ lastScrollTop = element.scrollTop;
2044
2127
  smoothScrollRAF = null;
2128
+ isAutoScrolling = false;
2045
2129
  }
2046
2130
  };
2047
2131
 
@@ -2481,20 +2565,6 @@ export const createAgentExperience = (
2481
2565
 
2482
2566
  // Use idiomorph to morph the container contents
2483
2567
  morphMessages(container, tempContainer);
2484
- // Defer scroll to next frame for smoother animation and to prevent jolt
2485
- // This allows the browser to update layout (e.g., typing indicator removal) before scrolling
2486
- // Use double RAF to ensure layout has fully settled before starting scroll animation
2487
- // Get the scrollable container using its unique ID (#persona-scroll-container)
2488
- // Only smooth-scroll if auto-scroll hasn't been blocked by the user scrolling up
2489
- if (shouldAutoScroll && !isAutoScrollBlocked) {
2490
- requestAnimationFrame(() => {
2491
- requestAnimationFrame(() => {
2492
- if (!shouldAutoScroll || isAutoScrollBlocked) return;
2493
- const scrollableContainer = getScrollableContainer();
2494
- smoothScrollToBottom(scrollableContainer);
2495
- });
2496
- });
2497
- }
2498
2568
  };
2499
2569
 
2500
2570
  // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
@@ -2562,12 +2632,46 @@ export const createAgentExperience = (
2562
2632
  const prevOpen = open;
2563
2633
  open = nextOpen;
2564
2634
  updateOpenState();
2565
-
2635
+
2636
+ // Sync host stacking and scroll lock for viewport-covering modes
2637
+ const isViewportCovering = (() => {
2638
+ const sm = config.launcher?.sidebarMode ?? false;
2639
+ const ow = mount.ownerDocument.defaultView ?? window;
2640
+ const mf = config.launcher?.mobileFullscreen ?? true;
2641
+ const mb = config.launcher?.mobileBreakpoint ?? 640;
2642
+ const isMobile = ow.innerWidth <= mb;
2643
+ const dockedMF = isDockedMountMode(config) && mf && isMobile;
2644
+ return sm || (mf && isMobile && launcherEnabled) || dockedMF;
2645
+ })();
2646
+
2647
+ if (open && isViewportCovering) {
2648
+ if (!teardownHostStacking) {
2649
+ const root = mount.getRootNode();
2650
+ const hostEl = root instanceof ShadowRoot
2651
+ ? (root.host as HTMLElement)
2652
+ : mount.closest<HTMLElement>(".persona-host");
2653
+ if (hostEl) {
2654
+ teardownHostStacking = syncOverlayHostStacking(
2655
+ hostEl,
2656
+ config.launcher?.zIndex ?? DEFAULT_OVERLAY_Z_INDEX
2657
+ );
2658
+ }
2659
+ }
2660
+ if (!releaseScrollLock) {
2661
+ releaseScrollLock = acquireScrollLock(mount.ownerDocument);
2662
+ }
2663
+ } else if (!open) {
2664
+ teardownHostStacking?.();
2665
+ teardownHostStacking = null;
2666
+ releaseScrollLock?.();
2667
+ releaseScrollLock = null;
2668
+ }
2669
+
2566
2670
  if (open) {
2567
2671
  recalcPanelHeight();
2568
2672
  scheduleAutoScroll(true);
2569
2673
  }
2570
-
2674
+
2571
2675
  // Emit widget state events
2572
2676
  const stateEvent: AgentWidgetStateEvent = {
2573
2677
  open,
@@ -3512,7 +3616,37 @@ export const createAgentExperience = (
3512
3616
  } finally {
3513
3617
  // applyFullHeightStyles() assigns wrapper.style.cssText (e.g. display:flex !important), which
3514
3618
  // overwrites updateOpenState()'s display:none when docked+closed. Re-sync after every recalc.
3619
+ updateScrollToBottomButtonOffset();
3515
3620
  updateOpenState();
3621
+
3622
+ // Sync scroll lock and host stacking when viewport mode changes (e.g. orientation change)
3623
+ if (open && launcherEnabled) {
3624
+ const ow = mount.ownerDocument.defaultView ?? window;
3625
+ const isMobile = ow.innerWidth <= (config.launcher?.mobileBreakpoint ?? 640);
3626
+ const sm = config.launcher?.sidebarMode ?? false;
3627
+ const mf = config.launcher?.mobileFullscreen ?? true;
3628
+ const dockedMF = isDockedMountMode(config) && mf && isMobile;
3629
+ const isVC = sm || (mf && isMobile && launcherEnabled) || dockedMF;
3630
+
3631
+ if (isVC && !releaseScrollLock) {
3632
+ const root = mount.getRootNode();
3633
+ const hostEl = root instanceof ShadowRoot
3634
+ ? (root.host as HTMLElement)
3635
+ : mount.closest<HTMLElement>(".persona-host");
3636
+ if (hostEl && !teardownHostStacking) {
3637
+ teardownHostStacking = syncOverlayHostStacking(
3638
+ hostEl,
3639
+ config.launcher?.zIndex ?? DEFAULT_OVERLAY_Z_INDEX
3640
+ );
3641
+ }
3642
+ releaseScrollLock = acquireScrollLock(mount.ownerDocument);
3643
+ } else if (!isVC) {
3644
+ teardownHostStacking?.();
3645
+ teardownHostStacking = null;
3646
+ releaseScrollLock?.();
3647
+ releaseScrollLock = null;
3648
+ }
3649
+ }
3516
3650
  }
3517
3651
  };
3518
3652
 
@@ -3520,37 +3654,68 @@ export const createAgentExperience = (
3520
3654
  const ownerWindow = mount.ownerDocument.defaultView ?? window;
3521
3655
  ownerWindow.addEventListener("resize", recalcPanelHeight);
3522
3656
  destroyCallbacks.push(() => ownerWindow.removeEventListener("resize", recalcPanelHeight));
3657
+ if (typeof ResizeObserver !== "undefined") {
3658
+ const footerResizeObserver = new ResizeObserver(() => {
3659
+ updateScrollToBottomButtonOffset();
3660
+ });
3661
+ footerResizeObserver.observe(footer);
3662
+ destroyCallbacks.push(() => footerResizeObserver.disconnect());
3663
+ }
3523
3664
 
3524
3665
  lastScrollTop = body.scrollTop;
3525
3666
 
3526
3667
  const handleScroll = () => {
3527
3668
  const scrollTop = body.scrollTop;
3528
- const scrollHeight = body.scrollHeight;
3529
- const clientHeight = body.clientHeight;
3530
- const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
3531
- const delta = Math.abs(scrollTop - lastScrollTop);
3532
- lastScrollTop = scrollTop;
3533
-
3534
- if (isAutoScrolling) return;
3535
- if (delta <= USER_SCROLL_THRESHOLD) return;
3536
-
3537
- if (!shouldAutoScroll && distanceFromBottom < BOTTOM_THRESHOLD) {
3538
- isAutoScrollBlocked = false;
3539
- shouldAutoScroll = true;
3669
+ const { action, nextLastScrollTop } = resolveFollowStateFromScroll({
3670
+ following: autoFollow.isFollowing(),
3671
+ currentScrollTop: scrollTop,
3672
+ lastScrollTop,
3673
+ nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
3674
+ userScrollThreshold: USER_SCROLL_THRESHOLD,
3675
+ isAutoScrolling,
3676
+ pauseOnUpwardScroll: true,
3677
+ pauseWhenAwayFromBottom: false,
3678
+ resumeRequiresDownwardScroll: true
3679
+ });
3680
+ lastScrollTop = nextLastScrollTop;
3681
+
3682
+ if (action === "resume") {
3683
+ resumeAutoScroll();
3540
3684
  return;
3541
3685
  }
3542
3686
 
3543
- if (shouldAutoScroll && distanceFromBottom > BOTTOM_THRESHOLD) {
3544
- isAutoScrollBlocked = true;
3545
- blockUntilTime = Date.now() + AUTO_SCROLL_BLOCK_TIME;
3546
- shouldAutoScroll = false;
3687
+ if (action === "pause") {
3688
+ pauseAutoScroll();
3547
3689
  }
3548
3690
  };
3549
3691
 
3550
3692
  body.addEventListener("scroll", handleScroll, { passive: true });
3551
3693
  destroyCallbacks.push(() => body.removeEventListener("scroll", handleScroll));
3694
+ const handleWheel = (event: WheelEvent) => {
3695
+ const action = resolveFollowStateFromWheel({
3696
+ following: autoFollow.isFollowing(),
3697
+ deltaY: event.deltaY,
3698
+ nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
3699
+ resumeWhenNearBottom: true
3700
+ });
3701
+
3702
+ if (action === "pause") {
3703
+ pauseAutoScroll();
3704
+ } else if (action === "resume") {
3705
+ resumeAutoScroll();
3706
+ }
3707
+ };
3708
+ body.addEventListener("wheel", handleWheel, { passive: true });
3709
+ destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
3710
+ scrollToBottomButton.addEventListener("click", () => {
3711
+ body.scrollTop = body.scrollHeight;
3712
+ lastScrollTop = body.scrollTop;
3713
+ resumeAutoScroll();
3714
+ scheduleAutoScroll(true);
3715
+ });
3716
+ destroyCallbacks.push(() => scrollToBottomButton.remove());
3552
3717
  destroyCallbacks.push(() => {
3553
- if (scrollRAF) cancelAnimationFrame(scrollRAF);
3718
+ cancelAutoScroll();
3554
3719
  });
3555
3720
 
3556
3721
  const refreshCloseButton = () => {
@@ -3581,6 +3746,7 @@ export const createAgentExperience = (
3581
3746
  // Clear messages in session (this will trigger onMessagesChanged which re-renders)
3582
3747
  session.clearMessages();
3583
3748
  messageCache.clear();
3749
+ resumeAutoScroll();
3584
3750
 
3585
3751
  // Always clear the default localStorage key
3586
3752
  try {
@@ -3697,6 +3863,9 @@ export const createAgentExperience = (
3697
3863
  autoExpand = config.launcher?.autoExpand ?? false;
3698
3864
  showReasoning = config.features?.showReasoning ?? true;
3699
3865
  showToolCalls = config.features?.showToolCalls ?? true;
3866
+ scrollToBottomFeature = config.features?.scrollToBottom ?? {};
3867
+ renderScrollToBottomButton();
3868
+ syncScrollToBottomButton();
3700
3869
  const prevShowEventStreamToggle = showEventStreamToggle;
3701
3870
  showEventStreamToggle = config.features?.showEventStreamToggle ?? false;
3702
3871
 
@@ -3875,6 +4044,8 @@ export const createAgentExperience = (
3875
4044
  if (footer) {
3876
4045
  footer.style.display = showFooter ? "" : "none";
3877
4046
  }
4047
+ updateScrollToBottomButtonOffset();
4048
+ syncScrollToBottomButton();
3878
4049
 
3879
4050
  // Only update open state if launcher enabled state changed or autoExpand value changed
3880
4051
  const launcherEnabledChanged = launcherEnabled !== prevLauncherEnabled;
@@ -4155,6 +4326,7 @@ export const createAgentExperience = (
4155
4326
 
4156
4327
  // Position tooltip above button
4157
4328
  portaledTooltip.style.position = "fixed";
4329
+ portaledTooltip.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
4158
4330
  portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
4159
4331
  portaledTooltip.style.top = `${buttonRect.top - 8}px`;
4160
4332
  portaledTooltip.style.transform = "translate(-50%, -100%)";
@@ -4369,6 +4541,7 @@ export const createAgentExperience = (
4369
4541
 
4370
4542
  // Position tooltip above button
4371
4543
  portaledTooltip.style.position = "fixed";
4544
+ portaledTooltip.style.zIndex = String(PORTALED_OVERLAY_Z_INDEX);
4372
4545
  portaledTooltip.style.left = `${buttonRect.left + buttonRect.width / 2}px`;
4373
4546
  portaledTooltip.style.top = `${buttonRect.top - 8}px`;
4374
4547
  portaledTooltip.style.transform = "translate(-50%, -100%)";
@@ -4899,6 +5072,7 @@ export const createAgentExperience = (
4899
5072
  artifactsPaneUserHidden = false;
4900
5073
  session.clearMessages();
4901
5074
  messageCache.clear();
5075
+ resumeAutoScroll();
4902
5076
 
4903
5077
  // Always clear the default localStorage key
4904
5078
  try {
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ createFollowStateController,
5
+ getScrollBottomOffset,
6
+ isElementNearBottom,
7
+ resolveFollowStateFromScroll,
8
+ resolveFollowStateFromWheel
9
+ } from "./auto-follow";
10
+
11
+ describe("auto-follow utilities", () => {
12
+ it("tracks pause and resume state", () => {
13
+ const state = createFollowStateController();
14
+
15
+ expect(state.isFollowing()).toBe(true);
16
+ expect(state.pause()).toBe(true);
17
+ expect(state.isFollowing()).toBe(false);
18
+ expect(state.pause()).toBe(false);
19
+ expect(state.resume()).toBe(true);
20
+ expect(state.isFollowing()).toBe(true);
21
+ });
22
+
23
+ it("computes bottom offset and near-bottom status", () => {
24
+ const element = {
25
+ scrollTop: 590,
26
+ scrollHeight: 1000,
27
+ clientHeight: 400
28
+ };
29
+
30
+ expect(getScrollBottomOffset(element)).toBe(600);
31
+ expect(isElementNearBottom(element, 10)).toBe(true);
32
+ expect(isElementNearBottom({ ...element, scrollTop: 560 }, 10)).toBe(false);
33
+ });
34
+
35
+ it("pauses transcript-style auto-follow on upward scroll immediately", () => {
36
+ const result = resolveFollowStateFromScroll({
37
+ following: true,
38
+ currentScrollTop: 597,
39
+ lastScrollTop: 600,
40
+ nearBottom: true,
41
+ userScrollThreshold: 1,
42
+ pauseOnUpwardScroll: true,
43
+ pauseWhenAwayFromBottom: false
44
+ });
45
+
46
+ expect(result.action).toBe("pause");
47
+ });
48
+
49
+ it("resumes event-log-style auto-follow only when scrolling down near bottom", () => {
50
+ const stayPaused = resolveFollowStateFromScroll({
51
+ following: false,
52
+ currentScrollTop: 550,
53
+ lastScrollTop: 560,
54
+ nearBottom: true,
55
+ userScrollThreshold: 1,
56
+ resumeRequiresDownwardScroll: true
57
+ });
58
+ const resume = resolveFollowStateFromScroll({
59
+ following: false,
60
+ currentScrollTop: 590,
61
+ lastScrollTop: 550,
62
+ nearBottom: true,
63
+ userScrollThreshold: 1,
64
+ resumeRequiresDownwardScroll: true
65
+ });
66
+
67
+ expect(stayPaused.action).toBe("none");
68
+ expect(resume.action).toBe("resume");
69
+ });
70
+
71
+ it("keeps transcript-style auto-follow paused near the bottom until scrolling down", () => {
72
+ const stayPaused = resolveFollowStateFromScroll({
73
+ following: false,
74
+ currentScrollTop: 597,
75
+ lastScrollTop: 600,
76
+ nearBottom: true,
77
+ userScrollThreshold: 1,
78
+ resumeRequiresDownwardScroll: true
79
+ });
80
+ const resume = resolveFollowStateFromScroll({
81
+ following: false,
82
+ currentScrollTop: 599,
83
+ lastScrollTop: 597,
84
+ nearBottom: true,
85
+ userScrollThreshold: 1,
86
+ resumeRequiresDownwardScroll: true
87
+ });
88
+
89
+ expect(stayPaused.action).toBe("none");
90
+ expect(resume.action).toBe("resume");
91
+ });
92
+
93
+ it("resolves wheel intent for pause and resume", () => {
94
+ expect(
95
+ resolveFollowStateFromWheel({
96
+ following: true,
97
+ deltaY: -12
98
+ })
99
+ ).toBe("pause");
100
+
101
+ expect(
102
+ resolveFollowStateFromWheel({
103
+ following: false,
104
+ deltaY: 12,
105
+ nearBottom: true,
106
+ resumeWhenNearBottom: true
107
+ })
108
+ ).toBe("resume");
109
+ });
110
+ });