Files
fusion/packages/dashboard/app/components/MissionManager.tsx
Fusion 6792fa5673 feat(FN-4124): address drafts UX review feedback for MissionManager
Fixes UX review feedback for the MissionManager drafts feature, refactoring the component logic and updating corresponding tests. Minor CSS adjustments for the updated UI.

Fusion-Task-Id: FN-4124
2026-05-12 20:57:29 -07:00

4197 lines
187 KiB
TypeScript

import "./MissionManager.css";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { getErrorMessage } from "@fusion/core";
import {
X,
Plus,
Pencil,
Trash2,
ChevronRight,
ChevronDown,
ChevronLeft,
Target,
Layers,
Package,
Box,
Check,
Loader2,
Link,
Unlink,
Play,
Square,
Sparkles,
Zap,
Activity,
FileText,
RefreshCw,
} from "lucide-react";
import type { ToastType } from "../hooks/useToast";
import { useViewportMode } from "../hooks/useViewportMode";
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
import { subscribeSse } from "../sse-bus";
import { MissionInterviewModal } from "./MissionInterviewModal";
import { MilestoneSliceInterviewModal } from "./MilestoneSliceInterviewModal";
import type {
Mission,
MissionWithHierarchy,
MissionWithSummary,
Milestone,
Slice,
MissionFeature,
MissionStatus,
MilestoneStatus,
SliceStatus,
FeatureStatus,
MissionHealth,
MissionEvent,
MissionEventType,
MissionAssertionStatus,
MissionContractAssertion,
MilestoneValidationRollup,
MilestoneValidationTelemetry,
MissionFeatureLoopSnapshot,
MissionValidatorRun,
} from "./mission-types";
import {
fetchMissions,
createMission,
fetchMission,
updateMission,
deleteMission,
ApiRequestError,
createMilestone,
updateMilestone,
deleteMilestone,
createSlice,
updateSlice,
deleteSlice,
activateSlice,
createFeature,
updateFeature,
deleteFeature,
linkFeatureToTask,
unlinkFeatureFromTask,
triageFeature,
triageAllSliceFeatures,
previewEnrichedDescription,
resumeMission,
stopMission,
startMission,
updateMissionAutopilot,
fetchMissionsHealth,
fetchMissionEvents,
fetchAssertions,
createAssertion,
updateAssertion,
linkFeatureToAssertion,
unlinkFeatureFromAssertion,
fetchFeaturesForAssertion,
fetchMilestoneValidation,
fetchMilestoneValidationTelemetry,
triggerValidation,
fetchValidationLoopState,
fetchValidationRuns,
fetchValidationRun,
fetchAiSessions,
fetchAiSession,
fetchMissionInterviewDrafts,
discardMissionInterviewDraft,
type AiSessionSummary,
} from "../api";
import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types";
const MISSION_SIDEBAR_DEFAULT_WIDTH = 300;
const MISSION_SIDEBAR_MIN_WIDTH = 220;
const MISSION_SIDEBAR_MAX_WIDTH = 560;
const MISSION_SIDEBAR_STORAGE_KEY = "fusion:mission-sidebar-width";
interface MissionManagerProps {
isOpen: boolean;
isInline?: boolean;
onClose: () => void;
addToast: (message: string, type?: ToastType) => void;
projectId?: string;
onSelectTask?: (taskId: string) => void;
availableTasks?: Array<{ id: string; title?: string }>;
resumeSessionId?: string;
/** Pre-select and load this mission when the modal opens */
targetMissionId?: string;
/** Resume session ID for milestone/slice interview sessions */
milestoneSliceResumeSessionId?: string;
/** Called when milestone/slice resume session fetch fails */
onMilestoneSliceResumeFetchError?: () => void;
}
// Status badge colors — use CSS custom-property-compatible tokens
const missionStatusColors: Record<MissionStatus, { bg: string; text: string }> = {
planning: { bg: "var(--mission-planning-bg)", text: "var(--mission-planning-text)" },
active: { bg: "var(--mission-active-bg)", text: "var(--mission-active-text)" },
blocked: { bg: "var(--mission-blocked-bg)", text: "var(--mission-blocked-text)" },
complete: { bg: "var(--mission-complete-bg)", text: "var(--mission-complete-text)" },
archived: { bg: "var(--mission-archived-bg)", text: "var(--mission-archived-text)" },
};
const milestoneStatusColors: Record<MilestoneStatus, { bg: string; text: string }> = {
planning: { bg: "var(--mission-planning-bg)", text: "var(--mission-planning-text)" },
active: { bg: "var(--mission-active-bg)", text: "var(--mission-active-text)" },
blocked: { bg: "var(--mission-blocked-bg)", text: "var(--mission-blocked-text)" },
complete: { bg: "var(--mission-complete-bg)", text: "var(--mission-complete-text)" },
};
const sliceStatusColors: Record<SliceStatus, { bg: string; text: string }> = {
pending: { bg: "var(--slice-pending-bg)", text: "var(--slice-pending-text)" },
active: { bg: "var(--slice-active-bg)", text: "var(--slice-active-text)" },
complete: { bg: "var(--slice-complete-bg)", text: "var(--slice-complete-text)" },
};
const featureStatusColors: Record<FeatureStatus, { bg: string; text: string }> = {
defined: { bg: "var(--feature-defined-bg)", text: "var(--feature-defined-text)" },
triaged: { bg: "var(--feature-triaged-bg)", text: "var(--feature-triaged-text)" },
"in-progress": { bg: "var(--feature-in-progress-bg)", text: "var(--feature-in-progress-text)" },
done: { bg: "var(--feature-done-bg)", text: "var(--feature-done-text)" },
blocked: { bg: "var(--mission-blocked-bg)", text: "var(--mission-blocked-text)" },
};
const autopilotStateColors: Record<AutopilotState, { bg: string; text: string }> = {
inactive: { bg: "var(--autopilot-inactive-bg)", text: "var(--autopilot-inactive-text)" },
watching: { bg: "var(--autopilot-watching-bg)", text: "var(--autopilot-watching-text)" },
activating: { bg: "var(--autopilot-activating-bg)", text: "var(--autopilot-activating-text)" },
completing: { bg: "var(--autopilot-completing-bg)", text: "var(--autopilot-completing-text)" },
};
/** Assertion status colors */
const assertionStatusColors: Record<MissionAssertionStatus, { bg: string; text: string }> = {
pending: { bg: "var(--assertion-pending-bg)", text: "var(--assertion-pending-text)" },
passed: { bg: "var(--assertion-passed-bg)", text: "var(--assertion-passed-text)" },
failed: { bg: "var(--assertion-failed-bg)", text: "var(--assertion-failed-text)" },
blocked: { bg: "var(--assertion-blocked-bg)", text: "var(--assertion-blocked-text)" },
};
const validationStateColors: Record<string, { bg: string; text: string }> = {
not_started: { bg: "var(--assertion-pending-bg)", text: "var(--assertion-pending-text)" },
needs_coverage: { bg: "var(--loop-needs-fix-bg)", text: "var(--loop-needs-fix-text)" },
ready: { bg: "var(--loop-validating-bg)", text: "var(--loop-validating-text)" },
passed: { bg: "var(--loop-passed-bg)", text: "var(--loop-passed-text)" },
failed: { bg: "var(--loop-blocked-bg)", text: "var(--loop-blocked-text)" },
blocked: { bg: "var(--loop-blocked-bg)", text: "var(--loop-blocked-text)" },
};
const featureRetryBudgetMax = 3;
const missionInterviewListStatuses: ReadonlySet<AiSessionSummary["status"]> = new Set([
"generating",
"awaiting_input",
"error",
]);
function getInterviewStatusLabel(status: AiSessionSummary["status"]): string {
switch (status) {
case "generating":
return "Generating plan";
case "awaiting_input":
return "Awaiting input";
case "error":
return "Needs retry";
default:
return status;
}
}
function getInterviewActionLabel(status: AiSessionSummary["status"]): string {
return status === "error" ? "Retry interview" : "Resume interview";
}
/** Get the plan state for a milestone (derived from interviewState) */
function getMilestonePlanState(interviewState?: string): "not_started" | "planned" | "needs_update" {
if (interviewState === "completed") return "planned";
if (interviewState === "needs_update") return "needs_update";
return "not_started";
}
/** Render a plan state indicator badge */
function PlanStateIndicator({ state }: { state: "not_started" | "planned" | "needs_update" }) {
const stateClass =
state === "planned"
? "mission-plan-state-indicator--planned"
: state === "needs_update"
? "mission-plan-state-indicator--needs-update"
: "mission-plan-state-indicator--not-started";
const title =
state === "planned"
? "Planned"
: state === "needs_update"
? "Needs update"
: "Not planned";
return (
<span
className={`mission-plan-state-indicator ${stateClass}`}
title={title}
aria-label={title}
/>
);
}
/** Convert validation state snake_case to human-readable label */
function formatValidationState(state?: string): string {
if (!state) return "Not started";
// Replace underscores with spaces and title-case the result
return state.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase());
}
// Form types
interface MissionFormData {
title: string;
description: string;
status: MissionStatus;
autopilotEnabled: boolean;
}
interface MilestoneFormData {
title: string;
description: string;
status: MilestoneStatus;
dependencies: string[];
}
interface SliceFormData {
title: string;
description: string;
status: SliceStatus;
}
interface FeatureFormData {
title: string;
description: string;
acceptanceCriteria: string;
status: FeatureStatus;
}
const EMPTY_MISSION_FORM: MissionFormData = {
title: "",
description: "",
status: "planning",
autopilotEnabled: false,
};
const EMPTY_MILESTONE_FORM: MilestoneFormData = {
title: "",
description: "",
status: "planning",
dependencies: [],
};
const EMPTY_SLICE_FORM: SliceFormData = {
title: "",
description: "",
status: "pending",
};
const EMPTY_FEATURE_FORM: FeatureFormData = {
title: "",
description: "",
acceptanceCriteria: "",
status: "defined",
};
type MissionHealthState = "healthy" | "warning" | "error";
const HOUR_MS = 60 * 60 * 1000;
function getRelativeTime(timestamp?: string): string {
if (!timestamp) return "—";
const ts = new Date(timestamp).getTime();
if (Number.isNaN(ts)) return "—";
const diffMs = Date.now() - ts;
if (diffMs < 0) return "just now";
const diffMinutes = Math.floor(diffMs / (60 * 1000));
if (diffMinutes < 1) return "just now";
if (diffMinutes < 60) return `${diffMinutes}m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
function getMissionHealthState(health?: MissionHealth): MissionHealthState {
if (!health) return "healthy";
const hasRecentError =
typeof health.lastErrorAt === "string" &&
Date.now() - new Date(health.lastErrorAt).getTime() <= HOUR_MS;
const failureRateThresholdExceeded =
health.totalTasks > 0 && health.tasksFailed > health.totalTasks * 0.3;
if (hasRecentError || failureRateThresholdExceeded) {
return "error";
}
if (health.tasksFailed > 0) {
return "warning";
}
if (health.tasksFailed === 0 && health.tasksInFlight <= health.totalTasks) {
return "healthy";
}
return "warning";
}
function isMissionHealth(value: unknown): value is MissionHealth {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<MissionHealth>;
return (
typeof candidate.missionId === "string" &&
typeof candidate.tasksCompleted === "number" &&
typeof candidate.tasksFailed === "number" &&
typeof candidate.tasksInFlight === "number" &&
typeof candidate.totalTasks === "number" &&
typeof candidate.estimatedCompletionPercent === "number"
);
}
function isMissionEvent(value: unknown): value is MissionEvent {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<MissionEvent>;
return (
typeof candidate.id === "string" &&
typeof candidate.missionId === "string" &&
typeof candidate.eventType === "string" &&
typeof candidate.description === "string" &&
typeof candidate.timestamp === "string"
);
}
function isMilestoneValidationTelemetry(value: unknown): value is MilestoneValidationTelemetry {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<MilestoneValidationTelemetry>;
return (
typeof candidate.rollup?.milestoneId === "string" &&
typeof candidate.rollup?.state === "string" &&
Array.isArray(candidate.validationTelemetry?.validationRounds) &&
typeof candidate.validationTelemetry?.totalRuns === "number" &&
candidate.validationContract !== undefined &&
Array.isArray(candidate.fixFeatures)
);
}
const TASK_EVENT_TYPES: MissionEventType[] = ["feature_triaged", "feature_completed"];
const SLICE_EVENT_TYPES: MissionEventType[] = ["slice_activated", "slice_completed", "milestone_completed"];
const STATE_CHANGE_EVENT_TYPES: MissionEventType[] = [
"mission_started",
"mission_paused",
"mission_resumed",
"mission_completed",
];
const AUTOPILOT_EVENT_TYPES: MissionEventType[] = [
"autopilot_enabled",
"autopilot_disabled",
"autopilot_state_changed",
"autopilot_retry",
"autopilot_stale",
];
function matchesEventFilter(
eventType: MissionEventType,
filter: "all" | "errors" | "state_changes" | "tasks" | "slices" | "autopilot",
): boolean {
switch (filter) {
case "errors":
return eventType === "error" || eventType === "warning";
case "state_changes":
return STATE_CHANGE_EVENT_TYPES.includes(eventType);
case "tasks":
return TASK_EVENT_TYPES.includes(eventType);
case "slices":
return SLICE_EVENT_TYPES.includes(eventType);
case "autopilot":
return AUTOPILOT_EVENT_TYPES.includes(eventType);
default:
return true;
}
}
function getEventTypeClassName(eventType: MissionEventType): string {
if (eventType === "error" || eventType === "warning") {
return "mission-event__type--error";
}
if (STATE_CHANGE_EVENT_TYPES.includes(eventType)) {
return "mission-event__type--state";
}
if (TASK_EVENT_TYPES.includes(eventType)) {
return "mission-event__type--task";
}
if (SLICE_EVENT_TYPES.includes(eventType)) {
return "mission-event__type--slice";
}
if (AUTOPILOT_EVENT_TYPES.includes(eventType)) {
return "mission-event__type--autopilot";
}
return "mission-event__type--default";
}
function getEventTypeLabel(eventType: MissionEventType): string {
return eventType.replace(/_/g, " ");
}
function getActivityQueryEventType(
_filter: "all" | "errors" | "state_changes" | "tasks" | "slices" | "autopilot",
): MissionEventType | undefined {
// Keep query unfiltered to support grouped UI filters (e.g. errors + warnings).
return undefined;
}
function getAutopilotActivitySummary(state: AutopilotState, lastActivityAt?: string): string | null {
if (!lastActivityAt) {
return null;
}
if (state === "watching") {
return `Watching since ${getRelativeTime(lastActivityAt)}`;
}
return `Last activation ${getRelativeTime(lastActivityAt)}`;
}
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError }: MissionManagerProps) {
const isActive = isInline || isOpen;
const [missions, setMissions] = useState<MissionWithSummary[]>([]);
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const isMobile = useViewportMode() === "mobile";
const { pushNav } = useNavigationHistoryContext();
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
if (typeof window === "undefined") return MISSION_SIDEBAR_DEFAULT_WIDTH;
const stored = window.localStorage.getItem(MISSION_SIDEBAR_STORAGE_KEY);
const parsed = stored ? Number(stored) : NaN;
if (!Number.isFinite(parsed)) return MISSION_SIDEBAR_DEFAULT_WIDTH;
return Math.max(MISSION_SIDEBAR_MIN_WIDTH, Math.min(MISSION_SIDEBAR_MAX_WIDTH, parsed));
});
const persistSidebarWidth = useCallback((width: number) => {
try {
window.localStorage.setItem(MISSION_SIDEBAR_STORAGE_KEY, String(width));
} catch {
// Ignore storage errors.
}
}, []);
const handleSidebarResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (isMobile) return;
event.preventDefault();
event.stopPropagation();
const handle = event.currentTarget;
if (typeof handle.setPointerCapture === "function") {
handle.setPointerCapture(event.pointerId);
}
const startX = event.clientX;
const startWidth = sidebarWidth;
let latestWidth = startWidth;
document.body.style.userSelect = "none";
const onPointerMove = (moveEvent: PointerEvent) => {
const deltaX = moveEvent.clientX - startX;
const nextWidth = Math.max(
MISSION_SIDEBAR_MIN_WIDTH,
Math.min(MISSION_SIDEBAR_MAX_WIDTH, startWidth + deltaX),
);
latestWidth = nextWidth;
setSidebarWidth(nextWidth);
};
const onPointerUp = (upEvent: PointerEvent) => {
if (typeof handle.releasePointerCapture === "function") {
handle.releasePointerCapture(upEvent.pointerId);
}
document.body.style.userSelect = "";
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
persistSidebarWidth(latestWidth);
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
}, [isMobile, persistSidebarWidth, sidebarWidth]);
const handleSidebarResizeKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (isMobile) return;
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const step = event.shiftKey ? 50 : 10;
const delta = event.key === "ArrowLeft" ? -step : step;
const nextWidth = Math.max(
MISSION_SIDEBAR_MIN_WIDTH,
Math.min(MISSION_SIDEBAR_MAX_WIDTH, sidebarWidth + delta),
);
setSidebarWidth(nextWidth);
persistSidebarWidth(nextWidth);
}, [isMobile, persistSidebarWidth, sidebarWidth]);
// Form states
const [isCreatingMission, setIsCreatingMission] = useState(false);
const [editingMissionId, setEditingMissionId] = useState<string | null>(null);
const [missionForm, setMissionForm] = useState<MissionFormData>(EMPTY_MISSION_FORM);
const [saving, setSaving] = useState(false);
const [expandedMilestones, setExpandedMilestones] = useState<Set<string>>(new Set());
const [expandedSlices, setExpandedSlices] = useState<Set<string>>(new Set());
// Editing states for nested items
const [editingMilestoneId, setEditingMilestoneId] = useState<string | null>(null);
const [milestoneForm, setMilestoneForm] = useState<MilestoneFormData>(EMPTY_MILESTONE_FORM);
const [isCreatingMilestone, setIsCreatingMilestone] = useState(false);
const [editingSliceId, setEditingSliceId] = useState<string | null>(null);
const [sliceForm, setSliceForm] = useState<SliceFormData>(EMPTY_SLICE_FORM);
const [isCreatingSlice, setIsCreatingSlice] = useState(false);
const [selectedMilestoneIdForNewSlice, setSelectedMilestoneIdForNewSlice] = useState<string | null>(null);
const [editingFeatureId, setEditingFeatureId] = useState<string | null>(null);
const [featureForm, setFeatureForm] = useState<FeatureFormData>(EMPTY_FEATURE_FORM);
const [isCreatingFeature, setIsCreatingFeature] = useState(false);
const [selectedSliceIdForNewFeature, setSelectedSliceIdForNewFeature] = useState<string | null>(null);
// Link task modal state
const [linkTaskFeatureId, setLinkTaskFeatureId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState("");
// AI Interview modal
const [showInterviewModal, setShowInterviewModal] = useState(false);
// Pending mission interview sessions (for resume prompt after page reload)
const [_pendingInterviewSessions, setPendingInterviewSessions] = useState<AiSessionSummary[]>([]);
const [missionInterviewDrafts, setMissionInterviewDrafts] = useState<MissionInterviewDraftSummary[]>([]);
const [localResumeSessionId, setLocalResumeSessionId] = useState<string | undefined>(undefined);
const dismissedResumeSessionIdRef = useRef<string | null>(null);
const effectiveResumeSessionId =
localResumeSessionId ??
(resumeSessionId && dismissedResumeSessionIdRef.current === resumeSessionId ? undefined : resumeSessionId);
// Milestone/Slice interview modal
const [interviewTarget, setInterviewTarget] = useState<{
type: "milestone" | "slice";
id: string;
title: string;
resumeSessionId?: string;
} | null>(null);
// Triage preview state
const [triagePreview, setTriagePreview] = useState<{
featureId: string;
enrichedDescription: string;
} | null>(null);
const [triagePreviewLoading, setTriagePreviewLoading] = useState<string | null>(null);
// Auto-open interview modal when resuming a session
useEffect(() => {
if (isActive && effectiveResumeSessionId) {
setShowInterviewModal(true);
}
}, [isActive, effectiveResumeSessionId]);
// If parent requests a different resume session, allow it to open again.
useEffect(() => {
if (resumeSessionId && dismissedResumeSessionIdRef.current !== resumeSessionId) {
dismissedResumeSessionIdRef.current = null;
}
}, [resumeSessionId]);
// 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]);
// Auto-open milestone/slice interview modal when resuming from background session
useEffect(() => {
if (!isActive || !milestoneSliceResumeSessionId) return;
let cancelled = false;
fetchAiSession(milestoneSliceResumeSessionId).then((session) => {
if (cancelled || !session) return;
// Parse the inputPayload to get target info
try {
const payload = JSON.parse(session.inputPayload || "{}");
if (payload.targetId && payload.targetType) {
setInterviewTarget({
type: payload.targetType as "milestone" | "slice",
id: payload.targetId,
title: payload.targetTitle || session.title,
resumeSessionId: milestoneSliceResumeSessionId,
});
}
} catch {
// If parsing fails, try to use session title as fallback
setInterviewTarget({
type: "milestone",
id: "",
title: session.title,
resumeSessionId: milestoneSliceResumeSessionId,
});
}
}).catch((err) => {
if (cancelled) return;
console.warn("[MissionManager] Failed to fetch session for milestone/slice resume:", err);
onMilestoneSliceResumeFetchError?.();
});
return () => { cancelled = true; };
}, [isActive, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError]);
// Delete confirmation
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);
// Assertion panel state
const [assertionsByMilestone, setAssertionsByMilestone] = useState<Map<string, MissionContractAssertion[]>>(new Map());
const [editingAssertionId, setEditingAssertionId] = useState<string | null>(null);
const [assertionForm, setAssertionForm] = useState<{ title: string; assertion: string; status: MissionAssertionStatus }>({
title: "",
assertion: "",
status: "pending",
});
const [isCreatingAssertion, setIsCreatingAssertion] = useState(false);
const [expandedAssertionId, setExpandedAssertionId] = useState<string | null>(null);
const [linkedFeaturesByAssertion, setLinkedFeaturesByAssertion] = useState<Map<string, MissionFeature[]>>(new Map());
const [linkingAssertions, setLinkingAssertions] = useState<Set<string>>(new Set());
const [unlinkingFeatures, setUnlinkingFeatures] = useState<Set<string>>(new Set());
const [featurePickerOpenForAssertion, setFeaturePickerOpenForAssertion] = useState<string | null>(null);
const [validationRollupByMilestone, setValidationRollupByMilestone] = useState<Map<string, MilestoneValidationRollup>>(new Map());
const [selectedMilestoneId, setSelectedMilestoneId] = useState<string | null>(null);
const [validationTelemetry, setValidationTelemetry] = useState<MilestoneValidationTelemetry | null>(null);
const [validationRoundsExpanded, setValidationRoundsExpanded] = useState(true);
const [validatingFeatures, setValidatingFeatures] = useState<Set<string>>(new Set());
// Feature loop state
const [featureLoopStates, setFeatureLoopStates] = useState<Map<string, MissionFeatureLoopSnapshot>>(new Map());
// Expanded feature for run history display
const [expandedFeatureId, setExpandedFeatureId] = useState<string | null>(null);
// Validation runs by feature
const [validationRunsByFeature, setValidationRunsByFeature] = useState<Map<string, MissionValidatorRun[]>>(new Map());
// Expanded run ID for showing details with failures
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
// Run details with failures (keyed by runId)
const [runDetailsByRunId, setRunDetailsByRunId] = useState<Map<string, MissionValidatorRun & { failures?: Array<{ id: string; assertionId: string; message?: string; expected?: string; actual?: string }> }>>(new Map());
const [missionHealthById, setMissionHealthById] = useState<Map<string, MissionHealth>>(new Map());
const [activeTab, setActiveTab] = useState<"structure" | "activity">("structure");
const [missionEvents, setMissionEvents] = useState<MissionEvent[]>([]);
const missionEventsRef = useRef<MissionEvent[]>([]);
const missionsRef = useRef<MissionWithSummary[]>([]);
const selectedMissionRef = useRef<MissionWithHierarchy | null>(null);
const selectedMilestoneIdRef = useRef<string | null>(null);
const activeTabRef = useRef<"structure" | "activity">("structure");
const eventsFilterRef = useRef<"all" | "errors" | "state_changes" | "tasks" | "slices" | "autopilot">("all");
const [eventsLoading, setEventsLoading] = useState(false);
const [eventsTotal, setEventsTotal] = useState(0);
const [eventsFilter, setEventsFilter] = useState<
"all" | "errors" | "state_changes" | "tasks" | "slices" | "autopilot"
>("all");
const [expandedEventMetadata, setExpandedEventMetadata] = useState<Set<string>>(new Set());
const activityEventsContainerRef = useRef<HTMLDivElement>(null);
const activityEventsEndRef = useRef<HTMLDivElement>(null);
// Keep latest state available to long-lived SSE handlers without reconnect churn.
missionsRef.current = missions;
selectedMissionRef.current = selectedMission;
selectedMilestoneIdRef.current = selectedMilestoneId;
activeTabRef.current = activeTab;
eventsFilterRef.current = eventsFilter;
const scrollActivityToLatest = useCallback((behavior: ScrollBehavior = "auto") => {
const endNode = activityEventsEndRef.current;
if (endNode && typeof endNode.scrollIntoView === "function") {
endNode.scrollIntoView({ block: "end", behavior });
return;
}
const container = activityEventsContainerRef.current;
if (container) {
container.scrollTop = container.scrollHeight;
}
}, []);
const isActivityScrolledNearBottom = useCallback(() => {
const container = activityEventsContainerRef.current;
if (!container) {
return true;
}
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
return distanceToBottom <= 100;
}, []);
const loadMissionHealth = useCallback(async (missionList: MissionWithSummary[]) => {
if (missionList.length === 0) {
setMissionHealthById(new Map());
return;
}
// Use batched endpoint for optimal performance (1 request instead of N)
const healthRecord = await fetchMissionsHealth(projectId);
setMissionHealthById((prev) => {
const next = new Map(prev);
for (const [missionId, health] of Object.entries(healthRecord)) {
if (isMissionHealth(health)) {
next.set(missionId, health);
}
}
return next;
});
}, [projectId]);
const loadMissions = useCallback(async () => {
try {
setLoading(true);
const data = await fetchMissions(projectId);
setMissions(data);
void loadMissionHealth(data);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to load missions", "error");
} finally {
setLoading(false);
}
}, [addToast, projectId, loadMissionHealth]);
const loadMissionDetail = useCallback(async (missionId: string) => {
try {
setDetailLoading(true);
const data = await fetchMission(missionId, projectId);
// Guard against malformed responses (e.g. test fetch fallbacks): without
// a milestones array the detail view crashes on `.milestones.length`.
if (!data || !Array.isArray((data as MissionWithHierarchy).milestones)) {
setDetailLoading(false);
return;
}
setSelectedMission(data);
// Auto-expand first milestone and slice
if (data.milestones.length > 0) {
const firstMilestoneId = data.milestones[0].id;
setSelectedMilestoneId(firstMilestoneId);
setValidationRoundsExpanded(true);
setExpandedMilestones(new Set([firstMilestoneId]));
// Load assertions and validation rollup for the first milestone (inline to avoid forward ref)
fetchAssertions(firstMilestoneId, projectId).then((assertions) => {
setAssertionsByMilestone((prev) => {
const next = new Map(prev);
next.set(firstMilestoneId, assertions);
return next;
});
}).catch(() => { /* silently fail */ });
fetchMilestoneValidation(firstMilestoneId, projectId).then((rollup) => {
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(firstMilestoneId, rollup);
return next;
});
}).catch(() => { /* silently fail */ });
if (data.milestones[0].slices.length > 0) {
setExpandedSlices(new Set([data.milestones[0].slices[0].id]));
}
} else {
setSelectedMilestoneId(null);
setValidationTelemetry(null);
}
} catch (err) {
addToast(getErrorMessage(err) || "Failed to load mission details", "error");
} finally {
setDetailLoading(false);
}
}, [addToast, projectId]);
useEffect(() => {
if (!isActive || !selectedMilestoneId) {
setValidationTelemetry(null);
return;
}
let cancelled = false;
setValidationTelemetry(null);
fetchMilestoneValidationTelemetry(selectedMilestoneId, projectId)
.then((telemetry) => {
if (cancelled) {
return;
}
if (!isMilestoneValidationTelemetry(telemetry)) {
setValidationTelemetry(null);
return;
}
setValidationTelemetry(telemetry);
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(selectedMilestoneId, telemetry.rollup);
return next;
});
})
.catch(() => {
if (!cancelled) {
setValidationTelemetry(null);
}
});
return () => {
cancelled = true;
};
}, [isActive, selectedMilestoneId, projectId]);
useEffect(() => {
setValidationRoundsExpanded(true);
}, [selectedMilestoneId]);
const refreshValidationTelemetry = useCallback((milestoneId: string) => {
if (!milestoneId || milestoneId !== selectedMilestoneIdRef.current) {
return;
}
void fetchMilestoneValidationTelemetry(milestoneId, projectId)
.then((telemetry) => {
if (selectedMilestoneIdRef.current !== milestoneId || !isMilestoneValidationTelemetry(telemetry)) {
return;
}
setValidationTelemetry(telemetry);
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, telemetry.rollup);
return next;
});
})
.catch(() => {
// Silently fail - telemetry is supplemental
});
}, [projectId]);
const loadMissionEvents = useCallback(async (
missionId: string,
options?: { append?: boolean },
) => {
const append = options?.append ?? false;
const offset = append ? missionEventsRef.current.length : 0;
if (!append) {
setEventsLoading(true);
setExpandedEventMetadata(new Set());
}
try {
const response = await fetchMissionEvents(
missionId,
{
limit: 50,
offset,
eventType: getActivityQueryEventType(eventsFilter),
},
projectId,
);
const incomingEvents = response.events.filter((event) => matchesEventFilter(event.eventType, eventsFilter));
setMissionEvents((prev) => {
if (!append) {
missionEventsRef.current = incomingEvents;
return incomingEvents;
}
const existing = new Set(prev.map((event) => event.id));
const merged = [...prev];
for (const event of incomingEvents) {
if (!existing.has(event.id)) {
merged.push(event);
}
}
missionEventsRef.current = merged;
return merged;
});
setEventsTotal(response.total);
if (!append) {
requestAnimationFrame(() => {
scrollActivityToLatest("auto");
});
}
} catch (err) {
addToast(getErrorMessage(err) || "Failed to load mission activity", "error");
} finally {
if (!append) {
setEventsLoading(false);
}
}
}, [addToast, eventsFilter, projectId, scrollActivityToLatest]);
useEffect(() => {
missionEventsRef.current = missionEvents;
}, [missionEvents]);
useEffect(() => {
if (isActive) {
loadMissions();
setSelectedMission(null);
setSelectedMilestoneId(null);
setValidationTelemetry(null);
setMissionEvents([]);
setEventsTotal(0);
setActiveTab("structure");
setEventsFilter("all");
setExpandedEventMetadata(new Set());
}
}, [isActive, loadMissions]);
// Auto-load target mission when specified
const targetLoadedRef = useRef<string | null>(null);
useEffect(() => {
if (isActive && targetMissionId && targetLoadedRef.current !== targetMissionId && missions.length > 0) {
targetLoadedRef.current = targetMissionId;
loadMissionDetail(targetMissionId);
}
}, [isActive, targetMissionId, missions, loadMissionDetail]);
// Reset target tracking when modal closes
useEffect(() => {
if (!isActive) {
targetLoadedRef.current = null;
}
}, [isActive]);
// Default-select the first mission once the list loads (inline desktop view).
// Gated on `isInline` so the standalone modal flow (and unit tests that
// render without isInline) keep the explicit "select a mission" empty state.
const defaultSelectedRef = useRef(false);
useEffect(() => {
if (!isActive || !isInline || isMobile || loading) return;
if (defaultSelectedRef.current) return;
if (selectedMission || targetMissionId) return;
if (missions.length === 0) return;
defaultSelectedRef.current = true;
loadMissionDetail(missions[0].id);
}, [isActive, isInline, isMobile, loading, missions, selectedMission, targetMissionId, loadMissionDetail]);
useEffect(() => {
if (!isActive) {
defaultSelectedRef.current = false;
}
}, [isActive]);
useEffect(() => {
if (!isActive || !selectedMission || activeTab !== "activity") {
return;
}
void loadMissionEvents(selectedMission.id);
}, [activeTab, isActive, loadMissionEvents, selectedMission, eventsFilter]);
useEffect(() => {
if (!isActive || typeof EventSource === "undefined") {
return;
}
const search = new URLSearchParams();
if (projectId) {
search.set("projectId", projectId);
}
const eventUrl = `/api/events${search.size > 0 ? `?${search.toString()}` : ""}`;
const refreshHealth = () => {
void loadMissionHealth(missionsRef.current);
};
const handleMissionUpdated = (rawEvent: Event) => {
refreshHealth();
// Update mission status in the list to keep badges in sync
const messageEvent = rawEvent as MessageEvent<string>;
if (messageEvent.data) {
try {
const updatedMission = JSON.parse(messageEvent.data);
if (updatedMission?.id) {
setMissions((prev) =>
prev.map((m) =>
m.id === updatedMission.id ? { ...m, ...updatedMission } : m
)
);
}
} catch {
// ignore invalid payloads
}
}
// Reload the selected mission detail to reflect updated mission state (autopilot, status, etc.)
if (selectedMissionRef.current) {
void loadMissionDetail(selectedMissionRef.current.id);
}
};
const handleSliceUpdated = (_rawEvent: Event) => {
refreshHealth();
// Reload the selected mission detail to reflect updated slice status
if (selectedMissionRef.current) {
void loadMissionDetail(selectedMissionRef.current.id);
}
};
const handleFeatureUpdated = () => {
refreshHealth();
// Reload the selected mission detail to reflect updated feature status
if (selectedMissionRef.current) {
void loadMissionDetail(selectedMissionRef.current.id);
}
};
const handleMilestoneUpdated = (_rawEvent: Event) => {
refreshHealth();
// Reload the selected mission detail to reflect updated milestone status
if (selectedMissionRef.current) {
void loadMissionDetail(selectedMissionRef.current.id);
}
};
// Handler for validator run started - refresh feature loop state and validation runs
const handleValidatorRunStarted = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) return;
try {
const payload = JSON.parse(messageEvent.data);
if (payload && payload.featureId) {
// Refresh feature loop state
void loadFeatureLoopState(payload.featureId);
// Refresh validation runs
void loadValidationRuns(payload.featureId);
if (payload.milestoneId) {
refreshValidationTelemetry(payload.milestoneId);
}
}
} catch {
// ignore invalid payloads
}
};
// Handler for validator run completed - refresh feature loop state, runs, mission detail, and telemetry
const handleValidatorRunCompleted = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) return;
try {
const payload = JSON.parse(messageEvent.data);
if (payload && payload.featureId) {
// Refresh feature loop state
void loadFeatureLoopState(payload.featureId);
// Refresh validation runs
void loadValidationRuns(payload.featureId);
if (payload.milestoneId) {
refreshValidationTelemetry(payload.milestoneId);
}
// Refresh mission detail to update feature status
if (selectedMissionRef.current) {
void loadMissionDetail(selectedMissionRef.current.id);
}
}
} catch {
// ignore invalid payloads
}
};
// Handler for milestone validation updated - refresh validation rollup
const handleMilestoneValidationUpdated = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) return;
try {
const payload = JSON.parse(messageEvent.data);
if (payload && payload.milestoneId) {
void loadValidationRollup(payload.milestoneId);
refreshValidationTelemetry(payload.milestoneId);
}
} catch {
// ignore invalid payloads
}
};
// Handler for assertion mutations - refresh assertions and validation rollup
const handleAssertionMutation = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) return;
try {
const payload = JSON.parse(messageEvent.data);
if (payload && payload.milestoneId) {
void loadAssertionsForMilestone(payload.milestoneId);
void loadValidationRollup(payload.milestoneId);
refreshValidationTelemetry(payload.milestoneId);
}
} catch {
// ignore invalid payloads
}
};
// Handler for fix-feature:created - refresh mission detail to show new fix feature with lineage
const handleFixFeatureCreated = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) return;
try {
const payload = JSON.parse(messageEvent.data);
if (payload && payload.sourceFeatureId) {
// Refresh feature loop state for the source feature
void loadFeatureLoopState(payload.sourceFeatureId);
const createdFeatureSliceId = payload?.feature?.sliceId as string | undefined;
const selectedMission = selectedMissionRef.current;
if (createdFeatureSliceId && selectedMission) {
const containingMilestone = selectedMission.milestones.find((milestone) =>
milestone.slices.some((slice) => slice.id === createdFeatureSliceId)
);
if (containingMilestone) {
refreshValidationTelemetry(containingMilestone.id);
}
}
// Refresh mission detail to show the new fix feature in the list
if (selectedMissionRef.current) {
void loadMissionDetail(selectedMissionRef.current.id);
}
}
} catch {
// ignore invalid payloads
}
};
const handleMissionEvent = (rawEvent: Event) => {
refreshHealth();
const currentSelectedMission = selectedMissionRef.current;
if (!currentSelectedMission || activeTabRef.current !== "activity") {
return;
}
const shouldAutoScroll = isActivityScrolledNearBottom();
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) {
return;
}
try {
const payload = JSON.parse(messageEvent.data);
if (!isMissionEvent(payload)) {
return;
}
if (payload.missionId !== currentSelectedMission.id) {
return;
}
if (!matchesEventFilter(payload.eventType, eventsFilterRef.current)) {
return;
}
setMissionEvents((prev) => {
const withoutExisting = prev.filter((event) => event.id !== payload.id);
return [payload, ...withoutExisting].slice(0, 100);
});
setEventsTotal((prev) => prev + 1);
if (shouldAutoScroll) {
requestAnimationFrame(() => {
const container = activityEventsContainerRef.current;
if (container) {
container.scrollTop = 0;
}
});
}
} catch {
// ignore invalid payloads
}
};
return subscribeSse(eventUrl, {
events: {
"mission:updated": handleMissionUpdated,
"slice:updated": handleSliceUpdated,
"feature:updated": handleFeatureUpdated,
"milestone:updated": handleMilestoneUpdated,
"mission:event": handleMissionEvent,
"validator-run:started": handleValidatorRunStarted,
"validator-run:completed": handleValidatorRunCompleted,
"milestone:validation:updated": handleMilestoneValidationUpdated,
"assertion:created": handleAssertionMutation,
"assertion:updated": handleAssertionMutation,
"assertion:deleted": handleAssertionMutation,
"assertion:linked": handleAssertionMutation,
"assertion:unlinked": handleAssertionMutation,
"fix-feature:created": handleFixFeatureCreated,
},
});
}, [
isActive,
isActivityScrolledNearBottom,
loadMissionDetail,
loadMissionHealth,
projectId,
refreshValidationTelemetry,
]);
// Mission handlers
const handleEditMission = useCallback((mission: Mission) => {
setEditingMissionId(mission.id);
setIsCreatingMission(false);
setMissionForm({
title: mission.title,
description: mission.description || "",
status: mission.status,
autopilotEnabled: mission.autopilotEnabled ?? false,
});
}, []);
const handleCancelMission = useCallback(() => {
setEditingMissionId(null);
setIsCreatingMission(false);
setMissionForm(EMPTY_MISSION_FORM);
}, []);
const handleSaveMission = useCallback(async () => {
if (!missionForm.title.trim()) {
addToast("Mission title is required", "error");
return;
}
try {
setSaving(true);
if (isCreatingMission) {
await createMission({
title: missionForm.title.trim(),
description: missionForm.description.trim() || undefined,
autopilotEnabled: missionForm.autopilotEnabled,
}, projectId);
addToast("Mission created", "success");
} else if (editingMissionId) {
// Build update payload - when autopilot is enabled, also set autoAdvance
// for backward compat with the engine (though engine no longer reads it)
const updates: Record<string, unknown> = {
title: missionForm.title.trim(),
description: missionForm.description.trim() || undefined,
status: missionForm.status,
autopilotEnabled: missionForm.autopilotEnabled,
};
if (missionForm.autopilotEnabled) {
updates.autoAdvance = true;
}
await updateMission(editingMissionId, updates as Parameters<typeof updateMission>[1], projectId);
addToast("Mission updated", "success");
// Refresh detail view if viewing this mission
if (selectedMission?.id === editingMissionId) {
await loadMissionDetail(editingMissionId);
}
}
await loadMissions();
handleCancelMission();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to save mission", "error");
} finally {
setSaving(false);
}
}, [missionForm, isCreatingMission, editingMissionId, addToast, loadMissions, loadMissionDetail, selectedMission, handleCancelMission, projectId]);
const handleDeleteMission = useCallback(async (missionId: string) => {
try {
await deleteMission(missionId, projectId);
addToast("Mission deleted", "success");
if (selectedMission?.id === missionId) {
setSelectedMission(null);
}
await loadMissions();
setDeleteConfirmId(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to delete mission", "error");
}
}, [addToast, loadMissions, selectedMission, projectId]);
// Milestone handlers
const handleCreateMilestone = useCallback(() => {
setIsCreatingMilestone(true);
setEditingMilestoneId(null);
setMilestoneForm(EMPTY_MILESTONE_FORM);
}, []);
const handleEditMilestone = useCallback((milestone: Milestone) => {
setEditingMilestoneId(milestone.id);
setIsCreatingMilestone(false);
setMilestoneForm({
title: milestone.title,
description: milestone.description || "",
status: milestone.status,
dependencies: milestone.dependencies,
});
}, []);
const handleCancelMilestone = useCallback(() => {
setEditingMilestoneId(null);
setIsCreatingMilestone(false);
setMilestoneForm(EMPTY_MILESTONE_FORM);
}, []);
const handleSaveMilestone = useCallback(async () => {
if (!milestoneForm.title.trim()) {
addToast("Milestone title is required", "error");
return;
}
try {
setSaving(true);
if (isCreatingMilestone && selectedMission) {
await createMilestone(selectedMission.id, {
title: milestoneForm.title.trim(),
description: milestoneForm.description.trim() || undefined,
dependencies: milestoneForm.dependencies,
}, projectId);
addToast("Milestone created", "success");
} else if (editingMilestoneId) {
await updateMilestone(editingMilestoneId, {
title: milestoneForm.title.trim(),
description: milestoneForm.description.trim() || undefined,
status: milestoneForm.status,
dependencies: milestoneForm.dependencies,
}, projectId);
addToast("Milestone updated", "success");
}
await loadMissionDetail(selectedMission!.id);
handleCancelMilestone();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to save milestone", "error");
} finally {
setSaving(false);
}
}, [milestoneForm, isCreatingMilestone, editingMilestoneId, selectedMission, addToast, loadMissionDetail, handleCancelMilestone, missionForm.title, projectId]);
const handleDeleteMilestone = useCallback(async (milestoneId: string) => {
try {
await deleteMilestone(milestoneId, projectId);
addToast("Milestone deleted", "success");
await loadMissionDetail(selectedMission!.id);
setDeleteConfirmId(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to delete milestone", "error");
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
const toggleMilestoneExpanded = useCallback((milestoneId: string) => {
setSelectedMilestoneId(milestoneId);
setValidationRoundsExpanded(true);
setExpandedMilestones((prev) => {
const next = new Set(prev);
const isExpanding = !next.has(milestoneId);
if (isExpanding) {
next.add(milestoneId);
// Load assertions and validation rollup when expanding milestone
fetchAssertions(milestoneId, projectId).then((assertions) => {
setAssertionsByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, assertions);
return next;
});
}).catch(() => { /* silently fail */ });
fetchMilestoneValidation(milestoneId, projectId).then((rollup) => {
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, rollup);
return next;
});
}).catch(() => { /* silently fail */ });
} else {
next.delete(milestoneId);
}
return next;
});
}, [projectId]);
// Slice handlers
const handleCreateSlice = useCallback((milestoneId: string) => {
setSelectedMilestoneIdForNewSlice(milestoneId);
setIsCreatingSlice(true);
setEditingSliceId(null);
setSliceForm(EMPTY_SLICE_FORM);
}, []);
const handleEditSlice = useCallback((slice: Slice) => {
setEditingSliceId(slice.id);
setIsCreatingSlice(false);
setSliceForm({
title: slice.title,
description: slice.description || "",
status: slice.status,
});
}, []);
const handleCancelSlice = useCallback(() => {
setEditingSliceId(null);
setIsCreatingSlice(false);
setSelectedMilestoneIdForNewSlice(null);
setSliceForm(EMPTY_SLICE_FORM);
}, []);
const handleSaveSlice = useCallback(async () => {
if (!sliceForm.title.trim()) {
addToast("Slice title is required", "error");
return;
}
try {
setSaving(true);
if (isCreatingSlice && selectedMilestoneIdForNewSlice) {
await createSlice(selectedMilestoneIdForNewSlice, {
title: sliceForm.title.trim(),
description: sliceForm.description.trim() || undefined,
}, projectId);
addToast("Slice created", "success");
} else if (editingSliceId) {
await updateSlice(editingSliceId, {
title: sliceForm.title.trim(),
description: sliceForm.description.trim() || undefined,
status: sliceForm.status,
}, projectId);
addToast("Slice updated", "success");
}
await loadMissionDetail(selectedMission!.id);
handleCancelSlice();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to save slice", "error");
} finally {
setSaving(false);
}
}, [sliceForm, isCreatingSlice, editingSliceId, selectedMilestoneIdForNewSlice, selectedMission, addToast, loadMissionDetail, handleCancelSlice, projectId]);
const handleDeleteSlice = useCallback(async (sliceId: string) => {
try {
await deleteSlice(sliceId, projectId);
addToast("Slice deleted", "success");
await loadMissionDetail(selectedMission!.id);
setDeleteConfirmId(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to delete slice", "error");
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
const handleActivateSlice = useCallback(async (sliceId: string) => {
try {
await activateSlice(sliceId, projectId);
addToast("Slice activated", "success");
await loadMissionDetail(selectedMission!.id);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to activate slice", "error");
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
const toggleSliceExpanded = useCallback((sliceId: string) => {
setExpandedSlices((prev) => {
const next = new Set(prev);
if (next.has(sliceId)) {
next.delete(sliceId);
} else {
next.add(sliceId);
}
return next;
});
}, []);
// Feature handlers
const handleCreateFeature = useCallback((sliceId: string) => {
setSelectedSliceIdForNewFeature(sliceId);
setIsCreatingFeature(true);
setEditingFeatureId(null);
setFeatureForm(EMPTY_FEATURE_FORM);
}, []);
const handleEditFeature = useCallback((feature: MissionFeature) => {
setEditingFeatureId(feature.id);
setIsCreatingFeature(false);
setFeatureForm({
title: feature.title,
description: feature.description || "",
acceptanceCriteria: feature.acceptanceCriteria || "",
status: feature.status,
});
}, []);
const handleCancelFeature = useCallback(() => {
setEditingFeatureId(null);
setIsCreatingFeature(false);
setSelectedSliceIdForNewFeature(null);
setFeatureForm(EMPTY_FEATURE_FORM);
}, []);
const handleSaveFeature = useCallback(async () => {
if (!featureForm.title.trim()) {
addToast("Feature title is required", "error");
return;
}
try {
setSaving(true);
if (isCreatingFeature && selectedSliceIdForNewFeature) {
await createFeature(selectedSliceIdForNewFeature, {
title: featureForm.title.trim(),
description: featureForm.description.trim() || undefined,
acceptanceCriteria: featureForm.acceptanceCriteria.trim() || undefined,
}, projectId);
addToast("Feature created", "success");
} else if (editingFeatureId) {
await updateFeature(editingFeatureId, {
title: featureForm.title.trim(),
description: featureForm.description.trim() || undefined,
acceptanceCriteria: featureForm.acceptanceCriteria.trim() || undefined,
status: featureForm.status,
}, projectId);
addToast("Feature updated", "success");
}
await loadMissionDetail(selectedMission!.id);
handleCancelFeature();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to save feature", "error");
} finally {
setSaving(false);
}
}, [featureForm, isCreatingFeature, editingFeatureId, selectedSliceIdForNewFeature, selectedMission, addToast, loadMissionDetail, handleCancelFeature, projectId]);
const handleDeleteFeature = useCallback(async (featureId: string) => {
try {
await deleteFeature(featureId, projectId);
addToast("Feature deleted", "success");
await loadMissionDetail(selectedMission!.id);
setDeleteConfirmId(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to delete feature", "error");
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
const handleLinkTask = useCallback(async () => {
if (!linkTaskFeatureId || !selectedTaskId.trim()) {
addToast("Task ID is required", "error");
return;
}
try {
await linkFeatureToTask(linkTaskFeatureId, selectedTaskId.trim(), projectId);
addToast("Feature linked to task", "success");
await loadMissionDetail(selectedMission!.id);
setLinkTaskFeatureId(null);
setSelectedTaskId("");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to link feature to task", "error");
}
}, [linkTaskFeatureId, selectedTaskId, addToast, loadMissionDetail, selectedMission, projectId]);
const handleUnlinkTask = useCallback(async (featureId: string) => {
try {
await unlinkFeatureFromTask(featureId, projectId);
addToast("Feature unlinked from task", "success");
await loadMissionDetail(selectedMission!.id);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to unlink feature", "error");
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
// Triage a single feature — creates a task and links it
const handleTriageFeature = useCallback(async (featureId: string) => {
try {
setSaving(true);
await triageFeature(featureId, undefined, undefined, projectId);
addToast("Feature triaged — task created", "success");
await loadMissionDetail(selectedMission!.id);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to triage feature", "error");
} finally {
setSaving(false);
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
// Triage with preview — fetches enriched description first
const handleTriageFeatureWithPreview = useCallback(async (featureId: string) => {
setTriagePreviewLoading(featureId);
try {
const result = await previewEnrichedDescription(featureId, projectId);
setTriagePreview({ featureId, enrichedDescription: result.description });
} catch {
// Fallback to direct triage if preview endpoint not available
await handleTriageFeature(featureId);
} finally {
setTriagePreviewLoading(null);
}
}, [handleTriageFeature, projectId]);
// Confirm triage from preview
const handleConfirmTriageFromPreview = useCallback(async () => {
if (!triagePreview) return;
setTriagePreview(null);
await handleTriageFeature(triagePreview.featureId);
}, [handleTriageFeature, triagePreview]);
// Cancel triage preview
const handleCancelTriagePreview = useCallback(() => {
setTriagePreview(null);
}, []);
// Triage all defined features in a slice
const handleTriageAllSliceFeatures = useCallback(async (sliceId: string) => {
try {
setSaving(true);
const result = await triageAllSliceFeatures(sliceId, projectId);
addToast(`Triaged ${result.count} feature${result.count !== 1 ? "s" : ""}`, "success");
await loadMissionDetail(selectedMission!.id);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to triage slice features", "error");
} finally {
setSaving(false);
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
// ── Assertion handlers ──
const loadAssertionsForMilestone = useCallback(async (milestoneId: string) => {
try {
const assertions = await fetchAssertions(milestoneId, projectId);
setAssertionsByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, assertions);
return next;
});
} catch {
// Silently fail - assertions are optional
}
}, [projectId]);
const loadValidationRollup = useCallback(async (milestoneId: string) => {
try {
const rollup = await fetchMilestoneValidation(milestoneId, projectId);
setValidationRollupByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, rollup);
return next;
});
} catch {
// Silently fail
}
}, [projectId]);
const handleCreateAssertion = useCallback(async (milestoneId: string) => {
if (!assertionForm.title.trim() || !assertionForm.assertion.trim()) {
addToast("Title and assertion text are required", "error");
return;
}
try {
setSaving(true);
await createAssertion(milestoneId, {
title: assertionForm.title.trim(),
assertion: assertionForm.assertion.trim(),
status: assertionForm.status,
}, projectId);
addToast("Assertion created", "success");
await loadAssertionsForMilestone(milestoneId);
await loadValidationRollup(milestoneId);
setIsCreatingAssertion(false);
setAssertionForm({ title: "", assertion: "", status: "pending" });
} catch (err) {
addToast(getErrorMessage(err) || "Failed to create assertion", "error");
} finally {
setSaving(false);
}
}, [assertionForm, addToast, loadAssertionsForMilestone, loadValidationRollup, projectId]);
const handleEditAssertion = useCallback((assertion: MissionContractAssertion) => {
setEditingAssertionId(assertion.id);
setAssertionForm({
title: assertion.title,
assertion: assertion.assertion,
status: assertion.status,
});
}, []);
const handleCancelAssertion = useCallback(() => {
setEditingAssertionId(null);
setIsCreatingAssertion(false);
setAssertionForm({ title: "", assertion: "", status: "pending" });
}, []);
const handleSaveAssertion = useCallback(async (assertionId: string, milestoneId: string) => {
if (!assertionForm.title.trim() || !assertionForm.assertion.trim()) {
addToast("Title and assertion text are required", "error");
return;
}
try {
setSaving(true);
await updateAssertion(assertionId, {
title: assertionForm.title.trim(),
assertion: assertionForm.assertion.trim(),
status: assertionForm.status,
}, projectId);
addToast("Assertion updated", "success");
await loadAssertionsForMilestone(milestoneId);
await loadValidationRollup(milestoneId);
handleCancelAssertion();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to update assertion", "error");
} finally {
setSaving(false);
}
}, [assertionForm, addToast, loadAssertionsForMilestone, loadValidationRollup, handleCancelAssertion, projectId]);
const loadLinkedFeaturesForAssertion = useCallback(async (assertionId: string) => {
try {
const features = await fetchFeaturesForAssertion(assertionId, projectId);
setLinkedFeaturesByAssertion((prev) => {
const next = new Map(prev);
next.set(assertionId, features);
return next;
});
} catch {
// Silently fail
}
}, [projectId]);
const handleToggleAssertionExpanded = useCallback(async (assertionId: string) => {
const isExpanding = expandedAssertionId !== assertionId;
setExpandedAssertionId((prev) => (prev === assertionId ? null : assertionId));
if (isExpanding) {
await loadLinkedFeaturesForAssertion(assertionId);
}
}, [expandedAssertionId, loadLinkedFeaturesForAssertion]);
const focusAssertion = useCallback((assertionId: string) => {
setExpandedAssertionId(assertionId);
void loadLinkedFeaturesForAssertion(assertionId);
requestAnimationFrame(() => {
const assertionElement = document.querySelector(`[data-mission-assertion-id="${assertionId}"]`);
if (assertionElement instanceof HTMLElement && typeof assertionElement.scrollIntoView === "function") {
assertionElement.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
}, [loadLinkedFeaturesForAssertion]);
const handleLinkFeatureToAssertion = useCallback(async (featureId: string, assertionId: string) => {
try {
setLinkingAssertions((prev) => new Set(prev).add(assertionId));
await linkFeatureToAssertion(featureId, assertionId, projectId);
addToast("Feature linked to assertion", "success");
await loadLinkedFeaturesForAssertion(assertionId);
setFeaturePickerOpenForAssertion(null);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to link feature", "error");
} finally {
setLinkingAssertions((prev) => {
const next = new Set(prev);
next.delete(assertionId);
return next;
});
}
}, [addToast, loadLinkedFeaturesForAssertion, projectId]);
const handleUnlinkFeatureFromAssertion = useCallback(async (featureId: string, assertionId: string) => {
const key = `${featureId}-${assertionId}`;
try {
setUnlinkingFeatures((prev) => new Set(prev).add(key));
await unlinkFeatureFromAssertion(featureId, assertionId, projectId);
addToast("Feature unlinked from assertion", "success");
await loadLinkedFeaturesForAssertion(assertionId);
} catch (err) {
addToast(getErrorMessage(err) || "Failed to unlink feature", "error");
} finally {
setUnlinkingFeatures((prev) => {
const next = new Set(prev);
next.delete(key);
return next;
});
}
}, [addToast, loadLinkedFeaturesForAssertion, projectId]);
// ── Validation trigger ──
const handleTriggerValidation = useCallback(async (featureId: string) => {
try {
setValidatingFeatures((prev) => new Set(prev).add(featureId));
await triggerValidation(featureId, projectId);
addToast("Validation triggered", "success");
// Reload feature loop state
const snapshot = await fetchValidationLoopState(featureId, projectId);
setFeatureLoopStates((prev) => {
const next = new Map(prev);
next.set(featureId, snapshot);
return next;
});
} catch (err) {
addToast(getErrorMessage(err) || "Failed to trigger validation", "error");
} finally {
setValidatingFeatures((prev) => {
const next = new Set(prev);
next.delete(featureId);
return next;
});
}
}, [addToast, projectId]);
const loadFeatureLoopState = useCallback(async (featureId: string) => {
try {
const snapshot = await fetchValidationLoopState(featureId, projectId);
setFeatureLoopStates((prev) => {
const next = new Map(prev);
next.set(featureId, snapshot);
return next;
});
} catch {
// Silently fail
}
}, [projectId]);
// Load validation runs for a feature
const loadValidationRuns = useCallback(async (featureId: string) => {
try {
const runs = await fetchValidationRuns(featureId, { limit: 10 }, projectId);
setValidationRunsByFeature((prev) => {
const next = new Map(prev);
next.set(featureId, runs);
return next;
});
} catch {
// Silently fail
}
}, [projectId]);
const focusFeature = useCallback((featureId: string) => {
const mission = selectedMissionRef.current;
if (!mission) {
return;
}
for (const milestone of mission.milestones) {
for (const slice of milestone.slices) {
const targetFeature = slice.features.find((feature) => feature.id === featureId);
if (!targetFeature) {
continue;
}
setExpandedMilestones((prev) => {
const next = new Set(prev);
next.add(milestone.id);
return next;
});
setExpandedSlices((prev) => {
const next = new Set(prev);
next.add(slice.id);
return next;
});
setExpandedFeatureId(featureId);
setSelectedMilestoneId(milestone.id);
void loadFeatureLoopState(featureId);
void loadValidationRuns(featureId);
requestAnimationFrame(() => {
const featureElement = document.querySelector(`[data-mission-feature-id="${featureId}"]`);
if (featureElement instanceof HTMLElement && typeof featureElement.scrollIntoView === "function") {
featureElement.scrollIntoView({ behavior: "smooth", block: "center" });
}
});
return;
}
}
}, [loadFeatureLoopState, loadValidationRuns]);
// Load run detail with failures
const loadRunDetail = useCallback(async (runId: string) => {
try {
const detail = await fetchValidationRun(runId, projectId);
setRunDetailsByRunId((prev) => {
const next = new Map(prev);
next.set(runId, detail);
return next;
});
} catch {
// Silently fail
}
}, [projectId]);
// Toggle feature expansion to show run history
const toggleFeatureExpanded = useCallback(async (featureId: string) => {
if (expandedFeatureId === featureId) {
setExpandedFeatureId(null);
} else {
setExpandedFeatureId(featureId);
// Load loop state and validation runs when expanding
await loadFeatureLoopState(featureId);
await loadValidationRuns(featureId);
}
}, [expandedFeatureId, loadFeatureLoopState, loadValidationRuns]);
// Toggle run expansion to show failures
const toggleRunExpanded = useCallback(async (runId: string) => {
if (expandedRunId === runId) {
setExpandedRunId(null);
} else {
setExpandedRunId(runId);
await loadRunDetail(runId);
}
}, [expandedRunId, loadRunDetail]);
// Resume a paused mission — set status back to "active"
const handleResumeMission = useCallback(async (missionId: string) => {
try {
await resumeMission(missionId, projectId);
addToast("Mission resumed", "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to resume mission", "error");
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
// Stop mission — set status to "blocked" and pause all linked tasks
const handleStopMission = useCallback(async (missionId: string) => {
try {
const result = await stopMission(missionId, projectId);
const count = result.pausedTaskIds?.length ?? 0;
addToast(`Mission stopped (${count} task${count !== 1 ? "s" : ""} paused)`, "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to stop mission", "error");
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
// Start a planning mission — set status to "active" and activate first slice
const handleStartMission = useCallback(async (missionId: string) => {
try {
await startMission(missionId, projectId);
addToast("Mission started — first slice activated", "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to start mission", "error");
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
// ── Autopilot handlers ──
const handleToggleAutopilot = useCallback(async (missionId: string, enabled: boolean) => {
try {
await updateMissionAutopilot(missionId, { enabled }, projectId);
addToast(enabled ? "Autopilot enabled" : "Autopilot disabled", "success");
// Reload mission detail to reflect updated fields
await loadMissionDetail(missionId);
loadMissions();
} catch (err) {
addToast(getErrorMessage(err) || "Failed to update autopilot", "error");
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
const handleSelectMission = useCallback((mission: Mission) => {
setActiveTab("structure");
setSelectedMilestoneId(null);
setValidationTelemetry(null);
setMissionEvents([]);
setEventsTotal(0);
setEventsFilter("all");
setExpandedEventMetadata(new Set());
loadMissionDetail(mission.id);
}, [loadMissionDetail]);
const handleBackToList = useCallback(() => {
setSelectedMission(null);
setSelectedMilestoneId(null);
setValidationTelemetry(null);
setActiveTab("structure");
setMissionEvents([]);
setEventsTotal(0);
setEventsFilter("all");
setExpandedEventMetadata(new Set());
loadMissions();
}, [loadMissions]);
const hasMoreEvents = missionEvents.length < eventsTotal;
const autopilotState = (selectedMission?.autopilotState ?? "inactive") as AutopilotState;
const autopilotPulseActive = autopilotState === "watching" || autopilotState === "activating";
const autopilotActivitySummary = getAutopilotActivitySummary(
autopilotState,
selectedMission?.lastAutopilotActivityAt,
);
const previousSelectedMissionIdRef = useRef<string | null>(selectedMission?.id ?? null);
useEffect(() => {
const previousSelectedMissionId = previousSelectedMissionIdRef.current;
const currentSelectedMissionId = selectedMission?.id ?? null;
previousSelectedMissionIdRef.current = currentSelectedMissionId;
if (!isActive || !isMobile || !currentSelectedMissionId || previousSelectedMissionId === currentSelectedMissionId) {
return;
}
// MissionManager may already sit behind an App-level modal nav entry.
// On mobile, selecting a mission stacks a view entry on top so back goes
// detail → list → modal close instead of skipping the in-modal list.
pushNav({ type: "view", revert: handleBackToList });
}, [handleBackToList, isActive, isMobile, pushNav, selectedMission?.id]);
const selectedMilestoneTelemetry = useMemo(() => {
if (!validationTelemetry || !selectedMilestoneId || !isMilestoneValidationTelemetry(validationTelemetry)) {
return null;
}
return validationTelemetry.rollup.milestoneId === selectedMilestoneId ? validationTelemetry : null;
}, [selectedMilestoneId, validationTelemetry]);
const latestRoundsByFeatureId = useMemo(() => {
const roundsByFeature = new Map<string, MilestoneValidationTelemetry["validationTelemetry"]["validationRounds"][number]>();
for (const round of selectedMilestoneTelemetry?.validationTelemetry.validationRounds ?? []) {
const existing = roundsByFeature.get(round.featureId);
if (!existing || round.startedAt > existing.startedAt) {
roundsByFeature.set(round.featureId, round);
}
}
return roundsByFeature;
}, [selectedMilestoneTelemetry]);
const handleLoadMoreEvents = useCallback(() => {
if (!selectedMission || eventsLoading || !hasMoreEvents) {
return;
}
void loadMissionEvents(selectedMission.id, { append: true });
}, [eventsLoading, hasMoreEvents, loadMissionEvents, selectedMission]);
const toggleEventMetadata = useCallback((eventId: string) => {
setExpandedEventMetadata((prev) => {
const next = new Set(prev);
if (next.has(eventId)) {
next.delete(eventId);
} else {
next.add(eventId);
}
return next;
});
}, []);
// Keyboard handler for mission form
const handleMissionFormKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSaveMission();
}
}, [handleSaveMission]);
const handleMilestoneFormKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSaveMilestone();
}
}, [handleSaveMilestone]);
const handleSliceFormKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSaveSlice();
}
}, [handleSaveSlice]);
const handleFeatureFormKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSaveFeature();
}
}, [handleSaveFeature]);
// Ref for focus management
const modalRef = useRef<HTMLDivElement>(null);
// Escape key handling
useEffect(() => {
if (!isActive) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isActive, onClose]);
if (!isActive) return null;
const renderMissionDetailContent = () => {
if (!selectedMission) {
return null;
}
return (
<div className="mission-detail">
<div className="mission-detail__header">
<div className="mission-detail__title-row">
<div className="mission-detail__title-text">
{autopilotPulseActive && (
<span className="mission-detail__autopilot-dot" title="Autopilot watching" />
)}
<h3 className="mission-detail__title">{selectedMission.title}</h3>
</div>
<span
className="mission-status-badge"
style={{
backgroundColor: (missionStatusColors[selectedMission.status] || missionStatusColors.planning).bg,
color: (missionStatusColors[selectedMission.status] || missionStatusColors.planning).text,
}}
>
{selectedMission.status}
</span>
</div>
{selectedMission.description && (
<p className="mission-detail__description">{selectedMission.description}</p>
)}
<div className="mission-detail__meta">
<span className="mission-detail__meta-info">
{selectedMission.milestones.length} milestones
</span>
</div>
{/* ── Autopilot section ── */}
<div className="mission-detail__autopilot">
<div className="mission-detail__autopilot-toggle">
<label className="mission-toggle" data-testid="mission-autopilot-toggle">
<input
type="checkbox"
checked={selectedMission.autopilotEnabled ?? false}
onChange={(e) => handleToggleAutopilot(selectedMission.id, e.target.checked)}
aria-label="Autopilot"
/>
<span className="mission-toggle__track" aria-hidden="true">
<span className="mission-toggle__thumb" />
</span>
<span className="mission-toggle__label">
<Zap size={14} className="mission-detail__autopilot-icon" />
Autopilot
</span>
</label>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: (autopilotStateColors[autopilotState] || autopilotStateColors.inactive).bg,
color: (autopilotStateColors[autopilotState] || autopilotStateColors.inactive).text,
}}
data-testid="autopilot-state-badge"
>
{autopilotPulseActive && <span className="mission-detail__autopilot-pulse" />}
{autopilotState}
</span>
</div>
{autopilotActivitySummary && (
<span className="mission-detail__autopilot-activity mission-relative-time">
{autopilotActivitySummary}
</span>
)}
</div>
<div className="mission-detail__actions">
{selectedMission.status === "active" && (
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleStopMission(selectedMission.id)}
title="Stop mission"
aria-label="Stop mission"
>
<Square size={14} />
</button>
)}
{selectedMission.status === "blocked" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleResumeMission(selectedMission.id)}
title="Resume mission"
aria-label="Resume mission"
>
<Play size={14} />
</button>
)}
{selectedMission.status === "planning" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleStartMission(selectedMission.id)}
title="Start mission"
aria-label="Start mission"
>
<Play size={14} />
</button>
)}
<button
className="mission-icon-btn"
onClick={() => handleEditMission(selectedMission)}
title="Edit mission"
aria-label="Edit mission"
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "mission", id: selectedMission.id })}
title="Delete mission"
aria-label="Delete mission"
>
<Trash2 size={14} />
</button>
</div>
</div>
{/* Inline edit mission form (detail view) */}
{editingMissionId === selectedMission.id && (
<div className="mission-form-card">
<input
type="text"
placeholder="Mission title"
value={missionForm.title}
onChange={(e) => setMissionForm({ ...missionForm, title: e.target.value })}
onKeyDown={handleMissionFormKeyDown}
autoFocus
/>
<textarea
placeholder="Description (optional)"
value={missionForm.description}
onChange={(e) => setMissionForm({ ...missionForm, description: e.target.value })}
rows={2}
/>
<div className="mission-form-card__row">
<select
value={missionForm.status}
onChange={(e) => setMissionForm({ ...missionForm, status: e.target.value as MissionStatus })}
>
<option value="planning">Planning</option>
<option value="active">Active</option>
<option value="blocked">Blocked</option>
<option value="complete">Complete</option>
<option value="archived">Archived</option>
</select>
<label className="mission-checkbox">
<input
type="checkbox"
checked={missionForm.autopilotEnabled}
onChange={(e) => setMissionForm({ ...missionForm, autopilotEnabled: e.target.checked })}
/>
<Zap size={12} /> Autopilot
</label>
</div>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveMission} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Update
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelMission}>
Cancel
</button>
</div>
</div>
)}
<div className="mission-detail__tabs" role="tablist" aria-label="Mission detail tabs">
<button
className={`mission-btn ${activeTab === "structure" ? "mission-btn--primary" : "mission-btn--ghost"} mission-btn--sm mission-detail__tab`}
onClick={() => setActiveTab("structure")}
role="tab"
aria-selected={activeTab === "structure"}
data-testid="mission-tab-structure"
>
Structure
</button>
<button
className={`mission-btn ${activeTab === "activity" ? "mission-btn--primary" : "mission-btn--ghost"} mission-btn--sm mission-detail__tab`}
onClick={() => setActiveTab("activity")}
role="tab"
aria-selected={activeTab === "activity"}
data-testid="mission-tab-activity"
>
Activity ({eventsTotal})
</button>
</div>
{activeTab === "structure" ? (
<div className="mission-detail__milestones">
{selectedMission.milestones.map((milestone) => {
const milestoneTelemetry = selectedMilestoneTelemetry?.rollup.milestoneId === milestone.id
? selectedMilestoneTelemetry
: null;
const milestoneRollup = milestoneTelemetry?.rollup ?? validationRollupByMilestone.get(milestone.id);
const milestoneRounds = milestoneTelemetry?.validationTelemetry.validationRounds ?? [];
const milestoneFixFeatures = milestoneTelemetry?.fixFeatures ?? [];
const milestoneValidationColors = validationStateColors[milestoneRollup?.state ?? "not_started"]
?? validationStateColors.not_started;
const milestoneBlockedReason =
milestoneRollup && (milestoneRollup.state === "blocked" || milestoneRollup.state === "failed")
? milestoneTelemetry?.validationTelemetry.validationRounds.find((round) => round.blockedReason)?.blockedReason
: undefined;
return (
<div key={milestone.id} className="mission-milestone">
<div className="mission-milestone__header" onClick={() => toggleMilestoneExpanded(milestone.id)}>
<button className="mission-milestone__expand">
{expandedMilestones.has(milestone.id) ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<Layers size={16} className="mission-milestone__icon" />
<span className="mission-milestone__title">{milestone.title}</span>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: milestoneStatusColors[milestone.status].bg,
color: milestoneStatusColors[milestone.status].text,
}}
>
{milestone.status}
</span>
<span className="mission-milestone__count">{milestone.slices.length} slices</span>
<PlanStateIndicator state={getMilestonePlanState(milestone.interviewState)} />
{/* Validation state badge and coverage bar in milestone header */}
{milestoneRollup && (
<>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: milestoneValidationColors.bg,
color: milestoneValidationColors.text,
}}
title="Validation state"
>
{formatValidationState(milestoneRollup.state)}
</span>
{milestoneRollup.totalAssertions > 0 && (
<div
className="mission-milestone__coverage-bar"
title={`${(milestoneRollup.passedAssertions ?? 0)} of ${milestoneRollup.totalAssertions} assertions passing`}
>
<div
className="mission-milestone__coverage-bar-fill"
style={{
width: `${((milestoneRollup.passedAssertions ?? 0) / milestoneRollup.totalAssertions) * 100}%`,
backgroundColor: (milestoneRollup.passedAssertions ?? 0) === milestoneRollup.totalAssertions
? "var(--color-success)"
: "var(--color-warning)",
}}
/>
</div>
)}
</>
)}
{milestone.status !== "complete" && (
<button
className="mission-icon-btn"
onClick={() => setInterviewTarget({ type: "milestone", id: milestone.id, title: milestone.title })}
title="Plan milestone"
aria-label="Plan milestone"
>
<FileText size={14} />
</button>
)}
<div className="mission-milestone__actions" onClick={(e) => e.stopPropagation()}>
<button
className="mission-icon-btn"
onClick={() => handleCreateSlice(milestone.id)}
title="Add slice"
>
<Plus size={14} />
</button>
<button
className="mission-icon-btn"
onClick={() => handleEditMilestone(milestone)}
title="Edit milestone"
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "milestone", id: milestone.id })}
title="Delete milestone"
>
<Trash2 size={14} />
</button>
</div>
</div>
{expandedMilestones.has(milestone.id) && (
<div className="mission-milestone__body">
{/* Create milestone form (inline edit) */}
{(isCreatingMilestone || editingMilestoneId === milestone.id) && (
<div className="mission-form-card">
<input
type="text"
placeholder="Milestone title"
value={milestoneForm.title}
onChange={(e) => setMilestoneForm({ ...milestoneForm, title: e.target.value })}
onKeyDown={handleMilestoneFormKeyDown}
autoFocus
/>
<textarea
placeholder="Description (optional)"
value={milestoneForm.description}
onChange={(e) => setMilestoneForm({ ...milestoneForm, description: e.target.value })}
rows={2}
/>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveMilestone} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
{editingMilestoneId ? "Update" : "Create"}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelMilestone}>
Cancel
</button>
</div>
</div>
)}
{milestoneTelemetry && (
<div className="mission-validation-telemetry">
<div className="mission-validation-telemetry__header">
<span className="mission-validation-telemetry__title">Validation Telemetry</span>
<span className="mission-validation-telemetry__meta">
{milestoneTelemetry.validationTelemetry.totalRuns} rounds
{milestoneTelemetry.validationTelemetry.lastValidatorStatus
? ` · Last ${milestoneTelemetry.validationTelemetry.lastValidatorStatus}`
: ""}
</span>
</div>
{milestoneBlockedReason && (
<div className="mission-blocked-reason">
<strong>Blocked reason:</strong> {milestoneBlockedReason}
</div>
)}
{milestoneRounds.length > 0 && (
<div className="mission-validation-rounds">
<button
className="mission-btn mission-btn--ghost mission-btn--sm mission-validation-rounds__toggle"
onClick={() => setValidationRoundsExpanded((prev) => !prev)}
title={validationRoundsExpanded ? "Hide validation rounds" : "Show validation rounds"}
>
{validationRoundsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Validation rounds ({milestoneRounds.length})
</button>
{validationRoundsExpanded && (
<div className="mission-validation-rounds__list">
{milestoneRounds.map((round) => (
<div key={round.roundId} className="mission-validation-round">
<div className="mission-validation-round__header">
<span className={`mission-status-badge mission-status-badge--sm mission-validation-round__status mission-validation-round__status--${round.validatorStatus}`}>
{round.validatorStatus}
</span>
<span className="mission-validation-round__feature">{round.featureTitle}</span>
<span className="mission-validation-round__attempts">
impl #{round.implementationAttempt} · reviewer #{round.validatorAttempt}
</span>
</div>
<div className="mission-validation-round__links">
<span className="mission-validation-round__label">Failed assertions:</span>
{round.failedAssertionIds.length > 0 ? (
<div className="mission-validation-round__chip-list">
{round.failedAssertionIds.map((assertionId) => (
<button
key={`${round.roundId}-${assertionId}`}
className="mission-validation-round__link-chip"
onClick={() => focusAssertion(assertionId)}
title={`Jump to assertion ${assertionId}`}
>
{assertionId}
</button>
))}
</div>
) : (
<span className="mission-validation-round__empty">None</span>
)}
</div>
<div className="mission-validation-round__links">
<span className="mission-validation-round__label">Generated fix features:</span>
{round.generatedFixFeatureIds.length > 0 ? (
<div className="mission-validation-round__chip-list">
{round.generatedFixFeatureIds.map((fixFeatureId) => (
<button
key={`${round.roundId}-${fixFeatureId}`}
className="mission-validation-round__link-chip"
onClick={() => focusFeature(fixFeatureId)}
title={`Jump to fix feature ${fixFeatureId}`}
>
{fixFeatureId}
</button>
))}
</div>
) : (
<span className="mission-validation-round__empty">None</span>
)}
</div>
{round.blockedReason && (
<div className="mission-validation-round__blocked-reason">
{round.blockedReason}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
{milestoneFixFeatures.length > 0 && (
<div className="mission-fix-features">
<div className="mission-fix-features__title">Generated Fix Features</div>
<div className="mission-fix-features__list">
{milestoneFixFeatures.map((fixFeature) => (
<div key={fixFeature.id} className="mission-fix-feature">
<div className="mission-fix-feature__header">
<button
className="mission-fix-feature__title"
onClick={() => focusFeature(fixFeature.id)}
title={`Jump to feature ${fixFeature.id}`}
>
{fixFeature.title}
</button>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: featureStatusColors[fixFeature.status].bg,
color: featureStatusColors[fixFeature.status].text,
}}
>
{fixFeature.status}
</span>
{fixFeature.loopState && (
<span className={`mission-loop-state mission-loop-state--${fixFeature.loopState}`}>
{fixFeature.loopState}
</span>
)}
</div>
<div className="mission-fix-feature__meta">
<span>Source:</span>
<button
className="mission-validation-round__link-chip"
onClick={() => focusFeature(fixFeature.sourceFeatureId)}
title={`Jump to source feature ${fixFeature.sourceFeatureId}`}
>
{fixFeature.sourceFeatureId}
</button>
<span>Run:</span>
<span className="mission-fix-feature__run">{fixFeature.runId}</span>
</div>
{fixFeature.failedAssertionIds.length > 0 && (
<div className="mission-fix-feature__assertions">
<span className="mission-validation-round__label">Failed assertions:</span>
<div className="mission-validation-round__chip-list">
{fixFeature.failedAssertionIds.map((assertionId) => (
<button
key={`${fixFeature.id}-${assertionId}`}
className="mission-validation-round__link-chip"
onClick={() => focusAssertion(assertionId)}
title={`Jump to assertion ${assertionId}`}
>
{assertionId}
</button>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Slices */}
<div className="mission-slices">
{milestone.slices.map((slice) => (
<div key={slice.id} className="mission-slice">
<div className="mission-slice__header" onClick={() => toggleSliceExpanded(slice.id)}>
<button className="mission-slice__expand">
{expandedSlices.has(slice.id) ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
<Package size={16} className="mission-slice__icon" />
<span className="mission-slice__title">{slice.title}</span>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: sliceStatusColors[slice.status].bg,
color: sliceStatusColors[slice.status].text,
}}
>
{slice.status}
</span>
<span className="mission-slice__count">{slice.features?.length || 0} features</span>
<PlanStateIndicator state={slice.planState ?? "not_started"} />
{slice.status !== "complete" && (
<button
className="mission-icon-btn"
onClick={() => setInterviewTarget({ type: "slice", id: slice.id, title: slice.title })}
title="Plan slice"
aria-label="Plan slice"
>
<FileText size={14} />
</button>
)}
<div className="mission-slice__actions" onClick={(e) => e.stopPropagation()}>
{slice.status === "pending" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleActivateSlice(slice.id)}
title="Activate slice"
>
<Play size={14} />
</button>
)}
{slice.status === "active" && slice.features?.some((f) => f.status === "defined") && (
<button
className="mission-icon-btn"
onClick={() => handleTriageAllSliceFeatures(slice.id)}
title="Triage all features"
disabled={saving}
>
{saving ? <Loader2 size={14} className="spinner" /> : <Zap size={14} />}
</button>
)}
<button
className="mission-icon-btn"
onClick={() => handleCreateFeature(slice.id)}
title="Add feature"
>
<Plus size={14} />
</button>
<button
className="mission-icon-btn"
onClick={() => handleEditSlice(slice)}
title="Edit slice"
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "slice", id: slice.id })}
title="Delete slice"
>
<Trash2 size={14} />
</button>
</div>
</div>
{expandedSlices.has(slice.id) && (
<div className="mission-slice__body">
{/* Create slice form */}
{(isCreatingSlice && selectedMilestoneIdForNewSlice === milestone.id && !editingSliceId) && (
<div className="mission-form-card">
<input
type="text"
placeholder="Slice title"
value={sliceForm.title}
onChange={(e) => setSliceForm({ ...sliceForm, title: e.target.value })}
onKeyDown={handleSliceFormKeyDown}
autoFocus
/>
<textarea
placeholder="Description (optional)"
value={sliceForm.description}
onChange={(e) => setSliceForm({ ...sliceForm, description: e.target.value })}
rows={2}
/>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveSlice} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Create
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelSlice}>
Cancel
</button>
</div>
</div>
)}
{/* Edit slice form */}
{editingSliceId === slice.id && (
<div className="mission-form-card">
<input
type="text"
placeholder="Slice title"
value={sliceForm.title}
onChange={(e) => setSliceForm({ ...sliceForm, title: e.target.value })}
onKeyDown={handleSliceFormKeyDown}
autoFocus
/>
<textarea
placeholder="Description (optional)"
value={sliceForm.description}
onChange={(e) => setSliceForm({ ...sliceForm, description: e.target.value })}
rows={2}
/>
<select
value={sliceForm.status}
onChange={(e) => setSliceForm({ ...sliceForm, status: e.target.value as SliceStatus })}
>
<option value="pending">Pending</option>
<option value="active">Active</option>
<option value="complete">Complete</option>
</select>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveSlice} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Update
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelSlice}>
Cancel
</button>
</div>
</div>
)}
{/* Features */}
<div className="mission-features">
{slice.features?.map((feature) => (
<div
key={feature.id}
className="mission-feature"
data-mission-feature-id={feature.id}
>
<div className="mission-feature__header">
<button
className="mission-feature__expand"
onClick={() => toggleFeatureExpanded(feature.id)}
title={expandedFeatureId === feature.id ? "Collapse details" : "Expand to show run history"}
>
{expandedFeatureId === feature.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Box size={14} className="mission-feature__icon" />
<span className="mission-feature__title">{feature.title}</span>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: featureStatusColors[feature.status].bg,
color: featureStatusColors[feature.status].text,
}}
>
{feature.status}
</span>
{/* Loop state indicator */}
{(feature.loopState && feature.loopState !== "idle") && (
<span
className={`mission-loop-state mission-loop-state--${feature.loopState}`}
title={`Loop state: ${feature.loopState}`}
>
{feature.loopState === "implementing" && "⏳"}
{feature.loopState === "validating" && "🔄"}
{feature.loopState === "needs_fix" && "🔧"}
{feature.loopState === "passed" && "✅"}
{feature.loopState === "blocked" && "🚫"}
</span>
)}
{/* Lineage indicator for fix features - click to navigate to source feature */}
{feature.generatedFromFeatureId && (
<button
className="mission-feature__lineage"
onClick={() => focusFeature(feature.generatedFromFeatureId!)}
title={`Generated from feature: ${feature.generatedFromFeatureId}`}
>
🔗 Fix
</button>
)}
{/* Retry/iteration display for validating and needs-fix states */}
{(feature.loopState === "validating" || feature.loopState === "needs_fix") && (() => {
const loopSnapshot = featureLoopStates.get(feature.id);
const latestRound = latestRoundsByFeatureId.get(feature.id);
const implementationAttempt = loopSnapshot?.implementationAttemptCount
?? latestRound?.implementationAttempt
?? feature.implementationAttemptCount
?? 0;
const retryBudgetRemaining = loopSnapshot?.retryBudgetRemaining
?? Math.max(0, featureRetryBudgetMax - implementationAttempt);
return (
<span
className="mission-feature__retry-budget"
title="Implementation attempts and remaining retry budget"
>
Attempt {implementationAttempt} · {retryBudgetRemaining} {retryBudgetRemaining === 1 ? "retry" : "retries"} left
</span>
);
})()}
{/* Validation trigger button for implementing features */}
{feature.loopState === "implementing" && (
<button
className="mission-icon-btn mission-icon-btn--validate"
onClick={() => handleTriggerValidation(feature.id)}
title="Validate feature"
disabled={validatingFeatures.has(feature.id)}
>
{validatingFeatures.has(feature.id) ? (
<Loader2 size={14} className="spinner" />
) : (
<Sparkles size={14} />
)}
</button>
)}
{feature.taskId && (
<span
className="mission-feature__task-link"
onClick={() => onSelectTask?.(feature.taskId!)}
title="Click to view task"
>
{feature.taskId}
</span>
)}
<div className="mission-feature__actions">
{feature.status === "defined" && !feature.taskId && (
<button
className="mission-icon-btn"
onClick={() => handleTriageFeatureWithPreview(feature.id)}
title="Triage — create task"
disabled={saving || triagePreviewLoading === feature.id}
>
{triagePreviewLoading === feature.id ? (
<Loader2 size={14} className="spinner" />
) : (
<Zap size={14} />
)}
</button>
)}
{feature.taskId ? (
<button
className="mission-icon-btn"
onClick={() => handleUnlinkTask(feature.id)}
title="Unlink task"
>
<Unlink size={14} />
</button>
) : feature.status !== "defined" ? (
<button
className="mission-icon-btn"
onClick={() => setLinkTaskFeatureId(feature.id)}
title="Link to task"
>
<Link size={14} />
</button>
) : null}
<button
className="mission-icon-btn"
onClick={() => handleEditFeature(feature)}
title="Edit feature"
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "feature", id: feature.id })}
title="Delete feature"
>
<Trash2 size={14} />
</button>
</div>
</div>
{feature.description && (
<p className="mission-feature__description">{feature.description}</p>
)}
{feature.acceptanceCriteria && (
<p className="mission-feature__criteria">
<strong>Acceptance:</strong> {feature.acceptanceCriteria}
</p>
)}
{/* Triage preview panel */}
{triagePreview?.featureId === feature.id && (
<div className="mission-triage-preview">
<div className="mission-triage-preview__header">
Enriched Description Preview
</div>
<div className="mission-triage-preview__content">
{triagePreview.enrichedDescription}
</div>
<div className="mission-triage-preview__actions">
<button
className="btn btn-primary"
onClick={handleConfirmTriageFromPreview}
disabled={saving}
>
{saving ? <Loader2 size={14} className="spinner" /> : null}
Create Task
</button>
<button
className="btn"
onClick={handleCancelTriagePreview}
disabled={saving}
>
Cancel
</button>
</div>
</div>
)}
{/* Edit feature form */}
{editingFeatureId === feature.id && (
<div className="mission-form-card">
<input
type="text"
placeholder="Feature title"
value={featureForm.title}
onChange={(e) => setFeatureForm({ ...featureForm, title: e.target.value })}
onKeyDown={handleFeatureFormKeyDown}
autoFocus
/>
<textarea
placeholder="Description (optional)"
value={featureForm.description}
onChange={(e) => setFeatureForm({ ...featureForm, description: e.target.value })}
rows={2}
/>
<textarea
placeholder="Acceptance criteria (optional)"
value={featureForm.acceptanceCriteria}
onChange={(e) => setFeatureForm({ ...featureForm, acceptanceCriteria: e.target.value })}
rows={2}
/>
<select
value={featureForm.status}
onChange={(e) => setFeatureForm({ ...featureForm, status: e.target.value as FeatureStatus })}
>
<option value="defined">Defined</option>
<option value="triaged">Triaged</option>
<option value="in-progress">In Progress</option>
<option value="done">Done</option>
</select>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveFeature} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Update
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelFeature}>
Cancel
</button>
</div>
</div>
)}
{/* Validation Run History - shown when feature is expanded */}
{expandedFeatureId === feature.id && (
<div className="mission-feature__run-history">
<div className="mission-feature__run-history-header">
<span className="mission-feature__run-history-title">Validation Runs</span>
</div>
{(validationRunsByFeature.get(feature.id) ?? []).map((run) => (
<div key={run.id} className="mission-run">
<div
className="mission-run__header"
onClick={() => toggleRunExpanded(run.id)}
>
<span
className={`mission-status-badge mission-status-badge--sm mission-run__status mission-run__status--${run.status}`}
title={run.status}
>
{run.status}
</span>
<span className="mission-run__time">
{new Date(run.startedAt).toLocaleString()}
</span>
{run.completedAt && (
<span className="mission-run__duration">
{Math.round((new Date(run.completedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)}s
</span>
)}
{run.triggerType && (
<span className="mission-run__trigger">
{run.triggerType}
</span>
)}
<button
className="mission-icon-btn"
title={expandedRunId === run.id ? "Hide details" : "Show details"}
>
{expandedRunId === run.id ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
</div>
{expandedRunId === run.id && runDetailsByRunId.get(run.id) && (
<div className="mission-run__details">
{run.summary && (
<p className="mission-run__summary">{run.summary}</p>
)}
{run.blockedReason && (
<p className="mission-run__blocked-reason">
<strong>Blocked:</strong> {run.blockedReason}
</p>
)}
{runDetailsByRunId.get(run.id)?.failures && runDetailsByRunId.get(run.id)!.failures!.length > 0 && (
<div className="mission-run__failures">
<span className="mission-run__failures-title">Failed Assertions:</span>
{runDetailsByRunId.get(run.id)!.failures!.map((failure) => (
<div key={failure.id} className="mission-run__failure">
<span className="mission-run__failure-message">{failure.message}</span>
{failure.expected && (
<span className="mission-run__failure-expected">
Expected: {failure.expected}
</span>
)}
{failure.actual && (
<span className="mission-run__failure-actual">
Actual: {failure.actual}
</span>
)}
</div>
))}
</div>
)}
{(!runDetailsByRunId.get(run.id)?.failures || runDetailsByRunId.get(run.id)!.failures!.length === 0) && (
<p className="mission-run__no-failures">No assertion failures</p>
)}
</div>
)}
</div>
))}
{(!validationRunsByFeature.get(feature.id) || validationRunsByFeature.get(feature.id)!.length === 0) && (
<div className="mission-run-history__empty">
No validation runs yet.
</div>
)}
</div>
)}
</div>
))}
{/* Create feature form */}
{isCreatingFeature && selectedSliceIdForNewFeature === slice.id && (
<div className="mission-form-card">
<input
type="text"
placeholder="Feature title"
value={featureForm.title}
onChange={(e) => setFeatureForm({ ...featureForm, title: e.target.value })}
onKeyDown={handleFeatureFormKeyDown}
autoFocus
/>
<textarea
placeholder="Description (optional)"
value={featureForm.description}
onChange={(e) => setFeatureForm({ ...featureForm, description: e.target.value })}
rows={2}
/>
<textarea
placeholder="Acceptance criteria (optional)"
value={featureForm.acceptanceCriteria}
onChange={(e) => setFeatureForm({ ...featureForm, acceptanceCriteria: e.target.value })}
rows={2}
/>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveFeature} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Create
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelFeature}>
Cancel
</button>
</div>
</div>
)}
{/* Empty state when no features exist and not creating */}
{!isCreatingFeature && (!slice.features || slice.features.length === 0) && (
<div className="mission-manager__empty mission-features__empty">
<Box size={16} />
<span>No fix features generated.</span>
</div>
)}
</div>
</div>
)}
</div>
))}
{milestone.slices.length === 0 && !isCreatingSlice && (
<div className="mission-manager__empty">
<Package size={16} />
<span>No slices yet</span>
</div>
)}
{/* Assertions Panel */}
<div className="mission-assertions">
<div className="mission-assertions__header">
<span className="mission-assertions__title">Assertions</span>
{milestoneRollup && (
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: milestoneValidationColors.bg,
color: milestoneValidationColors.text,
}}
>
{formatValidationState(milestoneRollup.state)}
</span>
)}
{/* Assertion coverage bar */}
{milestoneRollup && milestoneRollup.totalAssertions > 0 && (
<div className="mission-assertions__coverage-bar" title={`${(milestoneRollup.passedAssertions ?? 0)} of ${milestoneRollup.totalAssertions} assertions passing`}>
<div
className="mission-assertions__coverage-bar-fill"
style={{
width: `${((milestoneRollup.passedAssertions ?? 0) / milestoneRollup.totalAssertions) * 100}%`,
backgroundColor: (milestoneRollup.passedAssertions ?? 0) === milestoneRollup.totalAssertions
? "var(--color-success)"
: "var(--color-warning)",
}}
/>
</div>
)}
<button
className="mission-icon-btn"
onClick={() => {
setIsCreatingAssertion(true);
setEditingAssertionId(null);
setAssertionForm({ title: "", assertion: "", status: "pending" });
}}
title="Add assertion"
>
<Plus size={14} />
</button>
</div>
{/* Create assertion form */}
{isCreatingAssertion && (
<div className="mission-form-card">
<input
type="text"
placeholder="Assertion title"
value={assertionForm.title}
onChange={(e) => setAssertionForm({ ...assertionForm, title: e.target.value })}
autoFocus
/>
<textarea
placeholder="Assertion text (what should be true when complete)"
value={assertionForm.assertion}
onChange={(e) => setAssertionForm({ ...assertionForm, assertion: e.target.value })}
rows={2}
/>
<select
value={assertionForm.status}
onChange={(e) => setAssertionForm({ ...assertionForm, status: e.target.value as MissionAssertionStatus })}
>
<option value="pending">Pending</option>
<option value="passed">Passed</option>
<option value="failed">Failed</option>
<option value="blocked">Blocked</option>
</select>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={() => handleCreateAssertion(milestone.id)} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Create
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelAssertion}>
Cancel
</button>
</div>
</div>
)}
{/* Assertions list */}
<div className="mission-assertions__list">
{(Array.isArray(assertionsByMilestone.get(milestone.id)) ? assertionsByMilestone.get(milestone.id)! : [] as MissionContractAssertion[]).map((assertion) => (
<div
key={assertion.id}
className="mission-assertion"
data-mission-assertion-id={assertion.id}
>
<div className="mission-assertion__header">
{editingAssertionId === assertion.id ? (
<div className="mission-form-card">
<input
type="text"
placeholder="Assertion title"
value={assertionForm.title}
onChange={(e) => setAssertionForm({ ...assertionForm, title: e.target.value })}
autoFocus
/>
<textarea
placeholder="Assertion text"
value={assertionForm.assertion}
onChange={(e) => setAssertionForm({ ...assertionForm, assertion: e.target.value })}
rows={2}
/>
<select
value={assertionForm.status}
onChange={(e) => setAssertionForm({ ...assertionForm, status: e.target.value as MissionAssertionStatus })}
>
<option value="pending">Pending</option>
<option value="passed">Passed</option>
<option value="failed">Failed</option>
<option value="blocked">Blocked</option>
</select>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={() => handleSaveAssertion(assertion.id, milestone.id)} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Save
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelAssertion}>
Cancel
</button>
</div>
</div>
) : (
<>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: (assertionStatusColors[assertion.status] ?? assertionStatusColors.pending).bg,
color: (assertionStatusColors[assertion.status] ?? assertionStatusColors.pending).text,
}}
>
{assertion.status}
</span>
<span className="mission-assertion__title">{assertion.title}</span>
{(() => {
const linked = linkedFeaturesByAssertion.get(assertion.id);
const count = linked?.length ?? 0;
return count > 0 ? (
<span className="mission-assertion__linked-count" title={`${count} linked feature${count !== 1 ? "s" : ""}`}>
({count} linked)
</span>
) : null;
})()}
<button
className="mission-icon-btn"
onClick={() => handleToggleAssertionExpanded(assertion.id)}
title="Toggle details"
>
{expandedAssertionId === assertion.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<button
className="mission-icon-btn"
onClick={() => handleEditAssertion(assertion)}
title="Edit assertion"
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "assertion", id: assertion.id })}
title="Delete assertion"
>
<Trash2 size={14} />
</button>
</>
)}
</div>
{expandedAssertionId === assertion.id && (
<div className="mission-assertion__body">
<p className="mission-assertion__text">{assertion.assertion}</p>
{/* Linked features section */}
<div className="mission-assertion__linked-features">
<div className="mission-assertion__linked-features-header">
<span className="mission-assertion__linked-features-label">Linked Features</span>
<button
className="mission-btn mission-btn--ghost mission-btn--sm"
onClick={async () => {
// First expand the assertion if it's not already expanded
if (expandedAssertionId !== assertion.id) {
await handleToggleAssertionExpanded(assertion.id);
}
// Then toggle the picker
setFeaturePickerOpenForAssertion(featurePickerOpenForAssertion === assertion.id ? null : assertion.id);
}}
title="Link a feature"
>
<Link size={12} />
Link Feature
</button>
</div>
{/* Feature picker dropdown */}
{featurePickerOpenForAssertion === assertion.id && (
<div className="mission-assertion__feature-picker">
<div className="mission-assertion__feature-picker-dropdown">
{(() => {
const linkedFeatureIds = new Set((linkedFeaturesByAssertion.get(assertion.id) ?? []).map((f) => f.id));
const allFeatures: MissionFeature[] = [];
selectedMission?.milestones.forEach((m) =>
m.slices.forEach((s) => allFeatures.push(...s.features.filter((f) => !linkedFeatureIds.has(f.id))))
);
if (allFeatures.length === 0) {
return <span className="mission-assertion__feature-picker-empty">All features already linked</span>;
}
return allFeatures.map((feature) => (
<button
key={feature.id}
className="mission-assertion__feature-picker-item"
onClick={() => handleLinkFeatureToAssertion(feature.id, assertion.id)}
disabled={linkingAssertions.has(assertion.id)}
>
<span className="mission-assertion__feature-picker-title">{feature.title}</span>
{linkingAssertions.has(assertion.id) && <Loader2 size={12} className="spinner" />}
</button>
));
})()}
</div>
</div>
)}
{/* Linked features list */}
{(() => {
const linked = linkedFeaturesByAssertion.get(assertion.id) ?? [];
if (linked.length === 0) {
return <span className="mission-assertion__linked-empty">No features linked yet</span>;
}
return linked.map((feature) => {
const key = `${feature.id}-${assertion.id}`;
const isUnlinking = unlinkingFeatures.has(key);
return (
<div key={feature.id} className="mission-assertion__linked-feature">
<span className="mission-assertion__linked-feature-title">{feature.title}</span>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleUnlinkFeatureFromAssertion(feature.id, assertion.id)}
disabled={isUnlinking}
title="Unlink feature"
>
{isUnlinking ? <Loader2 size={12} className="spinner" /> : <Unlink size={12} />}
</button>
</div>
);
});
})()}
</div>
</div>
)}
</div>
))}
{(!assertionsByMilestone.get(milestone.id) || assertionsByMilestone.get(milestone.id)?.length === 0) && !isCreatingAssertion && (
<div className="mission-manager__empty mission-assertions__empty">
<span>No assertions defined. Add one to define completion criteria.</span>
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
);
})}
{/* Create milestone button/form */}
{selectedMission && !isCreatingMilestone && editingMilestoneId === null && (
<button className="mission-add-btn" onClick={handleCreateMilestone}>
<Plus size={16} />
Add Milestone
</button>
)}
{/* Global create milestone form */}
{isCreatingMilestone && editingMilestoneId === null && (
<div className="mission-form-card">
<input
type="text"
placeholder="Milestone title"
value={milestoneForm.title}
onChange={(e) => setMilestoneForm({ ...milestoneForm, title: e.target.value })}
onKeyDown={handleMilestoneFormKeyDown}
autoFocus
/>
<textarea
placeholder="Description (optional)"
value={milestoneForm.description}
onChange={(e) => setMilestoneForm({ ...milestoneForm, description: e.target.value })}
rows={2}
/>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveMilestone} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Create
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelMilestone}>
Cancel
</button>
</div>
</div>
)}
{selectedMission.milestones.length === 0 && !isCreatingMilestone && (
<div className="mission-manager__empty">
<Layers size={24} />
<span>No milestones yet. Add one to get started.</span>
</div>
)}
</div>
) : (
<div className="mission-detail__activity" data-testid="mission-activity-tab">
<div className="mission-detail__activity-controls">
<label className="mission-detail__activity-filter">
<span>Filter</span>
<select
value={eventsFilter}
onChange={(event) => setEventsFilter(event.target.value as typeof eventsFilter)}
data-testid="mission-activity-filter"
>
<option value="all">All events</option>
<option value="errors">Errors &amp; warnings</option>
<option value="state_changes">State changes</option>
<option value="tasks">Task events</option>
<option value="slices">Slice &amp; milestone events</option>
<option value="autopilot">Autopilot events</option>
</select>
</label>
<span className="mission-detail__activity-count">
{missionEvents.length} of {eventsTotal}
</span>
</div>
{!eventsLoading && hasMoreEvents && (
<div className="mission-detail__activity-load-more mission-detail__activity-load-more--top">
<button
className="mission-btn mission-btn--ghost"
onClick={handleLoadMoreEvents}
data-testid="mission-activity-load-more"
>
Load more
</button>
</div>
)}
{eventsLoading ? (
<div className="mission-manager__loading mission-detail__activity-loading">
<Loader2 size={18} className="spinner" />
<span>Loading mission activity...</span>
</div>
) : missionEvents.length === 0 ? (
<div className="mission-manager__empty">
<Activity size={18} />
<span>No events yet.</span>
</div>
) : (
<div
ref={activityEventsContainerRef}
className="mission-events"
data-testid="mission-activity-events"
>
{missionEvents.map((event) => {
const hasMetadata = Boolean(event.metadata && Object.keys(event.metadata).length > 0);
const metadataExpanded = expandedEventMetadata.has(event.id);
return (
<div key={event.id} className="mission-event">
<div className="mission-event__header">
<span className={`mission-event__type ${getEventTypeClassName(event.eventType)}`}>
{getEventTypeLabel(event.eventType)}
</span>
<span className="mission-event__time">{getRelativeTime(event.timestamp)}</span>
</div>
<p className="mission-event__description">{event.description}</p>
<span className="mission-event__timestamp">
{new Date(event.timestamp).toLocaleString()}
</span>
{hasMetadata && (
<div className="mission-event__metadata">
<button
className="mission-btn mission-btn--ghost mission-btn--sm"
onClick={() => toggleEventMetadata(event.id)}
data-testid={`mission-event-metadata-${event.id}`}
>
{metadataExpanded ? "Hide" : "Show"} metadata
</button>
{metadataExpanded && (
<pre className="mission-event__metadata-content">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
</div>
)}
</div>
);
})}
<div ref={activityEventsEndRef} />
</div>
)}
</div>
)}
</div>
);
};
const handleResumeInterviewSession = (sessionId: string) => {
setLocalResumeSessionId(sessionId);
setShowInterviewModal(true);
};
const handleInterviewModalClose = () => {
dismissedResumeSessionIdRef.current = effectiveResumeSessionId ?? null;
setLocalResumeSessionId(undefined);
setShowInterviewModal(false);
};
const handleDiscardInterviewSession = async (sessionId: string) => {
try {
await discardMissionInterviewDraft(sessionId, projectId);
setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId));
} catch (err) {
if (err instanceof ApiRequestError && err.status === 409) {
addToast("Draft is open in another tab", "error");
return;
}
if (err instanceof ApiRequestError && err.status === 404) {
setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId));
return;
}
addToast(getErrorMessage(err) || "Failed to discard draft", "error");
return;
} finally {
setDeleteConfirmId(null);
}
};
const renderInterviewSessionItems = () => missionInterviewDrafts.map((session) => {
const isErrored = session.status === "error";
const isGenerating = session.status === "generating";
const resumeActionLabel = getInterviewActionLabel(session.status);
return (
<div
key={session.id}
className="mission-list__item mission-list__item--interview"
onClick={() => handleResumeInterviewSession(session.id)}
>
<div className="mission-list__item-content">
<div className="mission-list__item-header">
<Sparkles size={16} className="mission-list__item-icon" />
<span className="mission-list__item-title">{session.title || "Mission interview"}</span>
</div>
<div className="mission-list__item-tags">
<span className={`mission-status-badge mission-status-badge--sm mission-interview-status mission-interview-status--${session.status}`}>
{getInterviewStatusLabel(session.status)}
</span>
</div>
<p className="mission-list__item-description">
{isGenerating
? "Generating mission hierarchy from interview context."
: isErrored
? "Interview hit an error. Retry from this list item."
: "Interview is waiting for your next response."}
</p>
</div>
<div className="mission-list__item-actions mission-list__resume-actions" onClick={(event) => event.stopPropagation()}>
<button
className="mission-btn mission-btn--ghost mission-btn--sm"
onClick={() => handleResumeInterviewSession(session.id)}
title={resumeActionLabel}
aria-label={resumeActionLabel}
>
{isGenerating ? <Loader2 size={14} className="spinner" /> : isErrored ? <RefreshCw size={14} /> : <Sparkles size={14} />}
<span>{isErrored ? "Retry" : "Resume"}</span>
</button>
<button
className="mission-btn mission-btn--danger mission-btn--sm"
onClick={() => setDeleteConfirmId({ type: "interview_draft", id: session.id })}
title="Discard draft"
aria-label="Discard draft"
>
<Trash2 size={14} />
<span>Discard</span>
</button>
</div>
</div>
);
});
const renderMissionListItems = (missionList: MissionWithSummary[], options?: { interviewStyle?: boolean }) => missionList.map((mission) => {
const m = mission;
const isSelected = selectedMission?.id === m.id;
const statusColors = missionStatusColors[m.status as MissionStatus] || { bg: "", text: "" };
const summary = m.summary;
const health = missionHealthById.get(m.id);
const healthState = getMissionHealthState(health);
const hasContent = Boolean(summary && (summary.totalMilestones > 0 || summary.totalFeatures > 0));
const totalTasks = health?.totalTasks ?? 0;
const tasksCompleted = health?.tasksCompleted ?? 0;
const tasksFailed = health?.tasksFailed ?? 0;
const progressPercent = health?.estimatedCompletionPercent ?? summary?.progressPercent ?? 0;
const showSummaryBlock = hasContent || totalTasks > 0 || tasksFailed > 0 || Boolean(health?.lastActivityAt);
const isInterviewStyle = options?.interviewStyle === true;
return (
<div
key={m.id}
className={`mission-list__item ${isSelected ? "mission-list__item--selected" : ""} ${isInterviewStyle ? "mission-list__item--interview" : ""}`}
onClick={() => handleSelectMission(mission)}
>
<div className="mission-list__item-content">
<div className="mission-list__item-header">
<Target size={16} className="mission-list__item-icon" />
<span className="mission-list__item-title">{m.title}</span>
</div>
<div className="mission-list__item-tags">
{mission.autopilotEnabled && (
<span title="Autopilot enabled"><Zap size={12} className="mission-list__item-autopilot-icon" /></span>
)}
<span
className={`mission-health-badge mission-health-badge--${healthState}`}
data-testid={`mission-health-badge-${m.id}`}
aria-label={`Mission health: ${healthState}`}
/>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: statusColors.bg,
color: statusColors.text,
}}
>
{m.status}
</span>
{isInterviewStyle && (
<span className="mission-status-badge mission-status-badge--sm mission-interview-status mission-interview-status--awaiting_input">
Interview in progress
</span>
)}
</div>
{isInterviewStyle ? (
<p className="mission-list__item-description">Mission interview is still in progress. Open this mission to continue planning.</p>
) : m.description ? (
<p className="mission-list__item-description">{m.description}</p>
) : null}
{showSummaryBlock && (
<div className="mission-list__item-summary">
{hasContent && (
<>
<span className="mission-list__item-stat">
{summary!.completedMilestones}/{summary!.totalMilestones} milestones
</span>
<span className="mission-list__item-stat">
{summary!.completedFeatures}/{summary!.totalFeatures} features
</span>
</>
)}
<span className="mission-list__item-stat" data-testid={`mission-task-stats-${m.id}`}>
{tasksCompleted}/{totalTasks} tasks
</span>
{tasksFailed > 0 && (
<button
className="mission-list__item-failed"
onClick={(event) => {
event.stopPropagation();
handleSelectMission(mission);
}}
data-testid={`mission-failed-${m.id}`}
title="View mission failures"
>
{tasksFailed} failed
</button>
)}
<div className={`mission-list__item-progress mission-list__item-progress--${healthState}`}>
<div
className="mission-list__item-progress-bar"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
{showSummaryBlock && (
<div className="mission-list__item-activity">
<span className="mission-relative-time" data-testid={`mission-last-activity-${m.id}`}>
Activity {getRelativeTime(health?.lastActivityAt)}
</span>
</div>
)}
</div>
<div className="mission-list__item-actions" onClick={(e) => e.stopPropagation()}>
{m.status === "active" && (
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleStopMission(m.id)}
title="Stop mission"
>
<Square size={14} />
</button>
)}
{m.status === "blocked" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleResumeMission(m.id)}
title="Resume mission"
>
<Play size={14} />
</button>
)}
{m.status === "planning" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleStartMission(m.id)}
title="Start mission"
>
<Play size={14} />
</button>
)}
<button
className="mission-icon-btn"
onClick={() => handleEditMission(mission)}
title="Edit mission"
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "mission", id: m.id })}
title="Delete mission"
>
<Trash2 size={14} />
</button>
</div>
</div>
);
});
const renderMissionListContent = ({ hideBottomButtons = false }: { hideBottomButtons?: boolean } = {}) => {
const persistedInterviewMissions = missions.filter((mission) => mission.interviewState === "in_progress");
const standardMissions = missions.filter((mission) => mission.interviewState !== "in_progress");
const showMobileTopPlanButton = isMobile && missions.length > 0 && !isCreatingMission;
const showBottomPlanButton = !hideBottomButtons && !showMobileTopPlanButton;
return (
<div className="mission-list">
{/* Create mission form */}
{isCreatingMission && (
<div className="mission-form-card">
<input
type="text"
placeholder="Mission title"
value={missionForm.title}
onChange={(e) => setMissionForm({ ...missionForm, title: e.target.value })}
onKeyDown={handleMissionFormKeyDown}
autoFocus
/>
<textarea
placeholder="Description (optional)"
value={missionForm.description}
onChange={(e) => setMissionForm({ ...missionForm, description: e.target.value })}
rows={2}
/>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveMission} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Create
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelMission}>
Cancel
</button>
</div>
</div>
)}
{showMobileTopPlanButton && (
<div className="mission-list__top-action">
<button
className="btn btn-sm btn-task-create mission-list__primary-cta"
onClick={() => setShowInterviewModal(true)}
>
<Sparkles size={14} />
Plan New Mission
</button>
</div>
)}
{/* Mission and interview items */}
{renderMissionListItems(persistedInterviewMissions, { interviewStyle: true })}
{renderMissionListItems(standardMissions)}
{missionInterviewDrafts.length > 0 && (
<div className="mission-list__drafts-group">
<div className="mission-list__drafts-header">
<Sparkles size={16} className="mission-list__item-icon" />
<span>Drafts</span>
<span>({missionInterviewDrafts.length})</span>
</div>
{renderInterviewSessionItems()}
</div>
)}
{/* Edit mission form */}
{editingMissionId && (
<div className="mission-form-card">
<input
type="text"
placeholder="Mission title"
value={missionForm.title}
onChange={(e) => setMissionForm({ ...missionForm, title: e.target.value })}
onKeyDown={handleMissionFormKeyDown}
autoFocus
/>
<textarea
placeholder="Description (optional)"
value={missionForm.description}
onChange={(e) => setMissionForm({ ...missionForm, description: e.target.value })}
rows={2}
/>
<div className="mission-form-card__row">
<select
value={missionForm.status}
onChange={(e) => setMissionForm({ ...missionForm, status: e.target.value as MissionStatus })}
>
<option value="planning">Planning</option>
<option value="active">Active</option>
<option value="blocked">Blocked</option>
<option value="complete">Complete</option>
<option value="archived">Archived</option>
</select>
<label className="mission-checkbox">
<input
type="checkbox"
checked={missionForm.autopilotEnabled}
onChange={(e) => setMissionForm({ ...missionForm, autopilotEnabled: e.target.checked })}
/>
<Zap size={12} /> Autopilot
</label>
</div>
<div className="mission-form-card__actions">
<button className="mission-btn mission-btn--primary" onClick={handleSaveMission} disabled={saving}>
{saving ? <Loader2 size={14} className="spinner" /> : <Check size={14} />}
Update
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelMission}>
Cancel
</button>
</div>
</div>
)}
{missions.length === 0 && missionInterviewDrafts.length === 0 && !isCreatingMission && (
<div className="mission-manager__empty mission-manager__empty--large mission-manager__empty--mission">
<Target size={32} />
<h3 className="mission-manager__empty-title">No missions yet</h3>
<p className="mission-manager__empty-body">
Missions are large initiatives that bundle milestones, slices, and features into a
single plan. Plan a mission to break down a goal end-to-end and let agents work
through it autopilot-style.
</p>
<button
className="btn btn-sm btn-primary mission-manager__empty-cta"
onClick={() => setShowInterviewModal(true)}
>
<Sparkles size={14} />
Plan New Mission
</button>
</div>
)}
{!isCreatingMission && (
<div className="mission-list__footer">
{showBottomPlanButton && (
<div className="mission-list__footer-actions">
<button className="mission-add-btn" onClick={() => setShowInterviewModal(true)}>
<Sparkles size={16} />
Plan New Mission
</button>
</div>
)}
</div>
)}
</div>
);
};
const renderDeleteConfirmPanel = () => {
const isInterviewDraftDelete = deleteConfirmId?.type === "interview_draft";
return (
<div className="mission-confirm-panel mission-confirm-panel--danger">
<div className="mission-confirm-panel__content">
<p>
{isInterviewDraftDelete
? "Discard this interview draft? This removes the saved draft and cannot be undone."
: `Delete this ${deleteConfirmId?.type}? This cannot be undone.`}
</p>
<div className="mission-confirm-panel__actions">
<button
className="mission-btn mission-btn--danger"
onClick={async () => {
if (!deleteConfirmId) return;
if (deleteConfirmId.type === "mission") {
await handleDeleteMission(deleteConfirmId.id);
} else if (deleteConfirmId.type === "milestone") {
await handleDeleteMilestone(deleteConfirmId.id);
} else if (deleteConfirmId.type === "slice") {
await handleDeleteSlice(deleteConfirmId.id);
} else if (deleteConfirmId.type === "feature") {
await handleDeleteFeature(deleteConfirmId.id);
} else if (deleteConfirmId.type === "interview_draft") {
await handleDiscardInterviewSession(deleteConfirmId.id);
}
}}
>
{isInterviewDraftDelete ? "Discard" : "Delete"}
</button>
<button className="mission-btn mission-btn--ghost" onClick={() => setDeleteConfirmId(null)}>
Cancel
</button>
</div>
</div>
</div>
);
};
const renderLinkTaskPanel = () => (
<div className="mission-confirm-panel mission-confirm-panel--link">
<div className="mission-confirm-panel__content">
<p>Link feature to task:</p>
<input
type="text"
placeholder="Task ID (e.g., FN-001)"
value={selectedTaskId}
onChange={(e) => setSelectedTaskId(e.target.value)}
autoFocus
/>
{availableTasks.length > 0 && (
<div className="mission-task-suggestions">
<small>Or select:</small>
<div className="mission-task-suggestions__list">
{availableTasks.slice(0, 5).map((task) => (
<button
key={task.id}
className="mission-task-suggestions__item"
onClick={() => setSelectedTaskId(task.id)}
>
{task.id}: {task.title || "Untitled"}
</button>
))}
</div>
</div>
)}
<div className="mission-confirm-panel__actions">
<button className="mission-btn mission-btn--primary" onClick={handleLinkTask}>
Link
</button>
<button className="mission-btn mission-btn--ghost" onClick={() => { setLinkTaskFeatureId(null); setSelectedTaskId(""); }}>
Cancel
</button>
</div>
</div>
</div>
);
const manager = (
<div
ref={modalRef}
className={`mission-manager mission-manager--desktop${isInline ? " mission-manager--inline" : ""}`}
role={isInline ? undefined : "dialog"}
aria-modal={isInline ? undefined : true}
aria-label={isInline ? undefined : "Mission Manager"}
data-testid="mission-manager-dialog"
>
<div className={`mission-manager__header${isInline ? " mission-manager__header--inline" : ""}`}>
<div className="mission-manager__header-title">
{selectedMission && (
<button
className="mission-manager__back-btn"
onClick={handleBackToList}
title="Back to missions"
aria-label="Back to missions list"
data-testid="mission-back-btn"
>
<ChevronLeft size={18} />
</button>
)}
<Target size={18} className="mission-manager__header-icon" />
<h2 className="mission-manager__title" data-testid="mission-header-title">
<span className="mission-manager__title-text mission-manager__title-text--desktop">Missions</span>
<span className="mission-manager__title-text mission-manager__title-text--mobile">
{selectedMission ? selectedMission.title : "Missions"}
</span>
</h2>
</div>
{!isInline && (
<button
className="modal-close"
onClick={onClose}
title="Close"
aria-label="Close Mission Manager"
data-testid="mission-close-btn"
>
<X size={18} />
</button>
)}
</div>
{isMobile ? (
<div className="mission-manager__body mission-manager__body--stacked">
{loading ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>Loading missions...</span>
</div>
) : detailLoading ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>Loading mission details...</span>
</div>
) : selectedMission ? (
renderMissionDetailContent()
) : (
renderMissionListContent()
)}
{deleteConfirmId && renderDeleteConfirmPanel()}
{linkTaskFeatureId && renderLinkTaskPanel()}
</div>
) : (
<div className="mission-manager__split">
<aside
className="mission-manager__sidebar"
data-testid="mission-sidebar"
aria-label="Mission list"
style={isMobile ? undefined : { width: `${sidebarWidth}px` }}
>
<div className="mission-manager__sidebar-list">
{loading ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>Loading missions...</span>
</div>
) : (
renderMissionListContent({ hideBottomButtons: true })
)}
</div>
<div className="mission-manager__sidebar-footer" data-testid="mission-sidebar-footer">
<button
className="btn btn-primary mission-manager__sidebar-cta"
onClick={() => setShowInterviewModal(true)}
title="Plan New Mission"
aria-label="Plan New Mission"
>
<Sparkles size={14} />
Plan New Mission
</button>
</div>
</aside>
{!isMobile && (
<div
className="mission-manager__sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-valuemin={MISSION_SIDEBAR_MIN_WIDTH}
aria-valuemax={MISSION_SIDEBAR_MAX_WIDTH}
aria-valuenow={sidebarWidth}
aria-label="Resize mission sidebar"
tabIndex={0}
onPointerDown={handleSidebarResizeStart}
onKeyDown={handleSidebarResizeKeyDown}
/>
)}
<div className="mission-manager__detail-pane">
{detailLoading ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>Loading mission details...</span>
</div>
) : selectedMission ? (
renderMissionDetailContent()
) : (
<div className="mission-manager__detail-pane-empty" data-testid="mission-empty-detail">
<Target size={32} />
<span>Select a mission to view details</span>
</div>
)}
{deleteConfirmId && renderDeleteConfirmPanel()}
{linkTaskFeatureId && renderLinkTaskPanel()}
</div>
</div>
)}
</div>
);
const interviewModal = (
<MissionInterviewModal
isOpen={showInterviewModal}
onClose={handleInterviewModalClose}
onMissionCreated={() => {
loadMissions();
addToast("Mission created from AI interview", "success");
}}
projectId={projectId}
resumeSessionId={effectiveResumeSessionId}
/>
);
const milestoneSliceInterviewModal = interviewTarget ? (
<MilestoneSliceInterviewModal
isOpen={true}
onClose={() => setInterviewTarget(null)}
onApplied={() => {
setInterviewTarget(null);
if (selectedMission) loadMissionDetail(selectedMission.id);
}}
targetType={interviewTarget.type}
targetId={interviewTarget.id}
targetTitle={interviewTarget.title}
missionContext={selectedMission?.title}
projectId={projectId}
resumeSessionId={interviewTarget.resumeSessionId}
/>
) : null;
if (isInline) {
return (
<>
{manager}
{interviewModal}
{milestoneSliceInterviewModal}
</>
);
}
return (
<>
<div
className="mission-manager-overlay open"
onClick={(e) => e.target === e.currentTarget && onClose()}
data-testid="mission-manager-overlay"
role="dialog"
aria-modal="true"
>
{manager}
</div>
{interviewModal}
{milestoneSliceInterviewModal}
</>
);
}