diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 19cf0437e5..a0fc64b5fa 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -74,6 +74,8 @@ The installed mobile/PWA home-screen icons are generated from `packages/dashboar The dashboard now handles browser back navigation consistently on desktop and mobile. Using Back will first dismiss open modals and then step back through in-app view changes before leaving the app. +On mobile, an open navigation-bar **More** sheet is dismissed by one browser Back action, iOS edge-swipe, or Android Back action before the current dashboard view changes. + When task detail is open from a board card, task popup, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser, iOS edge-swipe, or Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board/list). On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible. diff --git a/packages/dashboard/app/components/MobileNavBar.tsx b/packages/dashboard/app/components/MobileNavBar.tsx index ab24fcbd4a..b9e14da30c 100644 --- a/packages/dashboard/app/components/MobileNavBar.tsx +++ b/packages/dashboard/app/components/MobileNavBar.tsx @@ -1,5 +1,5 @@ import "./MobileNavBar.css"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Activity, Bot, @@ -34,6 +34,7 @@ import { useTranslation } from "react-i18next"; import { fetchScripts } from "../api"; import type { PluginDashboardViewEntry } from "../api"; import { useViewportMode } from "./Header"; +import { NavigationHistoryContext } from "../hooks/useNavigationHistory"; import type { TaskView } from "../hooks/useViewState"; import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry"; import { getPluginDashboardViewNavIcon } from "./pluginNavIcon"; @@ -169,6 +170,7 @@ export function MobileNavBar({ }: MobileNavBarProps) { const { t } = useTranslation("app"); const mode = useViewportMode(); + const navigationHistory = useContext(NavigationHistoryContext); const [isMoreOpen, setIsMoreOpen] = useState(false); const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false); const [scripts, setScripts] = useState>({}); @@ -227,6 +229,22 @@ export function MobileNavBar({ setIsMoreOpen(false); }, [resetSheetDrag]); + /* + FNXC:MobileNav 2026-07-16-14:30: + The More sheet must dismiss before navigation on iOS swipe-back, Android native Back, and browser Back. + Register its stable, idempotent closer as a modal entry, while reading nullable context so provider-less + component renders retain their existing behavior. + */ + useEffect(() => { + if (!isMoreOpen || !navigationHistory) return; + navigationHistory.pushNav({ type: "modal", close: closeMore }); + }, [closeMore, isMoreOpen, navigationHistory]); + + const dismissMore = useCallback(() => { + navigationHistory?.removeNav(closeMore); + closeMore(); + }, [closeMore, navigationHistory]); + /* FNXC:MobileNav 2026-07-16-12:00: The mobile More drawer must dismiss when a user drags down from its top or grab handle. @@ -288,16 +306,16 @@ export function MobileNavBar({ resetSheetDrag(); if (shouldDismiss && touch) { - closeMore(); + dismissMore(); } - }, [closeMore, resetSheetDrag]); + }, [dismissMore, resetSheetDrag]); const handleMoreAction = useCallback( (callback?: () => void) => { - closeMore(); + dismissMore(); callback?.(); }, - [closeMore], + [dismissMore], ); useEffect(() => { @@ -305,13 +323,13 @@ export function MobileNavBar({ const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - closeMore(); + dismissMore(); } }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); - }, [closeMore, isMoreOpen]); + }, [dismissMore, isMoreOpen]); useLayoutEffect(() => { const navEl = navRef.current; @@ -561,7 +579,13 @@ export function MobileNavBar({ data-testid="mobile-nav-tab-more" role="tab" aria-selected={false} - onClick={() => setIsMoreOpen((prev) => !prev)} + onClick={() => { + if (isMoreOpen) { + dismissMore(); + } else { + setIsMoreOpen(true); + } + }} > @@ -577,7 +601,7 @@ export function MobileNavBar({ <>
{ if (onRunScript) onRunScript(name, command); - closeMore(); + dismissMore(); setIsScriptsSubmenuOpen(false); }} > @@ -673,7 +697,7 @@ export function MobileNavBar({ className="mobile-more-item mobile-more-subitem mobile-more-subitem--manage" data-testid="mobile-more-scripts-manage" onClick={() => { - closeMore(); + dismissMore(); setIsScriptsSubmenuOpen(false); onOpenScripts(); }} @@ -690,7 +714,7 @@ export function MobileNavBar({ className="mobile-more-item mobile-more-subitem" data-testid="mobile-more-scripts-manage" onClick={() => { - closeMore(); + dismissMore(); setIsScriptsSubmenuOpen(false); onOpenScripts(); }} diff --git a/packages/dashboard/app/components/__tests__/MobileNavBar.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/MobileNavBar.swipe-back.test.tsx new file mode 100644 index 0000000000..89f4f56b07 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MobileNavBar.swipe-back.test.tsx @@ -0,0 +1,190 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useEffect, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchScripts } from "../../api"; +import { NavigationHistoryProvider, useNavigationHistory, type UseNavigationHistoryResult } from "../../hooks/useNavigationHistory"; +import { MobileNavBar } from "../MobileNavBar"; + +vi.mock("../../api", () => ({ + fetchScripts: vi.fn(), +})); + +function mockMobileViewport() { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function HistoryHarness({ children, onReady }: { children: ReactNode; onReady: (history: UseNavigationHistoryResult) => void }) { + const history = useNavigationHistory({ enabled: true }); + useEffect(() => onReady(history), [history, onReady]); + return {children}; +} + +const createDefaultProps = () => ({ + view: "board" as const, + onChangeView: vi.fn(), + footerVisible: false, + onOpenActivityLog: vi.fn(), + onOpenGitManager: vi.fn(), + onOpenWorkflowEditor: vi.fn(), + onOpenSchedules: vi.fn(), + onOpenScripts: vi.fn(), + onToggleTerminal: vi.fn(), + onOpenFiles: vi.fn(), + onOpenGitHubImport: vi.fn(), + onOpenPlanning: vi.fn(), + onResumePlanning: vi.fn(), + onOpenUsage: vi.fn(), + onViewAllProjects: vi.fn(), + onRunScript: vi.fn(), + projectId: "proj_1", +}); + +function dispatchPopState(navIndex: number) { + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex } })); + }); +} + +async function openMore() { + fireEvent.click(screen.getByTestId("mobile-nav-tab-more")); + await waitFor(() => expect(screen.getByTestId("mobile-more-item-activity")).toBeInTheDocument()); +} + +describe("MobileNavBar More sheet navigation history", () => { + let navigationHistory: UseNavigationHistoryResult | null; + + beforeEach(() => { + mockMobileViewport(); + navigationHistory = null; + window.history.replaceState({ navIndex: 0 }, ""); + vi.mocked(fetchScripts).mockReset(); + vi.mocked(fetchScripts).mockResolvedValue({}); + vi.spyOn(window.history, "back").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function renderWithHistory(props = createDefaultProps()) { + const rendered = render( + { navigationHistory = history; }}> + + , + ); + return { ...rendered, props }; + } + + it("dismisses the More sheet on browser Back without navigating away", async () => { + const { container } = renderWithHistory(); + await openMore(); + + dispatchPopState(0); + + await waitFor(() => expect(container.querySelector(".mobile-more-sheet")).toBeNull()); + dispatchPopState(0); + expect(container.querySelector(".mobile-more-sheet")).toBeNull(); + }); + + it("routes Android native Back through history before dismissing the More sheet", async () => { + const { container } = renderWithHistory(); + await openMore(); + const nativeBack = new CustomEvent("fusion:native-back", { cancelable: true }); + + expect(window.dispatchEvent(nativeBack)).toBe(false); + expect(window.history.back).toHaveBeenCalledOnce(); + expect(container.querySelector(".mobile-more-sheet")).not.toBeNull(); + + dispatchPopState(0); + await waitFor(() => expect(container.querySelector(".mobile-more-sheet")).toBeNull()); + }); + + async function expectProgrammaticCloseConsumesMoreEntry(close: () => void | Promise) { + vi.mocked(window.history.back).mockClear(); + const sentinelClose = vi.fn(); + const rendered = renderWithHistory(); + await waitFor(() => expect(navigationHistory).not.toBeNull()); + navigationHistory?.pushNav({ type: "modal", close: sentinelClose }); + await openMore(); + + await close(); + expect(screen.queryByTestId("mobile-more-item-activity")).toBeNull(); + expect(window.history.back).toHaveBeenCalledOnce(); + + // removeNav's history.back() self-pop is ignored before the lower entry can handle Back. + dispatchPopState(1); + expect(sentinelClose).not.toHaveBeenCalled(); + dispatchPopState(0); + expect(sentinelClose).toHaveBeenCalledOnce(); + rendered.unmount(); + } + + it("consumes the More entry on backdrop close", async () => { + await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.click(document.querySelector(".mobile-more-sheet-backdrop")!)); + }); + + it("consumes the More entry on item action", async () => { + await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.click(screen.getByTestId("mobile-more-item-activity"))); + }); + + it("consumes the More entry on Escape", async () => { + await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.keyDown(document, { key: "Escape" })); + }); + + it("consumes the More entry on drag dismissal", async () => { + await expectProgrammaticCloseConsumesMoreEntry(() => { + const sheet = document.querySelector(".mobile-more-sheet")!; + Object.defineProperty(sheet, "getBoundingClientRect", { configurable: true, value: () => ({ height: 400 }) }); + fireEvent.touchStart(sheet, { touches: [{ clientY: 100 }] }); + fireEvent.touchMove(sheet, { touches: [{ clientY: 300 }] }); + fireEvent.touchEnd(sheet, { changedTouches: [{ clientY: 300 }] }); + }); + }); + + it("consumes the More entry when its tab toggles closed", async () => { + await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.click(screen.getByTestId("mobile-nav-tab-more"))); + }); + + it("consumes the More entry when a script runs", async () => { + vi.mocked(fetchScripts).mockResolvedValueOnce({ build: "pnpm build" }); + await expectProgrammaticCloseConsumesMoreEntry(async () => { + fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle")); + await waitFor(() => expect(screen.getByTestId("mobile-more-script-item-build")).toBeInTheDocument()); + fireEvent.click(screen.getByTestId("mobile-more-script-item-build")); + }); + }); + + it("consumes the More entry from Manage Scripts", async () => { + vi.mocked(fetchScripts).mockResolvedValueOnce({ build: "pnpm build" }); + await expectProgrammaticCloseConsumesMoreEntry(async () => { + fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle")); + await waitFor(() => expect(screen.getByTestId("mobile-more-scripts-manage")).toBeInTheDocument()); + fireEvent.click(screen.getByTestId("mobile-more-scripts-manage")); + }); + }); + + it("consumes the More entry from the no-scripts add-one branch", async () => { + vi.mocked(fetchScripts).mockResolvedValueOnce({}); + await expectProgrammaticCloseConsumesMoreEntry(async () => { + fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle")); + await waitFor(() => expect(screen.getByText("No scripts — add one…")).toBeInTheDocument()); + fireEvent.click(screen.getByTestId("mobile-more-scripts-manage")); + }); + }); + + it("keeps provider-less More-sheet renders functional", async () => { + const { container } = render(); + await openMore(); + fireEvent.click(document.querySelector(".mobile-more-sheet-backdrop")!); + await waitFor(() => expect(container.querySelector(".mobile-more-sheet")).toBeNull()); + }); +});