studio: stabilize reasoning panel scroll behavior and prevent composer overlap (#4587)
* fix(studio): reasoning panel scroll and thread footer overlap * refactor(studio): dedupe reasoning scroll lock teardown
This commit is contained in:
parent
19e9c60a8e
commit
cc1be75621
3 changed files with 79 additions and 11 deletions
|
|
@ -17,7 +17,6 @@ import {
|
|||
type ReasoningGroupComponent,
|
||||
type ReasoningMessagePartComponent,
|
||||
useAuiState,
|
||||
useScrollLock,
|
||||
} from "@assistant-ui/react";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { Idea01Icon } from "@hugeicons/core-free-icons";
|
||||
|
|
@ -34,6 +33,7 @@ import {
|
|||
useState,
|
||||
} from "react";
|
||||
const ANIMATION_DURATION = 200;
|
||||
const AUTO_SCROLL_THRESHOLD_PX = 24;
|
||||
|
||||
export const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", {
|
||||
variants: {
|
||||
|
|
@ -68,8 +68,49 @@ function ReasoningRoot({
|
|||
...props
|
||||
}: ReasoningRootProps) {
|
||||
const collapsibleRef = useRef<HTMLDivElement>(null);
|
||||
const lockCleanupRef = useRef<(() => void) | null>(null);
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
||||
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
lockCleanupRef.current?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const lockScroll = useCallback(() => {
|
||||
lockCleanupRef.current?.();
|
||||
|
||||
const animatedElement = collapsibleRef.current;
|
||||
if (!animatedElement) return;
|
||||
|
||||
let scrollContainer: HTMLElement | null = animatedElement;
|
||||
while (scrollContainer) {
|
||||
const { overflowY } = getComputedStyle(scrollContainer);
|
||||
if (overflowY === "scroll" || overflowY === "auto") {
|
||||
break;
|
||||
}
|
||||
scrollContainer = scrollContainer.parentElement;
|
||||
}
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const scrollPosition = scrollContainer.scrollTop;
|
||||
const resetPosition = () => {
|
||||
scrollContainer.scrollTop = scrollPosition;
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener("scroll", resetPosition);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
scrollContainer.removeEventListener("scroll", resetPosition);
|
||||
lockCleanupRef.current = null;
|
||||
};
|
||||
timeoutId = setTimeout(cleanup, ANIMATION_DURATION);
|
||||
lockCleanupRef.current = cleanup;
|
||||
}, []);
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
|
||||
|
|
@ -220,6 +261,8 @@ function ReasoningText({
|
|||
}: ComponentProps<"div"> & { streaming?: boolean }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const shouldAutoScrollRef = useRef(true);
|
||||
const detachedFromBottomRef = useRef(false);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(streaming && scrollRef.current)) {
|
||||
|
|
@ -227,8 +270,25 @@ function ReasoningText({
|
|||
}
|
||||
const el = scrollRef.current;
|
||||
const updateAutoScroll = () => {
|
||||
const currentScrollTop = el.scrollTop;
|
||||
if (currentScrollTop < lastScrollTopRef.current) {
|
||||
detachedFromBottomRef.current = true;
|
||||
}
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
shouldAutoScrollRef.current = distanceFromBottom <= 24;
|
||||
if (
|
||||
detachedFromBottomRef.current &&
|
||||
distanceFromBottom <= AUTO_SCROLL_THRESHOLD_PX
|
||||
) {
|
||||
detachedFromBottomRef.current = false;
|
||||
}
|
||||
shouldAutoScrollRef.current = !detachedFromBottomRef.current;
|
||||
lastScrollTopRef.current = currentScrollTop;
|
||||
};
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.deltaY < 0) {
|
||||
detachedFromBottomRef.current = true;
|
||||
shouldAutoScrollRef.current = false;
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(() => {
|
||||
if (shouldAutoScrollRef.current) {
|
||||
|
|
@ -236,16 +296,19 @@ function ReasoningText({
|
|||
}
|
||||
});
|
||||
el.addEventListener("scroll", updateAutoScroll);
|
||||
el.addEventListener("wheel", handleWheel, { passive: true });
|
||||
observer.observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
shouldAutoScrollRef.current = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastScrollTopRef.current = el.scrollTop;
|
||||
detachedFromBottomRef.current = false;
|
||||
updateAutoScroll();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
el.removeEventListener("scroll", updateAutoScroll);
|
||||
el.removeEventListener("wheel", handleWheel);
|
||||
};
|
||||
}, [streaming]);
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({
|
|||
}}
|
||||
/>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mt-auto flex w-full flex-col gap-4 overflow-visible bg-background pb-4 md:pb-4">
|
||||
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 z-20 mt-auto flex w-full flex-col gap-4 overflow-visible bg-background pb-4 md:pb-4">
|
||||
<ThreadScrollToBottom />
|
||||
<AuiIf condition={({ thread }) => !thread.isEmpty}>
|
||||
{!hideComposer && <ComposerAnimated />}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,18 @@
|
|||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as React from "react";
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
const Collapsible = React.forwardRef<
|
||||
React.ElementRef<typeof CollapsiblePrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Root>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<CollapsiblePrimitive.Root ref={ref} data-slot="collapsible" {...props} />
|
||||
);
|
||||
});
|
||||
Collapsible.displayName = CollapsiblePrimitive.Root.displayName;
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue