Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5f1e437
Pause live output follow while reading
lnieuwenhuis Jul 13, 2026
aaa53b0
Fix timeline scroll intent tracking
lnieuwenhuis Jul 13, 2026
16e8cbb
Use explicit input for timeline scroll intent
lnieuwenhuis Jul 13, 2026
422a359
Simplify timeline manual navigation
lnieuwenhuis Jul 13, 2026
27a84b3
Handle timeline resume and overlay scrollbars
lnieuwenhuis Jul 13, 2026
9e4da8c
Keep timeline follow on no-op navigation
lnieuwenhuis Jul 13, 2026
cbe6e4a
Ignore timeline shortcuts from controls
lnieuwenhuis Jul 13, 2026
1ac3f53
Preserve timeline keyboard scrolling
lnieuwenhuis Jul 13, 2026
3b93406
Confirm keyboard timeline navigation
lnieuwenhuis Jul 13, 2026
0e32afa
Suspend timeline follow for keyboard intent
lnieuwenhuis Jul 13, 2026
851b9a7
Merge branch 'main' into codex/pause-stream-auto-scroll
lnieuwenhuis Jul 20, 2026
d4c9f70
Guard timeline follow resume
lnieuwenhuis Jul 20, 2026
8a718f7
Merge remote-tracking branch 'origin/main' into codex/pause-stream-au…
lnieuwenhuis Jul 20, 2026
c396bd2
Confirm timeline movement before pausing follow
lnieuwenhuis Jul 20, 2026
f7cb695
Use exact list end state for follow resume
lnieuwenhuis Jul 20, 2026
0e20c4d
Merge branch 'main' into codex/pause-stream-auto-scroll
lnieuwenhuis Jul 20, 2026
2119c97
Merge branch 'main' into codex/pause-stream-auto-scroll
lnieuwenhuis Jul 20, 2026
40c0a4e
Keep timeline recovery available after navigation
lnieuwenhuis Jul 20, 2026
4c3da13
Harden timeline navigation transitions
lnieuwenhuis Jul 20, 2026
7be9742
Clear temporary minimap follow guard
lnieuwenhuis Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 73 additions & 63 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ import {
isLatestTurnSettled,
} from "../session-logic";
import { type LegendListRef } from "@legendapp/list/react";
import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring";
import {
getAnchoredTurnMetrics,
resolveTimelineAutoFollowEnabledForRoute,
shouldResumeTimelineAutoFollow,
type TimelineScrollMode,
} from "./chat/timelineScrollAnchoring";
import {
buildPendingUserInputAnswers,
derivePendingUserInputProgress,
Expand Down Expand Up @@ -3303,6 +3308,13 @@ function ChatViewContent(props: ChatViewProps) {
const showScrollDebouncer = useRef(
new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }),
);
const [timelineAutoFollowEnabled, setTimelineAutoFollowEnabled] = useState(true);
const timelineAutoFollowRouteKeyRef = useRef(routeThreadKey);
const timelineAutoFollowEnabledForRoute = resolveTimelineAutoFollowEnabledForRoute({
autoFollowRouteKey: timelineAutoFollowRouteKeyRef.current,
routeKey: routeThreadKey,
autoFollowEnabled: timelineAutoFollowEnabled,
});
const timelineScrollModeRef = useRef<TimelineScrollMode>("following-end");
const pendingTimelineAnchorRef = useRef<MessageId | null>(null);
const positionedTimelineAnchorRef = useRef<MessageId | null>(null);
Expand All @@ -3317,9 +3329,19 @@ function ChatViewContent(props: ChatViewProps) {
} | null>(null);
const anchorScrollRestoreFrameRef = useRef<number | null>(null);
const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {
if (
liveFollowUserScrollGenerationRef.current === null &&
timelineScrollModeRef.current === "free-scrolling"
) {
return;
}
anchorUserScrollGenerationRef.current += 1;
isAtEndRef.current = false;
setTimelineAutoFollowEnabled(false);
timelineScrollModeRef.current = "free-scrolling";
liveFollowUserScrollGenerationRef.current = null;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(true);
pendingTimelineAnchorRef.current = null;
positionedTimelineAnchorRef.current = null;
settledTimelineAnchorRef.current = null;
Expand All @@ -3330,13 +3352,6 @@ function ChatViewContent(props: ChatViewProps) {
anchorScrollRestoreFrameRef.current = null;
}
}, []);
const cancelTimelineLiveFollowForUserNavigationRef = useRef(
cancelTimelineLiveFollowForUserNavigation,
);
useEffect(() => {
cancelTimelineLiveFollowForUserNavigationRef.current =
cancelTimelineLiveFollowForUserNavigation;
}, [cancelTimelineLiveFollowForUserNavigation]);
const getActiveTimelineTurnMetrics = useCallback(
(list?: LegendListRef | null) => {
const resolvedList = list ?? legendListRef.current;
Expand Down Expand Up @@ -3389,6 +3404,7 @@ function ChatViewContent(props: ChatViewProps) {
// gesture opts out.
const scrollToEnd = useCallback((animated = false) => {
isAtEndRef.current = true;
setTimelineAutoFollowEnabled(true);
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = null;
Expand All @@ -3397,38 +3413,6 @@ function ChatViewContent(props: ChatViewProps) {
setShowScrollToBottom(false);
void legendListRef.current?.scrollToEnd?.({ animated });
}, []);
useEffect(() => {
let removeListeners: (() => void) | null = null;
const frame = requestAnimationFrame(() => {
const scrollNode = legendListRef.current?.getScrollableNode();
if (!scrollNode) {
return;
}
const handleManualNavigation = () => {
cancelTimelineLiveFollowForUserNavigationRef.current();
};
scrollNode.addEventListener("wheel", handleManualNavigation, {
passive: true,
});
scrollNode.addEventListener("touchmove", handleManualNavigation, {
passive: true,
});
scrollNode.addEventListener("pointerdown", handleManualNavigation, {
passive: true,
});
removeListeners = () => {
scrollNode.removeEventListener("wheel", handleManualNavigation);
scrollNode.removeEventListener("touchmove", handleManualNavigation);
scrollNode.removeEventListener("pointerdown", handleManualNavigation);
};
});

return () => {
cancelAnimationFrame(frame);
removeListeners?.();
};
}, [activeThread?.id]);

const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => {
if (pendingTimelineAnchorRef.current === messageId) {
pendingTimelineAnchorRef.current = null;
Expand Down Expand Up @@ -3521,28 +3505,49 @@ function ChatViewContent(props: ChatViewProps) {
});
}, []);

const onIsAtEndChange = useCallback((isAtEnd: boolean) => {
if (
!isAtEnd &&
liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current
) {
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
return;
}
if (isAtEndRef.current === isAtEnd) return;
isAtEndRef.current = isAtEnd;
if (isAtEnd) {
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
} else {
timelineScrollModeRef.current = "free-scrolling";
liveFollowUserScrollGenerationRef.current = null;
showScrollDebouncer.current.maybeExecute();
}
}, []);
const onIsAtEndChange = useCallback(
({
isAtEnd,
manualNavigationReachedEnd,
}: {
readonly isAtEnd: boolean;
readonly manualNavigationReachedEnd: boolean;
}) => {
if (
!isAtEnd &&
liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current
) {
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
return;
}
if (isAtEnd) {
if (
!shouldResumeTimelineAutoFollow({
scrollMode: timelineScrollModeRef.current,
isAtEnd,
manualNavigationReachedEnd,
})
) {
return;
}
isAtEndRef.current = true;
setTimelineAutoFollowEnabled(true);
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
} else {
if (!isAtEndRef.current) return;
isAtEndRef.current = false;
setTimelineAutoFollowEnabled(false);
timelineScrollModeRef.current = "free-scrolling";
liveFollowUserScrollGenerationRef.current = null;
showScrollDebouncer.current.maybeExecute();
}
},
[],
);

useEffect(() => {
if (!activeThread?.id) {
Expand Down Expand Up @@ -3613,6 +3618,8 @@ function ChatViewContent(props: ChatViewProps) {
useEffect(() => {
setPullRequestDialogState(null);
isAtEndRef.current = true;
timelineAutoFollowRouteKeyRef.current = routeThreadKey;
setTimelineAutoFollowEnabled(true);
Comment thread
cursor[bot] marked this conversation as resolved.
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = null;
Expand All @@ -3629,7 +3636,7 @@ function ChatViewContent(props: ChatViewProps) {
}
planSidebarDismissedForTurnRef.current = null;
// activeThreadRef resets transitively with the active thread.
}, [activeThread?.id]);
}, [activeThread?.id, routeThreadKey]);

// 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).
Expand Down Expand Up @@ -4202,6 +4209,7 @@ function ChatViewContent(props: ChatViewProps) {
// anchored end-space target so it lands near the top while the response
// streams into the reserved space below it.
isAtEndRef.current = true;
setTimelineAutoFollowEnabled(true);
timelineScrollModeRef.current = "anchoring-new-turn";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = messageIdForSend;
Expand Down Expand Up @@ -4642,6 +4650,7 @@ function ChatViewContent(props: ChatViewProps) {

// Position this sent row once LegendList has measured the anchored tail.
isAtEndRef.current = true;
setTimelineAutoFollowEnabled(true);
timelineScrollModeRef.current = "anchoring-new-turn";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
pendingTimelineAnchorRef.current = messageIdForSend;
Expand Down Expand Up @@ -5279,6 +5288,7 @@ function ChatViewContent(props: ChatViewProps) {
onAnchorReady={onTimelineAnchorReady}
onAnchorSizeChanged={onTimelineAnchorSizeChanged}
contentInsetEndAdjustment={composerOverlayHeight}
autoFollowEnabled={timelineAutoFollowEnabledForRoute}
onIsAtEndChange={onIsAtEndChange}
onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
hideEmptyPlaceholder={isDraftHeroState}
Expand Down
90 changes: 89 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,101 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)";
export const TIMELINE_CONTENT_MAX_WIDTH = 768;
export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48;

export type TimelineNavigationInput =
| { readonly type: "wheel"; readonly deltaY: number }
| { readonly type: "touch"; readonly previousY: number | null; readonly currentY: number | null }
| { readonly type: "keyboard"; readonly key: string; readonly shiftKey: boolean };

export function timelineNavigationInputMovesTowardHistory(input: TimelineNavigationInput) {
switch (input.type) {
case "wheel":
return input.deltaY < 0;
case "touch":
return (
input.previousY !== null && input.currentY !== null && input.currentY > input.previousY
);
case "keyboard":
return (
input.key === "ArrowUp" ||
input.key === "PageUp" ||
input.key === "Home" ||
(input.key === " " && input.shiftKey)
);
}
}

export interface TimelineScrollableNodeState {
readonly clientHeight: number;
readonly scrollHeight: number;
readonly scrollTop: number;
}

export interface TimelineScrollOffsetState {
readonly scroll?: number;
}

export function resolveTimelineManualNavigationScrollTop(
scrollNode: Pick<TimelineScrollableNodeState, "scrollTop"> | null | undefined,
state: TimelineScrollOffsetState | null | undefined,
): number | null {
const scrollTop = scrollNode?.scrollTop ?? state?.scroll;
return typeof scrollTop === "number" && Number.isFinite(scrollTop) ? scrollTop : null;
}

export function timelineScrollableNodeCanNavigateTowardHistory(
scrollNode: TimelineScrollableNodeState | null | undefined,
): boolean {
return Boolean(
scrollNode && scrollNode.scrollHeight > scrollNode.clientHeight && scrollNode.scrollTop > 0,
);
}

export function resolveTimelineScrollableNodeIsAtEnd(
scrollNode: TimelineScrollableNodeState | null | undefined,
): boolean | undefined {
if (!scrollNode) {
return undefined;
}
return scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= 1;
}
Comment thread
cursor[bot] marked this conversation as resolved.

export function timelineManualNavigationMovedTowardHistory({
initialScrollTop,
scrollTop,
}: {
readonly initialScrollTop: number;
readonly scrollTop: number;
}) {
return scrollTop < initialScrollTop;
}

export function timelineManualNavigationReachedEnd({
previousScrollTop,
scrollTop,
isAtEnd,
}: {
readonly previousScrollTop: number;
readonly scrollTop: number;
readonly isAtEnd: boolean;
}) {
return isAtEnd && scrollTop > previousScrollTop;
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
cursor[bot] marked this conversation as resolved.

export interface TimelineEndState {
readonly isAtEnd?: boolean;
readonly isNearEnd?: boolean;
readonly isWithinMaintainScrollAtEndThreshold?: boolean;
}

export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined {
return state?.isNearEnd ?? state?.isAtEnd;
return state?.isWithinMaintainScrollAtEndThreshold ?? state?.isNearEnd ?? state?.isAtEnd;
}

export function resolveTimelineManualNavigationIsAtEnd(
state: TimelineEndState | undefined,
scrollNode: TimelineScrollableNodeState | null | undefined,
): boolean {
return state?.isAtEnd ?? resolveTimelineScrollableNodeIsAtEnd(scrollNode) ?? false;
}

export function resolveTimelineMinimapHeightStyle(itemCount: number): string {
Expand Down
Loading
Loading