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:
7
.changeset/project-switch-modal-reset.md
Normal file
7
.changeset/project-switch-modal-reset.md
Normal 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.
|
||||
@@ -985,6 +985,7 @@ function AppInner() {
|
||||
openSetupWizard: modalManager.openSetupWizard,
|
||||
closeSetupWizard: modalManager.closeSetupWizard,
|
||||
closeModelOnboarding: modalManager.closeModelOnboarding,
|
||||
closeProjectScopedModals: modalManager.closeProjectScopedModals,
|
||||
});
|
||||
|
||||
const { handleDetailClose } = useDeepLink({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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: [] }),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user