fix(planning): use back-only session navigation

This commit is contained in:
gsxdsm
2026-07-21 13:02:48 -07:00
parent 83209e64dc
commit 3b9d508def
6 changed files with 91 additions and 64 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Replace the Planning Sessions toggle with a consistent Back-to-sessions control.
category: fix
dev: Uses the existing session-list transition across desktop and compact Planning layouts.

View File

@@ -587,11 +587,12 @@ The New session button must look EXACTLY like Missions' primary sidebar create b
padding: 0 8px;
}
.planning-mobile-back {
/* Visible only on mobile (see media query below). On desktop the sidebar
is always visible alongside the detail pane, so a back button is never
needed. */
display: none;
.planning-session-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;
background: none;
border: none;
color: var(--text);
@@ -602,11 +603,11 @@ The New session button must look EXACTLY like Missions' primary sidebar create b
transition: background var(--transition-fast), box-shadow var(--transition-fast);
}
.planning-mobile-back:hover {
.planning-session-back:hover {
background: var(--card-hover);
}
.planning-mobile-back:focus-visible {
.planning-session-back:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
@@ -711,14 +712,6 @@ The New session button must look EXACTLY like Missions' primary sidebar create b
.planning-modal-body--show-list .planning-detail {
display: none;
}
/* 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,
.planning-sidebar-item-archive {
@@ -1830,14 +1823,6 @@ plan actions, and a token-sized bottom inset keep all three controls inline with
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;
@@ -2169,7 +2154,7 @@ mobile and desktop offer the same View task / Return to sessions choices.
/*
FNXC:PlanningModeMobile 2026-07-20-21:50:
The Planning detail header keeps Back, the lightbulb, a single-line truncated title, and Edit on
one row. Fixed icon controls may not shrink; only the title yields space before Sessions/History.
one row. Fixed icon controls may not shrink; only the title yields space before History.
*/
.planning-modal--embedded .modal-header--embedded .detail-title-row {
@@ -2191,7 +2176,7 @@ mobile and desktop offer the same View task / Return to sessions choices.
.planning-modal--embedded .modal-header--embedded .detail-title-row > svg,
.planning-modal--embedded .modal-header--embedded .detail-title-row > .btn-icon,
.planning-modal--embedded .modal-header--embedded .planning-mobile-back {
.planning-modal--embedded .modal-header--embedded .planning-session-back {
flex: 0 0 auto;
}
@@ -2202,7 +2187,7 @@ mobile and desktop offer the same View task / Return to sessions choices.
.planning-header-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(0, 1fr);
width: 100%;
}

View File

@@ -617,14 +617,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
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.
FN-8427 makes the saved-session list a real destination. Back enters this mode and unmounts
the active interview plan so it cannot consume flex height beneath session rows.
*/
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;
// FNXC:PlanningSessionBack 2026-07-21-11:15: Back covers selected details on every viewport and drafts whenever saved sessions exist; list mode removes it instead of leaving an orphaned control.
const canReturnToSessionList = !isSessionListMode
&& (selectedSessionId !== null || planningSessions.length > 0);
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
const [mobileWorkspaceTab, setMobileWorkspaceTab] = useState<"question" | "plan">("question");
/*
@@ -1918,6 +1917,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
}, [projectId, resetDetailState, resumeSessionId, selectedSessionId]);
const handleBackToList = useCallback(() => {
setIsHistoryOpen(false);
setShowSessionList(true);
setMobileShowDetail(false);
}, []);
@@ -2832,13 +2832,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
<div className={isEmbedded ? "modal modal-lg planning-modal planning-modal--embedded" : "modal modal-lg planning-modal"} ref={modalRef}>
{/*
FNXC:PlanningMode 2026-06-22-00:00:
Embedded planning is a main-content destination, not a dialog: it drops the modal close button and renders a plain common title (modal-header--embedded) matching other embedded views like Command Center. The mobile back affordance stays because it navigates the session list, not the view.
Embedded planning is a main-content destination, not a dialog: it drops the modal close button and renders a plain common title (modal-header--embedded) matching other embedded views like Command Center. The session-list Back affordance stays because it navigates within Planning, not away from the view.
*/}
<div className={isEmbedded ? "modal-header modal-header--embedded" : "modal-header"}>
<div className="detail-title-row">
{canReturnToSessionList && (
<button
className="modal-back planning-mobile-back"
className="modal-back planning-session-back"
onClick={handleBackToList}
aria-label={t("planning.backToSessions", "Back to sessions")}
title={t("planning.backToSessions", "Back to sessions")}
@@ -2867,26 +2867,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
)}
</div>
{/*
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.
FNXC:PlanningSessionBack 2026-07-21-11:15:
History remains the only detail action in this group. Session-list navigation lives in the
title-row Back control on every viewport, avoiding a duplicate Sessions toggle and keeping
compact list/detail state synchronized through one handler.
*/}
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error" || view.type === "plan_review" || view.type === "create_retry") && (
<div className="planning-header-controls">
<button
type="button"
className="btn"
onClick={() => {
const showList = !isSessionListMode;
setIsHistoryOpen(false);
setShowSessionList(showList);
if (isCompactInterview) setMobileShowDetail(!showList);
}}
>
{t("planning.sessions", "Sessions")}
</button>
<button
ref={historyTriggerRef}
type="button"

View File

@@ -115,12 +115,16 @@ describe("PlanningModeModal CSS responsive action contract", () => {
it("uses consistent full-width header controls without crowding the mobile session title", () => {
const css = loadPlanningCss();
const backRule = findRule(css, ".planning-session-back");
expect(findRule(css, ".planning-header-controls")).toMatch(/gap\s*:\s*var\(--space-sm\)\s*;/);
expect(findRule(css, ".planning-header-controls .btn")).toMatch(/min-height\s*:\s*calc\(var\(--space-2xl\) \+ var\(--space-sm\)\)\s*;/);
expect(backRule).toMatch(/display\s*:\s*inline-flex\s*;/);
expect(backRule).toMatch(/min-width\s*:\s*calc\(var\(--space-md\) \* 2\.25\)\s*;/);
expect(backRule).toMatch(/min-height\s*:\s*calc\(var\(--space-md\) \* 2\.25\)\s*;/);
const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n");
expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded")).toMatch(/flex-wrap\s*:\s*wrap\s*;/);
expect(findRule(mobileCss, ".planning-header-controls")).toMatch(/grid-template-columns\s*:\s*repeat\(2, minmax\(0, 1fr\)\)\s*;/);
expect(findRule(mobileCss, ".planning-header-controls")).toMatch(/grid-template-columns\s*:\s*minmax\(0, 1fr\)\s*;/);
expect(findRule(mobileCss, ".planning-header-controls")).toMatch(/width\s*:\s*100%\s*;/);
expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row")).toMatch(/flex-wrap\s*:\s*nowrap\s*;/);
expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row")).toMatch(/overflow\s*:\s*hidden\s*;/);
@@ -128,7 +132,7 @@ describe("PlanningModeModal CSS responsive action contract", () => {
expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row h3")).toMatch(/min-width\s*:\s*0\s*;/);
expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row h3")).toMatch(/text-overflow\s*:\s*ellipsis\s*;/);
expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row h3")).toMatch(/white-space\s*:\s*nowrap\s*;/);
expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row > svg,\n .planning-modal--embedded .modal-header--embedded .detail-title-row > .btn-icon,\n .planning-modal--embedded .modal-header--embedded .planning-mobile-back")).toMatch(/flex\s*:\s*0 0 auto\s*;/);
expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row > svg,\n .planning-modal--embedded .modal-header--embedded .detail-title-row > .btn-icon,\n .planning-modal--embedded .modal-header--embedded .planning-session-back")).toMatch(/flex\s*:\s*0 0 auto\s*;/);
});
it("keeps tablet question and plan actions on one aligned row with a tight bottom inset", () => {
@@ -173,9 +177,5 @@ 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*;/);
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

@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { resolve } from "node:path";
describe("PlanningModeModal initial surface", () => {
it("keeps Sessions as the single escape hatch while the active interview is sequential", () => {
it("keeps the sequential interview surfaces available", () => {
const source = readFileSync(resolve(process.cwd(), "app/components/PlanningModeModal.tsx"), "utf8");
expect(source).toContain('data-testid="planning-plan-review"');
expect(source).toContain('data-testid="planning-refine-menu"');

View File

@@ -3,7 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { PlanningModeModal } from "../PlanningModeModal";
import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockRetryPlanningSession, mockStartPlanningStreaming, mockStopPlanningGeneration, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "mobile"));
const mockViewportMode = vi.hoisted(() => vi.fn(() => "desktop" as "desktop" | "tablet" | "mobile"));
const mockConnectPlanningStream = vi.hoisted(() => vi.fn());
const mockPlanningSse = vi.hoisted(() => ({ events: null as Record<string, (event: MessageEvent) => void> | null }));
@@ -263,7 +263,7 @@ describe("PlanningModeModal sequential flow", () => {
expect(screen.queryByRole("checkbox", { name: "Security boundaries" })).toBeNull();
expect(screen.getByRole("button", { name: "Refine" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Proceed with plan" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sessions" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Sessions" })).toBeNull();
const scrollRegion = screen.getByTestId("planning-plan-scroll");
const actionBar = screen.getByTestId("planning-plan-actions");
expect(scrollRegion).not.toContainElement(actionBar);
@@ -301,7 +301,7 @@ describe("PlanningModeModal sequential flow", () => {
expect(mockConnectPlanningStream).not.toHaveBeenCalled();
});
it("opens question, answer, and collapsed AI reasoning history beside Sessions", async () => {
it("opens question, answer, and collapsed AI reasoning history without a Sessions toggle", async () => {
mockFetchAiSession.mockResolvedValue({
...base,
status: "awaiting_input",
@@ -321,9 +321,8 @@ describe("PlanningModeModal sequential flow", () => {
});
renderSession();
const sessionsButton = await screen.findByRole("button", { name: "Sessions" });
const historyButton = screen.getByRole("button", { name: "History" });
expect(sessionsButton.parentElement).toContainElement(historyButton);
const historyButton = await screen.findByRole("button", { name: "History" });
expect(screen.queryByRole("button", { name: "Sessions" })).toBeNull();
fireEvent.click(historyButton);
expect(screen.getByRole("region", { name: "Question and answer history" })).toBeInTheDocument();
@@ -343,6 +342,55 @@ describe("PlanningModeModal sequential flow", () => {
await waitFor(() => expect(historyButton).toHaveFocus());
});
/*
FNXC:PlanningSessionBack 2026-07-21-11:15:
Session detail navigation has one invariant across desktop and compact layouts: Back is the
only route to the saved-session list. The former Sessions toggle must not survive as a second
affordance, and list mode must not retain an orphaned Back target.
*/
it.each(["desktop", "tablet", "mobile"] as const)("uses only Back to return to sessions on %s", async (viewport) => {
mockViewportMode.mockReturnValue(viewport);
mockFetchAiSession.mockResolvedValue({
...base,
status: "awaiting_input",
currentQuestion: JSON.stringify({ id: "q-current", type: "text", question: "What should happen next?" }),
result: JSON.stringify(summaryWithRefinements),
inputPayload: "{}",
});
renderSession();
const backButton = await screen.findByRole("button", { name: "Back to sessions" });
const modalBody = document.querySelector(".planning-modal-body");
expect(screen.queryByRole("button", { name: "Sessions" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "History" }));
expect(screen.getByRole("region", { name: "Question and answer history" })).toBeInTheDocument();
fireEvent.click(backButton);
expect(modalBody).toHaveClass("planning-modal-body--show-list");
expect(screen.queryByRole("button", { name: "Back to sessions" })).toBeNull();
expect(screen.queryByRole("region", { name: "Question and answer history" })).toBeNull();
expect(screen.getByRole("complementary", { name: "Planning sessions" })).toBeInTheDocument();
});
it.each(["desktop", "tablet", "mobile"] as const)("keeps Back available from a new-session draft with saved sessions on %s", async (viewport) => {
mockViewportMode.mockReturnValue(viewport);
mockFetchAiSessions.mockResolvedValue([{
...base,
type: "planning",
status: "awaiting_input",
preview: "Saved plan",
}]);
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" />);
if (viewport !== "desktop") {
fireEvent.click(await screen.findByRole("button", { name: "New session" }));
}
expect(await screen.findByRole("button", { name: "Back to sessions" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Sessions" })).toBeNull();
});
it("creates the task directly and offers task and session-list handoffs", async () => {
mockFetchAiSession.mockResolvedValue({
...base,