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:
Lee Jackson 2026-03-25 12:32:31 +00:00 committed by GitHub
commit cc1be75621
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 79 additions and 11 deletions

View file

@ -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]);

View file

@ -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 />}

View file

@ -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