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
+12 -4
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 () => {
+138
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(
+164
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;
}
});
});
+179
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), {
+24
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",