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:
committed by
GitHub
parent
697b180532
commit
799904f0f4
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user