fix(dashboard): reset project-scoped modals and remount Planning on project switch

Switching projects left the previous project's modals (task detail, group,
subtask, git manager, ...) open over the new project, and the embedded
Planning view kept the old project's running plan, session list, and
persisted active session (the durable-session effect re-fired with the new
projectId while the old session was selected, contaminating the new
project's storage key).

- modalManager.closeProjectScopedModals() closes project-scoped modals and
  clears planning/subtask payloads; settings/schedules/usage/agents/setup
  wizard/model onboarding stay open. Called from handleSelectProject (only
  on an actual project change), handleViewAllProjects, handleSetupComplete.
- Embedded PlanningModeModal is keyed by project id so a project swap
  remounts it: stream closed by unmount cleanup, fresh session list, and
  the new project's own persisted draft/active session restored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 21:29:34 -07:00
parent 3b55502d48
commit 07541f78e2
8 changed files with 292 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Switching projects now dismisses the previous project's modals and resets the Planning view 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.

View File

@@ -985,6 +985,7 @@ function AppInner() {
openSetupWizard: modalManager.openSetupWizard,
closeSetupWizard: modalManager.closeSetupWizard,
closeModelOnboarding: modalManager.closeModelOnboarding,
closeProjectScopedModals: modalManager.closeProjectScopedModals,
});
const { handleDetailClose } = useDeepLink({

View File

@@ -716,7 +716,18 @@ export function MainContent({
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onWorkflowSelectionChange={(selection) => setPlanningHeaderWorkflowId(selection && !selection.isAllWorkflowsSelected ? selection.selectedWorkflow.id : null)}
/>
{/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Key embedded Planning by project so a project swap remounts it. Without the remount a
running plan kept its stream, selected session, and sidebar list from the previous
project, and the "durable active session" effect re-fired with the new projectId while
the old session was still selected — persisting project A's session as project B's
active planning session, so project B kept restoring project A's plan. Unmount cleanup
already closes the stream; the new mount fetches the new project's session list and
restores that project's own persisted draft/active session.
*/}
<PlanningModeModal
key={currentProject?.id ?? "all-projects"}
isOpen={true}
onClose={closePlanningView}
onTaskCreated={handlePlanningTaskCreated}

View File

@@ -0,0 +1,93 @@
import { render, screen } from "@testing-library/react";
import { useEffect } from "react";
import { describe, expect, it, vi } from "vitest";
import { MainContent } from "../MainContent";
import type { MainContentProps } from "../types";
/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Regression coverage: embedded Planning must remount when the active project changes.
Without the project-keyed remount, a running plan kept its stream, selected session, and
sidebar session list from the previous project, and the durable-active-session effect
persisted the old project's session under the new project's storage key — so the new
project kept restoring the previous project's plan.
*/
const { planningMounts } = vi.hoisted(() => ({
planningMounts: [] as Array<string | undefined>,
}));
vi.mock("../../PlanningModeModal", () => ({
PlanningModeModal: ({ projectId }: { projectId?: string }) => {
useEffect(() => {
planningMounts.push(projectId);
}, []);
return <output aria-label="Planning project">{projectId ?? "none"}</output>;
},
}));
vi.mock("../PlanningWorkflowSwitcherSlot", () => ({
PlanningWorkflowSwitcherSlot: () => null,
}));
function mainContentProps(overrides: Partial<MainContentProps> = {}): MainContentProps {
return {
showBackendConnectionErrorPage: false,
projectsError: null,
t: ((key: string, fallback?: string) => fallback ?? key) as MainContentProps["t"],
retryingProjects: false,
handleRetryProjects: vi.fn(),
shellApi: null,
taskView: "planning",
modalManager: {
closePlanning: vi.fn(),
planningInitialPlan: null,
planningResumeSessionId: undefined,
planningWorkflowId: null,
} as unknown as MainContentProps["modalManager"],
handleChangeTaskView: vi.fn(),
refreshAppSettings: vi.fn(async () => undefined),
addToast: vi.fn(),
currentProject: { id: "project-1", name: "Project 1" } as MainContentProps["currentProject"],
viewMode: "project",
tasks: [],
workflowSteps: [],
bgPlanningSessions: [],
openDetailTask: vi.fn(),
popOutTaskDetail: vi.fn(),
settingsLoaded: true,
skillsEnabled: true,
insightsEnabled: true,
researchEnabled: true,
evalsEnabled: true,
memoryEnabled: true,
goalsEnabled: true,
todosEnabled: true,
nodesEnabled: true,
capacityRiskBannerEnabled: false,
capacityRiskDismissed: false,
capacityRiskSignal: { level: "low", reasons: [] } as unknown as MainContentProps["capacityRiskSignal"],
...overrides,
} as unknown as MainContentProps;
}
describe("MainContent planning project remount", () => {
it("remounts embedded Planning when the active project changes", () => {
const { rerender } = render(<MainContent {...mainContentProps()} />);
expect(screen.getByLabelText("Planning project")).toHaveTextContent("project-1");
expect(planningMounts).toEqual(["project-1"]);
rerender(
<MainContent
{...mainContentProps({
currentProject: { id: "project-2", name: "Project 2" } as MainContentProps["currentProject"],
})}
/>,
);
// A fresh mount for the new project — not a prop update on the old instance.
expect(screen.getByLabelText("Planning project")).toHaveTextContent("project-2");
expect(planningMounts).toEqual(["project-1", "project-2"]);
});
});

View File

@@ -77,6 +77,58 @@ describe("useModalManager", () => {
expect(result.current.anyModalOpen).toBe(false);
});
/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Switching the active project must dismiss project-scoped modals and drop pending
planning payloads (so Planning does not reopen the previous project's session),
while cross-project modals like Settings stay open.
*/
it("closeProjectScopedModals dismisses project-scoped modals and clears planning payloads, keeping settings open", () => {
const { result } = renderHook(() =>
useModalManager({ projectId: "proj_1", planningSessions: [{ id: "plan-1" }] }),
);
act(() => {
result.current.openDetailTask(createTaskDetail("FN-1"));
result.current.openGroupModal("group-1");
result.current.openNewTaskWithDescription("draft");
result.current.openSubtaskBreakdown("subtask work");
result.current.openPlanningWithSession("plan-1");
result.current.openGitHubImport();
result.current.openFiles("project", "/README.md");
result.current.openActivityLog();
result.current.openGitManager();
result.current.openWorkflowEditor();
result.current.openScripts();
result.current.toggleTerminal();
result.current.openSettings("general");
});
act(() => {
result.current.closeProjectScopedModals();
});
expect(result.current.detailTask).toBeNull();
expect(result.current.groupModalGroupId).toBeNull();
expect(result.current.newTaskModalOpen).toBe(false);
expect(result.current.newTaskInitialDescription).toBeNull();
expect(result.current.isSubtaskOpen).toBe(false);
expect(result.current.subtaskInitialDescription).toBeNull();
expect(result.current.isPlanningOpen).toBe(false);
expect(result.current.planningResumeSessionId).toBeUndefined();
expect(result.current.planningInitialPlan).toBeNull();
expect(result.current.githubImportOpen).toBe(false);
expect(result.current.filesOpen).toBe(false);
expect(result.current.fileBrowserInitialFile).toBeNull();
expect(result.current.activityLogOpen).toBe(false);
expect(result.current.gitManagerOpen).toBe(false);
expect(result.current.workflowEditorOpen).toBe(false);
expect(result.current.scriptsOpen).toBe(false);
expect(result.current.terminalOpen).toBe(false);
// Cross-project surfaces survive the swap.
expect(result.current.settingsOpen).toBe(true);
});
it("opens the new task modal with a seeded description and resets it on close", () => {
const { result } = renderHook(() =>
useModalManager({ projectId: "proj_1", planningSessions: [] }),

View File

@@ -45,6 +45,7 @@ function createOptions(overrides: Partial<Parameters<typeof useProjectActions>[0
openSetupWizard: vi.fn(),
closeSetupWizard: vi.fn(),
closeModelOnboarding: vi.fn(),
closeProjectScopedModals: vi.fn(),
...overrides,
};
}
@@ -102,6 +103,58 @@ describe("useProjectActions", () => {
expect(window.location.search).not.toContain("Duplicate");
});
/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Project swap must dismiss the previous project's modals (task detail, planning payloads,
git manager, …) via closeProjectScopedModals, but re-selecting the current project is a
no-op navigation and must not close anything the user has open.
*/
it("handleSelectProject dismisses project-scoped modals when switching to a different project", () => {
const otherProject: ProjectInfo = { ...PROJECT, id: "proj_other", name: "Other" };
const options = createOptions();
const { result } = renderHook(() => useProjectActions(options));
act(() => {
result.current.handleSelectProject(otherProject);
});
expect(options.closeProjectScopedModals).toHaveBeenCalledTimes(1);
});
it("handleSelectProject leaves modals alone when re-selecting the current project", () => {
const options = createOptions();
const { result } = renderHook(() => useProjectActions(options));
act(() => {
result.current.handleSelectProject(PROJECT);
});
expect(options.closeProjectScopedModals).not.toHaveBeenCalled();
});
it("handleViewAllProjects dismisses project-scoped modals", () => {
const options = createOptions();
const { result } = renderHook(() => useProjectActions(options));
act(() => {
result.current.handleViewAllProjects();
});
expect(options.closeProjectScopedModals).toHaveBeenCalledTimes(1);
});
it("handleSetupComplete dismisses project-scoped modals when landing on a different project", () => {
const newProject: ProjectInfo = { ...PROJECT, id: "proj_new", name: "New" };
const options = createOptions();
const { result } = renderHook(() => useProjectActions(options));
act(() => {
result.current.handleSetupComplete(newProject);
});
expect(options.closeProjectScopedModals).toHaveBeenCalledTimes(1);
});
it("handleViewAllProjects clears current project, sets overview, and removes only URL project state", () => {
window.history.replaceState({ preserved: "state" }, "", "/?project=proj_123&task=FN-1&room=room-1#thread");
const options = createOptions();

View File

@@ -161,6 +161,16 @@ export interface ModalManager {
openModelOnboarding: () => void;
closeModelOnboarding: () => void;
/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Switching the active project must dismiss modals that show the previous project's data
(task detail, group, new task, subtask breakdown, GitHub import, files, git manager,
activity log, workflow editor, scripts, terminal) and drop pending planning payloads so
Planning does not reopen the old project's plan. Cross-project modals (settings,
schedules, usage, agents, setup wizard, model onboarding) stay open.
*/
closeProjectScopedModals: () => void;
onPlanningTaskCreated: (task: Task, addToast: (message: string, type?: ToastType) => void) => void;
onPlanningTasksCreated: (tasks: Task[], addToast: (message: string, type?: ToastType) => void) => void;
onSubtaskTasksCreated: (tasks: Task[], addToast: (message: string, type?: ToastType) => void) => void;
@@ -462,6 +472,46 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const openModelOnboarding = useCallback(() => setModelOnboardingOpen(true), []);
const closeModelOnboarding = useCallback(() => setModelOnboardingOpen(false), []);
/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Project swap left the previous project's modals open (a task-detail modal for project A
kept rendering over project B's board) and kept planning resume/initial-plan payloads,
so the docked Planning view re-entered project A's session. Close every project-scoped
modal and clear their payloads in one transition; deliberately leave settings,
schedules, usage, agents, setup wizard, and model onboarding alone — they are not
project-scoped surfaces.
*/
const closeProjectScopedModals = useCallback(() => {
setDetailTask(null);
setDetailTaskInitialTab(undefined);
setDetailTaskInitialAction(null);
setDetailTaskOrigin(null);
setGroupModalGroupId(null);
setNewTaskModalOpen(false);
setNewTaskInitialDescription(null);
setNewTaskInitialWorkflowId(undefined);
setIsSubtaskOpen(false);
setSubtaskInitialDescription(null);
setSubtaskResumeSessionId(undefined);
setSubtaskWorkflowId(undefined);
setIsPlanningOpen(false);
setPlanningInitialPlan(null);
setPlanningResumeSessionId(undefined);
setPlanningWorkflowId(undefined);
setGitHubImportOpen(false);
setFilesOpen(false);
setFileBrowserInitialFile(null);
setActivityLogOpen(false);
setGitManagerOpen(false);
setWorkflowEditorOpen(false);
setWorkflowEditorInitialPanel(undefined);
setWorkflowEditorInitialAction(undefined);
setWorkflowEditorInitialWorkflowId(undefined);
setScriptsOpen(false);
setTerminalOpen(false);
setTerminalInitialCommand(undefined);
}, []);
const clearQuickAddPlanningDrafts = useCallback(() => {
/*
FNXC:QuickAddPlanningPreserve 2026-06-22-00:00:
@@ -579,6 +629,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
closeSetupWizard,
openModelOnboarding,
closeModelOnboarding,
closeProjectScopedModals,
onPlanningTaskCreated,
onPlanningTasksCreated,
onSubtaskTasksCreated,

View File

@@ -20,6 +20,13 @@ interface UseProjectActionsOptions {
openSetupWizard: () => void;
closeSetupWizard: () => void;
closeModelOnboarding: () => void;
/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Every project-switch entry point (select, view-all, setup-complete) must dismiss
modals scoped to the previous project so its task detail / planning payloads do not
render over the newly selected project.
*/
closeProjectScopedModals: () => void;
}
export interface UseProjectActionsResult {
@@ -52,20 +59,31 @@ export function useProjectActions(options: UseProjectActionsOptions): UseProject
openSetupWizard,
closeSetupWizard,
closeModelOnboarding,
closeProjectScopedModals,
} = options;
const handleSelectProject = useCallback((project: ProjectInfo) => {
/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Swapping projects must not leave the previous project's modals (task detail, group,
subtask, git manager, …) rendering over the new project. Re-selecting the already
current project is a no-op navigation and must NOT dismiss whatever the user has open.
*/
if (project.id !== currentProject?.id) {
closeProjectScopedModals();
}
replaceProjectIdInUrl(project.id);
setCurrentProject(project);
setViewMode("project");
}, [setCurrentProject, setViewMode]);
}, [closeProjectScopedModals, currentProject?.id, setCurrentProject, setViewMode]);
const handleViewAllProjects = useCallback(() => {
closeProjectScopedModals();
replaceProjectIdInUrl(null);
clearCurrentProject();
setViewMode("overview");
setTaskView("command-center");
}, [clearCurrentProject, setViewMode, setTaskView]);
}, [clearCurrentProject, closeProjectScopedModals, setViewMode, setTaskView]);
const handleOpenSettings = useCallback(() => {
openSettings();
@@ -77,12 +95,15 @@ export function useProjectActions(options: UseProjectActionsOptions): UseProject
const handleSetupComplete = useCallback((project: ProjectInfo) => {
closeSetupWizard();
if (project.id !== currentProject?.id) {
closeProjectScopedModals();
}
replaceProjectIdInUrl(project.id);
setCurrentProject(project);
setViewMode("project");
addToast(t("projects.setup.success", "Project {{name}} registered successfully", { name: project.name }), "success");
void refreshProjects();
}, [closeSetupWizard, setCurrentProject, setViewMode, addToast, refreshProjects, t]);
}, [closeSetupWizard, closeProjectScopedModals, currentProject?.id, setCurrentProject, setViewMode, addToast, refreshProjects, t]);
const handleModelOnboardingComplete = useCallback(() => {
closeModelOnboarding();