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