fix(webui): stabilize live thread rendering and navigation

This commit is contained in:
Xubin Ren 2026-05-13 16:39:07 +00:00
parent 6a4ed255de
commit 5d7f3f2751
14 changed files with 876 additions and 77 deletions

View File

@ -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.

View File

@ -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()

View File

@ -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]);

View File

@ -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<SlashCommand[]>([]);
const [heroImageMode, setHeroImageMode] = useState(false);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const lastCachedChatIdRef = useRef<string | null>(null);
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
const pendingCanonicalHydrateRef = useRef<Set<string>>(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 ? (
<ThreadComposer
onSend={send}
onSend={handleThreadSend}
disabled={!chatId}
isStreaming={isStreaming}
placeholder={
@ -296,6 +345,8 @@ export function ThreadShell({
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
/>
</section>
);

View File

@ -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<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const lastConversationKeyRef = useRef<string | null>(conversationKey);
const pendingConversationScrollRef = useRef(true);
const scrollFrameIdsRef = useRef<number[]>([]);
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 ? (
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
<div className="flex-1 px-4 pb-20 pt-4">
<div className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages messages={messages} />
@ -82,7 +150,7 @@ export function ThreadViewport({
</div>
</div>
) : (
<div className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
<div className="flex w-full flex-1 items-center justify-center pb-[7vh] pt-8">
<div className="flex w-full max-w-[58rem] flex-col gap-6">
{emptyState}
@ -91,6 +159,7 @@ export function ThreadViewport({
</div>
</div>
)}
<div ref={bottomRef} aria-hidden className="h-px" />
</div>
<div

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import { toMediaAttachment } from "@/lib/media";
import { toolTraceLinesFromEvents } from "@/lib/tool-traces";
import type { StreamError } from "@/lib/nanobot-client";
import type {
InboundEvent,
@ -107,6 +108,59 @@ function closeReasoningStream(prev: UIMessage[]): UIMessage[] {
return prev;
}
function isReasoningOnlyPlaceholder(message: UIMessage): boolean {
return (
message.role === "assistant"
&& message.kind !== "trace"
&& message.content.trim().length === 0
&& !!message.reasoning
&& !message.reasoningStreaming
&& !message.media?.length
);
}
function isToolTrace(message: UIMessage | undefined): boolean {
return message?.kind === "trace";
}
function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
return prev.filter((message, index) => {
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, "id" | "role" | "createdAt">,
): 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",

View File

@ -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,
};
}

View File

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

View File

@ -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";

View File

@ -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 () => {

View File

@ -8,6 +8,7 @@ import { ClientProvider } from "@/providers/ClientProvider";
function makeClient() {
const errorHandlers = new Set<(err: { kind: string }) => void>();
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => 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,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
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,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
scrollIntoView.mockClear();
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
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(

View File

@ -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(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
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(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
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(
<ThreadViewport
messages={emptyMessages}
isStreaming={false}
composer={<div />}
conversationKey={null}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 0,
});
scrollIntoView.mockClear();
rerender(
<ThreadViewport
messages={emptyMessages}
isStreaming={false}
composer={<div />}
conversationKey="chat-a"
/>,
);
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
behavior: "auto",
});
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 2400,
});
scrollIntoView.mockClear();
rerender(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
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(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
scrollToBottomSignal={0}
/>,
);
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", {
configurable: true,
value: 2400,
});
scrollIntoView.mockClear();
rerender(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div />}
scrollToBottomSignal={1}
/>,
);
await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({
block: "end",
behavior: "smooth",
}),
);
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
});
});

View File

@ -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), {

View File

@ -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",