feat(FN-1206): promote missions to an inline top-level view

- Extend dashboard task view state and nav controls to include a persistent missions view toggle
- Render MissionManager inline in App project content and route mission/session navigation into the missions view
- Remove modal-based mission wiring from AppModals and useModalManager now that missions are no longer a modal
- Add inline MissionManager styling and update App/Header/tablet tests for the new missions navigation behavior
This commit is contained in:
gsxdsm
2026-04-08 15:33:19 -07:00
parent e80fb448d4
commit a47db4fa6b
12 changed files with 179 additions and 163 deletions

View File

@@ -18,7 +18,6 @@ import { NewTaskModal } from "./NewTaskModal";
import { ActivityLogModal } from "./ActivityLogModal";
import { GitManagerModal } from "./GitManagerModal";
import { WorkflowStepManager } from "./WorkflowStepManager";
import { MissionManager } from "./MissionManager";
import { AgentListModal } from "./AgentListModal";
import { MailboxModal } from "./MailboxModal";
import { SetupWizardModal } from "./SetupWizardModal";
@@ -208,22 +207,6 @@ export function AppModals({
projectId={projectId}
/>
<MissionManager
isOpen={modalManager.missionsOpen}
onClose={modalManager.closeMissions}
addToast={addToast}
projectId={projectId}
resumeSessionId={modalManager.missionResumeSessionId}
targetMissionId={modalManager.missionTargetId}
availableTasks={tasks.map((task) => ({ id: task.id, title: task.title }))}
onSelectTask={(taskId) => {
const task = tasks.find((candidate) => candidate.id === taskId);
if (task) {
modalManager.openDetailTask(task as TaskDetail);
}
}}
/>
<AgentListModal
isOpen={modalManager.agentsOpen}
onClose={modalManager.closeAgents}

View File

@@ -888,7 +888,6 @@ describe("Header", () => {
onOpenUsage: noop,
onOpenActivityLog: noop,
onOpenWorkflowSteps: noop,
onOpenMissions: noop,
onOpenFiles: noop,
onOpenGitManager: noop,
onOpenScripts: noop,
@@ -931,7 +930,6 @@ describe("Header", () => {
onOpenUsage: noop,
onOpenActivityLog: noop,
onOpenWorkflowSteps: noop,
onOpenMissions: noop,
onOpenFiles: noop,
onOpenGitManager: noop,
}, "mobile");

View File

@@ -41,7 +41,6 @@ export interface HeaderProps {
onOpenGitManager?: () => void;
onOpenNodes?: () => void;
onOpenWorkflowSteps?: () => void;
onOpenMissions?: () => void;
onOpenScripts?: () => void;
onRunScript?: (name: string, command: string) => void;
onToggleTerminal?: () => void;
@@ -52,8 +51,8 @@ export interface HeaderProps {
enginePaused?: boolean;
onToggleGlobalPause?: () => void;
onToggleEnginePause?: () => void;
view?: "board" | "list" | "agents";
onChangeView?: (view: "board" | "list" | "agents") => void;
view?: "board" | "list" | "agents" | "missions";
onChangeView?: (view: "board" | "list" | "agents" | "missions") => void;
searchQuery?: string;
onSearchChange?: (query: string) => void;
/** Multi-project props */
@@ -81,7 +80,6 @@ export function Header({
onOpenGitManager,
onOpenNodes,
onOpenWorkflowSteps,
onOpenMissions,
onOpenScripts,
onRunScript,
onToggleTerminal,
@@ -385,6 +383,15 @@ export function Header({
>
<Bot size={16} />
</button>
<button
className={`view-toggle-btn${view === "missions" ? " active" : ""}`}
onClick={() => onChangeView("missions")}
title="Missions view"
aria-label="Missions view"
aria-pressed={view === "missions"}
>
<Target size={16} />
</button>
</div>
)}
@@ -519,18 +526,6 @@ export function Header({
</button>
)}
{/* Missions - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenMissions && (
<button
className="btn-icon"
onClick={onOpenMissions}
title="Mission Manager"
data-testid="missions-btn"
>
<Target size={16} />
</button>
)}
{/* Scripts - desktop only (moved to overflow on mobile/tablet) */}
{!isCompact && onOpenScripts && onRunScript && (
<QuickScriptsDropdown
@@ -802,18 +797,6 @@ export function Header({
<span>Workflow Steps</span>
</button>
)}
{/* Missions - in overflow on mobile */}
{onOpenMissions && (
<button
className="mobile-overflow-item"
onClick={() => handleOverflowAction(onOpenMissions)}
role="menuitem"
data-testid="overflow-missions-btn"
>
<Target size={16} />
<span>Missions</span>
</button>
)}
{/* Settings - always last in overflow menu */}
<button
className="mobile-overflow-item"

View File

@@ -71,6 +71,7 @@ import type { AutopilotStatus as AutopilotStatusType, AutopilotState } from "./m
interface MissionManagerProps {
isOpen: boolean;
isInline?: boolean;
onClose: () => void;
addToast: (message: string, type?: ToastType) => void;
projectId?: string;
@@ -174,7 +175,8 @@ const EMPTY_FEATURE_FORM: FeatureFormData = {
status: "defined",
};
export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId }: MissionManagerProps) {
export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId }: MissionManagerProps) {
const isActive = isInline || isOpen;
const [missions, setMissions] = useState<MissionWithSummary[]>([]);
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
const [loading, setLoading] = useState(true);
@@ -213,10 +215,10 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
// Auto-open interview modal when resuming a session
useEffect(() => {
if (isOpen && resumeSessionId) {
if (isActive && resumeSessionId) {
setShowInterviewModal(true);
}
}, [isOpen, resumeSessionId]);
}, [isActive, resumeSessionId]);
// Delete confirmation
const [deleteConfirmId, setDeleteConfirmId] = useState<{ type: string; id: string } | null>(null);
@@ -257,27 +259,27 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
}, [addToast, projectId]);
useEffect(() => {
if (isOpen) {
if (isActive) {
loadMissions();
setSelectedMission(null);
}
}, [isOpen, loadMissions]);
}, [isActive, loadMissions]);
// Auto-load target mission when specified
const targetLoadedRef = useRef<string | null>(null);
useEffect(() => {
if (isOpen && targetMissionId && targetLoadedRef.current !== targetMissionId && missions.length > 0) {
if (isActive && targetMissionId && targetLoadedRef.current !== targetMissionId && missions.length > 0) {
targetLoadedRef.current = targetMissionId;
loadMissionDetail(targetMissionId);
}
}, [isOpen, targetMissionId, missions, loadMissionDetail]);
}, [isActive, targetMissionId, missions, loadMissionDetail]);
// Reset target tracking when modal closes
useEffect(() => {
if (!isOpen) {
if (!isActive) {
targetLoadedRef.current = null;
}
}, [isOpen]);
}, [isActive]);
// Mission handlers
const handleCreateMission = useCallback(() => {
@@ -801,7 +803,7 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
// Escape key handling
useEffect(() => {
if (!isOpen) return;
if (!isActive) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
@@ -809,24 +811,19 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
}, [isActive, onClose]);
if (!isOpen) return null;
if (!isActive) return null;
return (
const manager = (
<div
className="mission-manager-overlay open"
onClick={(e) => e.target === e.currentTarget && onClose()}
data-testid="mission-manager-overlay"
ref={modalRef}
className={`mission-manager${isInline ? " mission-manager--inline" : ""}`}
role={isInline ? undefined : "dialog"}
aria-modal={isInline ? undefined : true}
aria-label={isInline ? undefined : "Mission Manager"}
data-testid="mission-manager-dialog"
>
<div
ref={modalRef}
className="mission-manager"
role="dialog"
aria-modal="true"
aria-label="Mission Manager"
data-testid="mission-manager-dialog"
>
{/* ── Header ── */}
<div className="mission-manager__header">
<div className="mission-manager__header-title">
@@ -1800,17 +1797,40 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
</div>
)}
</div>
);
<MissionInterviewModal
isOpen={showInterviewModal}
onClose={() => setShowInterviewModal(false)}
onMissionCreated={() => {
loadMissions();
addToast("Mission created from AI interview", "success");
}}
projectId={projectId}
resumeSessionId={resumeSessionId}
/>
</div>
const interviewModal = (
<MissionInterviewModal
isOpen={showInterviewModal}
onClose={() => setShowInterviewModal(false)}
onMissionCreated={() => {
loadMissions();
addToast("Mission created from AI interview", "success");
}}
projectId={projectId}
resumeSessionId={resumeSessionId}
/>
);
if (isInline) {
return (
<>
{manager}
{interviewModal}
</>
);
}
return (
<>
<div
className="mission-manager-overlay open"
onClick={(e) => e.target === e.currentTarget && onClose()}
data-testid="mission-manager-overlay"
>
{manager}
</div>
{interviewModal}
</>
);
}

View File

@@ -19,9 +19,9 @@ import { useViewportMode } from "./Header";
export interface MobileNavBarProps {
/** Current task view mode */
view: "board" | "list" | "agents";
view: "board" | "list" | "agents" | "missions";
/** Change task view handler */
onChangeView: (view: "board" | "list" | "agents") => void;
onChangeView: (view: "board" | "list" | "agents" | "missions") => void;
/** Whether the ExecutorStatusBar footer is visible */
footerVisible: boolean;
/** Whether any full-screen modal is currently open (hides the tab bar) */

View File

@@ -486,7 +486,7 @@ describe("App mission wiring", () => {
localStorage.removeItem("kb-dashboard-view-mode");
});
it("hides mission controls when no project is selected", async () => {
it("hides missions view toggle when no project is selected", async () => {
mockCurrentProjectState.currentProject = null;
mockProjectsState.projects = [];
@@ -496,10 +496,10 @@ describe("App mission wiring", () => {
expect(fetchSettings).toHaveBeenCalled();
});
expect(screen.queryByTestId("missions-btn")).toBeNull();
expect(screen.queryByTitle("Missions view")).toBeNull();
});
it("shows mission controls in project view when a project is selected", async () => {
it("shows missions view toggle in project view when a project is selected", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
mockCurrentProjectState.currentProject = {
id: "proj_123",
@@ -514,7 +514,7 @@ describe("App mission wiring", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("missions-btn")).toBeTruthy();
expect(screen.getByTitle("Missions view")).toBeTruthy();
});
});
});

View File

@@ -98,16 +98,6 @@ describe("Header", () => {
expect(btn).toBeDefined();
});
it("renders the missions button when mission management is available", () => {
const onOpen = vi.fn();
render(<Header onOpenMissions={onOpen} />);
expect(screen.getByTestId("missions-btn")).toBeDefined();
});
it("does not render the missions button when mission management is unavailable", () => {
render(<Header />);
expect(screen.queryByTestId("missions-btn")).toBeNull();
});
it("calls onOpenGitHubImport when import button is clicked", () => {
const onOpen = vi.fn();
@@ -305,6 +295,39 @@ describe("Header", () => {
expect(boardBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Missions View Toggle ────────────────────────────────────────
it("renders missions view button in view toggle when onChangeView is provided", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const missionsBtn = screen.getByTitle("Missions view");
expect(missionsBtn).toBeDefined();
});
it("calls onChangeView with 'missions' when missions view button is clicked", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const missionsBtn = screen.getByTitle("Missions view");
fireEvent.click(missionsBtn);
expect(onChangeView).toHaveBeenCalledWith("missions");
});
it("marks missions view button as active when view is 'missions'", () => {
const onChangeView = vi.fn();
render(<Header view="missions" onChangeView={onChangeView} />);
const missionsBtn = screen.getByTitle("Missions view");
expect(missionsBtn.className).toContain("active");
expect(missionsBtn.getAttribute("aria-pressed")).toBe("true");
});
it("does not mark missions view button as active when view is 'board'", () => {
const onChangeView = vi.fn();
render(<Header view="board" onChangeView={onChangeView} />);
const missionsBtn = screen.getByTitle("Missions view");
expect(missionsBtn.className).not.toContain("active");
expect(missionsBtn.getAttribute("aria-pressed")).toBe("false");
});
// ── Search Visibility by View ─────────────────────────────────────
it("shows search input when view is 'board'", () => {
@@ -770,14 +793,6 @@ describe("Header", () => {
expect(screen.queryByTestId("overflow-project-selector-btn")).not.toBeNull();
});
it("missions overflow menu item calls onOpenMissions when clicked", () => {
const onOpenMissions = vi.fn();
render(<Header onOpenSettings={vi.fn()} onOpenMissions={onOpenMissions} />);
fireEvent.click(screen.getByTitle("More header actions"));
fireEvent.click(screen.getByText("Missions"));
expect(onOpenMissions).toHaveBeenCalledOnce();
});
it("workflow steps overflow menu item calls onOpenWorkflowSteps when clicked", () => {
const onOpenWorkflowSteps = vi.fn();
render(<Header onOpenSettings={vi.fn()} onOpenWorkflowSteps={onOpenWorkflowSteps} />);