FN-8427: restore mobile planning session navigation

Restore a full-pane saved-session destination for compact Planning Mode.

- Unmount the running-plan pane while mobile and tablet session lists are open.
- Keep Back and Sessions available from active, completed, and new planning surfaces.
- Cover compact navigation behavior and document the mobile interaction.

Files changed:
 .../fn-8427-planning-mobile-session-list-nav.md    |  7 ++
 MOBILE.md                                          |  2 +-
 docs/dashboard-guide.md                            |  2 +-
 .../dashboard/app/components/PlanningModeModal.css | 56 +++++++++++++++-
 .../dashboard/app/components/PlanningModeModal.tsx | 33 ++++++---
 .../__tests__/PlanningModeModal.css.test.ts        |  5 ++
 .../PlanningModeModal.ui-interactions.test.tsx     | 78 ++++++++++++++++++++++
 7 files changed, 167 insertions(+), 16 deletions(-)

Fusion-Task-Id: FN-8427
Fusion-Task-Lineage: 6664d075-0614-4b5a-ba36-34a0ec6a20a3
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 09:51:26 -07:00
parent 8d216e628c
commit bd558462ed
7 changed files with 167 additions and 16 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Restore mobile navigation back to the Planning session list without a stuck Running plan screen.
category: fix
dev: Hide RunningPlanPane in list mode; ensure Back/Sessions on non-list mobile Planning surfaces.

View File

@@ -101,7 +101,7 @@ gesture delivery enabled across `cap sync` regenerations.
### Planning Mode
Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. Desktop keeps its three-pane interview (answered history, current question, and running plan) when there is room. Mobile, phone-class short landscape, and tablet use a progressive interview: **Question** is the default full-width surface, while labeled **Running plan** and **Answered questions** controls switch to those surfaces without stacking three panes. **Sessions** and the mobile back control always return to the saved-session list; **Validate plan** remains on the Running plan surface. Once sessions are saved, mobile shows the session list as usual and the user can navigate between list and detail panes.
Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. Desktop keeps its three-pane interview (answered history, current question, and running plan) when there is room. Mobile, phone-class short landscape, and tablet use a progressive interview: **Question** is the default full-width surface, while labeled **Running plan** and **Answered questions** controls switch to those surfaces without stacking three panes. **Sessions** and the mobile back control always return to the saved-session list; this is a full-pane, scrollable list with **New session** as its footer and no Running plan stacked beneath it. The same escape is available from interview, summary, breakdown, and a new-session composer when saved sessions exist; **Validate plan** remains on the Running plan surface.
### Chat and Quick Chat mobile scroll/readability behavior

View File

@@ -529,7 +529,7 @@ When an active Planning AI generation appears stuck, Planning Mode automatically
Use **New session** to restart planning with a different idea.
<!-- FNXC:PlanningMode 2026-07-18-16:00: Planning Mode is an infinite, user-controlled interview. Each answer updates the running plan and produces another context-aware high-impact question; only Validate plan finalizes it. -->
Planning Mode keeps the running plan visible beside answered-question history and the current question on desktop; you can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. On tablet, mobile, and phone-class short landscape, the interview switches between labeled **Question**, **Running plan**, and **Answered questions** surfaces so the current question stays usable instead of competing with three columns. **Sessions** (and mobile Back) return to the saved-session list, while **Validate plan** remains available on the Running plan surface. The running title, description, and deliverables are available throughout the interview—including while the next question is generating or a recoverable error is shown. The AI never ends an interview on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question.
Planning Mode keeps the running plan visible beside answered-question history and the current question on desktop; you can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. On tablet, mobile, and phone-class short landscape, the interview switches between labeled **Question**, **Running plan**, and **Answered questions** surfaces so the current question stays usable instead of competing with three columns. **Sessions** (and mobile Back) return to a full-pane, scrollable saved-session list with **New session** pinned as its footer; the Running plan is not stacked under that list. This escape remains available from interview, summary, breakdown, and a new-session composer whenever saved sessions exist, while **Validate plan** remains available on the Running plan surface. The running title, description, and deliverables are available throughout the interview—including while the next question is generating or a recoverable error is shown. The AI never ends an interview on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question.
Choose **Validate plan** when the running plan is ready for task creation. Validation is durable and is required before **Create task**, **Create tasks**, or **Start breakdown**; those actions reject unvalidated sessions.

View File

@@ -469,6 +469,7 @@ The New session button must look EXACTLY like Missions' primary sidebar create b
cursor: pointer;
padding: var(--space-xs);
border-radius: var(--radius-sm);
flex-shrink: 0;
transition: background var(--transition-fast), box-shadow var(--transition-fast);
}
@@ -620,13 +621,24 @@ The New session button must look EXACTLY like Missions' primary sidebar create b
.planning-modal-body--show-detail .planning-sidebar {
display: none;
}
.planning-modal-body--show-list .planning-detail {
/*
FNXC:PlanningModeMobile 2026-07-20-10:30:
FN-8427 gives session-list mode exclusive ownership of the compact body. JS unmounts
RunningPlanPane; this selector is a layout backstop for future render paths so session
rows stay scrollable above the New session footer instead of sharing height with a plan.
*/
.planning-modal-body--show-list .planning-detail,
.planning-modal-body--show-list .planning-running-plan,
.planning-modal-body--show-list .planning-compact-pane-switcher {
display: none;
}
/* Show back button on mobile whenever it's rendered (the React component
only renders it when `mobileShowDetail` is true). */
/* The rendered Back control needs a full touch target and must not be squeezed out of the title row. */
.planning-mobile-back {
display: inline-flex;
min-width: calc(var(--space-md) * 2.25);
min-height: calc(var(--space-md) * 2.25);
align-items: center;
justify-content: center;
}
/* Always keep action buttons visible on mobile (no hover) */
.planning-sidebar-item-delete,
@@ -1535,6 +1547,44 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer
display: none;
}
/* FNXC:PlanningModeMobile 2026-07-20-10:30: tablet uses the same compact list destination as phone and short shells, so summary/compose detail cannot remain beside saved session rows. */
.planning-modal-body--show-list {
flex: 1;
min-height: 0;
overflow: hidden;
}
.planning-modal-body--show-list .planning-sidebar {
flex: 1 1 auto;
height: 100%;
min-height: 0;
max-height: 100%;
}
.planning-modal-body--show-list .planning-sidebar-list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
.planning-modal-body--show-list .planning-sidebar-footer {
flex-shrink: 0;
}
.planning-modal-body--show-list .planning-detail,
.planning-modal-body--show-list .planning-running-plan,
.planning-modal-body--show-list .planning-compact-pane-switcher {
display: none;
}
.planning-mobile-back {
display: inline-flex;
min-width: calc(var(--space-md) * 2.25);
min-height: calc(var(--space-md) * 2.25);
align-items: center;
justify-content: center;
}
.planning-summary-actions {
flex-wrap: wrap;
align-items: flex-start;

View File

@@ -521,6 +521,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
temporary while a keyboard is open, rather than a global viewport-mode change.
*/
const isCompactInterview = viewportMode !== "desktop" || isShortViewport();
/*
FNXC:PlanningModeMobile 2026-07-20-10:30:
FN-8427 makes the saved-session list a real compact destination. Both Back and Sessions
enter this one mode, which must unmount the active interview plan so it cannot consume
flex height beneath session rows. Desktop preserves its three-pane interview until its
explicit Sessions toggle requests the same list destination.
*/
const isSessionListMode = showSessionList || (isCompactInterview && !mobileShowDetail);
// FNXC:PlanningModeMobile 2026-07-20-10:30: Empty mobile state opens the composer because no saved destination exists; once sessions exist, every compact detail surface gets this single Back-to-list escape.
const canReturnToSessionList = isCompactInterview && mobileShowDetail && planningSessions.length > 0;
const [compactInterviewPane, setCompactInterviewPane] = useState<"question" | "plan" | "history">("question");
const { addToast } = useToast();
const { pushNav } = useNavigationHistoryContext();
@@ -1192,6 +1202,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
liveGenerationSessionIdRef.current = sessionId;
setSelectedSessionId(sessionId);
setShowSessionList(false);
setMobileShowDetail(true);
connectToPlanningStream(sessionId);
setResponseHistory([]);
@@ -2215,7 +2226,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
*/}
<div className={isEmbedded ? "modal-header modal-header--embedded" : "modal-header"}>
<div className="detail-title-row">
{mobileShowDetail && (
{canReturnToSessionList && (
<button
className="modal-back planning-mobile-back"
onClick={handleBackToList}
@@ -2257,7 +2268,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
type="button"
className="btn"
onClick={() => {
const showList = !showSessionList;
const showList = !isSessionListMode;
setShowSessionList(showList);
if (isCompactInterview) setMobileShowDetail(!showList);
}}
@@ -2276,14 +2287,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<div
className={`planning-modal-body planning-modal-body--split ${
mobileShowDetail ? "planning-modal-body--show-detail" : "planning-modal-body--show-list"
isSessionListMode ? "planning-modal-body--show-list" : "planning-modal-body--show-detail"
} ${
selectedSessionId && !showSessionList && (view.type === "question" || view.type === "loading" || view.type === "error") && isCompactInterview
selectedSessionId && !isSessionListMode && (view.type === "question" || view.type === "loading" || view.type === "error") && isCompactInterview
? `planning-modal-body--compact-interview planning-modal-body--compact-${compactInterviewPane}`
: ""
}`}
>
{selectedSessionId && !showSessionList && (view.type === "question" || view.type === "loading" || view.type === "error") ? (
{selectedSessionId && !isSessionListMode && (view.type === "question" || view.type === "loading" || view.type === "error") ? (
<AnsweredQuestionHistory
entries={conversationHistory}
selectedQuestionId={editingQuestionId ?? (view.type === "question" ? view.session.currentQuestion?.id : undefined)}
@@ -2327,7 +2338,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
/>
)}
{selectedSessionId && !showSessionList && (view.type === "question" || view.type === "loading" || view.type === "error") && isCompactInterview && (
{selectedSessionId && !isSessionListMode && (view.type === "question" || view.type === "loading" || view.type === "error") && isCompactInterview && (
<nav className="planning-compact-pane-switcher" aria-label={t("planning.interviewPanels", "Planning interview panels")}>
<button type="button" className={`btn ${compactInterviewPane === "question" ? "btn-primary" : ""}`} aria-pressed={compactInterviewPane === "question"} onClick={() => setCompactInterviewPane("question")}>
{t("planning.question", "Question")}
@@ -2653,12 +2664,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
</div>
{/*
FNXC:PlanningMode 2026-07-19-15:45:
Keep the running-plan pane mounted for an active session while a question is loading or an
error is recoverable. A session selection is the stable identity across those view states;
tying this pane to only the question view recreated the old dead-end interface.
FNXC:PlanningModeMobile 2026-07-20-10:30:
FN-8427 keeps the running plan through question, loading, and recoverable-error detail
states, but unmounts it in session-list mode. The list must own the entire compact body;
leaving this pane mounted reduced rows to the New session footer on mobile.
*/}
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && (
{selectedSessionId && !isSessionListMode && (view.type === "question" || view.type === "loading" || view.type === "error") && (
<RunningPlanPane
summary={runningSummary}
fallbackDescription={activePlanPrompt}

View File

@@ -112,5 +112,10 @@ describe("PlanningModeModal CSS responsive action contract", () => {
const footerRule = findRule(mobileShellCss, ".planning-modal-body--show-list .planning-sidebar-footer");
expect(footerRule).toBeTruthy();
expect(footerRule).toMatch(/flex-shrink\s*:\s*0\s*;/);
expect(mobileShellCss).toMatch(/\.planning-modal-body--show-list \.planning-running-plan[\s\S]*?display\s*:\s*none\s*;/);
const mobileBackRule = findRule(mobileShellCss, ".planning-mobile-back");
expect(mobileBackRule).toMatch(/display\s*:\s*inline-flex\s*;/);
expect(mobileBackRule).toMatch(/min-height\s*:\s*calc\(var\(--space-md\) \* 2\.25\)\s*;/);
});
});

View File

@@ -946,6 +946,84 @@ describe("PlanningModeModal", () => {
});
});
describe("mobile session-list navigation (FN-8427)", () => {
const sessions = [
{ id: "session-active", type: "planning", status: "awaiting_input", title: "Active planning session", projectId: null, updatedAt: "2026-07-20T00:00:00.000Z" },
{ id: "session-other", type: "planning", status: "complete", title: "Other planning session", projectId: null, updatedAt: "2026-07-20T00:00:00.000Z" },
];
function mockActiveSession() {
mockFetchAiSessions.mockResolvedValue(sessions);
mockFetchAiSession.mockResolvedValue({
id: "session-active",
type: "planning",
status: "awaiting_input",
title: "Active planning session",
inputPayload: JSON.stringify({ initialPlan: "Restore navigation" }),
conversationHistory: "[]",
currentQuestion: JSON.stringify(mockQuestion),
result: JSON.stringify(mockSummary),
thinkingOutput: "",
projectId: null,
});
}
it("returns an active mobile interview to usable session rows without a running plan", async () => {
mockViewport("mobile");
mockActiveSession();
const { container } = render(<PlanningModeModal isOpen={true} onClose={mockOnClose} onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} resumeSessionId="session-active" initialSessions={sessions} />);
await screen.findByText("What is the scope?");
expect(screen.getByLabelText("Running plan")).toBeInTheDocument();
const back = await screen.findByLabelText("Back to sessions");
expect(back).toBeEnabled();
fireEvent.click(back);
expect(container.querySelector(".planning-modal-body")).toHaveClass("planning-modal-body--show-list");
expect(screen.getByRole("button", { name: /Other planning session/ })).toBeVisible();
expect(screen.queryByLabelText("Running plan")).toBeNull();
expect(screen.getByRole("button", { name: "New session" })).toBeVisible();
});
it("keeps desktop running-plan interview chrome without mobile back navigation", async () => {
mockViewport("desktop");
mockActiveSession();
const { container } = render(<PlanningModeModal isOpen={true} onClose={mockOnClose} onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} resumeSessionId="session-active" initialSessions={sessions} />);
await screen.findByText("What is the scope?");
expect(screen.getByLabelText("Running plan")).toBeInTheDocument();
expect(container.querySelector(".planning-mobile-back")).toBeNull();
});
it("keeps a mobile list escape from summary and new-session compose when sessions exist", async () => {
mockViewport("mobile");
mockFetchAiSessions.mockResolvedValue(sessions);
mockFetchAiSession.mockResolvedValue({
id: "session-active",
type: "planning",
status: "complete",
title: "Active planning session",
inputPayload: JSON.stringify({ initialPlan: "Restore navigation", validated: true }),
conversationHistory: "[]",
currentQuestion: null,
result: JSON.stringify(mockSummary),
thinkingOutput: "",
projectId: null,
});
const { container } = render(<PlanningModeModal isOpen={true} onClose={mockOnClose} onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} resumeSessionId="session-active" initialSessions={sessions} />);
await screen.findByText("Planning Complete!");
fireEvent.click(screen.getByLabelText("Back to sessions"));
expect(screen.getByRole("button", { name: /Other planning session/ })).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "New session" }));
expect(screen.getByPlaceholderText(/e.g., Build a user authentication/)).toBeVisible();
fireEvent.click(screen.getByLabelText("Back to sessions"));
expect(container.querySelector(".planning-modal-body")).toHaveClass("planning-modal-body--show-list");
expect(screen.getByRole("button", { name: /Other planning session/ })).toBeVisible();
});
});
/*
FNXC:Planning 2026-06-23-02:00:
The embedded Planning sidebar is resizable like Missions: a desktop-only drag handle (role=separator) drives an inline width on .planning-sidebar that persists to localStorage and is clamped to the PLANNING_SIDEBAR_MIN/MAX range. These tests assert the handle exists on desktop, persists a clamped width on arrow-key resize, restores from localStorage, and is absent on mobile (where the sidebar stacks full-width).