mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
feat(webui): bucket dense prompt rails
This commit is contained in:
parent
1af2bc513f
commit
fd61203be4
@ -27,6 +27,7 @@ interface MeasuredPrompt extends PromptAnchor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PromptMarker {
|
interface PromptMarker {
|
||||||
|
count: number;
|
||||||
ids: string[];
|
ids: string[];
|
||||||
label: string;
|
label: string;
|
||||||
topPercent: number;
|
topPercent: number;
|
||||||
@ -34,7 +35,13 @@ interface PromptMarker {
|
|||||||
|
|
||||||
const MIN_PROMPTS_FOR_RAIL = 3;
|
const MIN_PROMPTS_FOR_RAIL = 3;
|
||||||
const RAIL_MIN_SCROLL_RANGE_PX = 240;
|
const RAIL_MIN_SCROLL_RANGE_PX = 240;
|
||||||
|
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_MIN_GAP_PX = 9;
|
||||||
|
const MARKER_BASE_WIDTH_PX = 26;
|
||||||
|
const MARKER_MAX_WIDTH_PX = 42;
|
||||||
|
|
||||||
export function PromptRail({
|
export function PromptRail({
|
||||||
bottomOffset,
|
bottomOffset,
|
||||||
@ -100,12 +107,14 @@ export function PromptRail({
|
|||||||
|
|
||||||
if (markers.length === 0) return null;
|
if (markers.length === 0) return null;
|
||||||
|
|
||||||
|
const maxMarkerCount = Math.max(...markers.map((marker) => marker.count));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={railRef}
|
ref={railRef}
|
||||||
aria-label="User prompt navigation"
|
aria-label="User prompt navigation"
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none absolute right-2 top-12 z-20 hidden w-10 md:block",
|
"pointer-events-none absolute right-6 top-12 z-20 hidden w-12 md:block",
|
||||||
"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",
|
||||||
)}
|
)}
|
||||||
style={{ bottom: Math.max(80, bottomOffset) }}
|
style={{ bottom: Math.max(80, bottomOffset) }}
|
||||||
@ -120,13 +129,17 @@ export function PromptRail({
|
|||||||
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(
|
||||||
"pointer-events-auto absolute right-1 h-1.5 w-7 -translate-y-1/2 rounded-full",
|
"pointer-events-auto absolute right-0 h-1.5 -translate-y-1/2 rounded-full",
|
||||||
"bg-muted-foreground/30 transition-all duration-150",
|
"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",
|
"hover:bg-blue-500/80 focus-visible:bg-blue-500",
|
||||||
"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",
|
||||||
active && "w-9 bg-foreground shadow-sm",
|
marker.count > 1 && "bg-muted-foreground/45",
|
||||||
|
active && "bg-foreground shadow-sm",
|
||||||
)}
|
)}
|
||||||
style={{ top: `${marker.topPercent}%` }}
|
style={{
|
||||||
|
top: `${marker.topPercent}%`,
|
||||||
|
width: markerWidth(marker.count, maxMarkerCount, active),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -171,6 +184,10 @@ function groupPromptMarkers(
|
|||||||
railHeight: number,
|
railHeight: number,
|
||||||
): PromptMarker[] {
|
): PromptMarker[] {
|
||||||
if (measured.length === 0) return [];
|
if (measured.length === 0) return [];
|
||||||
|
if (measured.length >= DENSE_PROMPT_THRESHOLD) {
|
||||||
|
return bucketPromptMarkers(measured, railHeight);
|
||||||
|
}
|
||||||
|
|
||||||
const minGapPercent = railHeight > 0
|
const minGapPercent = railHeight > 0
|
||||||
? (MARKER_MIN_GAP_PX / railHeight) * 100
|
? (MARKER_MIN_GAP_PX / railHeight) * 100
|
||||||
: 2;
|
: 2;
|
||||||
@ -179,11 +196,13 @@ function groupPromptMarkers(
|
|||||||
for (const prompt of measured) {
|
for (const prompt of measured) {
|
||||||
const last = groups[groups.length - 1];
|
const last = groups[groups.length - 1];
|
||||||
if (last && prompt.topPercent - last.topPercent < minGapPercent) {
|
if (last && prompt.topPercent - last.topPercent < minGapPercent) {
|
||||||
|
last.count += 1;
|
||||||
last.ids.push(prompt.id);
|
last.ids.push(prompt.id);
|
||||||
last.label = `${last.ids.length} prompts, latest: ${prompt.label}`;
|
last.label = groupedPromptLabel(last.count, prompt.label);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
groups.push({
|
groups.push({
|
||||||
|
count: 1,
|
||||||
ids: [prompt.id],
|
ids: [prompt.id],
|
||||||
label: prompt.label,
|
label: prompt.label,
|
||||||
topPercent: prompt.topPercent,
|
topPercent: prompt.topPercent,
|
||||||
@ -193,6 +212,44 @@ function groupPromptMarkers(
|
|||||||
return groups;
|
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),
|
||||||
|
topPercent,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function activePromptForScroll(
|
function activePromptForScroll(
|
||||||
measured: MeasuredPrompt[],
|
measured: MeasuredPrompt[],
|
||||||
scrollTop: number,
|
scrollTop: number,
|
||||||
@ -210,6 +267,18 @@ function activePromptForScroll(
|
|||||||
return active.id;
|
return active.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupedPromptLabel(count: number, latestLabel: string): string {
|
||||||
|
return `${count} prompts, latest: ${latestLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const width = MARKER_BASE_WIDTH_PX
|
||||||
|
+ (MARKER_MAX_WIDTH_PX - MARKER_BASE_WIDTH_PX) * density;
|
||||||
|
return Math.round(active ? width + 4 : width);
|
||||||
|
}
|
||||||
|
|
||||||
function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
|
function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
|
||||||
if (!scrollEl || !promptId) return;
|
if (!scrollEl || !promptId) return;
|
||||||
const target = findPromptElement(scrollEl, promptId);
|
const target = findPromptElement(scrollEl, promptId);
|
||||||
|
|||||||
@ -215,6 +215,58 @@ describe("ThreadViewport", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("buckets dense prompt rails without rendering every prompt as a marker", async () => {
|
||||||
|
const promptMessages = makeLongMessages(100);
|
||||||
|
const { container } = render(
|
||||||
|
<ThreadViewport
|
||||||
|
messages={promptMessages}
|
||||||
|
isStreaming={false}
|
||||||
|
composer={<div />}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||||
|
const scrollTo = vi.fn();
|
||||||
|
Object.defineProperties(scroller, {
|
||||||
|
scrollHeight: { configurable: true, value: 10000 },
|
||||||
|
clientHeight: { configurable: true, value: 600 },
|
||||||
|
scrollTop: { configurable: true, value: 0 },
|
||||||
|
scrollTo: { configurable: true, value: scrollTo },
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptEls = Array.from(
|
||||||
|
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
|
||||||
|
);
|
||||||
|
expect(promptEls).toHaveLength(100);
|
||||||
|
promptEls.forEach((el, index) => {
|
||||||
|
Object.defineProperty(el, "offsetTop", {
|
||||||
|
configurable: true,
|
||||||
|
value: index * 90,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptMarkers = screen.getAllByRole("button", { name: /Jump to prompt:/ });
|
||||||
|
expect(promptMarkers.length).toBeGreaterThan(3);
|
||||||
|
expect(promptMarkers.length).toBeLessThan(100);
|
||||||
|
expect(
|
||||||
|
promptMarkers.some((marker) =>
|
||||||
|
marker.getAttribute("aria-label")?.includes("prompts, latest"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.click(promptMarkers[promptMarkers.length - 1]);
|
||||||
|
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith({
|
||||||
|
top: 8894,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("expands the window start to avoid cutting an agent activity cluster", () => {
|
it("expands the window start to avoid cutting an agent activity cluster", () => {
|
||||||
const clustered = makeLongMessages(200);
|
const clustered = makeLongMessages(200);
|
||||||
clustered.splice(
|
clustered.splice(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user