FN-8437: restore Planning Mode sessions after navigation
Persist active Planning interviews across embedded navigation while honoring explicit dismissal. - Store the active Planning session per project and restore it through the existing session loader. - Clear the saved active session for dismissals and terminal session actions. - Cover leave-and-return, completed-away, fresh-plan, and dismissed-error flows; document the behavior. - Add a patch changeset for the Planning session recovery fix. Files changed: .changeset/fn-8437-planning-leave-return-restore.md | 7 ++ docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/PlanningModeModal.tsx | 51 ++++++++++++- packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx | 86 ++++++++++++++++++++++ packages/dashboard/app/hooks/__tests__/modalPersistence.test.ts | 18 +++++ packages/dashboard/app/hooks/modalPersistence.ts | 17 +++++ packages/dashboard/app/utils/__tests__/projectStorage.test.ts | 3 +- packages/dashboard/app/utils/projectStorage.ts | 1 + 8 files changed, 180 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-8437 Fusion-Task-Lineage: 892aacb6-139e-4487-a40c-1bedd51237ea Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8437-planning-leave-return-restore.md
Normal file
7
.changeset/fn-8437-planning-leave-return-restore.md
Normal file
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
<!-- FNXC:PlanningRetry 2026-07-15-00:00: FN-8332 confines automatic Planning Mode retry to failures observed by an active in-session SSE/poll turn. Browser reload or session resume must restore the persisted progress/error verbatim and leave retry as an explicit user choice. -->
|
||||
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.
|
||||
|
||||
<!-- FNXC:PlanningMode 2026-07-19-15:55: FN-8400 replaces the duplicate prompt-recovery controls with a focused three-pane interview; restarting remains a deliberate New session action. -->
|
||||
Use **New session** to restart planning with a different idea.
|
||||
|
||||
@@ -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<string | null>(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 ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||
<span className="icon-ml-6">{isRetrying ? t("planning.retrying", "Retrying...") : t("common.retry", "Retry")}</span>
|
||||
</button>
|
||||
<button className="btn" onClick={handleClose} disabled={isRetrying}>{t("planning.dismiss", "Dismiss")}</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
// FNXC:PlanningMode 2026-07-20-12:15: FN-8437 treats error dismissal as an intentional exit from the resumable interview, so reopening Planning starts fresh rather than restoring the dismissed failure.
|
||||
clearPlanningActiveSession(projectId);
|
||||
handleClose();
|
||||
}}
|
||||
disabled={isRetrying}
|
||||
>
|
||||
{t("planning.dismiss", "Dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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(
|
||||
<PlanningModeModal isOpen={true} onClose={mockOnClose} onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-restore" resumeSessionId="session-leave-return" presentation="embedded" />,
|
||||
);
|
||||
await waitFor(() => expect(mockConnectPlanningStream).toHaveBeenCalledWith("session-leave-return", "project-restore", expect.anything()));
|
||||
firstMount.unmount();
|
||||
|
||||
render(
|
||||
<PlanningModeModal isOpen={true} onClose={mockOnClose} onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-restore" presentation="embedded" />,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanningModeModal isOpen={true} onClose={mockOnClose} onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-restore" presentation="embedded" />,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanningModeModal isOpen={true} onClose={mockOnClose} onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-restore" presentation="embedded" />,
|
||||
);
|
||||
|
||||
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(
|
||||
<PlanningModeModal isOpen={true} onClose={mockOnClose} onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-restore" initialPlan="Intentional new plan" presentation="embedded" />,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user