fix(planning): use back-only session navigation
This commit is contained in:
7
.changeset/use-planning-session-back.md
Normal file
7
.changeset/use-planning-session-back.md
Normal 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.
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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*;/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user