mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-13 07:39:15 +03:00
style(webui): align prompt navigator with header actions
This commit is contained in:
@@ -47,15 +47,12 @@ export function PromptNavigator({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute right-3 top-2 z-20"
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto h-8 w-8 rounded-full text-muted-foreground/80",
|
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/80",
|
||||||
"hover:bg-accent/40 hover:text-foreground",
|
"hover:bg-accent/40 hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
aria-label={t("thread.promptNavigator.open")}
|
aria-label={t("thread.promptNavigator.open")}
|
||||||
@@ -63,7 +60,6 @@ export function PromptNavigator({
|
|||||||
>
|
>
|
||||||
<ListTree className="h-4 w-4" />
|
<ListTree className="h-4 w-4" />
|
||||||
</Button>
|
</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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user