diff --git a/.changeset/fix-planning-refinement-action.md b/.changeset/fix-planning-refinement-action.md new file mode 100644 index 0000000000..3670810f41 --- /dev/null +++ b/.changeset/fix-planning-refinement-action.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Make plan refinement submit reliably from stopped, active, restored, and mobile planning states. +category: fix +dev: Uses the visible session identity, stops conflicting turns, and submits mobile refinement on pointer-down. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index f9b4c3fc22..916dddc3bb 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -2396,6 +2396,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setGenerationStartTime(null); if (priorQuestion) { + currentSessionIdRef.current = sessionId; const answered = history.some((entry) => entry.question?.id === priorQuestion.id && entry.response); setEditingQuestionId(answered ? priorQuestion.id : null); setWorkspaceQuestion(priorQuestion); @@ -2404,6 +2405,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat session: { sessionId, currentQuestion: priorQuestion, summary }, }); } else if (summary) { + currentSessionIdRef.current = sessionId; setWorkspaceQuestion(null); setView({ type: "plan_review", @@ -2432,9 +2434,26 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat may finalize during loading or recoverable error states; the server cancels an active turn safely. */ const handleRefineFromPlan = useCallback(async () => { - const sessionId = currentSessionIdRef.current; + const visibleSessionId = "session" in view ? view.session.sessionId : undefined; + const sessionId = currentSessionIdRef.current ?? visibleSessionId; const summary = runningSummaryRef.current; - if (!sessionId || !summary || !refinementInstructions) return; + if (!sessionId || !summary || !refinementInstructions || refineSummaryInFlightRef.current) return; + refineSummaryInFlightRef.current = true; + if (view.type === "loading") { + streamConnectionEpochRef.current += 1; + streamConnectionRef.current?.close(); + streamConnectionRef.current = null; + try { + await stopPlanningGeneration(sessionId, projectId); + } catch { + // The turn may have settled between opening the refinement input and applying it. + } + } + currentSessionIdRef.current = sessionId; + liveGenerationSessionIdRef.current = sessionId; + if (!streamConnectionRef.current?.isConnected()) { + connectToPlanningStream(sessionId); + } setError(null); setGenerationActivity("question"); setIsRefineMenuOpen(false); @@ -2467,8 +2486,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } else { setView({ type: "plan_review", session: { sessionId, currentQuestion: null, summary }, summary }); } + } finally { + refineSummaryInFlightRef.current = false; } - }, [projectId, refinementInstructions, t, workspaceQuestion]); + }, [connectToPlanningStream, projectId, refinementInstructions, t, view, workspaceQuestion]); const handleProceedWithPlan = useCallback(async () => { const sessionId = currentSessionIdRef.current; @@ -2497,6 +2518,18 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat event.preventDefault(); }, [viewportMode]); + const handleApplyRefinementPointerDown = useCallback((event: ReactPointerEvent) => { + if (viewportMode !== "mobile" || event.pointerType === "mouse") return; + /* + FNXC:PlanningModeMobile 2026-07-21-00:50: + Preventing the touch pointer default suppresses the browser's compatibility click. Apply + before the keyboard resize can move/remove the popup button; the single-flight guard above + makes a browser that still emits click harmless. + */ + event.preventDefault(); + void handleRefineFromPlan(); + }, [handleRefineFromPlan, viewportMode]); + const handleRetryCreateTask = useCallback(async () => { if (view.type !== "create_retry" || validateCreateInFlightRef.current) return; validateCreateInFlightRef.current = true; @@ -2735,7 +2768,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat > {t("common.cancel", "Cancel")} - 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 95386c075b..97cd79fde4 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -344,9 +344,6 @@ describe("PlanningModeModal sequential flow", () => { fireEvent.change(screen.getByLabelText("Refinement instructions"), { target: { value: "Add migration sequencing and ask about rollout risks." } }); const applyButton = screen.getByRole("button", { name: "Apply refinement" }); expect(fireEvent.pointerDown(applyButton, { pointerType: "touch" })).toBe(false); - expect(applyButton).toBeInTheDocument(); - expect(mockRespondToPlanning).not.toHaveBeenCalled(); - fireEvent.click(applyButton); await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith("session-1", { refine: true, focus: "Add migration sequencing and ask about rollout risks." }, "project-1")); expect(await screen.findByText("Which migration risk should come first?")).toBeInTheDocument(); }); @@ -434,6 +431,58 @@ describe("PlanningModeModal sequential flow", () => { await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledTimes(2)); }); + it("can refine a stopped initial plan into the first question", async () => { + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "generating", + currentQuestion: null, + result: JSON.stringify(summaryWithRefinements), + inputPayload: JSON.stringify({ generationPurpose: "initial_plan", generationStartedAt: new Date().toISOString() }), + }); + mockRespondToPlanning.mockResolvedValue({ + currentQuestion: { id: "q-refined", type: "text", question: "Which refined area comes first?" }, + summary: summaryWithRefinements, + }); + renderSession({}); + + fireEvent.click(await screen.findByRole("button", { name: "Stop" })); + fireEvent.click(await screen.findByRole("button", { name: "Refine" })); + fireEvent.change(screen.getByLabelText("Refinement instructions"), { target: { value: "Focus the next questions on rollout." } }); + fireEvent.click(screen.getByRole("button", { name: "Apply refinement" })); + + await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith( + "session-1", + { refine: true, focus: "Focus the next questions on rollout." }, + "project-1", + )); + expect(await screen.findByText("Which refined area comes first?")).toBeInTheDocument(); + }); + it("replaces an active generation when refinement is applied", async () => { + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "generating", + currentQuestion: null, + result: JSON.stringify(summaryWithRefinements), + inputPayload: JSON.stringify({ generationPurpose: "plan_update", generationStartedAt: new Date().toISOString() }), + }); + mockRespondToPlanning.mockResolvedValue({ + currentQuestion: { id: "q-replaced", type: "text", question: "What should the replacement prioritize?" }, + summary: summaryWithRefinements, + }); + renderSession({}); + + fireEvent.click(await screen.findByRole("button", { name: "Refine" })); + fireEvent.change(screen.getByLabelText("Refinement instructions"), { target: { value: "Replace the current direction." } }); + fireEvent.click(screen.getByRole("button", { name: "Apply refinement" })); + + await waitFor(() => expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-1", "project-1")); + await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith( + "session-1", + { refine: true, focus: "Replace the current direction." }, + "project-1", + )); + expect(await screen.findByText("What should the replacement prioritize?")).toBeInTheDocument(); + }); it("renders exactly one write-your-own choice for normalized select questions", async () => { mockFetchAiSession.mockResolvedValue({ ...base, diff --git a/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts b/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts index 88bf313201..2a3315a593 100644 --- a/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts +++ b/packages/dashboard/src/__tests__/planning-generation-cancellation.test.ts @@ -1,6 +1,7 @@ // @vitest-environment node import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; import type { TaskStore } from "@fusion/core"; vi.mock("@fusion/engine", () => ({ @@ -23,6 +24,7 @@ import { createSessionWithAgent, getSession, planningStreamManager, + setAiSessionStore, stopGeneration, } from "../planning.js"; @@ -35,8 +37,15 @@ const MOCK_TASK_STORE = { } as unknown as TaskStore; describe("planning generation cancellation", () => { + const persistSession = vi.fn(async () => {}); + beforeEach(() => { __resetPlanningState(); + persistSession.mockClear(); + setAiSessionStore(Object.assign(new EventEmitter(), { + upsert: persistSession, + get: vi.fn(async () => null), + }) as any); }); it("forwards AbortSignal and disposes the in-flight planning prompt on user stop", async () => { @@ -70,12 +79,25 @@ describe("planning generation cancellation", () => { } expect(promptSignal).toBeDefined(); + const activeSession = await getSession(sessionId); + activeSession!.summary = { + title: "Reviewable plan", + description: "A partial plan that remains useful after stopping.", + suggestedSize: "M", + keyDeliverables: ["Resume refinement"], + }; + expect(stopGeneration(sessionId)).toBe(true); await new Promise((resolve) => setTimeout(resolve, 0)); expect(promptSignal?.aborted).toBe(true); expect(dispose).toHaveBeenCalledTimes(1); expect((await getSession(sessionId))?.error).toBeUndefined(); + expect(persistSession).toHaveBeenLastCalledWith(expect.objectContaining({ + id: sessionId, + status: "awaiting_input", + result: expect.stringContaining("Reviewable plan"), + })); resolveHungPrompt?.(); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index e498672902..73fd197643 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -3102,8 +3102,11 @@ export function stopGeneration(sessionId: string): boolean { } else { session.currentQuestion = undefined; session.editingQuestionId = undefined; - void persistSession(session, stoppedPurpose === "initial_plan" ? "draft" : "awaiting_input"); - if (session.summary && stoppedPurpose !== "initial_plan") { + // A stopped initial turn with a usable running plan resumes at plan review; only a turn + // stopped before any plan exists returns to the initial draft editor. + const hasReviewablePlan = Boolean(session.summary); + void persistSession(session, hasReviewablePlan || stoppedPurpose !== "initial_plan" ? "awaiting_input" : "draft"); + if (session.summary) { planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary }); } }