FN-6964: Close task details before browser Back navigation

Dashboard Back navigation now dismisses task detail surfaces before leaving the current context.

- Add full-panel task-detail history entries that restore board/list state or the previous nested detail.
- Route modal task-detail Back handling through the same close path as explicit dismissal so deep-link cleanup runs.
- Cover board-opened, nested, and modal popstate flows and document the Back behavior.
- Add a patch changeset for the published Fusion dashboard behavior.

Files changed:
 .changeset/fn-6964-dashboard-back-navigation.md    |  7 ++
 docs/dashboard-guide.md                            |  3 +-
 packages/dashboard/app/App.tsx                     | 50 ++++++++++--
 packages/dashboard/app/components/AppModals.tsx    | 31 ++++++--
 .../app/components/__tests__/App.test.tsx          | 90 ++++++++++++++++++++++
 .../app/components/__tests__/AppModals.test.tsx    |  5 +-
 .../app/components/dashboard/MainContent.tsx       |  6 +-
 .../dashboard/app/components/dashboard/types.ts    |  1 -
 8 files changed, 173 insertions(+), 20 deletions(-)

Fusion-Task-Id: FN-6964

Fusion-Task-Lineage: 2308b603-a6ac-4978-9a6a-a6028b9bc38c
This commit is contained in:
gsxdsm
2026-06-25 09:20:34 -07:00
parent b891f8f601
commit 0049fb99d4
8 changed files with 173 additions and 20 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make browser and Android Back close dashboard task detail before leaving the current view.
category: fix
dev: Updates dashboard task-detail history entries for full-panel and modal detail flows.

View File

@@ -15,7 +15,8 @@ The installed mobile/PWA home-screen icons are generated from `packages/dashboar
## Browser Navigation
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.
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).
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.

View File

@@ -377,6 +377,7 @@ function AppInner() {
*/
const { task: mainPanelDetailTask, initialTab: mainPanelDetailInitialTab, setTask: setMainPanelDetailTask, setInitialTab: setMainPanelDetailInitialTab } = useMainPanelTaskDetail();
const { capture: captureCurrentBoardScrollSnapshot, requestRestore } = useBoardScrollRestore(taskView);
const mainPanelDetailNavRevertRef = useRef<(() => void) | null>(null);
/*
FNXC:FloatingWindow 2026-06-22-20:45:
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).
@@ -806,21 +807,59 @@ function AppInner() {
/*
FNXC:Navigation 2026-06-22-00:00:
Board card clicks open task detail as a full main-content view that replaces the board (design: "Full main panel (replaces board)"), instead of the TaskDetailModal overlay. We store a snapshot of the clicked task and navigate to the registered `task-detail` view; renderMainContent renders TaskDetailContent embedded with a Back-to-board button. Only the Board uses this handler — list-view split-detail, right-dock cards, and other openDetail callers keep the modal behavior.
FNXC:TaskDetailBack 2026-06-25-00:00:
Browser and Android Back must close the currently viewed full-panel task detail before leaving the prior dashboard view. The history entry owns an idempotent revert callback that clears stale snapshot state for board/list origins or restores the previous task snapshot for nested task-detail links, and explicit Back-to-board consumes that same entry without pushing a contradictory view entry during popstate.
*/
const openTaskDetailInMainPanel = useCallback((task: Task | TaskDetail, initialTab: DetailTaskTab = "chat") => {
captureCurrentBoardScrollSnapshot();
const previousView = taskView;
const previousDetailTask = mainPanelDetailTask;
const previousDetailTab = mainPanelDetailInitialTab;
if (previousView === "task-detail" && previousDetailTask?.id === task.id && previousDetailTab === initialTab) {
setMainPanelDetailTask(task);
return;
}
if (previousView !== "task-detail") {
captureCurrentBoardScrollSnapshot();
}
const revertMainPanelDetail = () => {
if (previousView === "task-detail" && previousDetailTask) {
setMainPanelDetailTask(previousDetailTask);
setMainPanelDetailInitialTab(previousDetailTab);
handleChangeTaskView("task-detail");
mainPanelDetailNavRevertRef.current = null;
return;
}
requestRestore();
setMainPanelDetailTask(null);
setMainPanelDetailInitialTab("chat");
handleChangeTaskView(previousView);
mainPanelDetailNavRevertRef.current = null;
};
setMainPanelDetailTask(task);
setMainPanelDetailInitialTab(initialTab);
handleTaskViewChange("task-detail");
}, [captureCurrentBoardScrollSnapshot, handleTaskViewChange]);
handleChangeTaskView("task-detail");
mainPanelDetailNavRevertRef.current = revertMainPanelDetail;
pushNav({ type: "view", revert: revertMainPanelDetail });
}, [captureCurrentBoardScrollSnapshot, handleChangeTaskView, mainPanelDetailInitialTab, mainPanelDetailTask, pushNav, requestRestore, taskView]);
// FNXC:Navigation 2026-06-22-00:00: Leaving task-detail clears the snapshot so a stale task never lingers if the view is reopened empty.
const closeTaskDetailMainPanel = useCallback(() => {
const revert = mainPanelDetailNavRevertRef.current;
if (revert) {
removeNav(revert);
mainPanelDetailNavRevertRef.current = null;
}
requestRestore();
setMainPanelDetailTask(null);
setMainPanelDetailInitialTab("chat");
handleTaskViewChange("board");
}, [handleTaskViewChange]);
handleChangeTaskView("board");
}, [handleChangeTaskView, removeNav, requestRestore]);
const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => {
if (initialTab === "changes") {
@@ -1205,7 +1244,6 @@ function AppInner() {
mainPanelDetailInitialTab,
closeTaskDetailMainPanel,
setMainPanelDetailTask,
setMainPanelDetailInitialTab,
mergeTask,
resetTask,
duplicateTask,

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState, lazy, Suspense } from "react";
import { useCallback, useEffect, useRef, useState, lazy, Suspense } from "react";
import type { ProjectInfo } from "../api";
import type { ColorTheme, Column, MergeResult, Task, TaskCreateInput, ThemeMode, GithubIssueAction } from "@fusion/core";
import type { UseProjectActionsResult } from "../hooks/useProjectActions";
@@ -117,6 +117,7 @@ export function AppModals({
}: AppModalsProps) {
const { pushNav, removeNav } = useNavigationHistoryContext();
const [firstCreatedTask, setFirstCreatedTask] = useState<Task | null>(null);
const detailNavCloseRef = useRef<(() => void) | null>(null);
const detailTask = modalManager.detailTask
? (() => {
const liveTask = tasks.find((task) => task.id === modalManager.detailTask?.id);
@@ -140,10 +141,20 @@ export function AppModals({
// Use the override handler if provided, otherwise fall back to modalManager.closeSettings
const handleSettingsClose = onSettingsClose ?? modalManager.closeSettings;
const closeDetailWithNav = useCallback(() => {
removeNav(modalManager.closeDetailTask);
/*
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.
*/
const closeDetailFromHistory = useCallback(() => {
modalManager.closeDetailTask();
deepLink.handleDetailClose();
}, [deepLink, modalManager.closeDetailTask, removeNav]);
detailNavCloseRef.current = null;
}, [deepLink, modalManager]);
const closeDetailWithNav = useCallback(() => {
removeNav(detailNavCloseRef.current ?? closeDetailFromHistory);
closeDetailFromHistory();
}, [closeDetailFromHistory, removeNav]);
const closeGroupWithNav = useCallback(() => {
removeNav(modalManager.closeGroupModal);
@@ -234,9 +245,17 @@ export function AppModals({
tab?: Parameters<typeof modalManager.openDetailTask>[1],
) => {
modalManager.openDetailTask(task, tab);
pushNav({ type: "modal", close: modalManager.closeDetailTask });
const closeFromHistory = () => {
modalManager.closeDetailTask();
deepLink.handleDetailClose();
if (detailNavCloseRef.current === closeFromHistory) {
detailNavCloseRef.current = null;
}
};
detailNavCloseRef.current = closeFromHistory;
pushNav({ type: "modal", close: closeFromHistory });
},
[modalManager, pushNav],
[deepLink, modalManager, pushNav],
);
const openGroupModalWithNav = useCallback((groupId: string) => {

View File

@@ -292,6 +292,13 @@ vi.mock("../../components/TaskDetailModal", () => ({
</div>
</div>
),
TaskDetailContent: ({ task, onBackToBoard, onOpenDetail }: { task: { id: string; title?: string }; onBackToBoard?: () => void; onOpenDetail?: (task: { id: string; title?: string }) => void }) => (
<section data-testid="main-panel-task-detail">
<button type="button" onClick={onBackToBoard}>Back to board</button>
<h2>{task.title ?? task.id}</h2>
<button type="button" onClick={() => onOpenDetail?.({ id: "FN-6965", title: "Nested task" })}>Open nested task</button>
</section>
),
}));
vi.mock("../../components/GitHubImportModal", () => ({
@@ -1705,6 +1712,89 @@ describe("App deep link handling", () => {
expect(window.history.replaceState).not.toHaveBeenCalled();
});
it("closes board-opened main-panel task detail on one browser back", async () => {
const boardTask = { id: "FN-6964", title: "Back nav task", description: "x", status: "todo", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
mockUseTasks.mockImplementation(() => ({
tasks: [boardTask],
isStale: false,
createTask: mockCreateTask,
moveTask: vi.fn(),
pauseTask: vi.fn(),
unpauseTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
resetTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
loadArchivedTasks: vi.fn(),
refreshTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
render(<App />);
await waitForAppShell();
fireEvent.click(screen.getByText("Back nav task"));
expect(await screen.findByTestId("main-panel-task-detail")).toBeTruthy();
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
});
await waitFor(() => {
expect(screen.queryByTestId("main-panel-task-detail")).toBeNull();
expect(screen.getByText("Back nav task")).toBeTruthy();
});
});
it("restores the previous main-panel task detail on nested detail browser back", async () => {
const boardTask = { id: "FN-6964", title: "Back nav task", description: "x", status: "todo", column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "", updatedAt: "" };
mockUseTasks.mockImplementation(() => ({
tasks: [boardTask],
isStale: false,
createTask: mockCreateTask,
moveTask: vi.fn(),
pauseTask: vi.fn(),
unpauseTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
retryTask: vi.fn(),
resetTask: vi.fn(),
updateTask: vi.fn(),
duplicateTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
archiveAllDone: vi.fn(),
loadArchivedTasks: vi.fn(),
refreshTasks: vi.fn(),
ingestCreatedTasks: vi.fn(),
lastFetchTimeMs: Date.now(),
}));
render(<App />);
await waitForAppShell();
fireEvent.click(screen.getByText("Back nav task"));
expect(await screen.findByText("Open nested task")).toBeTruthy();
fireEvent.click(screen.getByText("Open nested task"));
expect(await screen.findByText("Nested task")).toBeTruthy();
act(() => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 1 } }));
});
await waitFor(() => {
expect(screen.queryByText("Nested task")).toBeNull();
expect(screen.getByTestId("main-panel-task-detail")).toBeTruthy();
expect(screen.getByText("Back nav task")).toBeTruthy();
});
});
it("does not reopen deep-linked task after dismissal and re-render", async () => {
Object.defineProperty(window, "location", {
configurable: true,

View File

@@ -542,12 +542,14 @@ describe("AppModals", () => {
settings: mockSettings,
};
it("pushes history for activity-log open and closes on popstate", async () => {
it("pushes history for activity-log open and closes with deep-link cleanup on popstate", async () => {
const pushStateSpy = vi.spyOn(window.history, "pushState");
const closeDetailTask = vi.fn();
const handleDetailClose = vi.fn();
render(
<AppModals
{...commonProps}
deepLink={{ handleDetailClose }}
modalManager={{ ...mockModalManager, activityLogOpen: true, closeDetailTask }}
/>,
);
@@ -557,6 +559,7 @@ describe("AppModals", () => {
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }));
await waitFor(() => expect(closeDetailTask).toHaveBeenCalledTimes(1));
expect(handleDetailClose).toHaveBeenCalledTimes(1);
});
it("pushes history for onboarding view-task open and closes on popstate", async () => {

View File

@@ -143,7 +143,6 @@ export function MainContent({
mainPanelDetailInitialTab,
closeTaskDetailMainPanel,
setMainPanelDetailTask,
setMainPanelDetailInitialTab,
mergeTask,
resetTask,
duplicateTask,
@@ -722,10 +721,7 @@ export function MainContent({
onBackToBoard={closeTaskDetailMainPanel}
/* FNXC:FloatingWindow 2026-06-22-21:10: Popping out from the board's full-panel detail also returns the main panel to the board, so the board (not the emptied detail) sits behind the floating window. */
onPopOut={(task) => { popOutTaskDetail(task); closeTaskDetailMainPanel(); }}
onOpenDetail={(value) => {
setMainPanelDetailTask(value);
setMainPanelDetailInitialTab("chat");
}}
onOpenDetail={(value) => openTaskDetailInMainPanel(value, "chat")}
onMoveTask={moveTask}
onDeleteTask={deleteTask}
onMergeTask={mergeTask}

View File

@@ -198,7 +198,6 @@ export interface MainContentProps {
mainPanelDetailInitialTab: DetailTaskTab;
closeTaskDetailMainPanel: () => void;
setMainPanelDetailTask: Dispatch<SetStateAction<Task | TaskDetail | null>>;
setMainPanelDetailInitialTab: (tab: DetailTaskTab) => void;
mergeTask: (id: string) => Promise<MergeResult>;
resetTask: (id: string) => Promise<Task>;
duplicateTask: (id: string) => Promise<Task>;