From 8993e912d233b2584d12200eda8bed8e066dd627 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 09:16:12 -0700 Subject: [PATCH] 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) --- .../fn-8426-planning-mobile-tablet-layout.md | 7 ++ MOBILE.md | 2 +- docs/dashboard-guide.md | 2 +- .../app/components/PlanningModeModal.css | 94 +++++++++++++++++-- .../app/components/PlanningModeModal.tsx | 51 +++++++++- .../__tests__/PlanningModeModal.css.test.ts | 17 ++++ .../PlanningModeModal.planning-flow.test.tsx | 36 +++++++ .../PlanningModeModal.test-helpers.ts | 23 +++++ 8 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 .changeset/fn-8426-planning-mobile-tablet-layout.md diff --git a/.changeset/fn-8426-planning-mobile-tablet-layout.md b/.changeset/fn-8426-planning-mobile-tablet-layout.md new file mode 100644 index 0000000000..44a062f745 --- /dev/null +++ b/.changeset/fn-8426-planning-mobile-tablet-layout.md @@ -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. diff --git a/MOBILE.md b/MOBILE.md index e79da0e2b0..09c7db75c6 100644 --- a/MOBILE.md +++ b/MOBILE.md @@ -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. 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 diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index b3e91a3a2a..1b5a9eacac 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. -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. diff --git a/packages/dashboard/app/components/PlanningModeModal.css b/packages/dashboard/app/components/PlanningModeModal.css index 8ebdd48123..c92198d6ce 100644 --- a/packages/dashboard/app/components/PlanningModeModal.css +++ b/packages/dashboard/app/components/PlanningModeModal.css @@ -537,6 +537,55 @@ The New session button must look EXACTLY like Missions' primary sidebar create b .planning-modal-body--split { 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 { width: 100%; 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. */ @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 { flex-wrap: wrap; align-items: flex-start; @@ -1858,12 +1946,6 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer font-size: 16px; } - .planning-running-plan { - flex-basis: auto; - border-top: 1px solid var(--border); - border-left: 0; - } - /* Confirm buttons: mobile-friendly sizing */ .planning-confirm-group { flex-direction: column; diff --git a/packages/dashboard/app/components/PlanningModeModal.tsx b/packages/dashboard/app/components/PlanningModeModal.tsx index e3e4fbed8a..cf3d1769b9 100644 --- a/packages/dashboard/app/components/PlanningModeModal.tsx +++ b/packages/dashboard/app/components/PlanningModeModal.tsx @@ -56,7 +56,7 @@ import { CustomModelDropdown } from "./CustomModelDropdown"; import { ConversationHistory } from "./ConversationHistory"; import { MailboxMessageContent } from "./MailboxMessageContent"; import { OnboardingDisclosure } from "./OnboardingDisclosure"; -import { useViewportMode } from "../hooks/useViewportMode"; +import { isShortViewport, useViewportMode } from "../hooks/useViewportMode"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; @@ -512,6 +512,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:planning-modal-size"); const viewportMode = useViewportMode(); 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 { pushNav } = useNavigationHistoryContext(); @@ -2235,8 +2245,23 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat {selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && activeSessionTitle && } )} - {selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && !isMobile && ( - )} @@ -2252,6 +2277,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
{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: 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() && (
)} + {selectedSessionId && !showSessionList && (view.type === "question" || view.type === "loading" || view.type === "error") && isCompactInterview && ( + + )} +
{error &&
{error}
} {/* diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.css.test.ts b/packages/dashboard/app/components/__tests__/PlanningModeModal.css.test.ts index 8d8f0cd2c5..aa593fa348 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.css.test.ts +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.css.test.ts @@ -69,6 +69,23 @@ describe("PlanningModeModal CSS responsive action contract", () => { 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", () => { const css = loadPlanningCss(); const mobileShellCss = getMediaBlocks(css, MOBILE_PLANNING_SHELL_QUERY).join("\n"); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index cb3e7d06a1..5ab9890a76 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -61,6 +61,7 @@ import { mockTaskDetail, MockEventSource, getMediaBlocks, + mockShortLandscapePhone, mockViewport, } 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(); + 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) => { it("keeps only Next question and the localized Other input affordance", async () => { mockViewport(viewport); diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.test-helpers.ts b/packages/dashboard/app/components/__tests__/PlanningModeModal.test-helpers.ts index ed9ff1518b..d2ebcb10df 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.test-helpers.ts @@ -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 };