feat(webui): add prompt navigator drawer

This commit is contained in:
Xubin Ren
2026-06-06 00:19:31 +08:00
parent 4111f70558
commit 860b672e5c
14 changed files with 427 additions and 64 deletions
@@ -0,0 +1,163 @@
import { useMemo, useState } from "react";
import { ListTree, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import {
type PromptAnchor,
userPromptAnchors,
} from "@/components/thread/promptNavigation";
import { fmtDateTime } from "@/lib/format";
import type { UIMessage } from "@/lib/types";
import { cn } from "@/lib/utils";
interface PromptNavigatorProps {
bottomOffset: number;
messages: UIMessage[];
onJumpToPrompt: (promptId: string) => void;
}
export function PromptNavigator({
bottomOffset,
messages,
onJumpToPrompt,
}: PromptNavigatorProps) {
const { i18n, t } = useTranslation();
const prompts = useMemo(() => userPromptAnchors(messages), [messages]);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filteredPrompts = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
if (!needle) return prompts;
return prompts.filter((prompt) =>
`${prompt.label}\n${prompt.preview}`.toLocaleLowerCase().includes(needle),
);
}, [prompts, query]);
if (prompts.length === 0) return null;
const jump = (promptId: string) => {
setOpen(false);
onJumpToPrompt(promptId);
};
return (
<>
<div
className="pointer-events-none absolute right-4 z-20"
style={{ bottom: Math.max(104, bottomOffset + 12) }}
>
<Button
type="button"
variant="outline"
size="icon"
className={cn(
"pointer-events-auto h-8 w-8 rounded-full border-border/70 bg-background/90 shadow-md backdrop-blur",
"text-muted-foreground hover:text-foreground",
)}
aria-label={t("thread.promptNavigator.open")}
onClick={() => setOpen(true)}
>
<ListTree className="h-4 w-4" />
</Button>
</div>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
side="right"
className="w-[min(92vw,24rem)] gap-0 p-0 sm:max-w-[24rem]"
>
<div className="border-b px-5 pb-4 pt-5">
<SheetTitle className="text-base font-medium">
{t("thread.promptNavigator.title")}
</SheetTitle>
<SheetDescription className="mt-1 text-xs">
{t("thread.promptNavigator.description", { count: prompts.length })}
</SheetDescription>
<div className="relative mt-4">
<Search
aria-hidden
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
aria-label={t("thread.promptNavigator.search")}
placeholder={t("thread.promptNavigator.search")}
className={cn(
"h-10 w-full rounded-full border border-border bg-background pl-9 pr-3 text-sm",
"outline-none transition focus:border-ring focus:ring-2 focus:ring-ring/20",
)}
/>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
{filteredPrompts.length > 0 ? (
<div className="space-y-1">
{filteredPrompts.map((prompt) => (
<PromptNavigatorRow
key={prompt.id}
locale={i18n.resolvedLanguage || i18n.language}
prompt={prompt}
onJump={jump}
/>
))}
</div>
) : (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
{t("thread.promptNavigator.noResults")}
</div>
)}
</div>
</SheetContent>
</Sheet>
</>
);
}
interface PromptNavigatorRowProps {
locale: string;
onJump: (promptId: string) => void;
prompt: PromptAnchor;
}
function PromptNavigatorRow({
locale,
onJump,
prompt,
}: PromptNavigatorRowProps) {
const { t } = useTranslation();
const timestamp = fmtDateTime(prompt.createdAt, locale);
return (
<button
type="button"
className={cn(
"w-full rounded-xl px-3 py-3 text-left transition",
"hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30",
)}
aria-label={t("thread.promptNavigator.jumpTo", { label: prompt.label })}
onClick={() => onJump(prompt.id)}
>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>{t("thread.promptNavigator.promptNumber", { number: prompt.index + 1 })}</span>
{timestamp ? (
<>
<span aria-hidden>·</span>
<span>{timestamp}</span>
</>
) : null}
</div>
<div className="mt-1 max-h-20 overflow-hidden whitespace-pre-wrap break-words text-sm leading-5 text-foreground">
{prompt.preview}
</div>
</button>
);
}
+7 -55
View File
@@ -9,6 +9,13 @@ import {
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
import {
findPromptElement,
jumpToPrompt,
type PromptAnchor,
promptTop,
userPromptAnchors,
} from "@/components/thread/promptNavigation";
interface PromptRailProps {
bottomOffset: number;
@@ -16,12 +23,6 @@ interface PromptRailProps {
scrollRef: RefObject<HTMLDivElement>;
}
interface PromptAnchor {
id: string;
label: string;
preview: string;
}
interface MeasuredPrompt extends PromptAnchor {
top: number;
topPercent: number;
@@ -213,28 +214,6 @@ export function PromptRail({
);
}
function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
return messages
.filter((message) => message.role === "user")
.map((message, index) => ({
id: message.id,
label: promptLabel(message.content, index),
preview: promptPreview(message.content, index),
}));
}
function promptLabel(content: string, index: number): string {
const text = content.replace(/\s+/g, " ").trim();
if (!text) return `Prompt ${index + 1}`;
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[],
@@ -361,33 +340,6 @@ function markerWidth(count: number, maxCount: number, active: boolean): number {
return Math.round(active ? width + 4 : width);
}
function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
if (!scrollEl || !promptId) return;
const target = findPromptElement(scrollEl, promptId);
if (!target) return;
scrollEl.scrollTo({
top: Math.max(0, promptTop(scrollEl, target) - 16),
behavior: "smooth",
});
}
function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
return Array.from(candidates).find(
(candidate) => candidate.dataset.userPromptId === promptId,
) ?? null;
}
function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
if (hasLayoutRect) {
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
}
return target.offsetTop;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
@@ -10,10 +10,15 @@ import {
import { ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { PromptRail } from "@/components/thread/PromptRail";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import {
findPromptElement,
jumpToPrompt,
} from "@/components/thread/promptNavigation";
import { cn } from "@/lib/utils";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
@@ -68,6 +73,7 @@ export function ThreadViewport({
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const pendingPromptJumpRef = useRef<string | null>(null);
const scrollFrameIdsRef = useRef<number[]>([]);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
@@ -141,6 +147,20 @@ export function ThreadViewport({
);
}, [messages.length]);
const jumpToUserPrompt = useCallback((promptId: string) => {
const scrollEl = scrollRef.current;
if (scrollEl && findPromptElement(scrollEl, promptId)) {
jumpToPrompt(scrollEl, promptId);
return;
}
const index = messages.findIndex((message) => message.id === promptId);
if (index < 0) return;
pendingPromptJumpRef.current = promptId;
userReadingHistoryRef.current = true;
setAtBottom(false);
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
}, [messages]);
const measureComposerDock = useCallback(() => {
const el = composerDockRef.current;
if (!el) return;
@@ -182,6 +202,15 @@ export function ThreadViewport({
el.scrollTop = pending.top + delta;
}, [visibleMessages.length]);
useLayoutEffect(() => {
const promptId = pendingPromptJumpRef.current;
const scrollEl = scrollRef.current;
if (!promptId || !scrollEl || !findPromptElement(scrollEl, promptId)) return;
pendingPromptJumpRef.current = null;
const frame = window.requestAnimationFrame(() => jumpToPrompt(scrollEl, promptId));
return () => window.cancelAnimationFrame(frame);
}, [visibleMessages.length]);
useLayoutEffect(() => {
if (!pendingConversationScrollRef.current) return;
if (!conversationKey) {
@@ -301,6 +330,14 @@ export function ThreadViewport({
/>
) : null}
{hasMessages ? (
<PromptNavigator
messages={messages}
onJumpToPrompt={jumpToUserPrompt}
bottomOffset={scrollButtonBottom}
/>
) : null}
{showScrollToBottomButton && !atBottom && (
<Button
variant="outline"
@@ -0,0 +1,64 @@
import type { UIMessage } from "@/lib/types";
export interface PromptAnchor {
id: string;
label: string;
preview: string;
createdAt: number;
index: number;
}
export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
let index = 0;
return messages.flatMap((message) => {
if (message.role !== "user") return [];
const anchor: PromptAnchor = {
id: message.id,
label: promptLabel(message.content, index),
preview: promptPreview(message.content, index),
createdAt: message.createdAt,
index,
};
index += 1;
return [anchor];
});
}
export function promptLabel(content: string, index: number): string {
const text = content.replace(/\s+/g, " ").trim();
if (!text) return `Prompt ${index + 1}`;
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
}
export 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;
}
export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
if (!scrollEl || !promptId) return;
const target = findPromptElement(scrollEl, promptId);
if (!target) return;
scrollEl.scrollTo({
top: Math.max(0, promptTop(scrollEl, target) - 16),
behavior: "smooth",
});
}
export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
return Array.from(candidates).find(
(candidate) => candidate.dataset.userPromptId === promptId,
) ?? null;
}
export function promptTop(scrollEl: HTMLElement, target: HTMLElement): number {
const scrollRect = scrollEl.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const hasLayoutRect = scrollRect.top !== 0 || targetRect.top !== 0;
if (hasLayoutRect) {
return targetRect.top - scrollRect.top + scrollEl.scrollTop;
}
return target.offsetTop;
}
+10 -1
View File
@@ -775,7 +775,16 @@
}
},
"scrollToBottom": "Scroll to bottom",
"loadEarlier": "Load earlier messages"
"loadEarlier": "Load earlier messages",
"promptNavigator": {
"open": "Open prompt navigator",
"title": "Prompts",
"description": "{{count}} user prompts",
"search": "Search prompts",
"noResults": "No matching prompts.",
"jumpTo": "Jump to prompt: {{label}}",
"promptNumber": "Prompt {{number}}"
}
},
"message": {
"streaming": "streaming",
+10 -1
View File
@@ -775,7 +775,16 @@
}
},
"scrollToBottom": "Desplazarse al final",
"loadEarlier": "Cargar mensajes anteriores"
"loadEarlier": "Cargar mensajes anteriores",
"promptNavigator": {
"open": "Abrir navegador de prompts",
"title": "Prompts",
"description": "{{count}} prompts del usuario",
"search": "Buscar prompts",
"noResults": "No hay prompts coincidentes.",
"jumpTo": "Ir al prompt: {{label}}",
"promptNumber": "Prompt {{number}}"
}
},
"message": {
"streaming": "transmitiendo",
+10 -1
View File
@@ -775,7 +775,16 @@
}
},
"scrollToBottom": "Faire défiler vers le bas",
"loadEarlier": "Charger les messages précédents"
"loadEarlier": "Charger les messages précédents",
"promptNavigator": {
"open": "Ouvrir le navigateur de prompts",
"title": "Prompts",
"description": "{{count}} prompts utilisateur",
"search": "Rechercher des prompts",
"noResults": "Aucun prompt correspondant.",
"jumpTo": "Aller au prompt : {{label}}",
"promptNumber": "Prompt {{number}}"
}
},
"message": {
"streaming": "en cours de génération",
+10 -1
View File
@@ -775,7 +775,16 @@
}
},
"scrollToBottom": "Gulir ke bawah",
"loadEarlier": "Muat pesan sebelumnya"
"loadEarlier": "Muat pesan sebelumnya",
"promptNavigator": {
"open": "Buka navigator prompt",
"title": "Prompt",
"description": "{{count}} prompt pengguna",
"search": "Cari prompt",
"noResults": "Tidak ada prompt yang cocok.",
"jumpTo": "Lompat ke prompt: {{label}}",
"promptNumber": "Prompt {{number}}"
}
},
"message": {
"streaming": "sedang mengalir",
+10 -1
View File
@@ -775,7 +775,16 @@
}
},
"scrollToBottom": "一番下へスクロール",
"loadEarlier": "以前のメッセージを読み込む"
"loadEarlier": "以前のメッセージを読み込む",
"promptNavigator": {
"open": "プロンプトナビゲーターを開く",
"title": "プロンプト",
"description": "{{count}} 件のユーザープロンプト",
"search": "プロンプトを検索",
"noResults": "一致するプロンプトがありません。",
"jumpTo": "プロンプトへ移動: {{label}}",
"promptNumber": "プロンプト {{number}}"
}
},
"message": {
"streaming": "生成中",
+10 -1
View File
@@ -775,7 +775,16 @@
}
},
"scrollToBottom": "맨 아래로 스크롤",
"loadEarlier": "이전 메시지 불러오기"
"loadEarlier": "이전 메시지 불러오기",
"promptNavigator": {
"open": "프롬프트 탐색기 열기",
"title": "프롬프트",
"description": "사용자 프롬프트 {{count}}개",
"search": "프롬프트 검색",
"noResults": "일치하는 프롬프트가 없습니다.",
"jumpTo": "프롬프트로 이동: {{label}}",
"promptNumber": "프롬프트 {{number}}"
}
},
"message": {
"streaming": "생성 중",
+10 -1
View File
@@ -775,7 +775,16 @@
}
},
"scrollToBottom": "Cuộn xuống cuối",
"loadEarlier": "Tải tin nhắn trước đó"
"loadEarlier": "Tải tin nhắn trước đó",
"promptNavigator": {
"open": "Mở trình điều hướng prompt",
"title": "Prompt",
"description": "{{count}} prompt của người dùng",
"search": "Tìm prompt",
"noResults": "Không có prompt phù hợp.",
"jumpTo": "Nhảy tới prompt: {{label}}",
"promptNumber": "Prompt {{number}}"
}
},
"message": {
"streaming": "đang truyền",
+10 -1
View File
@@ -775,7 +775,16 @@
}
},
"scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息"
"loadEarlier": "加载更早消息",
"promptNavigator": {
"open": "打开输入导航",
"title": "输入列表",
"description": "{{count}} 条用户输入",
"search": "搜索输入",
"noResults": "没有匹配的输入。",
"jumpTo": "跳转到输入:{{label}}",
"promptNumber": "第 {{number}} 条"
}
},
"message": {
"streaming": "流式输出中",
+10 -1
View File
@@ -775,7 +775,16 @@
}
},
"scrollToBottom": "捲動到底部",
"loadEarlier": "載入更早訊息"
"loadEarlier": "載入更早訊息",
"promptNavigator": {
"open": "開啟輸入導覽",
"title": "輸入列表",
"description": "{{count}} 則使用者輸入",
"search": "搜尋輸入",
"noResults": "沒有符合的輸入。",
"jumpTo": "跳到輸入:{{label}}",
"promptNumber": "第 {{number}} 則"
}
},
"message": {
"streaming": "串流輸出中",
+66
View File
@@ -218,6 +218,72 @@ describe("ThreadViewport", () => {
});
});
it("opens a prompt navigator list and jumps to a selected prompt", async () => {
const promptMessages = makeLongMessages(5);
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: 1800 },
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]"),
);
promptEls.forEach((el, index) => {
Object.defineProperty(el, "offsetTop", {
configurable: true,
value: index * 360,
});
});
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText("Prompts")).toBeInTheDocument();
expect(within(dialog).getByText("message 4")).toBeInTheDocument();
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search prompts" }), {
target: { value: "message 4" },
});
expect(within(dialog).queryByText("message 1")).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 4" }));
expect(scrollTo).toHaveBeenCalledWith({
top: 1424,
behavior: "smooth",
});
});
it("expands the history window before jumping to an older prompt from the navigator", async () => {
const longMessages = makeLongMessages(300);
render(
<ThreadViewport
messages={longMessages}
isStreaming={false}
composer={<div />}
/>,
);
expect(screen.queryByText("message 20")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 20" }));
await waitFor(() => expect(screen.getByText("message 20")).toBeInTheDocument());
});
it("renders the prompt rail for compact scroll ranges", async () => {
const promptMessages = makeLongMessages(3);
const { container } = render(