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:
gsxdsm
2026-04-09 19:35:17 -07:00
parent 7bd98d888c
commit e015fc8a54
5 changed files with 170 additions and 17 deletions

View File

@@ -16,10 +16,14 @@ import { MockEventSource } from "../../../vitest.setup";
vi.mock("../../api", () => ({
fetchAiSessions: vi.fn(),
deleteAiSession: vi.fn(),
cancelPlanning: vi.fn(),
cancelSubtaskBreakdown: vi.fn(),
}));
const mockFetchAiSessions = vi.mocked(apiModule.fetchAiSessions);
const mockDeleteAiSession = vi.mocked(apiModule.deleteAiSession);
const mockCancelPlanning = vi.mocked(apiModule.cancelPlanning);
const mockCancelSubtaskBreakdown = vi.mocked(apiModule.cancelSubtaskBreakdown);
function makeSession(overrides: Partial<apiModule.AiSessionSummary> & Pick<apiModule.AiSessionSummary, "id">): apiModule.AiSessionSummary {
return {
@@ -40,6 +44,8 @@ describe("useBackgroundSessions", () => {
__destroyAiSessionSyncStoreForTests();
mockFetchAiSessions.mockResolvedValue([]);
mockDeleteAiSession.mockResolvedValue(undefined);
mockCancelPlanning.mockResolvedValue(undefined);
mockCancelSubtaskBreakdown.mockResolvedValue(undefined);
});
afterEach(() => {
@@ -152,8 +158,8 @@ describe("useBackgroundSessions", () => {
expect(result.current.sessions.map((session) => session.id)).toEqual(["dismiss-me"]);
});
act(() => {
result.current.dismissSession("dismiss-me");
await act(async () => {
await result.current.dismissSession("dismiss-me");
});
expect(mockDeleteAiSession).toHaveBeenCalledWith("dismiss-me");
@@ -162,6 +168,64 @@ describe("useBackgroundSessions", () => {
});
});
it("dismissSession calls cancelPlanning for planning sessions", async () => {
mockFetchAiSessions.mockResolvedValueOnce([
makeSession({ id: "planning-session", status: "generating", type: "planning" }),
]);
const { result } = renderHook(() => useBackgroundSessions());
await waitFor(() => {
expect(result.current.sessions.map((session) => session.id)).toEqual(["planning-session"]);
});
await act(async () => {
await result.current.dismissSession("planning-session");
});
expect(mockCancelPlanning).toHaveBeenCalledWith("planning-session", undefined, expect.any(String));
expect(mockDeleteAiSession).toHaveBeenCalledWith("planning-session");
});
it("dismissSession calls cancelSubtaskBreakdown for subtask sessions", async () => {
mockFetchAiSessions.mockResolvedValueOnce([
makeSession({ id: "subtask-session", status: "generating", type: "subtask" }),
]);
const { result } = renderHook(() => useBackgroundSessions());
await waitFor(() => {
expect(result.current.sessions.map((session) => session.id)).toEqual(["subtask-session"]);
});
await act(async () => {
await result.current.dismissSession("subtask-session");
});
expect(mockCancelSubtaskBreakdown).toHaveBeenCalledWith("subtask-session", undefined, expect.any(String));
expect(mockDeleteAiSession).toHaveBeenCalledWith("subtask-session");
});
it("dismissSession does not call cancel for mission_interview sessions", async () => {
mockFetchAiSessions.mockResolvedValueOnce([
makeSession({ id: "interview-session", status: "generating", type: "mission_interview" }),
]);
const { result } = renderHook(() => useBackgroundSessions());
await waitFor(() => {
expect(result.current.sessions.map((session) => session.id)).toEqual(["interview-session"]);
});
await act(async () => {
await result.current.dismissSession("interview-session");
});
expect(mockCancelPlanning).not.toHaveBeenCalled();
expect(mockCancelSubtaskBreakdown).not.toHaveBeenCalled();
expect(mockDeleteAiSession).toHaveBeenCalledWith("interview-session");
});
it("returns accurate generating/needsInput counts and planningSessions filter", async () => {
mockFetchAiSessions.mockResolvedValueOnce([
makeSession({ id: "count-generating", status: "generating", type: "planning" }),

View File

@@ -1,6 +1,13 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { fetchAiSessions, deleteAiSession, type AiSessionSummary } from "../api";
import {
fetchAiSessions,
deleteAiSession,
cancelPlanning,
cancelSubtaskBreakdown,
type AiSessionSummary,
} from "../api";
import { useAiSessionSync } from "./useAiSessionSync";
import { getSessionTabId } from "../utils/getSessionTabId";
interface UseBackgroundSessionsResult {
sessions: AiSessionSummary[];
@@ -202,11 +209,57 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
};
}, [broadcastCompleted, broadcastUpdate, projectId]);
const dismissSession = useCallback((id: string) => {
deleteAiSession(id).catch(() => {});
const dismissSession = useCallback(async (id: string) => {
// Find the session to determine its type
const session = sessions.find((s) => s.id === id);
const sessionType = session?.type;
const sessionTabId = getSessionTabId();
// Cancel the session based on its type to ensure proper cleanup
// Pass tabId for lock-aware cancellation - if locked by another tab, the API returns 409
let cancelFailed = false;
let lockConflict = false;
if (sessionType === "planning") {
try {
await cancelPlanning(id, projectId, sessionTabId);
} catch (err: unknown) {
cancelFailed = true;
// Check if this was a lock conflict (409)
if (err instanceof Error && err.message.includes("locked")) {
lockConflict = true;
console.warn(`[useBackgroundSessions] Cannot dismiss planning session ${id}: locked by another tab`);
}
}
} else if (sessionType === "subtask") {
try {
await cancelSubtaskBreakdown(id, projectId, sessionTabId);
} catch (err: unknown) {
cancelFailed = true;
if (err instanceof Error && err.message.includes("locked")) {
lockConflict = true;
console.warn(`[useBackgroundSessions] Cannot dismiss subtask session ${id}: locked by another tab`);
}
}
}
// For other session types (mission_interview, etc.), just delete without cancellation
// Only proceed with deletion if cancellation succeeded or wasn't needed
if (cancelFailed && !lockConflict) {
// Non-lock cancellation failure: still try to delete
console.warn(`[useBackgroundSessions] Cancellation failed for session ${id}, attempting delete anyway`);
}
// Delete the session and update local state
try {
await deleteAiSession(id);
} catch {
// Best-effort: deletion failures should not block local state update
}
setSessions((prev) => prev.filter((s) => s.id !== id));
sessionTimestampsRef.current.delete(id);
}, []);
}, [projectId, sessions]);
const active = useMemo(
() => sessions.filter((session) => shouldIncludeSession(session)),