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:
@@ -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).
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user