feat(FN-3130): add viewport-conditional mission split layout and plugin cre

This merge consolidates a large batch of feature work, test infrastructure improvements, and performance fixes. Dashboard highlights include a viewport-conditional mission split layout for mobile (FN-3130), async planning draft sync (FN-3229), the ResearchTaskActionModal with model-fallback notifica

Fusion-Task-Id: FN-3130
This commit is contained in:
Fusion
2026-05-02 20:15:55 -07:00
committed by gsxdsm
parent 523a22492f
commit c6119f78ef
3 changed files with 451 additions and 263 deletions

View File

@@ -155,8 +155,39 @@
flex-direction: column;
border-right: var(--btn-border-width) solid var(--border);
background: var(--surface);
overflow: hidden;
}
/* Desktop split-only sidebar header layout for mission quick actions. */
.mission-manager__sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-md);
border-bottom: var(--btn-border-width) solid var(--border);
}
.mission-manager__sidebar-title {
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.mission-manager__sidebar-actions {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.mission-manager__sidebar-list {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-sm);
}
.mission-manager__detail-pane {
@@ -182,14 +213,6 @@
padding: var(--space-2xl);
}
/* Hide back button on desktop — sidebar is always visible */
@media (min-width: 769px) {
.mission-manager__split ~ .mission-manager__back-btn,
.mission-manager--desktop .mission-manager__back-btn {
display: none;
}
}
/* Hide mobile stacked body on desktop */
.mission-manager__body--stacked {
display: none;
@@ -360,6 +383,16 @@
background: var(--card);
}
/* Compact icon-only variant used by desktop split sidebar header actions. */
.mission-add-btn--sm {
gap: 0;
width: auto;
min-width: calc(var(--space-md) * 2 + var(--space-xs));
min-height: calc(var(--space-md) * 2 + var(--space-xs));
padding: var(--space-xs);
font-size: 0;
}
.mission-resume-prompt {
display: flex;
align-items: center;
@@ -393,6 +426,11 @@
gap: var(--space-sm);
}
/* Desktop split-only: list footer actions live in sidebar header, so hide duplicated footer buttons. */
.mission-manager__sidebar .mission-list__footer-actions {
display: none;
}
/* ── Inline Form Card ── */
.mission-form-card {
display: flex;
@@ -580,6 +618,11 @@
box-shadow: inset 0 0 0 1px var(--todo);
}
/* Desktop split-only selected-state background keeps focus contrast in sidebar cards. */
.mission-manager__sidebar .mission-list__item--selected {
background: var(--card);
}
.mission-list__item-content {
flex: 1;
min-width: 0;
@@ -614,6 +657,15 @@
white-space: nowrap;
}
/* Desktop split-only clamp prevents description overflow in the narrow sidebar column. */
.mission-manager__sidebar .mission-list__item-description {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
overflow: hidden;
}
.mission-list__item-actions {
display: flex;
align-items: center;

View File

@@ -27,6 +27,7 @@ import {
AlertCircle,
} from "lucide-react";
import type { ToastType } from "../hooks/useToast";
import { useViewportMode } from "../hooks/useViewportMode";
import { subscribeSse } from "../sse-bus";
import { MissionInterviewModal } from "./MissionInterviewModal";
import { MilestoneSliceInterviewModal } from "./MilestoneSliceInterviewModal";
@@ -436,10 +437,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [isMobileLayout, setIsMobileLayout] = useState(() => {
if (typeof window === "undefined") return false;
return window.innerWidth <= 768;
});
const isMobile = useViewportMode() === "mobile";
// Form states
const [isCreatingMission, setIsCreatingMission] = useState(false);
@@ -492,17 +490,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
} | null>(null);
const [triagePreviewLoading, setTriagePreviewLoading] = useState<string | null>(null);
useEffect(() => {
if (typeof window === "undefined") {
return;
}
const update = () => setIsMobileLayout(window.innerWidth <= 768);
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
// Auto-open interview modal when resuming a session
useEffect(() => {
if (isActive && effectiveResumeSessionId) {
@@ -1998,7 +1985,12 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
if (!isActive) return null;
const renderMissionDetailContent = (selectedMission: MissionWithHierarchy) => (
const renderMissionDetailContent = () => {
if (!selectedMission) {
return null;
}
return (
<div className="mission-detail">
<div className="mission-detail__header">
<div className="mission-detail__title-row">
@@ -3384,9 +3376,149 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
)}
</div>
);
);
};
const renderMissionListContent = () => (
const renderMissionListItems = () => missions.map((mission) => {
const m = mission;
const isSelected = selectedMission?.id === m.id;
const statusColors = missionStatusColors[m.status as MissionStatus] || { bg: "", text: "" };
const summary = m.summary;
const health = missionHealthById.get(m.id);
const healthState = getMissionHealthState(health);
const hasContent = Boolean(summary && (summary.totalMilestones > 0 || summary.totalFeatures > 0));
const totalTasks = health?.totalTasks ?? 0;
const tasksCompleted = health?.tasksCompleted ?? 0;
const tasksFailed = health?.tasksFailed ?? 0;
const progressPercent = health?.estimatedCompletionPercent ?? summary?.progressPercent ?? 0;
const showSummaryBlock = hasContent || totalTasks > 0 || tasksFailed > 0 || Boolean(health?.lastActivityAt);
const activeSliceLabel = m.status === "active" && (health?.currentMilestoneId || health?.currentSliceId)
? "Current milestone/slice in progress"
: null;
return (
<div
key={m.id}
className={`mission-list__item ${isSelected ? "mission-list__item--selected" : ""}`}
onClick={() => handleSelectMission(mission)}
>
<div className="mission-list__item-content">
<div className="mission-list__item-header">
<Target size={16} className="mission-list__item-icon" />
<span className="mission-list__item-title">{m.title}</span>
{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={{
backgroundColor: statusColors.bg,
color: statusColors.text,
}}
>
{m.status}
</span>
</div>
{m.description && (
<p className="mission-list__item-description">{m.description}</p>
)}
{activeSliceLabel && (
<p className="mission-list__item-active-slice">Active: {activeSliceLabel}</p>
)}
{showSummaryBlock && (
<div className="mission-list__item-summary">
{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>
{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 mission-list__item-progress--${healthState}`}>
<div
className="mission-list__item-progress-bar"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
</div>
<div className="mission-list__item-actions" onClick={(e) => e.stopPropagation()}>
{m.status === "active" && (
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleStopMission(m.id)}
title="Stop mission"
>
<Square size={14} />
</button>
)}
{m.status === "blocked" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleResumeMission(m.id)}
title="Resume mission"
>
<Play size={14} />
</button>
)}
{m.status === "planning" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleStartMission(m.id)}
title="Start mission"
>
<Play size={14} />
</button>
)}
<button
className="mission-icon-btn"
onClick={() => handleEditMission(mission)}
title="Edit mission"
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "mission", id: m.id })}
title="Delete mission"
>
<Trash2 size={14} />
</button>
</div>
</div>
);
});
const renderMissionListContent = ({ hideBottomButtons = false }: { hideBottomButtons?: boolean } = {}) => (
<div className="mission-list">
{/* Create mission form */}
{isCreatingMission && (
@@ -3418,145 +3550,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
)}
{/* Mission items */}
{missions.map((mission) => {
const m = mission;
const selId = selectedMission as { id: string } | null;
const isSelected = selId && selId.id === m.id;
const statusColors = missionStatusColors[m.status as MissionStatus] || { bg: "", text: "" };
const summary = m.summary;
const health = missionHealthById.get(m.id);
const healthState = getMissionHealthState(health);
const hasContent = Boolean(summary && (summary.totalMilestones > 0 || summary.totalFeatures > 0));
const totalTasks = health?.totalTasks ?? 0;
const tasksCompleted = health?.tasksCompleted ?? 0;
const tasksFailed = health?.tasksFailed ?? 0;
const progressPercent = health?.estimatedCompletionPercent ?? summary?.progressPercent ?? 0;
const showSummaryBlock = hasContent || totalTasks > 0 || tasksFailed > 0 || Boolean(health?.lastActivityAt);
const activeSliceLabel = m.status === "active" && (health?.currentMilestoneId || health?.currentSliceId)
? "Current milestone/slice in progress"
: null;
return (
<div
key={m.id}
className={`mission-list__item ${isSelected ? "mission-list__item--selected" : ""}`}
onClick={() => handleSelectMission(mission)}
>
<div className="mission-list__item-content">
<div className="mission-list__item-header">
<Target size={16} className="mission-list__item-icon" />
<span className="mission-list__item-title">{m.title}</span>
{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={{
backgroundColor: statusColors.bg,
color: statusColors.text,
}}
>
{m.status}
</span>
</div>
{m.description && (
<p className="mission-list__item-description">{m.description}</p>
)}
{activeSliceLabel && (
<p className="mission-list__item-active-slice">Active: {activeSliceLabel}</p>
)}
{showSummaryBlock && (
<div className="mission-list__item-summary">
{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>
{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 mission-list__item-progress--${healthState}`}>
<div
className="mission-list__item-progress-bar"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
</div>
<div className="mission-list__item-actions" onClick={(e) => e.stopPropagation()}>
{m.status === "active" && (
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => handleStopMission(m.id)}
title="Stop mission"
>
<Square size={14} />
</button>
)}
{m.status === "blocked" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleResumeMission(m.id)}
title="Resume mission"
>
<Play size={14} />
</button>
)}
{m.status === "planning" && (
<button
className="mission-icon-btn mission-icon-btn--success"
onClick={() => handleStartMission(m.id)}
title="Start mission"
>
<Play size={14} />
</button>
)}
<button
className="mission-icon-btn"
onClick={() => handleEditMission(mission)}
title="Edit mission"
>
<Pencil size={14} />
</button>
<button
className="mission-icon-btn mission-icon-btn--danger"
onClick={() => setDeleteConfirmId({ type: "mission", id: m.id })}
title="Delete mission"
>
<Trash2 size={14} />
</button>
</div>
</div>
);
})}
{renderMissionListItems()}
{/* Edit mission form */}
{editingMissionId && (
@@ -3642,22 +3636,24 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
</div>
</div>
)}
<div className="mission-list__footer-actions">
<button className="mission-add-btn" onClick={() => setShowInterviewModal(true)}>
<Sparkles size={16} />
Plan with AI
</button>
<button className="mission-add-btn" onClick={handleCreateMission}>
<Plus size={16} />
New Mission
</button>
</div>
{!hideBottomButtons && (
<div className="mission-list__footer-actions">
<button className="mission-add-btn" onClick={() => setShowInterviewModal(true)}>
<Sparkles size={16} />
Plan with AI
</button>
<button className="mission-add-btn" onClick={handleCreateMission}>
<Plus size={16} />
New Mission
</button>
</div>
)}
</div>
)}
</div>
);
const renderDeleteConfirmationPanel = () => (
const renderDeleteConfirmPanel = () => (
<div className="mission-confirm-panel mission-confirm-panel--danger">
<div className="mission-confirm-panel__content">
<p>
@@ -3731,57 +3727,97 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const manager = (
<div
ref={modalRef}
className={`mission-manager mission-manager--desktop${isInline ? " mission-manager--inline" : ""}`}
className={`mission-manager mission-manager--desktop-split${isInline ? " mission-manager--inline" : ""}`}
role={isInline ? undefined : "dialog"}
aria-modal={isInline ? undefined : true}
aria-label={isInline ? undefined : "Mission Manager"}
data-testid="mission-manager-dialog"
>
{/* ── Header ── */}
<div className={`mission-manager__header${isInline ? " mission-manager__header--inline" : ""}`}>
<div className="mission-manager__header-title">
{isMobileLayout && selectedMission && (
<button
className="mission-manager__back-btn"
onClick={handleBackToList}
title="Back to missions"
aria-label="Back to missions list"
data-testid="mission-back-btn"
>
<ChevronLeft size={18} />
</button>
)}
<Target size={18} className="mission-manager__header-icon" />
<h2 className="mission-manager__title">
{selectedMission ? selectedMission.title : "Missions"}
</h2>
</div>
{!isInline && (
/* Modal mode: show close button */
<div className={`mission-manager__header${isInline ? " mission-manager__header--inline" : ""}`}>
<div className="mission-manager__header-title">
{isMobile && selectedMission && (
<button
className="modal-close"
onClick={onClose}
title="Close"
aria-label="Close Mission Manager"
data-testid="mission-close-btn"
className="mission-manager__back-btn"
onClick={handleBackToList}
title="Back to missions"
aria-label="Back to missions list"
data-testid="mission-back-btn"
>
<X size={18} />
<ChevronLeft size={18} />
</button>
)}
<Target size={18} className="mission-manager__header-icon" />
<h2 className="mission-manager__title">
{selectedMission ? selectedMission.title : "Missions"}
</h2>
</div>
{!isInline && (
<button
className="modal-close"
onClick={onClose}
title="Close"
aria-label="Close Mission Manager"
data-testid="mission-close-btn"
>
<X size={18} />
</button>
)}
</div>
{/* ── Body ── */}
{/* ── Desktop split layout (visible on desktop, hidden on mobile via CSS) ── */}
{isMobile ? (
<div className="mission-manager__body mission-manager__body--stacked">
{loading ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>Loading missions...</span>
</div>
) : detailLoading ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>Loading mission details...</span>
</div>
) : selectedMission ? (
renderMissionDetailContent()
) : (
renderMissionListContent()
)}
{deleteConfirmId && renderDeleteConfirmPanel()}
{linkTaskFeatureId && renderLinkTaskPanel()}
</div>
) : (
<div className="mission-manager__split">
<div className="mission-manager__sidebar">
{loading ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>Loading missions...</span>
<div className="mission-manager__sidebar-header">
<span className="mission-manager__sidebar-title">Missions</span>
<div className="mission-manager__sidebar-actions">
<button
className="mission-add-btn mission-add-btn--sm"
onClick={() => setShowInterviewModal(true)}
title="Plan with AI"
aria-label="Plan with AI"
>
<Sparkles size={14} />
</button>
<button
className="mission-add-btn mission-add-btn--sm"
onClick={handleCreateMission}
title="New Mission"
aria-label="New Mission"
>
<Plus size={14} />
</button>
</div>
) : (
renderMissionListContent()
)}
</div>
<div className="mission-manager__sidebar-list">
{loading ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>Loading missions...</span>
</div>
) : (
renderMissionListContent({ hideBottomButtons: true })
)}
</div>
</div>
<div className="mission-manager__detail-pane">
@@ -3791,48 +3827,19 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
<span>Loading mission details...</span>
</div>
) : selectedMission ? (
renderMissionDetailContent(selectedMission)
renderMissionDetailContent()
) : (
<div className="mission-manager__detail-pane-empty">
<Target size={24} />
<span>Select a mission to view details</span>
</div>
)}
{deleteConfirmId && renderDeleteConfirmationPanel()}
{deleteConfirmId && renderDeleteConfirmPanel()}
{linkTaskFeatureId && renderLinkTaskPanel()}
</div>
</div>
{/* ── Mobile stacked layout (hidden on desktop via CSS, shown on mobile) ── */}
<div className="mission-manager__body mission-manager__body--stacked">
{isMobileLayout ? (
loading ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>Loading missions...</span>
</div>
) : detailLoading ? (
<div className="mission-manager__loading">
<Loader2 size={24} className="spinner" />
<span>Loading mission details...</span>
</div>
) : selectedMission ? (
renderMissionDetailContent(selectedMission)
) : (
renderMissionListContent()
)
) : null}
</div>
{/* Delete/link panels for mobile stacked layout */}
{isMobileLayout && (
<div className="mission-manager__mobile-panels">
{deleteConfirmId && renderDeleteConfirmationPanel()}
{linkTaskFeatureId && renderLinkTaskPanel()}
</div>
)}
</div>
)}
</div>
);
const interviewModal = (

View File

@@ -640,20 +640,28 @@ function createFetchMockWithHealth(
});
}
const setViewportWidth = (width: number) => {
Object.defineProperty(window, "innerWidth", {
configurable: true,
function mockViewport(mode: "mobile" | "desktop" | "tablet") {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: width,
value: vi.fn().mockImplementation((query: string) => {
const isMobileQuery = query === "(max-width: 768px)";
const isTabletQuery = query === "(min-width: 769px) and (max-width: 1024px)";
return {
matches: mode === "mobile" ? isMobileQuery : mode === "tablet" ? isTabletQuery : false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
}),
});
window.dispatchEvent(new Event("resize"));
};
}
describe("MissionManager", () => {
let originalFetch: typeof globalThis.fetch;
let originalEventSource: typeof globalThis.EventSource | undefined;
beforeEach(() => {
mockViewport("desktop");
originalFetch = globalThis.fetch;
originalEventSource = globalThis.EventSource;
mockFetchAiSession.mockReset();
@@ -711,7 +719,7 @@ describe("MissionManager", () => {
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Missions")).toBeDefined();
expect(screen.getByRole("heading", { name: "Missions" })).toBeDefined();
});
});
@@ -1344,7 +1352,7 @@ describe("MissionManager", () => {
expect(onClose).toHaveBeenCalledOnce();
});
it("navigates to detail view when a mission is clicked", async () => {
it("navigates to detail view when a mission is clicked on desktop", async () => {
globalThis.fetch = createDetailFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
@@ -1358,7 +1366,7 @@ describe("MissionManager", () => {
// Wait for detail view to render
await waitFor(() => {
// Back button should appear in detail view
// Desktop keeps sidebar visible and does not render the mobile back button.
expect(screen.queryByTestId("mission-back-btn")).toBeNull();
// Milestone should be visible (auto-expanded)
expect(screen.getByText("Database Schema")).toBeDefined();
@@ -1403,7 +1411,7 @@ describe("MissionManager", () => {
});
});
it("shows back button with accessible label in detail view", async () => {
it("does not render back button on desktop in detail view", async () => {
globalThis.fetch = createDetailFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
@@ -1423,7 +1431,7 @@ describe("MissionManager", () => {
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("New Mission")).toBeDefined();
expect(screen.getByRole("button", { name: "New Mission" })).toBeDefined();
});
});
@@ -1486,7 +1494,7 @@ describe("MissionManager", () => {
});
});
it("detail view in inline mode still shows back button", async () => {
it("does not render back button in inline desktop detail view", async () => {
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/health")) {
@@ -1520,10 +1528,10 @@ describe("MissionManager", () => {
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Plan with AI")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Plan with AI" })).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Plan with AI"));
fireEvent.click(screen.getByRole("button", { name: "Plan with AI" }));
await waitFor(() => {
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
@@ -1595,7 +1603,7 @@ describe("MissionManager", () => {
);
});
expect(screen.getByText("Missions")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Missions" })).toBeInTheDocument();
warnSpy.mockRestore();
});
@@ -2835,7 +2843,7 @@ describe("MissionManager", () => {
expect(document.querySelector(".mission-manager__split")).toBeTruthy();
expect(document.querySelector(".mission-manager__sidebar")).toBeTruthy();
expect(document.querySelector(".mission-manager__detail-pane")).toBeTruthy();
expect(document.querySelector(".mission-manager__body--stacked")).toBeTruthy();
expect(document.querySelector(".mission-manager__body--stacked")).toBeNull();
});
it("shows empty placeholder when no mission selected", async () => {
@@ -2872,7 +2880,7 @@ describe("MissionManager", () => {
});
it("does not render desktop back button", async () => {
setViewportWidth(1200);
mockViewport("desktop");
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(screen.getByText("Build Auth System")).toBeDefined());
@@ -2891,5 +2899,126 @@ describe("MissionManager", () => {
fireEvent.click(screen.getByLabelText("Delete mission"));
await waitFor(() => expect(document.querySelector(".mission-manager__detail-pane .mission-confirm-panel")).toBeTruthy());
});
it("renders sidebar header title and compact add buttons", async () => {
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(document.querySelector(".mission-manager__sidebar-title")?.textContent).toBe("Missions"));
expect(screen.getByLabelText("Plan with AI")).toBeInTheDocument();
expect(screen.getByLabelText("New Mission")).toBeInTheDocument();
});
});
describe("sidebar selected highlighting", () => {
it("applies selected class to clicked mission and not others", async () => {
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(screen.getByText("Build Auth System")).toBeInTheDocument());
const sidebar = document.querySelector(".mission-manager__sidebar") as HTMLElement;
fireEvent.click(within(sidebar).getByText("Build Auth System"));
await waitFor(() => {
const items = Array.from(document.querySelectorAll(".mission-manager__sidebar .mission-list__item"));
const selected = items.filter((item) => item.classList.contains("mission-list__item--selected"));
expect(selected).toHaveLength(1);
expect(selected[0]?.textContent).toContain("Build Auth System");
});
});
it("moves selected class when a different mission is clicked", async () => {
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/missions/health")) {
return Promise.resolve(mockApiResponse(mockMissionHealthById));
}
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));
}
const validationResponse = getValidationApiMock(url);
if (validationResponse !== null) {
return Promise.resolve(mockApiResponse(validationResponse));
}
if (url.includes("/api/missions/M-001") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse(mockMissionDetail));
}
if (url.includes("/api/missions/M-002") && !url.includes("/milestones") && !url.includes("/status")) {
return Promise.resolve(mockApiResponse({ ...mockMissionDetail, id: "M-002", title: "API Redesign" }));
}
return Promise.resolve(mockApiResponse(mockMissions));
});
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(screen.getByText("Build Auth System")).toBeInTheDocument());
fireEvent.click(within(document.querySelector(".mission-manager__sidebar") as HTMLElement).getByText("Build Auth System"));
await waitFor(() => expect(screen.getByTestId("mission-tab-structure")).toBeInTheDocument());
fireEvent.click(within(document.querySelector(".mission-manager__sidebar") as HTMLElement).getByText("API Redesign"));
await waitFor(() => {
const items = Array.from(document.querySelectorAll(".mission-manager__sidebar .mission-list__item"));
const selected = items.filter((item) => item.classList.contains("mission-list__item--selected"));
expect(selected).toHaveLength(1);
expect(selected[0]?.querySelector(".mission-list__item-title")?.textContent).toBe("API Redesign");
});
});
});
describe("mobile stacked layout", () => {
it("renders stacked body on mobile and hides desktop split", async () => {
mockViewport("mobile");
globalThis.fetch = createDetailFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(document.querySelector(".mission-manager__body--stacked")).toBeTruthy());
expect(document.querySelector(".mission-manager__split")).toBeNull();
});
it("shows back button in detail view and returns to list", async () => {
mockViewport("mobile");
globalThis.fetch = createDetailFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(screen.getByText("Build Auth System")).toBeInTheDocument());
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-back-btn")).toBeInTheDocument();
expect(screen.queryByText("API Redesign")).not.toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("mission-back-btn"));
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
expect(screen.queryByTestId("mission-back-btn")).not.toBeInTheDocument();
});
});
});
describe("sidebar always visible on desktop", () => {
it("keeps all mission list items rendered after selecting a mission", async () => {
mockViewport("desktop");
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeInTheDocument();
expect(screen.getByText("API Redesign")).toBeInTheDocument();
});
const sidebar = document.querySelector(".mission-manager__sidebar") as HTMLElement;
fireEvent.click(within(sidebar).getByText("Build Auth System"));
await waitFor(() => expect(document.querySelector(".mission-manager__detail-pane .mission-detail")).toBeTruthy());
expect(within(sidebar).getByText("Build Auth System")).toBeInTheDocument();
expect(within(sidebar).getByText("API Redesign")).toBeInTheDocument();
const sidebarList = document.querySelector(".mission-manager__sidebar-list") as HTMLElement;
expect(getComputedStyle(sidebarList).overflowY).toBe("auto");
});
});
});