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 payload["media_urls"] = urls
if msg.reply_to: if msg.reply_to:
payload["reply_to"] = 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 # Mark intermediate agent breadcrumbs (tool-call hints, generic
# progress strings) so WS clients can render them as subordinate # progress strings) so WS clients can render them as subordinate
# trace rows rather than conversational replies. # 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 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 @pytest.mark.asyncio
async def test_send_delta_removes_connection_on_connection_closed() -> None: async def test_send_delta_removes_connection_on_connection_closed() -> None:
bus = MagicMock() bus = MagicMock()

View File

@ -31,8 +31,10 @@ export function Sidebar(props: SidebarProps) {
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const filteredSessions = useMemo(() => { const filteredSessions = useMemo(() => {
if (!normalizedQuery) return props.sessions; if (!normalizedQuery) return props.sessions;
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
return props.sessions.filter((session) => { return props.sessions.filter((session) => {
const haystack = [ const haystack = [
session.title,
session.preview, session.preview,
session.chatId, session.chatId,
session.channel, session.channel,
@ -41,7 +43,7 @@ export function Sidebar(props: SidebarProps) {
.filter(Boolean) .filter(Boolean)
.join(" ") .join(" ")
.toLowerCase(); .toLowerCase();
return haystack.includes(normalizedQuery); return terms.every((term) => haystack.includes(term));
}); });
}, [normalizedQuery, props.sessions]); }, [normalizedQuery, props.sessions]);

View File

@ -81,19 +81,33 @@ export function ThreadShell({
const { t } = useTranslation(); const { t } = useTranslation();
const chatId = session?.chatId ?? null; const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null; const historyKey = session?.key ?? null;
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey); const {
const { modelName, token } = useClient(); messages: historical,
loading,
hasPendingToolCalls,
refresh: refreshHistory,
version: historyVersion,
} = useSessionHistory(historyKey);
const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false); const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]); const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [heroImageMode, setHeroImageMode] = useState(false); const [heroImageMode, setHeroImageMode] = useState(false);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null); const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map()); const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const lastCachedChatIdRef = useRef<string | null>(null); const lastCachedChatIdRef = useRef<string | null>(null);
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
const initial = useMemo(() => { const initial = useMemo(() => {
if (!chatId) return historical; if (!chatId) return historical;
return messageCacheRef.current.get(chatId) ?? historical; return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]); }, [chatId, historical]);
const handleTurnEnd = useCallback(() => {
if (chatId) pendingCanonicalHydrateRef.current.add(chatId);
refreshHistory();
onTurnEnd?.();
}, [chatId, onTurnEnd, refreshHistory]);
const { const {
messages, messages,
isStreaming, isStreaming,
@ -102,22 +116,48 @@ export function ThreadShell({
setMessages, setMessages,
streamError, streamError,
dismissStreamError, dismissStreamError,
} = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd); } = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
const showHeroComposer = messages.length === 0 && !loading; const showHeroComposer = messages.length === 0 && !loading;
useEffect(() => { useEffect(() => {
if (!chatId || loading) return; if (!chatId || loading) return;
const cached = messageCacheRef.current.get(chatId); 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 // When the user switches away and back, keep the local in-memory thread
// state (including not-yet-persisted messages) instead of replacing it with // 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) => { 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 (cached && cached.length > 0) return cached;
if (historical.length === 0 && prev.length > 0) return prev; if (historical.length === 0 && prev.length > 0) return prev;
appliedHistoryVersionRef.current.set(chatId, historyVersion);
return historical; return historical;
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // 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(() => { useEffect(() => {
if (chatId) return; if (chatId) return;
@ -148,6 +188,7 @@ export function ThreadShell({
const pending = pendingFirstRef.current; const pending = pendingFirstRef.current;
if (!pending) return; if (!pending) return;
pendingFirstRef.current = null; pendingFirstRef.current = null;
setScrollToBottomSignal((value) => value + 1);
send(pending.content, pending.images, pending.options); send(pending.content, pending.images, pending.options);
setBooting(false); setBooting(false);
}, [chatId, send]); }, [chatId, send]);
@ -181,18 +222,26 @@ export function ThreadShell({
[booting, onCreateChat], [booting, onCreateChat],
); );
const handleThreadSend = useCallback(
(content: string, images?: SendImage[], options?: SendOptions) => {
setScrollToBottomSignal((value) => value + 1);
send(content, images, options);
},
[send],
);
const handleQuickAction = useCallback( const handleQuickAction = useCallback(
(prompt: string) => { (prompt: string) => {
const options: SendOptions | undefined = heroImageMode const options: SendOptions | undefined = heroImageMode
? { imageGeneration: { enabled: true, aspect_ratio: null } } ? { imageGeneration: { enabled: true, aspect_ratio: null } }
: undefined; : undefined;
if (session) { if (session) {
send(prompt, undefined, options); handleThreadSend(prompt, undefined, options);
return; return;
} }
void handleWelcomeSend(prompt, undefined, options); void handleWelcomeSend(prompt, undefined, options);
}, },
[handleWelcomeSend, heroImageMode, send, session], [handleThreadSend, handleWelcomeSend, heroImageMode, session],
); );
const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS; const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS;
@ -233,7 +282,7 @@ export function ThreadShell({
) : null} ) : null}
{session ? ( {session ? (
<ThreadComposer <ThreadComposer
onSend={send} onSend={handleThreadSend}
disabled={!chatId} disabled={!chatId}
isStreaming={isStreaming} isStreaming={isStreaming}
placeholder={ placeholder={
@ -296,6 +345,8 @@ export function ThreadShell({
isStreaming={isStreaming} isStreaming={isStreaming}
emptyState={emptyState} emptyState={emptyState}
composer={composer} composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
/> />
</section> </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 { ArrowDown } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -12,6 +12,8 @@ interface ThreadViewportProps {
isStreaming: boolean; isStreaming: boolean;
composer: ReactNode; composer: ReactNode;
emptyState?: ReactNode; emptyState?: ReactNode;
scrollToBottomSignal?: number;
conversationKey?: string | null;
} }
const NEAR_BOTTOM_PX = 48; const NEAR_BOTTOM_PX = 48;
@ -21,26 +23,92 @@ export function ThreadViewport({
isStreaming, isStreaming,
composer, composer,
emptyState, emptyState,
scrollToBottomSignal = 0,
conversationKey = null,
}: ThreadViewportProps) { }: ThreadViewportProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); 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 [atBottom, setAtBottom] = useState(true);
const hasMessages = messages.length > 0; const hasMessages = messages.length > 0;
const scrollToBottom = useCallback((smooth = false) => { const cancelScheduledBottomScroll = useCallback(() => {
const el = scrollRef.current; for (const id of scrollFrameIdsRef.current) {
if (!el) return; window.cancelAnimationFrame(id);
el.scrollTo({ }
top: el.scrollHeight, scrollFrameIdsRef.current = [];
behavior: smooth ? "smooth" : "auto",
});
}, []); }, []);
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(() => { useEffect(() => {
if (!atBottom) return; if (!atBottom) return;
scrollToBottom(!isStreaming); scrollToBottom(!isStreaming);
}, [messages, isStreaming, atBottom, scrollToBottom]); }, [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(() => { useEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
@ -68,7 +136,7 @@ export function ThreadViewport({
)} )}
> >
{hasMessages ? ( {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="flex-1 px-4 pb-20 pt-4">
<div className="mx-auto w-full max-w-[49.5rem]"> <div className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages messages={messages} /> <ThreadMessages messages={messages} />
@ -82,7 +150,7 @@ export function ThreadViewport({
</div> </div>
</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 flex-1 items-center justify-center pb-[7vh] pt-8">
<div className="flex w-full max-w-[58rem] flex-col gap-6"> <div className="flex w-full max-w-[58rem] flex-col gap-6">
{emptyState} {emptyState}
@ -91,6 +159,7 @@ export function ThreadViewport({
</div> </div>
</div> </div>
)} )}
<div ref={bottomRef} aria-hidden className="h-px" />
</div> </div>
<div <div

View File

@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider"; import { useClient } from "@/providers/ClientProvider";
import { toMediaAttachment } from "@/lib/media"; import { toMediaAttachment } from "@/lib/media";
import { toolTraceLinesFromEvents } from "@/lib/tool-traces";
import type { StreamError } from "@/lib/nanobot-client"; import type { StreamError } from "@/lib/nanobot-client";
import type { import type {
InboundEvent, InboundEvent,
@ -107,6 +108,59 @@ function closeReasoningStream(prev: UIMessage[]): UIMessage[] {
return prev; 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, * 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 * a streaming flag, and a ``send`` function. Initial history must be seeded
@ -286,9 +340,10 @@ export function useNanobotStream(
streamEndTimerRef.current = null; streamEndTimerRef.current = null;
} }
setIsStreaming(false); setIsStreaming(false);
setMessages((prev) => setMessages((prev) => {
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)), const finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
); return pruneReasoningOnlyPlaceholders(finalized);
});
suppressStreamUntilTurnEndRef.current = false; suppressStreamUntilTurnEndRef.current = false;
onTurnEnd?.(); onTurnEnd?.();
return; return;
@ -314,14 +369,20 @@ export function useNanobotStream(
// Attach them to the last trace row if it was the last emitted item // 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. // so a sequence of calls collapses into one compact trace group.
if (ev.kind === "tool_hint" || ev.kind === "progress") { 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) => { setMessages((prev) => {
const last = prev[prev.length - 1]; const last = prev[prev.length - 1];
if (last && last.kind === "trace" && !last.isStreaming) { if (last && last.kind === "trace" && !last.isStreaming) {
const merged: UIMessage = { const merged: UIMessage = {
...last, ...last,
traces: [...(last.traces ?? [last.content]), line], traces: [...(last.traces ?? [last.content]), ...lines],
content: line, content: lines[lines.length - 1],
}; };
return [...prev.slice(0, -1), merged]; return [...prev.slice(0, -1), merged];
} }
@ -331,8 +392,8 @@ export function useNanobotStream(
id: crypto.randomUUID(), id: crypto.randomUUID(),
role: "tool", role: "tool",
kind: "trace", kind: "trace",
content: line, content: lines[lines.length - 1],
traces: [line], traces: lines,
createdAt: Date.now(), createdAt: Date.now(),
}, },
]; ];
@ -354,16 +415,10 @@ export function useNanobotStream(
setMessages((prev) => { setMessages((prev) => {
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev; const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
const content = ev.text; const content = ev.text;
return [ return absorbCompleteAssistantMessage(filtered, {
...filtered,
{
id: crypto.randomUUID(),
role: "assistant",
content, content,
createdAt: Date.now(),
...(hasMedia ? { media } : {}), ...(hasMedia ? { media } : {}),
}, });
];
}); });
if (hasMedia) { if (hasMedia) {
suppressStreamUntilTurnEndRef.current = true; suppressStreamUntilTurnEndRef.current = true;
@ -395,7 +450,7 @@ export function useNanobotStream(
const previews = hasImages ? images!.map((i) => i.preview) : undefined; const previews = hasImages ? images!.map((i) => i.preview) : undefined;
setMessages((prev) => [ setMessages((prev) => [
...prev, ...pruneReasoningOnlyPlaceholders(prev),
{ {
id: crypto.randomUUID(), id: crypto.randomUUID(),
role: "user", role: "user",

View File

@ -10,6 +10,7 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { deriveTitle } from "@/lib/format"; import { deriveTitle } from "@/lib/format";
import { toMediaAttachment } from "@/lib/media"; import { toMediaAttachment } from "@/lib/media";
import { formatToolCallTrace } from "@/lib/tool-traces";
import type { ChatSummary, UIMessage } from "@/lib/types"; import type { ChatSummary, UIMessage } from "@/lib/types";
const EMPTY_MESSAGES: UIMessage[] = []; const EMPTY_MESSAGES: UIMessage[] = [];
@ -31,24 +32,6 @@ function reasoningFromHistory(message: HistoryMessage): string | undefined {
return parts.length > 0 ? parts.join("\n\n") : 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[] { function toolTracesFromHistory(message: HistoryMessage): string[] {
if (!Array.isArray(message.tool_calls)) return []; if (!Array.isArray(message.tool_calls)) return [];
return message.tool_calls return message.tool_calls
@ -133,23 +116,31 @@ export function useSessionHistory(key: string | null): {
messages: UIMessage[]; messages: UIMessage[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refresh: () => void;
version: number;
/** ``true`` when the last persisted assistant turn has ``tool_calls`` but no /** ``true`` when the last persisted assistant turn has ``tool_calls`` but no
* final text yet the model was still processing when the page loaded. */ * final text yet the model was still processing when the page loaded. */
hasPendingToolCalls: boolean; hasPendingToolCalls: boolean;
} { } {
const { token } = useClient(); const { token } = useClient();
const [refreshSeq, setRefreshSeq] = useState(0);
const refresh = useCallback(() => {
setRefreshSeq((value) => value + 1);
}, []);
const [state, setState] = useState<{ const [state, setState] = useState<{
key: string | null; key: string | null;
messages: UIMessage[]; messages: UIMessage[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
hasPendingToolCalls: boolean; hasPendingToolCalls: boolean;
version: number;
}>({ }>({
key: null, key: null,
messages: [], messages: [],
loading: false, loading: false,
error: null, error: null,
hasPendingToolCalls: false, hasPendingToolCalls: false,
version: 0,
}); });
useEffect(() => { useEffect(() => {
@ -160,18 +151,22 @@ export function useSessionHistory(key: string | null): {
loading: false, loading: false,
error: null, error: null,
hasPendingToolCalls: false, hasPendingToolCalls: false,
version: 0,
}); });
return; return;
} }
let cancelled = false; let cancelled = false;
// Mark the new key as loading immediately so callers never see stale // Mark the new key as loading immediately so callers never see stale
// messages from the previous session during the render right after a switch. // messages from the previous session during the render right after a switch.
setState({ setState((prev) => prev.key === key
? { ...prev, loading: true, error: null }
: {
key, key,
messages: [], messages: [],
loading: true, loading: true,
error: null, error: null,
hasPendingToolCalls: false, hasPendingToolCalls: false,
version: 0,
}); });
(async () => { (async () => {
try { try {
@ -203,7 +198,9 @@ export function useSessionHistory(key: string | null): {
: {}), : {}),
}; };
const traces = m.role === "assistant" ? toolTracesFromHistory(m) : []; 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 [ return [
...(row.content.trim() || row.reasoning || row.media?.length ? [row] : []), ...(row.content.trim() || row.reasoning || row.media?.length ? [row] : []),
{ {
@ -225,55 +222,74 @@ export function useSessionHistory(key: string | null): {
lastRaw?.role === "assistant" && lastRaw?.role === "assistant" &&
Array.isArray(lastRaw.tool_calls) && Array.isArray(lastRaw.tool_calls) &&
lastRaw.tool_calls.length > 0; lastRaw.tool_calls.length > 0;
setState({ setState((prev) => ({
key, key,
messages: ui, messages: ui,
loading: false, loading: false,
error: null, error: null,
hasPendingToolCalls: hasPending, hasPendingToolCalls: hasPending,
}); version: prev.key === key ? prev.version + 1 : 1,
}));
} catch (e) { } catch (e) {
if (cancelled) return; if (cancelled) return;
// A 404 just means the session hasn't been persisted yet (brand-new // 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. // chat, first message not sent). That's a normal state, not an error.
if (e instanceof ApiError && e.status === 404) { if (e instanceof ApiError && e.status === 404) {
setState({ setState((prev) => ({
key, key,
messages: [], messages: [],
loading: false, loading: false,
error: null, error: null,
hasPendingToolCalls: false, hasPendingToolCalls: false,
}); version: prev.key === key ? prev.version + 1 : 1,
}));
} else { } else {
setState({ setState((prev) => ({
key, key,
messages: [], messages: [],
loading: false, loading: false,
error: (e as Error).message, error: (e as Error).message,
hasPendingToolCalls: false, hasPendingToolCalls: false,
}); version: prev.key === key ? prev.version : 0,
}));
} }
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [key, token]); }, [key, token, refreshSeq]);
if (!key) { 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 // Even before the effect above commits its loading state, never surface the
// previous session's payload for a brand-new key. // previous session's payload for a brand-new key.
if (state.key !== 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 { return {
messages: state.messages, messages: state.messages,
loading: state.loading, loading: state.loading,
error: state.error, error: state.error,
refresh,
version: state.version,
hasPendingToolCalls: state.hasPendingToolCalls, 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; 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 { export interface ChatSummary {
/** Server-side session key, e.g. ``websocket:abcd-...``. */ /** Server-side session key, e.g. ``websocket:abcd-...``. */
key: string; key: string;
@ -146,6 +158,7 @@ export type InboundEvent =
reply_to?: string; reply_to?: string;
media?: string[]; media?: string[];
media_urls?: Array<{ url: string; name?: string }>; media_urls?: Array<{ url: string; name?: string }>;
tool_events?: ToolProgressEvent[];
/** Present when the frame is an agent breadcrumb (e.g. tool hint, /** Present when the frame is an agent breadcrumb (e.g. tool hint,
* generic progress line) rather than a conversational reply. */ * generic progress line) rather than a conversational reply. */
kind?: "tool_hint" | "progress" | "reasoning"; kind?: "tool_hint" | "progress" | "reasoning";

View File

@ -342,6 +342,7 @@ describe("App layout", () => {
chatId: "chat-alpha", chatId: "chat-alpha",
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
title: "Q2 roadmap",
preview: "Project planning notes", preview: "Project planning notes",
}, },
{ {
@ -358,15 +359,22 @@ describe("App layout", () => {
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); 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(); expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), { 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("Q2 roadmap")).toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).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 () => { 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() { function makeClient() {
const errorHandlers = new Set<(err: { kind: string }) => void>(); const errorHandlers = new Set<(err: { kind: string }) => void>();
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>(); const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
return { return {
status: "open" as const, status: "open" as const,
defaultChatId: null as string | null, defaultChatId: null as string | null,
@ -30,12 +31,21 @@ function makeClient() {
errorHandlers.delete(handler); errorHandlers.delete(handler);
}; };
}, },
onSessionUpdate: (handler: (chatId: string) => void) => {
sessionUpdateHandlers.add(handler);
return () => {
sessionUpdateHandlers.delete(handler);
};
},
_emitError(err: { kind: string }) { _emitError(err: { kind: string }) {
for (const h of errorHandlers) h(err); for (const h of errorHandlers) h(err);
}, },
_emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) { _emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
for (const h of chatHandlers.get(chatId) ?? []) h(ev); for (const h of chatHandlers.get(chatId) ?? []) h(ev);
}, },
_emitSessionUpdate(chatId: string) {
for (const h of sessionUpdateHandlers) h(chatId);
},
sendMessage: vi.fn(), sendMessage: vi.fn(),
newChat: vi.fn(), newChat: vi.fn(),
attach: vi.fn(), attach: vi.fn(),
@ -573,6 +583,134 @@ describe("ThreadShell", () => {
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument()); 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 () => { it("opens slash commands on the blank welcome page", async () => {
const client = makeClient(); const client = makeClient();
vi.stubGlobal( 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(); 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", () => { it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
@ -315,6 +352,148 @@ describe("useNanobotStream", () => {
expect(result.current.messages[2].reasoning).toBe("Second reasoning."); 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", () => { it("attaches assistant media_urls to complete messages", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {

View File

@ -245,6 +245,30 @@ describe("useSessions", () => {
expect(result.current.messages[0].reasoningStreaming).toBe(false); 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 () => { it("hydrates historical assistant tool calls into a replay trace row", async () => {
vi.mocked(api.fetchSessionMessages).mockResolvedValue({ vi.mocked(api.fetchSessionMessages).mockResolvedValue({
key: "websocket:chat-tools", key: "websocket:chat-tools",