From 860b672e5cae9ff79b57d22a74e52127faf711f7 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Fri, 5 Jun 2026 20:55:39 +0800
Subject: [PATCH] feat(webui): add prompt navigator drawer
---
.../src/components/thread/PromptNavigator.tsx | 163 ++++++++++++++++++
webui/src/components/thread/PromptRail.tsx | 62 +------
.../src/components/thread/ThreadViewport.tsx | 37 ++++
.../src/components/thread/promptNavigation.ts | 64 +++++++
webui/src/i18n/locales/en/common.json | 11 +-
webui/src/i18n/locales/es/common.json | 11 +-
webui/src/i18n/locales/fr/common.json | 11 +-
webui/src/i18n/locales/id/common.json | 11 +-
webui/src/i18n/locales/ja/common.json | 11 +-
webui/src/i18n/locales/ko/common.json | 11 +-
webui/src/i18n/locales/vi/common.json | 11 +-
webui/src/i18n/locales/zh-CN/common.json | 11 +-
webui/src/i18n/locales/zh-TW/common.json | 11 +-
webui/src/tests/thread-viewport.test.tsx | 66 +++++++
14 files changed, 427 insertions(+), 64 deletions(-)
create mode 100644 webui/src/components/thread/PromptNavigator.tsx
create mode 100644 webui/src/components/thread/promptNavigation.ts
diff --git a/webui/src/components/thread/PromptNavigator.tsx b/webui/src/components/thread/PromptNavigator.tsx
new file mode 100644
index 000000000..692524d9d
--- /dev/null
+++ b/webui/src/components/thread/PromptNavigator.tsx
@@ -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 (
+ <>
+
+
+
+
+
+
+
+
+ {t("thread.promptNavigator.title")}
+
+
+ {t("thread.promptNavigator.description", { count: prompts.length })}
+
+
+
+ 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",
+ )}
+ />
+
+
+
+
+ {filteredPrompts.length > 0 ? (
+
+ {filteredPrompts.map((prompt) => (
+
+ ))}
+
+ ) : (
+
+ {t("thread.promptNavigator.noResults")}
+
+ )}
+
+
+
+ >
+ );
+}
+
+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 (
+
+ );
+}
diff --git a/webui/src/components/thread/PromptRail.tsx b/webui/src/components/thread/PromptRail.tsx
index 3b375e383..516339bbe 100644
--- a/webui/src/components/thread/PromptRail.tsx
+++ b/webui/src/components/thread/PromptRail.tsx
@@ -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;
}
-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("[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));
}
diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx
index 36a261af4..44ea38c4d 100644
--- a/webui/src/components/thread/ThreadViewport.tsx
+++ b/webui/src/components/thread/ThreadViewport.tsx
@@ -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(null);
const lastConversationKeyRef = useRef(conversationKey);
const pendingConversationScrollRef = useRef(true);
+ const pendingPromptJumpRef = useRef(null);
const scrollFrameIdsRef = useRef([]);
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 ? (
+
+ ) : null}
+
{showScrollToBottomButton && !atBottom && (