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; onViewTask?: (task: Task) => void;
tasks: Task[]; tasks: Task[];
initialPlan?: string; 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; projectId?: string;
/** Active workflow lane selected when Planning Mode was opened. */ /** Active workflow lane selected when Planning Mode was opened. */
workflowId?: string | null; 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"); const { t } = useTranslation("app");
// FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates. // 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 // 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. // re-initialized on each render, ensuring the auto-start effect runs correctly.
const hasAutoStartedRef = useRef(false); const hasAutoStartedRef = useRef(false);
const hasLoadedPersistedRef = 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 [streamingOutput, setStreamingOutput] = useState<string>("");
const [showThinking, setShowThinking] = useState(true); const [showThinking, setShowThinking] = useState(true);
const [isRetrying, setIsRetrying] = useState(false); const [isRetrying, setIsRetrying] = useState(false);
@@ -1868,6 +1882,18 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const timer = setTimeout(() => { const timer = setTimeout(() => {
// Only mark as auto-started when we actually start planning // Only mark as auto-started when we actually start planning
hasAutoStartedRef.current = true; 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); handleStartPlanning(initialPlanProp);
}, 0); }, 0);
return () => clearTimeout(timer); return () => clearTimeout(timer);

View File

@@ -1153,4 +1153,51 @@ describe("PlanningModeModal sequential flow", () => {
expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument(); expect(await screen.findByTestId("planning-plan-review")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Proceed with plan" })).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 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 already closes the stream; the new mount fetches the new project's session list and
restores that project's own persisted draft/active session. 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 <PlanningModeModal
key={currentProject?.id ?? "all-projects"} key={currentProject?.id ?? "all-projects"}
@@ -736,6 +744,7 @@ export function MainContent({
tasks={tasks} tasks={tasks}
initialSessions={bgPlanningSessions} initialSessions={bgPlanningSessions}
initialPlan={modalManager.planningInitialPlan ?? undefined} initialPlan={modalManager.planningInitialPlan ?? undefined}
onInitialPlanConsumed={modalManager.clearPlanningInitialPlan}
projectId={currentProject?.id} projectId={currentProject?.id}
workflowId={modalManager.planningWorkflowId ?? planningHeaderWorkflowId} workflowId={modalManager.planningWorkflowId ?? planningHeaderWorkflowId}
resumeSessionId={modalManager.planningResumeSessionId} resumeSessionId={modalManager.planningResumeSessionId}

View File

@@ -97,6 +97,14 @@ export interface ModalManager {
openPlanningWithInitialPlan: (initialPlan: string, workflowId?: string | null) => void; openPlanningWithInitialPlan: (initialPlan: string, workflowId?: string | null) => void;
resumePlanning: () => void; resumePlanning: () => void;
openPlanningWithSession: (sessionId: string) => 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; closePlanning: () => void;
openSubtaskBreakdown: (description: string, workflowId?: string | null) => void; openSubtaskBreakdown: (description: string, workflowId?: string | null) => void;
@@ -308,6 +316,9 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
setPlanningResumeSessionId(sessionId); setPlanningResumeSessionId(sessionId);
setIsPlanningOpen(true); setIsPlanningOpen(true);
}, []); }, []);
const clearPlanningInitialPlan = useCallback(() => {
setPlanningInitialPlan(null);
}, []);
const closePlanning = useCallback(() => { const closePlanning = useCallback(() => {
setIsPlanningOpen(false); setIsPlanningOpen(false);
setPlanningInitialPlan(null); setPlanningInitialPlan(null);
@@ -590,6 +601,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
openPlanningWithInitialPlan, openPlanningWithInitialPlan,
resumePlanning, resumePlanning,
openPlanningWithSession, openPlanningWithSession,
clearPlanningInitialPlan,
closePlanning, closePlanning,
openSubtaskBreakdown, openSubtaskBreakdown,
openSubtaskWithSession, openSubtaskWithSession,