import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import type { UIMessage } from "@/lib/types"; import { findPromptElement, type PromptAnchor, promptTop, userPromptAnchors, } from "@/components/thread/promptNavigation"; interface PromptRailProps { bottomOffset: number; messages: UIMessage[]; onJumpToPrompt: (promptId: string) => void; scrollRef: RefObject; } interface MeasuredPrompt extends PromptAnchor { top: number; topPercent: number; } interface PromptMarker { answerPreview: string; count: number; ids: string[]; label: string; preview: string; topPercent: number; } const MIN_PROMPTS_FOR_RAIL = 3; const RAIL_MIN_SCROLL_RANGE_PX = 80; const DENSE_PROMPT_THRESHOLD = 30; const DENSE_BUCKET_HEIGHT_PX = 12; const DENSE_BUCKET_FALLBACK_COUNT = 32; const DENSE_BUCKET_MAX_COUNT = 42; const MARKER_MIN_GAP_PX = 9; const MARKER_BASE_WIDTH_PX = 9; const MARKER_STACK_GAP_PX = 16; const RAIL_FALLBACK_HEIGHT_PX = 300; const MEASURE_RETRY_FRAMES = 4; const HOVER_MARKER_WIDTHS_PX = [28, 22, 16, 11]; export function PromptRail({ bottomOffset, messages, onJumpToPrompt, scrollRef, }: PromptRailProps) { const railRef = useRef(null); const measuredPromptsRef = useRef([]); const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]); const [markers, setMarkers] = useState([]); const [activePromptId, setActivePromptId] = useState(null); const [focusedMarkerIndex, setFocusedMarkerIndex] = useState(null); const updateMarkers = useCallback(() => { const scrollEl = scrollRef.current; const nextRailHeight = railRef.current?.clientHeight ?? 0; if (!scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) { measuredPromptsRef.current = []; setMarkers([]); setActivePromptId(null); return; } const scrollRange = scrollEl.scrollHeight - scrollEl.clientHeight; if (scrollRange < RAIL_MIN_SCROLL_RANGE_PX) { measuredPromptsRef.current = []; setMarkers([]); setActivePromptId(null); return; } const measured = measurePrompts(scrollEl, promptAnchors, scrollRange); measuredPromptsRef.current = measured; const grouped = groupPromptMarkers(measured, nextRailHeight); setMarkers(distributeMarkerPositions(grouped, nextRailHeight)); setActivePromptId(activePromptForScroll(measured, scrollEl.scrollTop)); }, [promptAnchors, scrollRef]); const updateActivePrompt = useCallback(() => { const scrollEl = scrollRef.current; if (!scrollEl) return; const next = activePromptForScroll(measuredPromptsRef.current, scrollEl.scrollTop); setActivePromptId((current) => current === next ? current : next); }, [scrollRef]); useEffect(() => { let frame = 0; let remainingFrames = MEASURE_RETRY_FRAMES; const measure = () => { updateMarkers(); remainingFrames -= 1; if (remainingFrames > 0) { frame = window.requestAnimationFrame(measure); } }; measure(); return () => window.cancelAnimationFrame(frame); }, [bottomOffset, updateMarkers]); useEffect(() => { const scrollEl = scrollRef.current; if (!scrollEl) return undefined; let scrollFrame = 0; let resizeFrame = 0; const scheduleActivePrompt = () => { window.cancelAnimationFrame(scrollFrame); scrollFrame = window.requestAnimationFrame(updateActivePrompt); }; const scheduleMeasurement = () => { window.cancelAnimationFrame(resizeFrame); resizeFrame = window.requestAnimationFrame(updateMarkers); }; scrollEl.addEventListener("scroll", scheduleActivePrompt, { passive: true }); window.addEventListener("resize", scheduleMeasurement); return () => { window.cancelAnimationFrame(scrollFrame); window.cancelAnimationFrame(resizeFrame); scrollEl.removeEventListener("scroll", scheduleActivePrompt); window.removeEventListener("resize", scheduleMeasurement); }; }, [scrollRef, updateActivePrompt, updateMarkers]); useEffect(() => { const scrollEl = scrollRef.current; if (!scrollEl || typeof ResizeObserver === "undefined") return undefined; const observer = new ResizeObserver(() => updateMarkers()); observer.observe(scrollEl); if (scrollEl.firstElementChild) observer.observe(scrollEl.firstElementChild); return () => observer.disconnect(); }, [scrollRef, updateMarkers]); if (markers.length === 0) return null; return (
setFocusedMarkerIndex(null)} style={{ bottom: Math.max(80, bottomOffset) }} > {markers.map((marker, index) => { const active = marker.ids.includes(activePromptId ?? ""); const hoverDistance = focusedMarkerIndex === null ? null : Math.abs(index - focusedMarkerIndex); return ( ); })}
); } function measurePrompts( scrollEl: HTMLElement, anchors: PromptAnchor[], scrollRange: number, ): MeasuredPrompt[] { return anchors.flatMap((anchor) => { const target = findPromptElement(scrollEl, anchor.id); if (!target) return []; const top = Math.max(0, Math.min(scrollRange, promptTop(scrollEl, target) - 16)); return [{ ...anchor, top, topPercent: clamp((top / scrollRange) * 100, 2, 98), }]; }); } function groupPromptMarkers( measured: MeasuredPrompt[], railHeight: number, ): PromptMarker[] { if (measured.length === 0) return []; if (measured.length >= DENSE_PROMPT_THRESHOLD) { return bucketPromptMarkers(measured, railHeight); } const minGapPercent = railHeight > 0 ? (MARKER_MIN_GAP_PX / railHeight) * 100 : 2; const groups: PromptMarker[] = []; for (const prompt of measured) { const last = groups[groups.length - 1]; if (last && prompt.topPercent - last.topPercent < minGapPercent) { last.count += 1; last.ids.push(prompt.id); last.label = groupedPromptLabel(last.count, prompt.label); last.answerPreview = prompt.answerPreview; last.preview = prompt.preview; continue; } groups.push({ answerPreview: prompt.answerPreview, count: 1, ids: [prompt.id], label: prompt.label, preview: prompt.preview, topPercent: prompt.topPercent, }); } return groups; } function bucketPromptMarkers( measured: MeasuredPrompt[], railHeight: number, ): PromptMarker[] { const bucketCount = railHeight > 0 ? clamp( Math.floor(railHeight / DENSE_BUCKET_HEIGHT_PX), 1, DENSE_BUCKET_MAX_COUNT, ) : DENSE_BUCKET_FALLBACK_COUNT; const buckets = Array.from({ length: bucketCount }, () => [] as MeasuredPrompt[]); for (const prompt of measured) { const bucketIndex = clamp( Math.floor((prompt.topPercent / 100) * bucketCount), 0, bucketCount - 1, ); buckets[bucketIndex].push(prompt); } return buckets.flatMap((bucket) => { if (bucket.length === 0) return []; const latest = bucket[bucket.length - 1]; const topPercent = bucket.reduce((sum, prompt) => sum + prompt.topPercent, 0) / bucket.length; return [{ count: bucket.length, ids: bucket.map((prompt) => prompt.id), label: bucket.length === 1 ? latest.label : groupedPromptLabel(bucket.length, latest.label), answerPreview: latest.answerPreview, preview: latest.preview, topPercent, }]; }); } function distributeMarkerPositions(markers: PromptMarker[], railHeight: number): PromptMarker[] { const height = railHeight > 0 ? railHeight : RAIL_FALLBACK_HEIGHT_PX; if (markers.length <= 1) { return markers.map((marker) => ({ ...marker, topPercent: 50 })); } const availableHeight = Math.max(0, height - MARKER_STACK_GAP_PX); const stepPx = Math.min(MARKER_STACK_GAP_PX, availableHeight / (markers.length - 1)); const stackHeight = stepPx * (markers.length - 1); const firstMarkerPx = (height - stackHeight) / 2; return markers.map((marker, index) => ({ ...marker, topPercent: ((firstMarkerPx + stepPx * index) / height) * 100, })); } function activePromptForScroll( measured: MeasuredPrompt[], scrollTop: number, ): string | null { if (measured.length === 0) return null; const cursor = scrollTop + 96; let lower = 0; let upper = measured.length - 1; let activeIndex = 0; while (lower <= upper) { const middle = Math.floor((lower + upper) / 2); if (measured[middle].top <= cursor) { activeIndex = middle; lower = middle + 1; } else { upper = middle - 1; } } return measured[activeIndex].id; } function groupedPromptLabel(count: number, latestLabel: string): string { return `${count} prompts, latest: ${latestLabel}`; } function markerWidth(hoverDistance: number | null): number { if (hoverDistance === null) return MARKER_BASE_WIDTH_PX; return HOVER_MARKER_WIDTHS_PX[hoverDistance] ?? MARKER_BASE_WIDTH_PX; } function markerHeight(hoverDistance: number | null): number { return hoverDistance === 0 ? 3 : 2; } function railMarkerTone(hoverDistance: number | null, active: boolean): string { if (hoverDistance === 0) { return "bg-[#222222] opacity-100 dark:bg-white"; } if (hoverDistance !== null && hoverDistance < HOVER_MARKER_WIDTHS_PX.length) { return "bg-[#d0d0d0] opacity-100 dark:bg-white/35"; } if (active) { return "bg-[#6f6f6f] opacity-100 dark:bg-white/55"; } return "bg-[#d8d8d8] opacity-100 dark:bg-white/25"; } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); }