feat(webui): refine prompt rail minimap
This commit is contained in:
@@ -1,11 +1,4 @@
|
|||||||
import {
|
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
type RefObject,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
@@ -29,6 +22,7 @@ interface MeasuredPrompt extends PromptAnchor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PromptMarker {
|
interface PromptMarker {
|
||||||
|
answerPreview: string;
|
||||||
count: number;
|
count: number;
|
||||||
ids: string[];
|
ids: string[];
|
||||||
label: string;
|
label: string;
|
||||||
@@ -43,10 +37,11 @@ const DENSE_BUCKET_HEIGHT_PX = 12;
|
|||||||
const DENSE_BUCKET_FALLBACK_COUNT = 32;
|
const DENSE_BUCKET_FALLBACK_COUNT = 32;
|
||||||
const DENSE_BUCKET_MAX_COUNT = 42;
|
const DENSE_BUCKET_MAX_COUNT = 42;
|
||||||
const MARKER_MIN_GAP_PX = 9;
|
const MARKER_MIN_GAP_PX = 9;
|
||||||
const MARKER_BASE_WIDTH_PX = 16;
|
const MARKER_BASE_WIDTH_PX = 9;
|
||||||
const MARKER_MAX_WIDTH_PX = 28;
|
const MARKER_STACK_GAP_PX = 16;
|
||||||
|
const RAIL_FALLBACK_HEIGHT_PX = 300;
|
||||||
const MEASURE_RETRY_FRAMES = 4;
|
const MEASURE_RETRY_FRAMES = 4;
|
||||||
const RAIL_REVEAL_MS = 1400;
|
const HOVER_MARKER_WIDTHS_PX = [28, 22, 16, 11];
|
||||||
|
|
||||||
export function PromptRail({
|
export function PromptRail({
|
||||||
bottomOffset,
|
bottomOffset,
|
||||||
@@ -57,22 +52,12 @@ export function PromptRail({
|
|||||||
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
|
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
|
||||||
const [markers, setMarkers] = useState<PromptMarker[]>([]);
|
const [markers, setMarkers] = useState<PromptMarker[]>([]);
|
||||||
const [activePromptId, setActivePromptId] = useState<string | null>(null);
|
const [activePromptId, setActivePromptId] = useState<string | null>(null);
|
||||||
const [revealed, setRevealed] = useState(false);
|
const [focusedMarkerIndex, setFocusedMarkerIndex] = useState<number | null>(null);
|
||||||
const revealTimeoutRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const revealTemporarily = useCallback(() => {
|
|
||||||
setRevealed(true);
|
|
||||||
if (revealTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(revealTimeoutRef.current);
|
|
||||||
}
|
|
||||||
revealTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
setRevealed(false);
|
|
||||||
revealTimeoutRef.current = null;
|
|
||||||
}, RAIL_REVEAL_MS);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateMarkers = useCallback(() => {
|
const updateMarkers = useCallback(() => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
|
const nextRailHeight = railRef.current?.clientHeight ?? 0;
|
||||||
|
|
||||||
if (!scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) {
|
if (!scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) {
|
||||||
setMarkers([]);
|
setMarkers([]);
|
||||||
setActivePromptId(null);
|
setActivePromptId(null);
|
||||||
@@ -87,7 +72,8 @@ export function PromptRail({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const measured = measurePrompts(scrollEl, promptAnchors, scrollRange);
|
const measured = measurePrompts(scrollEl, promptAnchors, scrollRange);
|
||||||
setMarkers(groupPromptMarkers(measured, railRef.current?.clientHeight ?? 0));
|
const grouped = groupPromptMarkers(measured, nextRailHeight);
|
||||||
|
setMarkers(distributeMarkerPositions(grouped, nextRailHeight));
|
||||||
setActivePromptId(activePromptForScroll(measured, scrollEl.scrollTop));
|
setActivePromptId(activePromptForScroll(measured, scrollEl.scrollTop));
|
||||||
}, [promptAnchors, scrollRef]);
|
}, [promptAnchors, scrollRef]);
|
||||||
|
|
||||||
@@ -112,7 +98,6 @@ export function PromptRail({
|
|||||||
let frame = 0;
|
let frame = 0;
|
||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
window.cancelAnimationFrame(frame);
|
window.cancelAnimationFrame(frame);
|
||||||
revealTemporarily();
|
|
||||||
frame = window.requestAnimationFrame(updateMarkers);
|
frame = window.requestAnimationFrame(updateMarkers);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,7 +108,7 @@ export function PromptRail({
|
|||||||
scrollEl.removeEventListener("scroll", schedule);
|
scrollEl.removeEventListener("scroll", schedule);
|
||||||
window.removeEventListener("resize", schedule);
|
window.removeEventListener("resize", schedule);
|
||||||
};
|
};
|
||||||
}, [revealTemporarily, scrollRef, updateMarkers]);
|
}, [scrollRef, updateMarkers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollEl = scrollRef.current;
|
const scrollEl = scrollRef.current;
|
||||||
@@ -134,77 +119,72 @@ export function PromptRail({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [scrollRef, updateMarkers]);
|
}, [scrollRef, updateMarkers]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (revealTimeoutRef.current !== null) {
|
|
||||||
window.clearTimeout(revealTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (markers.length === 0) return null;
|
if (markers.length === 0) return null;
|
||||||
|
|
||||||
const maxMarkerCount = Math.max(...markers.map((marker) => marker.count));
|
|
||||||
const activeMarkerIndex = markers.findIndex((marker) =>
|
|
||||||
marker.ids.includes(activePromptId ?? ""),
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={railRef}
|
ref={railRef}
|
||||||
aria-label="User prompt navigation"
|
aria-label="User prompt navigation"
|
||||||
className={cn(
|
className={cn(
|
||||||
"group pointer-events-auto absolute right-4 top-14 z-20 hidden w-8 opacity-70 md:block",
|
"group pointer-events-auto absolute left-7 top-3 z-20 hidden w-9 opacity-100 md:block",
|
||||||
"transition-opacity duration-200 hover:opacity-100",
|
"transition-opacity duration-200",
|
||||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
|
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-200",
|
||||||
)}
|
)}
|
||||||
|
onPointerLeave={() => setFocusedMarkerIndex(null)}
|
||||||
style={{ bottom: Math.max(80, bottomOffset) }}
|
style={{ bottom: Math.max(80, bottomOffset) }}
|
||||||
>
|
>
|
||||||
{markers.map((marker, index) => {
|
{markers.map((marker, index) => {
|
||||||
const active = marker.ids.includes(activePromptId ?? "");
|
const active = marker.ids.includes(activePromptId ?? "");
|
||||||
const nearActive = activeMarkerIndex < 0 || Math.abs(index - activeMarkerIndex) <= 1;
|
const hoverDistance =
|
||||||
|
focusedMarkerIndex === null ? null : Math.abs(index - focusedMarkerIndex);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={marker.ids.join("|")}
|
key={marker.ids.join("|")}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Jump to prompt: ${marker.label}`}
|
aria-label={`Jump to prompt: ${marker.label}`}
|
||||||
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
|
onClick={() => jumpToPrompt(scrollRef.current, marker.ids[marker.ids.length - 1])}
|
||||||
|
onBlur={() => setFocusedMarkerIndex(null)}
|
||||||
|
onFocus={() => setFocusedMarkerIndex(index)}
|
||||||
|
onPointerEnter={() => setFocusedMarkerIndex(index)}
|
||||||
|
onPointerLeave={() => setFocusedMarkerIndex(null)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/marker absolute right-0 h-5 -translate-y-1/2 overflow-visible rounded-full",
|
"group/marker absolute left-0 h-4 w-9 -translate-y-1/2 overflow-visible rounded-sm",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{ top: `${marker.topPercent}%` }}
|
||||||
top: `${marker.topPercent}%`,
|
|
||||||
width: markerWidth(marker.count, maxMarkerCount, active),
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
data-testid="prompt-rail-marker"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-0 top-1/2 h-[3px] w-full -translate-y-1/2 rounded-full",
|
"absolute left-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full",
|
||||||
"bg-foreground/20 transition-[background-color,opacity,transform,height] duration-200",
|
"transition-[width,background-color,opacity,height] duration-150",
|
||||||
"group-hover/marker:bg-blue-500/70 group-hover/marker:opacity-100 group-hover/marker:scale-x-110",
|
railMarkerTone(hoverDistance, active),
|
||||||
"group-focus-visible/marker:bg-blue-500 group-focus-visible/marker:opacity-100 group-focus-visible/marker:scale-x-110",
|
|
||||||
marker.count > 1 && "bg-foreground/30",
|
|
||||||
active && "h-1 bg-foreground/65 opacity-80 shadow-sm",
|
|
||||||
!active && nearActive && "opacity-25 group-hover:opacity-55",
|
|
||||||
!active && !nearActive && !revealed && "opacity-0 group-hover:opacity-40",
|
|
||||||
!active && !nearActive && revealed && "opacity-35",
|
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
height: markerHeight(hoverDistance),
|
||||||
|
width: markerWidth(hoverDistance),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute right-9 top-1/2 z-30 w-64 -translate-y-1/2 rounded-lg px-3 py-2 text-left",
|
"pointer-events-none absolute left-10 top-1/2 z-30 w-[34rem] max-w-[calc(100vw-4rem)] -translate-y-1/2 rounded-[20px] px-4 py-3 text-left",
|
||||||
"bg-background/95 text-xs leading-5 text-foreground shadow-lg ring-1 ring-border/80 backdrop-blur",
|
"border border-border/70 bg-popover/95 text-popover-foreground shadow-[0_18px_45px_rgba(0,0,0,0.12)] backdrop-blur-xl",
|
||||||
"opacity-0 translate-x-1 transition-[opacity,transform] duration-150",
|
"dark:border-white/10 dark:bg-[#2f2f2f]/95 dark:text-white dark:shadow-[0_18px_45px_rgba(0,0,0,0.45)]",
|
||||||
"group-hover/marker:opacity-100 group-hover/marker:translate-x-0",
|
"-translate-x-2 scale-[0.98] opacity-0 transition-[opacity,transform] duration-150",
|
||||||
"group-focus-visible/marker:opacity-100 group-focus-visible/marker:translate-x-0",
|
"group-hover/marker:translate-x-0 group-hover/marker:scale-100 group-hover/marker:opacity-100",
|
||||||
|
"group-focus-visible/marker:translate-x-0 group-focus-visible/marker:scale-100 group-focus-visible/marker:opacity-100",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="block max-h-24 overflow-hidden whitespace-pre-wrap break-words">
|
<span className="line-clamp-2 whitespace-pre-wrap break-words text-[15px] font-semibold leading-6">
|
||||||
{marker.preview}
|
{marker.preview}
|
||||||
</span>
|
</span>
|
||||||
|
{marker.answerPreview ? (
|
||||||
|
<span className="mt-1.5 line-clamp-3 whitespace-pre-wrap break-words text-[14px] leading-6 text-muted-foreground dark:text-white/55">
|
||||||
|
{marker.answerPreview}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -250,10 +230,12 @@ function groupPromptMarkers(
|
|||||||
last.count += 1;
|
last.count += 1;
|
||||||
last.ids.push(prompt.id);
|
last.ids.push(prompt.id);
|
||||||
last.label = groupedPromptLabel(last.count, prompt.label);
|
last.label = groupedPromptLabel(last.count, prompt.label);
|
||||||
last.preview = groupedPromptPreview(last.count, prompt.preview);
|
last.answerPreview = prompt.answerPreview;
|
||||||
|
last.preview = prompt.preview;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
groups.push({
|
groups.push({
|
||||||
|
answerPreview: prompt.answerPreview,
|
||||||
count: 1,
|
count: 1,
|
||||||
ids: [prompt.id],
|
ids: [prompt.id],
|
||||||
label: prompt.label,
|
label: prompt.label,
|
||||||
@@ -298,14 +280,30 @@ function bucketPromptMarkers(
|
|||||||
label: bucket.length === 1
|
label: bucket.length === 1
|
||||||
? latest.label
|
? latest.label
|
||||||
: groupedPromptLabel(bucket.length, latest.label),
|
: groupedPromptLabel(bucket.length, latest.label),
|
||||||
preview: bucket.length === 1
|
answerPreview: latest.answerPreview,
|
||||||
? latest.preview
|
preview: latest.preview,
|
||||||
: groupedPromptPreview(bucket.length, latest.preview),
|
|
||||||
topPercent,
|
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(
|
function activePromptForScroll(
|
||||||
measured: MeasuredPrompt[],
|
measured: MeasuredPrompt[],
|
||||||
scrollTop: number,
|
scrollTop: number,
|
||||||
@@ -327,16 +325,26 @@ function groupedPromptLabel(count: number, latestLabel: string): string {
|
|||||||
return `${count} prompts, latest: ${latestLabel}`;
|
return `${count} prompts, latest: ${latestLabel}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupedPromptPreview(count: number, latestPreview: string): string {
|
function markerWidth(hoverDistance: number | null): number {
|
||||||
return `${count} prompts\n\n${latestPreview}`;
|
if (hoverDistance === null) return MARKER_BASE_WIDTH_PX;
|
||||||
|
return HOVER_MARKER_WIDTHS_PX[hoverDistance] ?? MARKER_BASE_WIDTH_PX;
|
||||||
}
|
}
|
||||||
|
|
||||||
function markerWidth(count: number, maxCount: number, active: boolean): number {
|
function markerHeight(hoverDistance: number | null): number {
|
||||||
if (maxCount <= 1) return active ? 34 : MARKER_BASE_WIDTH_PX;
|
return hoverDistance === 0 ? 3 : 2;
|
||||||
const density = Math.log2(count + 1) / Math.log2(maxCount + 1);
|
}
|
||||||
const width = MARKER_BASE_WIDTH_PX
|
|
||||||
+ (MARKER_MAX_WIDTH_PX - MARKER_BASE_WIDTH_PX) * density;
|
function railMarkerTone(hoverDistance: number | null, active: boolean): string {
|
||||||
return Math.round(active ? width + 4 : width);
|
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 {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
export interface PromptAnchor {
|
export interface PromptAnchor {
|
||||||
|
answerPreview: string;
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
preview: string;
|
preview: string;
|
||||||
@@ -10,9 +11,10 @@ export interface PromptAnchor {
|
|||||||
|
|
||||||
export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
|
export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
return messages.flatMap((message) => {
|
return messages.flatMap((message, messageIndex) => {
|
||||||
if (message.role !== "user") return [];
|
if (message.role !== "user") return [];
|
||||||
const anchor: PromptAnchor = {
|
const anchor: PromptAnchor = {
|
||||||
|
answerPreview: nextAssistantPreview(messages, messageIndex),
|
||||||
id: message.id,
|
id: message.id,
|
||||||
label: promptLabel(message.content, index),
|
label: promptLabel(message.content, index),
|
||||||
preview: promptPreview(message.content, index),
|
preview: promptPreview(message.content, index),
|
||||||
@@ -27,13 +29,34 @@ export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
|
|||||||
export function promptLabel(content: string, index: number): string {
|
export function promptLabel(content: string, index: number): string {
|
||||||
const text = content.replace(/\s+/g, " ").trim();
|
const text = content.replace(/\s+/g, " ").trim();
|
||||||
if (!text) return `Prompt ${index + 1}`;
|
if (!text) return `Prompt ${index + 1}`;
|
||||||
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
|
return truncatePreview(text, 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function promptPreview(content: string, index: number): string {
|
export function promptPreview(content: string, index: number): string {
|
||||||
const text = content.replace(/\n{3,}/g, "\n\n").trim();
|
const text = compactPreview(content);
|
||||||
if (!text) return `Prompt ${index + 1}`;
|
if (!text) return `Prompt ${index + 1}`;
|
||||||
return text.length > 320 ? `${text.slice(0, 317)}...` : text;
|
return truncatePreview(text, 320);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAssistantPreview(messages: UIMessage[], promptIndex: number): string {
|
||||||
|
for (let index = promptIndex + 1; index < messages.length; index += 1) {
|
||||||
|
const message = messages[index];
|
||||||
|
if (message.role === "user") return "";
|
||||||
|
if (message.role !== "assistant") continue;
|
||||||
|
|
||||||
|
const preview = truncatePreview(compactPreview(message.content), 240);
|
||||||
|
if (preview) return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactPreview(content: string): string {
|
||||||
|
return content.replace(/\n{3,}/g, "\n\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncatePreview(text: string, maxLength: number): string {
|
||||||
|
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
|
export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
|
||||||
|
|||||||
@@ -91,6 +91,23 @@ function makeLongMessages(count: number): UIMessage[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makePromptExchangeMessages(count: number): UIMessage[] {
|
||||||
|
return Array.from({ length: count }, (_, index) => ([
|
||||||
|
{
|
||||||
|
id: `m${index}`,
|
||||||
|
role: "user" as const,
|
||||||
|
content: `message ${index}`,
|
||||||
|
createdAt: index * 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: `a${index}`,
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: `answer ${index}`,
|
||||||
|
createdAt: index * 2 + 1,
|
||||||
|
},
|
||||||
|
])).flat();
|
||||||
|
}
|
||||||
|
|
||||||
function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
|
function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
|
||||||
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
||||||
return (
|
return (
|
||||||
@@ -604,7 +621,7 @@ describe("ThreadViewport", () => {
|
|||||||
screen.queryByText(`message ${firstVisible - 1}`),
|
screen.queryByText(`message ${firstVisible - 1}`),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(screen.getByText(`message ${firstVisible}`)).toBeInTheDocument();
|
expect(screen.getByText(`message ${firstVisible}`)).toBeInTheDocument();
|
||||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
expect(screen.getAllByText("message 299").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("automatically requests older transcript pages near the top", () => {
|
it("automatically requests older transcript pages near the top", () => {
|
||||||
@@ -635,7 +652,7 @@ describe("ThreadViewport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders a prompt rail that jumps to user messages", async () => {
|
it("renders a prompt rail that jumps to user messages", async () => {
|
||||||
const promptMessages = makeLongMessages(5);
|
const promptMessages = makePromptExchangeMessages(5);
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ThreadViewport
|
<ThreadViewport
|
||||||
messages={promptMessages}
|
messages={promptMessages}
|
||||||
@@ -670,9 +687,31 @@ describe("ThreadViewport", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
|
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
|
||||||
|
const promptMarkers = screen.getAllByRole("button", { name: /Jump to prompt:/ });
|
||||||
|
const markerTops = promptMarkers.map((marker) => Number.parseFloat(marker.style.top));
|
||||||
|
expect(markerTops[2]).toBeCloseTo(50);
|
||||||
|
expect(markerTops[1] - markerTops[0]).toBeCloseTo(16 / 3);
|
||||||
|
expect(markerTops[4] - markerTops[0]).toBeCloseTo(64 / 3);
|
||||||
|
|
||||||
|
const railMarkers = screen.getAllByTestId("prompt-rail-marker");
|
||||||
|
expect(railMarkers).toHaveLength(promptMarkers.length);
|
||||||
|
expect(railMarkers.every((marker) => marker.style.width === "9px")).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.pointerEnter(promptMarkers[2]);
|
||||||
|
expect(railMarkers.map((marker) => marker.style.width)).toEqual([
|
||||||
|
"16px",
|
||||||
|
"22px",
|
||||||
|
"28px",
|
||||||
|
"22px",
|
||||||
|
"16px",
|
||||||
|
]);
|
||||||
|
|
||||||
|
fireEvent.pointerLeave(promptMarkers[2]);
|
||||||
|
expect(railMarkers.every((marker) => marker.style.width === "9px")).toBe(true);
|
||||||
|
|
||||||
const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" });
|
const targetPrompt = screen.getByRole("button", { name: "Jump to prompt: message 3" });
|
||||||
expect(within(targetPrompt).getByText("message 3")).toBeInTheDocument();
|
expect(within(targetPrompt).getByText("message 3")).toBeInTheDocument();
|
||||||
|
expect(within(targetPrompt).getByText("answer 3")).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(targetPrompt);
|
fireEvent.click(targetPrompt);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user