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:
committed by
gsxdsm
parent
52b33c0dfc
commit
93c3975532
5
.changeset/fn-4769-planning-mode-spinner.md
Normal file
5
.changeset/fn-4769-planning-mode-spinner.md
Normal 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.
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user