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:
7
.changeset/fix-planning-draft-per-keystroke.md
Normal file
7
.changeset/fix-planning-draft-per-keystroke.md
Normal 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.
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user