feat(FN-1149): add send-to-background actions for AI planning modals

- Add Send to background header action to Planning Mode, Subtask Breakdown, and Mission Interview modals using a shared minimize icon pattern
- Show the action only during active AI session states and keep it hidden in initial states
- Implement background behavior to close the live stream connection and modal without canceling the underlying AI session
- Add modal tests that verify button visibility rules and confirm send-to-background does not trigger cancel APIs
- Add shared CSS styling for modal send-to-background controls, including hover and focus-visible states
This commit is contained in:
gsxdsm
2026-04-08 10:15:14 -07:00
parent d3a8e27ef0
commit 33321a3681
7 changed files with 262 additions and 11 deletions

View File

@@ -33,6 +33,7 @@ import {
Box,
Plus,
Trash2,
Minimize2,
} from "lucide-react";
interface MissionInterviewModalProps {
@@ -286,6 +287,12 @@ export function MissionInterviewModal({
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isOpen, view]);
const handleSendToBackground = useCallback(() => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
onClose();
}, [onClose]);
const handleCancel = useCallback(async () => {
// Save to localStorage BEFORE any cleanup
if (missionGoal) {
@@ -400,6 +407,9 @@ export function MissionInterviewModal({
return 6;
};
const showSendToBackgroundButton =
view.type === "loading" || view.type === "question" || view.type === "summary";
if (!isOpen) return null;
return (
@@ -410,9 +420,21 @@ export function MissionInterviewModal({
<Target size={20} style={{ color: "var(--triage)" }} />
<h3>Plan Mission with AI</h3>
</div>
<button className="modal-close" onClick={handleCancel} aria-label="Close">
<X size={20} />
</button>
<div className="modal-header-actions">
{showSendToBackgroundButton && (
<button
className="modal-send-to-background"
onClick={handleSendToBackground}
title="Send to background"
aria-label="Send to background"
>
<Minimize2 size={16} />
</button>
)}
<button className="modal-close" onClick={handleCancel} aria-label="Close">
<X size={20} />
</button>
</div>
</div>
<div className="planning-modal-body">

View File

@@ -184,6 +184,19 @@ describe("PlanningModeModal", () => {
expect(screen.queryByText("Planning Mode")).toBeNull();
});
it("hides send to background button in initial state", () => {
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
expect(screen.queryByLabelText("Send to background")).toBeNull();
});
it("enables start button when text is entered", () => {
render(
<PlanningModeModal
@@ -1266,6 +1279,39 @@ describe("PlanningModeModal", () => {
expect(mockOnClose).toHaveBeenCalled();
});
it("sends active session to background without canceling", async () => {
const closeSpy = vi.fn();
mockConnectPlanningStream.mockImplementationOnce(() => ({
close: closeSpy,
isConnected: vi.fn().mockReturnValue(true),
}));
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
target: { value: "Build auth system" },
});
fireEvent.click(screen.getByText("Start Planning"));
await waitFor(() => {
expect(screen.getByText("Generating next question...")).toBeDefined();
});
fireEvent.click(screen.getByLabelText("Send to background"));
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("disconnects SSE stream on close", async () => {
const closeSpy = vi.fn();

View File

@@ -18,7 +18,7 @@ import {
getPlanningDescription,
clearPlanningDescription,
} from "../hooks/modalPersistence";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2 } from "lucide-react";
import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, Minimize2 } from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
interface PlanningModeModalProps {
@@ -335,6 +335,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isOpen]);
const handleSendToBackground = useCallback(() => {
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
onClose();
}, [onClose]);
const handleCancel = useCallback(() => {
// Save to localStorage BEFORE any cleanup (preserve for re-entry)
if (initialPlan) {
@@ -482,6 +488,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
return 3;
};
const showSendToBackgroundButton =
view.type === "loading" || view.type === "question" || view.type === "summary";
if (!isOpen) return null;
return (
@@ -492,9 +501,21 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<Lightbulb size={20} style={{ color: "var(--triage)" }} />
<h3>Planning Mode</h3>
</div>
<button className="modal-close" onClick={handleCancel} aria-label="Close">
<X size={20} />
</button>
<div className="modal-header-actions">
{showSendToBackgroundButton && (
<button
className="modal-send-to-background"
onClick={handleSendToBackground}
title="Send to background"
aria-label="Send to background"
>
<Minimize2 size={16} />
</button>
)}
<button className="modal-close" onClick={handleCancel} aria-label="Close">
<X size={20} />
</button>
</div>
</div>
<div className="planning-modal-body">

View File

@@ -70,6 +70,19 @@ describe("SubtaskBreakdownModal", () => {
expect(await screen.findByText("AI is generating subtasks...")).toBeInTheDocument();
});
it("hides send to background button in initial state", () => {
render(
<SubtaskBreakdownModal
isOpen={true}
onClose={onClose}
initialDescription=""
onTasksCreated={onTasksCreated}
/>,
);
expect(screen.queryByLabelText("Send to background")).not.toBeInTheDocument();
});
it("renders editable subtasks when stream returns items", async () => {
renderModal();
await waitFor(() => expect(streamHandlers).toBeDefined());
@@ -181,6 +194,24 @@ describe("SubtaskBreakdownModal", () => {
expect(onClose).toHaveBeenCalled();
});
it("sends active breakdown session to background without canceling", async () => {
const closeSpy = vi.fn();
mockConnectSubtaskStream.mockImplementationOnce((_sessionId, _projectId, handlers) => {
streamHandlers = handlers;
return { close: closeSpy, isConnected: () => true };
});
renderModal();
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalled());
await screen.findByText("AI is generating subtasks...");
fireEvent.click(screen.getByLabelText("Send to background"));
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(mockCancelSubtaskBreakdown).not.toHaveBeenCalled();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("cancel closes modal", async () => {
renderModal();
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalled());

View File

@@ -13,7 +13,7 @@ import {
getSubtaskDescription,
clearSubtaskDescription,
} from "../hooks/modalPersistence";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown } from "lucide-react";
import { CheckCircle, Loader2, ListTree, Plus, Trash2, X, GripVertical, ArrowUp, ArrowDown, Minimize2 } from "lucide-react";
interface SubtaskBreakdownModalProps {
isOpen: boolean;
@@ -91,6 +91,8 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
return hasDependencyCycle(subtasks);
}, [subtasks]);
const showSendToBackgroundButton = view.type === "generating" || view.type === "editing";
const resetState = useCallback(() => {
// Save to localStorage before cleanup (preserve for re-entry)
if (localDescription) {
@@ -108,6 +110,12 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
autoStartedRef.current = false;
}, [localDescription]);
const handleSendToBackground = useCallback(() => {
streamRef.current?.close();
streamRef.current = null;
onClose();
}, [onClose]);
const handleClose = useCallback(async () => {
if ((dirty || view.type === "editing" || view.type === "creating") && !confirm("Close subtask breakdown? Unsaved changes will be lost.")) {
return;
@@ -367,9 +375,21 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
<ListTree size={20} style={{ color: "var(--triage)" }} />
<h3>Subtask Breakdown</h3>
</div>
<button className="modal-close" onClick={() => void handleClose()} aria-label="Close">
<X size={20} />
</button>
<div className="modal-header-actions">
{showSendToBackgroundButton && (
<button
className="modal-send-to-background"
onClick={handleSendToBackground}
title="Send to background"
aria-label="Send to background"
>
<Minimize2 size={16} />
</button>
)}
<button className="modal-close" onClick={() => void handleClose()} aria-label="Close">
<X size={20} />
</button>
</div>
</div>
<div className="planning-modal-body">

View File

@@ -2,6 +2,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MissionManager } from "../MissionManager";
const mockFetchAiSession = vi.fn();
const mockCancelMissionInterview = vi.fn();
const mockConnectMissionInterviewStream = vi.fn();
vi.mock("../../api", async () => {
const actual = await vi.importActual<typeof import("../../api")>("../../api");
return {
...actual,
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
cancelMissionInterview: (...args: any[]) => mockCancelMissionInterview(...args),
connectMissionInterviewStream: (...args: any[]) => mockConnectMissionInterviewStream(...args),
};
});
// Mock data
const mockMissions = [
{
@@ -109,6 +123,15 @@ describe("MissionManager", () => {
beforeEach(() => {
originalFetch = globalThis.fetch;
mockFetchAiSession.mockReset();
mockCancelMissionInterview.mockReset();
mockConnectMissionInterviewStream.mockReset();
mockFetchAiSession.mockResolvedValue(null);
mockCancelMissionInterview.mockResolvedValue(undefined);
mockConnectMissionInterviewStream.mockReturnValue({
close: vi.fn(),
isConnected: () => true,
});
});
afterEach(() => {
@@ -346,6 +369,72 @@ describe("MissionManager", () => {
});
});
it("hides send to background button when mission interview is in initial state", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText("Plan with AI")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Plan with AI"));
await waitFor(() => {
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
});
expect(screen.queryByLabelText("Send to background")).not.toBeInTheDocument();
});
it("sends mission interview to background without canceling the session", async () => {
const closeSpy = vi.fn();
mockConnectMissionInterviewStream.mockReturnValueOnce({
close: closeSpy,
isConnected: () => true,
});
mockFetchAiSession.mockResolvedValueOnce({
id: "session-bg-1",
type: "mission_interview",
status: "generating",
title: "Background mission",
inputPayload: JSON.stringify({ missionTitle: "Background mission" }),
conversationHistory: "[]",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
globalThis.fetch = createFetchMock();
render(
<MissionManager
isOpen={true}
onClose={vi.fn()}
addToast={vi.fn()}
resumeSessionId="session-bg-1"
/>,
);
await waitFor(() => {
expect(screen.getByText("Plan Mission with AI")).toBeInTheDocument();
expect(screen.getByText("Preparing next question...")).toBeInTheDocument();
});
fireEvent.click(screen.getByLabelText("Send to background"));
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(mockCancelMissionInterview).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByText("Plan Mission with AI")).not.toBeInTheDocument();
});
});
it("shows milestone hierarchy in detail view", async () => {
globalThis.fetch = createDetailFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);

View File

@@ -2547,6 +2547,28 @@ body {
border-radius: var(--radius-sm);
}
.modal-send-to-background {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.modal-send-to-background:hover {
color: var(--text-primary);
}
.modal-send-to-background:focus-visible {
box-shadow: var(--focus-ring-strong);
outline: none;
}
.modal-actions {
display: flex;
justify-content: flex-end;