fix(planning): start and create tasks directly

This commit is contained in:
gsxdsm
2026-07-21 00:04:48 -07:00
parent e74183359a
commit 68a7f3f0e8
8 changed files with 60 additions and 35 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Start planning on the first mobile tap and create tasks without a separate validation step.
category: fix
dev: Handles touch activation before keyboard viewport resize and removes the planning create validation gate.

View File

@@ -306,7 +306,6 @@ export async function claimPlanningSessionTaskCreation(
const existing = await getAiSession(handle, sessionId); const existing = await getAiSession(handle, sessionId);
if (!existing || existing.type !== "planning") return null; if (!existing || existing.type !== "planning") return null;
const input = safeJsonParse(existing.inputPayload, {}) as Record<string, unknown>; const input = safeJsonParse(existing.inputPayload, {}) as Record<string, unknown>;
if (input.validated !== true) return null;
const inputPayload = { ...input, createClaimStatus: "creating", claimOwnerToken, claimStartedAt, createdTaskId: undefined }; const inputPayload = { ...input, createClaimStatus: "creating", claimOwnerToken, claimStartedAt, createdTaskId: undefined };
const rows = await handle.update(schema.project.aiSessions) const rows = await handle.update(schema.project.aiSessions)
.set({ inputPayload, updatedAt: claimStartedAt }) .set({ inputPayload, updatedAt: claimStartedAt })

View File

@@ -1,7 +1,7 @@
import "./PlanningModeModal.css"; import "./PlanningModeModal.css";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { useState, useCallback, useEffect, useRef, useMemo, type MouseEvent } from "react"; import { useState, useCallback, useEffect, useRef, useMemo, type MouseEvent, type PointerEvent as ReactPointerEvent } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { Task, PlanningQuestion, PlanningSummary, TaskPriority, ThinkingLevel } from "@fusion/core"; import type { Task, PlanningQuestion, PlanningSummary, TaskPriority, ThinkingLevel } from "@fusion/core";
@@ -443,6 +443,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const currentSessionIdRef = useRef<string | null>(null); const currentSessionIdRef = useRef<string | null>(null);
const viewRef = useRef<ViewState>({ type: "initial" }); const viewRef = useRef<ViewState>({ type: "initial" });
/* /*
FNXC:PlanningMode 2026-07-20-23:52:
Mobile touch starts on pointer-down because closing the software keyboard resizes the
visual viewport before click, which can move the button and cancel that first click.
Keep a synchronous single-flight guard so touch compatibility events and rapid input
cannot start two planning sessions.
*/
const startPlanningInFlightRef = useRef(false);
/*
FNXC:PlanningRetry 2026-07-15-00:00: FNXC:PlanningRetry 2026-07-15-00:00:
FN-8332 permits automatic retry only for a generation this mounted Planning FN-8332 permits automatic retry only for a generation this mounted Planning
Mode instance started. A reloaded session may reconnect to observe a server Mode instance started. A reloaded session may reconnect to observe a server
@@ -968,6 +976,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setPlanningModelId(undefined); setPlanningModelId(undefined);
setPlanningThinkingLevel(""); setPlanningThinkingLevel("");
currentSessionIdRef.current = null; currentSessionIdRef.current = null;
startPlanningInFlightRef.current = false;
}, [resetPlanningAutoRetryBudget]); }, [resetPlanningAutoRetryBudget]);
const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId); const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId);
@@ -1343,7 +1352,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const handleStartPlanning = useCallback(async (planOverride?: string) => { const handleStartPlanning = useCallback(async (planOverride?: string) => {
const plan = planOverride ?? initialPlan; const plan = planOverride ?? initialPlan;
const startedPlan = plan.trim(); const startedPlan = plan.trim();
if (!startedPlan) return; if (!startedPlan || startPlanningInFlightRef.current) return;
startPlanningInFlightRef.current = true;
setActivePlanPrompt(startedPlan); setActivePlanPrompt(startedPlan);
setError(null); setError(null);
@@ -1409,6 +1419,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
connectToPlanningStream(sessionId); connectToPlanningStream(sessionId);
setResponseHistory([]); setResponseHistory([]);
} catch (err) { } catch (err) {
startPlanningInFlightRef.current = false;
draftCreatePromiseRef.current = null; draftCreatePromiseRef.current = null;
draftCreateInFlightRef.current = false; draftCreateInFlightRef.current = false;
setError(getErrorMessage(err) || t("planning.failedStartSession", "Failed to start planning session")); setError(getErrorMessage(err) || t("planning.failedStartSession", "Failed to start planning session"));
@@ -2402,7 +2413,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
} }
}, [projectId, refinementInstructions, t, workspaceQuestion]); }, [projectId, refinementInstructions, t, workspaceQuestion]);
const handleValidatePlan = useCallback(async () => { const handleProceedWithPlan = useCallback(async () => {
const sessionId = currentSessionIdRef.current; const sessionId = currentSessionIdRef.current;
const summary = runningSummaryRef.current; const summary = runningSummaryRef.current;
if (!sessionId || !summary || validateCreateInFlightRef.current) return; if (!sessionId || !summary || validateCreateInFlightRef.current) return;
@@ -2410,29 +2421,26 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
validateCreateInFlightRef.current = true; validateCreateInFlightRef.current = true;
setError(null); setError(null);
setView({ type: "creating_task", session, summary }); setView({ type: "creating_task", session, summary });
let validationCompleted = false;
try { try {
const validated = await validatePlanningSession(sessionId, projectId); const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(sessionId, summary, projectId, {
validationCompleted = true;
const validatedSummary = normalizePlanningSummary(validated.summary);
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(sessionId, validatedSummary, projectId, {
...(workflowId !== undefined ? { workflowId } : {}), ...(workflowId !== undefined ? { workflowId } : {}),
})); }));
clearPlanningActiveSession(projectId); clearPlanningActiveSession(projectId);
setView({ type: "task_created", taskId: task.id, task }); setView({ type: "task_created", taskId: task.id, task });
} catch (err) { } catch (err) {
const errorMessage = getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task"); const errorMessage = getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task");
if (validationCompleted) { setView({ type: "create_retry", session, summary, errorMessage });
setView({ type: "create_retry", session, summary, errorMessage });
} else {
setError(errorMessage);
setView({ type: "plan_review", session, summary });
}
} finally { } finally {
validateCreateInFlightRef.current = false; validateCreateInFlightRef.current = false;
} }
}, [projectId, t, workflowId, workspaceQuestion]); }, [projectId, t, workflowId, workspaceQuestion]);
const handleStartPlanningPointerDown = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
if (viewportMode !== "mobile" || event.pointerType === "mouse") return;
event.preventDefault();
void handleStartPlanning();
}, [handleStartPlanning, viewportMode]);
const handleRetryCreateTask = useCallback(async () => { const handleRetryCreateTask = useCallback(async () => {
if (view.type !== "create_retry" || validateCreateInFlightRef.current) return; if (view.type !== "create_retry" || validateCreateInFlightRef.current) return;
validateCreateInFlightRef.current = true; validateCreateInFlightRef.current = true;
@@ -2690,7 +2698,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
> >
{t("planning.refine", "Refine")} {t("planning.refine", "Refine")}
</button> </button>
<button type="button" className="btn btn-primary" onClick={() => void handleValidatePlan()}>{t("planning.proceedWithPlan", "Proceed with plan")}</button> <button type="button" className="btn btn-primary" onClick={() => void handleProceedWithPlan()}>{t("planning.proceedWithPlan", "Proceed with plan")}</button>
</div> </div>
</section> </section>
); );
@@ -3063,7 +3071,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<div className="planning-view-footer"> <div className="planning-view-footer">
<button <button
className="btn btn-primary planning-start-btn" className="btn btn-primary planning-start-btn"
onClick={() => handleStartPlanning()} onPointerDown={handleStartPlanningPointerDown}
onClick={() => void handleStartPlanning()}
disabled={!initialPlan.trim()} disabled={!initialPlan.trim()}
> >
<Lightbulb size={16} className="icon-mr-8" /> <Lightbulb size={16} className="icon-mr-8" />

View File

@@ -129,6 +129,7 @@ describe("PlanningModeModal autosize", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
localStorage.clear();
mockAddToast.mockReset(); mockAddToast.mockReset();
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" }); mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
@@ -153,6 +154,26 @@ describe("PlanningModeModal autosize", () => {
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true }); mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
mockStopPlanningGeneration.mockResolvedValue({ success: true }); mockStopPlanningGeneration.mockResolvedValue({ success: true });
mockConnectPlanningStream.mockReturnValue({ close: vi.fn(), isConnected: vi.fn().mockReturnValue(true) } as any); mockConnectPlanningStream.mockReturnValue({ close: vi.fn(), isConnected: vi.fn().mockReturnValue(true) } as any);
mockUseViewportMode.mockReturnValue("desktop");
mockUseMobileKeyboard.mockReturnValue({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false });
});
it("starts planning on the first mobile touch while the keyboard is open", async () => {
mockUseViewportMode.mockReturnValue("mobile");
mockUseMobileKeyboard.mockReturnValue({ keyboardOverlap: 320, viewportHeight: 480, viewportOffsetTop: 0, keyboardOpen: true });
render(<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} />);
fireEvent.change(screen.getByPlaceholderText(/Build a user authentication/i), { target: { value: "Build a mobile-first dashboard" } });
fireEvent.pointerDown(screen.getByRole("button", { name: "Start Planning" }), { pointerType: "touch" });
await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledWith(
"Build a mobile-first dashboard",
undefined,
undefined,
{ clarificationEnabled: true },
"draft-123",
));
expect(screen.getByText("Generating initial plan…")).toBeInTheDocument();
}); });
it("grows initial planning textarea and caps at max", async () => { it("grows initial planning textarea and caps at max", async () => {

View File

@@ -167,13 +167,13 @@ describe("PlanningModeModal sequential flow", () => {
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" })); fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
await waitFor(() => expect(mockValidatePlanningSession).toHaveBeenCalledWith("session-1", "project-1"));
await waitFor(() => expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith( await waitFor(() => expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith(
"session-1", "session-1",
expect.objectContaining({ title: mockSummary.title }), expect.objectContaining({ title: mockSummary.title }),
"project-1", "project-1",
{}, {},
)); ));
expect(mockValidatePlanningSession).not.toHaveBeenCalled();
expect(screen.queryByRole("heading", { name: "Review your plan" })).toBeNull(); expect(screen.queryByRole("heading", { name: "Review your plan" })).toBeNull();
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-8442"); expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-8442");
expect(onTaskCreated).toHaveBeenCalledWith({ id: "FN-8442" }); expect(onTaskCreated).toHaveBeenCalledWith({ id: "FN-8442" });

View File

@@ -209,18 +209,12 @@ describe("Planning Mode plan creation E2E", () => {
__setCreateFnAgent(undefined as never); __setCreateFnAgent(undefined as never);
}); });
it("requires validation before converting the lean running plan into a task", async () => { it("converts the lean running plan into a task without a separate validation step", async () => {
const start = await post(app, "/api/planning/start", { initialPlan: "Build secure account recovery" }); const start = await post(app, "/api/planning/start", { initialPlan: "Build secure account recovery" });
expect(start.status).toBe(201); expect(start.status).toBe(201);
expectRunningPlan(start.body); expectRunningPlan(start.body);
const sessionId = start.body.sessionId as string; const sessionId = start.body.sessionId as string;
const createBeforeValidation = await post(app, "/api/planning/create-task", { sessionId });
expect(createBeforeValidation.status).toBe(400);
const validate = await post(app, `/api/planning/${sessionId}/validate`, {});
expect(validate).toMatchObject({ status: 200, body: { validated: true } });
const created = await post(app, "/api/planning/create-task", { sessionId }); const created = await post(app, "/api/planning/create-task", { sessionId });
expect(created).toMatchObject({ status: 201, body: { task: { id: "FN-E2E-001", title: "Plan: Build secure account recovery" }, alreadyCreated: false } }); expect(created).toMatchObject({ status: 201, body: { task: { id: "FN-E2E-001", title: "Plan: Build secure account recovery" }, alreadyCreated: false } });
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({ expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({

View File

@@ -3329,7 +3329,7 @@ export async function getDurablePlanningSession(sessionId: string): Promise<Sess
return row?.type === "planning" ? restoreClaimSession(row) : undefined; return row?.type === "planning" ? restoreClaimSession(row) : undefined;
} }
/** Atomically claim a validated planning session for its one task creation. */ /** Atomically claim a planning session for its one task creation. */
export async function claimPlanningTaskCreation(sessionId: string, ownerToken: string, startedAt: string): Promise<Session | undefined> { export async function claimPlanningTaskCreation(sessionId: string, ownerToken: string, startedAt: string): Promise<Session | undefined> {
if (!_aiSessionStore || typeof (_aiSessionStore as unknown as { claimPlanningTaskCreation?: unknown }).claimPlanningTaskCreation !== "function") { if (!_aiSessionStore || typeof (_aiSessionStore as unknown as { claimPlanningTaskCreation?: unknown }).claimPlanningTaskCreation !== "function") {
const session = await getSession(sessionId); const session = await getSession(sessionId);

View File

@@ -1163,7 +1163,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
} = await import("../planning.js"); } = await import("../planning.js");
let session = await getSession(sessionId); let session = await getSession(sessionId);
if (session && !session.validated) throw badRequest("Planning session must be validated before creating tasks");
let summary = summaryOverride ?? getSummary(sessionId); let summary = summaryOverride ?? getSummary(sessionId);
let initialPlan = session?.initialPlan; let initialPlan = session?.initialPlan;
@@ -1177,18 +1176,14 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
throw notFound(`Planning session ${sessionId} not found or expired`); throw notFound(`Planning session ${sessionId} not found or expired`);
} }
const persistedInput = JSON.parse(persistedSession.inputPayload) as { validated?: unknown }; const persistedResult = persistedSession.result;
if (persistedSession.status !== "complete" || persistedInput.validated !== true) { if (!summaryOverride && !persistedResult) {
throw badRequest("Planning session must be validated before creating tasks");
}
if (!persistedSession.result) {
throw badRequest("Planning session result is not available"); throw badRequest("Planning session result is not available");
} }
if (!summaryOverride) { if (!summaryOverride) {
try { try {
const parsedSummary = JSON.parse(persistedSession.result) as { const parsedSummary = JSON.parse(persistedResult as string) as {
title?: unknown; title?: unknown;
description?: unknown; description?: unknown;
suggestedSize?: unknown; suggestedSize?: unknown;
@@ -1297,7 +1292,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
*/ */
/* /*
FNXC:PlanningMode 2026-07-20-12:00: FNXC:PlanningMode 2026-07-20-12:00:
FN-8441 hands the validated lean plan to triage as task description plus a plan FN-8441 hands the current lean plan to triage as task description plus a plan
document. The raw session request remains a separate original-description document. document. The raw session request remains a separate original-description document.
*/ */
const planMd = formatPlanningPlanMd(summary); const planMd = formatPlanningPlanMd(summary);