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:
7
.changeset/planning-initial-plan-one-shot.md
Normal file
7
.changeset/planning-initial-plan-one-shot.md
Normal 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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user