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 {...@@ -22,6 +22,7 @@ import {
22 use,22 use,
23 useCallback,23 useCallback,
24 useEffect,24 useEffect,
25 useLayoutEffect,
25 useMemo,26 useMemo,
26 useRef,27 useRef,
27 useState,28 useState,
...@@ -68,6 +69,7 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage...@@ -68,6 +69,7 @@ import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImage
68import { ProposedPlanCard } from "./ProposedPlanCard";69import { ProposedPlanCard } from "./ProposedPlanCard";
69import { ChangedFilesCard } from "./ChangedFilesTree";70import { ChangedFilesCard } from "./ChangedFilesTree";
70import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";71import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation";
72import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring";
71import { MessageCopyButton } from "./MessageCopyButton";73import { MessageCopyButton } from "./MessageCopyButton";
72import {74import {
73 computeStableMessagesTimelineRows,75 computeStableMessagesTimelineRows,
...@@ -286,6 +288,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -286,6 +288,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({
286 const disclosureAnchorKeyRef = useRef<string | null>(null);288 const disclosureAnchorKeyRef = useRef<string | null>(null);
287 const disclosureSettleFrameRef = useRef<number | null>(null);289 const disclosureSettleFrameRef = useRef<number | null>(null);
288 const disclosureSettleSecondFrameRef = useRef<number | null>(null);290 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
290 useEffect(() => {303 useEffect(() => {
291 return () => {304 return () => {
apps/web/src/components/chat/timelineScrollAnchoring.test.tsx+33-2
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it, vi } from "vite-plus/test";
2import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring";2import {
3 getAnchoredTurnMetrics,
4 getRowBottom,
5 keepTimelineEndVisibleAfterOverlayGrowth,
6} from "./timelineScrollAnchoring";
37
4function buildState({8function buildState({
5 positions,9 positions,
...@@ -22,6 +26,33 @@ function buildState({...@@ -22,6 +26,33 @@ function buildState({
22}26}
2327
24describe("timeline scroll anchoring", () => {28describe("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
25 it("measures row bottoms from LegendList row position and size", () => {56 it("measures row bottoms from LegendList row position and size", () => {
26 const state = buildState({57 const state = buildState({
27 positions: [0, 120],58 positions: [0, 120],
apps/web/src/components/chat/timelineScrollAnchoring.ts+16
...@@ -19,6 +19,22 @@ export interface AnchoredTurnMetrics {...@@ -19,6 +19,22 @@ export interface AnchoredTurnMetrics {
19 readonly scrollDeltaToRevealEnd: number;19 readonly scrollDeltaToRevealEnd: number;
20}20}
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
22export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null {38export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null {
23 const top = state.positionAtIndex(index);39 const top = state.positionAtIndex(index);
24 const height = state.sizeAtIndex(index);40 const height = state.sizeAtIndex(index);