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.
|
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:
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user