mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 08:32:25 +00:00
854 lines
22 KiB
TypeScript
854 lines
22 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import type { ReactNode } from "react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { ThreadShell } from "@/components/thread/ThreadShell";
|
|
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>>();
|
|
return {
|
|
status: "open" as const,
|
|
defaultChatId: null as string | null,
|
|
onStatus: () => () => {},
|
|
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
|
|
let handlers = chatHandlers.get(chatId);
|
|
if (!handlers) {
|
|
handlers = new Set();
|
|
chatHandlers.set(chatId, handlers);
|
|
}
|
|
handlers.add(handler);
|
|
return () => {
|
|
handlers?.delete(handler);
|
|
};
|
|
},
|
|
onError: (handler: (err: { kind: string }) => void) => {
|
|
errorHandlers.add(handler);
|
|
return () => {
|
|
errorHandlers.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);
|
|
},
|
|
sendMessage: vi.fn(),
|
|
newChat: vi.fn(),
|
|
attach: vi.fn(),
|
|
connect: vi.fn(),
|
|
close: vi.fn(),
|
|
updateUrl: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function wrap(client: ReturnType<typeof makeClient>, children: ReactNode) {
|
|
return (
|
|
<ClientProvider
|
|
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
|
token="tok"
|
|
>
|
|
{children}
|
|
</ClientProvider>
|
|
);
|
|
}
|
|
|
|
function session(chatId: string) {
|
|
return {
|
|
key: `websocket:${chatId}`,
|
|
channel: "websocket" as const,
|
|
chatId,
|
|
createdAt: null,
|
|
updatedAt: null,
|
|
preview: "",
|
|
};
|
|
}
|
|
|
|
function httpJson(body: unknown) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => body,
|
|
};
|
|
}
|
|
|
|
describe("ThreadShell", () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 404,
|
|
json: async () => ({}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
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");
|
|
|
|
const { rerender } = render(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-a")}
|
|
title="Chat chat-a"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
|
target: { value: "persist me across tabs" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
|
|
|
await waitFor(() =>
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
"chat-a",
|
|
"persist me across tabs",
|
|
undefined,
|
|
),
|
|
);
|
|
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-b")}
|
|
title="Chat chat-b"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-a")}
|
|
title="Chat chat-a"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
|
});
|
|
|
|
it("clears the old thread when the active session is removed", async () => {
|
|
const client = makeClient();
|
|
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
|
|
|
const { rerender } = render(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-a")}
|
|
title="Chat chat-a"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
|
target: { value: "delete me cleanly" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
|
|
|
await waitFor(() =>
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
"chat-a",
|
|
"delete me cleanly",
|
|
undefined,
|
|
),
|
|
);
|
|
expect(screen.getByText("delete me cleanly")).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={null}
|
|
title="nanobot"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("delete me cleanly")).not.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("keeps the first landing message when new chat history is still empty", async () => {
|
|
const client = makeClient();
|
|
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => ({
|
|
ok: false,
|
|
status: 404,
|
|
json: async () => ({}),
|
|
})),
|
|
);
|
|
|
|
const { rerender } = render(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={null}
|
|
title="nanobot"
|
|
onToggleSidebar={() => {}}
|
|
onCreateChat={onCreateChat}
|
|
/>,
|
|
),
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
|
target: { value: "first message should stay" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
|
|
|
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-new")}
|
|
title="Chat chat-new"
|
|
onToggleSidebar={() => {}}
|
|
onCreateChat={onCreateChat}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
"chat-new",
|
|
"first message should stay",
|
|
undefined,
|
|
),
|
|
);
|
|
await waitFor(() =>
|
|
expect(screen.getByText("first message should stay")).toBeInTheDocument(),
|
|
);
|
|
expect(screen.queryByText("What can I do for you?")).not.toBeInTheDocument();
|
|
});
|
|
|
|
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 () => {
|
|
const client = makeClient();
|
|
const onNewChat = vi.fn().mockResolvedValue("chat-new");
|
|
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: "old question" },
|
|
{ role: "assistant", content: "old answer" },
|
|
],
|
|
});
|
|
}
|
|
return {
|
|
ok: false,
|
|
status: 404,
|
|
json: async () => ({}),
|
|
};
|
|
}),
|
|
);
|
|
|
|
const { rerender } = render(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-a")}
|
|
title="Chat chat-a"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-new")}
|
|
title="Chat chat-new"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
|
|
await waitFor(() =>
|
|
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(),
|
|
);
|
|
const input = screen.getByPlaceholderText("Ask anything...");
|
|
expect(input.className).toContain("min-h-[78px]");
|
|
expect(screen.queryByText("old answer")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("does not cache optimistic messages under the next chat during a session switch", async () => {
|
|
const client = makeClient();
|
|
const onNewChat = vi.fn().mockResolvedValue("chat-b");
|
|
|
|
const { rerender } = render(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-a")}
|
|
title="Chat chat-a"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
|
target: { value: "only in chat a" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
|
|
|
await waitFor(() =>
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
"chat-a",
|
|
"only in chat a",
|
|
undefined,
|
|
),
|
|
);
|
|
expect(screen.getByText("only in chat a")).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-b")}
|
|
title="Chat chat-b"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("only in chat a")).not.toBeInTheDocument();
|
|
});
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-a")}
|
|
title="Chat chat-a"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
expect(screen.getByText("only in chat a")).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-b")}
|
|
title="Chat chat-b"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("only in chat a")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("keeps live assistant replies after visiting the blank new-chat page", async () => {
|
|
const client = makeClient();
|
|
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,
|
|
// Simulate a stale history response that has not persisted the
|
|
// just-received assistant reply yet.
|
|
messages: [{ role: "user", content: "hello" }],
|
|
});
|
|
}
|
|
return {
|
|
ok: false,
|
|
status: 404,
|
|
json: async () => ({}),
|
|
};
|
|
}),
|
|
);
|
|
|
|
const { rerender } = render(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-a")}
|
|
title="Chat chat-a"
|
|
onToggleSidebar={() => {}}
|
|
onNewChat={() => {}}
|
|
/>,
|
|
),
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByText("hello")).toBeInTheDocument());
|
|
await act(async () => {
|
|
client._emitChat("chat-a", {
|
|
event: "message",
|
|
chat_id: "chat-a",
|
|
text: "live assistant reply",
|
|
});
|
|
});
|
|
expect(screen.getByText("live assistant reply")).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={null}
|
|
title="nanobot"
|
|
onToggleSidebar={() => {}}
|
|
onNewChat={() => {}}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
expect(screen.queryByText("live assistant reply")).not.toBeInTheDocument();
|
|
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-a")}
|
|
title="Chat chat-a"
|
|
onToggleSidebar={() => {}}
|
|
onNewChat={() => {}}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
|
|
});
|
|
|
|
it("does not open slash commands on the blank welcome page", async () => {
|
|
const client = makeClient();
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.endsWith("/api/commands")) {
|
|
return httpJson({
|
|
commands: [
|
|
{
|
|
command: "/stop",
|
|
title: "Stop current task",
|
|
description: "Cancel the active agent turn.",
|
|
icon: "square",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
return {
|
|
ok: false,
|
|
status: 404,
|
|
json: async () => ({}),
|
|
};
|
|
}),
|
|
);
|
|
|
|
render(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={null}
|
|
title="nanobot"
|
|
onToggleSidebar={() => {}}
|
|
onNewChat={() => {}}
|
|
/>,
|
|
),
|
|
);
|
|
|
|
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
|
"/api/commands",
|
|
expect.objectContaining({
|
|
headers: { Authorization: "Bearer tok" },
|
|
}),
|
|
));
|
|
|
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
|
target: { value: "/" },
|
|
});
|
|
|
|
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("switches welcome quick actions when image mode is enabled", async () => {
|
|
const client = makeClient();
|
|
render(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={null}
|
|
title="nanobot"
|
|
onToggleSidebar={() => {}}
|
|
onNewChat={() => {}}
|
|
/>,
|
|
),
|
|
);
|
|
await act(async () => {});
|
|
|
|
expect(screen.getByText("Write code")).toBeInTheDocument();
|
|
expect(screen.queryByText("Design an app icon")).not.toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Toggle image generation mode" }));
|
|
|
|
expect(screen.getByText("Design an app icon")).toBeInTheDocument();
|
|
expect(screen.queryByText("Write code")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("surfaces a dismissible banner when the stream reports message_too_big", 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}
|
|
/>,
|
|
),
|
|
);
|
|
|
|
// No banner yet: only appears once the client emits a matching error.
|
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
|
|
await act(async () => {});
|
|
await act(async () => {
|
|
client._emitError({ kind: "message_too_big" });
|
|
});
|
|
|
|
const banner = await screen.findByRole("alert");
|
|
expect(banner).toHaveTextContent("Message too large");
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("clears the stream error banner when the user switches to another chat", async () => {
|
|
const client = makeClient();
|
|
const onNewChat = vi.fn().mockResolvedValue("chat-a");
|
|
|
|
const { rerender } = render(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-a")}
|
|
title="Chat chat-a"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
|
|
await act(async () => {});
|
|
await act(async () => {
|
|
client._emitError({ kind: "message_too_big" });
|
|
});
|
|
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
|
|
|
// Switch to a different chat. The banner was about the *previous* send
|
|
// in chat-a; it must not leak into chat-b's view.
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-b")}
|
|
title="Chat chat-b"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("clears the previous thread immediately while the next session loads", async () => {
|
|
const client = makeClient();
|
|
const onNewChat = vi.fn().mockResolvedValue("chat-b");
|
|
let resolveChatB:
|
|
| ((value: { ok: boolean; status: number; json: () => Promise<unknown> }) => void)
|
|
| null = null;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn((input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.includes("websocket%3Achat-a/messages")) {
|
|
return Promise.resolve(
|
|
httpJson({
|
|
key: "websocket:chat-a",
|
|
created_at: null,
|
|
updated_at: null,
|
|
messages: [{ role: "assistant", content: "from chat a" }],
|
|
}),
|
|
);
|
|
}
|
|
if (url.includes("websocket%3Achat-b/messages")) {
|
|
return new Promise((resolve) => {
|
|
resolveChatB = resolve;
|
|
});
|
|
}
|
|
return Promise.resolve({
|
|
ok: false,
|
|
status: 404,
|
|
json: async () => ({}),
|
|
});
|
|
}),
|
|
);
|
|
|
|
const { rerender } = render(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-a")}
|
|
title="Chat chat-a"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByText("from chat a")).toBeInTheDocument());
|
|
|
|
await act(async () => {
|
|
rerender(
|
|
wrap(
|
|
client,
|
|
<ThreadShell
|
|
session={session("chat-b")}
|
|
title="Chat chat-b"
|
|
onToggleSidebar={() => {}}
|
|
onGoHome={() => {}}
|
|
onNewChat={onNewChat}
|
|
/>,
|
|
),
|
|
);
|
|
});
|
|
|
|
expect(screen.queryByText("from chat a")).not.toBeInTheDocument();
|
|
expect(screen.getByText("Loading conversation…")).toBeInTheDocument();
|
|
|
|
await act(async () => {
|
|
resolveChatB?.(
|
|
httpJson({
|
|
key: "websocket:chat-b",
|
|
created_at: null,
|
|
updated_at: null,
|
|
messages: [{ role: "assistant", content: "from chat b" }],
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(() => expect(screen.getByText("from chat b")).toBeInTheDocument());
|
|
expect(screen.queryByText("from chat a")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders ask_user options above the composer and sends selected answers", 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 act(async () => {
|
|
client._emitChat("chat-a", {
|
|
event: "message",
|
|
chat_id: "chat-a",
|
|
text: "How should I continue?",
|
|
buttons: [["Short answer", "Detailed answer"]],
|
|
});
|
|
});
|
|
|
|
expect(screen.getByRole("group", { name: "Question" })).toHaveTextContent(
|
|
"How should I continue?",
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "Short answer" }));
|
|
|
|
expect(client.sendMessage).toHaveBeenCalledWith(
|
|
"chat-a",
|
|
"Short answer",
|
|
undefined,
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole("group", { name: "Question" })).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|