import { describe, it, expect, vi, beforeEach } from "vitest"; import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react"; import * as api from "../../api"; import { PlanningModeModal } from "../PlanningModeModal"; import { TaskDetailModal } from "../TaskDetailModal"; import { useSessionLock } from "../../hooks/useSessionLock"; import { getSessionTabId } from "../../utils/getSessionTabId"; import type { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult } from "@fusion/core"; // Mock the API functions const mockStartPlanning = vi.fn(); const mockStartPlanningStreaming = vi.fn(); const mockCreatePlanningDraft = vi.fn(); const mockConnectPlanningStream = vi.fn(); const mockRespondToPlanning = vi.fn(); const mockRetryPlanningSession = vi.fn(); const mockCancelPlanning = vi.fn(); const mockStopPlanningGeneration = vi.fn(); const mockUpdatePlanningSessionDraft = vi.fn(); const mockCreateTaskFromPlanning = vi.fn(); const mockStartPlanningBreakdown = vi.fn(); const mockCreateTasksFromPlanning = vi.fn(); const mockFetchAiSession = vi.fn(); const mockParseConversationHistory = vi.fn(); const mockFetchModels = vi.fn(); const mockAcquireSessionLock = vi.fn(); const mockReleaseSessionLock = vi.fn(); const mockForceAcquireSessionLock = vi.fn(); const mockUploadAttachment = vi.fn(); const mockDeleteAttachment = vi.fn(); const mockUpdateTask = vi.fn(); const mockPauseTask = vi.fn(); const mockUnpauseTask = vi.fn(); const mockFetchTaskDetail = vi.fn(); const mockRequestSpecRevision = vi.fn(); const mockApprovePlan = vi.fn(); const mockRejectPlan = vi.fn(); const mockRefineTask = vi.fn(); const mockFetchAiSessions = vi.fn(); vi.mock("../../api", () => ({ startPlanning: (...args: any[]) => mockStartPlanning(...args), startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args), createPlanningDraft: (...args: any[]) => mockCreatePlanningDraft(...args), connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args), respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args), retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args), cancelPlanning: (...args: any[]) => mockCancelPlanning(...args), stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args), updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args), createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args), startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args), createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args), fetchAiSession: (...args: any[]) => mockFetchAiSession(...args), parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args), acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args), releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args), forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args), uploadAttachment: (...args: any[]) => mockUploadAttachment(...args), deleteAttachment: (...args: any[]) => mockDeleteAttachment(...args), updateTask: (...args: any[]) => mockUpdateTask(...args), pauseTask: (...args: any[]) => mockPauseTask(...args), unpauseTask: (...args: any[]) => mockUnpauseTask(...args), fetchTaskDetail: (...args: any[]) => mockFetchTaskDetail(...args), requestSpecRevision: (...args: any[]) => mockRequestSpecRevision(...args), approvePlan: (...args: any[]) => mockApprovePlan(...args), rejectPlan: (...args: any[]) => mockRejectPlan(...args), refineTask: (...args: any[]) => mockRefineTask(...args), fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }), fetchModels: (...args: any[]) => mockFetchModels(...args), fetchWorkflowSteps: vi.fn().mockResolvedValue([]), refineText: vi.fn(), getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"), updateGlobalSettings: vi.fn().mockResolvedValue({}), duplicateTask: vi.fn().mockResolvedValue({}), fetchAiSessions: (...args: any[]) => mockFetchAiSessions(...args), })); const mockConfirm = vi.fn(); vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm }), })); const mockUseViewportMode = vi.fn<() => "mobile" | "tablet" | "desktop">(() => "desktop"); vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: () => mockUseViewportMode(), })); const mockUseMobileKeyboard = vi.fn<() => { keyboardOverlap: number; viewportHeight: number | null; viewportOffsetTop: number; keyboardOpen: boolean; }>(() => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false, })); vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args), })); const mockTasks: Task[] = [ { id: "FN-001", description: "Existing task 1", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }, ]; const mockModels = [ { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000, }, { provider: "google", id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", reasoning: true, contextWindow: 1048576, }, ]; const mockQuestion: PlanningQuestion = { id: "q-scope", type: "single_select", question: "What is the scope?", description: "Choose the scope of this task", options: [ { id: "small", label: "Small" }, { id: "medium", label: "Medium" }, { id: "large", label: "Large" }, ], }; const mockSummary: PlanningSummary = { title: "Build authentication system", description: "Implement user auth with login and signup", suggestedSize: "M", suggestedDependencies: [], keyDeliverables: ["Login page", "Signup page", "Auth API"], }; const mockTaskDetail = { id: "KB-999", title: "Example task", description: "Example description", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], attachments: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", prompt: "# Task\n\nExample prompt", paused: false, } as TaskDetail; class MockEventSource { static instances: MockEventSource[] = []; url: string; closed = false; private listeners = new Map void>>(); constructor(url: string) { this.url = url; MockEventSource.instances.push(this); } addEventListener(event: string, listener: (event: MessageEvent) => void): void { const set = this.listeners.get(event) ?? new Set(); set.add(listener); this.listeners.set(event, set); } removeEventListener(event: string, listener: (event: MessageEvent) => void): void { const set = this.listeners.get(event); if (!set) return; set.delete(listener); } close(): void { this.closed = true; } emit(event: string, data: unknown): void { const listeners = this.listeners.get(event); if (!listeners) return; const message = { data: JSON.stringify(data) } as MessageEvent; listeners.forEach((listener) => listener(message)); } static reset(): void { MockEventSource.instances = []; } } function getMediaBlocks(css: string, mediaQuery: string): string[] { const blocks: string[] = []; let searchStart = 0; while (searchStart < css.length) { const start = css.indexOf(mediaQuery, searchStart); if (start === -1) break; const blockStart = css.indexOf("{", start); if (blockStart === -1) break; let depth = 1; let cursor = blockStart + 1; while (cursor < css.length && depth > 0) { if (css[cursor] === "{") depth += 1; else if (css[cursor] === "}") depth -= 1; cursor += 1; } blocks.push(css.slice(start, cursor)); searchStart = cursor; } return blocks; } function mockViewport(mode: "mobile" | "desktop" | "tablet") { mockUseViewportMode.mockReturnValue(mode); Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn().mockImplementation((query: string) => { const isMobileQuery = query === "(max-width: 768px)"; const isTabletQuery = query === "(min-width: 769px) and (max-width: 1024px)"; return { matches: mode === "mobile" ? isMobileQuery : mode === "tablet" ? isTabletQuery : false, media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), }; }), }); } describe("PlanningModeModal", () => { const mockOnClose = vi.fn(); const mockOnTaskCreated = vi.fn(); beforeEach(() => { vi.clearAllMocks(); mockConfirm.mockReset(); mockConfirm.mockResolvedValue(true); MockEventSource.reset(); vi.stubGlobal("EventSource", MockEventSource as any); window.sessionStorage.clear(); // Default to desktop viewport; mobile-specific tests override per-test. mockViewport("desktop"); // Default mock for streaming mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" }); // Server's createDraftSession always returns the placeholder title; the // real summarized title only arrives later via blur/close summarize or // when the session transitions out of draft. Mirror that in the mock so // the sidebar render rule (preview while title === placeholder) behaves // realistically in tests. mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" }); mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" }); mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] }); mockFetchAiSession.mockResolvedValue(null); mockFetchAiSessions.mockResolvedValue([]); mockParseConversationHistory.mockImplementation((raw: string) => { if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; } }); mockFetchModels.mockResolvedValue({ models: mockModels, favoriteProviders: [], favoriteModels: [], resolvedPlanningProvider: "openai", resolvedPlanningModelId: "gpt-4o", }); mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null }); mockReleaseSessionLock.mockResolvedValue(undefined); mockForceAcquireSessionLock.mockResolvedValue(undefined); mockCancelPlanning.mockResolvedValue(undefined); mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true }); mockStopPlanningGeneration.mockResolvedValue({ success: true }); // Default: simulate receiving a question after a brief delay mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { setTimeout(() => { handlers.onQuestion?.(mockQuestion); }, 10); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); }); describe("Initial view", () => { it("renders the initial input view when open", () => { const { container } = render( ); expect(screen.getByText("Planning Mode")).toBeDefined(); expect(screen.getByPlaceholderText(/e.g., Build a user authentication/)).toBeDefined(); expect(container.querySelector(".planning-modal-body")).not.toBeNull(); expect(container.querySelector(".planning-modal-body")?.classList.contains("modal-body")).toBe(false); expect(container.querySelector(".planning-examples-label")?.textContent).toBe("Try an example:"); }); it("does not render when closed", () => { render( ); expect(screen.queryByText("Planning Mode")).toBeNull(); }); it("hides send to background button in initial state", () => { render( ); expect(screen.queryByLabelText("Send to background")).toBeNull(); }); it("enables start button when text is entered", () => { render( ); const startButton = screen.getByText("Start Planning"); expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(true); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Test plan" } }); expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(false); }); it("shows example chips", () => { render( ); expect(screen.getByText(/Build a user authentication/)).toBeDefined(); }); it("renders planning model dropdown in initial view", async () => { render( ); expect(screen.getByRole("button", { name: "Advanced planning settings" })).toBeDefined(); expect(screen.queryByRole("button", { name: "Planning Model" })).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" })); const modelTrigger = screen.getByRole("button", { name: "Planning Model" }); expect(modelTrigger).toBeDefined(); await waitFor(() => { expect(mockFetchModels).toHaveBeenCalledTimes(1); expect(screen.getByText("openai/gpt-4o")).toBeDefined(); }); }); it("shows resolved default model badge and switches to override badge when selected", async () => { render( ); await waitFor(() => { expect(mockFetchModels).toHaveBeenCalledTimes(1); }); fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" })); expect(screen.getByText("openai/gpt-4o")).toBeDefined(); fireEvent.click(screen.getByRole("button", { name: "Planning Model" })); fireEvent.click(screen.getByRole("option", { name: /Claude Sonnet 4.5/ })); expect(screen.getByText("anthropic/claude-sonnet-4-5")).toBeDefined(); }); it("passes selected planning model to startPlanningStreaming", async () => { render( ); await waitFor(() => { expect(mockFetchModels).toHaveBeenCalledTimes(1); }); fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" })); fireEvent.click(screen.getByRole("button", { name: "Planning Model" })); fireEvent.click(screen.getByRole("option", { name: /Claude Sonnet 4.5/ })); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, { planningModelProvider: "anthropic", planningModelId: "claude-sonnet-4-5", }, { planningDepth: "medium", customQuestionCount: undefined, }, undefined); }); }); it("keeps advanced disclosure collapsed by default and reveals controls when expanded", async () => { render( ); const disclosureButton = screen.getByRole("button", { name: "Advanced planning settings" }); expect(disclosureButton).toBeDefined(); const disclosure = disclosureButton.closest(".onboarding-disclosure"); expect(disclosure).not.toBeNull(); const disclosureScope = within(disclosure as HTMLElement); expect(disclosureButton.getAttribute("aria-expanded")).toBe("false"); expect(disclosureScope.queryByRole("button", { name: "Planning Model" })).toBeNull(); expect(disclosureScope.queryByText(/Selects which model runs the planning interview/)).toBeNull(); fireEvent.click(disclosureButton); expect(disclosureButton.getAttribute("aria-expanded")).toBe("true"); expect(disclosureScope.getByRole("button", { name: "Planning Model" })).toBeDefined(); await waitFor(() => { expect(disclosureScope.getByText("openai/gpt-4o")).toBeDefined(); }); expect(disclosureScope.getByText(/Selects which model runs the planning interview/)).toBeDefined(); expect(disclosureScope.getByText(/Plan size sets default interview depth/)).toBeDefined(); expect(disclosureScope.getByRole("button", { name: "Small" })).toBeDefined(); expect(disclosureScope.getByRole("button", { name: "Medium" }).getAttribute("aria-pressed")).toBe("true"); expect(disclosureScope.getByRole("button", { name: "Large" })).toBeDefined(); expect(disclosureScope.getByLabelText("Questions")).toBeDefined(); }); it("updates selected depth and sends custom question count", async () => { render( ); fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" })); fireEvent.click(screen.getByRole("button", { name: "Large" })); fireEvent.change(screen.getByLabelText("Questions"), { target: { value: "7" } }); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, { planningDepth: "large", customQuestionCount: 7, }, undefined); }); }); it("calls startPlanningStreaming without model override when none selected", async () => { render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, { planningDepth: "medium", customQuestionCount: undefined, }, undefined); }); }); it("auto-creates a draft after typing and reuses it when starting", async () => { render( , ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build a detailed auth system plan" } }); await waitFor(() => { expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1); }); expect(mockCreatePlanningDraft).toHaveBeenCalledWith( "Build a detailed auth system plan", undefined, undefined, ); // Sidebar shows the inputPayload-derived preview for draft rows so // multiple drafts are distinguishable, not the placeholder title that // createDraftSession returns. The text also appears in the textarea // value, so scope the query to the sidebar item title element. const sidebarItem = document.querySelector(".planning-sidebar-item-title"); expect(sidebarItem?.textContent).toBe("Build a detailed auth system plan"); fireEvent.change(textarea, { target: { value: "Build a detailed auth system plan with extras" } }); await new Promise((resolve) => setTimeout(resolve, 350)); expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(mockStartPlanningStreaming).toHaveBeenCalledWith( "Build a detailed auth system plan with extras", undefined, undefined, { planningDepth: "medium", customQuestionCount: undefined, }, "draft-123", ); }); }); it("auto-starts planning when initialPlan prop is provided", async () => { render( ); // Wait for startPlanningStreaming to be called (allow time for setTimeout in useEffect) await waitFor(() => { expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from new task dialog", undefined, undefined, { planningDepth: "medium", customQuestionCount: undefined, }, undefined); }, { timeout: 2000 }); // Should transition to question view await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); }); it("sets initial plan text in textarea when initialPlan prop is provided", async () => { render( ); // The auto-start should happen with the initial plan (allow time for setTimeout in useEffect) await waitFor(() => { expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Pre-filled plan from new task", undefined, undefined, { planningDepth: "medium", customQuestionCount: undefined, }, undefined); }, { timeout: 2000 }); }); }); describe("modal height constraint regression", () => { it("desktop planning modal max-height accounts for overlay padding", async () => { const { container } = render( ); const modal = container.querySelector(".planning-modal"); expect(modal).toBeTruthy(); const { loadAllAppCss } = await import("../../test/cssFixture"); const css = loadAllAppCss(); const blockMatch = css.match( /\.planning-modal\s*\{[^}]*max-height:\s*([^;]+);/, ); expect(blockMatch).toBeTruthy(); const maxHeightValue = blockMatch![1].trim(); expect(maxHeightValue).toContain("calc("); expect(maxHeightValue).toContain("100dvh"); expect(maxHeightValue).toContain("--overlay-padding-top"); }); it("uses planning-scoped disclosure overrides to remove inherited content indent", async () => { const { loadAllAppCssBaseOnly } = await import("../../test/cssFixture"); const css = loadAllAppCssBaseOnly(); const blockMatch = css.match( /\.planning-advanced-disclosure\s+\.onboarding-disclosure-content\s*\{[^}]*\}/, ); expect(blockMatch).toBeTruthy(); expect(blockMatch![0]).toContain("padding-inline-start: 0;"); expect(blockMatch![0]).toContain("justify-content: center;"); }); it("keeps mobile question view top spacing compact", async () => { const { loadAllAppCss } = await import("../../test/cssFixture"); const css = loadAllAppCss(); const mobileBlocks = getMediaBlocks(css, "@media (max-width: 768px)"); const mobileCss = mobileBlocks.join("\n"); expect(mobileCss).toContain(".planning-question-scroll"); expect(mobileCss).toContain("padding-top: var(--space-sm);"); expect(mobileCss).toContain("gap: var(--space-md);"); }); }); describe("Planning flow", () => { it("starts planning and shows question view", async () => { render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); // Wait for streaming to be called await waitFor(() => { expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, { planningDepth: "medium", customQuestionCount: undefined, }, undefined); }); // Should transition to question view via streaming await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); }); it("shows locked overlay and allows take-control", async () => { window.sessionStorage.setItem("fusion-tab-id", "tab-self"); mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" }); render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByTestId("session-lock-overlay")).toBeDefined(); }); await act(async () => { fireEvent.click(screen.getByText("Take Control")); }); await waitFor(() => { expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-123", "tab-self"); }); await waitFor(() => { expect(screen.queryByTestId("session-lock-overlay")).toBeNull(); }); }); it("allows normal question interaction when lock is acquired", async () => { window.sessionStorage.setItem("fusion-tab-id", "tab-self"); render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.queryByTestId("session-lock-overlay")).toBeNull(); }); await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); fireEvent.click(screen.getByText("Small")); fireEvent.click(screen.getByText("Continue")); await waitFor(() => { expect(mockRespondToPlanning).toHaveBeenCalledWith( "session-123", { "q-scope": "small" }, undefined, "tab-self", ); }); }); it("shows stop action in loading and stops generation", async () => { let streamHandlers: any; const closeSpy = vi.fn(); mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamHandlers = handlers; return { close: closeSpy, isConnected: vi.fn().mockReturnValue(true), }; }); render( ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByRole("button", { name: "Stop" })).toBeDefined(); }); fireEvent.click(screen.getByRole("button", { name: "Stop" })); await waitFor(() => { expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-123", undefined, expect.any(String)); }); expect(closeSpy).toHaveBeenCalled(); await waitFor(() => { expect(screen.getByText("Generation stopped by user. You can retry or start a new session.")).toBeDefined(); }); expect(screen.getByRole("button", { name: "Retry" })).toBeDefined(); // avoid dangling handlers reference lint expect(streamHandlers).toBeDefined(); }); it("shows error message when planning fails", async () => { // Override the default mock to simulate an error mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { setTimeout(() => { handlers.onError?.("Rate limit exceeded"); }, 10); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Rate limit exceeded")).toBeDefined(); }); expect(screen.getByRole("button", { name: "Retry" })).toBeDefined(); }); it("retries from error state and reconnects stream", async () => { let streamAttempt = 0; mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamAttempt += 1; if (streamAttempt === 1) { setTimeout(() => handlers.onError?.("Temporary failure"), 10); } else { setTimeout(() => handlers.onQuestion?.(mockQuestion), 10); } return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Temporary failure")).toBeDefined(); }); fireEvent.click(screen.getByRole("button", { name: "Retry" })); await waitFor(() => { expect(mockRetryPlanningSession).toHaveBeenCalledWith("session-123", undefined, expect.any(String)); }); await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2); }); it("auto-recovers from a stream error when server session is still generating", async () => { let streamAttempt = 0; mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamAttempt += 1; if (streamAttempt === 1) { setTimeout(() => handlers.onError?.("Connection lost"), 10); } return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); mockFetchAiSession.mockResolvedValueOnce({ id: "session-123", type: "planning", status: "generating", title: "Build auth system", inputPayload: JSON.stringify({ initialPlan: "Build auth system" }), conversationHistory: "[]", currentQuestion: null, result: null, thinkingOutput: "Still thinking...", error: null, projectId: null, lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", lockedAt: null, }); render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); // No manual retry button — onError silently re-fetches the session, // sees status="generating", and reconnects without surfacing the error. await waitFor(() => { expect(mockFetchAiSession).toHaveBeenCalledWith("session-123"); expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2); }); expect(screen.queryByText("Connection lost")).toBeNull(); }); it("auto-recovers from a stream error when server session is awaiting input", async () => { let streamAttempt = 0; mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamAttempt += 1; if (streamAttempt === 1) { setTimeout(() => handlers.onError?.("Connection lost"), 10); } return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); mockFetchAiSession.mockResolvedValueOnce({ id: "session-123", type: "planning", status: "awaiting_input", title: "Build auth system", inputPayload: JSON.stringify({ initialPlan: "Build auth system" }), conversationHistory: "[]", currentQuestion: JSON.stringify(mockQuestion), result: null, thinkingOutput: "", error: null, projectId: null, lockedByTab: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", lockedAt: null, }); render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); // Silent recovery: onError re-fetches the session, sees status= // "awaiting_input", and reconnects without surfacing the error. await waitFor(() => { expect(mockFetchAiSession).toHaveBeenCalledWith("session-123"); expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2); }); expect(screen.queryByText("Connection lost")).toBeNull(); }); }); describe("Resuming complete sessions", () => { it("shows summary view when resuming a complete persisted session", async () => { const resumedSummary: PlanningSummary = { title: "Resume-ready planning output", description: "Recovered summary description from persisted session", suggestedSize: "L", suggestedDependencies: ["FN-001"], keyDeliverables: ["Deliverable A", "Deliverable B"], }; mockFetchAiSession.mockResolvedValueOnce({ id: "session-complete-1", type: "planning", status: "complete", title: "Resume-ready planning output", inputPayload: JSON.stringify({ initialPlan: "Build resilient planning resume" }), conversationHistory: "[]", currentQuestion: null, result: JSON.stringify(resumedSummary), thinkingOutput: "", error: null, projectId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); render( ); await waitFor(() => { expect(mockFetchAiSession).toHaveBeenCalledWith("session-complete-1"); }); await waitFor(() => { expect(screen.getByText("Planning Complete!")).toBeDefined(); }); expect(screen.getByDisplayValue("Recovered summary description from persisted session")).toBeDefined(); expect((screen.getByRole("combobox") as HTMLSelectElement).value).toBe("L"); expect(screen.getByText("Deliverable A")).toBeDefined(); expect(screen.getByText("Deliverable B")).toBeDefined(); }); it("restores the textarea and reattaches the draft id when reopening a persisted draft", async () => { const draftPlan = "Persisted draft text the user typed before closing the modal"; mockFetchAiSession.mockResolvedValueOnce({ id: "session-draft-1", type: "planning", status: "draft", title: "New planning session", inputPayload: JSON.stringify({ initialPlan: draftPlan }), conversationHistory: "[]", currentQuestion: null, result: null, thinkingOutput: "", error: null, projectId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); render( , ); await waitFor(() => { expect(mockFetchAiSession).toHaveBeenCalledWith("session-draft-1"); }); // The draft is restored to the editor (initial view), not surfaced as a // question or summary, and the textarea contains exactly the persisted // initialPlan so the user can keep editing or click Start Planning. const textarea = await screen.findByDisplayValue(draftPlan); expect((textarea as HTMLTextAreaElement).tagName).toBe("TEXTAREA"); expect(screen.getByText("Start Planning")).toBeDefined(); }); it("restores the persisted model override when reopening a draft so Start Planning uses it", async () => { // The draft was created under an explicit anthropic/claude-opus model. // Reopening must restore that selection into the modal's local state // so a subsequent Start Planning click uses it instead of silently // falling back to whatever the dropdown currently defaults to. The // server-side round-trip is covered separately in planning.test.ts; // this test pins the React-state restoration. mockFetchAiSession.mockResolvedValueOnce({ id: "session-draft-with-model", type: "planning", status: "draft", title: "New planning session", inputPayload: JSON.stringify({ initialPlan: "Plan that needs a specific model", modelProvider: "anthropic", modelId: "claude-sonnet-4-5", }), conversationHistory: "[]", currentQuestion: null, result: null, thinkingOutput: "", error: null, projectId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); render( , ); // Wait for the textarea to be populated from the draft — proves the // reopen path ran and the modal is in the editable initial view. await screen.findByDisplayValue("Plan that needs a specific model"); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(mockStartPlanningStreaming).toHaveBeenCalledWith( "Plan that needs a specific model", undefined, { planningModelProvider: "anthropic", planningModelId: "claude-sonnet-4-5" }, { planningDepth: "medium", customQuestionCount: undefined }, "session-draft-with-model", ); }); }); it("shows retry panel when resuming an errored session", async () => { mockFetchAiSession.mockResolvedValueOnce({ id: "session-error-1", type: "planning", status: "error", title: "Errored planning", inputPayload: JSON.stringify({ initialPlan: "Recover planning" }), conversationHistory: "[]", currentQuestion: null, result: null, thinkingOutput: "", error: "Session interrupted", projectId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); render( , ); await waitFor(() => { expect(screen.getByText("Session interrupted")).toBeDefined(); }); expect(screen.getByRole("button", { name: "Retry" })).toBeDefined(); }); it("creates a task from a resumed complete session", async () => { const resumedSummary: PlanningSummary = { title: "Resume-to-task", description: "Recovered summary for task creation", suggestedSize: "M", suggestedDependencies: [], keyDeliverables: ["Implement", "Verify"], }; mockFetchAiSession.mockResolvedValueOnce({ id: "session-complete-2", type: "planning", status: "complete", title: "Resume-to-task", inputPayload: JSON.stringify({ initialPlan: "Recover and create" }), conversationHistory: "[]", currentQuestion: null, result: JSON.stringify(resumedSummary), thinkingOutput: "", error: null, projectId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); mockCreateTaskFromPlanning.mockResolvedValueOnce({ id: "FN-100", title: "Resume-to-task", description: "Recovered summary for task creation", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); render( ); await waitFor(() => { expect(screen.getByText("Create Single Task")).toBeDefined(); }); const createSingleTaskButton = screen.getByRole("button", { name: "Create Single Task" }); const breakIntoTasksButton = screen.getByRole("button", { name: "Break into Tasks" }); expect(createSingleTaskButton.className).toContain("btn"); expect(createSingleTaskButton.className).not.toContain("btn-primary"); expect(breakIntoTasksButton.className).toContain("btn-primary"); fireEvent.click(createSingleTaskButton); await waitFor(() => { expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-complete-2", resumedSummary, undefined); }); }); }); describe("Conversation history", () => { it("hides completed-session Q&A by default behind a summary disclosure", async () => { const resumedSummary: PlanningSummary = { title: "Summary with hidden history", description: "Recovered summary description", suggestedSize: "M", suggestedDependencies: [], keyDeliverables: ["Deliverable A"], }; const restoredHistory = [ { question: { id: "q1", type: "single_select", question: "What scope do you need?", options: [ { id: "small", label: "Small" }, { id: "medium", label: "Medium" }, ], }, response: { q1: "medium" }, thinkingOutput: "Reasoning for scope question", }, ]; mockFetchAiSession.mockResolvedValueOnce({ id: "session-complete-with-history", type: "planning", status: "complete", title: resumedSummary.title, inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }), conversationHistory: JSON.stringify(restoredHistory), currentQuestion: null, result: JSON.stringify(resumedSummary), thinkingOutput: "", error: null, projectId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); render( , ); await waitFor(() => { expect(screen.getByText("Planning Complete!")).toBeDefined(); }); expect(screen.getByRole("button", { name: "Show user Q&A" })).toBeDefined(); expect(screen.queryByTestId("conversation-history")).toBeNull(); expect(screen.queryByText("What scope do you need?")).toBeNull(); expect(screen.queryByText("Medium")).toBeNull(); }); it("reveals completed-session Q&A when summary disclosure is expanded", async () => { const resumedSummary: PlanningSummary = { title: "Summary with expandable history", description: "Recovered summary description", suggestedSize: "M", suggestedDependencies: [], keyDeliverables: ["Deliverable A"], }; const restoredHistory = [ { question: { id: "q1", type: "single_select", question: "What scope do you need?", options: [ { id: "small", label: "Small" }, { id: "medium", label: "Medium" }, ], }, response: { q1: "medium" }, thinkingOutput: "Reasoning for scope question", }, ]; mockFetchAiSession.mockResolvedValueOnce({ id: "session-complete-with-history-2", type: "planning", status: "complete", title: resumedSummary.title, inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }), conversationHistory: JSON.stringify(restoredHistory), currentQuestion: null, result: JSON.stringify(resumedSummary), thinkingOutput: "", error: null, projectId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); render( , ); await waitFor(() => { expect(screen.getByText("Planning Complete!")).toBeDefined(); }); fireEvent.click(screen.getByRole("button", { name: "Show user Q&A" })); await waitFor(() => { expect(screen.getByTestId("conversation-history")).toBeDefined(); }); expect(screen.getByText("What scope do you need?")).toBeDefined(); expect(within(screen.getByTestId("conversation-history")).getByText("Medium")).toBeDefined(); }); it("restores all persisted Q&A pairs when resuming a session", async () => { mockConnectPlanningStream.mockImplementationOnce(() => ({ close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), })); const resumedQuestion: PlanningQuestion = { id: "q-current", type: "text", question: "What should we prioritize next?", description: "Current question", }; const restoredHistory = [ { question: { id: "q1", type: "single_select", question: "What scope do you need?", options: [ { id: "small", label: "Small" }, { id: "medium", label: "Medium" }, ], }, response: { q1: "medium" }, thinkingOutput: "Reasoning for scope question", }, { question: { id: "q2", type: "text", question: "List your acceptance criteria", }, response: { q2: "Must support offline mode" }, thinkingOutput: "Reasoning for criteria question", }, ]; mockFetchAiSession.mockResolvedValueOnce({ id: "session-awaiting-1", type: "planning", status: "awaiting_input", title: "Resume with history", inputPayload: JSON.stringify({ initialPlan: "Build planning history restore" }), conversationHistory: JSON.stringify(restoredHistory), currentQuestion: JSON.stringify(resumedQuestion), result: null, thinkingOutput: "", error: null, projectId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); render( , ); await waitFor(() => { expect(mockParseConversationHistory).toHaveBeenCalledWith(JSON.stringify(restoredHistory)); }); await waitFor(() => { expect(screen.getByText("What scope do you need?")).toBeDefined(); }); expect(screen.getByText("List your acceptance criteria")).toBeDefined(); expect(within(screen.getByTestId("conversation-history")).getByText("Medium")).toBeDefined(); expect(screen.getByText("Must support offline mode")).toBeDefined(); expect(screen.getByText("What should we prioritize next?")).toBeDefined(); }); it("starts fresh sessions with empty conversation history", async () => { render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); expect(screen.queryByTestId("conversation-history")).toBeNull(); }); it("appends submitted responses to visible conversation history", async () => { const secondQuestion: PlanningQuestion = { id: "q-requirements", type: "text", question: "What are the key requirements?", description: "Describe the requirements", }; let streamHandlers: any; mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamHandlers = handlers; setTimeout(() => { handlers.onQuestion?.(mockQuestion); }, 10); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); mockRespondToPlanning.mockImplementation(async () => { setTimeout(() => { streamHandlers?.onQuestion?.(secondQuestion); }, 10); return { sessionId: "session-123", currentQuestion: null, summary: null }; }); render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); fireEvent.click(screen.getByText("Medium")); fireEvent.click(screen.getByRole("button", { name: "Continue" })); await waitFor(() => { expect(screen.getByText("What are the key requirements?")).toBeDefined(); }); expect(screen.getByTestId("conversation-history")).toBeDefined(); expect(screen.getByText("What is the scope?")).toBeDefined(); expect(screen.getByText("Medium")).toBeDefined(); }); }); describe("Initial-turn reasoning visibility (FN-3274)", () => { it("preserves reasoning in conversation history when first question arrives after thinking", async () => { let streamHandlers: any; mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamHandlers = handlers; return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); // Wait for loading state to appear await waitFor(() => { expect(screen.getByText("Generating next question...")).toBeDefined(); }); // Simulate thinking output arriving during loading act(() => { streamHandlers.onThinking?.("Analyzing the plan requirements..."); }); await waitFor(() => { expect(screen.getByText("AI is thinking...")).toBeDefined(); }); // Transition to question view act(() => { streamHandlers.onQuestion?.(mockQuestion); }); // Question should be visible await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); // The reasoning should now be in conversation history as an expandable entry expect(screen.getByTestId("conversation-history")).toBeDefined(); expect(screen.getByText("AI Reasoning")).toBeDefined(); fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i })); expect(screen.getByText("Analyzing the plan requirements...")).toBeDefined(); // avoid dangling handlers reference lint expect(streamHandlers).toBeDefined(); }); it("preserves reasoning in conversation history when summary arrives after thinking", async () => { let streamHandlers: any; mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamHandlers = handlers; return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Generating next question...")).toBeDefined(); }); // Simulate thinking output arriving act(() => { streamHandlers.onThinking?.("Finalizing the planning summary..."); }); await waitFor(() => { expect(screen.getByText("AI is thinking...")).toBeDefined(); }); // Transition directly to summary view act(() => { streamHandlers.onSummary?.(mockSummary); }); await waitFor(() => { expect(screen.getByText("Planning Complete!")).toBeDefined(); }); // The reasoning should be visible in the Q&A disclosure fireEvent.click(screen.getByRole("button", { name: "Show user Q&A" })); await waitFor(() => { expect(screen.getByTestId("conversation-history")).toBeDefined(); }); expect(screen.getByText("AI Reasoning")).toBeDefined(); fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i })); expect(screen.getByText("Finalizing the planning summary...")).toBeDefined(); expect(streamHandlers).toBeDefined(); }); it("restores persisted thinkingOutput as conversation history when resuming awaiting_input session", async () => { mockConnectPlanningStream.mockImplementationOnce(() => ({ close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), })); const resumedQuestion: PlanningQuestion = { id: "q-current", type: "text", question: "What should we prioritize next?", }; const restoredHistory = [ { question: { id: "q1", type: "single_select", question: "What scope?", options: [{ id: "small", label: "Small" }], }, response: { q1: "small" }, }, ]; mockFetchAiSession.mockResolvedValueOnce({ id: "session-awaiting-reasoning", type: "planning", status: "awaiting_input", title: "Resume with reasoning", inputPayload: JSON.stringify({ initialPlan: "Build planning with reasoning" }), conversationHistory: JSON.stringify(restoredHistory), currentQuestion: JSON.stringify(resumedQuestion), result: null, thinkingOutput: "Server-side reasoning captured during generation", error: null, projectId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); render( , ); await waitFor(() => { expect(screen.getByText("What should we prioritize next?")).toBeDefined(); }); // The persisted thinkingOutput should appear as a conversation history entry const history = screen.getByTestId("conversation-history"); expect(history).toBeDefined(); // Should show the existing Q&A plus the AI Reasoning entry expect(screen.getByText("What scope?")).toBeDefined(); expect(screen.getByText("AI Reasoning")).toBeDefined(); fireEvent.click(screen.getByRole("button", { name: /Show AI reasoning/i })); expect(screen.getByText("Server-side reasoning captured during generation")).toBeDefined(); }); it("does not create duplicate reasoning entries on repeated transitions", async () => { let streamHandlers: any; mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamHandlers = handlers; return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); const secondQuestion: PlanningQuestion = { id: "q-second", type: "text", question: "Any additional requirements?", }; render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Generating next question...")).toBeDefined(); }); // Emit thinking then question act(() => { streamHandlers.onThinking?.("First reasoning block"); }); act(() => { streamHandlers.onQuestion?.(mockQuestion); }); await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); // Answer the question fireEvent.click(screen.getByText("Medium")); fireEvent.click(screen.getByRole("button", { name: "Continue" })); await waitFor(() => { expect(mockRespondToPlanning).toHaveBeenCalled(); }); // Simulate thinking for second question then emit second question act(() => { streamHandlers.onThinking?.("Second reasoning block"); }); act(() => { streamHandlers.onQuestion?.(secondQuestion); }); await waitFor(() => { expect(screen.getByText("Any additional requirements?")).toBeDefined(); }); // Conversation history should contain both reasoning entries without duplicates const history = screen.getByTestId("conversation-history"); expect(history).toBeDefined(); // Should have Q1, reasoning1, reasoning2 entries const reasoningButtons = screen.getAllByRole("button", { name: /Show AI reasoning/i }); // First reasoning button should be next to Q1, second should be standalone // There should be exactly 2 reasoning entries (not duplicated) expect(reasoningButtons.length).toBe(2); expect(streamHandlers).toBeDefined(); }); it("preserves reasoning when answer submission transitions back to loading then question", async () => { let streamHandlers: any; mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamHandlers = handlers; return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); const secondQuestion: PlanningQuestion = { id: "q-requirements", type: "text", question: "What are the key requirements?", }; mockRespondToPlanning.mockImplementation(async () => { // Simulate thinking then second question via the existing stream setTimeout(() => { streamHandlers?.onThinking?.("Thinking about requirements..."); streamHandlers?.onQuestion?.(secondQuestion); }, 10); return { sessionId: "session-123", currentQuestion: null, summary: null }; }); render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); // Wait for first thinking and question await waitFor(() => { expect(screen.getByText("Generating next question...")).toBeDefined(); }); act(() => { streamHandlers.onThinking?.("Initial analysis..."); }); act(() => { streamHandlers.onQuestion?.(mockQuestion); }); await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); // Answer the first question fireEvent.click(screen.getByText("Medium")); fireEvent.click(screen.getByRole("button", { name: "Continue" })); // Wait for second question to arrive await waitFor(() => { expect(screen.getByText("What are the key requirements?")).toBeDefined(); }, { timeout: 3000 }); // Conversation history should contain the first Q&A pair and initial reasoning const history = screen.getByTestId("conversation-history"); expect(history).toBeDefined(); expect(screen.getByText("What is the scope?")).toBeDefined(); expect(screen.getByText("Medium")).toBeDefined(); expect(streamHandlers).toBeDefined(); }); }); describe("Question view", () => { it("renders single_select question with options", async () => { const { container } = render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Small")).toBeDefined(); expect(screen.getByText("Medium")).toBeDefined(); expect(screen.getByText("Large")).toBeDefined(); }); expect(container.querySelector(".planning-question-form > .planning-view-scroll")).not.toBeNull(); expect(container.querySelector(".planning-question-form > .planning-actions")).not.toBeNull(); }); it("shows comment textarea for single_select questions", async () => { render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); expect(await screen.findByPlaceholderText("Add any extra context or direction...")).toBeInTheDocument(); }); it("does not show comment textarea for text questions", async () => { const textQuestion: PlanningQuestion = { id: "q-text", type: "text", question: "Describe your requirements", }; mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { setTimeout(() => { handlers.onQuestion?.(textQuestion); }, 10); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await screen.findByText("Describe your requirements"); expect(screen.queryByPlaceholderText("Add any extra context or direction...")).not.toBeInTheDocument(); }); it("includes _comment in response when comment is filled", async () => { render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await screen.findByText("What is the scope?"); fireEvent.click(screen.getByText("Medium")); fireEvent.change(screen.getByPlaceholderText("Add any extra context or direction..."), { target: { value: "Prioritize API first" }, }); fireEvent.click(screen.getByRole("button", { name: "Continue" })); await waitFor(() => { expect(mockRespondToPlanning).toHaveBeenCalledWith( "session-123", expect.objectContaining({ "q-scope": "medium", _comment: "Prioritize API first" }), undefined, expect.any(String), ); }); }); it("omits _comment when comment is empty", async () => { render( , ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await screen.findByText("What is the scope?"); fireEvent.click(screen.getByText("Medium")); fireEvent.click(screen.getByRole("button", { name: "Continue" })); await waitFor(() => { expect(mockRespondToPlanning).toHaveBeenCalledWith( "session-123", expect.not.objectContaining({ _comment: expect.anything() }), undefined, expect.any(String), ); }); }); it("shows reconnecting indicator without clearing current question state", async () => { let streamHandlers: any; mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamHandlers = handlers; setTimeout(() => { handlers.onQuestion?.(mockQuestion); }, 10); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); render( , ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); act(() => { streamHandlers.onConnectionStateChange?.("reconnecting"); }); expect(screen.getByText("Reconnecting…")).toBeDefined(); expect(screen.getByText("What is the scope?")).toBeDefined(); act(() => { streamHandlers.onConnectionStateChange?.("connected"); }); await waitFor(() => { expect(screen.queryByText("Reconnecting…")).toBeNull(); }); expect(screen.getByText("What is the scope?")).toBeDefined(); }); it("receives second question after answering first without hanging (race condition fix)", async () => { // Use fake timers to avoid CI flakiness from tiny setTimeout delays in this race-condition scenario. vi.useFakeTimers(); try { const secondQuestion: PlanningQuestion = { id: "q-requirements", type: "text", question: "What are the key requirements?", description: "Describe the requirements", }; // Track how many times connectPlanningStream is called let streamConnectionCount = 0; let streamHandlers: any = null; let deliverSecondQuestion: (() => void) | null = null; mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamConnectionCount++; streamHandlers = handlers; // Emit the first question synchronously on initial connection. if (streamConnectionCount === 1) { handlers.onQuestion?.(mockQuestion); } return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); mockRespondToPlanning.mockImplementation(async () => { return new Promise((resolve) => { deliverSecondQuestion = () => { streamHandlers?.onQuestion?.(secondQuestion); resolve({ sessionId: "session-123", currentQuestion: null, summary: null }); }; }); }); render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); expect(screen.getByText("What is the scope?")).toBeDefined(); // Answer the first question. fireEvent.click(screen.getByText("Medium")); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); const continueButton = screen.getByRole("button", { name: "Continue" }); expect(continueButton.hasAttribute("disabled")).toBe(false); fireEvent.click(continueButton); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); expect(mockRespondToPlanning).toHaveBeenCalledTimes(1); expect(deliverSecondQuestion).not.toBeNull(); act(() => { deliverSecondQuestion?.(); }); await act(async () => { await vi.advanceTimersByTimeAsync(0); }); // Verify second question appears without hanging. expect(screen.getByText("What are the key requirements?")).toBeDefined(); // Verify SSE connection was established only ONCE (not reconnected). // This confirms the race condition fix - the same connection is reused. expect(streamConnectionCount).toBe(1); } finally { vi.useRealTimers(); } }); it("connects to stream when resuming awaiting_input session to receive real-time updates", async () => { // This test verifies the fix for the mismatch where a session was advertised as // needing input but the resume path initially entered loading state. // The modal should connect to the stream for awaiting_input sessions to receive // real-time updates (thinking output, next question, etc.). const resumedQuestion: PlanningQuestion = { id: "q-priority", type: "single_select", question: "What's your priority?", options: [ { id: "speed", label: "Speed" }, { id: "quality", label: "Quality" }, { id: "cost", label: "Cost" }, ], }; mockFetchAiSession.mockResolvedValueOnce({ id: "session-awaiting-stream-1", type: "planning", status: "awaiting_input", title: "Resume with stream", inputPayload: JSON.stringify({ initialPlan: "Build planning with stream" }), conversationHistory: "[]", currentQuestion: JSON.stringify(resumedQuestion), result: null, thinkingOutput: "", error: null, projectId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }); // Track stream connections let streamConnectedSessionId: string | null = null; mockConnectPlanningStream.mockImplementationOnce((sessionId: string, _projectId: string | undefined, _handlers: any) => { streamConnectedSessionId = sessionId; return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); render( , ); // Flush React state updates from the resume effect await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); // Session should be fetched await waitFor(() => { expect(mockFetchAiSession).toHaveBeenCalledWith("session-awaiting-stream-1"); }); // Question should appear immediately from session data await waitFor(() => { expect(screen.getByText("What's your priority?")).toBeDefined(); }); // Modal should connect to the stream for real-time updates await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); // Verify stream connection was established expect(mockConnectPlanningStream).toHaveBeenCalled(); expect(streamConnectedSessionId).toBe("session-awaiting-stream-1"); // Should NOT be stuck in loading state expect(screen.queryByText("Generating next question...")).toBeNull(); }); }); describe("Summary view", () => { it("shows summary when planning is complete", async () => { // Override mock to return summary instead of question mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { setTimeout(() => { handlers.onSummary?.(mockSummary); }, 10); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); const { container } = render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Planning Complete!")).toBeDefined(); }); expect(container.querySelector(".planning-summary > .planning-view-scroll")).not.toBeNull(); expect(container.querySelector(".planning-summary > .planning-actions")).not.toBeNull(); expect(container.querySelector(".planning-summary .planning-deps-list")).not.toBeNull(); }); it("renders and updates summary size dropdown", async () => { mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { setTimeout(() => { handlers.onSummary?.(mockSummary); }, 10); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Planning Complete!")).toBeDefined(); }); const sizeSelect = screen.getByRole("combobox") as HTMLSelectElement; expect(sizeSelect.value).toBe("M"); expect(Array.from(sizeSelect.options).map((option) => option.textContent)).toEqual([ "S (Small)", "M (Medium)", "L (Large)", ]); fireEvent.change(sizeSelect, { target: { value: "L" } }); expect(sizeSelect.value).toBe("L"); }); it("creates task from summary", async () => { const createdTask: Task = { id: "FN-042", title: "Build authentication system", description: "Implement user auth with login and signup", column: "triage", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", }; // Override mock to return summary mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { setTimeout(() => { handlers.onSummary?.(mockSummary); }, 10); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); mockCreateTaskFromPlanning.mockResolvedValue(createdTask); render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Create Single Task")).toBeDefined(); }); fireEvent.click(screen.getByText("Create Single Task")); await waitFor(() => { expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith("session-123", mockSummary, undefined); expect(mockOnTaskCreated).toHaveBeenCalledWith(createdTask); }); }); }); describe("Breakdown view", () => { it("renders and updates subtask size dropdown", async () => { mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { setTimeout(() => { handlers.onSummary?.(mockSummary); }, 10); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [ { id: "subtask-1", title: "Design auth schema", description: "Design the auth data model", suggestedSize: "M", dependsOn: [], }, { id: "subtask-2", title: "Implement auth endpoints", description: "Create login/signup endpoints", suggestedSize: "S", dependsOn: ["subtask-1"], }, ], }); render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Planning Complete!")).toBeDefined(); }); fireEvent.click(screen.getByText("Break into Tasks")); await waitFor(() => { expect(mockStartPlanningBreakdown).toHaveBeenCalledWith("session-123", mockSummary, undefined); }); await waitFor(() => { expect(screen.getByText("Create Tasks")).toBeDefined(); }); const firstSubtask = screen.getByTestId("subtask-item-0"); const sizeSelect = within(firstSubtask).getByRole("combobox") as HTMLSelectElement; expect(sizeSelect.value).toBe("M"); expect(Array.from(sizeSelect.options).map((option) => option.textContent)).toEqual([ "S", "M", "L", ]); fireEvent.change(sizeSelect, { target: { value: "L" } }); expect(sizeSelect.value).toBe("L"); }); }); describe("Modal smoke checks", () => { it("renders TaskDetailModal with the standard detail body structure", () => { const onMoveTask = vi.fn<(_: string, __: any) => Promise>().mockResolvedValue(mockTasks[0]); const onDeleteTask = vi.fn<(_: string) => Promise>().mockResolvedValue(mockTasks[0]); const onMergeTask = vi .fn<(_: string) => Promise>() .mockResolvedValue({ merged: true, branch: "fusion/fn-999", task: mockTasks[0], worktreeRemoved: true, branchDeleted: true }); const { container } = render( ); expect(screen.getByText("Definition")).toBeDefined(); expect(container.querySelector(".detail-body")).not.toBeNull(); }); }); describe("Loading state", () => { it("shows 'Generating next question...' text when loading without streaming content", async () => { // Mock to delay the question response so we stay in loading state mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { // Don't call any handlers - stay in loading state return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); const { container } = render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); // Wait for loading state to appear await waitFor(() => { expect(container.querySelector(".planning-loading")).not.toBeNull(); }); // Should show "Generating next question..." not "Connecting..." expect(screen.getByText("Generating next question...")).toBeDefined(); expect(screen.queryByText("Connecting...")).toBeNull(); }); it("shows thinking container even when streaming output is initially empty", async () => { // Mock to delay the question response so we stay in loading state mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { // Don't call any handlers - stay in loading state return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); const { container } = render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); // Wait for loading state to appear await waitFor(() => { expect(container.querySelector(".planning-loading")).not.toBeNull(); }); // Thinking container should be visible even without streaming content expect(container.querySelector(".planning-thinking-container")).not.toBeNull(); // showThinking defaults to true, so button shows "Hide thinking" expect(screen.getByText("Hide thinking")).toBeDefined(); }); it("shows 'AI is thinking...' text and renders streaming content when it arrives", async () => { let streamHandlers: any = null; mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamHandlers = handlers; return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); const { container } = render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); // Wait for loading state to appear await waitFor(() => { expect(container.querySelector(".planning-loading")).not.toBeNull(); }); // Initially shows "Generating next question..." expect(screen.getByText("Generating next question...")).toBeDefined(); // Simulate streaming content arriving await waitFor(() => { streamHandlers.onThinking?.("Analyzing requirements..."); }); // Now should show "AI is thinking..." await waitFor(() => { expect(screen.getByText("AI is thinking...")).toBeDefined(); }); // The streaming content should be visible (showThinking defaults to true) await waitFor(() => { expect(screen.getByText("Analyzing requirements...")).toBeDefined(); }); // Click "Hide thinking" to hide the output fireEvent.click(screen.getByText("Hide thinking")); // The output should now be hidden expect(screen.queryByText("Analyzing requirements...")).toBeNull(); }); it("shows loading state with appropriate text after submitting a response", async () => { const secondQuestion: PlanningQuestion = { id: "q-requirements", type: "text", question: "What are the key requirements?", description: "Describe the requirements", }; let streamHandlers: any = null; mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => { streamHandlers = handlers; setTimeout(() => { handlers.onQuestion?.(mockQuestion); }, 10); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); mockRespondToPlanning.mockImplementation(async () => { // Simulate server broadcasting second question via the existing SSE connection setTimeout(() => { if (streamHandlers) { streamHandlers.onQuestion?.(secondQuestion); } }, 50); return { sessionId: "session-123", currentQuestion: null, summary: null }; }); const { container } = render( ); const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); fireEvent.change(textarea, { target: { value: "Build auth system" } }); fireEvent.click(screen.getByText("Start Planning")); // Wait for first question await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); // Answer the first question fireEvent.click(screen.getByText("Medium")); fireEvent.click(screen.getByText("Continue")); // Verify loading state appears with correct message await waitFor(() => { expect(container.querySelector(".planning-loading")).not.toBeNull(); expect(screen.getByText("Generating next question...")).toBeDefined(); }); // Verify thinking container is visible during loading expect(container.querySelector(".planning-thinking-container")).not.toBeNull(); // Wait for second question to appear await waitFor(() => { expect(screen.getByText("What are the key requirements?")).toBeDefined(); }, { timeout: 3000 }); }); }); describe("Modal close behavior", () => { it("no confirmation shown when no progress made (initial state)", () => { render( ); // Click X button while still in initial state (no planning started) const closeButton = screen.getByLabelText("Close"); fireEvent.click(closeButton); expect(mockConfirm).not.toHaveBeenCalled(); expect(mockCancelPlanning).not.toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled(); }); it("closes active question session WITHOUT abandoning the server session", async () => { render( ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Close")); expect(mockConfirm).not.toHaveBeenCalled(); // Closing the modal should leave the server session intact so it stays in the sidebar list expect(mockCancelPlanning).not.toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled(); }); it("closes summary view WITHOUT abandoning the server session", async () => { mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { setTimeout(() => { handlers.onSummary?.(mockSummary); }, 10); return { close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), }; }); render( ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Planning Complete!")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Close")); expect(mockConfirm).not.toHaveBeenCalled(); // Completed sessions remain available to resume; closing must not cancel them expect(mockCancelPlanning).not.toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled(); }); it("closes via overlay WITHOUT abandoning the server session", async () => { const { container } = render( ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); const overlay = container.querySelector(".modal-overlay"); expect(overlay).not.toBeNull(); // Simulate a real overlay click — both mousedown and click must originate // on the overlay, otherwise the dismissal guard suppresses close. fireEvent.mouseDown(overlay!); fireEvent.click(overlay!); expect(mockConfirm).not.toHaveBeenCalled(); // Sessions persist in the sidebar; overlay click should not cancel expect(mockCancelPlanning).not.toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled(); }); it("closes during loading state WITHOUT abandoning the server session", async () => { mockConnectPlanningStream.mockImplementationOnce(() => ({ close: vi.fn(), isConnected: vi.fn().mockReturnValue(true), })); render( ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Generating next question...")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Close")); expect(mockConfirm).not.toHaveBeenCalled(); // Loading state means the session is still being generated server-side; preserve it expect(mockCancelPlanning).not.toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled(); }); it("close (X) drops the local stream but preserves the server session", async () => { // The "Send to background" button was removed — closing the modal now // has the same semantics: tear down the SSE stream, keep the persisted // session alive so the user can reopen and resume it. const closeSpy = vi.fn(); mockConnectPlanningStream.mockImplementationOnce(() => ({ close: closeSpy, isConnected: vi.fn().mockReturnValue(true), })); render( ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Generating next question...")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Close")); expect(closeSpy).toHaveBeenCalledTimes(1); expect(mockCancelPlanning).not.toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalledTimes(1); }); it("disconnects the SSE stream on close (but keeps the server session)", async () => { const closeSpy = vi.fn(); mockConnectPlanningStream.mockImplementationOnce(() => ({ close: closeSpy, isConnected: vi.fn().mockReturnValue(true), })); render( ); fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), { target: { value: "Build auth system" }, }); fireEvent.click(screen.getByText("Start Planning")); await waitFor(() => { expect(screen.getByText("Generating next question...")).toBeDefined(); }); fireEvent.click(screen.getByLabelText("Close")); expect(closeSpy).toHaveBeenCalledTimes(1); // The local SSE stream closes on modal close, but the server session is preserved // for later resume from the sidebar list. expect(mockCancelPlanning).not.toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled(); }); }); describe("Mobile empty-session routing (FN-3269)", () => { it("shows detail pane on mobile when session list is empty", async () => { mockViewport("mobile"); mockFetchAiSessions.mockResolvedValue([]); const { container } = render( , ); await waitFor(() => { expect(mockFetchAiSessions).toHaveBeenCalled(); }); // Wait for the session list to load and the routing effect to fire await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); // The modal body should show detail pane (composer), not list pane const body = container.querySelector(".planning-modal-body"); expect(body?.classList.contains("planning-modal-body--show-detail")).toBe(true); expect(body?.classList.contains("planning-modal-body--show-list")).toBe(false); // The composer textarea should be visible expect(screen.getByPlaceholderText(/e.g., Build a user authentication/)).toBeDefined(); }); it("stays on list pane on mobile when sessions exist", async () => { mockViewport("mobile"); mockFetchAiSessions.mockResolvedValue([ { id: "session-existing", type: "planning", status: "complete", title: "Existing session", preview: "An existing planning session", projectId: null, lockedByTab: null, updatedAt: new Date().toISOString(), archived: false, }, ]); const { container } = render( , ); await waitFor(() => { expect(mockFetchAiSessions).toHaveBeenCalled(); }); // Wait one more tick to let state updates settle await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); // The modal body should show list pane (sidebar), not detail pane const body = container.querySelector(".planning-modal-body"); expect(body?.classList.contains("planning-modal-body--show-list")).toBe(true); expect(body?.classList.contains("planning-modal-body--show-detail")).toBe(false); }); it("does not auto-show detail pane on desktop with empty sessions", async () => { mockViewport("desktop"); mockFetchAiSessions.mockResolvedValue([]); const { container } = render( , ); await waitFor(() => { expect(mockFetchAiSessions).toHaveBeenCalled(); }); // Wait one more tick to let state updates settle await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); // Desktop shows both panes in split view regardless of mobileShowDetail. // Both sidebar and detail pane should be present in the DOM. expect(container.querySelector(".planning-sidebar")).not.toBeNull(); expect(container.querySelector(".planning-detail")).not.toBeNull(); // The mobile-only back button should NOT be visible (it's gated on mobileShowDetail, // which stays false on desktop since the routing effect skips non-mobile viewports). expect(container.querySelector(".planning-mobile-back")).toBeNull(); }); }); describe("Model favorites persistence", () => { it("persists provider favorite toggle to global settings", async () => { mockFetchModels.mockResolvedValue({ models: mockModels, favoriteProviders: ["anthropic"], favoriteModels: [], }); vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any); render( , ); await waitFor(() => { expect(mockFetchModels).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" })); fireEvent.click(screen.getByRole("button", { name: "Planning Model" })); await waitFor(() => { expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull(); }); const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement; // When provider is favorited, the optgroup header shows "Remove" button const removeButton = within(portal).queryByRole("button", { name: "Remove anthropic from favorites" }); expect(removeButton).not.toBeNull(); fireEvent.click(removeButton!); expect(api.updateGlobalSettings).toHaveBeenCalledWith({ favoriteProviders: [], favoriteModels: [], }); }); it("persists model favorite toggle to global settings", async () => { mockFetchModels.mockResolvedValue({ models: mockModels, favoriteProviders: [], favoriteModels: ["anthropic/claude-sonnet-4-5"], }); vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any); render( , ); await waitFor(() => { expect(mockFetchModels).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" })); fireEvent.click(screen.getByRole("button", { name: "Planning Model" })); await waitFor(() => { expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull(); }); const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement; // When model is favorited, it appears as a pinned row with "Remove" button // There may be duplicates (in pinned row + provider group), use first one const removeButtons = within(portal).queryAllByRole("button", { name: "Remove Claude Sonnet 4.5 from favorites" }); expect(removeButtons.length).toBeGreaterThan(0); fireEvent.click(removeButtons[0]); expect(api.updateGlobalSettings).toHaveBeenCalledWith({ favoriteProviders: [], favoriteModels: [], }); }); it("adds provider to favorites", async () => { mockFetchModels.mockResolvedValue({ models: mockModels, favoriteProviders: [], favoriteModels: [], }); vi.mocked(api.updateGlobalSettings).mockResolvedValue({} as any); render( , ); await waitFor(() => { expect(mockFetchModels).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" })); fireEvent.click(screen.getByRole("button", { name: "Planning Model" })); await waitFor(() => { expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull(); }); const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement; const addButton = within(portal).getByRole("button", { name: "Add anthropic to favorites" }); fireEvent.click(addButton); expect(api.updateGlobalSettings).toHaveBeenCalledWith({ favoriteProviders: ["anthropic"], favoriteModels: [], }); }); it("rolls back local favorite state when updateGlobalSettings fails", async () => { mockFetchModels.mockResolvedValue({ models: mockModels, favoriteProviders: ["anthropic"], favoriteModels: [], }); vi.mocked(api.updateGlobalSettings).mockRejectedValueOnce(new Error("Network error")); render( , ); await waitFor(() => { expect(mockFetchModels).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" })); fireEvent.click(screen.getByRole("button", { name: "Planning Model" })); await waitFor(() => { expect(document.body.querySelector('[data-testid="model-combobox-portal"]')).not.toBeNull(); }); const portal = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement; const removeButton = within(portal).getByRole("button", { name: "Remove anthropic from favorites" }); fireEvent.click(removeButton); // Wait for the rejected promise to settle await waitFor(() => { expect(api.updateGlobalSettings).toHaveBeenCalled(); }); // After rollback, the provider should still show as favorited (★ button with "Remove" aria-label) const portalAfterRollback = document.body.querySelector('[data-testid="model-combobox-portal"]') as HTMLElement; expect(within(portalAfterRollback).getByRole("button", { name: "Remove anthropic from favorites" })).toBeTruthy(); }); }); }); describe("getSessionTabId", () => { it("creates and persists a per-tab id in sessionStorage", () => { window.sessionStorage.clear(); const first = getSessionTabId(); const second = getSessionTabId(); expect(first).toBeTruthy(); expect(second).toBe(first); expect(window.sessionStorage.getItem("fusion-tab-id")).toBe(first); }); }); describe("useSessionLock", () => { beforeEach(() => { MockEventSource.reset(); vi.stubGlobal("EventSource", MockEventSource as any); window.sessionStorage.clear(); mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null }); mockReleaseSessionLock.mockResolvedValue(undefined); mockForceAcquireSessionLock.mockResolvedValue(undefined); }); it("acquires on mount and releases on unmount", async () => { window.sessionStorage.setItem("fusion-tab-id", "tab-self"); const { unmount } = renderHook(() => useSessionLock("session-1")); await waitFor(() => { expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-1", "tab-self"); }); unmount(); await waitFor(() => { expect(mockReleaseSessionLock).toHaveBeenCalledWith("session-1", "tab-self"); }); }); it("exposes locked state and allows taking control", async () => { window.sessionStorage.setItem("fusion-tab-id", "tab-self"); mockAcquireSessionLock.mockResolvedValueOnce({ acquired: false, currentHolder: "tab-other" }); const { result } = renderHook(() => useSessionLock("session-2")); await waitFor(() => { expect(result.current.isLockedByOther).toBe(true); expect(result.current.currentHolder).toBe("tab-other"); }); await act(async () => { await result.current.takeControl(); }); expect(mockForceAcquireSessionLock).toHaveBeenCalledWith("session-2", "tab-self"); expect(result.current.isLockedByOther).toBe(false); expect(result.current.currentHolder).toBeNull(); }); it("updates lock state from ai_session:updated SSE events and uses sendBeacon on beforeunload", async () => { window.sessionStorage.setItem("fusion-tab-id", "tab-self"); const sendBeaconSpy = vi.fn(() => true); vi.stubGlobal("navigator", { ...window.navigator, sendBeacon: sendBeaconSpy, } as Navigator); const { result } = renderHook(() => useSessionLock("session-3")); await waitFor(() => { expect(mockAcquireSessionLock).toHaveBeenCalledWith("session-3", "tab-self"); }); const source = MockEventSource.instances[0]; expect(source).toBeDefined(); act(() => { source?.emit("ai_session:updated", { id: "session-3", type: "planning", status: "awaiting_input", title: "Session", projectId: null, lockedByTab: "tab-other", updatedAt: new Date().toISOString(), }); }); expect(result.current.isLockedByOther).toBe(true); expect(result.current.currentHolder).toBe("tab-other"); act(() => { source?.emit("ai_session:updated", { id: "session-3", type: "planning", status: "awaiting_input", title: "Session", projectId: null, lockedByTab: "tab-self", updatedAt: new Date().toISOString(), }); }); expect(result.current.isLockedByOther).toBe(false); act(() => { window.dispatchEvent(new Event("beforeunload")); }); expect(sendBeaconSpy).toHaveBeenCalledWith( "/api/ai-sessions/session-3/lock/beacon?tabId=tab-self", ); }); describe("Mobile keyboard behavior (FN-3337)", () => { beforeEach(() => { mockUseViewportMode.mockReturnValue("desktop"); mockUseMobileKeyboard.mockReturnValue({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false, }); }); it("applies keyboard CSS variables when keyboard is open on mobile", () => { mockUseViewportMode.mockReturnValue("mobile"); mockUseMobileKeyboard.mockReturnValue({ keyboardOverlap: 300, viewportHeight: 400, viewportOffsetTop: 50, keyboardOpen: true, }); render( , ); const modal = screen.getByRole("dialog").querySelector(".planning-modal"); expect(modal).toBeTruthy(); expect(modal!.getAttribute("style")).toContain("--keyboard-overlap"); expect(modal!.getAttribute("style")).toContain("--vv-height"); expect(modal!.getAttribute("style")).toContain("--vv-offset-top"); }); it("does not apply keyboard CSS variables when keyboard is closed", () => { mockUseViewportMode.mockReturnValue("mobile"); mockUseMobileKeyboard.mockReturnValue({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false, }); render( , ); const modal = screen.getByRole("dialog").querySelector(".planning-modal"); expect(modal).toBeTruthy(); expect(modal!.getAttribute("style")).toBeNull(); }); it("does not apply keyboard CSS variables on desktop", () => { mockUseViewportMode.mockReturnValue("desktop"); mockUseMobileKeyboard.mockReturnValue({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false, }); render( , ); const modal = screen.getByRole("dialog").querySelector(".planning-modal"); expect(modal).toBeTruthy(); expect(modal!.getAttribute("style")).toBeNull(); }); }); });