diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index a77c8594f..09a9852b7 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -1476,6 +1476,8 @@ class WebSocketChannel(BaseChannel): payload["media_urls"] = urls if msg.reply_to: payload["reply_to"] = msg.reply_to + if msg.metadata.get("_tool_events"): + payload["tool_events"] = msg.metadata["_tool_events"] # Mark intermediate agent breadcrumbs (tool-call hints, generic # progress strings) so WS clients can render them as subordinate # trace rows rather than conversational replies. diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index f11cb21b4..2d4dd647e 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -322,6 +322,54 @@ async def test_send_removes_connection_on_connection_closed() -> None: assert mock_ws not in channel._conn_chats +@pytest.mark.asyncio +async def test_send_progress_includes_structured_tool_events() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content='search "hermes"', + metadata={ + "_progress": True, + "_tool_hint": True, + "_tool_events": [ + { + "version": 1, + "phase": "start", + "call_id": "call-1", + "name": "web_search", + "arguments": {"query": "hermes", "count": 8}, + "result": None, + "error": None, + "files": [], + "embeds": [], + } + ], + }, + )) + + payload = json.loads(mock_ws.send.await_args.args[0]) + assert payload["event"] == "message" + assert payload["kind"] == "tool_hint" + assert payload["tool_events"] == [ + { + "version": 1, + "phase": "start", + "call_id": "call-1", + "name": "web_search", + "arguments": {"query": "hermes", "count": 8}, + "result": None, + "error": None, + "files": [], + "embeds": [], + } + ] + + @pytest.mark.asyncio async def test_send_delta_removes_connection_on_connection_closed() -> None: bus = MagicMock() diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index b7dadfbea..4bb75a3ab 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -31,8 +31,10 @@ export function Sidebar(props: SidebarProps) { const normalizedQuery = query.trim().toLowerCase(); const filteredSessions = useMemo(() => { if (!normalizedQuery) return props.sessions; + const terms = normalizedQuery.split(/\s+/).filter(Boolean); return props.sessions.filter((session) => { const haystack = [ + session.title, session.preview, session.chatId, session.channel, @@ -41,7 +43,7 @@ export function Sidebar(props: SidebarProps) { .filter(Boolean) .join(" ") .toLowerCase(); - return haystack.includes(normalizedQuery); + return terms.every((term) => haystack.includes(term)); }); }, [normalizedQuery, props.sessions]); diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 0d330c2a9..d0b4faabf 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -81,19 +81,33 @@ export function ThreadShell({ const { t } = useTranslation(); const chatId = session?.chatId ?? null; const historyKey = session?.key ?? null; - const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey); - const { modelName, token } = useClient(); + const { + messages: historical, + loading, + hasPendingToolCalls, + refresh: refreshHistory, + version: historyVersion, + } = useSessionHistory(historyKey); + const { client, modelName, token } = useClient(); const [booting, setBooting] = useState(false); const [slashCommands, setSlashCommands] = useState([]); const [heroImageMode, setHeroImageMode] = useState(false); + const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0); const pendingFirstRef = useRef(null); const messageCacheRef = useRef>(new Map()); const lastCachedChatIdRef = useRef(null); + const appliedHistoryVersionRef = useRef>(new Map()); + const pendingCanonicalHydrateRef = useRef>(new Set()); const initial = useMemo(() => { if (!chatId) return historical; return messageCacheRef.current.get(chatId) ?? historical; }, [chatId, historical]); + const handleTurnEnd = useCallback(() => { + if (chatId) pendingCanonicalHydrateRef.current.add(chatId); + refreshHistory(); + onTurnEnd?.(); + }, [chatId, onTurnEnd, refreshHistory]); const { messages, isStreaming, @@ -102,22 +116,48 @@ export function ThreadShell({ setMessages, streamError, dismissStreamError, - } = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd); + } = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd); const showHeroComposer = messages.length === 0 && !loading; useEffect(() => { if (!chatId || loading) return; const cached = messageCacheRef.current.get(chatId); + const appliedVersion = appliedHistoryVersionRef.current.get(chatId) ?? 0; + const hasPendingCanonicalHydrate = pendingCanonicalHydrateRef.current.has(chatId); + const hasNewCanonicalHistory = hasPendingCanonicalHydrate && historyVersion > appliedVersion; // When the user switches away and back, keep the local in-memory thread // state (including not-yet-persisted messages) instead of replacing it with - // whatever the history endpoint currently knows about. + // whatever the history endpoint currently knows about. Once a fresh + // canonical replay arrives after turn_end, prefer it so live Markdown/tool + // rendering converges to the same shape as a manual refresh. setMessages((prev) => { + if (hasNewCanonicalHistory && historical.length > 0) { + pendingCanonicalHydrateRef.current.delete(chatId); + appliedHistoryVersionRef.current.set(chatId, historyVersion); + messageCacheRef.current.set(chatId, historical); + return historical; + } if (cached && cached.length > 0) return cached; if (historical.length === 0 && prev.length > 0) return prev; + appliedHistoryVersionRef.current.set(chatId, historyVersion); return historical; }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loading, chatId, historical]); + }, [loading, chatId, historical, historyVersion]); + + useEffect(() => { + if (!chatId) return; + return client.onSessionUpdate((updatedChatId) => { + if (updatedChatId !== chatId) return; + pendingCanonicalHydrateRef.current.add(chatId); + refreshHistory(); + }); + }, [chatId, client, refreshHistory]); + + useEffect(() => { + if (!chatId || loading) return; + setScrollToBottomSignal((value) => value + 1); + }, [chatId, loading, historical]); useEffect(() => { if (chatId) return; @@ -148,6 +188,7 @@ export function ThreadShell({ const pending = pendingFirstRef.current; if (!pending) return; pendingFirstRef.current = null; + setScrollToBottomSignal((value) => value + 1); send(pending.content, pending.images, pending.options); setBooting(false); }, [chatId, send]); @@ -181,18 +222,26 @@ export function ThreadShell({ [booting, onCreateChat], ); + const handleThreadSend = useCallback( + (content: string, images?: SendImage[], options?: SendOptions) => { + setScrollToBottomSignal((value) => value + 1); + send(content, images, options); + }, + [send], + ); + const handleQuickAction = useCallback( (prompt: string) => { const options: SendOptions | undefined = heroImageMode ? { imageGeneration: { enabled: true, aspect_ratio: null } } : undefined; if (session) { - send(prompt, undefined, options); + handleThreadSend(prompt, undefined, options); return; } void handleWelcomeSend(prompt, undefined, options); }, - [handleWelcomeSend, heroImageMode, send, session], + [handleThreadSend, handleWelcomeSend, heroImageMode, session], ); const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS; @@ -233,7 +282,7 @@ export function ThreadShell({ ) : null} {session ? ( ); diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 7d4a80f06..3d1c86266 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useCallback, useEffect, useRef, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { ArrowDown } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -12,6 +12,8 @@ interface ThreadViewportProps { isStreaming: boolean; composer: ReactNode; emptyState?: ReactNode; + scrollToBottomSignal?: number; + conversationKey?: string | null; } const NEAR_BOTTOM_PX = 48; @@ -21,26 +23,92 @@ export function ThreadViewport({ isStreaming, composer, emptyState, + scrollToBottomSignal = 0, + conversationKey = null, }: ThreadViewportProps) { const { t } = useTranslation(); const scrollRef = useRef(null); + const contentRef = useRef(null); + const bottomRef = useRef(null); + const lastConversationKeyRef = useRef(conversationKey); + const pendingConversationScrollRef = useRef(true); + const scrollFrameIdsRef = useRef([]); + const forceBottomUntilRef = useRef(0); const [atBottom, setAtBottom] = useState(true); const hasMessages = messages.length > 0; - const scrollToBottom = useCallback((smooth = false) => { - const el = scrollRef.current; - if (!el) return; - el.scrollTo({ - top: el.scrollHeight, - behavior: smooth ? "smooth" : "auto", - }); + const cancelScheduledBottomScroll = useCallback(() => { + for (const id of scrollFrameIdsRef.current) { + window.cancelAnimationFrame(id); + } + scrollFrameIdsRef.current = []; }, []); + const scrollToBottomNow = useCallback((smooth = false) => { + const el = scrollRef.current; + const marker = bottomRef.current; + const behavior: ScrollBehavior = smooth ? "smooth" : "auto"; + if (marker) { + marker.scrollIntoView({ block: "end", behavior }); + } else if (el) { + el.scrollTo({ top: el.scrollHeight, behavior }); + } + setAtBottom(true); + }, []); + + const scrollToBottom = useCallback((smooth = false, frames = 1) => { + cancelScheduledBottomScroll(); + scrollToBottomNow(smooth); + for (let i = 1; i < frames; i += 1) { + const id = window.requestAnimationFrame(() => scrollToBottomNow(smooth)); + scrollFrameIdsRef.current.push(id); + } + }, [cancelScheduledBottomScroll, scrollToBottomNow]); + useEffect(() => { if (!atBottom) return; scrollToBottom(!isStreaming); }, [messages, isStreaming, atBottom, scrollToBottom]); + useEffect(() => { + if (scrollToBottomSignal <= 0) return; + forceBottomUntilRef.current = Date.now() + 2_000; + scrollToBottom(true, 8); + }, [scrollToBottomSignal, scrollToBottom]); + + useLayoutEffect(() => { + if (lastConversationKeyRef.current === conversationKey) return; + lastConversationKeyRef.current = conversationKey; + pendingConversationScrollRef.current = true; + forceBottomUntilRef.current = Date.now() + 2_000; + setAtBottom(true); + }, [conversationKey]); + + useLayoutEffect(() => { + if (!pendingConversationScrollRef.current) return; + if (!conversationKey) { + pendingConversationScrollRef.current = false; + scrollToBottom(false, 4); + return; + } + scrollToBottom(false, 8); + if (!hasMessages) return; + pendingConversationScrollRef.current = false; + }, [conversationKey, hasMessages, messages, scrollToBottom]); + + useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]); + + useEffect(() => { + const target = contentRef.current; + if (!target || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(() => { + if (!atBottom && Date.now() > forceBottomUntilRef.current) return; + scrollToBottom(false, 4); + }); + observer.observe(target); + return () => observer.disconnect(); + }, [atBottom, hasMessages, scrollToBottom]); + useEffect(() => { const el = scrollRef.current; if (!el) return; @@ -68,7 +136,7 @@ export function ThreadViewport({ )} > {hasMessages ? ( -
+
@@ -82,7 +150,7 @@ export function ThreadViewport({
) : ( -
+
{emptyState} @@ -91,6 +159,7 @@ export function ThreadViewport({
)} +
{ + if (!isReasoningOnlyPlaceholder(message)) return true; + // A reasoning-only assistant row immediately followed by tool traces is + // the live equivalent of a persisted assistant tool-call message with + // empty content, reasoning_content, and tool_calls. Keep it so live render + // and history replay stay isomorphic. + return isToolTrace(prev[index + 1]); + }); +} + +function absorbCompleteAssistantMessage( + prev: UIMessage[], + message: Omit, +): UIMessage[] { + const last = prev[prev.length - 1]; + if (!last || !isReasoningOnlyPlaceholder(last)) { + return [ + ...prev, + { + id: crypto.randomUUID(), + role: "assistant", + createdAt: Date.now(), + ...message, + }, + ]; + } + return [ + ...prev.slice(0, -1), + { + ...last, + ...message, + isStreaming: false, + reasoningStreaming: false, + }, + ]; +} + /** * Subscribe to a chat by ID. Returns the in-memory message list for the chat, * a streaming flag, and a ``send`` function. Initial history must be seeded @@ -286,9 +340,10 @@ export function useNanobotStream( streamEndTimerRef.current = null; } setIsStreaming(false); - setMessages((prev) => - prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)), - ); + setMessages((prev) => { + const finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)); + return pruneReasoningOnlyPlaceholders(finalized); + }); suppressStreamUntilTurnEndRef.current = false; onTurnEnd?.(); return; @@ -314,14 +369,20 @@ export function useNanobotStream( // Attach them to the last trace row if it was the last emitted item // so a sequence of calls collapses into one compact trace group. if (ev.kind === "tool_hint" || ev.kind === "progress") { - const line = ev.text; + const structuredLines = toolTraceLinesFromEvents(ev.tool_events); + const lines = structuredLines.length > 0 + ? structuredLines + : ev.text + ? [ev.text] + : []; + if (lines.length === 0) return; setMessages((prev) => { const last = prev[prev.length - 1]; if (last && last.kind === "trace" && !last.isStreaming) { const merged: UIMessage = { ...last, - traces: [...(last.traces ?? [last.content]), line], - content: line, + traces: [...(last.traces ?? [last.content]), ...lines], + content: lines[lines.length - 1], }; return [...prev.slice(0, -1), merged]; } @@ -331,8 +392,8 @@ export function useNanobotStream( id: crypto.randomUUID(), role: "tool", kind: "trace", - content: line, - traces: [line], + content: lines[lines.length - 1], + traces: lines, createdAt: Date.now(), }, ]; @@ -354,16 +415,10 @@ export function useNanobotStream( setMessages((prev) => { const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev; const content = ev.text; - return [ - ...filtered, - { - id: crypto.randomUUID(), - role: "assistant", - content, - createdAt: Date.now(), - ...(hasMedia ? { media } : {}), - }, - ]; + return absorbCompleteAssistantMessage(filtered, { + content, + ...(hasMedia ? { media } : {}), + }); }); if (hasMedia) { suppressStreamUntilTurnEndRef.current = true; @@ -395,7 +450,7 @@ export function useNanobotStream( const previews = hasImages ? images!.map((i) => i.preview) : undefined; setMessages((prev) => [ - ...prev, + ...pruneReasoningOnlyPlaceholders(prev), { id: crypto.randomUUID(), role: "user", diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 89bf436cc..900ad6adf 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -10,6 +10,7 @@ import { } from "@/lib/api"; import { deriveTitle } from "@/lib/format"; import { toMediaAttachment } from "@/lib/media"; +import { formatToolCallTrace } from "@/lib/tool-traces"; import type { ChatSummary, UIMessage } from "@/lib/types"; const EMPTY_MESSAGES: UIMessage[] = []; @@ -31,24 +32,6 @@ function reasoningFromHistory(message: HistoryMessage): string | undefined { return parts.length > 0 ? parts.join("\n\n") : undefined; } -function formatToolCallTrace(call: unknown): string | null { - if (!call || typeof call !== "object") return null; - const item = call as { - name?: unknown; - function?: { name?: unknown; arguments?: unknown }; - }; - const name = - typeof item.function?.name === "string" - ? item.function.name - : typeof item.name === "string" - ? item.name - : ""; - if (!name) return null; - const args = item.function?.arguments; - if (typeof args === "string" && args.trim()) return `${name}(${args})`; - return `${name}()`; -} - function toolTracesFromHistory(message: HistoryMessage): string[] { if (!Array.isArray(message.tool_calls)) return []; return message.tool_calls @@ -133,23 +116,31 @@ export function useSessionHistory(key: string | null): { messages: UIMessage[]; loading: boolean; error: string | null; + refresh: () => void; + version: number; /** ``true`` when the last persisted assistant turn has ``tool_calls`` but no * final text yet — the model was still processing when the page loaded. */ hasPendingToolCalls: boolean; } { const { token } = useClient(); + const [refreshSeq, setRefreshSeq] = useState(0); + const refresh = useCallback(() => { + setRefreshSeq((value) => value + 1); + }, []); const [state, setState] = useState<{ key: string | null; messages: UIMessage[]; loading: boolean; error: string | null; hasPendingToolCalls: boolean; + version: number; }>({ key: null, messages: [], loading: false, error: null, hasPendingToolCalls: false, + version: 0, }); useEffect(() => { @@ -160,19 +151,23 @@ export function useSessionHistory(key: string | null): { loading: false, error: null, hasPendingToolCalls: false, + version: 0, }); return; } let cancelled = false; // Mark the new key as loading immediately so callers never see stale // messages from the previous session during the render right after a switch. - setState({ - key, - messages: [], - loading: true, - error: null, - hasPendingToolCalls: false, - }); + setState((prev) => prev.key === key + ? { ...prev, loading: true, error: null } + : { + key, + messages: [], + loading: true, + error: null, + hasPendingToolCalls: false, + version: 0, + }); (async () => { try { const body = await fetchSessionMessages(token, key); @@ -203,7 +198,9 @@ export function useSessionHistory(key: string | null): { : {}), }; const traces = m.role === "assistant" ? toolTracesFromHistory(m) : []; - if (traces.length === 0) return [row]; + if (traces.length === 0) { + return row.content.trim() || row.media?.length ? [row] : []; + } return [ ...(row.content.trim() || row.reasoning || row.media?.length ? [row] : []), { @@ -225,55 +222,74 @@ export function useSessionHistory(key: string | null): { lastRaw?.role === "assistant" && Array.isArray(lastRaw.tool_calls) && lastRaw.tool_calls.length > 0; - setState({ + setState((prev) => ({ key, messages: ui, loading: false, error: null, hasPendingToolCalls: hasPending, - }); + version: prev.key === key ? prev.version + 1 : 1, + })); } catch (e) { if (cancelled) return; // A 404 just means the session hasn't been persisted yet (brand-new // chat, first message not sent). That's a normal state, not an error. if (e instanceof ApiError && e.status === 404) { - setState({ + setState((prev) => ({ key, messages: [], loading: false, error: null, hasPendingToolCalls: false, - }); + version: prev.key === key ? prev.version + 1 : 1, + })); } else { - setState({ + setState((prev) => ({ key, messages: [], loading: false, error: (e as Error).message, hasPendingToolCalls: false, - }); + version: prev.key === key ? prev.version : 0, + })); } } })(); return () => { cancelled = true; }; - }, [key, token]); + }, [key, token, refreshSeq]); if (!key) { - return { messages: EMPTY_MESSAGES, loading: false, error: null, hasPendingToolCalls: false }; + return { + messages: EMPTY_MESSAGES, + loading: false, + error: null, + refresh, + version: 0, + hasPendingToolCalls: false, + }; } // Even before the effect above commits its loading state, never surface the // previous session's payload for a brand-new key. if (state.key !== key) { - return { messages: EMPTY_MESSAGES, loading: true, error: null, hasPendingToolCalls: false }; + return { + messages: EMPTY_MESSAGES, + loading: true, + error: null, + refresh, + version: 0, + hasPendingToolCalls: false, + }; } return { messages: state.messages, loading: state.loading, error: state.error, + refresh, + version: state.version, hasPendingToolCalls: state.hasPendingToolCalls, }; } diff --git a/webui/src/lib/tool-traces.ts b/webui/src/lib/tool-traces.ts new file mode 100644 index 000000000..3d277ebaf --- /dev/null +++ b/webui/src/lib/tool-traces.ts @@ -0,0 +1,30 @@ +export function formatToolCallTrace(call: unknown): string | null { + if (!call || typeof call !== "object") return null; + const item = call as { + name?: unknown; + arguments?: unknown; + function?: { name?: unknown; arguments?: unknown }; + }; + const name = + typeof item.function?.name === "string" + ? item.function.name + : typeof item.name === "string" + ? item.name + : ""; + if (!name) return null; + const args = item.function?.arguments ?? item.arguments; + if (typeof args === "string" && args.trim()) return `${name}(${args})`; + if (args && typeof args === "object") return `${name}(${JSON.stringify(args)})`; + return `${name}()`; +} + +export function toolTraceLinesFromEvents(events: unknown): string[] { + if (!Array.isArray(events)) return []; + return events + .filter((event) => { + if (!event || typeof event !== "object") return false; + return (event as { phase?: unknown }).phase === "start"; + }) + .map(formatToolCallTrace) + .filter((trace): trace is string => !!trace); +} diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 25c317753..094b5a6ee 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -53,6 +53,18 @@ export interface UIMessage { reasoningStreaming?: boolean; } +export interface ToolProgressEvent { + version?: number; + phase?: "start" | "end" | "error" | string; + call_id?: string; + name?: string; + arguments?: unknown; + result?: unknown; + error?: unknown; + files?: unknown[]; + embeds?: unknown[]; +} + export interface ChatSummary { /** Server-side session key, e.g. ``websocket:abcd-...``. */ key: string; @@ -146,6 +158,7 @@ export type InboundEvent = reply_to?: string; media?: string[]; media_urls?: Array<{ url: string; name?: string }>; + tool_events?: ToolProgressEvent[]; /** Present when the frame is an agent breadcrumb (e.g. tool hint, * generic progress line) rather than a conversational reply. */ kind?: "tool_hint" | "progress" | "reasoning"; diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 613ce35d1..d401b4942 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -342,6 +342,7 @@ describe("App layout", () => { chatId: "chat-alpha", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + title: "Q2 roadmap", preview: "Project planning notes", }, { @@ -358,15 +359,22 @@ describe("App layout", () => { await waitFor(() => expect(connectSpy).toHaveBeenCalled()); const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); - expect(within(sidebar).getByText("Project planning notes")).toBeInTheDocument(); + expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument(); expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument(); fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), { - target: { value: "travel" }, + target: { value: "planning" }, }); - expect(within(sidebar).queryByText("Project planning notes")).not.toBeInTheDocument(); - expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument(); + expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument(); + expect(within(sidebar).queryByText("Travel ideas")).not.toBeInTheDocument(); + + fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), { + target: { value: "road q2" }, + }); + + expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument(); + expect(within(sidebar).queryByText("Travel ideas")).not.toBeInTheDocument(); }); it("opens a blank start page without creating an empty chat", async () => { diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index f9bf7db0c..3b3261edc 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -8,6 +8,7 @@ import { ClientProvider } from "@/providers/ClientProvider"; function makeClient() { const errorHandlers = new Set<(err: { kind: string }) => void>(); const chatHandlers = new Map void>>(); + const sessionUpdateHandlers = new Set<(chatId: string) => void>(); return { status: "open" as const, defaultChatId: null as string | null, @@ -30,12 +31,21 @@ function makeClient() { errorHandlers.delete(handler); }; }, + onSessionUpdate: (handler: (chatId: string) => void) => { + sessionUpdateHandlers.add(handler); + return () => { + sessionUpdateHandlers.delete(handler); + }; + }, _emitError(err: { kind: string }) { for (const h of errorHandlers) h(err); }, _emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) { for (const h of chatHandlers.get(chatId) ?? []) h(ev); }, + _emitSessionUpdate(chatId: string) { + for (const h of sessionUpdateHandlers) h(chatId); + }, sendMessage: vi.fn(), newChat: vi.fn(), attach: vi.fn(), @@ -573,6 +583,134 @@ describe("ThreadShell", () => { await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument()); }); + it("replaces live streamed content with canonical history after turn end", async () => { + const client = makeClient(); + let historyCalls = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Achat-a/messages")) { + historyCalls += 1; + return httpJson({ + key: "websocket:chat-a", + created_at: null, + updated_at: null, + messages: historyCalls === 1 + ? [{ role: "user", content: "question" }] + : [ + { role: "user", content: "question" }, + { role: "assistant", content: "canonical markdown answer" }, + ], + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument()); + await act(async () => { + client._emitChat("chat-a", { + event: "delta", + chat_id: "chat-a", + text: "live half-parsed | markdown", + }); + client._emitChat("chat-a", { + event: "turn_end", + chat_id: "chat-a", + }); + }); + + await waitFor(() => expect(screen.getByText("canonical markdown answer")).toBeInTheDocument()); + expect(screen.queryByText("live half-parsed | markdown")).not.toBeInTheDocument(); + }); + + it("scrolls to the bottom after loading a session from the blank new-chat page", async () => { + const client = makeClient(); + const scrollIntoView = vi.fn(); + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + HTMLElement.prototype.scrollIntoView = scrollIntoView; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Achat-a/messages")) { + return httpJson({ + key: "websocket:chat-a", + created_at: null, + updated_at: null, + messages: [ + { role: "user", content: "question" }, + { role: "assistant", content: "loaded answer" }, + ], + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + try { + const { rerender } = render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + expect(screen.getByText("What can I do for you?")).toBeInTheDocument(); + scrollIntoView.mockClear(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + }); + + await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument()); + await waitFor(() => + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "end", + behavior: "smooth", + }), + ); + } finally { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + } + }); + it("opens slash commands on the blank welcome page", async () => { const client = makeClient(); vi.stubGlobal( diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx new file mode 100644 index 000000000..3f824455f --- /dev/null +++ b/webui/src/tests/thread-viewport.test.tsx @@ -0,0 +1,164 @@ +import { act, render, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { ThreadViewport } from "@/components/thread/ThreadViewport"; +import type { UIMessage } from "@/lib/types"; + +const messages: UIMessage[] = [ + { + id: "u1", + role: "user", + content: "hello", + createdAt: Date.now(), + }, +]; + +const emptyMessages: UIMessage[] = []; + +describe("ThreadViewport", () => { + it("resets to the bottom when opening a different conversation", async () => { + const scrollIntoView = vi.fn(); + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + try { + const { container, rerender } = render( + } + conversationKey="chat-a" + />, + ); + 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")); + }); + scrollIntoView.mockClear(); + + rerender( + } + conversationKey="chat-b" + />, + ); + + await waitFor(() => + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "end", + behavior: "auto", + }), + ); + } finally { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + } + }); + + it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => { + const scrollIntoView = vi.fn(); + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + try { + const { container, rerender } = render( + } + conversationKey={null} + />, + ); + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 0, + }); + scrollIntoView.mockClear(); + + rerender( + } + conversationKey="chat-a" + />, + ); + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "end", + behavior: "auto", + }); + + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 2400, + }); + scrollIntoView.mockClear(); + + rerender( + } + conversationKey="chat-a" + />, + ); + + await waitFor(() => + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "end", + behavior: "auto", + }), + ); + } finally { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + } + }); + + it("scrolls to the bottom when explicitly signalled after send", async () => { + const scrollIntoView = vi.fn(); + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; + HTMLElement.prototype.scrollIntoView = scrollIntoView; + + try { + const { container, rerender } = render( + } + scrollToBottomSignal={0} + />, + ); + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperty(scroller, "scrollHeight", { + configurable: true, + value: 2400, + }); + scrollIntoView.mockClear(); + + rerender( + } + scrollToBottomSignal={1} + />, + ); + + await waitFor(() => + expect(scrollIntoView).toHaveBeenCalledWith({ + block: "end", + behavior: "smooth", + }), + ); + } finally { + HTMLElement.prototype.scrollIntoView = originalScrollIntoView; + } + }); +}); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 311e7545f..1e69f79a1 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -113,6 +113,43 @@ describe("useNanobotStream", () => { expect(result.current.messages[1].kind).toBeUndefined(); }); + it("renders live tool traces from structured tool events", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-tool-events", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-tool-events", { + event: "message", + chat_id: "chat-tool-events", + text: 'search "hermes"', + kind: "tool_hint", + tool_events: [ + { + phase: "start", + name: "web_search", + arguments: { query: "NousResearch hermes-agent", count: 8 }, + }, + { + phase: "start", + name: "web_search", + arguments: { query: "hermes-agent GitHub stars", count: 8 }, + }, + ], + }); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].traces).toEqual([ + 'web_search({"query":"NousResearch hermes-agent","count":8})', + 'web_search({"query":"hermes-agent GitHub stars","count":8})', + ]); + expect(result.current.messages[0].content).toBe( + 'web_search({"query":"hermes-agent GitHub stars","count":8})', + ); + }); + it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), { @@ -315,6 +352,148 @@ describe("useNanobotStream", () => { expect(result.current.messages[2].reasoning).toBe("Second reasoning."); }); + it("keeps tool-call reasoning before the matching live tool trace", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-tool-reasoning", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-tool-reasoning", { + event: "reasoning_delta", + chat_id: "chat-tool-reasoning", + text: "I should search first.", + }); + fake.emit("chat-tool-reasoning", { + event: "reasoning_end", + chat_id: "chat-tool-reasoning", + }); + fake.emit("chat-tool-reasoning", { + event: "message", + chat_id: "chat-tool-reasoning", + text: "web_search({\"query\":\"hermes\"})", + kind: "tool_hint", + }); + fake.emit("chat-tool-reasoning", { + event: "turn_end", + chat_id: "chat-tool-reasoning", + }); + }); + + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toMatchObject({ + role: "assistant", + content: "", + reasoning: "I should search first.", + reasoningStreaming: false, + isStreaming: false, + }); + expect(result.current.messages[1]).toMatchObject({ + role: "tool", + kind: "trace", + traces: ["web_search({\"query\":\"hermes\"})"], + }); + }); + + it("absorbs non-streamed final answers into the preceding reasoning placeholder", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-final-reasoning", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-final-reasoning", { + event: "message", + chat_id: "chat-final-reasoning", + text: "web_search({\"query\":\"hermes\"})", + kind: "tool_hint", + }); + fake.emit("chat-final-reasoning", { + event: "reasoning_delta", + chat_id: "chat-final-reasoning", + text: "Got results; now summarize.", + }); + fake.emit("chat-final-reasoning", { + event: "reasoning_end", + chat_id: "chat-final-reasoning", + }); + fake.emit("chat-final-reasoning", { + event: "message", + chat_id: "chat-final-reasoning", + text: "Hermes is an open-source agent project.", + }); + fake.emit("chat-final-reasoning", { + event: "turn_end", + chat_id: "chat-final-reasoning", + }); + }); + + expect(result.current.messages).toHaveLength(2); + expect(result.current.messages[0]).toMatchObject({ + role: "tool", + kind: "trace", + }); + expect(result.current.messages[1]).toMatchObject({ + role: "assistant", + content: "Hermes is an open-source agent project.", + reasoning: "Got results; now summarize.", + reasoningStreaming: false, + isStreaming: false, + }); + }); + + it("prunes reasoning-only placeholders when a turn ends without an answer", () => { + const fake = fakeClient(); + const { result } = renderHook(() => useNanobotStream("chat-empty-thinking", EMPTY_MESSAGES), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-empty-thinking", { + event: "reasoning_delta", + chat_id: "chat-empty-thinking", + text: "thinking without final text", + }); + fake.emit("chat-empty-thinking", { + event: "reasoning_end", + chat_id: "chat-empty-thinking", + }); + fake.emit("chat-empty-thinking", { + event: "turn_end", + chat_id: "chat-empty-thinking", + }); + }); + + expect(result.current.messages).toHaveLength(0); + expect(result.current.isStreaming).toBe(false); + }); + + it("drops stale reasoning-only placeholders before sending the next user turn", () => { + const fake = fakeClient(); + const initialMessages = [ + { + id: "stale-thinking", + role: "assistant" as const, + content: "", + reasoning: "leftover thinking", + reasoningStreaming: false, + createdAt: Date.now(), + }, + ]; + const { result } = renderHook( + () => useNanobotStream("chat-stale-thinking", initialMessages), + { wrapper: wrap(fake.client) }, + ); + + act(() => { + result.current.send("fine"); + }); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.messages[0].role).toBe("user"); + expect(result.current.messages[0].content).toBe("fine"); + }); + it("attaches assistant media_urls to complete messages", () => { const fake = fakeClient(); const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), { diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index ecb1df681..75bc1bb6e 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -245,6 +245,30 @@ describe("useSessions", () => { expect(result.current.messages[0].reasoningStreaming).toBe(false); }); + it("drops replayed assistant turns that only contain reasoning", async () => { + vi.mocked(api.fetchSessionMessages).mockResolvedValue({ + key: "websocket:chat-empty-reasoning", + created_at: "2026-04-20T10:00:00Z", + updated_at: "2026-04-20T10:05:00Z", + messages: [ + { + role: "assistant", + content: "", + timestamp: "2026-04-20T10:00:01Z", + reasoning_content: "orphan reasoning", + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-empty-reasoning"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.messages).toHaveLength(0); + }); + it("hydrates historical assistant tool calls into a replay trace row", async () => { vi.mocked(api.fetchSessionMessages).mockResolvedValue({ key: "websocket:chat-tools",