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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 14:23:23 -07:00
parent d74018ff81
commit a75b2f4bb8
3 changed files with 114 additions and 8 deletions

View File

@@ -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).
<!-- FNXC:TaskDetailSwipeBackDocs 2026-07-15-10:36: Mobile task popups now register the same navigation entry as modal and full-panel task detail, so every Back delivery mechanism dismisses the popup before it can leave the originating Board or List. -->
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
<!-- FNXC:BoardNavigationDocs 2026-06-29-20:45: Mobile full-panel task detail temporarily replaces the board, so the user-facing navigation guide must document that Back to board restores the board/card scroll context instead of returning to the top of the board. -->
This behavior used to be mobile-only, and now applies across all viewports.

View File

@@ -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<string, () => 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<GraphWorkflowSelection | null>(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 (
<FloatingWindow
key={snapshot.id}

View File

@@ -21,6 +21,7 @@ import type { ProjectInfo } from "../../api";
import { scopedKey } from "../../utils/projectStorage";
const DEFAULT_PROJECT_ID = "proj-1";
let mobileTaskPopupEnabled = false;
const defaultSettings: Settings = {
maxConcurrent: 2,
@@ -45,7 +46,7 @@ vi.mock("../../api", async (importOriginal) => {
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
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;
}) => (
<div data-testid="list-view">
{tasks.map((task) => (
<button
@@ -159,7 +170,9 @@ vi.mock("../../components/ListView", () => ({
type="button"
data-testid={`list-open-${task.id}`}
onClick={() => {
if (mockUseViewportMode() === "mobile") {
if (openMobileTasksInPopup && onPopOut) {
onPopOut(task);
} else if (mockUseViewportMode() === "mobile") {
onOpenDetail(task, { origin: "list-mobile" });
}
}}
@@ -370,6 +383,7 @@ describe("Task detail mobile swipe-back", () => {
refreshTasks: vi.fn(),
}));
mockUseViewportMode.mockReturnValue("mobile");
mobileTaskPopupEnabled = false;
mockUseMobileKeyboard.mockReturnValue({
keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false,
});
@@ -529,6 +543,70 @@ describe("Task detail mobile swipe-back", () => {
});
});
it("dismisses a mobile board task popup on browser popstate while keeping the board visible", async () => {
mobileTaskPopupEnabled = true;
const task = makeTask("FN-1", "Popup Board Detail");
mockUseTasks.mockImplementation(() => ({
tasks: [task],
createTask: mockCreateTask,
moveTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
refreshTasks: vi.fn(),
}));
await renderAppAndWait("board-view");
fireEvent.click(screen.getByTestId("open-task-FN-1"));
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Popup Board Detail" })).toBeInTheDocument();
expect(screen.getByTestId("board-view")).toBeInTheDocument();
});
dispatchPopState({ navIndex: 0 });
await waitFor(() => {
expect(screen.queryByRole("heading", { name: "Popup Board Detail" })).toBeNull();
expect(screen.getByTestId("board-view")).toBeInTheDocument();
});
expect(dispatchNativeAndroidBack()).toBe(false);
});
it("dismisses a mobile list task popup on native Android Back while keeping the list visible", async () => {
mobileTaskPopupEnabled = true;
const task = makeTask("FN-1", "Popup List Detail");
mockUseTasks.mockImplementation(() => ({
tasks: [task],
createTask: mockCreateTask,
moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(),
updateTask: vi.fn(), duplicateTask: vi.fn(), archiveTask: vi.fn(), unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(), refreshTasks: vi.fn(),
}));
localStorage.setItem("kb-dashboard-view-mode", "project");
localStorage.setItem(scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID), "list");
await renderAppAndWait("list-view");
fireEvent.click(screen.getByTestId("list-open-FN-1"));
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Popup List Detail" })).toBeInTheDocument();
expect(screen.getByTestId("list-view")).toBeInTheDocument();
});
expect(dispatchNativeAndroidBack()).toBe(true);
dispatchPopState({ navIndex: 0 });
await waitFor(() => {
expect(screen.queryByRole("heading", { name: "Popup List Detail" })).toBeNull();
expect(screen.getByTestId("list-view")).toBeInTheDocument();
});
expect(dispatchNativeAndroidBack()).toBe(false);
});
it("does not swallow native Android Back when no Fusion nav entry exists", async () => {
await renderAppAndWait("board-view");