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 type { Part } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -23,9 +22,26 @@ type PromptNavigatorRailProps = {
|
|||||||
onLoadEarlier: () => void;
|
onLoadEarlier: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LINE_HIT_HEIGHT_PX = 8;
|
const PREVIEW_MAX_CHARS = 160;
|
||||||
const HOVER_CLOSE_DELAY_MS = 120;
|
// The whole gutter is one hover/click target: the cursor's vertical position
|
||||||
const COMPACT_BACKDROP_MAX_WIDTH_PX = 1280;
|
// 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 = (
|
const buildPromptEntries = (
|
||||||
turnIds: string[],
|
turnIds: string[],
|
||||||
@@ -35,212 +51,28 @@ const buildPromptEntries = (
|
|||||||
const parts = previewsByTurnId.get(turnId) ?? [];
|
const parts = previewsByTurnId.get(turnId) ?? [];
|
||||||
return {
|
return {
|
||||||
turnId,
|
turnId,
|
||||||
preview: getMessagePreview(parts, 120),
|
preview: getMessagePreview(parts, PREVIEW_MAX_CHARS),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveLineGapClass = (count: number): string => {
|
// Codex-style wave: the highlighted tick stretches, neighbours taper off.
|
||||||
if (count > 24) {
|
const PROXIMITY_FALLOFF = [1, 0.6, 0.35, 0.15];
|
||||||
return 'gap-px';
|
|
||||||
|
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) {
|
const distance = Math.abs(index - highlightedIndex);
|
||||||
return 'gap-0.5';
|
const factor = PROXIMITY_FALLOFF[distance] ?? 0;
|
||||||
}
|
return Math.round(base + (TICK_FOCUS_WIDTH_PX - base) * factor);
|
||||||
return 'gap-1';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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({
|
export function PromptNavigatorRail({
|
||||||
turnIds,
|
turnIds,
|
||||||
previewsByTurnId,
|
previewsByTurnId,
|
||||||
@@ -251,95 +83,318 @@ export function PromptNavigatorRail({
|
|||||||
onLoadEarlier,
|
onLoadEarlier,
|
||||||
}: PromptNavigatorRailProps) {
|
}: PromptNavigatorRailProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { screenWidth } = useDeviceInfo();
|
const isKeyboardNavOpen = useUIStore((state) => state.isPromptNavigatorPanelOpen);
|
||||||
const isPanelOpen = useUIStore((state) => state.isPromptNavigatorPanelOpen);
|
|
||||||
const setPromptNavigatorPanelOpen = useUIStore((state) => state.setPromptNavigatorPanelOpen);
|
const setPromptNavigatorPanelOpen = useUIStore((state) => state.setPromptNavigatorPanelOpen);
|
||||||
const closeTimeoutRef = React.useRef<number | null>(null);
|
const gutterRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
const navRef = React.useRef<HTMLElement | null>(null);
|
||||||
const openedByPointerRef = React.useRef(false);
|
const [highlightedIndex, setHighlightedIndex] = React.useState<number | null>(null);
|
||||||
const [focusActiveOnOpen, setFocusActiveOnOpen] = React.useState(false);
|
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(
|
const prompts = React.useMemo(
|
||||||
() => buildPromptEntries(turnIds, previewsByTurnId),
|
() => buildPromptEntries(turnIds, previewsByTurnId),
|
||||||
[previewsByTurnId, turnIds],
|
[previewsByTurnId, turnIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const needsBackdrop = screenWidth < COMPACT_BACKDROP_MAX_WIDTH_PX;
|
const visibleCount = Math.min(prompts.length, MAX_VISIBLE_TICKS);
|
||||||
const lineGapClass = resolveLineGapClass(prompts.length);
|
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 emptyPreviewLabel = t('chat.timeline.noTextContent');
|
||||||
const currentPromptLabel = t('chat.promptNavigator.currentPrompt');
|
const currentPromptLabel = t('chat.promptNavigator.currentPrompt');
|
||||||
const loadMoreLabel = t('chat.promptNavigator.loadMore');
|
const loadMoreLabel = t('chat.promptNavigator.loadMore');
|
||||||
|
|
||||||
const clearCloseTimeout = React.useCallback(() => {
|
const activeIndex = React.useMemo(() => {
|
||||||
if (closeTimeoutRef.current !== null) {
|
if (!activeTurnId) {
|
||||||
window.clearTimeout(closeTimeoutRef.current);
|
return -1;
|
||||||
closeTimeoutRef.current = null;
|
}
|
||||||
|
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(() => {
|
const carouselStep = React.useCallback(() => {
|
||||||
clearCloseTimeout();
|
const dir = carouselDirRef.current;
|
||||||
openedByPointerRef.current = true;
|
if (dir === 0) {
|
||||||
setFocusActiveOnOpen(false);
|
stopCarousel();
|
||||||
setPromptNavigatorPanelOpen(true);
|
return;
|
||||||
}, [clearCloseTimeout, setPromptNavigatorPanelOpen]);
|
}
|
||||||
|
const length = promptsLengthRef.current;
|
||||||
const scheduleClosePanel = React.useCallback(() => {
|
const count = Math.min(length, MAX_VISIBLE_TICKS);
|
||||||
clearCloseTimeout();
|
const maxStart = Math.max(0, length - count);
|
||||||
closeTimeoutRef.current = window.setTimeout(() => {
|
const current = Math.min(windowStartRef.current, maxStart);
|
||||||
// Keep keyboard-opened panel alive while focus is still inside the rail.
|
const next = Math.max(0, Math.min(maxStart, current + dir));
|
||||||
if (rootRef.current?.contains(document.activeElement)) {
|
if (next === current) {
|
||||||
return;
|
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);
|
}, [relativeIndexFromPointer, stopCarousel]);
|
||||||
setPromptNavigatorPanelOpen(false);
|
|
||||||
}, HOVER_CLOSE_DELAY_MS);
|
const updateCarousel = React.useCallback((clientY: number) => {
|
||||||
}, [clearCloseTimeout, setPromptNavigatorPanelOpen]);
|
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(() => () => {
|
React.useEffect(() => () => {
|
||||||
clearCloseTimeout();
|
if (carouselTimerRef.current !== null) {
|
||||||
setPromptNavigatorPanelOpen(false);
|
window.clearInterval(carouselTimerRef.current);
|
||||||
}, [clearCloseTimeout, setPromptNavigatorPanelOpen]);
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Keyboard shortcut flips the store open with focus outside the rail.
|
const handlePointerMove = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
// Pointer open sets openedByPointerRef so we don't steal focus on hover.
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (!isPanelOpen) {
|
if (!isKeyboardNavOpen) {
|
||||||
setFocusActiveOnOpen(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (openedByPointerRef.current) {
|
const gutter = gutterRef.current;
|
||||||
openedByPointerRef.current = false;
|
if (!gutter || gutter === document.activeElement) {
|
||||||
setFocusActiveOnOpen(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFocusActiveOnOpen(true);
|
gutter.focus();
|
||||||
}, [isPanelOpen]);
|
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) => {
|
React.useEffect(() => () => {
|
||||||
onSelectTurn(turnId);
|
|
||||||
openedByPointerRef.current = false;
|
|
||||||
setFocusActiveOnOpen(false);
|
|
||||||
setPromptNavigatorPanelOpen(false);
|
setPromptNavigatorPanelOpen(false);
|
||||||
}, [onSelectTurn, setPromptNavigatorPanelOpen]);
|
}, [setPromptNavigatorPanelOpen]);
|
||||||
|
|
||||||
const handleLoadEarlier = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
if (prompts.length === 0) {
|
||||||
event.stopPropagation();
|
|
||||||
if (isLoadingOlder) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onLoadEarlier();
|
const current = highlightedIndex ?? (activeIndex >= 0 ? activeIndex : prompts.length - 1);
|
||||||
}, [isLoadingOlder, onLoadEarlier]);
|
|
||||||
|
|
||||||
const handleWrapperBlur = React.useCallback((event: React.FocusEvent<HTMLDivElement>) => {
|
const moveTo = (index: number) => {
|
||||||
const next = event.relatedTarget;
|
const next = Math.max(0, Math.min(prompts.length - 1, index));
|
||||||
if (next instanceof Node && event.currentTarget.contains(next)) {
|
ensureWindowContains(next);
|
||||||
|
setHighlightedIndex(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
moveTo(current + (event.key === 'ArrowUp' ? -1 : 1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheduleClosePanel();
|
if (event.key === 'Home') {
|
||||||
}, [scheduleClosePanel]);
|
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) {
|
if (prompts.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -347,42 +402,127 @@ export function PromptNavigatorRail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
ref={rootRef}
|
ref={navRef}
|
||||||
aria-label={t('chat.promptNavigator.aria')}
|
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
|
<div className="pointer-events-auto flex flex-col items-end">
|
||||||
className="pointer-events-auto relative"
|
{canLoadEarlier ? (
|
||||||
onMouseEnter={openPanel}
|
<button
|
||||||
onMouseLeave={scheduleClosePanel}
|
type="button"
|
||||||
onFocus={openPanel}
|
tabIndex={-1}
|
||||||
onBlur={handleWrapperBlur}
|
className={cn(
|
||||||
>
|
// Nudge so the icon centers over the tick column
|
||||||
<LineRail
|
// (ticks sit at right-1 with a 10px base width).
|
||||||
prompts={prompts}
|
'-mr-px mb-1.5 flex size-5 shrink-0 items-center justify-center rounded-full',
|
||||||
activeTurnId={activeTurnId}
|
'text-[var(--surface-mutedForeground)] transition-colors',
|
||||||
lineGapClass={lineGapClass}
|
'hover:bg-[var(--interactive-hover)]/60 hover:text-[var(--surface-foreground)]',
|
||||||
needsBackdrop={needsBackdrop}
|
isLoadingOlder ? 'cursor-wait opacity-70' : undefined,
|
||||||
emptyPreviewLabel={emptyPreviewLabel}
|
)}
|
||||||
onSelectTurn={handleSelectPrompt}
|
aria-label={loadMoreLabel}
|
||||||
/>
|
title={loadMoreLabel}
|
||||||
|
disabled={isLoadingOlder}
|
||||||
{isPanelOpen ? (
|
onClick={(event) => {
|
||||||
<PromptMenuPanel
|
event.stopPropagation();
|
||||||
prompts={prompts}
|
if (!isLoadingOlder) {
|
||||||
activeTurnId={activeTurnId}
|
onLoadEarlier();
|
||||||
canLoadEarlier={canLoadEarlier}
|
}
|
||||||
isLoadingOlder={isLoadingOlder}
|
}}
|
||||||
emptyPreviewLabel={emptyPreviewLabel}
|
>
|
||||||
currentPromptLabel={currentPromptLabel}
|
{isLoadingOlder ? (
|
||||||
loadMoreLabel={loadMoreLabel}
|
<Icon name="loader-4" className="size-3.5 animate-spin" />
|
||||||
onSelectTurn={handleSelectPrompt}
|
) : (
|
||||||
onLoadEarlier={handleLoadEarlier}
|
<Icon name="arrow-up-s" className="size-3.5" />
|
||||||
onMouseEnter={openPanel}
|
)}
|
||||||
onMouseLeave={scheduleClosePanel}
|
</button>
|
||||||
focusOnMount={focusActiveOnOpen}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ const MOBILE_TURN_MODEL_CACHE_MAX = 4
|
|||||||
const MOBILE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
const MOBILE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
||||||
const HISTORY_RENDER_WAIT_TIMEOUT_MS = 250
|
const HISTORY_RENDER_WAIT_TIMEOUT_MS = 250
|
||||||
const HISTORY_INTERACTION_GUARD_MS = 2000
|
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 turnModelCache = new Map<string, { messages: ChatMessageEntry[]; model: TurnWindowModel }>()
|
||||||
const getTurnModelCacheMax = () => {
|
const getTurnModelCacheMax = () => {
|
||||||
if (isVSCodeRuntime()) return VSCODE_TURN_MODEL_CACHE_MAX
|
if (isVSCodeRuntime()) return VSCODE_TURN_MODEL_CACHE_MAX
|
||||||
@@ -241,6 +244,7 @@ export const useChatTimelineController = ({
|
|||||||
const initializedSessionRef = React.useRef<string | null>(null);
|
const initializedSessionRef = React.useRef<string | null>(null);
|
||||||
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
|
const pendingRenderResolversRef = React.useRef<Array<() => void>>([]);
|
||||||
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
|
const pendingScrollRequestRef = React.useRef<PendingScrollRequest | null>(null);
|
||||||
|
const scrollPinRef = React.useRef<{ turnId: string; expiresAt: number } | null>(null);
|
||||||
const historyInteractionRef = React.useRef(false);
|
const historyInteractionRef = React.useRef(false);
|
||||||
const historyInteractionTimerRef = React.useRef<number | null>(null);
|
const historyInteractionTimerRef = React.useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -305,6 +309,7 @@ export const useChatTimelineController = ({
|
|||||||
initializedSessionRef.current = sessionId;
|
initializedSessionRef.current = sessionId;
|
||||||
setIsLoadingOlder(false);
|
setIsLoadingOlder(false);
|
||||||
setPendingRevealWork(false);
|
setPendingRevealWork(false);
|
||||||
|
scrollPinRef.current = null;
|
||||||
setActiveTurnId(null);
|
setActiveTurnId(null);
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
@@ -362,6 +367,13 @@ export const useChatTimelineController = ({
|
|||||||
|
|
||||||
if (didScroll) {
|
if (didScroll) {
|
||||||
if (pending.turnId) {
|
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);
|
setActiveTurnId(pending.turnId);
|
||||||
}
|
}
|
||||||
resolvePendingScrollRequest(true);
|
resolvePendingScrollRequest(true);
|
||||||
@@ -890,6 +902,13 @@ export const useChatTimelineController = ({
|
|||||||
}, [goToBottom]);
|
}, [goToBottom]);
|
||||||
|
|
||||||
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
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);
|
setActiveTurnId(turnId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
export type VisibleTurn = {
|
type OffsetTurn = {
|
||||||
id: string;
|
|
||||||
ratio: number;
|
|
||||||
top: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OffsetTurn = {
|
|
||||||
id: string;
|
id: string;
|
||||||
top: number;
|
top: number;
|
||||||
};
|
};
|
||||||
@@ -13,32 +7,14 @@ type ScrollSpyInput = {
|
|||||||
onActive: (id: string) => void;
|
onActive: (id: string) => void;
|
||||||
raf?: (cb: FrameRequestCallback) => number;
|
raf?: (cb: FrameRequestCallback) => number;
|
||||||
caf?: (id: number) => void;
|
caf?: (id: number) => void;
|
||||||
IntersectionObserver?: typeof globalThis.IntersectionObserver;
|
|
||||||
ResizeObserver?: typeof globalThis.ResizeObserver;
|
ResizeObserver?: typeof globalThis.ResizeObserver;
|
||||||
MutationObserver?: typeof globalThis.MutationObserver;
|
MutationObserver?: typeof globalThis.MutationObserver;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickVisibleTurnId = (list: VisibleTurn[], line: number): string | undefined => {
|
// Reading line offset below the container top. The active turn is the last
|
||||||
if (list.length === 0) {
|
// one whose top edge sits at or above this line — a monotonic rule that stays
|
||||||
return undefined;
|
// stable while scrolling inside a long turn (no visibility-ratio flip-flop).
|
||||||
}
|
const READ_LINE_OFFSET_PX = 100;
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => {
|
const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefined => {
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
@@ -71,12 +47,10 @@ const pickOffsetTurnId = (list: OffsetTurn[], cutoff: number): string | undefine
|
|||||||
export const createScrollSpy = (input: ScrollSpyInput) => {
|
export const createScrollSpy = (input: ScrollSpyInput) => {
|
||||||
const raf = input.raf ?? requestAnimationFrame;
|
const raf = input.raf ?? requestAnimationFrame;
|
||||||
const caf = input.caf ?? cancelAnimationFrame;
|
const caf = input.caf ?? cancelAnimationFrame;
|
||||||
const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver;
|
|
||||||
const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver;
|
const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver;
|
||||||
const CtorMO = input.MutationObserver ?? globalThis.MutationObserver;
|
const CtorMO = input.MutationObserver ?? globalThis.MutationObserver;
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined;
|
let root: HTMLDivElement | undefined;
|
||||||
let io: IntersectionObserver | undefined;
|
|
||||||
let ro: ResizeObserver | undefined;
|
let ro: ResizeObserver | undefined;
|
||||||
let mo: MutationObserver | undefined;
|
let mo: MutationObserver | undefined;
|
||||||
let frame: number | undefined;
|
let frame: number | undefined;
|
||||||
@@ -85,8 +59,6 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
|||||||
let dirty = true;
|
let dirty = true;
|
||||||
|
|
||||||
const nodes = new Map<string, HTMLElement>();
|
const nodes = new Map<string, HTMLElement>();
|
||||||
const idByElement = new WeakMap<HTMLElement, string>();
|
|
||||||
const visible = new Map<string, { ratio: number; top: number }>();
|
|
||||||
let offsets: OffsetTurn[] = [];
|
let offsets: OffsetTurn[] = [];
|
||||||
|
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
@@ -122,23 +94,11 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const line = container.getBoundingClientRect().top + 100;
|
if (dirty) {
|
||||||
const next =
|
refreshOffsets();
|
||||||
pickVisibleTurnId(
|
}
|
||||||
[...visible].map(([id, value]) => ({
|
|
||||||
id,
|
|
||||||
ratio: value.ratio,
|
|
||||||
top: value.top,
|
|
||||||
})),
|
|
||||||
line,
|
|
||||||
)
|
|
||||||
?? (() => {
|
|
||||||
if (dirty) {
|
|
||||||
refreshOffsets();
|
|
||||||
}
|
|
||||||
return pickOffsetTurnId(offsets, container.scrollTop + 100);
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
const next = pickOffsetTurnId(offsets, container.scrollTop + READ_LINE_OFFSET_PX);
|
||||||
if (!next || next === active) {
|
if (!next || next === active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -153,52 +113,6 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
|||||||
return;
|
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);
|
clearTimeout(roDebounce);
|
||||||
roDebounce = undefined;
|
roDebounce = undefined;
|
||||||
ro?.disconnect();
|
ro?.disconnect();
|
||||||
@@ -245,7 +159,6 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
root = element;
|
root = element;
|
||||||
visible.clear();
|
|
||||||
active = undefined;
|
active = undefined;
|
||||||
observe();
|
observe();
|
||||||
};
|
};
|
||||||
@@ -253,15 +166,10 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
|||||||
const register = (element: HTMLElement, key: string) => {
|
const register = (element: HTMLElement, key: string) => {
|
||||||
const previous = nodes.get(key);
|
const previous = nodes.get(key);
|
||||||
if (previous && previous !== element) {
|
if (previous && previous !== element) {
|
||||||
io?.unobserve(previous);
|
|
||||||
ro?.unobserve(previous);
|
ro?.unobserve(previous);
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes.set(key, element);
|
nodes.set(key, element);
|
||||||
idByElement.set(element, key);
|
|
||||||
if (io) {
|
|
||||||
io.observe(element);
|
|
||||||
}
|
|
||||||
if (ro) {
|
if (ro) {
|
||||||
ro.observe(element);
|
ro.observe(element);
|
||||||
}
|
}
|
||||||
@@ -275,10 +183,8 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
io?.unobserve(element);
|
|
||||||
ro?.unobserve(element);
|
ro?.unobserve(element);
|
||||||
nodes.delete(key);
|
nodes.delete(key);
|
||||||
visible.delete(key);
|
|
||||||
dirty = true;
|
dirty = true;
|
||||||
schedule();
|
schedule();
|
||||||
};
|
};
|
||||||
@@ -290,12 +196,10 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
|||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
for (const element of nodes.values()) {
|
for (const element of nodes.values()) {
|
||||||
io?.unobserve(element);
|
|
||||||
ro?.unobserve(element);
|
ro?.unobserve(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes.clear();
|
nodes.clear();
|
||||||
visible.clear();
|
|
||||||
offsets = [];
|
offsets = [];
|
||||||
active = undefined;
|
active = undefined;
|
||||||
dirty = true;
|
dirty = true;
|
||||||
@@ -309,10 +213,8 @@ export const createScrollSpy = (input: ScrollSpyInput) => {
|
|||||||
clearTimeout(roDebounce);
|
clearTimeout(roDebounce);
|
||||||
roDebounce = undefined;
|
roDebounce = undefined;
|
||||||
clear();
|
clear();
|
||||||
io?.disconnect();
|
|
||||||
ro?.disconnect();
|
ro?.disconnect();
|
||||||
mo?.disconnect();
|
mo?.disconnect();
|
||||||
io = undefined;
|
|
||||||
ro = undefined;
|
ro = undefined;
|
||||||
mo = undefined;
|
mo = undefined;
|
||||||
root = undefined;
|
root = undefined;
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ function sortParts(parts: Part[], skipPartTypes: ReadonlySet<string>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function haveEquivalentPartSnapshots(left: Part[] | undefined, right: Part[]): boolean {
|
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
|
if (left.length !== right.length) return false
|
||||||
|
|
||||||
for (let index = 0; index < left.length; index += 1) {
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
@@ -175,18 +178,29 @@ export function materializeSessionSnapshots(
|
|||||||
const messageID = record.info.id
|
const messageID = record.info.id
|
||||||
if (isPrepend && nextPartState[messageID]) continue
|
if (isPrepend && nextPartState[messageID]) continue
|
||||||
|
|
||||||
|
const isAssistant = record.info.role === "assistant"
|
||||||
const existing = nextPartState[messageID]
|
const existing = nextPartState[messageID]
|
||||||
const nextParts = mergeMaterializedParts(
|
const nextParts = mergeMaterializedParts(
|
||||||
existing,
|
existing,
|
||||||
sortParts(record.parts ?? [], skipPartTypes),
|
sortParts(record.parts ?? [], skipPartTypes),
|
||||||
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]
|
delete nextPartState[messageID]
|
||||||
} else {
|
} 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
|
nextPartState[messageID] = nextParts
|
||||||
}
|
}
|
||||||
partsChanged = true
|
partsChanged = true
|
||||||
@@ -213,8 +227,12 @@ export function getSessionMaterializationStatus(
|
|||||||
const missingPartMessageIDs: string[] = []
|
const missingPartMessageIDs: string[] = []
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.role !== "assistant") continue
|
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]
|
const parts = state.part[message.id]
|
||||||
if (!parts || parts.length === 0) {
|
if (!parts) {
|
||||||
missingPartMessageIDs.push(message.id)
|
missingPartMessageIDs.push(message.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user