diff --git a/.changeset/fix-planning-draft-per-keystroke.md b/.changeset/fix-planning-draft-per-keystroke.md new file mode 100644 index 0000000000..e44079243b --- /dev/null +++ b/.changeset/fix-planning-draft-per-keystroke.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Planning mode no longer creates a new draft for every character you type. +category: fix +dev: PlanningModeModal's initial-plan textarea gated duplicate createPlanningDraft calls only on draftSessionIdRef, which is set after the create round-trip resolves; keystrokes during an in-flight create each spawned a fresh draft. A synchronous draftCreateInFlightRef sentinel now suppresses concurrent creates and is cleared on failure so a later keystroke can retry. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 37c3572e63..c0a0d9c507 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -335,6 +335,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const currentSessionIdRef = useRef(null); const draftSessionIdRef = useRef(null); + /* + FNXC:PlanningMode 2026-07-01-00:00: + A single draft session must back the whole "what to build" textarea; typing must not spawn one draft per keystroke. + draftSessionIdRef is only populated after createPlanningDraft resolves, so it cannot gate concurrent creates while a request is in flight. This synchronous sentinel flips true before the await and gates all subsequent debounce fires, collapsing the create path to exactly one call. Cleared on failure so a later keystroke can retry. + */ + const draftCreateInFlightRef = useRef(false); const draftDebounceRef = useRef | null>(null); // Tracks resumeSessionId values the user has explicitly dismissed (via "New // Session"). Without this, the resume effect re-fires on every callback @@ -2086,14 +2092,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat onChange={(e) => { const nextValue = e.target.value; setInitialPlan(nextValue); - if (draftSessionIdRef.current || nextValue.trim().length === 0) { + if (draftSessionIdRef.current || draftCreateInFlightRef.current || nextValue.trim().length === 0) { return; } if (draftDebounceRef.current) { clearTimeout(draftDebounceRef.current); } draftDebounceRef.current = setTimeout(() => { - if (draftSessionIdRef.current) { + if (draftSessionIdRef.current || draftCreateInFlightRef.current) { return; } const content = nextValue.trim(); @@ -2104,6 +2110,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat planningModelProvider && planningModelId ? { planningModelProvider, planningModelId } : undefined; + // FNXC:PlanningMode 2026-07-01-00:00: mark in-flight synchronously so debounce fires during the round-trip don't spawn duplicate drafts. + draftCreateInFlightRef.current = true; void createPlanningDraft(content, projectId, modelOverride) .then((response) => { draftSessionIdRef.current = response.sessionId; @@ -2124,7 +2132,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setSelectedSessionId(response.sessionId); }) .catch(() => { - // best-effort + // best-effort; clear the in-flight sentinel so a + // later keystroke can retry creating the draft. + draftCreateInFlightRef.current = false; }); }, 300); }} diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx index a1dc8dedc8..b20ea39e37 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.initial.test.tsx @@ -587,6 +587,57 @@ describe("PlanningModeModal", () => { }); }); + // FNXC:PlanningMode 2026-07-01-00:00: regression — deliberate typing must not spawn one draft per keystroke. + // Original symptom: each character created a new draft while the create-draft request was in flight, because + // the create-suppression guard only checked draftSessionIdRef, which is populated after the round-trip resolves. + it("creates exactly one draft when keystrokes arrive while the create request is still in flight", async () => { + let resolveCreate: ((value: { sessionId: string; title: string }) => void) | undefined; + mockCreatePlanningDraft.mockImplementation( + () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + + render( + , + ); + + const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/); + + // First keystroke → debounce (300ms) fires the create; it stays in flight (unresolved). + fireEvent.change(textarea, { target: { value: "Build" } }); + await waitFor(() => { + expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1); + }); + + // Subsequent keystrokes while the create is still in flight must be suppressed by the + // synchronous in-flight sentinel — not each spawn another draft. + fireEvent.change(textarea, { target: { value: "Build a" } }); + await new Promise((resolve) => setTimeout(resolve, 350)); + fireEvent.change(textarea, { target: { value: "Build an" } }); + await new Promise((resolve) => setTimeout(resolve, 350)); + fireEvent.change(textarea, { target: { value: "Build an auth" } }); + await new Promise((resolve) => setTimeout(resolve, 350)); + + expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1); + + // Once the create resolves and further edits arrive, they patch the single draft — no new create. + resolveCreate?.({ sessionId: "draft-123", title: "New planning session" }); + await waitFor(() => { + expect(document.querySelector(".planning-sidebar-item-title")).not.toBeNull(); + }); + fireEvent.change(textarea, { target: { value: "Build an auth system" } }); + await new Promise((resolve) => setTimeout(resolve, 350)); + expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1); + }); + it("auto-starts planning when initialPlan prop is provided", async () => { render(