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:
5
.changeset/fn-4187-planning-newsession-swipeback.md
Normal file
5
.changeset/fn-4187-planning-newsession-swipeback.md
Normal 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.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user