style(webui): align prompt navigator with header actions

This commit is contained in:
Xubin Ren
2026-06-06 00:19:31 +08:00
parent 8c9a5259a7
commit 855a0c3f07
5 changed files with 61 additions and 43 deletions
+12 -16
View File
@@ -47,23 +47,19 @@ export function PromptNavigator({
return ( return (
<> <>
<div <Button
className="pointer-events-none absolute right-3 top-2 z-20" type="button"
variant="ghost"
size="icon"
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/80",
"hover:bg-accent/40 hover:text-foreground",
)}
aria-label={t("thread.promptNavigator.open")}
onClick={() => setOpen(true)}
> >
<Button <ListTree className="h-4 w-4" />
type="button" </Button>
variant="ghost"
size="icon"
className={cn(
"pointer-events-auto h-8 w-8 rounded-full text-muted-foreground/80",
"hover:bg-accent/40 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}> <Sheet open={open} onOpenChange={setOpen}>
<SheetContent <SheetContent
@@ -14,6 +14,7 @@ interface ThreadHeaderProps {
hostChromeTitleInset?: boolean; hostChromeTitleInset?: boolean;
hideThemeButton?: boolean; hideThemeButton?: boolean;
minimal?: boolean; minimal?: boolean;
promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode; sessionInfoAction?: ReactNode;
} }
@@ -26,6 +27,7 @@ export function ThreadHeader({
hostChromeTitleInset = false, hostChromeTitleInset = false,
hideThemeButton = false, hideThemeButton = false,
minimal = false, minimal = false,
promptNavigatorAction,
sessionInfoAction, sessionInfoAction,
}: ThreadHeaderProps) { }: ThreadHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -60,6 +62,7 @@ export function ThreadHeader({
<div className="ml-auto flex shrink-0 items-center gap-1"> <div className="ml-auto flex shrink-0 items-center gap-1">
{sessionInfoAction} {sessionInfoAction}
{promptNavigatorAction}
{!hideThemeButton ? ( {!hideThemeButton ? (
<ThemeButton <ThemeButton
theme={theme} theme={theme}
+11 -1
View File
@@ -3,11 +3,12 @@ import type { PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FilePreviewPanel } from "@/components/FilePreviewPanel"; import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover"; import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { ThreadComposer } from "@/components/thread/ThreadComposer"; import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader"; import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport"; import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream"; import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions"; import { useSessionHistory } from "@/hooks/useSessions";
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api"; import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
@@ -211,6 +212,7 @@ export function ThreadShell({
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH); const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null); const filePreviewCloseTimerRef = useRef<number | null>(null);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null); const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const viewportRef = useRef<ThreadViewportHandle | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map()); const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */ /** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null); const prevChatIdForCacheRef = useRef<string | null>(null);
@@ -717,6 +719,12 @@ export function ThreadShell({
const sessionInfoAction = historyKey ? ( const sessionInfoAction = historyKey ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} /> <SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined; ) : undefined;
const promptNavigatorAction = historyKey ? (
<PromptNavigator
messages={displayMessages}
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
/>
) : undefined;
return ( return (
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden"> <section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
@@ -731,10 +739,12 @@ export function ThreadShell({
hostChromeTitleInset={hostChromeTitleInset} hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton} hideThemeButton={hideThemeButton}
minimal={!session && !loading} minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction} sessionInfoAction={sessionInfoAction}
/> />
) : null} ) : null}
<ThreadViewport <ThreadViewport
ref={viewportRef}
messages={displayMessages} messages={displayMessages}
isStreaming={isStreaming} isStreaming={isStreaming}
emptyState={emptyState} emptyState={emptyState}
+11 -11
View File
@@ -1,7 +1,9 @@
import { import {
forwardRef,
type ReactNode, type ReactNode,
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
@@ -10,7 +12,6 @@ import {
import { ArrowDown } from "lucide-react"; import { ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { PromptRail } from "@/components/thread/PromptRail"; import { PromptRail } from "@/components/thread/PromptRail";
import { ThreadMessages } from "@/components/thread/ThreadMessages"; import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster"; import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
@@ -22,6 +23,10 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types"; import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void;
}
interface ThreadViewportProps { interface ThreadViewportProps {
messages: UIMessage[]; messages: UIMessage[];
isStreaming: boolean; isStreaming: boolean;
@@ -54,7 +59,7 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
return messages.slice(start); return messages.slice(start);
} }
export function ThreadViewport({ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages, messages,
isStreaming, isStreaming,
composer, composer,
@@ -65,7 +70,7 @@ export function ThreadViewport({
cliApps = [], cliApps = [],
mcpPresets = [], mcpPresets = [],
onOpenFilePreview, onOpenFilePreview,
}: ThreadViewportProps) { }, ref) {
const { t } = useTranslation(); const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
@@ -161,6 +166,8 @@ export function ThreadViewport({
setVisibleMessageCount((count) => Math.max(count, messages.length - index)); setVisibleMessageCount((count) => Math.max(count, messages.length - index));
}, [messages]); }, [messages]);
useImperativeHandle(ref, () => ({ jumpToUserPrompt }), [jumpToUserPrompt]);
const measureComposerDock = useCallback(() => { const measureComposerDock = useCallback(() => {
const el = composerDockRef.current; const el = composerDockRef.current;
if (!el) return; if (!el) return;
@@ -330,13 +337,6 @@ export function ThreadViewport({
/> />
) : null} ) : null}
{hasMessages ? (
<PromptNavigator
messages={messages}
onJumpToPrompt={jumpToUserPrompt}
/>
) : null}
{showScrollToBottomButton && !atBottom && ( {showScrollToBottomButton && !atBottom && (
<Button <Button
variant="outline" variant="outline"
@@ -356,4 +356,4 @@ export function ThreadViewport({
)} )}
</div> </div>
); );
} });
+24 -15
View File
@@ -1,10 +1,13 @@
import { useRef } from "react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { import {
HISTORY_WINDOW_INCREMENT, HISTORY_WINDOW_INCREMENT,
INITIAL_HISTORY_WINDOW, INITIAL_HISTORY_WINDOW,
ThreadViewport, ThreadViewport,
type ThreadViewportHandle,
windowMessages, windowMessages,
} from "@/components/thread/ThreadViewport"; } from "@/components/thread/ThreadViewport";
import type { UIMessage } from "@/lib/types"; import type { UIMessage } from "@/lib/types";
@@ -35,6 +38,24 @@ function makeLongMessages(count: number): UIMessage[] {
})); }));
} }
function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
const viewportRef = useRef<ThreadViewportHandle | null>(null);
return (
<div>
<PromptNavigator
messages={messages}
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
/>
<ThreadViewport
ref={viewportRef}
messages={messages}
isStreaming={false}
composer={<div />}
/>
</div>
);
}
describe("ThreadViewport", () => { describe("ThreadViewport", () => {
it("keeps the scroll-to-bottom button above a growing composer", () => { it("keeps the scroll-to-bottom button above a growing composer", () => {
const originalResizeObserver = globalThis.ResizeObserver; const originalResizeObserver = globalThis.ResizeObserver;
@@ -220,15 +241,9 @@ describe("ThreadViewport", () => {
it("opens a prompt navigator list and jumps to a selected prompt", async () => { it("opens a prompt navigator list and jumps to a selected prompt", async () => {
const promptMessages = makeLongMessages(5); const promptMessages = makeLongMessages(5);
const { container } = render( const { container } = render(<ViewportWithPromptNavigator messages={promptMessages} />);
<ThreadViewport
messages={promptMessages}
isStreaming={false}
composer={<div />}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement;
const scrollTo = vi.fn(); const scrollTo = vi.fn();
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 1800 }, scrollHeight: { configurable: true, value: 1800 },
@@ -267,13 +282,7 @@ describe("ThreadViewport", () => {
it("expands the history window before jumping to an older prompt from the navigator", async () => { it("expands the history window before jumping to an older prompt from the navigator", async () => {
const longMessages = makeLongMessages(300); const longMessages = makeLongMessages(300);
render( render(<ViewportWithPromptNavigator messages={longMessages} />);
<ThreadViewport
messages={longMessages}
isStreaming={false}
composer={<div />}
/>,
);
expect(screen.queryByText("message 20")).not.toBeInTheDocument(); expect(screen.queryByText("message 20")).not.toBeInTheDocument();