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 && (