-
+
{composer}
@@ -183,17 +281,18 @@ export function ThreadViewport({
className="pointer-events-none absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent"
/>
- {!atBottom && (
+ {showScrollToBottomButton && !atBottom && (
}
+ />,
+ );
+ 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(
+
composer}
+ emptyState={
welcome
}
+ 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(
+
}
+ />,
+ );
+
+ 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(
+
}
+ />,
+ );
+
+ 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;
diff --git a/webui/src/tests/useDeferredTitleRefresh.test.tsx b/webui/src/tests/useDeferredTitleRefresh.test.tsx
new file mode 100644
index 000000000..a823e5040
--- /dev/null
+++ b/webui/src/tests/useDeferredTitleRefresh.test.tsx
@@ -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 {
+ 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);
+ });
+});
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index 57ecccd90..0f736a016 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -83,7 +83,112 @@ function wrap(client: ReturnType["client"]) {
};
}
+async function flushStreamFrame() {
+ await act(async () => {
+ await new Promise((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",
diff --git a/webui/src/types/react-syntax-highlighter-subpaths.d.ts b/webui/src/types/react-syntax-highlighter-subpaths.d.ts
new file mode 100644
index 000000000..57639f724
--- /dev/null
+++ b/webui/src/types/react-syntax-highlighter-subpaths.d.ts
@@ -0,0 +1,22 @@
+declare module "react-syntax-highlighter/dist/esm/prism-async-light" {
+ import * as React from "react";
+ import type { SyntaxHighlighterProps } from "react-syntax-highlighter";
+
+ export default class SyntaxHighlighter extends React.Component {
+ static registerLanguage(name: string, func: unknown): void;
+ }
+}
+
+declare module "react-syntax-highlighter/dist/esm/styles/prism/one-dark" {
+ import type * as React from "react";
+
+ const style: { [key: string]: React.CSSProperties };
+ export default style;
+}
+
+declare module "react-syntax-highlighter/dist/esm/styles/prism/one-light" {
+ import type * as React from "react";
+
+ const style: { [key: string]: React.CSSProperties };
+ export default style;
+}
diff --git a/webui/vite.config.ts b/webui/vite.config.ts
index 7a2c9edba..fb5dfe37b 100644
--- a/webui/vite.config.ts
+++ b/webui/vite.config.ts
@@ -25,6 +25,36 @@ export default defineConfig(({ mode }) => {
outDir: path.resolve(__dirname, "../nanobot/web/dist"),
emptyOutDir: true,
sourcemap: false,
+ rollupOptions: {
+ output: {
+ manualChunks(id) {
+ if (id.includes("node_modules/refractor/lang/")) {
+ return;
+ }
+ if (
+ id.includes("node_modules/react-syntax-highlighter")
+ || id.includes("node_modules/refractor/core")
+ ) {
+ return "syntax-highlight";
+ }
+ if (
+ id.includes("node_modules/react-markdown")
+ || id.includes("node_modules/remark-")
+ || id.includes("node_modules/rehype-")
+ || id.includes("node_modules/unified")
+ || id.includes("node_modules/mdast-")
+ || id.includes("node_modules/hast-")
+ || id.includes("node_modules/micromark")
+ || id.includes("node_modules/unist-")
+ ) {
+ return "markdown-vendor";
+ }
+ if (id.includes("node_modules/katex")) {
+ return "katex";
+ }
+ },
+ },
+ },
},
server: {
host: "127.0.0.1",