// @vitest-environment jsdom
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { Agent } from "@paperclipai/shared";
import { AgentConversationsSidebar } from "./AgentConversationsSidebar";
import { queryKeys } from "@/lib/queryKeys";
const state = vi.hoisted(() => ({ companyId: "company-a", navigate: vi.fn(), closeSidebar: vi.fn(), ensure: vi.fn(), getAgent: vi.fn() }));
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.companyId }) }));
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: true, setSidebarOpen: state.closeSidebar }) }));
vi.mock("@/hooks/useAgentChatEnabled", () => ({ useAgentChatEnabled: () => ({ enabled: true, loaded: true }) }));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ pathname: "/A/chats/alice" }), useNavigate: () => state.navigate,
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => {children},
}));
vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(), get: state.getAgent } }));
vi.mock("@/api/agentChats", () => ({ agentChatsApi: { list: vi.fn(), ensure: state.ensure } }));
vi.mock("@/api/auth", () => ({ authApi: { getSession: () => ({ user: { id: "user-a" } }) } }));
vi.mock("./AgentAvatar", () => ({ AgentAvatar: () => null }));
const memberships = vi.hoisted(() => ({ agentMemberships: {} as Record }));
vi.mock("@/api/resourceMemberships", () => ({ resourceMembershipsApi: {
listMine: async () => ({ projectMemberships: {}, agentMemberships: memberships.agentMemberships, starredProjectIds: [], starredAgentIds: [], starredDocumentIds: [] }),
} }));
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
const roster = ["alice", "bob"].map(id => ({ id, companyId: "company-a", name: id, urlKey: id, role: "general", title: "Teammate", status: "idle" } as Agent));
const chat = (id: string) => ({ id: `chat-${id}`, companyId: "company-a", conversationAgentId: id, conversationUserId: "user-a", updatedAt: id === "bob" ? "2026-09-02T00:00:00Z" : "2026-09-01T00:00:00Z" });
async function render() {
await act(async () => { root.render(); });
}
async function addChat() { await act(async () => container.querySelector('[aria-label="Add chat"]')!.click()); }
async function choose(name: string) {
await act(async () => [...document.querySelectorAll("[role=option]")].find(option => option.textContent?.startsWith(name))!.click());
}
beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} });
state.companyId = "company-a";
state.navigate.mockReset(); state.closeSidebar.mockReset(); state.ensure.mockReset(); state.getAgent.mockReset();
state.ensure.mockImplementation(async (_company, id) => chat(id));
memberships.agentMemberships = {};
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(queryKeys.auth.session, { user: { id: "user-a" } });
client.setQueryData(queryKeys.agents.list("company-a"), roster);
client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat("alice")]);
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); });
it("adds through the server and reopens one cached conversation without a duplicate row", async () => {
await render();
expect(container.querySelectorAll('nav[aria-label="Agent conversations"] a')).toHaveLength(2);
await addChat();
expect([...document.querySelectorAll("[role=option]")].find(option => option.textContent?.startsWith("bob"))?.textContent).not.toContain("Open chat");
await choose("bob");
expect(state.ensure).toHaveBeenCalledWith("company-a", "bob");
expect(state.navigate).toHaveBeenLastCalledWith("/chats/bob");
expect(state.closeSidebar).toHaveBeenCalledWith(false);
await render();
expect(client.getQueryData(queryKeys.agentChats.detail("company-a", "user-a", "bob"))).toEqual(chat("bob"));
await addChat();
expect([...document.querySelectorAll("[role=option]")].find(option => option.textContent?.startsWith("bob"))?.textContent).toContain("Open chat");
await choose("bob"); await render();
expect(container.querySelectorAll('a[href="/chats/bob"]')).toHaveLength(1);
expect(client.getQueryData(queryKeys.agentChats.list("company-a", "user-a"))).toHaveLength(2);
});
it("keeps failures retryable in the picker and closes it on company changes", async () => {
state.ensure.mockRejectedValueOnce(new Error("Couldn’t open chat. Try again."));
await render(); await addChat(); await choose("bob");
expect(document.querySelector('[role="alert"]')?.textContent).toContain("Couldn’t open chat");
expect(state.navigate).not.toHaveBeenCalled();
await choose("bob");
expect(state.navigate).toHaveBeenLastCalledWith("/chats/bob");
await addChat();
state.companyId = "company-b";
client.setQueryData(queryKeys.agents.list("company-b"), []);
client.setQueryData(queryKeys.agentChats.list("company-b", "user-a"), []);
await render();
expect(document.querySelector('[role="dialog"]')).toBeNull();
expect(container.textContent).not.toContain("alice");
});
it("preserves recent activity order when reopening an older conversation", async () => {
client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat("bob"), chat("alice")]);
await render();
const links = () => [...container.querySelectorAll('nav[aria-label="Agent conversations"] a')].map(link => link.getAttribute("href"));
expect(links()).toEqual(["/chats/bob", "/chats/alice"]);
await addChat(); await choose("alice"); await render();
expect(links()).toEqual(["/chats/bob", "/chats/alice"]);
});
it("retains terminated agents' history by id without offering them for new chats", async () => {
const retired = { ...roster[0], id: "retired-id", name: "Retired", status: "terminated", urlKey: "alice" };
client.setQueryData(queryKeys.agents.detail(retired.id), retired);
client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat(retired.id), chat("alice")]);
await render();
const historyLink = container.querySelector('a[href="/chats/retired-id"]')!;
expect(historyLink.textContent).toContain("RetiredTerminated");
await act(async () => historyLink.click());
expect(state.navigate).toHaveBeenLastCalledWith("/chats/retired-id");
await addChat();
expect([...document.querySelectorAll("[role=option]")].some(option => option.textContent?.includes("Retired"))).toBe(false);
});
it("does not let a dismissed selection override a newer chat", async () => {
let finishAlice!: (value: ReturnType) => void;
state.ensure.mockImplementation(async (_company, id) => id === "alice"
? new Promise(resolve => { finishAlice = resolve; }) : chat(id));
await render(); await addChat(); await choose("alice");
await act(async () => document.querySelector('[data-slot="dialog-close"]')!.click());
await addChat(); await choose("bob");
expect(state.navigate).toHaveBeenLastCalledWith("/chats/bob");
await act(async () => finishAlice(chat("alice")));
expect(state.navigate).toHaveBeenCalledTimes(1);
expect(client.getQueryData(queryKeys.agentChats.list("company-a", "user-a"))).toHaveLength(2);
});
it("keeps healthy chats usable while a historical lookup fails and retries", async () => {
const retired = { ...roster[0], id: "retired-id", name: "Retired", status: "terminated" };
state.getAgent.mockRejectedValueOnce(new Error("Unavailable")).mockResolvedValue(retired);
client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat(retired.id), chat("alice")]);
await render();
await vi.waitFor(async () => {
await act(async () => {});
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Some chat history couldn’t load");
});
expect(container.querySelector('a[href="/chats/alice"]')).not.toBeNull();
await act(async () => {
const input = container.querySelector('[aria-label="Search agents"]')!;
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "No match");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(container.textContent).toContain("No agents found");
await act(async () => container.querySelector('[aria-label="Clear search"]')!.click());
expect(container.querySelector('a[href="/chats/alice"]')).not.toBeNull();
await act(async () => [...container.querySelectorAll("button")].find(button => button.textContent === "Retry chat history")!.click());
await vi.waitFor(async () => {
await act(async () => {});
expect(container.querySelector('a[href="/chats/retired-id"]')).not.toBeNull();
});
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.querySelector('a[href="/chats/alice"]')).not.toBeNull();
});
it("lists every eligible agent: open chat, then conversations by recency, then the rest alphabetically", async () => {
const more = [
{ ...roster[0], id: "zed", name: "Zed", urlKey: "zed" },
{ ...roster[0], id: "carol", name: "carol", urlKey: "carol" },
{ ...roster[0], id: "gone", name: "Gone", urlKey: "gone", status: "terminated" },
{ ...roster[0], id: "left", name: "Left", urlKey: "left" },
] as Agent[];
memberships.agentMemberships = { left: "left" };
client.setQueryData(queryKeys.agents.list("company-a"), [...more, ...roster]);
client.setQueryData(queryKeys.agentChats.list("company-a", "user-a"), [chat("bob")]);
await render();
await vi.waitFor(async () => {
await act(async () => {});
expect([...container.querySelectorAll('nav[aria-label="Agent conversations"] a')].map(link => link.getAttribute("href")))
.toEqual(["/chats/alice", "/chats/bob", "/chats/carol", "/chats/zed"]); // the open chat (alice) is pinned first
});
});