From 4eb835a4166d78e8220413a216b6c04346893159 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:13:46 +0000 Subject: [PATCH] fix(web): keep messages clear of composer banners --- .../src/components/chat/MessagesTimeline.tsx | 13 +++++++ .../chat/timelineScrollAnchoring.test.tsx | 35 +++++++++++++++++-- .../chat/timelineScrollAnchoring.ts | 16 +++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6d89140d1105..438a9ce90034 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -22,6 +22,7 @@ import { use, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -68,6 +69,7 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; +import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, @@ -286,6 +288,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const disclosureAnchorKeyRef = useRef(null); const disclosureSettleFrameRef = useRef(null); const disclosureSettleSecondFrameRef = useRef(null); + const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment); + + useLayoutEffect(() => { + keepTimelineEndVisibleAfterOverlayGrowth({ + timeline: listRef.current, + previousOverlayHeight: previousContentInsetEndAdjustmentRef.current, + overlayHeight: contentInsetEndAdjustment, + followingEnd: liveFollowEnabled && anchorMessageId === null, + }); + previousContentInsetEndAdjustmentRef.current = contentInsetEndAdjustment; + }, [anchorMessageId, contentInsetEndAdjustment, listRef, liveFollowEnabled]); useEffect(() => { return () => { diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index 1bf82c47a614..50453c55cb17 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vite-plus/test"; -import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { + getAnchoredTurnMetrics, + getRowBottom, + keepTimelineEndVisibleAfterOverlayGrowth, +} from "./timelineScrollAnchoring"; function buildState({ positions, @@ -22,6 +26,33 @@ function buildState({ } describe("timeline scroll anchoring", () => { + it("keeps the live edge visible when the composer overlay grows", () => { + const scrollToEnd = vi.fn(); + + keepTimelineEndVisibleAfterOverlayGrowth({ + timeline: { scrollToEnd }, + previousOverlayHeight: 120, + overlayHeight: 180, + followingEnd: true, + }); + + expect(scrollToEnd).toHaveBeenCalledOnce(); + expect(scrollToEnd).toHaveBeenCalledWith({ animated: false }); + }); + + it("leaves the scroll position alone while the user reads history", () => { + const scrollToEnd = vi.fn(); + + keepTimelineEndVisibleAfterOverlayGrowth({ + timeline: { scrollToEnd }, + previousOverlayHeight: 120, + overlayHeight: 180, + followingEnd: false, + }); + + expect(scrollToEnd).not.toHaveBeenCalled(); + }); + it("measures row bottoms from LegendList row position and size", () => { const state = buildState({ positions: [0, 120], diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 48d3fc7542df..f38d0920b28b 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -19,6 +19,22 @@ export interface AnchoredTurnMetrics { readonly scrollDeltaToRevealEnd: number; } +export function keepTimelineEndVisibleAfterOverlayGrowth({ + timeline, + previousOverlayHeight, + overlayHeight, + followingEnd, +}: { + readonly timeline: { scrollToEnd: (options: { animated: boolean }) => unknown } | null; + readonly previousOverlayHeight: number; + readonly overlayHeight: number; + readonly followingEnd: boolean; +}): void { + if (timeline && followingEnd && overlayHeight > previousOverlayHeight) { + void timeline.scrollToEnd({ animated: false }); + } +} + export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null { const top = state.positionAtIndex(index); const height = state.sizeAtIndex(index);