mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 13:58:36 +03:00
feat(webui): add prompt navigator drawer
This commit is contained in:
@@ -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 (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute right-4 z-20"
|
||||
style={{ bottom: Math.max(104, bottomOffset + 12) }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"pointer-events-auto h-8 w-8 rounded-full border-border/70 bg-background/90 shadow-md backdrop-blur",
|
||||
"text-muted-foreground 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}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[min(92vw,24rem)] gap-0 p-0 sm:max-w-[24rem]"
|
||||
>
|
||||
<div className="border-b px-5 pb-4 pt-5">
|
||||
<SheetTitle className="text-base font-medium">
|
||||
{t("thread.promptNavigator.title")}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="mt-1 text-xs">
|
||||
{t("thread.promptNavigator.description", { count: prompts.length })}
|
||||
</SheetDescription>
|
||||
<div className="relative mt-4">
|
||||
<Search
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => 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",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-2">
|
||||
{filteredPrompts.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{filteredPrompts.map((prompt) => (
|
||||
<PromptNavigatorRow
|
||||
key={prompt.id}
|
||||
locale={i18n.resolvedLanguage || i18n.language}
|
||||
prompt={prompt}
|
||||
onJump={jump}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
|
||||
{t("thread.promptNavigator.noResults")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full rounded-xl px-3 py-3 text-left transition",
|
||||
"hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30",
|
||||
)}
|
||||
aria-label={t("thread.promptNavigator.jumpTo", { label: prompt.label })}
|
||||
onClick={() => onJump(prompt.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{t("thread.promptNavigator.promptNumber", { number: prompt.index + 1 })}</span>
|
||||
{timestamp ? (
|
||||
<>
|
||||
<span aria-hidden>·</span>
|
||||
<span>{timestamp}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 max-h-20 overflow-hidden whitespace-pre-wrap break-words text-sm leading-5 text-foreground">
|
||||
{prompt.preview}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>;
|
||||
}
|
||||
|
||||
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<HTMLElement>("[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));
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||
const pendingConversationScrollRef = useRef(true);
|
||||
const pendingPromptJumpRef = useRef<string | null>(null);
|
||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
||||
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 ? (
|
||||
<PromptNavigator
|
||||
messages={messages}
|
||||
onJumpToPrompt={jumpToUserPrompt}
|
||||
bottomOffset={scrollButtonBottom}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showScrollToBottomButton && !atBottom && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
export interface PromptAnchor {
|
||||
id: string;
|
||||
label: string;
|
||||
preview: string;
|
||||
createdAt: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function userPromptAnchors(messages: UIMessage[]): PromptAnchor[] {
|
||||
let index = 0;
|
||||
return messages.flatMap((message) => {
|
||||
if (message.role !== "user") return [];
|
||||
const anchor: PromptAnchor = {
|
||||
id: message.id,
|
||||
label: promptLabel(message.content, index),
|
||||
preview: promptPreview(message.content, index),
|
||||
createdAt: message.createdAt,
|
||||
index,
|
||||
};
|
||||
index += 1;
|
||||
return [anchor];
|
||||
});
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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",
|
||||
});
|
||||
}
|
||||
|
||||
export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
|
||||
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
|
||||
return Array.from(candidates).find(
|
||||
(candidate) => candidate.dataset.userPromptId === promptId,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
@@ -775,7 +775,16 @@
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"loadEarlier": "Load earlier messages"
|
||||
"loadEarlier": "Load earlier messages",
|
||||
"promptNavigator": {
|
||||
"open": "Open prompt navigator",
|
||||
"title": "Prompts",
|
||||
"description": "{{count}} user prompts",
|
||||
"search": "Search prompts",
|
||||
"noResults": "No matching prompts.",
|
||||
"jumpTo": "Jump to prompt: {{label}}",
|
||||
"promptNumber": "Prompt {{number}}"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"streaming": "streaming",
|
||||
|
||||
@@ -775,7 +775,16 @@
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "Desplazarse al final",
|
||||
"loadEarlier": "Cargar mensajes anteriores"
|
||||
"loadEarlier": "Cargar mensajes anteriores",
|
||||
"promptNavigator": {
|
||||
"open": "Abrir navegador de prompts",
|
||||
"title": "Prompts",
|
||||
"description": "{{count}} prompts del usuario",
|
||||
"search": "Buscar prompts",
|
||||
"noResults": "No hay prompts coincidentes.",
|
||||
"jumpTo": "Ir al prompt: {{label}}",
|
||||
"promptNumber": "Prompt {{number}}"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"streaming": "transmitiendo",
|
||||
|
||||
@@ -775,7 +775,16 @@
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "Faire défiler vers le bas",
|
||||
"loadEarlier": "Charger les messages précédents"
|
||||
"loadEarlier": "Charger les messages précédents",
|
||||
"promptNavigator": {
|
||||
"open": "Ouvrir le navigateur de prompts",
|
||||
"title": "Prompts",
|
||||
"description": "{{count}} prompts utilisateur",
|
||||
"search": "Rechercher des prompts",
|
||||
"noResults": "Aucun prompt correspondant.",
|
||||
"jumpTo": "Aller au prompt : {{label}}",
|
||||
"promptNumber": "Prompt {{number}}"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"streaming": "en cours de génération",
|
||||
|
||||
@@ -775,7 +775,16 @@
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "Gulir ke bawah",
|
||||
"loadEarlier": "Muat pesan sebelumnya"
|
||||
"loadEarlier": "Muat pesan sebelumnya",
|
||||
"promptNavigator": {
|
||||
"open": "Buka navigator prompt",
|
||||
"title": "Prompt",
|
||||
"description": "{{count}} prompt pengguna",
|
||||
"search": "Cari prompt",
|
||||
"noResults": "Tidak ada prompt yang cocok.",
|
||||
"jumpTo": "Lompat ke prompt: {{label}}",
|
||||
"promptNumber": "Prompt {{number}}"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"streaming": "sedang mengalir",
|
||||
|
||||
@@ -775,7 +775,16 @@
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "一番下へスクロール",
|
||||
"loadEarlier": "以前のメッセージを読み込む"
|
||||
"loadEarlier": "以前のメッセージを読み込む",
|
||||
"promptNavigator": {
|
||||
"open": "プロンプトナビゲーターを開く",
|
||||
"title": "プロンプト",
|
||||
"description": "{{count}} 件のユーザープロンプト",
|
||||
"search": "プロンプトを検索",
|
||||
"noResults": "一致するプロンプトがありません。",
|
||||
"jumpTo": "プロンプトへ移動: {{label}}",
|
||||
"promptNumber": "プロンプト {{number}}"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"streaming": "生成中",
|
||||
|
||||
@@ -775,7 +775,16 @@
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "맨 아래로 스크롤",
|
||||
"loadEarlier": "이전 메시지 불러오기"
|
||||
"loadEarlier": "이전 메시지 불러오기",
|
||||
"promptNavigator": {
|
||||
"open": "프롬프트 탐색기 열기",
|
||||
"title": "프롬프트",
|
||||
"description": "사용자 프롬프트 {{count}}개",
|
||||
"search": "프롬프트 검색",
|
||||
"noResults": "일치하는 프롬프트가 없습니다.",
|
||||
"jumpTo": "프롬프트로 이동: {{label}}",
|
||||
"promptNumber": "프롬프트 {{number}}"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"streaming": "생성 중",
|
||||
|
||||
@@ -775,7 +775,16 @@
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "Cuộn xuống cuối",
|
||||
"loadEarlier": "Tải tin nhắn trước đó"
|
||||
"loadEarlier": "Tải tin nhắn trước đó",
|
||||
"promptNavigator": {
|
||||
"open": "Mở trình điều hướng prompt",
|
||||
"title": "Prompt",
|
||||
"description": "{{count}} prompt của người dùng",
|
||||
"search": "Tìm prompt",
|
||||
"noResults": "Không có prompt phù hợp.",
|
||||
"jumpTo": "Nhảy tới prompt: {{label}}",
|
||||
"promptNumber": "Prompt {{number}}"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"streaming": "đang truyền",
|
||||
|
||||
@@ -775,7 +775,16 @@
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "滚动到底部",
|
||||
"loadEarlier": "加载更早消息"
|
||||
"loadEarlier": "加载更早消息",
|
||||
"promptNavigator": {
|
||||
"open": "打开输入导航",
|
||||
"title": "输入列表",
|
||||
"description": "{{count}} 条用户输入",
|
||||
"search": "搜索输入",
|
||||
"noResults": "没有匹配的输入。",
|
||||
"jumpTo": "跳转到输入:{{label}}",
|
||||
"promptNumber": "第 {{number}} 条"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"streaming": "流式输出中",
|
||||
|
||||
@@ -775,7 +775,16 @@
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "捲動到底部",
|
||||
"loadEarlier": "載入更早訊息"
|
||||
"loadEarlier": "載入更早訊息",
|
||||
"promptNavigator": {
|
||||
"open": "開啟輸入導覽",
|
||||
"title": "輸入列表",
|
||||
"description": "{{count}} 則使用者輸入",
|
||||
"search": "搜尋輸入",
|
||||
"noResults": "沒有符合的輸入。",
|
||||
"jumpTo": "跳到輸入:{{label}}",
|
||||
"promptNumber": "第 {{number}} 則"
|
||||
}
|
||||
},
|
||||
"message": {
|
||||
"streaming": "串流輸出中",
|
||||
|
||||
@@ -218,6 +218,72 @@ 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 scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
const scrollTo = vi.fn();
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1800 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
|
||||
const promptEls = Array.from(
|
||||
container.querySelectorAll<HTMLElement>("[data-user-prompt-id]"),
|
||||
);
|
||||
promptEls.forEach((el, index) => {
|
||||
Object.defineProperty(el, "offsetTop", {
|
||||
configurable: true,
|
||||
value: index * 360,
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(within(dialog).getByText("Prompts")).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("message 4")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(within(dialog).getByRole("textbox", { name: "Search prompts" }), {
|
||||
target: { value: "message 4" },
|
||||
});
|
||||
expect(within(dialog).queryByText("message 1")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 4" }));
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1424,
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
|
||||
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 />}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("message 20")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open prompt navigator" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Jump to prompt: message 20" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("message 20")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("renders the prompt rail for compact scroll ranges", async () => {
|
||||
const promptMessages = makeLongMessages(3);
|
||||
const { container } = render(
|
||||
|
||||
Reference in New Issue
Block a user