FN-5681: clarify mission run controls and autopilot states

Improve Mission Manager run-state UX by separating mission run controls from autopilot behavior.

- Group mission run settings in the detail view and add explicit Start mission / Stop mission / Resume mission buttons with contextual helper text.
- Humanize autopilot state labels (Off, Watching, Activating slice, Completing) and add explanatory autopilot description copy.
- Update Mission Manager styling to support the new run-settings layout and helper text blocks in list/detail views.
- Extend MissionManager tests to verify labeled run controls, endpoint wiring for start/stop/resume, and humanized autopilot badge labels.
- Update mission docs to clarify the distinction between mission run-state controls and the autopilot toggle.

Files changed:
 docs/missions.md                                   |   2 +-
 packages/dashboard/app/components/MissionManager.css    |  62 ++++-
 packages/dashboard/app/components/MissionManager.tsx    | 272 +++++++++++++--------
 packages/dashboard/app/components/__tests__/MissionManager.test.tsx   | 107 +++++++-
 4 files changed, 330 insertions(+), 113 deletions(-)

Fusion-Task-Id: FN-5681

Fusion-Task-Lineage: 0dcac659-2eff-435b-a176-b66e7646b054
This commit is contained in:
gsxdsm
2026-05-29 16:07:35 -07:00
parent 480915b4f8
commit dac9b96262
4 changed files with 330 additions and 113 deletions

View File

@@ -213,7 +213,7 @@ If validation cannot run (unexpected loop state, duplicate trigger, blocked vali
- `autopilotEnabled=false`, `autoAdvance=true` → next pending slice is activated (legacy compat)
- `autopilotEnabled=false`, `autoAdvance=false` → manual activation required
**Dashboard UI:** The Mission Manager shows `autopilotEnabled` as the primary control. When enabling autopilot on an already-active mission, the system automatically checks whether recovery is needed (no active slice or completed active slice) and progresses accordingly.
**Dashboard UI:** The Mission Manager groups mission run settings together: explicit **Start mission / Stop mission / Resume mission** actions control mission run-state, while the **Autopilot** toggle controls automatic slice advancement and feature planning. The autopilot badge uses human-readable states (`Off`, `Watching`, `Activating slice`, `Completing`). When enabling autopilot on an already-active mission, the system automatically checks whether recovery is needed (no active slice or completed active slice) and progresses accordingly.
## Autopilot API Endpoints

View File

@@ -866,6 +866,19 @@
flex-shrink: 0;
}
.mission-list__item-run-controls {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: calc(var(--space-xs) / 2);
}
.mission-list__item-run-help {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
max-width: calc(var(--space-3xl) * 5);
}
.mission-list__item-summary {
display: flex;
flex-wrap: wrap;
@@ -1031,17 +1044,50 @@
/* ── Detail-level action bar (edit / delete the mission itself) ── */
.mission-detail__actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
padding-top: var(--space-xs);
}
.mission-detail__run-controls {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: calc(var(--space-xs) / 2);
}
.mission-detail__run-help {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
}
.mission-detail__management-actions {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
.mission-detail__run-settings {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding-top: var(--space-xs);
}
.mission-detail__run-settings-title {
margin: 0;
font-size: calc(var(--space-sm) + var(--space-xs));
color: var(--text-muted);
font-weight: 600;
}
/* ── Autopilot section ── */
.mission-detail__autopilot {
display: flex;
align-items: center;
gap: var(--space-md);
flex-wrap: wrap;
flex-direction: column;
align-items: flex-start;
gap: var(--space-xs);
padding: var(--space-xs) 0;
}
@@ -1055,6 +1101,11 @@
color: var(--color-warning);
}
.mission-detail__autopilot-description {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
}
.mission-detail__autopilot-activity {
font-size: calc(var(--space-sm) + var(--space-xs) * 0.75);
color: var(--text-muted);
@@ -2442,6 +2493,11 @@
flex-wrap: wrap;
}
.mission-detail__run-help,
.mission-list__item-run-help {
max-width: 100%;
}
/* Milestone headers wrap actions below title row */
.mission-milestone__header {
padding: var(--space-sm) var(--space-md);

View File

@@ -162,6 +162,13 @@ const autopilotStateColors: Record<AutopilotState, { bg: string; text: string }>
completing: { bg: "var(--autopilot-completing-bg)", text: "var(--autopilot-completing-text)" },
};
const autopilotStateLabels: Record<AutopilotState, string> = {
inactive: "Off",
watching: "Watching",
activating: "Activating slice",
completing: "Completing",
};
/** Assertion status colors */
const assertionStatusColors: Record<MissionAssertionStatus, { bg: string; text: string }> = {
pending: { bg: "var(--assertion-pending-bg)", text: "var(--assertion-pending-text)" },
@@ -210,6 +217,26 @@ function getInterviewActionLabel(status: AiSessionSummary["status"]): string {
}
}
function getMissionRunHelperText(status: MissionStatus): string | null {
switch (status) {
case "planning":
return "Starting activates the first slice so work can begin.";
case "active":
return "Stopping pauses linked tasks and marks the mission blocked.";
case "blocked":
return "Resuming re-activates the mission and continues execution.";
default:
return null;
}
}
function getAutopilotStateLabel(state: string): string {
if (state in autopilotStateLabels) {
return autopilotStateLabels[state as AutopilotState];
}
return state ? state.replace(/_/g, " ") : "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";
@@ -2407,91 +2434,107 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</span>
</div>
{/* ── Autopilot section ── */}
<div className="mission-detail__autopilot">
<div className="mission-detail__autopilot-toggle">
<label className="mission-toggle" data-testid="mission-autopilot-toggle">
<input
type="checkbox"
checked={selectedMission.autopilotEnabled ?? false}
onChange={(e) => handleToggleAutopilot(selectedMission.id, e.target.checked)}
aria-label="Autopilot"
/>
<span className="mission-toggle__track" aria-hidden="true">
<span className="mission-toggle__thumb" />
<section className="mission-detail__run-settings" aria-label="Mission run settings">
<h4 className="mission-detail__run-settings-title">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="Autopilot"
/>
<span className="mission-toggle__track" aria-hidden="true">
<span className="mission-toggle__thumb" />
</span>
<span className="mission-toggle__label">
<Zap size={14} className="mission-detail__autopilot-icon" />
Autopilot
</span>
</label>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: (autopilotStateColors[autopilotState] || autopilotStateColors.inactive).bg,
color: (autopilotStateColors[autopilotState] || autopilotStateColors.inactive).text,
}}
data-testid="autopilot-state-badge"
>
{autopilotPulseActive && <span className="mission-detail__autopilot-pulse" />}
{getAutopilotStateLabel(autopilotState)}
</span>
<span className="mission-toggle__label">
<Zap size={14} className="mission-detail__autopilot-icon" />
Autopilot
</span>
</label>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: (autopilotStateColors[autopilotState] || autopilotStateColors.inactive).bg,
color: (autopilotStateColors[autopilotState] || autopilotStateColors.inactive).text,
}}
data-testid="autopilot-state-badge"
>
{autopilotPulseActive && <span className="mission-detail__autopilot-pulse" />}
{autopilotState}
</div>
<span className="mission-detail__autopilot-description">
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>
{autopilotActivitySummary && (
<span className="mission-detail__autopilot-activity mission-relative-time">
{autopilotActivitySummary}
</span>
)}
</div>
<div className="mission-detail__actions">
{selectedMission.status === "active" && (
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleStopMission(selectedMission.id)}
title="Stop mission"
aria-label="Stop mission"
>
<Square size={14} />
</button>
)}
{selectedMission.status === "blocked" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleResumeMission(selectedMission.id)}
title="Resume mission"
aria-label="Resume mission"
>
<Play size={14} />
</button>
)}
{selectedMission.status === "planning" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleStartMission(selectedMission.id)}
title="Start mission"
aria-label="Start mission"
>
<Play size={14} />
</button>
)}
<button
className="mission-icon-btn"
onClick={() => handleEditMission(selectedMission)}
title="Edit mission"
aria-label="Edit mission"
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "mission", id: selectedMission.id })}
title="Delete mission"
aria-label="Delete mission"
>
<Trash2 size={14} />
</button>
</div>
<div 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="Stop mission"
aria-label="Stop mission"
>
<Square size={14} />
<span>Stop mission</span>
</button>
)}
{selectedMission.status === "blocked" && (
<button
className="mission-btn mission-btn--primary"
onClick={() => handleResumeMission(selectedMission.id)}
title="Resume mission"
aria-label="Resume mission"
>
<Play size={14} />
<span>Resume mission</span>
</button>
)}
{selectedMission.status === "planning" && (
<button
className="mission-btn mission-btn--primary"
onClick={() => handleStartMission(selectedMission.id)}
title="Start mission"
aria-label="Start mission"
>
<Play size={14} />
<span>Start mission</span>
</button>
)}
{getMissionRunHelperText(selectedMission.status) && (
<span className="mission-detail__run-help">{getMissionRunHelperText(selectedMission.status)}</span>
)}
</div>
<div className="mission-detail__management-actions">
<button
className="mission-icon-btn"
onClick={() => handleEditMission(selectedMission)}
title="Edit mission"
aria-label="Edit mission"
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "mission", id: selectedMission.id })}
title="Delete mission"
aria-label="Delete mission"
>
<Trash2 size={14} />
</button>
</div>
</div>
</section>
</div>
{/* Inline edit mission form (detail view) */}
@@ -4087,37 +4130,49 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
)}
</div>
<div className="mission-list__item-actions" onClick={(e) => e.stopPropagation()}>
{m.status === "active" && (
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleStopMission(m.id)}
title="Stop mission"
>
<Square size={14} />
</button>
)}
{m.status === "blocked" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleResumeMission(m.id)}
title="Resume mission"
>
<Play size={14} />
</button>
)}
{m.status === "planning" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleStartMission(m.id)}
title="Start mission"
>
<Play size={14} />
</button>
)}
<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="Stop mission"
aria-label="Stop mission"
>
<Square size={14} />
<span>Stop mission</span>
</button>
)}
{m.status === "blocked" && (
<button
className="mission-btn mission-btn--primary mission-btn--sm"
onClick={() => handleResumeMission(m.id)}
title="Resume mission"
aria-label="Resume mission"
>
<Play size={14} />
<span>Resume mission</span>
</button>
)}
{m.status === "planning" && (
<button
className="mission-btn mission-btn--primary mission-btn--sm"
onClick={() => handleStartMission(m.id)}
title="Start mission"
aria-label="Start mission"
>
<Play size={14} />
<span>Start mission</span>
</button>
)}
{getMissionRunHelperText(m.status) && (
<span className="mission-list__item-run-help">{getMissionRunHelperText(m.status)}</span>
)}
</div>
<button
className="mission-icon-btn"
onClick={() => handleEditMission(mission)}
title="Edit mission"
aria-label="Edit mission"
>
<Pencil size={14} />
</button>
@@ -4125,6 +4180,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "mission", id: m.id })}
title="Delete mission"
aria-label="Delete mission"
>
<Trash2 size={14} />
</button>

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, act, within } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, act, within, cleanup } from "@testing-library/react";
import { MissionManager } from "../MissionManager";
import { loadAllAppCssBaseOnly } from "../../test/cssFixture";
@@ -3588,6 +3588,57 @@ describe("MissionManager", () => {
});
}
it("renders labeled run controls and calls matching mission status endpoints", async () => {
const runControlMissions = [
{ ...autopilotMockMissions[0], id: "M-RUN-ACTIVE", title: "Run Active", status: "active" },
{ ...autopilotMockMissions[1], id: "M-RUN-PLANNING", title: "Run Planning", status: "planning" },
{ ...autopilotMockMissions[1], id: "M-RUN-BLOCKED", title: "Run Blocked", status: "blocked" },
];
const fetchMock = vi.fn().mockImplementation((url: string, options?: RequestInit) => {
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-RUN-PLANNING";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (url.includes("/start") || url.includes("/stop") || url.includes("/resume")) {
return Promise.resolve(mockApiResponse({ ok: true }));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
const mission = runControlMissions.find((item) => url.includes(item.id)) ?? runControlMissions[0];
return Promise.resolve(mockApiResponse({ ...mission, milestones: [] }));
}
return Promise.resolve(mockApiResponse(runControlMissions));
});
globalThis.fetch = fetchMock;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Run Active")).toBeDefined();
});
const startButtons = screen.getAllByRole("button", { name: "Start mission" });
const stopButtons = screen.getAllByRole("button", { name: "Stop mission" });
const resumeButtons = screen.getAllByRole("button", { name: "Resume mission" });
expect(startButtons.length).toBeGreaterThan(0);
expect(stopButtons.length).toBeGreaterThan(0);
expect(resumeButtons.length).toBeGreaterThan(0);
fireEvent.click(startButtons[0]);
fireEvent.click(stopButtons[0]);
fireEvent.click(resumeButtons[0]);
await waitFor(() => {
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("/api/missions/M-RUN-PLANNING/start"))).toBe(true);
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("/api/missions/M-RUN-ACTIVE/stop"))).toBe(true);
expect(fetchMock.mock.calls.some(([url]) => String(url).includes("/api/missions/M-RUN-BLOCKED/resume"))).toBe(true);
});
});
it("shows autopilot icon for missions with autopilotEnabled in list view", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse(autopilotMockMissions));
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
@@ -3612,6 +3663,60 @@ describe("MissionManager", () => {
expect(autopilotIcons).toHaveLength(1);
});
it("shows autopilot toggle, helper copy, and humanized state labels", async () => {
const stateCases: Array<{ state: "inactive" | "watching" | "activating" | "completing"; label: string }> = [
{ state: "inactive", label: "Off" },
{ state: "watching", label: "Watching" },
{ state: "activating", label: "Activating slice" },
{ state: "completing", label: "Completing" },
];
for (const stateCase of stateCases) {
const fetchMock = vi.fn().mockImplementation((url: string) => {
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-AUTO1";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse({
enabled: true,
state: stateCase.state,
watched: stateCase.state !== "inactive",
lastActivityAt: "2026-01-01T12:00:00.000Z",
nextScheduledCheck: "2026-01-01T12:05:00.000Z",
}));
}
if (url.includes("/api/missions/M-AUTO1") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse({
...autopilotMockDetail,
autopilotState: stateCase.state,
}));
}
return Promise.resolve(mockApiResponse([{ ...autopilotMockMissions[0], autopilotState: stateCase.state }]));
});
globalThis.fetch = fetchMock;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Autopilot Mission")).toBeDefined();
});
fireEvent.click(screen.getByText("Autopilot Mission"));
await waitFor(() => {
expect(screen.getByLabelText("Autopilot")).toBeDefined();
expect(screen.getByText("When on, Fusion automatically activates the next slice and plans its features as work completes.")).toBeDefined();
expect(screen.getByTestId("autopilot-state-badge").textContent).toContain(stateCase.label);
});
expect(screen.queryByText(stateCase.state)).toBeNull();
cleanup();
}
});
it("shows autopilot toggle and status badge in detail view", async () => {
globalThis.fetch = createAutopilotFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);