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:
@@ -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,
|
||||
|
||||
@@ -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 & warnings</option>
|
||||
<option value="state_changes">State changes</option>
|
||||
<option value="tasks">Task events</option>
|
||||
<option value="slices">Slice & milestone events</option>
|
||||
<option value="autopilot">Autopilot events</option>
|
||||
</select>
|
||||
</label>
|
||||
<span className="mission-detail__activity-count">
|
||||
{missionEvents.length} of {eventsTotal}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!eventsLoading && hasMoreEvents && (
|
||||
<div className="mission-detail__activity-load-more mission-detail__activity-load-more--top">
|
||||
<button
|
||||
className="mission-btn mission-btn--ghost"
|
||||
onClick={handleLoadMoreEvents}
|
||||
data-testid="mission-activity-load-more"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventsLoading ? (
|
||||
<div className="mission-manager__loading mission-detail__activity-loading">
|
||||
<Loader2 size={18} className="spinner" />
|
||||
<span>Loading mission activity...</span>
|
||||
</div>
|
||||
) : missionEvents.length === 0 ? (
|
||||
<div className="mission-manager__empty">
|
||||
<Activity size={18} />
|
||||
<span>No events yet.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={activityEventsContainerRef}
|
||||
className="mission-events"
|
||||
data-testid="mission-activity-events"
|
||||
>
|
||||
{missionEvents.map((event) => {
|
||||
const hasMetadata = Boolean(event.metadata && Object.keys(event.metadata).length > 0);
|
||||
const metadataExpanded = expandedEventMetadata.has(event.id);
|
||||
|
||||
return (
|
||||
<div key={event.id} className="mission-event">
|
||||
<div className="mission-event__header">
|
||||
<span className={`mission-event__type ${getEventTypeClassName(event.eventType)}`}>
|
||||
{getEventTypeLabel(event.eventType)}
|
||||
</span>
|
||||
<span className="mission-event__time">{getRelativeTime(event.timestamp)}</span>
|
||||
</div>
|
||||
<p className="mission-event__description">{event.description}</p>
|
||||
<span className="mission-event__timestamp">
|
||||
{new Date(event.timestamp).toLocaleString()}
|
||||
</span>
|
||||
{hasMetadata && (
|
||||
<div className="mission-event__metadata">
|
||||
<button
|
||||
className="mission-btn mission-btn--ghost mission-btn--sm"
|
||||
onClick={() => toggleEventMetadata(event.id)}
|
||||
data-testid={`mission-event-metadata-${event.id}`}
|
||||
>
|
||||
{metadataExpanded ? "Hide" : "Show"} metadata
|
||||
</button>
|
||||
{metadataExpanded && (
|
||||
<pre className="mission-event__metadata-content">
|
||||
{JSON.stringify(event.metadata, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={activityEventsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* ── 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>
|
||||
|
||||
@@ -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;");
|
||||
});
|
||||
});
|
||||
@@ -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()} />);
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user