From 53d5bb1cbaaca3bf41c22b237564dd7f5f0831e4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 2 Jul 2026 08:15:03 -0700 Subject: [PATCH] FN-7431: guard Planning Mode summary refinement Prevent duplicate Refine Further activations from interrupting an active completed-summary refinement stream. - Add a synchronous single-flight guard and loading state for summary refinement. - Preserve the connected planning stream when a same-turn generation-in-progress response is reported. - Cover rapid desktop/mobile activation and active-stream conflict behavior with regression tests. - Add a patch changeset for the published Fusion package. Files changed: .../fn-7431-planning-refine-single-flight.md | 7 + .../dashboard/app/components/PlanningModeModal.tsx | 41 +++++- .../PlanningModeModal.planning-flow.test.tsx | 156 +++++++++++++++++++++ 3 files changed, 201 insertions(+), 3 deletions(-) Fusion-Task-Id: FN-7431 Fusion-Task-Lineage: fc876022-da86-409e-818d-8c4798b49aa5 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7431-planning-refine-single-flight.md | 7 + .../app/components/PlanningModeModal.tsx | 41 ++++- .../PlanningModeModal.planning-flow.test.tsx | 156 ++++++++++++++++++ 3 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 .changeset/fn-7431-planning-refine-single-flight.md diff --git a/.changeset/fn-7431-planning-refine-single-flight.md b/.changeset/fn-7431-planning-refine-single-flight.md new file mode 100644 index 0000000000..58a90f87b1 --- /dev/null +++ b/.changeset/fn-7431-planning-refine-single-flight.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep Planning Mode Refine Further from getting stuck on duplicate generation. +category: fix +dev: Guards completed-summary refinement as a single-flight UI turn and preserves the active planning stream on same-refine in-progress responses. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index c0a0d9c507..4015fcc3f1 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -316,6 +316,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const [isCreatingTask, setIsCreatingTask] = useState(false); const [isStartingBreakdown, setIsStartingBreakdown] = useState(false); const [isCreatingFromBreakdown, setIsCreatingFromBreakdown] = useState(false); + const [isRefiningSummary, setIsRefiningSummary] = useState(false); const [generationStartTime, setGenerationStartTime] = useState(null); const [elapsedSeconds, setElapsedSeconds] = useState(0); const textareaRef = useRef(null); @@ -334,6 +335,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const modalRef = useRef(null); const streamConnectionRef = useRef<{ close: () => void; isConnected: () => boolean } | null>(null); const currentSessionIdRef = useRef(null); + /* + FNXC:PlanningMode 2026-07-02-07:56: + Refine Further is a single-flight completed-summary turn. Guard synchronously with a ref so duplicate click, touch, or keyboard activations cannot submit a second refine request or close the active stream with a generation-in-progress error before React renders the disabled state. + */ + const refineSummaryInFlightRef = useRef(false); const draftSessionIdRef = useRef(null); /* FNXC:PlanningMode 2026-07-01-00:00: @@ -606,6 +612,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setStreamingOutput(""); setIsReconnecting(false); setIsRetrying(false); + setIsRefiningSummary(false); + refineSummaryInFlightRef.current = false; setPlanningModelProvider(undefined); setPlanningModelId(undefined); setPlanningDepth("medium"); @@ -717,6 +725,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const normalizedQuestion = normalizeQuestionOptions(question); setIsReconnecting(false); setIsRetrying(false); + setIsRefiningSummary(false); + refineSummaryInFlightRef.current = false; clearPlanningDescription(projectId); // Preserve reasoning accumulated during the loading turn as a @@ -756,6 +766,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const normalizedSummary = normalizePlanningSummary(summary); setIsReconnecting(false); setIsRetrying(false); + setIsRefiningSummary(false); + refineSummaryInFlightRef.current = false; clearPlanningDescription(projectId); // Preserve reasoning accumulated during the loading turn. @@ -811,6 +823,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setIsReconnecting(false); setIsRetrying(false); + setIsRefiningSummary(false); + refineSummaryInFlightRef.current = false; setError(null); setView((prev) => { if (prev.type === "question" || prev.type === "summary" || prev.type === "error") { @@ -840,6 +854,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat onComplete: () => { setIsReconnecting(false); setIsRetrying(false); + setIsRefiningSummary(false); + refineSummaryInFlightRef.current = false; currentSessionIdRef.current = null; broadcastCompleted({ sessionId, status: "complete" }); }, @@ -862,6 +878,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setConversationHistory([]); setResponseHistory([]); setIsReconnecting(false); + setIsRefiningSummary(false); + refineSummaryInFlightRef.current = false; setView({ type: "loading" }); try { @@ -966,6 +984,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setConversationHistory([]); setEditedSummary(null); setIsRetrying(false); + setIsRefiningSummary(false); + refineSummaryInFlightRef.current = false; setView({ type: "loading" }); try { @@ -1573,6 +1593,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat streamConnectionRef.current = null; setIsReconnecting(false); setIsRetrying(false); + setIsRefiningSummary(false); + refineSummaryInFlightRef.current = false; resetMobileViewportAfterClose(); onClose(); }, [flushDraftAndSummarize, initialPlan, onClose, projectId, resetMobileViewportAfterClose, view.type]); @@ -1647,7 +1669,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat ); const handleRefineFurther = useCallback(async () => { - if (view.type !== "summary") { + if (view.type !== "summary" || refineSummaryInFlightRef.current) { return; } @@ -1656,6 +1678,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat currentSessionIdRef.current = sessionId; setLockSessionId(sessionId); + refineSummaryInFlightRef.current = true; + setIsRefiningSummary(true); setError(null); setIsRetrying(false); setStreamingOutput(""); @@ -1666,9 +1690,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat try { await respondToPlanning(sessionId, { refine: true }, projectId, sessionTabId); } catch (err) { + const message = getErrorMessage(err) || t("planning.failedRefinePlan", "Failed to refine plan"); + if (/generation already in progress/i.test(message)) { + return; + } + refineSummaryInFlightRef.current = false; + setIsRefiningSummary(false); streamConnectionRef.current?.close(); streamConnectionRef.current = null; - setError(getErrorMessage(err) || t("planning.failedRefinePlan", "Failed to refine plan")); + setError(message); setView({ type: "summary", session, summary: editedSummary ?? summary }); } }, [connectToPlanningStream, editedSummary, projectId, sessionTabId, view]); @@ -1689,6 +1719,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat streamConnectionRef.current = null; setIsReconnecting(false); setIsRetrying(false); + setIsRefiningSummary(false); + refineSummaryInFlightRef.current = false; setView({ type: "error", session: { sessionId, currentQuestion: null, summary: null }, @@ -2376,6 +2408,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }} isCreatingTask={isCreatingTask} isStartingBreakdown={isStartingBreakdown} + isRefiningSummary={isRefiningSummary} /> )} @@ -2811,6 +2844,7 @@ interface SummaryViewProps { onRefine: () => void; isCreatingTask: boolean; isStartingBreakdown: boolean; + isRefiningSummary: boolean; } function SummaryView({ @@ -2829,6 +2863,7 @@ function SummaryView({ onRefine, isCreatingTask, isStartingBreakdown, + isRefiningSummary, }: SummaryViewProps) { const { t } = useTranslation("app"); const summary = normalizePlanningSummary(rawSummary); @@ -2846,7 +2881,7 @@ function SummaryView({ const selectedPriority = normalizeTaskPriority(summary.priority); const isBranchNameRequired = branchMode === "existing" || branchMode === "custom-new"; const hasInvalidBranchSelection = isBranchNameRequired && !branchName.trim(); - const isLoading = isCreatingTask || isStartingBreakdown; + const isLoading = isCreatingTask || isStartingBreakdown || isRefiningSummary; const handleDependencyToggle = (taskId: string) => { const newDeps = selectedDependencies.includes(taskId) diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index cb643eae14..9be8bebc9b 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -2818,6 +2818,162 @@ describe("PlanningModeModal", () => { }); expect(screen.queryByText("No active question in session")).toBeNull(); }); + + it.each(["desktop", "mobile"] as const)("keeps resumed Refine Further single-flight on rapid %s activation", async (viewportMode) => { + mockViewport(viewportMode); + const resumedSummary: PlanningSummary = { + title: "Populated summary for duplicate refine", + description: "Recovered summary with edited details before refine", + suggestedSize: "M", + suggestedDependencies: ["FN-001"], + keyDeliverables: ["Keep edits", "Ask follow-up"], + }; + const refinedQuestion: PlanningQuestion = { + id: `q-refine-${viewportMode}`, + type: "text", + question: `What should we refine next on ${viewportMode}?`, + description: "Follow-up from the original refine stream", + }; + + mockFetchAiSession.mockResolvedValueOnce({ + id: `session-complete-refine-${viewportMode}`, + type: "planning", + status: "complete", + title: resumedSummary.title, + inputPayload: JSON.stringify({ initialPlan: "Recover and refine without duplicate generation" }), + conversationHistory: "[]", + currentQuestion: null, + result: JSON.stringify(resumedSummary), + thinkingOutput: "", + error: null, + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + let streamHandlers: any; + let streamClosed = false; + mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { + streamHandlers = handlers; + return { + close: vi.fn(() => { + streamClosed = true; + }), + isConnected: vi.fn(() => !streamClosed), + }; + }); + mockRespondToPlanning.mockImplementation(async () => { + setTimeout(() => { + if (!streamClosed) { + streamHandlers?.onQuestion?.(refinedQuestion); + } + }, 10); + return { type: "question", data: refinedQuestion }; + }); + + render( + + ); + + const refineButton = await screen.findByRole("button", { name: "Refine Further" }); + fireEvent.click(refineButton); + fireEvent.click(refineButton); + + await waitFor(() => { + expect(mockRespondToPlanning).toHaveBeenCalledTimes(1); + }); + expect(mockRespondToPlanning).toHaveBeenCalledWith( + `session-complete-refine-${viewportMode}`, + { refine: true }, + undefined, + expect.any(String), + ); + + await waitFor(() => { + expect(screen.getByText(`What should we refine next on ${viewportMode}?`)).toBeDefined(); + }); + expect(screen.queryByText(/generation already in progress/i)).toBeNull(); + expect(screen.queryByText(/generation in progress/i)).toBeNull(); + }); + + it("keeps the refine stream alive when the accepted turn reports generation already in progress", async () => { + const resumedSummary: PlanningSummary = { + title: "Backend conflict refine", + description: "Summary that was already accepted for refinement", + suggestedSize: "M", + suggestedDependencies: [], + keyDeliverables: ["Continue stream"], + }; + const refinedQuestion: PlanningQuestion = { + id: "q-refine-conflict", + type: "text", + question: "What detail should the already-running refine turn clarify?", + description: "Follow-up from the active refine generation", + }; + + mockFetchAiSession.mockResolvedValueOnce({ + id: "session-complete-refine-conflict", + type: "planning", + status: "complete", + title: resumedSummary.title, + inputPayload: JSON.stringify({ initialPlan: "Refine active conflict" }), + conversationHistory: "[]", + currentQuestion: null, + result: JSON.stringify(resumedSummary), + thinkingOutput: "", + error: null, + projectId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + let streamHandlers: any; + let streamClosed = false; + mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => { + streamHandlers = handlers; + return { + close: vi.fn(() => { + streamClosed = true; + }), + isConnected: vi.fn(() => !streamClosed), + }; + }); + mockRespondToPlanning.mockImplementationOnce(async () => { + setTimeout(() => { + if (!streamClosed) { + streamHandlers?.onQuestion?.(refinedQuestion); + } + }, 10); + throw new Error("Generation already in progress for this response"); + }); + + render( + + ); + + fireEvent.click(await screen.findByRole("button", { name: "Refine Further" })); + + await waitFor(() => { + expect(screen.getByText("What detail should the already-running refine turn clarify?")).toBeDefined(); + }); + expect(streamClosed).toBe(false); + expect(screen.queryByText(/generation already in progress/i)).toBeNull(); + expect(screen.queryByText(/generation in progress/i)).toBeNull(); + }); }); describe("Conversation history", () => {