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:
gsxdsm
2026-07-23 09:55:52 -07:00
parent b4856e3c1e
commit 5a5796bca2
3 changed files with 75 additions and 7 deletions

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

View File

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

View File

@@ -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" }));