feat(webui): polish chat layout and titles

Align the WebUI sidebar and chat chrome with the updated design, and generate WebUI session titles asynchronously without blocking turns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren
2026-05-06 22:20:35 +08:00
committed by Xubin Ren
co-authored by Cursor
parent d8fd4c80bf
commit 790a03ec28
33 changed files with 1270 additions and 312 deletions
+25 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { deleteSession, fetchSessionMessages, updateSettings } from "@/lib/api";
import { deleteSession, fetchSessionMessages, listSessions, updateSettings } from "@/lib/api";
describe("webui API helpers", () => {
beforeEach(() => {
@@ -48,4 +48,28 @@ describe("webui API helpers", () => {
}),
);
});
it("maps generated session titles from the sessions list", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
sessions: [
{
key: "websocket:chat-1",
created_at: "2026-05-01T10:00:00",
updated_at: "2026-05-01T10:01:00",
title: "优化 WebUI 标题",
},
],
}),
} as Response);
await expect(listSessions("tok")).resolves.toMatchObject([
{
key: "websocket:chat-1",
title: "优化 WebUI 标题",
preview: "",
},
]);
});
});
+106 -6
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types";
@@ -7,6 +7,7 @@ const connectSpy = vi.fn();
const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
const toggleThemeSpy = vi.fn();
let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => {
@@ -34,7 +35,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({
theme: "light" as const,
toggle: vi.fn(),
toggle: toggleThemeSpy,
}),
}));
@@ -74,6 +75,7 @@ describe("App layout", () => {
refreshSpy.mockReset();
createChatSpy.mockClear();
deleteChatSpy.mockReset();
toggleThemeSpy.mockReset();
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
@@ -121,8 +123,11 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() =>
expect(screen.getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(),
expect(
within(sidebar).getByRole("button", { name: /^First chat$/ }),
).toBeInTheDocument(),
);
fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), {
@@ -140,14 +145,24 @@ describe("App layout", () => {
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: /^Second chat$/ }),
within(sidebar).getByRole("button", { name: /^Second chat$/ }),
).toBeInTheDocument(),
);
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
it("opens the Cursor-style settings view from the sidebar", async () => {
it("opens the Cursor-style settings view from the header", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Existing chat",
},
];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
@@ -180,10 +195,95 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Settings" }));
fireEvent.click(screen.getByRole("button", { name: "Open settings" }));
expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
expect(screen.getByText("AI")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument();
});
it("filters sidebar sessions through the lightweight search row", async () => {
mockSessions = [
{
key: "websocket:chat-alpha",
channel: "websocket",
chatId: "chat-alpha",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
preview: "Project planning notes",
},
{
key: "websocket:chat-beta",
channel: "websocket",
chatId: "chat-beta",
createdAt: "2026-04-15T10:00:00Z",
updatedAt: "2026-04-15T10:00:00Z",
preview: "Travel ideas",
},
];
render(<App />);
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("Travel ideas")).toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
target: { value: "travel" },
});
expect(within(sidebar).queryByText("Project planning notes")).not.toBeInTheDocument();
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
});
it("opens a blank start page without creating an empty chat", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Existing chat",
},
];
const matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("1024px"),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal("matchMedia", matchMedia);
const { container } = render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Toggle theme from header" }));
expect(toggleThemeSpy).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement;
await waitFor(() => expect(desktopAside.style.width).toBe("0px"));
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" }));
await waitFor(() => expect(desktopAside.style.width).toBe("272px"));
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" }));
expect(createChatSpy).not.toHaveBeenCalled();
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument();
expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument();
});
});
+40 -2
View File
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
import type { UIMessage } from "@/lib/types";
@@ -19,6 +19,44 @@ describe("MessageBubble", () => {
expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]");
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("copies completed assistant replies from the action row", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
const message: UIMessage = {
id: "a-copy",
role: "assistant",
content: "I can help with the next step.",
createdAt: Date.now(),
};
render(<MessageBubble message={message} />);
fireEvent.click(screen.getByRole("button", { name: "Copy reply" }));
expect(writeText).toHaveBeenCalledWith("I can help with the next step.");
await waitFor(() =>
expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(),
);
});
it("does not show copy actions for streaming placeholders", () => {
const message: UIMessage = {
id: "a-streaming",
role: "assistant",
content: "",
isStreaming: true,
createdAt: Date.now(),
};
render(<MessageBubble message={message} />);
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("renders trace messages as collapsible tool groups", () => {
+3 -1
View File
@@ -116,7 +116,7 @@ describe("NanobotClient", () => {
// Attach is sent first because sendMessage adds to knownChats, which
// handleOpen re-attaches; then the queued message follows.
expect(lastSocket().sent).toContain(
JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello" }),
JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello", webui: true }),
);
});
@@ -196,6 +196,7 @@ describe("NanobotClient", () => {
chat_id: "chat-x",
content: "look",
media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }],
webui: true,
});
});
@@ -214,6 +215,7 @@ describe("NanobotClient", () => {
type: "message",
chat_id: "chat-x",
content: "hello",
webui: true,
});
});
+27 -4
View File
@@ -9,15 +9,38 @@ describe("ThreadComposer", () => {
<ThreadComposer
onSend={vi.fn()}
modelLabel="claude-opus-4-5"
placeholder="What's on your mind?"
placeholder="Ask anything..."
variant="hero"
/>,
);
expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument();
const input = screen.getByPlaceholderText("What's on your mind?");
expect(screen.queryByRole("button", { name: "Search" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Reason" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Deep research" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Voice input" })).not.toBeInTheDocument();
const input = screen.getByPlaceholderText("Ask anything...");
expect(input).toBeInTheDocument();
expect(input.className).toContain("min-h-[96px]");
expect(input.parentElement?.className).toContain("max-w-[40rem]");
expect(input.className).toContain("min-h-[78px]");
expect(input.parentElement?.className).toContain("max-w-[58rem]");
});
it("keeps the thread composer compact while matching the hero style", () => {
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="gpt-4o"
placeholder="Type your message..."
/>,
);
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
const input = screen.getByPlaceholderText("Type your message...");
expect(input.className).toContain("min-h-[50px]");
expect(input.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.className).toContain("rounded-[22px]");
expect(input.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
});
});
+84 -4
View File
@@ -86,6 +86,26 @@ describe("ThreadShell", () => {
);
});
it("does not navigate away when clicking the chat title", async () => {
const client = makeClient();
const onGoHome = vi.fn();
render(wrap(
client,
<ThreadShell
session={session("chat-title")}
title="Important conversation"
onToggleSidebar={() => {}}
onGoHome={onGoHome}
onNewChat={() => {}}
/>,
));
await waitFor(() => expect(screen.getByText("Important conversation")).toBeInTheDocument());
fireEvent.click(screen.getByText("Important conversation"));
expect(onGoHome).not.toHaveBeenCalled();
});
it("restores in-memory messages when switching away and back to a session", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
@@ -199,7 +219,67 @@ describe("ThreadShell", () => {
await waitFor(() => {
expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument();
});
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
});
it("creates a chat only when the blank landing sends a first message", async () => {
const client = makeClient();
const onNewChat = vi.fn();
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "start for real" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
expect(onNewChat).not.toHaveBeenCalled();
});
it("sends quick action prompts from the empty thread landing", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");
render(
wrap(
client,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
onGoHome={() => {}}
onNewChat={onNewChat}
/>,
),
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Write code" })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Write code" }));
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith(
"chat-a",
"Help me write the code for this task, starting with the smallest useful change.",
undefined,
),
);
});
it("does not leak the previous thread when opening a brand-new chat", async () => {
@@ -260,10 +340,10 @@ describe("ThreadShell", () => {
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
await waitFor(() =>
expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(),
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(),
);
const input = screen.getByPlaceholderText("What's on your mind?");
expect(input.className).toContain("min-h-[96px]");
const input = screen.getByPlaceholderText("Ask anything...");
expect(input.className).toContain("min-h-[78px]");
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
});
+20 -1
View File
@@ -159,7 +159,8 @@ describe("useNanobotStream", () => {
it("keeps streaming alive across stream_end and completes on turn_end", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES), {
const onTurnEnd = vi.fn();
const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), {
wrapper: wrap(fake.client),
});
@@ -211,5 +212,23 @@ describe("useNanobotStream", () => {
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true);
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
it("refreshes session metadata when the server reports a session update", () => {
const fake = fakeClient();
const onTurnEnd = vi.fn();
renderHook(() => useNanobotStream("chat-title", EMPTY_MESSAGES, false, onTurnEnd), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-title", {
event: "session_updated",
chat_id: "chat-title",
});
});
expect(onTurnEnd).toHaveBeenCalledTimes(1);
});
});