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"); + }); + }); });