From 7b444edc8659e50dba486e764c84dbfb64d8a90d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 23:35:32 -0700 Subject: [PATCH] fix(planning): complete task creation handoff Treat active creation claims as transient coordination, keep the created-task handoff visible, and provide direct task and session navigation across desktop and mobile. --- .../fix-planning-task-creation-handoff.md | 7 ++ .../app/components/PlanningModeModal.css | 77 +++++++++++++++- .../app/components/PlanningModeModal.tsx | 85 ++++++++++++++---- .../PlanningModeModal.planning-flow.test.tsx | 87 ++++++++++++++++++- .../app/components/dashboard/MainContent.tsx | 1 + 5 files changed, 238 insertions(+), 19 deletions(-) create mode 100644 .changeset/fix-planning-task-creation-handoff.md diff --git a/.changeset/fix-planning-task-creation-handoff.md b/.changeset/fix-planning-task-creation-handoff.md new file mode 100644 index 0000000000..585e27c56d --- /dev/null +++ b/.changeset/fix-planning-task-creation-handoff.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Finish plan task creation automatically and show links to the task or planning sessions. +category: fix +dev: Retries transient planning creation claims and keeps a durable success handoff visible. diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index 0b7c5e0811..ebb8a07ba7 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -787,7 +787,7 @@ An empty footer must NOT reserve vertical space or paint its divider band. When gap: var(--space-sm); padding: var(--space-md); background: var(--surface); - border: 1px solid var(--border); + border: var(--btn-border-width) solid var(--border); border-radius: var(--radius-md); } @@ -1863,6 +1863,72 @@ plan actions, and a token-sized bottom inset keep all three controls inline with color: var(--text-muted); } +/* +FNXC:PlanningMode 2026-07-20-23:20: +Successful plan creation remains a compact handoff instead of closing Planning or presenting +another review screen. Reuse the shared button primitives and semantic success token so both +mobile and desktop offer the same View task / Return to sessions choices. +*/ +.planning-task-created { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-lg); + padding: var(--space-xl); + text-align: center; +} + +.planning-task-created-icon { + display: grid; + place-items: center; + width: calc(var(--space-2xl) + var(--space-lg)); + aspect-ratio: 1; + color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 12%, transparent); + border: var(--btn-border-width) solid color-mix(in srgb, var(--color-success) 35%, var(--border)); + border-radius: 50%; +} + +.planning-task-created-copy { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-sm); + max-width: calc(var(--space-2xl) * 12); +} + +.planning-task-created-copy h4, +.planning-task-created-copy p { + margin: 0; +} + +.planning-task-created-copy p { + color: var(--text-muted); +} + +.planning-task-created-id { + padding: var(--space-xs) var(--space-sm); + color: var(--text-secondary); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + font-family: var(--font-mono); + font-size: var(--font-size-sm); +} + +.planning-task-created-actions { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.planning-task-created-actions .btn { + justify-content: center; +} + /* AI Thinking Output Display */ .planning-thinking-container { display: flex; @@ -2051,6 +2117,15 @@ plan actions, and a token-sized bottom inset keep all three controls inline with /* Responsive */ @media (max-width: 768px) { + .planning-task-created-actions { + flex-direction: column; + width: 100%; + } + + .planning-task-created-actions .btn { + width: 100%; + } + .planning-modal--embedded .modal-header--embedded { align-content: center; flex-wrap: wrap; diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 891cf33ece..17f9a02050 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -79,12 +79,44 @@ const PLANNING_SIDEBAR_MAX_WIDTH = 560; const PLANNING_SIDEBAR_STORAGE_KEY = "fusion:planning-sidebar-width"; const MAX_PLANNING_AUTO_RETRIES = 3; +const MAX_PLANNING_CREATE_CLAIM_RETRIES = 20; + +function isPlanningCreateClaimConflict(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "status" in error + && (error as { status?: unknown }).status === 409 + && error instanceof Error + && error.message.includes("Planning task creation is already in progress"); +} + +async function createTaskAfterActiveClaim(createTask: () => Promise): Promise { + for (let retryCount = 0; ; retryCount += 1) { + try { + return await createTask(); + } catch (error) { + if (!isPlanningCreateClaimConflict(error) || retryCount >= MAX_PLANNING_CREATE_CLAIM_RETRIES) throw error; + /* + FNXC:PlanningMode 2026-07-20-23:20: + A 409 create-claim response is cross-process coordination, not a failed user action. The + endpoint is idempotent by planning session, so keep the single Proceed action in its loading + state and retry until the active creator returns the one canonical task (or its lease expires). + The first retry is immediate for the common just-finished race; later retries are bounded. + */ + if (retryCount > 0) { + await new Promise((resolve) => window.setTimeout(resolve, Math.min(750 * retryCount, 2_000))); + } + } + } +} interface PlanningModeModalProps { isOpen: boolean; onClose: () => void; onTaskCreated: (task: Task) => void; onTasksCreated: (tasks: Task[]) => void; + /** FNXC:PlanningMode 2026-07-20-23:20: Open the task produced by a completed planning session from the durable success handoff. */ + onViewTask?: (task: Task) => void; tasks: Task[]; initialPlan?: string; projectId?: string; @@ -109,7 +141,7 @@ type ViewState = | { type: "plan_review"; session: PlanningSession; summary: PlanningSummary } | { type: "creating_task"; session: PlanningSession; summary: PlanningSummary } | { type: "create_retry"; session: PlanningSession; summary: PlanningSummary; errorMessage: string } - | { type: "task_created"; taskId: string } + | { type: "task_created"; taskId: string; task?: Task } | { type: "error"; session: PlanningSession; errorMessage: string } | { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean } | { type: "loading" }; @@ -334,7 +366,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri }; } -export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId, initialSessions, presentation = "modal" }: PlanningModeModalProps) { +export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, onViewTask, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId, initialSessions, presentation = "modal" }: PlanningModeModalProps) { const { t } = useTranslation("app"); // FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates. // Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical @@ -2138,14 +2170,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat */ useEffect(() => { if (view.type !== "task_created" || restoredTaskHandoffRef.current === view.taskId) return; - const task = tasks.find((candidate) => candidate.id === view.taskId); + const task = view.task ?? tasks.find((candidate) => candidate.id === view.taskId); if (!task) return; restoredTaskHandoffRef.current = task.id; onTaskCreated(task); clearPlanningActiveSession(projectId); - setSelectedSessionId(null); - handleClose(); - }, [handleClose, onTaskCreated, projectId, tasks, view]); + }, [onTaskCreated, projectId, tasks, view]); // Handle escape key to close useEffect(() => { @@ -2385,13 +2415,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat const validated = await validatePlanningSession(sessionId, projectId); validationCompleted = true; const validatedSummary = normalizePlanningSummary(validated.summary); - const task = await createTaskFromPlanning(sessionId, validatedSummary, projectId, { + const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(sessionId, validatedSummary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}), - }); - onTaskCreated(task); + })); clearPlanningActiveSession(projectId); - setSelectedSessionId(null); - handleClose(); + setView({ type: "task_created", taskId: task.id, task }); } catch (err) { const errorMessage = getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task"); if (validationCompleted) { @@ -2403,21 +2431,22 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } finally { validateCreateInFlightRef.current = false; } - }, [handleClose, onTaskCreated, projectId, t, workflowId, workspaceQuestion]); + }, [projectId, t, workflowId, workspaceQuestion]); const handleRetryCreateTask = useCallback(async () => { if (view.type !== "create_retry" || validateCreateInFlightRef.current) return; validateCreateInFlightRef.current = true; setView({ type: "creating_task", session: view.session, summary: view.summary }); try { - const task = await createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) }); - onTaskCreated(task); clearPlanningActiveSession(projectId); setSelectedSessionId(null); handleClose(); + const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) })); + clearPlanningActiveSession(projectId); + setView({ type: "task_created", taskId: task.id, task }); } catch (err) { setView({ ...view, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") }); } finally { validateCreateInFlightRef.current = false; } - }, [handleClose, onTaskCreated, projectId, t, view, workflowId]); + }, [projectId, t, view, workflowId]); const handleCreateTask = useCallback(async () => { if (view.type !== "summary") return; @@ -3187,7 +3216,31 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat {view.type === "creating_task" &&
{t("planning.creatingTask", "Creating task…")}
} {view.type === "task_created" && ( -
{t("planning.taskCreated", "Task created")}
+
+
+
+

{t("planning.taskCreated", "Task created")}

+

{t("planning.taskCreatedHint", "Your approved plan is ready to work on.")}

+ {view.taskId} +
+
+ + +
+
)} {view.type === "create_retry" && (
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 3f0be25412..761b10b334 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -152,7 +152,7 @@ describe("PlanningModeModal sequential flow", () => { await waitFor(() => expect(historyButton).toHaveFocus()); }); - it("creates the task directly when the user proceeds with the plan", async () => { + it("creates the task directly and offers task and session-list handoffs", async () => { mockFetchAiSession.mockResolvedValue({ ...base, status: "awaiting_input", @@ -160,7 +160,10 @@ describe("PlanningModeModal sequential flow", () => { result: JSON.stringify(summaryWithRefinements), inputPayload: "{}", }); - renderSession({}); + const onClose = vi.fn(); + const onTaskCreated = vi.fn(); + const onViewTask = vi.fn(); + render(); fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" })); @@ -172,6 +175,86 @@ describe("PlanningModeModal sequential flow", () => { {}, )); expect(screen.queryByRole("heading", { name: "Review your plan" })).toBeNull(); + expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-8442"); + expect(onTaskCreated).toHaveBeenCalledWith({ id: "FN-8442" }); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "View task" })); + expect(onViewTask).toHaveBeenCalledWith({ id: "FN-8442" }); + + fireEvent.click(screen.getByRole("button", { name: "Return to sessions" })); + expect(await screen.findByRole("complementary", { name: "Planning sessions" })).toBeInTheDocument(); + }); + + it("automatically resolves an in-progress create claim without showing retry UI", async () => { + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "awaiting_input", + currentQuestion: JSON.stringify({ id: "q-1", type: "text", question: "Anything else?" }), + result: JSON.stringify(summaryWithRefinements), + inputPayload: "{}", + }); + mockCreateTaskFromPlanning + .mockRejectedValueOnce(Object.assign(new Error("Planning task creation is already in progress"), { status: 409 })) + .mockResolvedValueOnce({ id: "FN-8442" }); + + renderSession({}); + fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" })); + + expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-8442"); + expect(mockCreateTaskFromPlanning).toHaveBeenCalledTimes(2); + expect(screen.queryByTestId("planning-create-retry")).toBeNull(); + }); + + it("keeps both created-task handoffs reachable on mobile", async () => { + mockViewportMode.mockReturnValue("mobile"); + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "awaiting_input", + currentQuestion: JSON.stringify({ id: "q-1", type: "text", question: "Anything else?" }), + result: JSON.stringify(summaryWithRefinements), + inputPayload: "{}", + }); + const onViewTask = vi.fn(); + render(); + + fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" })); + + expect(await screen.findByRole("button", { name: "View task" })).toBeEnabled(); + expect(screen.getByRole("button", { name: "Return to sessions" })).toBeEnabled(); + }); + + it("restores a linked task into the created-task handoff", async () => { + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "complete", + currentQuestion: null, + result: JSON.stringify(mockSummary), + inputPayload: JSON.stringify({ validated: true, createdTaskId: "FN-001" }), + }); + const onTaskCreated = vi.fn(); + const onViewTask = vi.fn(); + render(); + + expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-001"); + await waitFor(() => expect(onTaskCreated).toHaveBeenCalledWith(mockTasks[0])); + expect(screen.getByRole("button", { name: "View task" })).toBeEnabled(); + }); + + it("waits for a restored linked task before enabling its task handoff", async () => { + mockFetchAiSession.mockResolvedValue({ + ...base, + status: "complete", + currentQuestion: null, + result: JSON.stringify(mockSummary), + inputPayload: JSON.stringify({ validated: true, createdTaskId: "FN-LATER" }), + }); + const onTaskCreated = vi.fn(); + render(); + + expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-LATER"); + expect(screen.getByRole("button", { name: "View task" })).toBeDisabled(); + expect(onTaskCreated).not.toHaveBeenCalled(); }); it("uses full-view Questions and Plan preview tabs on mobile", async () => { diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index 3a2f1a9c04..74495d5c12 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -721,6 +721,7 @@ export function MainContent({ onClose={closePlanningView} onTaskCreated={handlePlanningTaskCreated} onTasksCreated={handlePlanningTasksCreated} + onViewTask={openBoardTaskDetail} tasks={tasks} initialSessions={bgPlanningSessions} initialPlan={modalManager.planningInitialPlan ?? undefined}