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 (
<>
<div
className="pointer-events-none absolute right-3 top-2 z-20"
<Button
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
type="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>
<ListTree className="h-4 w-4" />
</Button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
@@ -14,6 +14,7 @@ interface ThreadHeaderProps {
hostChromeTitleInset?: boolean;
hideThemeButton?: boolean;
minimal?: boolean;
promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode;
}
@@ -26,6 +27,7 @@ export function ThreadHeader({
hostChromeTitleInset = false,
hideThemeButton = false,
minimal = false,
promptNavigatorAction,
sessionInfoAction,
}: ThreadHeaderProps) {
const { t } = useTranslation();
@@ -60,6 +62,7 @@ export function ThreadHeader({
<div className="ml-auto flex shrink-0 items-center gap-1">
{sessionInfoAction}
{promptNavigatorAction}
{!hideThemeButton ? (
<ThemeButton
theme={theme}
+11 -1
View File
@@ -3,11 +3,12 @@ import type { PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
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 { useSessionHistory } from "@/hooks/useSessions";
import { fetchCliApps, fetchMcpPresets, fetchSettings, listSlashCommands } from "@/lib/api";
@@ -211,6 +212,7 @@ export function ThreadShell({
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const viewportRef = useRef<ThreadViewportHandle | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
@@ -717,6 +719,12 @@ export function ThreadShell({
const sessionInfoAction = historyKey ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined;
const promptNavigatorAction = historyKey ? (
<PromptNavigator
messages={displayMessages}
onJumpToPrompt={(promptId) => viewportRef.current?.jumpToUserPrompt(promptId)}
/>
) : undefined;
return (
<section ref={shellRef} className="relative flex min-h-0 flex-1 overflow-hidden">
@@ -731,10 +739,12 @@ export function ThreadShell({
hostChromeTitleInset={hostChromeTitleInset}
hideThemeButton={hideThemeButton}
minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction}
/>
) : null}
<ThreadViewport
ref={viewportRef}
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
+11 -11
View File
@@ -1,7 +1,9 @@
import {
forwardRef,
type ReactNode,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
@@ -10,7 +12,6 @@ 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";
@@ -22,6 +23,10 @@ import {
import { cn } from "@/lib/utils";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
export interface ThreadViewportHandle {
jumpToUserPrompt: (promptId: string) => void;
}
interface ThreadViewportProps {
messages: UIMessage[];
isStreaming: boolean;
@@ -54,7 +59,7 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
return messages.slice(start);
}
export function ThreadViewport({
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages,
isStreaming,
composer,
@@ -65,7 +70,7 @@ export function ThreadViewport({
cliApps = [],
mcpPresets = [],
onOpenFilePreview,
}: ThreadViewportProps) {
}, ref) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
@@ -161,6 +166,8 @@ export function ThreadViewport({
setVisibleMessageCount((count) => Math.max(count, messages.length - index));
}, [messages]);
useImperativeHandle(ref, () => ({ jumpToUserPrompt }), [jumpToUserPrompt]);
const measureComposerDock = useCallback(() => {
const el = composerDockRef.current;
if (!el) return;
@@ -330,13 +337,6 @@ export function ThreadViewport({
/>
) : null}
{hasMessages ? (
<PromptNavigator
messages={messages}
onJumpToPrompt={jumpToUserPrompt}
/>
) : null}
{showScrollToBottomButton && !atBottom && (
<Button
variant="outline"
@@ -356,4 +356,4 @@ export function ThreadViewport({
)}
</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 { describe, expect, it, vi } from "vitest";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import {
HISTORY_WINDOW_INCREMENT,
INITIAL_HISTORY_WINDOW,
ThreadViewport,
type ThreadViewportHandle,
windowMessages,
} from "@/components/thread/ThreadViewport";
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", () => {
it("keeps the scroll-to-bottom button above a growing composer", () => {
const originalResizeObserver = globalThis.ResizeObserver;
@@ -220,15 +241,9 @@ 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 { container } = render(<ViewportWithPromptNavigator messages={promptMessages} />);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
const scroller = container.querySelector(".thread-viewport-scrollbar") as HTMLElement;
const scrollTo = vi.fn();
Object.defineProperties(scroller, {
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 () => {
const longMessages = makeLongMessages(300);
render(
<ThreadViewport
messages={longMessages}
isStreaming={false}
composer={<div />}
/>,
);
render(<ViewportWithPromptNavigator messages={longMessages} />);
expect(screen.queryByText("message 20")).not.toBeInTheDocument();