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. 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 ### Responsive Header
The dashboard header adapts across three responsive tiers to remain usable without wrapping or dropping controls: 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"); useModalResizePersist(modalRef, isOpen, "fusion:planning-modal-size");
const viewportMode = useViewportMode(); const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
const { pushNav } = useNavigationHistoryContext(); const { pushNav } = useNavigationHistoryContext();
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } =
@@ -976,41 +977,37 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setMobileShowDetail(false); setMobileShowDetail(false);
}, []); }, []);
const handleClearSelectedSession = useCallback(() => { const previousMobileShowDetailRef = useRef<boolean>(false);
setSelectedSessionId(null);
setMobileShowDetail(false);
}, []);
const previousSelectedSessionIdRef = useRef<string | null>(selectedSessionId);
const previousMobileShowDetailRef = useRef(mobileShowDetail);
useEffect(() => { useEffect(() => {
const previousSelectedSessionId = previousSelectedSessionIdRef.current; if (!isOpen) {
previousSelectedSessionIdRef.current = selectedSessionId; previousMobileShowDetailRef.current = false;
if (viewportMode !== "mobile" || !selectedSessionId || previousSelectedSessionId !== null) {
return;
} }
}, [isOpen]);
pushNav({
type: "view",
revert: handleClearSelectedSession,
});
}, [handleClearSelectedSession, pushNav, selectedSessionId, viewportMode]);
useEffect(() => { useEffect(() => {
const previousMobileShowDetail = previousMobileShowDetailRef.current; 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; return;
} }
pushNav({ if (!mobileShowDetail) {
type: "view", previousMobileShowDetailRef.current = false;
revert: handleBackToList, return;
}); }
}, [handleBackToList, mobileShowDetail, pushNav, selectedSessionId, viewportMode]);
// 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( const syncPlanningDraft = useCallback(
async (sessionId: string, planText: string) => { async (sessionId: string, planText: string) => {

View File

@@ -80,8 +80,11 @@ function HistoryHarness({ children }: { children: ReactNode }) {
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>; 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", () => { describe("PlanningModeModal mobile swipe-back", () => {
const originalPushState = window.history.pushState; let pushStateSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -89,23 +92,13 @@ describe("PlanningModeModal mobile swipe-back", () => {
mockFetchAiSessions.mockResolvedValue([planningSessionSummary]); mockFetchAiSessions.mockResolvedValue([planningSessionSummary]);
mockFetchAiSession.mockResolvedValue(planningSessionDetail); mockFetchAiSession.mockResolvedValue(planningSessionDetail);
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }); mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
window.history.pushState = vi.fn(); pushStateSpy = vi.spyOn(window.history, "pushState");
}); });
afterEach(() => { it("pushes one mobile nav entry when opening a planning session and popstate returns to list view", async () => {
window.history.pushState = originalPushState; const { rerender } = render(
});
it("pushes a mobile nav entry when opening a planning session and popstate returns to the list", async () => {
render(
<HistoryHarness> <HistoryHarness>
<PlanningModeModal <PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
isOpen={true}
onClose={vi.fn()}
onTaskCreated={vi.fn()}
onTasksCreated={vi.fn()}
tasks={[]}
/>
</HistoryHarness>, </HistoryHarness>,
); );
@@ -117,30 +110,34 @@ describe("PlanningModeModal mobile swipe-back", () => {
await waitFor(() => { await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("plan-1"); 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(() => { act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } })); window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
}); });
await waitFor(() => { 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( render(
<HistoryHarness> <HistoryHarness>
<PlanningModeModal <PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
isOpen={true}
onClose={vi.fn()}
onTaskCreated={vi.fn()}
onTasksCreated={vi.fn()}
tasks={[]}
/>
</HistoryHarness>, </HistoryHarness>,
); );
@@ -151,32 +148,26 @@ describe("PlanningModeModal mobile swipe-back", () => {
fireEvent.click(screen.getByRole("button", { name: /new session/i })); fireEvent.click(screen.getByRole("button", { name: /new session/i }));
await waitFor(() => { 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(() => { act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } })); window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
}); });
await waitFor(() => { 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"); mockViewportMode.mockReturnValue("desktop");
render( render(
<HistoryHarness> <HistoryHarness>
<PlanningModeModal <PlanningModeModal isOpen={true} onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={[]} />
isOpen={true}
onClose={vi.fn()}
onTaskCreated={vi.fn()}
onTasksCreated={vi.fn()}
tasks={[]}
/>
</HistoryHarness>, </HistoryHarness>,
); );
@@ -185,10 +176,61 @@ describe("PlanningModeModal mobile swipe-back", () => {
}); });
fireEvent.click(screen.getByText("Roadmap draft")); fireEvent.click(screen.getByText("Roadmap draft"));
fireEvent.click(screen.getByRole("button", { name: /new session/i }));
await waitFor(() => { await waitFor(() => {
expect(mockFetchAiSession).toHaveBeenCalledWith("plan-1"); 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);
});
}); });
}); });