diff --git a/.changeset/fn-8437-planning-leave-return-restore.md b/.changeset/fn-8437-planning-leave-return-restore.md new file mode 100644 index 0000000000..49097444e1 --- /dev/null +++ b/.changeset/fn-8437-planning-leave-return-restore.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Restore in-progress Planning Mode interviews after leave/return, including mid-generation. +category: fix +dev: Persists last-active planning session id project-scoped; remount rehydrates via loadSession without auto-retry. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5ef08b889e..57b2fdca56 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -523,7 +523,7 @@ Planning is a desktop/tablet left-sidebar main-content destination after **Comma When a Planning session needs your input or needs attention, open the docked Planning view from the **Planning** navigation item. Its yellow needs-input dot is visible on the desktop left sidebar and mobile More controls. Non-planning in-progress, needs-input, and error sessions appear in the session notification banner, where available Resume actions reconnect to their matching surface. -When an active Planning AI generation appears stuck, Planning Mode automatically retries the same session up to three times and shows **Retrying… (attempt N of 3)** before falling back to the permanent **Retry**/**Dismiss** error panel. Any successful question or summary progress resets the automatic retry budget. Reopening or reloading a saved Planning session restores its saved question, summary, thinking, or error without starting another generation; choose **Retry** explicitly from a restored error panel if you want to run it again. +When an active Planning AI generation appears stuck, Planning Mode automatically retries the same session up to three times and shows **Retrying… (attempt N of 3)** before falling back to the permanent **Retry**/**Dismiss** error panel. Any successful question or summary progress resets the automatic retry budget. Leaving Planning—including while a next question is generating—and returning restores the last active interview for that project: a generation reconnects with its saved thinking, while a turn that completed while away shows its new question and running plan. Reopening or reloading a saved Planning session restores its saved question, summary, thinking, or error without starting another generation; choose **Retry** explicitly from a restored error panel if you want to run it again. Use **New session** to restart planning with a different idea. diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index 6425973b25..f09ae9ec02 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -49,6 +49,9 @@ import { savePlanningDescription, getPlanningDescription, clearPlanningDescription, + savePlanningActiveSession, + getPlanningActiveSession, + clearPlanningActiveSession, } from "../hooks/modalPersistence"; import { getRelativeTimeBucket } from "../utils/relativeTimeAgo"; import { Lightbulb, X, Loader2, CheckCircle, ArrowLeft, ArrowRight, Sparkles, ListTree, GripVertical, ArrowUp, ArrowDown, Plus, Trash2, RefreshCw, ChevronLeft, MessageSquarePlus, AlertCircle, Clock, HelpCircle, StopCircle, Archive, ArchiveRestore, Pencil } from "lucide-react"; @@ -425,6 +428,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // identity change (e.g. typing into the textarea recreates loadSession) and // yanks the user back into the previous session's question view. const dismissedResumeRef = useRef(null); + // A mount only needs one storage-backed resume decision. Re-reading after a + // user intentionally starts fresh would otherwise pull the old interview + // back in when unrelated callbacks change identity. + const hasAttemptedStoredResumeRef = useRef(false); useEffect(() => { viewRef.current = view; @@ -1320,6 +1327,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // The session was deleted (commonly: this tab just turned it into // tasks via Create Task / Create Tasks). Quietly fall back to the // new-session view rather than surfacing a scary error banner. + clearPlanningActiveSession(projectId); setSelectedSessionId(null); setMobileShowDetail(false); setActivePlanPrompt(""); @@ -1476,7 +1484,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat }); } }, - [connectToPlanningStream, projectId, resetPlanningAutoRetryBudget], + [connectToPlanningStream, projectId, resetPlanningAutoRetryBudget, t], ); // Resume the externally-requested session when the modal first opens. @@ -1494,6 +1502,28 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat void loadSession(resumeSessionId); }, [isOpen, resumeSessionId]); + // Restore the persisted active interview for ordinary Planning navigation. + // Explicit resume props and seeded opens own their destination and must not + // be replaced by a prior session. + useEffect(() => { + if (!isOpen || resumeSessionId || initialPlanProp || selectedSessionId || hasAttemptedStoredResumeRef.current) return; + hasAttemptedStoredResumeRef.current = true; + const storedSessionId = getPlanningActiveSession(projectId); + if (!storedSessionId || dismissedResumeRef.current === storedSessionId) return; + setSelectedSessionId(storedSessionId); + setMobileShowDetail(true); + void loadSession(storedSessionId); + }, [initialPlanProp, isOpen, projectId, resumeSessionId, selectedSessionId]); + + // Keep the focused interview durable before embedded Planning unmounts on a + // main-content navigation change. Selection writes cover starts, sidebar + // picks, explicit resumes, and storage-backed restores with one authority. + useEffect(() => { + if (selectedSessionId) { + savePlanningActiveSession(selectedSessionId, projectId); + } + }, [projectId, selectedSessionId]); + // Re-sync the selected session whenever the planning screen is shown. // loadSession tears down any existing stream and reconnects, so the right // view always reflects the freshest server state for whatever row is @@ -1622,13 +1652,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat if (resumeSessionId) { dismissedResumeRef.current = resumeSessionId; } + clearPlanningActiveSession(projectId); const preserveActiveDraft = selectedSessionId === null && viewRef.current.type === "initial"; resetDetailState({ preserveInitialPlan: preserveActiveDraft }); setSelectedSessionId(null); setShowSessionList(false); setMobileShowDetail(true); setNewSessionFocusSignal((signal) => signal + 1); - }, [resetDetailState, resumeSessionId, selectedSessionId]); + }, [projectId, resetDetailState, resumeSessionId, selectedSessionId]); const handleBackToList = useCallback(() => { setShowSessionList(true); @@ -1782,6 +1813,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat streamConnectionRef.current?.close(); streamConnectionRef.current = null; resetDetailState(); + clearPlanningActiveSession(projectId); setSelectedSessionId(null); setMobileShowDetail(false); } @@ -1820,6 +1852,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat streamConnectionRef.current?.close(); streamConnectionRef.current = null; resetDetailState(); + clearPlanningActiveSession(projectId); setSelectedSessionId(null); setMobileShowDetail(false); } @@ -2142,6 +2175,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // Single-task creation should preserve completed planning history, so // only clear the active selection before closing; keep the sidebar row // in local state to match persisted server truth. + clearPlanningActiveSession(projectId); setSelectedSessionId(null); handleClose(); } catch (err) { @@ -2221,6 +2255,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setPlanningModelId(undefined); setPlanningThinkingLevel(""); currentSessionIdRef.current = null; + clearPlanningActiveSession(projectId); setSelectedSessionId(null); handleClose(); } catch (err) { @@ -2684,7 +2719,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat {isRetrying ? : } {isRetrying ? t("planning.retrying", "Retrying...") : t("common.retry", "Retry")} - + diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index c1697ad1fa..68a3e8161b 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -191,6 +191,7 @@ describe("PlanningModeModal", () => { MockEventSource.reset(); vi.stubGlobal("EventSource", MockEventSource as any); window.sessionStorage.clear(); + localStorage.clear(); // Default to desktop viewport; mobile-specific tests override per-test. mockViewport("desktop"); @@ -273,6 +274,91 @@ describe("PlanningModeModal", () => { }); }); + describe("last-active session restoration", () => { + const activeSession = (status: "generating" | "awaiting_input") => ({ + id: "session-leave-return", + type: "planning" as const, + status, + title: "Restored planning interview", + inputPayload: JSON.stringify({ initialPlan: "Restore a planning interview" }), + conversationHistory: JSON.stringify([{ question: mockQuestion, response: { [mockQuestion.id]: "Small" } }]), + currentQuestion: status === "awaiting_input" + ? JSON.stringify({ ...mockQuestion, id: "q-return", question: "What should happen after return?" }) + : null, + result: JSON.stringify({ ...mockSummary, title: "Running plan restored" }), + thinkingOutput: "Persisted mid-generation reasoning", + error: null, + projectId: "project-restore", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + it.each(["desktop", "mobile"] as const)("FN-8437 restores a generating session after embedded leave/return on %s", async (viewportMode) => { + mockViewport(viewportMode); + mockFetchAiSession.mockResolvedValue(activeSession("generating")); + + const firstMount = render( + , + ); + await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-leave-return", "project-restore", expect.anything())); + firstMount.unmount(); + + render( + , + ); + + await waitFor(() => expect(mockFetchAiSession).toHaveBeenCalledTimes(2)); + expect(await screen.findByText("AI is thinking...")).toBeInTheDocument(); + expect(screen.getByText("Persisted mid-generation reasoning")).toBeInTheDocument(); + expect(mockConnectPlanningStream).toHaveBeenLastCalledWith("session-leave-return", "project-restore", expect.anything()); + expect(screen.queryByPlaceholderText(/e.g., Build a user authentication/)).toBeNull(); + }); + + it("FN-8437 rehydrates the server question and running plan when generation finishes while away", async () => { + localStorage.setItem("kb:project-restore:kb-planning-active-session", "session-leave-return"); + mockFetchAiSession.mockResolvedValue(activeSession("awaiting_input")); + + render( + , + ); + + expect(await screen.findByText("What should happen after return?")).toBeInTheDocument(); + expect(screen.getByText("Running plan restored")).toBeInTheDocument(); + expect(screen.getByText("What is the scope?")).toBeInTheDocument(); + expect(screen.queryByPlaceholderText(/e.g., Build a user authentication/)).toBeNull(); + }); + + it("FN-8437 clears the active session when dismissing a restored error", async () => { + localStorage.setItem("kb:project-restore:kb-planning-active-session", "session-leave-return"); + mockFetchAiSession.mockResolvedValue({ + ...activeSession("awaiting_input"), + status: "error", + currentQuestion: null, + error: "Restored planning failure", + }); + + render( + , + ); + + expect(await screen.findByRole("alert")).toHaveTextContent("Restored planning failure"); + fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); + expect(mockOnClose).toHaveBeenCalledTimes(1); + expect(localStorage.getItem("kb:project-restore:kb-planning-active-session")).toBeNull(); + }); + + it("FN-8437 keeps an initial-plan handoff fresh instead of restoring the last active session", async () => { + localStorage.setItem("kb:project-restore:kb-planning-active-session", "session-leave-return"); + + render( + , + ); + + await waitFor(() => expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Intentional new plan", "project-restore", undefined, { clarificationEnabled: true }, undefined)); + expect(mockFetchAiSession).not.toHaveBeenCalled(); + }); + }); + describe("Planning flow", () => { it.each(["desktop", "mobile"] as const)("FN-6977 keeps malformed live running plans non-terminal on %s", async (viewportMode) => { mockViewport(viewportMode); diff --git a/packages/dashboard/app/hooks/__tests__/modalPersistence.test.ts b/packages/dashboard/app/hooks/__tests__/modalPersistence.test.ts index d71550a022..675617b39b 100644 --- a/packages/dashboard/app/hooks/__tests__/modalPersistence.test.ts +++ b/packages/dashboard/app/hooks/__tests__/modalPersistence.test.ts @@ -1,11 +1,15 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { STORED_PLANNING_KEY, + STORED_PLANNING_ACTIVE_SESSION_KEY, STORED_SUBTASK_KEY, STORED_MISSION_KEY, savePlanningDescription, getPlanningDescription, clearPlanningDescription, + savePlanningActiveSession, + getPlanningActiveSession, + clearPlanningActiveSession, saveSubtaskDescription, getSubtaskDescription, clearSubtaskDescription, @@ -25,6 +29,10 @@ describe("modalPersistence", () => { expect(STORED_PLANNING_KEY).toBe("kb-planning-last-description"); }); + it("exports planning active-session key", () => { + expect(STORED_PLANNING_ACTIVE_SESSION_KEY).toBe("kb-planning-active-session"); + }); + it("exports subtask key", () => { expect(STORED_SUBTASK_KEY).toBe("kb-subtask-last-description"); }); @@ -77,6 +85,16 @@ describe("modalPersistence", () => { }); }); + describe("Planning active-session persistence", () => { + it("saves, reads, and clears an active session per project", () => { + savePlanningActiveSession("planning-123", "proj-123"); + expect(getPlanningActiveSession("proj-123")).toBe("planning-123"); + expect(getPlanningActiveSession("proj-other")).toBe(""); + clearPlanningActiveSession("proj-123"); + expect(getPlanningActiveSession("proj-123")).toBe(""); + }); + }); + describe("Subtask persistence", () => { it("saves and retrieves subtask description", () => { saveSubtaskDescription("Implement login feature"); diff --git a/packages/dashboard/app/hooks/modalPersistence.ts b/packages/dashboard/app/hooks/modalPersistence.ts index 9e2d57b8db..a5ff2934b8 100644 --- a/packages/dashboard/app/hooks/modalPersistence.ts +++ b/packages/dashboard/app/hooks/modalPersistence.ts @@ -2,6 +2,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project // Storage keys — each modal type has independent storage export const STORED_PLANNING_KEY = "kb-planning-last-description"; +export const STORED_PLANNING_ACTIVE_SESSION_KEY = "kb-planning-active-session"; export const STORED_SUBTASK_KEY = "kb-subtask-last-description"; export const STORED_MISSION_KEY = "kb-mission-last-goal"; export const STORED_GITHUB_IMPORT_KEY = "kb-dashboard-github-import-state"; @@ -20,6 +21,22 @@ export function clearPlanningDescription(projectId?: string): void { removeScopedItem(STORED_PLANNING_KEY, projectId); } +/* +FNXC:PlanningMode 2026-07-20-12:00: +Embedded Planning unmounts whenever main-content navigation leaves its view. FN-8437 keeps the last active interview id project-scoped, matching Chat's active-session persistence, so a return during generation can rehydrate through the modal's single loadSession path. +*/ +export function savePlanningActiveSession(sessionId: string, projectId?: string): void { + setScopedItem(STORED_PLANNING_ACTIVE_SESSION_KEY, sessionId, projectId); +} + +export function getPlanningActiveSession(projectId?: string): string { + return getScopedItem(STORED_PLANNING_ACTIVE_SESSION_KEY, projectId) || ""; +} + +export function clearPlanningActiveSession(projectId?: string): void { + removeScopedItem(STORED_PLANNING_ACTIVE_SESSION_KEY, projectId); +} + // Subtask persistence export function saveSubtaskDescription(description: string, projectId?: string): void { diff --git a/packages/dashboard/app/utils/__tests__/projectStorage.test.ts b/packages/dashboard/app/utils/__tests__/projectStorage.test.ts index 4edd5aa06c..1078731da3 100644 --- a/packages/dashboard/app/utils/__tests__/projectStorage.test.ts +++ b/packages/dashboard/app/utils/__tests__/projectStorage.test.ts @@ -93,6 +93,7 @@ describe("projectStorage", () => { "fn-agent-view", "kb-terminal-tabs", "kb-planning-last-description", + "kb-planning-active-session", "kb-subtask-last-description", "kb-mission-last-goal", "kb-usage-view-mode", @@ -114,7 +115,7 @@ describe("projectStorage", () => { FNXC:ProjectStorage 2026-07-14-19:20: Keep PROJECT_STORAGE_KEYS length lockstep with the source array (todo hide-done, github import state, github setup warning dismissals). */ - expect(PROJECT_STORAGE_KEYS).toHaveLength(32); + expect(PROJECT_STORAGE_KEYS).toHaveLength(33); }); it("stores branch filter values as scoped strings per project", () => { diff --git a/packages/dashboard/app/utils/projectStorage.ts b/packages/dashboard/app/utils/projectStorage.ts index 8453ce355d..758b93ed07 100644 --- a/packages/dashboard/app/utils/projectStorage.ts +++ b/packages/dashboard/app/utils/projectStorage.ts @@ -26,6 +26,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [ "fn-agent-view", "kb-terminal-tabs", "kb-planning-last-description", + "kb-planning-active-session", "kb-subtask-last-description", "kb-mission-last-goal", "kb-usage-view-mode",