feat(FN-1410): add inline missions header parity with agents view

- Add MissionManager component updates with expanded header controls
- Implement inline mission actions (interview, autopilot toggle) matching AgentsView style
- Add MissionManager unit tests with comprehensive coverage
- Add CSS styles for mission header alignment and interaction states
This commit is contained in:
gsxdsm
2026-04-10 02:14:12 -07:00
parent fb8e77290b
commit 5598b49e68
3 changed files with 156 additions and 10 deletions

View File

@@ -21,6 +21,7 @@ import {
Zap,
Activity,
FileText,
RefreshCw,
} from "lucide-react";
import type { ToastType } from "../hooks/useToast";
import { MissionInterviewModal } from "./MissionInterviewModal";
@@ -1273,7 +1274,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
data-testid="mission-manager-dialog"
>
{/* ── Header ── */}
<div className="mission-manager__header">
<div className={`mission-manager__header${isInline ? " mission-manager__header--inline" : ""}`}>
<div className="mission-manager__header-title">
{selectedMission ? (
<button
@@ -1291,15 +1292,32 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{selectedMission ? selectedMission.title : "Missions"}
</h2>
</div>
<button
className="modal-close"
onClick={onClose}
title="Close"
aria-label="Close Mission Manager"
data-testid="mission-close-btn"
>
<X size={18} />
</button>
{isInline ? (
/* Inline mode: show refresh button instead of modal close */
<div className="mission-manager__header-controls">
<button
className="btn-icon"
onClick={() => loadMissions()}
title="Refresh"
disabled={loading}
aria-label="Refresh missions"
data-testid="mission-refresh-btn"
>
<RefreshCw size={16} className={loading ? "spin" : ""} />
</button>
</div>
) : (
/* Modal mode: show close button */
<button
className="modal-close"
onClick={onClose}
title="Close"
aria-label="Close Mission Manager"
data-testid="mission-close-btn"
>
<X size={18} />
</button>
)}
</div>
{/* ── Body ── */}

View File

@@ -47,6 +47,7 @@ vi.mock("lucide-react", () => ({
Activity: () => <span data-testid="activity-icon">Activity</span>,
FileText: () => <span data-testid="file-text-icon">FileText</span>,
Minimize2: () => <span data-testid="minimize-icon">Minimize2</span>,
RefreshCw: ({ className }: any) => <span data-testid="refresh-icon" className={className}>Refresh</span>,
}));
// Mock data
@@ -1079,6 +1080,117 @@ describe("MissionManager", () => {
});
});
// ── Inline vs Modal Header Behavior ──────────────────────────────
describe("inline vs modal header behavior", () => {
it("renders with page-style header class when isInline is true", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
render(<MissionManager isOpen={true} isInline={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
const header = document.querySelector(".mission-manager__header--inline");
expect(header).toBeDefined();
});
});
it("does not show modal close button in inline mode", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
render(<MissionManager isOpen={true} isInline={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
// The modal close button should not be present in inline mode
expect(screen.queryByTestId("mission-close-btn")).toBeNull();
});
});
it("shows modal close button in modal mode (isInline=false)", async () => {
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} isInline={false} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("mission-close-btn")).toBeDefined();
});
});
it("shows refresh button in inline mode header", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
render(<MissionManager isOpen={true} isInline={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByTestId("mission-refresh-btn")).toBeDefined();
});
});
it("does not show refresh button in modal mode", async () => {
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} isInline={false} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.queryByTestId("mission-refresh-btn")).toBeNull();
});
});
it("refresh button triggers loadMissions when clicked in inline mode", async () => {
const fetchMock = vi.fn().mockResolvedValue(mockApiResponse([]));
globalThis.fetch = fetchMock;
render(<MissionManager isOpen={true} isInline={true} onClose={vi.fn()} addToast={vi.fn()} />);
// Wait for initial load
await waitFor(() => {
expect(screen.getByText("No missions yet. Create one to start planning.")).toBeDefined();
});
// Reset fetch mock to track reload
fetchMock.mockClear();
fetchMock.mockResolvedValueOnce(mockApiResponse([]));
// Click refresh button
fireEvent.click(screen.getByTestId("mission-refresh-btn"));
// Verify missions were fetched again
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled();
});
});
it("inline mode header has inline class modifier for styling parity with agents view", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
render(<MissionManager isOpen={true} isInline={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
const dialog = screen.getByTestId("mission-manager-dialog");
expect(dialog.className).toContain("mission-manager--inline");
});
});
it("detail view in inline mode still shows back button", async () => {
let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation((url: string) => {
if (url.includes("/health")) {
return Promise.resolve(mockApiResponse(getMockMissionHealth("M-001")));
}
callCount++;
if (callCount <= 1) {
return Promise.resolve(mockApiResponse(mockMissions));
}
return Promise.resolve(mockApiResponse(mockMissionDetail));
});
render(<MissionManager isOpen={true} isInline={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Build Auth System")).toBeDefined();
});
fireEvent.click(screen.getByText("Build Auth System"));
await waitFor(() => {
expect(screen.getByTestId("mission-back-btn")).toBeDefined();
// Close button should still be absent in inline mode even in detail view
expect(screen.queryByTestId("mission-close-btn")).toBeNull();
});
});
});
it("hides send to background button when mission interview is in initial state", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));