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 streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null);
|
||||||
const currentSessionIdRef = useRef<string | null>(null);
|
const currentSessionIdRef = useRef<string | null>(null);
|
||||||
const draftSessionIdRef = 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);
|
const draftDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
// Tracks resumeSessionId values the user has explicitly dismissed (via "New
|
// Tracks resumeSessionId values the user has explicitly dismissed (via "New
|
||||||
// Session"). Without this, the resume effect re-fires on every callback
|
// Session"). Without this, the resume effect re-fires on every callback
|
||||||
@@ -2086,14 +2092,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const nextValue = e.target.value;
|
const nextValue = e.target.value;
|
||||||
setInitialPlan(nextValue);
|
setInitialPlan(nextValue);
|
||||||
if (draftSessionIdRef.current || nextValue.trim().length === 0) {
|
if (draftSessionIdRef.current || draftCreateInFlightRef.current || nextValue.trim().length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draftDebounceRef.current) {
|
if (draftDebounceRef.current) {
|
||||||
clearTimeout(draftDebounceRef.current);
|
clearTimeout(draftDebounceRef.current);
|
||||||
}
|
}
|
||||||
draftDebounceRef.current = setTimeout(() => {
|
draftDebounceRef.current = setTimeout(() => {
|
||||||
if (draftSessionIdRef.current) {
|
if (draftSessionIdRef.current || draftCreateInFlightRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = nextValue.trim();
|
const content = nextValue.trim();
|
||||||
@@ -2104,6 +2110,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
planningModelProvider && planningModelId
|
planningModelProvider && planningModelId
|
||||||
? { planningModelProvider, planningModelId }
|
? { planningModelProvider, planningModelId }
|
||||||
: undefined;
|
: 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)
|
void createPlanningDraft(content, projectId, modelOverride)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
draftSessionIdRef.current = response.sessionId;
|
draftSessionIdRef.current = response.sessionId;
|
||||||
@@ -2124,7 +2132,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setSelectedSessionId(response.sessionId);
|
setSelectedSessionId(response.sessionId);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// best-effort
|
// best-effort; clear the in-flight sentinel so a
|
||||||
|
// later keystroke can retry creating the draft.
|
||||||
|
draftCreateInFlightRef.current = false;
|
||||||
});
|
});
|
||||||
}, 300);
|
}, 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 () => {
|
it("auto-starts planning when initialPlan prop is provided", async () => {
|
||||||
render(
|
render(
|
||||||
<PlanningModeModal
|
<PlanningModeModal
|
||||||
|
|||||||
Reference in New Issue
Block a user