| 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,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 |
| 68 | import { ProposedPlanCard } from "./ProposedPlanCard"; | 69 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 69 | import { ChangedFilesCard } from "./ChangedFilesTree"; | 70 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 70 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; | 71 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; |
| 72 | import { keepTimelineEndVisibleAfterOverlayGrowth } from "./timelineScrollAnchoring"; | ||
| 71 | import { MessageCopyButton } from "./MessageCopyButton"; | 73 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 72 | import { | 74 | import { |
| 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]); | ||
| 289 | 302 | ||
| 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 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 2 | import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring"; | 2 | import { |
| 3 | getAnchoredTurnMetrics, | ||
| 4 | getRowBottom, | ||
| 5 | keepTimelineEndVisibleAfterOverlayGrowth, | ||
| 6 | } from "./timelineScrollAnchoring"; | ||
| 3 | 7 | ||
| 4 | function buildState({ | 8 | function buildState({ |
| 5 | positions, | 9 | positions, |
| ... | @@ -22,6 +26,33 @@ function buildState({ | ... | @@ -22,6 +26,33 @@ function buildState({ |
| 22 | } | 26 | } |
| 23 | 27 | ||
| 24 | describe("timeline scroll anchoring", () => { | 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 | 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 | } |
| 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 | export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null { | 38 | export 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); |