diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cfde95e491335ee43006bf0029ddf16a0eb932c4..e27cc4543b561dfa38c17132b9ab178585628af0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1779,6 +1779,8 @@ export default function ChatView(props: ChatViewProps) { const [composerTimelineInset, setComposerTimelineInset] = useState(0); const composerTimelineInsetRef = useRef(0); const composerRestingRef = useRef(false); + // The last overlay height the composer published for its settled layout. + const composerOverlayHeightRef = useRef(0); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); const isTimelineAtLogicalEnd = useCallback( @@ -5868,19 +5870,8 @@ export default function ChatView(props: ChatViewProps) { ? activePlan.steps : null; - const publishComposerOverlayHeight = useCallback( - (height: number) => { - const nextHeight = Math.ceil(height); - if (nextHeight <= 0) return; - const nextInset = resolveComposerTimelineInset({ - currentInset: composerTimelineInsetRef.current, - overlayHeight: nextHeight, - isResting: composerRestingRef.current, - }); - if (composerTimelineInsetRef.current !== nextInset) { - composerTimelineInsetRef.current = nextInset; - setComposerTimelineInset(nextInset); - } + const publishScrollToEndClearance = useCallback( + (overlayHeight: number) => { const mainSurface = composerOverlayElement?.querySelector( '[data-chat-composer-main-surface="true"]', ); @@ -5890,7 +5881,7 @@ export default function ChatView(props: ChatViewProps) { const clearance = composerOverlayElement && mainSurface && button ? resolveScrollToEndClearance({ - overlayHeight: nextHeight, + overlayHeight, mainSurfaceTop: mainSurface.getBoundingClientRect().top, button: button.getBoundingClientRect(), attachments: Array.from( @@ -5900,11 +5891,29 @@ export default function ChatView(props: ChatViewProps) { (element) => element.getBoundingClientRect(), ), }) - : nextHeight; + : overlayHeight; setScrollToEndClearance(clearance); }, [composerOverlayElement], ); + const publishComposerOverlayHeight = useCallback( + (height: number) => { + const nextHeight = Math.ceil(height); + if (nextHeight <= 0) return; + composerOverlayHeightRef.current = nextHeight; + const nextInset = resolveComposerTimelineInset({ + currentInset: composerTimelineInsetRef.current, + overlayHeight: nextHeight, + isResting: composerRestingRef.current, + }); + if (composerTimelineInsetRef.current !== nextInset) { + composerTimelineInsetRef.current = nextInset; + setComposerTimelineInset(nextInset); + } + publishScrollToEndClearance(nextHeight); + }, + [publishScrollToEndClearance], + ); // The composer reports its resting flag from a layout effect, which runs // before this component's own layout effects and before any resize // observation, so every measurement below sees the flag for its layout. @@ -5937,7 +5946,17 @@ export default function ChatView(props: ChatViewProps) { return () => { resizeObserver.disconnect(); }; - }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]); + }, [composerOverlayElement, publishComposerOverlayHeight]); + // The pill mounts and unmounts in the same commits that expand or rest the + // composer, and a fast fling lands there while the previous resting tween + // still pins the overlay at its old height. Measuring the overlay here would + // publish that stale height against the new resting flag, drop the timeline + // reservation, and yank the scroll position. The pill only needs its + // clearance, so it reuses the height the composer last published. + useLayoutEffect(() => { + if (!composerOverlayElement) return; + publishScrollToEndClearance(composerOverlayHeightRef.current); + }, [composerOverlayElement, publishScrollToEndClearance, showScrollToBottom]); const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); const activeThreadReferenceCopyTarget = useMemo( () =>