Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 9 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ import {
} from "~/projectScripts";
import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
import { useBrowserHistoryStore } from "~/browserHistoryStore";
import { readTimelineScrollPosition } from "~/timelineScrollState";
import { registerFaviconProjectForThread } from "~/browserFaviconStore";
import { getProviderModelCapabilities } from "../providerModels";
import {
Expand Down Expand Up @@ -5092,20 +5093,23 @@ export default function ChatView(props: ChatViewProps) {
}, [activeThread?.id, timelineEntries, getActiveTimelineTurnMetrics]);

useEffect(() => {
const restoringSavedPosition = readTimelineScrollPosition(routeThreadKey) !== null;
setPullRequestDialogState(null);
isAtEndRef.current = true;
isAtEndRef.current = !restoringSavedPosition;
timelineScrollIntentRef.current = null;
timelineScrollModeRef.current = "following-end";
liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
setTimelineLiveFollowEnabled(true);
timelineScrollModeRef.current = restoringSavedPosition ? "free-scrolling" : "following-end";
liveFollowUserScrollGenerationRef.current = restoringSavedPosition
? null
: anchorUserScrollGenerationRef.current;
setTimelineLiveFollowEnabled(!restoringSavedPosition);
pendingTimelineAnchorRef.current = null;
positionedTimelineAnchorRef.current = null;
settledTimelineAnchorRef.current = null;
activeTimelineAnchorIndexRef.current = null;
showScrollDebouncer.current.cancel();
setShowScrollToBottom(false);
// activeThreadRef resets transitively with the active thread.
}, [activeThread?.id]);
}, [activeThread?.id, routeThreadKey]);

useEffect(() => {
setIsRevertingCheckpoint(false);
Expand Down
60 changes: 59 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ import {
} from "lucide-react";
import { Button } from "../ui/button";
import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls";
import {
clearTimelineScrollPosition,
readTimelineScrollPosition,
rememberTimelineScrollPosition,
} from "../../timelineScrollState";
import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
import {
Expand Down Expand Up @@ -397,6 +402,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({
topFadeEnabled = false,
loadEarlier = null,
}: MessagesTimelineProps) {
const [initialScrollOffset] = useState(() => readTimelineScrollPosition(routeThreadKey));
const [restoringSavedPosition, setRestoringSavedPosition] = useState(
initialScrollOffset !== null,
);
const pendingScrollPositionRef = useRef<number | null>(null);
const scrollPositionFrameRef = useRef<number | null>(null);
const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]);
const expandCitedTurn = useCallback((turnId: TurnId) => {
Expand Down Expand Up @@ -650,6 +661,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({
const handleScroll = useCallback(() => {
const state = listRef.current?.getState?.();
const isAtEnd = resolveTimelineIsAtEnd(state);
if (!citationPositioning) {
if (restoringSavedPosition && state) {
setRestoringSavedPosition(false);
} else if (isAtEnd === true) {
pendingScrollPositionRef.current = null;
if (scrollPositionFrameRef.current !== null) {
cancelAnimationFrame(scrollPositionFrameRef.current);
scrollPositionFrameRef.current = null;
}
clearTimelineScrollPosition(routeThreadKey);
} else if (isAtEnd === false && state?.scroll !== undefined) {
pendingScrollPositionRef.current = state.scroll;
if (scrollPositionFrameRef.current === null) {
scrollPositionFrameRef.current = requestAnimationFrame(() => {
scrollPositionFrameRef.current = null;
const pendingScrollTop = pendingScrollPositionRef.current;
pendingScrollPositionRef.current = null;
if (pendingScrollTop !== null) {
rememberTimelineScrollPosition(routeThreadKey, pendingScrollTop);
}
});
}
}
}
if (isAtEnd !== undefined && !citationPositioning) {
onIsAtEndChange(isAtEnd);
}
Expand Down Expand Up @@ -683,8 +718,29 @@ export const MessagesTimeline = memo(function MessagesTimeline({
minimapStripMap,
onIsAtEndChange,
reportContentOverflow,
restoringSavedPosition,
routeThreadKey,
]);

useEffect(() => {
const mountedList = listRef.current;
return () => {
pendingScrollPositionRef.current = null;
if (scrollPositionFrameRef.current !== null) {
cancelAnimationFrame(scrollPositionFrameRef.current);
scrollPositionFrameRef.current = null;
}
if (citationPositioning) return;
const state = mountedList?.getState?.();
const isAtEnd = resolveTimelineIsAtEnd(state);
if (isAtEnd === true) {
clearTimelineScrollPosition(routeThreadKey);
} else if (isAtEnd === false && state?.scroll !== undefined) {
rememberTimelineScrollPosition(routeThreadKey, state.scroll);
}
};
}, [citationPositioning, listRef, routeThreadKey]);

useEffect(() => {
const frame = requestAnimationFrame(handleScroll);
return () => cancelAnimationFrame(frame);
Expand Down Expand Up @@ -823,7 +879,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
getItemType={getItemType}
renderItem={renderItem}
estimatedItemSize={90}
initialScrollAtEnd={citationRequest === null}
initialScrollAtEnd={citationRequest === null && initialScrollOffset === null}
{...(initialScrollOffset !== null ? { initialScrollOffset } : {})}
// Legend needs a data refresh to mount new pins without a scroll event.
{...(readyCitationRequest ? { dataVersion: readyCitationRequest.key } : {})}
{...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})}
Expand All @@ -833,6 +890,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
maintainScrollAtEnd={
citationPositioning ||
anchoredEndSpace ||
restoringSavedPosition ||
!liveFollowEnabled ||
disclosureToggleSettling
? false
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/lib/lruCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ describe("LRUCache", () => {
expect(cache.get("missing")).toBeNull();
});

it("deletes an entry and releases its memory budget", () => {
const cache = new LRUCache<string>(2, 10);
cache.set("a", "A", 10);
cache.delete("a");
cache.set("b", "B", 10);

expect(cache.get("a")).toBeNull();
expect(cache.get("b")).toBe("B");
});

it("evicts oldest by max entries", () => {
const cache = new LRUCache<string>(2, 1_000);
cache.set("a", "A", 10);
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/lib/lruCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export class LRUCache<T> {
this.totalSize += approximateSize;
}

delete(key: string): void {
const entry = this.cache.get(key);
if (!entry) return;
this.cache.delete(key);
this.totalSize -= entry.approximateSize;
}

clear(): void {
this.cache.clear();
this.totalSize = 0;
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/timelineScrollState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { beforeEach, describe, expect, it } from "vite-plus/test";

import {
clearTimelineScrollPosition,
clearTimelineScrollStateForTests,
readTimelineScrollPosition,
rememberTimelineScrollPosition,
} from "./timelineScrollState";

describe("timeline scroll state", () => {
beforeEach(clearTimelineScrollStateForTests);

it("keeps independent positions for each thread", () => {
rememberTimelineScrollPosition("environment:thread-a", 420);
rememberTimelineScrollPosition("environment:thread-b", 860);

expect(readTimelineScrollPosition("environment:thread-a")).toBe(420);
expect(readTimelineScrollPosition("environment:thread-b")).toBe(860);

clearTimelineScrollPosition("environment:thread-a");
expect(readTimelineScrollPosition("environment:thread-a")).toBeNull();
expect(readTimelineScrollPosition("environment:thread-b")).toBe(860);
});
});
26 changes: 26 additions & 0 deletions apps/web/src/timelineScrollState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { LRUCache } from "./lib/lruCache";

const TIMELINE_SCROLL_CACHE_SIZE = 200;
const TIMELINE_SCROLL_POSITION_BYTES = 8;

const timelineScrollPositions = new LRUCache<number>(
TIMELINE_SCROLL_CACHE_SIZE,
TIMELINE_SCROLL_CACHE_SIZE * TIMELINE_SCROLL_POSITION_BYTES,
);

export function readTimelineScrollPosition(threadKey: string): number | null {
return timelineScrollPositions.get(threadKey);
}

export function rememberTimelineScrollPosition(threadKey: string, scrollTop: number): void {
if (!Number.isFinite(scrollTop) || scrollTop < 0) return;
timelineScrollPositions.set(threadKey, scrollTop, TIMELINE_SCROLL_POSITION_BYTES);
}

export function clearTimelineScrollPosition(threadKey: string): void {
timelineScrollPositions.delete(threadKey);
}

export function clearTimelineScrollStateForTests(): void {
timelineScrollPositions.clear();
}
5 changes: 5 additions & 0 deletions docs/user/thread-sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ prevent automatic settlement. An open pull request does not prevent inactivity
settlement, but an old closed or merged pull request does not settle work you
resumed after it closed.

## Returning to a thread

When you switch between threads on web or desktop, each conversation keeps your reading position.
Positions belong to the current client session and are not shared across devices.

Change these rules in **Settings → General**. They continue to run when your apps
are closed. Changes apply to connected environments that support shared settings;
offline environments and older servers keep their previous values. If connected
Expand Down
Loading