From a75b2f4bb8d088ea2de65de96b2661529306b76b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 14:23:23 -0700 Subject: [PATCH] FN-7980: dismiss mobile task popups on swipe/back without leaving board Register mobile task popups on the Fusion nav stack so browser Back, iOS edge-swipe, and Android Back close the popup and keep the board/list visible. - Push a modal nav entry when opening a mobile task popup and clean it up on close - Route FloatingWindow and shortcut closes through nav-aware popup close - Add swipe-back tests for board and list popup dismissal - Document popup Back behavior in the dashboard guide Files changed: docs/dashboard-guide.md | 3 +- packages/dashboard/app/App.tsx | 35 +++++++-- .../__tests__/TaskDetail.swipe-back.test.tsx | 84 +++++++++++++++++++++- 3 files changed, 114 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7980 Fusion-Task-Lineage: e321a1df-e271-41c0-81af-3560d759f7bb Co-authored-by: Fusion (runfusion.ai) --- docs/dashboard-guide.md | 3 +- packages/dashboard/app/App.tsx | 35 +++++++- .../__tests__/TaskDetail.swipe-back.test.tsx | 84 ++++++++++++++++++- 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 4e2e3bcba2..5783350e02 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -68,7 +68,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. -When task detail is open from a board card, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser/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). +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. This behavior used to be mobile-only, and now applies across all viewports. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 6b4fd74946..dd875907a0 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -474,9 +474,36 @@ function AppInner() { Open popped-out task-detail windows. Each entry is a task snapshot rendered inside its own movable, resizable, non-blocking FloatingWindow. Several can be open at once and coexist with the right-dock pop-out and terminal (all click-through overlays). Snapshots survive a tasks revalidation; rendering prefers the live row by id and falls back to the snapshot. Pop-out dedupes by task id — re-popping an already-open task is a no-op (its window stays; focus-to-front in FloatingWindow handles re-raising on click). */ const { entries: poppedOutTaskEntries, popOut: popOutTaskDetail, close: closePoppedOutTask } = usePoppedOutTasks(); + const popupNavCloseRef = useRef(new Map void>()); + + /* + FNXC:TaskDetailSwipeBack 2026-07-15-10:32: + Mobile task popups keep Board or List visible, but they are still task-detail + surfaces. Give each newly opened popup its own navigation callback so browser, + iOS swipe, and Android Back dismiss only that popup rather than leaving the + Fusion stack empty and allowing Back to skip past the originating task view. + */ + const closePoppedOutTaskWithNav = useCallback((taskId: string) => { + const closeFromHistory = popupNavCloseRef.current.get(taskId); + if (closeFromHistory) { + popupNavCloseRef.current.delete(taskId); + removeNav(closeFromHistory); + } + closePoppedOutTask(taskId); + }, [closePoppedOutTask, removeNav]); + const popOutTaskDetailForCurrentView = useCallback((task: Task | TaskDetail) => { + const alreadyOpen = poppedOutTaskEntries.some((entry) => entry.task.id === task.id); + if (isMobile && !alreadyOpen) { + const closeFromHistory = () => { + popupNavCloseRef.current.delete(task.id); + closePoppedOutTask(task.id); + }; + popupNavCloseRef.current.set(task.id, closeFromHistory); + pushNav({ type: "modal", close: closeFromHistory }); + } popOutTaskDetail(task, taskView); - }, [popOutTaskDetail, taskView]); + }, [isMobile, poppedOutTaskEntries, popOutTaskDetail, pushNav, closePoppedOutTask, taskView]); const boardSourceTasks = isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks; const [graphWorkflowSelection, setGraphWorkflowSelection] = useState(null); @@ -1079,12 +1106,12 @@ function AppInner() { ], }, { - closePoppedOutTask, + closePoppedOutTask: closePoppedOutTaskWithNav, closeQuickChat: () => setQuickChatOpen(false), closeTerminal: closeTerminalWithNav, }, ); - }, [closePoppedOutTask, closeTerminalWithNav, modalManager, quickChatOpen, visiblePoppedOutTasks]); + }, [closePoppedOutTaskWithNav, closeTerminalWithNav, modalManager, quickChatOpen, visiblePoppedOutTasks]); const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => { modalManager.openFiles(workspace, initialFile); @@ -1812,7 +1839,7 @@ function AppInner() { */} {visiblePoppedOutTaskEntries.map(({ task: snapshot }) => { const liveTask = tasks.find((candidate) => candidate.id === snapshot.id) ?? snapshot; - const close = () => closePoppedOutTask(snapshot.id); + const close = () => closePoppedOutTaskWithNav(snapshot.id); return ( { return createDashboardApiMock(() => importOriginal(), { fetchTasks: vi.fn(() => Promise.resolve([])), fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2, rootDir: "/workspace/project" })), - fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })), + fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings, openMobileTasksInPopup: mobileTaskPopupEnabled })), updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })), fetchGlobalSettings: vi.fn(() => Promise.resolve({})), fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [] })), @@ -151,7 +152,17 @@ vi.mock("../../components/Board", () => ({ })); vi.mock("../../components/ListView", () => ({ - ListView: ({ tasks, onOpenDetail }: { tasks: Task[]; onOpenDetail: (task: Task, options?: { origin?: "list-mobile" }) => void }) => ( + ListView: ({ + tasks, + onOpenDetail, + onPopOut, + openMobileTasksInPopup, + }: { + tasks: Task[]; + onOpenDetail: (task: Task, options?: { origin?: "list-mobile" }) => void; + onPopOut?: (task: Task) => void; + openMobileTasksInPopup?: boolean; + }) => (
{tasks.map((task) => (