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