@runtypelabs/persona 3.6.0 → 3.7.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/src/ui.ts CHANGED
@@ -33,6 +33,13 @@ 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 {
37
+ createFollowStateController,
38
+ getScrollBottomOffset,
39
+ isElementNearBottom,
40
+ resolveFollowStateFromScroll,
41
+ resolveFollowStateFromWheel
42
+ } from "./utils/auto-follow";
36
43
  import { statusCopy } from "./utils/constants";
37
44
  import { isDockedMountMode, resolveDockConfig } from "./utils/dock";
38
45
  import { createLauncherButton } from "./components/launcher";
@@ -545,6 +552,7 @@ export const createAgentExperience = (
545
552
  let showReasoning = config.features?.showReasoning ?? true;
546
553
  let showToolCalls = config.features?.showToolCalls ?? true;
547
554
  let showEventStreamToggle = config.features?.showEventStreamToggle ?? false;
555
+ let scrollToBottomFeature = config.features?.scrollToBottom ?? {};
548
556
  const persistKeyPrefix = (typeof config.persistState === 'object' ? config.persistState?.keyPrefix : undefined) ?? "persona-";
549
557
  const eventStreamDbName = `${persistKeyPrefix}event-stream`;
550
558
  let eventStreamStore = showEventStreamToggle ? new EventStreamStore(eventStreamDbName) : null;
@@ -655,6 +663,49 @@ export const createAgentExperience = (
655
663
  let attachmentButtonWrapper: HTMLElement | null = panelElements.attachmentButtonWrapper;
656
664
  let attachmentInput: HTMLInputElement | null = panelElements.attachmentInput;
657
665
  let attachmentPreviewsContainer: HTMLElement | null = panelElements.attachmentPreviewsContainer;
666
+ container.classList.add("persona-relative");
667
+ body.classList.add("persona-relative");
668
+ const SCROLL_TO_BOTTOM_EDGE_OFFSET = 12;
669
+
670
+ const getScrollToBottomLabel = () => scrollToBottomFeature.label ?? "";
671
+ const getScrollToBottomIconName = () => scrollToBottomFeature.iconName ?? "arrow-down";
672
+ const isScrollToBottomEnabled = () => scrollToBottomFeature.enabled !== false;
673
+ const scrollToBottomButton = createElement(
674
+ "button",
675
+ "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"
676
+ ) as HTMLButtonElement;
677
+ scrollToBottomButton.type = "button";
678
+ scrollToBottomButton.style.display = "none";
679
+ scrollToBottomButton.setAttribute("data-persona-scroll-to-bottom", "true");
680
+ const scrollToBottomIcon = createElement("span", "persona-flex persona-items-center");
681
+ const scrollToBottomLabel = createElement("span", "");
682
+ scrollToBottomButton.append(scrollToBottomIcon, scrollToBottomLabel);
683
+ container.appendChild(scrollToBottomButton);
684
+
685
+ const updateScrollToBottomButtonOffset = () => {
686
+ const footerHidden = footer.style.display === "none";
687
+ const footerHeight = footerHidden ? 0 : footer.offsetHeight;
688
+ scrollToBottomButton.style.bottom = `${footerHeight + SCROLL_TO_BOTTOM_EDGE_OFFSET}px`;
689
+ };
690
+ updateScrollToBottomButtonOffset();
691
+
692
+ const renderScrollToBottomButton = () => {
693
+ const hasLabel = Boolean(getScrollToBottomLabel());
694
+ scrollToBottomButton.setAttribute("aria-label", getScrollToBottomLabel() || "Jump to latest");
695
+ scrollToBottomButton.title = getScrollToBottomLabel();
696
+ scrollToBottomButton.setAttribute("data-persona-scroll-to-bottom-has-label", hasLabel ? "true" : "false");
697
+ scrollToBottomIcon.innerHTML = "";
698
+ const icon = renderLucideIcon(getScrollToBottomIconName(), "14px", "currentColor", 2);
699
+ if (icon) {
700
+ scrollToBottomIcon.appendChild(icon);
701
+ scrollToBottomIcon.style.display = "";
702
+ } else {
703
+ scrollToBottomIcon.style.display = "none";
704
+ }
705
+ scrollToBottomLabel.textContent = getScrollToBottomLabel();
706
+ scrollToBottomLabel.style.display = hasLabel ? "" : "none";
707
+ };
708
+ renderScrollToBottomButton();
658
709
 
659
710
  // Initialized after composer plugins rebind footer DOM (see `bindComposerRefsFromFooter`)
660
711
  let attachmentManager: AttachmentManager | null = null;
@@ -719,6 +770,7 @@ export const createAgentExperience = (
719
770
  };
720
771
  eventStreamLastUpdate = 0;
721
772
  eventStreamRAF = requestAnimationFrame(rafLoop);
773
+ syncScrollToBottomButton();
722
774
  eventBus.emit("eventStream:opened", { timestamp: Date.now() });
723
775
  };
724
776
 
@@ -739,6 +791,7 @@ export const createAgentExperience = (
739
791
  cancelAnimationFrame(eventStreamRAF);
740
792
  eventStreamRAF = null;
741
793
  }
794
+ syncScrollToBottomButton();
742
795
  eventBus.emit("eventStream:closed", { timestamp: Date.now() });
743
796
  };
744
797
 
@@ -1845,18 +1898,13 @@ export const createAgentExperience = (
1845
1898
  let isStreaming = false;
1846
1899
  const messageCache = createMessageCache();
1847
1900
  let configVersion = 0;
1848
- let shouldAutoScroll = true;
1901
+ const autoFollow = createFollowStateController();
1849
1902
  let lastScrollTop = 0;
1850
- let lastAutoScrollTime = 0;
1851
1903
  let scrollRAF: number | null = null;
1852
- let isAutoScrollBlocked = false;
1853
- let blockUntilTime = 0;
1854
1904
  let isAutoScrolling = false;
1855
1905
 
1856
- const AUTO_SCROLL_THROTTLE = 125;
1857
- const AUTO_SCROLL_BLOCK_TIME = 2000;
1858
- const USER_SCROLL_THRESHOLD = 5;
1859
- const BOTTOM_THRESHOLD = 50;
1906
+ const USER_SCROLL_THRESHOLD = 1;
1907
+ const BOTTOM_THRESHOLD = 8;
1860
1908
  const messageState = new Map<
1861
1909
  string,
1862
1910
  { streaming?: boolean; role: AgentWidgetMessage["role"] }
@@ -1946,75 +1994,91 @@ export const createAgentExperience = (
1946
1994
  }
1947
1995
  }
1948
1996
 
1949
- const scheduleAutoScroll = (force = false) => {
1950
- if (!shouldAutoScroll) return;
1997
+ // Track ongoing smooth scroll animation
1998
+ let smoothScrollRAF: number | null = null;
1999
+
2000
+ // Get the scrollable container using its unique ID
2001
+ const getScrollableContainer = (): HTMLElement => {
2002
+ // Use the unique ID for reliable selection
2003
+ const scrollable = wrapper.querySelector('#persona-scroll-container') as HTMLElement;
2004
+ // Fallback to body if ID not found (shouldn't happen, but safe fallback)
2005
+ return scrollable || body;
2006
+ };
1951
2007
 
1952
- const now = Date.now();
2008
+ const cancelSmoothScroll = () => {
2009
+ if (smoothScrollRAF !== null) {
2010
+ cancelAnimationFrame(smoothScrollRAF);
2011
+ smoothScrollRAF = null;
2012
+ }
2013
+ isAutoScrolling = false;
2014
+ };
1953
2015
 
1954
- if (isAutoScrollBlocked && now < blockUntilTime) {
1955
- if (!force) return;
2016
+ const cancelAutoScroll = () => {
2017
+ if (scrollRAF !== null) {
2018
+ cancelAnimationFrame(scrollRAF);
2019
+ scrollRAF = null;
1956
2020
  }
2021
+ cancelSmoothScroll();
2022
+ };
1957
2023
 
1958
- if (isAutoScrollBlocked && now >= blockUntilTime) {
1959
- isAutoScrollBlocked = false;
2024
+ const syncScrollToBottomButton = () => {
2025
+ if (!isScrollToBottomEnabled() || eventStreamVisible) {
2026
+ if (scrollToBottomButton.parentNode) {
2027
+ scrollToBottomButton.remove();
2028
+ }
2029
+ scrollToBottomButton.style.display = "none";
2030
+ return;
2031
+ }
2032
+ if (scrollToBottomButton.parentNode !== container) {
2033
+ container.appendChild(scrollToBottomButton);
1960
2034
  }
2035
+ updateScrollToBottomButtonOffset();
2036
+ scrollToBottomButton.style.display = autoFollow.isFollowing() ? "none" : "";
2037
+ };
1961
2038
 
1962
- if (!force && !isStreaming) return;
2039
+ const pauseAutoScroll = () => {
2040
+ if (!autoFollow.pause()) return;
2041
+ cancelAutoScroll();
2042
+ syncScrollToBottomButton();
2043
+ };
1963
2044
 
1964
- if (now - lastAutoScrollTime < AUTO_SCROLL_THROTTLE) return;
1965
- lastAutoScrollTime = now;
2045
+ const resumeAutoScroll = () => {
2046
+ autoFollow.resume();
2047
+ syncScrollToBottomButton();
2048
+ };
1966
2049
 
1967
- if (scrollRAF) {
1968
- cancelAnimationFrame(scrollRAF);
1969
- }
2050
+ const scheduleAutoScroll = (force = false) => {
2051
+ if (!autoFollow.isFollowing()) return;
2052
+
2053
+ if (!force && !isStreaming) return;
2054
+
2055
+ cancelAutoScroll();
1970
2056
 
1971
2057
  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
2058
  scrollRAF = null;
2059
+ if (!autoFollow.isFollowing()) return;
2060
+ smoothScrollToBottom(getScrollableContainer(), force ? 220 : 140);
1980
2061
  });
1981
2062
  };
1982
2063
 
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
2064
  // Custom smooth scroll animation with easing
1995
2065
  const smoothScrollToBottom = (element: HTMLElement, duration = 500) => {
1996
2066
  const start = element.scrollTop;
1997
- const clientHeight = element.clientHeight;
1998
2067
  // Recalculate target dynamically to handle layout changes
1999
- let target = element.scrollHeight;
2068
+ let target = getScrollBottomOffset(element);
2000
2069
  let distance = target - start;
2001
2070
 
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
2071
  // If already at bottom or very close, skip animation to prevent glitch
2007
- if (isAtBottom || Math.abs(distance) < 5) {
2072
+ if (Math.abs(distance) < 1) {
2073
+ lastScrollTop = element.scrollTop;
2008
2074
  return;
2009
2075
  }
2010
2076
 
2011
2077
  // Cancel any ongoing smooth scroll animation
2012
- if (smoothScrollRAF !== null) {
2013
- cancelAnimationFrame(smoothScrollRAF);
2014
- smoothScrollRAF = null;
2015
- }
2078
+ cancelSmoothScroll();
2016
2079
 
2017
2080
  const startTime = performance.now();
2081
+ isAutoScrolling = true;
2018
2082
 
2019
2083
  // Easing function: ease-out cubic for smooth deceleration
2020
2084
  const easeOutCubic = (t: number): number => {
@@ -2022,8 +2086,13 @@ export const createAgentExperience = (
2022
2086
  };
2023
2087
 
2024
2088
  const animate = (currentTime: number) => {
2089
+ if (!autoFollow.isFollowing()) {
2090
+ cancelSmoothScroll();
2091
+ return;
2092
+ }
2093
+
2025
2094
  // Recalculate target each frame in case scrollHeight changed
2026
- const currentTarget = element.scrollHeight;
2095
+ const currentTarget = getScrollBottomOffset(element);
2027
2096
  if (currentTarget !== target) {
2028
2097
  target = currentTarget;
2029
2098
  distance = target - start;
@@ -2035,13 +2104,16 @@ export const createAgentExperience = (
2035
2104
 
2036
2105
  const currentScroll = start + distance * eased;
2037
2106
  element.scrollTop = currentScroll;
2107
+ lastScrollTop = element.scrollTop;
2038
2108
 
2039
2109
  if (progress < 1) {
2040
2110
  smoothScrollRAF = requestAnimationFrame(animate);
2041
2111
  } else {
2042
2112
  // Ensure we end exactly at the target
2043
- element.scrollTop = element.scrollHeight;
2113
+ element.scrollTop = target;
2114
+ lastScrollTop = element.scrollTop;
2044
2115
  smoothScrollRAF = null;
2116
+ isAutoScrolling = false;
2045
2117
  }
2046
2118
  };
2047
2119
 
@@ -2481,20 +2553,6 @@ export const createAgentExperience = (
2481
2553
 
2482
2554
  // Use idiomorph to morph the container contents
2483
2555
  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
2556
  };
2499
2557
 
2500
2558
  // Alias for clarity - the implementation handles flicker prevention via typing indicator logic
@@ -3512,6 +3570,7 @@ export const createAgentExperience = (
3512
3570
  } finally {
3513
3571
  // applyFullHeightStyles() assigns wrapper.style.cssText (e.g. display:flex !important), which
3514
3572
  // overwrites updateOpenState()'s display:none when docked+closed. Re-sync after every recalc.
3573
+ updateScrollToBottomButtonOffset();
3515
3574
  updateOpenState();
3516
3575
  }
3517
3576
  };
@@ -3520,37 +3579,68 @@ export const createAgentExperience = (
3520
3579
  const ownerWindow = mount.ownerDocument.defaultView ?? window;
3521
3580
  ownerWindow.addEventListener("resize", recalcPanelHeight);
3522
3581
  destroyCallbacks.push(() => ownerWindow.removeEventListener("resize", recalcPanelHeight));
3582
+ if (typeof ResizeObserver !== "undefined") {
3583
+ const footerResizeObserver = new ResizeObserver(() => {
3584
+ updateScrollToBottomButtonOffset();
3585
+ });
3586
+ footerResizeObserver.observe(footer);
3587
+ destroyCallbacks.push(() => footerResizeObserver.disconnect());
3588
+ }
3523
3589
 
3524
3590
  lastScrollTop = body.scrollTop;
3525
3591
 
3526
3592
  const handleScroll = () => {
3527
3593
  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;
3594
+ const { action, nextLastScrollTop } = resolveFollowStateFromScroll({
3595
+ following: autoFollow.isFollowing(),
3596
+ currentScrollTop: scrollTop,
3597
+ lastScrollTop,
3598
+ nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
3599
+ userScrollThreshold: USER_SCROLL_THRESHOLD,
3600
+ isAutoScrolling,
3601
+ pauseOnUpwardScroll: true,
3602
+ pauseWhenAwayFromBottom: false,
3603
+ resumeRequiresDownwardScroll: true
3604
+ });
3605
+ lastScrollTop = nextLastScrollTop;
3606
+
3607
+ if (action === "resume") {
3608
+ resumeAutoScroll();
3540
3609
  return;
3541
3610
  }
3542
3611
 
3543
- if (shouldAutoScroll && distanceFromBottom > BOTTOM_THRESHOLD) {
3544
- isAutoScrollBlocked = true;
3545
- blockUntilTime = Date.now() + AUTO_SCROLL_BLOCK_TIME;
3546
- shouldAutoScroll = false;
3612
+ if (action === "pause") {
3613
+ pauseAutoScroll();
3547
3614
  }
3548
3615
  };
3549
3616
 
3550
3617
  body.addEventListener("scroll", handleScroll, { passive: true });
3551
3618
  destroyCallbacks.push(() => body.removeEventListener("scroll", handleScroll));
3619
+ const handleWheel = (event: WheelEvent) => {
3620
+ const action = resolveFollowStateFromWheel({
3621
+ following: autoFollow.isFollowing(),
3622
+ deltaY: event.deltaY,
3623
+ nearBottom: isElementNearBottom(body, BOTTOM_THRESHOLD),
3624
+ resumeWhenNearBottom: true
3625
+ });
3626
+
3627
+ if (action === "pause") {
3628
+ pauseAutoScroll();
3629
+ } else if (action === "resume") {
3630
+ resumeAutoScroll();
3631
+ }
3632
+ };
3633
+ body.addEventListener("wheel", handleWheel, { passive: true });
3634
+ destroyCallbacks.push(() => body.removeEventListener("wheel", handleWheel));
3635
+ scrollToBottomButton.addEventListener("click", () => {
3636
+ body.scrollTop = body.scrollHeight;
3637
+ lastScrollTop = body.scrollTop;
3638
+ resumeAutoScroll();
3639
+ scheduleAutoScroll(true);
3640
+ });
3641
+ destroyCallbacks.push(() => scrollToBottomButton.remove());
3552
3642
  destroyCallbacks.push(() => {
3553
- if (scrollRAF) cancelAnimationFrame(scrollRAF);
3643
+ cancelAutoScroll();
3554
3644
  });
3555
3645
 
3556
3646
  const refreshCloseButton = () => {
@@ -3697,6 +3787,9 @@ export const createAgentExperience = (
3697
3787
  autoExpand = config.launcher?.autoExpand ?? false;
3698
3788
  showReasoning = config.features?.showReasoning ?? true;
3699
3789
  showToolCalls = config.features?.showToolCalls ?? true;
3790
+ scrollToBottomFeature = config.features?.scrollToBottom ?? {};
3791
+ renderScrollToBottomButton();
3792
+ syncScrollToBottomButton();
3700
3793
  const prevShowEventStreamToggle = showEventStreamToggle;
3701
3794
  showEventStreamToggle = config.features?.showEventStreamToggle ?? false;
3702
3795
 
@@ -3875,6 +3968,8 @@ export const createAgentExperience = (
3875
3968
  if (footer) {
3876
3969
  footer.style.display = showFooter ? "" : "none";
3877
3970
  }
3971
+ updateScrollToBottomButtonOffset();
3972
+ syncScrollToBottomButton();
3878
3973
 
3879
3974
  // Only update open state if launcher enabled state changed or autoExpand value changed
3880
3975
  const launcherEnabledChanged = launcherEnabled !== prevLauncherEnabled;
@@ -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
+ });
@@ -0,0 +1,112 @@
1
+ export type FollowStateAction = "none" | "pause" | "resume";
2
+
3
+ export type FollowStateController = {
4
+ isFollowing: () => boolean;
5
+ pause: () => boolean;
6
+ resume: () => boolean;
7
+ };
8
+
9
+ export type FollowStateScrollInput = {
10
+ following: boolean;
11
+ currentScrollTop: number;
12
+ lastScrollTop: number;
13
+ nearBottom: boolean;
14
+ userScrollThreshold: number;
15
+ isAutoScrolling?: boolean;
16
+ pauseOnUpwardScroll?: boolean;
17
+ pauseWhenAwayFromBottom?: boolean;
18
+ resumeRequiresDownwardScroll?: boolean;
19
+ };
20
+
21
+ export type FollowStateWheelInput = {
22
+ following: boolean;
23
+ deltaY: number;
24
+ nearBottom?: boolean;
25
+ resumeWhenNearBottom?: boolean;
26
+ };
27
+
28
+ export function createFollowStateController(initiallyFollowing = true): FollowStateController {
29
+ let following = initiallyFollowing;
30
+
31
+ return {
32
+ isFollowing: () => following,
33
+ pause: () => {
34
+ if (!following) return false;
35
+ following = false;
36
+ return true;
37
+ },
38
+ resume: () => {
39
+ if (following) return false;
40
+ following = true;
41
+ return true;
42
+ }
43
+ };
44
+ }
45
+
46
+ export function getScrollBottomOffset(element: Pick<HTMLElement, "scrollHeight" | "clientHeight">): number {
47
+ return Math.max(0, element.scrollHeight - element.clientHeight);
48
+ }
49
+
50
+ export function isElementNearBottom(
51
+ element: Pick<HTMLElement, "scrollTop" | "scrollHeight" | "clientHeight">,
52
+ threshold: number
53
+ ): boolean {
54
+ return getScrollBottomOffset(element) - element.scrollTop <= threshold;
55
+ }
56
+
57
+ export function resolveFollowStateFromScroll(
58
+ input: FollowStateScrollInput
59
+ ): { action: FollowStateAction; delta: number; nextLastScrollTop: number } {
60
+ const {
61
+ following,
62
+ currentScrollTop,
63
+ lastScrollTop,
64
+ nearBottom,
65
+ userScrollThreshold,
66
+ isAutoScrolling = false,
67
+ pauseOnUpwardScroll = false,
68
+ pauseWhenAwayFromBottom = true,
69
+ resumeRequiresDownwardScroll = false
70
+ } = input;
71
+
72
+ const delta = currentScrollTop - lastScrollTop;
73
+
74
+ if (isAutoScrolling || Math.abs(delta) < userScrollThreshold) {
75
+ return { action: "none", delta, nextLastScrollTop: currentScrollTop };
76
+ }
77
+
78
+ if (!following && nearBottom && (!resumeRequiresDownwardScroll || delta > 0)) {
79
+ return { action: "resume", delta, nextLastScrollTop: currentScrollTop };
80
+ }
81
+
82
+ if (following && pauseOnUpwardScroll && delta < 0) {
83
+ return { action: "pause", delta, nextLastScrollTop: currentScrollTop };
84
+ }
85
+
86
+ if (following && pauseWhenAwayFromBottom && !nearBottom) {
87
+ return { action: "pause", delta, nextLastScrollTop: currentScrollTop };
88
+ }
89
+
90
+ return { action: "none", delta, nextLastScrollTop: currentScrollTop };
91
+ }
92
+
93
+ export function resolveFollowStateFromWheel(
94
+ input: FollowStateWheelInput
95
+ ): FollowStateAction {
96
+ const {
97
+ following,
98
+ deltaY,
99
+ nearBottom = false,
100
+ resumeWhenNearBottom = false
101
+ } = input;
102
+
103
+ if (following && deltaY < 0) {
104
+ return "pause";
105
+ }
106
+
107
+ if (!following && resumeWhenNearBottom && deltaY > 0 && nearBottom) {
108
+ return "resume";
109
+ }
110
+
111
+ return "none";
112
+ }
@@ -218,6 +218,40 @@ describe('theme utils', () => {
218
218
  expect(cssVars['--persona-composer-shadow']).toBe('none');
219
219
  });
220
220
 
221
+ it('maps scroll-to-bottom component tokens to dedicated CSS variables', () => {
222
+ const theme = createTheme({
223
+ components: {
224
+ scrollToBottom: {
225
+ background: 'palette.colors.accent.500',
226
+ foreground: 'palette.colors.gray.50',
227
+ border: 'palette.colors.gray.900',
228
+ size: '40px',
229
+ borderRadius: 'palette.radius.full',
230
+ shadow: 'palette.shadows.md',
231
+ padding: '0.5rem 0.875rem',
232
+ gap: '0.5rem',
233
+ fontSize: '0.875rem',
234
+ iconSize: '14px',
235
+ },
236
+ },
237
+ } as any);
238
+
239
+ const cssVars = themeToCssVariables(theme);
240
+
241
+ expect(cssVars['--persona-scroll-to-bottom-bg']).toBe('#06b6d4');
242
+ expect(cssVars['--persona-scroll-to-bottom-fg']).toBe('#f9fafb');
243
+ expect(cssVars['--persona-scroll-to-bottom-border']).toBe('#111827');
244
+ expect(cssVars['--persona-scroll-to-bottom-size']).toBe('40px');
245
+ expect(cssVars['--persona-scroll-to-bottom-radius']).toBe('9999px');
246
+ expect(cssVars['--persona-scroll-to-bottom-shadow']).toBe(
247
+ '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)'
248
+ );
249
+ expect(cssVars['--persona-scroll-to-bottom-padding']).toBe('0.5rem 0.875rem');
250
+ expect(cssVars['--persona-scroll-to-bottom-gap']).toBe('0.5rem');
251
+ expect(cssVars['--persona-scroll-to-bottom-font-size']).toBe('0.875rem');
252
+ expect(cssVars['--persona-scroll-to-bottom-icon-size']).toBe('14px');
253
+ });
254
+
221
255
  it('lets config.toolCall.shadow override theme tool bubble shadow on the root element', () => {
222
256
  const el = document.createElement('div');
223
257
  applyThemeVariables(el, {