From d984fce0ad90927fb9e554cd4c8a91f4ec1b5f79 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 03:12:08 -0700 Subject: [PATCH] FN-6984: fix mission draft discard scoping Fix mission interview draft discard so the Missions view can remove the intended draft without crossing project or tab ownership boundaries. - Send the current session tab id when discarding drafts from MissionManager. - Scope backend discard handling to mission-interview rows in the requested project. - Cover desktop, mobile, stale, locked, project-scoped, and owning-tab discard cases. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-6984-mission-draft-discard.md | 7 ++ .../dashboard/app/components/MissionManager.tsx | 8 +- .../MissionManager.delete-confirm.test.tsx | 137 +++++++++++++++++++++ .../mission-interview-drafts-routes.test.ts | 46 +++++++ packages/dashboard/src/mission-interview.ts | 28 ++++- packages/dashboard/src/mission-routes.ts | 5 +- 6 files changed, 223 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-6984 Fusion-Task-Lineage: dff01077-6e37-4ff9-9e5e-7eb24f0e010a --- .changeset/fn-6984-mission-draft-discard.md | 7 + .../app/components/MissionManager.tsx | 8 +- .../MissionManager.delete-confirm.test.tsx | 137 ++++++++++++++++++ .../mission-interview-drafts-routes.test.ts | 46 ++++++ packages/dashboard/src/mission-interview.ts | 28 +++- packages/dashboard/src/mission-routes.ts | 5 +- 6 files changed, 223 insertions(+), 8 deletions(-) create mode 100644 .changeset/fn-6984-mission-draft-discard.md diff --git a/.changeset/fn-6984-mission-draft-discard.md b/.changeset/fn-6984-mission-draft-discard.md new file mode 100644 index 0000000000..1fcd239536 --- /dev/null +++ b/.changeset/fn-6984-mission-draft-discard.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix discarding mission interview drafts from the Missions view. +category: fix +dev: Preserves project and owning-tab scope for mission interview draft discard requests. diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index f873c3d224..3d01093cb3 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -106,6 +106,7 @@ import { import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types"; import { readCache, SWR_CACHE_KEYS, writeCache } from "../utils/swrCache"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; +import { getSessionTabId } from "../utils/getSessionTabId"; const MISSION_SIDEBAR_DEFAULT_WIDTH = 300; const MISSION_SIDEBAR_MIN_WIDTH = 220; @@ -616,6 +617,7 @@ function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHi export function MissionManager({ isOpen, isInline = false, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId, milestoneSliceResumeSessionId, onMilestoneSliceResumeFetchError, onNavigateToGoal }: MissionManagerProps) { const { t } = useTranslation("app"); const { confirm } = useConfirm(); + const sessionTabId = useMemo(() => getSessionTabId(), []); const isActive = isInline || isOpen; const cacheSuffix = projectId ?? ""; const missionsCacheKey = `${SWR_CACHE_KEYS.MISSIONS_PREFIX}${cacheSuffix}`; @@ -4232,7 +4234,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr const handleDiscardInterviewSession = async (sessionId: string) => { try { - await discardMissionInterviewDraft(sessionId, projectId); + /* + FNXC:MissionDraftDiscard 2026-06-24-02:42: + The mission draft Discard confirmation must send the current browser tab id so a draft locked by this tab can be removed while a draft actively owned by another tab returns the lock warning and stays visible. + */ + await discardMissionInterviewDraft(sessionId, projectId, sessionTabId); setMissionInterviewDrafts((current) => current.filter((session) => session.id !== sessionId)); } catch (err) { if (err instanceof ApiRequestError && err.status === 409) { diff --git a/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx index 580435aced..942b64aa7f 100644 --- a/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionManager.delete-confirm.test.tsx @@ -16,8 +16,10 @@ const mockFetchValidationRuns = vi.fn(); const mockFetchAiSessions = vi.fn(); const mockFetchAiSession = vi.fn(); const mockFetchMissionInterviewDrafts = vi.fn(); +const mockDiscardMissionInterviewDraft = vi.fn(); const mockDeleteMission = vi.fn(); const mockSubscribeSse = vi.fn(() => vi.fn()); +const mockGetSessionTabId = vi.fn(() => "mission-manager-tab"); vi.mock("../../hooks/useViewportMode", () => ({ MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", @@ -38,6 +40,10 @@ vi.mock("../../sse-bus", () => ({ subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args), })); +vi.mock("../../utils/getSessionTabId", () => ({ + getSessionTabId: () => mockGetSessionTabId(), +})); + vi.mock("../MissionInterviewModal", () => ({ MissionInterviewModal: () => null, })); @@ -62,6 +68,7 @@ vi.mock("../../api", async (importOriginal) => { fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args), fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args), fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args), + discardMissionInterviewDraft: (...args: unknown[]) => mockDiscardMissionInterviewDraft(...args), deleteMission: (...args: unknown[]) => mockDeleteMission(...args), fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }), }; @@ -132,6 +139,7 @@ function setupMocks() { mockFetchAiSessions.mockResolvedValue([]); mockFetchAiSession.mockResolvedValue(null); mockFetchMissionInterviewDrafts.mockResolvedValue([]); + mockDiscardMissionInterviewDraft.mockResolvedValue({ removed: true }); mockDeleteMission.mockResolvedValue(undefined); } @@ -144,6 +152,39 @@ function renderMissionManager(addToast = vi.fn()) { return { ...result, addToast }; } +function makeDraft(overrides: Partial<{ + id: string; + title: string; + status: "generating" | "awaiting_input" | "error" | "complete"; + projectId: string | null; + createdAt: string; + updatedAt: string; + hasConversation: boolean; +}> = {}) { + const id = overrides.id ?? "draft-1"; + return { + id, + title: overrides.title ?? id, + status: overrides.status ?? "awaiting_input", + projectId: overrides.projectId ?? projectId, + createdAt: overrides.createdAt ?? "2026-01-03T00:00:00.000Z", + updatedAt: overrides.updatedAt ?? "2026-01-03T00:00:00.000Z", + hasConversation: overrides.hasConversation ?? true, + }; +} + +function getConfirmPanel() { + const panel = document.querySelector(".mission-confirm-panel"); + expect(panel).not.toBeNull(); + return panel as HTMLElement; +} + +function clickConfirmPanelAction(label: "Discard" | "Delete" | "Cancel") { + const button = Array.from(getConfirmPanel().querySelectorAll("button")).find((candidate) => candidate.textContent?.trim() === label); + expect(button).toBeDefined(); + fireEvent.click(button as HTMLButtonElement); +} + async function findMissionListItem(title: string): Promise { const titleNode = await screen.findByText(title); const item = titleNode.closest(".mission-list__item"); @@ -240,4 +281,100 @@ describe("MissionManager mission delete confirmation", () => { expect(screen.getByText("API Redesign")).toBeInTheDocument(); expect(addToast).toHaveBeenCalledWith("delete failed upstream", "error"); }); + + it("discards the selected desktop draft row without removing duplicate-titled drafts or missions", async () => { + mockFetchMissionInterviewDrafts.mockResolvedValue([ + makeDraft({ id: "draft-duplicate-a", title: "Shared draft", status: "awaiting_input" }), + makeDraft({ id: "draft-duplicate-b", title: "Shared draft", status: "complete" }), + makeDraft({ id: "draft-error", title: "Error draft", status: "error" }), + ]); + renderMissionManager(); + + const planReady = await screen.findByText("Plan ready"); + const selectedDraft = planReady.closest(".mission-list__item"); + expect(selectedDraft).not.toBeNull(); + fireEvent.click(within(selectedDraft as HTMLElement).getByRole("button", { name: "Discard draft" })); + + const panel = getConfirmPanel(); + expect(panel).toHaveTextContent("Discard this interview draft?"); + clickConfirmPanelAction("Discard"); + + await waitFor(() => { + expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-duplicate-b", projectId, "mission-manager-tab"); + }); + await waitFor(() => { + expect(document.querySelector(".mission-confirm-panel")).toBeNull(); + }); + expect(screen.getAllByText("Build Auth System").length).toBeGreaterThan(0); + expect(screen.getByText("API Redesign")).toBeInTheDocument(); + expect(screen.getByText("Awaiting input")).toBeInTheDocument(); + expect(screen.queryByText("Plan ready")).not.toBeInTheDocument(); + expect(screen.getByText("Error draft")).toBeInTheDocument(); + }); + + it("discards a mobile draft row from the stacked list", async () => { + mockViewportMode.mockReturnValue("mobile"); + mockFetchMissionInterviewDrafts.mockResolvedValue([ + makeDraft({ id: "draft-mobile", title: "Mobile draft", status: "awaiting_input" }), + ]); + renderMissionManager(); + + const draftTitle = await screen.findByText("Mobile draft"); + const draftRow = draftTitle.closest(".mission-list__item"); + expect(draftRow).not.toBeNull(); + fireEvent.click((draftRow as HTMLElement).querySelector('button[aria-label="Discard draft"]') as HTMLButtonElement); + clickConfirmPanelAction("Discard"); + + await waitFor(() => { + expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-mobile", projectId, "mission-manager-tab"); + }); + expect(screen.queryByText("Mobile draft")).not.toBeInTheDocument(); + expect(document.querySelector(".mission-confirm-panel")).toBeNull(); + }); + + it("removes an already-gone draft after a 404 discard response", async () => { + const { ApiRequestError } = await import("../../api"); + mockFetchMissionInterviewDrafts.mockResolvedValue([ + makeDraft({ id: "draft-stale", title: "Stale draft", status: "awaiting_input" }), + ]); + mockDiscardMissionInterviewDraft.mockRejectedValueOnce(new ApiRequestError("missing", 404)); + const addToast = vi.fn(); + renderMissionManager(addToast); + + const staleDraft = await screen.findByText("Stale draft"); + const draftRow = staleDraft.closest(".mission-list__item"); + expect(draftRow).not.toBeNull(); + fireEvent.click((draftRow as HTMLElement).querySelector('button[aria-label="Discard draft"]') as HTMLButtonElement); + clickConfirmPanelAction("Discard"); + + await waitFor(() => { + expect(screen.queryByText("Stale draft")).not.toBeInTheDocument(); + }); + expect(document.querySelector(".mission-confirm-panel")).toBeNull(); + expect(addToast).not.toHaveBeenCalledWith("Failed to discard draft", "error"); + }); + + it("keeps a locked draft row after a 409 discard response", async () => { + const { ApiRequestError } = await import("../../api"); + mockFetchMissionInterviewDrafts.mockResolvedValue([ + makeDraft({ id: "draft-locked", title: "Locked draft", status: "awaiting_input" }), + ]); + mockDiscardMissionInterviewDraft.mockRejectedValueOnce(new ApiRequestError("locked", 409)); + const addToast = vi.fn(); + renderMissionManager(addToast); + + const lockedDraft = await screen.findByText("Locked draft"); + const draftRow = lockedDraft.closest(".mission-list__item"); + expect(draftRow).not.toBeNull(); + fireEvent.click((draftRow as HTMLElement).querySelector('button[aria-label="Discard draft"]') as HTMLButtonElement); + clickConfirmPanelAction("Discard"); + + await waitFor(() => { + expect(mockDiscardMissionInterviewDraft).toHaveBeenCalledWith("draft-locked", projectId, "mission-manager-tab"); + }); + expect(screen.getByText("Locked draft")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Discard draft" })).toBeInTheDocument(); + expect(document.querySelector(".mission-confirm-panel")).toBeNull(); + expect(addToast).toHaveBeenCalledWith("Draft is open in another tab", "error"); + }); }); diff --git a/packages/dashboard/src/__tests__/mission-interview-drafts-routes.test.ts b/packages/dashboard/src/__tests__/mission-interview-drafts-routes.test.ts index c8f5367a6e..5c2553b3c1 100644 --- a/packages/dashboard/src/__tests__/mission-interview-drafts-routes.test.ts +++ b/packages/dashboard/src/__tests__/mission-interview-drafts-routes.test.ts @@ -165,6 +165,31 @@ describe("mission interview draft routes", () => { expect(aiSessionStore.get("draft-cold")).toBeNull(); }); + it("POST /interview/drafts/:sessionId/discard is scoped by project and leaves other session types intact", async () => { + aiSessionStore.upsert(makeRow({ id: "draft-project-b", title: "Project B", projectId: "project-b", status: "awaiting_input" })); + aiSessionStore.upsert(makeRow({ id: "planning-row", type: "planning", title: "Planning", projectId: "project-a", status: "awaiting_input" })); + + const wrongProject = await request( + app, + "POST", + "/api/missions/interview/drafts/draft-project-b/discard?projectId=project-a", + JSON.stringify({}), + { "content-type": "application/json" }, + ); + const planningRow = await request( + app, + "POST", + "/api/missions/interview/drafts/planning-row/discard?projectId=project-a", + JSON.stringify({}), + { "content-type": "application/json" }, + ); + + expect(wrongProject.status).toBe(404); + expect(planningRow.status).toBe(404); + expect(aiSessionStore.get("draft-project-b")).not.toBeNull(); + expect(aiSessionStore.get("planning-row")).not.toBeNull(); + }); + it("POST /interview/drafts/:sessionId/discard returns 404 when the session does not exist", async () => { const res = await request(app, "POST", "/api/missions/interview/drafts/missing/discard", JSON.stringify({}), { "content-type": "application/json", @@ -174,6 +199,27 @@ describe("mission interview draft routes", () => { expect((res.body as { error: string }).error).toContain("missing"); }); + it("POST /interview/drafts/:sessionId/discard allows the owning tab to discard a locked draft", async () => { + aiSessionStore.upsert(makeRow({ id: "draft-owned", title: "Owned draft", status: "awaiting_input" })); + db.prepare("UPDATE ai_sessions SET lockedByTab = ?, lockedAt = ? WHERE id = ?").run( + "tab-owner", + "2026-05-12T00:00:00.000Z", + "draft-owned", + ); + + const res = await request( + app, + "POST", + "/api/missions/interview/drafts/draft-owned/discard", + JSON.stringify({ tabId: "tab-owner" }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, removed: true }); + expect(aiSessionStore.get("draft-owned")).toBeNull(); + }); + it("POST /interview/drafts/:sessionId/discard returns 409 when locked by another tab", async () => { aiSessionStore.upsert(makeRow({ id: "draft-locked", title: "Locked draft", status: "awaiting_input" })); db.prepare("UPDATE ai_sessions SET lockedByTab = ?, lockedAt = ? WHERE id = ?").run( diff --git a/packages/dashboard/src/mission-interview.ts b/packages/dashboard/src/mission-interview.ts index f2cffd22d6..a617c715c4 100644 --- a/packages/dashboard/src/mission-interview.ts +++ b/packages/dashboard/src/mission-interview.ts @@ -1362,16 +1362,32 @@ export function listMissionInterviewDrafts(projectId?: string): MissionInterview }); } -export async function discardMissionInterviewSession(sessionId: string): Promise<{ removed: boolean }> { - try { +function isMissionInterviewSessionInProjectScope(sessionProjectId: string | null | undefined, projectId?: string): boolean { + if (projectId) { + return sessionProjectId === projectId; + } + return sessionProjectId == null; +} + +export async function discardMissionInterviewSession(sessionId: string, projectId?: string): Promise<{ removed: boolean }> { + const hotSession = sessions.get(sessionId); + if (hotSession) { + if (!isMissionInterviewSessionInProjectScope(hotSession.projectId, projectId)) { + return { removed: false }; + } await cancelMissionInterviewSession(sessionId); return { removed: true }; - } catch (error) { - if (!(error instanceof SessionNotFoundError)) { - throw error; - } } + const persistedSession = _aiSessionStore?.get(sessionId); + if (persistedSession?.type === "mission_interview" && !isMissionInterviewSessionInProjectScope(persistedSession.projectId, projectId)) { + return { removed: false }; + } + + /* + FNXC:MissionDraftDiscard 2026-06-24-02:47: + Draft discard uses the same project scope as draft listing: a project-scoped request can remove only that project's mission interview rows, and an unscoped request can remove only unscoped drafts. Ordinary planning sessions are excluded by the type guard. + */ const removed = _aiSessionStore?.deleteByIdAndType(sessionId, "mission_interview") ?? false; return { removed }; } diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index 10d38d4cfd..2d2a832c38 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -742,6 +742,9 @@ export function createMissionRouter( const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0 ? req.body.tabId.trim() : undefined; + const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim().length > 0 + ? req.query.projectId.trim() + : undefined; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); @@ -757,7 +760,7 @@ export function createMissionRouter( } const { discardMissionInterviewSession } = await import("./mission-interview.js"); - const result = await discardMissionInterviewSession(sessionId); + const result = await discardMissionInterviewSession(sessionId, projectId); if (!result.removed) { throw notFound(`Mission interview session ${sessionId} not found or expired`); }