mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 08:32:25 +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 { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
|
|
||||||
import { useSessions } from "@/hooks/useSessions";
|
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 { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
clearSavedSecret,
|
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 { t, i18n } = useTranslation();
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
@ -362,9 +369,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
});
|
});
|
||||||
}, [client, t]);
|
}, [client, t]);
|
||||||
|
|
||||||
const onTurnEnd = useCallback(() => {
|
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const onConfirmDelete = useCallback(async () => {
|
const onConfirmDelete = useCallback(async () => {
|
||||||
if (!pendingDelete) return;
|
if (!pendingDelete) return;
|
||||||
@ -415,93 +420,95 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
|
|||||||
const showMainSidebar = view !== "settings";
|
const showMainSidebar = view !== "settings";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full overflow-hidden">
|
<ThemeProvider theme={theme}>
|
||||||
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
<div className="relative flex h-full w-full overflow-hidden">
|
||||||
{showMainSidebar ? (
|
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
|
||||||
<aside
|
{showMainSidebar ? (
|
||||||
className={cn(
|
<aside
|
||||||
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
className={cn(
|
||||||
"transition-[width] duration-300 ease-out",
|
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
|
||||||
)}
|
"transition-[width] duration-300 ease-out",
|
||||||
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
|
)}
|
||||||
>
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
|
"absolute inset-0 flex flex-col",
|
||||||
"transition-transform duration-300 ease-out",
|
view === "settings" && "invisible pointer-events-none",
|
||||||
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
|
||||||
)}
|
)}
|
||||||
style={{ width: SIDEBAR_WIDTH }}
|
|
||||||
>
|
>
|
||||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
<ThreadShell
|
||||||
</div>
|
session={activeSession}
|
||||||
</aside>
|
title={headerTitle}
|
||||||
) : null}
|
onToggleSidebar={toggleSidebar}
|
||||||
|
onNewChat={onNewChat}
|
||||||
{showMainSidebar ? (
|
onCreateChat={onCreateChat}
|
||||||
<Sheet
|
onTurnEnd={onTurnEnd}
|
||||||
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
|
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
onBackToChat={onBackToChat}
|
hideSidebarToggleOnDesktop={desktopSidebarOpen}
|
||||||
onModelNameChange={onModelNameChange}
|
|
||||||
onLogout={onLogout}
|
|
||||||
onRestart={onRestart}
|
|
||||||
isRestarting={isRestarting}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{view === "settings" && (
|
||||||
</main>
|
<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
|
<DeleteConfirm
|
||||||
open={!!pendingDelete}
|
open={!!pendingDelete}
|
||||||
title={pendingDelete?.label ?? ""}
|
title={pendingDelete?.label ?? ""}
|
||||||
onCancel={() => setPendingDelete(null)}
|
onCancel={() => setPendingDelete(null)}
|
||||||
onConfirm={onConfirmDelete}
|
onConfirm={onConfirmDelete}
|
||||||
/>
|
/>
|
||||||
{restartToast ? (
|
{restartToast ? (
|
||||||
<div
|
<div
|
||||||
role="status"
|
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"
|
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}
|
{restartToast}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 { Check, Copy } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
@ -15,30 +11,59 @@ interface CodeBlockProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */
|
interface HighlightedCodeProps {
|
||||||
function useIsDark() {
|
language?: string;
|
||||||
const [isDark, setIsDark] = useState(() =>
|
code: string;
|
||||||
typeof document !== "undefined"
|
isDark: boolean;
|
||||||
? document.documentElement.classList.contains("dark")
|
}
|
||||||
: true,
|
|
||||||
|
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) {
|
export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const isDark = useIsDark();
|
const isDark = useThemeValue() === "dark";
|
||||||
|
|
||||||
const onCopy = useCallback(() => {
|
const onCopy = useCallback(() => {
|
||||||
if (!navigator.clipboard) return;
|
if (!navigator.clipboard) return;
|
||||||
@ -86,20 +111,9 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
|
|||||||
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
<span>{copied ? t("code.copied") : t("code.copy")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<SyntaxHighlighter
|
<Suspense fallback={<PlainCodeFallback code={code} />}>
|
||||||
language={language}
|
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
|
||||||
style={isDark ? oneDark : oneLight}
|
</Suspense>
|
||||||
customStyle={{
|
|
||||||
margin: 0,
|
|
||||||
padding: "1rem",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
PreTag="pre"
|
|
||||||
wrapLongLines
|
|
||||||
>
|
|
||||||
{code}
|
|
||||||
</SyntaxHighlighter>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -167,10 +167,15 @@ function MessageMedia({
|
|||||||
align: "left" | "right";
|
align: "left" | "right";
|
||||||
}) {
|
}) {
|
||||||
if (media.length === 0) return null;
|
if (media.length === 0) return null;
|
||||||
const images = media
|
const images: UIImage[] = [];
|
||||||
.filter((item) => item.kind === "image")
|
const nonImages: UIMediaAttachment[] = [];
|
||||||
.map(({ url, name }) => ({ url, name }));
|
for (const item of media) {
|
||||||
const nonImages = media.filter((item) => item.kind !== "image");
|
if (item.kind === "image") {
|
||||||
|
images.push({ url: item.url, name: item.name });
|
||||||
|
} else {
|
||||||
|
nonImages.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -276,13 +281,14 @@ function UserImages({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Only real-URL images can open in the lightbox; historical-replay
|
// Only real-URL images can open in the lightbox; historical-replay
|
||||||
// placeholders (no URL) have nothing to zoom into.
|
// placeholders (no URL) have nothing to zoom into.
|
||||||
const viewable = images
|
const viewableImages: UIImage[] = [];
|
||||||
.map((img, i) => ({ img, i }))
|
const originalToViewable = new Map<number, number>();
|
||||||
.filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
|
for (let i = 0; i < images.length; i += 1) {
|
||||||
const viewableImages = viewable.map(({ img }) => img);
|
const img = images[i];
|
||||||
const originalToViewable = new Map<number, number>(
|
if (typeof img.url !== "string" || img.url.length === 0) continue;
|
||||||
viewable.map(({ i }, v) => [i, v]),
|
originalToViewable.set(i, viewableImages.length);
|
||||||
);
|
viewableImages.push(img);
|
||||||
|
}
|
||||||
|
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
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({
|
export function StreamingLabelSheen({
|
||||||
children,
|
children,
|
||||||
active,
|
active,
|
||||||
@ -426,21 +432,21 @@ export function StreamingLabelSheen({
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const sheenText =
|
||||||
|
typeof children === "string" || typeof children === "number"
|
||||||
|
? String(children)
|
||||||
|
: undefined;
|
||||||
return (
|
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
|
<span
|
||||||
|
data-sheen-text={active ? sheenText : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative z-0 block font-medium leading-normal text-muted-foreground",
|
"block w-fit max-w-full truncate font-medium leading-normal",
|
||||||
!active && "truncate",
|
active ? "streaming-text-sheen" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
{active ? (
|
|
||||||
<span className="reasoning-sheen-track" aria-hidden dir="ltr">
|
|
||||||
<span className="reasoning-sheen-stripe" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { ChevronRight, Layers } from "lucide-react";
|
import { ChevronRight, Layers } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
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). */
|
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
|
||||||
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
|
||||||
|
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
|
||||||
|
|
||||||
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
|
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
|
||||||
if (m.role !== "assistant" || m.kind === "trace") return false;
|
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";
|
return isReasoningOnlyAssistant(m) || m.kind === "trace";
|
||||||
}
|
}
|
||||||
|
|
||||||
function countToolCalls(messages: UIMessage[]): number {
|
function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCalls: number } {
|
||||||
let n = 0;
|
let reasoningSteps = 0;
|
||||||
|
let toolCalls = 0;
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
if (m.kind !== "trace") continue;
|
if (isReasoningOnlyAssistant(m)) {
|
||||||
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
|
reasoningSteps += 1;
|
||||||
n += Math.max(lines, 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 {
|
interface AgentActivityClusterProps {
|
||||||
@ -46,11 +53,14 @@ export function AgentActivityCluster({
|
|||||||
hasBodyBelow,
|
hasBodyBelow,
|
||||||
}: AgentActivityClusterProps) {
|
}: AgentActivityClusterProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length;
|
const { reasoningSteps, toolCalls } = countActivity(messages);
|
||||||
const toolCalls = countToolCalls(messages);
|
|
||||||
|
|
||||||
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
const [userToggledOuter, setUserToggledOuter] = useState(false);
|
||||||
const [outerOpenLocal, setOuterOpenLocal] = 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. */
|
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
|
||||||
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
|
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
|
||||||
|
|
||||||
@ -79,11 +89,66 @@ export function AgentActivityCluster({
|
|||||||
defaultValue: "{{tools}} tool calls",
|
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 toggleOuter = () => {
|
||||||
|
const nextOpen = userToggledOuter ? !outerOpenLocal : !outerExpanded;
|
||||||
|
if (nextOpen) {
|
||||||
|
autoFollowActivityRef.current = true;
|
||||||
|
}
|
||||||
setUserToggledOuter(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 (
|
return (
|
||||||
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
|
||||||
<button
|
<button
|
||||||
@ -118,12 +183,15 @@ export function AgentActivityCluster({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={activityScrollRef}
|
||||||
|
data-testid="agent-activity-scroll"
|
||||||
|
onScroll={onActivityScroll}
|
||||||
className={cn(
|
className={cn(
|
||||||
CLUSTER_SCROLL_MAX_CLASS,
|
CLUSTER_SCROLL_MAX_CLASS,
|
||||||
"overflow-y-auto px-2 py-1.5 scrollbar-thin scrollbar-track-transparent",
|
"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) => {
|
{messages.map((m) => {
|
||||||
if (isReasoningOnlyAssistant(m)) {
|
if (isReasoningOnlyAssistant(m)) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { MessageBubble } from "@/components/MessageBubble";
|
import { MessageBubble } from "@/components/MessageBubble";
|
||||||
import {
|
import {
|
||||||
AgentActivityCluster,
|
AgentActivityCluster,
|
||||||
@ -9,6 +12,8 @@ interface ThreadMessagesProps {
|
|||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
/** When true, agent turn still in flight — keeps activity cluster expanded. */
|
/** When true, agent turn still in flight — keeps activity cluster expanded. */
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
hiddenMessageCount?: number;
|
||||||
|
onLoadEarlier?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DisplayUnit =
|
export type DisplayUnit =
|
||||||
@ -30,7 +35,7 @@ export function isFinalAssistantSliceBeforeNextUser(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
||||||
const out: DisplayUnit[] = [];
|
const out: DisplayUnit[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < messages.length) {
|
while (i < messages.length) {
|
||||||
@ -50,11 +55,49 @@ function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThreadMessages({ messages, isStreaming = false }: ThreadMessagesProps) {
|
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
|
||||||
const units = buildDisplayUnits(messages);
|
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 (
|
return (
|
||||||
<div className="flex w-full flex-col">
|
<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) => {
|
{units.map((unit, index) => {
|
||||||
const prev = units[index - 1];
|
const prev = units[index - 1];
|
||||||
const marginTop =
|
const marginTop =
|
||||||
@ -80,7 +123,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
|
|||||||
message={unit.message}
|
message={unit.message}
|
||||||
showAssistantCopyAction={
|
showAssistantCopyAction={
|
||||||
unit.message.role === "assistant"
|
unit.message.role === "assistant"
|
||||||
? isFinalAssistantSliceBeforeNextUser(units, index)
|
? copyFlags[index]
|
||||||
: true
|
: true
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -389,6 +389,7 @@ export function ThreadShell({
|
|||||||
composer={composer}
|
composer={composer}
|
||||||
scrollToBottomSignal={scrollToBottomSignal}
|
scrollToBottomSignal={scrollToBottomSignal}
|
||||||
conversationKey={historyKey}
|
conversationKey={historyKey}
|
||||||
|
showScrollToBottomButton={!!session}
|
||||||
/>
|
/>
|
||||||
</section>
|
</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 { ArrowDown } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
import { ThreadMessages } from "@/components/thread/ThreadMessages";
|
||||||
|
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { UIMessage } from "@/lib/types";
|
import type { UIMessage } from "@/lib/types";
|
||||||
@ -14,9 +23,27 @@ interface ThreadViewportProps {
|
|||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
scrollToBottomSignal?: number;
|
scrollToBottomSignal?: number;
|
||||||
conversationKey?: string | null;
|
conversationKey?: string | null;
|
||||||
|
showScrollToBottomButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
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({
|
export function ThreadViewport({
|
||||||
messages,
|
messages,
|
||||||
@ -25,18 +52,33 @@ export function ThreadViewport({
|
|||||||
emptyState,
|
emptyState,
|
||||||
scrollToBottomSignal = 0,
|
scrollToBottomSignal = 0,
|
||||||
conversationKey = null,
|
conversationKey = null,
|
||||||
|
showScrollToBottomButton = true,
|
||||||
}: ThreadViewportProps) {
|
}: ThreadViewportProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const composerDockRef = useRef<HTMLDivElement>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||||
const pendingConversationScrollRef = useRef(true);
|
const pendingConversationScrollRef = useRef(true);
|
||||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
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). */
|
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
|
||||||
const userReadingHistoryRef = useRef(false);
|
const userReadingHistoryRef = useRef(false);
|
||||||
const [atBottom, setAtBottom] = useState(true);
|
const [atBottom, setAtBottom] = useState(true);
|
||||||
|
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
||||||
|
const [visibleMessageCount, setVisibleMessageCount] =
|
||||||
|
useState(INITIAL_HISTORY_WINDOW);
|
||||||
const hasMessages = messages.length > 0;
|
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(() => {
|
const cancelScheduledBottomScroll = useCallback(() => {
|
||||||
for (const id of scrollFrameIdsRef.current) {
|
for (const id of scrollFrameIdsRef.current) {
|
||||||
@ -77,6 +119,30 @@ export function ThreadViewport({
|
|||||||
[cancelScheduledBottomScroll, scrollToBottomNow],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!atBottom) return;
|
if (!atBottom) return;
|
||||||
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
||||||
@ -96,8 +162,19 @@ export function ThreadViewport({
|
|||||||
pendingConversationScrollRef.current = true;
|
pendingConversationScrollRef.current = true;
|
||||||
userReadingHistoryRef.current = false;
|
userReadingHistoryRef.current = false;
|
||||||
setAtBottom(true);
|
setAtBottom(true);
|
||||||
|
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
|
||||||
}, [conversationKey]);
|
}, [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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!pendingConversationScrollRef.current) return;
|
if (!pendingConversationScrollRef.current) return;
|
||||||
if (!conversationKey) {
|
if (!conversationKey) {
|
||||||
@ -110,6 +187,10 @@ export function ThreadViewport({
|
|||||||
pendingConversationScrollRef.current = false;
|
pendingConversationScrollRef.current = false;
|
||||||
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
measureComposerDock();
|
||||||
|
}, [composer, hasMessages, measureComposerDock]);
|
||||||
|
|
||||||
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -123,6 +204,14 @@ export function ThreadViewport({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [hasMessages, scrollToBottom]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
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 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="flex-1 px-4 pb-20 pt-4">
|
||||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
<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>
|
</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">
|
<div className="px-4 pb-3">
|
||||||
{composer}
|
{composer}
|
||||||
</div>
|
</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"
|
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!atBottom && (
|
{showScrollToBottomButton && !atBottom && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => scrollToBottom(true, 1, { force: true })}
|
onClick={() => scrollToBottom(true, 1, { force: true })}
|
||||||
className={cn(
|
className={cn(
|
||||||
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
|
/* 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",
|
"bg-background/90 backdrop-blur",
|
||||||
"animate-in fade-in-0 zoom-in-95",
|
"animate-in fade-in-0 zoom-in-95",
|
||||||
)}
|
)}
|
||||||
|
style={{ bottom: scrollButtonBottom }}
|
||||||
aria-label={t("thread.scrollToBottom")}
|
aria-label={t("thread.scrollToBottom")}
|
||||||
>
|
>
|
||||||
<ArrowDown className="h-4 w-4" />
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
|||||||
@ -117,53 +117,60 @@
|
|||||||
--cjk-line-height: 1.625;
|
--cjk-line-height: 1.625;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* L→R sheen over solid label text (overlay stripe). Avoids ``background-clip:
|
/* L→R sheen clipped to live activity labels. The highlight lives inside
|
||||||
text`` loop seams that read as RTL “erase” or one-frame transparent glyphs. */
|
the glyphs, not in the row background, so dark mode stays quiet. */
|
||||||
@keyframes reasoning-sheen-ltr {
|
@keyframes streaming-text-sheen-ltr {
|
||||||
0% {
|
0% {
|
||||||
left: -44%;
|
background-position: 140% 50%;
|
||||||
}
|
}
|
||||||
100% {
|
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;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 2px;
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
color: transparent;
|
||||||
.reasoning-sheen-stripe {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 44%;
|
|
||||||
min-width: 3.25rem;
|
|
||||||
left: -44%;
|
|
||||||
border-radius: inherit;
|
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
transparent 0%,
|
transparent 0%,
|
||||||
hsl(0 0% 100% / 0.07) 34%,
|
transparent 38%,
|
||||||
hsl(0 0% 100% / 0.76) 50%,
|
hsl(var(--foreground) / 0.98) 50%,
|
||||||
hsl(0 0% 100% / 0.07) 66%,
|
transparent 62%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
mix-blend-mode: soft-light;
|
background-size: 260% 100%;
|
||||||
opacity: 0.95;
|
background-position: 140% 50%;
|
||||||
animation: reasoning-sheen-ltr 5.2s linear infinite;
|
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 {
|
.dark .streaming-text-sheen::after {
|
||||||
mix-blend-mode: overlay;
|
background-image: linear-gradient(
|
||||||
opacity: 1;
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 38%,
|
||||||
|
hsl(var(--foreground) / 0.98) 50%,
|
||||||
|
transparent 62%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.reasoning-sheen-stripe {
|
.streaming-text-sheen::after {
|
||||||
animation: none;
|
animation: none;
|
||||||
opacity: 0;
|
content: "";
|
||||||
visibility: hidden;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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;
|
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. */
|
/** 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) {
|
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||||
const m = prev[i];
|
const m = prev[i];
|
||||||
if (m.kind === "trace") continue;
|
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;
|
if (m.role === "user") break;
|
||||||
}
|
}
|
||||||
return null;
|
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
|
* the model already produced an answer in a previous turn, so the new
|
||||||
* delta belongs in a fresh row.
|
* delta belongs in a fresh row.
|
||||||
*/
|
*/
|
||||||
function findActiveAssistantPlaceholder(prev: UIMessage[]): string | null {
|
function findActiveAssistantPlaceholderIndex(prev: UIMessage[]): number | null {
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (!last) return null;
|
if (!last) return null;
|
||||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
if (last.role !== "assistant" || last.kind === "trace") return null;
|
||||||
if (last.content.length > 0) return null;
|
if (last.content.length > 0) return null;
|
||||||
if (!last.isStreaming) 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 [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
|
||||||
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
const [streamError, setStreamError] = useState<StreamError | null>(null);
|
||||||
const buffer = useRef<StreamBuffer | 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);
|
const suppressStreamUntilTurnEndRef = useRef(false);
|
||||||
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
|
||||||
*
|
*
|
||||||
@ -255,6 +273,115 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
const dismissStreamError = useCallback(() => setStreamError(null), []);
|
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
|
// Reset local state when switching chats. Do not reset on every
|
||||||
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
// ``initialMessages`` update: a brand-new chat can receive an empty/404
|
||||||
// history response after the optimistic first message has already rendered.
|
// history response after the optimistic first message has already rendered.
|
||||||
@ -269,13 +396,15 @@ export function useNanobotStream(
|
|||||||
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
||||||
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
|
clearPendingStreamWork();
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
if (streamEndTimerRef.current !== null) {
|
if (streamEndTimerRef.current !== null) {
|
||||||
clearTimeout(streamEndTimerRef.current);
|
clearTimeout(streamEndTimerRef.current);
|
||||||
streamEndTimerRef.current = null;
|
streamEndTimerRef.current = null;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [chatId, client]);
|
}, [chatId, client, clearPendingStreamWork]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasPendingToolCalls) setIsStreaming(true);
|
if (hasPendingToolCalls) setIsStreaming(true);
|
||||||
@ -296,44 +425,25 @@ export function useNanobotStream(
|
|||||||
if (ev.event === "delta") {
|
if (ev.event === "delta") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
const chunk = typeof ev.text === "string" ? ev.text : "";
|
const chunk = typeof ev.text === "string" ? ev.text : "";
|
||||||
|
if (!chunk) return;
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setMessages((prev) => {
|
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
|
||||||
const adopted = findActiveAssistantPlaceholder(prev);
|
schedulePendingStreamFlush();
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return;
|
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 (ev.event === "stream_end") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) {
|
if (suppressStreamUntilTurnEndRef.current) {
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
@ -347,15 +457,6 @@ export function useNanobotStream(
|
|||||||
return;
|
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 (ev.event === "reasoning_end") {
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
setMessages((prev) => closeReasoningStream(prev));
|
setMessages((prev) => closeReasoningStream(prev));
|
||||||
@ -393,6 +494,8 @@ export function useNanobotStream(
|
|||||||
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
|
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
|
||||||
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
|
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
|
||||||
}
|
}
|
||||||
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
return finalized;
|
return finalized;
|
||||||
});
|
});
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
@ -459,11 +562,12 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
// A complete (non-streamed) assistant message. If a stream was in
|
// A complete (non-streamed) assistant message. If a stream was in
|
||||||
// flight, drop the placeholder so we don't render the text twice.
|
// 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
|
// Do NOT reset isStreaming here — only ``turn_end`` signals that
|
||||||
// the full turn (all tool calls + final text) is complete.
|
// the full turn (all tool calls + final text) is complete.
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
|
const activeId = buffer.current?.messageId;
|
||||||
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
||||||
const content = ev.text;
|
const content = ev.text;
|
||||||
const lat =
|
const lat =
|
||||||
@ -489,12 +593,21 @@ export function useNanobotStream(
|
|||||||
return () => {
|
return () => {
|
||||||
unsub();
|
unsub();
|
||||||
buffer.current = null;
|
buffer.current = null;
|
||||||
|
activeAssistantRef.current = null;
|
||||||
|
clearPendingStreamWork();
|
||||||
if (streamEndTimerRef.current !== null) {
|
if (streamEndTimerRef.current !== null) {
|
||||||
clearTimeout(streamEndTimerRef.current);
|
clearTimeout(streamEndTimerRef.current);
|
||||||
streamEndTimerRef.current = null;
|
streamEndTimerRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [chatId, client, onTurnEnd]);
|
}, [
|
||||||
|
chatId,
|
||||||
|
client,
|
||||||
|
clearPendingStreamWork,
|
||||||
|
flushPendingStreamEvents,
|
||||||
|
onTurnEnd,
|
||||||
|
schedulePendingStreamFlush,
|
||||||
|
]);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||||
@ -504,17 +617,22 @@ export function useNanobotStream(
|
|||||||
// the image blocks via ``media`` paths.
|
// the image blocks via ``media`` paths.
|
||||||
if (!hasImages && !content.trim()) return;
|
if (!hasImages && !content.trim()) return;
|
||||||
|
|
||||||
|
flushPendingStreamEvents();
|
||||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||||
setMessages((prev) => [
|
setMessages((prev) => {
|
||||||
...pruneReasoningOnlyPlaceholders(prev),
|
buffer.current = null;
|
||||||
{
|
activeAssistantRef.current = null;
|
||||||
id: crypto.randomUUID(),
|
return [
|
||||||
role: "user",
|
...pruneReasoningOnlyPlaceholders(prev),
|
||||||
content,
|
{
|
||||||
createdAt: Date.now(),
|
id: crypto.randomUUID(),
|
||||||
...(previews ? { images: previews } : {}),
|
role: "user",
|
||||||
},
|
content,
|
||||||
]);
|
createdAt: Date.now(),
|
||||||
|
...(previews ? { images: previews } : {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
// Mark streaming immediately so the UI shows the loading indicator
|
// Mark streaming immediately so the UI shows the loading indicator
|
||||||
// right away, before the first delta arrives from the server.
|
// right away, before the first delta arrives from the server.
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
@ -525,18 +643,21 @@ export function useNanobotStream(
|
|||||||
client.sendMessage(chatId, content, wireMedia);
|
client.sendMessage(chatId, content, wireMedia);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[chatId, client],
|
[chatId, client, flushPendingStreamEvents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
|
flushPendingStreamEvents();
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setMessages((prev) =>
|
setMessages((prev) => {
|
||||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
|
buffer.current = null;
|
||||||
);
|
activeAssistantRef.current = null;
|
||||||
|
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||||
|
});
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
client.sendMessage(chatId, "/stop");
|
client.sendMessage(chatId, "/stop");
|
||||||
}, [chatId, client]);
|
}, [chatId, client, flushPendingStreamEvents]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
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";
|
type Theme = "light" | "dark";
|
||||||
const STORAGE_KEY = "nanobot-webui.theme";
|
const STORAGE_KEY = "nanobot-webui.theme";
|
||||||
|
const ThemeContext = createContext<Theme>("light");
|
||||||
|
|
||||||
function readStored(): Theme | null {
|
function readStored(): Theme | null {
|
||||||
try {
|
try {
|
||||||
@ -18,7 +27,11 @@ function applyTheme(theme: Theme): void {
|
|||||||
else root.classList.remove("dark");
|
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 [theme, setThemeState] = useState<Theme>(() => {
|
||||||
const stored = readStored();
|
const stored = readStored();
|
||||||
if (stored) return stored;
|
if (stored) return stored;
|
||||||
@ -46,3 +59,11 @@ export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Th
|
|||||||
);
|
);
|
||||||
return { theme, toggle, setTheme };
|
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"
|
"io": "Couldn't read this file"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Scroll to bottom"
|
"scrollToBottom": "Scroll to bottom",
|
||||||
|
"loadEarlier": "Load earlier messages"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "streaming",
|
"streaming": "streaming",
|
||||||
|
|||||||
@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Cerrar objetivo"
|
"goalStateCloseAria": "Cerrar objetivo"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Desplazarse al final"
|
"scrollToBottom": "Desplazarse al final",
|
||||||
|
"loadEarlier": "Cargar mensajes anteriores"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "transmitiendo",
|
"streaming": "transmitiendo",
|
||||||
|
|||||||
@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Fermer l’objectif"
|
"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": {
|
"message": {
|
||||||
"streaming": "en cours de génération",
|
"streaming": "en cours de génération",
|
||||||
|
|||||||
@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Tutup tujuan"
|
"goalStateCloseAria": "Tutup tujuan"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "Gulir ke bawah"
|
"scrollToBottom": "Gulir ke bawah",
|
||||||
|
"loadEarlier": "Muat pesan sebelumnya"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "sedang mengalir",
|
"streaming": "sedang mengalir",
|
||||||
|
|||||||
@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "目標を閉じる"
|
"goalStateCloseAria": "目標を閉じる"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "一番下へスクロール"
|
"scrollToBottom": "一番下へスクロール",
|
||||||
|
"loadEarlier": "以前のメッセージを読み込む"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "生成中",
|
"streaming": "生成中",
|
||||||
|
|||||||
@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "목표 닫기"
|
"goalStateCloseAria": "목표 닫기"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "맨 아래로 스크롤"
|
"scrollToBottom": "맨 아래로 스크롤",
|
||||||
|
"loadEarlier": "이전 메시지 불러오기"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "생성 중",
|
"streaming": "생성 중",
|
||||||
|
|||||||
@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "Đóng mục tiêu"
|
"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": {
|
"message": {
|
||||||
"streaming": "đang truyền",
|
"streaming": "đang truyền",
|
||||||
|
|||||||
@ -323,7 +323,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "关闭目标"
|
"goalStateCloseAria": "关闭目标"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "滚动到底部"
|
"scrollToBottom": "滚动到底部",
|
||||||
|
"loadEarlier": "加载更早消息"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "流式输出中",
|
"streaming": "流式输出中",
|
||||||
|
|||||||
@ -303,7 +303,8 @@
|
|||||||
},
|
},
|
||||||
"goalStateCloseAria": "關閉目標"
|
"goalStateCloseAria": "關閉目標"
|
||||||
},
|
},
|
||||||
"scrollToBottom": "捲動到底部"
|
"scrollToBottom": "捲動到底部",
|
||||||
|
"loadEarlier": "載入更早訊息"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"streaming": "串流輸出中",
|
"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", () => ({
|
vi.mock("@/hooks/useTheme", async () => {
|
||||||
useTheme: () => ({
|
const React = await import("react");
|
||||||
theme: "light" as const,
|
return {
|
||||||
toggle: toggleThemeSpy,
|
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", () => ({
|
vi.mock("@/lib/bootstrap", () => ({
|
||||||
fetchBootstrap: vi.fn().mockResolvedValue({
|
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("Thinking…")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Step 1: parse intent\./)).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");
|
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
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";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
describe("ThreadMessages", () => {
|
describe("ThreadMessages", () => {
|
||||||
@ -89,4 +93,37 @@ describe("ThreadMessages", () => {
|
|||||||
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
render(<ThreadMessages messages={messages} isStreaming={false} />);
|
||||||
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
|
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 { 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";
|
import type { UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
const messages: UIMessage[] = [
|
const messages: UIMessage[] = [
|
||||||
@ -15,7 +20,191 @@ const messages: UIMessage[] = [
|
|||||||
|
|
||||||
const emptyMessages: 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", () => {
|
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 () => {
|
it("resets to the bottom when opening a different conversation", async () => {
|
||||||
const scrollIntoView = vi.fn();
|
const scrollIntoView = vi.fn();
|
||||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
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", () => {
|
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", () => {
|
it("starts in streaming mode when history shows pending tool calls", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const initialMessages = [{
|
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 fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
||||||
wrapper: wrap(fake.client),
|
wrapper: wrap(fake.client),
|
||||||
@ -222,6 +327,8 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await flushStreamFrame();
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(1);
|
expect(result.current.messages).toHaveLength(1);
|
||||||
expect(result.current.messages[0].role).toBe("assistant");
|
expect(result.current.messages[0].role).toBe("assistant");
|
||||||
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
|
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);
|
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 fake = fakeClient();
|
||||||
const initialMessages = [
|
const initialMessages = [
|
||||||
{
|
{
|
||||||
@ -358,6 +465,8 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await flushStreamFrame();
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.messages).toHaveLength(3);
|
||||||
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
|
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
|
||||||
expect(result.current.messages[2].role).toBe("assistant");
|
expect(result.current.messages[2].role).toBe("assistant");
|
||||||
@ -366,7 +475,7 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.messages[2].reasoningStreaming).toBe(true);
|
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 fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
|
||||||
wrapper: wrap(fake.client),
|
wrapper: wrap(fake.client),
|
||||||
@ -392,6 +501,8 @@ describe("useNanobotStream", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await flushStreamFrame();
|
||||||
|
|
||||||
expect(result.current.messages).toHaveLength(3);
|
expect(result.current.messages).toHaveLength(3);
|
||||||
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
|
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
|
||||||
"message",
|
"message",
|
||||||
@ -651,7 +762,7 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.messages[0].content).toBe("long task");
|
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 fake = fakeClient();
|
||||||
const onTurnEnd = vi.fn();
|
const onTurnEnd = vi.fn();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
|
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.isStreaming).toBe(true);
|
||||||
expect(result.current.messages[0]).toMatchObject({
|
expect(result.current.messages[0]).toMatchObject({
|
||||||
role: "assistant",
|
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"),
|
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
sourcemap: false,
|
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: {
|
server: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user