mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 05:48:38 +03:00
style(webui): align prompt navigator with header actions
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user