From d22b8cc60a6807bc4757bb08ed9659c41e078d89 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 14:34:44 -0700 Subject: [PATCH] FN-7232: dismiss task detail on mobile swipe-back Mobile task details now participate in history navigation so native swipe-back dismisses or unwinds the detail modal. - Push a navigation entry whenever task detail opens through the app-level modal path. - Restore the previous task detail snapshot when backing out of nested detail opens before closing the modal. - Add mobile swipe-back coverage for board, list, dock, nested, and explicit close surfaces. - Include a patch changeset for the published Fusion CLI package. Files changed: .changeset/fn-7232-swipe-back-task-detail.md | 7 + packages/dashboard/app/App.tsx | 5 +- packages/dashboard/app/components/AppModals.tsx | 26 +- .../__tests__/TaskDetail.swipe-back.test.tsx | 572 +++++++++++++++++++++ packages/dashboard/vitest.config.ts | 1 + 5 files changed, 605 insertions(+), 6 deletions(-) Fusion-Task-Id: FN-7232 Fusion-Task-Lineage: 0291d01c-2008-490c-b0d1-0f0641fbd18c Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7232-swipe-back-task-detail.md | 7 + packages/dashboard/app/App.tsx | 5 +- .../dashboard/app/components/AppModals.tsx | 26 +- .../__tests__/TaskDetail.swipe-back.test.tsx | 572 ++++++++++++++++++ packages/dashboard/vitest.config.ts | 1 + 5 files changed, 605 insertions(+), 6 deletions(-) create mode 100644 .changeset/fn-7232-swipe-back-task-detail.md create mode 100644 packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx diff --git a/.changeset/fn-7232-swipe-back-task-detail.md b/.changeset/fn-7232-swipe-back-task-detail.md new file mode 100644 index 0000000000..2bb11c15c2 --- /dev/null +++ b/.changeset/fn-7232-swipe-back-task-detail.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Swiping back on mobile now dismisses the open task detail view. +category: fix +dev: Routes mobile task-detail opens through useNavigationHistory pushNav/removeNav so the native back gesture (popstate) reverts to the originating board/list/dock surface across all detail surfaces. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index a27d7773e3..365ad694b6 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -781,7 +781,10 @@ function AppInner() { }; }, [currentProject?.id]); - // History-aware modal open handlers — push nav entries for back-navigation. + /* + FNXC:TaskDetailSwipeBack 2026-06-29-14:21: + Mobile task-detail opens that use the modal path must still push a history entry even when the originating surface is the single-pane mobile list. AppModals owns nested-detail restoration, while this first-open path keeps the baseline dismiss-to-origin callback (`modalManager.closeDetailTask`) for the top-level modal entry. + */ const openDetailTask = useCallback((task: Task | TaskDetail, tab?: Parameters[1], opts?: { origin?: DetailTaskOrigin }) => { modalManager.openDetailTask(task, tab, opts); pushNav({ type: "modal", close: modalManager.closeDetailTask }); diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index c33fba9d1a..5554098343 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -144,6 +144,9 @@ export function AppModals({ /* FNXC:TaskDetailBack 2026-06-25-00:00: Modal task detail uses the same idempotent close path for explicit Close and browser/Android Back so deep-link URL cleanup is not skipped during popstate. Each open records the pushed history callback because nested task-detail links can create multiple detail entries with otherwise identical close behavior. + + FNXC:TaskDetailSwipeBack 2026-06-29-14:20: + Mobile swipe-back (`popstate`) for modal task detail must step back through nested task-detail opens before dismissing the modal. The latest pushed callback restores the previous task/tab/origin snapshot when one exists, and explicit close falls back to the original first-open callback (`modalManager.closeDetailTask`) so programmatic closes still consume the matching history entry. */ const closeDetailFromHistory = useCallback(() => { modalManager.closeDetailTask(); @@ -152,9 +155,9 @@ export function AppModals({ }, [deepLink, modalManager]); const closeDetailWithNav = useCallback(() => { - removeNav(detailNavCloseRef.current ?? closeDetailFromHistory); + removeNav(detailNavCloseRef.current ?? modalManager.closeDetailTask); closeDetailFromHistory(); - }, [closeDetailFromHistory, removeNav]); + }, [closeDetailFromHistory, modalManager, removeNav]); const closeGroupWithNav = useCallback(() => { removeNav(modalManager.closeGroupModal); @@ -244,13 +247,26 @@ export function AppModals({ task: Parameters[0], tab?: Parameters[1], ) => { + const previousDetailTask = modalManager.detailTask; + const previousDetailTab = modalManager.detailTaskInitialTab; + const previousDetailOrigin = modalManager.detailTaskOrigin; + const previousNavClose = detailNavCloseRef.current; + modalManager.openDetailTask(task, tab); const closeFromHistory = () => { + if (detailNavCloseRef.current === closeFromHistory) { + detailNavCloseRef.current = previousNavClose; + } + if (previousDetailTask) { + modalManager.openDetailTask( + previousDetailTask, + previousDetailTab, + previousDetailOrigin ? { origin: previousDetailOrigin } : undefined, + ); + return; + } modalManager.closeDetailTask(); deepLink.handleDetailClose(); - if (detailNavCloseRef.current === closeFromHistory) { - detailNavCloseRef.current = null; - } }; detailNavCloseRef.current = closeFromHistory; pushNav({ type: "modal", close: closeFromHistory }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx new file mode 100644 index 0000000000..c42551a7f5 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/TaskDetail.swipe-back.test.tsx @@ -0,0 +1,572 @@ +/** + * Focused regression coverage for mobile task-detail swipe-back behavior. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import type { Settings, Task } from "@fusion/core"; +import type { ProjectInfo } from "../../api"; +import { scopedKey } from "../../utils/projectStorage"; + +const DEFAULT_PROJECT_ID = "proj-1"; + +const defaultSettings: Settings = { + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + groupOverlappingFiles: false, + autoMerge: true, + recycleWorktrees: false, + worktreeInitCommand: "", + testCommand: "", + buildCommand: "", + experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, evalsView: true, todoView: true, leftSidebarNav: false, rightDock: false }, +}; + +const mockSubscribeSse = vi.fn((..._args: any[]) => vi.fn()); +vi.mock("../../sse-bus", () => ({ + subscribeSse: (...args: any[]) => mockSubscribeSse(...args), +})); + +vi.mock("../../api", async (importOriginal) => { + const { createDashboardApiMock } = await import("../../test/mockApi"); + return createDashboardApiMock(() => importOriginal(), { + fetchTasks: vi.fn(() => Promise.resolve([])), + fetchConfig: vi.fn(() => Promise.resolve({ maxConcurrent: 2, rootDir: "/workspace/project" })), + fetchSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })), + updateSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })), + fetchGlobalSettings: vi.fn(() => Promise.resolve({})), + fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [] })), + fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [], favoriteModels: [] })), + fetchGitRemotes: vi.fn(() => Promise.resolve([])), + fetchAgents: vi.fn(() => Promise.resolve([])), + fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })), + fetchUnreadCount: vi.fn(() => Promise.resolve({ unreadCount: 0 })), + fetchPluginDashboardViews: vi.fn(() => Promise.resolve([])), + fetchExecutorStats: vi.fn(() => Promise.resolve({ + globalPause: false, + enginePaused: false, + maxConcurrent: 2, + lastActivityAt: new Date().toISOString(), + })), + fetchScripts: vi.fn(() => Promise.resolve({})), + runScript: vi.fn(() => Promise.resolve({ sessionId: "sess-1", command: "echo" })), + killPtyTerminalSession: vi.fn(() => Promise.resolve({ killed: true })), + }); +}); + +const mockCreateTask = vi.fn(); +const mockUseTasks = vi.fn(() => ({ + tasks: [], + 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(), +})); +vi.mock("../../hooks/useTasks", () => ({ + useTasks: (_options?: any) => mockUseTasks(), +})); + +vi.mock("../../hooks/useInsights", () => ({ + useInsights: () => ({ + sections: [], loading: false, error: null, latestRun: null, + isRunInFlight: false, runError: null, refresh: vi.fn(), + runInsights: vi.fn(), dismiss: vi.fn(), createTask: vi.fn(), + dismissStates: new Map(), createTaskStates: new Map(), + totalCount: 0, dismissedCount: 0, + }), +})); + +vi.mock("../../hooks/useRemoteNodeData", () => ({ + useRemoteNodeData: vi.fn(() => ({ + projects: [], tasks: [], health: null, loading: false, + error: null, refresh: vi.fn(), + })), +})); + +vi.mock("../../hooks/useRemoteNodeEvents", () => ({ + useRemoteNodeEvents: vi.fn(() => ({ isConnected: false, lastEvent: null })), +})); + +vi.mock("../../hooks/useBackgroundSessions", () => ({ + useBackgroundSessions: vi.fn(() => ({ + sessions: [], generating: false, needsInput: false, + planningSessions: [], dismissSession: vi.fn(), + })), +})); + +const mockNodeContextValue = { + currentNode: null, currentNodeId: null, isRemote: false, + setCurrentNode: vi.fn(), clearCurrentNode: vi.fn(), +}; +vi.mock("../../context/NodeContext", () => ({ + NodeProvider: ({ children }: { children: React.ReactNode }) => children, + useNodeContext: vi.fn(() => mockNodeContextValue), +})); + +vi.mock("../../components/model-onboarding-state", () => ({ + isOnboardingResumable: () => false, + getOnboardingResumeStep: () => null, + getOnboardingState: () => null, + saveOnboardingState: vi.fn(), + clearOnboardingState: vi.fn(), + isOnboardingCompleted: () => false, + markOnboardingCompleted: vi.fn(), + markStepSkipped: vi.fn(), + getOnboardingCompletedAt: () => null, + getSkippedSteps: () => [], + getStepData: () => null, + ONBOARDING_FLOW_STEPS: ["ai-setup", "github", "project-setup", "agent", "first-task"], +})); + +vi.mock("../../components/Board", () => ({ + Board: ({ tasks, onOpenDetail }: { tasks: Task[]; onOpenDetail: (task: Task) => void }) => ( +
+ {tasks.map((task) => ( + + ))} +
+ ), +})); + +vi.mock("../../components/ListView", () => ({ + ListView: ({ tasks, onOpenDetail }: { tasks: Task[]; onOpenDetail: (task: Task, options?: { origin?: "list-mobile" }) => void }) => ( +
+ {tasks.map((task) => ( + + ))} +
+ ), +})); + +vi.mock("../../components/TaskDetailModal", () => ({ + TaskDetailModal: ({ + task, + onClose, + onOpenDetail, + mobileHeaderMode, + }: { + task: { id: string; title?: string }; + onClose: () => void; + onOpenDetail: (task: { id: string; title: string }) => void; + mobileHeaderMode?: "back" | "close"; + }) => ( +
+
+
{mobileHeaderMode ?? "close"}
+ + {task.id === "FN-1" ? ( + + ) : null} +

{task.title ?? task.id}

+
+
+ ), + TaskDetailContent: ({ + task, + onBackToBoard, + }: { + task: { id: string; title?: string }; + onBackToBoard?: () => void; + }) => ( +
+ {onBackToBoard ? : null} +

{task.title ?? task.id}

+
+ ), +})); + +vi.mock("../../components/SettingsModal", () => ({ + SettingsModal: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), + SettingsView: () =>
Settings
, +})); + +vi.mock("../../components/GitHubImportModal", () => ({ GitHubImportModal: () => null })); +vi.mock("../../components/PlanningModeModal", () => ({ PlanningModeModal: () => null })); +vi.mock("../../components/AgentsView", () => ({ AgentsView: () =>
Agents
})); +vi.mock("../../components/ResearchView", () => ({ ResearchView: () =>
Research
})); +vi.mock("../../components/EvalsView", () => ({ EvalsView: () =>
Evals
})); +vi.mock("../../components/TodoView", () => ({ TodoView: () =>
Todo
})); +vi.mock("../../components/QuickChatFAB", () => ({ QuickChatFAB: () => null })); +vi.mock("../../components/ScriptsModal", () => ({ ScriptsModal: () => null })); +vi.mock("../../components/TerminalModal", () => ({ TerminalModal: () => null })); +vi.mock("../../components/FileBrowser", () => ({ FileBrowserModal: () => null })); +vi.mock("../../components/ActivityLogModal", () => ({ ActivityLogModal: () => null })); +vi.mock("../../components/GitManagerModal", () => ({ GitManagerModal: () => null })); +vi.mock("../../components/SchedulesModal", () => ({ SchedulesModal: () => null })); +vi.mock("../../components/WorkflowEditorModal", () => ({ WorkflowEditorModal: () => null })); +vi.mock("../../components/AgentsModal", () => ({ AgentsModal: () => null })); +vi.mock("../../components/SubtaskBreakdownModal", () => ({ SubtaskBreakdownModal: () => null })); +vi.mock("../../components/UsageModal", () => ({ UsageModal: () => null })); +vi.mock("../../components/ModelOnboardingModal", () => ({ ModelOnboardingModal: () => null })); +vi.mock("../../components/SetupWizardModal", () => ({ SetupWizardModal: () => null })); +vi.mock("../../components/GroupTaskModal", () => ({ GroupTaskModal: () => null })); +vi.mock("../../components/ProjectSelector", () => ({ ProjectSelector: () =>
})); +vi.mock("../../components/ProjectCard", () => ({ ProjectCard: () =>
})); +vi.mock("../../components/Sidebar", () => ({ Sidebar: () =>
})); +vi.mock("../../components/Header", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Header: () =>
, + }; +}); +vi.mock("../../components/MobileNavBar", () => ({ MobileNavBar: () => null })); +vi.mock("../../components/RightDock", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + RightDock: () => null, + RightDockExpandModal: () => null, + }; +}); + +const mockUseProjects = vi.fn(() => ({ projects: [], loading: false, error: null })); +const mockCurrentProjectState = { + currentProject: { + id: DEFAULT_PROJECT_ID, + name: "Test Project", + path: "/test", + status: "active", + isolationMode: "in-process", + createdAt: "", + updatedAt: "", + } as ProjectInfo, + loading: false, + setCurrentProject: vi.fn(), + clearCurrentProject: vi.fn(), +}; +vi.mock("../../hooks/useProjects", () => ({ useProjects: () => mockUseProjects() })); +vi.mock("../../hooks/useCurrentProject", () => ({ + useCurrentProject: () => mockCurrentProjectState, +})); +vi.mock("../../hooks/useNodes", () => ({ + useNodes: vi.fn(() => ({ + nodes: [], loading: false, error: null, + refresh: vi.fn(), register: vi.fn(), update: vi.fn(), unregister: vi.fn(), healthCheck: vi.fn(), + })), +})); + +const mockUseViewportMode = vi.fn(() => "desktop"); +vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", + getViewportMode: () => mockUseViewportMode(), + isMobileViewport: () => mockUseViewportMode() === "mobile", + useViewportMode: (..._args: unknown[]) => mockUseViewportMode(..._args), +})); + +const mockUseMobileKeyboard = vi.fn(() => ({ + keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false, +})); +vi.mock("../../hooks/useMobileKeyboard", () => ({ + useMobileKeyboard: (..._args: unknown[]) => mockUseMobileKeyboard(..._args), +})); + +import { App } from "../../App"; + +function makeTask(id: string, title: string): Task { + return { + id, + title, + description: "Test task description", + column: "todo", + status: "todo", + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + } as Task; +} + +function dispatchPopState(state: Record | null) { + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state })); + }); +} + +async function renderAppAndWait(expectedTestId: string = "board-view") { + const result = render(); + await waitFor(() => { + expect(screen.getByTestId(expectedTestId)).toBeTruthy(); + }); + return result; +} + +describe("Task detail mobile swipe-back", () => { + const originalPushState = window.history.pushState; + const originalReplaceState = window.history.replaceState; + + beforeEach(() => { + vi.clearAllMocks(); + mockSubscribeSse.mockReset(); + mockSubscribeSse.mockReturnValue(vi.fn()); + mockUseTasks.mockReset(); + mockUseTasks.mockImplementation(() => ({ + tasks: [], + 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(), + })); + mockUseViewportMode.mockReturnValue("mobile"); + mockUseMobileKeyboard.mockReturnValue({ + keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false, + }); + localStorage.clear(); + window.history.pushState = vi.fn(); + window.history.replaceState = vi.fn(); + }); + + afterEach(() => { + window.history.pushState = originalPushState; + window.history.replaceState = originalReplaceState; + }); + + it("dismisses the board main-panel task detail on mobile popstate", async () => { + const task = makeTask("FN-1", "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.getByTestId("task-detail-main-panel-content")).toBeInTheDocument(); + }); + + dispatchPopState({ navIndex: 0 }); + + await waitFor(() => { + expect(screen.queryByTestId("task-detail-main-panel-content")).toBeNull(); + expect(screen.getByTestId("board-view")).toBeInTheDocument(); + }); + }); + + it("dismisses the list-mobile task detail on mobile popstate", async () => { + const task = makeTask("FN-1", "Mobile 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.getByTestId("task-detail-modal")).toBeInTheDocument(); + expect(screen.getByTestId("task-detail-mobile-header-mode")).toHaveTextContent("back"); + }); + + dispatchPopState({ navIndex: 0 }); + + await waitFor(() => { + expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + expect(screen.getByTestId("list-view")).toBeInTheDocument(); + }); + }); + + it("pushes a fresh mobile nav entry after close and reopen from the list", async () => { + const task = makeTask("FN-1", "Repeat Mobile 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.getByTestId("task-detail-modal")).toBeInTheDocument(); + }); + expect(window.history.pushState).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTestId("task-detail-close")); + dispatchPopState({ navIndex: 0 }); + await waitFor(() => { + expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + }); + + fireEvent.click(screen.getByTestId("list-open-FN-1")); + await waitFor(() => { + expect(screen.getByTestId("task-detail-modal")).toBeInTheDocument(); + }); + + expect(window.history.pushState).toHaveBeenCalledTimes(2); + + dispatchPopState({ navIndex: 0 }); + await waitFor(() => { + expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + expect(screen.getByTestId("list-view")).toBeInTheDocument(); + }); + }); + + it("restores the previous modal detail when mobile popstate closes a nested task detail", async () => { + const task = makeTask("FN-1", "Parent Task"); + mockUseTasks.mockImplementation(() => ({ + tasks: [task, makeTask("FN-2", "Nested 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("dialog", { name: "Parent Task" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("task-detail-open-nested")); + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "Nested Task" })).toBeInTheDocument(); + }); + expect(window.history.pushState).toHaveBeenCalledTimes(2); + + dispatchPopState({ navIndex: 1 }); + + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "Parent Task" })).toBeInTheDocument(); + }); + expect(screen.queryByRole("dialog", { name: "Nested Task" })).toBeNull(); + }); + + it("pops multiple mobile detail entries back to the list target on a rapid Android-style pop", async () => { + const task = makeTask("FN-1", "Parent Task"); + mockUseTasks.mockImplementation(() => ({ + tasks: [task, makeTask("FN-2", "Nested 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("dialog", { name: "Parent Task" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("task-detail-open-nested")); + await waitFor(() => { + expect(screen.getByRole("dialog", { name: "Nested Task" })).toBeInTheDocument(); + }); + + dispatchPopState({ navIndex: 0 }); + + await waitFor(() => { + expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + expect(screen.getByTestId("list-view")).toBeInTheDocument(); + }); + }); + + it("does not push mobile detail history entries on desktop list selection", async () => { + mockUseViewportMode.mockReturnValue("desktop"); + const task = makeTask("FN-1", "Desktop 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")); + + expect(window.history.pushState).not.toHaveBeenCalled(); + expect(screen.queryByTestId("task-detail-modal")).toBeNull(); + }); +}); diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index bf97695ae2..2476fcbdc6 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -185,6 +185,7 @@ const qualityAppComponentTests = [ "TaskCard.footer-wrap", "TaskChangesTab", "TaskComments", + "TaskDetail.swipe-back", "TaskDetailModal", "TaskDetailModal.allow-resurrection", "TaskDetailModal.create-pr-e2e",