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.
|
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. -->
|
<!-- 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. -->
|
<!-- 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.
|
Use **New session** to restart planning with a different idea.
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ import {
|
|||||||
savePlanningDescription,
|
savePlanningDescription,
|
||||||
getPlanningDescription,
|
getPlanningDescription,
|
||||||
clearPlanningDescription,
|
clearPlanningDescription,
|
||||||
|
savePlanningActiveSession,
|
||||||
|
getPlanningActiveSession,
|
||||||
|
clearPlanningActiveSession,
|
||||||
} from "../hooks/modalPersistence";
|
} from "../hooks/modalPersistence";
|
||||||
import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
|
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";
|
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
|
// identity change (e.g. typing into the textarea recreates loadSession) and
|
||||||
// yanks the user back into the previous session's question view.
|
// yanks the user back into the previous session's question view.
|
||||||
const dismissedResumeRef = useRef<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
viewRef.current = view;
|
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
|
// The session was deleted (commonly: this tab just turned it into
|
||||||
// tasks via Create Task / Create Tasks). Quietly fall back to the
|
// tasks via Create Task / Create Tasks). Quietly fall back to the
|
||||||
// new-session view rather than surfacing a scary error banner.
|
// new-session view rather than surfacing a scary error banner.
|
||||||
|
clearPlanningActiveSession(projectId);
|
||||||
setSelectedSessionId(null);
|
setSelectedSessionId(null);
|
||||||
setMobileShowDetail(false);
|
setMobileShowDetail(false);
|
||||||
setActivePlanPrompt("");
|
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.
|
// Resume the externally-requested session when the modal first opens.
|
||||||
@@ -1494,6 +1502,28 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
void loadSession(resumeSessionId);
|
void loadSession(resumeSessionId);
|
||||||
}, [isOpen, 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.
|
// Re-sync the selected session whenever the planning screen is shown.
|
||||||
// loadSession tears down any existing stream and reconnects, so the right
|
// loadSession tears down any existing stream and reconnects, so the right
|
||||||
// view always reflects the freshest server state for whatever row is
|
// view always reflects the freshest server state for whatever row is
|
||||||
@@ -1622,13 +1652,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
if (resumeSessionId) {
|
if (resumeSessionId) {
|
||||||
dismissedResumeRef.current = resumeSessionId;
|
dismissedResumeRef.current = resumeSessionId;
|
||||||
}
|
}
|
||||||
|
clearPlanningActiveSession(projectId);
|
||||||
const preserveActiveDraft = selectedSessionId === null && viewRef.current.type === "initial";
|
const preserveActiveDraft = selectedSessionId === null && viewRef.current.type === "initial";
|
||||||
resetDetailState({ preserveInitialPlan: preserveActiveDraft });
|
resetDetailState({ preserveInitialPlan: preserveActiveDraft });
|
||||||
setSelectedSessionId(null);
|
setSelectedSessionId(null);
|
||||||
setShowSessionList(false);
|
setShowSessionList(false);
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
setNewSessionFocusSignal((signal) => signal + 1);
|
setNewSessionFocusSignal((signal) => signal + 1);
|
||||||
}, [resetDetailState, resumeSessionId, selectedSessionId]);
|
}, [projectId, resetDetailState, resumeSessionId, selectedSessionId]);
|
||||||
|
|
||||||
const handleBackToList = useCallback(() => {
|
const handleBackToList = useCallback(() => {
|
||||||
setShowSessionList(true);
|
setShowSessionList(true);
|
||||||
@@ -1782,6 +1813,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
streamConnectionRef.current?.close();
|
streamConnectionRef.current?.close();
|
||||||
streamConnectionRef.current = null;
|
streamConnectionRef.current = null;
|
||||||
resetDetailState();
|
resetDetailState();
|
||||||
|
clearPlanningActiveSession(projectId);
|
||||||
setSelectedSessionId(null);
|
setSelectedSessionId(null);
|
||||||
setMobileShowDetail(false);
|
setMobileShowDetail(false);
|
||||||
}
|
}
|
||||||
@@ -1820,6 +1852,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
streamConnectionRef.current?.close();
|
streamConnectionRef.current?.close();
|
||||||
streamConnectionRef.current = null;
|
streamConnectionRef.current = null;
|
||||||
resetDetailState();
|
resetDetailState();
|
||||||
|
clearPlanningActiveSession(projectId);
|
||||||
setSelectedSessionId(null);
|
setSelectedSessionId(null);
|
||||||
setMobileShowDetail(false);
|
setMobileShowDetail(false);
|
||||||
}
|
}
|
||||||
@@ -2142,6 +2175,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
// Single-task creation should preserve completed planning history, so
|
// Single-task creation should preserve completed planning history, so
|
||||||
// only clear the active selection before closing; keep the sidebar row
|
// only clear the active selection before closing; keep the sidebar row
|
||||||
// in local state to match persisted server truth.
|
// in local state to match persisted server truth.
|
||||||
|
clearPlanningActiveSession(projectId);
|
||||||
setSelectedSessionId(null);
|
setSelectedSessionId(null);
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2221,6 +2255,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setPlanningModelId(undefined);
|
setPlanningModelId(undefined);
|
||||||
setPlanningThinkingLevel("");
|
setPlanningThinkingLevel("");
|
||||||
currentSessionIdRef.current = null;
|
currentSessionIdRef.current = null;
|
||||||
|
clearPlanningActiveSession(projectId);
|
||||||
setSelectedSessionId(null);
|
setSelectedSessionId(null);
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2684,7 +2719,17 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
{isRetrying ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
|
||||||
<span className="icon-ml-6">{isRetrying ? t("planning.retrying", "Retrying...") : t("common.retry", "Retry")}</span>
|
<span className="icon-ml-6">{isRetrying ? t("planning.retrying", "Retrying...") : t("common.retry", "Retry")}</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ describe("PlanningModeModal", () => {
|
|||||||
MockEventSource.reset();
|
MockEventSource.reset();
|
||||||
vi.stubGlobal("EventSource", MockEventSource as any);
|
vi.stubGlobal("EventSource", MockEventSource as any);
|
||||||
window.sessionStorage.clear();
|
window.sessionStorage.clear();
|
||||||
|
localStorage.clear();
|
||||||
// Default to desktop viewport; mobile-specific tests override per-test.
|
// Default to desktop viewport; mobile-specific tests override per-test.
|
||||||
mockViewport("desktop");
|
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", () => {
|
describe("Planning flow", () => {
|
||||||
it.each(["desktop", "mobile"] as const)("FN-6977 keeps malformed live running plans non-terminal on %s", async (viewportMode) => {
|
it.each(["desktop", "mobile"] as const)("FN-6977 keeps malformed live running plans non-terminal on %s", async (viewportMode) => {
|
||||||
mockViewport(viewportMode);
|
mockViewport(viewportMode);
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
STORED_PLANNING_KEY,
|
STORED_PLANNING_KEY,
|
||||||
|
STORED_PLANNING_ACTIVE_SESSION_KEY,
|
||||||
STORED_SUBTASK_KEY,
|
STORED_SUBTASK_KEY,
|
||||||
STORED_MISSION_KEY,
|
STORED_MISSION_KEY,
|
||||||
savePlanningDescription,
|
savePlanningDescription,
|
||||||
getPlanningDescription,
|
getPlanningDescription,
|
||||||
clearPlanningDescription,
|
clearPlanningDescription,
|
||||||
|
savePlanningActiveSession,
|
||||||
|
getPlanningActiveSession,
|
||||||
|
clearPlanningActiveSession,
|
||||||
saveSubtaskDescription,
|
saveSubtaskDescription,
|
||||||
getSubtaskDescription,
|
getSubtaskDescription,
|
||||||
clearSubtaskDescription,
|
clearSubtaskDescription,
|
||||||
@@ -25,6 +29,10 @@ describe("modalPersistence", () => {
|
|||||||
expect(STORED_PLANNING_KEY).toBe("kb-planning-last-description");
|
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", () => {
|
it("exports subtask key", () => {
|
||||||
expect(STORED_SUBTASK_KEY).toBe("kb-subtask-last-description");
|
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", () => {
|
describe("Subtask persistence", () => {
|
||||||
it("saves and retrieves subtask description", () => {
|
it("saves and retrieves subtask description", () => {
|
||||||
saveSubtaskDescription("Implement login feature");
|
saveSubtaskDescription("Implement login feature");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/project
|
|||||||
|
|
||||||
// Storage keys — each modal type has independent storage
|
// Storage keys — each modal type has independent storage
|
||||||
export const STORED_PLANNING_KEY = "kb-planning-last-description";
|
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_SUBTASK_KEY = "kb-subtask-last-description";
|
||||||
export const STORED_MISSION_KEY = "kb-mission-last-goal";
|
export const STORED_MISSION_KEY = "kb-mission-last-goal";
|
||||||
export const STORED_GITHUB_IMPORT_KEY = "kb-dashboard-github-import-state";
|
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);
|
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
|
// Subtask persistence
|
||||||
|
|
||||||
export function saveSubtaskDescription(description: string, projectId?: string): void {
|
export function saveSubtaskDescription(description: string, projectId?: string): void {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ describe("projectStorage", () => {
|
|||||||
"fn-agent-view",
|
"fn-agent-view",
|
||||||
"kb-terminal-tabs",
|
"kb-terminal-tabs",
|
||||||
"kb-planning-last-description",
|
"kb-planning-last-description",
|
||||||
|
"kb-planning-active-session",
|
||||||
"kb-subtask-last-description",
|
"kb-subtask-last-description",
|
||||||
"kb-mission-last-goal",
|
"kb-mission-last-goal",
|
||||||
"kb-usage-view-mode",
|
"kb-usage-view-mode",
|
||||||
@@ -114,7 +115,7 @@ describe("projectStorage", () => {
|
|||||||
FNXC:ProjectStorage 2026-07-14-19:20:
|
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).
|
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", () => {
|
it("stores branch filter values as scoped strings per project", () => {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
|||||||
"fn-agent-view",
|
"fn-agent-view",
|
||||||
"kb-terminal-tabs",
|
"kb-terminal-tabs",
|
||||||
"kb-planning-last-description",
|
"kb-planning-last-description",
|
||||||
|
"kb-planning-active-session",
|
||||||
"kb-subtask-last-description",
|
"kb-subtask-last-description",
|
||||||
"kb-mission-last-goal",
|
"kb-mission-last-goal",
|
||||||
"kb-usage-view-mode",
|
"kb-usage-view-mode",
|
||||||
|
|||||||
Reference in New Issue
Block a user