FN-5882: fix mission interview draft resume handling

Keep mission interview drafts recoverable when closing and refreshing Missions.

- make MissionInterviewModal use a single non-destructive close path for header, Escape, and backdrop actions
- refresh mission interview draft/session rows on mission and ai-session SSE updates and reconnects
- open Plan New Mission as a fresh interview instead of always resuming the last session, with regression coverage and docs updates

Files changed:
 docs/missions.md                                   |   2 +-
 .../app/components/MissionInterviewModal.tsx       |  89 +++-----
 .../dashboard/app/components/MissionManager.tsx    | 156 ++++++++++----
 .../__tests__/MissionInterviewModal.test.tsx       | 100 ++++++++-
 .../components/__tests__/MissionManager.test.tsx   | 224 +++++++++++++++++++++
 5 files changed, 455 insertions(+), 116 deletions(-)

Fusion-Task-Id: FN-5882

Fusion-Task-Lineage: 75de9e94-edef-4564-a266-91782e60c2ac
This commit is contained in:
gsxdsm
2026-06-02 10:04:28 -07:00
parent 9e00a72d7d
commit cc5e61dbc4
5 changed files with 459 additions and 120 deletions

View File

@@ -5,7 +5,6 @@ import {
startMissionInterview,
respondToMissionInterview,
retryMissionInterviewSession,
cancelMissionInterview,
createMissionFromInterview,
connectMissionInterviewStream,
fetchAiSession,
@@ -40,7 +39,6 @@ import {
Box,
Plus,
Trash2,
Minimize2,
RefreshCw,
Lock,
} from "lucide-react";
@@ -48,7 +46,6 @@ import { ConversationHistory } from "./ConversationHistory";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { getSessionTabId } from "../utils/getSessionTabId";
import "./MissionInterviewModal.css";
@@ -116,8 +113,9 @@ export function MissionInterviewModal({
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
const [editedSummary, setEditedSummary] = useState<MissionPlanSummary | null>(null);
const [hasProgress, setHasProgress] = useState(false);
const [_hasProgress, setHasProgress] = useState(false);
const hasAutoStartedRef = useRef(false);
const overlayMouseDownOnSelfRef = useRef(false);
const [streamingOutput, setStreamingOutput] = useState("");
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
@@ -142,7 +140,6 @@ export function MissionInterviewModal({
broadcastUnlock,
broadcastHeartbeat,
} = useAiSessionSync();
const { confirm } = useConfirm();
// Model selection state
const [modelProvider, setModelProvider] = useState<string | undefined>(undefined);
@@ -518,57 +515,19 @@ export function MissionInterviewModal({
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isOpen, view]);
const handleSendToBackground = useCallback(() => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
onClose();
}, [onClose]);
const handleCancel = useCallback(async () => {
// Save to localStorage BEFORE any cleanup
if (missionGoal) {
const handleClose = useCallback(() => {
if (missionGoal && view.type === "initial") {
saveMissionGoal(missionGoal, projectId);
}
if (hasProgress) {
const shouldClose = await confirm({
title: "Close Interview",
message: "Are you sure you want to close? Your interview progress will be lost.",
danger: true,
});
if (!shouldClose) {
return;
}
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
if (view.type === "question" || view.type === "summary" || view.type === "error") {
try {
await cancelMissionInterview(view.sessionId, projectId, sessionTabId);
} catch {
// Ignore errors on cancel
}
}
setMissionGoal("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setStreamingOutput("");
overlayMouseDownOnSelfRef.current = false;
setIsReconnecting(false);
setIsRetrying(false);
setHasProgress(false);
setIsCreating(false);
setModelProvider(undefined);
setModelId(undefined);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
}, [missionGoal, hasProgress, view, onClose, projectId, sessionTabId, confirm]);
}, [missionGoal, onClose, projectId, view.type]);
// Escape key handler
useEffect(() => {
@@ -576,13 +535,13 @@ export function MissionInterviewModal({
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
void handleCancel();
handleClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleCancel]);
}, [isOpen, handleClose]);
const handleSubmitResponse = useCallback(
async (responses: QuestionResponse) => {
@@ -748,9 +707,6 @@ export function MissionInterviewModal({
return 6;
};
const showSendToBackgroundButton =
view.type === "loading" || view.type === "question" || view.type === "summary" || view.type === "error";
const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null;
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale);
@@ -759,7 +715,20 @@ export function MissionInterviewModal({
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && handleCancel()} role="dialog" aria-modal="true">
<div
className="modal-overlay open"
onMouseDown={(e) => {
overlayMouseDownOnSelfRef.current = e.target === e.currentTarget;
}}
onClick={(e) => {
if (e.target === e.currentTarget && overlayMouseDownOnSelfRef.current) {
handleClose();
}
overlayMouseDownOnSelfRef.current = false;
}}
role="dialog"
aria-modal="true"
>
<div className="modal modal-lg planning-modal">
<div className="modal-header">
<div className="detail-title-row">
@@ -767,17 +736,7 @@ export function MissionInterviewModal({
<h3>Plan Mission with AI</h3>
</div>
<div className="modal-header-actions">
{showSendToBackgroundButton && (
<button
className="modal-send-to-background"
onClick={handleSendToBackground}
title="Send to background"
aria-label="Send to background"
>
<Minimize2 size={16} />
</button>
)}
<button className="modal-close" onClick={handleCancel} aria-label="Close">
<button className="modal-close" onClick={handleClose} aria-label="Close">
<X size={20} />
</button>
</div>
@@ -960,7 +919,7 @@ export function MissionInterviewModal({
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
<span className="icon-ml-6">{isRetrying ? "Retrying..." : "Retry"}</span>
</button>
<button className="btn" onClick={handleCancel} disabled={isRetrying}>Cancel</button>
<button className="btn" onClick={handleClose} disabled={isRetrying}>Dismiss</button>
</div>
</div>
</div>

View File

@@ -713,6 +713,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
// AI Interview modal
const [showInterviewModal, setShowInterviewModal] = useState(false);
const [interviewLaunchMode, setInterviewLaunchMode] = useState<"new" | "resume">("new");
const [interviewModalKey, setInterviewModalKey] = useState(0);
// Pending mission interview sessions (for resume prompt after page reload)
const [_pendingInterviewSessions, setPendingInterviewSessions] = useState<AiSessionSummary[]>([]);
@@ -741,6 +743,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
// Auto-open interview modal when resuming a session
useEffect(() => {
if (isActive && effectiveResumeSessionId) {
setInterviewLaunchMode("resume");
setShowInterviewModal(true);
}
}, [isActive, effectiveResumeSessionId]);
@@ -752,46 +755,39 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [resumeSessionId]);
const loadPendingInterviewSessions = useCallback(async () => {
const sessions = await fetchAiSessions(projectId);
const pending = sessions.filter((s) => {
if (s.type !== "mission_interview" || !missionInterviewListStatuses.has(s.status)) {
return false;
}
if (projectId) {
return s.projectId === projectId;
}
return s.projectId == null;
});
setPendingInterviewSessions(pending);
}, [projectId]);
const loadMissionInterviewDraftRows = useCallback(async () => {
const drafts = await fetchMissionInterviewDrafts(projectId);
setMissionInterviewDrafts(drafts);
}, [projectId]);
const refreshMissionSidebar = useCallback(() => {
void loadPendingInterviewSessions().catch((err) => {
console.warn("[MissionManager] Failed to fetch pending interview sessions:", err);
});
void loadMissionInterviewDraftRows().catch((err) => {
console.warn("[MissionManager] Failed to fetch mission interview drafts:", err);
});
}, [loadMissionInterviewDraftRows, loadPendingInterviewSessions]);
// Detect pending mission interview sessions for resume prompt
useEffect(() => {
if (!isActive) return;
let cancelled = false;
fetchAiSessions(projectId).then((sessions) => {
if (cancelled) return;
const pending = sessions.filter((s) => {
if (s.type !== "mission_interview" || !missionInterviewListStatuses.has(s.status)) {
return false;
}
if (projectId) {
return s.projectId === projectId;
}
return s.projectId == null;
});
setPendingInterviewSessions(pending);
}).catch((err) => {
console.warn("[MissionManager] Failed to fetch pending interview sessions:", err);
});
return () => { cancelled = true; };
}, [isActive, projectId, effectiveResumeSessionId]);
useEffect(() => {
if (!isActive) return;
let cancelled = false;
fetchMissionInterviewDrafts(projectId)
.then((drafts) => {
if (!cancelled) {
setMissionInterviewDrafts(drafts);
}
})
.catch((err) => {
console.warn("[MissionManager] Failed to fetch mission interview drafts:", err);
});
return () => {
cancelled = true;
};
}, [isActive, projectId, effectiveResumeSessionId]);
refreshMissionSidebar();
}, [effectiveResumeSessionId, isActive, refreshMissionSidebar]);
// Auto-open milestone/slice interview modal when resuming from background session
useEffect(() => {
@@ -1318,12 +1314,38 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}
void loadMissions();
refreshMissionSidebar();
// Reload the selected mission detail to reflect updated mission state (autopilot, status, etc.)
if (selectedMissionRef.current) {
void loadMissionDetail(selectedMissionRef.current.id);
}
};
const handleMissionCreated = () => {
refreshHealth();
void loadMissions();
refreshMissionSidebar();
};
const handleMissionDeleted = (rawEvent: Event) => {
refreshHealth();
const messageEvent = rawEvent as MessageEvent<string>;
if (messageEvent.data) {
try {
const deletedMissionId = JSON.parse(messageEvent.data) as string;
if (deletedMissionId && selectedMissionRef.current?.id === deletedMissionId) {
setSelectedMission(null);
}
} catch {
// ignore invalid payloads
}
}
void loadMissions();
refreshMissionSidebar();
};
const handleSliceUpdated = (_rawEvent: Event) => {
refreshHealth();
// Reload the selected mission detail to reflect updated slice status
@@ -1348,6 +1370,28 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
};
const handleAiSessionUpdated = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) {
return;
}
try {
const updatedSession = JSON.parse(messageEvent.data) as AiSessionSummary;
if (updatedSession.type !== "mission_interview") {
return;
}
} catch {
return;
}
void loadMissions();
refreshMissionSidebar();
};
const handleAiSessionDeleted = () => {
void loadMissions();
refreshMissionSidebar();
};
// Handler for validator run started - refresh feature loop state and validation runs
const handleValidatorRunStarted = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
@@ -1501,11 +1545,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
return subscribeSse(eventUrl, {
events: {
"mission:created": handleMissionCreated,
"mission:updated": handleMissionUpdated,
"mission:deleted": handleMissionDeleted,
"slice:updated": handleSliceUpdated,
"feature:updated": handleFeatureUpdated,
"milestone:updated": handleMilestoneUpdated,
"mission:event": handleMissionEvent,
"ai_session:updated": handleAiSessionUpdated,
"ai_session:deleted": handleAiSessionDeleted,
"validator-run:started": handleValidatorRunStarted,
"validator-run:completed": handleValidatorRunCompleted,
"milestone:validation:updated": handleMilestoneValidationUpdated,
@@ -1516,13 +1564,19 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
"assertion:unlinked": handleAssertionMutation,
"fix-feature:created": handleFixFeatureCreated,
},
onReconnect: () => {
void loadMissions();
refreshMissionSidebar();
},
});
}, [
isActive,
isActivityScrolledNearBottom,
loadMissionDetail,
loadMissionHealth,
loadMissions,
projectId,
refreshMissionSidebar,
refreshValidationTelemetry,
]);
@@ -3992,13 +4046,31 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
);
};
const handleResumeInterviewSession = (sessionId: string) => {
setLocalResumeSessionId(sessionId);
const openNewMissionInterview = () => {
if (resumeSessionId) {
dismissedResumeSessionIdRef.current = resumeSessionId;
}
setInterviewLaunchMode("new");
setLocalResumeSessionId(undefined);
setInterviewModalKey((current) => current + 1);
setShowInterviewModal(true);
};
const handleResumeInterviewSession = (sessionId: string) => {
setInterviewLaunchMode("resume");
setLocalResumeSessionId(sessionId);
setInterviewModalKey((current) => current + 1);
setShowInterviewModal(true);
};
const shouldRenderSidebarDeleteConfirm =
deleteConfirmId != null &&
(deleteConfirmId.type === "interview_draft" ||
(deleteConfirmId.type === "mission" && selectedMission?.id !== deleteConfirmId.id));
const handleInterviewModalClose = () => {
dismissedResumeSessionIdRef.current = effectiveResumeSessionId ?? null;
setInterviewLaunchMode("new");
setLocalResumeSessionId(undefined);
setShowInterviewModal(false);
};
@@ -4360,7 +4432,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<div className="mission-list__top-action">
<button
className="btn btn-sm btn-task-create mission-list__primary-cta"
onClick={() => setShowInterviewModal(true)}
onClick={openNewMissionInterview}
>
<Sparkles size={14} />
Plan New Mission
@@ -4493,7 +4565,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</p>
<button
className="btn btn-sm btn-primary mission-manager__empty-cta"
onClick={() => setShowInterviewModal(true)}
onClick={openNewMissionInterview}
>
<Sparkles size={14} />
Plan New Mission
@@ -4505,7 +4577,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<div className="mission-list__footer">
{showBottomPlanButton && (
<div className="mission-list__footer-actions">
<button className="mission-add-btn" onClick={() => setShowInterviewModal(true)}>
<button className="mission-add-btn" onClick={openNewMissionInterview}>
<Sparkles size={16} />
Plan New Mission
</button>
@@ -4678,9 +4750,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
)}
</div>
<div className="mission-manager__sidebar-footer" data-testid="mission-sidebar-footer">
{shouldRenderSidebarDeleteConfirm && renderDeleteConfirmPanel()}
<button
className="btn btn-primary mission-manager__sidebar-cta"
onClick={() => setShowInterviewModal(true)}
onClick={openNewMissionInterview}
title="Plan New Mission"
aria-label="Plan New Mission"
>
@@ -4719,7 +4792,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<span>Select a mission to view details</span>
</div>
)}
{deleteConfirmId && renderDeleteConfirmPanel()}
{deleteConfirmId && !shouldRenderSidebarDeleteConfirm && renderDeleteConfirmPanel()}
{linkTaskFeatureId && renderLinkTaskPanel()}
</div>
</div>
@@ -4729,6 +4802,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const interviewModal = (
<MissionInterviewModal
key={interviewModalKey}
isOpen={showInterviewModal}
onClose={handleInterviewModalClose}
onMissionCreated={() => {
@@ -4736,7 +4810,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
addToast("Mission created from AI interview", "success");
}}
projectId={projectId}
resumeSessionId={effectiveResumeSessionId}
resumeSessionId={interviewLaunchMode === "resume" ? effectiveResumeSessionId : undefined}
/>
);

View File

@@ -1,3 +1,4 @@
import type React from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MissionInterviewModal } from "../MissionInterviewModal";
@@ -31,9 +32,10 @@ vi.mock("../../api", () => ({
}));
const mockGetMissionGoal = vi.fn(() => "");
const mockSaveMissionGoal = vi.fn();
vi.mock("../../hooks/modalPersistence", () => ({
saveMissionGoal: vi.fn(),
saveMissionGoal: (...args: any[]) => mockSaveMissionGoal(...args),
getMissionGoal: (...args: any[]) => mockGetMissionGoal(...args),
clearMissionGoal: vi.fn(),
}));
@@ -59,6 +61,7 @@ describe("MissionInterviewModal", () => {
mockStartMissionInterview.mockResolvedValue({ sessionId: "mission-session-1" });
mockRetryMissionInterviewSession.mockResolvedValue({ success: true, sessionId: "mission-session-1" });
mockFetchAiSession.mockResolvedValue(null);
mockSaveMissionGoal.mockReset();
mockParseConversationHistory.mockImplementation((raw: string) => {
if (!raw) return [];
try {
@@ -81,14 +84,20 @@ describe("MissionInterviewModal", () => {
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
});
function renderModal() {
return render(
<MissionInterviewModal
isOpen={true}
onClose={vi.fn()}
onMissionCreated={vi.fn()}
/>,
);
function renderModal(props: Partial<React.ComponentProps<typeof MissionInterviewModal>> = {}) {
const onClose = props.onClose ?? vi.fn();
return {
onClose,
...render(
<MissionInterviewModal
isOpen={true}
onClose={onClose}
onMissionCreated={vi.fn()}
{...props}
/>,
),
};
}
it("shows lock overlay and allows take-control", async () => {
@@ -336,6 +345,79 @@ describe("MissionInterviewModal", () => {
expect(textarea).toHaveValue("Previous mission goal");
});
it("closes without cancelling an in-progress interview and renders only one close button", async () => {
const { onClose } = renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(streamHandlers).toBeDefined();
});
act(() => {
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
});
const closeButtons = screen.getAllByRole("button", { name: "Close" });
expect(closeButtons).toHaveLength(1);
expect(screen.queryByRole("button", { name: "Send to background" })).not.toBeInTheDocument();
fireEvent.click(closeButtons[0]);
expect(onClose).toHaveBeenCalledTimes(1);
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
});
it("persists the draft goal and closes from the initial view", () => {
const { onClose } = renderModal({ projectId: "proj-1" });
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Draft mission goal" },
});
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(mockSaveMissionGoal).toHaveBeenCalledWith("Draft mission goal", "proj-1");
expect(onClose).toHaveBeenCalledTimes(1);
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
});
it("closes without cancelling when pressing Escape", async () => {
const { onClose } = renderModal();
fireEvent.change(screen.getByLabelText("What do you want to build?"), {
target: { value: "Build a mission planning workflow" },
});
fireEvent.click(screen.getByText("Start Interview"));
await waitFor(() => {
expect(streamHandlers).toBeDefined();
});
act(() => {
streamHandlers.onQuestion?.(SAMPLE_QUESTION);
});
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
});
it("closes from the backdrop after overlay mousedown", () => {
const { onClose } = renderModal();
const overlay = screen.getByRole("dialog");
fireEvent.mouseDown(overlay);
fireEvent.click(overlay);
expect(onClose).toHaveBeenCalledTimes(1);
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
});
it("allows typing in textarea without resetting to stale persisted goal", async () => {
// Simulate a stale persisted goal from a previous session
mockGetMissionGoal.mockReturnValue("Old stale goal");

View File

@@ -1409,6 +1409,116 @@ describe("MissionManager", () => {
});
});
it("reloads the mission list when mission:created SSE arrives", async () => {
let missionListCallCount = 0;
const createdMission = {
id: "M-003",
title: "Realtime Mission",
description: "Appears after SSE refresh",
status: "planning",
interviewState: "not_started",
milestones: [],
createdAt: "2026-01-03T00:00:00.000Z",
updatedAt: "2026-01-03T00:00:00.000Z",
};
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes("/missions/health")) {
return Promise.resolve(mockApiResponse(mockMissionHealthById));
}
if (url.includes("/events")) {
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url)));
}
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-001";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
const validationResponse = getValidationApiMock(url);
if (validationResponse !== null) {
return Promise.resolve(mockApiResponse(validationResponse));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
missionListCallCount += 1;
return Promise.resolve(mockApiResponse(missionListCallCount === 1 ? mockMissions : [...mockMissions, createdMission]));
});
globalThis.fetch = fetchMock;
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
});
await act(async () => {
for (const source of MockEventSource.instances) {
source.emit("mission:created", createdMission);
}
});
await waitFor(() => {
expect(screen.getByText("Realtime Mission")).toBeInTheDocument();
});
expect(missionListCallCount).toBeGreaterThanOrEqual(2);
});
it("reloads mission interview drafts when ai_session:updated SSE arrives", async () => {
globalThis.fetch = createFetchMock();
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
mockFetchAiSessions.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: "session-draft-1",
type: "mission_interview",
status: "awaiting_input",
title: "Draft mission",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-03T00:00:00.000Z",
},
]);
mockFetchMissionInterviewDrafts.mockResolvedValueOnce([]).mockResolvedValueOnce([
{
id: "session-draft-1",
title: "Draft mission",
status: "awaiting_input",
projectId: null,
createdAt: "2026-01-03T00:00:00.000Z",
updatedAt: "2026-01-03T00:00:00.000Z",
hasConversation: true,
},
]);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.queryByText("Draft mission")).not.toBeInTheDocument();
});
await act(async () => {
for (const source of MockEventSource.instances) {
source.emit("ai_session:updated", {
id: "session-draft-1",
type: "mission_interview",
status: "awaiting_input",
title: "Draft mission",
projectId: null,
lockedByTab: null,
updatedAt: "2026-01-03T00:00:00.000Z",
});
}
});
await waitFor(() => {
expect(screen.getByText("Draft mission")).toBeInTheDocument();
});
expect(mockFetchAiSessions).toHaveBeenCalledTimes(2);
expect(mockFetchMissionInterviewDrafts).toHaveBeenCalledTimes(2);
});
it("reloads selected mission detail when slice:updated SSE event arrives", async () => {
const fetchMock = createDetailFetchMock(mockMissionEvents);
globalThis.fetch = fetchMock;
@@ -1769,6 +1879,57 @@ describe("MissionManager", () => {
});
});
it("opens Plan New Mission as a fresh initial interview instead of resuming the last session", async () => {
mockFetchAiSession.mockResolvedValueOnce({
id: "session-bg-1",
type: "mission_interview",
status: "generating",
title: "Background mission",
inputPayload: JSON.stringify({ missionTitle: "Background mission" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
globalThis.fetch = createFetchMock();
render(
<MissionManager
isOpen={true}
onClose={vi.fn()}
addToast={vi.fn()}
resumeSessionId="session-bg-1"
/>,
);
await waitFor(() => {
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
expect(screen.getByText("Preparing next question...")).toBeInTheDocument();
});
fireEvent.click(document.querySelector(".planning-modal .modal-close") as HTMLElement);
await waitFor(() => {
expect(screen.queryByText("Plan Mission with AI")).not.toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Plan New Mission" }));
await waitFor(() => {
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
expect(screen.getByLabelText("What do you want to build?")).toBeInTheDocument();
});
expect(screen.queryByText("Preparing next question...")).not.toBeInTheDocument();
expect(screen.queryByText("What is the target scope?")).not.toBeInTheDocument();
expect(mockFetchAiSession).toHaveBeenCalledTimes(1);
});
it("keeps interview-pending rows visible while opened from a resume session", async () => {
mockFetchAiSession.mockResolvedValueOnce({
id: "session-bg-1",
@@ -4136,6 +4297,69 @@ describe("MissionManager", () => {
await waitFor(() => expect(document.querySelector(".mission-manager__detail-pane .mission-confirm-panel")).toBeTruthy());
});
it("deletes a mission from the sidebar and reloads the list", async () => {
let deleted = false;
let missionListFetches = 0;
const fetchMock = vi.fn().mockImplementation((url: string, init?: RequestInit) => {
const method = init?.method ?? "GET";
if (url.includes("/missions/health")) {
return Promise.resolve(mockApiResponse(deleted ? { "M-002": mockMissionHealthById["M-002"] } : mockMissionHealthById));
}
if (url.includes("/events")) {
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url)));
}
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-001";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
const validationResponse = getValidationApiMock(url);
if (validationResponse !== null) {
return Promise.resolve(mockApiResponse(validationResponse));
}
if (method === "DELETE" && url.includes("/api/missions/M-001")) {
deleted = true;
return Promise.resolve(mockApiResponse({}));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
missionListFetches += 1;
return Promise.resolve(mockApiResponse(deleted ? [mockMissions[1]] : mockMissions));
});
globalThis.fetch = fetchMock;
const addToast = vi.fn();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={addToast} projectId="proj-1" />);
await waitFor(() => expect(screen.getByText("Build Auth System")).toBeInTheDocument());
const sidebar = screen.getByTestId("mission-sidebar");
fireEvent.click(within(sidebar).getAllByLabelText("Delete mission")[0]);
await waitFor(() => {
expect(document.querySelector(".mission-manager__sidebar .mission-confirm-panel")).toBeTruthy();
});
fireEvent.click(within(document.querySelector(".mission-manager__sidebar .mission-confirm-panel") as HTMLElement).getByRole("button", { name: "Delete" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/api/missions/M-001?projectId=proj-1"),
expect.objectContaining({ method: "DELETE" }),
);
});
await waitFor(() => {
expect(screen.queryByText("Build Auth System")).not.toBeInTheDocument();
});
expect(missionListFetches).toBeGreaterThanOrEqual(2);
expect(addToast).toHaveBeenCalledWith("Mission deleted", "success");
});
it("renders sidebar header with Plan New Mission CTA button", async () => {
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);