From 336b0cf95d3b3dca07479235ab74946804b76ee1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 21:37:11 -0700 Subject: [PATCH] fix(dashboard): key subtask breakdown by project to stop cross-project draft leaks Same bug class as the Planning project-switch fix: on a project swap the SubtaskBreakdownModal survived as the same instance, so the isOpen=false reset ran with the NEW projectId and persisted the old project's draft description under the new project's storage key (and kept it in memory), making the breakdown reopen in project B with project A's draft. - AppModals keys SubtaskBreakdownModal by project id so a swap remounts it. - Unmount cleanup now saves the in-progress draft under the instance's own project key (constant per keyed instance), so the old project's draft is preserved instead of dropped or misfiled. - Dismissal on swap was already covered by closeProjectScopedModals. Co-Authored-By: Claude Fable 5 --- .changeset/project-switch-modal-reset.md | 4 +- .../dashboard/app/components/AppModals.tsx | 11 +++++ .../app/components/SubtaskBreakdownModal.tsx | 14 ++++++ .../components/__tests__/AppModals.test.tsx | 49 ++++++++++++++++++- .../__tests__/SubtaskBreakdownModal.test.tsx | 26 ++++++++++ 5 files changed, 101 insertions(+), 3 deletions(-) diff --git a/.changeset/project-switch-modal-reset.md b/.changeset/project-switch-modal-reset.md index 4364f3a299..f2b3e89828 100644 --- a/.changeset/project-switch-modal-reset.md +++ b/.changeset/project-switch-modal-reset.md @@ -2,6 +2,6 @@ "@runfusion/fusion": patch --- -summary: Switching projects now dismisses the previous project's modals and resets the Planning view to the new project. +summary: Switching projects now dismisses the old project's modals and resets Planning and subtask breakdown to the new project. category: fix -dev: New `closeProjectScopedModals()` on the modal manager, invoked by project select/view-all/setup-complete; embedded PlanningModeModal is keyed by project id so a running plan's stream, session list, and persisted active session no longer leak across projects. +dev: New `closeProjectScopedModals()` on the modal manager, invoked by project select/view-all/setup-complete; embedded PlanningModeModal and SubtaskBreakdownModal are keyed by project id so running streams, session lists, and per-project persisted drafts/active sessions no longer leak across projects (subtask drafts save on unmount under their own project key). diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 5486a651a4..b3f7cf2ba6 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -402,7 +402,18 @@ export function AppModals({ /> + {/* + FNXC:ProjectSwitchModalReset 2026-07-23-00:00: + Key the subtask breakdown by project so a project swap remounts it, mirroring the + embedded Planning view. Without the remount, the swap flipped isOpen=false and the + NEW projectId in the same render, so resetState persisted the old project's draft + description under the new project's storage key and kept it in memory — reopening + the breakdown in the new project showed the previous project's draft. The old + instance's unmount cleanup closes its stream and saves the draft under its own + project key. + */} { return () => { streamRef.current?.close(); + if (localDescriptionRef.current) { + saveSubtaskDescription(localDescriptionRef.current, projectId); + } }; + // projectId is intentionally omitted: it is constant per keyed instance. }, []); useEffect(() => { diff --git a/packages/dashboard/app/components/__tests__/AppModals.test.tsx b/packages/dashboard/app/components/__tests__/AppModals.test.tsx index fc3609a920..acc4d1d11e 100644 --- a/packages/dashboard/app/components/__tests__/AppModals.test.tsx +++ b/packages/dashboard/app/components/__tests__/AppModals.test.tsx @@ -1,3 +1,4 @@ +import { useEffect as reactUseEffect } from "react"; import type { ReactElement, ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { fireEvent, render as rtlRender, screen, waitFor, type RenderOptions } from "@testing-library/react"; @@ -35,8 +36,20 @@ vi.mock("../PlanningModeModal", () => ({ PlanningModeModal: () => null, })); +/* +FNXC:ProjectSwitchModalReset 2026-07-23-00:00: +The subtask breakdown mock records mounts so the project-keyed remount contract is +testable: a project swap must create a fresh instance, not update the old one (which +persisted the previous project's draft under the new project's storage key). +*/ +const subtaskMounts = vi.hoisted(() => [] as Array); vi.mock("../SubtaskBreakdownModal", () => ({ - SubtaskBreakdownModal: () => null, + SubtaskBreakdownModal: ({ projectId }: { projectId?: string }) => { + reactUseEffect(() => { + subtaskMounts.push(projectId); + }, []); + return null; + }, })); vi.mock("../TerminalModal", () => ({ @@ -218,6 +231,7 @@ describe("AppModals", () => { closeSetupWizard: vi.fn(), openModelOnboarding: vi.fn(), closeModelOnboarding: vi.fn(), + closeProjectScopedModals: vi.fn(), onPlanningTaskCreated: vi.fn(), onPlanningTasksCreated: vi.fn(), onSubtaskTasksCreated: vi.fn(), @@ -262,6 +276,39 @@ describe("AppModals", () => { expect(document.body).toBeDefined(); }); + /* + FNXC:ProjectSwitchModalReset 2026-07-23-00:00: + Switching projects must remount the subtask breakdown (project-keyed), mirroring the + embedded Planning view: a prop update on the surviving instance ran resetState with the + NEW projectId and persisted the old project's draft under the new project's storage key. + */ + it("remounts the subtask breakdown when the active project changes", () => { + const buildProps = (projectId: string) => ({ + projectId, + tasks: [], + projects: [], + currentProject: null, + addToast: vi.fn(), + toasts: mockToasts, + removeToast: vi.fn(), + modalManager: mockModalManager, + projectActions: { handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() }, + taskHandlers: { handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() }, + taskOperations: { moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() }, + deepLink: { handleDetailClose: vi.fn() }, + settings: mockSettings, + }); + + subtaskMounts.length = 0; + const { rerender } = render(); + expect(subtaskMounts).toEqual(["proj_a"]); + + rerender(); + + // A fresh mount for the new project — not a prop update on the old instance. + expect(subtaskMounts).toEqual(["proj_a", "proj_b"]); + }); + it("passes the live board task snapshot into the open detail modal while preserving prompt data", async () => { const manager = { ...mockModalManager, diff --git a/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx b/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx index d0a9d9506c..cb7e68f0a2 100644 --- a/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SubtaskBreakdownModal.test.tsx @@ -877,4 +877,30 @@ describe("SubtaskBreakdownModal", () => { expect(await screen.findByText("Stream error")).toBeInTheDocument(); }); }); + + /* + FNXC:ProjectSwitchModalReset 2026-07-23-00:00: + AppModals keys this modal by project, so a project swap unmounts the old instance. + Unmount must persist the in-progress draft under the OLD project's storage key — the + pre-fix prop-update path ran resetState with the NEW projectId and wrote project A's + draft into project B's persisted description. + */ + describe("project switch unmount", () => { + it("saves the draft under its own project id on unmount", async () => { + const { saveSubtaskDescription } = await import("../../hooks/modalPersistence"); + const { unmount } = render( + , + ); + + unmount(); + + expect(saveSubtaskDescription).toHaveBeenCalledWith("Draft from project A", "proj_a"); + }); + }); });