Files
fusion/packages/dashboard/app/components/MissionInterviewModal.tsx
gsxdsm a0954c7f27 FN-6975: make mission planning modal movable and resilient
Make the AI mission planning workspace movable on desktop and safer when streams fail.

- Host the Plan Mission with AI modal in FloatingWindow with desktop drag/resize geometry and mobile full-screen preservation.
- Normalize terminal mission interview stream failures, close SSE/keepalive once, and suppress duplicate late terminal events.
- Cover modal geometry and stream-error behavior with dashboard tests and document the operator-facing behavior.

Files changed:
 .changeset/fn-6975-mission-modal-stream.md         |   7 ++
 docs/dashboard-guide.md                            |   7 ++
 .../api/__tests__/mission-interview-stream.test.ts |  98 ++++++++++++++++++
 packages/dashboard/app/api/legacy.ts               |  66 ++++++++++---
 .../app/components/MissionInterviewModal.css       |  51 ++++++++++
 .../app/components/MissionInterviewModal.tsx       |  37 ++++---
 .../__tests__/MissionInterviewModal.test.tsx       | 110 ++++++++++++++++++++-
 7 files changed, 344 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-6975

Fusion-Task-Lineage: b2ffa558-8b7e-4a46-8882-f2c4f6189831
2026-06-25 16:31:10 -07:00

1679 lines
64 KiB
TypeScript

import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { PlanningQuestion } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
startMissionInterview,
respondToMissionInterview,
retryMissionInterviewSession,
createMissionFromInterview,
connectMissionInterviewStream,
fetchAiSession,
parseConversationHistory,
fetchModels,
updateGlobalSettings,
type MissionPlanSummary,
type ConversationHistoryEntry,
type MissionPlanMilestone,
type MissionPlanSlice,
type MissionPlanFeature,
type MissionWithHierarchy,
type ModelInfo,
type AiSessionDetail,
} from "../api";
import {
saveMissionGoal,
getMissionGoal,
clearMissionGoal,
} from "../hooks/modalPersistence";
import {
Target,
X,
Loader2,
CheckCircle,
ArrowLeft,
ArrowRight,
Sparkles,
ChevronRight,
ChevronDown,
Layers,
Package,
Box,
Plus,
Trash2,
RefreshCw,
Lock,
Minimize2,
} from "lucide-react";
import { ConversationHistory } from "./ConversationHistory";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { FloatingWindow } from "./FloatingWindow";
import { useSessionLock } from "../hooks/useSessionLock";
import { useAiSessionSync } from "../hooks/useAiSessionSync";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { getSessionTabId } from "../utils/getSessionTabId";
import "./MissionInterviewModal.css";
// Helper functions for model selection
function getModelSelectionValue(provider?: string, modelId?: string): string {
return provider && modelId ? `${provider}/${modelId}` : "";
}
function parseModelSelection(value: string): { provider?: string; modelId?: string } {
if (!value) {
return { provider: undefined, modelId: undefined };
}
const slashIndex = value.indexOf("/");
if (slashIndex === -1) {
return { provider: undefined, modelId: undefined };
}
return {
provider: value.slice(0, slashIndex),
modelId: value.slice(slashIndex + 1),
};
}
const WARNING_ICON = "⚠️";
interface MissionInterviewModalProps {
isOpen: boolean;
onClose: () => void;
onMissionCreated: (mission: MissionWithHierarchy) => void;
projectId?: string;
initialGoal?: string;
resumeSessionId?: string;
onSendToBackground?: () => void;
showSendToBackgroundButton?: boolean;
}
interface QuestionResponse {
[key: string]: unknown;
}
type ViewState =
| { type: "initial" }
| { type: "loading" }
| { type: "question"; sessionId: string; question: PlanningQuestion }
| { type: "summary"; sessionId: string; summary: MissionPlanSummary }
| { type: "error"; sessionId: string; errorMessage: string };
const EXAMPLE_MISSIONS = [
"Build a real-time collaborative document editor",
"Create a customer onboarding flow with email verification",
"Add a reporting dashboard with charts and CSV export",
"Implement a plugin system with marketplace",
];
export function MissionInterviewModal({
isOpen,
onClose,
onMissionCreated,
projectId,
initialGoal: initialGoalProp,
resumeSessionId,
onSendToBackground,
showSendToBackgroundButton = false,
}: MissionInterviewModalProps) {
const { t } = useTranslation("app");
useMobileScrollLock(isOpen);
const [missionGoal, setMissionGoal] = useState("");
const [view, setView] = useState<ViewState>({ type: "initial" });
const [error, setError] = useState<string | null>(null);
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
const [conversationHistory, setConversationHistory] = useState<ConversationHistoryEntry[]>([]);
const [editedSummary, setEditedSummary] = useState<MissionPlanSummary | null>(null);
const [_hasProgress, setHasProgress] = useState(false);
const hasAutoStartedRef = useRef(false);
const [streamingOutput, setStreamingOutput] = useState("");
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
const streamErrorRecoverySeqRef = useRef(0);
const trackedLockSessionRef = useRef<string | null>(null);
const [lockSessionId, setLockSessionId] = useState<string | null>(resumeSessionId ?? null);
const sessionTabId = useMemo(() => getSessionTabId(), []);
const canSendToBackground = showSendToBackgroundButton && view.type !== "initial";
const {
isLockedByOther,
takeControl,
isLoading: isLockLoading,
} = useSessionLock(isOpen ? lockSessionId : null);
const {
activeTabMap,
broadcastUpdate,
broadcastCompleted,
broadcastLock,
broadcastUnlock,
broadcastHeartbeat,
} = useAiSessionSync();
// Model selection state
const [modelProvider, setModelProvider] = useState<string | undefined>(undefined);
const [modelId, setModelId] = useState<string | undefined>(undefined);
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(true);
const [modelsError, setModelsError] = useState<string | null>(null);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const modelSelectionValue = getModelSelectionValue(modelProvider, modelId);
// Load models on mount
useEffect(() => {
const load = async () => {
try {
setModelsLoading(true);
const resp = await fetchModels();
setLoadedModels(resp.models);
setFavoriteProviders(resp.favoriteProviders);
setFavoriteModels(resp.favoriteModels);
} catch (err) {
setModelsError(getErrorMessage(err) || "Failed to load models");
} finally {
setModelsLoading(false);
}
};
void load();
}, []);
const handleToggleFavoriteProvider = useCallback((provider: string) => {
setFavoriteProviders((prev) => {
const currentFavorites = prev;
const isFavorite = currentFavorites.includes(provider);
const newFavorites = isFavorite
? currentFavorites.filter((item) => item !== provider)
: [provider, ...currentFavorites];
updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels }).catch(() => {
setFavoriteProviders(currentFavorites);
});
return newFavorites;
});
}, [favoriteModels]);
const handleToggleFavoriteModel = useCallback((modelIdToToggle: string) => {
setFavoriteModels((prev) => {
const currentFavorites = prev;
const isFavorite = currentFavorites.includes(modelIdToToggle);
const newFavorites = isFavorite
? currentFavorites.filter((item) => item !== modelIdToToggle)
: [modelIdToToggle, ...currentFavorites];
updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites }).catch(() => {
setFavoriteModels(currentFavorites);
});
return newFavorites;
});
}, [favoriteProviders]);
const getModelBadgeLabel = useCallback(
(provider?: string, mid?: string) => {
if (!provider || !mid) return "Using default";
const matched = loadedModels.find((model) => model.provider === provider && model.id === mid);
return matched ? `${matched.provider}/${matched.id}` : `${provider}/${mid}`;
},
[loadedModels],
);
const connectToMissionInterviewStream = useCallback(
(sessionId: string) => {
streamConnectionRef.current?.close();
const connection = connectMissionInterviewStream(sessionId, projectId, {
onThinking: (data) => {
setStreamingOutput((prev) => prev + data);
broadcastUpdate({
sessionId,
status: "generating",
needsInput: false,
owningTabId: sessionTabId,
type: "mission_interview",
title: missionGoal.trim() || undefined,
projectId: projectId ?? null,
});
},
onQuestion: (question) => {
setIsReconnecting(false);
setIsRetrying(false);
clearMissionGoal(projectId);
setView({ type: "question", sessionId, question });
setStreamingOutput("");
setHasProgress(true);
broadcastUpdate({
sessionId,
status: "awaiting_input",
needsInput: true,
owningTabId: sessionTabId,
type: "mission_interview",
title: missionGoal.trim() || undefined,
projectId: projectId ?? null,
});
},
onSummary: (summary) => {
setIsReconnecting(false);
setIsRetrying(false);
clearMissionGoal(projectId);
setView({ type: "summary", sessionId, summary });
setEditedSummary(summary);
setStreamingOutput("");
setHasProgress(true);
broadcastUpdate({
sessionId,
status: "complete",
needsInput: false,
owningTabId: sessionTabId,
type: "mission_interview",
title: missionGoal.trim() || undefined,
projectId: projectId ?? null,
});
},
onError: (message) => {
const errorMessage = message || t("missions.interviewErrorDefault", "Session failed while contacting the AI.");
if (currentSessionIdRef.current && currentSessionIdRef.current !== sessionId) {
return;
}
const recoverySeq = streamErrorRecoverySeqRef.current + 1;
streamErrorRecoverySeqRef.current = recoverySeq;
/*
FNXC:MissionInterview 2026-06-24-21:43:
Mission interview SSE errors can be transient while the server-side AI session remains recoverable.
Refetch persisted session state before showing the permanent Retry/Dismiss panel so issue #1745 cannot strand users on a literal Stream error.
*/
setIsReconnecting(true);
(async () => {
let terminalErrorMessage = errorMessage;
const isCurrentRecovery = () =>
streamErrorRecoverySeqRef.current === recoverySeq &&
(!currentSessionIdRef.current || currentSessionIdRef.current === sessionId);
const restoreHistoryFromSession = (session: AiSessionDetail) => {
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
parsedHistory
.map((entry) => entry.response)
.filter((response): response is QuestionResponse =>
Boolean(response && typeof response === "object" && !Array.isArray(response)),
),
);
};
try {
const session = await fetchAiSession(sessionId);
if (!isCurrentRecovery()) return;
if (session?.type === "mission_interview") {
restoreHistoryFromSession(session);
currentSessionIdRef.current = session.id;
setLockSessionId(session.id);
setHasProgress(true);
if (session.status === "generating") {
if (session.thinkingOutput) {
setStreamingOutput(session.thinkingOutput);
}
connectToMissionInterviewStream(session.id);
return;
}
if (session.status === "awaiting_input") {
if (!session.currentQuestion) {
throw new Error("Interview session is awaiting input but has no current question.");
}
const question = JSON.parse(session.currentQuestion) as PlanningQuestion;
clearMissionGoal(projectId);
setView({ type: "question", sessionId: session.id, question });
connectToMissionInterviewStream(session.id);
return;
}
if (session.status === "complete") {
if (!session.result) {
throw new Error("Interview session is complete but has no result.");
}
const summary = JSON.parse(session.result) as MissionPlanSummary;
clearMissionGoal(projectId);
setEditedSummary(summary);
setView({ type: "summary", sessionId: session.id, summary });
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
broadcastUpdate({
sessionId: session.id,
status: "complete",
needsInput: false,
owningTabId: sessionTabId,
type: "mission_interview",
title: missionGoal.trim() || undefined,
projectId: projectId ?? null,
});
broadcastCompleted({ sessionId: session.id, status: "complete" });
return;
}
if (session.status === "error") {
terminalErrorMessage = session.error ?? t("missions.sessionEncounteredError", "The session encountered an error.");
}
}
} catch {
if (!isCurrentRecovery()) return;
}
if (!isCurrentRecovery()) return;
setIsReconnecting(false);
setIsRetrying(false);
setError(null);
setView({ type: "error", sessionId, errorMessage: terminalErrorMessage });
setStreamingOutput("");
setHasProgress(true);
currentSessionIdRef.current = sessionId;
broadcastUpdate({
sessionId,
status: "error",
needsInput: false,
owningTabId: sessionTabId,
type: "mission_interview",
title: missionGoal.trim() || undefined,
projectId: projectId ?? null,
});
broadcastCompleted({ sessionId, status: "error" });
})();
},
onComplete: () => {
setIsReconnecting(false);
setIsRetrying(false);
currentSessionIdRef.current = null;
broadcastCompleted({ sessionId, status: "complete" });
},
onConnectionStateChange: (state) => {
setIsReconnecting(state === "reconnecting");
},
});
streamConnectionRef.current = connection;
},
[broadcastCompleted, broadcastUpdate, missionGoal, projectId, sessionTabId],
);
const handleStartInterview = useCallback(
async (goalOverride?: string) => {
const goal = goalOverride ?? missionGoal;
if (!goal.trim()) return;
setError(null);
setStreamingOutput("");
setResponseHistory([]);
setConversationHistory([]);
setIsReconnecting(false);
setView({ type: "loading" });
try {
const { sessionId } = await startMissionInterview(
goal.trim(),
projectId,
modelProvider && modelId ? { modelProvider, modelId } : undefined,
);
currentSessionIdRef.current = sessionId;
setLockSessionId(sessionId);
clearMissionGoal(projectId);
connectToMissionInterviewStream(sessionId);
setResponseHistory([]);
} catch (err) {
setIsReconnecting(false);
setError(getErrorMessage(err) || "Failed to start interview session");
setView({ type: "initial" });
currentSessionIdRef.current = null;
setLockSessionId(null);
}
},
[connectToMissionInterviewStream, missionGoal, modelProvider, modelId, projectId]
);
// Focus textarea when opening
useEffect(() => {
if (isOpen && view.type === "initial") {
textareaRef.current?.focus();
}
}, [isOpen, view.type]);
// Restore persisted goal from localStorage ONCE when the modal opens.
// Must NOT depend on handleStartInterview or missionGoal — otherwise every
// keystroke recreates handleStartInterview, re-triggers this effect, and
// overwrites what the user just typed (cursor jumps to end, can't edit).
useEffect(() => {
if (isOpen && !initialGoalProp && !resumeSessionId && !hasAutoStartedRef.current && view.type === "initial") {
const persisted = getMissionGoal(projectId);
if (persisted) {
setMissionGoal(persisted);
}
}
}, [isOpen]);
// Auto-start when initialGoal prop is provided
useEffect(() => {
if (isOpen && initialGoalProp && !hasAutoStartedRef.current && view.type === "initial") {
setMissionGoal(initialGoalProp);
const timer = setTimeout(() => {
hasAutoStartedRef.current = true;
handleStartInterview(initialGoalProp);
}, 0);
return () => clearTimeout(timer);
}
}, [isOpen, initialGoalProp, view.type, handleStartInterview]);
useEffect(() => {
if (!isOpen) {
hasAutoStartedRef.current = false;
setIsReconnecting(false);
setIsRetrying(false);
setLockSessionId(null);
}
}, [isOpen]);
// Reconnect to a persisted session when resumeSessionId is provided
useEffect(() => {
if (!isOpen || !resumeSessionId || view.type !== "initial") return;
let cancelled = false;
fetchAiSession(resumeSessionId).then((session) => {
if (cancelled || !session) return;
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setLockSessionId(session.id);
setResponseHistory(
parsedHistory
.map((entry) => entry.response)
.filter((response): response is QuestionResponse =>
Boolean(response && typeof response === "object" && !Array.isArray(response)),
),
);
if (session.status === "awaiting_input" && session.currentQuestion) {
try {
clearMissionGoal(projectId);
const question = JSON.parse(session.currentQuestion) as import("@fusion/core").PlanningQuestion;
currentSessionIdRef.current = session.id;
setHasProgress(true);
setView({ type: "question", sessionId: session.id, question });
} catch {
setError("Failed to restore session question.");
}
} else if (session.status === "complete" && session.result) {
try {
clearMissionGoal(projectId);
const summary = JSON.parse(session.result) as MissionPlanSummary;
currentSessionIdRef.current = session.id;
setHasProgress(true);
setEditedSummary(summary);
setView({ type: "summary", sessionId: session.id, summary });
} catch {
setError("Failed to restore session result.");
}
} else if (session.status === "generating") {
currentSessionIdRef.current = session.id;
setHasProgress(true);
if (session.thinkingOutput) {
setStreamingOutput(session.thinkingOutput);
}
setView({ type: "loading" });
connectToMissionInterviewStream(session.id);
} else if (session.status === "error") {
currentSessionIdRef.current = session.id;
setHasProgress(true);
setError(null);
setView({
type: "error",
sessionId: session.id,
errorMessage: session.error ?? "The session encountered an error.",
});
}
}).catch(() => {
if (!cancelled) setError("Failed to resume session.");
});
return () => {
cancelled = true;
};
}, [connectToMissionInterviewStream, isOpen, resumeSessionId, view.type, projectId]);
// Broadcast ownership transitions between tabs.
useEffect(() => {
if (!isOpen) {
if (trackedLockSessionRef.current) {
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
trackedLockSessionRef.current = null;
}
return;
}
if (lockSessionId && trackedLockSessionRef.current !== lockSessionId) {
if (trackedLockSessionRef.current) {
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
}
broadcastLock(lockSessionId, sessionTabId);
trackedLockSessionRef.current = lockSessionId;
return;
}
if (!lockSessionId && trackedLockSessionRef.current) {
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
trackedLockSessionRef.current = null;
}
}, [broadcastLock, broadcastUnlock, isOpen, lockSessionId, sessionTabId]);
// Keep heartbeat alive while this tab owns an active mission interview session.
useEffect(() => {
if (!isOpen || !lockSessionId || trackedLockSessionRef.current !== lockSessionId) {
return;
}
broadcastHeartbeat(sessionTabId);
const timer = setInterval(() => {
broadcastHeartbeat(sessionTabId);
}, 30_000);
return () => {
clearInterval(timer);
};
}, [broadcastHeartbeat, isOpen, lockSessionId, sessionTabId]);
// Cleanup stream on unmount
useEffect(() => {
return () => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
if (trackedLockSessionRef.current) {
broadcastUnlock(trackedLockSessionRef.current, sessionTabId);
trackedLockSessionRef.current = null;
}
};
}, [broadcastUnlock, sessionTabId]);
// Unload protection
useEffect(() => {
if (!isOpen) return;
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (view.type === "question" || view.type === "summary") {
e.preventDefault();
e.returnValue = "";
}
streamConnectionRef.current?.close();
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isOpen, view]);
const handleClose = useCallback(() => {
if (missionGoal && view.type === "initial") {
saveMissionGoal(missionGoal, projectId);
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setIsReconnecting(false);
setIsRetrying(false);
setIsCreating(false);
onClose();
}, [missionGoal, onClose, projectId, view.type]);
const handleSendToBackground = useCallback(() => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setIsReconnecting(false);
setIsRetrying(false);
setIsCreating(false);
if (onSendToBackground) {
onSendToBackground();
return;
}
onClose();
}, [onClose, onSendToBackground]);
// Escape key handler
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, handleClose]);
const handleSubmitResponse = useCallback(
async (responses: QuestionResponse) => {
if (view.type !== "question") return;
const { sessionId } = view;
setError(null);
setResponseHistory((prev) => [...prev, responses]);
setConversationHistory((prev) => [
...prev,
{
question: view.question,
response: responses,
},
]);
setView({ type: "loading" });
setStreamingOutput("");
try {
connectToMissionInterviewStream(sessionId);
await respondToMissionInterview(sessionId, responses, projectId, sessionTabId);
setHasProgress(true);
} catch (err) {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setError(getErrorMessage(err) || "Failed to submit response");
setView({ type: "question", sessionId, question: view.question });
}
},
[view, projectId, sessionTabId, connectToMissionInterviewStream]
);
const handleRetryFromError = useCallback(async () => {
if (view.type !== "error") {
return;
}
const retrySessionId = view.sessionId;
setError(null);
setIsRetrying(true);
setStreamingOutput("");
setView({ type: "loading" });
connectToMissionInterviewStream(retrySessionId);
try {
currentSessionIdRef.current = retrySessionId;
setLockSessionId(retrySessionId);
await retryMissionInterviewSession(retrySessionId, projectId, sessionTabId);
} catch (err) {
let retryError: unknown = err;
const retryErrorMessage = getErrorMessage(err) || "";
if (retryErrorMessage.includes("not in an error state")) {
try {
const session = await fetchAiSession(retrySessionId);
if (!session) {
throw new Error("Failed to refresh interview session.");
}
const parsedHistory = parseConversationHistory(session.conversationHistory);
setConversationHistory(parsedHistory);
setResponseHistory(
parsedHistory
.map((entry) => entry.response)
.filter((response): response is QuestionResponse =>
Boolean(response && typeof response === "object" && !Array.isArray(response)),
),
);
currentSessionIdRef.current = session.id;
setLockSessionId(session.id);
setHasProgress(true);
if (session.status === "generating") {
setStreamingOutput(session.thinkingOutput ?? "");
setView({ type: "loading" });
if (!streamConnectionRef.current?.isConnected()) {
connectToMissionInterviewStream(session.id);
}
} else if (session.status === "awaiting_input") {
if (!session.currentQuestion) {
throw new Error("Interview session is awaiting input but has no current question.");
}
clearMissionGoal(projectId);
const question = JSON.parse(session.currentQuestion) as PlanningQuestion;
setView({ type: "question", sessionId: session.id, question });
if (!streamConnectionRef.current?.isConnected()) {
connectToMissionInterviewStream(session.id);
}
} else if (session.status === "complete") {
if (!session.result) {
throw new Error("Interview session is complete but has no result.");
}
clearMissionGoal(projectId);
const summary = JSON.parse(session.result) as MissionPlanSummary;
setEditedSummary(summary);
setView({ type: "summary", sessionId: session.id, summary });
} else if (session.status === "error") {
setView({
type: "error",
sessionId: session.id,
errorMessage: session.error ?? "Retry failed. Please try again.",
});
}
setIsReconnecting(false);
return;
} catch (sessionRefreshError) {
retryError = sessionRefreshError;
}
}
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setView({
type: "error",
sessionId: retrySessionId,
errorMessage: getErrorMessage(retryError) || "Retry failed. Please try again.",
});
setIsReconnecting(false);
} finally {
setIsRetrying(false);
}
}, [connectToMissionInterviewStream, projectId, sessionTabId, view]);
const handleApprovePlan = useCallback(async () => {
if (view.type !== "summary") return;
setError(null);
setIsCreating(true);
try {
const mission = await createMissionFromInterview(view.sessionId, editedSummary || undefined, projectId);
onMissionCreated(mission);
clearMissionGoal(projectId);
// Reset state without confirmation
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
setMissionGoal("");
setView({ type: "initial" });
setError(null);
setResponseHistory([]);
setConversationHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setIsReconnecting(false);
setIsRetrying(false);
setHasProgress(false);
setIsCreating(false);
currentSessionIdRef.current = null;
setLockSessionId(null);
onClose();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create mission");
setIsCreating(false);
}
}, [view, editedSummary, onMissionCreated, onClose, projectId]);
const getProgress = () => {
if (view.type === "question") {
return Math.min(responseHistory.length + 1, 6);
}
return 6;
};
const activeLockInfo = lockSessionId ? activeTabMap.get(lockSessionId) : null;
const activeRemoteTab = activeLockInfo && activeLockInfo.tabId !== sessionTabId;
const activeInAnotherTab = Boolean(activeRemoteTab && !activeLockInfo.stale);
const allowTakeover = isLockedByOther && (!activeRemoteTab || activeLockInfo.stale);
if (!isOpen) return null;
return (
<FloatingWindow
windowKey="mission-interview"
title={t("missions.planTitle", "Plan Mission with AI")}
onClose={handleClose}
hideHeader
dragHandleSelector=".mission-interview-modal__drag-handle"
className="floating-window--mission-interview"
defaultSize={{ width: 760, height: 680 }}
minSize={{ width: 560, height: 420 }}
persistGeometryKey="floating-window:mission-interview"
>
{/*
FNXC:MissionInterviewModal 2026-06-24-00:00:
The Plan Mission with AI workspace must be draggable and resizable on desktop by delegating geometry to FloatingWindow, while mobile keeps the existing full-screen/sheet-like mission interview flow. Keep one embedded mission header so close/send-to-background/session-lock controls do not duplicate FloatingWindow chrome.
*/}
<div className="modal modal-lg planning-modal mission-interview-modal">
<div className="modal-header mission-interview-modal__drag-handle">
<div className="detail-title-row">
<Target size={20} className="icon-triage" />
<h3>{t("missions.planTitle", "Plan Mission with AI")}</h3>
</div>
<div className="modal-header-actions">
{canSendToBackground && (
<button
className="modal-send-to-background"
onClick={handleSendToBackground}
title={t("missions.sendToBackground", "Send to background")}
aria-label={t("missions.sendToBackground", "Send to background")}
>
<Minimize2 size={16} />
</button>
)}
<button className="modal-close" onClick={handleClose} aria-label={t("actions.close", "Close")}>
<X size={20} />
</button>
</div>
</div>
<div className="planning-modal-body">
{error && <div className="form-error planning-error">{error}</div>}
{isReconnecting && <div className="form-hint text-muted">{t("missions.reconnecting", "Reconnecting…")}</div>}
{activeInAnotherTab && (
<div className="form-hint text-muted" data-testid="session-active-another-tab-banner">
{t("missions.sessionActiveAnother", "Session is active in another tab.")}
</div>
)}
{view.type === "initial" && (
<div className="planning-initial">
<div className="planning-view-scroll">
<div className="planning-intro">
<Sparkles size={32} className="icon-triage-lg" />
<h4>{t("missions.transformVision", "Transform your vision into a structured mission")}</h4>
<p className="text-muted">
{t("missions.describeGoal", "Describe what you want to build. The AI will interview you to understand scope, constraints, and requirements, then produce a structured plan with milestones, slices, and features.")}
</p>
</div>
<div className="form-group">
<label htmlFor="mission-goal">{t("missions.whatToBuild", "What do you want to build?")}</label>
<textarea
ref={textareaRef}
id="mission-goal"
rows={4}
className="planning-textarea"
placeholder={t("missions.buildExample", "e.g., Build a real-time collaborative document editor with presence, comments, and version history...")}
value={missionGoal}
onChange={(e) => setMissionGoal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && missionGoal.trim()) {
e.preventDefault();
handleStartInterview();
}
}}
/>
</div>
<div className="planning-examples">
<span className="planning-examples-label">{t("missions.tryExample", "Try an example:")}</span>
<div className="planning-example-chips">
{EXAMPLE_MISSIONS.map((mission, i) => (
<button
key={i}
className="planning-example-chip"
onClick={() => setMissionGoal(mission)}
>
{mission.length > 45 ? mission.slice(0, 45) + "..." : mission}
</button>
))}
</div>
</div>
<div className="planning-model-select-group">
<label htmlFor="mission-interview-modal-model" className="form-label">
{t("missions.planningModel", "Planning Model")}
{modelsLoading && (
<span className="text-muted text-muted-sm">
{t("missions.loadingModels", "Loading models…")}
</span>
)}
</label>
<CustomModelDropdown
id="mission-interview-modal-model"
label="Planning Model"
value={modelSelectionValue}
onChange={(value) => {
const { provider, modelId: selectedModelId } = parseModelSelection(value);
setModelProvider(provider);
setModelId(selectedModelId);
}}
models={loadedModels}
disabled={modelsLoading}
favoriteProviders={favoriteProviders}
onToggleFavorite={handleToggleFavoriteProvider}
favoriteModels={favoriteModels}
onToggleModelFavorite={handleToggleFavoriteModel}
/>
{modelsError && (
<div className="form-hint form-hint-error">
{modelsError}{" "}
<button
type="button"
className="text-link-btn"
onClick={() => {
void (async () => {
try {
setModelsLoading(true);
const resp = await fetchModels();
setLoadedModels(resp.models);
setFavoriteProviders(resp.favoriteProviders);
setFavoriteModels(resp.favoriteModels);
setModelsError(null);
} catch (err) {
setModelsError(getErrorMessage(err) || t("missions.failedLoadModels", "Failed to load models"));
} finally {
setModelsLoading(false);
}
})();
}}
>
{t("actions.retry", "Retry")}
</button>
</div>
)}
<div className="model-selector-current model-selector-current--spaced">
<span
className={`model-badge ${
modelProvider && modelId
? "model-badge-custom"
: "model-badge-default"
}`}
>
{getModelBadgeLabel(modelProvider, modelId)}
</span>
</div>
</div>
</div>
<div className="planning-view-footer">
<button
className="btn btn-primary planning-start-btn"
onClick={() => handleStartInterview()}
disabled={!missionGoal.trim()}
>
<Target size={16} className="icon-mr-8" />
{t("missions.startInterview", "Start Interview")}
</button>
</div>
</div>
)}
{view.type === "loading" && (
<div className="planning-loading">
<Loader2 size={40} className="spin icon-todo" />
<p>{streamingOutput ? t("missions.aiThinking", "AI is thinking...") : t("missions.prepareQuestion", "Preparing next question...")}</p>
<div className="planning-thinking-container">
<button
className="planning-thinking-toggle"
onClick={() => setShowThinking(!showThinking)}
type="button"
>
{showThinking ? t("missions.hideThinking", "Hide thinking") : t("missions.showThinking", "Show thinking")}
</button>
{showThinking && streamingOutput && (
<div className="planning-thinking-output">
<pre>{streamingOutput}</pre>
</div>
)}
</div>
</div>
)}
{view.type === "error" && (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
{conversationHistory.length > 0 && (
<>
<ConversationHistory entries={conversationHistory} />
<div className="conversation-separator" />
</>
)}
<div
className="ai-error-panel"
role="alert"
>
<div className="ai-error-icon">{WARNING_ICON}</div>
<div className="ai-error-message">{view.errorMessage}</div>
<div className="ai-error-actions">
<button className="btn btn-primary" onClick={() => void handleRetryFromError()} disabled={isRetrying}>
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
<span className="icon-ml-6">{isRetrying ? t("missions.retrying", "Retrying...") : t("actions.retry", "Retry")}</span>
</button>
<button className="btn" onClick={handleClose} disabled={isRetrying}>{t("missions.dismiss", "Dismiss")}</button>
</div>
</div>
</div>
</div>
)}
{view.type === "question" && (
<InterviewQuestionForm
question={view.question}
progress={getProgress()}
historyEntries={conversationHistory}
onSubmit={handleSubmitResponse}
/>
)}
{view.type === "summary" && editedSummary && (
<MissionPlanReview
summary={editedSummary}
historyEntries={conversationHistory}
onSummaryChange={setEditedSummary}
onApprove={handleApprovePlan}
onStartOver={() => {
setView({ type: "initial" });
setHasProgress(false);
setEditedSummary(null);
setResponseHistory([]);
setConversationHistory([]);
setLockSessionId(null);
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
}}
isCreating={isCreating}
/>
)}
{isLockedByOther && (
<div className="session-lock-overlay" data-testid="session-lock-overlay">
<div className="session-lock-banner">
<Lock size={16} />
<span>
{allowTakeover
? t("missions.sessionActiveTab", "This session is active in another tab")
: t("missions.sessionActiveHeartbeat", "This session is active in another tab (live heartbeat)")}
</span>
{allowTakeover && (
<button
type="button"
onClick={() => {
void takeControl();
}}
disabled={isLockLoading}
className="btn btn-primary session-lock-take-control"
>
{isLockLoading ? t("missions.takingControl", "Taking control...") : t("missions.takeControl", "Take Control")}
</button>
)}
</div>
</div>
)}
</div>
</div>
</FloatingWindow>
);
}
// ── Question Form (reused from PlanningModeModal pattern) ────────────────
interface InterviewQuestionFormProps {
question: PlanningQuestion;
progress: number;
historyEntries: ConversationHistoryEntry[];
onSubmit: (responses: QuestionResponse) => void;
}
function InterviewQuestionForm({ question, progress, historyEntries, onSubmit }: InterviewQuestionFormProps) {
const { t } = useTranslation("app");
const [response, setResponse] = useState<QuestionResponse>({});
const [textValue, setTextValue] = useState("");
const [commentValue, setCommentValue] = useState("");
const handleSubmit = useCallback(() => {
let nextResponse: QuestionResponse;
if (question.type === "text") {
nextResponse = { [question.id]: textValue };
} else if (question.type === "confirm") {
nextResponse = { [question.id]: response[question.id] === true };
} else {
nextResponse = response;
}
const trimmedComment = commentValue.trim();
if (trimmedComment.length > 0) {
nextResponse = { ...nextResponse, _comment: trimmedComment };
}
onSubmit(nextResponse);
}, [commentValue, question, response, textValue, onSubmit]);
useEffect(() => {
setResponse({});
setTextValue("");
setCommentValue("");
}, [question.id]);
const isValid = () => {
switch (question.type) {
case "text":
return textValue.trim().length > 0;
case "single_select":
return response[question.id] !== undefined;
case "multi_select":
return Array.isArray(response[question.id] as unknown) && (response[question.id] as unknown[]).length > 0;
case "confirm":
return response[question.id] !== undefined;
default:
return true;
}
};
return (
<div className="planning-question-form">
<div className="planning-view-scroll planning-question-scroll">
{historyEntries.length > 0 && (
<>
<ConversationHistory entries={historyEntries} />
<div className="conversation-separator" />
</>
)}
<div className="planning-question-panel">
<div className="planning-progress">
<div className="planning-progress-bar">
{[1, 2, 3, 4, 5, 6].map((step) => (
<div
key={step}
className={`planning-progress-step ${step <= progress ? "active" : ""}`}
/>
))}
</div>
<span className="planning-progress-text">{t("missions.progressText", "Question {{count}} of ~6", { count: progress })}</span>
</div>
<div className="planning-question-content">
<h4 className="planning-question-text">{question.question}</h4>
{question.description && (
<p className="planning-question-desc">{question.description}</p>
)}
<div className="planning-options">
{question.type === "text" && (
<textarea
className="planning-textarea"
rows={4}
placeholder={t("missions.typeAnswer", "Type your answer here...")}
value={textValue}
onChange={(e) => setTextValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && textValue.trim()) {
e.preventDefault();
handleSubmit();
}
}}
/>
)}
{question.type === "single_select" && question.options && (
<div className="planning-radio-group" role="radiogroup">
{question.options.map((option) => (
<label key={option.id} className="planning-option planning-option--radio">
<input
type="radio"
name={question.id}
value={option.id}
checked={response[question.id] === option.id}
onChange={() => setResponse({ [question.id]: option.id })}
/>
<div className="planning-option-content">
<span className="planning-option-label">{option.label}</span>
{option.description && (
<span className="planning-option-desc">{option.description}</span>
)}
</div>
</label>
))}
</div>
)}
{question.type === "multi_select" && question.options && (
<div className="planning-checkbox-group">
{question.options.map((option) => {
const selected = (response[question.id] as string[]) || [];
return (
<label key={option.id} className="planning-option planning-option--checkbox">
<input
type="checkbox"
value={option.id}
checked={selected.includes(option.id)}
onChange={(e) => {
const newSelected = e.target.checked
? [...selected, option.id]
: selected.filter((id) => id !== option.id);
setResponse({ [question.id]: newSelected });
}}
/>
<div className="planning-option-content">
<span className="planning-option-label">{option.label}</span>
{option.description && (
<span className="planning-option-desc">{option.description}</span>
)}
</div>
</label>
);
})}
</div>
)}
{question.type === "confirm" && (
<div className="planning-confirm-group">
<button
className={`planning-confirm-btn ${response[question.id] === true ? "selected" : ""}`}
onClick={() => setResponse({ [question.id]: true })}
>
<CheckCircle size={18} />
{t("actions.yes", "Yes")}
</button>
<button
className={`planning-confirm-btn ${response[question.id] === false ? "selected" : ""}`}
onClick={() => setResponse({ [question.id]: false })}
>
<X size={18} />
{t("actions.no", "No")}
</button>
</div>
)}
</div>
{question.type !== "text" && (
<div className="planning-comment-section">
<label className="planning-comment-label" htmlFor={`planning-comment-${question.id}`}>
{t("missions.additionalComments", "Additional comments (optional)")}
</label>
<textarea
id={`planning-comment-${question.id}`}
className="planning-textarea"
rows={2}
placeholder={t("missions.addContext", "Add any extra context or direction...")}
value={commentValue}
onChange={(e) => setCommentValue(e.target.value)}
/>
</div>
)}
</div>
</div>
</div>
<div className="planning-actions">
<button
className="btn btn-primary planning-actions-primary"
onClick={handleSubmit}
disabled={!isValid()}
>
{t("actions.continue", "Continue")}
<ArrowRight size={16} className="icon-ml-4" />
</button>
</div>
</div>
);
}
// ── Mission Plan Review (hierarchical summary view) ──────────────────────
interface MissionPlanReviewProps {
summary: MissionPlanSummary;
historyEntries: ConversationHistoryEntry[];
onSummaryChange: (summary: MissionPlanSummary) => void;
onApprove: () => void;
onStartOver: () => void;
isCreating: boolean;
}
function MissionPlanReview({
summary,
historyEntries,
onSummaryChange,
onApprove,
onStartOver,
isCreating,
}: MissionPlanReviewProps) {
const { t } = useTranslation("app");
const [expandedMilestones, setExpandedMilestones] = useState<Set<number>>(
() => new Set(summary.milestones.map((_, i) => i))
);
const [expandedSlices, setExpandedSlices] = useState<Set<string>>(
() => {
const set = new Set<string>();
summary.milestones.forEach((ms, mi) => {
ms.slices.forEach((_, si) => set.add(`${mi}-${si}`));
});
return set;
}
);
const toggleMilestone = (index: number) => {
setExpandedMilestones((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
};
const toggleSlice = (key: string) => {
setExpandedSlices((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const updateMilestone = (index: number, updates: Partial<MissionPlanMilestone>) => {
const milestones = [...summary.milestones];
milestones[index] = { ...milestones[index], ...updates };
onSummaryChange({ ...summary, milestones });
};
const updateSlice = (mi: number, si: number, updates: Partial<MissionPlanSlice>) => {
const milestones = [...summary.milestones];
const slices = [...milestones[mi].slices];
slices[si] = { ...slices[si], ...updates };
milestones[mi] = { ...milestones[mi], slices };
onSummaryChange({ ...summary, milestones });
};
const updateFeature = (mi: number, si: number, fi: number, updates: Partial<MissionPlanFeature>) => {
const milestones = [...summary.milestones];
const slices = [...milestones[mi].slices];
const features = [...slices[si].features];
features[fi] = { ...features[fi], ...updates };
slices[si] = { ...slices[si], features };
milestones[mi] = { ...milestones[mi], slices };
onSummaryChange({ ...summary, milestones });
};
const removeMilestone = (index: number) => {
const milestones = summary.milestones.filter((_, i) => i !== index);
onSummaryChange({ ...summary, milestones });
};
const removeSlice = (mi: number, si: number) => {
const milestones = [...summary.milestones];
milestones[mi] = {
...milestones[mi],
slices: milestones[mi].slices.filter((_, i) => i !== si),
};
onSummaryChange({ ...summary, milestones });
};
const removeFeature = (mi: number, si: number, fi: number) => {
const milestones = [...summary.milestones];
const slices = [...milestones[mi].slices];
slices[si] = {
...slices[si],
features: slices[si].features.filter((_, i) => i !== fi),
};
milestones[mi] = { ...milestones[mi], slices };
onSummaryChange({ ...summary, milestones });
};
const addFeature = (mi: number, si: number) => {
const milestones = [...summary.milestones];
const slices = [...milestones[mi].slices];
slices[si] = {
...slices[si],
features: [...slices[si].features, { title: "New feature", description: "" }],
};
milestones[mi] = { ...milestones[mi], slices };
onSummaryChange({ ...summary, milestones });
};
const totalFeatures = summary.milestones.reduce(
(acc, ms) => acc + ms.slices.reduce((a, sl) => a + sl.features.length, 0),
0
);
return (
<div className="planning-summary">
<div className="planning-view-scroll planning-summary-scroll">
{historyEntries.length > 0 && (
<>
<ConversationHistory entries={historyEntries} />
<div className="conversation-separator" />
</>
)}
<div className="planning-summary-header">
<CheckCircle size={24} className="icon-success" />
<h4>{t("missions.planReady", "Mission Plan Ready")}</h4>
<p className="text-muted">
{t("missions.summaryStats", "{{milestones}} milestones, {{features}} features. Review and edit before approving.", { milestones: summary.milestones.length, features: totalFeatures })}
</p>
</div>
<div className="planning-summary-form">
{/* Mission title & description */}
<div className="form-group">
<label>{t("missions.titleLabel", "Mission Title")}</label>
<input
type="text"
className="form-input"
value={summary.missionTitle || ""}
onChange={(e) => onSummaryChange({ ...summary, missionTitle: e.target.value })}
/>
</div>
<div className="form-group">
<label>{t("missions.descriptionLabel", "Mission Description")}</label>
<textarea
className="planning-textarea"
rows={3}
value={summary.missionDescription || ""}
onChange={(e) => onSummaryChange({ ...summary, missionDescription: e.target.value })}
/>
</div>
{/* Milestones hierarchy */}
<div className="form-group">
<label>{t("missions.roadmapLabel", "Roadmap")}</label>
<div className="roadmap-list">
{summary.milestones.map((milestone, mi) => (
<div
key={mi}
className="roadmap-card"
>
{/* Milestone header */}
<div
className="roadmap-card-header"
onClick={() => toggleMilestone(mi)}
>
{expandedMilestones.has(mi) ? (
<ChevronDown size={16} className="icon-text-secondary" />
) : (
<ChevronRight size={16} className="icon-text-secondary" />
)}
<Layers size={16} className="icon-milestone" />
<input
type="text"
className="form-input roadmap-input-title"
value={milestone.title}
onChange={(e) => updateMilestone(mi, { title: e.target.value })}
onClick={(e) => e.stopPropagation()}
/>
{summary.milestones.length > 1 && (
<button
className="btn-icon roadmap-shrink"
onClick={(e) => {
e.stopPropagation();
removeMilestone(mi);
}}
title={t("missions.removeMilestone", "Remove milestone")}
>
<Trash2 size={14} className="icon-text-secondary" />
</button>
)}
</div>
{expandedMilestones.has(mi) && (
<div className="roadmap-card-body">
<textarea
className="planning-textarea roadmap-textarea-md"
rows={2}
placeholder={t("missions.milestoneDescriptionPlaceholder", "Milestone description...")}
value={milestone.description || ""}
onChange={(e) => updateMilestone(mi, { description: e.target.value })}
/>
<div className="roadmap-field-group">
<label className="roadmap-field-label">
{t("missions.verificationCriteria", "Verification Criteria")}
</label>
<textarea
className="planning-textarea roadmap-textarea-sm"
rows={2}
placeholder={t("missions.confirmMilestonePlaceholder", "How to confirm this milestone is complete...")}
value={milestone.verification || ""}
onChange={(e) => updateMilestone(mi, { verification: e.target.value })}
/>
</div>
{/* Slices */}
{milestone.slices.map((slice, si) => {
const sliceKey = `${mi}-${si}`;
return (
<div
key={si}
className="roadmap-slice-card"
>
<div
className="roadmap-slice-header"
onClick={() => toggleSlice(sliceKey)}
>
{expandedSlices.has(sliceKey) ? (
<ChevronDown size={14} className="icon-text-secondary" />
) : (
<ChevronRight size={14} className="icon-text-secondary" />
)}
<Package size={14} className="icon-slice" />
<input
type="text"
className="form-input roadmap-input-subtitle"
value={slice.title}
onChange={(e) => updateSlice(mi, si, { title: e.target.value })}
onClick={(e) => e.stopPropagation()}
/>
{milestone.slices.length > 1 && (
<button
className="btn-icon roadmap-shrink"
onClick={(e) => {
e.stopPropagation();
removeSlice(mi, si);
}}
title={t("missions.removeSlice", "Remove slice")}
>
<Trash2 size={12} className="icon-text-secondary" />
</button>
)}
</div>
{expandedSlices.has(sliceKey) && (
<div className="roadmap-slice-body">
{/* Slice verification */}
<div className="roadmap-slice-field-group">
<label className="roadmap-field-label">
{t("missions.sliceVerification", "Slice Verification")}
</label>
<textarea
className="planning-textarea roadmap-textarea-xs"
rows={1}
placeholder={t("missions.confirmSlicePlaceholder", "How to confirm this slice is done...")}
value={slice.verification || ""}
onChange={(e) => updateSlice(mi, si, { verification: e.target.value })}
/>
</div>
{/* Features */}
{slice.features.map((feature, fi) => (
<div
key={fi}
className="roadmap-feature-row"
>
<Box size={12} className="icon-feature" />
<div className="roadmap-feature-content">
<input
type="text"
className="form-input roadmap-input-feature"
value={feature.title}
onChange={(e) =>
updateFeature(mi, si, fi, { title: e.target.value })
}
/>
{feature.description && (
<p className="roadmap-feature-text">
{feature.description}
</p>
)}
{feature.acceptanceCriteria && (
<p className="roadmap-feature-text--italic">
{t("missions.acceptanceCriteriaPrefix", "AC:")} {feature.acceptanceCriteria}
</p>
)}
</div>
<button
className="btn-icon roadmap-shrink"
onClick={() => removeFeature(mi, si, fi)}
title={t("missions.removeFeature", "Remove feature")}
>
<Trash2 size={12} className="icon-text-secondary" />
</button>
</div>
))}
<button
className="btn roadmap-add-feature-btn"
onClick={() => addFeature(mi, si)}
>
<Plus size={12} />
{t("missions.addFeature", "Add Feature")}
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
<div className="planning-actions planning-summary-actions">
<button className="btn" onClick={onStartOver} disabled={isCreating}>
<ArrowLeft size={16} className="icon-mr-4" />
{t("missions.startOver", "Start Over")}
</button>
<button
className="btn btn-primary"
onClick={onApprove}
disabled={isCreating || summary.milestones.length === 0}
>
{isCreating ? (
<>
<Loader2 size={16} className="spin icon-mr-8" />
{t("missions.creatingMission", "Creating Mission...")}
</>
) : (
<>
<CheckCircle size={16} className="icon-mr-8" />
{t("missions.approvePlan", "Approve Plan")}
</>
)}
</button>
</div>
</div>
);
}