lib/assistant-panel-restoration.test.ts
Rama main · réplica de lectura del repositorio original.
// @vitest-environment jsdom
import { act, createElement, Fragment, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenAssistantOptions } from "./assistant-panel-context";
const h = vi.hoisted(() => ({
pending: null as OpenAssistantOptions | null,
isOpen: true,
read: vi.fn(),
close: vi.fn(), clear: vi.fn(), active: vi.fn(), pointer: vi.fn(),
send: vi.fn(), fill: vi.fn(), load: vi.fn(), reset: vi.fn(), abort: vi.fn(),
router: { push: vi.fn(), refresh: vi.fn() },
theme: { setTheme: vi.fn() }, auth: { refreshUser: vi.fn() },
state: { conversationId: null as string | null, conversationProjectId: null, messages: [], status: "idle", completedResponse: null as { conversationId: string; messageId: string } | null },
projects: [],
}));
vi.mock("./assistant-panel-context", () => ({ useAssistantPanel: () => ({
isOpen: h.isOpen, routeProjectId: null, pendingOptions: h.pending,
activePageContext: null, ambientContext: null, close: h.close, clearPendingOptions: h.clear,
}) }));
vi.mock("./use-assistant-chat", () => ({ useAssistantChat: () => ({
state: h.state, loadConversation: h.load, reset: h.reset, sendMessage: h.send, abort: h.abort,
}) }));
vi.mock("./assistant-api", () => ({
fetchActiveConversation: h.active, setActiveConversation: h.pointer,
updateConversation: h.read,
}));
vi.mock("./use-agent-runs", () => ({ allAgentSessionsQueryKey: ["agent-sessions", "all"] as const }));
vi.mock("@tanstack/react-query", async () => {
const { createContext } = await import("react");
return {
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
// The chat context reads it to refresh hidden projects (MIN-955); empty here.
QueryClientContext: createContext(undefined),
};
});
vi.mock("./auth-context", () => ({ useAuth: () => h.auth }));
vi.mock("./projects-context", () => ({ useProjects: () => ({ projects: h.projects }) }));
vi.mock("next-intl", () => ({ useLocale: () => "en", useTranslations: () => (key: string) => key }));
vi.mock("next/navigation", () => ({ useRouter: () => h.router }));
vi.mock("@/components/theme-provider", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTheme: () => h.theme,
}));
vi.mock("./set-locale", () => ({ setLocaleCookie: async () => {} }));
vi.mock("@/components/ui/sheet", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
Sheet: ({ open, children }: { open: boolean; children: ReactNode }) => open ? children : null,
SheetContent: ({ children }: { children: ReactNode }) => children,
SheetTitle: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/components/assistant/assistant-shell", async () => {
const { forwardRef, useImperativeHandle, useState, useEffect, createElement: element } = await import("react");
const { useAssistantChatContext, useAssistantUnreadResponseConversationId } = await import("./assistant-chat-context");
return { AssistantShell: forwardRef(function Shell({ visible }: { visible?: boolean }, ref) {
const [draft, setDraft] = useState("");
const { state, restoring, requestRestore, markResponseRead } = useAssistantChatContext();
const unreadConversationId = useAssistantUnreadResponseConversationId();
// The real shell asks for the lazy restore on mount — the provider no
// longer restores as a side effect of the panel opening.
useEffect(() => { requestRestore(); }, [requestRestore]);
useEffect(() => {
if (visible && !restoring && state.status === "idle" && state.conversationId) {
markResponseRead(state.conversationId);
}
}, [visible, restoring, state.status, state.conversationId, markResponseRead]);
useImperativeHandle(ref, () => ({
sendMessage: h.send,
fill: (text: string) => { h.fill(text); setDraft(text); },
}), []);
return element("span", { "data-draft": true, "data-visible": !!visible, "data-unread": unreadConversationId }, draft);
}) };
});
import { AssistantChatProvider } from "./assistant-chat-context";
import { AssistantPanel } from "@/components/assistant-panel";
let root: Root;
let container: HTMLDivElement;
async function render() {
await act(async () => root.render(createElement(AssistantChatProvider, {
children: createElement(Fragment, null, createElement(AssistantPanel)),
})));
}
beforeEach(() => {
vi.clearAllMocks();
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
h.pending = null;
h.isOpen = true;
h.state = { ...h.state, conversationId: null, completedResponse: null, status: "idle" };
h.read.mockResolvedValue(true);
h.clear.mockImplementation(() => { h.pending = null; });
// Loading a conversation installs it as the live thread (the real
// LOAD_HISTORY dispatch): the pointer mirror then sees it as already read.
h.load.mockImplementation(async (conversationId: string) => {
h.state = { ...h.state, conversationId };
});
container = document.createElement("div");
root = createRoot(container);
});
afterEach(() => act(() => root.unmount()));
const work = { conversationId: "work", projectId: "a" };
describe("contextual openings while restoring work", () => {
it("keeps the old response unread after opening options clear until the requested thread loads", async () => {
h.state = { ...h.state, conversationId: "original", completedResponse: { conversationId: "original", messageId: "answer" } };
h.active.mockResolvedValue({ conversationId: null, projectId: null });
h.pending = { conversationId: "requested" };
let finishLoad!: () => void;
h.load.mockImplementation(() => new Promise<void>((resolve) => {
finishLoad = () => {
h.state = { ...h.state, conversationId: "requested", completedResponse: null };
resolve();
};
}));
await render();
expect(h.load).toHaveBeenCalledExactlyOnceWith("requested", null);
expect(h.pending).toBeNull();
// The options have been consumed, but the old thread is still in live state.
await render();
expect(container.querySelector("[data-draft]")?.getAttribute("data-visible")).toBe("false");
expect(container.querySelector("[data-draft]")?.getAttribute("data-unread")).toBe("original");
expect(h.read).not.toHaveBeenCalledWith("original", { read: true });
await act(async () => finishLoad());
await render();
expect(container.querySelector("[data-draft]")?.getAttribute("data-visible")).toBe("true");
expect(container.querySelector("[data-draft]")?.getAttribute("data-unread")).toBe("original");
expect(h.read).not.toHaveBeenCalledWith("original", { read: true });
// Once the requested thread arrives, ordinary history selection is visible.
h.state = { ...h.state, conversationId: "original" };
await render();
expect(container.querySelector("[data-draft]")?.getAttribute("data-visible")).toBe("true");
expect(h.read).toHaveBeenCalledWith("original", { read: true });
expect(container.querySelector("[data-draft]")?.hasAttribute("data-unread")).toBe(false);
});
it("releases the requested-thread guard when the panel closes during a load", async () => {
h.state = { ...h.state, conversationId: "original" };
h.active.mockResolvedValue({ conversationId: null, projectId: null });
h.pending = { conversationId: "requested" };
h.load.mockImplementation(() => new Promise<void>(() => {}));
await render();
await render();
expect(container.querySelector("[data-draft]")?.getAttribute("data-visible")).toBe("false");
h.isOpen = false;
await render();
h.isOpen = true;
await render();
expect(container.querySelector("[data-draft]")?.getAttribute("data-visible")).toBe("true");
});
it.each([
{ kind: "prompt", late: false }, { kind: "draft", late: false },
{ kind: "prompt", late: true }, { kind: "draft", late: true },
] as const)("delivers $kind once when it arrives late=$late", async ({ kind, late }) => {
let resolve!: (value: typeof work) => void;
h.active.mockReturnValue(new Promise((done) => { resolve = done; }));
const options: OpenAssistantOptions = {
[kind]: "Discuss B", projectId: "b", pageContext: { projectId: "b" },
};
if (!late) h.pending = options;
await render();
expect(h.send).not.toHaveBeenCalled();
expect(h.fill).not.toHaveBeenCalled();
if (late) { h.pending = options; await render(); }
await act(async () => resolve(work));
// Consume the cleared opening options on the next render too.
await render();
expect(h.close).not.toHaveBeenCalled();
expect(h.router.push).not.toHaveBeenCalled();
// The resume loads the remembered thread first (a queued prompt or draft
// continues it); the opening dispatches once, after the restore settles.
expect(h.load).toHaveBeenCalledExactlyOnceWith(work.conversationId, work.projectId);
expect(h.pointer).not.toHaveBeenCalled();
expect(h.clear).toHaveBeenCalledTimes(1);
expect(h.pending).toBeNull();
if (kind === "prompt") {
expect(h.send).toHaveBeenCalledExactlyOnceWith("b", "Discuss B", expect.objectContaining({ pageContext: { projectId: "b" } }));
expect(h.fill).not.toHaveBeenCalled();
} else {
expect(h.fill).toHaveBeenCalledExactlyOnceWith("Discuss B");
expect(container.querySelector("[data-draft]")?.textContent).toBe("Discuss B");
expect(h.send).not.toHaveBeenCalled();
}
});
it("reopens the remembered conversation in the panel on a plain resume", async () => {
h.active.mockResolvedValue(work);
await render();
expect(h.close).not.toHaveBeenCalled();
expect(h.router.push).not.toHaveBeenCalled();
expect(h.load).toHaveBeenCalledExactlyOnceWith(work.conversationId, work.projectId);
expect(h.pointer).not.toHaveBeenCalled();
expect(h.send).not.toHaveBeenCalled();
});
});