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
@@ -2,7 +2,6 @@ import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import { Icon } from '@/components/icon/Icon';
import { useDeviceInfo } from '@/lib/device';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { cn } from '@/lib/utils';
@@ -23,9 +22,26 @@ type PromptNavigatorRailProps = {
onLoadEarlier: () => void;
};
const LINE_HIT_HEIGHT_PX = 8;
const HOVER_CLOSE_DELAY_MS = 120;
const COMPACT_BACKDROP_MAX_WIDTH_PX = 1280;
const PREVIEW_MAX_CHARS = 160;
// The whole gutter is one hover/click target: the cursor's vertical position
// maps to the nearest tick, so tick density never demands pointer precision.
// When the centered message column extends under the full-width gutter (narrow
// windows), the hit zone shrinks so it can't swallow clicks on the right edge
// of user bubbles (expand/collapse).
const GUTTER_WIDTH_PX = 28;
const GUTTER_NARROW_WIDTH_PX = 12;
const GUTTER_RIGHT_OFFSET_PX = 6;
// The rail shows at most a window of ticks; hovering the gutter edges
// carousels the window through the rest of the prompts.
const MAX_VISIBLE_TICKS = 30;
const TICK_PITCH_PX = 12;
const EDGE_ZONE_PX = 18;
const CAROUSEL_INTERVAL_MS = 80;
const TICK_OVERSCAN = 4;
// Tick lengths for the proximity wave around the cursor.
const TICK_BASE_WIDTH_PX = 10;
const TICK_ACTIVE_WIDTH_PX = 14;
const TICK_FOCUS_WIDTH_PX = 20;
const buildPromptEntries = (
turnIds: string[],
@@ -35,212 +51,28 @@ const buildPromptEntries = (
const parts = previewsByTurnId.get(turnId) ?? [];
return {
turnId,
preview: getMessagePreview(parts, 120),
preview: getMessagePreview(parts, PREVIEW_MAX_CHARS),
};
});
};
const resolveLineGapClass = (count: number): string => {
if (count > 24) {
return 'gap-px';
// Codex-style wave: the highlighted tick stretches, neighbours taper off.
const PROXIMITY_FALLOFF = [1, 0.6, 0.35, 0.15];
const resolveTickWidth = (
index: number,
highlightedIndex: number | null,
isActive: boolean,
): number => {
const base = isActive ? TICK_ACTIVE_WIDTH_PX : TICK_BASE_WIDTH_PX;
if (highlightedIndex === null) {
return base;
}
if (count > 12) {
return 'gap-0.5';
}
return 'gap-1';
const distance = Math.abs(index - highlightedIndex);
const factor = PROXIMITY_FALLOFF[distance] ?? 0;
return Math.round(base + (TICK_FOCUS_WIDTH_PX - base) * factor);
};
type LineRailProps = {
prompts: PromptEntry[];
activeTurnId: string | null;
lineGapClass: string;
needsBackdrop: boolean;
emptyPreviewLabel: string;
onSelectTurn: (turnId: string) => void;
};
/** Compact marker stack only — never renders load-more. Markers stay out of tab order. */
function LineRail({
prompts,
activeTurnId,
lineGapClass,
needsBackdrop,
emptyPreviewLabel,
onSelectTurn,
}: LineRailProps) {
const activeButtonRef = React.useRef<HTMLButtonElement | null>(null);
React.useLayoutEffect(() => {
activeButtonRef.current?.scrollIntoView({ block: 'nearest' });
}, [activeTurnId, prompts.length]);
return (
<div
className={cn(
'flex flex-col items-center rounded-full px-1 py-1.5',
needsBackdrop
? 'border border-[var(--interactive-border)]/40 bg-[var(--surface-background)]/90 shadow-sm backdrop-blur-sm'
: 'bg-transparent',
)}
>
<div
className={cn(
'flex max-h-[40vh] min-h-0 flex-col items-center overflow-y-auto overflow-x-hidden',
lineGapClass,
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
)}
>
{prompts.map((prompt) => {
const isActive = prompt.turnId === activeTurnId;
const preview = prompt.preview.trim() || emptyPreviewLabel;
return (
<button
key={prompt.turnId}
ref={isActive ? activeButtonRef : undefined}
type="button"
tabIndex={-1}
className={cn(
'flex shrink-0 items-center justify-center rounded-full',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focusRing)]',
)}
style={{
width: '16px',
height: `${LINE_HIT_HEIGHT_PX}px`,
}}
aria-label={preview}
aria-current={isActive ? 'true' : undefined}
onClick={() => {
onSelectTurn(prompt.turnId);
}}
>
<span
aria-hidden="true"
className={cn(
'block h-0.5 rounded-full transition-colors',
isActive
? 'w-3.5 bg-[var(--surface-foreground)]'
: 'w-3 bg-[var(--surface-foreground)]/40',
)}
/>
</button>
);
})}
</div>
</div>
);
}
type PromptMenuPanelProps = {
prompts: PromptEntry[];
activeTurnId: string | null;
canLoadEarlier: boolean;
isLoadingOlder: boolean;
emptyPreviewLabel: string;
currentPromptLabel: string;
loadMoreLabel: string;
onSelectTurn: (turnId: string) => void;
onLoadEarlier: (event: React.MouseEvent<HTMLButtonElement>) => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
focusOnMount: boolean;
};
/** Hover/keyboard menu — the only place load-more is allowed. */
function PromptMenuPanel({
prompts,
activeTurnId,
canLoadEarlier,
isLoadingOlder,
emptyPreviewLabel,
currentPromptLabel,
loadMoreLabel,
onSelectTurn,
onLoadEarlier,
onMouseEnter,
onMouseLeave,
focusOnMount,
}: PromptMenuPanelProps) {
const activeItemRef = React.useRef<HTMLButtonElement | null>(null);
React.useEffect(() => {
if (!focusOnMount) {
return;
}
activeItemRef.current?.focus();
}, [focusOnMount]);
return (
<div
className={cn(
'absolute right-full top-1/2 z-30 mr-3 w-[min(18rem,calc(100vw-5rem))] -translate-y-1/2',
'rounded-xl border border-[var(--interactive-border)]/60 bg-[var(--surface-elevated)] p-1 shadow-md',
)}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<ul className="max-h-[min(24rem,70vh)] overflow-y-auto">
{canLoadEarlier ? (
<li className="border-b border-[var(--interactive-border)]/40 px-1 pb-1">
<button
type="button"
className={cn(
'flex w-full items-center justify-center gap-1.5 rounded-lg px-2.5 py-2',
'typography-meta text-[var(--surface-mutedForeground)] transition-colors',
'hover:bg-[var(--interactive-hover)]/60 hover:text-[var(--surface-foreground)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focusRing)]',
isLoadingOlder ? 'cursor-wait opacity-70' : undefined,
)}
disabled={isLoadingOlder}
onClick={onLoadEarlier}
>
{isLoadingOlder ? (
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin" />
) : (
<Icon name="arrow-up-s" className="size-3.5 shrink-0" />
)}
<span>{loadMoreLabel}</span>
</button>
</li>
) : null}
{prompts.map((prompt) => {
const isActive = prompt.turnId === activeTurnId;
const preview = prompt.preview.trim() || emptyPreviewLabel;
return (
<li key={prompt.turnId}>
<button
ref={isActive ? activeItemRef : undefined}
type="button"
className={cn(
'flex w-full items-start rounded-lg px-2.5 py-2 text-left transition-colors',
'hover:bg-[var(--interactive-hover)]/60',
isActive
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]'
: 'text-[var(--surface-foreground)]',
)}
aria-current={isActive ? 'true' : undefined}
onClick={() => {
onSelectTurn(prompt.turnId);
}}
>
<span className="min-w-0 flex-1">
<span className="typography-meta line-clamp-2">{preview}</span>
{isActive ? (
<span className="mt-0.5 block typography-micro text-[var(--interactive-selection-foreground)]/80">
{currentPromptLabel}
</span>
) : null}
</span>
</button>
</li>
);
})}
</ul>
</div>
);
}
export function PromptNavigatorRail({
turnIds,
previewsByTurnId,
@@ -251,95 +83,318 @@ export function PromptNavigatorRail({
onLoadEarlier,
}: PromptNavigatorRailProps) {
const { t } = useI18n();
const { screenWidth } = useDeviceInfo();
const isPanelOpen = useUIStore((state) => state.isPromptNavigatorPanelOpen);
const isKeyboardNavOpen = useUIStore((state) => state.isPromptNavigatorPanelOpen);
const setPromptNavigatorPanelOpen = useUIStore((state) => state.setPromptNavigatorPanelOpen);
const closeTimeoutRef = React.useRef<number | null>(null);
const rootRef = React.useRef<HTMLElement | null>(null);
const openedByPointerRef = React.useRef(false);
const [focusActiveOnOpen, setFocusActiveOnOpen] = React.useState(false);
const gutterRef = React.useRef<HTMLDivElement | null>(null);
const navRef = React.useRef<HTMLElement | null>(null);
const [highlightedIndex, setHighlightedIndex] = React.useState<number | null>(null);
const [windowStart, setWindowStart] = React.useState(0);
const [isNarrowGutter, setIsNarrowGutter] = React.useState(false);
// Shrink the hit zone whenever the message column reaches under the
// full-width gutter, so bubble clicks (expand/collapse) stay clickable.
React.useEffect(() => {
const container = navRef.current?.parentElement;
if (!container || typeof ResizeObserver === 'undefined') {
return;
}
const measure = () => {
const column = container.querySelector('.chat-message-column');
if (!column) {
setIsNarrowGutter(false);
return;
}
const containerRect = container.getBoundingClientRect();
const columnRect = column.getBoundingClientRect();
const fullGutterLeft = containerRect.right - GUTTER_RIGHT_OFFSET_PX - GUTTER_WIDTH_PX;
setIsNarrowGutter(columnRect.right > fullGutterLeft);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(container);
return () => observer.disconnect();
}, []);
const prompts = React.useMemo(
() => buildPromptEntries(turnIds, previewsByTurnId),
[previewsByTurnId, turnIds],
);
const needsBackdrop = screenWidth < COMPACT_BACKDROP_MAX_WIDTH_PX;
const lineGapClass = resolveLineGapClass(prompts.length);
const visibleCount = Math.min(prompts.length, MAX_VISIBLE_TICKS);
const maxWindowStart = Math.max(0, prompts.length - visibleCount);
const clampedWindowStart = Math.min(windowStart, maxWindowStart);
const windowEnd = clampedWindowStart + visibleCount;
const hasMoreAbove = clampedWindowStart > 0;
const hasMoreBelow = windowEnd < prompts.length;
const emptyPreviewLabel = t('chat.timeline.noTextContent');
const currentPromptLabel = t('chat.promptNavigator.currentPrompt');
const loadMoreLabel = t('chat.promptNavigator.loadMore');
const clearCloseTimeout = React.useCallback(() => {
if (closeTimeoutRef.current !== null) {
window.clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
const activeIndex = React.useMemo(() => {
if (!activeTurnId) {
return -1;
}
return prompts.findIndex((prompt) => prompt.turnId === activeTurnId);
}, [activeTurnId, prompts]);
// Refs mirroring hot values so the carousel interval reads fresh state.
const windowStartRef = React.useRef(clampedWindowStart);
windowStartRef.current = clampedWindowStart;
const promptsLengthRef = React.useRef(prompts.length);
promptsLengthRef.current = prompts.length;
const pointerYRef = React.useRef<number | null>(null);
const carouselTimerRef = React.useRef<number | null>(null);
const carouselDirRef = React.useRef<0 | 1 | -1>(0);
const ensureWindowContains = React.useCallback((index: number) => {
setWindowStart((start) => {
const length = promptsLengthRef.current;
const count = Math.min(length, MAX_VISIBLE_TICKS);
const maxStart = Math.max(0, length - count);
const clamped = Math.min(start, maxStart);
if (index < clamped) {
return index;
}
if (index >= clamped + count) {
return Math.min(maxStart, index - count + 1);
}
return clamped;
});
}, []);
// Load-earlier prepends shift every index; move the window with them so
// the visible ticks (and the active one) don't jump around.
const firstTurnIdRef = React.useRef<string | undefined>(prompts[0]?.turnId);
const prevLengthRef = React.useRef(prompts.length);
React.useLayoutEffect(() => {
const prevFirst = firstTurnIdRef.current;
const prevLength = prevLengthRef.current;
const added = prompts.length - prevLength;
if (added > 0 && prevLength > 0 && prevFirst && prompts[0]?.turnId !== prevFirst) {
setWindowStart((start) => start + added);
setHighlightedIndex((index) => (index === null ? null : index + added));
}
firstTurnIdRef.current = prompts[0]?.turnId;
prevLengthRef.current = prompts.length;
}, [prompts]);
// While the user isn't interacting with the rail, the tape glides so the
// active prompt stays centered — the scale moves, not the marker.
React.useEffect(() => {
if (highlightedIndex !== null) {
return;
}
const target = activeIndex >= 0 ? activeIndex : prompts.length - 1;
setWindowStart(() => {
const length = promptsLengthRef.current;
const count = Math.min(length, MAX_VISIBLE_TICKS);
const maxStart = Math.max(0, length - count);
return Math.max(0, Math.min(maxStart, target - Math.floor(count / 2)));
});
}, [activeIndex, highlightedIndex, prompts.length]);
const relativeIndexFromPointer = React.useCallback((clientY: number): number | null => {
const gutter = gutterRef.current;
if (!gutter) {
return null;
}
const rect = gutter.getBoundingClientRect();
const raw = Math.floor((clientY - rect.top) / TICK_PITCH_PX);
const count = Math.min(promptsLengthRef.current, MAX_VISIBLE_TICKS);
if (count === 0) {
return null;
}
return Math.max(0, Math.min(count - 1, raw));
}, []);
const stopCarousel = React.useCallback(() => {
carouselDirRef.current = 0;
if (carouselTimerRef.current !== null) {
window.clearInterval(carouselTimerRef.current);
carouselTimerRef.current = null;
}
}, []);
const openPanel = React.useCallback(() => {
clearCloseTimeout();
openedByPointerRef.current = true;
setFocusActiveOnOpen(false);
setPromptNavigatorPanelOpen(true);
}, [clearCloseTimeout, setPromptNavigatorPanelOpen]);
const scheduleClosePanel = React.useCallback(() => {
clearCloseTimeout();
closeTimeoutRef.current = window.setTimeout(() => {
// Keep keyboard-opened panel alive while focus is still inside the rail.
if (rootRef.current?.contains(document.activeElement)) {
return;
const carouselStep = React.useCallback(() => {
const dir = carouselDirRef.current;
if (dir === 0) {
stopCarousel();
return;
}
const length = promptsLengthRef.current;
const count = Math.min(length, MAX_VISIBLE_TICKS);
const maxStart = Math.max(0, length - count);
const current = Math.min(windowStartRef.current, maxStart);
const next = Math.max(0, Math.min(maxStart, current + dir));
if (next === current) {
stopCarousel();
return;
}
windowStartRef.current = next;
setWindowStart(next);
const pointerY = pointerYRef.current;
if (pointerY !== null) {
const relative = relativeIndexFromPointer(pointerY);
if (relative !== null) {
setHighlightedIndex(Math.min(length - 1, next + relative));
}
openedByPointerRef.current = false;
setFocusActiveOnOpen(false);
setPromptNavigatorPanelOpen(false);
}, HOVER_CLOSE_DELAY_MS);
}, [clearCloseTimeout, setPromptNavigatorPanelOpen]);
}
}, [relativeIndexFromPointer, stopCarousel]);
const updateCarousel = React.useCallback((clientY: number) => {
const gutter = gutterRef.current;
if (!gutter) {
return;
}
const rect = gutter.getBoundingClientRect();
const y = clientY - rect.top;
let dir: 0 | 1 | -1 = 0;
if (y <= EDGE_ZONE_PX && hasMoreAbove) {
dir = -1;
} else if (y >= rect.height - EDGE_ZONE_PX && hasMoreBelow) {
dir = 1;
}
carouselDirRef.current = dir;
if (dir === 0) {
stopCarousel();
return;
}
if (carouselTimerRef.current === null) {
carouselTimerRef.current = window.setInterval(carouselStep, CAROUSEL_INTERVAL_MS);
}
}, [carouselStep, hasMoreAbove, hasMoreBelow, stopCarousel]);
React.useEffect(() => () => {
clearCloseTimeout();
setPromptNavigatorPanelOpen(false);
}, [clearCloseTimeout, setPromptNavigatorPanelOpen]);
if (carouselTimerRef.current !== null) {
window.clearInterval(carouselTimerRef.current);
}
}, []);
// Keyboard shortcut flips the store open with focus outside the rail.
// Pointer open sets openedByPointerRef so we don't steal focus on hover.
const handlePointerMove = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
pointerYRef.current = event.clientY;
const relative = relativeIndexFromPointer(event.clientY);
if (relative !== null) {
setHighlightedIndex(
Math.min(promptsLengthRef.current - 1, windowStartRef.current + relative),
);
}
updateCarousel(event.clientY);
}, [relativeIndexFromPointer, updateCarousel]);
const handlePointerLeave = React.useCallback(() => {
pointerYRef.current = null;
stopCarousel();
setHighlightedIndex(null);
}, [stopCarousel]);
const closeKeyboardNav = React.useCallback(() => {
setPromptNavigatorPanelOpen(false);
}, [setPromptNavigatorPanelOpen]);
const handleSelect = React.useCallback((index: number | null) => {
if (index === null) {
return;
}
const prompt = prompts[index];
if (!prompt) {
return;
}
onSelectTurn(prompt.turnId);
stopCarousel();
setHighlightedIndex(null);
closeKeyboardNav();
gutterRef.current?.blur();
}, [closeKeyboardNav, onSelectTurn, prompts, stopCarousel]);
const handleGutterClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const relative = relativeIndexFromPointer(event.clientY);
if (relative === null) {
return;
}
handleSelect(Math.min(prompts.length - 1, windowStartRef.current + relative));
}, [handleSelect, prompts.length, relativeIndexFromPointer]);
// Keyboard shortcut flips the store flag; entering keyboard mode focuses
// the gutter and highlights the active (or last) prompt.
React.useEffect(() => {
if (!isPanelOpen) {
setFocusActiveOnOpen(false);
if (!isKeyboardNavOpen) {
return;
}
if (openedByPointerRef.current) {
openedByPointerRef.current = false;
setFocusActiveOnOpen(false);
const gutter = gutterRef.current;
if (!gutter || gutter === document.activeElement) {
return;
}
setFocusActiveOnOpen(true);
}, [isPanelOpen]);
gutter.focus();
setHighlightedIndex((current) => {
if (current !== null) {
return current;
}
const target = activeIndex >= 0 ? activeIndex : prompts.length - 1;
ensureWindowContains(target);
return target;
});
}, [activeIndex, ensureWindowContains, isKeyboardNavOpen, prompts.length]);
const handleSelectPrompt = React.useCallback((turnId: string) => {
onSelectTurn(turnId);
openedByPointerRef.current = false;
setFocusActiveOnOpen(false);
React.useEffect(() => () => {
setPromptNavigatorPanelOpen(false);
}, [onSelectTurn, setPromptNavigatorPanelOpen]);
}, [setPromptNavigatorPanelOpen]);
const handleLoadEarlier = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (isLoadingOlder) {
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (prompts.length === 0) {
return;
}
onLoadEarlier();
}, [isLoadingOlder, onLoadEarlier]);
const current = highlightedIndex ?? (activeIndex >= 0 ? activeIndex : prompts.length - 1);
const handleWrapperBlur = React.useCallback((event: React.FocusEvent<HTMLDivElement>) => {
const next = event.relatedTarget;
if (next instanceof Node && event.currentTarget.contains(next)) {
const moveTo = (index: number) => {
const next = Math.max(0, Math.min(prompts.length - 1, index));
ensureWindowContains(next);
setHighlightedIndex(next);
};
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
moveTo(current + (event.key === 'ArrowUp' ? -1 : 1));
return;
}
scheduleClosePanel();
}, [scheduleClosePanel]);
if (event.key === 'Home') {
event.preventDefault();
moveTo(0);
return;
}
if (event.key === 'End') {
event.preventDefault();
moveTo(prompts.length - 1);
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleSelect(current);
return;
}
if (event.key === 'Escape') {
event.preventDefault();
setHighlightedIndex(null);
closeKeyboardNav();
gutterRef.current?.blur();
}
}, [activeIndex, closeKeyboardNav, ensureWindowContains, handleSelect, highlightedIndex, prompts.length]);
const handleBlur = React.useCallback(() => {
stopCarousel();
setHighlightedIndex(null);
closeKeyboardNav();
}, [closeKeyboardNav, stopCarousel]);
const highlightedPrompt = highlightedIndex !== null ? prompts[highlightedIndex] : undefined;
// Overscan a few ticks beyond the window so they slide in under the
// gradient mask instead of popping into existence at the edges.
const overscanStart = Math.max(0, clampedWindowStart - TICK_OVERSCAN);
const overscanEnd = Math.min(prompts.length, windowEnd + TICK_OVERSCAN);
const visiblePrompts = prompts.slice(overscanStart, overscanEnd);
const gutterMask = hasMoreAbove || hasMoreBelow
? `linear-gradient(to bottom, ${hasMoreAbove ? 'transparent, black 14%' : 'black'}, ${hasMoreBelow ? 'black 86%, transparent' : 'black'})`
: undefined;
if (prompts.length === 0) {
return null;
@@ -347,42 +402,127 @@ export function PromptNavigatorRail({
return (
<nav
ref={rootRef}
ref={navRef}
aria-label={t('chat.promptNavigator.aria')}
className="pointer-events-none absolute right-3 top-1/2 z-20 -translate-y-1/2"
className="pointer-events-none absolute right-1.5 top-1/2 z-20 -translate-y-1/2"
>
<div
className="pointer-events-auto relative"
onMouseEnter={openPanel}
onMouseLeave={scheduleClosePanel}
onFocus={openPanel}
onBlur={handleWrapperBlur}
>
<LineRail
prompts={prompts}
activeTurnId={activeTurnId}
lineGapClass={lineGapClass}
needsBackdrop={needsBackdrop}
emptyPreviewLabel={emptyPreviewLabel}
onSelectTurn={handleSelectPrompt}
/>
{isPanelOpen ? (
<PromptMenuPanel
prompts={prompts}
activeTurnId={activeTurnId}
canLoadEarlier={canLoadEarlier}
isLoadingOlder={isLoadingOlder}
emptyPreviewLabel={emptyPreviewLabel}
currentPromptLabel={currentPromptLabel}
loadMoreLabel={loadMoreLabel}
onSelectTurn={handleSelectPrompt}
onLoadEarlier={handleLoadEarlier}
onMouseEnter={openPanel}
onMouseLeave={scheduleClosePanel}
focusOnMount={focusActiveOnOpen}
/>
<div className="pointer-events-auto flex flex-col items-end">
{canLoadEarlier ? (
<button
type="button"
tabIndex={-1}
className={cn(
// Nudge so the icon centers over the tick column
// (ticks sit at right-1 with a 10px base width).
'-mr-px mb-1.5 flex size-5 shrink-0 items-center justify-center rounded-full',
'text-[var(--surface-mutedForeground)] transition-colors',
'hover:bg-[var(--interactive-hover)]/60 hover:text-[var(--surface-foreground)]',
isLoadingOlder ? 'cursor-wait opacity-70' : undefined,
)}
aria-label={loadMoreLabel}
title={loadMoreLabel}
disabled={isLoadingOlder}
onClick={(event) => {
event.stopPropagation();
if (!isLoadingOlder) {
onLoadEarlier();
}
}}
>
{isLoadingOlder ? (
<Icon name="loader-4" className="size-3.5 animate-spin" />
) : (
<Icon name="arrow-up-s" className="size-3.5" />
)}
</button>
) : null}
<div
ref={gutterRef}
role="listbox"
tabIndex={-1}
aria-activedescendant={
highlightedPrompt ? `prompt-rail-tick-${highlightedPrompt.turnId}` : undefined
}
className="relative cursor-pointer outline-none"
style={{
width: `${isNarrowGutter ? GUTTER_NARROW_WIDTH_PX : GUTTER_WIDTH_PX}px`,
height: `${visibleCount * TICK_PITCH_PX}px`,
}}
onMouseMove={handlePointerMove}
onMouseLeave={handlePointerLeave}
onClick={handleGutterClick}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
>
<div
className="absolute inset-0 overflow-hidden"
style={gutterMask ? { maskImage: gutterMask, WebkitMaskImage: gutterMask } : undefined}
>
{/* The tape: ticks keep their absolute position on the
strip, and the strip itself glides. */}
<div
// Remount on prepend so the index shift doesn't
// play as a spurious slide animation.
key={prompts[0]?.turnId}
className="absolute inset-x-0 top-0 transition-transform duration-300 ease-out"
style={{ transform: `translateY(-${clampedWindowStart * TICK_PITCH_PX}px)` }}
>
{visiblePrompts.map((prompt, slot) => {
const index = overscanStart + slot;
const isActive = prompt.turnId === activeTurnId;
const isHighlighted = highlightedIndex === index;
const tickWidth = resolveTickWidth(index, highlightedIndex, isActive);
return (
<div
key={prompt.turnId}
id={`prompt-rail-tick-${prompt.turnId}`}
role="option"
aria-selected={isHighlighted}
aria-current={isActive ? 'true' : undefined}
aria-label={prompt.preview.trim() || emptyPreviewLabel}
className="pointer-events-none absolute right-1 flex items-center justify-end"
style={{ top: `${index * TICK_PITCH_PX}px`, height: `${TICK_PITCH_PX}px` }}
>
<span
aria-hidden="true"
className={cn(
'block h-0.5 rounded-full transition-all duration-200 ease-out',
isActive
? 'bg-[var(--surface-foreground)]'
: isHighlighted
? 'bg-[var(--surface-foreground)]/80'
: 'bg-[var(--surface-foreground)]/30',
)}
style={{ width: `${tickWidth}px` }}
/>
</div>
);
})}
</div>
</div>
{highlightedPrompt && highlightedIndex !== null ? (
<div
className={cn(
'pointer-events-none absolute right-full z-30 mr-3 -translate-y-1/2',
'w-[min(20rem,calc(100vw-6rem))] rounded-xl border border-[var(--interactive-border)]/60',
'bg-[var(--surface-elevated)] px-3 py-2 shadow-md',
)}
style={{
top: `${(highlightedIndex - clampedWindowStart) * TICK_PITCH_PX + TICK_PITCH_PX / 2}px`,
}}
>
<span className="typography-meta line-clamp-3 block text-[var(--surface-foreground)]">
{highlightedPrompt.preview.trim() || emptyPreviewLabel}
</span>
{highlightedPrompt.turnId === activeTurnId ? (
<span className="mt-0.5 block typography-micro text-[var(--surface-mutedForeground)]">
{currentPromptLabel}
</span>
) : null}
</div>
) : null}
</div>
</div>
</nav>
);
@@ -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);
}, []);
@@ -1,10 +1,4 @@
export type VisibleTurn = {
id: string;
ratio: number;
top: number;
};
export type OffsetTurn = {
type OffsetTurn = {
id: string;
top: number;
};
@@ -13,32 +7,14 @@ type ScrollSpyInput = {
onActive: (id: string) => void;
raf?: (cb: FrameRequestCallback) => number;
caf?: (id: number) => void;
IntersectionObserver?: typeof globalThis.IntersectionObserver;
ResizeObserver?: typeof globalThis.ResizeObserver;
MutationObserver?: typeof globalThis.MutationObserver;
};
const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | undefined => {
if (list.length === 0) {
return undefined;
}
const sorted = [...list].sort((a, b) => {
if (b.ratio !== a.ratio) {
return b.ratio - a.ratio;
}
const distanceA = Math.abs(a.top - line);
const distanceB = Math.abs(b.top - line);
if (distanceA !== distanceB) {
return distanceA - distanceB;
}
return a.top - b.top;
});
return sorted[0]?.id;
};
// Reading line offset below the container top. The active turn is the last
// one whose top edge sits at or above this line — a monotonic rule that stays
// stable while scrolling inside a long turn (no visibility-ratio flip-flop).
const READ_LINE_OFFSET_PX = 100;
const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => {
if (list.length === 0) {
@@ -71,12 +47,10 @@ const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefine
export const createScrollSpy = (input: ScrollSpyInput) => {
const raf = input.raf ?? requestAnimationFrame;
const caf = input.caf ?? cancelAnimationFrame;
const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver;
const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver;
const CtorMO = input.MutationObserver ?? globalThis.MutationObserver;
let root: HTMLDivElement | undefined;
let io: IntersectionObserver | undefined;
let ro: ResizeObserver | undefined;
let mo: MutationObserver | undefined;
let frame: number | undefined;
@@ -85,8 +59,6 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
let dirty = true;
const nodes = new Map<string, HTMLElement>();
const idByElement = new WeakMap<HTMLElement, string>();
const visible = new Map<string, { ratio: number; top: number }>();
let offsets: OffsetTurn[] = [];
const schedule = () => {
@@ -122,23 +94,11 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
return;
}
const line = container.getBoundingClientRect().top + 100;
const next =
pickVisibleTurnId(
[...visible].map(([id, value]) => ({
id,
ratio: value.ratio,
top: value.top,
})),
line,
)
?? (() => {
if (dirty) {
refreshOffsets();
}
return pickOffsetTurnId(offsets, container.scrollTop + 100);
})();
if (dirty) {
refreshOffsets();
}
const next = pickOffsetTurnId(offsets, container.scrollTop + READ_LINE_OFFSET_PX);
if (!next || next === active) {
return;
}
@@ -153,52 +113,6 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
return;
}
io?.disconnect();
io = undefined;
if (CtorIO) {
try {
io = new CtorIO(
(entries) => {
for (const entry of entries) {
const element = entry.target;
if (!(element instanceof HTMLElement)) {
continue;
}
const key = idByElement.get(element);
if (!key) {
continue;
}
if (!entry.isIntersecting || entry.intersectionRatio <= 0) {
visible.delete(key);
continue;
}
visible.set(key, {
ratio: entry.intersectionRatio,
top: entry.boundingClientRect.top,
});
}
schedule();
},
{
root: container,
threshold: [0, 0.25, 0.5, 0.75, 1],
},
);
} catch {
io = undefined;
}
}
if (io) {
for (const element of nodes.values()) {
io.observe(element);
}
}
clearTimeout(roDebounce);
roDebounce = undefined;
ro?.disconnect();
@@ -245,7 +159,6 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
}
root = element;
visible.clear();
active = undefined;
observe();
};
@@ -253,15 +166,10 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
const register = (element: HTMLElement, key: string) => {
const previous = nodes.get(key);
if (previous && previous !== element) {
io?.unobserve(previous);
ro?.unobserve(previous);
}
nodes.set(key, element);
idByElement.set(element, key);
if (io) {
io.observe(element);
}
if (ro) {
ro.observe(element);
}
@@ -275,10 +183,8 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
return;
}
io?.unobserve(element);
ro?.unobserve(element);
nodes.delete(key);
visible.delete(key);
dirty = true;
schedule();
};
@@ -290,12 +196,10 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
const clear = () => {
for (const element of nodes.values()) {
io?.unobserve(element);
ro?.unobserve(element);
}
nodes.clear();
visible.clear();
offsets = [];
active = undefined;
dirty = true;
@@ -309,10 +213,8 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
clearTimeout(roDebounce);
roDebounce = undefined;
clear();
io?.disconnect();
ro?.disconnect();
mo?.disconnect();
io = undefined;
ro = undefined;
mo = undefined;
root = undefined;
+23 -5
View File
@@ -40,7 +40,10 @@ function sortParts(parts: Part[], skipPartTypes: ReadonlySet<string>) {
}
function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean {
if (!left) return right.length === 0
// `undefined` means "parts never fetched", which is NOT equivalent to a
// fetched-empty snapshot — the empty array must be committed so
// getSessionMaterializationStatus can tell the two apart.
if (!left) return false
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) {
@@ -175,18 +178,29 @@ export function materializeSessionSnapshots(
const messageID = record.info.id
if (isPrepend && nextPartState[messageID]) continue
const isAssistant = record.info.role === "assistant"
const existing = nextPartState[messageID]
const nextParts = mergeMaterializedParts(
existing,
sortParts(record.parts ?? [], skipPartTypes),
skipPartTypes,
record.info.role === "assistant",
isAssistant,
)
if (haveEquivalentPartSnapshots(existing, nextParts)) continue
// For non-assistant messages an empty snapshot keeps the old "absent"
// representation; only assistant messages need the explicit [] marker
// (getSessionMaterializationStatus checks only assistant messages).
const equivalent = existing
? haveEquivalentPartSnapshots(existing, nextParts)
: nextParts.length === 0 && !isAssistant
if (equivalent) continue
if (nextParts.length === 0) {
if (nextParts.length === 0 && !isAssistant) {
delete nextPartState[messageID]
} else {
// Store fetched-empty as an explicit [] (not absence): an assistant
// message the server returned with zero parts (e.g. aborted before any
// output) is authoritatively empty and must count as renderable, or
// the ensure-renderable effects retry syncSession forever.
nextPartState[messageID] = nextParts
}
partsChanged = true
@@ -213,8 +227,12 @@ export function getSessionMaterializationStatus(
const missingPartMessageIDs: string[] = []
for (const message of messages) {
if (message.role !== "assistant") continue
// `undefined` = parts never fetched (not renderable yet). An explicit []
// is a fetched-empty snapshot (e.g. aborted assistant turn) and counts
// as renderable — otherwise sessions containing such a message can never
// reach renderable state and ensure-renderable callers loop forever.
const parts = state.part[message.id]
if (!parts || parts.length === 0) {
if (!parts) {
missingPartMessageIDs.push(message.id)
}
}