diff --git a/.changeset/planning-initial-plan-one-shot.md b/.changeset/planning-initial-plan-one-shot.md new file mode 100644 index 0000000000..780b256400 --- /dev/null +++ b/.changeset/planning-initial-plan-one-shot.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix duplicate planning sessions created when navigating away from and back to Planning. +category: fix +dev: The seeded `planningInitialPlan` handoff is now one-shot — `PlanningModeModal` consumes it via `onInitialPlanConsumed` when auto-start fires, so remounts restore the persisted active session instead of auto-starting again. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index ec729eda27..1c78dbab28 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -141,6 +141,16 @@ interface PlanningModeModalProps { onViewTask?: (task: Task) => void; tasks: Task[]; initialPlan?: string; + /** + FNXC:PlanningMode 2026-07-23-00:00: + Called exactly once when the auto-start effect actually consumes `initialPlan`, so the owner + (useModalManager) can clear the payload. The seeded plan is a one-shot handoff: embedded + Planning fully unmounts on main-content navigation, which resets the in-component + hasAutoStartedRef guard — without owner-side clearing, every navigate-back remount (and the + project-switch remount key) re-fired auto-start and created a duplicate planning session while + the original session was silently abandoned. + */ + onInitialPlanConsumed?: () => void; projectId?: string; /** Active workflow lane selected when Planning Mode was opened. */ workflowId?: string | null; @@ -456,7 +466,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri }; } -export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, onViewTask, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId, initialSessions, presentation = "modal" }: PlanningModeModalProps) { +export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, onViewTask, tasks, initialPlan: initialPlanProp, onInitialPlanConsumed, projectId, workflowId, resumeSessionId, initialSessions, presentation = "modal" }: PlanningModeModalProps) { const { t } = useTranslation("app"); // FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates. // Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical @@ -491,6 +501,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // re-initialized on each render, ensuring the auto-start effect runs correctly. const hasAutoStartedRef = useRef(false); const hasLoadedPersistedRef = useRef(false); + // FNXC:PlanningMode 2026-07-23-00:00: latest-callback ref so the auto-start effect does not + // re-fire when the parent re-renders with a new onInitialPlanConsumed identity. + const onInitialPlanConsumedRef = useRef(onInitialPlanConsumed); + onInitialPlanConsumedRef.current = onInitialPlanConsumed; const [streamingOutput, setStreamingOutput] = useState(""); const [showThinking, setShowThinking] = useState(true); const [isRetrying, setIsRetrying] = useState(false); @@ -1868,6 +1882,18 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const timer = setTimeout(() => { // Only mark as auto-started when we actually start planning hasAutoStartedRef.current = true; + /* + FNXC:PlanningMode 2026-07-23-00:00: + Consuming the seeded plan is what prevents duplicate sessions: the owner clears + `planningInitialPlan` so a later remount (navigate away/back, project-switch key) takes + the stored-active-session restore path instead of auto-starting a second session. + Mark the stored-resume attempt as done BEFORE the parent clears the prop — the seeded + start owns this mount's destination, and without this the stored-resume effect would + re-fire on the prop clearing and load a previous session over the just-started plan + while startPlanningStreaming is still in flight. + */ + hasAttemptedStoredResumeRef.current = true; + onInitialPlanConsumedRef.current?.(); handleStartPlanning(initialPlanProp); }, 0); return () => clearTimeout(timer); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 1184b3ca7a..b6e8833e0f 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -1153,4 +1153,51 @@ describe("PlanningModeModal sequential flow", () => { expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument(); }); + + /* + FNXC:PlanningMode 2026-07-23-00:00: + The seeded initialPlan handoff must be one-shot. Embedded Planning unmounts on every + main-content navigation, resetting its in-component auto-start guard; before consumption + existed, navigating back re-fired auto-start against the still-set modalManager payload and + created a duplicate planning session while the first one was silently abandoned. The remount + must instead restore the persisted active session. + */ + it("consumes the seeded initial plan on auto-start so a navigate-back remount restores the session instead of creating a duplicate", async () => { + mockFetchAiSession.mockResolvedValue({ + ...base, + id: "draft-1", + status: "generating", + currentQuestion: null, + result: null, + inputPayload: "{}", + }); + const onInitialPlanConsumed = vi.fn(); + const commonProps = { + isOpen: true, + onClose: vi.fn(), + onTaskCreated: vi.fn(), + onTasksCreated: vi.fn(), + tasks: mockTasks, + projectId: "project-1", + }; + + const first = render( + , + ); + await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledTimes(1)); + // Consumption fires with the start itself so the owner clears the payload immediately. + expect(onInitialPlanConsumed).toHaveBeenCalledTimes(1); + + // Navigate away: the embedded Planning view unmounts entirely. + first.unmount(); + + // Navigate back: the owner cleared the payload, so the remount takes the + // stored-active-session restore path. + render(); + await waitFor(() => expect(mockFetchAiSession).toHaveBeenCalledWith("draft-1")); + + // No second session was drafted or started by the remount. + expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1); + expect(mockStartPlanningStreaming).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index b01dddc9eb..2386d964d8 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -725,6 +725,14 @@ export function MainContent({ 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. + + FNXC:PlanningMode 2026-07-23-00:00: + The seeded initialPlan is a one-shot handoff consumed via onInitialPlanConsumed the moment + Planning's auto-start fires. Planning fully unmounts whenever taskView leaves "planning", + which resets its in-component auto-start guard; before consumption existed, the still-set + modalManager.planningInitialPlan re-auto-started a duplicate planning session on every + navigate-back remount (and on the project-switch remount key above) while the original + session was silently abandoned. */} void; resumePlanning: () => void; openPlanningWithSession: (sessionId: string) => void; + /** + FNXC:PlanningModals 2026-07-23-00:00: + One-shot consumption of the seeded initial plan. Embedded Planning calls this the moment its + auto-start fires; the payload must not survive that start, because Planning unmounts on + main-content navigation and a still-set planningInitialPlan re-auto-started a duplicate + planning session on every navigate-back remount. + */ + clearPlanningInitialPlan: () => void; closePlanning: () => void; openSubtaskBreakdown: (description: string, workflowId?: string | null) => void; @@ -308,6 +316,9 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { setPlanningResumeSessionId(sessionId); setIsPlanningOpen(true); }, []); + const clearPlanningInitialPlan = useCallback(() => { + setPlanningInitialPlan(null); + }, []); const closePlanning = useCallback(() => { setIsPlanningOpen(false); setPlanningInitialPlan(null); @@ -590,6 +601,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager { openPlanningWithInitialPlan, resumePlanning, openPlanningWithSession, + clearPlanningInitialPlan, closePlanning, openSubtaskBreakdown, openSubtaskWithSession,