243 lines
6.9 KiB
TypeScript
243 lines
6.9 KiB
TypeScript
import {
|
|||
|
|
type RefObject,
|
||
|
|
useCallback,
|
||
|
|
useEffect,
|
||
|
|
useMemo,
|
||
|
|
useRef,
|
||
|
|
useState,
|
||
|
|
} from "react";
|
||
|
|
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import type { UIMessage } from "@/lib/types";
|
||
|
|
|
||
|
|
interface PromptRailProps {
|
||
|
|
bottomOffset: number;
|
||
|
|
messages: UIMessage[];
|
||
|
|
scrollRef: RefObject<HTMLDivElement>;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface PromptAnchor {
|
||
|
|
id: string;
|
||
|
|
label: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface MeasuredPrompt extends PromptAnchor {
|
||
|
|
top: number;
|
||
|
|
topPercent: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface PromptMarker {
|
||
|
|
ids: string[];
|
||
|
|
label: string;
|
||
|
|
topPercent: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
const MIN_PROMPTS_FOR_RAIL = 3;
|
||
|
|
const RAIL_MIN_SCROLL_RANGE_PX = 240;
|
||
|
|
const MARKER_MIN_GAP_PX = 9;
|
||
|
|
|
||
|
|
export function PromptRail({
|
||
|
|
bottomOffset,
|
||
|
|
messages,
|
||
|
|
scrollRef,
|
||
|
|
}: PromptRailProps) {
|
||
|
|
const railRef = useRef<HTMLDivElement>(null);
|
||
|
|
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
|
||
|
|
const [markers, setMarkers] = useState<PromptMarker[]>([]);
|
||
|
|
const [activePromptId, setActivePromptId] = useState<string | null>(null);
|
||
|
|
|
||
|
|
const updateMarkers = useCallback(() => {
|
||
|
|
const scrollEl = scrollRef.current;
|
||
|
|
if (!scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) {
|
||
|
|
setMarkers([]);
|
||
|
|
setActivePromptId(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const scrollRange = scrollEl.scrollHeight - scrollEl.clientHeight;
|
||
|
|
if (scrollRange < RAIL_MIN_SCROLL_RANGE_PX) {
|
||
|
|
setMarkers([]);
|
||
|
|
setActivePromptId(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const measured = measurePrompts(scrollEl, promptAnchors, scrollRange);
|
||
|
|
setMarkers(groupPromptMarkers(measured, railRef.current?.clientHeight ?? 0));
|
||
|
|
setActivePromptId(activePromptForScroll(measured, scrollEl.scrollTop));
|
||
|
|
}, [promptAnchors, scrollRef]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
updateMarkers();
|
||
|
|
}, [updateMarkers]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const scrollEl = scrollRef.current;
|
||
|
|
if (!scrollEl) return undefined;
|
||
|
|
|
||
|
|
let frame = 0;
|
||
|
|
const schedule = () => {
|
||
|
|
window.cancelAnimationFrame(frame);
|
||
|
|
frame = window.requestAnimationFrame(updateMarkers);
|
||
|
|
};
|
||
|
|
|
||
|
|
scrollEl.addEventListener("scroll", schedule, { passive: true });
|
||
|
|
window.addEventListener("resize", schedule);
|
||
|
|
return () => {
|
||
|
|
window.cancelAnimationFrame(frame);
|
||
|
|
scrollEl.removeEventListener("scroll", schedule);
|
||
|
|
window.removeEventListener("resize", schedule);
|
||
|
|
};
|
||
|
|
}, [scrollRef, 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 (
|
||
|
|
<div
|
||
|
|
ref={railRef}
|
||
|
|
aria-label="User prompt navigation"
|
||
|
|
className={cn(
|
||
|
|
"pointer-events-none absolute right-2 top-12 z-20 hidden w-10 md:block",
|
||
|
|
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
|
||
|
|
)}
|
||
|
|
style={{ bottom: Math.max(80, bottomOffset) }}
|
||
|
|
>
|
||
|
|
{markers.map((marker) => {
|
||
|
|
const active = marker.ids.includes(activePromptId ?? "");
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
key={marker.ids.join("|")}
|
||
|
|
type="button"
|
||
|
|
title={marker.label}
|
||
|
|
aria-label={`Jump to prompt: ${marker.label}`}
|
||
|
|
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
|
||
|
|
className={cn(
|
||
|
|
"pointer-events-auto absolute right-1 h-1.5 w-7 -translate-y-1/2 rounded-full",
|
||
|
|
"bg-muted-foreground/30 transition-all duration-150",
|
||
|
|
"hover:w-9 hover:bg-blue-500/80 focus-visible:w-9 focus-visible:bg-blue-500",
|
||
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
|
||
|
|
active && "w-9 bg-foreground shadow-sm",
|
||
|
|
)}
|
||
|
|
style={{ top: `${marker.topPercent}%` }}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
|
||
|
|
return messages
|
||
|
|
.filter((message) => message.role === "user")
|
||
|
|
.map((message, index) => ({
|
||
|
|
id: message.id,
|
||
|
|
label: promptLabel(message.content, index),
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
function promptLabel(content: string, index: number): string {
|
||
|
|
const text = content.replace(/\s+/g, " ").trim();
|
||
|
|
if (!text) return `Prompt ${index + 1}`;
|
||
|
|
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 [];
|
||
|
|
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.ids.push(prompt.id);
|
||
|
|
last.label = `${last.ids.length} prompts, latest: ${prompt.label}`;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
groups.push({
|
||
|
|
ids: [prompt.id],
|
||
|
|
label: prompt.label,
|
||
|
|
topPercent: prompt.topPercent,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return groups;
|
||
|
|
}
|
||
|
|
|
||
|
|
function activePromptForScroll(
|
||
|
|
measured: MeasuredPrompt[],
|
||
|
|
scrollTop: number,
|
||
|
|
): string | null {
|
||
|
|
if (measured.length === 0) return null;
|
||
|
|
let active = measured[0];
|
||
|
|
const cursor = scrollTop + 96;
|
||
|
|
for (const prompt of measured) {
|
||
|
|
if (prompt.top <= cursor) {
|
||
|
|
active = prompt;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
return active.id;
|
||
|
|
}
|
||
|
|
|
||
|
|
function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
|
||
|
|
if (!scrollEl || !promptId) return;
|
||
|
|
const target = findPromptElement(scrollEl, promptId);
|
||
|
|
if (!target) return;
|
||
|
|
scrollEl.scrollTo({
|
||
|
|
top: Math.max(0, promptTop(scrollEl, target) - 16),
|
||
|
|
behavior: "smooth",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
|
||
|
|
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
|
||
|
|
return Array.from(candidates).find(
|
||
|
|
(candidate) => candidate.dataset.userPromptId === promptId,
|
||
|
|
) ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
|
||
|
|
const scrollRect = scrollEl.getBoundingClientRect();
|
||
|
|
const targetRect = target.getBoundingClientRect();
|
||
|
|
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
|
||
|
|
if (hasLayoutRect) {
|
||
|
|
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
|
||
|
|
}
|
||
|
|
return target.offsetTop;
|
||
|
|
}
|
||
|
|
|
||
|
|
function clamp(value: number, min: number, max: number): number {
|
||
|
|
return Math.max(min, Math.min(max, value));
|
||
|
|
}
|