feat(FN-1456): add explicit abandon flow for planning and subtask AI sessions
- Add abandon action to useBackgroundSessions hook for deterministic session cleanup - Make background dismissal deterministic instead of relying on 7-day TTL expiration - Update PlanningModeModal to include abandon button in header actions - Add comprehensive tests for abandon behavior and session lifecycle - Update dashboard guide with session lifecycle documentation
This commit is contained in:
@@ -207,6 +207,7 @@ describe("PlanningModeModal", () => {
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
||||
mockCancelPlanning.mockResolvedValue(undefined);
|
||||
|
||||
// Default: simulate receiving a question after a brief delay
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
@@ -1530,7 +1531,7 @@ describe("PlanningModeModal", () => {
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes active question session without canceling server session", async () => {
|
||||
it("closes active question session and abandons server session", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm");
|
||||
|
||||
render(
|
||||
@@ -1554,11 +1555,12 @@ describe("PlanningModeModal", () => {
|
||||
fireEvent.click(screen.getByLabelText("Close"));
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(mockCancelPlanning).not.toHaveBeenCalled();
|
||||
// Closing an active session should abandon it on the server
|
||||
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes summary view without canceling server session", async () => {
|
||||
it("closes summary view and abandons server session", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm");
|
||||
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
@@ -1593,11 +1595,12 @@ describe("PlanningModeModal", () => {
|
||||
fireEvent.click(screen.getByLabelText("Close"));
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(mockCancelPlanning).not.toHaveBeenCalled();
|
||||
// Closing an active session should abandon it on the server
|
||||
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes via overlay without canceling server session", async () => {
|
||||
it("closes via overlay and abandons server session", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm");
|
||||
|
||||
const { container } = render(
|
||||
@@ -1623,11 +1626,12 @@ describe("PlanningModeModal", () => {
|
||||
fireEvent.click(overlay!);
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(mockCancelPlanning).not.toHaveBeenCalled();
|
||||
// Closing an active session should abandon it on the server
|
||||
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes during loading state without canceling server session", async () => {
|
||||
it("closes during loading state and abandons server session", async () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm");
|
||||
|
||||
mockConnectPlanningStream.mockImplementationOnce(() => ({
|
||||
@@ -1656,7 +1660,8 @@ describe("PlanningModeModal", () => {
|
||||
fireEvent.click(screen.getByLabelText("Close"));
|
||||
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
expect(mockCancelPlanning).not.toHaveBeenCalled();
|
||||
// Closing an active session should abandon it on the server
|
||||
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1693,7 +1698,7 @@ describe("PlanningModeModal", () => {
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disconnects SSE stream on close", async () => {
|
||||
it("disconnects SSE stream and abandons session on close", async () => {
|
||||
const closeSpy = vi.fn();
|
||||
|
||||
mockConnectPlanningStream.mockImplementationOnce(() => ({
|
||||
@@ -1722,7 +1727,8 @@ describe("PlanningModeModal", () => {
|
||||
fireEvent.click(screen.getByLabelText("Close"));
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(mockCancelPlanning).not.toHaveBeenCalled();
|
||||
// Closing an active session should abandon it on the server
|
||||
expect(mockCancelPlanning).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
startPlanningBreakdown,
|
||||
createTasksFromPlanning,
|
||||
fetchModels,
|
||||
cancelPlanning,
|
||||
type PlanningSession,
|
||||
type SubtaskItem,
|
||||
type ModelInfo,
|
||||
@@ -467,6 +468,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}, [onClose]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
// Determine the active session ID to abandon
|
||||
let activeSessionId: string | null = null;
|
||||
if (view.type === "question" || view.type === "summary" || view.type === "error") {
|
||||
activeSessionId = view.session.sessionId;
|
||||
} else if (view.type === "breakdown") {
|
||||
activeSessionId = view.sessionId;
|
||||
} else if (view.type === "loading") {
|
||||
// During loading, the session ID is stored in the ref
|
||||
activeSessionId = currentSessionIdRef.current;
|
||||
}
|
||||
|
||||
// Save to localStorage BEFORE any cleanup (preserve for re-entry)
|
||||
if (initialPlan) {
|
||||
savePlanningDescription(initialPlan, projectId);
|
||||
@@ -476,6 +488,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
streamConnectionRef.current?.close();
|
||||
streamConnectionRef.current = null;
|
||||
|
||||
// Explicitly abandon the session on the server to prevent zombie sessions
|
||||
if (activeSessionId) {
|
||||
void cancelPlanning(activeSessionId, projectId, sessionTabId).catch(() => {
|
||||
// Best-effort: cancellation failures should not block UI reset
|
||||
});
|
||||
}
|
||||
|
||||
setInitialPlan("");
|
||||
setView({ type: "initial" });
|
||||
setError(null);
|
||||
@@ -490,7 +509,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
currentSessionIdRef.current = null;
|
||||
setLockSessionId(null);
|
||||
onClose();
|
||||
}, [initialPlan, onClose, projectId]);
|
||||
}, [initialPlan, onClose, projectId, sessionTabId, view]);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user