From 68e426e5f0b6ffd968d58e6ac4ff1a22c5e5f626 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 7 Oct 2026 14:07:26 -0600 Subject: [PATCH 1/9] fix(web): open threads at the bottom --- apps/web/src/components/ChatView.tsx | 300 ++++++++---------- .../components/chat/MessagesTimeline.test.tsx | 213 ++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 224 +++---------- .../chat/timelineScrollAnchoring.test.tsx | 37 ++- .../chat/timelineScrollAnchoring.ts | 41 ++- 5 files changed, 420 insertions(+), 395 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d6baf94b5ff4..d2ecc51e7fde 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -204,7 +204,6 @@ import { type LegendListRef } from "@legendapp/list/react"; import { CHAT_TIMELINE_ANCHOR_OFFSET, timelineContentOverflowsViewport, - readTimelinePosition, observeTimelineRun, type TimelineRunObservation, type TimelineScrollMode, @@ -6351,13 +6350,17 @@ export default function ChatView(props: ChatViewProps) { // re-pins on its own (independent of the refs), so the timeline needs a // render-visible flag to switch it off once the user scrolls away. const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); + const [timelineFollowThreadKey, setTimelineFollowThreadKey] = useState(routeThreadKey); + if (timelineFollowThreadKey !== routeThreadKey) { + setTimelineFollowThreadKey(routeThreadKey); + setTimelineLiveFollowEnabled(true); + } const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); const activeTimelineAnchorIndexRef = useRef(null); const observedTimelineActivityRef = useRef(null); const anchorUserScrollGenerationRef = useRef(0); - const cancelPositionRestoreRef = useRef<(() => void) | null>(null); const liveFollowUserScrollGenerationRef = useRef(0); const pendingAnchorScrollRestoreRef = useRef<{ readonly messageId: MessageId; @@ -6366,7 +6369,6 @@ export default function ChatView(props: ChatViewProps) { } | null>(null); const anchorScrollRestoreFrameRef = useRef(null); const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { - cancelPositionRestoreRef.current?.(); anchorUserScrollGenerationRef.current += 1; const wasProgrammaticScrollMode = timelineScrollModeRef.current !== "free-scrolling"; timelineScrollModeRef.current = "free-scrolling"; @@ -6495,7 +6497,6 @@ export default function ChatView(props: ChatViewProps) { // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. const scrollToEnd = useCallback((animated = false) => { - cancelPositionRestoreRef.current?.(); isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; @@ -6511,161 +6512,139 @@ export default function ChatView(props: ChatViewProps) { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); - useEffect(() => { - let removeListeners: (() => void) | null = null; - let frame: number | null = null; - const attach = (remainingAttempts: number) => { - frame = requestAnimationFrame(() => { - frame = null; - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - // The list may not have mounted on the first frame after a thread - // switch — without a retry the opt-out listeners never attach and - // live-follow becomes impossible to escape for the whole thread. - if (remainingAttempts > 0) { - attach(remainingAttempts - 1); - } + const onTimelineScrollNodeMount = useCallback( + (scrollNode: HTMLElement) => { + const handleManualNavigation = () => { + cancelTimelineLiveFollowForUserNavigationRef.current(); + }; + // The gestures below must only break follow when they can actually + // move the viewport away from the live edge (#5566): a spurious break + // while pinned at the end produces no scroll event, never re-arms, + // and streaming silently stops following. Underflowing content can't + // scroll at all, so nothing there should break follow. + const contentScrollsUp = () => timelineRealContentOverflowsViewport(); + // The follow re-arm band, not the strict flag: streaming growth makes + // isAtEnd flicker false for a frame before the follow scroll catches + // up, and a gesture landing in that window while still pinned would + // otherwise break follow with no scroll event left to re-arm it. + const viewportIsAwayFromEnd = () => + resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; + // Only an upward wheel is a navigation intent; wheeling down while + // following either does nothing (at the end) or moves toward it. + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - // The gestures below must only break follow when they can actually - // move the viewport away from the live edge (#5566): a spurious break - // while pinned at the end produces no scroll event, never re-arms, - // and streaming silently stops following. Underflowing content can't - // scroll at all, so nothing there should break follow. - const contentScrollsUp = () => timelineRealContentOverflowsViewport(); - // The follow re-arm band, not the strict flag: streaming growth makes - // isAtEnd flicker false for a frame before the follow scroll catches - // up, and a gesture landing in that window while still pinned would - // otherwise break follow with no scroll event left to re-arm it. - const viewportIsAwayFromEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; - // Only an upward wheel is a navigation intent; wheeling down while - // following either does nothing (at the end) or moves toward it. - const handleWheel = (event: WheelEvent) => { - if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) - return; - if (event.deltaY > 0) { - timelineScrollIntentRef.current = "toward-end"; - if (isAtEndRef.current) { - composerRef.current?.restoreAfterTimelineReachedEnd(); - } - } else if (event.deltaY < 0) { - timelineScrollIntentRef.current = "away-from-end"; - } - if (event.deltaY < 0 && contentScrollsUp()) { - handleManualNavigation(); + if (event.deltaY > 0) { + timelineScrollIntentRef.current = "toward-end"; + if (isAtEndRef.current) { + composerRef.current?.restoreAfterTimelineReachedEnd(); } - }; - // Touch direction isn't observable here (touchmove fires on any - // finger motion, scrolling or not), so break only once the drag has - // actually carried the viewport out of the end band — an upward flick - // gets there within its first few events and later touchmoves break. - const handleTouchMove = () => { - if (viewportIsAwayFromEnd()) { + } else if (event.deltaY < 0) { + timelineScrollIntentRef.current = "away-from-end"; + } + if (event.deltaY < 0 && contentScrollsUp()) { + handleManualNavigation(); + } + }; + // Touch direction isn't observable here (touchmove fires on any + // finger motion, scrolling or not), so break only once the drag has + // actually carried the viewport out of the end band — an upward flick + // gets there within its first few events and later touchmoves break. + const handleTouchMove = () => { + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Scrollbar drags produce no wheel/touch events; they are the only + // pointerdowns whose target is the scroll node itself rather than a + // message row. Content clicks break follow only away from the end + // (reading or selecting up there must hold position); clicking near + // the live edge keeps following. + const handlePointerDown = (event: PointerEvent) => { + if (event.target === scrollNode) { + if (contentScrollsUp()) { handleManualNavigation(); } - }; - // Scrollbar drags produce no wheel/touch events; they are the only - // pointerdowns whose target is the scroll node itself rather than a - // message row. Content clicks break follow only away from the end - // (reading or selecting up there must hold position); clicking near - // the live edge keeps following. - const handlePointerDown = (event: PointerEvent) => { - if (event.target === scrollNode) { + return; + } + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and + // pointer events entirely; without this the timeline yanks back to + // the end on the next stream chunk. Clicking message text can leave + // DOM focus on body, so these keys must also be heard at document. + const handleKeyDown = (event: KeyboardEvent) => { + if ( + !(event.target instanceof Node) || + (!scrollNode.contains(event.target) && + event.target !== document.body && + event.target !== document.documentElement) || + event.defaultPrevented || + event.isComposing || + event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey || + eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || + document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) + ) { + return; + } + if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) + return; + const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; + if ( + scrollNode.contains(event.target) && + !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) + ) + return; + switch (event.key) { + case "PageUp": + case "Home": + case "ArrowUp": + timelineScrollIntentRef.current = "away-from-end"; if (contentScrollsUp()) { handleManualNavigation(); - } - return; - } - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and - // pointer events entirely; without this the timeline yanks back to - // the end on the next stream chunk. Clicking message text can leave - // DOM focus on body, so these keys must also be heard at document. - const handleKeyDown = (event: KeyboardEvent) => { - if ( - !(event.target instanceof Node) || - (!scrollNode.contains(event.target) && - event.target !== document.body && - event.target !== document.documentElement) || - event.defaultPrevented || - event.isComposing || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || - document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) - ) { - return; - } - if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) - return; - const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; - if ( - scrollNode.contains(event.target) && - !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) - ) - return; - switch (event.key) { - case "PageUp": - case "Home": - case "ArrowUp": - timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp()) { - handleManualNavigation(); - composerRef.current?.collapseForTimelineScrollKey(event.key); - } - break; - case "PageDown": - case "End": - case "ArrowDown": - timelineScrollIntentRef.current = "toward-end"; - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } composerRef.current?.collapseForTimelineScrollKey(event.key); - if (isTimelineAtLogicalEnd()) { - composerRef.current?.restoreAfterTimelineReachedEnd(); - } - break; - default: - break; - } - }; - scrollNode.addEventListener("wheel", handleWheel, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleTouchMove, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handlePointerDown, { - passive: true, - }); - document.addEventListener("keydown", handleKeyDown); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleWheel); - scrollNode.removeEventListener("touchmove", handleTouchMove); - scrollNode.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; + } + break; + case "PageDown": + case "End": + case "ArrowDown": + timelineScrollIntentRef.current = "toward-end"; + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + composerRef.current?.collapseForTimelineScrollKey(event.key); + if (isTimelineAtLogicalEnd()) { + composerRef.current?.restoreAfterTimelineReachedEnd(); + } + break; + default: + break; + } + }; + scrollNode.addEventListener("wheel", handleWheel, { + passive: true, }); - }; - attach(12); - - return () => { - if (frame !== null) { - cancelAnimationFrame(frame); - } - removeListeners?.(); - }; - }, [activeThread?.id, isTimelineAtLogicalEnd, timelineRealContentOverflowsViewport]); + scrollNode.addEventListener("touchmove", handleTouchMove, { + passive: true, + }); + scrollNode.addEventListener("pointerdown", handlePointerDown, { + passive: true, + }); + document.addEventListener("keydown", handleKeyDown); + return () => { + scrollNode.removeEventListener("wheel", handleWheel); + scrollNode.removeEventListener("touchmove", handleTouchMove); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, + [isTimelineAtLogicalEnd, timelineRealContentOverflowsViewport], + ); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { if (pendingTimelineAnchorRef.current === messageId) { @@ -6815,24 +6794,20 @@ export default function ChatView(props: ChatViewProps) { timelineLiveFollowEnabled, ]); - useEffect(() => { + useLayoutEffect(() => { setPullRequestDialogState(null); - const followEnd = readTimelinePosition(routeThreadKey)?.atEnd !== false; - isAtEndRef.current = followEnd; + isAtEndRef.current = true; timelineScrollIntentRef.current = null; - timelineScrollModeRef.current = followEnd ? "following-end" : "free-scrolling"; - liveFollowUserScrollGenerationRef.current = followEnd - ? anchorUserScrollGenerationRef.current - : null; - setTimelineLiveFollowEnabled(followEnd); + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); - setShowScrollToBottom(!followEnd); + setShowScrollToBottom(false); // Environment identity is part of the scroll session too. - }, [activeThreadKey]); + }, [routeThreadKey]); useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; @@ -11410,6 +11385,7 @@ export default function ChatView(props: ChatViewProps) { } routeThreadKey={displayedTimelineKey} displayThreadKey={displayedTimelineKey} + entryThreadKey={routeThreadKey} onOpenTurnDiff={paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiff} onOpenThread={onOpenRelatedThread} parentThreadLink={paintOnlyDisplayedTimeline ? null : parentThreadLink} @@ -11454,7 +11430,7 @@ export default function ChatView(props: ChatViewProps) { onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} - cancelPositionRestoreRef={cancelPositionRestoreRef} + onScrollNodeMount={onTimelineScrollNodeMount} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} {...(paintOnlyDisplayedTimeline || threadHistoryControls === undefined diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f7aeb679a87b..06b8ab2301c5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -23,12 +23,13 @@ import { create, type ReactTestRenderer } from "react-test-renderer"; import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; import { useComposerFocusState } from "./useComposerFocusState"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { LegendListRef } from "@legendapp/list/react"; +import type { LegendListRef, MaintainVisibleContentPositionConfig } from "@legendapp/list/react"; const activityTestState = vi.hoisted(() => ({ expanded: false, expandedRuns: false, subagentTooltips: false, + nativeList: false, })); // Expose tooltip contents in the renderer without requiring a browser portal. @@ -82,25 +83,27 @@ vi.mock("./MessagesTimeline.logic", async (importOriginal) => { }); beforeEach(() => { + activityTestState.nativeList = false; activityTestState.subagentTooltips = false; activityTestState.expanded = false; activityTestState.expandedRuns = false; }); -vi.mock("@legendapp/list/react", async () => { +vi.mock("@legendapp/list/react", async (importOriginal) => { + const actual = await importOriginal(); const legendListTestId = "legend-list"; const LegendList = (props: { data: Array<{ id: string }>; keyExtractor: (item: { id: string }) => string; renderItem: (args: { item: { id: string } }) => ReactNode; - ListHeaderComponent?: ReactNode; - ListFooterComponent?: ReactNode; + ListHeaderComponent?: ReactElement | null; + ListFooterComponent?: ReactElement | null; anchoredEndSpace?: { anchorIndex: number; anchorMaxSize?: number; anchorOffset?: number; - onReady?: (info: { anchorIndex: number }) => void; + onReady?: (info: { anchorIndex: number | undefined }) => void; onSizeChanged?: (size: number) => void; }; maintainScrollAtEnd?: @@ -113,17 +116,12 @@ vi.mock("@legendapp/list/react", async () => { layout?: boolean; }; }; - maintainVisibleContentPosition?: - | boolean - | { - data?: boolean; - size?: boolean; - shouldRestorePosition?: boolean; - }; + maintainVisibleContentPosition?: boolean | MaintainVisibleContentPositionConfig<{ id: string }>; className?: string; contentInsetEndAdjustment?: number; ref?: Ref; }) => { + if (activityTestState.nativeList) return ; if (props.anchoredEndSpace) { props.anchoredEndSpace.onSizeChanged?.(240); props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); @@ -2718,3 +2716,194 @@ describe("MessagesTimeline", () => { } }); }); + +describe("thread entry with the real virtualizer", () => { + it("returns a previously scrolled thread to its measured end", async () => { + vi.unstubAllGlobals(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + activityTestState.nativeList = true; + vi.useFakeTimers(); + const frames = new Map(); + let frameId = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id)); + const observers = new Set<{ callback: ResizeObserverCallback; elements: Set }>(); + vi.stubGlobal( + "ResizeObserver", + class { + elements = new Set(); + constructor(readonly callback: ResizeObserverCallback) { + observers.add(this); + } + observe(element: Element) { + this.elements.add(element); + } + unobserve(element: Element) { + this.elements.delete(element); + } + disconnect() { + this.elements.clear(); + observers.delete(this); + } + }, + ); + // jsdom supplies no layout. Give the real virtualizer a 600px viewport + // and 180px message rows, twice its 90px estimate. + let lastRowHeight = 180; + const rectSpy = vi + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockImplementation(function (this: HTMLElement) { + const height = this.classList.contains("messages-timeline-scroll") + ? 600 + : this.firstElementChild?.classList.contains("messages-timeline-row-frame") + ? this.textContent?.includes("Message 39") + ? lastRowHeight + : 180 + : this.style.height.endsWith("px") + ? Number.parseFloat(this.style.height) + : 0; + return { + x: 0, + y: 0, + top: 0, + left: 0, + right: 800, + bottom: height, + width: 800, + height, + toJSON() {}, + }; + }); + const heightSpy = vi + .spyOn(HTMLElement.prototype, "clientHeight", "get") + .mockImplementation(function (this: HTMLElement) { + return this.classList.contains("messages-timeline-scroll") ? 600 : 0; + }); + const widthSpy = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800); + const scrollHeightSpy = vi + .spyOn(HTMLElement.prototype, "scrollHeight", "get") + .mockImplementation(function (this: HTMLElement) { + return Math.max( + 600, + ...Array.from(this.querySelectorAll("div")).map((node) => + node.style.height.endsWith("px") ? Number.parseFloat(node.style.height) : 0, + ), + ); + }); + const originalScrollTo = HTMLElement.prototype.scrollTo; + HTMLElement.prototype.scrollTo = function ( + this: HTMLElement, + options?: ScrollToOptions | number, + y?: number, + ) { + this.scrollTop = typeof options === "number" ? (y ?? 0) : (options?.top ?? this.scrollTop); + this.dispatchEvent(new Event("scroll")); + }; + const flushLayout = async () => { + for (let i = 0; i < 12; i++) { + await act(async () => { + for (const observer of observers) { + observer.callback( + Array.from(observer.elements).map( + (target) => + ({ + target, + contentRect: target.getBoundingClientRect(), + }) as ResizeObserverEntry, + ), + observer as unknown as ResizeObserver, + ); + } + }); + await act(async () => { + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((callback) => callback(i * 16)); + vi.advanceTimersByTime(16); + }); + } + }; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const props = buildProps(); + const entries = Array.from({ length: 40 }, (_, index) => { + const entry = buildUserTimelineEntry(`Message ${index}`); + return { + ...entry, + id: `entry-${index}`, + message: { ...entry.message, id: MessageId.make(`message-${index}`) }, + }; + }); + const renderThread = async (key: string, displayThreadKey = key) => { + await act(async () => + root.render( + , + ), + ); + await flushLayout(); + }; + try { + await renderThread("entry-test:thread-a"); + const firstViewport = props.listRef.current!.getScrollableNode(); + expect(firstViewport.scrollTop).toBeGreaterThan(3000); + await act(async () => { + firstViewport.scrollTop = 100; + firstViewport.dispatchEvent(new Event("scroll")); + }); + // B has no cached history yet, so the client keeps A visible while B loads. + await renderThread("entry-test:thread-b", "entry-test:thread-a"); + await renderThread("entry-test:thread-a"); + const viewport = props.listRef.current!.getScrollableNode(); + expect(viewport.scrollTop).toBeGreaterThan(3000); + expect( + Math.abs(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop), + ).toBeLessThanOrEqual(1); + expect(container.textContent).toContain("Message 39"); + lastRowHeight = 2800; + await flushLayout(); + expect( + Math.abs(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop), + ).toBeLessThanOrEqual(1); + expect(viewport.scrollTop).toBeGreaterThan(5600); + props.liveFollowEnabled = false; + await renderThread("entry-test:thread-a"); + await act(async () => { + // Use the production cancellation path before the browser's user scroll. + await props.listRef.current!.scrollToItem({ item: {} as never, animated: false }); + viewport.scrollTop = 100; + viewport.dispatchEvent(new Event("scroll")); + }); + lastRowHeight = 4000; + await flushLayout(); + expect(viewport.scrollTop).toBe(100); + props.liveFollowEnabled = true; + await renderThread("entry-test:thread-b"); + await renderThread("entry-test:thread-a"); + const reopened = props.listRef.current!.getScrollableNode(); + expect( + Math.abs(reopened.scrollHeight - reopened.clientHeight - reopened.scrollTop), + ).toBeLessThanOrEqual(1); + } finally { + await act(async () => root.unmount()); + container.remove(); + rectSpy.mockRestore(); + heightSpy.mockRestore(); + widthSpy.mockRestore(); + scrollHeightSpy.mockRestore(); + HTMLElement.prototype.scrollTo = originalScrollTo; + vi.useRealTimers(); + vi.unstubAllGlobals(); + activityTestState.nativeList = false; + } + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8d2b445a22ce..206b8c8ccbe0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -180,8 +180,8 @@ import { useFileContextMenuHandler } from "../../fileContextMenu"; import { useProject, useThreadShell } from "../../state/entities"; import { CHAT_TIMELINE_ANCHOR_OFFSET, - readTimelinePosition, - rememberTimelinePosition, + readTimelineDisclosures, + rememberTimelineDisclosures, timelineContentOverflowsViewport, } from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; @@ -474,11 +474,13 @@ interface MessagesTimelineProps { listRef: React.RefObject; timelineEntries: ReadonlyArray; + onScrollNodeMount?: (node: HTMLElement) => () => void; latestRun: TimelineLatestRun | null; runningRunId?: RunId | null; turnDiffSummaries: ReadonlyArray; routeThreadKey: string; displayThreadKey?: string; + entryThreadKey?: string; onOpenTurnDiff: (runId: RunId, filePath?: string) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; parentThreadLink?: { @@ -530,7 +532,6 @@ interface MessagesTimelineProps { onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; findOpen?: boolean; - cancelPositionRestoreRef?: React.RefObject<(() => void) | null> | undefined; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; historyControls?: MessagesTimelineHistoryControls | undefined; @@ -588,11 +589,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ activeFindMatch = null, findNavigationId = 0, findPositionReaderRef, + onScrollNodeMount, latestRun, runningRunId = null, turnDiffSummaries, routeThreadKey, displayThreadKey, + entryThreadKey = routeThreadKey, onOpenTurnDiff, onOpenThread, parentThreadLink = null, @@ -624,47 +627,40 @@ const ConversationTimeline = memo(function ConversationTimeline({ liveFollowEnabled, onToolOutputCollapsedAtEnd, onManualNavigation, - cancelPositionRestoreRef, hideEmptyPlaceholder = false, topFadeEnabled = false, historyControls, loadEarlier = null, }: MessagesTimelineProps) { const listIdentityKey = displayThreadKey ?? routeThreadKey; - const rememberedPosition = useMemo( - () => readTimelinePosition(listIdentityKey), + const rememberedDisclosures = useMemo( + () => readTimelineDisclosures(listIdentityKey), [listIdentityKey], ); const [expandedRunIds, setExpandedRunIds] = useState>( - () => rememberedPosition?.disclosures?.runs ?? new Set(), + () => rememberedDisclosures?.runs ?? new Set(), ); const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>( - () => rememberedPosition?.disclosures?.workGroups ?? new Set(), + () => rememberedDisclosures?.workGroups ?? new Set(), ); const [expandedAttemptIds, setExpandedAttemptIds] = useState>( - () => rememberedPosition?.disclosures?.attempts ?? new Set(), + () => rememberedDisclosures?.attempts ?? new Set(), ); - const [positionedThreadKey, setPositionedThreadKey] = useState(() => - rememberedPosition?.atEnd === false ? null : listIdentityKey, - ); - const restoringThreadPosition = positionedThreadKey !== listIdentityKey; const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const listIdentityRef = useRef(listIdentityKey); const previousLatestRunRef = useRef(latestRun); - // The list stays mounted across thread switches. Its first end pins on the - // new thread must snap, not glide, even if that thread is mid-turn. + // The new thread must open with an instant end pin, even mid-turn. const [settlingListIdentity, setSettlingListIdentity] = useState(null); let paintedExpandedRunIds = expandedRunIds; let paintedExpandedWorkGroupIds = expandedWorkGroupIds; let paintedExpandedAttemptIds = expandedAttemptIds; if (listIdentityRef.current !== listIdentityKey) { listIdentityRef.current = listIdentityKey; - setPositionedThreadKey(null); previousLatestRunRef.current = latestRun; setSettlingListIdentity(listIdentityKey); - paintedExpandedRunIds = rememberedPosition?.disclosures?.runs ?? new Set(); - paintedExpandedWorkGroupIds = rememberedPosition?.disclosures?.workGroups ?? new Set(); - paintedExpandedAttemptIds = rememberedPosition?.disclosures?.attempts ?? new Set(); + paintedExpandedRunIds = rememberedDisclosures?.runs ?? new Set(); + paintedExpandedWorkGroupIds = rememberedDisclosures?.workGroups ?? new Set(); + paintedExpandedAttemptIds = rememberedDisclosures?.attempts ?? new Set(); setExpandedRunIds(paintedExpandedRunIds); setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); setExpandedAttemptIds(paintedExpandedAttemptIds); @@ -677,11 +673,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ // Nested tool state shares the bounded thread-position cache. const workGroupViewState = useMemo( () => - rememberedPosition?.disclosures?.workGroupState ?? { + rememberedDisclosures?.workGroupState ?? { scrollPositions: new Map(), expandedEntries: new Set(), }, - [listIdentityKey, rememberedPosition], + [listIdentityKey, rememberedDisclosures], ); const [minimapStripMap] = useState(() => new Map()); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); @@ -922,130 +918,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ // must not change with them or every timeline row re-renders per event. const runs = useStableHandoffRuns(runsProp); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); - const restoreRowIndex = - restoringThreadPosition && rememberedPosition?.atEnd === false - ? rows.findIndex((row) => row.id === rememberedPosition.rowId) - : -1; - const restoringAlwaysRender = useMemo( - () => - restoringThreadPosition && restoreRowIndex >= 0 ? { indices: [restoreRowIndex] } : undefined, - [restoreRowIndex, restoringThreadPosition], - ); - useLayoutEffect(() => { - if (!restoringThreadPosition || rows.length === 0) return; - const list = listRef.current; - if (!list) return; - if (citationRequest !== null) { - setPositionedThreadKey(listIdentityKey); - return; - } - let cancelled = false; - let settleFrame: number | null = null; - const viewport: HTMLElement | null = list.getScrollableNode(); - const cancelRestoration = () => { - if (cancelled) return; - cancelled = true; - if (settleFrame !== null) cancelAnimationFrame(settleFrame); - // Supersede any pending estimated-index scroll before the browser applies the gesture. - if (viewport) void list.scrollToOffset({ offset: viewport.scrollTop, animated: false }); - setPositionedThreadKey(listIdentityKey); - }; - const cancelForNavigation = () => { - cancelRestoration(); - onManualNavigation(); - }; - const onScrollKey = (event: globalThis.KeyboardEvent) => { - if ( - ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key) && - !( - event.target instanceof Element && - event.target.closest("input, textarea, [contenteditable=true]") - ) - ) - cancelForNavigation(); - }; - viewport?.addEventListener("wheel", cancelForNavigation, { passive: true }); - viewport?.addEventListener("touchmove", cancelForNavigation, { passive: true }); - viewport?.addEventListener("pointerdown", cancelForNavigation, { passive: true }); - viewport?.ownerDocument.addEventListener("keydown", onScrollKey); - const position = rememberedPosition; - const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1; - if (position?.atEnd === false) onManualNavigation(); - if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration; - const scrolling = - position?.atEnd === false - ? index >= 0 - ? list.scrollToIndex({ - index, - animated: false, - viewPosition: 0, - viewOffset: -position.offsetWithinRow, - }) - : list.scrollToOffset({ offset: position.scrollOffset, animated: false }) - : list.scrollToEnd({ animated: false }); - void Promise.resolve(scrolling).then(() => { - if (cancelled) return; - if (position?.atEnd !== false || index < 0) { - setPositionedThreadKey(listIdentityKey); - return; - } - // Index scrolling starts from estimates. Keep the saved row mounted - // until its measured position and the DOM agree for two layout frames. - let stableFrames = 0; - const reconcile = () => { - if (cancelled) return; - const state = list.getState(); - const rowIndex = state.indexByKey(position.rowId); - const row = rowIndex === undefined ? undefined : state.elementAtIndex(rowIndex); - const element = list.getScrollableNode(); - if (!row || !element) return; - const offset = Math.max( - 0, - Math.min( - element.scrollTop + - row.getBoundingClientRect().top - - element.getBoundingClientRect().top + - position.offsetWithinRow, - element.scrollHeight - element.clientHeight, - ), - ); - if (Math.abs(element.scrollTop - offset) > 1) { - stableFrames = 0; - void list.scrollToOffset({ offset, animated: false }).then(() => { - if (!cancelled) settleFrame = requestAnimationFrame(reconcile); - }); - return; - } - if (++stableFrames >= 2) { - setPositionedThreadKey(listIdentityKey); - } else { - settleFrame = requestAnimationFrame(reconcile); - } - }; - settleFrame = requestAnimationFrame(reconcile); - }); - return () => { - cancelled = true; - if (cancelPositionRestoreRef?.current === cancelRestoration) { - cancelPositionRestoreRef.current = null; - } - if (settleFrame !== null) cancelAnimationFrame(settleFrame); - viewport?.removeEventListener("wheel", cancelForNavigation); - viewport?.removeEventListener("touchmove", cancelForNavigation); - viewport?.removeEventListener("pointerdown", cancelForNavigation); - viewport?.ownerDocument.removeEventListener("keydown", onScrollKey); - }; - }, [ - citationRequest, - cancelPositionRestoreRef, - listIdentityKey, - listRef, - onManualNavigation, - rememberedPosition, - restoringThreadPosition, - rows, - ]); - const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); @@ -1078,21 +950,16 @@ const ConversationTimeline = memo(function ConversationTimeline({ const onAppFullscreenChange = useCallback((rowId: string, fullscreen: boolean) => { setFullscreenAppRowId((current) => (fullscreen ? rowId : current === rowId ? null : current)); }, []); - // Every pin holds at once, so navigating to a citation or restoring a - // position never drops a full-screen app's row. The app is pinned by key, + // Citation navigation never drops a full-screen app's row. The app is pinned by key, // which stays right as earlier rows load in. const alwaysRender = useMemo(() => { - const indices = restoringAlwaysRender?.indices ?? []; const keys = [ ...(citationAlwaysRender?.keys ?? []), ...(fullscreenAppRowId === null ? [] : [fullscreenAppRowId]), ]; - if (indices.length === 0 && keys.length === 0) return undefined; - return { - ...(indices.length === 0 ? {} : { indices }), - ...(keys.length === 0 ? {} : { keys }), - }; - }, [citationAlwaysRender, restoringAlwaysRender, fullscreenAppRowId]); + if (keys.length === 0) return undefined; + return { keys }; + }, [citationAlwaysRender, fullscreenAppRowId]); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const [minimapCurrentIndex, setMinimapCurrentIndex] = useState(null); const handleAnchorReady = useCallback( @@ -1177,29 +1044,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); - if (restoringThreadPosition || state?.data !== rows) return; + if (state?.data !== rows) return; const isAtEnd = resolveTimelineIsAtEnd(state); - const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined; - if (position && state && isAtEnd !== undefined) { - const index = state.indexByKey(position.rowId); - const row = index === undefined ? undefined : state.elementAtIndex(index); - const element = listRef.current?.getScrollableNode(); - if (row && element) { - rememberTimelinePosition(listIdentityKey, { - ...position, - // DOM geometry includes the header and the virtualizer's layout adjustment. - offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, - scrollOffset: element.scrollTop, - atEnd: isAtEnd, - disclosures: { - runs: paintedExpandedRunIds, - workGroups: paintedExpandedWorkGroupIds, - attempts: paintedExpandedAttemptIds, - workGroupState: workGroupViewState, - }, - }); - } - } + rememberTimelineDisclosures(listIdentityKey, { + runs: paintedExpandedRunIds, + workGroups: paintedExpandedWorkGroupIds, + attempts: paintedExpandedAttemptIds, + workGroupState: workGroupViewState, + }); if (isAtEnd !== undefined && !citationPositioning) { onIsAtEndChange(isAtEnd); } @@ -1249,7 +1101,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ workGroupViewState, rows, listIdentityKey, - restoringThreadPosition, listRef, minimapItems, minimapStripMap, @@ -1441,9 +1292,16 @@ const ConversationTimeline = memo(function ConversationTimeline({ const setTimelineList = useCallback( (list: LegendListRef | null) => { listRef.current = list; - registerTimeline?.(list?.getScrollableNode() ?? null); + const node = list?.getScrollableNode() ?? null; + registerTimeline?.(node); + const removeListeners = node ? onScrollNodeMount?.(node) : undefined; + return () => { + removeListeners?.(); + listRef.current = null; + registerTimeline?.(null); + }; }, - [listRef, registerTimeline], + [listRef, onScrollNodeMount, registerTimeline], ); useLayoutEffect(() => { @@ -1578,6 +1436,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ /> ) : null} + // Loading can retain the previous timeline after the requested thread changes. + key={JSON.stringify([entryThreadKey, listIdentityKey])} ref={setTimelineList} data={rows} extraData={`${listIdentityKey}:${rows.length}`} @@ -1586,7 +1446,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd={ - !findActive && citationRequest === null && rememberedPosition?.atEnd !== false + !findActive && citationRequest === null } // Legend needs a data refresh to mount new pins without a scroll event. dataVersion={readyCitationRequest?.key ?? listIdentityKey} @@ -1596,7 +1456,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} maintainScrollAtEnd={ citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) || anchoredEndSpace || !liveFollowEnabled || fullscreenAppRowId !== null || @@ -1608,8 +1467,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ } maintainVisibleContentPosition={ findActive || - citationPositioning || - (restoringThreadPosition && rememberedPosition?.atEnd === false) + citationPositioning ? false : maintainVisibleContentPosition } diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index d0c49711ee26..b9c81f99ded1 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -1,11 +1,11 @@ -import { MessageId, RunId } from "@t3tools/contracts"; +import { MessageId, RunId, RunAttemptId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { observeTimelineRun, getAnchoredTurnMetrics, getRowBottom, - readTimelinePosition, - rememberTimelinePosition, + readTimelineDisclosures, + rememberTimelineDisclosures, timelineContentOverflowsViewport, } from "./timelineScrollAnchoring"; @@ -267,18 +267,23 @@ describe("observeTimelineRun", () => { }); }); -describe("remembered timeline positions", () => { - it("keeps reading positions and end-follow independent across threads and environments", () => { - const reading = { rowId: "message-4", offsetWithinRow: 32, scrollOffset: 932, atEnd: false }; - const following = { rowId: "message-9", offsetWithinRow: 10, scrollOffset: 2010, atEnd: true }; - rememberTimelinePosition("scroll-test-a:thread-1", reading); - rememberTimelinePosition("scroll-test-a:thread-2", following); - rememberTimelinePosition("scroll-test-b:thread-1", following); - expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(reading); - expect(readTimelinePosition("scroll-test-a:thread-2")).toEqual(following); - expect(readTimelinePosition("scroll-test-b:thread-1")).toEqual(following); - expect(readTimelinePosition("scroll-test-a:unvisited")).toBeUndefined(); - rememberTimelinePosition("scroll-test-a:thread-1", following); - expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(following); +describe("remembered timeline disclosures", () => { + it("keeps disclosure state independent across threads and environments", () => { + const first = { + runs: new Set([RunId.make("run-4")]), + workGroups: new Set(), + attempts: new Set(), + workGroupState: { scrollPositions: new Map(), expandedEntries: new Set() }, + }; + const second = { ...first, runs: new Set([RunId.make("run-9")]) }; + rememberTimelineDisclosures("scroll-test-a:thread-1", first); + rememberTimelineDisclosures("scroll-test-a:thread-2", second); + rememberTimelineDisclosures("scroll-test-b:thread-1", second); + expect(readTimelineDisclosures("scroll-test-a:thread-1")).toEqual(first); + expect(readTimelineDisclosures("scroll-test-a:thread-2")).toEqual(second); + expect(readTimelineDisclosures("scroll-test-b:thread-1")).toEqual(second); + expect(readTimelineDisclosures("scroll-test-a:unvisited")).toBeUndefined(); + rememberTimelineDisclosures("scroll-test-a:thread-1", second); + expect(readTimelineDisclosures("scroll-test-a:thread-1")).toEqual(second); }); }); diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index c83bb8b83ca4..531e31bdd93e 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -142,34 +142,31 @@ export function getAnchoredTurnMetrics({ }; } -export interface RememberedTimelinePosition { - readonly rowId: string; - readonly offsetWithinRow: number; - readonly scrollOffset: number; - readonly atEnd: boolean; - readonly disclosures?: { - readonly runs: ReadonlySet; - readonly workGroups: ReadonlySet; - readonly attempts: ReadonlySet; - readonly workGroupState: { - scrollPositions: Map; - expandedEntries: Set; - }; +export interface RememberedTimelineDisclosures { + readonly runs: ReadonlySet; + readonly workGroups: ReadonlySet; + readonly attempts: ReadonlySet; + readonly workGroupState: { + scrollPositions: Map; + expandedEntries: Set; }; } // Scoped thread keys keep separate environments independent. Bound the session cache. -const rememberedTimelinePositions = new Map(); +const rememberedTimelineDisclosures = new Map(); -export function readTimelinePosition(threadKey: string) { - return rememberedTimelinePositions.get(threadKey); +export function readTimelineDisclosures(threadKey: string) { + return rememberedTimelineDisclosures.get(threadKey); } -export function rememberTimelinePosition(threadKey: string, position: RememberedTimelinePosition) { - rememberedTimelinePositions.delete(threadKey); - rememberedTimelinePositions.set(threadKey, position); - if (rememberedTimelinePositions.size > 100) { - const oldest = rememberedTimelinePositions.keys().next().value; - if (oldest !== undefined) rememberedTimelinePositions.delete(oldest); +export function rememberTimelineDisclosures( + threadKey: string, + disclosures: RememberedTimelineDisclosures, +) { + rememberedTimelineDisclosures.delete(threadKey); + rememberedTimelineDisclosures.set(threadKey, disclosures); + if (rememberedTimelineDisclosures.size > 100) { + const oldest = rememberedTimelineDisclosures.keys().next().value; + if (oldest !== undefined) rememberedTimelineDisclosures.delete(oldest); } } From 06c41efc761f8ce43943d9f44adc0793c1dba0d8 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Wed, 7 Oct 2026 14:43:08 -0600 Subject: [PATCH 2/9] refactor(web): simplify timeline state persistence --- apps/web/src/components/ChatView.tsx | 15 ++--- .../components/chat/MessagesTimeline.test.tsx | 58 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 29 +++++----- 3 files changed, 80 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d2ecc51e7fde..9f95aaba67c5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6408,13 +6408,6 @@ export default function ChatView(props: ChatViewProps) { } } }, []); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( - cancelTimelineLiveFollowForUserNavigation, - ); - useEffect(() => { - cancelTimelineLiveFollowForUserNavigationRef.current = - cancelTimelineLiveFollowForUserNavigation; - }, [cancelTimelineLiveFollowForUserNavigation]); useEffect(() => { const previous = observedTimelineActivityRef.current; const dispatchedUserItem = @@ -6515,7 +6508,7 @@ export default function ChatView(props: ChatViewProps) { const onTimelineScrollNodeMount = useCallback( (scrollNode: HTMLElement) => { const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); + cancelTimelineLiveFollowForUserNavigation(); }; // The gestures below must only break follow when they can actually // move the viewport away from the live edge (#5566): a spurious break @@ -6643,7 +6636,11 @@ export default function ChatView(props: ChatViewProps) { document.removeEventListener("keydown", handleKeyDown); }; }, - [isTimelineAtLogicalEnd, timelineRealContentOverflowsViewport], + [ + cancelTimelineLiveFollowForUserNavigation, + isTimelineAtLogicalEnd, + timelineRealContentOverflowsViewport, + ], ); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 06b8ab2301c5..f777df1f994f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -2717,6 +2717,64 @@ describe("MessagesTimeline", () => { }); }); +describe("disclosure state across thread switches", () => { + it("remembers an expanded turn without waiting for a scroll or animation frame", async () => { + vi.unstubAllGlobals(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const runId = RunId.make("disclosure-switch-run"); + const entries = ["Earlier response", "Final response"].map((text, index) => { + const entry = buildAssistantTimelineEntry(text); + return { + ...entry, + id: `disclosure-switch-entry-${index}`, + message: { ...entry.message, id: MessageId.make(`disclosure-switch-${index}`), runId }, + }; + }); + const renderThread = async (threadKey: string) => { + await act(async () => { + root.render( + , + ); + }); + }; + try { + await renderThread("disclosure-switch:thread-a"); + expect(container.textContent).not.toContain("Earlier response"); + const toggle = container.querySelector( + '[data-timeline-row-kind="turn-fold"] button', + ); + expect(toggle).not.toBeNull(); + await act(async () => toggle!.click()); + expect(container.textContent).toContain("Earlier response"); + await renderThread("disclosure-switch:thread-b"); + expect(container.textContent).not.toContain("Earlier response"); + await renderThread("disclosure-switch:thread-a"); + expect(container.textContent).toContain("Earlier response"); + } finally { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } + }); +}); + describe("thread entry with the real virtualizer", () => { it("returns a previously scrolled thread to its measured end", async () => { vi.unstubAllGlobals(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 206b8c8ccbe0..3d9476b4885d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -670,7 +670,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ const expandCitedRun = useCallback((runId: RunId) => { setExpandedRunIds((current) => (current.has(runId) ? current : new Set([...current, runId]))); }, []); - // Nested tool state shares the bounded thread-position cache. + // Nested tool state shares the bounded disclosure cache. const workGroupViewState = useMemo( () => rememberedDisclosures?.workGroupState ?? { @@ -1046,12 +1046,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ const state = listRef.current?.getState?.(); if (state?.data !== rows) return; const isAtEnd = resolveTimelineIsAtEnd(state); - rememberTimelineDisclosures(listIdentityKey, { - runs: paintedExpandedRunIds, - workGroups: paintedExpandedWorkGroupIds, - attempts: paintedExpandedAttemptIds, - workGroupState: workGroupViewState, - }); if (isAtEnd !== undefined && !citationPositioning) { onIsAtEndChange(isAtEnd); } @@ -1095,12 +1089,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); }, [ citationPositioning, - paintedExpandedRunIds, - paintedExpandedWorkGroupIds, - paintedExpandedAttemptIds, - workGroupViewState, rows, - listIdentityKey, listRef, minimapItems, minimapStripMap, @@ -1109,9 +1098,23 @@ const ConversationTimeline = memo(function ConversationTimeline({ ]); useEffect(() => { + rememberTimelineDisclosures(listIdentityKey, { + runs: paintedExpandedRunIds, + workGroups: paintedExpandedWorkGroupIds, + attempts: paintedExpandedAttemptIds, + workGroupState: workGroupViewState, + }); const frame = requestAnimationFrame(handleScroll); return () => cancelAnimationFrame(frame); - }, [handleScroll, rows.length]); + }, [ + handleScroll, + listIdentityKey, + paintedExpandedRunIds, + paintedExpandedWorkGroupIds, + paintedExpandedAttemptIds, + workGroupViewState, + rows.length, + ]); useEffect(() => { if (!timelineViewportElement) { From 73fb039c720c62502866a6d377e12a7bd69cc640 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Thu, 8 Oct 2026 13:55:02 -0600 Subject: [PATCH 3/9] fix(web): reset timeline navigation on thread entry --- .../components/chat/MessagesTimeline.test.tsx | 83 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 62 +++++--------- 2 files changed, 105 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f777df1f994f..c2ea0d2b6870 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -30,6 +30,7 @@ const activityTestState = vi.hoisted(() => ({ expandedRuns: false, subagentTooltips: false, nativeList: false, + onListLoad: null as (() => void) | null, })); // Expose tooltip contents in the renderer without requiring a browser portal. @@ -84,6 +85,7 @@ vi.mock("./MessagesTimeline.logic", async (importOriginal) => { beforeEach(() => { activityTestState.nativeList = false; + activityTestState.onListLoad = null; activityTestState.subagentTooltips = false; activityTestState.expanded = false; activityTestState.expandedRuns = false; @@ -120,8 +122,10 @@ vi.mock("@legendapp/list/react", async (importOriginal) => { className?: string; contentInsetEndAdjustment?: number; ref?: Ref; + onLoad?: () => void; }) => { if (activityTestState.nativeList) return ; + activityTestState.onListLoad = props.onLoad ?? null; if (props.anchoredEndSpace) { props.anchoredEndSpace.onSizeChanged?.(240); props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); @@ -2775,6 +2779,85 @@ describe("disclosure state across thread switches", () => { }); }); +describe("thread search across list remounts", () => { + type Event = "load" | "switch"; + async function checkSequence(events: readonly Event[], sequenceId: string) { + vi.unstubAllGlobals(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const onLoadEarlier = vi.fn(); + const props = buildProps(); + const renderThread = async (key: string) => { + await act(async () => { + root.render( + , + ); + }); + }; + let threadIndex = 0; + let listLoaded = false; + try { + await renderThread(`search-load:${sequenceId}:${threadIndex}`); + expect(onLoadEarlier).not.toHaveBeenCalled(); + for (const event of events) { + onLoadEarlier.mockClear(); + if (event === "switch") { + threadIndex += 1; + listLoaded = false; + await renderThread(`search-load:${sequenceId}:${threadIndex}`); + expect(onLoadEarlier, events.join(" → ")).not.toHaveBeenCalled(); + } else { + await act(async () => activityTestState.onListLoad!()); + expect(onLoadEarlier, events.join(" → ")).toHaveBeenCalledTimes(listLoaded ? 0 : 1); + listLoaded = true; + } + } + } finally { + await act(async () => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + } + } + + it("waits for each new list to load before paging toward a search result", async () => { + await checkSequence(["load", "switch", "load"], "regression"); + await checkSequence(["load", "load", "switch"], "repeated-load-regression"); + }); + + it("never pages before the current list loads across all three-event orderings", async () => { + // Repeated load callbacks and switches exercise the two independent actors. + const events: readonly Event[] = ["load", "switch"]; + for (const first of events) { + for (const second of events) { + for (const third of events) { + const sequence = [first, second, third]; + await checkSequence(sequence, sequence.join("-")); + } + } + } + }); +}); + describe("thread entry with the real virtualizer", () => { it("returns a previously scrolled thread to its measured end", async () => { vi.unstubAllGlobals(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3d9476b4885d..0dfabd16c404 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -557,6 +557,11 @@ export function MessagesTimeline(props: MessagesTimelineProps) { }, [findOpen, onManualNavigation]); return ( rememberedDisclosures?.attempts ?? new Set(), ); const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); - const listIdentityRef = useRef(listIdentityKey); const previousLatestRunRef = useRef(latestRun); // The new thread must open with an instant end pin, even mid-turn. - const [settlingListIdentity, setSettlingListIdentity] = useState(null); - let paintedExpandedRunIds = expandedRunIds; - let paintedExpandedWorkGroupIds = expandedWorkGroupIds; - let paintedExpandedAttemptIds = expandedAttemptIds; - if (listIdentityRef.current !== listIdentityKey) { - listIdentityRef.current = listIdentityKey; - previousLatestRunRef.current = latestRun; - setSettlingListIdentity(listIdentityKey); - paintedExpandedRunIds = rememberedDisclosures?.runs ?? new Set(); - paintedExpandedWorkGroupIds = rememberedDisclosures?.workGroups ?? new Set(); - paintedExpandedAttemptIds = rememberedDisclosures?.attempts ?? new Set(); - setExpandedRunIds(paintedExpandedRunIds); - setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); - setExpandedAttemptIds(paintedExpandedAttemptIds); - } + const [settlingListIdentity, setSettlingListIdentity] = useState(listIdentityKey); const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]); const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined); const expandCitedRun = useCallback((runId: RunId) => { @@ -845,11 +834,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); }, [activeFindRunId]); const visibleExpandedRunIds = useMemo(() => { - if (!activeFindRunId || paintedExpandedRunIds.has(activeFindRunId)) { - return paintedExpandedRunIds; + if (!activeFindRunId || expandedRunIds.has(activeFindRunId)) { + return expandedRunIds; } - return new Set(paintedExpandedRunIds).add(activeFindRunId); - }, [activeFindRunId, paintedExpandedRunIds]); + return new Set(expandedRunIds).add(activeFindRunId); + }, [activeFindRunId, expandedRunIds]); const activeFindAttemptId = activeFindMatch ? timelineEntries.find((entry) => entry.id === activeFindMatch.entryId)?.attempt?.id @@ -862,10 +851,10 @@ const ConversationTimeline = memo(function ConversationTimeline({ }, [activeFindAttemptId]); const visibleExpandedAttemptIds = useMemo( () => - activeFindAttemptId && !paintedExpandedAttemptIds.has(activeFindAttemptId) - ? new Set(paintedExpandedAttemptIds).add(activeFindAttemptId) - : paintedExpandedAttemptIds, - [activeFindAttemptId, paintedExpandedAttemptIds], + activeFindAttemptId && !expandedAttemptIds.has(activeFindAttemptId) + ? new Set(expandedAttemptIds).add(activeFindAttemptId) + : expandedAttemptIds, + [activeFindAttemptId, expandedAttemptIds], ); const rowsProjectionRef = useRef<{ @@ -1099,9 +1088,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ useEffect(() => { rememberTimelineDisclosures(listIdentityKey, { - runs: paintedExpandedRunIds, - workGroups: paintedExpandedWorkGroupIds, - attempts: paintedExpandedAttemptIds, + runs: expandedRunIds, + workGroups: expandedWorkGroupIds, + attempts: expandedAttemptIds, workGroupState: workGroupViewState, }); const frame = requestAnimationFrame(handleScroll); @@ -1109,9 +1098,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ }, [ handleScroll, listIdentityKey, - paintedExpandedRunIds, - paintedExpandedWorkGroupIds, - paintedExpandedAttemptIds, + expandedRunIds, + expandedWorkGroupIds, + expandedAttemptIds, workGroupViewState, rows.length, ]); @@ -1439,8 +1428,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ /> ) : null} - // Loading can retain the previous timeline after the requested thread changes. - key={JSON.stringify([entryThreadKey, listIdentityKey])} ref={setTimelineList} data={rows} extraData={`${listIdentityKey}:${rows.length}`} @@ -1448,9 +1435,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} - initialScrollAtEnd={ - !findActive && citationRequest === null - } + initialScrollAtEnd={!findActive && citationRequest === null} // Legend needs a data refresh to mount new pins without a scroll event. dataVersion={readyCitationRequest?.key ?? listIdentityKey} {...(alwaysRender ? { alwaysRender } : {})} @@ -1469,10 +1454,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ - findActive || - citationPositioning - ? false - : maintainVisibleContentPosition + findActive || citationPositioning ? false : maintainVisibleContentPosition } maintainScrollAtEndThreshold={1} onScroll={handleScroll} From a53f44fed40a2a5d5e309a86c1d340bba666ce08 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Thu, 8 Oct 2026 14:35:13 -0600 Subject: [PATCH 4/9] fix(web): keep timeline visible when switching threads --- .../components/chat/MessagesTimeline.test.tsx | 66 ++++++++-- .../src/components/chat/MessagesTimeline.tsx | 118 ++++++++++++------ .../chat/useAssistantCitationTarget.ts | 7 +- 3 files changed, 139 insertions(+), 52 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c2ea0d2b6870..a8eb116c32ea 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -2779,8 +2779,8 @@ describe("disclosure state across thread switches", () => { }); }); -describe("thread search across list remounts", () => { - type Event = "load" | "switch"; +describe("thread search across retained list updates", () => { + type Event = "load" | "switch" | "empty"; async function checkSequence(events: readonly Event[], sequenceId: string) { vi.unstubAllGlobals(); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); @@ -2821,12 +2821,16 @@ describe("thread search across list remounts", () => { expect(onLoadEarlier).not.toHaveBeenCalled(); for (const event of events) { onLoadEarlier.mockClear(); - if (event === "switch") { - threadIndex += 1; + if (event === "empty") { listLoaded = false; - await renderThread(`search-load:${sequenceId}:${threadIndex}`); + await act(async () => root.render()); expect(onLoadEarlier, events.join(" → ")).not.toHaveBeenCalled(); + } else if (event === "switch") { + threadIndex += 1; + await renderThread(`search-load:${sequenceId}:${threadIndex}`); + expect(onLoadEarlier, events.join(" → ")).toHaveBeenCalledTimes(listLoaded ? 1 : 0); } else { + if (container.querySelector('[data-testid="legend-list"]') === null) continue; await act(async () => activityTestState.onListLoad!()); expect(onLoadEarlier, events.join(" → ")).toHaveBeenCalledTimes(listLoaded ? 0 : 1); listLoaded = true; @@ -2839,14 +2843,15 @@ describe("thread search across list remounts", () => { } } - it("waits for each new list to load before paging toward a search result", async () => { + it("waits for the list to load and reuses readiness when switching threads", async () => { await checkSequence(["load", "switch", "load"], "regression"); await checkSequence(["load", "load", "switch"], "repeated-load-regression"); + await checkSequence(["load", "empty", "switch", "load"], "actual-remount-regression"); }); - it("never pages before the current list loads across all three-event orderings", async () => { + it("never pages before the retained list loads across all three-event orderings", async () => { // Repeated load callbacks and switches exercise the two independent actors. - const events: readonly Event[] = ["load", "switch"]; + const events: readonly Event[] = ["load", "switch", "empty"]; for (const first of events) { for (const second of events) { for (const third of events) { @@ -2940,9 +2945,25 @@ describe("thread entry with the real virtualizer", () => { options?: ScrollToOptions | number, y?: number, ) { - this.scrollTop = typeof options === "number" ? (y ?? 0) : (options?.top ?? this.scrollTop); + this.scrollTop = Math.max( + 0, + Math.min( + this.scrollHeight - this.clientHeight, + typeof options === "number" ? (y ?? 0) : (options?.top ?? this.scrollTop), + ), + ); this.dispatchEvent(new Event("scroll")); }; + const originalScrollBy = HTMLElement.prototype.scrollBy; + HTMLElement.prototype.scrollBy = function ( + this: HTMLElement, + options?: ScrollToOptions | number, + y?: number, + ) { + this.scrollTo({ + top: this.scrollTop + (typeof options === "number" ? (y ?? 0) : (options?.top ?? 0)), + }); + }; const flushLayout = async () => { for (let i = 0; i < 12; i++) { await act(async () => { @@ -2979,7 +3000,17 @@ describe("thread entry with the real virtualizer", () => { message: { ...entry.message, id: MessageId.make(`message-${index}`) }, }; }); + const entriesB = entries.slice(0, 24).map((entry, index) => ({ + ...entry, + id: `thread-b-entry-${index}`, + message: { + ...entry.message, + id: MessageId.make(`thread-b-message-${index}`), + text: `Thread B message ${index}`, + }, + })); const renderThread = async (key: string, displayThreadKey = key) => { + const previousViewport = props.listRef.current?.getScrollableNode(); await act(async () => root.render( { routeThreadKey={displayThreadKey} displayThreadKey={displayThreadKey} entryThreadKey={key} - timelineEntries={entries} + timelineEntries={displayThreadKey.endsWith("thread-b") ? entriesB : entries} />, ), ); + if (previousViewport) { + expect(props.listRef.current!.getScrollableNode()).toBe(previousViewport); + expect(previousViewport.isConnected).toBe(true); + const row = previousViewport.querySelector(".messages-timeline-row-frame"); + expect(row).not.toBeNull(); + for ( + let node = row?.parentElement; + node && node !== previousViewport; + node = node.parentElement + ) { + expect(node.style.opacity).not.toBe("0"); + } + } await flushLayout(); }; try { @@ -3029,6 +3073,7 @@ describe("thread entry with the real virtualizer", () => { expect(viewport.scrollTop).toBe(100); props.liveFollowEnabled = true; await renderThread("entry-test:thread-b"); + expect(container.textContent).toContain("Thread B message 23"); await renderThread("entry-test:thread-a"); const reopened = props.listRef.current!.getScrollableNode(); expect( @@ -3042,6 +3087,7 @@ describe("thread entry with the real virtualizer", () => { widthSpy.mockRestore(); scrollHeightSpy.mockRestore(); HTMLElement.prototype.scrollTo = originalScrollTo; + HTMLElement.prototype.scrollBy = originalScrollBy; vi.useRealTimers(); vi.unstubAllGlobals(); activityTestState.nativeList = false; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0dfabd16c404..bab277dfbdea 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -557,11 +557,6 @@ export function MessagesTimeline(props: MessagesTimelineProps) { }, [findOpen, onManualNavigation]); return ( rememberedDisclosures?.attempts ?? new Set(), ); const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); + const listIdentityRef = useRef(listIdentityKey); const previousLatestRunRef = useRef(latestRun); // The new thread must open with an instant end pin, even mid-turn. const [settlingListIdentity, setSettlingListIdentity] = useState(listIdentityKey); + let paintedExpandedRunIds = expandedRunIds; + let paintedExpandedWorkGroupIds = expandedWorkGroupIds; + let paintedExpandedAttemptIds = expandedAttemptIds; + if (listIdentityRef.current !== listIdentityKey) { + listIdentityRef.current = listIdentityKey; + previousLatestRunRef.current = latestRun; + setSettlingListIdentity(listIdentityKey); + paintedExpandedRunIds = rememberedDisclosures?.runs ?? new Set(); + paintedExpandedWorkGroupIds = rememberedDisclosures?.workGroups ?? new Set(); + paintedExpandedAttemptIds = rememberedDisclosures?.attempts ?? new Set(); + setExpandedRunIds(paintedExpandedRunIds); + setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); + setExpandedAttemptIds(paintedExpandedAttemptIds); + } const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]); const openPullRequest = useOpenPrLink(citationThreadRef ?? undefined); const expandCitedRun = useCallback((runId: RunId) => { @@ -751,7 +762,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); const onToggleWorkGroup = useCallback( (groupId: string, anchorKey: string) => { - suspendEndScrollMaintenanceForDisclosure(anchorKey, expandedWorkGroupIds.has(groupId)); + suspendEndScrollMaintenanceForDisclosure(anchorKey, paintedExpandedWorkGroupIds.has(groupId)); setExpandedWorkGroupIds((existing) => { const next = new Set(existing); if (next.has(groupId)) { @@ -762,7 +773,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ return next; }); }, - [expandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure], + [paintedExpandedWorkGroupIds, suspendEndScrollMaintenanceForDisclosure], ); const onToggleAttemptFold = useCallback( (attemptId: RunAttemptId) => { @@ -834,11 +845,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); }, [activeFindRunId]); const visibleExpandedRunIds = useMemo(() => { - if (!activeFindRunId || expandedRunIds.has(activeFindRunId)) { - return expandedRunIds; + if (!activeFindRunId || paintedExpandedRunIds.has(activeFindRunId)) { + return paintedExpandedRunIds; } - return new Set(expandedRunIds).add(activeFindRunId); - }, [activeFindRunId, expandedRunIds]); + return new Set(paintedExpandedRunIds).add(activeFindRunId); + }, [activeFindRunId, paintedExpandedRunIds]); const activeFindAttemptId = activeFindMatch ? timelineEntries.find((entry) => entry.id === activeFindMatch.entryId)?.attempt?.id @@ -851,10 +862,10 @@ const ConversationTimeline = memo(function ConversationTimeline({ }, [activeFindAttemptId]); const visibleExpandedAttemptIds = useMemo( () => - activeFindAttemptId && !expandedAttemptIds.has(activeFindAttemptId) - ? new Set(expandedAttemptIds).add(activeFindAttemptId) - : expandedAttemptIds, - [activeFindAttemptId, expandedAttemptIds], + activeFindAttemptId && !paintedExpandedAttemptIds.has(activeFindAttemptId) + ? new Set(paintedExpandedAttemptIds).add(activeFindAttemptId) + : paintedExpandedAttemptIds, + [activeFindAttemptId, paintedExpandedAttemptIds], ); const rowsProjectionRef = useRef<{ @@ -871,7 +882,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ runningRunId, expandedRunIds: visibleExpandedRunIds, expandedAttemptIds: visibleExpandedAttemptIds, - expandedWorkGroupIds, + expandedWorkGroupIds: paintedExpandedWorkGroupIds, isWorking, runlessWorkActive, activeTurnStartedAt, @@ -894,7 +905,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ runningRunId, visibleExpandedRunIds, visibleExpandedAttemptIds, - expandedWorkGroupIds, + paintedExpandedWorkGroupIds, isWorking, runlessWorkActive, activeTurnStartedAt, @@ -912,10 +923,17 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); // Re-measure the minimap gutter when the chat column changes width without a viewport resize. const chatWidth = useClientSettings((settings) => settings.chatWidth); + const [findListReady, setFindListReady] = useState(false); + const hasTimelineList = + rows.length > 0 || + isWorking || + parentThreadLink !== null || + historyControls !== undefined || + footer !== null; + if (!hasTimelineList && findListReady) setFindListReady(false); const { target: readyCitationRequest, positioning: citationPositioning, - onListLoad: onCitationListLoad, alwaysRender: citationAlwaysRender, } = useAssistantCitationTarget({ request: citationRequest, @@ -923,16 +941,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ rows, listRef, viewport: timelineViewportElement, + listLoaded: findListReady, historyLoading: citationHistoryLoading, loadEarlier, onExpandTurn: expandCitedRun, onManualNavigation, }); - const [findListReady, setFindListReady] = useState(false); - const handleListLoad = useCallback(() => { - onCitationListLoad(); - setFindListReady(true); - }, [onCitationListLoad]); + const handleListLoad = useCallback(() => setFindListReady(true), []); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [fullscreenAppRowId, setFullscreenAppRowId] = useState(null); // Only the row that holds the pin can release it. @@ -1088,9 +1103,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ useEffect(() => { rememberTimelineDisclosures(listIdentityKey, { - runs: expandedRunIds, - workGroups: expandedWorkGroupIds, - attempts: expandedAttemptIds, + runs: paintedExpandedRunIds, + workGroups: paintedExpandedWorkGroupIds, + attempts: paintedExpandedAttemptIds, workGroupState: workGroupViewState, }); const frame = requestAnimationFrame(handleScroll); @@ -1098,9 +1113,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ }, [ handleScroll, listIdentityKey, - expandedRunIds, - expandedWorkGroupIds, - expandedAttemptIds, + paintedExpandedRunIds, + paintedExpandedWorkGroupIds, + paintedExpandedAttemptIds, workGroupViewState, rows.length, ]); @@ -1296,6 +1311,38 @@ const ConversationTimeline = memo(function ConversationTimeline({ [listRef, onScrollNodeMount, registerTimeline], ); + const entryNavigationRef = useRef({ + requested: entryThreadKey, + displayed: listIdentityKey, + pending: false, + }); + useLayoutEffect(() => { + const navigation = entryNavigationRef.current; + if (navigation.requested !== entryThreadKey || navigation.displayed !== listIdentityKey) { + navigation.requested = entryThreadKey; + navigation.displayed = listIdentityKey; + navigation.pending = true; + } + // Keep the outgoing thread's position until the incoming timeline is ready. + if (!navigation.pending || entryThreadKey !== listIdentityKey) return; + if (findActive || citationRequest !== null) { + navigation.pending = false; + return; + } + if (!liveFollowEnabled || !listRef.current) return; + navigation.pending = false; + setSettlingListIdentity(listIdentityKey); + void listRef.current.scrollToEnd({ animated: false }); + }, [ + entryThreadKey, + listIdentityKey, + findActive, + citationRequest, + liveFollowEnabled, + listRef, + rows.length, + ]); + useLayoutEffect(() => { if (!findPositionReaderRef || !timelineViewportElement) return; const read: ThreadFindPositionReader = (query) => { @@ -1389,14 +1436,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ [], ); - if ( - rows.length === 0 && - !isWorking && - parentThreadLink === null && - historyControls === undefined && - // A status line (settled, snoozed) still needs the list, whose footer renders it. - footer === null - ) { + if (!hasTimelineList) { if (hideEmptyPlaceholder) { // Occupy the pane with the theme surface so a thread switch cannot // punch a hole through to the window chrome (white in light mode). @@ -1456,7 +1496,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ maintainVisibleContentPosition={ findActive || citationPositioning ? false : maintainVisibleContentPosition } - maintainScrollAtEndThreshold={1} + // Manual navigation disables follow explicitly. Measuring a tall row + // must not break follow merely because its end moved beyond the viewport. + maintainScrollAtEndThreshold={liveFollowEnabled ? Number.MAX_SAFE_INTEGER : 1} onScroll={handleScroll} onItemSizeChanged={reportContentOverflow} className={cn( diff --git a/apps/web/src/components/chat/useAssistantCitationTarget.ts b/apps/web/src/components/chat/useAssistantCitationTarget.ts index 958e2531eeee..11c667702873 100644 --- a/apps/web/src/components/chat/useAssistantCitationTarget.ts +++ b/apps/web/src/components/chat/useAssistantCitationTarget.ts @@ -1,6 +1,6 @@ import type { LegendListRef } from "@legendapp/list/react"; import type { RunId } from "@t3tools/contracts"; -import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { useEffect, useRef, useState, type RefObject } from "react"; import type { TimelineEntry } from "../../session-logic"; import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; import type { AssistantCitationRequest, AssistantCitationTarget } from "./AssistantCitationSource"; @@ -19,6 +19,7 @@ export function useAssistantCitationTarget({ rows, listRef, viewport, + listLoaded, historyLoading, loadEarlier, onExpandTurn, @@ -29,6 +30,7 @@ export function useAssistantCitationTarget({ rows: ReadonlyArray; listRef: RefObject; viewport: HTMLElement | null; + listLoaded: boolean; historyLoading: boolean; loadEarlier: CitationHistoryPage | null; onExpandTurn: (runId: RunId) => void; @@ -36,8 +38,6 @@ export function useAssistantCitationTarget({ }) { const [ready, setReady] = useState(null); const [finishedKey, setFinishedKey] = useState(null); - const [listLoaded, setListLoaded] = useState(false); - const onListLoad = useCallback(() => setListLoaded(true), []); const navigationRef = useRef<{ target: AssistantCitationTarget; requestedPages: Set; @@ -185,7 +185,6 @@ export function useAssistantCitationTarget({ return { target, positioning, - onListLoad, alwaysRender: sourceRow ? { keys: [sourceRow.id] } : undefined, }; } From 414c3dd5e76f506477541e2429a702f3784b662b Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Thu, 8 Oct 2026 14:56:38 -0600 Subject: [PATCH 5/9] fix(web): preserve navigation intent during timeline handoff --- apps/web/src/components/ChatView.tsx | 310 +++++++++--------- .../components/chat/MessagesTimeline.test.tsx | 66 +++- .../src/components/chat/MessagesTimeline.tsx | 5 +- .../chat/timelineScrollAnchoring.ts | 15 + 4 files changed, 236 insertions(+), 160 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9f95aaba67c5..b8f8fe3246da 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -204,6 +204,7 @@ import { type LegendListRef } from "@legendapp/list/react"; import { CHAT_TIMELINE_ANCHOR_OFFSET, timelineContentOverflowsViewport, + cancelTimelineProgrammaticScroll, observeTimelineRun, type TimelineRunObservation, type TimelineScrollMode, @@ -611,7 +612,6 @@ const EMPTY_PROVIDER_MODELS: ServerProvider["models"] = []; const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates"; -const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); const EMPTY_FEEDBACK_SUBMISSIONS: ReadonlyArray = []; // During an active turn the thread's updatedAt advances several times per // second, and every server-side visit is a full command dispatch plus a @@ -6350,9 +6350,10 @@ export default function ChatView(props: ChatViewProps) { // re-pins on its own (independent of the refs), so the timeline needs a // render-visible flag to switch it off once the user scrolls away. const [timelineLiveFollowEnabled, setTimelineLiveFollowEnabled] = useState(true); - const [timelineFollowThreadKey, setTimelineFollowThreadKey] = useState(routeThreadKey); - if (timelineFollowThreadKey !== routeThreadKey) { - setTimelineFollowThreadKey(routeThreadKey); + const timelineFollowEntryKey = paintOnlyDisplayedTimeline ? null : routeThreadKey; + const [timelineFollowThreadKey, setTimelineFollowThreadKey] = useState(timelineFollowEntryKey); + if (timelineFollowThreadKey !== timelineFollowEntryKey) { + setTimelineFollowThreadKey(timelineFollowEntryKey); setTimelineLiveFollowEnabled(true); } const pendingTimelineAnchorRef = useRef(null); @@ -6384,28 +6385,7 @@ export default function ChatView(props: ChatViewProps) { anchorScrollRestoreFrameRef.current = null; } if (wasProgrammaticScrollMode) { - // While following or anchoring, our scrollToEnd/scrollToOffset calls can - // sit in LegendList's pending-imperative-scroll queue (it defers them - // while layout settles) and fire seconds later with stale targets, - // yanking the view away after the user scrolled. Starting a new - // imperative scroll cancels everything queued; targeting an item that - // is not in the data makes the new request itself resolve without ever - // scrolling, so this is a pure cancel. - void legendListRef.current?.scrollToItem({ - item: TIMELINE_SCROLL_CANCEL_SENTINEL, - animated: false, - }); - // An already-started animated scroll (behavior: smooth) keeps running in - // the browser regardless of the queue; a same-position instant write is - // the only way to halt it where it is. - const scrollNode = legendListRef.current?.getScrollableNode() as - | { scrollTop?: number } - | null - | undefined; - const currentScrollTop = scrollNode?.scrollTop; - if (scrollNode && typeof currentScrollTop === "number") { - scrollNode.scrollTop = currentScrollTop; - } + cancelTimelineProgrammaticScroll(legendListRef.current); } }, []); useEffect(() => { @@ -6505,143 +6485,159 @@ export default function ChatView(props: ChatViewProps) { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); - const onTimelineScrollNodeMount = useCallback( - (scrollNode: HTMLElement) => { - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigation(); - }; - // The gestures below must only break follow when they can actually - // move the viewport away from the live edge (#5566): a spurious break - // while pinned at the end produces no scroll event, never re-arms, - // and streaming silently stops following. Underflowing content can't - // scroll at all, so nothing there should break follow. - const contentScrollsUp = () => timelineRealContentOverflowsViewport(); - // The follow re-arm band, not the strict flag: streaming growth makes - // isAtEnd flicker false for a frame before the follow scroll catches - // up, and a gesture landing in that window while still pinned would - // otherwise break follow with no scroll event left to re-arm it. - const viewportIsAwayFromEnd = () => - resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; - // Only an upward wheel is a navigation intent; wheeling down while - // following either does nothing (at the end) or moves toward it. - const handleWheel = (event: WheelEvent) => { - if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) - return; - if (event.deltaY > 0) { - timelineScrollIntentRef.current = "toward-end"; - if (isAtEndRef.current) { - composerRef.current?.restoreAfterTimelineReachedEnd(); - } - } else if (event.deltaY < 0) { - timelineScrollIntentRef.current = "away-from-end"; - } - if (event.deltaY < 0 && contentScrollsUp()) { - handleManualNavigation(); + const scrollContentOverflows = useEffectEvent(() => timelineRealContentOverflowsViewport()); + const cancelFollowForNavigation = useEffectEvent(() => + cancelTimelineLiveFollowForUserNavigation(), + ); + const timelineAtLogicalEnd = useEffectEvent(() => isTimelineAtLogicalEnd()); + const onTimelineScrollNodeMount = useCallback((scrollNode: HTMLElement) => { + const handleManualNavigation = () => { + cancelFollowForNavigation(); + }; + // The gestures below must only break follow when they can actually + // move the viewport away from the live edge (#5566): a spurious break + // while pinned at the end produces no scroll event, never re-arms, + // and streaming silently stops following. Underflowing content can't + // scroll at all, so nothing there should break follow. + const contentScrollsUp = () => scrollContentOverflows(); + // The follow re-arm band, not the strict flag: streaming growth makes + // isAtEnd flicker false for a frame before the follow scroll catches + // up, and a gesture landing in that window while still pinned would + // otherwise break follow with no scroll event left to re-arm it. + const viewportIsAwayFromEnd = () => + resolveTimelineIsAtEnd(legendListRef.current?.getState()) === false; + // Only an upward wheel is a navigation intent; wheeling down while + // following either does nothing (at the end) or moves toward it. + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) return; + if (event.deltaY > 0) { + timelineScrollIntentRef.current = "toward-end"; + if (isAtEndRef.current) { + composerRef.current?.restoreAfterTimelineReachedEnd(); } - }; - // Touch direction isn't observable here (touchmove fires on any - // finger motion, scrolling or not), so break only once the drag has - // actually carried the viewport out of the end band — an upward flick - // gets there within its first few events and later touchmoves break. - const handleTouchMove = () => { - if (viewportIsAwayFromEnd()) { + } else if (event.deltaY < 0) { + timelineScrollIntentRef.current = "away-from-end"; + } + if (event.deltaY < 0 && contentScrollsUp()) { + handleManualNavigation(); + } + }; + // Touch direction isn't observable here (touchmove fires on any + // finger motion, scrolling or not), so break only once the drag has + // actually carried the viewport out of the end band — an upward flick + // gets there within its first few events and later touchmoves break. + const handleTouchMove = () => { + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Scrollbar drags produce no wheel/touch events; they are the only + // pointerdowns whose target is the scroll node itself rather than a + // message row. Content clicks break follow only away from the end + // (reading or selecting up there must hold position); clicking near + // the live edge keeps following. + const handlePointerDown = (event: PointerEvent) => { + if (event.target === scrollNode || event.button === 1) { + if (contentScrollsUp()) { handleManualNavigation(); } - }; - // Scrollbar drags produce no wheel/touch events; they are the only - // pointerdowns whose target is the scroll node itself rather than a - // message row. Content clicks break follow only away from the end - // (reading or selecting up there must hold position); clicking near - // the live edge keeps following. - const handlePointerDown = (event: PointerEvent) => { - if (event.target === scrollNode) { + return; + } + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + }; + // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and + // pointer events entirely; without this the timeline yanks back to + // the end on the next stream chunk. Clicking message text can leave + // DOM focus on body, so these keys must also be heard at document. + const handleKeyDown = (event: KeyboardEvent) => { + if ( + !(event.target instanceof Node) || + (!scrollNode.contains(event.target) && + event.target !== document.body && + event.target !== document.documentElement) || + event.defaultPrevented || + event.isComposing || + event.altKey || + event.ctrlKey || + event.metaKey || + (event.shiftKey && event.key !== " ") || + eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || + document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) + ) { + return; + } + const scrollKey = event.key === " " ? (event.shiftKey ? "PageUp" : "PageDown") : event.key; + if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(scrollKey)) + return; + const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(scrollKey) ? -1 : 1; + if ( + scrollNode.contains(event.target) && + !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) + ) + return; + switch (scrollKey) { + case "PageUp": + case "Home": + case "ArrowUp": + timelineScrollIntentRef.current = "away-from-end"; if (contentScrollsUp()) { handleManualNavigation(); + composerRef.current?.collapseForTimelineScrollKey(scrollKey); } - return; - } - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - }; - // Keyboard scrolling (PageUp/Home/ArrowUp) bypasses wheel and - // pointer events entirely; without this the timeline yanks back to - // the end on the next stream chunk. Clicking message text can leave - // DOM focus on body, so these keys must also be heard at document. - const handleKeyDown = (event: KeyboardEvent) => { - if ( - !(event.target instanceof Node) || - (!scrollNode.contains(event.target) && - event.target !== document.body && - event.target !== document.documentElement) || - event.defaultPrevented || - event.isComposing || - event.altKey || - event.ctrlKey || - event.metaKey || - event.shiftKey || - eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR) || - document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR) - ) { - return; - } - if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) - return; - const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; - if ( - scrollNode.contains(event.target) && - !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) - ) - return; - switch (event.key) { - case "PageUp": - case "Home": - case "ArrowUp": - timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp()) { - handleManualNavigation(); - composerRef.current?.collapseForTimelineScrollKey(event.key); - } - break; - case "PageDown": - case "End": - case "ArrowDown": - timelineScrollIntentRef.current = "toward-end"; - if (viewportIsAwayFromEnd()) { - handleManualNavigation(); - } - composerRef.current?.collapseForTimelineScrollKey(event.key); - if (isTimelineAtLogicalEnd()) { - composerRef.current?.restoreAfterTimelineReachedEnd(); - } - break; - default: - break; - } - }; - scrollNode.addEventListener("wheel", handleWheel, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleTouchMove, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handlePointerDown, { - passive: true, - }); - document.addEventListener("keydown", handleKeyDown); - return () => { - scrollNode.removeEventListener("wheel", handleWheel); - scrollNode.removeEventListener("touchmove", handleTouchMove); - scrollNode.removeEventListener("pointerdown", handlePointerDown); - document.removeEventListener("keydown", handleKeyDown); - }; - }, - [ - cancelTimelineLiveFollowForUserNavigation, - isTimelineAtLogicalEnd, - timelineRealContentOverflowsViewport, - ], - ); + break; + case "PageDown": + case "End": + case "ArrowDown": + timelineScrollIntentRef.current = "toward-end"; + if (viewportIsAwayFromEnd()) { + handleManualNavigation(); + } + composerRef.current?.collapseForTimelineScrollKey(scrollKey); + if (timelineAtLogicalEnd()) { + composerRef.current?.restoreAfterTimelineReachedEnd(); + } + break; + default: + break; + } + }; + const handleSelectionChange = () => { + const selection = document.getSelection(); + if ( + selection?.anchorNode && + scrollNode.contains(selection.anchorNode) && + !selection.isCollapsed && + contentScrollsUp() + ) { + handleManualNavigation(); + } + }; + const handleFocusIn = () => { + if (viewportIsAwayFromEnd()) handleManualNavigation(); + }; + document.addEventListener("selectionchange", handleSelectionChange); + scrollNode.addEventListener("focusin", handleFocusIn); + scrollNode.addEventListener("wheel", handleWheel, { + passive: true, + }); + scrollNode.addEventListener("touchmove", handleTouchMove, { + passive: true, + }); + scrollNode.addEventListener("pointerdown", handlePointerDown, { + passive: true, + }); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("selectionchange", handleSelectionChange); + scrollNode.removeEventListener("focusin", handleFocusIn); + scrollNode.removeEventListener("wheel", handleWheel); + scrollNode.removeEventListener("touchmove", handleTouchMove); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, []); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { if (pendingTimelineAnchorRef.current === messageId) { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index a8eb116c32ea..34997a5a5e7e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -20,6 +20,7 @@ import { import { renderToStaticMarkup } from "react-dom/server"; import { createRoot } from "react-dom/client"; import { create, type ReactTestRenderer } from "react-test-renderer"; +import { cancelTimelineProgrammaticScroll } from "./timelineScrollAnchoring"; import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; import { useComposerFocusState } from "./useComposerFocusState"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -2905,7 +2906,8 @@ describe("thread entry with the real virtualizer", () => { const height = this.classList.contains("messages-timeline-scroll") ? 600 : this.firstElementChild?.classList.contains("messages-timeline-row-frame") - ? this.textContent?.includes("Message 39") + ? this.textContent?.includes("Message 39") || + this.textContent?.includes("Thread B message 39") ? lastRowHeight : 180 : this.style.height.endsWith("px") @@ -2990,7 +2992,7 @@ describe("thread entry with the real virtualizer", () => { }; const container = document.createElement("div"); document.body.append(container); - const root = createRoot(container); + let root = createRoot(container); const props = buildProps(); const entries = Array.from({ length: 40 }, (_, index) => { const entry = buildUserTimelineEntry(`Message ${index}`); @@ -3046,7 +3048,10 @@ describe("thread entry with the real virtualizer", () => { firstViewport.dispatchEvent(new Event("scroll")); }); // B has no cached history yet, so the client keeps A visible while B loads. + props.liveFollowEnabled = false; await renderThread("entry-test:thread-b", "entry-test:thread-a"); + expect(firstViewport.scrollTop).toBe(100); + props.liveFollowEnabled = true; await renderThread("entry-test:thread-a"); const viewport = props.listRef.current!.getScrollableNode(); expect(viewport.scrollTop).toBeGreaterThan(3000); @@ -3079,6 +3084,63 @@ describe("thread entry with the real virtualizer", () => { expect( Math.abs(reopened.scrollHeight - reopened.clientHeight - reopened.scrollTop), ).toBeLessThanOrEqual(1); + + // Interrupt a native entry target before its completion frames, then measure a tall tail. + await act(async () => root.unmount()); + root = createRoot(container); + const { LegendList } = + await vi.importActual("@legendapp/list/react"); + const nativeRef = createRef(); + const a = Array.from({ length: 40 }, (_, index) => ({ + id: `a-${index}`, + text: `A ${index}`, + })); + const b = a.map((item, index) => ({ + ...item, + id: `b-${index}`, + text: `Thread B message ${index}`, + })); + function NativeEntry({ data, follow }: { data: typeof a; follow: boolean }) { + useLayoutEffect(() => { + if (data === b) void nativeRef.current?.scrollToEnd({ animated: false }); + }, [data]); + return ( + item.id} + renderItem={({ item }) => ( +
{item.text}
+ )} + estimatedItemSize={90} + className="messages-timeline-scroll" + initialScrollAtEnd + maintainScrollAtEnd={ + follow + ? { animated: false, on: { dataChange: true, itemLayout: true, layout: true } } + : false + } + maintainScrollAtEndThreshold={follow ? Number.MAX_SAFE_INTEGER : 1} + maintainVisibleContentPosition={{ data: true, size: true }} + /> + ); + } + lastRowHeight = 180; + await act(async () => root.render()); + await flushLayout(); + await act(async () => root.render()); + const interrupted = nativeRef.current!.getScrollableNode(); + await act(async () => { + cancelTimelineProgrammaticScroll(nativeRef.current); + root.render(); + }); + await act(async () => { + interrupted.scrollTop = 100; + interrupted.dispatchEvent(new Event("scroll")); + }); + lastRowHeight = 4000; + await flushLayout(); + expect(interrupted.scrollTop).toBe(100); } finally { await act(async () => root.unmount()); container.remove(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bab277dfbdea..61fa383ed435 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1332,7 +1332,10 @@ const ConversationTimeline = memo(function ConversationTimeline({ if (!liveFollowEnabled || !listRef.current) return; navigation.pending = false; setSettlingListIdentity(listIdentityKey); - void listRef.current.scrollToEnd({ animated: false }); + void listRef.current.scrollToOffset({ + offset: listRef.current.getState().contentLength, + animated: false, + }); }, [ entryThreadKey, listIdentityKey, diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 531e31bdd93e..77ad9abdc706 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -1,3 +1,4 @@ +import type { LegendListRef } from "@legendapp/list/react"; import type { MessageId, RunId } from "@t3tools/contracts"; export interface TimelineRunObservation { @@ -170,3 +171,17 @@ export function rememberTimelineDisclosures( if (oldest !== undefined) rememberedTimelineDisclosures.delete(oldest); } } + +const TIMELINE_SCROLL_CANCEL_SENTINEL = Object.freeze({}); + +export function cancelTimelineProgrammaticScroll(list: LegendListRef | null): void { + const node = list?.getScrollableNode(); + const offset = node?.scrollTop; + if (typeof offset === "number") { + // Clear an active native index target before cancelling queued requests. + void list?.scrollToOffset({ offset, animated: false }); + } + void list?.scrollToItem({ item: TIMELINE_SCROLL_CANCEL_SENTINEL, animated: false }); + // An instant same-position write also stops a browser smooth scroll. + if (node && typeof offset === "number") node.scrollTop = offset; +} From 615c2b6e11b4d17b6f6a477f32ea5c0711f87713 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Thu, 8 Oct 2026 15:13:45 -0600 Subject: [PATCH 6/9] fix(web): reset follow state when incoming history is ready --- apps/web/src/components/ChatView.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b8f8fe3246da..116c60b553a0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -726,6 +726,9 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ '[role="switch"]', '[role="tab"]', ].join(","); +const TIMELINE_SPACE_ACTIVATION_SELECTOR = TYPE_TO_FOCUS_INTERACTIVE_SELECTOR.split(",") + .filter((selector) => selector !== "a[href]") + .join(","); // Popups match only while open or closing: some stay mounted when closed, // such as the chat header actions menu. const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ @@ -6537,6 +6540,11 @@ export default function ChatView(props: ChatViewProps) { // (reading or selecting up there must hold position); clicking near // the live edge keeps following. const handlePointerDown = (event: PointerEvent) => { + if ( + event.button === 1 && + eventPathContainsSelector(event, `a[href],${TYPE_TO_FOCUS_EDITABLE_SELECTOR}`) + ) + return; if (event.target === scrollNode || event.button === 1) { if (contentScrollsUp()) { handleManualNavigation(); @@ -6568,6 +6576,8 @@ export default function ChatView(props: ChatViewProps) { ) { return; } + if (event.key === " " && eventPathContainsSelector(event, TIMELINE_SPACE_ACTIVATION_SELECTOR)) + return; const scrollKey = event.key === " " ? (event.shiftKey ? "PageUp" : "PageDown") : event.key; if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(scrollKey)) return; @@ -6799,8 +6809,8 @@ export default function ChatView(props: ChatViewProps) { activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - // Environment identity is part of the scroll session too. - }, [routeThreadKey]); + // Readiness starts a fresh scroll session after an outgoing thread was held. + }, [timelineFollowEntryKey]); useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; From 9e3391e96be4bc0e1e36cd000e971100238c83f0 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Thu, 8 Oct 2026 15:57:06 -0600 Subject: [PATCH 7/9] fix(web): stop following on scrolling without input gestures --- apps/web/src/components/ChatView.tsx | 7 ++ .../components/chat/MessagesTimeline.test.tsx | 82 ++++++++++++++++++- .../chat/timelineScrollAnchoring.ts | 27 ++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 116c60b553a0..f776574f3e29 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -205,6 +205,7 @@ import { CHAT_TIMELINE_ANCHOR_OFFSET, timelineContentOverflowsViewport, cancelTimelineProgrammaticScroll, + observeTimelineScrollNavigation, observeTimelineRun, type TimelineRunObservation, type TimelineScrollMode, @@ -6627,6 +6628,11 @@ export default function ChatView(props: ChatViewProps) { const handleFocusIn = () => { if (viewportIsAwayFromEnd()) handleManualNavigation(); }; + const removeScrollNavigation = observeTimelineScrollNavigation( + scrollNode, + () => timelineScrollModeRef.current === "following-end", + handleManualNavigation, + ); document.addEventListener("selectionchange", handleSelectionChange); scrollNode.addEventListener("focusin", handleFocusIn); scrollNode.addEventListener("wheel", handleWheel, { @@ -6640,6 +6646,7 @@ export default function ChatView(props: ChatViewProps) { }); document.addEventListener("keydown", handleKeyDown); return () => { + removeScrollNavigation(); document.removeEventListener("selectionchange", handleSelectionChange); scrollNode.removeEventListener("focusin", handleFocusIn); scrollNode.removeEventListener("wheel", handleWheel); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 34997a5a5e7e..4a632c8aef1b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -13,6 +13,9 @@ import { act, createRef, useLayoutEffect, + useState, + useRef, + useCallback, type ReactNode, type Ref, type ReactElement, @@ -20,7 +23,10 @@ import { import { renderToStaticMarkup } from "react-dom/server"; import { createRoot } from "react-dom/client"; import { create, type ReactTestRenderer } from "react-test-renderer"; -import { cancelTimelineProgrammaticScroll } from "./timelineScrollAnchoring"; +import { + cancelTimelineProgrammaticScroll, + observeTimelineScrollNavigation, +} from "./timelineScrollAnchoring"; import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; import { useComposerFocusState } from "./useComposerFocusState"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -3085,6 +3091,80 @@ describe("thread entry with the real virtualizer", () => { Math.abs(reopened.scrollHeight - reopened.clientHeight - reopened.scrollTop), ).toBeLessThanOrEqual(1); + const unsignaledNavigation = vi.fn(); + let appendMessage = () => {}; + function UnsignaledNavigationTimeline() { + const [follow, setFollow] = useState(true); + const [readingEntries, setReadingEntries] = useState(entries); + appendMessage = () => { + const next = buildUserTimelineEntry("New streamed message"); + setReadingEntries([ + ...entries, + { + ...next, + id: "entry-streamed", + message: { ...next.message, id: MessageId.make("message-streamed") }, + }, + ]); + }; + const following = useRef(follow); + following.current = follow; + const mountScrollNode = useCallback( + (node: HTMLElement) => + observeTimelineScrollNavigation( + node, + () => following.current, + () => { + unsignaledNavigation(); + following.current = false; + cancelTimelineProgrammaticScroll(props.listRef.current); + setFollow(false); + }, + ), + [], + ); + + return ( + + ); + } + lastRowHeight = 180; + await act(async () => root.render()); + await flushLayout(); + expect(unsignaledNavigation).not.toHaveBeenCalled(); + const readingViewport = props.listRef.current!.getScrollableNode(); + lastRowHeight = 4000; + await flushLayout(); + lastRowHeight = 180; + await flushLayout(); + expect(unsignaledNavigation).not.toHaveBeenCalled(); + expect( + Math.abs( + readingViewport.scrollHeight - readingViewport.clientHeight - readingViewport.scrollTop, + ), + ).toBeLessThanOrEqual(1); + await act(async () => { + // Accessibility and external scrolling need not dispatch an input gesture. + readingViewport.scrollTop = 100; + readingViewport.dispatchEvent(new Event("scroll")); + }); + await flushLayout(); + expect(unsignaledNavigation).toHaveBeenCalledTimes(1); + // Newly visible rows replace their estimates before subsequent streaming. + const readingOffset = readingViewport.scrollTop; + expect(readingOffset).toBeLessThan(300); + await act(async () => appendMessage()); + lastRowHeight = 4000; + await flushLayout(); + expect(readingViewport.scrollTop).toBe(readingOffset); + // Interrupt a native entry target before its completion frames, then measure a tall tail. await act(async () => root.unmount()); root = createRoot(container); diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 77ad9abdc706..a3138ba41b0a 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -185,3 +185,30 @@ export function cancelTimelineProgrammaticScroll(list: LegendListRef | null): vo // An instant same-position write also stops a browser smooth scroll. if (node && typeof offset === "number") node.scrollTop = offset; } + +export function observeTimelineScrollNavigation( + node: HTMLElement, + isFollowingEnd: () => boolean, + onManualNavigation: () => void, +): () => void { + let previousOffset = node.scrollTop; + let previousHeight = node.scrollHeight; + let previousViewport = node.clientHeight; + const handleScroll = () => { + const offset = node.scrollTop; + const height = node.scrollHeight; + const viewport = node.clientHeight; + const movedAway = + offset < previousOffset - 1 && + height >= previousHeight && + viewport === previousViewport && + height - viewport - offset > 40; + previousOffset = offset; + previousHeight = height; + previousViewport = viewport; + if (movedAway && isFollowingEnd()) onManualNavigation(); + }; + // Observe navigation before the virtualizer handles the event and schedules follow. + node.addEventListener("scroll", handleScroll, { capture: true, passive: true }); + return () => node.removeEventListener("scroll", handleScroll, true); +} From 1e7a6385d30516f6210a76adc947a120346ad5b5 Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Thu, 8 Oct 2026 16:17:35 -0600 Subject: [PATCH 8/9] fix(web): preserve navigation across timeline layout changes --- .../components/chat/MessagesTimeline.test.tsx | 79 ++++++++++++------- .../chat/timelineScrollAnchoring.ts | 12 +-- 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 4a632c8aef1b..4439d92816b6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -2906,6 +2906,8 @@ describe("thread entry with the real virtualizer", () => { // jsdom supplies no layout. Give the real virtualizer a 600px viewport // and 180px message rows, twice its 90px estimate. let lastRowHeight = 180; + let pendingScrollHeight: number | undefined; + let pendingViewportHeight: number | undefined; const rectSpy = vi .spyOn(HTMLElement.prototype, "getBoundingClientRect") .mockImplementation(function (this: HTMLElement) { @@ -2934,12 +2936,20 @@ describe("thread entry with the real virtualizer", () => { const heightSpy = vi .spyOn(HTMLElement.prototype, "clientHeight", "get") .mockImplementation(function (this: HTMLElement) { - return this.classList.contains("messages-timeline-scroll") ? 600 : 0; + return this.classList.contains("messages-timeline-scroll") + ? (pendingViewportHeight ?? 600) + : 0; }); const widthSpy = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800); const scrollHeightSpy = vi .spyOn(HTMLElement.prototype, "scrollHeight", "get") .mockImplementation(function (this: HTMLElement) { + if ( + pendingScrollHeight !== undefined && + this.classList.contains("messages-timeline-scroll") + ) { + return pendingScrollHeight; + } return Math.max( 600, ...Array.from(this.querySelectorAll("div")).map((node) => @@ -3135,35 +3145,44 @@ describe("thread entry with the real virtualizer", () => { /> ); } - lastRowHeight = 180; - await act(async () => root.render()); - await flushLayout(); - expect(unsignaledNavigation).not.toHaveBeenCalled(); - const readingViewport = props.listRef.current!.getScrollableNode(); - lastRowHeight = 4000; - await flushLayout(); - lastRowHeight = 180; - await flushLayout(); - expect(unsignaledNavigation).not.toHaveBeenCalled(); - expect( - Math.abs( - readingViewport.scrollHeight - readingViewport.clientHeight - readingViewport.scrollTop, - ), - ).toBeLessThanOrEqual(1); - await act(async () => { - // Accessibility and external scrolling need not dispatch an input gesture. - readingViewport.scrollTop = 100; - readingViewport.dispatchEvent(new Event("scroll")); - }); - await flushLayout(); - expect(unsignaledNavigation).toHaveBeenCalledTimes(1); - // Newly visible rows replace their estimates before subsequent streaming. - const readingOffset = readingViewport.scrollTop; - expect(readingOffset).toBeLessThan(300); - await act(async () => appendMessage()); - lastRowHeight = 4000; - await flushLayout(); - expect(readingViewport.scrollTop).toBe(readingOffset); + for (const geometryChange of ["none", "shrink", "resize"] as const) { + unsignaledNavigation.mockClear(); + lastRowHeight = 180; + await act(async () => root.render()); + await flushLayout(); + expect(unsignaledNavigation).not.toHaveBeenCalled(); + const readingViewport = props.listRef.current!.getScrollableNode(); + lastRowHeight = 4000; + await flushLayout(); + lastRowHeight = 180; + await flushLayout(); + expect(unsignaledNavigation).not.toHaveBeenCalled(); + expect( + Math.abs( + readingViewport.scrollHeight - readingViewport.clientHeight - readingViewport.scrollTop, + ), + ).toBeLessThanOrEqual(1); + await act(async () => { + // A shrink and external navigation can coalesce into one scroll event. + // The browser first clamps the old offset, then the reader moves farther up. + if (geometryChange === "shrink") pendingScrollHeight = readingViewport.scrollHeight - 100; + if (geometryChange === "resize") pendingViewportHeight = 700; + readingViewport.scrollTop = readingViewport.scrollHeight - readingViewport.clientHeight; + readingViewport.scrollTop = 100; + readingViewport.dispatchEvent(new Event("scroll")); + }); + pendingScrollHeight = undefined; + pendingViewportHeight = undefined; + await flushLayout(); + // Newly visible rows replace their estimates before subsequent streaming. + const readingOffset = readingViewport.scrollTop; + expect(readingOffset).toBeLessThan(300); + expect(unsignaledNavigation).toHaveBeenCalledTimes(1); + await act(async () => appendMessage()); + lastRowHeight = 4000; + await flushLayout(); + expect(readingViewport.scrollTop).toBe(readingOffset); + } // Interrupt a native entry target before its completion frames, then measure a tall tail. await act(async () => root.unmount()); diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index a3138ba41b0a..a9f7759e3eaf 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -192,20 +192,14 @@ export function observeTimelineScrollNavigation( onManualNavigation: () => void, ): () => void { let previousOffset = node.scrollTop; - let previousHeight = node.scrollHeight; - let previousViewport = node.clientHeight; const handleScroll = () => { const offset = node.scrollTop; const height = node.scrollHeight; const viewport = node.clientHeight; - const movedAway = - offset < previousOffset - 1 && - height >= previousHeight && - viewport === previousViewport && - height - viewport - offset > 40; + // Layout changes may clamp the old offset; only movement beyond that is navigation. + const clampedPreviousOffset = Math.min(previousOffset, Math.max(0, height - viewport)); + const movedAway = offset < clampedPreviousOffset - 1 && height - viewport - offset > 40; previousOffset = offset; - previousHeight = height; - previousViewport = viewport; if (movedAway && isFollowingEnd()) onManualNavigation(); }; // Observe navigation before the virtualizer handles the event and schedules follow. From 0e54fbf8a0277e5f8f47ef6ed672d8e109630c8c Mon Sep 17 00:00:00 2001 From: Aaron Queen Date: Thu, 8 Oct 2026 16:27:50 -0600 Subject: [PATCH 9/9] fix(web): distinguish native timeline position corrections --- apps/web/src/components/ChatView.tsx | 1 + .../components/chat/MessagesTimeline.test.tsx | 44 +++++++++++++++++-- .../chat/timelineScrollAnchoring.ts | 9 +++- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f776574f3e29..6aa0058bbba6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6632,6 +6632,7 @@ export default function ChatView(props: ChatViewProps) { scrollNode, () => timelineScrollModeRef.current === "following-end", handleManualNavigation, + () => legendListRef.current?.getState().scroll, ); document.addEventListener("selectionchange", handleSelectionChange); scrollNode.addEventListener("focusin", handleFocusIn); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 4439d92816b6..5d0aa522cd39 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -2906,18 +2906,26 @@ describe("thread entry with the real virtualizer", () => { // jsdom supplies no layout. Give the real virtualizer a 600px viewport // and 180px message rows, twice its 90px estimate. let lastRowHeight = 180; + const rowHeights = new Map(); let pendingScrollHeight: number | undefined; let pendingViewportHeight: number | undefined; const rectSpy = vi .spyOn(HTMLElement.prototype, "getBoundingClientRect") .mockImplementation(function (this: HTMLElement) { + const rowIndex = Number( + this.querySelector("[data-message-id]") + ?.getAttribute("data-message-id") + ?.match(/^message-(\d+)$/)?.[1], + ); const height = this.classList.contains("messages-timeline-scroll") ? 600 : this.firstElementChild?.classList.contains("messages-timeline-row-frame") - ? this.textContent?.includes("Message 39") || - this.textContent?.includes("Thread B message 39") - ? lastRowHeight - : 180 + ? rowHeights.has(rowIndex) + ? rowHeights.get(rowIndex)! + : this.textContent?.includes("Message 39") || + this.textContent?.includes("Thread B message 39") + ? lastRowHeight + : 180 : this.style.height.endsWith("px") ? Number.parseFloat(this.style.height) : 0; @@ -3130,6 +3138,7 @@ describe("thread entry with the real virtualizer", () => { cancelTimelineProgrammaticScroll(props.listRef.current); setFollow(false); }, + () => props.listRef.current?.getState().scroll, ), [], ); @@ -3162,6 +3171,33 @@ describe("thread entry with the real virtualizer", () => { readingViewport.scrollHeight - readingViewport.clientHeight - readingViewport.scrollTop, ), ).toBeLessThanOrEqual(1); + if (geometryChange === "none") { + // Native visible-position corrections must not be mistaken for navigation. + const aboveRow = Math.min( + ...Array.from(readingViewport.querySelectorAll("[data-message-id]")) + .map((row) => + Number(row.getAttribute("data-message-id")?.match(/^message-(\d+)$/)?.[1]), + ) + .filter(Number.isFinite), + ); + expect(aboveRow).toBeLessThan(39); + for (const tailGrowth of [60, 0, 240]) { + rowHeights.set(aboveRow, 60); + lastRowHeight = 180 + tailGrowth; + await flushLayout(); + expect(unsignaledNavigation).not.toHaveBeenCalled(); + expect( + Math.abs( + readingViewport.scrollHeight - + readingViewport.clientHeight - + readingViewport.scrollTop, + ), + ).toBeLessThanOrEqual(1); + rowHeights.clear(); + lastRowHeight = 180; + await flushLayout(); + } + } await act(async () => { // A shrink and external navigation can coalesce into one scroll event. // The browser first clamps the old offset, then the reader moves farther up. diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index a9f7759e3eaf..f14d80a69497 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -190,6 +190,7 @@ export function observeTimelineScrollNavigation( node: HTMLElement, isFollowingEnd: () => boolean, onManualNavigation: () => void, + getManagedOffset: () => number | undefined, ): () => void { let previousOffset = node.scrollTop; const handleScroll = () => { @@ -200,7 +201,13 @@ export function observeTimelineScrollNavigation( const clampedPreviousOffset = Math.min(previousOffset, Math.max(0, height - viewport)); const movedAway = offset < clampedPreviousOffset - 1 && height - viewport - offset > 40; previousOffset = offset; - if (movedAway && isFollowingEnd()) onManualNavigation(); + if (movedAway && isFollowingEnd()) { + // Legend records its own position correction before applying the DOM scroll. + const managedOffset = getManagedOffset(); + if (managedOffset === undefined || Math.abs(offset - managedOffset) > 1) { + onManualNavigation(); + } + } }; // Observe navigation before the virtualizer handles the event and schedules follow. node.addEventListener("scroll", handleScroll, { capture: true, passive: true });