FN-8426: adapt Planning Mode for compact layouts

Make Planning Mode usable on mobile, tablet, and short-height layouts.

- Collapse compact Planning interviews into selectable question, plan, and history surfaces.
- Keep Sessions navigation synchronized so returning from the session list restores a visible interview pane.
- Document and test mobile, tablet, and short-landscape behavior.

Files changed:
 .../fn-8426-planning-mobile-tablet-layout.md       |  7 ++
 MOBILE.md                                          |  2 +-
 docs/dashboard-guide.md                            |  2 +-
 .../dashboard/app/components/PlanningModeModal.css | 94 ++++++++++++++++++++--
 .../dashboard/app/components/PlanningModeModal.tsx | 51 +++++++++++-
 .../__tests__/PlanningModeModal.css.test.ts        | 17 ++++
 .../PlanningModeModal.planning-flow.test.tsx       | 36 +++++++++
 .../__tests__/PlanningModeModal.test-helpers.ts    | 23 ++++++
 8 files changed, 220 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-8426

Fusion-Task-Lineage: 489d6f92-326c-4780-bc85-d28967c7ab35

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-20 09:16:12 -07:00
parent 104bf69b3b
commit 8993e912d2
8 changed files with 220 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make Planning Mode usable on mobile and tablet with progressive interview layout.
category: fix
dev: Reflows FN-8400/FN-8420 three-pane Planning Mode via viewport progressive disclosure (incl. short landscape); no API contract change.

View File

@@ -101,7 +101,7 @@ gesture delivery enabled across `cap sync` regenerations.
### Planning Mode ### Planning Mode
Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. On desktop/tablet the split view is unaffected. Once sessions are saved, mobile shows the session list as usual and the user can navigate between list and detail panes; active interviews keep the running plan reachable beside the answered-question history. 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.
### Chat and Quick Chat mobile scroll/readability behavior ### 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. 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. --> <!-- 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; you can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. The running title, description, and deliverables are available throughout the interview—including while the next question is generating or a recoverable error is shown; use **Sessions** to return to the saved-session list. 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 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.
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. 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

@@ -537,6 +537,55 @@ The New session button must look EXACTLY like Missions' primary sidebar create b
.planning-modal-body--split { .planning-modal-body--split {
flex-direction: column; flex-direction: column;
} }
/*
FNXC:PlanningModeMobileTablet 2026-07-20-09:30:
The compact interview class is shared by width-mobile, tablet, and short CSS shells.
Only one interview surface is visible at once, so a short landscape viewport cannot retain
desktop's three competing columns. Non-phone short screens use this same class as the explicit
mismatch guard for the compound shell query.
*/
.planning-modal-body--compact-interview {
flex-direction: column;
}
.planning-compact-pane-switcher {
display: flex;
flex: 0 0 auto;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
overflow-x: auto;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.planning-compact-pane-switcher .btn {
flex: 1 0 auto;
min-height: calc(var(--space-md) * 2.25);
justify-content: center;
}
.planning-modal-body--compact-interview .planning-sidebar,
.planning-modal-body--compact-interview .planning-detail,
.planning-modal-body--compact-interview .planning-running-plan {
width: 100%;
flex: 1 1 auto;
min-height: 0;
border: 0;
}
.planning-modal-body--compact-interview .planning-running-plan {
border-top: 0;
}
.planning-modal-body--compact-question .planning-sidebar,
.planning-modal-body--compact-question .planning-running-plan,
.planning-modal-body--compact-plan .planning-sidebar,
.planning-modal-body--compact-plan .planning-detail,
.planning-modal-body--compact-history .planning-detail,
.planning-modal-body--compact-history .planning-running-plan {
display: none;
}
.planning-sidebar { .planning-sidebar {
width: 100%; width: 100%;
border-right: none; border-right: none;
@@ -1447,6 +1496,45 @@ FNXC:PlanningMode 2026-06-25-09:20:
Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer must absorb the narrower detail pane instead of relying on the mobile full-width stack. Allow the three labeled actions to wrap inside the pane while preserving the desktop left/right grouping when space is available. Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer must absorb the narrower detail pane instead of relying on the mobile full-width stack. Allow the three labeled actions to wrap inside the pane while preserving the desktop left/right grouping when space is available.
*/ */
@media (min-width: 769px) and (max-width: 1024px) { @media (min-width: 769px) and (max-width: 1024px) {
/* FNXC:PlanningModeMobileTablet 2026-07-20-09:30: tablet uses the same one-surface interview switcher rather than compressing three fixed panes beside the project navigation. */
.planning-modal-body--compact-interview {
flex-direction: column;
}
.planning-compact-pane-switcher {
display: flex;
flex: 0 0 auto;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
overflow-x: auto;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.planning-compact-pane-switcher .btn {
flex: 1 0 auto;
min-height: calc(var(--space-md) * 2.25);
justify-content: center;
}
.planning-modal-body--compact-interview .planning-sidebar,
.planning-modal-body--compact-interview .planning-detail,
.planning-modal-body--compact-interview .planning-running-plan {
width: 100%;
flex: 1 1 auto;
min-height: 0;
border: 0;
}
.planning-modal-body--compact-question .planning-sidebar,
.planning-modal-body--compact-question .planning-running-plan,
.planning-modal-body--compact-plan .planning-sidebar,
.planning-modal-body--compact-plan .planning-detail,
.planning-modal-body--compact-history .planning-detail,
.planning-modal-body--compact-history .planning-running-plan {
display: none;
}
.planning-summary-actions { .planning-summary-actions {
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
@@ -1858,12 +1946,6 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer
font-size: 16px; font-size: 16px;
} }
.planning-running-plan {
flex-basis: auto;
border-top: 1px solid var(--border);
border-left: 0;
}
/* Confirm buttons: mobile-friendly sizing */ /* Confirm buttons: mobile-friendly sizing */
.planning-confirm-group { .planning-confirm-group {
flex-direction: column; flex-direction: column;

View File

@@ -56,7 +56,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown";
import { ConversationHistory } from "./ConversationHistory"; import { ConversationHistory } from "./ConversationHistory";
import { MailboxMessageContent } from "./MailboxMessageContent"; import { MailboxMessageContent } from "./MailboxMessageContent";
import { OnboardingDisclosure } from "./OnboardingDisclosure"; import { OnboardingDisclosure } from "./OnboardingDisclosure";
import { useViewportMode } from "../hooks/useViewportMode"; import { isShortViewport, useViewportMode } from "../hooks/useViewportMode";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
@@ -512,6 +512,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:planning-modal-size"); useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:planning-modal-size");
const viewportMode = useViewportMode(); const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile"; const isMobile = viewportMode === "mobile";
/*
FNXC:PlanningModeMobileTablet 2026-07-20-09:30:
Active interviews use progressive disclosure below desktop, plus every short CSS shell viewport.
`viewportMode === "mobile"` preserves the phone-class short-landscape contract from
isMobileViewport(); isShortViewport additionally guards non-phone short shells so CSS never
collapses three panes while JavaScript leaves their controls inaccessible. This is intentionally
temporary while a keyboard is open, rather than a global viewport-mode change.
*/
const isCompactInterview = viewportMode !== "desktop" || isShortViewport();
const [compactInterviewPane, setCompactInterviewPane] = useState<"question" | "plan" | "history">("question");
const { addToast } = useToast(); const { addToast } = useToast();
const { pushNav } = useNavigationHistoryContext(); const { pushNav } = useNavigationHistoryContext();
@@ -2235,8 +2245,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle && <button type="button" className="btn-icon" aria-label={t("planning.renameSession", "Rename session")} onClick={() => { setSessionTitleDraft(activeSessionTitle); setIsRenamingSession(true); }}><Pencil /></button>}</> {selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle && <button type="button" className="btn-icon" aria-label={t("planning.renameSession", "Rename session")} onClick={() => { setSessionTitleDraft(activeSessionTitle); setIsRenamingSession(true); }}><Pencil /></button>}</>
)} )}
</div> </div>
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && !isMobile && ( {/*
<button type="button" className="btn" onClick={() => setShowSessionList((current) => !current)}> FNXC:PlanningModeMobileTablet 2026-07-20-09:12:
When the viewport cannot fit three interview panes, operators must still be able to return
to the session list and then back to the active question. Keep the list and detail state
synchronized on both transitions so a second Sessions press cannot leave the question pane
hidden by the mobile list CSS.
*/}
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && (
<button
type="button"
className="btn"
onClick={() => {
const showList = !showSessionList;
setShowSessionList(showList);
if (isCompactInterview) setMobileShowDetail(!showList);
}}
>
{t("planning.sessions", "Sessions")} {t("planning.sessions", "Sessions")}
</button> </button>
)} )}
@@ -2252,6 +2277,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<div <div
className={`planning-modal-body planning-modal-body--split ${ className={`planning-modal-body planning-modal-body--split ${
mobileShowDetail ? "planning-modal-body--show-detail" : "planning-modal-body--show-list" mobileShowDetail ? "planning-modal-body--show-detail" : "planning-modal-body--show-list"
} ${
selectedSessionId && !showSessionList && (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 && !showSessionList && (view.type === "question" || view.type === "loading" || view.type === "error") ? (
@@ -2283,7 +2312,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
FNXC:Planning 2026-06-23-02:00: FNXC:Planning 2026-06-23-02:00:
Sidebar resize handle — parity with MissionManager's mission-manager__sidebar-resize-handle. Rendered only on desktop (sidebar stacks on mobile). Pointer-drag and arrow-key resize both clamp + persist width. Sidebar resize handle — parity with MissionManager's mission-manager__sidebar-resize-handle. Rendered only on desktop (sidebar stacks on mobile). Pointer-drag and arrow-key resize both clamp + persist width.
*/} */}
{!isMobile && ( {viewportMode === "desktop" && !isShortViewport() && (
<div <div
className="planning-sidebar-resize-handle" className="planning-sidebar-resize-handle"
role="separator" role="separator"
@@ -2298,6 +2327,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
/> />
)} )}
{selectedSessionId && !showSessionList && (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")}
</button>
<button type="button" className={`btn ${compactInterviewPane === "plan" ? "btn-primary" : ""}`} aria-pressed={compactInterviewPane === "plan"} onClick={() => setCompactInterviewPane("plan")}>
{t("planning.runningPlan", "Running plan")}
</button>
<button type="button" className={`btn ${compactInterviewPane === "history" ? "btn-primary" : ""}`} aria-pressed={compactInterviewPane === "history"} onClick={() => setCompactInterviewPane("history")}>
{t("planning.answeredQuestions", "Answered questions")}
</button>
</nav>
)}
<div className="planning-detail"> <div className="planning-detail">
{error && <div className="form-error planning-error">{error}</div>} {error && <div className="form-error planning-error">{error}</div>}
{/* {/*

View File

@@ -69,6 +69,23 @@ describe("PlanningModeModal CSS responsive action contract", () => {
expect(embeddedRule).toMatch(/max-height\s*:\s*100%\s*;/); expect(embeddedRule).toMatch(/max-height\s*:\s*100%\s*;/);
}); });
it("uses the compact interview switcher for mobile, short landscape, and tablet instead of crushing three panes", () => {
const css = loadPlanningCss();
const shortShellCss = getMediaBlocks(css, MOBILE_PLANNING_SHELL_QUERY).join("\n");
const tabletCss = getMediaBlocks(css, TABLET_SUMMARY_ACTIONS_QUERY).join("\n");
expect(findRule(shortShellCss, ".planning-modal-body--compact-interview")).toMatch(/flex-direction\s*:\s*column\s*;/);
expect(findRule(shortShellCss, ".planning-compact-pane-switcher")).toMatch(/display\s*:\s*flex\s*;/);
expect(findRule(shortShellCss, ".planning-compact-pane-switcher .btn")).toMatch(/min-height\s*:\s*calc\(var\(--space-md\) \* 2\.25\)\s*;/);
expect(shortShellCss).toContain(".planning-modal-body--compact-question .planning-running-plan");
expect(shortShellCss).toContain(".planning-modal-body--compact-plan .planning-detail");
expect(shortShellCss).toContain(".planning-modal-body--compact-history .planning-detail");
expect(findRule(tabletCss, ".planning-modal-body--compact-interview")).toMatch(/flex-direction\s*:\s*column\s*;/);
expect(tabletCss).toContain(".planning-modal-body--compact-question .planning-running-plan");
expectSomeRule(css, ".planning-running-plan", /flex\s*:\s*0 1 24rem\s*;/);
});
it("keeps the mobile sessions list scrolling above the bottom-pinned New session footer", () => { it("keeps the mobile sessions list scrolling above the bottom-pinned New session footer", () => {
const css = loadPlanningCss(); const css = loadPlanningCss();
const mobileShellCss = getMediaBlocks(css, MOBILE_PLANNING_SHELL_QUERY).join("\n"); const mobileShellCss = getMediaBlocks(css, MOBILE_PLANNING_SHELL_QUERY).join("\n");

View File

@@ -61,6 +61,7 @@ import {
mockTaskDetail, mockTaskDetail,
MockEventSource, MockEventSource,
getMediaBlocks, getMediaBlocks,
mockShortLandscapePhone,
mockViewport, mockViewport,
} from "./PlanningModeModal.test-helpers"; } from "./PlanningModeModal.test-helpers";
@@ -3612,6 +3613,41 @@ describe("PlanningModeModal", () => {
}); });
}); });
it("uses progressive interview controls on tablet and short-landscape phone sessions", async () => {
for (const viewport of ["tablet", "short-landscape"] as const) {
if (viewport === "tablet") mockViewport("tablet");
else mockShortLandscapePhone();
mockFetchAiSession.mockResolvedValueOnce({
id: `session-${viewport}`,
type: "planning",
status: "awaiting_input",
title: "Responsive planning session",
inputPayload: JSON.stringify({ initialPlan: "Responsive plan prompt" }),
conversationHistory: "[]",
result: JSON.stringify(mockSummary),
thinkingOutput: "",
projectId: null,
currentQuestion: JSON.stringify(mockQuestion),
});
const rendered = render(<PlanningModeModal isOpen={true} onClose={mockOnClose} onTaskCreated={mockOnTaskCreated} onTasksCreated={vi.fn()} tasks={mockTasks} resumeSessionId={`session-${viewport}`} />);
await screen.findByText("What is the scope?");
expect(screen.getByRole("button", { name: "Question" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Running plan" })).toBeDefined();
expect(screen.getByRole("button", { name: "Answered questions" })).toBeDefined();
expect(screen.getByRole("button", { name: "Next question" })).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: "Sessions" }));
expect(await screen.findByRole("complementary", { name: "Planning sessions" })).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: "Sessions" }));
expect(rendered.container.querySelector(".planning-modal-body")).toHaveClass("planning-modal-body--show-detail", "planning-modal-body--compact-question");
expect(screen.getByRole("button", { name: "Next question" })).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Running plan" }));
expect(screen.getByRole("button", { name: "Validate plan" })).toBeDefined();
rendered.unmount();
}
});
describe.each(["desktop", "mobile"] as const)("single interview action on %s", (viewport) => { describe.each(["desktop", "mobile"] as const)("single interview action on %s", (viewport) => {
it("keeps only Next question and the localized Other input affordance", async () => { it("keeps only Next question and the localized Other input affordance", async () => {
mockViewport(viewport); mockViewport(viewport);

View File

@@ -200,4 +200,27 @@ export function mockViewport(mode: "mobile" | "desktop" | "tablet") {
}); });
} }
/*
FNXC:PlanningModeMobileTablet 2026-07-20-09:30:
Short-landscape Planning tests must exercise the wide CSS viewport plus phone-class physical screen
combination. Returning mobile mode mirrors getViewportMode() after isMobileViewport() accepts that
phone-class short-height exception; width-only mocks cannot cover the historical desync.
*/
export function mockShortLandscapePhone() {
mockUseViewportMode.mockReturnValue("mobile");
Object.defineProperty(window, "screen", {
configurable: true,
value: { width: 844, height: 390 },
});
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: query === "(max-height: 480px)" || query === "(max-width: 768px), (max-height: 480px)",
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
});
}
export type { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult }; export type { Task, TaskDetail, PlanningQuestion, PlanningSummary, MergeResult };