authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-08-21 21:00:45+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-21 14:00:45-04:00
log0a46daaf63e78cdd188f6eb944ce5ee463e63216
tree9ac7c9de25858b62c1176c4ab884318954dc2741
parentbe7d35aaeb49a04483ec5e0d2284e8b5b70a3b6e
signature Signed by PGP key B5690EEEBB952194

fix(web): keep messages clear of composer banners (#7792)


3 files changed, 62 insertions(+), 2 deletions(-)

apps/web/src/components/chat/MessagesTimeline.tsx+13
......@@ -22,6 +22,7 @@ import {
2222 use,
2323 useCallback,
2424 useEffect,
25 useLayoutEffect,
2526 useMemo,
2627 useRef,
2728 useState,
......@@ -68,6 +69,7 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage
6869import { ProposedPlanCard } from "./ProposedPlanCard";
6970import { ChangedFilesCard } from "./ChangedFilesTree";
7071import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
72import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring";
7173import { MessageCopyButton } from "./MessageCopyButton";
7274import {
7375 computeStableMessagesTimelineRows,
......@@ -286,6 +288,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({
286288 const disclosureAnchorKeyRef = useRef<string | null>(null);
287289 const disclosureSettleFrameRef = useRef<number | null>(null);
288290 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
291 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustment);
292
293 useLayoutEffect(() => {
294 keepTimelineEndVisibleAfterOverlayGrowth({
295 timeline: listRef.current,
296 previousOverlayHeight: previousContentInsetEndAdjustmentRef.current,
297 overlayHeight: contentInsetEndAdjustment,
298 followingEnd: liveFollowEnabled && anchorMessageId === null,
299 });
300 previousContentInsetEndAdjustmentRef.current = contentInsetEndAdjustment;
301 }, [anchorMessageId, contentInsetEndAdjustment, listRef, liveFollowEnabled]);
289302
290303 useEffect(() => {
291304 return () => {
apps/web/src/components/chat/timelineScrollAnchoring.test.tsx+33-2
......@@ -1,5 +1,9 @@
1import { describe, expect, it } from "vite-plus/test";
2import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring";
1import { describe, expect, it, vi } from "vite-plus/test";
2import {
3 getAnchoredTurnMetrics,
4 getRowBottom,
5 keepTimelineEndVisibleAfterOverlayGrowth,
6} from "./timelineScrollAnchoring";
37
48function buildState({
59 positions,
......@@ -22,6 +26,33 @@ function buildState({
2226}
2327
2428describe("timeline scroll anchoring", () => {
29 it("keeps the live edge visible when the composer overlay grows", () => {
30 const scrollToEnd = vi.fn();
31
32 keepTimelineEndVisibleAfterOverlayGrowth({
33 timeline: { scrollToEnd },
34 previousOverlayHeight: 120,
35 overlayHeight: 180,
36 followingEnd: true,
37 });
38
39 expect(scrollToEnd).toHaveBeenCalledOnce();
40 expect(scrollToEnd).toHaveBeenCalledWith({ animated: false });
41 });
42
43 it("leaves the scroll position alone while the user reads history", () => {
44 const scrollToEnd = vi.fn();
45
46 keepTimelineEndVisibleAfterOverlayGrowth({
47 timeline: { scrollToEnd },
48 previousOverlayHeight: 120,
49 overlayHeight: 180,
50 followingEnd: false,
51 });
52
53 expect(scrollToEnd).not.toHaveBeenCalled();
54 });
55
2556 it("measures row bottoms from LegendList row position and size", () => {
2657 const state = buildState({
2758 positions: [0, 120],
apps/web/src/components/chat/timelineScrollAnchoring.ts+16
......@@ -19,6 +19,22 @@ export interface AnchoredTurnMetrics {
1919 readonly scrollDeltaToRevealEnd: number;
2020}
2121
22export function keepTimelineEndVisibleAfterOverlayGrowth({
23 timeline,
24 previousOverlayHeight,
25 overlayHeight,
26 followingEnd,
27}: {
28 readonly timeline: { scrollToEnd: (options: { animated: boolean }) => unknown } | null;
29 readonly previousOverlayHeight: number;
30 readonly overlayHeight: number;
31 readonly followingEnd: boolean;
32}): void {
33 if (timeline && followingEnd && overlayHeight > previousOverlayHeight) {
34 void timeline.scrollToEnd({ animated: false });
35 }
36}
37
2238export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null {
2339 const top = state.positionAtIndex(index);
2440 const height = state.sizeAtIndex(index);