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:
7
.changeset/fn-8427-planning-mobile-session-list-nav.md
Normal file
7
.changeset/fn-8427-planning-mobile-session-list-nav.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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*;/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user