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,
|
Box,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Minimize2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
interface MissionInterviewModalProps {
|
interface MissionInterviewModalProps {
|
||||||
@@ -286,6 +287,12 @@ export function MissionInterviewModal({
|
|||||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
}, [isOpen, view]);
|
}, [isOpen, view]);
|
||||||
|
|
||||||
|
const handleSendToBackground = useCallback(() => {
|
||||||
|
streamConnectionRef.current?.close();
|
||||||
|
streamConnectionRef.current = null;
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const handleCancel = useCallback(async () => {
|
const handleCancel = useCallback(async () => {
|
||||||
// Save to localStorage BEFORE any cleanup
|
// Save to localStorage BEFORE any cleanup
|
||||||
if (missionGoal) {
|
if (missionGoal) {
|
||||||
@@ -400,6 +407,9 @@ export function MissionInterviewModal({
|
|||||||
return 6;
|
return 6;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showSendToBackgroundButton =
|
||||||
|
view.type === "loading" || view.type === "question" || view.type === "summary";
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -410,9 +420,21 @@ export function MissionInterviewModal({
|
|||||||
<Target size={20} style={{ color: "var(--triage)" }} />
|
<Target size={20} style={{ color: "var(--triage)" }} />
|
||||||
<h3>Plan Mission with AI</h3>
|
<h3>Plan Mission with AI</h3>
|
||||||
</div>
|
</div>
|
||||||
<button className="modal-close" onClick={handleCancel} aria-label="Close">
|
<div className="modal-header-actions">
|
||||||
<X size={20} />
|
{showSendToBackgroundButton && (
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div className="planning-modal-body">
|
<div className="planning-modal-body">
|
||||||
|
|||||||
@@ -184,6 +184,19 @@ describe("PlanningModeModal", () => {
|
|||||||
expect(screen.queryByText("Planning Mode")).toBeNull();
|
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", () => {
|
it("enables start button when text is entered", () => {
|
||||||
render(
|
render(
|
||||||
<PlanningModeModal
|
<PlanningModeModal
|
||||||
@@ -1266,6 +1279,39 @@ describe("PlanningModeModal", () => {
|
|||||||
expect(mockOnClose).toHaveBeenCalled();
|
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 () => {
|
it("disconnects SSE stream on close", async () => {
|
||||||
const closeSpy = vi.fn();
|
const closeSpy = vi.fn();
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
getPlanningDescription,
|
getPlanningDescription,
|
||||||
clearPlanningDescription,
|
clearPlanningDescription,
|
||||||
} from "../hooks/modalPersistence";
|
} 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";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
|
|
||||||
interface PlanningModeModalProps {
|
interface PlanningModeModalProps {
|
||||||
@@ -335,6 +335,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSendToBackground = useCallback(() => {
|
||||||
|
streamConnectionRef.current?.close();
|
||||||
|
streamConnectionRef.current = null;
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
// Save to localStorage BEFORE any cleanup (preserve for re-entry)
|
// Save to localStorage BEFORE any cleanup (preserve for re-entry)
|
||||||
if (initialPlan) {
|
if (initialPlan) {
|
||||||
@@ -482,6 +488,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
return 3;
|
return 3;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showSendToBackgroundButton =
|
||||||
|
view.type === "loading" || view.type === "question" || view.type === "summary";
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -492,9 +501,21 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
<Lightbulb size={20} style={{ color: "var(--triage)" }} />
|
<Lightbulb size={20} style={{ color: "var(--triage)" }} />
|
||||||
<h3>Planning Mode</h3>
|
<h3>Planning Mode</h3>
|
||||||
</div>
|
</div>
|
||||||
<button className="modal-close" onClick={handleCancel} aria-label="Close">
|
<div className="modal-header-actions">
|
||||||
<X size={20} />
|
{showSendToBackgroundButton && (
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div className="planning-modal-body">
|
<div className="planning-modal-body">
|
||||||
|
|||||||
@@ -70,6 +70,19 @@ describe("SubtaskBreakdownModal", () => {
|
|||||||
expect(await screen.findByText("AI is generating subtasks...")).toBeInTheDocument();
|
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 () => {
|
it("renders editable subtasks when stream returns items", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(streamHandlers).toBeDefined());
|
await waitFor(() => expect(streamHandlers).toBeDefined());
|
||||||
@@ -181,6 +194,24 @@ describe("SubtaskBreakdownModal", () => {
|
|||||||
expect(onClose).toHaveBeenCalled();
|
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 () => {
|
it("cancel closes modal", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalled());
|
await waitFor(() => expect(mockStartSubtaskBreakdown).toHaveBeenCalled());
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
getSubtaskDescription,
|
getSubtaskDescription,
|
||||||
clearSubtaskDescription,
|
clearSubtaskDescription,
|
||||||
} from "../hooks/modalPersistence";
|
} 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 {
|
interface SubtaskBreakdownModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -91,6 +91,8 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
return hasDependencyCycle(subtasks);
|
return hasDependencyCycle(subtasks);
|
||||||
}, [subtasks]);
|
}, [subtasks]);
|
||||||
|
|
||||||
|
const showSendToBackgroundButton = view.type === "generating" || view.type === "editing";
|
||||||
|
|
||||||
const resetState = useCallback(() => {
|
const resetState = useCallback(() => {
|
||||||
// Save to localStorage before cleanup (preserve for re-entry)
|
// Save to localStorage before cleanup (preserve for re-entry)
|
||||||
if (localDescription) {
|
if (localDescription) {
|
||||||
@@ -108,6 +110,12 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
autoStartedRef.current = false;
|
autoStartedRef.current = false;
|
||||||
}, [localDescription]);
|
}, [localDescription]);
|
||||||
|
|
||||||
|
const handleSendToBackground = useCallback(() => {
|
||||||
|
streamRef.current?.close();
|
||||||
|
streamRef.current = null;
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const handleClose = useCallback(async () => {
|
const handleClose = useCallback(async () => {
|
||||||
if ((dirty || view.type === "editing" || view.type === "creating") && !confirm("Close subtask breakdown? Unsaved changes will be lost.")) {
|
if ((dirty || view.type === "editing" || view.type === "creating") && !confirm("Close subtask breakdown? Unsaved changes will be lost.")) {
|
||||||
return;
|
return;
|
||||||
@@ -367,9 +375,21 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
|
|||||||
<ListTree size={20} style={{ color: "var(--triage)" }} />
|
<ListTree size={20} style={{ color: "var(--triage)" }} />
|
||||||
<h3>Subtask Breakdown</h3>
|
<h3>Subtask Breakdown</h3>
|
||||||
</div>
|
</div>
|
||||||
<button className="modal-close" onClick={() => void handleClose()} aria-label="Close">
|
<div className="modal-header-actions">
|
||||||
<X size={20} />
|
{showSendToBackgroundButton && (
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div className="planning-modal-body">
|
<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 { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { MissionManager } from "../MissionManager";
|
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
|
// Mock data
|
||||||
const mockMissions = [
|
const mockMissions = [
|
||||||
{
|
{
|
||||||
@@ -109,6 +123,15 @@ describe("MissionManager", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
originalFetch = globalThis.fetch;
|
originalFetch = globalThis.fetch;
|
||||||
|
mockFetchAiSession.mockReset();
|
||||||
|
mockCancelMissionInterview.mockReset();
|
||||||
|
mockConnectMissionInterviewStream.mockReset();
|
||||||
|
mockFetchAiSession.mockResolvedValue(null);
|
||||||
|
mockCancelMissionInterview.mockResolvedValue(undefined);
|
||||||
|
mockConnectMissionInterviewStream.mockReturnValue({
|
||||||
|
close: vi.fn(),
|
||||||
|
isConnected: () => true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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 () => {
|
it("shows milestone hierarchy in detail view", async () => {
|
||||||
globalThis.fetch = createDetailFetchMock();
|
globalThis.fetch = createDetailFetchMock();
|
||||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|||||||
@@ -2547,6 +2547,28 @@ body {
|
|||||||
border-radius: var(--radius-sm);
|
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 {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|||||||
Reference in New Issue
Block a user