fix(dashboard): key Chat, Missions, and GitHub Import by project against cross-project leaks

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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 21:59:55 -07:00
parent 0dd34cd306
commit d8b5ef62db
7 changed files with 151 additions and 10 deletions

View File

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

View File

@@ -391,7 +391,17 @@ export function AppModals({
</ModalErrorBoundary>
)}
{/*
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.
*/}
<GitHubImportModal
key={projectId ?? "no-project"}
isOpen={modalManager.githubImportOpen}
onClose={closeGitHubImportWithNav}
onImport={taskHandlers.handleGitHubImport}

View File

@@ -565,12 +565,28 @@ export function MissionInterviewModal({
};
}, [connectToMissionInterviewStream, isOpen, resumeSessionId, view.type, projectId]);
/*
FNXC:ProjectSwitchModalReset 2026-07-23-00:00:
Missions is keyed by project, so a project switch unmounts this modal mid-composition.
Mirror handleClose's draft rule on unmount: persist an un-started goal under THIS
instance's projectId (constant for its lifetime thanks to the key), so the old project's
draft is neither dropped nor written under the new project's kb-mission-last-goal key.
*/
const missionGoalRef = useRef(missionGoal);
missionGoalRef.current = missionGoal;
const viewTypeRef = useRef(view.type);
viewTypeRef.current = view.type;
// Cleanup stream on unmount
useEffect(() => {
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

View File

@@ -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<string | undefined>);
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(<AppModals {...buildProps("proj_a")} />);
expect(subtaskMounts).toEqual(["proj_a"]);
expect(githubImportMounts).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"]);
expect(githubImportMounts).toEqual(["proj_a", "proj_b"]);
});
it("passes the live board task snapshot into the open detail modal while preserving prompt data", async () => {

View File

@@ -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");
});
});

View File

@@ -419,7 +419,18 @@ export function MainContent({
return (
<PageErrorBoundary>
<Suspense fallback={null}>
{/*
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.
*/}
<ChatView
key={currentProject?.id ?? "all-projects"}
addToast={addToast}
projectId={currentProject?.id}
experimentalFeatures={experimentalFeatures}
@@ -482,7 +493,18 @@ export function MainContent({
onOpenWorkflowEditor={openWorkflowEditorWithNav}
onWorkflowSelectionChange={(selection) => 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.
*/}
<MissionManager
key={currentProject?.id ?? "all-projects"}
isInline={true}
isOpen={true}
onClose={() => {

View File

@@ -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<string | undefined>,
chatMounts: [] as Array<string | undefined>,
missionMounts: [] as Array<string | undefined>,
}));
vi.mock("../../PlanningModeModal", () => ({
@@ -30,6 +33,28 @@ vi.mock("../PlanningWorkflowSwitcherSlot", () => ({
PlanningWorkflowSwitcherSlot: () => null,
}));
vi.mock("../../MissionManager", () => ({
MissionManager: ({ projectId }: { projectId?: string }) => {
useEffect(() => {
missionMounts.push(projectId);
}, []);
return <output aria-label="Missions project">{projectId ?? "none"}</output>;
},
}));
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 <output aria-label="Chat project">{projectId ?? "none"}</output>;
}
function mainContentProps(overrides: Partial<MainContentProps> = {}): MainContentProps {
return {
showBackendConnectionErrorPage: false,
@@ -49,6 +74,7 @@ function mainContentProps(overrides: Partial<MainContentProps> = {}): 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(<MainContent {...mainContentProps({ taskView: "chat" })} />);
expect(chatMounts).toEqual(["project-1"]);
rerender(
<MainContent
{...mainContentProps({
taskView: "chat",
currentProject: { id: "project-2", name: "Project 2" } as MainContentProps["currentProject"],
})}
/>,
);
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(<MainContent {...mainContentProps({ taskView: "missions" })} />);
expect(missionMounts).toEqual(["project-1"]);
rerender(
<MainContent
{...mainContentProps({
taskView: "missions",
currentProject: { id: "project-2", name: "Project 2" } as MainContentProps["currentProject"],
})}
/>,
);
expect(screen.getByLabelText("Missions project")).toHaveTextContent("project-2");
expect(missionMounts).toEqual(["project-1", "project-2"]);
});
});