fix(webui): complete temporary chat mode

This commit is contained in:
chengyongru
2026-08-08 23:20:59 +08:00
committed by Xubin Ren
parent c9a6145878
commit a5bc3bfbb9
51 changed files with 1285 additions and 945 deletions
+210 -34
View File
@@ -3,7 +3,12 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import type { ChatSummary, SessionAutomationJob } from "@/lib/types";
import type {
ChatSummary,
ConnectionStatus,
SessionAutomationJob,
WorkspaceScopePayload,
} from "@/lib/types";
const connectSpy = vi.fn();
const refreshSpy = vi.fn();
@@ -15,8 +20,14 @@ const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const setSidebarStateSpy = vi.fn();
const discardTemporaryChatSpy = vi.fn();
const sendMessageSpy = vi.fn();
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
const sessionUpdateHandlers = new Set<(
chatId: string,
scope?: string,
workspaceScope?: WorkspaceScopePayload,
) => void>();
let mockSessions: ChatSummary[] = [];
const HERO_GREETING_PATTERN =
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
@@ -198,16 +209,24 @@ vi.mock("@/lib/bootstrap", () => ({
clearSavedSecret: vi.fn(),
}));
vi.mock("@/lib/nanobot-client", () => {
vi.mock("@/lib/nanobot-client", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/nanobot-client")>();
class MockClient {
status = "idle" as const;
defaultChatId: string | null = null;
connect = connectSpy;
onStatus = () => () => {};
onStatus = (handler: (status: ConnectionStatus) => void) => {
statusHandlers.add(handler);
return () => statusHandlers.delete(handler);
};
onRuntimeModelUpdate = () => () => {};
onError = () => () => {};
onChat = () => () => {};
onSessionUpdate = (handler: (chatId: string, scope?: string) => void) => {
onSessionUpdate = (handler: (
chatId: string,
scope?: string,
workspaceScope?: WorkspaceScopePayload,
) => void) => {
sessionUpdateHandlers.add(handler);
return () => sessionUpdateHandlers.delete(handler);
};
@@ -217,7 +236,7 @@ vi.mock("@/lib/nanobot-client", () => {
};
getRunStartedAt = () => null;
getGoalState = () => undefined;
sendMessage = vi.fn();
sendMessage = sendMessageSpy;
newChat = vi.fn();
attach = attachSpy;
setSidebarState = setSidebarStateSpy;
@@ -227,7 +246,7 @@ vi.mock("@/lib/nanobot-client", () => {
updateMaxFrameBytes = vi.fn();
}
return { NanobotClient: MockClient };
return { ...actual, NanobotClient: MockClient };
});
import {
@@ -251,6 +270,8 @@ describe("App layout", () => {
attachSpy.mockReset();
setSidebarStateSpy.mockReset();
discardTemporaryChatSpy.mockReset();
sendMessageSpy.mockReset();
statusHandlers.clear();
runStatusHandlers.clear();
sessionUpdateHandlers.clear();
window.history.replaceState(null, "", "/");
@@ -387,54 +408,190 @@ describe("App layout", () => {
);
});
it("keeps a temporary chat while navigating and discards it on unmount", async () => {
it("creates a new temporary chat from the hero each time", async () => {
const { unmount } = render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const temporaryButton = within(sidebar).getByRole("button", { name: "Temporary chat" });
expect(within(sidebar).queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
const firstToggle = screen.getByRole("button", { name: "Temporary chat" });
expect(firstToggle).toHaveAttribute("aria-pressed", "false");
fireEvent.click(firstToggle);
expect(firstToggle).toHaveAttribute("aria-pressed", "true");
expect(window.location.hash).toBe("");
fireEvent.click(temporaryButton);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "first private message" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(temporaryButton).toHaveAttribute("aria-current", "page");
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"temporary-chat",
);
expect(window.location.hash).toBe("#/temporary");
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
const firstHash = window.location.hash;
expect(firstHash).toMatch(/^#\/temporary\/temporary-/);
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
expect(createChatSpy).not.toHaveBeenCalled();
fireEvent.click(within(sidebar).getByRole("button", { name: "New topic" }));
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
const secondToggle = screen.getByRole("button", { name: "Temporary chat" });
expect(secondToggle).toHaveAttribute("aria-pressed", "false");
fireEvent.click(temporaryButton);
expect(window.location.hash).toBe("#/temporary");
expect(temporaryButton).toHaveAttribute("aria-current", "page");
fireEvent.click(secondToggle);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "second private message" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
const secondHash = window.location.hash;
expect(secondHash).toMatch(/^#\/temporary\/temporary-/);
expect(secondHash).not.toBe(firstHash);
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
expect(within(sidebar).getByText("Temporary chats")).toBeInTheDocument();
expect(within(sidebar).getByRole("button", {
name: "first private message",
})).toBeInTheDocument();
expect(within(sidebar).getByRole("button", {
name: "second private message",
})).toBeInTheDocument();
fireEvent.click(within(sidebar).getByRole("button", {
name: "first private message",
}));
await waitFor(() => expect(window.location.hash).toBe(firstHash));
expect(screen.getByText("Temporary chat")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
fireEvent.pointerDown(within(sidebar).getByRole("button", {
name: "Topic actions for first private message",
}), { button: 0 });
fireEvent.click(await screen.findByRole("menuitem", { name: "Close temporary chat" }));
await waitFor(() => expect(window.location.hash).toBe(secondHash));
expect(within(sidebar).queryByRole("button", {
name: "first private message",
})).not.toBeInTheDocument();
expect(within(sidebar).getByRole("button", {
name: "second private message",
})).toBeInTheDocument();
expect(discardTemporaryChatSpy).toHaveBeenCalledTimes(1);
unmount();
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledOnce());
expect(discardTemporaryChatSpy.mock.calls[0][0]).toMatch(/^temporary-/);
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledTimes(2));
const discardedChatIds = discardTemporaryChatSpy.mock.calls.map(([chatId]) => chatId);
expect(new Set(discardedChatIds).size).toBe(2);
expect(discardedChatIds).toEqual([
expect.stringMatching(/^temporary-/),
expect.stringMatching(/^temporary-/),
]);
});
it("clears a temporary chat explicitly without leaving it", async () => {
it("shows the temporary-chat control only on the new-topic hero", async () => {
mockSessions = [{
key: "websocket:existing-chat",
channel: "websocket",
chatId: "existing-chat",
createdAt: "2026-08-06T10:00:00Z",
updatedAt: "2026-08-06T10:00:00Z",
preview: "Existing topic",
}];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Temporary chat" }));
const heroHeader = screen.getByTestId("thread-header");
const heroTemporaryToggle = within(heroHeader).getByRole("button", {
name: "Temporary chat",
});
const themeToggle = within(heroHeader).getByRole("button", {
name: "Toggle theme from header",
});
expect(within(sidebar).queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
expect(within(screen.getByTestId("thread-composer-motion")).queryByRole("button", {
name: "Temporary chat",
})).not.toBeInTheDocument();
expect(heroTemporaryToggle.compareDocumentPosition(themeToggle)
& Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
fireEvent.click(await screen.findByRole("button", { name: "Clear temporary chat" }));
fireEvent.click(within(sidebar).getByText("Existing topic"));
expect(window.location.hash).toBe("#/chat/websocket%3Aexisting-chat");
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledOnce());
expect(window.location.hash).toBe("#/temporary");
expect(within(sidebar).getByRole("button", { name: "Temporary chat" })).toHaveAttribute(
"aria-current",
"page",
fireEvent.click(within(sidebar).getByRole("button", { name: "New topic" }));
const temporaryToggle = screen.getByRole("button", { name: "Temporary chat" });
expect(temporaryToggle).toHaveClass("h-8", "w-8", "rounded-full");
expect(within(temporaryToggle).queryByText("Temporary chat")).not.toBeInTheDocument();
fireEvent.click(temporaryToggle);
expect(temporaryToggle).toHaveAttribute("aria-pressed", "true");
expect(temporaryToggle).toHaveClass("bg-transparent", "shadow-none", "hover:bg-transparent");
expect(within(temporaryToggle).getByTestId("temporary-chat-icon")).toHaveClass(
"motion-safe:duration-150",
"text-[var(--temporary-control-active)]",
);
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
expect(screen.queryByTestId("temporary-chat-outline")).not.toBeInTheDocument();
fireEvent.click(temporaryToggle);
expect(temporaryToggle).toHaveAttribute("aria-pressed", "false");
expect(within(temporaryToggle).getByTestId("temporary-chat-icon")).toHaveClass(
"motion-safe:duration-75",
"text-current",
);
fireEvent.click(temporaryToggle);
expect(window.location.hash).toBe("#/new");
expect(temporaryToggle).toHaveAttribute("aria-pressed", "true");
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "start temporary chat" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
expect(screen.queryByText("Not saved")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Clear temporary chat" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
});
it("starts temporary chat with restricted on-demand workspace controls", async () => {
it("allows leaving a page with temporary chats without blocking", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "do not lose this" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
const beforeUnload = new Event("beforeunload", { cancelable: true });
act(() => window.dispatchEvent(beforeUnload));
expect(beforeUnload.defaultPrevented).toBe(false);
});
it("ends temporary chats quietly after a connection interruption", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
act(() => {
statusHandlers.forEach((handler) => handler("open"));
});
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "connection-sensitive message" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
act(() => {
statusHandlers.forEach((handler) => handler("reconnecting"));
});
await waitFor(() => expect(window.location.hash).toBe("#/new"));
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.queryByText("connection-sensitive message")).not.toBeInTheDocument();
});
it("uses the restricted default scope without offering project selection", async () => {
mockFetchRoutes({
"/api/settings": baseSettingsPayload(),
"/api/workspaces": {
schema_version: 1,
default_access_mode: "full",
@@ -450,11 +607,30 @@ describe("App layout", () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Temporary chat" }));
expect(await screen.findByRole("button", { name: "Choose project" })).toBeInTheDocument();
act(() => {
sessionUpdateHandlers.forEach((handler) => handler("selected-chat", "metadata", {
project_path: "/tmp/selected-project",
project_name: "selected-project",
access_mode: "full",
restrict_to_workspace: false,
}));
});
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
expect(screen.queryByRole("button", { name: "Choose project" })).not.toBeInTheDocument();
expect(screen.queryByText("Full Access")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "temporary project check" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled());
const options = sendMessageSpy.mock.calls.at(-1)?.[3];
expect(options?.workspaceScope).toMatchObject({
project_path: "/tmp/workspace",
access_mode: "restricted",
restrict_to_workspace: true,
});
});
it("restores the Settings route after a restart fallback hash", async () => {
+35
View File
@@ -152,6 +152,41 @@ describe("ChatList", () => {
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
});
it("shows temporary chats separately and lets the user reopen or close them", async () => {
const temporarySession = session({
key: "temporary:temporary-one",
chatId: "temporary-one",
preview: "Private planning",
});
const onSelect = vi.fn();
const onClose = vi.fn();
render(
<ChatList
sessions={[]}
temporarySessions={[temporarySession]}
activeKey={null}
onSelect={onSelect}
onCloseTemporaryChat={onClose}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const section = screen.getByRole("region", { name: "Temporary chats" });
fireEvent.click(within(section).getByRole("button", { name: "Private planning" }));
expect(onSelect).toHaveBeenCalledWith("temporary:temporary-one");
fireEvent.pointerDown(
within(section).getByRole("button", { name: "Topic actions for Private planning" }),
{ button: 0 },
);
fireEvent.click(await screen.findByRole("menuitem", { name: "Close temporary chat" }));
expect(onClose).toHaveBeenCalledWith("temporary:temporary-one");
});
it("orders chats by latest session activity by default", () => {
const sessions = [
session({
+19
View File
@@ -113,6 +113,25 @@ describe("MessageBubble", () => {
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
});
it("outlines temporary-chat user messages with a short dashed border", () => {
const message: UIMessage = {
id: "u-temporary",
role: "user",
content: "private question",
createdAt: Date.now(),
};
const { rerender } = render(<MessageBubble message={message} temporary />);
const bubble = screen.getByText("private question");
expect(bubble).toHaveAttribute("data-temporary-message", "true");
expect(bubble).toHaveClass("border-dashed", "border-muted-foreground/40", "bg-transparent");
rerender(<MessageBubble message={message} />);
expect(bubble).not.toHaveClass("border-dashed");
expect(bubble).toHaveClass("bg-secondary/70");
});
it("does not replay an entrance animation when persisted messages mount", () => {
const messages: UIMessage[] = [
{
+42 -2
View File
@@ -101,22 +101,37 @@ describe("NanobotClient", () => {
});
});
it("forgets temporary chats when the socket drops", async () => {
it("forgets every temporary chat when the socket drops", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: true,
maxBackoffMs: 1,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const firstHandler = vi.fn();
const secondHandler = vi.fn();
client.connect();
lastSocket().fakeOpen();
client.onChat("temporary-drop", vi.fn());
client.onChat("temporary-drop-a", firstHandler);
client.onChat("temporary-drop-b", secondHandler);
lastSocket().close();
await vi.advanceTimersByTimeAsync(1);
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "message",
chat_id: "temporary-drop-a",
text: "stale first chat",
});
lastSocket().fakeMessage({
event: "message",
chat_id: "temporary-drop-b",
text: "stale second chat",
});
expect(lastSocket().sent).toEqual([]);
expect(firstHandler).not.toHaveBeenCalled();
expect(secondHandler).not.toHaveBeenCalled();
});
it("routes events to the matching chat handler", () => {
@@ -262,6 +277,31 @@ describe("NanobotClient", () => {
expect(client.getRunStartedAt("chat-strip")).toBeNull();
});
it("clears the local run strip immediately when a stop is requested", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onRunStatus(handler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-stop",
status: "running",
started_at: 12_345,
turn_id: "turn-stop",
});
client.finishRunLocally("chat-stop");
expect(client.getRunStartedAt("chat-stop")).toBeNull();
expect(client.hasUnsettledRun("chat-stop")).toBe(false);
expect(handler).toHaveBeenLastCalledWith("chat-stop", null);
});
it("clears stale run strip when reconnecting after a dropped socket", async () => {
const client = new NanobotClient({
url: "ws://test",
+30 -33
View File
@@ -1070,56 +1070,53 @@ describe("ThreadComposer", () => {
}));
});
it("keeps temporary-chat workspace controls on demand", async () => {
const user = userEvent.setup();
const onWorkspaceScopeChange = vi.fn();
it("slides project controls closed without offering a compact replacement", () => {
const defaultScope = {
project_path: "/Users/test/.nanobot/workspace",
project_name: "workspace",
access_mode: "restricted" as const,
restrict_to_workspace: true,
access_mode: "full" as const,
restrict_to_workspace: false,
};
const { rerender } = render(
const composer = (workspaceControlsHidden: boolean) => (
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
compactWorkspaceControls
workspaceControlsHidden={workspaceControlsHidden}
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
onWorkspaceScopeChange={vi.fn()}
/>
);
const { container, rerender } = render(composer(false));
const drawer = container.querySelector("[data-composer-workspace-drawer]");
expect(drawer).toHaveAttribute("data-state", "open");
expect(drawer).not.toHaveAttribute("aria-hidden");
expect(container.querySelector("[data-composer-workspace-compact]")).not.toBeInTheDocument();
rerender(composer(true));
expect(container.querySelector("[data-composer-workspace-drawer]")).toBe(drawer);
expect(drawer).toHaveAttribute("data-state", "closed");
expect(drawer).toHaveAttribute("aria-hidden", "true");
expect(within(drawer as HTMLElement).getByRole("button", {
hidden: true,
name: "Choose project",
})).toBeDisabled();
expect(screen.queryByRole("button", { name: "Choose project" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", {
name: "Workspace access mode: Default Permission",
name: "Workspace access mode: Full Access",
})).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Choose project" }));
const input = await screen.findByLabelText("Paste path");
fireEvent.change(input, { target: { value: "relative/project" } });
fireEvent.click(screen.getByRole("button", { name: "Use Path" }));
expect(screen.getByRole("alert")).toHaveTextContent(
"Enter an absolute folder path on this machine.",
);
rerender(
<ThreadComposer
onSend={vi.fn()}
placeholder="Ask anything..."
variant="hero"
compactWorkspaceControls
workspaceConnected
workspaceScope={defaultScope}
workspaceDefaultScope={defaultScope}
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
onWorkspaceScopeChange={onWorkspaceScopeChange}
/>,
);
rerender(composer(false));
expect(screen.getByRole("button", {
name: "Workspace access mode: Default Permission",
})).toBeInTheDocument();
expect(container.querySelector("[data-composer-workspace-drawer]")).toBe(drawer);
expect(drawer).toHaveAttribute("data-state", "open");
expect(within(drawer as HTMLElement).getByRole("button", {
name: "Choose project",
})).toBeEnabled();
});
it("uses the native folder picker for project selection on native host", async () => {
+17 -6
View File
@@ -107,6 +107,10 @@ function makeClient() {
};
},
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
finishRunLocally: vi.fn((chatId: string) => {
runStartedAtByChatId.delete(chatId);
latestRunTurnIdByChatId.delete(chatId);
}),
hasUnsettledRun: () => false,
getRunGeneration: (chatId: string) => runGenerationByChatId.get(chatId) ?? 0,
canReconcileCanonicalCompletion,
@@ -850,16 +854,22 @@ describe("ThreadShell", () => {
it("keeps temporary messages across navigation and drops them after clear", async () => {
const client = makeClient();
const view = (chatId: string, temporary: boolean) => wrap(
const view = (
chatId: string,
temporary: boolean,
temporaryChatIds: readonly string[],
) => wrap(
client,
<ThreadShell
session={session(chatId)}
title={temporary ? "Temporary chat" : "Regular chat"}
temporary={temporary}
temporaryChatIds={temporaryChatIds}
onToggleSidebar={() => {}}
/>,
);
const { rerender } = render(view("temporary-live", true));
const retainedTemporaryChats = ["temporary-live"];
const { rerender } = render(view("temporary-live", true, retainedTemporaryChats));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "keep this only in memory" },
@@ -871,14 +881,14 @@ describe("ThreadShell", () => {
"keep this only in memory",
));
rerender(view("regular", false));
rerender(view("regular", false, retainedTemporaryChats));
await waitFor(() => {
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
});
rerender(view("temporary-live", true));
rerender(view("temporary-live", true, retainedTemporaryChats));
expect(screen.getByText("keep this only in memory")).toBeInTheDocument();
rerender(view("temporary-cleared", true));
rerender(view("temporary-cleared", true, ["temporary-cleared"]));
await waitFor(() => {
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
});
@@ -980,6 +990,7 @@ describe("ThreadShell", () => {
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
expect(onCreateChat).toHaveBeenCalledWith(null, "start for real");
expect(onNewChat).not.toHaveBeenCalled();
});
@@ -1260,7 +1271,7 @@ describe("ThreadShell", () => {
const greeting = screen.getByRole("heading", { level: 1, name: HERO_GREETING_PATTERN });
expect(greeting).toHaveAttribute("data-testid", "hero-greeting");
expect(greeting).toHaveClass("whitespace-nowrap");
expect(greeting).toHaveClass("select-none", "whitespace-nowrap");
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Write code" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Create a project plan" })).not.toBeInTheDocument();
@@ -76,6 +76,7 @@ function fakeClient() {
return () => set!.delete(h);
},
sendMessage: vi.fn(),
finishRunLocally: vi.fn(),
newChat: vi.fn(),
forkChat: vi.fn(),
attach: vi.fn(),
@@ -2247,6 +2248,7 @@ describe("useNanobotStream", () => {
});
expect(fake.client.sendMessage).toHaveBeenLastCalledWith("chat-stop", "/stop");
expect(fake.client.finishRunLocally).toHaveBeenCalledWith("chat-stop");
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe("long task");