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) {
17791779 const [composerTimelineInset, setComposerTimelineInset] = useState(0);
17801780 const composerTimelineInsetRef = useRef(0);
17811781 const composerRestingRef = useRef(false);
1782 // The last overlay height the composer published for its settled layout.
1783 const composerOverlayHeightRef = useRef(0);
17821784 const [scrollToEndClearance, setScrollToEndClearance] = useState(0);
17831785 const isAtEndRef = useRef(true);
17841786 const isTimelineAtLogicalEnd = useCallback(
......@@ -5868,19 +5870,8 @@ export default function ChatView(props: ChatViewProps) {
58685870 ? activePlan.steps
58695871 : null;
58705872
5871 const publishComposerOverlayHeight = useCallback(
5872 (height: 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 }
5873 const publishScrollToEndClearance = useCallback(
5874 (overlayHeight: number) => {
58845875 const mainSurface = composerOverlayElement?.querySelector<HTMLElement>(
58855876 '[data-chat-composer-main-surface="true"]',
58865877 );
......@@ -5890,7 +5881,7 @@ export default function ChatView(props: ChatViewProps) {
58905881 const clearance =
58915882 composerOverlayElement && mainSurface && button
58925883 ? resolveScrollToEndClearance({
5893 overlayHeight: nextHeight,
5884 overlayHeight,
58945885 mainSurfaceTop: mainSurface.getBoundingClientRect().top,
58955886 button: button.getBoundingClientRect(),
58965887 attachments: Array.from(
......@@ -5900,11 +5891,29 @@ export default function ChatView(props: ChatViewProps) {
59005891 (element) => element.getBoundingClientRect(),
59015892 ),
59025893 })
5903 : nextHeight;
5894 : overlayHeight;
59045895 setScrollToEndClearance(clearance);
59055896 },
59065897 [composerOverlayElement],
59075898 );
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 );
59085917 // The composer reports its resting flag from a layout effect, which runs
59095918 // before this component's own layout effects and before any resize
59105919 // observation, so every measurement below sees the flag for its layout.
......@@ -5937,7 +5946,17 @@ export default function ChatView(props: ChatViewProps) {
59375946 return () => {
59385947 resizeObserver.disconnect();
59395948 };
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]);
59415960 const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef);
59425961 const activeThreadReferenceCopyTarget = useMemo(
59435962 () =>