This merge includes three features: FN-3337 fixes mobile keyboard handling in the PlanningModeModal by wiring in `useMobileKeyboard` and adding keyboard-aware CSS sizing, with regression tests; FN-3336 recovers the `isGenerating` chat state on session reload to prevent UI flickering; and FN-3338 fix Fusion-Task-Id: FN-3337
3441 lines
116 KiB
TypeScript
3441 lines
116 KiB
TypeScript
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<string, Set<(event: MessageEvent) => 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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={false}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByText("Planning Mode")).toBeNull();
|
|
});
|
|
|
|
it("hides send to background button in initial state", () => {
|
|
render(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
expect(screen.queryByLabelText("Send to background")).toBeNull();
|
|
});
|
|
|
|
it("enables start button when text is entered", () => {
|
|
render(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText(/Build a user authentication/)).toBeDefined();
|
|
});
|
|
|
|
it("renders planning model dropdown in initial view", async () => {
|
|
render(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
initialPlan="Build a login system from new task dialog"
|
|
/>
|
|
);
|
|
|
|
// 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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
initialPlan="Pre-filled plan from new task"
|
|
/>
|
|
);
|
|
|
|
// 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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
resumeSessionId="session-complete-1"
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
resumeSessionId="session-draft-1"
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
resumeSessionId="session-draft-with-model"
|
|
/>,
|
|
);
|
|
|
|
// 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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
resumeSessionId="session-error-1"
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
resumeSessionId="session-complete-2"
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
resumeSessionId="session-complete-with-history"
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
resumeSessionId="session-complete-with-history-2"
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
resumeSessionId="session-awaiting-1"
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
resumeSessionId="session-awaiting-reasoning"
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
resumeSessionId="session-awaiting-stream-1"
|
|
/>,
|
|
);
|
|
|
|
// 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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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<Task>>().mockResolvedValue(mockTasks[0]);
|
|
const onDeleteTask = vi.fn<(_: string) => Promise<Task>>().mockResolvedValue(mockTasks[0]);
|
|
const onMergeTask = vi
|
|
.fn<(_: string) => Promise<MergeResult>>()
|
|
.mockResolvedValue({ merged: true, branch: "fusion/fn-999", task: mockTasks[0], worktreeRemoved: true, branchDeleted: true });
|
|
|
|
const { container } = render(
|
|
<TaskDetailModal
|
|
task={mockTaskDetail}
|
|
tasks={mockTasks}
|
|
onClose={mockOnClose}
|
|
onOpenDetail={vi.fn()}
|
|
onMoveTask={onMoveTask}
|
|
onDeleteTask={onDeleteTask}
|
|
onMergeTask={onMergeTask}
|
|
addToast={vi.fn()}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
// 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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={mockOnClose}
|
|
onTaskCreated={mockOnTaskCreated}
|
|
onTasksCreated={vi.fn()}
|
|
tasks={mockTasks}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<PlanningModeModal
|
|
isOpen={true}
|
|
onClose={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const modal = screen.getByRole("dialog").querySelector(".planning-modal");
|
|
expect(modal).toBeTruthy();
|
|
expect(modal!.getAttribute("style")).toBeNull();
|
|
});
|
|
});
|
|
|
|
});
|