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:
Xubin Ren 2026-05-17 17:04:57 +08:00
parent 175b58e259
commit e5be4dac7a
30 changed files with 1551 additions and 282 deletions

View File

@ -7,7 +7,8 @@ import { ThreadShell } from "@/components/thread/ThreadShell";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useSessions } from "@/hooks/useSessions";
import { useTheme } from "@/hooks/useTheme";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import { ThemeProvider, useTheme } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
import {
clearSavedSecret,
@ -219,7 +220,13 @@ export default function App() {
);
}
function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName: string | null) => void; onLogout: () => void }) {
function Shell({
onModelNameChange,
onLogout,
}: {
onModelNameChange: (modelName: string | null) => void;
onLogout: () => void;
}) {
const { t, i18n } = useTranslation();
const { client } = useClient();
const { theme, toggle } = useTheme();
@ -362,9 +369,7 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
});
}, [client, t]);
const onTurnEnd = useCallback(() => {
void refresh();
}, [refresh]);
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
@ -415,93 +420,95 @@ function Shell({ onModelNameChange, onLogout }: { onModelNameChange: (modelName:
const showMainSidebar = view !== "settings";
return (
<div className="relative flex h-full w-full overflow-hidden">
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
>
<ThemeProvider theme={theme}>
<div className="relative flex h-full w-full overflow-hidden">
{/* Desktop sidebar: in normal flow, so the thread area width stays honest. */}
{showMainSidebar ? (
<aside
className={cn(
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
>
<div
className={cn(
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
style={{ width: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div>
</aside>
) : null}
{showMainSidebar ? (
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent
side="left"
showCloseButton={false}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent>
</Sheet>
) : null}
<main className="relative flex h-full min-w-0 flex-1 flex-col">
<div
className={cn(
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
"absolute inset-0 flex flex-col",
view === "settings" && "invisible pointer-events-none",
)}
style={{ width: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div>
</aside>
) : null}
{showMainSidebar ? (
<Sheet
open={mobileSidebarOpen}
onOpenChange={(open) => setMobileSidebarOpen(open)}
>
<SheetContent
side="left"
showCloseButton={false}
className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
>
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent>
</Sheet>
) : null}
<main className="relative flex h-full min-w-0 flex-1 flex-col">
<div
className={cn(
"absolute inset-0 flex flex-col",
view === "settings" && "invisible pointer-events-none",
)}
>
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
</div>
{view === "settings" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
<ThreadShell
session={activeSession}
title={headerTitle}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onLogout={onLogout}
onRestart={onRestart}
isRestarting={isRestarting}
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
</div>
)}
</main>
{view === "settings" && (
<div className="absolute inset-0 flex flex-col">
<SettingsView
theme={theme}
onToggleTheme={toggle}
onBackToChat={onBackToChat}
onModelNameChange={onModelNameChange}
onLogout={onLogout}
onRestart={onRestart}
isRestarting={isRestarting}
/>
</div>
)}
</main>
<DeleteConfirm
open={!!pendingDelete}
title={pendingDelete?.label ?? ""}
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
/>
{restartToast ? (
<div
role="status"
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
>
{restartToast}
</div>
) : null}
</div>
<DeleteConfirm
open={!!pendingDelete}
title={pendingDelete?.label ?? ""}
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
/>
{restartToast ? (
<div
role="status"
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
>
{restartToast}
</div>
) : null}
</div>
</ThemeProvider>
);
}

View File

@ -1,12 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { Suspense, lazy, useCallback, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { useThemeValue } from "@/hooks/useTheme";
import { cn } from "@/lib/utils";
interface CodeBlockProps {
@ -15,30 +11,59 @@ interface CodeBlockProps {
className?: string;
}
/** Read dark mode straight from the DOM — stays in sync with Tailwind's `dark:`. */
function useIsDark() {
const [isDark, setIsDark] = useState(() =>
typeof document !== "undefined"
? document.documentElement.classList.contains("dark")
: true,
interface HighlightedCodeProps {
language?: string;
code: string;
isDark: boolean;
}
const LazyHighlightedCode = lazy(async () => {
const [
{ default: SyntaxHighlighter },
{ default: oneDark },
{ default: oneLight },
] = await Promise.all([
import("react-syntax-highlighter/dist/esm/prism-async-light"),
import("react-syntax-highlighter/dist/esm/styles/prism/one-dark"),
import("react-syntax-highlighter/dist/esm/styles/prism/one-light"),
]);
return {
default({ language, code, isDark }: HighlightedCodeProps) {
return (
<SyntaxHighlighter
language={language}
style={isDark ? oneDark : oneLight}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
lineHeight: 1.6,
}}
PreTag="pre"
wrapLongLines
>
{code}
</SyntaxHighlighter>
);
},
};
});
function PlainCodeFallback({ code }: { code: string }) {
return (
<pre
className="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-sm leading-[1.6]"
>
<code>{code}</code>
</pre>
);
useEffect(() => {
const el = document.documentElement;
const observer = new MutationObserver(() => {
setIsDark(el.classList.contains("dark"));
});
observer.observe(el, { attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
return isDark;
}
export function CodeBlock({ language, code, className }: CodeBlockProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const isDark = useIsDark();
const isDark = useThemeValue() === "dark";
const onCopy = useCallback(() => {
if (!navigator.clipboard) return;
@ -86,20 +111,9 @@ export function CodeBlock({ language, code, className }: CodeBlockProps) {
<span>{copied ? t("code.copied") : t("code.copy")}</span>
</button>
</div>
<SyntaxHighlighter
language={language}
style={isDark ? oneDark : oneLight}
customStyle={{
margin: 0,
padding: "1rem",
fontSize: "0.875rem",
lineHeight: 1.6,
}}
PreTag="pre"
wrapLongLines
>
{code}
</SyntaxHighlighter>
<Suspense fallback={<PlainCodeFallback code={code} />}>
<LazyHighlightedCode language={language} code={code} isDark={isDark} />
</Suspense>
</div>
);
}

View File

@ -167,10 +167,15 @@ function MessageMedia({
align: "left" | "right";
}) {
if (media.length === 0) return null;
const images = media
.filter((item) => item.kind === "image")
.map(({ url, name }) => ({ url, name }));
const nonImages = media.filter((item) => item.kind !== "image");
const images: UIImage[] = [];
const nonImages: UIMediaAttachment[] = [];
for (const item of media) {
if (item.kind === "image") {
images.push({ url: item.url, name: item.name });
} else {
nonImages.push(item);
}
}
return (
<div
@ -276,13 +281,14 @@ function UserImages({
const { t } = useTranslation();
// Only real-URL images can open in the lightbox; historical-replay
// placeholders (no URL) have nothing to zoom into.
const viewable = images
.map((img, i) => ({ img, i }))
.filter(({ img }) => typeof img.url === "string" && img.url.length > 0);
const viewableImages = viewable.map(({ img }) => img);
const originalToViewable = new Map<number, number>(
viewable.map(({ i }, v) => [i, v]),
);
const viewableImages: UIImage[] = [];
const originalToViewable = new Map<number, number>();
for (let i = 0; i < images.length; i += 1) {
const img = images[i];
if (typeof img.url !== "string" || img.url.length === 0) continue;
originalToViewable.set(i, viewableImages.length);
viewableImages.push(img);
}
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
@ -416,7 +422,7 @@ function Dot({ delay }: { delay: string }) {
);
}
/** L→R sheen overlay on label text; base copy stays solid ``text-muted-foreground``. */
/** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */
export function StreamingLabelSheen({
children,
active,
@ -426,21 +432,21 @@ export function StreamingLabelSheen({
active: boolean;
className?: string;
}) {
const sheenText =
typeof children === "string" || typeof children === "number"
? String(children)
: undefined;
return (
<span className={cn("relative block min-w-0 py-px", className)}>
<span className={cn("block min-w-0 overflow-hidden py-px", className)}>
<span
data-sheen-text={active ? sheenText : undefined}
className={cn(
"relative z-0 block font-medium leading-normal text-muted-foreground",
!active && "truncate",
"block w-fit max-w-full truncate font-medium leading-normal",
active ? "streaming-text-sheen" : "text-muted-foreground",
)}
>
{children}
</span>
{active ? (
<span className="reasoning-sheen-track" aria-hidden dir="ltr">
<span className="reasoning-sheen-stripe" />
</span>
) : null}
</span>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { ChevronRight, Layers } from "lucide-react";
import { useTranslation } from "react-i18next";
@ -8,6 +8,7 @@ import type { UIMessage } from "@/lib/types";
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
const ACTIVITY_SCROLL_NEAR_BOTTOM_PX = 24;
export function isReasoningOnlyAssistant(m: UIMessage): boolean {
if (m.role !== "assistant" || m.kind === "trace") return false;
@ -19,14 +20,20 @@ export function isAgentActivityMember(m: UIMessage): boolean {
return isReasoningOnlyAssistant(m) || m.kind === "trace";
}
function countToolCalls(messages: UIMessage[]): number {
let n = 0;
function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCalls: number } {
let reasoningSteps = 0;
let toolCalls = 0;
for (const m of messages) {
if (m.kind !== "trace") continue;
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
n += Math.max(lines, 1);
if (isReasoningOnlyAssistant(m)) {
reasoningSteps += 1;
continue;
}
if (m.kind === "trace") {
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
toolCalls += Math.max(lines, 1);
}
}
return n;
return { reasoningSteps, toolCalls };
}
interface AgentActivityClusterProps {
@ -46,11 +53,14 @@ export function AgentActivityCluster({
hasBodyBelow,
}: AgentActivityClusterProps) {
const { t } = useTranslation();
const reasoningSteps = messages.filter(isReasoningOnlyAssistant).length;
const toolCalls = countToolCalls(messages);
const { reasoningSteps, toolCalls } = countActivity(messages);
const [userToggledOuter, setUserToggledOuter] = useState(false);
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
const activityScrollRef = useRef<HTMLDivElement>(null);
const activityContentRef = useRef<HTMLDivElement>(null);
const autoFollowActivityRef = useRef(true);
const scrollFrameRef = useRef<number | null>(null);
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
@ -79,11 +89,66 @@ export function AgentActivityCluster({
defaultValue: "{{tools}} tool calls",
});
const cancelActivityScrollFrame = useCallback(() => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
scrollFrameRef.current = null;
}
}, []);
const scrollActivityToBottom = useCallback(() => {
const el = activityScrollRef.current;
if (!el) return;
el.scrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
}, []);
const scheduleActivityScrollToBottom = useCallback(() => {
cancelActivityScrollFrame();
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
scrollActivityToBottom();
});
}, [cancelActivityScrollFrame, scrollActivityToBottom]);
const toggleOuter = () => {
const nextOpen = userToggledOuter ? !outerOpenLocal : !outerExpanded;
if (nextOpen) {
autoFollowActivityRef.current = true;
}
setUserToggledOuter(true);
setOuterOpenLocal((v) => (userToggledOuter ? !v : !outerExpanded));
setOuterOpenLocal(nextOpen);
};
useLayoutEffect(() => {
if (!outerExpanded || !autoFollowActivityRef.current) return;
scheduleActivityScrollToBottom();
}, [outerExpanded, messages, isTurnStreaming, scheduleActivityScrollToBottom]);
useEffect(() => {
if (!outerExpanded) {
autoFollowActivityRef.current = true;
return;
}
const target = activityContentRef.current;
if (!target || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
if (autoFollowActivityRef.current) {
scheduleActivityScrollToBottom();
}
});
observer.observe(target);
return () => observer.disconnect();
}, [outerExpanded, scheduleActivityScrollToBottom]);
useEffect(() => cancelActivityScrollFrame, [cancelActivityScrollFrame]);
const onActivityScroll = useCallback(() => {
const el = activityScrollRef.current;
if (!el) return;
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX;
}, []);
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button
@ -118,12 +183,15 @@ export function AgentActivityCluster({
)}
>
<div
ref={activityScrollRef}
data-testid="agent-activity-scroll"
onScroll={onActivityScroll}
className={cn(
CLUSTER_SCROLL_MAX_CLASS,
"overflow-y-auto px-2 py-1.5 scrollbar-thin scrollbar-track-transparent",
)}
>
<div className="flex flex-col gap-2">
<div ref={activityContentRef} className="flex flex-col gap-2">
{messages.map((m) => {
if (isReasoningOnlyAssistant(m)) {
return (

View File

@ -1,3 +1,6 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { MessageBubble } from "@/components/MessageBubble";
import {
AgentActivityCluster,
@ -9,6 +12,8 @@ interface ThreadMessagesProps {
messages: UIMessage[];
/** When true, agent turn still in flight — keeps activity cluster expanded. */
isStreaming?: boolean;
hiddenMessageCount?: number;
onLoadEarlier?: () => void;
}
export type DisplayUnit =
@ -30,7 +35,7 @@ export function isFinalAssistantSliceBeforeNextUser(
return true;
}
function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
const out: DisplayUnit[] = [];
let i = 0;
while (i < messages.length) {
@ -50,11 +55,49 @@ function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
return out;
}
export function ThreadMessages({ messages, isStreaming = false }: ThreadMessagesProps) {
const units = buildDisplayUnits(messages);
export function assistantCopyFlags(units: DisplayUnit[]): boolean[] {
const flags = new Array<boolean>(units.length).fill(true);
let hasLaterUnitBeforeUser = false;
for (let i = units.length - 1; i >= 0; i -= 1) {
const unit = units[i];
if (unit.type === "single" && unit.message.role === "user") {
hasLaterUnitBeforeUser = false;
continue;
}
if (unit.type === "single" && unit.message.role === "assistant") {
flags[i] = !hasLaterUnitBeforeUser;
}
hasLaterUnitBeforeUser = true;
}
return flags;
}
export function ThreadMessages({
messages,
isStreaming = false,
hiddenMessageCount = 0,
onLoadEarlier,
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
return (
<div className="flex w-full flex-col">
{hiddenMessageCount > 0 && onLoadEarlier ? (
<div className="mb-4 flex justify-center">
<button
type="button"
onClick={onLoadEarlier}
className="rounded-full border border-border/60 bg-background/85 px-3 py-1.5 text-xs font-medium text-muted-foreground shadow-sm transition-colors hover:bg-muted/55 hover:text-foreground"
>
{t("thread.loadEarlier", {
count: hiddenMessageCount,
defaultValue: "Load earlier messages",
})}
</button>
</div>
) : null}
{units.map((unit, index) => {
const prev = units[index - 1];
const marginTop =
@ -80,7 +123,7 @@ export function ThreadMessages({ messages, isStreaming = false }: ThreadMessages
message={unit.message}
showAssistantCopyAction={
unit.message.role === "assistant"
? isFinalAssistantSliceBeforeNextUser(units, index)
? copyFlags[index]
: true
}
/>

View File

@ -389,6 +389,7 @@ export function ThreadShell({
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
/>
</section>
);

View File

@ -1,8 +1,17 @@
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
@ -14,9 +23,27 @@ interface ThreadViewportProps {
emptyState?: ReactNode;
scrollToBottomSignal?: number;
conversationKey?: string | null;
showScrollToBottomButton?: boolean;
}
const NEAR_BOTTOM_PX = 48;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
export const INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120;
export function windowMessages(messages: UIMessage[], visibleCount: number): UIMessage[] {
if (messages.length <= visibleCount) return messages;
let start = Math.max(0, messages.length - visibleCount);
while (
start > 0
&& isAgentActivityMember(messages[start])
&& isAgentActivityMember(messages[start - 1])
) {
start -= 1;
}
return messages.slice(start);
}
export function ThreadViewport({
messages,
@ -25,18 +52,33 @@ export function ThreadViewport({
emptyState,
scrollToBottomSignal = 0,
conversationKey = null,
showScrollToBottomButton = true,
}: ThreadViewportProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const composerDockRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const scrollFrameIdsRef = useRef<number[]>([]);
const restoreScrollAfterPrependRef =
useRef<{ height: number; top: number } | null>(null);
/** User scrolled away from the bottom; do not auto-yank until they return or we reset (new chat / send). */
const userReadingHistoryRef = useRef(false);
const [atBottom, setAtBottom] = useState(true);
const [composerDockHeight, setComposerDockHeight] = useState(0);
const [visibleMessageCount, setVisibleMessageCount] =
useState(INITIAL_HISTORY_WINDOW);
const hasMessages = messages.length > 0;
const visibleMessages = useMemo(
() => windowMessages(messages, visibleMessageCount),
[messages, visibleMessageCount],
);
const hiddenMessageCount = messages.length - visibleMessages.length;
const scrollButtonBottom = composerDockHeight > 0
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX;
const cancelScheduledBottomScroll = useCallback(() => {
for (const id of scrollFrameIdsRef.current) {
@ -77,6 +119,30 @@ export function ThreadViewport({
[cancelScheduledBottomScroll, scrollToBottomNow],
);
const loadEarlierMessages = useCallback(() => {
const el = scrollRef.current;
if (el) {
restoreScrollAfterPrependRef.current = {
height: el.scrollHeight,
top: el.scrollTop,
};
}
userReadingHistoryRef.current = true;
setAtBottom(false);
setVisibleMessageCount((count) =>
Math.min(messages.length, count + HISTORY_WINDOW_INCREMENT),
);
}, [messages.length]);
const measureComposerDock = useCallback(() => {
const el = composerDockRef.current;
if (!el) return;
const height = el.getBoundingClientRect().height || el.offsetHeight;
setComposerDockHeight((current) =>
Math.abs(current - height) < 1 ? current : height,
);
}, []);
useEffect(() => {
if (!atBottom) return;
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
@ -96,8 +162,19 @@ export function ThreadViewport({
pendingConversationScrollRef.current = true;
userReadingHistoryRef.current = false;
setAtBottom(true);
setVisibleMessageCount(INITIAL_HISTORY_WINDOW);
}, [conversationKey]);
useLayoutEffect(() => {
const pending = restoreScrollAfterPrependRef.current;
if (!pending) return;
const el = scrollRef.current;
restoreScrollAfterPrependRef.current = null;
if (!el) return;
const delta = el.scrollHeight - pending.height;
el.scrollTop = pending.top + delta;
}, [visibleMessages.length]);
useLayoutEffect(() => {
if (!pendingConversationScrollRef.current) return;
if (!conversationKey) {
@ -110,6 +187,10 @@ export function ThreadViewport({
pendingConversationScrollRef.current = false;
}, [conversationKey, hasMessages, messages, scrollToBottom]);
useLayoutEffect(() => {
measureComposerDock();
}, [composer, hasMessages, measureComposerDock]);
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
useEffect(() => {
@ -123,6 +204,14 @@ export function ThreadViewport({
return () => observer.disconnect();
}, [hasMessages, scrollToBottom]);
useEffect(() => {
const target = composerDockRef.current;
if (!target || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => measureComposerDock());
observer.observe(target);
return () => observer.disconnect();
}, [hasMessages, measureComposerDock]);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
@ -155,11 +244,20 @@ export function ThreadViewport({
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
<div className="flex-1 px-4 pb-20 pt-4">
<div className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages messages={messages} isStreaming={isStreaming} />
<ThreadMessages
messages={visibleMessages}
isStreaming={isStreaming}
hiddenMessageCount={hiddenMessageCount}
onLoadEarlier={loadEarlierMessages}
/>
</div>
</div>
<div className="sticky bottom-0 z-10 mt-auto bg-background">
<div
ref={composerDockRef}
data-testid="thread-composer-dock"
className="sticky bottom-0 z-10 mt-auto bg-background"
>
<div className="px-4 pb-3">
{composer}
</div>
@ -183,17 +281,18 @@ export function ThreadViewport({
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
/>
{!atBottom && (
{showScrollToBottomButton && !atBottom && (
<Button
variant="outline"
size="icon"
onClick={() => scrollToBottom(true, 1, { force: true })}
className={cn(
/* Keep clear of sticky composer (textarea + toolbar + optional goal strip). */
"absolute bottom-48 left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"absolute left-1/2 z-20 h-8 w-8 -translate-x-1/2 rounded-full shadow-md",
"bg-background/90 backdrop-blur",
"animate-in fade-in-0 zoom-in-95",
)}
style={{ bottom: scrollButtonBottom }}
aria-label={t("thread.scrollToBottom")}
>
<ArrowDown className="h-4 w-4" />

View File

@ -117,53 +117,60 @@
--cjk-line-height: 1.625;
}
/* LR sheen over solid label text (overlay stripe). Avoids ``background-clip:
text`` loop seams that read as RTL erase or one-frame transparent glyphs. */
@keyframes reasoning-sheen-ltr {
/* LR sheen clipped to live activity labels. The highlight lives inside
the glyphs, not in the row background, so dark mode stays quiet. */
@keyframes streaming-text-sheen-ltr {
0% {
left: -44%;
background-position: 140% 50%;
}
100% {
left: 118%;
background-position: -40% 50%;
}
}
.reasoning-sheen-track {
.streaming-text-sheen {
position: relative;
color: hsl(var(--muted-foreground));
}
.streaming-text-sheen::after {
content: attr(data-sheen-text);
position: absolute;
inset: 0;
z-index: 1;
display: block;
overflow: hidden;
border-radius: 2px;
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: none;
}
.reasoning-sheen-stripe {
position: absolute;
top: 0;
bottom: 0;
width: 44%;
min-width: 3.25rem;
left: -44%;
border-radius: inherit;
color: transparent;
background: linear-gradient(
90deg,
transparent 0%,
hsl(0 0% 100% / 0.07) 34%,
hsl(0 0% 100% / 0.76) 50%,
hsl(0 0% 100% / 0.07) 66%,
transparent 38%,
hsl(var(--foreground) / 0.98) 50%,
transparent 62%,
transparent 100%
);
mix-blend-mode: soft-light;
opacity: 0.95;
animation: reasoning-sheen-ltr 5.2s linear infinite;
background-size: 260% 100%;
background-position: 140% 50%;
background-repeat: no-repeat;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: streaming-text-sheen-ltr 2.8s ease-in-out infinite;
}
.dark .reasoning-sheen-stripe {
mix-blend-mode: overlay;
opacity: 1;
.dark .streaming-text-sheen::after {
background-image: linear-gradient(
90deg,
transparent 0%,
transparent 38%,
hsl(var(--foreground) / 0.98) 50%,
transparent 62%,
transparent 100%
);
}
@media (prefers-reduced-motion: reduce) {
.reasoning-sheen-stripe {
.streaming-text-sheen::after {
animation: none;
opacity: 0;
visibility: hidden;
content: "";
}
}

View 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]);
}

View File

@ -18,12 +18,21 @@ interface StreamBuffer {
messageId: string;
}
interface ActiveAssistantCursor {
id: string;
index: number;
}
type PendingStreamEvent =
| { kind: "delta"; text: string }
| { kind: "reasoning"; text: string };
/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */
function findStreamingAssistantId(prev: UIMessage[]): string | null {
function findStreamingAssistantIndex(prev: UIMessage[]): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.kind === "trace") continue;
if (m.role === "assistant" && m.isStreaming) return m.id;
if (m.role === "assistant" && m.isStreaming) return i;
if (m.role === "user") break;
}
return null;
@ -95,13 +104,19 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
* the model already produced an answer in a previous turn, so the new
* delta belongs in a fresh row.
*/
function findActiveAssistantPlaceholder(prev: UIMessage[]): string | null {
function findActiveAssistantPlaceholderIndex(prev: UIMessage[]): number | null {
const last = prev[prev.length - 1];
if (!last) return null;
if (last.role !== "assistant" || last.kind === "trace") return null;
if (last.content.length > 0) return null;
if (!last.isStreaming) return null;
return last.id;
return prev.length - 1;
}
function replaceMessageAt(prev: UIMessage[], index: number, message: UIMessage): UIMessage[] {
const next = prev.slice();
next[index] = message;
return next;
}
/**
@ -239,6 +254,9 @@ export function useNanobotStream(
const [goalState, setGoalState] = useState<GoalStateWsPayload | undefined>(undefined);
const [streamError, setStreamError] = useState<StreamError | null>(null);
const buffer = useRef<StreamBuffer | null>(null);
const activeAssistantRef = useRef<ActiveAssistantCursor | null>(null);
const pendingStreamEventsRef = useRef<PendingStreamEvent[]>([]);
const streamFrameRef = useRef<number | null>(null);
const suppressStreamUntilTurnEndRef = useRef(false);
/** Timer that defers ``isStreaming = false`` after ``stream_end``.
*
@ -255,6 +273,115 @@ export function useNanobotStream(
const dismissStreamError = useCallback(() => setStreamError(null), []);
const clearPendingStreamWork = useCallback(() => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
pendingStreamEventsRef.current = [];
}, []);
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
const cursor = activeAssistantRef.current;
if (!cursor) return null;
const indexed = prev[cursor.index];
if (indexed?.id === cursor.id && indexed.role === "assistant" && indexed.kind !== "trace") {
return cursor.index;
}
const idx = prev.findIndex((m) => m.id === cursor.id);
if (idx === -1) {
activeAssistantRef.current = null;
return null;
}
const found = prev[idx];
if (found.role !== "assistant" || found.kind === "trace") {
activeAssistantRef.current = null;
return null;
}
activeAssistantRef.current = { id: cursor.id, index: idx };
return idx;
}, []);
const appendAnswerChunk = useCallback(
(prev: UIMessage[], chunk: string): UIMessage[] => {
let next = prev;
let targetIndex = resolveActiveAssistantIndex(next);
if (targetIndex === null) {
targetIndex = findActiveAssistantPlaceholderIndex(next);
}
if (targetIndex === null) {
targetIndex = findStreamingAssistantIndex(next);
}
if (targetIndex === null) {
const id = crypto.randomUUID();
next = [
...next,
{
id,
role: "assistant",
content: "",
isStreaming: true,
createdAt: Date.now(),
},
];
targetIndex = next.length - 1;
}
const target = next[targetIndex];
const merged: UIMessage = {
...target,
content: target.content + chunk,
isStreaming: true,
};
activeAssistantRef.current = { id: merged.id, index: targetIndex };
buffer.current = { messageId: merged.id };
return replaceMessageAt(next, targetIndex, merged);
},
[resolveActiveAssistantIndex],
);
const applyPendingStreamEvents = useCallback(
(prev: UIMessage[], events: PendingStreamEvent[]): UIMessage[] => {
let next = prev;
for (let i = 0; i < events.length;) {
const kind = events[i].kind;
let text = "";
while (i < events.length && events[i].kind === kind) {
text += events[i].text;
i += 1;
}
next = kind === "delta"
? appendAnswerChunk(next, text)
: attachReasoningChunk(next, text);
}
return next;
},
[appendAnswerChunk],
);
const flushPendingStreamEvents = useCallback(() => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
const events = pendingStreamEventsRef.current;
if (events.length === 0) return;
pendingStreamEventsRef.current = [];
setMessages((prev) => applyPendingStreamEvents(prev, events));
}, [applyPendingStreamEvents]);
const schedulePendingStreamFlush = useCallback(() => {
if (streamFrameRef.current !== null) return;
streamFrameRef.current = window.requestAnimationFrame(() => {
streamFrameRef.current = null;
const events = pendingStreamEventsRef.current;
if (events.length === 0) return;
pendingStreamEventsRef.current = [];
setMessages((prev) => applyPendingStreamEvents(prev, events));
});
}, [applyPendingStreamEvents]);
// Reset local state when switching chats. Do not reset on every
// ``initialMessages`` update: a brand-new chat can receive an empty/404
// history response after the optimistic first message has already rendered.
@ -269,13 +396,15 @@ export function useNanobotStream(
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
buffer.current = null;
activeAssistantRef.current = null;
clearPendingStreamWork();
suppressStreamUntilTurnEndRef.current = false;
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chatId, client]);
}, [chatId, client, clearPendingStreamWork]);
useEffect(() => {
if (hasPendingToolCalls) setIsStreaming(true);
@ -296,44 +425,25 @@ export function useNanobotStream(
if (ev.event === "delta") {
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = typeof ev.text === "string" ? ev.text : "";
if (!chunk) return;
setIsStreaming(true);
setMessages((prev) => {
const adopted = findActiveAssistantPlaceholder(prev);
const streamingAssistId = findStreamingAssistantId(prev);
let targetId: string;
let next: UIMessage[];
if (adopted) {
targetId = adopted;
next = prev;
} else if (streamingAssistId) {
targetId = streamingAssistId;
next = prev;
} else {
targetId = crypto.randomUUID();
next = [
...prev,
{
id: targetId,
role: "assistant",
content: "",
isStreaming: true,
createdAt: Date.now(),
},
];
}
buffer.current = { messageId: targetId };
const priorContent = next.find((m) => m.id === targetId)?.content ?? "";
const combined = priorContent + chunk;
return next.map((m) =>
m.id === targetId ? { ...m, content: combined, isStreaming: true } : m,
);
});
pendingStreamEventsRef.current.push({ kind: "delta", text: chunk });
schedulePendingStreamFlush();
return;
}
if (ev.event === "reasoning_delta") {
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = ev.text;
if (!chunk) return;
setIsStreaming(true);
pendingStreamEventsRef.current.push({ kind: "reasoning", text: chunk });
schedulePendingStreamFlush();
return;
}
flushPendingStreamEvents();
if (ev.event === "stream_end") {
if (suppressStreamUntilTurnEndRef.current) {
buffer.current = null;
@ -347,15 +457,6 @@ export function useNanobotStream(
return;
}
if (ev.event === "reasoning_delta") {
if (suppressStreamUntilTurnEndRef.current) return;
const chunk = ev.text;
if (!chunk) return;
setMessages((prev) => attachReasoningChunk(prev, chunk));
setIsStreaming(true);
return;
}
if (ev.event === "reasoning_end") {
if (suppressStreamUntilTurnEndRef.current) return;
setMessages((prev) => closeReasoningStream(prev));
@ -393,6 +494,8 @@ export function useNanobotStream(
if (typeof ev.latency_ms === "number" && ev.latency_ms >= 0) {
finalized = stampLastAssistantLatency(finalized, Math.round(ev.latency_ms));
}
buffer.current = null;
activeAssistantRef.current = null;
return finalized;
});
suppressStreamUntilTurnEndRef.current = false;
@ -459,11 +562,12 @@ export function useNanobotStream(
// A complete (non-streamed) assistant message. If a stream was in
// flight, drop the placeholder so we don't render the text twice.
const activeId = buffer.current?.messageId;
buffer.current = null;
// Do NOT reset isStreaming here — only ``turn_end`` signals that
// the full turn (all tool calls + final text) is complete.
setMessages((prev) => {
const activeId = buffer.current?.messageId;
buffer.current = null;
activeAssistantRef.current = null;
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
const content = ev.text;
const lat =
@ -489,12 +593,21 @@ export function useNanobotStream(
return () => {
unsub();
buffer.current = null;
activeAssistantRef.current = null;
clearPendingStreamWork();
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
streamEndTimerRef.current = null;
}
};
}, [chatId, client, onTurnEnd]);
}, [
chatId,
client,
clearPendingStreamWork,
flushPendingStreamEvents,
onTurnEnd,
schedulePendingStreamFlush,
]);
const send = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
@ -504,17 +617,22 @@ export function useNanobotStream(
// the image blocks via ``media`` paths.
if (!hasImages && !content.trim()) return;
flushPendingStreamEvents();
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
setMessages((prev) => [
...pruneReasoningOnlyPlaceholders(prev),
{
id: crypto.randomUUID(),
role: "user",
content,
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
},
]);
setMessages((prev) => {
buffer.current = null;
activeAssistantRef.current = null;
return [
...pruneReasoningOnlyPlaceholders(prev),
{
id: crypto.randomUUID(),
role: "user",
content,
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
},
];
});
// Mark streaming immediately so the UI shows the loading indicator
// right away, before the first delta arrives from the server.
setIsStreaming(true);
@ -525,18 +643,21 @@ export function useNanobotStream(
client.sendMessage(chatId, content, wireMedia);
}
},
[chatId, client],
[chatId, client, flushPendingStreamEvents],
);
const stop = useCallback(() => {
if (!chatId) return;
flushPendingStreamEvents();
setIsStreaming(false);
setMessages((prev) =>
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
);
setMessages((prev) => {
buffer.current = null;
activeAssistantRef.current = null;
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
});
suppressStreamUntilTurnEndRef.current = false;
client.sendMessage(chatId, "/stop");
}, [chatId, client]);
}, [chatId, client, flushPendingStreamEvents]);
return {
messages,

View File

@ -1,7 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import {
createContext,
createElement,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
type Theme = "light" | "dark";
const STORAGE_KEY = "nanobot-webui.theme";
const ThemeContext = createContext<Theme>("light");
function readStored(): Theme | null {
try {
@ -18,7 +27,11 @@ function applyTheme(theme: Theme): void {
else root.classList.remove("dark");
}
export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Theme) => void } {
export function useTheme(): {
theme: Theme;
toggle: () => void;
setTheme: (t: Theme) => void;
} {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = readStored();
if (stored) return stored;
@ -46,3 +59,11 @@ export function useTheme(): { theme: Theme; toggle: () => void; setTheme: (t: Th
);
return { theme, toggle, setTheme };
}
export function ThemeProvider({ theme, children }: { theme: Theme; children: ReactNode }) {
return createElement(ThemeContext.Provider, { value: theme }, children);
}
export function useThemeValue(): Theme {
return useContext(ThemeContext);
}

View File

@ -335,7 +335,8 @@
"io": "Couldn't read this file"
}
},
"scrollToBottom": "Scroll to bottom"
"scrollToBottom": "Scroll to bottom",
"loadEarlier": "Load earlier messages"
},
"message": {
"streaming": "streaming",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "Cerrar objetivo"
},
"scrollToBottom": "Desplazarse al final"
"scrollToBottom": "Desplazarse al final",
"loadEarlier": "Cargar mensajes anteriores"
},
"message": {
"streaming": "transmitiendo",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "Fermer lobjectif"
},
"scrollToBottom": "Faire défiler vers le bas"
"scrollToBottom": "Faire défiler vers le bas",
"loadEarlier": "Charger les messages précédents"
},
"message": {
"streaming": "en cours de génération",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "Tutup tujuan"
},
"scrollToBottom": "Gulir ke bawah"
"scrollToBottom": "Gulir ke bawah",
"loadEarlier": "Muat pesan sebelumnya"
},
"message": {
"streaming": "sedang mengalir",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "目標を閉じる"
},
"scrollToBottom": "一番下へスクロール"
"scrollToBottom": "一番下へスクロール",
"loadEarlier": "以前のメッセージを読み込む"
},
"message": {
"streaming": "生成中",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "목표 닫기"
},
"scrollToBottom": "맨 아래로 스크롤"
"scrollToBottom": "맨 아래로 스크롤",
"loadEarlier": "이전 메시지 불러오기"
},
"message": {
"streaming": "생성 중",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "Đóng mục tiêu"
},
"scrollToBottom": "Cuộn xuống cuối"
"scrollToBottom": "Cuộn xuống cuối",
"loadEarlier": "Tải tin nhắn trước đó"
},
"message": {
"streaming": "đang truyền",

View File

@ -323,7 +323,8 @@
},
"goalStateCloseAria": "关闭目标"
},
"scrollToBottom": "滚动到底部"
"scrollToBottom": "滚动到底部",
"loadEarlier": "加载更早消息"
},
"message": {
"streaming": "流式输出中",

View File

@ -303,7 +303,8 @@
},
"goalStateCloseAria": "關閉目標"
},
"scrollToBottom": "捲動到底部"
"scrollToBottom": "捲動到底部",
"loadEarlier": "載入更早訊息"
},
"message": {
"streaming": "串流輸出中",

View 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();
}
});
});

View File

@ -32,12 +32,18 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
};
});
vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({
theme: "light" as const,
toggle: toggleThemeSpy,
}),
}));
vi.mock("@/hooks/useTheme", async () => {
const React = await import("react");
return {
ThemeProvider: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
useTheme: () => ({
theme: "light" as const,
toggle: toggleThemeSpy,
}),
useThemeValue: () => "light" as const,
};
});
vi.mock("@/lib/bootstrap", () => ({
fetchBootstrap: vi.fn().mockResolvedValue({

View 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);
}
});
});

View File

@ -131,7 +131,9 @@ describe("MessageBubble", () => {
expect(screen.getByText("Thinking…")).toBeInTheDocument();
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
expect(container.querySelector(".reasoning-sheen-stripe")).toBeInTheDocument();
expect(container.querySelector(".reasoning-sheen-stripe")).not.toBeInTheDocument();
expect(screen.getByText("Thinking…")).toHaveClass("streaming-text-sheen");
expect(screen.getByText("Thinking…")).toHaveAttribute("data-sheen-text", "Thinking…");
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
});

View File

@ -1,7 +1,11 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import {
assistantCopyFlags,
buildDisplayUnits,
ThreadMessages,
} from "@/components/thread/ThreadMessages";
import type { UIMessage } from "@/lib/types";
describe("ThreadMessages", () => {
@ -89,4 +93,37 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
});
it("computes final assistant copy flags with user-boundary semantics", () => {
const units = buildDisplayUnits([
{ id: "u1", role: "user", content: "one", createdAt: 1 },
{ id: "a1", role: "assistant", content: "draft", createdAt: 2 },
{
id: "t1",
role: "tool",
kind: "trace",
content: "tool()",
traces: ["tool()"],
createdAt: 3,
},
{ id: "a2", role: "assistant", content: "final", createdAt: 4 },
{ id: "u2", role: "user", content: "two", createdAt: 5 },
{ id: "a3", role: "assistant", content: "next", createdAt: 6 },
]);
const flags = assistantCopyFlags(units);
const assistantFlags = units
.map((unit, index) =>
unit.type === "single" && unit.message.role === "assistant"
? [unit.message.id, flags[index]]
: null,
)
.filter(Boolean);
expect(assistantFlags).toEqual([
["a1", false],
["a2", true],
["a3", true],
]);
});
});

View File

@ -1,7 +1,12 @@
import { act, render, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import {
HISTORY_WINDOW_INCREMENT,
INITIAL_HISTORY_WINDOW,
ThreadViewport,
windowMessages,
} from "@/components/thread/ThreadViewport";
import type { UIMessage } from "@/lib/types";
const messages: UIMessage[] = [
@ -15,7 +20,191 @@ const messages: UIMessage[] = [
const emptyMessages: UIMessage[] = [];
interface ResizeObserverInstance {
element?: Element;
callback: ResizeObserverCallback;
disconnect: ReturnType<typeof vi.fn>;
}
function makeLongMessages(count: number): UIMessage[] {
return Array.from({ length: count }, (_, index) => ({
id: `m${index}`,
role: "user" as const,
content: `message ${index}`,
createdAt: index,
}));
}
describe("ThreadViewport", () => {
it("keeps the scroll-to-bottom button above a growing composer", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const resizeObservers: ResizeObserverInstance[] = [];
class MockResizeObserver {
element?: Element;
callback: ResizeObserverCallback;
disconnect = vi.fn();
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
resizeObservers.push(this);
}
observe(element: Element) {
this.element = element;
}
}
vi.stubGlobal("ResizeObserver", MockResizeObserver);
try {
const { container } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div>composer</div>}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
const button = screen.getByRole("button", { name: "Scroll to bottom" });
expect(button).toHaveStyle({ bottom: "192px" });
const composerDock = screen.getByTestId("thread-composer-dock");
composerDock.getBoundingClientRect = () =>
({
height: 240,
width: 800,
top: 0,
right: 800,
bottom: 240,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
const composerObserver = resizeObservers.find(
(observer) => observer.element === composerDock,
);
expect(composerObserver).toBeDefined();
act(() => {
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
});
expect(button).toHaveStyle({ bottom: "256px" });
} finally {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
}
});
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
const { container } = render(
<ThreadViewport
messages={emptyMessages}
isStreaming={false}
composer={<div>composer</div>}
emptyState={<div>welcome</div>}
showScrollToBottomButton={false}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 },
});
act(() => {
scroller.dispatchEvent(new Event("scroll"));
});
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
});
it("renders only the tail window for long history by default", () => {
const longMessages = makeLongMessages(300);
render(
<ThreadViewport
messages={longMessages}
isStreaming={false}
composer={<div />}
/>,
);
expect(screen.queryByText("message 139")).not.toBeInTheDocument();
expect(screen.getByText("message 140")).toBeInTheDocument();
expect(screen.getByText("message 299")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Load earlier messages" })).toBeInTheDocument();
});
it("loads earlier history in fixed increments without rendering the whole transcript", () => {
const longMessages = makeLongMessages(300);
render(
<ThreadViewport
messages={longMessages}
isStreaming={false}
composer={<div />}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Load earlier messages" }));
const firstVisible =
300 - INITIAL_HISTORY_WINDOW - HISTORY_WINDOW_INCREMENT;
expect(
screen.queryByText(`message ${firstVisible - 1}`),
).not.toBeInTheDocument();
expect(screen.getByText(`message ${firstVisible}`)).toBeInTheDocument();
expect(screen.getByText("message 299")).toBeInTheDocument();
});
it("expands the window start to avoid cutting an agent activity cluster", () => {
const clustered = makeLongMessages(200);
clustered.splice(
38,
3,
{
id: "r0",
role: "assistant",
content: "",
reasoning: "first reasoning",
createdAt: 38,
},
{
id: "t0",
role: "tool",
kind: "trace",
content: "tool()",
traces: ["tool()"],
createdAt: 39,
},
{
id: "r1",
role: "assistant",
content: "",
reasoning: "second reasoning",
createdAt: 40,
},
);
const visible = windowMessages(clustered, INITIAL_HISTORY_WINDOW);
expect(visible[0].id).toBe("r0");
expect(visible).toHaveLength(INITIAL_HISTORY_WINDOW + 2);
});
it("resets to the bottom when opening a different conversation", async () => {
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;

View 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);
});
});

View File

@ -83,7 +83,112 @@ function wrap(client: ReturnType<typeof fakeClient>["client"]) {
};
}
async function flushStreamFrame() {
await act(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
});
}
describe("useNanobotStream", () => {
it("batches answer deltas into one animation-frame update", async () => {
const fake = fakeClient();
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
const { result } = renderHook(() => useNanobotStream("chat-batch", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-batch", {
event: "delta",
chat_id: "chat-batch",
text: "Hello",
});
fake.emit("chat-batch", {
event: "delta",
chat_id: "chat-batch",
text: " world",
});
});
expect(requestFrame).toHaveBeenCalledTimes(1);
expect(result.current.messages).toHaveLength(0);
await flushStreamFrame();
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "Hello world",
isStreaming: true,
});
requestFrame.mockRestore();
});
it("flushes pending delta text before turn_end finalizes the turn", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-flush", {
event: "delta",
chat_id: "chat-flush",
text: "final chunk",
});
fake.emit("chat-flush", {
event: "turn_end",
chat_id: "chat-flush",
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "final chunk",
isStreaming: false,
});
expect(result.current.isStreaming).toBe(false);
});
it("drops pending stream work when switching chats", async () => {
const fake = fakeClient();
const { result, rerender } = renderHook(
({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES),
{
wrapper: wrap(fake.client),
initialProps: { chatId: "chat-old" },
},
);
act(() => {
fake.emit("chat-old", {
event: "delta",
chat_id: "chat-old",
text: "stale",
});
});
rerender({ chatId: "chat-new" });
act(() => {
fake.emit("chat-new", {
event: "delta",
chat_id: "chat-new",
text: "fresh",
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "fresh",
});
});
it("starts in streaming mode when history shows pending tool calls", () => {
const fake = fakeClient();
const initialMessages = [{
@ -203,7 +308,7 @@ describe("useNanobotStream", () => {
);
});
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@ -222,6 +327,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].role).toBe("assistant");
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
@ -328,7 +435,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].reasoningStreaming).toBe(false);
});
it("does not attach a new turn's reasoning across the latest user boundary", () => {
it("does not attach a new turn's reasoning across the latest user boundary", async () => {
const fake = fakeClient();
const initialMessages = [
{
@ -358,6 +465,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
expect(result.current.messages[2].role).toBe("assistant");
@ -366,7 +475,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[2].reasoningStreaming).toBe(true);
});
it("does not attach reasoning across a tool trace boundary", () => {
it("does not attach reasoning across a tool trace boundary", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@ -392,6 +501,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
"message",
@ -651,7 +762,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].content).toBe("long task");
});
it("keeps streaming alive across stream_end and completes on turn_end", () => {
it("keeps streaming alive across stream_end and completes on turn_end", async () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
@ -666,6 +777,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",

View 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;
}

View File

@ -25,6 +25,36 @@ export default defineConfig(({ mode }) => {
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
emptyOutDir: true,
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("node_modules/refractor/lang/")) {
return;
}
if (
id.includes("node_modules/react-syntax-highlighter")
|| id.includes("node_modules/refractor/core")
) {
return "syntax-highlight";
}
if (
id.includes("node_modules/react-markdown")
|| id.includes("node_modules/remark-")
|| id.includes("node_modules/rehype-")
|| id.includes("node_modules/unified")
|| id.includes("node_modules/mdast-")
|| id.includes("node_modules/hast-")
|| id.includes("node_modules/micromark")
|| id.includes("node_modules/unist-")
) {
return "markdown-vendor";
}
if (id.includes("node_modules/katex")) {
return "katex";
}
},
},
},
},
server: {
host: "127.0.0.1",