refactor(FN-1353): simplify mission autopilot to single toggle

- Deprecate autoAdvance field in favor of autopilotEnabled as the sole control
- Remove autoAdvance guard logic from MissionAutopilot engine class
- Simplify MissionManager UI to use single autopilot toggle with visual state indicator
- Update MissionAutopilot tests to use autopilotEnabled instead of autoAdvance
- Update MissionManager component tests for simplified UI
- Update AGENTS.md documentation to reflect the simplified autopilot model
This commit is contained in:
gsxdsm
2026-04-09 13:45:19 -07:00
parent 4ef5d73ba1
commit e26321790c
7 changed files with 76 additions and 244 deletions

View File

@@ -16,9 +16,7 @@ import {
Link,
Unlink,
Play,
Pause,
Square,
RefreshCw,
Sparkles,
Zap,
Activity,
@@ -62,14 +60,10 @@ import {
unlinkFeatureFromTask,
triageFeature,
triageAllSliceFeatures,
pauseMission,
resumeMission,
stopMission,
startMission,
fetchMissionAutopilotStatus,
updateMissionAutopilot,
startMissionAutopilot,
stopMissionAutopilot,
fetchMissionHealth,
fetchMissionsHealth,
fetchMissionEvents,
@@ -130,7 +124,6 @@ interface MissionFormData {
title: string;
description: string;
status: MissionStatus;
autoAdvance: boolean;
autopilotEnabled: boolean;
}
@@ -158,7 +151,6 @@ const EMPTY_MISSION_FORM: MissionFormData = {
title: "",
description: "",
status: "planning",
autoAdvance: false,
autopilotEnabled: false,
};
@@ -382,8 +374,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
// Delete confirmation
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);
// Autopilot state
const [autopilotStatus, setAutopilotStatus] = useState<AutopilotStatusType | null>(null);
// Autopilot loading state
const [autopilotLoading, setAutopilotLoading] = useState(false);
const [missionHealthById, setMissionHealthById] = useState<Map<string, MissionHealth>>(new Map());
@@ -679,7 +670,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
title: mission.title,
description: mission.description || "",
status: mission.status,
autoAdvance: mission.autoAdvance ?? false,
autopilotEnabled: mission.autopilotEnabled ?? false,
});
}, []);
@@ -706,13 +696,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}, projectId);
addToast("Mission created", "success");
} else if (editingMissionId) {
await updateMission(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,
autoAdvance: missionForm.autoAdvance,
autopilotEnabled: missionForm.autopilotEnabled,
}, projectId);
};
if (missionForm.autopilotEnabled) {
updates.autoAdvance = true;
}
await updateMission(editingMissionId, updates as Parameters<typeof updateMission>[1], projectId);
addToast("Mission updated", "success");
// Refresh detail view if viewing this mission
if (selectedMission?.id === editingMissionId) {
@@ -1036,18 +1031,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [addToast, loadMissionDetail, selectedMission, projectId]);
// Pause mission — set status to "blocked"
const handlePauseMission = useCallback(async (missionId: string) => {
try {
await pauseMission(missionId, projectId);
addToast("Mission paused", "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err: any) {
addToast(err.message || "Failed to pause mission", "error");
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
// Resume a paused mission — set status back to "active"
const handleResumeMission = useCallback(async (missionId: string) => {
try {
@@ -1087,20 +1070,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
// ── Autopilot handlers ──
const loadAutopilotStatus = useCallback(async (missionId: string) => {
try {
const status = await fetchMissionAutopilotStatus(missionId, projectId);
setAutopilotStatus(status);
} catch {
// Silently ignore — autopilot status is supplementary
}
}, [projectId]);
const handleToggleAutopilot = useCallback(async (missionId: string, enabled: boolean) => {
try {
setAutopilotLoading(true);
const status = await updateMissionAutopilot(missionId, { enabled }, projectId);
setAutopilotStatus(status);
await updateMissionAutopilot(missionId, { enabled }, projectId);
addToast(enabled ? "Autopilot enabled" : "Autopilot disabled", "success");
// Reload mission detail to reflect updated fields
await loadMissionDetail(missionId);
@@ -1112,36 +1085,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
const handleStartAutopilot = useCallback(async (missionId: string) => {
try {
setAutopilotLoading(true);
const status = await startMissionAutopilot(missionId, projectId);
setAutopilotStatus(status);
addToast("Autopilot started", "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err: any) {
addToast(err.message || "Failed to start autopilot", "error");
} finally {
setAutopilotLoading(false);
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
const handleStopAutopilot = useCallback(async (missionId: string) => {
try {
setAutopilotLoading(true);
const status = await stopMissionAutopilot(missionId, projectId);
setAutopilotStatus(status);
addToast("Autopilot stopped", "success");
await loadMissionDetail(missionId);
loadMissions();
} catch (err: any) {
addToast(err.message || "Failed to stop autopilot", "error");
} finally {
setAutopilotLoading(false);
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
const handleSelectMission = useCallback((mission: Mission) => {
setActiveTab("structure");
setMissionEvents([]);
@@ -1149,12 +1092,10 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
setEventsFilter("all");
setExpandedEventMetadata(new Set());
loadMissionDetail(mission.id);
loadAutopilotStatus(mission.id);
}, [loadMissionDetail, loadAutopilotStatus]);
}, [loadMissionDetail]);
const handleBackToList = useCallback(() => {
setSelectedMission(null);
setAutopilotStatus(null);
setActiveTab("structure");
setMissionEvents([]);
setEventsTotal(0);
@@ -1164,11 +1105,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}, [loadMissions]);
const hasMoreEvents = missionEvents.length < eventsTotal;
const autopilotState = (autopilotStatus?.state ?? selectedMission?.autopilotState ?? "inactive") as AutopilotState;
const autopilotState = (selectedMission?.autopilotState ?? "inactive") as AutopilotState;
const autopilotPulseActive = autopilotState === "watching" || autopilotState === "activating";
const autopilotActivitySummary = getAutopilotActivitySummary(
autopilotState,
autopilotStatus?.lastActivityAt ?? selectedMission?.lastAutopilotActivityAt,
selectedMission?.lastAutopilotActivityAt,
);
const handleLoadMoreEvents = useCallback(() => {
@@ -1313,11 +1254,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<p className="mission-detail__description">{selectedMission.description}</p>
)}
<div className="mission-detail__meta">
{selectedMission.autoAdvance && (
<span className="mission-detail__meta-badge">
<Play size={12} /> Auto-advance
</span>
)}
<span className="mission-detail__meta-info">
{selectedMission.milestones.length} milestones
</span>
@@ -1359,65 +1295,18 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{autopilotActivitySummary}
</span>
)}
{autopilotStatus?.nextScheduledCheck && (
<span className="mission-detail__autopilot-next-check">
Next check: {new Date(autopilotStatus.nextScheduledCheck).toLocaleTimeString()}
</span>
)}
<div className="mission-detail__autopilot-actions">
<button
className="mission-btn mission-btn--primary mission-btn--sm"
onClick={() => handleStartAutopilot(selectedMission.id)}
disabled={autopilotLoading || !selectedMission.autopilotEnabled || Boolean(autopilotStatus?.watched)}
title="Start autopilot watching"
aria-label="Start autopilot watching"
data-testid="mission-autopilot-start"
>
<Play size={12} /> Start
</button>
<button
className="mission-btn mission-btn--danger mission-btn--sm"
onClick={() => handleStopAutopilot(selectedMission.id)}
disabled={autopilotLoading || !autopilotStatus?.watched}
title="Stop autopilot watching"
aria-label="Stop autopilot watching"
data-testid="mission-autopilot-stop"
>
<Square size={12} /> Stop
</button>
<button
className="mission-btn mission-btn--ghost mission-btn--sm"
onClick={() => loadAutopilotStatus(selectedMission.id)}
disabled={autopilotLoading}
title="Refresh autopilot status"
aria-label="Refresh autopilot status"
data-testid="mission-autopilot-refresh"
>
<RefreshCw size={12} /> Refresh
</button>
</div>
</div>
<div className="mission-detail__actions">
{selectedMission.status === "active" && (
<>
<button
className="mission-icon-btn"
onClick={() => handlePauseMission(selectedMission.id)}
title="Pause mission"
aria-label="Pause mission"
>
<Pause size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleStopMission(selectedMission.id)}
title="Stop mission"
aria-label="Stop mission"
>
<Square size={14} />
</button>
</>
<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
@@ -1426,7 +1315,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
title="Resume mission"
aria-label="Resume mission"
>
<RefreshCw size={14} />
<Play size={14} />
</button>
)}
{selectedMission.status === "planning" && (
@@ -1486,14 +1375,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<option value="complete">Complete</option>
<option value="archived">Archived</option>
</select>
<label className="mission-checkbox">
<input
type="checkbox"
checked={missionForm.autoAdvance}
onChange={(e) => setMissionForm({ ...missionForm, autoAdvance: e.target.checked })}
/>
Auto-advance slices
</label>
<label className="mission-checkbox">
<input
type="checkbox"
@@ -2187,22 +2068,13 @@ 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"
onClick={() => handlePauseMission(m.id)}
title="Pause mission"
>
<Pause size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleStopMission(m.id)}
title="Stop mission"
>
<Square size={14} />
</button>
</>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleStopMission(m.id)}
title="Stop mission"
>
<Square size={14} />
</button>
)}
{m.status === "blocked" && (
<button
@@ -2210,7 +2082,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
onClick={() => handleResumeMission(m.id)}
title="Resume mission"
>
<RefreshCw size={14} />
<Play size={14} />
</button>
)}
{m.status === "planning" && (
@@ -2269,14 +2141,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<option value="complete">Complete</option>
<option value="archived">Archived</option>
</select>
<label className="mission-checkbox">
<input
type="checkbox"
checked={missionForm.autoAdvance}
onChange={(e) => setMissionForm({ ...missionForm, autoAdvance: e.target.checked })}
/>
Auto-advance slices
</label>
<label className="mission-checkbox">
<input
type="checkbox"

View File

@@ -23,7 +23,6 @@ const mockMissions = [
title: "Build Auth System",
description: "Complete authentication flow",
status: "planning",
autoAdvance: false,
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -33,7 +32,8 @@ const mockMissions = [
title: "API Redesign",
description: "Redesign the REST API",
status: "active",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "watching",
milestones: [],
summary: {
totalMilestones: 2,
@@ -52,7 +52,6 @@ const mockMissionDetail = {
title: "Build Auth System",
description: "Complete authentication flow",
status: "planning",
autoAdvance: false,
milestones: [
{
id: "MS-001",
@@ -1056,7 +1055,6 @@ describe("MissionManager", () => {
title: "Generated Mission",
description: "Mission with realistic generated ID",
status: "planning",
autoAdvance: false,
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -1068,7 +1066,6 @@ describe("MissionManager", () => {
title: "Generated Mission",
description: "Mission with realistic generated ID",
status: "planning",
autoAdvance: false,
milestones: [
{
id: generatedMilestoneId,
@@ -1412,9 +1409,9 @@ describe("MissionManager", () => {
title: "Autopilot Mission",
description: "Mission with autopilot enabled",
status: "active",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "watching",
lastAutopilotActivityAt: "2026-01-01T00:00:00.000Z",
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
@@ -1424,7 +1421,6 @@ describe("MissionManager", () => {
title: "Normal Mission",
description: "Mission without autopilot",
status: "planning",
autoAdvance: false,
autopilotEnabled: false,
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
@@ -1437,9 +1433,9 @@ describe("MissionManager", () => {
title: "Autopilot Mission",
description: "Mission with autopilot enabled",
status: "active",
autoAdvance: true,
autopilotEnabled: true,
autopilotState: "watching",
lastAutopilotActivityAt: "2026-01-01T00:00:00.000Z",
milestones: [
{
id: "MS-001",
@@ -1532,7 +1528,7 @@ describe("MissionManager", () => {
});
});
it("shows enhanced autopilot controls with expected button states", async () => {
it("shows autopilot toggle and status badge", async () => {
globalThis.fetch = createAutopilotFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
@@ -1542,20 +1538,16 @@ describe("MissionManager", () => {
fireEvent.click(screen.getByText("Autopilot Mission"));
await waitFor(() => {
expect(screen.getByTestId("mission-autopilot-start")).toBeDefined();
expect(screen.getByTestId("mission-autopilot-stop")).toBeDefined();
expect(screen.getByTestId("mission-autopilot-refresh")).toBeDefined();
// Should show autopilot toggle and state badge
expect(screen.getByLabelText("Autopilot")).toBeDefined();
expect(screen.getByTestId("autopilot-state-badge")).toBeDefined();
expect(screen.getByText(/Watching since/)).toBeDefined();
});
const startButton = screen.getByTestId("mission-autopilot-start") as HTMLButtonElement;
const stopButton = screen.getByTestId("mission-autopilot-stop") as HTMLButtonElement;
const refreshButton = screen.getByTestId("mission-autopilot-refresh") as HTMLButtonElement;
expect(startButton.disabled).toBe(true);
expect(stopButton.disabled).toBe(false);
expect(refreshButton.disabled).toBe(false);
expect(screen.getByText(/Watching since/)).toBeDefined();
expect(screen.getByText(/Next check:/)).toBeDefined();
// Verify no action buttons exist (they were removed)
expect(screen.queryByTestId("mission-autopilot-start")).toBeNull();
expect(screen.queryByTestId("mission-autopilot-stop")).toBeNull();
expect(screen.queryByTestId("mission-autopilot-refresh")).toBeNull();
});
it("toggles autopilot with a PATCH request", async () => {