From d8b5ef62db168a9c588ddb11cde331c81a37636f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 21:59:55 -0700 Subject: [PATCH] fix(dashboard): key Chat, Missions, and GitHub Import by project against cross-project leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit follow-up to the Planning/subtask project-switch fixes — three more surfaces had the same bug class: - Embedded ChatView survived a project swap when both projects last used the chat view: useChat refetched the session list on projectId change but never reset activeSession/messages or closed the live stream, so project A's conversation kept rendering (and streaming) under project B. Now keyed by project like Quick Chat's FN-8257 FloatingWindow key. - MissionManager's nested always-mounted MissionInterviewModal (and the interviewTarget-driven MilestoneSliceInterviewModal) reconnected the previous project's interview session under the new projectId and saved the goal draft under the new project's kb-mission-last-goal key. MissionManager is now keyed by project; the interview modal saves an un-started goal draft on unmount under its own project id. - Always-mounted GitHubImportModal's persist effect depends on projectId, so a swap re-fired it with the old project's provider/labels/repo selections and wrote them under the new project's storage key. Now keyed by project (the embedded Import Tasks view already unmounted). Verified SAFE without changes: TaskDetail/Group/Files/WorkflowNodeEditor (conditionally rendered + closed on swap), Settings/Usage/Schedules/ Agents (cross-project by design or self-correcting projectId-keyed hydration), and the remaining embedded views (projectId-keyed fetch hooks, no streams or scoped drafts). Co-Authored-By: Claude Fable 5 --- .changeset/project-switch-modal-reset.md | 4 +- .../dashboard/app/components/AppModals.tsx | 10 +++ .../app/components/MissionInterviewModal.tsx | 16 +++++ .../components/__tests__/AppModals.test.tsx | 19 ++++- .../__tests__/MissionInterviewModal.test.tsx | 18 +++++ .../app/components/dashboard/MainContent.tsx | 22 ++++++ ...nContent.planning-project-remount.test.tsx | 72 +++++++++++++++++-- 7 files changed, 151 insertions(+), 10 deletions(-) diff --git a/.changeset/project-switch-modal-reset.md b/.changeset/project-switch-modal-reset.md index f2b3e89828..b9fcd8c3f4 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 old project's modals and resets Planning and subtask breakdown to the new project. +summary: Switching projects now fully resets Planning, Chat, Missions, subtask breakdown, GitHub import, and open modals. category: fix -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). +dev: New `closeProjectScopedModals()` on the modal manager, invoked by project select/view-all/setup-complete; PlanningModeModal, ChatView, MissionManager, SubtaskBreakdownModal, and GitHubImportModal are keyed by project id so running streams, session lists, and per-project persisted drafts/active sessions no longer leak or mis-file across projects (subtask/mission 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 b3f7cf2ba6..95fa54fbf1 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -391,7 +391,17 @@ export function AppModals({ )} + {/* + FNXC:ProjectSwitchModalReset 2026-07-23-00:00: + Key the always-mounted GitHub Import modal by project. Its persist effect depends on + projectId, so a project swap (close + new projectId in one render) re-fired it with the + OLD project's provider/labels/repo selections and wrote them under the NEW project's + kb-dashboard-github-import-state key. Keying remounts instead; unmount writes nothing, + so each project's last persisted import state stays under its own key. The embedded + Import Tasks view already unmounts on navigation and was never affected. + */} { return () => { streamConnectionRef.current?.close(); streamConnectionRef.current = null; + if (missionGoalRef.current && viewTypeRef.current === "initial") { + saveMissionGoal(missionGoalRef.current, projectId); + } }; + // projectId is intentionally omitted: it is constant per keyed instance. }, []); // Unload protection diff --git a/packages/dashboard/app/components/__tests__/AppModals.test.tsx b/packages/dashboard/app/components/__tests__/AppModals.test.tsx index acc4d1d11e..c9573aa5b2 100644 --- a/packages/dashboard/app/components/__tests__/AppModals.test.tsx +++ b/packages/dashboard/app/components/__tests__/AppModals.test.tsx @@ -28,8 +28,20 @@ vi.mock("../SettingsModal", () => ({ }, })); +/* +FNXC:ProjectSwitchModalReset 2026-07-23-00:00: +GitHub Import is always-mounted and its persist effect depends on projectId, so a project +swap used to re-fire it with the old project's selections under the new project's storage +key. The mock records mounts so the project-keyed remount contract is testable. +*/ +const githubImportMounts = vi.hoisted(() => [] as Array); vi.mock("../GitHubImportModal", () => ({ - GitHubImportModal: () => null, + GitHubImportModal: ({ projectId }: { projectId?: string }) => { + reactUseEffect(() => { + githubImportMounts.push(projectId); + }, []); + return null; + }, })); vi.mock("../PlanningModeModal", () => ({ @@ -282,7 +294,7 @@ describe("AppModals", () => { 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", () => { + it("remounts the project-keyed modals (subtask breakdown, GitHub import) when the active project changes", () => { const buildProps = (projectId: string) => ({ projectId, tasks: [], @@ -300,13 +312,16 @@ describe("AppModals", () => { }); subtaskMounts.length = 0; + githubImportMounts.length = 0; const { rerender } = render(); expect(subtaskMounts).toEqual(["proj_a"]); + expect(githubImportMounts).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"]); + expect(githubImportMounts).toEqual(["proj_a", "proj_b"]); }); it("passes the live board task snapshot into the open detail modal while preserving prompt data", async () => { diff --git a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx index fdfc98f758..80856412c0 100644 --- a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx @@ -999,4 +999,22 @@ describe("MissionInterviewModal", () => { }); expect(textarea).toHaveValue("New mission idea"); }); + + /* + FNXC:ProjectSwitchModalReset 2026-07-23-00:00: + Missions is keyed by project, so a project switch unmounts this modal mid-composition. + The unmount must persist an un-started goal under the instance's OWN project id — before + the keyed remount, the surviving instance saved it under the NEW project's + kb-mission-last-goal key on close. + */ + it("saves an un-started goal draft under its own project id on unmount", () => { + const { unmount } = renderModal({ projectId: "proj_a" }); + + const textarea = screen.getByLabelText("What do you want to build?"); + fireEvent.change(textarea, { target: { value: "Goal from project A" } }); + + unmount(); + + expect(mockSaveMissionGoal).toHaveBeenCalledWith("Goal from project A", "proj_a"); + }); }); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 2386d964d8..0964f26a28 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -419,7 +419,18 @@ export function MainContent({ return ( + {/* + FNXC:ProjectSwitchModalReset 2026-07-23-00:00: + Key embedded Chat by project, mirroring Quick Chat's FN-8257 FloatingWindow key and + the embedded Planning view. taskView persists per project, so when both projects last + used Chat the component survived a swap: useChat refetched the session LIST on + projectId change but never reset activeSession/messages or closed the live stream, so + project A's open conversation kept rendering (and streaming) under project B. The + remount closes the stream via unmount cleanup and restores project B's own persisted + active session. + */} setMissionWorkflowId(selection && !selection.isAllWorkflowsSelected ? selection.selectedWorkflow.id : null)} /> + {/* + FNXC:ProjectSwitchModalReset 2026-07-23-00:00: + Key Missions by project. MissionManager refetches its list on projectId change, but its + nested always-mounted MissionInterviewModal (and the interviewTarget-driven + MilestoneSliceInterviewModal) did not reset: their resume/connect effects re-fired on + the new projectId and reconnected the PREVIOUS project's interview session under the + new project, and close persisted the goal draft under the new project's + kb-mission-last-goal key. The remount unmounts both modals (stream cleanup runs) and + clears showInterviewModal/interviewTarget with fresh state. + */} { 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 147eafcf12..add8e1d5dc 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 @@ -6,15 +6,18 @@ 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. +Regression coverage: embedded project-scoped views must remount when the active project +changes. Without the project-keyed remount, Planning kept a running plan's stream/selected +session/sidebar list from the previous project (and persisted its session under the new +project's storage key), Chat kept the previous project's active conversation and live +stream rendering under the new project, and Missions kept its nested interview modals +connected to the previous project's sessions. */ -const { planningMounts } = vi.hoisted(() => ({ +const { planningMounts, chatMounts, missionMounts } = vi.hoisted(() => ({ planningMounts: [] as Array, + chatMounts: [] as Array, + missionMounts: [] as Array, })); vi.mock("../../PlanningModeModal", () => ({ @@ -30,6 +33,28 @@ vi.mock("../PlanningWorkflowSwitcherSlot", () => ({ PlanningWorkflowSwitcherSlot: () => null, })); +vi.mock("../../MissionManager", () => ({ + MissionManager: ({ projectId }: { projectId?: string }) => { + useEffect(() => { + missionMounts.push(projectId); + }, []); + return {projectId ?? "none"}; + }, +})); + +vi.mock("../../HeaderWorkflowSwitcherSlot", () => ({ + HeaderWorkflowSwitcherSlot: () => null, +})); + +// ChatView is a lazy chunk threaded in via props (see MainContent header comment), +// so the mock is passed through mainContentProps instead of vi.mock. +function MockChatView({ projectId }: { projectId?: string }) { + useEffect(() => { + chatMounts.push(projectId); + }, []); + return {projectId ?? "none"}; +} + function mainContentProps(overrides: Partial = {}): MainContentProps { return { showBackendConnectionErrorPage: false, @@ -49,6 +74,7 @@ function mainContentProps(overrides: Partial = {}): MainConten refreshAppSettings: vi.fn(async () => undefined), addToast: vi.fn(), currentProject: { id: "project-1", name: "Project 1" } as MainContentProps["currentProject"], + ChatView: MockChatView, viewMode: "project", tasks: [], workflowSteps: [], @@ -90,4 +116,38 @@ describe("MainContent planning project remount", () => { expect(screen.getByLabelText("Planning project")).toHaveTextContent("project-2"); expect(planningMounts).toEqual(["project-1", "project-2"]); }); + + it("remounts embedded Chat when the active project changes", () => { + const { rerender } = render(); + expect(chatMounts).toEqual(["project-1"]); + + rerender( + , + ); + + expect(screen.getByLabelText("Chat project")).toHaveTextContent("project-2"); + expect(chatMounts).toEqual(["project-1", "project-2"]); + }); + + it("remounts Missions when the active project changes", () => { + const { rerender } = render(); + expect(missionMounts).toEqual(["project-1"]); + + rerender( + , + ); + + expect(screen.getByLabelText("Missions project")).toHaveTextContent("project-2"); + expect(missionMounts).toEqual(["project-1", "project-2"]); + }); });