Optimize WebUI streaming and long history rendering

Batch stream deltas, window long transcripts, lazy-load syntax highlighting, and refine activity/composer interactions.

Add title refresh retries plus tests for streaming, windowing, code blocks, and live activity behavior.
This commit is contained in:
Xubin Ren
2026-05-17 17:04:57 +08:00
parent 175b58e259
commit e5be4dac7a
30 changed files with 1551 additions and 282 deletions
@@ -0,0 +1,204 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import type { UIMessage } from "@/lib/types";
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
const rows: UIMessage[] = [
{
id: "r1",
role: "assistant",
content: "",
reasoning: `thinking${extraReasoning}`,
reasoningStreaming: true,
isStreaming: true,
createdAt: 1,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "search()",
traces: ["search()"],
createdAt: 2,
},
];
if (extraTool) rows.push(extraTool);
return rows;
}
function installAnimationFrameQueue() {
const originalRequest = window.requestAnimationFrame;
const originalCancel = window.cancelAnimationFrame;
const callbacks = new Map<number, FrameRequestCallback>();
let nextId = 1;
window.requestAnimationFrame = ((callback: FrameRequestCallback) => {
const id = nextId;
nextId += 1;
callbacks.set(id, callback);
return id;
}) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((id: number) => {
callbacks.delete(id);
}) as typeof window.cancelAnimationFrame;
return {
flush() {
const pending = Array.from(callbacks.entries());
callbacks.clear();
for (const [, callback] of pending) callback(0);
},
restore() {
window.requestAnimationFrame = originalRequest;
window.cancelAnimationFrame = originalCancel;
},
};
}
function setScrollGeometry(
element: HTMLElement,
geometry: { scrollHeight: number; clientHeight: number; scrollTop?: number },
) {
Object.defineProperties(element, {
scrollHeight: { configurable: true, value: geometry.scrollHeight },
clientHeight: { configurable: true, value: geometry.clientHeight },
scrollTop: {
configurable: true,
value: geometry.scrollTop ?? element.scrollTop,
writable: true,
},
});
}
describe("AgentActivityCluster", () => {
it("jumps to the latest activity when opened", () => {
const raf = installAnimationFrameQueue();
try {
render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(880);
} finally {
raf.restore();
}
});
it("follows new reasoning and tool activity while the user is at the bottom", () => {
const raf = installAnimationFrameQueue();
try {
const { rerender } = render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
rerender(
<AgentActivityCluster
messages={activityMessages(" with more detail", {
id: "t2",
role: "tool",
kind: "trace",
content: "open_browser()",
traces: ["open_browser()"],
createdAt: 3,
})}
isTurnStreaming
hasBodyBelow={false}
/>,
);
setScrollGeometry(scrollport, {
scrollHeight: 1500,
clientHeight: 120,
scrollTop: scrollport.scrollTop,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(1380);
} finally {
raf.restore();
}
});
it("does not pull the user down after they scroll up inside the activity pane", () => {
const raf = installAnimationFrameQueue();
try {
const { rerender } = render(
<AgentActivityCluster
messages={activityMessages()}
isTurnStreaming
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /working/i }));
const scrollport = screen.getByTestId("agent-activity-scroll");
setScrollGeometry(scrollport, {
scrollHeight: 1000,
clientHeight: 120,
scrollTop: 0,
});
act(() => {
raf.flush();
});
scrollport.scrollTop = 100;
fireEvent.scroll(scrollport);
rerender(
<AgentActivityCluster
messages={activityMessages(" still streaming")}
isTurnStreaming
hasBodyBelow={false}
/>,
);
setScrollGeometry(scrollport, {
scrollHeight: 1500,
clientHeight: 120,
scrollTop: scrollport.scrollTop,
});
act(() => {
raf.flush();
});
expect(scrollport.scrollTop).toBe(100);
} finally {
raf.restore();
}
});
});
+12 -6
View File
@@ -32,12 +32,18 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
};
});
vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({
theme: "light" as const,
toggle: toggleThemeSpy,
}),
}));
vi.mock("@/hooks/useTheme", async () => {
const React = await import("react");
return {
ThemeProvider: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
useTheme: () => ({
theme: "light" as const,
toggle: toggleThemeSpy,
}),
useThemeValue: () => "light" as const,
};
});
vi.mock("@/lib/bootstrap", () => ({
fetchBootstrap: vi.fn().mockResolvedValue({
+92
View File
@@ -0,0 +1,92 @@
import { act, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { CodeBlock } from "@/components/CodeBlock";
import { ThemeProvider } from "@/hooks/useTheme";
const mockedStyles = vi.hoisted(() => ({
dark: { pre: { background: "#111" } },
light: { pre: { background: "#fff" } },
}));
vi.mock("react-syntax-highlighter/dist/esm/prism-async-light", () => ({
default: ({
children,
style,
}: {
children: string;
style: Record<string, unknown>;
}) => (
<pre
data-testid="highlighted-code"
data-theme={style === mockedStyles.dark ? "dark" : "light"}
>
<code>{children}</code>
</pre>
),
}));
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-dark", () => ({
default: mockedStyles.dark,
}));
vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({
default: mockedStyles.light,
}));
describe("CodeBlock", () => {
it("reads theme from context without creating per-block observers", async () => {
const originalMutationObserver = globalThis.MutationObserver;
const observer = vi.fn();
class MockMutationObserver {
constructor(callback: MutationCallback) {
observer(callback);
}
observe = vi.fn();
disconnect = vi.fn();
takeRecords() {
return [];
}
}
vi.stubGlobal("MutationObserver", MockMutationObserver);
try {
const { rerender } = render(
<ThemeProvider theme="dark">
<CodeBlock language="ts" code="const value = 1;" />
</ThemeProvider>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
"data-theme",
"dark",
);
rerender(
<ThemeProvider theme="light">
<CodeBlock language="ts" code="const value = 1;" />
</ThemeProvider>,
);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByTestId("highlighted-code")).toHaveAttribute(
"data-theme",
"light",
);
expect(observer).not.toHaveBeenCalled();
} finally {
vi.stubGlobal("MutationObserver", originalMutationObserver);
}
});
});
+3 -1
View File
@@ -131,7 +131,9 @@ describe("MessageBubble", () => {
expect(screen.getByText("Thinking…")).toBeInTheDocument();
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
expect(container.querySelector(".reasoning-sheen-stripe")).toBeInTheDocument();
expect(container.querySelector(".reasoning-sheen-stripe")).not.toBeInTheDocument();
expect(screen.getByText("Thinking…")).toHaveClass("streaming-text-sheen");
expect(screen.getByText("Thinking…")).toHaveAttribute("data-sheen-text", "Thinking…");
expect(screen.getByRole("button", { name: /thinking/i }).parentElement).not.toHaveClass("mb-2");
});
+38 -1
View File
@@ -1,7 +1,11 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ThreadMessages } from "@/components/thread/ThreadMessages";
import {
assistantCopyFlags,
buildDisplayUnits,
ThreadMessages,
} from "@/components/thread/ThreadMessages";
import type { UIMessage } from "@/lib/types";
describe("ThreadMessages", () => {
@@ -89,4 +93,37 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.getAllByRole("button", { name: "Copy reply" })).toHaveLength(1);
});
it("computes final assistant copy flags with user-boundary semantics", () => {
const units = buildDisplayUnits([
{ id: "u1", role: "user", content: "one", createdAt: 1 },
{ id: "a1", role: "assistant", content: "draft", createdAt: 2 },
{
id: "t1",
role: "tool",
kind: "trace",
content: "tool()",
traces: ["tool()"],
createdAt: 3,
},
{ id: "a2", role: "assistant", content: "final", createdAt: 4 },
{ id: "u2", role: "user", content: "two", createdAt: 5 },
{ id: "a3", role: "assistant", content: "next", createdAt: 6 },
]);
const flags = assistantCopyFlags(units);
const assistantFlags = units
.map((unit, index) =>
unit.type === "single" && unit.message.role === "assistant"
? [unit.message.id, flags[index]]
: null,
)
.filter(Boolean);
expect(assistantFlags).toEqual([
["a1", false],
["a2", true],
["a3", true],
]);
});
});
+191 -2
View File
@@ -1,7 +1,12 @@
import { act, render, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import {
HISTORY_WINDOW_INCREMENT,
INITIAL_HISTORY_WINDOW,
ThreadViewport,
windowMessages,
} from "@/components/thread/ThreadViewport";
import type { UIMessage } from "@/lib/types";
const messages: UIMessage[] = [
@@ -15,7 +20,191 @@ const messages: UIMessage[] = [
const emptyMessages: UIMessage[] = [];
interface ResizeObserverInstance {
element?: Element;
callback: ResizeObserverCallback;
disconnect: ReturnType<typeof vi.fn>;
}
function makeLongMessages(count: number): UIMessage[] {
return Array.from({ length: count }, (_, index) => ({
id: `m${index}`,
role: "user" as const,
content: `message ${index}`,
createdAt: index,
}));
}
describe("ThreadViewport", () => {
it("keeps the scroll-to-bottom button above a growing composer", () => {
const originalResizeObserver = globalThis.ResizeObserver;
const resizeObservers: ResizeObserverInstance[] = [];
class MockResizeObserver {
element?: Element;
callback: ResizeObserverCallback;
disconnect = vi.fn();
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
resizeObservers.push(this);
}
observe(element: Element) {
this.element = element;
}
}
vi.stubGlobal("ResizeObserver", MockResizeObserver);
try {
const { container } = render(
<ThreadViewport
messages={messages}
isStreaming={false}
composer={<div>composer</div>}
/>,
);
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"));
});
const button = screen.getByRole("button", { name: "Scroll to bottom" });
expect(button).toHaveStyle({ bottom: "192px" });
const composerDock = screen.getByTestId("thread-composer-dock");
composerDock.getBoundingClientRect = () =>
({
height: 240,
width: 800,
top: 0,
right: 800,
bottom: 240,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
}) as DOMRect;
const composerObserver = resizeObservers.find(
(observer) => observer.element === composerDock,
);
expect(composerObserver).toBeDefined();
act(() => {
composerObserver!.callback([], composerObserver as unknown as ResizeObserver);
});
expect(button).toHaveStyle({ bottom: "256px" });
} finally {
vi.stubGlobal("ResizeObserver", originalResizeObserver);
}
});
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
const { container } = render(
<ThreadViewport
messages={emptyMessages}
isStreaming={false}
composer={<div>composer</div>}
emptyState={<div>welcome</div>}
showScrollToBottomButton={false}
/>,
);
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"));
});
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
});
it("renders only the tail window for long history by default", () => {
const longMessages = makeLongMessages(300);
render(
<ThreadViewport
messages={longMessages}
isStreaming={false}
composer={<div />}
/>,
);
expect(screen.queryByText("message 139")).not.toBeInTheDocument();
expect(screen.getByText("message 140")).toBeInTheDocument();
expect(screen.getByText("message 299")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Load earlier messages" })).toBeInTheDocument();
});
it("loads earlier history in fixed increments without rendering the whole transcript", () => {
const longMessages = makeLongMessages(300);
render(
<ThreadViewport
messages={longMessages}
isStreaming={false}
composer={<div />}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Load earlier messages" }));
const firstVisible =
300 - INITIAL_HISTORY_WINDOW - HISTORY_WINDOW_INCREMENT;
expect(
screen.queryByText(`message ${firstVisible - 1}`),
).not.toBeInTheDocument();
expect(screen.getByText(`message ${firstVisible}`)).toBeInTheDocument();
expect(screen.getByText("message 299")).toBeInTheDocument();
});
it("expands the window start to avoid cutting an agent activity cluster", () => {
const clustered = makeLongMessages(200);
clustered.splice(
38,
3,
{
id: "r0",
role: "assistant",
content: "",
reasoning: "first reasoning",
createdAt: 38,
},
{
id: "t0",
role: "tool",
kind: "trace",
content: "tool()",
traces: ["tool()"],
createdAt: 39,
},
{
id: "r1",
role: "assistant",
content: "",
reasoning: "second reasoning",
createdAt: 40,
},
);
const visible = windowMessages(clustered, INITIAL_HISTORY_WINDOW);
expect(visible[0].id).toBe("r0");
expect(visible).toHaveLength(INITIAL_HISTORY_WINDOW + 2);
});
it("resets to the bottom when opening a different conversation", async () => {
const scrollIntoView = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
@@ -0,0 +1,110 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useDeferredTitleRefresh } from "@/hooks/useDeferredTitleRefresh";
import type { ChatSummary } from "@/lib/types";
function session(overrides: Partial<ChatSummary> = {}): ChatSummary {
return {
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: null,
updatedAt: null,
title: "",
preview: "First user message",
...overrides,
};
}
describe("useDeferredTitleRefresh", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("retries refreshing untitled sessions after turn_end", () => {
const refresh = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useDeferredTitleRefresh(session(), refresh, [100, 300]),
);
act(() => {
result.current();
});
expect(refresh).toHaveBeenCalledTimes(1);
act(() => {
vi.advanceTimersByTime(100);
});
expect(refresh).toHaveBeenCalledTimes(2);
act(() => {
vi.advanceTimersByTime(200);
});
expect(refresh).toHaveBeenCalledTimes(3);
});
it("stops pending retries once a generated title arrives", () => {
const refresh = vi.fn().mockResolvedValue(undefined);
const { result, rerender } = renderHook(
({ activeSession }) =>
useDeferredTitleRefresh(activeSession, refresh, [100, 300]),
{ initialProps: { activeSession: session() } },
);
act(() => {
result.current();
});
rerender({ activeSession: session({ title: "Generated title" }) });
act(() => {
vi.advanceTimersByTime(300);
});
expect(refresh).toHaveBeenCalledTimes(1);
});
it("does not retry when the active session already has a title", () => {
const refresh = vi.fn().mockResolvedValue(undefined);
const { result } = renderHook(() =>
useDeferredTitleRefresh(session({ title: "Existing title" }), refresh, [100]),
);
act(() => {
result.current();
vi.advanceTimersByTime(100);
});
expect(refresh).toHaveBeenCalledTimes(1);
});
it("clears pending retries when the active chat changes", () => {
const refresh = vi.fn().mockResolvedValue(undefined);
const { result, rerender } = renderHook(
({ activeSession }) =>
useDeferredTitleRefresh(activeSession, refresh, [100]),
{ initialProps: { activeSession: session() } },
);
act(() => {
result.current();
});
rerender({
activeSession: session({
key: "websocket:chat-b",
chatId: "chat-b",
}),
});
act(() => {
vi.advanceTimersByTime(100);
});
expect(refresh).toHaveBeenCalledTimes(1);
});
});
+117 -4
View File
@@ -83,7 +83,112 @@ function wrap(client: ReturnType<typeof fakeClient>["client"]) {
};
}
async function flushStreamFrame() {
await act(async () => {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
});
}
describe("useNanobotStream", () => {
it("batches answer deltas into one animation-frame update", async () => {
const fake = fakeClient();
const requestFrame = vi.spyOn(window, "requestAnimationFrame");
const { result } = renderHook(() => useNanobotStream("chat-batch", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-batch", {
event: "delta",
chat_id: "chat-batch",
text: "Hello",
});
fake.emit("chat-batch", {
event: "delta",
chat_id: "chat-batch",
text: " world",
});
});
expect(requestFrame).toHaveBeenCalledTimes(1);
expect(result.current.messages).toHaveLength(0);
await flushStreamFrame();
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "Hello world",
isStreaming: true,
});
requestFrame.mockRestore();
});
it("flushes pending delta text before turn_end finalizes the turn", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-flush", {
event: "delta",
chat_id: "chat-flush",
text: "final chunk",
});
fake.emit("chat-flush", {
event: "turn_end",
chat_id: "chat-flush",
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "final chunk",
isStreaming: false,
});
expect(result.current.isStreaming).toBe(false);
});
it("drops pending stream work when switching chats", async () => {
const fake = fakeClient();
const { result, rerender } = renderHook(
({ chatId }: { chatId: string }) => useNanobotStream(chatId, EMPTY_MESSAGES),
{
wrapper: wrap(fake.client),
initialProps: { chatId: "chat-old" },
},
);
act(() => {
fake.emit("chat-old", {
event: "delta",
chat_id: "chat-old",
text: "stale",
});
});
rerender({ chatId: "chat-new" });
act(() => {
fake.emit("chat-new", {
event: "delta",
chat_id: "chat-new",
text: "fresh",
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "fresh",
});
});
it("starts in streaming mode when history shows pending tool calls", () => {
const fake = fakeClient();
const initialMessages = [{
@@ -203,7 +308,7 @@ describe("useNanobotStream", () => {
);
});
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@@ -222,6 +327,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].role).toBe("assistant");
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
@@ -328,7 +435,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].reasoningStreaming).toBe(false);
});
it("does not attach a new turn's reasoning across the latest user boundary", () => {
it("does not attach a new turn's reasoning across the latest user boundary", async () => {
const fake = fakeClient();
const initialMessages = [
{
@@ -358,6 +465,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[0].reasoning).toBe("Previous thought.");
expect(result.current.messages[2].role).toBe("assistant");
@@ -366,7 +475,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[2].reasoningStreaming).toBe(true);
});
it("does not attach reasoning across a tool trace boundary", () => {
it("does not attach reasoning across a tool trace boundary", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r7", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
@@ -392,6 +501,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages.map((m) => m.kind ?? "message")).toEqual([
"message",
@@ -651,7 +762,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].content).toBe("long task");
});
it("keeps streaming alive across stream_end and completes on turn_end", () => {
it("keeps streaming alive across stream_end and completes on turn_end", async () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
@@ -666,6 +777,8 @@ describe("useNanobotStream", () => {
});
});
await flushStreamFrame();
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages[0]).toMatchObject({
role: "assistant",