feat(FN-1219): add mission observability and autopilot dashboard UX

- Add mission observability types and API helpers, and forward mission events through SSE with mission filtering support
- Expand MissionManager with health indicators, activity/event log views, improved autopilot controls, and activity auto-scroll behavior
- Refine mission detail mobile controls and styling updates for responsive observability surfaces
- Add regression coverage for MissionManager behavior/mobile CSS and SSE mission filtering
- Include a changeset documenting the mission dashboard observability update
This commit is contained in:
gsxdsm
2026-04-08 18:08:06 -07:00
parent 61117c8a2b
commit b2f0df5455
9 changed files with 1786 additions and 86 deletions

View File

@@ -24,6 +24,9 @@ import type {
NodeConfig,
NodeStatus,
DiscoveryConfig,
MissionEvent,
MissionHealth,
MissionEventType,
} from "@fusion/core";
import type { PlanningQuestion, PlanningSummary, PlanningResponse } from "@fusion/core";
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStep } from "@fusion/core";
@@ -3034,6 +3037,43 @@ export function fetchMissionStatus(missionId: string, projectId?: string): Promi
return api<{ status: string }>(withProjectId(`/missions/${encodeURIComponent(missionId)}/status`, projectId));
}
/** Query options for paginated mission event logs. */
export interface MissionEventQueryOptions {
limit?: number;
offset?: number;
eventType?: MissionEventType;
}
/** Paginated mission event log response. */
export interface MissionEventsResponse {
events: MissionEvent[];
total: number;
limit: number;
offset: number;
}
/** Fetch paginated mission observability events. */
export function fetchMissionEvents(
missionId: string,
options?: MissionEventQueryOptions,
projectId?: string,
): Promise<MissionEventsResponse> {
const query = new URLSearchParams();
if (options?.limit !== undefined) query.set("limit", String(options.limit));
if (options?.offset !== undefined) query.set("offset", String(options.offset));
if (options?.eventType !== undefined) query.set("eventType", options.eventType);
const suffix = query.size > 0 ? `?${query.toString()}` : "";
return api<MissionEventsResponse>(
withProjectId(`/missions/${encodeURIComponent(missionId)}/events${suffix}`, projectId),
);
}
/** Fetch computed mission health metrics. */
export function fetchMissionHealth(missionId: string, projectId?: string): Promise<MissionHealth> {
return api<MissionHealth>(withProjectId(`/missions/${encodeURIComponent(missionId)}/health`, projectId));
}
/** Add milestone to mission */
export function createMilestone(
missionId: string,

View File

@@ -21,6 +21,7 @@ import {
RefreshCw,
Sparkles,
Zap,
Activity,
} from "lucide-react";
import type { ToastType } from "../hooks/useToast";
import { MissionInterviewModal } from "./MissionInterviewModal";
@@ -37,6 +38,9 @@ import type {
FeatureStatus,
MilestoneWithSlices,
SliceWithFeatures,
MissionHealth,
MissionEvent,
MissionEventType,
} from "./mission-types";
import {
fetchMissions,
@@ -66,6 +70,8 @@ import {
updateMissionAutopilot,
startMissionAutopilot,
stopMissionAutopilot,
fetchMissionHealth,
fetchMissionEvents,
} from "../api";
import type { AutopilotStatus as AutopilotStatusType, AutopilotState } from "./mission-types";
@@ -175,6 +181,158 @@ const EMPTY_FEATURE_FORM: FeatureFormData = {
status: "defined",
};
type MissionHealthState = "healthy" | "warning" | "error";
const HOUR_MS = 60 * 60 * 1000;
function getRelativeTime(timestamp?: string): string {
if (!timestamp) return "—";
const ts = new Date(timestamp).getTime();
if (Number.isNaN(ts)) return "—";
const diffMs = Date.now() - ts;
if (diffMs < 0) return "just now";
const diffMinutes = Math.floor(diffMs / (60 * 1000));
if (diffMinutes < 1) return "just now";
if (diffMinutes < 60) return `${diffMinutes}m ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
function getMissionHealthState(health?: MissionHealth): MissionHealthState {
if (!health) return "healthy";
const hasRecentError =
typeof health.lastErrorAt === "string" &&
Date.now() - new Date(health.lastErrorAt).getTime() <= HOUR_MS;
const failureRateThresholdExceeded =
health.totalTasks > 0 && health.tasksFailed > health.totalTasks * 0.3;
if (hasRecentError || failureRateThresholdExceeded) {
return "error";
}
if (health.tasksFailed > 0) {
return "warning";
}
if (health.tasksFailed === 0 && health.tasksInFlight <= health.totalTasks) {
return "healthy";
}
return "warning";
}
function isMissionHealth(value: unknown): value is MissionHealth {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<MissionHealth>;
return (
typeof candidate.missionId === "string" &&
typeof candidate.tasksCompleted === "number" &&
typeof candidate.tasksFailed === "number" &&
typeof candidate.tasksInFlight === "number" &&
typeof candidate.totalTasks === "number" &&
typeof candidate.estimatedCompletionPercent === "number"
);
}
function isMissionEvent(value: unknown): value is MissionEvent {
if (!value || typeof value !== "object") return false;
const candidate = value as Partial<MissionEvent>;
return (
typeof candidate.id === "string" &&
typeof candidate.missionId === "string" &&
typeof candidate.eventType === "string" &&
typeof candidate.description === "string" &&
typeof candidate.timestamp === "string"
);
}
const TASK_EVENT_TYPES: MissionEventType[] = ["feature_triaged", "feature_completed"];
const SLICE_EVENT_TYPES: MissionEventType[] = ["slice_activated", "slice_completed", "milestone_completed"];
const STATE_CHANGE_EVENT_TYPES: MissionEventType[] = [
"mission_started",
"mission_paused",
"mission_resumed",
"mission_completed",
];
const AUTOPILOT_EVENT_TYPES: MissionEventType[] = [
"autopilot_enabled",
"autopilot_disabled",
"autopilot_state_changed",
"autopilot_retry",
"autopilot_stale",
];
function matchesEventFilter(
eventType: MissionEventType,
filter: "all" | "errors" | "state_changes" | "tasks" | "slices" | "autopilot",
): boolean {
switch (filter) {
case "errors":
return eventType === "error" || eventType === "warning";
case "state_changes":
return STATE_CHANGE_EVENT_TYPES.includes(eventType);
case "tasks":
return TASK_EVENT_TYPES.includes(eventType);
case "slices":
return SLICE_EVENT_TYPES.includes(eventType);
case "autopilot":
return AUTOPILOT_EVENT_TYPES.includes(eventType);
default:
return true;
}
}
function getEventTypeClassName(eventType: MissionEventType): string {
if (eventType === "error" || eventType === "warning") {
return "mission-event__type--error";
}
if (STATE_CHANGE_EVENT_TYPES.includes(eventType)) {
return "mission-event__type--state";
}
if (TASK_EVENT_TYPES.includes(eventType)) {
return "mission-event__type--task";
}
if (SLICE_EVENT_TYPES.includes(eventType)) {
return "mission-event__type--slice";
}
if (AUTOPILOT_EVENT_TYPES.includes(eventType)) {
return "mission-event__type--autopilot";
}
return "mission-event__type--default";
}
function getEventTypeLabel(eventType: MissionEventType): string {
return eventType.replace(/_/g, " ");
}
function getActivityQueryEventType(
_filter: "all" | "errors" | "state_changes" | "tasks" | "slices" | "autopilot",
): MissionEventType | undefined {
// Keep query unfiltered to support grouped UI filters (e.g. errors + warnings).
return undefined;
}
function getAutopilotActivitySummary(state: AutopilotState, lastActivityAt?: string): string | null {
if (!lastActivityAt) {
return null;
}
if (state === "watching") {
return `Watching since ${getRelativeTime(lastActivityAt)}`;
}
return `Last activation ${getRelativeTime(lastActivityAt)}`;
}
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId }: MissionManagerProps) {
const isActive = isInline || isOpen;
const [missions, setMissions] = useState<MissionWithSummary[]>([]);
@@ -227,17 +385,83 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const [autopilotStatus, setAutopilotStatus] = useState<AutopilotStatusType | null>(null);
const [autopilotLoading, setAutopilotLoading] = useState(false);
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 [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 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;
}
const healthResults = await Promise.allSettled(
missionList.map(async (mission) => {
const health = await fetchMissionHealth(mission.id, projectId);
return [mission.id, health] as const;
}),
);
setMissionHealthById((prev) => {
const next = new Map(prev);
for (const result of healthResults) {
if (result.status === "fulfilled") {
const [missionId, health] = result.value;
if (isMissionHealth(health)) {
next.set(missionId, health);
}
}
}
return next;
});
}, [projectId]);
const loadMissions = useCallback(async () => {
try {
setLoading(true);
const data = await fetchMissions(projectId);
setMissions(data);
void loadMissionHealth(data);
} catch (err: any) {
addToast(err.message || "Failed to load missions", "error");
} finally {
setLoading(false);
}
}, [addToast, projectId]);
}, [addToast, projectId, loadMissionHealth]);
const loadMissionDetail = useCallback(async (missionId: string) => {
try {
@@ -258,10 +482,75 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [addToast, 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) {
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);
}
}
return merged;
});
setEventsTotal(response.total);
if (!append) {
requestAnimationFrame(() => {
scrollActivityToLatest("auto");
});
}
} catch (err: any) {
addToast(err.message || "Failed to load mission activity", "error");
} finally {
if (!append) {
setEventsLoading(false);
}
}
}, [addToast, eventsFilter, projectId, scrollActivityToLatest]);
useEffect(() => {
missionEventsRef.current = missionEvents;
}, [missionEvents]);
useEffect(() => {
if (isActive) {
loadMissions();
setSelectedMission(null);
setMissionEvents([]);
setEventsTotal(0);
setActiveTab("structure");
setEventsFilter("all");
setExpandedEventMetadata(new Set());
}
}, [isActive, loadMissions]);
@@ -281,6 +570,98 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [isActive]);
useEffect(() => {
if (!isActive || !selectedMission || activeTab !== "activity") {
return;
}
void loadMissionEvents(selectedMission.id);
}, [activeTab, isActive, loadMissionEvents, selectedMission, eventsFilter]);
useEffect(() => {
if (!isActive || missions.length === 0 || typeof EventSource === "undefined") {
return;
}
const search = new URLSearchParams();
if (projectId) {
search.set("projectId", projectId);
}
const eventUrl = `/api/events${search.size > 0 ? `?${search.toString()}` : ""}`;
const eventSource = new EventSource(eventUrl);
const refreshHealth = () => {
void loadMissionHealth(missions);
};
const handleMissionEvent = (rawEvent: Event) => {
refreshHealth();
if (!selectedMission || activeTab !== "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 !== selectedMission.id) {
return;
}
if (!matchesEventFilter(payload.eventType, eventsFilter)) {
return;
}
setMissionEvents((prev) => {
const withoutExisting = prev.filter((event) => event.id !== payload.id);
return [payload, ...withoutExisting].slice(0, 100);
});
setEventsTotal((prev) => prev + 1);
if (shouldAutoScroll) {
requestAnimationFrame(() => {
const container = activityEventsContainerRef.current;
if (container) {
container.scrollTop = 0;
}
});
}
} catch {
// ignore invalid payloads
}
};
eventSource.addEventListener("mission:updated", refreshHealth);
eventSource.addEventListener("slice:updated", refreshHealth);
eventSource.addEventListener("feature:updated", refreshHealth);
eventSource.addEventListener("mission:event", handleMissionEvent);
return () => {
eventSource.removeEventListener("mission:updated", refreshHealth);
eventSource.removeEventListener("slice:updated", refreshHealth);
eventSource.removeEventListener("feature:updated", refreshHealth);
eventSource.removeEventListener("mission:event", handleMissionEvent);
eventSource.close();
};
}, [
activeTab,
eventsFilter,
isActive,
isActivityScrolledNearBottom,
loadMissionHealth,
missions,
projectId,
scrollActivityToLatest,
selectedMission,
]);
// Mission handlers
const handleCreateMission = useCallback(() => {
setIsCreatingMission(true);
@@ -759,6 +1140,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}, [addToast, loadMissionDetail, loadMissions, projectId]);
const handleSelectMission = useCallback((mission: Mission) => {
setActiveTab("structure");
setMissionEvents([]);
setEventsTotal(0);
setEventsFilter("all");
setExpandedEventMetadata(new Set());
loadMissionDetail(mission.id);
loadAutopilotStatus(mission.id);
}, [loadMissionDetail, loadAutopilotStatus]);
@@ -766,9 +1152,42 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const handleBackToList = useCallback(() => {
setSelectedMission(null);
setAutopilotStatus(null);
setActiveTab("structure");
setMissionEvents([]);
setEventsTotal(0);
setEventsFilter("all");
setExpandedEventMetadata(new Set());
loadMissions();
}, [loadMissions]);
const hasMoreEvents = missionEvents.length < eventsTotal;
const autopilotState = (autopilotStatus?.state ?? selectedMission?.autopilotState ?? "inactive") as AutopilotState;
const autopilotPulseActive = autopilotState === "watching" || autopilotState === "activating";
const autopilotActivitySummary = getAutopilotActivitySummary(
autopilotState,
autopilotStatus?.lastActivityAt ?? selectedMission?.lastAutopilotActivityAt,
);
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) {
@@ -872,7 +1291,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<div className="mission-detail__header">
<div className="mission-detail__title-row">
<div className="mission-detail__title-text">
{(autopilotStatus?.watched || selectedMission.autopilotState === "watching" || selectedMission.autopilotState === "activating") && (
{autopilotPulseActive && (
<span className="mission-detail__autopilot-dot" title="Autopilot watching" />
)}
<h3 className="mission-detail__title">{selectedMission.title}</h3>
@@ -880,8 +1299,8 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<span
className="mission-status-badge"
style={{
backgroundColor: missionStatusColors[selectedMission.status].bg,
color: missionStatusColors[selectedMission.status].text,
backgroundColor: (missionStatusColors[selectedMission.status] || missionStatusColors.planning).bg,
color: (missionStatusColors[selectedMission.status] || missionStatusColors.planning).text,
}}
>
{selectedMission.status}
@@ -904,58 +1323,75 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{/* ── Autopilot section ── */}
<div className="mission-detail__autopilot">
<div className="mission-detail__autopilot-toggle">
<label className="mission-checkbox mission-checkbox--autopilot">
<label className="mission-toggle" data-testid="mission-autopilot-toggle">
<input
type="checkbox"
checked={selectedMission.autopilotEnabled ?? false}
onChange={(e) => handleToggleAutopilot(selectedMission.id, e.target.checked)}
disabled={autopilotLoading}
aria-label="Autopilot"
/>
<Zap size={14} className="mission-detail__autopilot-icon" />
Autopilot
</label>
{(selectedMission.autopilotState || autopilotStatus?.state) && (
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: (autopilotStateColors[(autopilotStatus?.state ?? selectedMission.autopilotState) as AutopilotState] || autopilotStateColors.inactive).bg,
color: (autopilotStateColors[(autopilotStatus?.state ?? selectedMission.autopilotState) as AutopilotState] || autopilotStateColors.inactive).text,
}}
data-testid="autopilot-state-badge"
>
{(autopilotStatus?.watched || selectedMission.autopilotState === "watching" || selectedMission.autopilotState === "activating") && (
<span className="mission-detail__autopilot-pulse" />
)}
{autopilotStatus?.state ?? selectedMission.autopilotState ?? "inactive"}
<span className="mission-toggle__track" aria-hidden="true">
<span className="mission-toggle__thumb" />
</span>
)}
<span className="mission-toggle__label">
<Zap size={14} className="mission-detail__autopilot-icon" />
Autopilot
</span>
</label>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
backgroundColor: (autopilotStateColors[autopilotState] || autopilotStateColors.inactive).bg,
color: (autopilotStateColors[autopilotState] || autopilotStateColors.inactive).text,
}}
data-testid="autopilot-state-badge"
>
{autopilotPulseActive && <span className="mission-detail__autopilot-pulse" />}
{autopilotState}
</span>
</div>
{autopilotStatus?.lastActivityAt && (
<span className="mission-detail__autopilot-activity">
Last activity: {new Date(autopilotStatus.lastActivityAt).toLocaleTimeString()}
{autopilotActivitySummary && (
<span className="mission-detail__autopilot-activity mission-relative-time">
{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">
{selectedMission.autopilotEnabled && !autopilotStatus?.watched && (
<button
className="mission-btn mission-btn--ghost mission-btn--sm"
onClick={() => handleStartAutopilot(selectedMission.id)}
disabled={autopilotLoading}
title="Start autopilot watching"
>
<Play size={12} /> Start
</button>
)}
{autopilotStatus?.watched && (
<button
className="mission-btn mission-btn--ghost mission-btn--sm"
onClick={() => handleStopAutopilot(selectedMission.id)}
disabled={autopilotLoading}
title="Stop autopilot watching"
>
<Square size={12} /> Stop
</button>
)}
<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>
@@ -1076,7 +1512,29 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</div>
)}
<div className="mission-detail__milestones">
<div className="mission-detail__tabs" role="tablist" aria-label="Mission detail tabs">
<button
className={`mission-btn ${activeTab === "structure" ? "mission-btn--primary" : "mission-btn--ghost"} mission-btn--sm mission-detail__tab`}
onClick={() => setActiveTab("structure")}
role="tab"
aria-selected={activeTab === "structure"}
data-testid="mission-tab-structure"
>
Structure
</button>
<button
className={`mission-btn ${activeTab === "activity" ? "mission-btn--primary" : "mission-btn--ghost"} mission-btn--sm mission-detail__tab`}
onClick={() => setActiveTab("activity")}
role="tab"
aria-selected={activeTab === "activity"}
data-testid="mission-tab-activity"
>
Activity ({eventsTotal})
</button>
</div>
{activeTab === "structure" ? (
<div className="mission-detail__milestones">
{selectedMission.milestones.map((milestone) => (
<div key={milestone.id} className="mission-milestone">
<div className="mission-milestone__header" onClick={() => toggleMilestoneExpanded(milestone.id)}>
@@ -1503,7 +1961,99 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<span>No milestones yet. Add one to get started.</span>
</div>
)}
</div>
</div>
) : (
<div className="mission-detail__activity" data-testid="mission-activity-tab">
<div className="mission-detail__activity-controls">
<label className="mission-detail__activity-filter">
<span>Filter</span>
<select
value={eventsFilter}
onChange={(event) => setEventsFilter(event.target.value as typeof eventsFilter)}
data-testid="mission-activity-filter"
>
<option value="all">All events</option>
<option value="errors">Errors &amp; warnings</option>
<option value="state_changes">State changes</option>
<option value="tasks">Task events</option>
<option value="slices">Slice &amp; milestone events</option>
<option value="autopilot">Autopilot events</option>
</select>
</label>
<span className="mission-detail__activity-count">
{missionEvents.length} of {eventsTotal}
</span>
</div>
{!eventsLoading && hasMoreEvents && (
<div className="mission-detail__activity-load-more mission-detail__activity-load-more--top">
<button
className="mission-btn mission-btn--ghost"
onClick={handleLoadMoreEvents}
data-testid="mission-activity-load-more"
>
Load more
</button>
</div>
)}
{eventsLoading ? (
<div className="mission-manager__loading mission-detail__activity-loading">
<Loader2 size={18} className="spinner" />
<span>Loading mission activity...</span>
</div>
) : missionEvents.length === 0 ? (
<div className="mission-manager__empty">
<Activity size={18} />
<span>No events yet.</span>
</div>
) : (
<div
ref={activityEventsContainerRef}
className="mission-events"
data-testid="mission-activity-events"
>
{missionEvents.map((event) => {
const hasMetadata = Boolean(event.metadata && Object.keys(event.metadata).length > 0);
const metadataExpanded = expandedEventMetadata.has(event.id);
return (
<div key={event.id} className="mission-event">
<div className="mission-event__header">
<span className={`mission-event__type ${getEventTypeClassName(event.eventType)}`}>
{getEventTypeLabel(event.eventType)}
</span>
<span className="mission-event__time">{getRelativeTime(event.timestamp)}</span>
</div>
<p className="mission-event__description">{event.description}</p>
<span className="mission-event__timestamp">
{new Date(event.timestamp).toLocaleString()}
</span>
{hasMetadata && (
<div className="mission-event__metadata">
<button
className="mission-btn mission-btn--ghost mission-btn--sm"
onClick={() => toggleEventMetadata(event.id)}
data-testid={`mission-event-metadata-${event.id}`}
>
{metadataExpanded ? "Hide" : "Show"} metadata
</button>
{metadataExpanded && (
<pre className="mission-event__metadata-content">
{JSON.stringify(event.metadata, null, 2)}
</pre>
)}
</div>
)}
</div>
);
})}
<div ref={activityEventsEndRef} />
</div>
)}
</div>
)}
</div>
) : (
/* ── List View ── */
@@ -1545,7 +2095,19 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const isSelected = selId && selId.id === m.id;
const statusColors = missionStatusColors[m.status as MissionStatus] || { bg: "", text: "" };
const summary = m.summary;
const hasContent = summary && (summary.totalMilestones > 0 || summary.totalFeatures > 0);
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 activeSliceLabel = m.status === "active" && (health?.currentMilestoneId || health?.currentSliceId)
? "Current milestone/slice in progress"
: null;
return (
<div
key={m.id}
@@ -1559,6 +2121,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{mission.autopilotEnabled && (
<span title="Autopilot enabled"><Zap size={12} className="mission-list__item-autopilot-icon" /></span>
)}
<span
className={`mission-health-badge mission-health-badge--${healthState}`}
data-testid={`mission-health-badge-${m.id}`}
aria-label={`Mission health: ${healthState}`}
/>
<span
className="mission-status-badge mission-status-badge--sm"
style={{
@@ -1572,18 +2139,44 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{m.description && (
<p className="mission-list__item-description">{m.description}</p>
)}
{hasContent && (
{activeSliceLabel && (
<p className="mission-list__item-active-slice">Active: {activeSliceLabel}</p>
)}
{showSummaryBlock && (
<div className="mission-list__item-summary">
<span className="mission-list__item-stat">
{summary.completedMilestones}/{summary.totalMilestones} milestones
{hasContent && (
<>
<span className="mission-list__item-stat">
{summary!.completedMilestones}/{summary!.totalMilestones} milestones
</span>
<span className="mission-list__item-stat">
{summary!.completedFeatures}/{summary!.totalFeatures} features
</span>
</>
)}
<span className="mission-list__item-stat" data-testid={`mission-task-stats-${m.id}`}>
{tasksCompleted}/{totalTasks} tasks
</span>
<span className="mission-list__item-stat">
{summary.completedFeatures}/{summary.totalFeatures} features
{tasksFailed > 0 && (
<button
className="mission-list__item-failed"
onClick={(event) => {
event.stopPropagation();
handleSelectMission(mission);
}}
data-testid={`mission-failed-${m.id}`}
title="View mission failures"
>
{tasksFailed} failed
</button>
)}
<span className="mission-relative-time" data-testid={`mission-last-activity-${m.id}`}>
Activity {getRelativeTime(health?.lastActivityAt)}
</span>
<div className="mission-list__item-progress">
<div className={`mission-list__item-progress mission-list__item-progress--${healthState}`}>
<div
className="mission-list__item-progress-bar"
style={{ width: `${summary.progressPercent}%` }}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>

View File

@@ -0,0 +1,41 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const stylesPath = path.resolve(__dirname, "../../styles.css");
function getMissionMobileSection(css: string): string {
const start = css.indexOf("/* ================================================================\n Responsive — Mission Manager on mobile (≤768px)");
expect(start).toBeGreaterThan(-1);
const end = css.indexOf("}\n\n/* ── Workflow Results ── */", start);
expect(end).toBeGreaterThan(start);
return css.slice(start, end);
}
describe("MissionManager mobile styles", () => {
it("adds responsive tab and activity layout rules", () => {
const css = fs.readFileSync(stylesPath, "utf-8");
const section = getMissionMobileSection(css);
expect(section).toContain(".mission-detail__tabs {");
expect(section).toContain("overflow-x: auto;");
expect(section).toContain(".mission-detail__activity-controls {");
expect(section).toContain("flex-direction: column;");
expect(section).toContain(".mission-detail__activity-filter,");
expect(section).toContain(".mission-detail__activity-filter select {");
expect(section).toContain("width: 100%;");
});
it("adds mobile overflow protection for activity event content", () => {
const css = fs.readFileSync(stylesPath, "utf-8");
const section = getMissionMobileSection(css);
expect(section).toContain(".mission-events {");
expect(section).toContain("max-height: min(46vh, 360px);");
expect(section).toContain(".mission-event__description,");
expect(section).toContain("overflow-wrap: anywhere;");
expect(section).toContain("word-break: break-word;");
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import { MissionManager } from "../MissionManager";
const mockFetchAiSession = vi.fn();
@@ -89,6 +89,56 @@ const mockMissionDetail = {
updatedAt: "2026-01-01T00:00:00.000Z",
};
const mockAutopilotStatus = {
enabled: false,
state: "inactive",
watched: false,
};
const mockMissionEvents = [
{
id: "E-001",
missionId: "M-001",
eventType: "mission_started",
description: "Mission started",
metadata: null,
timestamp: "2026-01-03T10:00:00.000Z",
},
{
id: "E-002",
missionId: "M-001",
eventType: "warning",
description: "Task queue is delayed",
metadata: { queueDepth: 4 },
timestamp: "2026-01-03T10:10:00.000Z",
},
{
id: "E-003",
missionId: "M-001",
eventType: "feature_completed",
description: "Feature F-001 completed",
metadata: { featureId: "F-001" },
timestamp: "2026-01-03T10:20:00.000Z",
},
{
id: "E-004",
missionId: "M-001",
eventType: "autopilot_state_changed",
description: "Autopilot moved to watching",
metadata: { previous: "inactive", next: "watching" },
timestamp: "2026-01-03T10:30:00.000Z",
},
];
const mockMissionEventsPaged = Array.from({ length: 65 }, (_, index) => ({
id: `E-${String(index + 1).padStart(3, "0")}`,
missionId: "M-001",
eventType: index % 2 === 0 ? "feature_completed" : "slice_activated",
description: `Mission event ${index + 1}`,
metadata: { index: index + 1 },
timestamp: new Date(Date.UTC(2026, 0, 3, 10, index)).toISOString(),
}));
/** Create a mock Response that matches the real api() function's expectations (text + content-type headers) */
function mockApiResponse(data: unknown) {
return {
@@ -98,31 +148,204 @@ function mockApiResponse(data: unknown) {
};
}
/** Fetch mock that returns missions list for /missions and detail for /missions/M-001 */
const mockMissionHealthById: Record<string, unknown> = {
"M-001": {
missionId: "M-001",
status: "planning",
tasksCompleted: 0,
tasksFailed: 0,
tasksInFlight: 0,
totalTasks: 0,
currentSliceId: undefined,
currentMilestoneId: undefined,
estimatedCompletionPercent: 0,
lastErrorAt: undefined,
lastErrorDescription: undefined,
autopilotState: "inactive",
autopilotEnabled: false,
lastActivityAt: undefined,
},
"M-002": {
missionId: "M-002",
status: "active",
tasksCompleted: 3,
tasksFailed: 0,
tasksInFlight: 1,
totalTasks: 5,
currentSliceId: "SL-API-1",
currentMilestoneId: "MS-API-1",
estimatedCompletionPercent: 60,
lastErrorAt: undefined,
lastErrorDescription: undefined,
autopilotState: "watching",
autopilotEnabled: true,
lastActivityAt: "2026-01-02T00:00:00.000Z",
},
};
function getMockMissionHealth(missionId: string) {
return (
mockMissionHealthById[missionId] ?? {
missionId,
status: "planning",
tasksCompleted: 0,
tasksFailed: 0,
tasksInFlight: 0,
totalTasks: 0,
currentSliceId: undefined,
currentMilestoneId: undefined,
estimatedCompletionPercent: 0,
lastErrorAt: undefined,
lastErrorDescription: undefined,
autopilotState: "inactive",
autopilotEnabled: false,
lastActivityAt: undefined,
}
);
}
function extractMissionId(url: string): string | null {
const match = url.match(/\/api\/missions\/([^/?]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
function parseMissionEventsResponse(url: string, events = mockMissionEvents) {
const parsed = new URL(url, "http://localhost");
const offset = Number(parsed.searchParams.get("offset") ?? "0");
const limit = Number(parsed.searchParams.get("limit") ?? "25");
const eventType = parsed.searchParams.get("eventType");
const filtered = eventType
? events.filter((event) => event.eventType === eventType)
: events;
return {
events: filtered.slice(offset, offset + limit),
total: filtered.length,
limit,
offset,
};
}
class MockEventSource {
static instances: MockEventSource[] = [];
private readonly listeners = new Map<string, Set<(event: MessageEvent<string>) => void>>();
constructor(public readonly url: string) {
MockEventSource.instances.push(this);
}
addEventListener(type: string, callback: (event: MessageEvent<string>) => void) {
const existing = this.listeners.get(type) ?? new Set();
existing.add(callback);
this.listeners.set(type, existing);
}
removeEventListener(type: string, callback: (event: MessageEvent<string>) => void) {
this.listeners.get(type)?.delete(callback);
}
close() {
this.listeners.clear();
}
emit(type: string, payload: unknown) {
const event = { data: JSON.stringify(payload) } as MessageEvent<string>;
for (const callback of this.listeners.get(type) ?? []) {
callback(event);
}
}
static reset() {
MockEventSource.instances = [];
}
}
/** Fetch mock that returns mission list, detail, health, autopilot, and events endpoints. */
function createFetchMock() {
return vi.fn().mockImplementation((_url: string) => {
return vi.fn().mockImplementation((url: string) => {
if (url.includes("/events")) {
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url)));
}
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-001";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
return Promise.resolve(mockApiResponse(mockMissions));
});
}
/** Fetch mock for navigating into a mission detail */
function createDetailFetchMock() {
let callCount = 0;
return vi.fn().mockImplementation((_url: string) => {
callCount++;
// First call: list, subsequent: detail
if (callCount === 1) {
return Promise.resolve(mockApiResponse(mockMissions));
function createDetailFetchMock(events = mockMissionEvents) {
return vi.fn().mockImplementation((url: string) => {
if (url.includes("/events")) {
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url, events)));
}
return Promise.resolve(mockApiResponse(mockMissionDetail));
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-001";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
const missionId = extractMissionId(url);
if (missionId === "M-001") {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
}
return Promise.resolve(mockApiResponse(mockMissions));
});
}
function createFetchMockWithHealth(
missions: Array<Record<string, unknown>>,
healthByMissionId: Record<string, unknown>,
) {
return vi.fn().mockImplementation((url: string) => {
if (url.includes("/events")) {
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url)));
}
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "";
return Promise.resolve(mockApiResponse(healthByMissionId[missionId] ?? getMockMissionHealth(missionId)));
}
if (url.includes("/autopilot")) {
return Promise.resolve(mockApiResponse(mockAutopilotStatus));
}
if (url.includes("/api/missions/") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
return Promise.resolve(mockApiResponse(missions));
});
}
describe("MissionManager", () => {
let originalFetch: typeof globalThis.fetch;
let originalEventSource: typeof globalThis.EventSource | undefined;
beforeEach(() => {
originalFetch = globalThis.fetch;
originalEventSource = globalThis.EventSource;
mockFetchAiSession.mockReset();
mockCancelMissionInterview.mockReset();
mockConnectMissionInterviewStream.mockReset();
@@ -132,10 +355,12 @@ describe("MissionManager", () => {
close: vi.fn(),
isConnected: () => true,
});
MockEventSource.reset();
});
afterEach(() => {
globalThis.fetch = originalFetch;
globalThis.EventSource = originalEventSource as typeof globalThis.EventSource;
vi.restoreAllMocks();
});
@@ -233,6 +458,310 @@ describe("MissionManager", () => {
});
});
it("renders healthy, warning, and error health badges based on mission health", async () => {
const missions = [
{ id: "M-H1", title: "Healthy Mission", status: "planning", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
{ id: "M-H2", title: "Warning Mission", status: "active", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
{ id: "M-H3", title: "Error Mission", status: "active", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
];
globalThis.fetch = createFetchMockWithHealth(missions as Array<Record<string, unknown>>, {
"M-H1": {
missionId: "M-H1",
status: "planning",
tasksCompleted: 2,
tasksFailed: 0,
tasksInFlight: 0,
totalTasks: 2,
estimatedCompletionPercent: 100,
autopilotState: "inactive",
autopilotEnabled: false,
},
"M-H2": {
missionId: "M-H2",
status: "active",
tasksCompleted: 1,
tasksFailed: 1,
tasksInFlight: 1,
totalTasks: 4,
estimatedCompletionPercent: 25,
autopilotState: "watching",
autopilotEnabled: true,
},
"M-H3": {
missionId: "M-H3",
status: "active",
tasksCompleted: 3,
tasksFailed: 4,
tasksInFlight: 0,
totalTasks: 10,
estimatedCompletionPercent: 30,
lastErrorAt: new Date().toISOString(),
autopilotState: "activating",
autopilotEnabled: true,
},
});
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("mission-health-badge-M-H1").className).toContain("mission-health-badge--healthy");
expect(screen.getByTestId("mission-health-badge-M-H2").className).toContain("mission-health-badge--warning");
expect(screen.getByTestId("mission-health-badge-M-H3").className).toContain("mission-health-badge--error");
});
});
it("shows task progress stats and failed-task indicator", async () => {
const missions = [
{
id: "M-TASKS",
title: "Task Stats Mission",
status: "active",
summary: {
totalMilestones: 2,
completedMilestones: 1,
totalFeatures: 5,
completedFeatures: 2,
progressPercent: 40,
},
milestones: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
];
globalThis.fetch = createFetchMockWithHealth(missions as Array<Record<string, unknown>>, {
"M-TASKS": {
missionId: "M-TASKS",
status: "active",
tasksCompleted: 3,
tasksFailed: 1,
tasksInFlight: 1,
totalTasks: 5,
estimatedCompletionPercent: 60,
autopilotState: "watching",
autopilotEnabled: true,
lastActivityAt: new Date().toISOString(),
},
});
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("mission-task-stats-M-TASKS")).toHaveTextContent("3/5 tasks");
expect(screen.getByTestId("mission-failed-M-TASKS")).toHaveTextContent("1 failed");
});
});
it("formats mission relative activity time", async () => {
const missions = [
{ id: "M-TIME", title: "Relative Time Mission", status: "active", milestones: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" },
];
globalThis.fetch = createFetchMockWithHealth(missions as Array<Record<string, unknown>>, {
"M-TIME": {
missionId: "M-TIME",
status: "active",
tasksCompleted: 0,
tasksFailed: 0,
tasksInFlight: 0,
totalTasks: 1,
estimatedCompletionPercent: 0,
autopilotState: "inactive",
autopilotEnabled: false,
lastActivityAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
},
});
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("mission-last-activity-M-TIME").textContent).toMatch(/Activity\s+\d+m ago|Activity just now/);
});
});
it("renders mission activity tab with filter and metadata toggle", async () => {
globalThis.fetch = createDetailFetchMock(mockMissionEvents);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-tab-activity")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mission-tab-activity"));
await waitFor(() => {
expect(screen.getByTestId("mission-activity-events")).toBeDefined();
expect(screen.getByText("Mission started")).toBeDefined();
expect(screen.getByText("Task queue is delayed")).toBeDefined();
});
fireEvent.change(screen.getByTestId("mission-activity-filter"), {
target: { value: "tasks" },
});
await waitFor(() => {
expect(screen.getByText("Feature F-001 completed")).toBeDefined();
expect(screen.queryByText("Mission started")).toBeNull();
});
fireEvent.change(screen.getByTestId("mission-activity-filter"), {
target: { value: "errors" },
});
await waitFor(() => {
expect(screen.getByText("Task queue is delayed")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mission-event-metadata-E-002"));
expect(screen.getByText(/"queueDepth": 4/)).toBeDefined();
fireEvent.click(screen.getByTestId("mission-event-metadata-E-002"));
expect(screen.queryByText(/"queueDepth": 4/)).toBeNull();
});
it("loads more mission activity events", async () => {
globalThis.fetch = createDetailFetchMock(mockMissionEventsPaged);
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-tab-activity")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mission-tab-activity"));
await waitFor(() => {
expect(screen.getByText("Mission event 50")).toBeDefined();
expect(screen.queryByText("Mission event 51")).toBeNull();
expect(screen.getByTestId("mission-activity-load-more")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mission-activity-load-more"));
await waitFor(() => {
expect(screen.getByText("Mission event 65")).toBeDefined();
expect(screen.queryByTestId("mission-activity-load-more")).toBeNull();
});
});
it("auto-scrolls to latest mission activity on initial load", async () => {
globalThis.fetch = createDetailFetchMock(mockMissionEvents);
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
const scrollIntoViewSpy = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoViewSpy,
});
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-tab-activity")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mission-tab-activity"));
await waitFor(() => {
expect(screen.getByText("Mission started")).toBeDefined();
expect(scrollIntoViewSpy).toHaveBeenCalled();
});
});
it("prepends real-time mission events and scrolls to top when near bottom", async () => {
globalThis.fetch = createDetailFetchMock(mockMissionEvents);
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-tab-activity")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mission-tab-activity"));
const eventsContainer = await screen.findByTestId("mission-activity-events");
Object.defineProperty(eventsContainer, "scrollHeight", { configurable: true, value: 1000 });
Object.defineProperty(eventsContainer, "clientHeight", { configurable: true, value: 300 });
Object.defineProperty(eventsContainer, "scrollTop", { configurable: true, value: 650, writable: true });
await act(async () => {
for (const source of MockEventSource.instances) {
source.emit("mission:event", {
id: "E-REALTIME",
missionId: "M-001",
eventType: "warning",
description: "Real-time warning event",
metadata: { source: "sse" },
timestamp: "2026-01-03T11:00:00.000Z",
});
}
});
await waitFor(() => {
expect(screen.getByText("Real-time warning event")).toBeDefined();
expect(eventsContainer.scrollTop).toBe(0);
});
const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description"));
expect(eventDescriptions[0]?.textContent).toBe("Real-time warning event");
});
it("ignores real-time mission events for non-selected missions", async () => {
globalThis.fetch = createDetailFetchMock(mockMissionEvents);
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-tab-activity")).toBeDefined();
});
fireEvent.click(screen.getByTestId("mission-tab-activity"));
await screen.findByTestId("mission-activity-events");
await act(async () => {
for (const source of MockEventSource.instances) {
source.emit("mission:event", {
id: "E-OTHER",
missionId: "M-999",
eventType: "warning",
description: "Other mission warning",
metadata: null,
timestamp: "2026-01-03T11:00:00.000Z",
});
}
});
await waitFor(() => {
expect(screen.queryByText("Other mission warning")).toBeNull();
});
});
it("shows empty state when no missions exist", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
@@ -291,15 +820,7 @@ describe("MissionManager", () => {
});
it("navigates back to list view when back button is clicked", async () => {
// call 1: list load, call 2: detail load, call 3: re-list load after back
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation(() => {
callCount++;
if (callCount === 2) {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
return Promise.resolve(mockApiResponse(mockMissions));
});
globalThis.fetch = createDetailFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
@@ -531,7 +1052,10 @@ describe("MissionManager", () => {
it("navigates to detail view for a mission with generated ID", async () => {
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation(() => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/health")) {
return Promise.resolve(mockApiResponse(getMockMissionHealth(generatedMissionId)));
}
callCount++;
if (callCount === 1) {
return Promise.resolve(mockApiResponse(generatedMockMissions));
@@ -557,6 +1081,9 @@ describe("MissionManager", () => {
const addToast = vi.fn();
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation((_url: string) => {
if (_url.includes("/health")) {
return Promise.resolve(mockApiResponse(getMockMissionHealth(generatedMissionId)));
}
callCount++;
if (callCount <= 1) {
// Initial list load
@@ -594,6 +1121,9 @@ describe("MissionManager", () => {
const addToast = vi.fn();
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation((_url: string, options?: RequestInit) => {
if (_url.includes("/health")) {
return Promise.resolve(mockApiResponse(getMockMissionHealth(generatedMissionId)));
}
callCount++;
// DELETE request — return 204 empty
if (options?.method === "DELETE") {
@@ -684,7 +1214,11 @@ describe("MissionManager", () => {
it("opens inline edit form when edit mission is clicked in detail view", async () => {
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation(() => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-001";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
callCount++;
if (callCount === 1) return Promise.resolve(mockApiResponse(mockMissions));
return Promise.resolve(mockApiResponse(mockMissionDetail));
@@ -858,21 +1392,37 @@ describe("MissionManager", () => {
};
function createAutopilotFetchMock() {
let callCount = 0;
return vi.fn().mockImplementation((_url: string) => {
callCount++;
if (callCount === 1) {
return Promise.resolve(mockApiResponse(autopilotMockMissions));
return vi.fn().mockImplementation((url: string, options?: RequestInit) => {
if (url.includes("/health")) {
const missionId = extractMissionId(url) ?? "M-AUTO1";
return Promise.resolve(mockApiResponse(getMockMissionHealth(missionId)));
}
if (_url.includes("/autopilot")) {
if (url.includes("/autopilot")) {
if (options?.method === "PATCH") {
return Promise.resolve(mockApiResponse({
enabled: true,
state: "watching",
watched: true,
lastActivityAt: "2026-01-01T12:00:00.000Z",
nextScheduledCheck: "2026-01-01T12:05:00.000Z",
}));
}
return Promise.resolve(mockApiResponse({
enabled: true,
state: "watching",
watched: true,
lastActivityAt: "2026-01-01T12:00:00.000Z",
nextScheduledCheck: "2026-01-01T12:05:00.000Z",
}));
}
return Promise.resolve(mockApiResponse(autopilotMockDetail));
if (url.includes("/api/missions/M-AUTO1") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(autopilotMockDetail));
}
return Promise.resolve(mockApiResponse(autopilotMockMissions));
});
}
@@ -918,6 +1468,70 @@ describe("MissionManager", () => {
});
});
it("shows enhanced autopilot controls with expected button states", async () => {
globalThis.fetch = createAutopilotFetchMock();
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.getByTestId("mission-autopilot-start")).toBeDefined();
expect(screen.getByTestId("mission-autopilot-stop")).toBeDefined();
expect(screen.getByTestId("mission-autopilot-refresh")).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();
});
it("toggles autopilot with a PATCH request", async () => {
const fetchMock = createAutopilotFetchMock();
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"));
const toggle = await screen.findByLabelText("Autopilot");
fireEvent.click(toggle);
await waitFor(() => {
const patchCall = fetchMock.mock.calls.find((call) => {
const [url, options] = call as [string, RequestInit | undefined];
return url.includes("/api/missions/M-AUTO1/autopilot") && options?.method === "PATCH";
});
expect(patchCall).toBeDefined();
expect((patchCall?.[1] as RequestInit | undefined)?.body).toContain('"enabled":false');
});
});
it("shows pulse indicator in the autopilot state badge for active states", async () => {
globalThis.fetch = createAutopilotFetchMock();
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(() => {
const badge = screen.getByTestId("autopilot-state-badge");
expect(badge.querySelector(".mission-detail__autopilot-pulse")).not.toBeNull();
});
});
it("shows pulsing dot when autopilot is watching in detail view", async () => {
globalThis.fetch = createAutopilotFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);

View File

@@ -1,5 +1,11 @@
// Mission types for MissionManager - local copy to avoid module resolution issues
import type {
MissionEvent as CoreMissionEvent,
MissionEventType as CoreMissionEventType,
MissionHealth as CoreMissionHealth,
} from "@fusion/core";
export type MissionStatus = "planning" | "active" | "blocked" | "complete" | "archived";
export type MilestoneStatus = "planning" | "active" | "blocked" | "complete";
export type SliceStatus = "pending" | "active" | "complete";
@@ -89,3 +95,13 @@ export type MissionWithSummary = Mission & { summary?: MissionSummary };
export interface MissionWithHierarchy extends Mission {
milestones: Milestone[];
}
/** Mission event categories emitted by mission observability APIs. */
export type MissionEventType = CoreMissionEventType;
/** Mission lifecycle event persisted in the mission event log. */
export interface MissionEvent extends CoreMissionEvent {}
/** Computed mission health snapshot returned by observability APIs. */
export interface MissionHealth extends CoreMissionHealth {}

View File

@@ -21036,6 +21036,62 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
transition: width 0.3s ease;
}
.mission-health-badge {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-flex;
flex-shrink: 0;
}
.mission-health-badge--healthy {
background: var(--color-success, #22c55e);
}
.mission-health-badge--warning {
background: var(--color-warning, #eab308);
}
.mission-health-badge--error {
background: var(--color-error, #ef4444);
}
.mission-list__item-progress--healthy .mission-list__item-progress-bar {
background: var(--color-success, #22c55e);
}
.mission-list__item-progress--warning .mission-list__item-progress-bar {
background: var(--color-warning, #eab308);
}
.mission-list__item-progress--error .mission-list__item-progress-bar {
background: var(--color-error, #ef4444);
}
.mission-list__item-failed {
border: 0;
background: transparent;
color: var(--color-error, #ef4444);
font-size: 11px;
padding: 0;
cursor: pointer;
}
.mission-list__item-failed:hover {
text-decoration: underline;
}
.mission-relative-time {
font-size: 11px;
color: var(--text-dim);
}
.mission-list__item-active-slice {
margin: 4px 0 0;
font-size: 11px;
color: var(--text-muted);
}
/* ================================================================
Mission Detail View
================================================================ */
@@ -21132,6 +21188,11 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
color: var(--text-muted);
}
.mission-detail__autopilot-next-check {
font-size: 11px;
color: var(--text-dim);
}
.mission-detail__autopilot-actions {
display: flex;
align-items: center;
@@ -21164,8 +21225,68 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
gap: 6px;
}
.mission-checkbox--autopilot {
font-weight: 500;
.mission-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
min-height: 44px;
}
.mission-toggle input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.mission-toggle__track {
position: relative;
width: 38px;
height: 22px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface);
transition: background var(--transition-fast), border-color var(--transition-fast);
flex-shrink: 0;
}
.mission-toggle__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-muted);
transition: transform var(--transition-fast), background var(--transition-fast);
}
.mission-toggle input[type="checkbox"]:checked + .mission-toggle__track {
background: rgba(34, 197, 94, 0.2);
border-color: var(--color-success, #22c55e);
}
.mission-toggle input[type="checkbox"]:checked + .mission-toggle__track .mission-toggle__thumb {
transform: translateX(16px);
background: var(--color-success, #22c55e);
}
.mission-toggle input[type="checkbox"]:focus-visible + .mission-toggle__track {
box-shadow: var(--focus-ring);
}
.mission-toggle input[type="checkbox"]:disabled + .mission-toggle__track {
opacity: 0.6;
}
.mission-toggle__label {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.mission-btn--sm {
@@ -21182,6 +21303,168 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
flex-shrink: 0;
}
.mission-detail__tabs {
display: flex;
gap: var(--space-xs);
border-bottom: 1px solid var(--border);
padding-bottom: var(--space-sm);
}
.mission-detail__tab {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-muted);
border-radius: var(--radius-pill);
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
}
.mission-detail__tab:hover {
color: var(--text);
border-color: var(--text-dim);
}
.mission-detail__tab--active {
background: var(--button-primary-bg);
color: var(--button-primary-text);
border-color: var(--button-primary-bg);
}
.mission-detail__activity {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.mission-detail__activity-controls {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: var(--space-sm);
flex-wrap: wrap;
}
.mission-detail__activity-filter {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--text-muted);
}
.mission-detail__activity-filter select {
min-width: 220px;
}
.mission-detail__activity-count {
font-size: 11px;
color: var(--text-dim);
}
.mission-detail__activity-loading {
min-height: 120px;
}
.mission-events {
display: flex;
flex-direction: column;
gap: var(--space-sm);
max-height: min(52vh, 420px);
overflow-y: auto;
padding-right: 2px;
}
.mission-event {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: 6px;
}
.mission-event__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.mission-event__type {
display: inline-flex;
align-items: center;
border-radius: var(--radius-pill);
padding: 2px 8px;
font-size: 11px;
text-transform: capitalize;
background: var(--surface);
color: var(--text-muted);
}
.mission-event__type--error {
background: rgba(239, 68, 68, 0.15);
color: #fca5a5;
}
.mission-event__type--state {
background: rgba(59, 130, 246, 0.15);
color: #93c5fd;
}
.mission-event__type--task {
background: rgba(16, 185, 129, 0.15);
color: #6ee7b7;
}
.mission-event__type--slice {
background: rgba(245, 158, 11, 0.15);
color: #fcd34d;
}
.mission-event__type--autopilot {
background: rgba(168, 85, 247, 0.15);
color: #d8b4fe;
}
.mission-event__description {
margin: 0;
font-size: 13px;
color: var(--text);
}
.mission-event__time,
.mission-event__timestamp {
font-size: 11px;
color: var(--text-dim);
}
.mission-event__metadata {
display: flex;
flex-direction: column;
gap: 6px;
}
.mission-event__metadata-content {
margin: 0;
padding: var(--space-sm);
border-radius: var(--radius-sm);
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 11px;
overflow-x: auto;
}
.mission-detail__activity-load-more {
display: flex;
justify-content: center;
}
@keyframes autopilot-pulse {
0%, 100% {
opacity: 1;
@@ -21579,6 +21862,74 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
padding-bottom: calc(var(--space-lg) + env(safe-area-inset-bottom, 0px));
}
.mission-detail__autopilot {
align-items: flex-start;
gap: var(--space-xs);
}
.mission-detail__autopilot-actions {
width: 100%;
flex-wrap: wrap;
gap: var(--space-xs);
}
.mission-detail__autopilot-actions .mission-btn {
min-height: 36px;
}
.mission-detail__tabs {
overflow-x: auto;
padding-bottom: var(--space-xs);
scrollbar-width: thin;
}
.mission-detail__tab {
flex: 0 0 auto;
min-height: 34px;
white-space: nowrap;
}
.mission-detail__activity-controls {
flex-direction: column;
align-items: stretch;
}
.mission-detail__activity-filter,
.mission-detail__activity-filter select {
width: 100%;
}
.mission-detail__activity-count {
align-self: flex-end;
}
.mission-events {
max-height: min(46vh, 360px);
}
.mission-event {
padding: var(--space-sm);
}
.mission-event__header {
align-items: flex-start;
flex-direction: column;
}
.mission-event__description,
.mission-event__metadata-content {
overflow-wrap: anywhere;
word-break: break-word;
}
.mission-list__item-summary {
gap: var(--space-xs);
}
.mission-list__item-failed {
width: fit-content;
}
/* Feature actions wrap on narrow screens */
.mission-feature__actions {
flex-wrap: wrap;