diff --git a/webui/src/App.tsx b/webui/src/App.tsx index e8dc0722c..fabcff180 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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 ( -
- {/* Desktop sidebar: in normal flow, so the thread area width stays honest. */} - {showMainSidebar ? ( - - ) : null} - - {showMainSidebar ? ( - setMobileSidebarOpen(open)} - > - - - - - ) : null} - -
-
- -
- {view === "settings" && ( -
-
- )} -
+ {view === "settings" && ( +
+ +
+ )} + - setPendingDelete(null)} - onConfirm={onConfirmDelete} - /> - {restartToast ? ( -
- {restartToast} -
- ) : null} -
+ setPendingDelete(null)} + onConfirm={onConfirmDelete} + /> + {restartToast ? ( +
+ {restartToast} +
+ ) : null} + + ); } diff --git a/webui/src/components/CodeBlock.tsx b/webui/src/components/CodeBlock.tsx index c19a78645..2ab6bd572 100644 --- a/webui/src/components/CodeBlock.tsx +++ b/webui/src/components/CodeBlock.tsx @@ -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 ( + + {code} + + ); + }, + }; +}); + +function PlainCodeFallback({ code }: { code: string }) { + return ( +
+      {code}
+    
); - - 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) { {copied ? t("code.copied") : t("code.copy")} - - {code} - + }> + + ); } diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index ae15ced62..d5427ec42 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -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 (
({ img, i })) - .filter(({ img }) => typeof img.url === "string" && img.url.length > 0); - const viewableImages = viewable.map(({ img }) => img); - const originalToViewable = new Map( - viewable.map(({ i }, v) => [i, v]), - ); + const viewableImages: UIImage[] = []; + const originalToViewable = new Map(); + 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(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 ( - + {children} - {active ? ( - - - - ) : null} ); } diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx index 0bd052997..a29f590a8 100644 --- a/webui/src/components/thread/AgentActivityCluster.tsx +++ b/webui/src/components/thread/AgentActivityCluster.tsx @@ -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(null); + const activityContentRef = useRef(null); + const autoFollowActivityRef = useRef(true); + const scrollFrameRef = useRef(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 (
+
+ ) : 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 } /> diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 309f206c5..a4844d304 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -389,6 +389,7 @@ export function ThreadShell({ composer={composer} scrollToBottomSignal={scrollToBottomSignal} conversationKey={historyKey} + showScrollToBottomButton={!!session} /> ); diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 38b64340a..3f84da680 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -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(null); const contentRef = useRef(null); + const composerDockRef = useRef(null); const bottomRef = useRef(null); const lastConversationKeyRef = useRef(conversationKey); const pendingConversationScrollRef = useRef(true); const scrollFrameIdsRef = useRef([]); + 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({
- +
-
+
{composer}
@@ -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 && (
} + />, + ); + 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( + composer
} + emptyState={
welcome
} + 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( + } + />, + ); + + 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( + } + />, + ); + + 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; diff --git a/webui/src/tests/useDeferredTitleRefresh.test.tsx b/webui/src/tests/useDeferredTitleRefresh.test.tsx new file mode 100644 index 000000000..a823e5040 --- /dev/null +++ b/webui/src/tests/useDeferredTitleRefresh.test.tsx @@ -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 { + 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); + }); +}); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 57ecccd90..0f736a016 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -83,7 +83,112 @@ function wrap(client: ReturnType["client"]) { }; } +async function flushStreamFrame() { + await act(async () => { + await new Promise((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", diff --git a/webui/src/types/react-syntax-highlighter-subpaths.d.ts b/webui/src/types/react-syntax-highlighter-subpaths.d.ts new file mode 100644 index 000000000..57639f724 --- /dev/null +++ b/webui/src/types/react-syntax-highlighter-subpaths.d.ts @@ -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 { + 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; +} diff --git a/webui/vite.config.ts b/webui/vite.config.ts index 7a2c9edba..fb5dfe37b 100644 --- a/webui/vite.config.ts +++ b/webui/vite.config.ts @@ -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",