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