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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 14:34:44 -07:00
parent e80d85b101
commit d22b8cc60a
5 changed files with 605 additions and 6 deletions

View File

@@ -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.

View File

@@ -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<typeof modalManager.openDetailTask>[1], opts?: { origin?: DetailTaskOrigin }) => {
modalManager.openDetailTask(task, tab, opts);
pushNav({ type: "modal", close: modalManager.closeDetailTask });

View File

@@ -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<typeof modalManager.openDetailTask>[0],
tab?: Parameters<typeof modalManager.openDetailTask>[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 });

View File

@@ -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<typeof import("../../api")>(), {
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 }) => (
<div data-testid="board-view">
{tasks.map((task) => (
<button key={task.id} type="button" data-testid={`open-task-${task.id}`} onClick={() => onOpenDetail(task)}>
{task.title}
</button>
))}
</div>
),
}));
vi.mock("../../components/ListView", () => ({
ListView: ({ tasks, onOpenDetail }: { tasks: Task[]; onOpenDetail: (task: Task, options?: { origin?: "list-mobile" }) => void }) => (
<div data-testid="list-view">
{tasks.map((task) => (
<button
key={task.id}
type="button"
data-testid={`list-open-${task.id}`}
onClick={() => {
if (mockUseViewportMode() === "mobile") {
onOpenDetail(task, { origin: "list-mobile" });
}
}}
>
{task.title}
</button>
))}
</div>
),
}));
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";
}) => (
<div className="modal-overlay open" data-testid="task-detail-modal">
<div role="dialog" aria-label={task.title ?? task.id}>
<div data-testid="task-detail-mobile-header-mode">{mobileHeaderMode ?? "close"}</div>
<button type="button" data-testid="task-detail-close" onClick={onClose}>Close</button>
{task.id === "FN-1" ? (
<button type="button" data-testid="task-detail-open-nested" onClick={() => onOpenDetail({ id: "FN-2", title: "Nested Task" })}>
Open nested
</button>
) : null}
<h2>{task.title ?? task.id}</h2>
</div>
</div>
),
TaskDetailContent: ({
task,
onBackToBoard,
}: {
task: { id: string; title?: string };
onBackToBoard?: () => void;
}) => (
<div data-testid="task-detail-main-panel-content">
{onBackToBoard ? <button type="button" data-testid="task-detail-back-to-board" onClick={onBackToBoard}>Back to board</button> : null}
<h2>{task.title ?? task.id}</h2>
</div>
),
}));
vi.mock("../../components/SettingsModal", () => ({
SettingsModal: ({ onClose }: { onClose: () => void }) => (
<div className="modal-overlay open" data-testid="settings-modal">
<button type="button" data-testid="settings-close-btn" onClick={onClose}>Close</button>
</div>
),
SettingsView: () => <div data-testid="settings-view">Settings</div>,
}));
vi.mock("../../components/GitHubImportModal", () => ({ GitHubImportModal: () => null }));
vi.mock("../../components/PlanningModeModal", () => ({ PlanningModeModal: () => null }));
vi.mock("../../components/AgentsView", () => ({ AgentsView: () => <div data-testid="agents-view">Agents</div> }));
vi.mock("../../components/ResearchView", () => ({ ResearchView: () => <div data-testid="research-view">Research</div> }));
vi.mock("../../components/EvalsView", () => ({ EvalsView: () => <div data-testid="evals-view">Evals</div> }));
vi.mock("../../components/TodoView", () => ({ TodoView: () => <div data-testid="todo-view">Todo</div> }));
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: () => <div /> }));
vi.mock("../../components/ProjectCard", () => ({ ProjectCard: () => <div /> }));
vi.mock("../../components/Sidebar", () => ({ Sidebar: () => <div /> }));
vi.mock("../../components/Header", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../components/Header")>();
return {
...actual,
Header: () => <div><button title="Settings" type="button">Settings</button></div>,
};
});
vi.mock("../../components/MobileNavBar", () => ({ MobileNavBar: () => null }));
vi.mock("../../components/RightDock", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../components/RightDock")>();
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<string, unknown> | null) {
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state }));
});
}
async function renderAppAndWait(expectedTestId: string = "board-view") {
const result = render(<App />);
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();
});
});

View File

@@ -185,6 +185,7 @@ const qualityAppComponentTests = [
"TaskCard.footer-wrap",
"TaskChangesTab",
"TaskComments",
"TaskDetail.swipe-back",
"TaskDetailModal",
"TaskDetailModal.allow-resurrection",
"TaskDetailModal.create-pr-e2e",