fix: stop planning mode from creating a draft per keystroke

The initial-plan textarea gated duplicate createPlanningDraft calls only
on draftSessionIdRef, which is populated after the create round-trip
resolves. Keystrokes arriving while a create was in flight each passed the
guard and spawned a fresh draft. Add a synchronous draftCreateInFlightRef
sentinel that suppresses concurrent creates and clears on failure so a
later keystroke can retry. Includes an in-flight-concurrency regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-01 21:43:07 -07:00
parent 40b44e40b9
commit 074462eade
3 changed files with 71 additions and 3 deletions

View File

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

View File

@@ -335,6 +335,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
const currentSessionIdRef = useRef<string | null>(null);
const draftSessionIdRef = useRef<string | null>(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<ReturnType<typeof setTimeout> | 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);
}}

View File

@@ -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(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
/>,
);
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(
<PlanningModeModal