fix(planning): start and create tasks directly
This commit is contained in:
7
.changeset/fix-mobile-planning-start.md
Normal file
7
.changeset/fix-mobile-planning-start.md
Normal 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.
|
||||
@@ -306,7 +306,6 @@ export async function claimPlanningSessionTaskCreation(
|
||||
const existing = await getAiSession(handle, sessionId);
|
||||
if (!existing || existing.type !== "planning") return null;
|
||||
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 rows = await handle.update(schema.project.aiSessions)
|
||||
.set({ inputPayload, updatedAt: claimStartedAt })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "./PlanningModeModal.css";
|
||||
import { useTranslation } from "react-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 remarkGfm from "remark-gfm";
|
||||
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 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:
|
||||
FN-8332 permits automatic retry only for a generation this mounted Planning
|
||||
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);
|
||||
setPlanningThinkingLevel("");
|
||||
currentSessionIdRef.current = null;
|
||||
startPlanningInFlightRef.current = false;
|
||||
}, [resetPlanningAutoRetryBudget]);
|
||||
|
||||
const planningSelectionValue = getModelSelectionValue(planningModelProvider, planningModelId);
|
||||
@@ -1343,7 +1352,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const handleStartPlanning = useCallback(async (planOverride?: string) => {
|
||||
const plan = planOverride ?? initialPlan;
|
||||
const startedPlan = plan.trim();
|
||||
if (!startedPlan) return;
|
||||
if (!startedPlan || startPlanningInFlightRef.current) return;
|
||||
startPlanningInFlightRef.current = true;
|
||||
|
||||
setActivePlanPrompt(startedPlan);
|
||||
setError(null);
|
||||
@@ -1409,6 +1419,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
connectToPlanningStream(sessionId);
|
||||
setResponseHistory([]);
|
||||
} catch (err) {
|
||||
startPlanningInFlightRef.current = false;
|
||||
draftCreatePromiseRef.current = null;
|
||||
draftCreateInFlightRef.current = false;
|
||||
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]);
|
||||
|
||||
const handleValidatePlan = useCallback(async () => {
|
||||
const handleProceedWithPlan = useCallback(async () => {
|
||||
const sessionId = currentSessionIdRef.current;
|
||||
const summary = runningSummaryRef.current;
|
||||
if (!sessionId || !summary || validateCreateInFlightRef.current) return;
|
||||
@@ -2410,29 +2421,26 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
validateCreateInFlightRef.current = true;
|
||||
setError(null);
|
||||
setView({ type: "creating_task", session, summary });
|
||||
let validationCompleted = false;
|
||||
try {
|
||||
const validated = await validatePlanningSession(sessionId, projectId);
|
||||
validationCompleted = true;
|
||||
const validatedSummary = normalizePlanningSummary(validated.summary);
|
||||
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(sessionId, validatedSummary, projectId, {
|
||||
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(sessionId, summary, projectId, {
|
||||
...(workflowId !== undefined ? { workflowId } : {}),
|
||||
}));
|
||||
clearPlanningActiveSession(projectId);
|
||||
setView({ type: "task_created", taskId: task.id, task });
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task");
|
||||
if (validationCompleted) {
|
||||
setView({ type: "create_retry", session, summary, errorMessage });
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
setView({ type: "plan_review", session, summary });
|
||||
}
|
||||
setView({ type: "create_retry", session, summary, errorMessage });
|
||||
} finally {
|
||||
validateCreateInFlightRef.current = false;
|
||||
}
|
||||
}, [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 () => {
|
||||
if (view.type !== "create_retry" || validateCreateInFlightRef.current) return;
|
||||
validateCreateInFlightRef.current = true;
|
||||
@@ -2690,7 +2698,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
>
|
||||
{t("planning.refine", "Refine")}
|
||||
</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>
|
||||
</section>
|
||||
);
|
||||
@@ -3063,7 +3071,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
<div className="planning-view-footer">
|
||||
<button
|
||||
className="btn btn-primary planning-start-btn"
|
||||
onClick={() => handleStartPlanning()}
|
||||
onPointerDown={handleStartPlanningPointerDown}
|
||||
onClick={() => void handleStartPlanning()}
|
||||
disabled={!initialPlan.trim()}
|
||||
>
|
||||
<Lightbulb size={16} className="icon-mr-8" />
|
||||
|
||||
@@ -129,6 +129,7 @@ describe("PlanningModeModal autosize", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockAddToast.mockReset();
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
|
||||
@@ -153,6 +154,26 @@ describe("PlanningModeModal autosize", () => {
|
||||
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
|
||||
mockStopPlanningGeneration.mockResolvedValue({ success: true });
|
||||
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 () => {
|
||||
|
||||
@@ -167,13 +167,13 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
|
||||
|
||||
await waitFor(() => expect(mockValidatePlanningSession).toHaveBeenCalledWith("session-1", "project-1"));
|
||||
await waitFor(() => expect(mockCreateTaskFromPlanning).toHaveBeenCalledWith(
|
||||
"session-1",
|
||||
expect.objectContaining({ title: mockSummary.title }),
|
||||
"project-1",
|
||||
{},
|
||||
));
|
||||
expect(mockValidatePlanningSession).not.toHaveBeenCalled();
|
||||
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" });
|
||||
|
||||
@@ -209,18 +209,12 @@ describe("Planning Mode plan creation E2E", () => {
|
||||
__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" });
|
||||
expect(start.status).toBe(201);
|
||||
expectRunningPlan(start.body);
|
||||
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 });
|
||||
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({
|
||||
|
||||
@@ -3329,7 +3329,7 @@ export async function getDurablePlanningSession(sessionId: string): Promise<Sess
|
||||
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> {
|
||||
if (!_aiSessionStore || typeof (_aiSessionStore as unknown as { claimPlanningTaskCreation?: unknown }).claimPlanningTaskCreation !== "function") {
|
||||
const session = await getSession(sessionId);
|
||||
|
||||
@@ -1163,7 +1163,6 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
} = await import("../planning.js");
|
||||
|
||||
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 initialPlan = session?.initialPlan;
|
||||
|
||||
@@ -1177,18 +1176,14 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
throw notFound(`Planning session ${sessionId} not found or expired`);
|
||||
}
|
||||
|
||||
const persistedInput = JSON.parse(persistedSession.inputPayload) as { validated?: unknown };
|
||||
if (persistedSession.status !== "complete" || persistedInput.validated !== true) {
|
||||
throw badRequest("Planning session must be validated before creating tasks");
|
||||
}
|
||||
|
||||
if (!persistedSession.result) {
|
||||
const persistedResult = persistedSession.result;
|
||||
if (!summaryOverride && !persistedResult) {
|
||||
throw badRequest("Planning session result is not available");
|
||||
}
|
||||
|
||||
if (!summaryOverride) {
|
||||
try {
|
||||
const parsedSummary = JSON.parse(persistedSession.result) as {
|
||||
const parsedSummary = JSON.parse(persistedResult as string) as {
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
suggestedSize?: unknown;
|
||||
@@ -1297,7 +1292,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
*/
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
const planMd = formatPlanningPlanMd(summary);
|
||||
|
||||
Reference in New Issue
Block a user