diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx
index 1cf22cab30..9d6da80474 100644
--- a/packages/dashboard/app/components/__tests__/App.test.tsx
+++ b/packages/dashboard/app/components/__tests__/App.test.tsx
@@ -3235,6 +3235,64 @@ describe("App Planning Mode", () => {
expect(screen.getByTestId("planning-view")).toBeTruthy();
});
+ /*
+ FNXC:ProjectSwitchModalReset 2026-07-30-23:45:
+ A PROJECT SWITCH MUST NOT LEAVE THE PREVIOUS PROJECT'S PLANNING SUBTREE MOUNTED.
+
+ Relocated from `MainContent.planning-project-remount.test.tsx`. FN-8619 moved Planning out of
+ MainContent into `PlanningKeepAlive`, mounted by App — so the old file could only fail, and the
+ invariant it guarded had no assertion anywhere. The product was already correct; only the coverage
+ was left behind.
+
+ What is at stake is a cross-project leak, not layout: before the project-keyed host, Planning kept a
+ running plan's stream, selected session and sidebar list from the PREVIOUS project, and persisted
+ its session under the NEW project's storage key.
+
+ WHY THE ASSERTION IS "gone OR a different node", and why single-guard mutations do NOT break it.
+ App defends this twice, independently:
+
+ 1. the `planningEverOpenedProjectId === currentProject.id` gate (App.tsx), which unmounts the
+ host for a project that never opened Planning; and
+ 2. the project id inside the host's `key`, which forces a remount rather than reconciling the
+ live instance under the new project.
+
+ Either alone upholds the invariant, so breaking one leaves this green — correctly. MEASURED:
+ breaking BOTH fails it. An earlier draft of mine asserted the subtree must be ABSENT, which is
+ wrong: `planningViewActive` stays true across the switch, so the latch re-arms and a fresh host is
+ expected. Both outcomes satisfy "project A's instance is not reused", which is the actual contract.
+ */
+ it("never leaves the previous project's Planning subtree mounted after a switch", async () => {
+ localStorage.setItem("kb-dashboard-view-mode", "project");
+ const projectA = { ...DEFAULT_PROJECT, id: "proj_switch_a", name: "Project A" };
+ const projectB = { ...DEFAULT_PROJECT, id: "proj_switch_b", name: "Project B" };
+ mockCurrentProjectState.currentProject = projectA;
+ vi.mocked(fetchSettings).mockResolvedValueOnce({
+ ...defaultSettings,
+ experimentalFeatures: { ...defaultSettings.experimentalFeatures, leftSidebarNav: true },
+ });
+
+ const { rerender } = render();
+
+ await screen.findByTestId("sidebar-nav-planning");
+ fireEvent.click(screen.getByTestId("sidebar-nav-planning"));
+ await waitFor(() => {
+ expect(screen.getByTestId("planning-keep-alive")).toBeTruthy();
+ });
+
+ /* Control: capture A's live subtree, so "not this node" below is a real statement. */
+ const subtreeForA = screen.getByTestId("planning-keep-alive");
+
+ mockCurrentProjectState.currentProject = projectB;
+ rerender();
+
+ await waitFor(() => {
+ const current = screen.queryByTestId("planning-keep-alive");
+ expect(current === null || current !== subtreeForA).toBe(true);
+ });
+ /* The load-bearing half: A's instance is off the page either way. */
+ expect(subtreeForA.isConnected).toBe(false);
+ });
+
it("renders planning embedded view with correct initial state", async () => {
localStorage.setItem(taskViewStorageKey(), "planning");
diff --git a/packages/dashboard/app/components/dashboard/__tests__/MainContent.planning-project-remount.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/MainContent.planning-project-remount.test.tsx
index add8e1d5dc..fb45cc6443 100644
--- a/packages/dashboard/app/components/dashboard/__tests__/MainContent.planning-project-remount.test.tsx
+++ b/packages/dashboard/app/components/dashboard/__tests__/MainContent.planning-project-remount.test.tsx
@@ -98,25 +98,19 @@ function mainContentProps(overrides: Partial = {}): MainConten
}
describe("MainContent planning project remount", () => {
- it("remounts embedded Planning when the active project changes", () => {
- const { rerender } = render();
+ /*
+ FNXC:ProjectSwitchModalReset 2026-07-30-23:40:
+ THE PLANNING CASE MOVED TO App.test.tsx — it is not gone, and must not be re-added here.
- expect(screen.getByLabelText("Planning project")).toHaveTextContent("project-1");
- expect(planningMounts).toEqual(["project-1"]);
-
- rerender(
- ,
- );
-
- // 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"]);
- });
+ It rendered MainContent and counted Planning mounts per project. FN-8619 moved Planning out of
+ MainContent entirely (its branch returns `null`; see MainContent.tsx) into `PlanningKeepAlive`,
+ which App mounts as a sibling. Asserting it here could only fail, and it did — this was the last
+ red test in the dashboard lane.
+ App owns BOTH halves of the guarantee, which is why the replacement lives there: the
+ `planningEverOpenedProjectId === currentProject.id` gate and the project id inside the host's
+ `key`. Chat and Missions still render from MainContent, so their cases stay.
+ */
it("remounts embedded Chat when the active project changes", () => {
const { rerender } = render();
expect(chatMounts).toEqual(["project-1"]);