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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
Reference in New Issue
Block a user