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