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);
|
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 })
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user