authorgravatar for github@jayz.usJay Meistrich <github@jayz.us> 2026-08-07 11:12:19+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 12:12:19+02:00
logb792ed9f7b9c7c6386a14a1697c4393567812546
treee3a7152594fd3ddae0bb9a5f737432379c97c28d
parent2e66b1fdfc63d39822cd58948bcf43e8fbc239b6
signature Signed by PGP key B5690EEEBB952194

fix(web): stabilize chat timeline positioning (#5449)

Co-authored-by: codex <codex@users.noreply.github.com>

5 files changed, 133 insertions(+), 202 deletions(-)

apps/web/package.json+1-1
...@@ -21,7 +21,7 @@...@@ -21,7 +21,7 @@
21 "@dnd-kit/utilities": "^3.2.2",21 "@dnd-kit/utilities": "^3.2.2",
22 "@effect/atom-react": "catalog:",22 "@effect/atom-react": "catalog:",
23 "@formkit/auto-animate": "^0.9.0",23 "@formkit/auto-animate": "^0.9.0",
24 "@legendapp/list": "3.2.0",24 "@legendapp/list": "3.3.3",
25 "@lexical/react": "^0.41.0",25 "@lexical/react": "^0.41.0",
26 "@pierre/diffs": "catalog:",26 "@pierre/diffs": "catalog:",
27 "@pierre/trees": "1.0.0-beta.4",27 "@pierre/trees": "1.0.0-beta.4",
apps/web/src/components/ChatView.tsx+32-104
...@@ -3532,12 +3532,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3532,12 +3532,8 @@ function ChatViewContent(props: ChatViewProps) {
3532 const activeTimelineAnchorIndexRef = useRef<number | null>(null);3532 const activeTimelineAnchorIndexRef = useRef<number | null>(null);
3533 const anchorUserScrollGenerationRef = useRef(0);3533 const anchorUserScrollGenerationRef = useRef(0);
3534 const liveFollowUserScrollGenerationRef = useRef<number | null>(0);3534 const liveFollowUserScrollGenerationRef = useRef<number | null>(0);
3535 const pendingAnchorScrollRestoreRef = useRef<{3535 // Manual navigation stops live-follow without removing anchored end space.
3536 readonly messageId: MessageId;3536 // Collapsing that space during a gesture clamps the viewport back to the end.
3537 readonly offset: number;
3538 readonly userScrollGeneration: number;
3539 } | null>(null);
3540 const anchorScrollRestoreFrameRef = useRef<number | null>(null);
3541 const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {3537 const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {
3542 anchorUserScrollGenerationRef.current += 1;3538 anchorUserScrollGenerationRef.current += 1;
3543 timelineScrollModeRef.current = "free-scrolling";3539 timelineScrollModeRef.current = "free-scrolling";
...@@ -3547,11 +3543,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3547,11 +3543,6 @@ function ChatViewContent(props: ChatViewProps) {
3547 positionedTimelineAnchorRef.current = null;3543 positionedTimelineAnchorRef.current = null;
3548 settledTimelineAnchorRef.current = null;3544 settledTimelineAnchorRef.current = null;
3549 activeTimelineAnchorIndexRef.current = null;3545 activeTimelineAnchorIndexRef.current = null;
3550 pendingAnchorScrollRestoreRef.current = null;
3551 if (anchorScrollRestoreFrameRef.current !== null) {
3552 cancelAnimationFrame(anchorScrollRestoreFrameRef.current);
3553 anchorScrollRestoreFrameRef.current = null;
3554 }
3555 }, []);3546 }, []);
3556 const cancelTimelineLiveFollowForUserNavigationRef = useRef(3547 const cancelTimelineLiveFollowForUserNavigationRef = useRef(
3557 cancelTimelineLiveFollowForUserNavigation,3548 cancelTimelineLiveFollowForUserNavigation,
...@@ -3607,7 +3598,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3607,7 +3598,6 @@ function ChatViewContent(props: ChatViewProps) {
3607 },3598 },
3608 [composerOverlayHeight],3599 [composerOverlayHeight],
3609 );3600 );
3610
3611 // Live-follow stays active after send/thread-open until an actual list scroll3601 // Live-follow stays active after send/thread-open until an actual list scroll
3612 // gesture opts out.3602 // gesture opts out.
3613 const scrollToEnd = useCallback((animated = false) => {3603 const scrollToEnd = useCallback((animated = false) => {
...@@ -3619,7 +3609,12 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3619,7 +3609,12 @@ function ChatViewContent(props: ChatViewProps) {
3619 activeTimelineAnchorIndexRef.current = null;3609 activeTimelineAnchorIndexRef.current = null;
3620 showScrollDebouncer.current.cancel();3610 showScrollDebouncer.current.cancel();
3621 setShowScrollToBottom(false);3611 setShowScrollToBottom(false);
3622 void legendListRef.current?.scrollToEnd?.({ animated });3612 setTimelineAnchor((current) =>
3613 current.messageId === null ? current : { ...current, messageId: null },
3614 );
3615 requestAnimationFrame(() => {
3616 void legendListRef.current?.scrollToEnd?.({ animated });
3617 });
3623 }, []);3618 }, []);
3624 useEffect(() => {3619 useEffect(() => {
3625 let removeListeners: (() => void) | null = null;3620 let removeListeners: (() => void) | null = null;
...@@ -3758,75 +3753,23 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3758,75 +3753,23 @@ function ChatViewContent(props: ChatViewProps) {
3758 }3753 }
3759 return;3754 return;
3760 }3755 }
3761 const scrollNode = list.getScrollableNode();3756 void list
3762 let finished = false;3757 .scrollToIndex({
3763 const finishAnimatedPositioning = () => {3758 index: anchorIndex,
3764 if (finished) {3759 animated: true,
3765 return;3760 viewPosition: 0,
3766 }3761 viewOffset: CHAT_LIST_ANCHOR_OFFSET,
3767 finished = true;3762 })
3768 window.clearTimeout(fallbackTimer);3763 .then(() => {
3769 scrollNode.removeEventListener("scrollend", finishAnimatedPositioning);3764 if (positionedTimelineAnchorRef.current !== messageId) {
3770 if (positionedTimelineAnchorRef.current !== messageId) {3765 return;
3771 return;3766 }
3772 }3767 settledTimelineAnchorRef.current = messageId;
3773 const scrollOffset = list.getState().scroll;3768 });
3774 void list.scrollToOffset({ offset: scrollOffset, animated: false });
3775 settledTimelineAnchorRef.current = messageId;
3776 };
3777 const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750);
3778 scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true });
3779 void list.scrollToIndex({
3780 index: anchorIndex,
3781 animated: true,
3782 viewPosition: 0,
3783 viewOffset: CHAT_LIST_ANCHOR_OFFSET,
3784 });
3785 });3769 });
3786 };3770 };
3787 requestAnimationFrame(() => positionAnchor(12));3771 requestAnimationFrame(() => positionAnchor(12));
3788 }, []);3772 }, []);
3789 const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => {
3790 if (settledTimelineAnchorRef.current !== messageId) {
3791 return;
3792 }
3793 if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) {
3794 return;
3795 }
3796 const scrollOffset = legendListRef.current?.getState().scroll;
3797 if (scrollOffset === undefined) {
3798 return;
3799 }
3800 if (pendingAnchorScrollRestoreRef.current === null) {
3801 pendingAnchorScrollRestoreRef.current = {
3802 messageId,
3803 offset: scrollOffset,
3804 userScrollGeneration: anchorUserScrollGenerationRef.current,
3805 };
3806 }
3807 if (anchorScrollRestoreFrameRef.current !== null) {
3808 return;
3809 }
3810 anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => {
3811 anchorScrollRestoreFrameRef.current = null;
3812 const pending = pendingAnchorScrollRestoreRef.current;
3813 pendingAnchorScrollRestoreRef.current = null;
3814 if (
3815 pending &&
3816 settledTimelineAnchorRef.current === pending.messageId &&
3817 pending.userScrollGeneration === anchorUserScrollGenerationRef.current
3818 ) {
3819 const list = legendListRef.current;
3820 const currentScrollOffset = list?.getState().scroll;
3821 if (
3822 typeof currentScrollOffset === "number" &&
3823 Math.abs(currentScrollOffset - pending.offset) <= 2
3824 ) {
3825 void list?.scrollToOffset({ offset: pending.offset, animated: false });
3826 }
3827 }
3828 });
3829 }, []);
38303773
3831 const onIsAtEndChange = useCallback((isAtEnd: boolean) => {3774 const onIsAtEndChange = useCallback((isAtEnd: boolean) => {
3832 if (3775 if (
...@@ -3852,6 +3795,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3852,6 +3795,9 @@ function ChatViewContent(props: ChatViewProps) {
3852 }3795 }
3853 }, []);3796 }, []);
38543797
3798 // Anchored end space intentionally disables LegendList's normal end-follow so
3799 // the sent message can stay near the top. T3 only owns streaming adjustments
3800 // during that mode; LegendList owns ordinary end-follow everywhere else.
3855 useEffect(() => {3801 useEffect(() => {
3856 if (!activeThread?.id) {3802 if (!activeThread?.id) {
3857 return;3803 return;
...@@ -3859,6 +3805,9 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3859,6 +3805,9 @@ function ChatViewContent(props: ChatViewProps) {
3859 if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) {3805 if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) {
3860 return;3806 return;
3861 }3807 }
3808 if (timelineScrollModeRef.current !== "anchoring-new-turn") {
3809 return;
3810 }
38623811
3863 let secondFrame: number | null = null;3812 let secondFrame: number | null = null;
3864 const frame = requestAnimationFrame(() => {3813 const frame = requestAnimationFrame(() => {
...@@ -3880,28 +3829,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3880,28 +3829,13 @@ function ChatViewContent(props: ChatViewProps) {
3880 return;3829 return;
3881 }3830 }
38823831
3883 if (timelineScrollModeRef.current === "anchoring-new-turn") {3832 const metrics = getActiveTimelineTurnMetrics(list);
3884 const metrics = getActiveTimelineTurnMetrics(list);3833 if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) {
3885 if (!metrics) {
3886 return;
3887 }
3888 if (metrics.scrollDeltaToRevealEnd <= 1) {
3889 return;
3890 }
3891
3892 const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd;
3893 void list.scrollToOffset({ offset: nextOffset, animated: false });
3894 return;
3895 }
3896
3897 if (timelineScrollModeRef.current !== "following-end") {
3898 return;
3899 }
3900 if (!timelineRealContentOverflowsViewport(list)) {
3901 return;3834 return;
3902 }3835 }
39033836
3904 void list.scrollToEnd?.({ animated: false });3837 const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd;
3838 void list.scrollToOffset({ offset: nextOffset, animated: false });
3905 });3839 });
3906 });3840 });
39073841
...@@ -3911,12 +3845,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3911,12 +3845,7 @@ function ChatViewContent(props: ChatViewProps) {
3911 cancelAnimationFrame(secondFrame);3845 cancelAnimationFrame(secondFrame);
3912 }3846 }
3913 };3847 };
3914 }, [3848 }, [activeThread?.id, timelineEntries, getActiveTimelineTurnMetrics]);
3915 activeThread?.id,
3916 timelineEntries,
3917 getActiveTimelineTurnMetrics,
3918 timelineRealContentOverflowsViewport,
3919 ]);
39203849
3921 useEffect(() => {3850 useEffect(() => {
3922 setPullRequestDialogState(null);3851 setPullRequestDialogState(null);
...@@ -6118,7 +6047,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6118,7 +6047,6 @@ function ChatViewContent(props: ChatViewProps) {
6118 skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}6047 skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
6119 anchorMessageId={timelineAnchorMessageId}6048 anchorMessageId={timelineAnchorMessageId}
6120 onAnchorReady={onTimelineAnchorReady}6049 onAnchorReady={onTimelineAnchorReady}
6121 onAnchorSizeChanged={onTimelineAnchorSizeChanged}
6122 contentInsetEndAdjustment={composerOverlayHeight}6050 contentInsetEndAdjustment={composerOverlayHeight}
6123 liveFollowEnabled={timelineLiveFollowEnabled}6051 liveFollowEnabled={timelineLiveFollowEnabled}
6124 onIsAtEndChange={onIsAtEndChange}6052 onIsAtEndChange={onIsAtEndChange}
apps/web/src/components/chat/MessagesTimeline.test.tsx+7-7
...@@ -18,7 +18,6 @@ vi.mock("@legendapp/list/react", async () => {...@@ -18,7 +18,6 @@ vi.mock("@legendapp/list/react", async () => {
18 anchorMaxSize?: number;18 anchorMaxSize?: number;
19 anchorOffset?: number;19 anchorOffset?: number;
20 onReady?: (info: { anchorIndex: number }) => void;20 onReady?: (info: { anchorIndex: number }) => void;
21 onSizeChanged?: (size: number) => void;
22 };21 };
23 contentInsetEndAdjustment?: number;22 contentInsetEndAdjustment?: number;
24 className?: string;23 className?: string;
...@@ -42,7 +41,6 @@ vi.mock("@legendapp/list/react", async () => {...@@ -42,7 +41,6 @@ vi.mock("@legendapp/list/react", async () => {
42 ref?: Ref<LegendListRef>;41 ref?: Ref<LegendListRef>;
43 }) => {42 }) => {
44 if (props.anchoredEndSpace) {43 if (props.anchoredEndSpace) {
45 props.anchoredEndSpace.onSizeChanged?.(240);
46 props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex });44 props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex });
47 }45 }
48 return (46 return (
...@@ -90,6 +88,11 @@ vi.mock("@legendapp/list/react", async () => {...@@ -90,6 +88,11 @@ vi.mock("@legendapp/list/react", async () => {
90 ? props.maintainVisibleContentPosition.size88 ? props.maintainVisibleContentPosition.size
91 : undefined89 : undefined
92 }90 }
91 data-maintain-visible-content-position-restore={
92 typeof props.maintainVisibleContentPosition === "object"
93 ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition)
94 : undefined
95 }
93 >96 >
94 {props.ListHeaderComponent}97 {props.ListHeaderComponent}
95 {props.data.map((item) => (98 {props.data.map((item) => (
...@@ -192,7 +195,6 @@ function buildProps() {...@@ -192,7 +195,6 @@ function buildProps() {
192 workspaceRoot: undefined,195 workspaceRoot: undefined,
193 anchorMessageId: null,196 anchorMessageId: null,
194 onAnchorReady: () => {},197 onAnchorReady: () => {},
195 onAnchorSizeChanged: () => {},
196 contentInsetEndAdjustment: 0,198 contentInsetEndAdjustment: 0,
197 liveFollowEnabled: true,199 liveFollowEnabled: true,
198 onIsAtEndChange: () => {},200 onIsAtEndChange: () => {},
...@@ -386,7 +388,6 @@ describe("MessagesTimeline", () => {...@@ -386,7 +388,6 @@ describe("MessagesTimeline", () => {
386388
387 it("anchors a sent attachment message using its measured height", () => {389 it("anchors a sent attachment message using its measured height", () => {
388 const onAnchorReady = vi.fn();390 const onAnchorReady = vi.fn();
389 const onAnchorSizeChanged = vi.fn();
390 const firstEntry = buildUserTimelineEntry("First prompt.");391 const firstEntry = buildUserTimelineEntry("First prompt.");
391 const secondEntry = {392 const secondEntry = {
392 ...buildUserTimelineEntry("Newest prompt."),393 ...buildUserTimelineEntry("Newest prompt."),
...@@ -411,7 +412,6 @@ describe("MessagesTimeline", () => {...@@ -411,7 +412,6 @@ describe("MessagesTimeline", () => {
411 {...buildProps()}412 {...buildProps()}
412 anchorMessageId={secondEntry.message.id}413 anchorMessageId={secondEntry.message.id}
413 onAnchorReady={onAnchorReady}414 onAnchorReady={onAnchorReady}
414 onAnchorSizeChanged={onAnchorSizeChanged}
415 contentInsetEndAdjustment={144}415 contentInsetEndAdjustment={144}
416 timelineEntries={[firstEntry, secondEntry]}416 timelineEntries={[firstEntry, secondEntry]}
417 />,417 />,
...@@ -426,10 +426,10 @@ describe("MessagesTimeline", () => {...@@ -426,10 +426,10 @@ describe("MessagesTimeline", () => {
426 expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');426 expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"');
427 expect(markup).toContain('data-maintain-visible-content-position="object"');427 expect(markup).toContain('data-maintain-visible-content-position="object"');
428 expect(markup).toContain('data-maintain-visible-content-position-data="true"');428 expect(markup).toContain('data-maintain-visible-content-position-data="true"');
429 expect(markup).toContain('data-maintain-visible-content-position-size="false"');429 expect(markup).toContain('data-maintain-visible-content-position-size="true"');
430 expect(markup).toContain('data-maintain-visible-content-position-restore="true"');
430 expect(onAnchorReady).toHaveBeenCalledOnce();431 expect(onAnchorReady).toHaveBeenCalledOnce();
431 expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1);432 expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1);
432 expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240);
433 });433 });
434434
435 it("renders collapse controls for long user messages", () => {435 it("renders collapse controls for long user messages", () => {
apps/web/src/components/chat/MessagesTimeline.tsx+84-69
...@@ -29,7 +29,6 @@ import {...@@ -29,7 +29,6 @@ import {
29 type MouseEvent,29 type MouseEvent,
30 type ReactNode,30 type ReactNode,
31} from "react";31} from "react";
32import { flushSync } from "react-dom";
33import { LegendList, type LegendListRef } from "@legendapp/list/react";32import { LegendList, type LegendListRef } from "@legendapp/list/react";
34import { FileDiff } from "@pierre/diffs/react";33import { FileDiff } from "@pierre/diffs/react";
35import {34import {
...@@ -142,7 +141,7 @@ interface TimelineRowSharedState {...@@ -142,7 +141,7 @@ interface TimelineRowSharedState {
142 onImageExpand: (preview: ExpandedImagePreview) => void;141 onImageExpand: (preview: ExpandedImagePreview) => void;
143 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;142 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
144 onToggleTurnFold: (turnId: TurnId) => void;143 onToggleTurnFold: (turnId: TurnId) => void;
145 onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void;144 onToggleWorkGroup: (groupId: string, anchorKey: string) => void;
146 agentPanelModel: AgentPanelModel;145 agentPanelModel: AgentPanelModel;
147 onOpenAgents: () => void;146 onOpenAgents: () => void;
148}147}
...@@ -189,6 +188,14 @@ function TimelineLoadEarlierHeader({...@@ -189,6 +188,14 @@ function TimelineLoadEarlierHeader({
189}188}
190const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;189const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />;
191const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];190const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = [];
191const TIMELINE_MAINTAIN_SCROLL_AT_END = {
192 animated: false,
193 on: {
194 dataChange: true,
195 itemLayout: true,
196 layout: true,
197 },
198} as const;
192199
193// ---------------------------------------------------------------------------200// ---------------------------------------------------------------------------
194// Props (public API)201// Props (public API)
...@@ -220,7 +227,6 @@ interface MessagesTimelineProps {...@@ -220,7 +227,6 @@ interface MessagesTimelineProps {
220 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;227 skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
221 anchorMessageId: MessageId | null;228 anchorMessageId: MessageId | null;
222 onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;229 onAnchorReady: (messageId: MessageId, anchorIndex: number) => void;
223 onAnchorSizeChanged: (messageId: MessageId, size: number) => void;
224 contentInsetEndAdjustment: number;230 contentInsetEndAdjustment: number;
225 /**231 /**
226 * Whether the timeline should keep pinning to the live edge as content232 * Whether the timeline should keep pinning to the live edge as content
...@@ -267,7 +273,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -267,7 +273,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
267 skills = EMPTY_TIMELINE_SKILLS,273 skills = EMPTY_TIMELINE_SKILLS,
268 anchorMessageId,274 anchorMessageId,
269 onAnchorReady,275 onAnchorReady,
270 onAnchorSizeChanged,
271 contentInsetEndAdjustment,276 contentInsetEndAdjustment,
272 liveFollowEnabled,277 liveFollowEnabled,
273 onIsAtEndChange,278 onIsAtEndChange,
...@@ -278,51 +283,85 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -278,51 +283,85 @@ export const MessagesTimeline = memo(function MessagesTimeline({
278}: MessagesTimelineProps) {283}: MessagesTimelineProps) {
279 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());284 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
280 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());285 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
286 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
281 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());287 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
288 const disclosureAnchorKeyRef = useRef<string | null>(null);
289 const disclosureSettleFrameRef = useRef<number | null>(null);
290 const disclosureSettleSecondFrameRef = useRef<number | null>(null);
282291
283 const onToggleTurnFold = useCallback((turnId: TurnId) => {292 useEffect(() => {
284 setExpandedTurnIds((existing) => {293 return () => {
285 const next = new Set(existing);294 if (disclosureSettleFrameRef.current !== null) {
286 if (next.has(turnId)) {295 cancelAnimationFrame(disclosureSettleFrameRef.current);
287 next.delete(turnId);
288 } else {
289 next.add(turnId);
290 }296 }
291 return next;297 if (disclosureSettleSecondFrameRef.current !== null) {
292 });298 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
299 }
300 };
293 }, []);301 }, []);
294 const onToggleWorkGroup = useCallback(
295 (groupId: string, anchorElement?: HTMLElement) => {
296 const anchorBottomBeforeToggle = anchorElement?.getBoundingClientRect().bottom ?? null;
297302
298 flushSync(() => {303 const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string) => {
299 setExpandedWorkGroupIds((existing) => {304 disclosureAnchorKeyRef.current = anchorKey;
300 const next = new Set(existing);305 setDisclosureToggleSettling(true);
301 if (next.has(groupId)) {306 if (disclosureSettleFrameRef.current !== null) {
302 next.delete(groupId);307 cancelAnimationFrame(disclosureSettleFrameRef.current);
303 } else {308 }
304 next.add(groupId);309 if (disclosureSettleSecondFrameRef.current !== null) {
305 }310 cancelAnimationFrame(disclosureSettleSecondFrameRef.current);
306 return next;311 }
307 });312 disclosureSettleFrameRef.current = requestAnimationFrame(() => {
313 disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => {
314 disclosureAnchorKeyRef.current = null;
315 setDisclosureToggleSettling(false);
316 disclosureSettleFrameRef.current = null;
317 disclosureSettleSecondFrameRef.current = null;
308 });318 });
319 });
320 }, []);
309321
310 if (anchorBottomBeforeToggle === null || !anchorElement) {322 const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => {
311 return;323 const disclosureAnchorKey = disclosureAnchorKeyRef.current;
312 }324 return disclosureAnchorKey === null || row.id === disclosureAnchorKey;
325 }, []);
313326
314 const delta = anchorElement.getBoundingClientRect().bottom - anchorBottomBeforeToggle;327 const maintainVisibleContentPosition = useMemo(
315 if (Math.abs(delta) < 0.5) {328 () => ({
316 return;329 data: true,
317 }330 size: true,
331 shouldRestorePosition: shouldRestoreVisibleContentPosition,
332 }),
333 [shouldRestoreVisibleContentPosition],
334 );
318335
319 const list = listRef.current;336 const onToggleTurnFold = useCallback(
320 const currentScroll = list?.getState?.().scroll;337 (turnId: TurnId) => {
321 if (list && typeof currentScroll === "number") {338 suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`);
322 list.scrollToOffset({ offset: currentScroll + delta, animated: false });339 setExpandedTurnIds((existing) => {
323 }340 const next = new Set(existing);
341 if (next.has(turnId)) {
342 next.delete(turnId);
343 } else {
344 next.add(turnId);
345 }
346 return next;
347 });
348 },
349 [suspendEndScrollMaintenanceForDisclosure],
350 );
351 const onToggleWorkGroup = useCallback(
352 (groupId: string, anchorKey: string) => {
353 suspendEndScrollMaintenanceForDisclosure(anchorKey);
354 setExpandedWorkGroupIds((existing) => {
355 const next = new Set(existing);
356 if (next.has(groupId)) {
357 next.delete(groupId);
358 } else {
359 next.add(groupId);
360 }
361 return next;
362 });
324 },363 },
325 [listRef],364 [suspendEndScrollMaintenanceForDisclosure],
326 );365 );
327366
328 // An in-session interrupt leaves its turn expanded so the user keeps their367 // An in-session interrupt leaves its turn expanded so the user keeps their
...@@ -394,22 +433,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -394,22 +433,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({
394 },433 },
395 [anchorMessageId, onAnchorReady],434 [anchorMessageId, onAnchorReady],
396 );435 );
397 const handleAnchorSizeChanged = useCallback(
398 (size: number) => {
399 if (anchorMessageId !== null) {
400 onAnchorSizeChanged(anchorMessageId, size);
401 }
402 },
403 [anchorMessageId, onAnchorSizeChanged],
404 );
405 const anchoredEndSpace = useMemo(() => {436 const anchoredEndSpace = useMemo(() => {
406 const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>437 const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) =>
407 row.kind === "message" ? row.message.id : null,438 row.kind === "message" ? row.message.id : null,
408 );439 );
409 return config440 return config ? { ...config, onReady: handleAnchorReady } : undefined;
410 ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged }441 }, [anchorMessageId, handleAnchorReady, rows]);
411 : undefined;
412 }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]);
413442
414 const handleScroll = useCallback(() => {443 const handleScroll = useCallback(() => {
415 const state = listRef.current?.getState?.();444 const state = listRef.current?.getState?.();
...@@ -554,21 +583,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -554,21 +583,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({
554 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}583 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
555 contentInsetEndAdjustment={contentInsetEndAdjustment}584 contentInsetEndAdjustment={contentInsetEndAdjustment}
556 maintainScrollAtEnd={585 maintainScrollAtEnd={
557 anchoredEndSpace || !liveFollowEnabled586 anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling
558 ? false587 ? false
559 : {588 : TIMELINE_MAINTAIN_SCROLL_AT_END
560 animated: false,
561 on: {
562 dataChange: true,
563 itemLayout: true,
564 layout: true,
565 },
566 }
567 }589 }
568 maintainVisibleContentPosition={{590 maintainVisibleContentPosition={maintainVisibleContentPosition}
569 data: true,
570 size: false,
571 }}
572 onScroll={handleScroll}591 onScroll={handleScroll}
573 className={cn(592 className={cn(
574 "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",593 "scrollbar-gutter-both h-full min-h-0 overflow-x-hidden overscroll-y-contain px-3 [overflow-anchor:none] sm:px-5",
...@@ -1381,11 +1400,7 @@ function WorkGroupToggleTimelineRow({...@@ -1381,11 +1400,7 @@ function WorkGroupToggleTimelineRow({
1381 type="button"1400 type="button"
1382 className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"1401 className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-0.5 py-0.5 text-left text-[12px] leading-5 transition-colors duration-150 hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
1383 aria-expanded={row.expanded}1402 aria-expanded={row.expanded}
1384 onClick={(event) => {1403 onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)}
1385 const anchorElement =
1386 event.currentTarget.closest<HTMLElement>("[data-timeline-row-id]") ?? event.currentTarget;
1387 ctx.onToggleWorkGroup(row.groupId, anchorElement);
1388 }}
1389 >1404 >
1390 <span className="flex size-5 shrink-0 items-center justify-center text-icon-muted">1405 <span className="flex size-5 shrink-0 items-center justify-center text-icon-muted">
1391 <ChevronDownIcon1406 <ChevronDownIcon
pnpm-lock.yaml+9-21
...@@ -536,8 +536,8 @@ importers:...@@ -536,8 +536,8 @@ importers:
536 specifier: ^0.9.0536 specifier: ^0.9.0
537 version: 0.9.0537 version: 0.9.0
538 '@legendapp/list':538 '@legendapp/list':
539 specifier: 3.2.0539 specifier: 3.3.3
540 version: 3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)540 version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
541 '@lexical/react':541 '@lexical/react':
542 specifier: ^0.41.0542 specifier: ^0.41.0
543 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)543 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31)
...@@ -2949,18 +2949,6 @@ packages:...@@ -2949,18 +2949,6 @@ packages:
2949 resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==}2949 resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==}
2950 engines: {node: '>=12'}2950 engines: {node: '>=12'}
29512951
2952 '@legendapp/list@3.2.0':
2953 resolution: {integrity: sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg==}
2954 peerDependencies:
2955 react: '*'
2956 react-dom: '*'
2957 react-native: '*'
2958 peerDependenciesMeta:
2959 react-dom:
2960 optional: true
2961 react-native:
2962 optional: true
2963
2964 '@legendapp/list@3.3.3':2952 '@legendapp/list@3.3.3':
2965 resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==}2953 resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==}
2966 peerDependencies:2954 peerDependencies:
...@@ -12998,13 +12986,6 @@ snapshots:...@@ -12998,13 +12986,6 @@ snapshots:
12998 dependencies:12986 dependencies:
12999 jsbi: 4.3.212987 jsbi: 4.3.2
1300012988
13001 '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
13002 dependencies:
13003 react: 19.2.6
13004 use-sync-external-store: 1.6.0(react@19.2.6)
13005 optionalDependencies:
13006 react-dom: 19.2.6(react@19.2.6)
13007
13008 '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':12989 '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
13009 dependencies:12990 dependencies:
13010 react: 19.2.312991 react: 19.2.3
...@@ -13013,6 +12994,13 @@ snapshots:...@@ -13013,6 +12994,13 @@ snapshots:
13013 react-dom: 19.2.3(react@19.2.3)12994 react-dom: 19.2.3(react@19.2.3)
13014 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)12995 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1301512996
12997 '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
12998 dependencies:
12999 react: 19.2.6
13000 use-sync-external-store: 1.6.0(react@19.2.6)
13001 optionalDependencies:
13002 react-dom: 19.2.6(react@19.2.6)
13003
13016 '@lexical/clipboard@0.41.0':13004 '@lexical/clipboard@0.41.0':
13017 dependencies:13005 dependencies:
13018 '@lexical/html': 0.41.013006 '@lexical/html': 0.41.0