diff --git a/.changeset/planning-session-loading-state.md b/.changeset/planning-session-loading-state.md
new file mode 100644
index 0000000000..681d5923a1
--- /dev/null
+++ b/.changeset/planning-session-loading-state.md
@@ -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.
diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx
index 6a1f153eaf..93b985f247 100644
--- a/packages/dashboard/app/components/PlanningModeModal.tsx
+++ b/packages/dashboard/app/components/PlanningModeModal.tsx
@@ -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 : 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).
*/}
- {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 ? (
) : (
- <>
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle ? activeSessionTitle : t("planning.title", "Planning Mode")}
- {selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle && }>
+ <>{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "session_loading" || view.type === "error") && activeSessionTitle ? activeSessionTitle : t("planning.title", "Planning Mode")}
+ {selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "session_loading" || view.type === "error") && activeSessionTitle && }>
)}
{/*
@@ -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") && (