fix(dashboard): consume seeded Planning initial plan on auto-start to stop duplicate sessions

The seeded initialPlan lived in modalManager state while embedded Planning
fully unmounts on main-content navigation, wiping its in-component auto-start
guard — so every navigate-back remount (and the project-switch remount key)
auto-started a duplicate planning session and abandoned the one in flight.
The payload is now a one-shot handoff: PlanningModeModal calls the new
onInitialPlanConsumed the moment auto-start fires, useModalManager clears
planningInitialPlan, and remounts take the stored-active-session restore path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-23 21:43:47 -07:00
parent b007de5f94
commit 345dccbe7d
5 changed files with 102 additions and 1 deletions

View File

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

View File

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

View File

@@ -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(
<PlanningModeModal {...commonProps} initialPlan="Seeded plan from the board" onInitialPlanConsumed={onInitialPlanConsumed} />,
);
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(<PlanningModeModal {...commonProps} />);
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);
});
});

View File

@@ -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.
*/}
<PlanningModeModal
key={currentProject?.id ?? "all-projects"}
@@ -736,6 +744,7 @@ export function MainContent({
tasks={tasks}
initialSessions={bgPlanningSessions}
initialPlan={modalManager.planningInitialPlan ?? undefined}
onInitialPlanConsumed={modalManager.clearPlanningInitialPlan}
projectId={currentProject?.id}
workflowId={modalManager.planningWorkflowId ?? planningHeaderWorkflowId}
resumeSessionId={modalManager.planningResumeSessionId}

View File

@@ -97,6 +97,14 @@ export interface ModalManager {
openPlanningWithInitialPlan: (initialPlan: string, workflowId?: string | null) => 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,