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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 21:37:11 -07:00
parent 86f56b5cdc
commit 336b0cf95d
5 changed files with 101 additions and 3 deletions

View File

@@ -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).

View File

@@ -402,7 +402,18 @@ export function AppModals({
/>
<ModalErrorBoundary>
{/*
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.
*/}
<SubtaskBreakdownModal
key={projectId ?? "no-project"}
isOpen={modalManager.isSubtaskOpen}
onClose={closeSubtaskWithNav}
initialDescription={modalManager.subtaskInitialDescription ?? ""}

View File

@@ -359,10 +359,24 @@ export function SubtaskBreakdownModal({ isOpen, onClose, initialDescription, onT
})();
}, [connectToSubtaskStream, isOpen, resumeSessionId, view.type, projectId]);
/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
This modal only unmounts on a project switch (AppModals keys it by project) or app
teardown — a normal close just renders null and resetState persists the draft. Save the
draft on unmount too, under THIS instance's projectId (constant for its lifetime thanks
to the key), so switching projects mid-draft neither drops the old project's draft nor
writes it under the new project's storage key.
*/
const localDescriptionRef = useRef(localDescription);
localDescriptionRef.current = localDescription;
useEffect(() => {
return () => {
streamRef.current?.close();
if (localDescriptionRef.current) {
saveSubtaskDescription(localDescriptionRef.current, projectId);
}
};
// projectId is intentionally omitted: it is constant per keyed instance.
}, []);
useEffect(() => {

View File

@@ -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<string | undefined>);
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(<AppModals {...buildProps("proj_a")} />);
expect(subtaskMounts).toEqual(["proj_a"]);
rerender(<AppModals {...buildProps("proj_b")} />);
// 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,

View File

@@ -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(
<SubtaskBreakdownModal
isOpen={true}
onClose={onClose}
initialDescription="Draft from project A"
onTasksCreated={onTasksCreated}
projectId="proj_a"
/>,
);
unmount();
expect(saveSubtaskDescription).toHaveBeenCalledWith("Draft from project A", "proj_a");
});
});
});