fix(chat): rework prompt navigator rail into sliding tape with hover preview (#2185)

* fix(chat): rework prompt navigator rail into sliding tape with hover preview

- Pin the active indicator to the target during programmatic scrolls so the
  scroll spy's intermediate reports don't drag it backwards mid-animation
- Replace the visibility-ratio active-turn picker with a stable reading-line
  rule (last turn whose top is above the line), dropping IntersectionObserver
- Replace the list panel with a Codex-style gutter: the whole strip is one
  hover/click target mapped to the nearest tick, with a per-prompt preview
  card that follows the cursor
- Cap the rail at a fixed window of ticks; hovering the edges carousels
  through the rest, with gradient masks hinting at more content
- Render ticks as a tape that glides to keep the active prompt centered,
  remounting on history prepend to avoid spurious slide animations
- Keep load-earlier as a compact button aligned over the tick column

* fix(chat): shrink navigator gutter when message column sits under it

On narrow windows the centered message column extends under the rail's
full-width invisible hover zone, which swallowed clicks on the right edge
of user bubbles — including the expand/collapse control. Measure the
column against the gutter and switch to a narrow hit zone when they
overlap.

* fix(sync): stop runaway history auto-load on sessions with empty assistant messages

An assistant message fetched with zero parts (e.g. a run aborted before any
output) was stored as absence — indistinguishable from parts that were never
fetched. getSessionMaterializationStatus therefore reported the session as
never renderable, so the ensure-renderable effects (ChatContainer,
ModelControls) retried syncSession forever; each retry refetched the whole
grown window and fired another background prepend, progressively loading the
entire history of large sessions on open.

Commit an explicit empty [] snapshot for assistant messages so fetched-empty
counts as renderable, while non-assistant messages keep the absent
representation and its no-op commit behavior.

Reproduced and verified headless against a real 857-message session: before,
20 message fetches escalating to limit=857; after, one initial page and a
single progressive-mount prepend.
This commit is contained in:
Bohdan Triapitsyn
2026-07-13 12:45:23 +03:00
committed by GitHub
parent 697b180532
commit 799904f0f4
4 changed files with 485 additions and 406 deletions
@@ -76,6 +76,9 @@ const MOBILE_TURN_MODEL_CACHE_MAX = 4
const MOBILE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
const HISTORY_RENDER_WAIT_TIMEOUT_MS = 250
const HISTORY_INTERACTION_GUARD_MS = 2000
// Long smooth scrolls across a big session can take a couple of seconds;
// the pin releases early as soon as the spy reports the target turn.
const SCROLL_PIN_TIMEOUT_MS = 2500
const turnModelCache = new Map<string, { messages: ChatMessageEntry[]; model: TurnWindowModel }>()
const getTurnModelCacheMax = () => {
if (isVSCodeRuntime()) return VSCODE_TURN_MODEL_CACHE_MAX
@@ -241,6 +244,7 @@ export const useChatTimelineController = ({
const initializedSessionRef = React.useRef<string | null>(null);
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
const scrollPinRef = React.useRef<{ turnId: string; expiresAt: number } | null>(null);
const historyInteractionRef = React.useRef(false);
const historyInteractionTimerRef = React.useRef<number | null>(null);
@@ -305,6 +309,7 @@ export const useChatTimelineController = ({
initializedSessionRef.current = sessionId;
setIsLoadingOlder(false);
setPendingRevealWork(false);
scrollPinRef.current = null;
setActiveTurnId(null);
}, [sessionId]);
@@ -362,6 +367,13 @@ export const useChatTimelineController = ({
if (didScroll) {
if (pending.turnId) {
// Pin the indicator to the target so the scroll spy's
// intermediate reports during the smooth scroll don't drag
// it backwards before the animation lands.
scrollPinRef.current = {
turnId: pending.turnId,
expiresAt: Date.now() + SCROLL_PIN_TIMEOUT_MS,
};
setActiveTurnId(pending.turnId);
}
resolvePendingScrollRequest(true);
@@ -890,6 +902,13 @@ export const useChatTimelineController = ({
}, [goToBottom]);
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
const pin = scrollPinRef.current;
if (pin) {
if (turnId !== pin.turnId && Date.now() < pin.expiresAt) {
return;
}
scrollPinRef.current = null;
}
setActiveTurnId(turnId);
}, []);