FN-5705: wire task-detail opens into navigation history

Ensure task detail modal openings consistently register browser history so Android back swipe/button dismisses the modal from all entry paths.

- add a shared openDetailTaskWithNav callback in AppModals that opens task detail and pushes a modal close handler into navigation history
- route onboarding view-task, activity log open-task, and task-to-task detail navigation through the new history-aware opener
- extend AppModals tests with a navigation history wrapper and popstate assertions for onboarding/activity log/task-to-task flows
- document that these task detail entry points now participate in navigation history for consistent Android back behavior

Files changed:
 docs/dashboard-guide.md                            |   1 +
 packages/dashboard/app/components/AppModals.tsx    |  21 ++++-
 .../app/components/__tests__/AppModals.test.tsx    | 101 ++++++++++++++++++++-
 3 files changed, 115 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-5705

Fusion-Task-Lineage: fd0309ae-e0da-4489-95e4-c34699287e7c
This commit is contained in:
gsxdsm
2026-05-29 20:26:34 -07:00
parent afc3b4749f
commit beab117771
3 changed files with 115 additions and 8 deletions

View File

@@ -9,6 +9,7 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, sett
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 (for example, task detail → board) before leaving the app.
This behavior used to be mobile-only, and now applies across all viewports.
Task Detail modal opens from onboarding, activity log, and task-to-task navigation now all register navigation history entries, so Android back swipe/button dismisses them consistently.
## Deep Links

View File

@@ -24,6 +24,7 @@ import { WorkflowStepManager } from "./WorkflowStepManager";
import { AgentListModal } from "./AgentListModal";
import { ModelOnboardingModal } from "./ModelOnboardingModal";
import { ToastContainer } from "./ToastContainer";
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
const SetupWizardModal = lazy(() => import("./SetupWizardModal").then((m) => ({ default: m.SetupWizardModal })));
const SettingsModal = lazy(() => import("./SettingsModal").then((m) => ({ default: m.SettingsModal })));
@@ -104,6 +105,7 @@ export function AppModals({
onReopenOnboarding,
onOpenApprovals,
}: AppModalsProps) {
const { pushNav } = useNavigationHistoryContext();
const [firstCreatedTask, setFirstCreatedTask] = useState<Task | null>(null);
const detailTask = modalManager.detailTask
? (() => {
@@ -136,11 +138,22 @@ export function AppModals({
modalManager.openGitHubImport();
}, [modalManager]);
const openDetailTaskWithNav = useCallback(
(
task: Parameters<typeof modalManager.openDetailTask>[0],
tab?: Parameters<typeof modalManager.openDetailTask>[1],
) => {
modalManager.openDetailTask(task, tab);
pushNav({ type: "modal", close: modalManager.closeDetailTask });
},
[modalManager, pushNav],
);
const handleOnboardingViewTask = useCallback((task: Task) => {
setFirstCreatedTask(null);
modalManager.closeModelOnboarding();
modalManager.openDetailTask(task);
}, [modalManager]);
openDetailTaskWithNav(task);
}, [modalManager, openDetailTaskWithNav]);
const handleModalCreateWithOnboardingTracking = useCallback(
async (input: TaskCreateInput): Promise<Task> => {
@@ -172,7 +185,7 @@ export function AppModals({
projectId={projectId}
tasks={tasks}
onClose={deepLink.handleDetailClose}
onOpenDetail={modalManager.openDetailTask}
onOpenDetail={openDetailTaskWithNav}
mobileHeaderMode={modalManager.detailTaskOrigin === "list-mobile" ? "back" : "close"}
onMoveTask={taskOperations.moveTask}
onDeleteTask={taskOperations.deleteTask}
@@ -320,7 +333,7 @@ export function AppModals({
onOpenTaskDetail={(taskId) => {
const task = tasks.find((candidate) => candidate.id === taskId);
if (task) {
modalManager.openDetailTask(task);
openDetailTaskWithNav(task);
}
}}
/>

View File

@@ -1,6 +1,8 @@
import type { ReactElement, ReactNode } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render as rtlRender, screen, waitFor, type RenderOptions } from "@testing-library/react";
import { AppModals } from "../AppModals";
import { NavigationHistoryProvider, useNavigationHistory } from "../../hooks/useNavigationHistory";
import type { ModalManager } from "../../hooks/useModalManager";
import type { Toast } from "../../hooks/useToast";
@@ -9,7 +11,11 @@ const mockTaskDetailModalProps = vi.fn();
vi.mock("../TaskDetailModal", () => ({
TaskDetailModal: (props: any) => {
mockTaskDetailModalProps(props);
return null;
return (
<button data-testid="task-detail-open-detail" onClick={() => props.onOpenDetail?.({ id: "FN-2", title: "Nested" })}>
open detail
</button>
);
},
}));
@@ -78,8 +84,16 @@ vi.mock("../SystemStatsModal", () => ({
},
}));
const mockActivityLogModalProps = vi.fn();
vi.mock("../ActivityLogModal", () => ({
ActivityLogModal: () => null,
ActivityLogModal: (props: any) => {
mockActivityLogModalProps(props);
return (
<button data-testid="activity-log-open-task" onClick={() => props.onOpenTaskDetail?.("FN-1")}>
open task detail
</button>
);
},
}));
vi.mock("../GitManagerModal", () => ({
@@ -102,7 +116,11 @@ const mockModelOnboardingModalProps = vi.fn();
vi.mock("../ModelOnboardingModal", () => ({
ModelOnboardingModal: (props: any) => {
mockModelOnboardingModalProps(props);
return null;
return (
<button data-testid="onboarding-view-task" onClick={() => props.onViewTask?.({ id: "FN-1", title: "Created task" })}>
view task
</button>
);
},
}));
@@ -135,6 +153,15 @@ vi.mock("../ErrorBoundary", () => ({
ModalErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
function NavigationWrapper({ children }: { children: ReactNode }) {
const history = useNavigationHistory({ enabled: true });
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
}
function render(ui: ReactElement, options?: Omit<RenderOptions, "wrapper">) {
return rtlRender(ui, { wrapper: NavigationWrapper, ...options });
}
describe("AppModals", () => {
const mockModalManager: ModalManager = {
// State
@@ -234,6 +261,7 @@ describe("AppModals", () => {
mockTaskDetailModalProps.mockClear();
mockScheduledTasksModalProps.mockClear();
mockModelOnboardingModalProps.mockClear();
mockActivityLogModalProps.mockClear();
mockSettingsModalProps.mockClear();
mockSystemStatsModalProps.mockClear();
mockTodoModalProps.mockClear();
@@ -543,4 +571,69 @@ describe("AppModals", () => {
);
});
});
describe("task detail history wiring", () => {
const commonProps = {
projectId: "proj-1",
tasks: [{ id: "FN-1", title: "Task one" }],
projects: [],
currentProject: null,
addToast: vi.fn(),
toasts: mockToasts,
removeToast: vi.fn(),
projectActions: { handleAddProject: vi.fn(), handleSetupComplete: vi.fn(), handleModelOnboardingComplete: vi.fn() },
taskHandlers: { handleModalCreate: vi.fn(), handlePlanningTaskCreated: vi.fn(), handlePlanningTasksCreated: vi.fn(), handleSubtaskTasksCreated: vi.fn(), handleGitHubImport: vi.fn() },
taskOperations: { moveTask: vi.fn(), deleteTask: vi.fn(), mergeTask: vi.fn(), retryTask: vi.fn(), duplicateTask: vi.fn() },
deepLink: { handleDetailClose: vi.fn() },
settings: mockSettings,
};
it("pushes history for activity-log open and closes on popstate", async () => {
const pushStateSpy = vi.spyOn(window.history, "pushState");
const closeDetailTask = vi.fn();
render(
<AppModals
{...commonProps}
modalManager={{ ...mockModalManager, activityLogOpen: true, closeDetailTask }}
/>,
);
fireEvent.click(screen.getByTestId("activity-log-open-task"));
expect(pushStateSpy).toHaveBeenCalled();
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
await waitFor(() => expect(closeDetailTask).toHaveBeenCalledTimes(1));
});
it("pushes history for onboarding view-task open and closes on popstate", async () => {
const pushStateSpy = vi.spyOn(window.history, "pushState");
const closeDetailTask = vi.fn();
render(
<AppModals
{...commonProps}
modalManager={{ ...mockModalManager, modelOnboardingOpen: true, closeDetailTask }}
/>,
);
fireEvent.click(screen.getByTestId("onboarding-view-task"));
expect(pushStateSpy).toHaveBeenCalled();
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
await waitFor(() => expect(closeDetailTask).toHaveBeenCalledTimes(1));
});
it("pushes an additional history entry for task-to-task detail navigation", () => {
const pushStateSpy = vi.spyOn(window.history, "pushState");
render(
<AppModals
{...commonProps}
modalManager={{ ...mockModalManager, detailTask: { id: "FN-1", title: "Task one" } }}
/>,
);
fireEvent.click(screen.getByTestId("task-detail-open-detail"));
expect(pushStateSpy).toHaveBeenCalledTimes(1);
expect(mockModalManager.openDetailTask).toHaveBeenCalledWith({ id: "FN-2", title: "Nested" }, undefined);
});
});
});