fix: show session loader instead of Generating while planning mode hydrates from DB
Reloading Planning re-entered the generation view ("Generating initial
plan…", Stop button, elapsed timer, 8s watchdog) while merely fetching a
persisted session. A new session_loading view state renders a neutral
"Loading session…" spinner during hydration; the generating view is
reserved for sessions the server reports as generating. Unrecognized
persisted session shapes now land in the retryable error view instead of
spinning forever.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/planning-session-loading-state.md
Normal file
7
.changeset/planning-session-loading-state.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Planning mode now shows a neutral session loader while restoring a saved session instead of "Generating…".
|
||||
category: fix
|
||||
dev: New `session_loading` view state in PlanningModeModal; generating copy, Stop button, elapsed timer, and the missed-SSE watchdog are reserved for sessions the server reports as generating. Unrecognized persisted session shapes land in the retryable error view instead of spinning forever.
|
||||
@@ -166,7 +166,16 @@ type ViewState =
|
||||
| { 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" };
|
||||
| { type: "loading" }
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-23-00:00:
|
||||
Fetching a persisted session from the database is not generation. `session_loading` renders a
|
||||
neutral "Loading session…" spinner during that fetch; the `loading` state (with its
|
||||
"Generating…" copy, Stop button, elapsed timer, and 8s missed-SSE watchdog) is reserved for
|
||||
turns the server is actually generating. Before this split, every reload/reopen flashed
|
||||
"Generating initial plan…" while merely hydrating from the DB.
|
||||
*/
|
||||
| { type: "session_loading" };
|
||||
|
||||
type PlanningGenerationActivity = "initial_plan" | "plan_update" | "question";
|
||||
|
||||
@@ -1621,8 +1630,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setIsRefiningSummary(false);
|
||||
refineSummaryInFlightRef.current = false;
|
||||
setGenerationStartTime(null);
|
||||
viewRef.current = { type: "loading" };
|
||||
setView({ type: "loading" });
|
||||
// FNXC:PlanningMode 2026-07-23-00:00: hydrate-from-DB shows the neutral session loader,
|
||||
// not the generation pane — only a fetched status of "generating" enters `loading` below.
|
||||
viewRef.current = { type: "session_loading" };
|
||||
setView({ type: "session_loading" });
|
||||
|
||||
try {
|
||||
const session = await fetchAiSession(sessionId);
|
||||
@@ -1817,6 +1828,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
// thinkingOutput — the stream replay reconstructs the loading view exactly once;
|
||||
// seeding here and then replaying doubled the visible output on every reload.
|
||||
connectToPlanningStream(sessionId);
|
||||
} else {
|
||||
// FNXC:PlanningMode 2026-07-23-00:00: a persisted row none of the branches above
|
||||
// recognize (e.g. awaiting_input with neither question nor summary, or complete
|
||||
// without a result) used to strand the modal on the generation spinner forever.
|
||||
// Surface it as a retryable error instead of an indefinite loader.
|
||||
setView({
|
||||
type: "error",
|
||||
session: { sessionId, currentQuestion: null, summary: persistedRunningSummary },
|
||||
errorMessage: t("planning.sessionUnrecoverableState", "This session could not be restored. Retry to continue the interview."),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (planningSessionLoadEpochRef.current !== loadEpoch || currentSessionIdRef.current !== sessionId) return;
|
||||
@@ -3082,7 +3103,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
Header icon mirrors MissionManager's <Target size={20} className="mission-manager__header-icon" />: same size (20) and same var(--todo) tint + flex-shrink:0, applied via the scoped .planning-modal--embedded .modal-header--embedded .detail-title-row > svg rule (it overrides the shared icon-triage brown so the two headers read as siblings).
|
||||
*/}
|
||||
<Lightbulb size={20} className="icon-triage" />
|
||||
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle && isRenamingSession ? (
|
||||
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "session_loading" || view.type === "error") && activeSessionTitle && isRenamingSession ? (
|
||||
<input
|
||||
className="input planning-session-title-input"
|
||||
aria-label={t("planning.renameSession", "Rename session")}
|
||||
@@ -3093,8 +3114,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<><h3>{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle ? activeSessionTitle : t("planning.title", "Planning Mode")}</h3>
|
||||
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle && <button type="button" className="btn-icon" aria-label={t("planning.renameSession", "Rename session")} onClick={() => { setSessionTitleDraft(activeSessionTitle); setIsRenamingSession(true); }}><Pencil /></button>}</>
|
||||
<><h3>{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "session_loading" || view.type === "error") && activeSessionTitle ? activeSessionTitle : t("planning.title", "Planning Mode")}</h3>
|
||||
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "session_loading" || view.type === "error") && activeSessionTitle && <button type="button" className="btn-icon" aria-label={t("planning.renameSession", "Rename session")} onClick={() => { setSessionTitleDraft(activeSessionTitle); setIsRenamingSession(true); }}><Pencil /></button>}</>
|
||||
)}
|
||||
</div>
|
||||
{/*
|
||||
@@ -3103,7 +3124,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
title-row Back control on every viewport, avoiding a duplicate Sessions toggle and keeping
|
||||
compact list/detail state synchronized through one handler.
|
||||
*/}
|
||||
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error" || view.type === "plan_review" || view.type === "create_retry") && (
|
||||
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "session_loading" || view.type === "error" || view.type === "plan_review" || view.type === "create_retry") && (
|
||||
<div className="planning-header-controls">
|
||||
<button
|
||||
ref={historyTriggerRef}
|
||||
@@ -3409,6 +3430,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "session_loading" && (
|
||||
<div className="planning-loading" data-testid="planning-session-loading" role="status" aria-live="polite">
|
||||
<Loader2 size={40} className="spin icon-todo" />
|
||||
<p>{t("planning.loadingSession", "Loading session…")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "loading" && !runningSummary && (
|
||||
<div className="planning-loading">
|
||||
<Loader2 size={40} className="spin icon-todo" />
|
||||
|
||||
@@ -720,6 +720,31 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith("session-1", { refine: true, focus: "Add migration sequencing and ask about rollout risks." }, "project-1"));
|
||||
expect(await screen.findByText("Which migration risk should come first?")).toBeInTheDocument();
|
||||
});
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-23-00:00:
|
||||
Hydrating a persisted session from the database is not generation. While the fetch is in
|
||||
flight the modal must show the neutral session loader; the "Generating…" copy (and its Stop
|
||||
affordance) is reserved for sessions the server reports as actually generating.
|
||||
*/
|
||||
it("shows a session loader, not generating copy, while a persisted session hydrates", async () => {
|
||||
let resolveFetch!: (session: Record<string, unknown>) => void;
|
||||
mockFetchAiSession.mockReturnValue(new Promise((resolve) => { resolveFetch = resolve; }));
|
||||
renderSession();
|
||||
|
||||
expect(await screen.findByTestId("planning-session-loading")).toHaveTextContent("Loading session…");
|
||||
expect(screen.queryByText(/Generating/)).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
|
||||
|
||||
resolveFetch({
|
||||
...base,
|
||||
status: "awaiting_input",
|
||||
currentQuestion: JSON.stringify({ id: "q-1", type: "text", question: "What should the plan prioritize?" }),
|
||||
result: JSON.stringify(summaryWithRefinements),
|
||||
inputPayload: "{}",
|
||||
});
|
||||
expect(await screen.findByText("What should the plan prioritize?")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("planning-session-loading")).toBeNull();
|
||||
});
|
||||
it("restores the updating-plan progress state after refresh", async () => {
|
||||
mockFetchAiSession.mockResolvedValue({ ...base, status: "generating", currentQuestion: null, result: JSON.stringify(summaryWithRefinements), inputPayload: JSON.stringify({ generationPurpose: "plan_update" }) });
|
||||
renderSession();
|
||||
@@ -819,6 +844,14 @@ describe("PlanningModeModal sequential flow", () => {
|
||||
renderSession();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-23-00:00:
|
||||
Wait for the stop to settle into plan review before grabbing Refine. Clicking the workspace
|
||||
pane's Refine while the stop transition remounts the plan pane dispatches on a detached node
|
||||
and the refinement menu never opens.
|
||||
*/
|
||||
await waitFor(() => expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-1", "project-1"));
|
||||
await screen.findByTestId("planning-plan-review");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Refine" }));
|
||||
fireEvent.change(screen.getByLabelText("Refinement instructions"), { target: { value: "Focus the next questions on rollout." } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply refinement" }));
|
||||
|
||||
Reference in New Issue
Block a user