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