From 799904f0f4e6d5633a843c340983a8107f004eaf Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 13 Jul 2026 12:45:23 +0300 Subject: [PATCH] fix(chat): rework prompt navigator rail into sliding tape with hover preview (#2185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../chat/components/PromptNavigatorRail.tsx | 728 +++++++++++------- .../chat/hooks/useChatTimelineController.ts | 19 + .../components/chat/lib/scroll/scrollSpy.ts | 116 +-- packages/ui/src/sync/materialization.ts | 28 +- 4 files changed, 485 insertions(+), 406 deletions(-) diff --git a/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx b/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx index 179ee522..09e8e297 100644 --- a/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx +++ b/packages/ui/src/components/chat/components/PromptNavigatorRail.tsx @@ -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(null); - - React.useLayoutEffect(() => { - activeButtonRef.current?.scrollIntoView({ block: 'nearest' }); - }, [activeTurnId, prompts.length]); - - return ( -
-
- {prompts.map((prompt) => { - const isActive = prompt.turnId === activeTurnId; - const preview = prompt.preview.trim() || emptyPreviewLabel; - - return ( - - ); - })} -
-
- ); -} - -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) => 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(null); - - React.useEffect(() => { - if (!focusOnMount) { - return; - } - activeItemRef.current?.focus(); - }, [focusOnMount]); - - return ( -
-
    - {canLoadEarlier ? ( -
  • - -
  • - ) : null} - {prompts.map((prompt) => { - const isActive = prompt.turnId === activeTurnId; - const preview = prompt.preview.trim() || emptyPreviewLabel; - - return ( -
  • - -
  • - ); - })} -
-
- ); -} - 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(null); - const rootRef = React.useRef(null); - const openedByPointerRef = React.useRef(false); - const [focusActiveOnOpen, setFocusActiveOnOpen] = React.useState(false); + const gutterRef = React.useRef(null); + const navRef = React.useRef(null); + const [highlightedIndex, setHighlightedIndex] = React.useState(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(null); + const carouselTimerRef = React.useRef(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(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) => { + 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) => { + 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) => { - event.preventDefault(); - event.stopPropagation(); - if (isLoadingOlder) { + const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => { + if (prompts.length === 0) { return; } - onLoadEarlier(); - }, [isLoadingOlder, onLoadEarlier]); + const current = highlightedIndex ?? (activeIndex >= 0 ? activeIndex : prompts.length - 1); - const handleWrapperBlur = React.useCallback((event: React.FocusEvent) => { - 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 ( ); diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index 2926ca66..a47dd207 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -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() const getTurnModelCacheMax = () => { if (isVSCodeRuntime()) return VSCODE_TURN_MODEL_CACHE_MAX @@ -241,6 +244,7 @@ export const useChatTimelineController = ({ const initializedSessionRef = React.useRef(null); const pendingRenderResolversRef = React.useRef void>>([]); const pendingScrollRequestRef = React.useRef(null); + const scrollPinRef = React.useRef<{ turnId: string; expiresAt: number } | null>(null); const historyInteractionRef = React.useRef(false); const historyInteractionTimerRef = React.useRef(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); }, []); diff --git a/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts b/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts index 3e50e9f2..a4dd30e9 100644 --- a/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts +++ b/packages/ui/src/components/chat/lib/scroll/scrollSpy.ts @@ -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(); - const idByElement = new WeakMap(); - const visible = new Map(); 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; diff --git a/packages/ui/src/sync/materialization.ts b/packages/ui/src/sync/materialization.ts index acb06ca9..7f984253 100644 --- a/packages/ui/src/sync/materialization.ts +++ b/packages/ui/src/sync/materialization.ts @@ -40,7 +40,10 @@ function sortParts(parts: Part[], skipPartTypes: ReadonlySet) { } 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) } }