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;
|
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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user