feat(FN-4769): show inline spinners in planning-mode modal during creation

Adds inline loading spinners to the planning mode task-creation modal, replacing any placeholder states during the creation flow, with a new planning-flow test covering the spinner behavior.

Fusion-Task-Id: FN-4769
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 11:49:32 -07:00
committed by gsxdsm
parent 52b33c0dfc
commit 93c3975532
3 changed files with 233 additions and 23 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix missing spinner when creating tasks from Planning Mode. The "Create Single Task", "Break into Tasks", and "Create Tasks" buttons now show an inline loading spinner while the async create/breakdown call is in flight, instead of leaving the user staring at an unchanged button or AI-question copy.

View File

@@ -73,8 +73,7 @@ type ViewState =
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
| { type: "error"; session: PlanningSession; errorMessage: string }
| { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean }
| { type: "loading" }
| { type: "creating" };
| { type: "loading" };
const EXAMPLE_PLANS = [
"Build a user authentication system with login and signup",
@@ -169,6 +168,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [showThinking, setShowThinking] = useState(true);
const [isReconnecting, setIsReconnecting] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const [isCreatingTask, setIsCreatingTask] = useState(false);
const [isStartingBreakdown, setIsStartingBreakdown] = useState(false);
const [isCreatingFromBreakdown, setIsCreatingFromBreakdown] = useState(false);
const [generationStartTime, setGenerationStartTime] = useState<number | null>(null);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -1554,7 +1556,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (view.type !== "summary") return;
setError(null);
setView({ type: "loading" });
setIsCreatingTask(true);
try {
const completedSessionId = view.session.sessionId;
@@ -1574,7 +1576,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
handleClose();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create task");
setView({ type: "summary", session: view.session, summary: view.summary });
} finally {
setIsCreatingTask(false);
}
}, [broadcastCompleted, editedSummary, view, projectId, onTaskCreated, handleClose]);
@@ -1582,7 +1585,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (view.type !== "summary") return;
setError(null);
setView({ type: "loading" });
setIsStartingBreakdown(true);
try {
const result = await startPlanningBreakdown(view.session.sessionId, editedSummary ?? undefined, projectId);
@@ -1601,7 +1604,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
});
} catch (err) {
setError(getErrorMessage(err) || "Failed to start breakdown");
setView({ type: "summary", session: view.session, summary: view.summary });
} finally {
setIsStartingBreakdown(false);
}
}, [editedSummary, view, projectId]);
@@ -1609,7 +1613,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
if (view.type !== "breakdown") return;
setError(null);
setView({ type: "creating" });
setIsCreatingFromBreakdown(true);
try {
const completedSessionId = view.sessionId;
@@ -1651,13 +1655,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
handleClose();
} catch (err) {
setError(getErrorMessage(err) || "Failed to create tasks");
setView({
type: "breakdown",
sessionId: view.sessionId,
originalSubtasks: view.originalSubtasks,
subtasks: view.subtasks,
dirty: view.dirty,
});
} finally {
setIsCreatingFromBreakdown(false);
}
}, [broadcastCompleted, handleClose, view, onTasksCreated, projectId]);
@@ -2050,13 +2049,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</div>
)}
{view.type === "creating" && (
<div className="planning-loading">
<Loader2 size={40} className="spin icon-todo" />
<p>Creating tasks...</p>
</div>
)}
{view.type === "question" && view.session.currentQuestion && (
<div className="planning-question">
<QuestionForm
@@ -2080,14 +2072,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
onRefine={() => {
void handleRefineFurther();
}}
isLoading={false}
isLoading={isCreatingTask || isStartingBreakdown}
/>
)}
{view.type === "breakdown" && (
<BreakdownView
subtasks={view.subtasks}
isLoading={false}
isLoading={isCreatingFromBreakdown}
onUpdateSubtasks={(newSubtasks) =>
setView({ ...view, subtasks: newSubtasks, dirty: true })
}

View File

@@ -572,6 +572,219 @@ describe("PlanningModeModal", () => {
});
describe("Resuming complete sessions", () => {
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
it("FN-4769 shows inline Creating spinner for Create Single Task while task creation is pending", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-spinner-single-task",
description: "Recovered summary for spinner",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement", "Verify"],
};
const createTaskDeferred = createDeferred<Task>();
mockFetchAiSession.mockResolvedValueOnce({
id: "session-spinner-single-task",
type: "planning",
status: "complete",
title: "Resume-spinner-single-task",
inputPayload: JSON.stringify({ initialPlan: "Recover and create" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockCreateTaskFromPlanning.mockReturnValueOnce(createTaskDeferred.promise);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-spinner-single-task"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Create Single Task" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Create Single Task" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Creating..." })).toBeDefined();
});
createTaskDeferred.resolve({
id: "FN-4769",
title: "Created from spinner test",
description: "",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
} as Task);
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it("FN-4769 shows inline Breaking down spinner for Break into Tasks while breakdown start is pending", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-spinner-breakdown-start",
description: "Recovered summary for breakdown start spinner",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement", "Verify"],
};
const breakdownDeferred = createDeferred<{ sessionId: string; subtasks: any[] }>();
mockFetchAiSession.mockResolvedValueOnce({
id: "session-spinner-breakdown-start",
type: "planning",
status: "complete",
title: "Resume-spinner-breakdown-start",
inputPayload: JSON.stringify({ initialPlan: "Recover and break down" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockStartPlanningBreakdown.mockReturnValueOnce(breakdownDeferred.promise);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-spinner-breakdown-start"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Breaking down..." })).toBeDefined();
});
breakdownDeferred.resolve({
sessionId: "session-spinner-breakdown-start",
subtasks: [
{
id: "subtask-1",
title: "First subtask",
description: "First description",
suggestedSize: "M",
dependsOn: [],
},
],
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Create Tasks" })).toBeDefined();
});
});
it("FN-4769 shows inline Creating spinner for Create Tasks while breakdown creation is pending", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-spinner-breakdown-create",
description: "Recovered summary for breakdown create spinner",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implement", "Verify"],
};
const createTasksDeferred = createDeferred<{ tasks: Task[] }>();
mockFetchAiSession.mockResolvedValueOnce({
id: "session-spinner-breakdown-create",
type: "planning",
status: "complete",
title: "Resume-spinner-breakdown-create",
inputPayload: JSON.stringify({ initialPlan: "Recover and create tasks" }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(resumedSummary),
thinkingOutput: "",
error: null,
projectId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
mockStartPlanningBreakdown.mockResolvedValueOnce({
sessionId: "session-spinner-breakdown-create",
subtasks: [
{
id: "subtask-1",
title: "First subtask",
description: "First description",
suggestedSize: "M",
dependsOn: [],
},
],
});
mockCreateTasksFromPlanning.mockReturnValueOnce(createTasksDeferred.promise);
render(
<PlanningModeModal
isOpen={true}
onClose={mockOnClose}
onTaskCreated={mockOnTaskCreated}
onTasksCreated={vi.fn()}
tasks={mockTasks}
resumeSessionId="session-spinner-breakdown-create"
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Break into Tasks" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Break into Tasks" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Create Tasks" })).toBeDefined();
});
fireEvent.click(screen.getByRole("button", { name: "Create Tasks" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Creating..." })).toBeDefined();
});
createTasksDeferred.resolve({ tasks: [] });
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it("shows summary view when resuming a complete persisted session", async () => {
const resumedSummary: PlanningSummary = {
title: "Resume-ready planning output",