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;
|
||||
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;
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user