mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 15:19:16 +03:00
feat(webui): preview prompt rail entries
This commit is contained in:
@@ -19,6 +19,7 @@ interface PromptRailProps {
|
|||||||
interface PromptAnchor {
|
interface PromptAnchor {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
preview: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeasuredPrompt extends PromptAnchor {
|
interface MeasuredPrompt extends PromptAnchor {
|
||||||
@@ -30,6 +31,7 @@ interface PromptMarker {
|
|||||||
count: number;
|
count: number;
|
||||||
ids: string[];
|
ids: string[];
|
||||||
label: string;
|
label: string;
|
||||||
|
preview: string;
|
||||||
topPercent: number;
|
topPercent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,26 +167,46 @@ export function PromptRail({
|
|||||||
<button
|
<button
|
||||||
key={marker.ids.join("|")}
|
key={marker.ids.join("|")}
|
||||||
type="button"
|
type="button"
|
||||||
title={marker.label}
|
|
||||||
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])}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute right-0 h-[3px] -translate-y-1/2 rounded-full",
|
"group/marker absolute right-0 h-5 -translate-y-1/2 overflow-visible 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",
|
|
||||||
"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={{
|
||||||
|
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",
|
marker.count > 1 && "bg-foreground/30",
|
||||||
active && "h-1 bg-foreground/65 opacity-80 shadow-sm",
|
active && "h-1 bg-foreground/65 opacity-80 shadow-sm",
|
||||||
!active && nearActive && "opacity-25 group-hover:opacity-55",
|
!active && nearActive && "opacity-25 group-hover:opacity-55",
|
||||||
!active && !nearActive && !revealed && "opacity-0 group-hover:opacity-40",
|
!active && !nearActive && !revealed && "opacity-0 group-hover:opacity-40",
|
||||||
!active && !nearActive && revealed && "opacity-35",
|
!active && !nearActive && revealed && "opacity-35",
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
top: `${marker.topPercent}%`,
|
|
||||||
width: markerWidth(marker.count, maxMarkerCount, active),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
@@ -197,6 +219,7 @@ function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
|
|||||||
.map((message, index) => ({
|
.map((message, index) => ({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
label: promptLabel(message.content, index),
|
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;
|
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(
|
function measurePrompts(
|
||||||
scrollEl: HTMLElement,
|
scrollEl: HTMLElement,
|
||||||
anchors: PromptAnchor[],
|
anchors: PromptAnchor[],
|
||||||
@@ -243,12 +272,14 @@ 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);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
groups.push({
|
groups.push({
|
||||||
count: 1,
|
count: 1,
|
||||||
ids: [prompt.id],
|
ids: [prompt.id],
|
||||||
label: prompt.label,
|
label: prompt.label,
|
||||||
|
preview: prompt.preview,
|
||||||
topPercent: prompt.topPercent,
|
topPercent: prompt.topPercent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -289,6 +320,9 @@ 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
|
||||||
|
? latest.preview
|
||||||
|
: groupedPromptPreview(bucket.length, latest.preview),
|
||||||
topPercent,
|
topPercent,
|
||||||
}];
|
}];
|
||||||
});
|
});
|
||||||
@@ -315,6 +349,10 @@ function groupedPromptLabel(count: number, latestLabel: string): string {
|
|||||||
return `${count} prompts, latest: ${latestLabel}`;
|
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 {
|
function markerWidth(count: number, maxCount: number, active: boolean): number {
|
||||||
if (maxCount <= 1) return active ? 34 : MARKER_BASE_WIDTH_PX;
|
if (maxCount <= 1) return active ? 34 : MARKER_BASE_WIDTH_PX;
|
||||||
const density = Math.log2(count + 1) / Math.log2(maxCount + 1);
|
const density = Math.log2(count + 1) / Math.log2(maxCount + 1);
|
||||||
|
|||||||
@@ -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 { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -207,7 +207,10 @@ describe("ThreadViewport", () => {
|
|||||||
|
|
||||||
expect(screen.getByLabelText("User prompt navigation")).toBeInTheDocument();
|
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({
|
expect(scrollTo).toHaveBeenCalledWith({
|
||||||
top: 1064,
|
top: 1064,
|
||||||
|
|||||||
Reference in New Issue
Block a user