Files
fusion/packages/dashboard/app/components/MissionManager.tsx
gsxdsm cdf67c1d98 fix(dashboard): stop Planning Mode retry loop, make AI sessions multi-tab (#2101)
## Problem

Reported: planning gets stuck in a cycle of retrying and regenerating
after a response was already supplied.

After the user answers a planning question, `submitResponse` pushed the
answer to history but left `session.currentQuestion` pointing at the
just-answered question for the whole next generation. The planning SSE
route's catch-up path re-emits `currentQuestion` to every fresh
connection — and each FN-7946 auto-retry (#2073) opens a fresh
connection. So after any generation error:

1. Auto-retry connects a fresh stream → the server re-emits the
**already-answered** question.
2. The client treats any question event as progress: it **resets the
3-attempt auto-retry budget** and re-shows the answered question.
3. The retry regenerates; if it errors again the cycle repeats with a
fresh budget — an unbounded retry/regenerate loop. Re-answering the
stale question also 409-collided with the in-flight generation, feeding
the same loop.

## Fix

Invariant: `currentQuestion` is only set while the session is genuinely
awaiting user input.

- `submitResponse` clears it the moment an answer is accepted (normal
turns and the deepening checkpoint), while preserving the legacy 200
respond contract on generation failure (the modal ignores the body and
lets the SSE error drive recovery).
- `retrySession` scrubs stale questions persisted by pre-fix builds
before regenerating.
- `buildSessionFromRow` only restores a question when the persisted row
is `awaiting_input`.
- `didSubmitSameAnswer` now compares against the last history entry so
the duplicate-submit 409 message survives.
- Agent onboarding gets the same fix (its SSE route also re-emits
`currentQuestion` on connect); retry now asks the next question instead
of re-asking the answered one.

Surface enumeration: mission and milestone interviews keep questions the
same way but their SSE routes never re-emit on connect, and the
auto-retry budget machinery is Planning-Mode-only — planning +
onboarding were the two affected surfaces.

## Symptom Verification

- **Original symptom:** after answering a question, Planning Mode loops
between "Retrying…" and regenerating, re-showing the already-answered
question, with the auto-retry budget never exhausting.
- **Exact reproduction:** answer a question, have the next generation
fail (stuck watchdog/provider error), let the client auto-retry open a
fresh SSE connection.
- **Assertion it is gone:** new regression suite
`planning-answered-question-reemit.test.ts` asserts `currentQuestion` is
cleared mid-generation, on generation failure, on retry, and on restore
from non-`awaiting_input` rows — so the SSE catch-up path has nothing
stale to re-emit. All 5 tests fail against pre-fix code and pass with
the fix; an onboarding regression test covers the sibling surface.

## Verification

- New regression tests: 5/5 fail on pre-fix code, pass with the fix
(plus 1 onboarding test).
- Existing suites: 137 planning server tests pass (3 failures in
`routes-planning.test.ts` fail identically without this change —
pre-existing on the branch); all 69 `PlanningModeModal.planning-flow`
client tests pass; `tsc --noEmit` clean; `pnpm check:changesets` passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Made Planning Mode (and related planning controls) lock-free and
multi-tab—no more take-over/active-in-another-tab lock overlays.

* **Bug Fixes**
* Fixed Planning Mode retry/generation flows where already-answered
questions could reappear.
* Ensured answered questions clear immediately and aren’t re-emitted
during session recovery/SSE catch-up.
* Improved session restoration and preserved legacy recovery behavior
when generation fails after an answer.

* **Tests**
* Added regression coverage for the answered-question invariant and
updated existing tests to reflect lock-free behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---

## Follow-up: Planning Mode is now multi-tab via DB state (lock-free)

Second commit removes all cross-tab coordination from planning — the
persisted session row is the single source of truth and multiple tabs
can read and interact with the same session:

- **Server:** `/planning/*` routes no longer run `checkSessionLock` or
parse `tabId`; a stale `tabId` from an older client is ignored instead
of 409'd. Subtask/mission interview routes keep their existing lock
behavior.
- **Client:** `PlanningModeModal` drops `useSessionLock`, the
`useAiSessionSync` BroadcastChannel broadcasts,
`sessionTabId`/`lockSessionId` state, and the "Take Control" overlay.
Tabs stay current via the per-session SSE stream plus the global
`ai_session:updated` events `useBackgroundSessions` already consumes;
concurrent writes resolve via the server's generation-in-progress guard
(409).
- **API client:** planning functions lose their `tabId` params.
- **Fix uncovered by the refactor:** the 8s stuck-poll now resolves the
session id inside each tick — the removed lock state was what previously
re-armed the poll after Start Planning resolved the session id.
- Also fixes a pre-existing PG-cutover break in
`planning-generation-cancellation.test.ts` (`getSession` is async).

Verification: 144 client planning tests and 137 server planning tests
pass (the 3 remaining `routes-planning.test.ts` failures are
pre-existing on the branch and fail identically without these changes);
`tsc --noEmit` and eslint clean on changed files; `pnpm
check:changesets` passes. Lock-conflict route tests were rewritten to
assert lock-free semantics, plus a new modal test proving a session
stays fully interactive with no lock acquisition even when another tab
is active.


---

## Follow-up 2: the per-tab session lock is gone entirely

Third commit extends the multi-tab model from planning to **every** AI
interview surface (planning, subtask breakdown, mission interview,
milestone/slice interview) and deletes the lock machinery root and
branch.

**Server**
- Deleted the `/ai-sessions/:id/lock`, `/lock/force`, and `/lock/beacon`
routes.
- Dropped `checkSessionLock` from every
planning/subtask/mission/milestone route (both copies — `routes.ts` and
`mission-routes.ts`). A `tabId` from an older client is ignored, never
409'd; all `tabId` body parsing is gone.
- Dropped `acquireLock` / `releaseLock` / `forceAcquireLock` /
`getLockHolder` / `releaseStaleLocks` from `AiSessionStore`, plus the
`@fusion/core` async helpers (`acquireAiSessionLock` et al) and core's
re-exports.
- Removed `lockedByTab`/`lockedAt` from
`AiSessionRow`/`AiSessionSummary`, the upsert SQL, and all four session
producers.

**Client**
- Deleted `useSessionLock` and the now-orphaned `getSessionTabId` util.
- Removed the Take Control overlay, the "active in another tab" banners,
and `BackgroundTasksIndicator`'s active-elsewhere gate (the confirm
prompt and lock badge — sessions now just open).
- Reduced `useAiSessionSync` to what its own comments already called it
— a low-latency *status* supplement to SSE: no `activeTabMap`,
`broadcastLock/Unlock/Heartbeat`, `owningTabId`, `tab:*` messages, or
stale-heartbeat sweep.
- Dropped `tabId` from every session API client function; removed the
lock CSS.

**Deliberately kept: the two DB columns.** `ai_sessions.locked_by_tab` /
`locked_at` remain as dead, always-NULL columns with a deprecation note.
Dropping them is an irreversible migration, and released binaries still
name those columns explicitly in their upsert — an older install pointed
at the same database would fail every session write. They can be dropped
once no such binary can reach it. No code reads or writes them.

**Verification**: 397 client tests and 137 server planning tests pass
(the same 3 `routes-planning.test.ts` failures are pre-existing —
verified identical on a clean stash); `tsc --noEmit` clean for
`@fusion/core` and `@fusion/dashboard`; eslint clean on all changed
files; the 30 PG `schema-applier` tests pass (they exercise the retained
columns); `pnpm check:changesets` passes. The lock-conflict route tests
and both modal lock tests were rewritten to assert the inverse: routes
and modals stay fully interactive while another tab "holds" a lock, and
the lock API is never called.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:47:53 -07:00

5011 lines
239 KiB
TypeScript

import "./MissionManager.css";
import { useState, useEffect, useCallback, useRef, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { getErrorMessage, type Goal } 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 { useConfirm } from "../hooks/useConfirm";
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,
api,
type AiSessionSummary,
} from "../api";
import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types";
import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache";
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
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;
workflowId?: string | null;
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;
/** Navigate to the goals view anchored to a specific goal */
onNavigateToGoal?: (goalId: string) => 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",
"complete",
]);
function getInterviewStatusLabel(status: AiSessionSummary["status"], t: (key: string, fallback: string) => string): string {
switch (status) {
case "generating":
return t("missions.interviewStatusGenerating", "Generating plan");
case "awaiting_input":
return t("missions.interviewStatusAwaitingInput", "Awaiting input");
case "error":
return t("missions.interviewStatusError", "Needs retry");
case "complete":
return t("missions.interviewStatusComplete", "Plan ready");
default:
return status;
}
}
function getMissionRunHelperText(status: MissionStatus, t: (key: string, fallback: string) => string): string | null {
switch (status) {
case "planning":
return t("missions.runHelperPlanning", "Starting activates the first slice so work can begin.");
case "active":
return t("missions.runHelperActive", "Stopping pauses linked tasks and marks the mission blocked.");
case "blocked":
return t("missions.runHelperBlocked", "Resuming re-activates the mission and continues execution.");
default:
return null;
}
}
function getAutopilotStateLabel(state: string, t: (key: string, fallback: string) => string): string {
switch (state as AutopilotState) {
case "inactive":
return t("missions.autopilotStateInactive", "Off");
case "watching":
return t("missions.autopilotStateWatching", "Watching");
case "activating":
return t("missions.autopilotStateActivating", "Activating slice");
case "completing":
return t("missions.autopilotStateCompleting", "Completing");
default:
return state ? state.replace(/_/g, " ") : t("missions.autopilotStateUnknown", "Unknown");
}
}
/** 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 { t } = useTranslation("app");
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"
? t("missions.planStatePlanned", "Planned")
: state === "needs_update"
? t("missions.planStateNeedsUpdate", "Needs update")
: t("missions.planStateNotPlanned", "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 | undefined, t: (key: string, fallback: string) => string): string {
if (!state) return t("missions.validationStateNotStarted", "Not started");
// Replace underscores with spaces and title-case the result
return state.replace(/_/g, " ").replace(/^\w/, (c) => c.toUpperCase());
}
// Form types
type MissionBranchStrategyMode = "project-default" | "existing" | "custom-new" | "auto-per-task";
interface MissionBranchStrategy {
mode: MissionBranchStrategyMode;
branchName?: string;
}
interface MissionFormData {
title: string;
description: string;
status: MissionStatus;
autopilotEnabled: boolean;
baseBranch: string;
branchStrategy: MissionBranchStrategy;
}
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,
baseBranch: "",
branchStrategy: {
mode: "project-default",
},
};
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",
};
function normalizeMissionBranchStrategy(strategy?: Mission["branchStrategy"]): MissionBranchStrategy {
if (!strategy) {
return { mode: "project-default" };
}
if (strategy.mode === "existing" || strategy.mode === "custom-new") {
return {
mode: strategy.mode,
branchName: strategy.branchName ?? "",
};
}
if (strategy.mode === "auto-per-task") {
return { mode: "auto-per-task" };
}
return { mode: "project-default" };
}
function toMissionBranchOptions(mission?: Mission): Parameters<typeof triageFeature>[4] | undefined {
if (!mission?.baseBranch && !mission?.branchStrategy) {
return undefined;
}
const strategy = mission.branchStrategy;
const branchSelection: NonNullable<Parameters<typeof triageFeature>[4]>["branchSelection"] = {
mode: "project-default",
...(mission.baseBranch ? { baseBranch: mission.baseBranch } : {}),
};
const options: NonNullable<Parameters<typeof triageFeature>[4]> = { branchSelection };
if (strategy?.mode === "existing" || strategy?.mode === "custom-new") {
const branchName = strategy.branchName?.trim();
if (branchName) {
options.branchSelection = {
mode: strategy.mode,
branchName,
...(mission.baseBranch ? { baseBranch: mission.baseBranch } : {}),
};
}
} else if (strategy?.mode === "auto-per-task") {
options.branchAssignment = { mode: "per-task-derived" };
}
return options;
}
type MissionHealthState = "healthy" | "warning" | "error";
const HOUR_MS = 60 * 60 * 1000;
/*
FNXC:MissionTimestamps 2026-06-17-17:34:
FN-6601 uses the shared relative-time bucket helper while preserving MissionManager's missing-value em dash and days-forever fallback.
*/
function getRelativeTime(timestamp: string | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {
if (!timestamp) return "—";
const ts = new Date(timestamp).getTime();
if (Number.isNaN(ts)) return "—";
const bucket = getRelativeTimeBucket(timestamp);
if (!bucket) return t("missions.relativeTimeJustNow", "just now");
switch (bucket.bucket) {
case "just-now":
return t("missions.relativeTimeJustNow", "just now");
case "minutes":
return t("missions.relativeTimeMinutes", "{{count}}m ago", { count: bucket.count });
case "hours":
return t("missions.relativeTimeHours", "{{count}}h ago", { count: bucket.count });
case "days":
case "weeks":
case "older":
return t("missions.relativeTimeDays", "{{count}}d ago", { count: bucket.days });
}
}
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 renderMarkdownText(text: string): ReactNode {
return (
<div className="markdown-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
</div>
);
}
function getAutopilotActivitySummary(state: AutopilotState, lastActivityAt: string | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string | null {
if (!lastActivityAt) {
return null;
}
if (state === "watching") {
return t("missions.autopilotWatchingSince", "Watching since {{time}}", { time: getRelativeTime(lastActivityAt, t) });
}
return t("missions.autopilotLastActivation", "Last activation {{time}}", { time: getRelativeTime(lastActivityAt, t) });
}
function buildMissionScopedPath(path: string, projectId?: string): string {
if (!projectId) return path;
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}${new URLSearchParams({ projectId }).toString()}`;
}
function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHierarchy {
if (!Array.isArray(mission.milestones)) {
throw new Error("Malformed mission detail response: missing milestones");
}
return {
...mission,
linkedGoals: Array.isArray(mission.linkedGoals) ? mission.linkedGoals : [],
milestones: mission.milestones.map((milestone) => {
if (!Array.isArray(milestone.slices)) {
throw new Error(`Malformed mission detail response: milestone ${milestone.id} is missing slices`);
}
return {
...milestone,
slices: milestone.slices.map((slice) => {
if (!Array.isArray(slice.features)) {
throw new Error(`Malformed mission detail response: slice ${slice.id} is missing features`);
}
return {
...slice,
features: slice.features,
};
}),
};
}),
};
}
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, workflowId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError, onNavigateToGoal }: MissionManagerProps) {
const { t } = useTranslation("app");
const { confirm } = useConfirm();
/*
FNXC:Missions 2026-06-27-20:25:
Inline Missions can stay mounted while hidden, so tab visibility must drive active state. Re-opening the tab must reset to overview instead of preserving the previously selected mission.
*/
const isActive = isOpen;
const cacheSuffix = projectId ?? "";
const missionsCacheKey = `${SWR_CACHE_KEYS.MISSIONS_PREFIX}${cacheSuffix}`;
const initialMissions = readCache<MissionWithSummary[]>(missionsCacheKey);
const [missions, setMissions] = useState<MissionWithSummary[]>(() => (Array.isArray(initialMissions) ? initialMissions : []));
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
const [loading, setLoading] = useState(!(Array.isArray(initialMissions) && initialMissions.length > 0));
const hasHydratedRef = useRef(Array.isArray(initialMissions) && initialMissions.length > 0);
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("");
const [activeGoals, setActiveGoals] = useState<Goal[]>([]);
const [selectedGoalToLink, setSelectedGoalToLink] = useState("");
const [goalLinkBusy, setGoalLinkBusy] = useState(false);
const [unlinkingGoalId, setUnlinkingGoalId] = useState<string | null>(null);
// AI Interview modal
const [showInterviewModal, setShowInterviewModal] = useState(false);
const [interviewLaunchMode, setInterviewLaunchMode] = useState<"new" | "resume">("new");
const [interviewModalKey, setInterviewModalKey] = useState(0);
// Pending mission interview sessions (for resume prompt after page reload)
const [_pendingInterviewSessions, setPendingInterviewSessions] = useState<AiSessionSummary[]>([]);
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) {
setInterviewLaunchMode("resume");
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]);
const loadPendingInterviewSessions = useCallback(async () => {
const sessions = await fetchAiSessions(projectId);
const pending = sessions.filter((s) => {
if (s.type !== "mission_interview" || !missionInterviewListStatuses.has(s.status)) {
return false;
}
if (projectId) {
return s.projectId === projectId;
}
return s.projectId == null;
});
setPendingInterviewSessions(pending);
}, [projectId]);
const loadMissionInterviewDraftRows = useCallback(async () => {
const drafts = await fetchMissionInterviewDrafts(projectId);
setMissionInterviewDrafts(drafts);
}, [projectId]);
const refreshMissionSidebar = useCallback(() => {
void loadPendingInterviewSessions().catch((err) => {
console.warn("[MissionManager] Failed to fetch pending interview sessions:", err);
});
void loadMissionInterviewDraftRows().catch((err) => {
console.warn("[MissionManager] Failed to fetch mission interview drafts:", err);
});
}, [loadMissionInterviewDraftRows, loadPendingInterviewSessions]);
const loadAssertionsForMilestone = useCallback(async (milestoneId: string) => {
try {
const assertions = await fetchAssertions(milestoneId, projectId);
const linkedFeatureEntries = await Promise.all(
assertions.map(async (assertion): Promise<readonly [string, MissionFeature[]]> => {
try {
const features = await fetchFeaturesForAssertion(assertion.id, projectId);
return [assertion.id, features] as const;
} catch {
return [assertion.id, []] as const;
}
}),
);
setAssertionsByMilestone((prev) => {
const next = new Map(prev);
next.set(milestoneId, assertions);
return next;
});
setLinkedFeaturesByAssertion((prev) => {
const next = new Map(prev);
for (const [assertionId, features] of linkedFeatureEntries) {
next.set(assertionId, features);
}
return next;
});
} catch {
// Silently fail - assertions are optional
}
}, [projectId]);
// Detect pending mission interview sessions for resume prompt
useEffect(() => {
if (!isActive) return;
refreshMissionSidebar();
}, [effectiveResumeSessionId, isActive, refreshMissionSidebar]);
// 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);
const activityTabEventCount = useMemo(() => {
if (!selectedMission?.id) {
return eventsTotal;
}
const baseCount = selectedMission.eventCount
?? missions.find((mission) => mission.id === selectedMission.id)?.summary?.eventCount;
if (baseCount == null) {
return eventsTotal;
}
return Math.max(baseCount, eventsTotal);
}, [eventsTotal, missions, selectedMission?.eventCount, selectedMission?.id]);
const displayedMissionEvents = useMemo(() => [...missionEvents].reverse(), [missionEvents]);
// 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 {
if (!hasHydratedRef.current) {
setLoading(true);
}
const fetched = await fetchMissions(projectId);
// Defensive: API helpers can return an envelope or non-array under
// failure paths; downstream code (render, filter) assumes an array.
const data = Array.isArray(fetched)
? fetched
: fetched && Array.isArray((fetched as { data?: unknown }).data)
? ((fetched as { data: MissionWithSummary[] }).data)
: [];
setMissions(data);
writeCache(
missionsCacheKey,
data.length > 200 ? data.slice(0, 200) : data,
{ maxBytes: 500_000 },
);
hasHydratedRef.current = true;
void loadMissionHealth(data);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.loadFailed", "Failed to load missions"), "error");
} finally {
setLoading(false);
}
}, [addToast, loadMissionHealth, missionsCacheKey, projectId]);
const loadActiveGoals = useCallback(async () => {
try {
const result = await api<{ goals?: Goal[] }>(buildMissionScopedPath("/goals?status=active", projectId));
setActiveGoals(Array.isArray(result.goals) ? result.goals : []);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.loadGoalsFailed", "Failed to load goals"), "error");
setActiveGoals([]);
}
}, [addToast, projectId, t]);
const loadMissionDetail = useCallback(async (missionId: string) => {
try {
setDetailLoading(true);
const payload = await fetchMission(missionId, projectId);
if (!payload || typeof payload !== "object") {
throw new Error("Malformed mission detail response");
}
const data = normalizeMissionHierarchy(payload as MissionWithHierarchy);
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.
void loadAssertionsForMilestone(nextSelectedMilestoneId);
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) {
console.error("[MissionManager] loadMissionDetail:", err);
addToast(getErrorMessage(err) || t("missions.loadDetailFailed", "Failed to load mission details"), "error");
} finally {
setDetailLoading(false);
}
}, [addToast, loadAssertionsForMilestone, 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) || t("missions.loadActivityFailed", "Failed to load mission activity"), "error");
} finally {
if (!append) {
setEventsLoading(false);
}
}
}, [addToast, eventsFilter, projectId, scrollActivityToLatest]);
useEffect(() => {
missionEventsRef.current = missionEvents;
}, [missionEvents]);
useEffect(() => {
const cachedMissions = readCache<MissionWithSummary[]>(missionsCacheKey);
const hasCachedMissions = Array.isArray(cachedMissions) && cachedMissions.length > 0;
setMissions(Array.isArray(cachedMissions) ? cachedMissions : []);
setLoading(!hasCachedMissions);
hasHydratedRef.current = hasCachedMissions;
}, [missionsCacheKey]);
useEffect(() => {
if (isActive) {
loadMissions();
loadActiveGoals();
setSelectedMission(null);
setSelectedMilestoneId(null);
setValidationTelemetry(null);
setMissionEvents([]);
setEventsTotal(0);
setActiveTab("structure");
setEventsFilter("all");
setExpandedEventMetadata(new Set());
}
}, [isActive, loadActiveGoals, loadMissions]);
/*
FNXC:Missions 2026-06-27-00:00:
Entering the Missions tab must always land on the overview list with an empty detail pane. Only explicit `targetMissionId` deep links or interview resume sessions may open a specific mission automatically; do not restore the last selected mission or default-select the first mission on tab entry.
*/
// 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]);
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();
const messageEvent = rawEvent as MessageEvent<string>;
if (messageEvent.data) {
try {
const updatedMission = JSON.parse(messageEvent.data) as Partial<MissionWithSummary> & { id?: string };
if (updatedMission?.id) {
setMissions((prev) =>
prev.map((m) =>
m.id === updatedMission.id ? { ...m, ...updatedMission } : m
)
);
setSelectedMission((prev) =>
prev && prev.id === updatedMission.id
? normalizeMissionHierarchy({ ...prev, ...updatedMission })
: prev
);
}
} catch {
// ignore invalid payloads
}
}
refreshMissionSidebar();
// Reload the selected mission detail to reflect updated mission state (autopilot, status, etc.)
if (selectedMissionRef.current) {
void loadMissionDetail(selectedMissionRef.current.id);
}
};
const handleMissionCreated = () => {
refreshHealth();
void loadMissions();
refreshMissionSidebar();
};
const handleMissionDeleted = (rawEvent: Event) => {
refreshHealth();
const messageEvent = rawEvent as MessageEvent<string>;
if (messageEvent.data) {
try {
const deletedMissionId = JSON.parse(messageEvent.data) as string;
if (deletedMissionId && selectedMissionRef.current?.id === deletedMissionId) {
setSelectedMission(null);
}
} catch {
// ignore invalid payloads
}
}
void loadMissions();
refreshMissionSidebar();
};
const handleSliceUpdated = (_rawEvent: Event) => {
refreshHealth();
// Reload the selected mission detail to reflect updated slice status
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);
}
};
const handleAiSessionUpdated = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
if (!messageEvent.data) {
return;
}
try {
const updatedSession = JSON.parse(messageEvent.data) as AiSessionSummary;
if (updatedSession.type !== "mission_interview") {
return;
}
} catch {
return;
}
void loadMissions();
refreshMissionSidebar();
};
const handleAiSessionDeleted = () => {
void loadMissions();
refreshMissionSidebar();
};
// Handler for validator run started - refresh feature loop state and validation runs
const handleValidatorRunStarted = (rawEvent: Event) => {
const messageEvent = rawEvent as MessageEvent<string>;
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(() => {
scrollActivityToLatest();
});
}
} catch {
// ignore invalid payloads
}
};
return subscribeSse(eventUrl, {
events: {
"mission:created": handleMissionCreated,
"mission:updated": handleMissionUpdated,
"mission:deleted": handleMissionDeleted,
"slice:updated": handleSliceUpdated,
"feature:updated": handleFeatureUpdated,
"milestone:updated": handleMilestoneUpdated,
"mission:event": handleMissionEvent,
"ai_session:updated": handleAiSessionUpdated,
"ai_session:deleted": handleAiSessionDeleted,
"validator-run:started": handleValidatorRunStarted,
"validator-run:completed": handleValidatorRunCompleted,
"milestone:validation:updated": handleMilestoneValidationUpdated,
"assertion:created": handleAssertionMutation,
"assertion:updated": handleAssertionMutation,
"assertion:deleted": handleAssertionMutation,
"assertion:linked": handleAssertionMutation,
"assertion:unlinked": handleAssertionMutation,
"fix-feature:created": handleFixFeatureCreated,
},
onReconnect: () => {
void loadMissions();
refreshMissionSidebar();
},
});
}, [
isActive,
isActivityScrolledNearBottom,
loadMissionDetail,
loadMissionHealth,
loadMissions,
projectId,
refreshMissionSidebar,
refreshValidationTelemetry,
scrollActivityToLatest,
]);
// 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,
baseBranch: mission.baseBranch ?? "",
branchStrategy: normalizeMissionBranchStrategy(mission.branchStrategy),
});
}, []);
const handleCancelMission = useCallback(() => {
setEditingMissionId(null);
setIsCreatingMission(false);
setMissionForm(EMPTY_MISSION_FORM);
}, []);
const handleSaveMission = useCallback(async () => {
if (!missionForm.title.trim()) {
addToast(t("missions.titleRequired", "Mission title is required"), "error");
return;
}
const branchNameRequired =
missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new";
const branchName = missionForm.branchStrategy.branchName?.trim() ?? "";
if (branchNameRequired && !branchName) {
addToast(t("missions.branchNameRequired", "Branch name is required for selected branch strategy"), "error");
return;
}
const branchStrategy: Mission["branchStrategy"] =
missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new"
? { mode: missionForm.branchStrategy.mode, branchName }
: { mode: missionForm.branchStrategy.mode };
try {
setSaving(true);
if (isCreatingMission) {
await createMission({
title: missionForm.title.trim(),
description: missionForm.description.trim() || undefined,
autopilotEnabled: missionForm.autopilotEnabled,
baseBranch: missionForm.baseBranch.trim() || undefined,
branchStrategy,
}, projectId);
addToast(t("missions.created", "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,
baseBranch: missionForm.baseBranch.trim() || "",
branchStrategy,
};
if (missionForm.autopilotEnabled) {
updates.autoAdvance = true;
}
await updateMission(editingMissionId, updates as Parameters<typeof updateMission>[1], projectId);
addToast(t("missions.updated", "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) || t("missions.saveFailed", "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(t("missions.deleted", "Mission deleted"), "success");
if (selectedMission?.id === missionId) {
setSelectedMission(null);
}
await loadMissions();
} catch (err) {
addToast(getErrorMessage(err) || t("missions.deleteFailed", "Failed to delete mission"), "error");
}
}, [addToast, loadMissions, selectedMission, projectId, t]);
const requestDeleteMission = useCallback(async (missionId: string) => {
/*
FNXC:MissionManager 2026-06-24-00:00:
Mission deletes are destructive and must use the app-level modal confirmation from both list and detail delete affordances. Keeping this path out of the inline mission-confirm-panel prevents the prompt from landing in the sidebar footer, detail pane, or below mobile scroll content.
*/
const confirmed = await confirm({
title: t("missions.deleteMission", "Delete mission"),
message: t("missions.deleteConfirm", "Delete this {{type}}? This cannot be undone.", { type: "mission" }),
confirmLabel: t("missions.deleteButton", "Delete"),
cancelLabel: t("missions.cancelButton", "Cancel"),
danger: true,
});
if (!confirmed) {
return;
}
await handleDeleteMission(missionId);
}, [confirm, handleDeleteMission, t]);
// 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(t("missions.milestoneTitleRequired", "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(t("missions.milestoneCreated", "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(t("missions.milestoneUpdated", "Milestone updated"), "success");
}
await loadMissionDetail(selectedMission!.id);
handleCancelMilestone();
} catch (err) {
addToast(getErrorMessage(err) || t("missions.milestoneSaveFailed", "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(t("missions.milestoneDeleted", "Milestone deleted"), "success");
await loadMissionDetail(selectedMission!.id);
setDeleteConfirmId(null);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.milestoneDeleteFailed", "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
void loadAssertionsForMilestone(milestoneId);
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;
});
}, [loadAssertionsForMilestone, 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(t("missions.sliceTitleRequired", "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(t("missions.sliceCreated", "Slice created"), "success");
} else if (editingSliceId) {
await updateSlice(editingSliceId, {
title: sliceForm.title.trim(),
description: sliceForm.description.trim() || undefined,
status: sliceForm.status,
}, projectId);
addToast(t("missions.sliceUpdated", "Slice updated"), "success");
}
await loadMissionDetail(selectedMission!.id);
handleCancelSlice();
} catch (err) {
addToast(getErrorMessage(err) || t("missions.sliceSaveFailed", "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(t("missions.sliceDeleted", "Slice deleted"), "success");
await loadMissionDetail(selectedMission!.id);
setDeleteConfirmId(null);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.sliceDeleteFailed", "Failed to delete slice"), "error");
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
const handleActivateSlice = useCallback(async (sliceId: string) => {
try {
await activateSlice(sliceId, projectId);
addToast(t("missions.sliceActivated", "Slice activated"), "success");
await loadMissionDetail(selectedMission!.id);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.sliceActivateFailed", "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(t("missions.featureTitleRequired", "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(t("missions.featureCreated", "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(t("missions.featureUpdated", "Feature updated"), "success");
}
await loadMissionDetail(selectedMission!.id);
handleCancelFeature();
} catch (err) {
addToast(getErrorMessage(err) || t("missions.featureSaveFailed", "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(t("missions.featureDeleted", "Feature deleted"), "success");
await loadMissionDetail(selectedMission!.id);
setDeleteConfirmId(null);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.featureDeleteFailed", "Failed to delete feature"), "error");
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
const handleLinkTask = useCallback(async () => {
if (!linkTaskFeatureId || !selectedTaskId.trim()) {
addToast(t("missions.taskIdRequired", "Task ID is required"), "error");
return;
}
try {
await linkFeatureToTask(linkTaskFeatureId, selectedTaskId.trim(), projectId);
addToast(t("missions.featureLinkedToTask", "Feature linked to task"), "success");
await loadMissionDetail(selectedMission!.id);
setLinkTaskFeatureId(null);
setSelectedTaskId("");
} catch (err) {
addToast(getErrorMessage(err) || t("missions.featureLinkTaskFailed", "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(t("missions.featureUnlinkedFromTask", "Feature unlinked from task"), "success");
await loadMissionDetail(selectedMission!.id);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.featureUnlinkFailed", "Failed to unlink feature"), "error");
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
const missionTriageOptions = useMemo(() => {
const branchOptions = toMissionBranchOptions(selectedMission ?? undefined);
/*
FNXC:MissionWorkflows 2026-06-25-00:00:
Mission feature and slice triage must carry the active header workflow into task creation just like Planning. Omit the field when the switcher has no resolved selection so API/tool/autopilot paths continue inheriting the project default.
*/
return {
...branchOptions,
...(workflowId ? { workflowId } : {}),
} as NonNullable<Parameters<typeof triageFeature>[4]> & { workflowId?: string };
}, [selectedMission, workflowId]);
// 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, missionTriageOptions);
addToast(t("missions.featureTriaged", "Feature triaged — task created"), "success");
await loadMissionDetail(selectedMission!.id);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.featureTriageFailed", "Failed to triage feature"), "error");
} finally {
setSaving(false);
}
}, [addToast, loadMissionDetail, missionTriageOptions, 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, missionTriageOptions);
addToast(t("missions.sliceTriaged", { count: result.count, defaultValue_one: "Triaged {{count}} feature", defaultValue_other: "Triaged {{count}} features" }), "success");
await loadMissionDetail(selectedMission!.id);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.sliceTriageFailed", "Failed to triage slice features"), "error");
} finally {
setSaving(false);
}
}, [addToast, loadMissionDetail, missionTriageOptions, selectedMission, projectId]);
// ── Assertion handlers ──
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(t("missions.assertionFieldsRequired", "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(t("missions.assertionCreated", "Assertion created"), "success");
await loadAssertionsForMilestone(milestoneId);
await loadValidationRollup(milestoneId);
setIsCreatingAssertion(false);
setAssertionForm({ title: "", assertion: "", status: "pending" });
} catch (err) {
addToast(getErrorMessage(err) || t("missions.assertionCreateFailed", "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(t("missions.assertionFieldsRequired", "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(t("missions.assertionUpdated", "Assertion updated"), "success");
await loadAssertionsForMilestone(milestoneId);
await loadValidationRollup(milestoneId);
handleCancelAssertion();
} catch (err) {
addToast(getErrorMessage(err) || t("missions.assertionUpdateFailed", "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(t("missions.featureLinkedToAssertion", "Feature linked to assertion"), "success");
await loadLinkedFeaturesForAssertion(assertionId);
setFeaturePickerOpenForAssertion(null);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.featureLinkFailed", "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(t("missions.featureUnlinkedFromAssertion", "Feature unlinked from assertion"), "success");
await loadLinkedFeaturesForAssertion(assertionId);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.featureUnlinkFromAssertionFailed", "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(t("missions.validationTriggered", "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) || t("missions.validationTriggerFailed", "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(t("missions.resumed", "Mission resumed"), "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err) {
addToast(getErrorMessage(err) || t("missions.resumeFailed", "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(t("missions.stopped", { count, defaultValue_one: "Mission stopped ({{count}} task paused)", defaultValue_other: "Mission stopped ({{count}} tasks paused)" }), "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err) {
addToast(getErrorMessage(err) || t("missions.stopFailed", "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(t("missions.started", "Mission started — first slice activated"), "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err) {
addToast(getErrorMessage(err) || t("missions.startFailed", "Failed to start mission"), "error");
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
const linkableGoalsForSelectedMission = useMemo(() => {
const linkedIds = new Set((selectedMission?.linkedGoals ?? []).map((goal) => goal.id));
return activeGoals.filter((goal) => goal.status === "active" && !linkedIds.has(goal.id));
}, [activeGoals, selectedMission?.linkedGoals]);
useEffect(() => {
if (selectedGoalToLink && !linkableGoalsForSelectedMission.some((goal) => goal.id === selectedGoalToLink)) {
setSelectedGoalToLink("");
}
}, [linkableGoalsForSelectedMission, selectedGoalToLink]);
/**
* FNXC:Missions 2026-06-15-15:04:
* Mission detail is one side of the bidirectional goal-mission graph, so users must be able to link active goals and unlink existing chips without losing chip navigation.
* Refresh both detail and mission summaries after mutations because the sidebar unlinked indicator reads summary.linkedGoalCount.
*/
const handleLinkGoalToSelectedMission = useCallback(async () => {
if (!selectedMission || !selectedGoalToLink) return;
try {
setGoalLinkBusy(true);
await api(buildMissionScopedPath(`/missions/${encodeURIComponent(selectedMission.id)}/goals/${encodeURIComponent(selectedGoalToLink)}`, projectId), { method: "POST" });
await loadMissionDetail(selectedMission.id);
await loadMissions();
setSelectedGoalToLink("");
addToast(t("missions.goalLinked", "Goal linked to mission"), "success");
} catch (err) {
addToast(getErrorMessage(err) || t("missions.goalLinkFailed", "Failed to link goal"), "error");
} finally {
setGoalLinkBusy(false);
}
}, [addToast, loadMissionDetail, loadMissions, projectId, selectedGoalToLink, selectedMission, t]);
const handleUnlinkGoalFromSelectedMission = useCallback(async (goalId: string) => {
if (!selectedMission) return;
try {
setUnlinkingGoalId(goalId);
await api(buildMissionScopedPath(`/missions/${encodeURIComponent(selectedMission.id)}/goals/${encodeURIComponent(goalId)}`, projectId), { method: "DELETE" });
await loadMissionDetail(selectedMission.id);
await loadMissions();
addToast(t("missions.goalUnlinked", "Goal unlinked from mission"), "success");
} catch (err) {
addToast(getErrorMessage(err) || t("missions.goalUnlinkFailed", "Failed to unlink goal"), "error");
} finally {
setUnlinkingGoalId(null);
}
}, [addToast, loadMissionDetail, loadMissions, projectId, selectedMission, t]);
// ── Autopilot handlers ──
const handleToggleAutopilot = useCallback(async (missionId: string, enabled: boolean) => {
try {
await updateMissionAutopilot(missionId, { enabled }, projectId);
addToast(enabled ? t("missions.autopilotEnabled", "Autopilot enabled") : t("missions.autopilotDisabled", "Autopilot disabled"), "success");
// Reload mission detail to reflect updated fields
await loadMissionDetail(missionId);
loadMissions();
} catch (err) {
addToast(getErrorMessage(err) || t("missions.autopilotUpdateFailed", "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,
t,
);
const previousMobileDetailVisibleRef = useRef(false);
useEffect(() => {
if (!isActive) {
previousMobileDetailVisibleRef.current = false;
}
}, [isActive]);
useEffect(() => {
const isMobileDetailVisible = isActive && isMobile && Boolean(selectedMission);
if (!isMobile) {
// Keep the mobile detail flag untouched on desktop so split-pane refreshes do not consume or create mobile nav entries.
return;
}
if (!isMobileDetailVisible) {
previousMobileDetailVisibleRef.current = false;
return;
}
/*
FNXC:MissionNavigation 2026-06-24-23:48:
Mobile Missions treats Structure and Activity as tabs inside one detail view. Push one view entry when detail becomes visible so browser/Android Back exits to the Missions list before any outer modal or app navigation, and do not push again for tab switches, SSE refreshes, or cached mission rehydration.
*/
if (!previousMobileDetailVisibleRef.current) {
pushNav({ type: "view", revert: handleBackToList });
}
previousMobileDetailVisibleRef.current = true;
}, [handleBackToList, isActive, isMobile, pushNav, selectedMission]);
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={t("missions.autopilotWatching", "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 && (
<div className="mission-detail__description">{renderMarkdownText(selectedMission.description)}</div>
)}
<div className="mission-detail__meta">
<span className="mission-detail__meta-info">
{t("missions.milestonesCount", "{{count}} milestones", { count: selectedMission.milestones.length })}
</span>
</div>
<section className="mission-detail__linked-goals" aria-label={t("missions.linkedGoals", "Linked goals")}>
<div className="mission-detail__linked-goals-header">
<h4 className="mission-detail__linked-goals-title">{t("missions.linkedGoalsTitle", "Linked Goals")}</h4>
<span className="mission-detail__meta-info">
{t("missions.linkedCount", { count: selectedMission.linkedGoals?.length ?? 0, defaultValue_one: "{{count}} linked", defaultValue_other: "{{count}} linked" })}
</span>
</div>
<div className="mission-detail__linked-goal-controls">
<select
className="input mission-detail__linked-goal-picker"
data-testid="mission-goal-picker"
value={selectedGoalToLink}
onChange={(event) => setSelectedGoalToLink(event.target.value)}
aria-label={t("missions.goalPicker", "Goal to link")}
disabled={goalLinkBusy || linkableGoalsForSelectedMission.length === 0}
>
<option value="">{t("missions.selectGoal", "Select an active goal")}</option>
{linkableGoalsForSelectedMission.map((goal) => (
<option key={goal.id} value={goal.id}>{goal.title}</option>
))}
</select>
<button
type="button"
className="btn btn-primary mission-detail__linked-goal-link-button"
data-testid="mission-goal-link-button"
disabled={!selectedGoalToLink || goalLinkBusy}
onClick={handleLinkGoalToSelectedMission}
>
<Link size={16} aria-hidden="true" />
{goalLinkBusy ? t("missions.linkingGoal", "Linking…") : t("missions.linkGoal", "Link goal")}
</button>
</div>
{(selectedMission.linkedGoals?.length ?? 0) > 0 ? (
<div className="mission-detail__linked-goals-list">
{(selectedMission.linkedGoals ?? []).map((goal) => (
<div
key={goal.id}
className="mission-detail__linked-goal-chip"
data-testid={`mission-linked-goal-chip-${goal.id}`}
>
<button
type="button"
className="btn mission-detail__linked-goal-chip-link"
onClick={() => onNavigateToGoal?.(goal.id)}
>
{goal.title}
</button>
<button
type="button"
className="btn-icon mission-detail__linked-goal-unlink"
data-testid={`mission-linked-goal-unlink-${goal.id}`}
aria-label={t("missions.unlinkGoal", "Unlink goal")}
disabled={unlinkingGoalId === goal.id}
onClick={() => handleUnlinkGoalFromSelectedMission(goal.id)}
>
<X size={16} aria-hidden="true" />
</button>
</div>
))}
</div>
) : (
<p className="mission-detail__linked-goals-empty">{t("missions.noLinkedGoals", "No linked goals.")}</p>
)}
</section>
<section className="mission-detail__run-settings" aria-label={t("missions.runSettings", "Mission run settings")}>
<h4 className="mission-detail__run-settings-title">{t("missions.runSettingsTitle", "Mission run settings")}</h4>
{/* ── 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={t("missions.autopilotLabel", "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" />
{t("missions.autopilotLabel", "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 === "inactive" ? t("missions.autopilotOff", "Off")
: autopilotState === "watching" ? t("missions.autopilotWatchingState", "Watching")
: autopilotState === "activating" ? t("missions.autopilotActivatingSlice", "Activating slice")
: autopilotState === "completing" ? t("missions.autopilotCompleting", "Completing")
: getAutopilotStateLabel(autopilotState, t)}
</span>
</div>
<span className="mission-detail__autopilot-description">
{t("missions.autopilotDescription", "When on, Fusion automatically activates the next slice and plans its features as work completes.")}
</span>
{autopilotActivitySummary && (
<span className="mission-detail__autopilot-activity mission-relative-time">
{autopilotActivitySummary}
</span>
)}
</div>
<div className="mission-detail__actions">
<div className="mission-detail__run-controls">
{selectedMission.status === "active" && (
<button
className="mission-btn mission-btn--danger"
onClick={() => handleStopMission(selectedMission.id)}
title={t("missions.stopMission", "Stop mission")}
aria-label={t("missions.stopMission", "Stop mission")}
>
<Square size={14} />
<span>{t("missions.stopMission", "Stop mission")}</span>
</button>
)}
{selectedMission.status === "blocked" && (
<button
className="mission-btn mission-btn--primary"
onClick={() => handleResumeMission(selectedMission.id)}
title={t("missions.resumeMission", "Resume mission")}
aria-label={t("missions.resumeMission", "Resume mission")}
>
<Play size={14} />
<span>{t("missions.resumeMission", "Resume mission")}</span>
</button>
)}
{selectedMission.status === "planning" && (
<button
className="mission-btn mission-btn--primary"
onClick={() => handleStartMission(selectedMission.id)}
title={t("missions.startMission", "Start mission")}
aria-label={t("missions.startMission", "Start mission")}
>
<Play size={14} />
<span>{t("missions.startMission", "Start mission")}</span>
</button>
)}
{getMissionRunHelperText(selectedMission.status, t) && (
<span className="mission-detail__run-help">{getMissionRunHelperText(selectedMission.status, t)}</span>
)}
</div>
<div className="mission-detail__management-actions">
<button
className="mission-icon-btn"
onClick={() => handleEditMission(selectedMission)}
title={t("missions.editMission", "Edit mission")}
aria-label={t("missions.editMission", "Edit mission")}
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => void requestDeleteMission(selectedMission.id)}
title={t("missions.deleteMission", "Delete mission")}
aria-label={t("missions.deleteMission", "Delete mission")}
>
<Trash2 size={14} />
</button>
</div>
</div>
</section>
</div>
{/* Inline edit mission form (detail view) */}
{editingMissionId === selectedMission.id && (
<div className="mission-form-card">
<input
type="text"
placeholder={t("missions.missionTitlePlaceholder", "Mission title")}
value={missionForm.title}
onChange={(e) => setMissionForm({ ...missionForm, title: e.target.value })}
onKeyDown={handleMissionFormKeyDown}
autoFocus
/>
<textarea
placeholder={t("missions.descriptionOptional", "Description (optional)")}
value={missionForm.description}
onChange={(e) => setMissionForm({ ...missionForm, description: e.target.value })}
rows={2}
/>
<label>
{t("missions.targetBranch", "Target branch")}
<input
type="text"
placeholder={t("missions.targetBranchPlaceholder", "e.g. main")}
value={missionForm.baseBranch}
onChange={(e) => setMissionForm({ ...missionForm, baseBranch: e.target.value })}
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
/>
</label>
<label>
{t("missions.branchStrategy", "Branch strategy")}
<select
value={missionForm.branchStrategy.mode}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
mode: e.target.value as MissionBranchStrategyMode,
branchName: missionForm.branchStrategy.branchName,
},
})
}
aria-label={t("missions.branchStrategyAriaLabel", "Mission branch strategy")}
>
<option value="project-default">{t("missions.branchStrategyProjectDefault", "Use project/default branch")}</option>
<option value="auto-per-task">{t("missions.branchStrategyAutoPerTask", "Auto-name a branch per task (from details)")}</option>
<option value="existing">{t("missions.branchStrategyExisting", "Use existing branch")}</option>
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
</select>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label>
{t("missions.branchName", "Branch name")}
<input
type="text"
placeholder={t("missions.branchNamePlaceholder", "e.g. feature/mission-work")}
value={missionForm.branchStrategy.branchName ?? ""}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
...missionForm.branchStrategy,
branchName: e.target.value,
},
})
}
aria-label={t("missions.branchNameAriaLabel", "Mission branch name")}
/>
</label>
)}
<div className="mission-form-card__row">
<select
value={missionForm.status}
onChange={(e) => setMissionForm({ ...missionForm, status: e.target.value as MissionStatus })}
>
<option value="planning">{t("missions.statusPlanning", "Planning")}</option>
<option value="active">{t("missions.statusActive", "Active")}</option>
<option value="blocked">{t("missions.statusBlocked", "Blocked")}</option>
<option value="complete">{t("missions.statusComplete", "Complete")}</option>
<option value="archived">{t("missions.statusArchived", "Archived")}</option>
</select>
<label className="mission-checkbox">
<input
type="checkbox"
checked={missionForm.autopilotEnabled}
onChange={(e) => setMissionForm({ ...missionForm, autopilotEnabled: e.target.checked })}
/>
<Zap size={12} /> {t("missions.autopilotLabel", "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} />}
{t("missions.updateButton", "Update")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelMission}>
{t("missions.cancelButton", "Cancel")}
</button>
</div>
</div>
)}
<div className="mission-detail__tabs" role="tablist" aria-label={t("missions.detailTabs", "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"
>
{t("missions.tabStructure", "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"
>
{t("missions.tabActivity", "Activity ({{count}})", { count: activityTabEventCount })}
</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);
const milestoneAssertions = Array.isArray(assertionsByMilestone.get(milestone.id))
? assertionsByMilestone.get(milestone.id)!
: [] as MissionContractAssertion[];
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">{t("missions.slicesCount", "{{count}} slices", { count: milestone.slices.length })}</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={t("missions.validationState", "Validation state")}
>
{formatValidationState(milestoneRollup.state, t)}
</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={t("missions.planMilestone", "Plan milestone")}
aria-label={t("missions.planMilestone", "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={t("missions.addSlice", "Add slice")}
>
<Plus size={14} />
</button>
<button
className="mission-icon-btn"
onClick={() => handleEditMilestone(milestone)}
title={t("missions.editMilestone", "Edit milestone")}
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "milestone", id: milestone.id })}
title={t("missions.deleteMilestone", "Delete milestone")}
>
<Trash2 size={14} />
</button>
</div>
</div>
{expandedMilestones.has(milestone.id) && (
<div className="mission-milestone__body">
{milestone.acceptanceCriteria && (
<div className="mission-feature__criteria">
<strong>{t("missions.acceptance", "Acceptance:")}</strong>
{renderMarkdownText(milestone.acceptanceCriteria)}
</div>
)}
{/* Create milestone form (inline edit) */}
{(isCreatingMilestone || editingMilestoneId === milestone.id) && (
<div className="mission-form-card">
<input
type="text"
placeholder={t("missions.milestoneTitlePlaceholder", "Milestone title")}
value={milestoneForm.title}
onChange={(e) => setMilestoneForm({ ...milestoneForm, title: e.target.value })}
onKeyDown={handleMilestoneFormKeyDown}
autoFocus
/>
<textarea
placeholder={t("missions.descriptionOptional", "Description (optional)")}
value={milestoneForm.description}
onChange={(e) => setMilestoneForm({ ...milestoneForm, description: e.target.value })}
rows={2}
/>
<textarea
placeholder={t("missions.acceptanceCriteriaOptional", "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 ? t("missions.updateButton", "Update") : t("missions.createButton", "Create")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelMilestone}>
{t("missions.cancelButton", "Cancel")}
</button>
</div>
</div>
)}
{milestoneTelemetry && (
<div className="mission-validation-telemetry">
<div className="mission-validation-telemetry__header">
<span className="mission-validation-telemetry__title">{t("missions.validationTelemetry", "Validation Telemetry")}</span>
<span className="mission-validation-telemetry__meta">
{t("missions.validationRoundsCount", { count: milestoneTelemetry.validationTelemetry.totalRuns, defaultValue_one: "{{count}} round", defaultValue_other: "{{count}} rounds" })}
{milestoneTelemetry.validationTelemetry.lastValidatorStatus
? ` · ${t("missions.lastValidatorStatus", "Last {{status}}", { status: milestoneTelemetry.validationTelemetry.lastValidatorStatus })}`
: ""}
</span>
</div>
{milestoneBlockedReason && (
<div className="mission-blocked-reason">
<strong>{t("missions.blockedReason", "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 ? t("missions.hideValidationRounds", "Hide validation rounds") : t("missions.showValidationRounds", "Show validation rounds")}
>
{validationRoundsExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
{t("missions.validationRoundsLabel", "Validation rounds ({{count}})", { count: 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">
{t("missions.validationRoundAttempts", "impl #{{implementation}} · reviewer #{{reviewer}}", { implementation: round.implementationAttempt, reviewer: round.validatorAttempt })}
</span>
</div>
<div className="mission-validation-round__links">
<span className="mission-validation-round__label">{t("missions.failedAssertions", "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">{t("missions.none", "None")}</span>
)}
</div>
<div className="mission-validation-round__links">
<span className="mission-validation-round__label">{t("missions.generatedFixFeatures", "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">{t("missions.none", "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">{t("missions.generatedFixFeaturesTitle", "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>{t("missions.source", "Source:")}</span>
<button
className="mission-validation-round__link-chip"
onClick={() => focusFeature(fixFeature.sourceFeatureId)}
title={`Jump to source feature ${fixFeature.sourceFeatureId}`}
>
{fixFeature.sourceFeatureId}
</button>
<span>{t("missions.run", "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">{t("missions.failedAssertions", "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">{t("missions.featuresCount", "{{count}} features", { count: slice.features?.length || 0 })}</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={t("missions.planSlice", "Plan slice")}
aria-label={t("missions.planSlice", "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={t("missions.activateSlice", "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={t("missions.triageAllFeatures", "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={t("missions.addFeature", "Add feature")}
>
<Plus size={14} />
</button>
<button
className="mission-icon-btn"
onClick={() => handleEditSlice(slice)}
title={t("missions.editSlice", "Edit slice")}
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "slice", id: slice.id })}
title={t("missions.deleteSlice", "Delete slice")}
>
<Trash2 size={14} />
</button>
</div>
</div>
{expandedSlices.has(slice.id) && (
<div className="mission-slice__body">
{slice.verification?.trim() && (
<div className="mission-feature__criteria">
<strong>{t("missions.verification", "Verification:")}</strong>
{renderMarkdownText(slice.verification)}
</div>
)}
{/* Create slice form */}
{(isCreatingSlice && selectedMilestoneIdForNewSlice === milestone.id && !editingSliceId) && (
<div className="mission-form-card">
<input
type="text"
placeholder={t("missions.sliceTitlePlaceholder", "Slice title")}
value={sliceForm.title}
onChange={(e) => setSliceForm({ ...sliceForm, title: e.target.value })}
onKeyDown={handleSliceFormKeyDown}
autoFocus
/>
<textarea
placeholder={t("missions.descriptionOptional", "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} />}
{t("missions.createButton", "Create")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelSlice}>
{t("missions.cancelButton", "Cancel")}
</button>
</div>
</div>
)}
{/* Edit slice form */}
{editingSliceId === slice.id && (
<div className="mission-form-card">
<input
type="text"
placeholder={t("missions.sliceTitlePlaceholder", "Slice title")}
value={sliceForm.title}
onChange={(e) => setSliceForm({ ...sliceForm, title: e.target.value })}
onKeyDown={handleSliceFormKeyDown}
autoFocus
/>
<textarea
placeholder={t("missions.descriptionOptional", "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">{t("missions.statusPending", "Pending")}</option>
<option value="active">{t("missions.statusActive", "Active")}</option>
<option value="complete">{t("missions.statusComplete", "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} />}
{t("missions.updateButton", "Update")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelSlice}>
{t("missions.cancelButton", "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 ? t("missions.collapseDetails", "Collapse details") : t("missions.expandRunHistory", "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={t("missions.loopState", "Loop state: {{state}}", { 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={t("missions.generatedFromFeature", "Generated from feature: {{id}}", { id: feature.generatedFromFeatureId })}
>
{t("missions.fixLineageButton", "🔗 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={t("missions.retryBudgetTitle", "Implementation attempts and remaining retry budget")}
>
{t("missions.attemptRetries", "Attempt {{attempt}} · {{count}} {{label}} left", { attempt: implementationAttempt, count: retryBudgetRemaining, label: retryBudgetRemaining === 1 ? t("missions.retry", "retry") : t("missions.retries", "retries") })}
</span>
);
})()}
{/* Validation trigger button for implementing features */}
{feature.loopState === "implementing" && (
<button
className="mission-icon-btn mission-icon-btn--validate"
onClick={() => handleTriggerValidation(feature.id)}
title={t("missions.validateFeature", "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={t("missions.clickToViewTask", "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={t("missions.triageCreateTask", "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={t("missions.unlinkTask", "Unlink task")}
>
<Unlink size={14} />
</button>
) : feature.status !== "defined" ? (
<button
className="mission-icon-btn"
onClick={() => setLinkTaskFeatureId(feature.id)}
title={t("missions.linkToTask", "Link to task")}
>
<Link size={14} />
</button>
) : null}
<button
className="mission-icon-btn"
onClick={() => handleEditFeature(feature)}
title={t("missions.editFeature", "Edit feature")}
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "feature", id: feature.id })}
title={t("missions.deleteFeature", "Delete feature")}
>
<Trash2 size={14} />
</button>
</div>
</div>
{feature.description && (
<div className="mission-feature__description">{renderMarkdownText(feature.description)}</div>
)}
{feature.acceptanceCriteria && (
<div className="mission-feature__criteria">
<strong>{t("missions.acceptance", "Acceptance:")}</strong>
{renderMarkdownText(feature.acceptanceCriteria)}
</div>
)}
{/* Triage preview panel */}
{triagePreview?.featureId === feature.id && (
<div className="mission-triage-preview">
<div className="mission-triage-preview__header">
{t("missions.enrichedDescPreview", "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}
{t("missions.createTask", "Create Task")}
</button>
<button
className="btn"
onClick={handleCancelTriagePreview}
disabled={saving}
>
{t("missions.cancelButton", "Cancel")}
</button>
</div>
</div>
)}
{/* Edit feature form */}
{editingFeatureId === feature.id && (
<div className="mission-form-card">
<input
type="text"
placeholder={t("missions.featureTitlePlaceholder", "Feature title")}
value={featureForm.title}
onChange={(e) => setFeatureForm({ ...featureForm, title: e.target.value })}
onKeyDown={handleFeatureFormKeyDown}
autoFocus
/>
<textarea
placeholder={t("missions.descriptionOptional", "Description (optional)")}
value={featureForm.description}
onChange={(e) => setFeatureForm({ ...featureForm, description: e.target.value })}
rows={2}
/>
<textarea
placeholder={t("missions.acceptanceCriteriaOptional", "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">{t("missions.statusDefined", "Defined")}</option>
<option value="triaged">{t("missions.statusTriaged", "Triaged")}</option>
<option value="in-progress">{t("missions.statusInProgress", "In Progress")}</option>
<option value="done">{t("missions.statusDone", "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} />}
{t("missions.updateButton", "Update")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelFeature}>
{t("missions.cancelButton", "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">{t("missions.validationRuns", "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 ? t("missions.hideDetails", "Hide details") : t("missions.showDetails", "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>{t("missions.blocked", "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">{t("missions.failedAssertionsTitle", "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">
{t("missions.expected", "Expected: {{value}}", { value: failure.expected })}
</span>
)}
{failure.actual && (
<span className="mission-run__failure-actual">
{t("missions.actual", "Actual: {{value}}", { value: failure.actual })}
</span>
)}
</div>
))}
</div>
)}
{(!runDetailsByRunId.get(run.id)?.failures || runDetailsByRunId.get(run.id)!.failures!.length === 0) && (
<p className="mission-run__no-failures">{t("missions.noAssertionFailures", "No assertion failures")}</p>
)}
</div>
)}
</div>
))}
{(!validationRunsByFeature.get(feature.id) || validationRunsByFeature.get(feature.id)!.length === 0) && (
<div className="mission-run-history__empty">
{t("missions.noValidationRunsYet", "No validation runs yet.")}
</div>
)}
</div>
)}
</div>
))}
{/* Create feature form */}
{isCreatingFeature && selectedSliceIdForNewFeature === slice.id && (
<div className="mission-form-card">
<input
type="text"
placeholder={t("missions.featureTitlePlaceholder", "Feature title")}
value={featureForm.title}
onChange={(e) => setFeatureForm({ ...featureForm, title: e.target.value })}
onKeyDown={handleFeatureFormKeyDown}
autoFocus
/>
<textarea
placeholder={t("missions.descriptionOptional", "Description (optional)")}
value={featureForm.description}
onChange={(e) => setFeatureForm({ ...featureForm, description: e.target.value })}
rows={2}
/>
<textarea
placeholder={t("missions.acceptanceCriteriaOptional", "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} />}
{t("missions.createButton", "Create")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelFeature}>
{t("missions.cancelButton", "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>{t("missions.noFixFeaturesGenerated", "No fix features generated.")}</span>
</div>
)}
</div>
</div>
)}
</div>
))}
{milestone.slices.length === 0 && !isCreatingSlice && (
<div className="mission-manager__empty">
<Package size={16} />
<span>{t("missions.noSlicesYet", "No slices yet")}</span>
</div>
)}
{/* Assertions Panel */}
<div className="mission-assertions">
<div className="mission-assertions__header">
<span className="mission-assertions__title">{t("missions.contractAssertions", "Contract assertions (AI-validated)")}</span>
<span className="mission-assertions__mode-tag" data-testid="milestone-assertions-enforced-indicator">
<span className="status-dot status-dot--running" />
{t("missions.aiValidatedMissionGate", "AI-validated mission gate")}
</span>
{milestoneRollup && (
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: milestoneValidationColors.bg,
color: milestoneValidationColors.text,
}}
>
{formatValidationState(milestoneRollup.state, t)}
</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={t("missions.addAssertion", "Add assertion")}
>
<Plus size={14} />
</button>
</div>
{/* Create assertion form */}
{isCreatingAssertion && (
<div className="mission-form-card">
<input
type="text"
placeholder={t("missions.assertionTitlePlaceholder", "Assertion title")}
value={assertionForm.title}
onChange={(e) => setAssertionForm({ ...assertionForm, title: e.target.value })}
autoFocus
/>
<textarea
placeholder={t("missions.assertionTextPlaceholder", "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">{t("missions.statusPending", "Pending")}</option>
<option value="passed">{t("missions.statusPassed", "Passed")}</option>
<option value="failed">{t("missions.statusFailed", "Failed")}</option>
<option value="blocked">{t("missions.statusBlocked", "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} />}
{t("missions.createButton", "Create")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelAssertion}>
{t("missions.cancelButton", "Cancel")}
</button>
</div>
</div>
)}
{/* Assertions list */}
<div className="mission-assertions__list">
{milestoneAssertions.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={t("missions.assertionTitlePlaceholder", "Assertion title")}
value={assertionForm.title}
onChange={(e) => setAssertionForm({ ...assertionForm, title: e.target.value })}
autoFocus
/>
<textarea
placeholder={t("missions.assertionTextEditPlaceholder", "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">{t("missions.statusPending", "Pending")}</option>
<option value="passed">{t("missions.statusPassed", "Passed")}</option>
<option value="failed">{t("missions.statusFailed", "Failed")}</option>
<option value="blocked">{t("missions.statusBlocked", "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} />}
{t("missions.saveButton", "Save")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelAssertion}>
{t("missions.cancelButton", "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={t("missions.linkedFeaturesCount", { count, defaultValue_one: "{{count}} linked feature", defaultValue_other: "{{count}} linked features" })}>
{t("missions.linkedCountShort", "({{count}} linked)", { count })}
</span>
) : null;
})()}
<button
className="mission-icon-btn"
onClick={() => handleToggleAssertionExpanded(assertion.id)}
title={t("missions.toggleDetails", "Toggle details")}
>
{expandedAssertionId === assertion.id ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<button
className="mission-icon-btn"
onClick={() => handleEditAssertion(assertion)}
title={t("missions.editAssertion", "Edit assertion")}
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "assertion", id: assertion.id })}
title={t("missions.deleteAssertion", "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">{t("missions.linkedFeaturesLabel", "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={t("missions.linkAFeature", "Link a feature")}
>
<Link size={12} />
{t("missions.linkFeatureButton", "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">{t("missions.allFeaturesLinked", "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">{t("missions.noFeaturesLinkedYet", "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={t("missions.unlinkFeature", "Unlink feature")}
>
{isUnlinking ? <Loader2 size={12} className="spinner" /> : <Unlink size={12} />}
</button>
</div>
);
});
})()}
</div>
</div>
)}
</div>
))}
{(milestoneAssertions.length === 0)
&& !isCreatingAssertion
&& (
// Render from feature prose presence directly. Legacy
// hasProseButNoAssertions telemetry is no longer the gate.
featuresWithAcceptanceCriteria.length > 0 ? (
<>
<div className="mission-manager__empty mission-assertions__empty">
<span>{t("missions.noAssertionsYet", "No linked contract assertions are loaded yet. Feature criteria below will still be AI-validated when mission validation runs.")}</span>
</div>
<div className="mission-assertions__list" data-testid="milestone-feature-acceptance-rollup">
<div className="mission-assertions__rollup-header">
<span className="mission-assertions__title">{t("missions.featureCriteriaAwaitingSync", "Feature criteria awaiting assertion sync")}</span>
<span className="mission-assertions__mode-tag" data-testid="milestone-feature-acceptance-ai-validated-indicator">
<span className="status-dot status-dot--running" />
{t("missions.aiValidatedAtRuntime", "AI-validated at runtime")}
</span>
</div>
{featuresWithAcceptanceCriteria.map((feature) => (
<div key={feature.id} className="mission-assertion">
<div className="mission-assertion__header">
<span className="mission-assertion__title">{feature.title}</span>
<span
className="mission-status-badge mission-status-badge--sm"
data-testid={`mission-feature-acceptance-status-${feature.id}`}
style={{
backgroundColor: featureStatusColors[feature.status].bg,
color: featureStatusColors[feature.status].text,
}}
>
{feature.status}
</span>
</div>
<div className="mission-assertion__text">
<strong>{t("missions.acceptance", "Acceptance:")}</strong>
{renderMarkdownText(feature.acceptanceCriteria ?? "")}
</div>
</div>
))}
</div>
</>
) : (
!milestone.acceptanceCriteria?.trim() ? (
<div className="mission-manager__empty mission-assertions__empty">
<span>{t("missions.noAssertionsDefined", "No feature acceptance criteria or contract assertions defined yet.")}</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} />
{t("missions.addMilestone", "Add Milestone")}
</button>
)}
{/* Global create milestone form */}
{isCreatingMilestone && editingMilestoneId === null && (
<div className="mission-form-card">
<input
type="text"
placeholder={t("missions.milestoneTitlePlaceholder", "Milestone title")}
value={milestoneForm.title}
onChange={(e) => setMilestoneForm({ ...milestoneForm, title: e.target.value })}
onKeyDown={handleMilestoneFormKeyDown}
autoFocus
/>
<textarea
placeholder={t("missions.descriptionOptional", "Description (optional)")}
value={milestoneForm.description}
onChange={(e) => setMilestoneForm({ ...milestoneForm, description: e.target.value })}
rows={2}
/>
<textarea
placeholder={t("missions.acceptanceCriteriaOptional", "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} />}
{t("missions.createButton", "Create")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelMilestone}>
{t("missions.cancelButton", "Cancel")}
</button>
</div>
</div>
)}
{selectedMission.milestones.length === 0 && !isCreatingMilestone && (
<div className="mission-manager__empty">
<Layers size={24} />
<span>{t("missions.noMilestonesYet", "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>{t("missions.filterLabel", "Filter")}</span>
<select
value={eventsFilter}
onChange={(event) => setEventsFilter(event.target.value as typeof eventsFilter)}
data-testid="mission-activity-filter"
>
<option value="all">{t("missions.filterAll", "All events")}</option>
<option value="errors">{t("missions.filterErrors", "Errors & warnings")}</option>
<option value="state_changes">{t("missions.filterStateChanges", "State changes")}</option>
<option value="tasks">{t("missions.filterTasks", "Task events")}</option>
<option value="slices">{t("missions.filterSlices", "Slice & milestone events")}</option>
<option value="autopilot">{t("missions.filterAutopilot", "Autopilot events")}</option>
</select>
</label>
<span className="mission-detail__activity-count">
{t("missions.activityCount", "{{visible}} of {{total}}", { visible: missionEvents.length, total: 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"
>
{t("missions.loadMore", "Load more")}
</button>
</div>
)}
{eventsLoading ? (
<div className="mission-manager__loading mission-detail__activity-loading">
<Loader2 size={18} className="spinner" />
<span>{t("missions.loadingActivity", "Loading mission activity...")}</span>
</div>
) : missionEvents.length === 0 ? (
<div className="mission-manager__empty">
<Activity size={18} />
<span>{t("missions.noEventsYet", "No events yet.")}</span>
</div>
) : (
<div
ref={activityEventsContainerRef}
className="mission-events"
data-testid="mission-activity-events"
>
{displayedMissionEvents.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, t)}</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 ? t("missions.hideMetadata", "Hide metadata") : t("missions.showMetadata", "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 openNewMissionInterview = () => {
if (resumeSessionId) {
dismissedResumeSessionIdRef.current = resumeSessionId;
}
setInterviewLaunchMode("new");
setLocalResumeSessionId(undefined);
setInterviewModalKey((current) => current + 1);
setShowInterviewModal(true);
};
const handleResumeInterviewSession = (sessionId: string) => {
setInterviewLaunchMode("resume");
setLocalResumeSessionId(sessionId);
setInterviewModalKey((current) => current + 1);
setShowInterviewModal(true);
};
const shouldRenderSidebarDeleteConfirm =
deleteConfirmId != null && deleteConfirmId.type === "interview_draft";
const handleInterviewModalClose = () => {
dismissedResumeSessionIdRef.current = effectiveResumeSessionId ?? null;
setInterviewLaunchMode("new");
setLocalResumeSessionId(undefined);
setShowInterviewModal(false);
};
const handleDiscardInterviewSession = async (sessionId: string) => {
try {
/*
FNXC:MissionDraftDiscard 2026-06-24-02:42:
The mission draft Discard confirmation must send the current browser tab id so a draft locked by this tab can be removed while a draft actively owned by another tab returns the lock warning and stays visible.
*/
await discardMissionInterviewDraft(sessionId, projectId);
setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId));
} catch (err) {
if (err instanceof ApiRequestError && err.status === 409) {
addToast(t("missions.draftOpenInAnotherTab", "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) || t("missions.draftDiscardFailed", "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 isComplete = session.status === "complete";
const resumeActionLabel = session.status === "error" ? t("missions.interviewActionRetry", "Retry interview")
: session.status === "generating" ? t("missions.interviewActionGenerating", "Generating plan")
: session.status === "complete" ? t("missions.interviewActionReview", "Review plan")
: t("missions.interviewActionResume", "Resume interview");
const description = isGenerating
? t("missions.interviewGenerating", "Generating mission hierarchy from interview context.")
: isErrored
? t("missions.interviewErrored", "Interview hit an error. Retry from this list item.")
: isComplete
? t("missions.interviewComplete", "Plan ready — review and approve to create the mission.")
: t("missions.interviewWaiting", "Interview is waiting for your next response.");
const actionText = isGenerating ? t("missions.actionGenerating", "Generating…") : isErrored ? t("missions.actionRetry", "Retry") : isComplete ? t("missions.actionReview", "Review") : t("missions.actionResume", "Resume");
return (
<div
key={session.id}
className="mission-list__item mission-list__item--interview"
role="button"
tabIndex={0}
aria-label={t("missions.resumeInterviewAriaLabel", "Resume interview {{title}}", { title: session.title || t("missions.defaultInterviewTitle", "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 || t("missions.defaultInterviewTitle", "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, t)}
</span>
</div>
<p className="mission-list__item-description">{description}</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>{actionText}</span>
</button>
<button
className="mission-btn mission-btn--danger mission-btn--sm"
onClick={() => setDeleteConfirmId({ type: "interview_draft", id: session.id })}
title={t("missions.discardDraft", "Discard draft")}
aria-label={t("missions.discardDraft", "Discard draft")}
>
<Trash2 size={14} />
<span>{t("missions.discardButton", "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;
const showUnlinkedIndicator =
m.status === "active" &&
!isInterviewStyle &&
(mission.summary?.linkedGoalCount ?? 0) === 0;
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={t("missions.openMissionAriaLabel", "Open mission {{title}}", { title: 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={t("missions.autopilotEnabledLabel", "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={t("missions.missionHealthAriaLabel", "Mission health: {{state}}", { state: healthState })}
/>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: statusColors.bg,
color: statusColors.text,
}}
>
{m.status}
</span>
{showUnlinkedIndicator && (
<span
className="mission-status-badge mission-status-badge--sm mission-status-badge--unlinked"
title={t("missions.noGoalsLinked", "No goals linked to this mission")}
aria-label={t("missions.noGoalsLinked", "No goals linked to this mission")}
data-testid={`mission-unlinked-indicator-${m.id}`}
>
{t("missions.unlinkedBadge", "Unlinked")}
</span>
)}
{isInterviewStyle && (
<span className="mission-status-badge mission-status-badge--sm mission-interview-status mission-interview-status--awaiting_input">
{t("missions.interviewInProgress", "Interview in progress")}
</span>
)}
</div>
{isInterviewStyle ? (
<p className="mission-list__item-description">{t("missions.missionInterviewInProgressDesc", "Mission interview is still in progress. Open this mission to continue planning.")}</p>
) : m.description ? (
<div className="mission-list__item-description">{renderMarkdownText(m.description)}</div>
) : null}
{showSummaryBlock && (
<div className="mission-list__item-summary">
{hasContent && (
<>
<span className="mission-list__item-stat">
{t("missions.completedMilestones", "{{completed}}/{{total}} milestones", { completed: summary!.completedMilestones, total: summary!.totalMilestones })}
</span>
<span className="mission-list__item-stat">
{t("missions.completedFeatures", "{{completed}}/{{total}} features", { completed: summary!.completedFeatures, total: summary!.totalFeatures })}
</span>
</>
)}
<span className="mission-list__item-stat" data-testid={`mission-task-stats-${m.id}`}>
{t("missions.completedTasks", "{{completed}}/{{total}} tasks", { completed: tasksCompleted, total: totalTasks })}
</span>
{tasksFailed > 0 && (
<button
className="mission-list__item-failed"
onClick={(event) => {
event.stopPropagation();
handleSelectMission(mission);
}}
data-testid={`mission-failed-${m.id}`}
title={t("missions.viewMissionFailures", "View mission failures")}
>
{t("missions.tasksFailed", { count: tasksFailed, defaultValue_one: "{{count}} failed", defaultValue_other: "{{count}} 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}`}>
{t("missions.activityTime", "Activity {{time}}", { time: getRelativeTime(health?.lastActivityAt, t) })}
</span>
</div>
)}
</div>
<div className="mission-list__item-actions" onClick={(e) => e.stopPropagation()}>
<div className="mission-list__item-run-controls">
{m.status === "active" && (
<button
className="mission-btn mission-btn--danger mission-btn--sm"
onClick={() => handleStopMission(m.id)}
title={t("missions.stopMission", "Stop mission")}
aria-label={t("missions.stopMission", "Stop mission")}
>
<Square size={14} />
<span>{t("missions.stopMission", "Stop mission")}</span>
</button>
)}
{m.status === "blocked" && (
<button
className="mission-btn mission-btn--primary mission-btn--sm"
onClick={() => handleResumeMission(m.id)}
title={t("missions.resumeMission", "Resume mission")}
aria-label={t("missions.resumeMission", "Resume mission")}
>
<Play size={14} />
<span>{t("missions.resumeMission", "Resume mission")}</span>
</button>
)}
{m.status === "planning" && (
<button
className="mission-btn mission-btn--primary mission-btn--sm"
onClick={() => handleStartMission(m.id)}
title={t("missions.startMission", "Start mission")}
aria-label={t("missions.startMission", "Start mission")}
>
<Play size={14} />
<span>{t("missions.startMission", "Start mission")}</span>
</button>
)}
{getMissionRunHelperText(m.status, t) && (
<span className="mission-list__item-run-help">{getMissionRunHelperText(m.status, t)}</span>
)}
</div>
<button
className="mission-icon-btn"
onClick={() => handleEditMission(mission)}
title={t("missions.editMission", "Edit mission")}
aria-label={t("missions.editMission", "Edit mission")}
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => void requestDeleteMission(m.id)}
title={t("missions.deleteMission", "Delete mission")}
aria-label={t("missions.deleteMission", "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 showBottomPlanButton = !hideBottomButtons;
return (
<div className="mission-list">
{/* Create mission form */}
{isCreatingMission && (
<div className="mission-form-card">
<input
type="text"
placeholder={t("missions.missionTitlePlaceholder", "Mission title")}
value={missionForm.title}
onChange={(e) => setMissionForm({ ...missionForm, title: e.target.value })}
onKeyDown={handleMissionFormKeyDown}
autoFocus
/>
<textarea
placeholder={t("missions.descriptionOptional", "Description (optional)")}
value={missionForm.description}
onChange={(e) => setMissionForm({ ...missionForm, description: e.target.value })}
rows={2}
/>
<label>
{t("missions.targetBranch", "Target branch")}
<input
type="text"
placeholder={t("missions.targetBranchPlaceholder", "e.g. main")}
value={missionForm.baseBranch}
onChange={(e) => setMissionForm({ ...missionForm, baseBranch: e.target.value })}
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
/>
</label>
<label>
{t("missions.branchStrategy", "Branch strategy")}
<select
value={missionForm.branchStrategy.mode}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
mode: e.target.value as MissionBranchStrategyMode,
branchName: missionForm.branchStrategy.branchName,
},
})
}
aria-label={t("missions.branchStrategyAriaLabel", "Mission branch strategy")}
>
<option value="project-default">{t("missions.branchStrategyProjectDefault", "Use project/default branch")}</option>
<option value="auto-per-task">{t("missions.branchStrategyAutoPerTask", "Auto-name a branch per task (from details)")}</option>
<option value="existing">{t("missions.branchStrategyExisting", "Use existing branch")}</option>
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
</select>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label>
{t("missions.branchName", "Branch name")}
<input
type="text"
placeholder={t("missions.branchNamePlaceholder", "e.g. feature/mission-work")}
value={missionForm.branchStrategy.branchName ?? ""}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
...missionForm.branchStrategy,
branchName: e.target.value,
},
})
}
aria-label={t("missions.branchNameAriaLabel", "Mission branch name")}
/>
</label>
)}
<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} />}
{t("missions.createButton", "Create")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelMission}>
{t("missions.cancelButton", "Cancel")}
</button>
</div>
</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>{t("missions.draftsLabel", "Drafts")}</span>
<span>({missionInterviewDrafts.length})</span>
</div>
{renderInterviewSessionItems()}
</div>
)}
{renderMissionListItems(persistedInterviewMissions, { interviewStyle: true })}
{renderMissionListItems(standardMissions)}
{/* Edit mission form */}
{editingMissionId && selectedMission?.id !== editingMissionId && (
<div className="mission-form-card">
<input
type="text"
placeholder={t("missions.missionTitlePlaceholder", "Mission title")}
value={missionForm.title}
onChange={(e) => setMissionForm({ ...missionForm, title: e.target.value })}
onKeyDown={handleMissionFormKeyDown}
autoFocus
/>
<textarea
placeholder={t("missions.descriptionOptional", "Description (optional)")}
value={missionForm.description}
onChange={(e) => setMissionForm({ ...missionForm, description: e.target.value })}
rows={2}
/>
<label>
{t("missions.targetBranch", "Target branch")}
<input
type="text"
placeholder={t("missions.targetBranchPlaceholder", "e.g. main")}
value={missionForm.baseBranch}
onChange={(e) => setMissionForm({ ...missionForm, baseBranch: e.target.value })}
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
/>
</label>
<label>
{t("missions.branchStrategy", "Branch strategy")}
<select
value={missionForm.branchStrategy.mode}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
mode: e.target.value as MissionBranchStrategyMode,
branchName: missionForm.branchStrategy.branchName,
},
})
}
aria-label={t("missions.branchStrategyAriaLabel", "Mission branch strategy")}
>
<option value="project-default">{t("missions.branchStrategyProjectDefault", "Use project/default branch")}</option>
<option value="auto-per-task">{t("missions.branchStrategyAutoPerTask", "Auto-name a branch per task (from details)")}</option>
<option value="existing">{t("missions.branchStrategyExisting", "Use existing branch")}</option>
<option value="custom-new">{t("missions.branchStrategyCustomNew", "Create custom branch")}</option>
</select>
</label>
{(missionForm.branchStrategy.mode === "existing" || missionForm.branchStrategy.mode === "custom-new") && (
<label>
{t("missions.branchName", "Branch name")}
<input
type="text"
placeholder={t("missions.branchNamePlaceholder", "e.g. feature/mission-work")}
value={missionForm.branchStrategy.branchName ?? ""}
onChange={(e) =>
setMissionForm({
...missionForm,
branchStrategy: {
...missionForm.branchStrategy,
branchName: e.target.value,
},
})
}
aria-label={t("missions.branchNameAriaLabel", "Mission branch name")}
/>
</label>
)}
<div className="mission-form-card__row">
<select
value={missionForm.status}
onChange={(e) => setMissionForm({ ...missionForm, status: e.target.value as MissionStatus })}
>
<option value="planning">{t("missions.statusPlanning", "Planning")}</option>
<option value="active">{t("missions.statusActive", "Active")}</option>
<option value="blocked">{t("missions.statusBlocked", "Blocked")}</option>
<option value="complete">{t("missions.statusComplete", "Complete")}</option>
<option value="archived">{t("missions.statusArchived", "Archived")}</option>
</select>
<label className="mission-checkbox">
<input
type="checkbox"
checked={missionForm.autopilotEnabled}
onChange={(e) => setMissionForm({ ...missionForm, autopilotEnabled: e.target.checked })}
/>
<Zap size={12} /> {t("missions.autopilotLabel", "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} />}
{t("missions.updateButton", "Update")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={handleCancelMission}>
{t("missions.cancelButton", "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">{t("missions.noMissionsYetTitle", "No missions yet")}</h3>
<p className="mission-manager__empty-body">
{t("missions.noMissionsYetBody", "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={openNewMissionInterview}
>
<Sparkles size={14} />
{t("missions.planNewMission", "Plan New Mission")}
</button>
</div>
)}
{!isCreatingMission && (
<div className="mission-list__footer">
{showBottomPlanButton && (
<div className="mission-list__footer-actions">
<button className="btn btn-sm btn-primary mission-list__primary-cta" onClick={openNewMissionInterview}>
<Sparkles size={14} />
{t("missions.planNewMission", "Plan New Mission")}
</button>
</div>
)}
</div>
)}
</div>
);
};
const renderDeleteConfirmPanel = () => {
if (deleteConfirmId?.type === "mission") {
return null;
}
const isInterviewDraftDelete = deleteConfirmId?.type === "interview_draft";
return (
<div className="mission-confirm-panel mission-confirm-panel--danger">
<div className="mission-confirm-panel__content">
<p>
{isInterviewDraftDelete
? t("missions.discardDraftConfirm", "Discard this interview draft? This removes the saved draft and cannot be undone.")
: t("missions.deleteConfirm", "Delete this {{type}}? This cannot be undone.", { type: deleteConfirmId?.type })}
</p>
<div className="mission-confirm-panel__actions">
<button
className="mission-btn mission-btn--danger"
onClick={async () => {
if (!deleteConfirmId) return;
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 ? t("missions.discardButton", "Discard") : t("missions.deleteButton", "Delete")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={() => setDeleteConfirmId(null)}>
{t("missions.cancelButton", "Cancel")}
</button>
</div>
</div>
</div>
);
};
const renderLinkTaskPanel = () => (
<div className="mission-confirm-panel mission-confirm-panel--link">
<div className="mission-confirm-panel__content">
<p>{t("missions.linkFeatureToTask", "Link feature to task:")}</p>
<input
type="text"
placeholder={t("missions.taskIdPlaceholder", "Task ID (e.g., FN-001)")}
value={selectedTaskId}
onChange={(e) => setSelectedTaskId(e.target.value)}
autoFocus
/>
{availableTasks.length > 0 && (
<div className="mission-task-suggestions">
<small>{t("missions.orSelect", "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 || t("missions.untitled", "Untitled")}
</button>
))}
</div>
</div>
)}
<div className="mission-confirm-panel__actions">
<button className="mission-btn mission-btn--primary" onClick={handleLinkTask}>
{t("missions.linkButton", "Link")}
</button>
<button className="mission-btn mission-btn--ghost" onClick={() => { setLinkTaskFeatureId(null); setSelectedTaskId(""); }}>
{t("missions.cancelButton", "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 : t("missions.missionManagerAriaLabel", "Mission Manager")}
data-testid="mission-manager-dialog"
>
{/*
FNXC:Navigation 2026-06-22-01:10:
Missions keeps its own header element (not the shared ViewHeader component) because it owns a dynamic mobile title (mission title when one is selected), a back button for stacked list->detail nav, an inline-vs-modal padding variant, and the mission-header-title test id. To stay visually consistent with the Command Center-modeled ViewHeader, the title uses the same icon size (20) and 1.125rem title metric via .mission-manager__title.
*/}
<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={t("missions.backToMissions", "Back to missions")}
aria-label={t("missions.backToMissionsList", "Back to missions list")}
data-testid="mission-back-btn"
>
<ChevronLeft size={18} />
</button>
)}
<Target size={20} 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">{t("missions.title", "Missions")}</span>
<span className="mission-manager__title-text mission-manager__title-text--mobile">
{selectedMission ? selectedMission.title : t("missions.title", "Missions")}
</span>
</h2>
</div>
{!isInline && (
<button
className="modal-close"
onClick={onClose}
title={t("missions.close", "Close")}
aria-label={t("missions.closeMissionManager", "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>{t("missions.loadingMissions", "Loading missions...")}</span>
</div>
) : detailLoading ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>{t("missions.loadingMissionDetails", "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={t("missions.missionList", "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>{t("missions.loadingMissions", "Loading missions...")}</span>
</div>
) : (
renderMissionListContent({ hideBottomButtons: true })
)}
</div>
<div className="mission-manager__sidebar-footer" data-testid="mission-sidebar-footer">
{shouldRenderSidebarDeleteConfirm && renderDeleteConfirmPanel()}
<button
className="btn btn-primary mission-manager__sidebar-cta"
onClick={openNewMissionInterview}
title={t("missions.planNewMission", "Plan New Mission")}
aria-label={t("missions.planNewMission", "Plan New Mission")}
>
<Sparkles size={14} />
{t("missions.planNewMission", "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={t("missions.resizeSidebar", "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>{t("missions.loadingMissionDetails", "Loading mission details...")}</span>
</div>
) : selectedMission ? (
renderMissionDetailContent()
) : (
<div className="mission-manager__detail-pane-empty" data-testid="mission-empty-detail">
<Target size={32} />
<span>{t("missions.selectMissionToView", "Select a mission to view details")}</span>
</div>
)}
{deleteConfirmId && !shouldRenderSidebarDeleteConfirm && renderDeleteConfirmPanel()}
{linkTaskFeatureId && renderLinkTaskPanel()}
</div>
</div>
)}
</div>
);
const interviewModal = (
<MissionInterviewModal
key={interviewModalKey}
isOpen={showInterviewModal}
onClose={handleInterviewModalClose}
onSendToBackground={handleInterviewModalClose}
showSendToBackgroundButton={interviewLaunchMode === "resume"}
onMissionCreated={() => {
loadMissions();
addToast(t("missions.createdFromInterview", "Mission created from AI interview"), "success");
}}
projectId={projectId}
resumeSessionId={interviewLaunchMode === "resume" ? effectiveResumeSessionId : undefined}
/>
);
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}
</>
);
}