fix(webui): stabilize live thread rendering and navigation
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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), {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user