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.
This commit is contained in:
7
.changeset/fix-planning-task-creation-handoff.md
Normal file
7
.changeset/fix-planning-task-creation-handoff.md
Normal file
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Task>): Promise<Task> {
|
||||
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<void>((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" && <div className="planning-loading"><Loader2 size={24} className="spin" /> {t("planning.creatingTask", "Creating task…")}</div>}
|
||||
{view.type === "task_created" && (
|
||||
<div className="planning-loading" data-testid="planning-task-created"><CheckCircle size={24} /> {t("planning.taskCreated", "Task created")}</div>
|
||||
<div className="planning-task-created" data-testid="planning-task-created" role="status" aria-live="polite">
|
||||
<div className="planning-task-created-icon"><CheckCircle size={28} /></div>
|
||||
<div className="planning-task-created-copy">
|
||||
<h4>{t("planning.taskCreated", "Task created")}</h4>
|
||||
<p>{t("planning.taskCreatedHint", "Your approved plan is ready to work on.")}</p>
|
||||
<span className="planning-task-created-id">{view.taskId}</span>
|
||||
</div>
|
||||
<div className="planning-task-created-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!onViewTask || !(view.task ?? tasks.find((candidate) => candidate.id === view.taskId))}
|
||||
onClick={() => {
|
||||
const task = view.task ?? tasks.find((candidate) => candidate.id === view.taskId);
|
||||
if (task) onViewTask?.(task);
|
||||
}}
|
||||
>
|
||||
{t("planning.viewTask", "View task")}
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={handleBackToList}>
|
||||
{t("planning.returnToSessions", "Return to sessions")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{view.type === "create_retry" && (
|
||||
<div className="planning-summary" data-testid="planning-create-retry">
|
||||
|
||||
@@ -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(<PlanningModeModal isOpen onClose={onClose} onTaskCreated={onTaskCreated} onTasksCreated={vi.fn()} onViewTask={onViewTask} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />);
|
||||
|
||||
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(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} onViewTask={onViewTask} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />);
|
||||
|
||||
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(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={onTaskCreated} onTasksCreated={vi.fn()} onViewTask={onViewTask} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />);
|
||||
|
||||
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(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={onTaskCreated} onTasksCreated={vi.fn()} onViewTask={vi.fn()} tasks={[]} projectId="project-1" resumeSessionId="session-1" />);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -721,6 +721,7 @@ export function MainContent({
|
||||
onClose={closePlanningView}
|
||||
onTaskCreated={handlePlanningTaskCreated}
|
||||
onTasksCreated={handlePlanningTasksCreated}
|
||||
onViewTask={openBoardTaskDetail}
|
||||
tasks={tasks}
|
||||
initialSessions={bgPlanningSessions}
|
||||
initialPlan={modalManager.planningInitialPlan ?? undefined}
|
||||
|
||||
Reference in New Issue
Block a user