feat(FN-4187): fix mobile swipe-back navigation into PlanningMode detail pa

Merged PlanningModeModal swipe-back and New Session handling: the feature commits wire key swipe-back gestures to control mobile detail-pane visibility and document the nav trigger rule, while the test commit adds coverage for New Session and mobile swipe-back regressions.

Fusion-Task-Id: FN-4187
This commit is contained in:
Fusion
2026-05-12 22:40:31 -07:00
committed by gsxdsm
parent 07f008d4eb
commit d084ff452f
4 changed files with 110 additions and 64 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix mobile swipe-back from the Planning modal's "New Session" path: opening a new planning session on mobile now registers a back-stack entry so swipe-back returns to the planning sessions list instead of closing the modal.

View File

@@ -115,6 +115,8 @@ AI-guided interactive planning for creating well-specified tasks from high-level
Any mobile list→detail surface that swaps panes in place (for example Chat, Missions, or Planning) must push a `view` entry when detail opens, with an idempotent `revert` callback that returns to the list. This keeps iOS swipe-back and Android/browser back aligned with the in-app back button instead of skipping the intermediate list state.
For surfaces with multiple entry paths into detail (for example selecting an existing row and creating a new item/session), key the push effect on the visible detail-pane signal rather than selected-item ID transitions so every path registers the back-stack entry.
### Responsive Header
The dashboard header adapts across three responsive tiers to remain usable without wrapping or dropping controls:

View File

@@ -239,6 +239,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const { pushNav } = useNavigationHistoryContext();
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
@@ -976,41 +977,37 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setMobileShowDetail(false);
}, []);
const handleClearSelectedSession = useCallback(() => {
setSelectedSessionId(null);
setMobileShowDetail(false);
}, []);
const previousSelectedSessionIdRef = useRef<string | null>(selectedSessionId);
const previousMobileShowDetailRef = useRef(mobileShowDetail);
const previousMobileShowDetailRef = useRef<boolean>(false);
useEffect(() => {
const previousSelectedSessionId = previousSelectedSessionIdRef.current;
previousSelectedSessionIdRef.current = selectedSessionId;
if (viewportMode !== "mobile" || !selectedSessionId || previousSelectedSessionId !== null) {
return;
if (!isOpen) {
previousMobileShowDetailRef.current = false;
}
pushNav({
type: "view",
revert: handleClearSelectedSession,
});
}, [handleClearSelectedSession, pushNav, selectedSessionId, viewportMode]);
}, [isOpen]);
useEffect(() => {
const previousMobileShowDetail = previousMobileShowDetailRef.current;
previousMobileShowDetailRef.current = mobileShowDetail;
if (viewportMode !== "mobile" || !mobileShowDetail || previousMobileShowDetail || selectedSessionId !== null) {
if (!isMobile) {
// Keep the previous mobile detail state untouched on desktop so viewport flips don't trigger stale pushes.
return;
}
pushNav({
type: "view",
revert: handleBackToList,
});
}, [handleBackToList, mobileShowDetail, pushNav, selectedSessionId, viewportMode]);
if (!mobileShowDetail) {
previousMobileShowDetailRef.current = false;
return;
}
// FN-4187: Push on mobileShowDetail transitions (not selectedSessionId) so New Session also gets a back-stack entry.
if (!previousMobileShowDetail) {
pushNav({
type: "view",
revert: handleBackToList,
});
}
previousMobileShowDetailRef.current = true;
}, [handleBackToList, isMobile, mobileShowDetail, pushNav]);
const syncPlanningDraft = useCallback(
async (sessionId: string, planText: string) => {

View File

@@ -80,8 +80,11 @@ function HistoryHarness({ children }: { children: ReactNode }) {
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
}
const countNavIndexPushes = (pushStateSpy: ReturnType<typeof vi.spyOn>) =>
pushStateSpy.mock.calls.filter(([state]) => typeof (state as { navIndex?: unknown })?.navIndex === "number").length;
describe("PlanningModeModal mobile swipe-back", () => {
const originalPushState = window.history.pushState;
let pushStateSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
@@ -89,23 +92,13 @@ describe("PlanningModeModal mobile swipe-back", () => {
mockFetchAiSessions.mockResolvedValue([planningSessionSummary]);
mockFetchAiSession.mockResolvedValue(planningSessionDetail);
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
window.history.pushState = vi.fn();
pushStateSpy = vi.spyOn(window.history, "pushState");
});
afterEach(() => {
window.history.pushState = originalPushState;
});
it("pushes a mobile nav entry when opening a planning session and popstate returns to the list", async () => {
render(
it("pushes one mobile nav entry when opening a planning session and popstate returns to list view", async () => {
const { rerender } = render(
<HistoryHarness>
<PlanningModeModal
isOpen={true}
onClose={vi.fn()}
onTaskCreated={vi.fn()}
onTasksCreated={vi.fn()}
tasks={[]}
/>
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
</HistoryHarness>,
);
@@ -117,30 +110,34 @@ describe("PlanningModeModal mobile swipe-back", () => {
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("plan-1");
expect(window.history.pushState).toHaveBeenCalledWith(expect.objectContaining({ navIndex: 1 }), "");
expect(countNavIndexPushes(pushStateSpy)).toBe(1);
});
expect(screen.getByLabelText("Back to sessions")).toBeInTheDocument();
rerender(
<HistoryHarness>
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
</HistoryHarness>,
);
await waitFor(() => {
expect(countNavIndexPushes(pushStateSpy)).toBe(1);
});
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
await waitFor(() => {
expect(screen.queryByLabelText("Back to sessions")).not.toBeInTheDocument();
const body = document.querySelector(".planning-modal-body");
expect(body).toHaveClass("planning-modal-body--show-list");
expect(body).not.toHaveClass("planning-modal-body--show-detail");
});
});
it("pushes a mobile nav entry when opening the new-session detail and popstate returns to the list", async () => {
it("pushes a mobile nav entry when opening New Session and popstate returns to the list", async () => {
render(
<HistoryHarness>
<PlanningModeModal
isOpen={true}
onClose={vi.fn()}
onTaskCreated={vi.fn()}
onTasksCreated={vi.fn()}
tasks={[]}
/>
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
</HistoryHarness>,
);
@@ -151,32 +148,26 @@ describe("PlanningModeModal mobile swipe-back", () => {
fireEvent.click(screen.getByRole("button", { name: /new session/i }));
await waitFor(() => {
expect(window.history.pushState).toHaveBeenCalledWith(expect.objectContaining({ navIndex: 1 }), "");
expect(pushStateSpy).toHaveBeenCalledWith(expect.objectContaining({ navIndex: expect.any(Number) }), "");
});
expect(screen.getByLabelText("Back to sessions")).toBeInTheDocument();
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
await waitFor(() => {
expect(screen.queryByLabelText("Back to sessions")).not.toBeInTheDocument();
const body = document.querySelector(".planning-modal-body");
expect(body).toHaveClass("planning-modal-body--show-list");
expect(body).not.toHaveClass("planning-modal-body--show-detail");
});
});
it("does not push a nav entry on desktop session selection", async () => {
it("does not push nav entries on desktop for either selecting a session or opening New Session", async () => {
mockViewportMode.mockReturnValue("desktop");
render(
<HistoryHarness>
<PlanningModeModal
isOpen={true}
onClose={vi.fn()}
onTaskCreated={vi.fn()}
onTasksCreated={vi.fn()}
tasks={[]}
/>
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
</HistoryHarness>,
);
@@ -185,10 +176,61 @@ describe("PlanningModeModal mobile swipe-back", () => {
});
fireEvent.click(screen.getByText("Roadmap draft"));
fireEvent.click(screen.getByRole("button", { name: /new session/i }));
await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("plan-1");
});
expect(window.history.pushState).not.toHaveBeenCalled();
expect(countNavIndexPushes(pushStateSpy)).toBe(0);
});
it("re-arms mobile push after closing and reopening the modal", async () => {
const { rerender } = render(
<HistoryHarness>
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
</HistoryHarness>,
);
await waitFor(() => {
expect(screen.getByText("Roadmap draft")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /new session/i }));
await waitFor(() => {
expect(countNavIndexPushes(pushStateSpy)).toBe(1);
});
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
await waitFor(() => {
const body = document.querySelector(".planning-modal-body");
expect(body).toHaveClass("planning-modal-body--show-list");
expect(body).not.toHaveClass("planning-modal-body--show-detail");
});
rerender(
<HistoryHarness>
<PlanningModeModal isOpen={false} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
</HistoryHarness>,
);
rerender(
<HistoryHarness>
<PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
</HistoryHarness>,
);
await waitFor(() => {
expect(screen.getByText("Roadmap draft")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /new session/i }));
await waitFor(() => {
expect(countNavIndexPushes(pushStateSpy)).toBe(2);
});
});
});