4329 lines
194 KiB
TypeScript
4329 lines
194 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 {
|
|
switch (status) {
|
|
case "error":
|
|
return "Retry interview";
|
|
case "generating":
|
|
return "Generating plan";
|
|
default:
|
|
return "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;
|
|
acceptanceCriteria: 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: "",
|
|
acceptanceCriteria: "",
|
|
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);
|
|
if (data.milestones.length > 0) {
|
|
const firstMilestoneId = data.milestones[0].id;
|
|
const milestoneIds = new Set(data.milestones.map((milestone) => milestone.id));
|
|
const currentSelectedMilestoneId = selectedMilestoneIdRef.current;
|
|
const selectedMilestoneStillExists =
|
|
typeof currentSelectedMilestoneId === "string" && milestoneIds.has(currentSelectedMilestoneId);
|
|
const nextSelectedMilestoneId = selectedMilestoneStillExists
|
|
? currentSelectedMilestoneId
|
|
: firstMilestoneId;
|
|
|
|
setSelectedMilestoneId(nextSelectedMilestoneId);
|
|
setValidationRoundsExpanded(true);
|
|
|
|
// FN-4613: preserve user-expanded milestones/slices across refetch instead of resetting to first-only.
|
|
setExpandedMilestones((prev) => {
|
|
const next = new Set(Array.from(prev).filter((milestoneId) => milestoneIds.has(milestoneId)));
|
|
next.add(nextSelectedMilestoneId);
|
|
return next;
|
|
});
|
|
|
|
const availableSliceIds = new Set(
|
|
data.milestones.flatMap((milestone) => milestone.slices.map((slice) => slice.id)),
|
|
);
|
|
const selectedMilestone = data.milestones.find((milestone) => milestone.id === nextSelectedMilestoneId);
|
|
const selectedMilestoneFirstSliceId = selectedMilestone?.slices[0]?.id ?? null;
|
|
setExpandedSlices((prev) => {
|
|
const next = new Set(Array.from(prev).filter((sliceId) => availableSliceIds.has(sliceId)));
|
|
if (selectedMilestoneFirstSliceId) {
|
|
const hasExpandedSliceForSelectedMilestone = selectedMilestone?.slices.some((slice) => next.has(slice.id)) ?? false;
|
|
if (!hasExpandedSliceForSelectedMilestone) {
|
|
next.add(selectedMilestoneFirstSliceId);
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
|
|
// Load assertions and validation rollup for the selected milestone.
|
|
fetchAssertions(nextSelectedMilestoneId, projectId).then((assertions) => {
|
|
setAssertionsByMilestone((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(nextSelectedMilestoneId, assertions);
|
|
return next;
|
|
});
|
|
}).catch(() => { /* silently fail */ });
|
|
fetchMilestoneValidation(nextSelectedMilestoneId, projectId).then((rollup) => {
|
|
setValidationRollupByMilestone((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(nextSelectedMilestoneId, rollup);
|
|
return next;
|
|
});
|
|
}).catch(() => { /* silently fail */ });
|
|
} 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);
|
|
if (!selectedMilestoneId) {
|
|
return;
|
|
}
|
|
// FN-4613: selecting a milestone from any navigation path keeps its acceptance criteria visible.
|
|
setExpandedMilestones((prev) => {
|
|
if (prev.has(selectedMilestoneId)) {
|
|
return prev;
|
|
}
|
|
const next = new Set(prev);
|
|
next.add(selectedMilestoneId);
|
|
return next;
|
|
});
|
|
}, [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 || "",
|
|
acceptanceCriteria: milestone.acceptanceCriteria || "",
|
|
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,
|
|
acceptanceCriteria: milestoneForm.acceptanceCriteria.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,
|
|
acceptanceCriteria: milestoneForm.acceptanceCriteria.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;
|
|
const featuresWithAcceptanceCriteria = milestone.slices
|
|
.flatMap((slice) => slice.features)
|
|
.filter((feature) => (feature.acceptanceCriteria ?? "").trim().length > 0);
|
|
|
|
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">
|
|
{milestone.acceptanceCriteria && (
|
|
<p className="mission-feature__criteria">
|
|
<strong>Acceptance:</strong> {milestone.acceptanceCriteria}
|
|
</p>
|
|
)}
|
|
|
|
{/* 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}
|
|
/>
|
|
<textarea
|
|
placeholder="Acceptance criteria (optional)"
|
|
value={milestoneForm.acceptanceCriteria}
|
|
onChange={(e) => setMilestoneForm({ ...milestoneForm, acceptanceCriteria: 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">
|
|
{slice.verification?.trim() && (
|
|
<p className="mission-feature__criteria">
|
|
<strong>Verification:</strong> {slice.verification}
|
|
</p>
|
|
)}
|
|
|
|
{/* 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
|
|
&& (
|
|
featuresWithAcceptanceCriteria.length > 0 ? (
|
|
// Product contract (2026-05-15 #product): completion criteria canonically
|
|
// live on MissionFeature.acceptanceCriteria; MissionContractAssertion rows
|
|
// are an additive milestone-level structure. Missing assertions do not
|
|
// mean missing criteria when child features already carry acceptance text,
|
|
// even when milestone.acceptanceCriteria is also populated (FN-4613/FN-4652).
|
|
// If FN-4578/4579/4580 (or successors) change the model, update this.
|
|
<div className="mission-assertions__list" data-testid="milestone-feature-acceptance-rollup">
|
|
<span className="mission-assertions__title">Completion criteria (from features)</span>
|
|
{featuresWithAcceptanceCriteria.map((feature) => (
|
|
<div key={feature.id} className="mission-assertion">
|
|
<span className="mission-assertion__title">{feature.title}</span>
|
|
<p className="mission-assertion__text">
|
|
<strong>Acceptance:</strong> {feature.acceptanceCriteria}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
!milestone.acceptanceCriteria?.trim() ? (
|
|
<div className="mission-manager__empty mission-assertions__empty">
|
|
<span>No assertions defined. Add one to define completion criteria.</span>
|
|
</div>
|
|
) : null
|
|
)
|
|
)}
|
|
</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}
|
|
/>
|
|
<textarea
|
|
placeholder="Acceptance criteria (optional)"
|
|
value={milestoneForm.acceptanceCriteria}
|
|
onChange={(e) => setMilestoneForm({ ...milestoneForm, acceptanceCriteria: 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 & warnings</option>
|
|
<option value="state_changes">State changes</option>
|
|
<option value="tasks">Task events</option>
|
|
<option value="slices">Slice & 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"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Resume interview ${session.title || "Mission interview"}`}
|
|
onClick={() => handleResumeInterviewSession(session.id)}
|
|
onKeyDown={(event) => {
|
|
if (event.currentTarget !== event.target) return;
|
|
if (event.key === "Enter") {
|
|
handleResumeInterviewSession(session.id);
|
|
return;
|
|
}
|
|
if (event.key === " ") {
|
|
event.preventDefault();
|
|
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}
|
|
disabled={isGenerating}
|
|
>
|
|
{isGenerating ? <Loader2 size={14} className="spinner" /> : isErrored ? <RefreshCw size={14} /> : <Sparkles size={14} />}
|
|
<span>{isGenerating ? "Generating…" : 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" : ""}`}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`Open mission ${m.title}`}
|
|
aria-pressed={isSelected}
|
|
onClick={() => handleSelectMission(mission)}
|
|
onKeyDown={(event) => {
|
|
if (event.currentTarget !== event.target) return;
|
|
if (event.key === "Enter") {
|
|
handleSelectMission(mission);
|
|
return;
|
|
}
|
|
if (event.key === " ") {
|
|
event.preventDefault();
|
|
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 */}
|
|
{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>
|
|
)}
|
|
{renderMissionListItems(persistedInterviewMissions, { interviewStyle: true })}
|
|
{renderMissionListItems(standardMissions)}
|
|
|
|
{/* 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 && persistedInterviewMissions.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}
|
|
</>
|
|
);
|
|
}
|