feat(KB-639): add dismiss warning for unsaved progress in PlanningModeModal

- Add hasProgress state to track unsaved changes in PlanningModeModal\n- Show confirmation dialog when dismissing modal with unsaved progress\n- Prevent accidental data loss when users close the planning modal\n- Add comprehensive tests for dismiss warning behavior
This commit is contained in:
gsxdsm
2026-03-31 19:58:25 -07:00
parent 40164e6b04
commit 591345c791
2 changed files with 188 additions and 3 deletions

View File

@@ -684,4 +684,177 @@ describe("PlanningModeModal", () => {
}, { timeout: 3000 });
});
});
describe("Dismiss warning", () => {
it("shows confirmation when clicking X button with progress", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
// Wait for question view (progress made)
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
// Click X button
const closeButton = screen.getByLabelText("Close");
fireEvent.click(closeButton);
// Should show confirmation dialog
expect(confirmSpy).toHaveBeenCalledWith("Are you sure you want to close? Your planning progress will be lost.");
// onClose should NOT be called since confirm returned false
expect(mockOnClose).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
it("shows confirmation when clicking overlay with progress", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
const { container } = render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
// Wait for question view (progress made)
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
// Click overlay (modal-overlay)
const overlay = container.querySelector(".modal-overlay");
expect(overlay).not.toBeNull();
fireEvent.click(overlay!);
// Should show confirmation dialog
expect(confirmSpy).toHaveBeenCalledWith("Are you sure you want to close? Your planning progress will be lost.");
// onClose should NOT be called since confirm returned false
expect(mockOnClose).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
it("no confirmation shown when no progress made (initial state)", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
// Click X button while still in initial state (no planning started)
const closeButton = screen.getByLabelText("Close");
fireEvent.click(closeButton);
// Should NOT show confirmation dialog
expect(confirmSpy).not.toHaveBeenCalled();
// onClose should be called immediately
expect(mockOnClose).toHaveBeenCalled();
confirmSpy.mockRestore();
});
it("closes without confirmation after confirming dismiss", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
// Wait for question view (progress made)
await waitFor(() => {
expect(screen.getByText("What is the scope?")).toBeDefined();
});
// Click X button
const closeButton = screen.getByLabelText("Close");
fireEvent.click(closeButton);
// Should show confirmation dialog
expect(confirmSpy).toHaveBeenCalledWith("Are you sure you want to close? Your planning progress will be lost.");
// Wait for async handleCancel to complete
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
confirmSpy.mockRestore();
});
it("shows confirmation in summary view", async () => {
// Override mock to return summary
mockConnectPlanningStream.mockImplementationOnce((sessionId: string, handlers: any) => {
setTimeout(() => {
handlers.onSummary?.(mockSummary);
}, 10);
return {
close: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
};
});
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
tasks={mockTasks}
/>
);
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
fireEvent.change(textarea, { target: { value: "Build auth system" } });
fireEvent.click(screen.getByText("Start Planning"));
// Wait for summary view (progress made)
await waitFor(() => {
expect(screen.getByText("Planning Complete!")).toBeDefined();
});
// Click X button
const closeButton = screen.getByLabelText("Close");
fireEvent.click(closeButton);
// Should show confirmation dialog
expect(confirmSpy).toHaveBeenCalledWith("Are you sure you want to close? Your planning progress will be lost.");
expect(mockOnClose).not.toHaveBeenCalled();
confirmSpy.mockRestore();
});
});
});

View File

@@ -42,6 +42,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
const [error, setError] = useState<string | null>(null);
const [responseHistory, setResponseHistory] = useState<QuestionResponse[]>([]);
const [editedSummary, setEditedSummary] = useState<PlanningSummary | null>(null);
const [hasProgress, setHasProgress] = useState(false);
// Use ref instead of state for hasAutoStarted to handle React StrictMode double-render.
// In StrictMode, components render twice but state persists across renders,
// which would skip auto-start on the second (committed) render. Refs are
@@ -77,6 +78,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
session: { sessionId, currentQuestion: question, summary: null },
});
setStreamingOutput("");
setHasProgress(true);
},
onSummary: (summary) => {
setView({
@@ -86,6 +88,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
});
setEditedSummary(summary);
setStreamingOutput("");
setHasProgress(true);
},
onError: (message) => {
setError(message);
@@ -166,7 +169,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (view.type === "question" || view.type === "summary") {
if (hasProgress) {
if (confirm("Are you sure you want to close? Your planning progress will be lost.")) {
handleCancel();
}
@@ -178,7 +181,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, view]);
}, [isOpen, hasProgress, handleCancel]);
const handleSubmitResponse = useCallback(
async (responses: QuestionResponse) => {
@@ -201,6 +204,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
// Submit response - AI will broadcast events via the already-connected stream
await respondToPlanning(sessionId, responses);
setResponseHistory((prev) => [...prev, responses]);
setHasProgress(true);
// Events (question/summary) will arrive via the existing SSE stream
} catch (err: any) {
setError(err.message || "Failed to submit response");
@@ -211,6 +215,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
);
const handleCancel = useCallback(async () => {
// Show confirmation if user has made progress
if (hasProgress) {
if (!confirm("Are you sure you want to close? Your planning progress will be lost.")) {
return;
}
}
// Always close the stream connection
streamConnectionRef.current?.close();
streamConnectionRef.current = null;
@@ -228,9 +239,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, tasks, initi
setResponseHistory([]);
setEditedSummary(null);
setStreamingOutput("");
setHasProgress(false);
currentSessionIdRef.current = null;
onClose();
}, [view, onClose]);
}, [hasProgress, view, onClose]);
const handleCreateTask = useCallback(async () => {
if (view.type !== "summary") return;