From 5aeeae830ed53074ea757b62abe922aa754696cd Mon Sep 17 00:00:00 2001 From: Arnav Bansal <67191889+bansalarnav@users.noreply.github.com> Date: Fri, 7 Aug 2026 02:39:12 +0530 Subject: [PATCH 1/5] fix(web): restore thread timeline scroll position - Preserve each thread's timeline offset across route changes - Restore the saved position when returning to a thread --- apps/web/src/components/ChatView.tsx | 26 ++++++++++++++++--- .../src/components/chat/MessagesTimeline.tsx | 13 ++++++++-- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7b59530c955..d4ba3a1493b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1152,6 +1152,9 @@ type LocalThreadErrorEntry = { readonly at: number; }; +// Module state survives route changes but resets when the client reloads. +const timelineScrollOffsetByThreadKey = new Map(); + function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } @@ -1174,6 +1177,10 @@ function ChatViewContent(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); + const restoredTimelineScrollOffset = useMemo( + () => timelineScrollOffsetByThreadKey.get(routeThreadKey), + [routeThreadKey], + ); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, @@ -3769,6 +3776,13 @@ function ChatViewContent(props: ChatViewProps) { } }, []); + const onTimelineScrollOffsetChange = useCallback( + (offset: number) => { + timelineScrollOffsetByThreadKey.set(routeThreadKey, offset); + }, + [routeThreadKey], + ); + useEffect(() => { if (!activeThread?.id) { return; @@ -3838,8 +3852,10 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + timelineScrollModeRef.current = + restoredTimelineScrollOffset === undefined ? "following-end" : "free-scrolling"; + liveFollowUserScrollGenerationRef.current = + restoredTimelineScrollOffset === undefined ? anchorUserScrollGenerationRef.current : null; pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -3854,7 +3870,7 @@ function ChatViewContent(props: ChatViewProps) { } } // activeThreadRef resets transitively with the active thread. - }, [activeThread?.id]); + }, [activeThread?.id, restoredTimelineScrollOffset]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). @@ -5979,7 +5995,7 @@ function ChatViewContent(props: ChatViewProps) { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c952eb3d128..0e59d81bfc2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -14,6 +14,7 @@ import { const EMPTY_AGENT_PANEL_MODEL = emptyAgentPanelModel(); const NOOP_OPEN_AGENTS = () => {}; +const NOOP_SCROLL_OFFSET_CHANGE = () => {}; import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { createContext, @@ -192,8 +193,10 @@ interface MessagesTimelineProps { onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; + initialScrollOffset?: number | undefined; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; + onScrollOffsetChange?: (offset: number) => void; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; } @@ -229,8 +232,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, + initialScrollOffset, onIsAtEndChange, onManualNavigation, + onScrollOffsetChange = NOOP_SCROLL_OFFSET_CHANGE, hideEmptyPlaceholder = false, topFadeEnabled = false, }: MessagesTimelineProps) { @@ -371,6 +376,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); + if (typeof state?.scroll === "number" && Number.isFinite(state.scroll)) { + onScrollOffsetChange(state.scroll); + } const isAtEnd = resolveTimelineIsAtEnd(state); if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); @@ -397,7 +405,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange, onScrollOffsetChange]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -509,7 +517,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} - initialScrollAtEnd + initialScrollAtEnd={initialScrollOffset === undefined} + {...(initialScrollOffset === undefined ? {} : { initialScrollOffset })} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ From c31d010f8409256dd15ae849683ebf2b452acdd8 Mon Sep 17 00:00:00 2001 From: Arnav Bansal <67191889+bansalarnav@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:03:32 +0530 Subject: [PATCH 2/5] fix(web): restore thread scroll position during active responses --- apps/web/src/components/ChatView.tsx | 98 +++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d4ba3a1493b..046e5061473 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1153,7 +1153,13 @@ type LocalThreadErrorEntry = { }; // Module state survives route changes but resets when the client reloads. -const timelineScrollOffsetByThreadKey = new Map(); +const timelineScrollPositionByThreadKey = new Map< + string, + { + readonly offset: number; + readonly manuallyPositionedTurnId: TurnId | null; + } +>(); function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; @@ -1177,8 +1183,8 @@ function ChatViewContent(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); - const restoredTimelineScrollOffset = useMemo( - () => timelineScrollOffsetByThreadKey.get(routeThreadKey), + const restoredTimelineScrollPosition = useMemo( + () => timelineScrollPositionByThreadKey.get(routeThreadKey), [routeThreadKey], ); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); @@ -1518,6 +1524,34 @@ function ChatViewContent(props: ChatViewProps) { [activeThread], ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; + const activeRunningTurnId = + activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null; + const responseTurnOnEntryRef = useRef<{ + readonly captured: boolean; + readonly threadKey: string | null; + readonly turnId: TurnId | null; + }>({ + captured: !threadDetailLoading, + threadKey: activeThreadKey, + turnId: threadDetailLoading ? null : activeRunningTurnId, + }); + if ( + responseTurnOnEntryRef.current.threadKey !== activeThreadKey || + (!responseTurnOnEntryRef.current.captured && !threadDetailLoading) + ) { + responseTurnOnEntryRef.current = { + captured: !threadDetailLoading, + threadKey: activeThreadKey, + turnId: threadDetailLoading ? null : activeRunningTurnId, + }; + } + const responseTurnOnEntry = responseTurnOnEntryRef.current.turnId; + const restoreTimelinePositionOnEntry = + restoredTimelineScrollPosition !== undefined && + (responseTurnOnEntry === null || + restoredTimelineScrollPosition.manuallyPositionedTurnId === responseTurnOnEntry); + const focusWorkingResponseOnEntry = + responseTurnOnEntry !== null && !restoreTimelinePositionOnEntry; const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -2406,6 +2440,18 @@ function ChatViewContent(props: ChatViewProps) { deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); + const workingResponseAnchorMessageId = useMemo(() => { + if (!focusWorkingResponseOnEntry) { + return null; + } + for (let index = timelineEntries.length - 1; index >= 0; index -= 1) { + const entry = timelineEntries[index]; + if (entry?.kind === "message" && entry.message.role === "user") { + return entry.message.id; + } + } + return null; + }, [focusWorkingResponseOnEntry, timelineEntries]); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -3536,6 +3582,7 @@ function ChatViewContent(props: ChatViewProps) { new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const timelineScrollModeRef = useRef("following-end"); + const preserveInitialResponsePositionRef = useRef(false); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); const settledTimelineAnchorRef = useRef(null); @@ -3551,6 +3598,14 @@ function ChatViewContent(props: ChatViewProps) { const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; + preserveInitialResponsePositionRef.current = false; + const currentOffset = legendListRef.current?.getState().scroll; + if (typeof currentOffset === "number" && Number.isFinite(currentOffset)) { + timelineScrollPositionByThreadKey.set(routeThreadKey, { + offset: currentOffset, + manuallyPositionedTurnId: activeRunningTurnId, + }); + } liveFollowUserScrollGenerationRef.current = null; pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; @@ -3561,7 +3616,7 @@ function ChatViewContent(props: ChatViewProps) { cancelAnimationFrame(anchorScrollRestoreFrameRef.current); anchorScrollRestoreFrameRef.current = null; } - }, []); + }, [activeRunningTurnId, routeThreadKey]); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, ); @@ -3622,6 +3677,7 @@ function ChatViewContent(props: ChatViewProps) { const scrollToEnd = useCallback((animated = false) => { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; + preserveInitialResponsePositionRef.current = false; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; @@ -3765,6 +3821,13 @@ function ChatViewContent(props: ChatViewProps) { if (isAtEndRef.current === isAtEnd) return; isAtEndRef.current = isAtEnd; if (isAtEnd) { + if (preserveInitialResponsePositionRef.current) { + timelineScrollModeRef.current = "free-scrolling"; + liveFollowUserScrollGenerationRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + return; + } timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; showScrollDebouncer.current.cancel(); @@ -3778,7 +3841,11 @@ function ChatViewContent(props: ChatViewProps) { const onTimelineScrollOffsetChange = useCallback( (offset: number) => { - timelineScrollOffsetByThreadKey.set(routeThreadKey, offset); + const existing = timelineScrollPositionByThreadKey.get(routeThreadKey); + timelineScrollPositionByThreadKey.set(routeThreadKey, { + offset, + manuallyPositionedTurnId: existing?.manuallyPositionedTurnId ?? null, + }); }, [routeThreadKey], ); @@ -3852,10 +3919,15 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; + preserveInitialResponsePositionRef.current = focusWorkingResponseOnEntry; timelineScrollModeRef.current = - restoredTimelineScrollOffset === undefined ? "following-end" : "free-scrolling"; + !restoreTimelinePositionOnEntry && !focusWorkingResponseOnEntry + ? "following-end" + : "free-scrolling"; liveFollowUserScrollGenerationRef.current = - restoredTimelineScrollOffset === undefined ? anchorUserScrollGenerationRef.current : null; + !restoreTimelinePositionOnEntry && !focusWorkingResponseOnEntry + ? anchorUserScrollGenerationRef.current + : null; pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -3870,7 +3942,7 @@ function ChatViewContent(props: ChatViewProps) { } } // activeThreadRef resets transitively with the active thread. - }, [activeThread?.id, restoredTimelineScrollOffset]); + }, [activeThread?.id, focusWorkingResponseOnEntry, restoreTimelinePositionOnEntry]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). @@ -4913,6 +4985,7 @@ function ChatViewContent(props: ChatViewProps) { // streams into the reserved space below it. isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; + preserveInitialResponsePositionRef.current = false; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; @@ -5357,6 +5430,7 @@ function ChatViewContent(props: ChatViewProps) { // Position this sent row once LegendList has measured the anchored tail. isAtEndRef.current = true; timelineScrollModeRef.current = "anchoring-new-turn"; + preserveInitialResponsePositionRef.current = false; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = messageIdForSend; activeTimelineAnchorIndexRef.current = null; @@ -6020,11 +6094,15 @@ function ChatViewContent(props: ChatViewProps) { timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} - anchorMessageId={timelineAnchorMessageId} + anchorMessageId={timelineAnchorMessageId ?? workingResponseAnchorMessageId} onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} - initialScrollOffset={restoredTimelineScrollOffset} + initialScrollOffset={ + restoreTimelinePositionOnEntry + ? restoredTimelineScrollPosition?.offset + : undefined + } onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} onScrollOffsetChange={onTimelineScrollOffsetChange} From 6705a75e0e4ad32b70a8f56002b65d91b393dc83 Mon Sep 17 00:00:00 2001 From: Arnav Bansal <67191889+bansalarnav@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:17:52 +0530 Subject: [PATCH 3/5] fix(web): respect manual scroll during active turns --- apps/web/src/components/ChatView.tsx | 211 +++++++++++++++++++-------- 1 file changed, 152 insertions(+), 59 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 046e5061473..e024f368ab5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1153,13 +1153,47 @@ type LocalThreadErrorEntry = { }; // Module state survives route changes but resets when the client reloads. -const timelineScrollPositionByThreadKey = new Map< - string, - { - readonly offset: number; - readonly manuallyPositionedTurnId: TurnId | null; +type TimelineScrollPosition = + | { readonly kind: "automatic"; readonly offset: number } + | { + readonly kind: "manual"; + readonly offset: number; + readonly turnId: TurnId | null; + }; + +type TimelineEntryScrollMode = + | { readonly kind: "follow-end" } + | { readonly kind: "restore-position"; readonly offset: number } + | { readonly kind: "anchor-working-turn"; readonly turnId: TurnId }; + +const timelineScrollPositionByThreadKey = new Map(); +const TIMELINE_SCROLL_NAVIGATION_KEYS = new Set([ + "ArrowDown", + "ArrowUp", + "End", + "Home", + "PageDown", + "PageUp", + " ", +]); + +function resolveTimelineEntryScrollMode(input: { + readonly runningTurnId: TurnId | null; + readonly savedPosition: TimelineScrollPosition | undefined; +}): TimelineEntryScrollMode { + if (input.runningTurnId !== null) { + if ( + input.savedPosition?.kind === "manual" && + input.savedPosition.turnId === input.runningTurnId + ) { + return { kind: "restore-position", offset: input.savedPosition.offset }; + } + return { kind: "anchor-working-turn", turnId: input.runningTurnId }; } ->(); + return input.savedPosition + ? { kind: "restore-position", offset: input.savedPosition.offset } + : { kind: "follow-end" }; +} function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; @@ -1526,7 +1560,7 @@ function ChatViewContent(props: ChatViewProps) { const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeRunningTurnId = activeThread?.session?.status === "running" ? activeThread.session.activeTurnId : null; - const responseTurnOnEntryRef = useRef<{ + const runningTurnOnEntryRef = useRef<{ readonly captured: boolean; readonly threadKey: string | null; readonly turnId: TurnId | null; @@ -1536,22 +1570,34 @@ function ChatViewContent(props: ChatViewProps) { turnId: threadDetailLoading ? null : activeRunningTurnId, }); if ( - responseTurnOnEntryRef.current.threadKey !== activeThreadKey || - (!responseTurnOnEntryRef.current.captured && !threadDetailLoading) + runningTurnOnEntryRef.current.threadKey !== activeThreadKey || + (!runningTurnOnEntryRef.current.captured && !threadDetailLoading) ) { - responseTurnOnEntryRef.current = { + runningTurnOnEntryRef.current = { captured: !threadDetailLoading, threadKey: activeThreadKey, turnId: threadDetailLoading ? null : activeRunningTurnId, }; } - const responseTurnOnEntry = responseTurnOnEntryRef.current.turnId; - const restoreTimelinePositionOnEntry = - restoredTimelineScrollPosition !== undefined && - (responseTurnOnEntry === null || - restoredTimelineScrollPosition.manuallyPositionedTurnId === responseTurnOnEntry); - const focusWorkingResponseOnEntry = - responseTurnOnEntry !== null && !restoreTimelinePositionOnEntry; + const runningTurnOnEntry = runningTurnOnEntryRef.current.turnId; + const timelineEntryScrollMode = useMemo( + () => + resolveTimelineEntryScrollMode({ + runningTurnId: runningTurnOnEntry, + savedPosition: restoredTimelineScrollPosition, + }), + [restoredTimelineScrollPosition, runningTurnOnEntry], + ); + const [manuallyNavigatedTimelineEntry, setManuallyNavigatedTimelineEntry] = useState<{ + readonly threadKey: string; + readonly turnId: TurnId | null; + } | null>(null); + const workingTurnAnchorActive = + timelineEntryScrollMode.kind === "anchor-working-turn" && + !( + manuallyNavigatedTimelineEntry?.threadKey === routeThreadKey && + manuallyNavigatedTimelineEntry.turnId === timelineEntryScrollMode.turnId + ); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -2441,7 +2487,7 @@ function ChatViewContent(props: ChatViewProps) { [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); const workingResponseAnchorMessageId = useMemo(() => { - if (!focusWorkingResponseOnEntry) { + if (!workingTurnAnchorActive) { return null; } for (let index = timelineEntries.length - 1; index >= 0; index -= 1) { @@ -2451,7 +2497,7 @@ function ChatViewContent(props: ChatViewProps) { } } return null; - }, [focusWorkingResponseOnEntry, timelineEntries]); + }, [timelineEntries, workingTurnAnchorActive]); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -3594,18 +3640,16 @@ function ChatViewContent(props: ChatViewProps) { readonly offset: number; readonly userScrollGeneration: number; } | null>(null); + const pendingTimelineManualScrollRef = useRef<{ + readonly initialOffset: number; + readonly threadKey: string; + readonly turnId: TurnId | null; + } | null>(null); const anchorScrollRestoreFrameRef = useRef(null); const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; preserveInitialResponsePositionRef.current = false; - const currentOffset = legendListRef.current?.getState().scroll; - if (typeof currentOffset === "number" && Number.isFinite(currentOffset)) { - timelineScrollPositionByThreadKey.set(routeThreadKey, { - offset: currentOffset, - manuallyPositionedTurnId: activeRunningTurnId, - }); - } liveFollowUserScrollGenerationRef.current = null; pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; @@ -3616,14 +3660,18 @@ function ChatViewContent(props: ChatViewProps) { cancelAnimationFrame(anchorScrollRestoreFrameRef.current); anchorScrollRestoreFrameRef.current = null; } - }, [activeRunningTurnId, routeThreadKey]); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( - cancelTimelineLiveFollowForUserNavigation, - ); - useEffect(() => { - cancelTimelineLiveFollowForUserNavigationRef.current = - cancelTimelineLiveFollowForUserNavigation; - }, [cancelTimelineLiveFollowForUserNavigation]); + }, []); + const beginTimelineManualNavigation = useCallback(() => { + const currentOffset = legendListRef.current?.getState().scroll; + if (typeof currentOffset === "number" && Number.isFinite(currentOffset)) { + pendingTimelineManualScrollRef.current = { + initialOffset: currentOffset, + threadKey: routeThreadKey, + turnId: activeRunningTurnId, + }; + } + cancelTimelineLiveFollowForUserNavigation(); + }, [activeRunningTurnId, cancelTimelineLiveFollowForUserNavigation, routeThreadKey]); const getActiveTimelineTurnMetrics = useCallback( (list?: LegendListRef | null) => { const resolvedList = list ?? legendListRef.current; @@ -3692,22 +3740,40 @@ function ChatViewContent(props: ChatViewProps) { if (!scrollNode) { return; } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); + const handlePointerDown = (event: PointerEvent) => { + if (event.target === scrollNode) { + beginTimelineManualNavigation(); + } else { + cancelTimelineLiveFollowForUserNavigation(); + } }; - scrollNode.addEventListener("wheel", handleManualNavigation, { + const clearPendingManualScroll = () => { + pendingTimelineManualScrollRef.current = null; + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (TIMELINE_SCROLL_NAVIGATION_KEYS.has(event.key)) { + beginTimelineManualNavigation(); + } + }; + scrollNode.addEventListener("wheel", beginTimelineManualNavigation, { passive: true, }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { + scrollNode.addEventListener("touchmove", beginTimelineManualNavigation, { passive: true, }); - scrollNode.addEventListener("pointerdown", handleManualNavigation, { + scrollNode.addEventListener("pointerdown", handlePointerDown, { passive: true, }); + scrollNode.addEventListener("pointerup", clearPendingManualScroll, { passive: true }); + scrollNode.addEventListener("pointercancel", clearPendingManualScroll, { passive: true }); + scrollNode.addEventListener("keydown", handleKeyDown); removeListeners = () => { - scrollNode.removeEventListener("wheel", handleManualNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handleManualNavigation); + scrollNode.removeEventListener("wheel", beginTimelineManualNavigation); + scrollNode.removeEventListener("touchmove", beginTimelineManualNavigation); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + scrollNode.removeEventListener("pointerup", clearPendingManualScroll); + scrollNode.removeEventListener("pointercancel", clearPendingManualScroll); + scrollNode.removeEventListener("keydown", handleKeyDown); }; }); @@ -3715,7 +3781,7 @@ function ChatViewContent(props: ChatViewProps) { cancelAnimationFrame(frame); removeListeners?.(); }; - }, [activeThread?.id]); + }, [activeThread?.id, beginTimelineManualNavigation, cancelTimelineLiveFollowForUserNavigation]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { if (pendingTimelineAnchorRef.current === messageId) { @@ -3842,10 +3908,36 @@ function ChatViewContent(props: ChatViewProps) { const onTimelineScrollOffsetChange = useCallback( (offset: number) => { const existing = timelineScrollPositionByThreadKey.get(routeThreadKey); - timelineScrollPositionByThreadKey.set(routeThreadKey, { - offset, - manuallyPositionedTurnId: existing?.manuallyPositionedTurnId ?? null, - }); + const pendingManualScroll = pendingTimelineManualScrollRef.current; + const manualScrollLanded = + pendingManualScroll?.threadKey === routeThreadKey && + Math.abs(offset - pendingManualScroll.initialOffset) > 0.5; + + if (manualScrollLanded) { + const manualPosition: TimelineScrollPosition = { + kind: "manual", + offset, + turnId: pendingManualScroll.turnId, + }; + timelineScrollPositionByThreadKey.set(routeThreadKey, manualPosition); + pendingTimelineManualScrollRef.current = null; + setTimelineAnchor((current) => + current.threadKey === routeThreadKey && current.messageId !== null + ? { threadKey: routeThreadKey, messageId: null } + : current, + ); + setManuallyNavigatedTimelineEntry((current) => + current?.threadKey === routeThreadKey && current.turnId === manualPosition.turnId + ? current + : { threadKey: routeThreadKey, turnId: manualPosition.turnId }, + ); + return; + } + + timelineScrollPositionByThreadKey.set( + routeThreadKey, + existing?.kind === "manual" ? { ...existing, offset } : { kind: "automatic", offset }, + ); }, [routeThreadKey], ); @@ -3919,16 +4011,14 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; - preserveInitialResponsePositionRef.current = focusWorkingResponseOnEntry; + preserveInitialResponsePositionRef.current = + timelineEntryScrollMode.kind === "anchor-working-turn"; timelineScrollModeRef.current = - !restoreTimelinePositionOnEntry && !focusWorkingResponseOnEntry - ? "following-end" - : "free-scrolling"; + timelineEntryScrollMode.kind === "follow-end" ? "following-end" : "free-scrolling"; liveFollowUserScrollGenerationRef.current = - !restoreTimelinePositionOnEntry && !focusWorkingResponseOnEntry - ? anchorUserScrollGenerationRef.current - : null; + timelineEntryScrollMode.kind === "follow-end" ? anchorUserScrollGenerationRef.current : null; pendingTimelineAnchorRef.current = null; + pendingTimelineManualScrollRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; @@ -3942,7 +4032,7 @@ function ChatViewContent(props: ChatViewProps) { } } // activeThreadRef resets transitively with the active thread. - }, [activeThread?.id, focusWorkingResponseOnEntry, restoreTimelinePositionOnEntry]); + }, [routeThreadKey, timelineEntryScrollMode.kind]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). @@ -6099,12 +6189,12 @@ function ChatViewContent(props: ChatViewProps) { onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} initialScrollOffset={ - restoreTimelinePositionOnEntry - ? restoredTimelineScrollPosition?.offset + timelineEntryScrollMode.kind === "restore-position" + ? timelineEntryScrollMode.offset : undefined } onIsAtEndChange={onIsAtEndChange} - onManualNavigation={cancelTimelineLiveFollowForUserNavigation} + onManualNavigation={beginTimelineManualNavigation} onScrollOffsetChange={onTimelineScrollOffsetChange} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} @@ -6120,7 +6210,10 @@ function ChatViewContent(props: ChatViewProps) { type="button" aria-label="Scroll to end" title="Scroll to end" - onClick={() => scrollToEnd(true)} + onClick={() => { + beginTimelineManualNavigation(); + scrollToEnd(true); + }} className="chat-composer-glass pointer-events-auto flex items-center gap-1.5 rounded-full border border-border/60 px-3 py-1 text-muted-foreground text-xs shadow-sm transition-colors hover:border-border hover:text-foreground hover:cursor-pointer" > From a7cd406286ba6353cef8fce9e787c1c2cdb28565 Mon Sep 17 00:00:00 2001 From: Arnav Bansal <67191889+bansalarnav@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:33:51 +0530 Subject: [PATCH 4/5] fix(web): preserve response position on thread entry --- apps/web/src/components/ChatView.tsx | 47 +++++++++++++++++----------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e024f368ab5..b98af366b16 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1164,7 +1164,7 @@ type TimelineScrollPosition = type TimelineEntryScrollMode = | { readonly kind: "follow-end" } | { readonly kind: "restore-position"; readonly offset: number } - | { readonly kind: "anchor-working-turn"; readonly turnId: TurnId }; + | { readonly kind: "anchor-response"; readonly turnId: TurnId }; const timelineScrollPositionByThreadKey = new Map(); const TIMELINE_SCROLL_NAVIGATION_KEYS = new Set([ @@ -1178,6 +1178,7 @@ const TIMELINE_SCROLL_NAVIGATION_KEYS = new Set([ ]); function resolveTimelineEntryScrollMode(input: { + readonly latestTurnId: TurnId | null; readonly runningTurnId: TurnId | null; readonly savedPosition: TimelineScrollPosition | undefined; }): TimelineEntryScrollMode { @@ -1188,10 +1189,13 @@ function resolveTimelineEntryScrollMode(input: { ) { return { kind: "restore-position", offset: input.savedPosition.offset }; } - return { kind: "anchor-working-turn", turnId: input.runningTurnId }; + return { kind: "anchor-response", turnId: input.runningTurnId }; } - return input.savedPosition - ? { kind: "restore-position", offset: input.savedPosition.offset } + if (input.savedPosition) { + return { kind: "restore-position", offset: input.savedPosition.offset }; + } + return input.latestTurnId + ? { kind: "anchor-response", turnId: input.latestTurnId } : { kind: "follow-end" }; } @@ -1583,17 +1587,18 @@ function ChatViewContent(props: ChatViewProps) { const timelineEntryScrollMode = useMemo( () => resolveTimelineEntryScrollMode({ + latestTurnId: activeThread?.latestTurn?.turnId ?? null, runningTurnId: runningTurnOnEntry, savedPosition: restoredTimelineScrollPosition, }), - [restoredTimelineScrollPosition, runningTurnOnEntry], + [activeThread?.latestTurn?.turnId, restoredTimelineScrollPosition, runningTurnOnEntry], ); const [manuallyNavigatedTimelineEntry, setManuallyNavigatedTimelineEntry] = useState<{ readonly threadKey: string; readonly turnId: TurnId | null; } | null>(null); - const workingTurnAnchorActive = - timelineEntryScrollMode.kind === "anchor-working-turn" && + const entryResponseAnchorActive = + timelineEntryScrollMode.kind === "anchor-response" && !( manuallyNavigatedTimelineEntry?.threadKey === routeThreadKey && manuallyNavigatedTimelineEntry.turnId === timelineEntryScrollMode.turnId @@ -2486,8 +2491,8 @@ function ChatViewContent(props: ChatViewProps) { deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), [activeThread?.proposedPlans, timelineMessages, workLogEntries], ); - const workingResponseAnchorMessageId = useMemo(() => { - if (!workingTurnAnchorActive) { + const entryResponseAnchorMessageId = useMemo(() => { + if (!entryResponseAnchorActive) { return null; } for (let index = timelineEntries.length - 1; index >= 0; index -= 1) { @@ -2497,7 +2502,7 @@ function ChatViewContent(props: ChatViewProps) { } } return null; - }, [timelineEntries, workingTurnAnchorActive]); + }, [entryResponseAnchorActive, timelineEntries]); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -3667,11 +3672,20 @@ function ChatViewContent(props: ChatViewProps) { pendingTimelineManualScrollRef.current = { initialOffset: currentOffset, threadKey: routeThreadKey, - turnId: activeRunningTurnId, + turnId: + activeRunningTurnId ?? + (timelineEntryScrollMode.kind === "anchor-response" + ? timelineEntryScrollMode.turnId + : null), }; } cancelTimelineLiveFollowForUserNavigation(); - }, [activeRunningTurnId, cancelTimelineLiveFollowForUserNavigation, routeThreadKey]); + }, [ + activeRunningTurnId, + cancelTimelineLiveFollowForUserNavigation, + routeThreadKey, + timelineEntryScrollMode, + ]); const getActiveTimelineTurnMetrics = useCallback( (list?: LegendListRef | null) => { const resolvedList = list ?? legendListRef.current; @@ -3743,8 +3757,6 @@ function ChatViewContent(props: ChatViewProps) { const handlePointerDown = (event: PointerEvent) => { if (event.target === scrollNode) { beginTimelineManualNavigation(); - } else { - cancelTimelineLiveFollowForUserNavigation(); } }; const clearPendingManualScroll = () => { @@ -3781,7 +3793,7 @@ function ChatViewContent(props: ChatViewProps) { cancelAnimationFrame(frame); removeListeners?.(); }; - }, [activeThread?.id, beginTimelineManualNavigation, cancelTimelineLiveFollowForUserNavigation]); + }, [activeThread?.id, beginTimelineManualNavigation]); const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { if (pendingTimelineAnchorRef.current === messageId) { @@ -4011,8 +4023,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; - preserveInitialResponsePositionRef.current = - timelineEntryScrollMode.kind === "anchor-working-turn"; + preserveInitialResponsePositionRef.current = timelineEntryScrollMode.kind === "anchor-response"; timelineScrollModeRef.current = timelineEntryScrollMode.kind === "follow-end" ? "following-end" : "free-scrolling"; liveFollowUserScrollGenerationRef.current = @@ -6184,7 +6195,7 @@ function ChatViewContent(props: ChatViewProps) { timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} - anchorMessageId={timelineAnchorMessageId ?? workingResponseAnchorMessageId} + anchorMessageId={timelineAnchorMessageId ?? entryResponseAnchorMessageId} onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} From b515a4ae6b700d1fa7a472111ac5179a001a19cc Mon Sep 17 00:00:00 2001 From: Arnav Bansal <67191889+bansalarnav@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:57:10 +0530 Subject: [PATCH 5/5] fix(web): prioritize response anchor over automatic offset --- apps/web/src/components/ChatView.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b98af366b16..1e9e970277e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1191,11 +1191,14 @@ function resolveTimelineEntryScrollMode(input: { } return { kind: "anchor-response", turnId: input.runningTurnId }; } - if (input.savedPosition) { + if (input.savedPosition?.kind === "manual") { return { kind: "restore-position", offset: input.savedPosition.offset }; } - return input.latestTurnId - ? { kind: "anchor-response", turnId: input.latestTurnId } + if (input.latestTurnId) { + return { kind: "anchor-response", turnId: input.latestTurnId }; + } + return input.savedPosition + ? { kind: "restore-position", offset: input.savedPosition.offset } : { kind: "follow-end" }; }