@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/dist/index.cjs +44 -44
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.global.js +67 -67
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +44 -44
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +514 -227
- package/dist/theme-editor.d.cts +32 -1
- package/dist/theme-editor.d.ts +32 -1
- package/dist/theme-editor.js +513 -227
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +19 -0
- package/dist/theme-reference.d.ts +19 -0
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +40 -0
- package/package.json +1 -1
- package/src/components/demo-carousel.ts +1 -1
- package/src/components/event-stream-view.test.ts +142 -0
- package/src/components/event-stream-view.ts +67 -28
- package/src/defaults.ts +15 -0
- package/src/scroll-to-bottom-defaults.test.ts +13 -0
- package/src/styles/widget.css +40 -0
- package/src/theme-editor/index.ts +1 -0
- package/src/theme-editor/role-mappings.ts +12 -0
- package/src/theme-editor/sections.test.ts +43 -0
- package/src/theme-editor/sections.ts +42 -0
- package/src/theme-reference.ts +8 -0
- package/src/types/theme.ts +10 -0
- package/src/types.ts +22 -0
- package/src/ui.scroll.test.ts +554 -0
- package/src/ui.ts +178 -83
- package/src/utils/auto-follow.test.ts +110 -0
- package/src/utils/auto-follow.ts +112 -0
- package/src/utils/theme.test.ts +34 -0
- package/src/utils/tokens.ts +49 -0
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
|
-
|
|
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
|
|
1857
|
-
const
|
|
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
|
-
|
|
1950
|
-
|
|
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
|
-
|
|
2008
|
+
const cancelSmoothScroll = () => {
|
|
2009
|
+
if (smoothScrollRAF !== null) {
|
|
2010
|
+
cancelAnimationFrame(smoothScrollRAF);
|
|
2011
|
+
smoothScrollRAF = null;
|
|
2012
|
+
}
|
|
2013
|
+
isAutoScrolling = false;
|
|
2014
|
+
};
|
|
1953
2015
|
|
|
1954
|
-
|
|
1955
|
-
|
|
2016
|
+
const cancelAutoScroll = () => {
|
|
2017
|
+
if (scrollRAF !== null) {
|
|
2018
|
+
cancelAnimationFrame(scrollRAF);
|
|
2019
|
+
scrollRAF = null;
|
|
1956
2020
|
}
|
|
2021
|
+
cancelSmoothScroll();
|
|
2022
|
+
};
|
|
1957
2023
|
|
|
1958
|
-
|
|
1959
|
-
|
|
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
|
-
|
|
2039
|
+
const pauseAutoScroll = () => {
|
|
2040
|
+
if (!autoFollow.pause()) return;
|
|
2041
|
+
cancelAutoScroll();
|
|
2042
|
+
syncScrollToBottomButton();
|
|
2043
|
+
};
|
|
1963
2044
|
|
|
1964
|
-
|
|
1965
|
-
|
|
2045
|
+
const resumeAutoScroll = () => {
|
|
2046
|
+
autoFollow.resume();
|
|
2047
|
+
syncScrollToBottomButton();
|
|
2048
|
+
};
|
|
1966
2049
|
|
|
1967
|
-
|
|
1968
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
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 (
|
|
3544
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/utils/theme.test.ts
CHANGED
|
@@ -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, {
|