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:
7
.changeset/fn-8426-planning-mobile-tablet-layout.md
Normal file
7
.changeset/fn-8426-planning-mobile-tablet-layout.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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; 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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && <button type="button" className="btn-icon" aria-label={t("planning.renameSession", "Rename session")} onClick={() => { setSessionTitleDraft(activeSessionTitle); setIsRenamingSession(true); }}><Pencil /></button>}</>
|
||||
)}
|
||||
</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")}
|
||||
</button>
|
||||
)}
|
||||
@@ -2252,6 +2277,10 @@ 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"
|
||||
} ${
|
||||
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") ? (
|
||||
@@ -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() && (
|
||||
<div
|
||||
className="planning-sidebar-resize-handle"
|
||||
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">
|
||||
{error && <div className="form-error planning-error">{error}</div>}
|
||||
{/*
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(<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) => {
|
||||
it("keeps only Next question and the localized Other input affordance", async () => {
|
||||
mockViewport(viewport);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user