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:
gsxdsm
2026-07-20 23:35:32 -07:00
parent 6d6bfdf6c1
commit 7b444edc86
5 changed files with 238 additions and 19 deletions

View 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.

View File

@@ -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;

View File

@@ -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">

View File

@@ -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 () => {

View File

@@ -721,6 +721,7 @@ export function MainContent({
onClose={closePlanningView}
onTaskCreated={handlePlanningTaskCreated}
onTasksCreated={handlePlanningTasksCreated}
onViewTask={openBoardTaskDetail}
tasks={tasks}
initialSessions={bgPlanningSessions}
initialPlan={modalManager.planningInitialPlan ?? undefined}