authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-09-17 18:47:13-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 18:47:13-05:00
logfcfd9f911a27ee3b813d64582c53d82dfa2e892b
treec39907706b66795e4e67a74775c821482244fa4d
parentd17f46d7631b178b6834596264f0f44f210d584b
signature Signed by PGP key B5690EEEBB952194

fix(web): keep chat from jumping when the scroll-to-end pill mounts (#12317)


1 files changed, 35 insertions(+), 16 deletions(-)

apps/web/src/components/ChatView.tsx+35-16
...@@ -1779,6 +1779,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -1779,6 +1779,8 @@ export default function ChatView(props: ChatViewProps) {
1779 const [composerTimelineInset, setComposerTimelineInset] = useState(0);1779 const [composerTimelineInset, setComposerTimelineInset] = useState(0);
1780 const composerTimelineInsetRef = useRef(0);1780 const composerTimelineInsetRef = useRef(0);
1781 const composerRestingRef = useRef(false);1781 const composerRestingRef = useRef(false);
1782 // The last overlay height the composer published for its settled layout.
1783 const composerOverlayHeightRef = useRef(0);
1782 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);1784 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
1783 const isAtEndRef = useRef(true);1785 const isAtEndRef = useRef(true);
1784 const isTimelineAtLogicalEnd = useCallback(1786 const isTimelineAtLogicalEnd = useCallback(
...@@ -5868,19 +5870,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -5868,19 +5870,8 @@ export default function ChatView(props: ChatViewProps) {
5868 ? activePlan.steps5870 ? activePlan.steps
5869 : null;5871 : null;
58705872
5871 const publishComposerOverlayHeight = useCallback(5873 const publishScrollToEndClearance = useCallback(
5872 (height: number) => {5874 (overlayHeight: number) => {
5873 const nextHeight = Math.ceil(height);
5874 if (nextHeight <= 0) return;
5875 const nextInset = resolveComposerTimelineInset({
5876 currentInset: composerTimelineInsetRef.current,
5877 overlayHeight: nextHeight,
5878 isResting: composerRestingRef.current,
5879 });
5880 if (composerTimelineInsetRef.current !== nextInset) {
5881 composerTimelineInsetRef.current = nextInset;
5882 setComposerTimelineInset(nextInset);
5883 }
5884 const mainSurface = composerOverlayElement?.querySelector<HTMLElement>(5875 const mainSurface = composerOverlayElement?.querySelector<HTMLElement>(
5885 '[data-chat-composer-main-surface="true"]',5876 '[data-chat-composer-main-surface="true"]',
5886 );5877 );
...@@ -5890,7 +5881,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5890,7 +5881,7 @@ export default function ChatView(props: ChatViewProps) {
5890 const clearance =5881 const clearance =
5891 composerOverlayElement && mainSurface && button5882 composerOverlayElement && mainSurface && button
5892 ? resolveScrollToEndClearance({5883 ? resolveScrollToEndClearance({
5893 overlayHeight: nextHeight,5884 overlayHeight,
5894 mainSurfaceTop: mainSurface.getBoundingClientRect().top,5885 mainSurfaceTop: mainSurface.getBoundingClientRect().top,
5895 button: button.getBoundingClientRect(),5886 button: button.getBoundingClientRect(),
5896 attachments: Array.from(5887 attachments: Array.from(
...@@ -5900,11 +5891,29 @@ export default function ChatView(props: ChatViewProps) {...@@ -5900,11 +5891,29 @@ export default function ChatView(props: ChatViewProps) {
5900 (element) => element.getBoundingClientRect(),5891 (element) => element.getBoundingClientRect(),
5901 ),5892 ),
5902 })5893 })
5903 : nextHeight;5894 : overlayHeight;
5904 setScrollToEndClearance(clearance);5895 setScrollToEndClearance(clearance);
5905 },5896 },
5906 [composerOverlayElement],5897 [composerOverlayElement],
5907 );5898 );
5899 const publishComposerOverlayHeight = useCallback(
5900 (height: number) => {
5901 const nextHeight = Math.ceil(height);
5902 if (nextHeight <= 0) return;
5903 composerOverlayHeightRef.current = nextHeight;
5904 const nextInset = resolveComposerTimelineInset({
5905 currentInset: composerTimelineInsetRef.current,
5906 overlayHeight: nextHeight,
5907 isResting: composerRestingRef.current,
5908 });
5909 if (composerTimelineInsetRef.current !== nextInset) {
5910 composerTimelineInsetRef.current = nextInset;
5911 setComposerTimelineInset(nextInset);
5912 }
5913 publishScrollToEndClearance(nextHeight);
5914 },
5915 [publishScrollToEndClearance],
5916 );
5908 // The composer reports its resting flag from a layout effect, which runs5917 // The composer reports its resting flag from a layout effect, which runs
5909 // before this component's own layout effects and before any resize5918 // before this component's own layout effects and before any resize
5910 // observation, so every measurement below sees the flag for its layout.5919 // observation, so every measurement below sees the flag for its layout.
...@@ -5937,7 +5946,17 @@ export default function ChatView(props: ChatViewProps) {...@@ -5937,7 +5946,17 @@ export default function ChatView(props: ChatViewProps) {
5937 return () => {5946 return () => {
5938 resizeObserver.disconnect();5947 resizeObserver.disconnect();
5939 };5948 };
5940 }, [composerOverlayElement, publishComposerOverlayHeight, showScrollToBottom]);5949 }, [composerOverlayElement, publishComposerOverlayHeight]);
5950 // The pill mounts and unmounts in the same commits that expand or rest the
5951 // composer, and a fast fling lands there while the previous resting tween
5952 // still pins the overlay at its old height. Measuring the overlay here would
5953 // publish that stale height against the new resting flag, drop the timeline
5954 // reservation, and yank the scroll position. The pill only needs its
5955 // clearance, so it reuses the height the composer last published.
5956 useLayoutEffect(() => {
5957 if (!composerOverlayElement) return;
5958 publishScrollToEndClearance(composerOverlayHeightRef.current);
5959 }, [composerOverlayElement, publishScrollToEndClearance, showScrollToBottom]);
5941 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);5960 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);
5942 const activeThreadReferenceCopyTarget = useMemo(5961 const activeThreadReferenceCopyTarget = useMemo(
5943 () =>5962 () =>