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"; import type { UIMessage } from "@/lib/types"; function makeClient() { const errorHandlers = new Set<(err: { kind: string }) => void>(); const chatHandlers = new Map void>>(); const sessionUpdateHandlers = new Set<(chatId: string) => void>(); const goalStateByChatId = new Map(); return { status: "open" as const, defaultChatId: null as string | null, onStatus: () => () => {}, onRuntimeModelUpdate: () => () => {}, getRunStartedAt: () => null, getGoalState: (chatId: string) => goalStateByChatId.get(chatId), 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); }; }, onSessionUpdate: (handler: (chatId: string) => void) => { sessionUpdateHandlers.add(handler); return () => { sessionUpdateHandlers.delete(handler); }; }, _emitError(err: { kind: string }) { for (const h of errorHandlers) h(err); }, _emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) { if (ev.event === "goal_state") { goalStateByChatId.set(chatId, ev.goal_state); } for (const h of chatHandlers.get(chatId) ?? []) h(ev); }, _emitSessionUpdate(chatId: string) { for (const h of sessionUpdateHandlers) h(chatId); }, sendMessage: vi.fn(), newChat: vi.fn(), attach: vi.fn(), connect: vi.fn(), close: vi.fn(), updateUrl: vi.fn(), }; } function wrap(client: ReturnType, children: ReactNode) { return ( {children} ); } function session(chatId: string) { return { key: `websocket:${chatId}`, channel: "websocket" as const, chatId, createdAt: null, updatedAt: null, preview: "", }; } function transcriptFromSimpleMessages( rows: Array<{ role: "user" | "assistant"; content: string }>, ): { schemaVersion: number; messages: UIMessage[] } { return { schemaVersion: 3, messages: rows.map((m, i) => ({ id: `m-${i}`, role: m.role, content: m.content, createdAt: 1000 + i, })), }; } 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, {}} 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, {}} 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, {}} onGoHome={() => {}} onNewChat={onNewChat} />, ), ); }); await act(async () => { rerender( wrap( client, {}} 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, {}} 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, {}} 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, {}} 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, {}} 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, {}} 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, {}} 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/webui-thread")) { return httpJson( transcriptFromSimpleMessages([ { role: "user", content: "old question" }, { role: "assistant", content: "old answer" }, ]), ); } return { ok: false, status: 404, json: async () => ({}), }; }), ); const { rerender } = render( wrap( client, {}} onGoHome={() => {}} onNewChat={onNewChat} />, ), ); await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument()); await act(async () => { rerender( wrap( client, {}} 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, {}} 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, {}} onGoHome={() => {}} onNewChat={onNewChat} />, ), ); }); await waitFor(() => { expect(screen.queryByText("only in chat a")).not.toBeInTheDocument(); }); await act(async () => { rerender( wrap( client, {}} onGoHome={() => {}} onNewChat={onNewChat} />, ), ); }); expect(screen.getByText("only in chat a")).toBeInTheDocument(); await act(async () => { rerender( wrap( client, {}} 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/webui-thread")) { return httpJson(transcriptFromSimpleMessages([{ role: "user", content: "hello" }])); } return { ok: false, status: 404, json: async () => ({}), }; }), ); const { rerender } = render( wrap( client, {}} 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, {}} 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, {}} onNewChat={() => {}} />, ), ); }); await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument()); }); it("does not refetch thread history on turn_end", async () => { const client = makeClient(); let historyCalls = 0; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("websocket%3Achat-a/webui-thread")) { historyCalls += 1; return httpJson( transcriptFromSimpleMessages( historyCalls === 1 ? [{ role: "user", content: "question" }] : [ { role: "user", content: "question" }, { role: "assistant", content: "canonical markdown answer" }, ], ), ); } return { ok: false, status: 404, json: async () => ({}), }; }), ); render( wrap( client, {}} onNewChat={() => {}} />, ), ); await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument()); await act(async () => { client._emitChat("chat-a", { event: "delta", chat_id: "chat-a", text: "live half-parsed | markdown", }); client._emitChat("chat-a", { event: "turn_end", chat_id: "chat-a", }); }); await waitFor(() => expect(screen.getByText("live half-parsed | markdown")).toBeInTheDocument()); expect(screen.queryByText("canonical markdown answer")).not.toBeInTheDocument(); expect(historyCalls).toBe(1); }); it("scrolls to the bottom after loading a session from the blank new-chat page", async () => { const client = makeClient(); const scrollIntoView = vi.fn(); const originalScrollIntoView = HTMLElement.prototype.scrollIntoView; HTMLElement.prototype.scrollIntoView = scrollIntoView; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.includes("websocket%3Achat-a/webui-thread")) { return httpJson( transcriptFromSimpleMessages([ { role: "user", content: "question" }, { role: "assistant", content: "loaded answer" }, ]), ); } return { ok: false, status: 404, json: async () => ({}), }; }), ); try { const { rerender } = render( wrap( client, {}} onNewChat={() => {}} />, ), ); expect(screen.getByText("What can I do for you?")).toBeInTheDocument(); scrollIntoView.mockClear(); await act(async () => { rerender( wrap( client, {}} onNewChat={() => {}} />, ), ); }); await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument()); await waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({ block: "end", behavior: "auto", }), ); } finally { HTMLElement.prototype.scrollIntoView = originalScrollIntoView; } }); it("opens 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: "/history", title: "Show conversation history", description: "Print the last N persisted messages.", icon: "history", arg_hint: "[n]", }, ], }); } return { ok: false, status: 404, json: async () => ({}), }; }), ); render( wrap( client, {}} onNewChat={() => {}} />, ), ); await waitFor(() => expect(fetch).toHaveBeenCalledWith( "/api/commands", expect.objectContaining({ headers: { Authorization: "Bearer tok" }, }), )); fireEvent.change(screen.getByLabelText("Message input"), { target: { value: "/" }, }); expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument(); expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument(); }); it("switches welcome quick actions when image mode is enabled", async () => { const client = makeClient(); render( wrap( client, {}} 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, {}} 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, {}} 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, {}} 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 }) => void) | null = null; vi.stubGlobal( "fetch", vi.fn((input: RequestInfo | URL) => { const url = String(input); if (url.includes("websocket%3Achat-a/webui-thread")) { return Promise.resolve( httpJson( transcriptFromSimpleMessages([{ role: "assistant", content: "from chat a" }]), ), ); } if (url.includes("websocket%3Achat-b/webui-thread")) { return new Promise((resolve) => { resolveChatB = resolve; }); } return Promise.resolve({ ok: false, status: 404, json: async () => ({}), }); }), ); const { rerender } = render( wrap( client, {}} onGoHome={() => {}} onNewChat={onNewChat} />, ), ); await waitFor(() => expect(screen.getByText("from chat a")).toBeInTheDocument()); await act(async () => { rerender( wrap( client, {}} onGoHome={() => {}} onNewChat={onNewChat} />, ), ); }); expect(screen.queryByText("from chat a")).not.toBeInTheDocument(); expect(screen.getByText("Loading conversation…")).toBeInTheDocument(); await act(async () => { resolveChatB?.( httpJson(transcriptFromSimpleMessages([{ role: "assistant", content: "from chat b" }])), ); }); await waitFor(() => expect(screen.getByText("from chat b")).toBeInTheDocument()); expect(screen.queryByText("from chat a")).not.toBeInTheDocument(); }); });