diff --git a/apps/web/package.json b/apps/web/package.json index 83f55abcfd5..0a2f0d8e86b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.2.0", + "@legendapp/list": "3.3.3", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 84f8b7ee509..a7c2f785362 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3527,12 +3527,8 @@ function ChatViewContent(props: ChatViewProps) { const activeTimelineAnchorIndexRef = useRef(null); const anchorUserScrollGenerationRef = useRef(0); const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); + // Manual navigation stops live-follow without removing anchored end space. + // Collapsing that space during a gesture clamps the viewport back to the end. const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; @@ -3542,11 +3538,6 @@ function ChatViewContent(props: ChatViewProps) { positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } }, []); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, @@ -3602,7 +3593,6 @@ function ChatViewContent(props: ChatViewProps) { }, [composerOverlayHeight], ); - // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. const scrollToEnd = useCallback((animated = false) => { @@ -3614,7 +3604,12 @@ function ChatViewContent(props: ChatViewProps) { activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - void legendListRef.current?.scrollToEnd?.({ animated }); + setTimelineAnchor((current) => + current.messageId === null ? current : { ...current, messageId: null }, + ); + requestAnimationFrame(() => { + void legendListRef.current?.scrollToEnd?.({ animated }); + }); }, []); useEffect(() => { let removeListeners: (() => void) | null = null; @@ -3753,75 +3748,23 @@ function ChatViewContent(props: ChatViewProps) { } return; } - const scrollNode = list.getScrollableNode(); - let finished = false; - const finishAnimatedPositioning = () => { - if (finished) { - return; - } - finished = true; - window.clearTimeout(fallbackTimer); - scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const scrollOffset = list.getState().scroll; - void list.scrollToOffset({ offset: scrollOffset, animated: false }); - settledTimelineAnchorRef.current = messageId; - }; - const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750); - scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); - void list.scrollToIndex({ - index: anchorIndex, - animated: true, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }); + void list + .scrollToIndex({ + index: anchorIndex, + animated: true, + viewPosition: 0, + viewOffset: CHAT_LIST_ANCHOR_OFFSET, + }) + .then(() => { + if (positionedTimelineAnchorRef.current !== messageId) { + return; + } + settledTimelineAnchorRef.current = messageId; + }); }); }; requestAnimationFrame(() => positionAnchor(12)); }, []); - const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = legendListRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== null) { - return; - } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { - const list = legendListRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }, []); const onIsAtEndChange = useCallback((isAtEnd: boolean) => { if ( @@ -3847,6 +3790,9 @@ function ChatViewContent(props: ChatViewProps) { } }, []); + // Anchored end space intentionally disables LegendList's normal end-follow so + // the sent message can stay near the top. T3 only owns streaming adjustments + // during that mode; LegendList owns ordinary end-follow everywhere else. useEffect(() => { if (!activeThread?.id) { return; @@ -3854,6 +3800,9 @@ function ChatViewContent(props: ChatViewProps) { if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { return; } + if (timelineScrollModeRef.current !== "anchoring-new-turn") { + return; + } let secondFrame: number | null = null; const frame = requestAnimationFrame(() => { @@ -3875,28 +3824,13 @@ function ChatViewContent(props: ChatViewProps) { return; } - if (timelineScrollModeRef.current === "anchoring-new-turn") { - const metrics = getActiveTimelineTurnMetrics(list); - if (!metrics) { - return; - } - if (metrics.scrollDeltaToRevealEnd <= 1) { - return; - } - - const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; - void list.scrollToOffset({ offset: nextOffset, animated: false }); - return; - } - - if (timelineScrollModeRef.current !== "following-end") { - return; - } - if (!timelineRealContentOverflowsViewport(list)) { + const metrics = getActiveTimelineTurnMetrics(list); + if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) { return; } - void list.scrollToEnd?.({ animated: false }); + const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; + void list.scrollToOffset({ offset: nextOffset, animated: false }); }); }); @@ -3906,12 +3840,7 @@ function ChatViewContent(props: ChatViewProps) { cancelAnimationFrame(secondFrame); } }; - }, [ - activeThread?.id, - timelineEntries, - getActiveTimelineTurnMetrics, - timelineRealContentOverflowsViewport, - ]); + }, [activeThread?.id, timelineEntries, getActiveTimelineTurnMetrics]); useEffect(() => { setPullRequestDialogState(null); @@ -6103,7 +6032,6 @@ function ChatViewContent(props: ChatViewProps) { skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} - onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0f6832f4063..f22130906eb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -18,7 +18,6 @@ vi.mock("@legendapp/list/react", async () => { anchorMaxSize?: number; anchorOffset?: number; onReady?: (info: { anchorIndex: number }) => void; - onSizeChanged?: (size: number) => void; }; contentInsetEndAdjustment?: number; className?: string; @@ -42,7 +41,6 @@ vi.mock("@legendapp/list/react", async () => { ref?: Ref; }) => { if (props.anchoredEndSpace) { - props.anchoredEndSpace.onSizeChanged?.(240); props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); } return ( @@ -90,6 +88,11 @@ vi.mock("@legendapp/list/react", async () => { ? props.maintainVisibleContentPosition.size : undefined } + data-maintain-visible-content-position-restore={ + typeof props.maintainVisibleContentPosition === "object" + ? Boolean(props.maintainVisibleContentPosition.shouldRestorePosition) + : undefined + } > {props.ListHeaderComponent} {props.data.map((item) => ( @@ -192,7 +195,6 @@ function buildProps() { workspaceRoot: undefined, anchorMessageId: null, onAnchorReady: () => {}, - onAnchorSizeChanged: () => {}, contentInsetEndAdjustment: 0, liveFollowEnabled: true, onIsAtEndChange: () => {}, @@ -386,7 +388,6 @@ describe("MessagesTimeline", () => { it("anchors a sent attachment message using its measured height", () => { const onAnchorReady = vi.fn(); - const onAnchorSizeChanged = vi.fn(); const firstEntry = buildUserTimelineEntry("First prompt."); const secondEntry = { ...buildUserTimelineEntry("Newest prompt."), @@ -411,7 +412,6 @@ describe("MessagesTimeline", () => { {...buildProps()} anchorMessageId={secondEntry.message.id} onAnchorReady={onAnchorReady} - onAnchorSizeChanged={onAnchorSizeChanged} contentInsetEndAdjustment={144} timelineEntries={[firstEntry, secondEntry]} />, @@ -426,10 +426,10 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); expect(markup).toContain('data-maintain-visible-content-position="object"'); expect(markup).toContain('data-maintain-visible-content-position-data="true"'); - expect(markup).toContain('data-maintain-visible-content-position-size="false"'); + expect(markup).toContain('data-maintain-visible-content-position-size="true"'); + expect(markup).toContain('data-maintain-visible-content-position-restore="true"'); expect(onAnchorReady).toHaveBeenCalledOnce(); expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1); - expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240); }); it("renders collapse controls for long user messages", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ec9ef1bf708..c6e28dcef5c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -29,7 +29,6 @@ import { type MouseEvent, type ReactNode, } from "react"; -import { flushSync } from "react-dom"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { FileDiff } from "@pierre/diffs/react"; import { @@ -142,7 +141,7 @@ interface TimelineRowSharedState { onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; - onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; + onToggleWorkGroup: (groupId: string, anchorKey: string) => void; agentPanelModel: AgentPanelModel; onOpenAgents: () => void; } @@ -189,6 +188,14 @@ function TimelineLoadEarlierHeader({ } const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; +const TIMELINE_MAINTAIN_SCROLL_AT_END = { + animated: false, + on: { + dataChange: true, + itemLayout: true, + layout: true, + }, +} as const; // --------------------------------------------------------------------------- // Props (public API) @@ -220,7 +227,6 @@ interface MessagesTimelineProps { skills?: ReadonlyArray>; anchorMessageId: MessageId | null; onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; - onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; /** * Whether the timeline should keep pinning to the live edge as content @@ -267,7 +273,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills = EMPTY_TIMELINE_SKILLS, anchorMessageId, onAnchorReady, - onAnchorSizeChanged, contentInsetEndAdjustment, liveFollowEnabled, onIsAtEndChange, @@ -278,51 +283,85 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); + const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map()); + const disclosureAnchorKeyRef = useRef(null); + const disclosureSettleFrameRef = useRef(null); + const disclosureSettleSecondFrameRef = useRef(null); - const onToggleTurnFold = useCallback((turnId: TurnId) => { - setExpandedTurnIds((existing) => { - const next = new Set(existing); - if (next.has(turnId)) { - next.delete(turnId); - } else { - next.add(turnId); + useEffect(() => { + return () => { + if (disclosureSettleFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleFrameRef.current); } - return next; - }); + if (disclosureSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleSecondFrameRef.current); + } + }; }, []); - const onToggleWorkGroup = useCallback( - (groupId: string, anchorElement?: HTMLElement) => { - const anchorBottomBeforeToggle = anchorElement?.getBoundingClientRect().bottom ?? null; - flushSync(() => { - setExpandedWorkGroupIds((existing) => { - const next = new Set(existing); - if (next.has(groupId)) { - next.delete(groupId); - } else { - next.add(groupId); - } - return next; - }); + const suspendEndScrollMaintenanceForDisclosure = useCallback((anchorKey: string) => { + disclosureAnchorKeyRef.current = anchorKey; + setDisclosureToggleSettling(true); + if (disclosureSettleFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleFrameRef.current); + } + if (disclosureSettleSecondFrameRef.current !== null) { + cancelAnimationFrame(disclosureSettleSecondFrameRef.current); + } + disclosureSettleFrameRef.current = requestAnimationFrame(() => { + disclosureSettleSecondFrameRef.current = requestAnimationFrame(() => { + disclosureAnchorKeyRef.current = null; + setDisclosureToggleSettling(false); + disclosureSettleFrameRef.current = null; + disclosureSettleSecondFrameRef.current = null; }); + }); + }, []); - if (anchorBottomBeforeToggle === null || !anchorElement) { - return; - } + const shouldRestoreVisibleContentPosition = useCallback((row: MessagesTimelineRow) => { + const disclosureAnchorKey = disclosureAnchorKeyRef.current; + return disclosureAnchorKey === null || row.id === disclosureAnchorKey; + }, []); - const delta = anchorElement.getBoundingClientRect().bottom - anchorBottomBeforeToggle; - if (Math.abs(delta) < 0.5) { - return; - } + const maintainVisibleContentPosition = useMemo( + () => ({ + data: true, + size: true, + shouldRestorePosition: shouldRestoreVisibleContentPosition, + }), + [shouldRestoreVisibleContentPosition], + ); - const list = listRef.current; - const currentScroll = list?.getState?.().scroll; - if (list && typeof currentScroll === "number") { - list.scrollToOffset({ offset: currentScroll + delta, animated: false }); - } + const onToggleTurnFold = useCallback( + (turnId: TurnId) => { + suspendEndScrollMaintenanceForDisclosure(`turn-fold:${turnId}`); + setExpandedTurnIds((existing) => { + const next = new Set(existing); + if (next.has(turnId)) { + next.delete(turnId); + } else { + next.add(turnId); + } + return next; + }); + }, + [suspendEndScrollMaintenanceForDisclosure], + ); + const onToggleWorkGroup = useCallback( + (groupId: string, anchorKey: string) => { + suspendEndScrollMaintenanceForDisclosure(anchorKey); + setExpandedWorkGroupIds((existing) => { + const next = new Set(existing); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); }, - [listRef], + [suspendEndScrollMaintenanceForDisclosure], ); // An in-session interrupt leaves its turn expanded so the user keeps their @@ -394,22 +433,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }, [anchorMessageId, onAnchorReady], ); - const handleAnchorSizeChanged = useCallback( - (size: number) => { - if (anchorMessageId !== null) { - onAnchorSizeChanged(anchorMessageId, size); - } - }, - [anchorMessageId, onAnchorSizeChanged], - ); const anchoredEndSpace = useMemo(() => { const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => row.kind === "message" ? row.message.id : null, ); - return config - ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged } - : undefined; - }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]); + return config ? { ...config, onReady: handleAnchorReady } : undefined; + }, [anchorMessageId, handleAnchorReady, rows]); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); @@ -554,21 +583,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace || !liveFollowEnabled + anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling ? false - : { - animated: false, - on: { - dataChange: true, - itemLayout: true, - layout: true, - }, - } + : TIMELINE_MAINTAIN_SCROLL_AT_END } - maintainVisibleContentPosition={{ - data: true, - size: false, - }} + maintainVisibleContentPosition={maintainVisibleContentPosition} onScroll={handleScroll} className={cn( "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({ type="button" 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" aria-expanded={row.expanded} - onClick={(event) => { - const anchorElement = - event.currentTarget.closest("[data-timeline-row-id]") ?? event.currentTarget; - ctx.onToggleWorkGroup(row.groupId, anchorElement); - }} + onClick={() => ctx.onToggleWorkGroup(row.groupId, row.id)} > =12'} - '@legendapp/list@3.2.0': - resolution: {integrity: sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg==} - peerDependencies: - react: '*' - react-dom: '*' - react-native: '*' - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - '@legendapp/list@3.3.3': resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==} peerDependencies: @@ -12998,13 +12986,6 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': - dependencies: - react: 19.2.6 - use-sync-external-store: 1.6.0(react@19.2.6) - optionalDependencies: - react-dom: 19.2.6(react@19.2.6) - '@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)': dependencies: react: 19.2.3 @@ -13013,6 +12994,13 @@ snapshots: 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) + '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + '@lexical/clipboard@0.41.0': dependencies: '@lexical/html': 0.41.0