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:
@@ -155,8 +155,39 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: var(--btn-border-width) solid var(--border);
|
border-right: var(--btn-border-width) solid var(--border);
|
||||||
background: var(--surface);
|
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-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
padding: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mission-manager__detail-pane {
|
.mission-manager__detail-pane {
|
||||||
@@ -182,14 +213,6 @@
|
|||||||
padding: var(--space-2xl);
|
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 */
|
/* Hide mobile stacked body on desktop */
|
||||||
.mission-manager__body--stacked {
|
.mission-manager__body--stacked {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -360,6 +383,16 @@
|
|||||||
background: var(--card);
|
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 {
|
.mission-resume-prompt {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -393,6 +426,11 @@
|
|||||||
gap: var(--space-sm);
|
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 ── */
|
/* ── Inline Form Card ── */
|
||||||
.mission-form-card {
|
.mission-form-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -580,6 +618,11 @@
|
|||||||
box-shadow: inset 0 0 0 1px var(--todo);
|
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 {
|
.mission-list__item-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -614,6 +657,15 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.mission-list__item-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
import { useViewportMode } from "../hooks/useViewportMode";
|
||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
import { MissionInterviewModal } from "./MissionInterviewModal";
|
import { MissionInterviewModal } from "./MissionInterviewModal";
|
||||||
import { MilestoneSliceInterviewModal } from "./MilestoneSliceInterviewModal";
|
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 [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
const [isMobileLayout, setIsMobileLayout] = useState(() => {
|
const isMobile = useViewportMode() === "mobile";
|
||||||
if (typeof window === "undefined") return false;
|
|
||||||
return window.innerWidth <= 768;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Form states
|
// Form states
|
||||||
const [isCreatingMission, setIsCreatingMission] = useState(false);
|
const [isCreatingMission, setIsCreatingMission] = useState(false);
|
||||||
@@ -492,17 +490,6 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [triagePreviewLoading, setTriagePreviewLoading] = useState<string | 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
|
// Auto-open interview modal when resuming a session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isActive && effectiveResumeSessionId) {
|
if (isActive && effectiveResumeSessionId) {
|
||||||
@@ -1998,7 +1985,12 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
|
|
||||||
if (!isActive) return null;
|
if (!isActive) return null;
|
||||||
|
|
||||||
const renderMissionDetailContent = (selectedMission: MissionWithHierarchy) => (
|
const renderMissionDetailContent = () => {
|
||||||
|
if (!selectedMission) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="mission-detail">
|
<div className="mission-detail">
|
||||||
<div className="mission-detail__header">
|
<div className="mission-detail__header">
|
||||||
<div className="mission-detail__title-row">
|
<div className="mission-detail__title-row">
|
||||||
@@ -3384,9 +3376,149 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="mission-list">
|
||||||
{/* Create mission form */}
|
{/* Create mission form */}
|
||||||
{isCreatingMission && (
|
{isCreatingMission && (
|
||||||
@@ -3418,145 +3550,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mission items */}
|
{/* Mission items */}
|
||||||
{missions.map((mission) => {
|
{renderMissionListItems()}
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Edit mission form */}
|
{/* Edit mission form */}
|
||||||
{editingMissionId && (
|
{editingMissionId && (
|
||||||
@@ -3642,22 +3636,24 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mission-list__footer-actions">
|
{!hideBottomButtons && (
|
||||||
<button className="mission-add-btn" onClick={() => setShowInterviewModal(true)}>
|
<div className="mission-list__footer-actions">
|
||||||
<Sparkles size={16} />
|
<button className="mission-add-btn" onClick={() => setShowInterviewModal(true)}>
|
||||||
Plan with AI
|
<Sparkles size={16} />
|
||||||
</button>
|
Plan with AI
|
||||||
<button className="mission-add-btn" onClick={handleCreateMission}>
|
</button>
|
||||||
<Plus size={16} />
|
<button className="mission-add-btn" onClick={handleCreateMission}>
|
||||||
New Mission
|
<Plus size={16} />
|
||||||
</button>
|
New Mission
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderDeleteConfirmationPanel = () => (
|
const renderDeleteConfirmPanel = () => (
|
||||||
<div className="mission-confirm-panel mission-confirm-panel--danger">
|
<div className="mission-confirm-panel mission-confirm-panel--danger">
|
||||||
<div className="mission-confirm-panel__content">
|
<div className="mission-confirm-panel__content">
|
||||||
<p>
|
<p>
|
||||||
@@ -3731,57 +3727,97 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
const manager = (
|
const manager = (
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
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"}
|
role={isInline ? undefined : "dialog"}
|
||||||
aria-modal={isInline ? undefined : true}
|
aria-modal={isInline ? undefined : true}
|
||||||
aria-label={isInline ? undefined : "Mission Manager"}
|
aria-label={isInline ? undefined : "Mission Manager"}
|
||||||
data-testid="mission-manager-dialog"
|
data-testid="mission-manager-dialog"
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
<div className={`mission-manager__header${isInline ? " mission-manager__header--inline" : ""}`}>
|
||||||
<div className={`mission-manager__header${isInline ? " mission-manager__header--inline" : ""}`}>
|
<div className="mission-manager__header-title">
|
||||||
<div className="mission-manager__header-title">
|
{isMobile && selectedMission && (
|
||||||
{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 */
|
|
||||||
<button
|
<button
|
||||||
className="modal-close"
|
className="mission-manager__back-btn"
|
||||||
onClick={onClose}
|
onClick={handleBackToList}
|
||||||
title="Close"
|
title="Back to missions"
|
||||||
aria-label="Close Mission Manager"
|
aria-label="Back to missions list"
|
||||||
data-testid="mission-close-btn"
|
data-testid="mission-back-btn"
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<ChevronLeft size={18} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<Target size={18} className="mission-manager__header-icon" />
|
||||||
|
<h2 className="mission-manager__title">
|
||||||
|
{selectedMission ? selectedMission.title : "Missions"}
|
||||||
|
</h2>
|
||||||
</div>
|
</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 ── */}
|
{isMobile ? (
|
||||||
{/* ── Desktop split layout (visible on desktop, hidden on mobile via CSS) ── */}
|
<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__split">
|
||||||
<div className="mission-manager__sidebar">
|
<div className="mission-manager__sidebar">
|
||||||
{loading ? (
|
<div className="mission-manager__sidebar-header">
|
||||||
<div className="mission-manager__loading">
|
<span className="mission-manager__sidebar-title">Missions</span>
|
||||||
<Loader2 size={24} className="spinner" />
|
<div className="mission-manager__sidebar-actions">
|
||||||
<span>Loading missions...</span>
|
<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>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
renderMissionListContent()
|
<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>
|
||||||
|
|
||||||
<div className="mission-manager__detail-pane">
|
<div className="mission-manager__detail-pane">
|
||||||
@@ -3791,48 +3827,19 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
<span>Loading mission details...</span>
|
<span>Loading mission details...</span>
|
||||||
</div>
|
</div>
|
||||||
) : selectedMission ? (
|
) : selectedMission ? (
|
||||||
renderMissionDetailContent(selectedMission)
|
renderMissionDetailContent()
|
||||||
) : (
|
) : (
|
||||||
<div className="mission-manager__detail-pane-empty">
|
<div className="mission-manager__detail-pane-empty">
|
||||||
<Target size={24} />
|
<Target size={24} />
|
||||||
<span>Select a mission to view details</span>
|
<span>Select a mission to view details</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{deleteConfirmId && renderDeleteConfirmPanel()}
|
||||||
{deleteConfirmId && renderDeleteConfirmationPanel()}
|
|
||||||
{linkTaskFeatureId && renderLinkTaskPanel()}
|
{linkTaskFeatureId && renderLinkTaskPanel()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* ── Mobile stacked layout (hidden on desktop via CSS, shown on mobile) ── */}
|
</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const interviewModal = (
|
const interviewModal = (
|
||||||
|
|||||||
@@ -640,20 +640,28 @@ function createFetchMockWithHealth(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const setViewportWidth = (width: number) => {
|
function mockViewport(mode: "mobile" | "desktop" | "tablet") {
|
||||||
Object.defineProperty(window, "innerWidth", {
|
Object.defineProperty(window, "matchMedia", {
|
||||||
configurable: true,
|
|
||||||
writable: true,
|
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", () => {
|
describe("MissionManager", () => {
|
||||||
let originalFetch: typeof globalThis.fetch;
|
let originalFetch: typeof globalThis.fetch;
|
||||||
let originalEventSource: typeof globalThis.EventSource | undefined;
|
let originalEventSource: typeof globalThis.EventSource | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
mockViewport("desktop");
|
||||||
originalFetch = globalThis.fetch;
|
originalFetch = globalThis.fetch;
|
||||||
originalEventSource = globalThis.EventSource;
|
originalEventSource = globalThis.EventSource;
|
||||||
mockFetchAiSession.mockReset();
|
mockFetchAiSession.mockReset();
|
||||||
@@ -711,7 +719,7 @@ describe("MissionManager", () => {
|
|||||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Missions")).toBeDefined();
|
expect(screen.getByRole("heading", { name: "Missions" })).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1344,7 +1352,7 @@ describe("MissionManager", () => {
|
|||||||
expect(onClose).toHaveBeenCalledOnce();
|
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();
|
globalThis.fetch = createDetailFetchMock();
|
||||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
@@ -1358,7 +1366,7 @@ describe("MissionManager", () => {
|
|||||||
|
|
||||||
// Wait for detail view to render
|
// Wait for detail view to render
|
||||||
await waitFor(() => {
|
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();
|
expect(screen.queryByTestId("mission-back-btn")).toBeNull();
|
||||||
// Milestone should be visible (auto-expanded)
|
// Milestone should be visible (auto-expanded)
|
||||||
expect(screen.getByText("Database Schema")).toBeDefined();
|
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();
|
globalThis.fetch = createDetailFetchMock();
|
||||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
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()} />);
|
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
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;
|
let callCount = 0;
|
||||||
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
|
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
|
||||||
if (url.includes("/health")) {
|
if (url.includes("/health")) {
|
||||||
@@ -1520,10 +1528,10 @@ describe("MissionManager", () => {
|
|||||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
|
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();
|
warnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2835,7 +2843,7 @@ describe("MissionManager", () => {
|
|||||||
expect(document.querySelector(".mission-manager__split")).toBeTruthy();
|
expect(document.querySelector(".mission-manager__split")).toBeTruthy();
|
||||||
expect(document.querySelector(".mission-manager__sidebar")).toBeTruthy();
|
expect(document.querySelector(".mission-manager__sidebar")).toBeTruthy();
|
||||||
expect(document.querySelector(".mission-manager__detail-pane")).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 () => {
|
it("shows empty placeholder when no mission selected", async () => {
|
||||||
@@ -2872,7 +2880,7 @@ describe("MissionManager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not render desktop back button", async () => {
|
it("does not render desktop back button", async () => {
|
||||||
setViewportWidth(1200);
|
mockViewport("desktop");
|
||||||
globalThis.fetch = createFetchMock();
|
globalThis.fetch = createFetchMock();
|
||||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
await waitFor(() => expect(screen.getByText("Build Auth System")).toBeDefined());
|
await waitFor(() => expect(screen.getByText("Build Auth System")).toBeDefined());
|
||||||
@@ -2891,5 +2899,126 @@ describe("MissionManager", () => {
|
|||||||
fireEvent.click(screen.getByLabelText("Delete mission"));
|
fireEvent.click(screen.getByLabelText("Delete mission"));
|
||||||
await waitFor(() => expect(document.querySelector(".mission-manager__detail-pane .mission-confirm-panel")).toBeTruthy());
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user