feat(webui): preview prompt rail entries

This commit is contained in:
Xubin Ren
2026-06-06 00:19:31 +08:00
parent 08438ab5ed
commit 4111f70558
2 changed files with 54 additions and 13 deletions
+49 -11
View File
@@ -19,6 +19,7 @@ interface PromptRailProps {
interface PromptAnchor {
id: string;
label: string;
preview: string;
}
interface MeasuredPrompt extends PromptAnchor {
@@ -30,6 +31,7 @@ interface PromptMarker {
count: number;
ids: string[];
label: string;
preview: string;
topPercent: number;
}
@@ -165,26 +167,46 @@ export function PromptRail({
<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(
"absolute right-0 h-[3px] -translate-y-1/2 rounded-full",
"bg-foreground/20 transition-[background-color,opacity,transform,width] duration-200",
"hover:bg-blue-500/70 hover:opacity-100 hover:scale-x-110",
"focus-visible:bg-blue-500 focus-visible:opacity-100 focus-visible:scale-x-110",
"group/marker absolute right-0 h-5 -translate-y-1/2 overflow-visible rounded-full",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60",
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={{
top: `${marker.topPercent}%`,
width: markerWidth(marker.count, maxMarkerCount, active),
}}
/>
>
<span
aria-hidden
className={cn(
"absolute right-0 top-1/2 h-[3px] w-full -translate-y-1/2 rounded-full",
"bg-foreground/20 transition-[background-color,opacity,transform,height] duration-200",
"group-hover/marker:bg-blue-500/70 group-hover/marker:opacity-100 group-hover/marker:scale-x-110",
"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",
)}
/>
<span
aria-hidden
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",
"bg-background/95 text-xs leading-5 text-foreground shadow-lg ring-1 ring-border/80 backdrop-blur",
"opacity-0 translate-x-1 transition-[opacity,transform] duration-150",
"group-hover/marker:opacity-100 group-hover/marker:translate-x-0",
"group-focus-visible/marker:opacity-100 group-focus-visible/marker:translate-x-0",
)}
>
<span className="block max-h-24 overflow-hidden whitespace-pre-wrap break-words">
{marker.preview}
</span>
</span>
</button>
);
})}
</div>
@@ -197,6 +219,7 @@ function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
.map((message, index) => ({
id: message.id,
label: promptLabel(message.content, index),
preview: promptPreview(message.content, index),
}));
}
@@ -206,6 +229,12 @@ function promptLabel(content: string, index: number): string {
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
}
function promptPreview(content: string, index: number): string {
const text = content.replace(/\n{3,}/g, "\n\n").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 320 ? `${text.slice(0, 317)}...` : text;
}
function measurePrompts(
scrollEl: HTMLElement,
anchors: PromptAnchor[],
@@ -243,12 +272,14 @@ function groupPromptMarkers(
last.count += 1;
last.ids.push(prompt.id);
last.label = groupedPromptLabel(last.count, prompt.label);
last.preview = groupedPromptPreview(last.count, prompt.preview);
continue;
}
groups.push({
count: 1,
ids: [prompt.id],
label: prompt.label,
preview: prompt.preview,
topPercent: prompt.topPercent,
});
}
@@ -289,6 +320,9 @@ function bucketPromptMarkers(
label: bucket.length === 1
? latest.label
: groupedPromptLabel(bucket.length, latest.label),
preview: bucket.length === 1
? latest.preview
: groupedPromptPreview(bucket.length, latest.preview),
topPercent,
}];
});
@@ -315,6 +349,10 @@ function groupedPromptLabel(count: number, latestLabel: string): string {
return `${count} prompts, latest: ${latestLabel}`;
}
function groupedPromptPreview(count: number, latestPreview: string): string {
return `${count} prompts\n\n${latestPreview}`;
}
function markerWidth(count: number, maxCount: number, active: boolean): number {
if (maxCount <= 1) return active ? 34 : MARKER_BASE_WIDTH_PX;
const density = Math.log2(count + 1) / Math.log2(maxCount + 1);
+5 -2
View File
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
@@ -207,7 +207,10 @@ describe("ThreadViewport", () => {
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
fireEvent.click(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();
fireEvent.click(targetPrompt);
expect(scrollTo).toHaveBeenCalledWith({
top: 1064,