From 4111f70558cecce0af5c0d82533cbf4cc8e28c60 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Fri, 5 Jun 2026 18:56:05 +0800 Subject: [PATCH] feat(webui): preview prompt rail entries --- webui/src/components/thread/PromptRail.tsx | 60 ++++++++++++++++++---- webui/src/tests/thread-viewport.test.tsx | 7 ++- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/webui/src/components/thread/PromptRail.tsx b/webui/src/components/thread/PromptRail.tsx index e04f9f0dc..3b375e383 100644 --- a/webui/src/components/thread/PromptRail.tsx +++ b/webui/src/components/thread/PromptRail.tsx @@ -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({ ); })} @@ -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); diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index 8f89dd17e..9f07e9a59 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -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,