studio: stop scroll hijack during generation and fix thinking panel layout shift (#4543)

* fix(chat): stabilize thinking panel and thread scroll during generation

* fix: match ChatGPT scroll and thinking panel behavior

- Remove autoScroll={false} from thread viewport to restore default
  follow-scroll during streaming (pauses when user scrolls up, resumes
  at bottom)
- Rewrite reasoning panel state: auto-opens on stream start, user can
  close during streaming, auto-collapses when reasoning ends, user can
  re-expand after collapse

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
Lee Jackson 2026-03-24 05:33:46 +00:00 committed by GitHub
commit 45e4a0473a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

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";
@ -67,29 +66,23 @@ function ReasoningRoot({
children,
...props
}: ReasoningRootProps) {
const collapsibleRef = useRef<HTMLDivElement>(null);
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
lockScroll();
}
if (!isControlled) {
setUncontrolledOpen(open);
}
controlledOnOpenChange?.(open);
},
[lockScroll, isControlled, controlledOnOpenChange],
[isControlled, controlledOnOpenChange],
);
return (
<Collapsible
ref={collapsibleRef}
data-slot="reasoning-root"
data-variant={variant}
open={isOpen}
@ -151,7 +144,7 @@ function ReasoningTrigger({
<CollapsibleTrigger
data-slot="reasoning-trigger"
className={cn(
"aui-reasoning-trigger group/trigger flex max-w-[75%] items-center gap-2 py-1 text-muted-foreground text-sm transition-colors hover:text-foreground",
"aui-reasoning-trigger group/trigger flex min-w-0 flex-1 items-center gap-2 py-1 text-muted-foreground text-sm transition-colors hover:text-foreground",
className,
)}
{...props}
@ -219,22 +212,34 @@ function ReasoningText({
...props
}: ComponentProps<"div"> & { streaming?: boolean }) {
const scrollRef = useRef<HTMLDivElement>(null);
const shouldAutoScrollRef = useRef(true);
useEffect(() => {
if (!(streaming && scrollRef.current)) {
return;
}
const el = scrollRef.current;
const updateAutoScroll = () => {
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
shouldAutoScrollRef.current = distanceFromBottom <= 24;
};
const observer = new MutationObserver(() => {
el.scrollTop = el.scrollHeight;
if (shouldAutoScrollRef.current) {
el.scrollTop = el.scrollHeight;
}
});
el.addEventListener("scroll", updateAutoScroll);
observer.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
shouldAutoScrollRef.current = true;
el.scrollTop = el.scrollHeight;
return () => observer.disconnect();
return () => {
observer.disconnect();
el.removeEventListener("scroll", updateAutoScroll);
};
}, [streaming]);
return (
@ -330,6 +335,7 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
});
const [manualOpen, setManualOpen] = useState(false);
const [dismissedWhileStreaming, setDismissedWhileStreaming] = useState(false);
const [duration, setDuration] = useState<number>(0);
const startTimeRef = useRef<number | null>(null);
@ -345,17 +351,23 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
}
}, [isReasoningStreaming]);
const isOpen = isReasoningStreaming || manualOpen;
// Reset dismissed flag when a new stream starts
useEffect(() => {
if (isReasoningStreaming) {
setDismissedWhileStreaming(false);
}
}, [isReasoningStreaming]);
const variant = isReasoningStreaming
? "outline"
: manualOpen
? "outline"
: "ghost";
// Derived: open during streaming (unless dismissed), or if user manually opened after
const isOpen = (isReasoningStreaming && !dismissedWhileStreaming) || manualOpen;
const variant = isOpen ? "outline" : "ghost";
// Allow closing during streaming (matches ChatGPT)
const handleOpenChange = useCallback(
(open: boolean) => {
if (!isReasoningStreaming) {
if (isReasoningStreaming) {
setDismissedWhileStreaming(!open);
} else {
setManualOpen(open);
}
},
@ -368,14 +380,17 @@ const ReasoningGroupImpl: ReasoningGroupComponent = ({
onOpenChange={handleOpenChange}
variant={variant}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ReasoningTrigger
className="min-w-0 flex-1"
active={isReasoningStreaming}
duration={duration || persistedDuration}
/>
{isOpen && !isReasoningStreaming && (
<ReasoningCopyButton startIndex={startIndex} endIndex={endIndex} />
)}
<div className="flex w-16 shrink-0 justify-end">
{isOpen && !isReasoningStreaming && (
<ReasoningCopyButton startIndex={startIndex} endIndex={endIndex} />
)}
</div>
</div>
<ReasoningContent
aria-busy={isReasoningStreaming}