mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
Optimize WebUI streaming and long history rendering
Batch stream deltas, window long transcripts, lazy-load syntax highlighting, and refine activity/composer interactions. Add title refresh retries plus tests for streaming, windowing, code blocks, and live activity behavior.
This commit is contained in:
parent
175b58e259
commit
e5be4dac7a
@ -7,7 +7,8 @@ import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
|
||||
import { useSessions } from "@/hooks/useSessions";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
|
||||
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
clearSavedSecret,
|
||||
@ -219,7 +220,13 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
|
||||
function Shell({
|
||||
onModelNameChange,
|
||||
onLogout,
|
||||
}: {
|
||||
onModelNameChange: (modelName: string | null) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { client } = useClient();
|
||||
const { theme, toggle } = useTheme();
|
||||
@ -362,9 +369,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
});
|
||||
}, [client, t]);
|
||||
|
||||
const onTurnEnd = useCallback(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) return;
|
||||
@ -415,93 +420,95 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
||||
const showMainSidebar = view !== "settings";
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
||||
{showMainSidebar ? (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||
"transition-[width] duration-300 ease-out",
|
||||
)}
|
||||
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
||||
>
|
||||
<ThemeProvider theme={theme}>
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
||||
{showMainSidebar ? (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||
"transition-[width] duration-300 ease-out",
|
||||
)}
|
||||
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
|
||||
"transition-transform duration-300 ease-out",
|
||||
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
style={{ width: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
{showMainSidebar ? (
|
||||
<Sheet
|
||||
open={mobileSidebarOpen}
|
||||
onOpenChange={(open) => setMobileSidebarOpen(open)}
|
||||
>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="p-0 lg:hidden"
|
||||
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : null}
|
||||
|
||||
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
|
||||
"transition-transform duration-300 ease-out",
|
||||
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
"absolute inset-0 flex flex-col",
|
||||
view === "settings" && "invisible pointer-events-none",
|
||||
)}
|
||||
style={{ width: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
{showMainSidebar ? (
|
||||
<Sheet
|
||||
open={mobileSidebarOpen}
|
||||
onOpenChange={(open) => setMobileSidebarOpen(open)}
|
||||
>
|
||||
<SheetContent
|
||||
side="left"
|
||||
showCloseButton={false}
|
||||
className="p-0 lg:hidden"
|
||||
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : null}
|
||||
|
||||
<main className="relative flex h-full min-w-0 flex-1 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col",
|
||||
view === "settings" && "invisible pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title={headerTitle}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||
/>
|
||||
</div>
|
||||
{view === "settings" && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<SettingsView
|
||||
<ThreadShell
|
||||
session={activeSession}
|
||||
title={headerTitle}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={onBackToChat}
|
||||
onModelNameChange={onModelNameChange}
|
||||
onLogout={onLogout}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{view === "settings" && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<SettingsView
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={onBackToChat}
|
||||
onModelNameChange={onModelNameChange}
|
||||
onLogout={onLogout}
|
||||
onRestart={onRestart}
|
||||
isRestarting={isRestarting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<DeleteConfirm
|
||||
open={!!pendingDelete}
|
||||
title={pendingDelete?.label ?? ""}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={onConfirmDelete}
|
||||
/>
|
||||
{restartToast ? (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
|
||||
>
|
||||
{restartToast}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DeleteConfirm
|
||||
open={!!pendingDelete}
|
||||
title={pendingDelete?.label ?? ""}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={onConfirmDelete}
|
||||
/>
|
||||
{restartToast ? (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
|
||||
>
|
||||
{restartToast}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Suspense, lazy, useCallback, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
import { useThemeValue } from "@/hooks/useTheme";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CodeBlockProps {
|
||||
@ -15,30 +11,59 @@ interface CodeBlockProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */
|
||||
function useIsDark() {
|
||||
const [isDark, setIsDark] = useState(() =>
|
||||
typeof document !== "undefined"
|
||||
? document.documentElement.classList.contains("dark")
|
||||
: true,
|
||||
interface HighlightedCodeProps {
|
||||
language?: string;
|
||||
code: string;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
const LazyHighlightedCode = lazy(async () => {
|
||||
const [
|
||||
{ default: SyntaxHighlighter },
|
||||
{ default: oneDark },
|
||||
{ default: oneLight },
|
||||
] = await Promise.all([
|
||||
import("react-syntax-highlighter/dist/esm/prism-async-light"),
|
||||
import("react-syntax-highlighter/dist/esm/styles/prism/one-dark"),
|
||||
import("react-syntax-highlighter/dist/esm/styles/prism/one-light"),
|
||||
]);
|
||||
|
||||
return {
|
||||
default({ language, code, isDark }: HighlightedCodeProps) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={isDark ? oneDark : oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
PreTag="pre"
|
||||
wrapLongLines
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function PlainCodeFallback({ code }: { code: string }) {
|
||||
return (
|
||||
<pre
|
||||
className="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-sm leading-[1.6]"
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(el.classList.contains("dark"));
|
||||
});
|
||||
observer.observe(el, { attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
}
|
||||
|
||||
export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isDark = useIsDark();
|
||||
const isDark = useThemeValue() === "dark";
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (!navigator.clipboard) return;
|
||||
@ -86,20 +111,9 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={isDark ? oneDark : oneLight}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
PreTag="pre"
|
||||
wrapLongLines
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
<Suspense fallback={<PlainCodeFallback code={code} />}>
|
||||
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -167,10 +167,15 @@ function MessageMedia({
|
||||
align: "left" | "right";
|
||||
}) {
|
||||
if (media.length === 0) return null;
|
||||
const images = media
|
||||
.filter((item) => item.kind === "image")
|
||||
.map(({ url, name }) => ({ url, name }));
|
||||
const nonImages = media.filter((item) => item.kind !== "image");
|
||||
const images: UIImage[] = [];
|
||||
const nonImages: UIMediaAttachment[] = [];
|
||||
for (const item of media) {
|
||||
if (item.kind === "image") {
|
||||
images.push({ url: item.url, name: item.name });
|
||||
} else {
|
||||
nonImages.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -276,13 +281,14 @@ function UserImages({
|
||||
const { t } = useTranslation();
|
||||
// Only real-URL images can open in the lightbox; historical-replay
|
||||
// placeholders (no URL) have nothing to zoom into.
|
||||
const viewable = images
|
||||
.map((img, i) => ({ img, i }))
|
||||
.filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
|
||||
const viewableImages = viewable.map(({ img }) => img);
|
||||
const originalToViewable = new Map<number, number>(
|
||||
viewable.map(({ i }, v) => [i, v]),
|
||||
);
|
||||
const viewableImages: UIImage[] = [];
|
||||
const originalToViewable = new Map<number, number>();
|
||||
for (let i = 0; i < images.length; i += 1) {
|
||||
const img = images[i];
|
||||
if (typeof img.url !== "string" || img.url.length === 0) continue;
|
||||
originalToViewable.set(i, viewableImages.length);
|
||||
viewableImages.push(img);
|
||||
}
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
@ -416,7 +422,7 @@ function Dot({ delay }: { delay: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** L→R sheen overlay on label text; base copy stays solid ``text-muted-foreground``. */
|
||||
/** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */
|
||||
export function StreamingLabelSheen({
|
||||
children,
|
||||
active,
|
||||
@ -426,21 +432,21 @@ export function StreamingLabelSheen({
|
||||
active: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const sheenText =
|
||||
typeof children === "string" || typeof children === "number"
|
||||
? String(children)
|
||||
: undefined;
|
||||
return (
|
||||
<span className={cn("relative block min-w-0 py-px", className)}>
|
||||
<span className={cn("block min-w-0 overflow-hidden py-px", className)}>
|
||||
<span
|
||||
data-sheen-text={active ? sheenText : undefined}
|
||||
className={cn(
|
||||
"relative z-0 block font-medium leading-normal text-muted-foreground",
|
||||
!active && "truncate",
|
||||
"block w-fit max-w-full truncate font-medium leading-normal",
|
||||
active ? "streaming-text-sheen" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{active ? (
|
||||
<span className="reasoning-sheen-track" aria-hidden dir="ltr">
|
||||
<span className="reasoning-sheen-stripe" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ChevronRight, Layers } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -8,6 +8,7 @@ import type { UIMessage } from "@/lib/types";
|
||||
|
||||
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||
|
||||
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
|
||||
if (m.role !== "assistant" || m.kind === "trace") return false;
|
||||
@ -19,14 +20,20 @@ export function isAgentActivityMember(m: UIMessage): boolean {
|
||||
return isReasoningOnlyAssistant(m) || m.kind === "trace";
|
||||
}
|
||||
|
||||
function countToolCalls(messages: UIMessage[]): number {
|
||||
let n = 0;
|
||||
function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCalls: number } {
|
||||
let reasoningSteps = 0;
|
||||
let toolCalls = 0;
|
||||
for (const m of messages) {
|
||||
if (m.kind !== "trace") continue;
|
||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||
n += Math.max(lines, 1);
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
reasoningSteps += 1;
|
||||
continue;
|
||||
}
|
||||
if (m.kind === "trace") {
|
||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
||||
toolCalls += Math.max(lines, 1);
|
||||
}
|
||||
}
|
||||
return n;
|
||||
return { reasoningSteps, toolCalls };
|
||||
}
|
||||
|
||||
interface AgentActivityClusterProps {
|
||||
@ -46,11 +53,14 @@ export function AgentActivityCluster({
|
||||
hasBodyBelow,
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length;
|
||||
const toolCalls = countToolCalls(messages);
|
||||
const { reasoningSteps, toolCalls } = countActivity(messages);
|
||||
|
||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
|
||||
const activityScrollRef = useRef<HTMLDivElement>(null);
|
||||
const activityContentRef = useRef<HTMLDivElement>(null);
|
||||
const autoFollowActivityRef = useRef(true);
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
|
||||
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
|
||||
|
||||
@ -79,11 +89,66 @@ export function AgentActivityCluster({
|
||||
defaultValue: "{{tools}} tool calls",
|
||||
});
|
||||
|
||||
const cancelActivityScrollFrame = useCallback(() => {
|
||||
if (scrollFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(scrollFrameRef.current);
|
||||
scrollFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollActivityToBottom = useCallback(() => {
|
||||
const el = activityScrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
}, []);
|
||||
|
||||
const scheduleActivityScrollToBottom = useCallback(() => {
|
||||
cancelActivityScrollFrame();
|
||||
scrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
scrollFrameRef.current = null;
|
||||
scrollActivityToBottom();
|
||||
});
|
||||
}, [cancelActivityScrollFrame, scrollActivityToBottom]);
|
||||
|
||||
const toggleOuter = () => {
|
||||
const nextOpen = userToggledOuter ? !outerOpenLocal : !outerExpanded;
|
||||
if (nextOpen) {
|
||||
autoFollowActivityRef.current = true;
|
||||
}
|
||||
setUserToggledOuter(true);
|
||||
setOuterOpenLocal((v) => (userToggledOuter ? !v : !outerExpanded));
|
||||
setOuterOpenLocal(nextOpen);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!outerExpanded || !autoFollowActivityRef.current) return;
|
||||
scheduleActivityScrollToBottom();
|
||||
}, [outerExpanded, messages, isTurnStreaming, scheduleActivityScrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!outerExpanded) {
|
||||
autoFollowActivityRef.current = true;
|
||||
return;
|
||||
}
|
||||
const target = activityContentRef.current;
|
||||
if (!target || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (autoFollowActivityRef.current) {
|
||||
scheduleActivityScrollToBottom();
|
||||
}
|
||||
});
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [outerExpanded, scheduleActivityScrollToBottom]);
|
||||
|
||||
useEffect(() => cancelActivityScrollFrame, [cancelActivityScrollFrame]);
|
||||
|
||||
const onActivityScroll = useCallback(() => {
|
||||
const el = activityScrollRef.current;
|
||||
if (!el) return;
|
||||
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||
<button
|
||||
@ -118,12 +183,15 @@ export function AgentActivityCluster({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={activityScrollRef}
|
||||
data-testid="agent-activity-scroll"
|
||||
onScroll={onActivityScroll}
|
||||
className={cn(
|
||||
CLUSTER_SCROLL_MAX_CLASS,
|
||||
"overflow-y-auto px-2 py-1.5 scrollbar-thin scrollbar-track-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div ref={activityContentRef} className="flex flex-col gap-2">
|
||||
{messages.map((m) => {
|
||||
if (isReasoningOnlyAssistant(m)) {
|
||||
return (
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import {
|
||||
AgentActivityCluster,
|
||||
@ -9,6 +12,8 @@ interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
/** When true, agent turn still in flight — keeps activity cluster expanded. */
|
||||
isStreaming?: boolean;
|
||||
hiddenMessageCount?: number;
|
||||
onLoadEarlier?: () => void;
|
||||
}
|
||||
|
||||
export type DisplayUnit =
|
||||
@ -30,7 +35,7 @@ export function isFinalAssistantSliceBeforeNextUser(
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
const out: DisplayUnit[] = [];
|
||||
let i = 0;
|
||||
while (i < messages.length) {
|
||||
@ -50,11 +55,49 @@ function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function ThreadMessages({ messages, isStreaming = false }: ThreadMessagesProps) {
|
||||
const units = buildDisplayUnits(messages);
|
||||
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||
const flags = new Array<boolean>(units.length).fill(true);
|
||||
let hasLaterUnitBeforeUser = false;
|
||||
for (let i = units.length - 1; i >= 0; i -= 1) {
|
||||
const unit = units[i];
|
||||
if (unit.type === "single" && unit.message.role === "user") {
|
||||
hasLaterUnitBeforeUser = false;
|
||||
continue;
|
||||
}
|
||||
if (unit.type === "single" && unit.message.role === "assistant") {
|
||||
flags[i] = !hasLaterUnitBeforeUser;
|
||||
}
|
||||
hasLaterUnitBeforeUser = true;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
export function ThreadMessages({
|
||||
messages,
|
||||
isStreaming = false,
|
||||
hiddenMessageCount = 0,
|
||||
onLoadEarlier,
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
|
||||
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
{hiddenMessageCount > 0 && onLoadEarlier ? (
|
||||
<div className="mb-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadEarlier}
|
||||
className="rounded-full border border-border/60 bg-background/85 px-3 py-1.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"
|
||||
>
|
||||
{t("thread.loadEarlier", {
|
||||
count: hiddenMessageCount,
|
||||
defaultValue: "Load earlier messages",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{units.map((unit, index) => {
|
||||
const prev = units[index - 1];
|
||||
const marginTop =
|
||||
@ -80,7 +123,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
||||
message={unit.message}
|
||||
showAssistantCopyAction={
|
||||
unit.message.role === "assistant"
|
||||
? isFinalAssistantSliceBeforeNextUser(units, index)
|
||||
? copyFlags[index]
|
||||
: true
|
||||
}
|
||||
/>
|
||||
|
||||
@ -389,6 +389,7 @@ export function ThreadShell({
|
||||
composer={composer}
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
conversationKey={historyKey}
|
||||
showScrollToBottomButton={!!session}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
@ -14,9 +23,27 @@ interface ThreadViewportProps {
|
||||
emptyState?: ReactNode;
|
||||
scrollToBottomSignal?: number;
|
||||
conversationKey?: string | null;
|
||||
showScrollToBottomButton?: boolean;
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||
|
||||
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
|
||||
if (messages.length <= visibleCount) return messages;
|
||||
let start = Math.max(0, messages.length - visibleCount);
|
||||
while (
|
||||
start > 0
|
||||
&& isAgentActivityMember(messages[start])
|
||||
&& isAgentActivityMember(messages[start - 1])
|
||||
) {
|
||||
start -= 1;
|
||||
}
|
||||
return messages.slice(start);
|
||||
}
|
||||
|
||||
export function ThreadViewport({
|
||||
messages,
|
||||
@ -25,18 +52,33 @@ export function ThreadViewport({
|
||||
emptyState,
|
||||
scrollToBottomSignal = 0,
|
||||
conversationKey = null,
|
||||
showScrollToBottomButton = true,
|
||||
}: ThreadViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const composerDockRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||
const pendingConversationScrollRef = useRef(true);
|
||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
||||
const restoreScrollAfterPrependRef =
|
||||
useRef<{ height: number; top: number } | null>(null);
|
||||
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
|
||||
const userReadingHistoryRef = useRef(false);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
||||
const [visibleMessageCount, setVisibleMessageCount] =
|
||||
useState(INITIAL_HISTORY_WINDOW);
|
||||
const hasMessages = messages.length > 0;
|
||||
const visibleMessages = useMemo(
|
||||
() => windowMessages(messages, visibleMessageCount),
|
||||
[messages, visibleMessageCount],
|
||||
);
|
||||
const hiddenMessageCount = messages.length - visibleMessages.length;
|
||||
const scrollButtonBottom = composerDockHeight > 0
|
||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX;
|
||||
|
||||
const cancelScheduledBottomScroll = useCallback(() => {
|
||||
for (const id of scrollFrameIdsRef.current) {
|
||||
@ -77,6 +119,30 @@ export function ThreadViewport({
|
||||
[cancelScheduledBottomScroll, scrollToBottomNow],
|
||||
);
|
||||
|
||||
const loadEarlierMessages = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) {
|
||||
restoreScrollAfterPrependRef.current = {
|
||||
height: el.scrollHeight,
|
||||
top: el.scrollTop,
|
||||
};
|
||||
}
|
||||
userReadingHistoryRef.current = true;
|
||||
setAtBottom(false);
|
||||
setVisibleMessageCount((count) =>
|
||||
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
|
||||
);
|
||||
}, [messages.length]);
|
||||
|
||||
const measureComposerDock = useCallback(() => {
|
||||
const el = composerDockRef.current;
|
||||
if (!el) return;
|
||||
const height = el.getBoundingClientRect().height || el.offsetHeight;
|
||||
setComposerDockHeight((current) =>
|
||||
Math.abs(current - height) < 1 ? current : height,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!atBottom) return;
|
||||
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
||||
@ -96,8 +162,19 @@ export function ThreadViewport({
|
||||
pendingConversationScrollRef.current = true;
|
||||
userReadingHistoryRef.current = false;
|
||||
setAtBottom(true);
|
||||
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||
}, [conversationKey]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pending = restoreScrollAfterPrependRef.current;
|
||||
if (!pending) return;
|
||||
const el = scrollRef.current;
|
||||
restoreScrollAfterPrependRef.current = null;
|
||||
if (!el) return;
|
||||
const delta = el.scrollHeight - pending.height;
|
||||
el.scrollTop = pending.top + delta;
|
||||
}, [visibleMessages.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingConversationScrollRef.current) return;
|
||||
if (!conversationKey) {
|
||||
@ -110,6 +187,10 @@ export function ThreadViewport({
|
||||
pendingConversationScrollRef.current = false;
|
||||
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measureComposerDock();
|
||||
}, [composer, hasMessages, measureComposerDock]);
|
||||
|
||||
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -123,6 +204,14 @@ export function ThreadViewport({
|
||||
return () => observer.disconnect();
|
||||
}, [hasMessages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = composerDockRef.current;
|
||||
if (!target || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => measureComposerDock());
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMessages, measureComposerDock]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@ -155,11 +244,20 @@ export function ThreadViewport({
|
||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
||||
<div className="flex-1 px-4 pb-20 pt-4">
|
||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages messages={messages} isStreaming={isStreaming} />
|
||||
<ThreadMessages
|
||||
messages={visibleMessages}
|
||||
isStreaming={isStreaming}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
onLoadEarlier={loadEarlierMessages}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-10 mt-auto bg-background">
|
||||
<div
|
||||
ref={composerDockRef}
|
||||
data-testid="thread-composer-dock"
|
||||
className="sticky bottom-0 z-10 mt-auto bg-background"
|
||||
>
|
||||
<div className="px-4 pb-3">
|
||||
{composer}
|
||||
</div>
|
||||
@ -183,17 +281,18 @@ export function ThreadViewport({
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
||||
/>
|
||||
|
||||
{!atBottom && (
|
||||
{showScrollToBottomButton && !atBottom && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => scrollToBottom(true, 1, { force: true })}
|
||||
className={cn(
|
||||
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
|
||||
"absolute bottom-48 left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
|
||||
"absolute left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
|
||||
"bg-background/90 backdrop-blur",
|
||||
"animate-in fade-in-0 zoom-in-95",
|
||||
)}
|
||||
style={{ bottom: scrollButtonBottom }}
|
||||
aria-label={t("thread.scrollToBottom")}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
|
||||
@ -117,53 +117,60 @@
|
||||
--cjk-line-height: 1.625;
|
||||
}
|
||||
|
||||
/* L→R sheen over solid label text (overlay stripe). Avoids ``background-clip:
|
||||
text`` loop seams that read as RTL “erase” or one-frame transparent glyphs. */
|
||||
@keyframes reasoning-sheen-ltr {
|
||||
/* L→R sheen clipped to live activity labels. The highlight lives inside
|
||||
the glyphs, not in the row background, so dark mode stays quiet. */
|
||||
@keyframes streaming-text-sheen-ltr {
|
||||
0% {
|
||||
left: -44%;
|
||||
background-position: 140% 50%;
|
||||
}
|
||||
100% {
|
||||
left: 118%;
|
||||
background-position: -40% 50%;
|
||||
}
|
||||
}
|
||||
.reasoning-sheen-track {
|
||||
.streaming-text-sheen {
|
||||
position: relative;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.streaming-text-sheen::after {
|
||||
content: attr(data-sheen-text);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
border-radius: 2px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
}
|
||||
.reasoning-sheen-stripe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 44%;
|
||||
min-width: 3.25rem;
|
||||
left: -44%;
|
||||
border-radius: inherit;
|
||||
color: transparent;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
hsl(0 0% 100% / 0.07) 34%,
|
||||
hsl(0 0% 100% / 0.76) 50%,
|
||||
hsl(0 0% 100% / 0.07) 66%,
|
||||
transparent 38%,
|
||||
hsl(var(--foreground) / 0.98) 50%,
|
||||
transparent 62%,
|
||||
transparent 100%
|
||||
);
|
||||
mix-blend-mode: soft-light;
|
||||
opacity: 0.95;
|
||||
animation: reasoning-sheen-ltr 5.2s linear infinite;
|
||||
background-size: 260% 100%;
|
||||
background-position: 140% 50%;
|
||||
background-repeat: no-repeat;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: streaming-text-sheen-ltr 2.8s ease-in-out infinite;
|
||||
}
|
||||
.dark .reasoning-sheen-stripe {
|
||||
mix-blend-mode: overlay;
|
||||
opacity: 1;
|
||||
.dark .streaming-text-sheen::after {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
transparent 38%,
|
||||
hsl(var(--foreground) / 0.98) 50%,
|
||||
transparent 62%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reasoning-sheen-stripe {
|
||||
.streaming-text-sheen::after {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
68
webui/src/hooks/useDeferredTitleRefresh.ts
Normal file
68
webui/src/hooks/useDeferredTitleRefresh.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
const TITLE_REFRESH_RETRY_DELAYS_MS = [1_000, 3_000, 7_000] as const;
|
||||
|
||||
function hasGeneratedTitle(session: ChatSummary | null): boolean {
|
||||
return !!session?.title?.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The server generates WebUI titles after the main turn has already ended.
|
||||
* Refresh once immediately, then retry lightly for untitled sessions so the
|
||||
* async title appears even if the websocket metadata notification is delayed.
|
||||
*/
|
||||
export function useDeferredTitleRefresh(
|
||||
activeSession: ChatSummary | null,
|
||||
refresh: () => Promise<void>,
|
||||
retryDelaysMs: readonly number[] = TITLE_REFRESH_RETRY_DELAYS_MS,
|
||||
): () => void {
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
activeSessionRef.current = activeSession;
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
for (const timer of timersRef.current) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timersRef.current = [];
|
||||
}, []);
|
||||
|
||||
useEffect(() => clearTimers, [clearTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
clearTimers();
|
||||
}, [activeSession?.key, clearTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasGeneratedTitle(activeSession)) {
|
||||
clearTimers();
|
||||
}
|
||||
}, [activeSession, clearTimers]);
|
||||
|
||||
return useCallback(() => {
|
||||
void refresh();
|
||||
|
||||
const sessionAtTurnEnd = activeSessionRef.current;
|
||||
if (!sessionAtTurnEnd || hasGeneratedTitle(sessionAtTurnEnd)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimers();
|
||||
for (const delayMs of retryDelaysMs) {
|
||||
const timer = setTimeout(() => {
|
||||
const latest = activeSessionRef.current;
|
||||
if (
|
||||
!latest ||
|
||||
latest.key !== sessionAtTurnEnd.key ||
|
||||
hasGeneratedTitle(latest)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, delayMs);
|
||||
timersRef.current.push(timer);
|
||||
}
|
||||
}, [clearTimers, refresh, retryDelaysMs]);
|
||||
}
|
||||
@ -18,12 +18,21 @@ interface StreamBuffer {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
interface ActiveAssistantCursor {
|
||||
id: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
type PendingStreamEvent =
|
||||
| { kind: "delta"; text: string }
|
||||
| { kind: "reasoning"; text: string };
|
||||
|
||||
/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */
|
||||
function findStreamingAssistantId(prev: UIMessage[]): string | null {
|
||||
function findStreamingAssistantIndex(prev: UIMessage[]): number | null {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const m = prev[i];
|
||||
if (m.kind === "trace") continue;
|
||||
if (m.role === "assistant" && m.isStreaming) return m.id;
|
||||
if (m.role === "assistant" && m.isStreaming) return i;
|
||||
if (m.role === "user") break;
|
||||
}
|
||||
return null;
|
||||
@ -95,13 +104,19 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
* the model already produced an answer in a previous turn, so the new
|
||||
* delta belongs in a fresh row.
|
||||
*/
|
||||
function findActiveAssistantPlaceholder(prev: UIMessage[]): string | null {
|
||||
function findActiveAssistantPlaceholderIndex(prev: UIMessage[]): number | null {
|
||||
const last = prev[prev.length - 1];
|
||||
if (!last) return null;
|
||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
||||
if (last.content.length > 0) return null;
|
||||
if (!last.isStreaming) return null;
|
||||
return last.id;
|
||||
return prev.length - 1;
|
||||
}
|
||||
|
||||
function replaceMessageAt(prev: UIMessage[], index: number, message: UIMessage): UIMessage[] {
|
||||
const next = prev.slice();
|
||||
next[index] = message;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -239,6 +254,9 @@ export function useNanobotStream(
|
||||
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||
const buffer = useRef<StreamBuffer | null>(null);
|
||||
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
|
||||
const pendingStreamEventsRef = useRef<PendingStreamEvent[]>([]);
|
||||
const streamFrameRef = useRef<number | null>(null);
|
||||
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
||||
*
|
||||
@ -255,6 +273,115 @@ export function useNanobotStream(
|
||||
|
||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
||||
|
||||
const clearPendingStreamWork = useCallback(() => {
|
||||
if (streamFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(streamFrameRef.current);
|
||||
streamFrameRef.current = null;
|
||||
}
|
||||
pendingStreamEventsRef.current = [];
|
||||
}, []);
|
||||
|
||||
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
|
||||
const cursor = activeAssistantRef.current;
|
||||
if (!cursor) return null;
|
||||
const indexed = prev[cursor.index];
|
||||
if (indexed?.id === cursor.id && indexed.role === "assistant" && indexed.kind !== "trace") {
|
||||
return cursor.index;
|
||||
}
|
||||
const idx = prev.findIndex((m) => m.id === cursor.id);
|
||||
if (idx === -1) {
|
||||
activeAssistantRef.current = null;
|
||||
return null;
|
||||
}
|
||||
const found = prev[idx];
|
||||
if (found.role !== "assistant" || found.kind === "trace") {
|
||||
activeAssistantRef.current = null;
|
||||
return null;
|
||||
}
|
||||
activeAssistantRef.current = { id: cursor.id, index: idx };
|
||||
return idx;
|
||||
}, []);
|
||||
|
||||
const appendAnswerChunk = useCallback(
|
||||
(prev: UIMessage[], chunk: string): UIMessage[] => {
|
||||
let next = prev;
|
||||
let targetIndex = resolveActiveAssistantIndex(next);
|
||||
|
||||
if (targetIndex === null) {
|
||||
targetIndex = findActiveAssistantPlaceholderIndex(next);
|
||||
}
|
||||
if (targetIndex === null) {
|
||||
targetIndex = findStreamingAssistantIndex(next);
|
||||
}
|
||||
if (targetIndex === null) {
|
||||
const id = crypto.randomUUID();
|
||||
next = [
|
||||
...next,
|
||||
{
|
||||
id,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
targetIndex = next.length - 1;
|
||||
}
|
||||
|
||||
const target = next[targetIndex];
|
||||
const merged: UIMessage = {
|
||||
...target,
|
||||
content: target.content + chunk,
|
||||
isStreaming: true,
|
||||
};
|
||||
activeAssistantRef.current = { id: merged.id, index: targetIndex };
|
||||
buffer.current = { messageId: merged.id };
|
||||
return replaceMessageAt(next, targetIndex, merged);
|
||||
},
|
||||
[resolveActiveAssistantIndex],
|
||||
);
|
||||
|
||||
const applyPendingStreamEvents = useCallback(
|
||||
(prev: UIMessage[], events: PendingStreamEvent[]): UIMessage[] => {
|
||||
let next = prev;
|
||||
for (let i = 0; i < events.length;) {
|
||||
const kind = events[i].kind;
|
||||
let text = "";
|
||||
while (i < events.length && events[i].kind === kind) {
|
||||
text += events[i].text;
|
||||
i += 1;
|
||||
}
|
||||
next = kind === "delta"
|
||||
? appendAnswerChunk(next, text)
|
||||
: attachReasoningChunk(next, text);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
[appendAnswerChunk],
|
||||
);
|
||||
|
||||
const flushPendingStreamEvents = useCallback(() => {
|
||||
if (streamFrameRef.current !== null) {
|
||||
window.cancelAnimationFrame(streamFrameRef.current);
|
||||
streamFrameRef.current = null;
|
||||
}
|
||||
const events = pendingStreamEventsRef.current;
|
||||
if (events.length === 0) return;
|
||||
pendingStreamEventsRef.current = [];
|
||||
setMessages((prev) => applyPendingStreamEvents(prev, events));
|
||||
}, [applyPendingStreamEvents]);
|
||||
|
||||
const schedulePendingStreamFlush = useCallback(() => {
|
||||
if (streamFrameRef.current !== null) return;
|
||||
streamFrameRef.current = window.requestAnimationFrame(() => {
|
||||
streamFrameRef.current = null;
|
||||
const events = pendingStreamEventsRef.current;
|
||||
if (events.length === 0) return;
|
||||
pendingStreamEventsRef.current = [];
|
||||
setMessages((prev) => applyPendingStreamEvents(prev, events));
|
||||
});
|
||||
}, [applyPendingStreamEvents]);
|
||||
|
||||
// Reset local state when switching chats. Do not reset on every
|
||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||
// history response after the optimistic first message has already rendered.
|
||||
@ -269,13 +396,15 @@ export function useNanobotStream(
|
||||
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
||||
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
clearPendingStreamWork();
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatId, client]);
|
||||
}, [chatId, client, clearPendingStreamWork]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasPendingToolCalls) setIsStreaming(true);
|
||||
@ -296,44 +425,25 @@ export function useNanobotStream(
|
||||
if (ev.event === "delta") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const chunk = typeof ev.text === "string" ? ev.text : "";
|
||||
if (!chunk) return;
|
||||
setIsStreaming(true);
|
||||
setMessages((prev) => {
|
||||
const adopted = findActiveAssistantPlaceholder(prev);
|
||||
const streamingAssistId = findStreamingAssistantId(prev);
|
||||
let targetId: string;
|
||||
let next: UIMessage[];
|
||||
|
||||
if (adopted) {
|
||||
targetId = adopted;
|
||||
next = prev;
|
||||
} else if (streamingAssistId) {
|
||||
targetId = streamingAssistId;
|
||||
next = prev;
|
||||
} else {
|
||||
targetId = crypto.randomUUID();
|
||||
next = [
|
||||
...prev,
|
||||
{
|
||||
id: targetId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
buffer.current = { messageId: targetId };
|
||||
|
||||
const priorContent = next.find((m) => m.id === targetId)?.content ?? "";
|
||||
const combined = priorContent + chunk;
|
||||
return next.map((m) =>
|
||||
m.id === targetId ? { ...m, content: combined, isStreaming: true } : m,
|
||||
);
|
||||
});
|
||||
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
|
||||
schedulePendingStreamFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "reasoning_delta") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const chunk = ev.text;
|
||||
if (!chunk) return;
|
||||
setIsStreaming(true);
|
||||
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
|
||||
schedulePendingStreamFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
flushPendingStreamEvents();
|
||||
|
||||
if (ev.event === "stream_end") {
|
||||
if (suppressStreamUntilTurnEndRef.current) {
|
||||
buffer.current = null;
|
||||
@ -347,15 +457,6 @@ export function useNanobotStream(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "reasoning_delta") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const chunk = ev.text;
|
||||
if (!chunk) return;
|
||||
setMessages((prev) => attachReasoningChunk(prev, chunk));
|
||||
setIsStreaming(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "reasoning_end") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
setMessages((prev) => closeReasoningStream(prev));
|
||||
@ -393,6 +494,8 @@ export function useNanobotStream(
|
||||
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
|
||||
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
|
||||
}
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
return finalized;
|
||||
});
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
@ -459,11 +562,12 @@ export function useNanobotStream(
|
||||
|
||||
// A complete (non-streamed) assistant message. If a stream was in
|
||||
// flight, drop the placeholder so we don't render the text twice.
|
||||
const activeId = buffer.current?.messageId;
|
||||
buffer.current = null;
|
||||
// Do NOT reset isStreaming here — only ``turn_end`` signals that
|
||||
// the full turn (all tool calls + final text) is complete.
|
||||
setMessages((prev) => {
|
||||
const activeId = buffer.current?.messageId;
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
||||
const content = ev.text;
|
||||
const lat =
|
||||
@ -489,12 +593,21 @@ export function useNanobotStream(
|
||||
return () => {
|
||||
unsub();
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
clearPendingStreamWork();
|
||||
if (streamEndTimerRef.current !== null) {
|
||||
clearTimeout(streamEndTimerRef.current);
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [chatId, client, onTurnEnd]);
|
||||
}, [
|
||||
chatId,
|
||||
client,
|
||||
clearPendingStreamWork,
|
||||
flushPendingStreamEvents,
|
||||
onTurnEnd,
|
||||
schedulePendingStreamFlush,
|
||||
]);
|
||||
|
||||
const send = useCallback(
|
||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
@ -504,17 +617,22 @@ export function useNanobotStream(
|
||||
// the image blocks via ``media`` paths.
|
||||
if (!hasImages && !content.trim()) return;
|
||||
|
||||
flushPendingStreamEvents();
|
||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||
setMessages((prev) => [
|
||||
...pruneReasoningOnlyPlaceholders(prev),
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
},
|
||||
]);
|
||||
setMessages((prev) => {
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
return [
|
||||
...pruneReasoningOnlyPlaceholders(prev),
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(previews ? { images: previews } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
// Mark streaming immediately so the UI shows the loading indicator
|
||||
// right away, before the first delta arrives from the server.
|
||||
setIsStreaming(true);
|
||||
@ -525,18 +643,21 @@ export function useNanobotStream(
|
||||
client.sendMessage(chatId, content, wireMedia);
|
||||
}
|
||||
},
|
||||
[chatId, client],
|
||||
[chatId, client, flushPendingStreamEvents],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!chatId) return;
|
||||
flushPendingStreamEvents();
|
||||
setIsStreaming(false);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
|
||||
);
|
||||
setMessages((prev) => {
|
||||
buffer.current = null;
|
||||
activeAssistantRef.current = null;
|
||||
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||
});
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
client.sendMessage(chatId, "/stop");
|
||||
}, [chatId, client]);
|
||||
}, [chatId, client, flushPendingStreamEvents]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
|
||||
@ -1,7 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const STORAGE_KEY = "nanobot-webui.theme";
|
||||
const ThemeContext = createContext<Theme>("light");
|
||||
|
||||
function readStored(): Theme | null {
|
||||
try {
|
||||
@ -18,7 +27,11 @@ function applyTheme(theme: Theme): void {
|
||||
else root.classList.remove("dark");
|
||||
}
|
||||
|
||||
export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Theme) => void } {
|
||||
export function useTheme(): {
|
||||
theme: Theme;
|
||||
toggle: () => void;
|
||||
setTheme: (t: Theme) => void;
|
||||
} {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = readStored();
|
||||
if (stored) return stored;
|
||||
@ -46,3 +59,11 @@ export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Th
|
||||
);
|
||||
return { theme, toggle, setTheme };
|
||||
}
|
||||
|
||||
export function ThemeProvider({ theme, children }: { theme: Theme; children: ReactNode }) {
|
||||
return createElement(ThemeContext.Provider, { value: theme }, children);
|
||||
}
|
||||
|
||||
export function useThemeValue(): Theme {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
@ -335,7 +335,8 @@
|
||||
"io": "Couldn't read this file"
|
||||
}
|
||||
},
|
||||
"scrollToBottom": "Scroll to bottom"
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"loadEarlier": "Load earlier messages"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "streaming",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Cerrar objetivo"
|
||||
},
|
||||
"scrollToBottom": "Desplazarse al final"
|
||||
"scrollToBottom": "Desplazarse al final",
|
||||
"loadEarlier": "Cargar mensajes anteriores"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "transmitiendo",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Fermer l’objectif"
|
||||
},
|
||||
"scrollToBottom": "Faire défiler vers le bas"
|
||||
"scrollToBottom": "Faire défiler vers le bas",
|
||||
"loadEarlier": "Charger les messages précédents"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "en cours de génération",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Tutup tujuan"
|
||||
},
|
||||
"scrollToBottom": "Gulir ke bawah"
|
||||
"scrollToBottom": "Gulir ke bawah",
|
||||
"loadEarlier": "Muat pesan sebelumnya"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "sedang mengalir",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "目標を閉じる"
|
||||
},
|
||||
"scrollToBottom": "一番下へスクロール"
|
||||
"scrollToBottom": "一番下へスクロール",
|
||||
"loadEarlier": "以前のメッセージを読み込む"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "生成中",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "목표 닫기"
|
||||
},
|
||||
"scrollToBottom": "맨 아래로 스크롤"
|
||||
"scrollToBottom": "맨 아래로 스크롤",
|
||||
"loadEarlier": "이전 메시지 불러오기"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "생성 중",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "Đóng mục tiêu"
|
||||
},
|
||||
"scrollToBottom": "Cuộn xuống cuối"
|
||||
"scrollToBottom": "Cuộn xuống cuối",
|
||||
"loadEarlier": "Tải tin nhắn trước đó"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "đang truyền",
|
||||
|
||||
@ -323,7 +323,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "关闭目标"
|
||||
},
|
||||
"scrollToBottom": "滚动到底部"
|
||||
"scrollToBottom": "滚动到底部",
|
||||
"loadEarlier": "加载更早消息"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "流式输出中",
|
||||
|
||||
@ -303,7 +303,8 @@
|
||||
},
|
||||
"goalStateCloseAria": "關閉目標"
|
||||
},
|
||||
"scrollToBottom": "捲動到底部"
|
||||
"scrollToBottom": "捲動到底部",
|
||||
"loadEarlier": "載入更早訊息"
|
||||
},
|
||||
"message": {
|
||||
"streaming": "串流輸出中",
|
||||
|
||||
204
webui/src/tests/agent-activity-cluster.test.tsx
Normal file
204
webui/src/tests/agent-activity-cluster.test.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
|
||||
const rows: UIMessage[] = [
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: `thinking${extraReasoning}`,
|
||||
reasoningStreaming: true,
|
||||
isStreaming: true,
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "search()",
|
||||
traces: ["search()"],
|
||||
createdAt: 2,
|
||||
},
|
||||
];
|
||||
if (extraTool) rows.push(extraTool);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function installAnimationFrameQueue() {
|
||||
const originalRequest = window.requestAnimationFrame;
|
||||
const originalCancel = window.cancelAnimationFrame;
|
||||
const callbacks = new Map<number, FrameRequestCallback>();
|
||||
let nextId = 1;
|
||||
|
||||
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
callbacks.set(id, callback);
|
||||
return id;
|
||||
}) as typeof window.requestAnimationFrame;
|
||||
window.cancelAnimationFrame = ((id: number) => {
|
||||
callbacks.delete(id);
|
||||
}) as typeof window.cancelAnimationFrame;
|
||||
|
||||
return {
|
||||
flush() {
|
||||
const pending = Array.from(callbacks.entries());
|
||||
callbacks.clear();
|
||||
for (const [, callback] of pending) callback(0);
|
||||
},
|
||||
restore() {
|
||||
window.requestAnimationFrame = originalRequest;
|
||||
window.cancelAnimationFrame = originalCancel;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setScrollGeometry(
|
||||
element: HTMLElement,
|
||||
geometry: { scrollHeight: number; clientHeight: number; scrollTop?: number },
|
||||
) {
|
||||
Object.defineProperties(element, {
|
||||
scrollHeight: { configurable: true, value: geometry.scrollHeight },
|
||||
clientHeight: { configurable: true, value: geometry.clientHeight },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
value: geometry.scrollTop ?? element.scrollTop,
|
||||
writable: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("AgentActivityCluster", () => {
|
||||
it("jumps to the latest activity when opened", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(880);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("follows new reasoning and tool activity while the user is at the bottom", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages(" with more detail", {
|
||||
id: "t2",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "open_browser()",
|
||||
traces: ["open_browser()"],
|
||||
createdAt: 3,
|
||||
})}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1500,
|
||||
clientHeight: 120,
|
||||
scrollTop: scrollport.scrollTop,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(1380);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not pull the user down after they scroll up inside the activity pane", () => {
|
||||
const raf = installAnimationFrameQueue();
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages()}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /working/i }));
|
||||
const scrollport = screen.getByTestId("agent-activity-scroll");
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 120,
|
||||
scrollTop: 0,
|
||||
});
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
scrollport.scrollTop = 100;
|
||||
fireEvent.scroll(scrollport);
|
||||
|
||||
rerender(
|
||||
<AgentActivityCluster
|
||||
messages={activityMessages(" still streaming")}
|
||||
isTurnStreaming
|
||||
hasBodyBelow={false}
|
||||
/>,
|
||||
);
|
||||
setScrollGeometry(scrollport, {
|
||||
scrollHeight: 1500,
|
||||
clientHeight: 120,
|
||||
scrollTop: scrollport.scrollTop,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
raf.flush();
|
||||
});
|
||||
|
||||
expect(scrollport.scrollTop).toBe(100);
|
||||
} finally {
|
||||
raf.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -32,12 +32,18 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/useTheme", () => ({
|
||||
useTheme: () => ({
|
||||
theme: "light" as const,
|
||||
toggle: toggleThemeSpy,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/hooks/useTheme", async () => {
|
||||
const React = await import("react");
|
||||
return {
|
||||
ThemeProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
useTheme: () => ({
|
||||
theme: "light" as const,
|
||||
toggle: toggleThemeSpy,
|
||||
}),
|
||||
useThemeValue: () => "light" as const,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/bootstrap", () => ({
|
||||
fetchBootstrap: vi.fn().mockResolvedValue({
|
||||
|
||||
92
webui/src/tests/code-block.test.tsx
Normal file
92
webui/src/tests/code-block.test.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
import { ThemeProvider } from "@/hooks/useTheme";
|
||||
|
||||
const mockedStyles = vi.hoisted(() => ({
|
||||
dark: { pre: { background: "#111" } },
|
||||
light: { pre: { background: "#fff" } },
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/prism-async-light", () => ({
|
||||
default: ({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children: string;
|
||||
style: Record<string, unknown>;
|
||||
}) => (
|
||||
<pre
|
||||
data-testid="highlighted-code"
|
||||
data-theme={style === mockedStyles.dark ? "dark" : "light"}
|
||||
>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-dark", () => ({
|
||||
default: mockedStyles.dark,
|
||||
}));
|
||||
|
||||
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({
|
||||
default: mockedStyles.light,
|
||||
}));
|
||||
|
||||
describe("CodeBlock", () => {
|
||||
it("reads theme from context without creating per-block observers", async () => {
|
||||
const originalMutationObserver = globalThis.MutationObserver;
|
||||
const observer = vi.fn();
|
||||
class MockMutationObserver {
|
||||
constructor(callback: MutationCallback) {
|
||||
observer(callback);
|
||||
}
|
||||
|
||||
observe = vi.fn();
|
||||
|
||||
disconnect = vi.fn();
|
||||
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("MutationObserver", MockMutationObserver);
|
||||
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<ThemeProvider theme="dark">
|
||||
<CodeBlock language="ts" code="const value = 1;" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
|
||||
"data-theme",
|
||||
"dark",
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ThemeProvider theme="light">
|
||||
<CodeBlock language="ts" code="const value = 1;" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
|
||||
"data-theme",
|
||||
"light",
|
||||
);
|
||||
expect(observer).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.stubGlobal("MutationObserver", originalMutationObserver);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -131,7 +131,9 @@ describe("MessageBubble", () => {
|
||||
|
||||
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
|
||||
expect(container.querySelector(".reasoning-sheen-stripe")).toBeInTheDocument();
|
||||
expect(container.querySelector(".reasoning-sheen-stripe")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Thinking…")).toHaveClass("streaming-text-sheen");
|
||||
expect(screen.getByText("Thinking…")).toHaveAttribute("data-sheen-text", "Thinking…");
|
||||
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||
import {
|
||||
assistantCopyFlags,
|
||||
buildDisplayUnits,
|
||||
ThreadMessages,
|
||||
} from "@/components/thread/ThreadMessages";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
describe("ThreadMessages", () => {
|
||||
@ -89,4 +93,37 @@ describe("ThreadMessages", () => {
|
||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("computes final assistant copy flags with user-boundary semantics", () => {
|
||||
const units = buildDisplayUnits([
|
||||
{ id: "u1", role: "user", content: "one", createdAt: 1 },
|
||||
{ id: "a1", role: "assistant", content: "draft", createdAt: 2 },
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "tool()",
|
||||
traces: ["tool()"],
|
||||
createdAt: 3,
|
||||
},
|
||||
{ id: "a2", role: "assistant", content: "final", createdAt: 4 },
|
||||
{ id: "u2", role: "user", content: "two", createdAt: 5 },
|
||||
{ id: "a3", role: "assistant", content: "next", createdAt: 6 },
|
||||
]);
|
||||
|
||||
const flags = assistantCopyFlags(units);
|
||||
const assistantFlags = units
|
||||
.map((unit, index) =>
|
||||
unit.type === "single" && unit.message.role === "assistant"
|
||||
? [unit.message.id, flags[index]]
|
||||
: null,
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(assistantFlags).toEqual([
|
||||
["a1", false],
|
||||
["a2", true],
|
||||
["a3", true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import {
|
||||
HISTORY_WINDOW_INCREMENT,
|
||||
INITIAL_HISTORY_WINDOW,
|
||||
ThreadViewport,
|
||||
windowMessages,
|
||||
} from "@/components/thread/ThreadViewport";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
const messages: UIMessage[] = [
|
||||
@ -15,7 +20,191 @@ const messages: UIMessage[] = [
|
||||
|
||||
const emptyMessages: UIMessage[] = [];
|
||||
|
||||
interface ResizeObserverInstance {
|
||||
element?: Element;
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeLongMessages(count: number): UIMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `m${index}`,
|
||||
role: "user" as const,
|
||||
content: `message ${index}`,
|
||||
createdAt: index,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("ThreadViewport", () => {
|
||||
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const resizeObservers: ResizeObserverInstance[] = [];
|
||||
class MockResizeObserver {
|
||||
element?: Element;
|
||||
callback: ResizeObserverCallback;
|
||||
disconnect = vi.fn();
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback;
|
||||
resizeObservers.push(this);
|
||||
}
|
||||
|
||||
observe(element: Element) {
|
||||
this.element = element;
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
|
||||
try {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div>composer</div>}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: "Scroll to bottom" });
|
||||
expect(button).toHaveStyle({ bottom: "192px" });
|
||||
|
||||
const composerDock = screen.getByTestId("thread-composer-dock");
|
||||
composerDock.getBoundingClientRect = () =>
|
||||
({
|
||||
height: 240,
|
||||
width: 800,
|
||||
top: 0,
|
||||
right: 800,
|
||||
bottom: 240,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
const composerObserver = resizeObservers.find(
|
||||
(observer) => observer.element === composerDock,
|
||||
);
|
||||
expect(composerObserver).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
|
||||
});
|
||||
|
||||
expect(button).toHaveStyle({ bottom: "256px" });
|
||||
} finally {
|
||||
vi.stubGlobal("ResizeObserver", originalResizeObserver);
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={emptyMessages}
|
||||
isStreaming={false}
|
||||
composer={<div>composer</div>}
|
||||
emptyState={<div>welcome</div>}
|
||||
showScrollToBottomButton={false}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only the tail window for long history by default", () => {
|
||||
const longMessages = makeLongMessages(300);
|
||||
|
||||
render(
|
||||
<ThreadViewport
|
||||
messages={longMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("message 139")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("message 140")).toBeInTheDocument();
|
||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Load earlier messages" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads earlier history in fixed increments without rendering the whole transcript", () => {
|
||||
const longMessages = makeLongMessages(300);
|
||||
|
||||
render(
|
||||
<ThreadViewport
|
||||
messages={longMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load earlier messages" }));
|
||||
|
||||
const firstVisible =
|
||||
300 - INITIAL_HISTORY_WINDOW - HISTORY_WINDOW_INCREMENT;
|
||||
|
||||
expect(
|
||||
screen.queryByText(`message ${firstVisible - 1}`),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(`message ${firstVisible}`)).toBeInTheDocument();
|
||||
expect(screen.getByText("message 299")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("expands the window start to avoid cutting an agent activity cluster", () => {
|
||||
const clustered = makeLongMessages(200);
|
||||
clustered.splice(
|
||||
38,
|
||||
3,
|
||||
{
|
||||
id: "r0",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "first reasoning",
|
||||
createdAt: 38,
|
||||
},
|
||||
{
|
||||
id: "t0",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "tool()",
|
||||
traces: ["tool()"],
|
||||
createdAt: 39,
|
||||
},
|
||||
{
|
||||
id: "r1",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "second reasoning",
|
||||
createdAt: 40,
|
||||
},
|
||||
);
|
||||
|
||||
const visible = windowMessages(clustered, INITIAL_HISTORY_WINDOW);
|
||||
|
||||
expect(visible[0].id).toBe("r0");
|
||||
expect(visible).toHaveLength(INITIAL_HISTORY_WINDOW + 2);
|
||||
});
|
||||
|
||||
it("resets to the bottom when opening a different conversation", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
|
||||
110
webui/src/tests/useDeferredTitleRefresh.test.tsx
Normal file
110
webui/src/tests/useDeferredTitleRefresh.test.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
|
||||
import type { ChatSummary } from "@/lib/types";
|
||||
|
||||
function session(overrides: Partial<ChatSummary> = {}): ChatSummary {
|
||||
return {
|
||||
key: "websocket:chat-a",
|
||||
channel: "websocket",
|
||||
chatId: "chat-a",
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
title: "",
|
||||
preview: "First user message",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useDeferredTitleRefresh", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("retries refreshing untitled sessions after turn_end", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
useDeferredTitleRefresh(session(), refresh, [100, 300]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(200);
|
||||
});
|
||||
expect(refresh).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("stops pending retries once a generated title arrives", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeSession }) =>
|
||||
useDeferredTitleRefresh(activeSession, refresh, [100, 300]),
|
||||
{ initialProps: { activeSession: session() } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
rerender({ activeSession: session({ title: "Generated title" }) });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry when the active session already has a title", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result } = renderHook(() =>
|
||||
useDeferredTitleRefresh(session({ title: "Existing title" }), refresh, [100]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("clears pending retries when the active chat changes", () => {
|
||||
const refresh = vi.fn().mockResolvedValue(undefined);
|
||||
const { result, rerender } = renderHook(
|
||||
({ activeSession }) =>
|
||||
useDeferredTitleRefresh(activeSession, refresh, [100]),
|
||||
{ initialProps: { activeSession: session() } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current();
|
||||
});
|
||||
rerender({
|
||||
activeSession: session({
|
||||
key: "websocket:chat-b",
|
||||
chatId: "chat-b",
|
||||
}),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@ -83,7 +83,112 @@ function wrap(client: ReturnType<typeof fakeClient>["client"]) {
|
||||
};
|
||||
}
|
||||
|
||||
async function flushStreamFrame() {
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("useNanobotStream", () => {
|
||||
it("batches answer deltas into one animation-frame update", async () => {
|
||||
const fake = fakeClient();
|
||||
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
|
||||
const { result } = renderHook(() => useNanobotStream("chat-batch", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-batch", {
|
||||
event: "delta",
|
||||
chat_id: "chat-batch",
|
||||
text: "Hello",
|
||||
});
|
||||
fake.emit("chat-batch", {
|
||||
event: "delta",
|
||||
chat_id: "chat-batch",
|
||||
text: " world",
|
||||
});
|
||||
});
|
||||
|
||||
expect(requestFrame).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Hello world",
|
||||
isStreaming: true,
|
||||
});
|
||||
requestFrame.mockRestore();
|
||||
});
|
||||
|
||||
it("flushes pending delta text before turn_end finalizes the turn", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-flush", {
|
||||
event: "delta",
|
||||
chat_id: "chat-flush",
|
||||
text: "final chunk",
|
||||
});
|
||||
fake.emit("chat-flush", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-flush",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "final chunk",
|
||||
isStreaming: false,
|
||||
});
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("drops pending stream work when switching chats", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result, rerender } = renderHook(
|
||||
({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES),
|
||||
{
|
||||
wrapper: wrap(fake.client),
|
||||
initialProps: { chatId: "chat-old" },
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-old", {
|
||||
event: "delta",
|
||||
chat_id: "chat-old",
|
||||
text: "stale",
|
||||
});
|
||||
});
|
||||
|
||||
rerender({ chatId: "chat-new" });
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-new", {
|
||||
event: "delta",
|
||||
chat_id: "chat-new",
|
||||
text: "fresh",
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "fresh",
|
||||
});
|
||||
});
|
||||
|
||||
it("starts in streaming mode when history shows pending tool calls", () => {
|
||||
const fake = fakeClient();
|
||||
const initialMessages = [{
|
||||
@ -203,7 +308,7 @@ describe("useNanobotStream", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
|
||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@ -222,6 +327,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].role).toBe("assistant");
|
||||
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
|
||||
@ -328,7 +435,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("does not attach a new turn's reasoning across the latest user boundary", () => {
|
||||
it("does not attach a new turn's reasoning across the latest user boundary", async () => {
|
||||
const fake = fakeClient();
|
||||
const initialMessages = [
|
||||
{
|
||||
@ -358,6 +465,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
|
||||
expect(result.current.messages[2].role).toBe("assistant");
|
||||
@ -366,7 +475,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[2].reasoningStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("does not attach reasoning across a tool trace boundary", () => {
|
||||
it("does not attach reasoning across a tool trace boundary", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@ -392,6 +501,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
|
||||
"message",
|
||||
@ -651,7 +762,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].content).toBe("long task");
|
||||
});
|
||||
|
||||
it("keeps streaming alive across stream_end and completes on turn_end", () => {
|
||||
it("keeps streaming alive across stream_end and completes on turn_end", async () => {
|
||||
const fake = fakeClient();
|
||||
const onTurnEnd = vi.fn();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
|
||||
@ -666,6 +777,8 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.isStreaming).toBe(true);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
|
||||
22
webui/src/types/react-syntax-highlighter-subpaths.d.ts
vendored
Normal file
22
webui/src/types/react-syntax-highlighter-subpaths.d.ts
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
declare module "react-syntax-highlighter/dist/esm/prism-async-light" {
|
||||
import * as React from "react";
|
||||
import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
|
||||
|
||||
export default class SyntaxHighlighter extends React.Component<SyntaxHighlighterProps> {
|
||||
static registerLanguage(name: string, func: unknown): void;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "react-syntax-highlighter/dist/esm/styles/prism/one-dark" {
|
||||
import type * as React from "react";
|
||||
|
||||
const style: { [key: string]: React.CSSProperties };
|
||||
export default style;
|
||||
}
|
||||
|
||||
declare module "react-syntax-highlighter/dist/esm/styles/prism/one-light" {
|
||||
import type * as React from "react";
|
||||
|
||||
const style: { [key: string]: React.CSSProperties };
|
||||
export default style;
|
||||
}
|
||||
@ -25,6 +25,36 @@ export default defineConfig(({ mode }) => {
|
||||
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
|
||||
emptyOutDir: true,
|
||||
sourcemap: false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes("node_modules/refractor/lang/")) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
id.includes("node_modules/react-syntax-highlighter")
|
||||
|| id.includes("node_modules/refractor/core")
|
||||
) {
|
||||
return "syntax-highlight";
|
||||
}
|
||||
if (
|
||||
id.includes("node_modules/react-markdown")
|
||||
|| id.includes("node_modules/remark-")
|
||||
|| id.includes("node_modules/rehype-")
|
||||
|| id.includes("node_modules/unified")
|
||||
|| id.includes("node_modules/mdast-")
|
||||
|| id.includes("node_modules/hast-")
|
||||
|| id.includes("node_modules/micromark")
|
||||
|| id.includes("node_modules/unist-")
|
||||
) {
|
||||
return "markdown-vendor";
|
||||
}
|
||||
if (id.includes("node_modules/katex")) {
|
||||
return "katex";
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user