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
This commit is contained in:
7
.changeset/fn-6984-mission-draft-discard.md
Normal file
7
.changeset/fn-6984-mission-draft-discard.md
Normal file
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<HTMLElement> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user