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 ## Browser Navigation
The dashboard now handles browser back navigation consistently on desktop and mobile. 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. 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. 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 { task: mainPanelDetailTask, initialTab: mainPanelDetailInitialTab, setTask: setMainPanelDetailTask, setInitialTab: setMainPanelDetailInitialTab } = useMainPanelTaskDetail();
const { capture: captureCurrentBoardScrollSnapshot, requestRestore } = useBoardScrollRestore(taskView); const { capture: captureCurrentBoardScrollSnapshot, requestRestore } = useBoardScrollRestore(taskView);
const mainPanelDetailNavRevertRef = useRef<(() => void) | null>(null);
/* /*
FNXC:FloatingWindow 2026-06-22-20:45: 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). 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: 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. 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") => { 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); setMainPanelDetailTask(task);
setMainPanelDetailInitialTab(initialTab); setMainPanelDetailInitialTab(initialTab);
handleTaskViewChange("task-detail"); handleChangeTaskView("task-detail");
}, [captureCurrentBoardScrollSnapshot, handleTaskViewChange]); 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. // 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 closeTaskDetailMainPanel = useCallback(() => {
const revert = mainPanelDetailNavRevertRef.current;
if (revert) {
removeNav(revert);
mainPanelDetailNavRevertRef.current = null;
}
requestRestore(); requestRestore();
setMainPanelDetailTask(null); setMainPanelDetailTask(null);
setMainPanelDetailInitialTab("chat"); setMainPanelDetailInitialTab("chat");
handleTaskViewChange("board"); handleChangeTaskView("board");
}, [handleTaskViewChange]); }, [handleChangeTaskView, removeNav, requestRestore]);
const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => { const handleOpenDetailWithTab = useCallback((task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => {
if (initialTab === "changes") { if (initialTab === "changes") {
@@ -1205,7 +1244,6 @@ function AppInner() {
mainPanelDetailInitialTab, mainPanelDetailInitialTab,
closeTaskDetailMainPanel, closeTaskDetailMainPanel,
setMainPanelDetailTask, setMainPanelDetailTask,
setMainPanelDetailInitialTab,
mergeTask, mergeTask,
resetTask, resetTask,
duplicateTask, 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 { ProjectInfo } from "../api";
import type { ColorTheme, Column, MergeResult, Task, TaskCreateInput, ThemeMode, GithubIssueAction } from "@fusion/core"; import type { ColorTheme, Column, MergeResult, Task, TaskCreateInput, ThemeMode, GithubIssueAction } from "@fusion/core";
import type { UseProjectActionsResult } from "../hooks/useProjectActions"; import type { UseProjectActionsResult } from "../hooks/useProjectActions";
@@ -117,6 +117,7 @@ export function AppModals({
}: AppModalsProps) { }: AppModalsProps) {
const { pushNav, removeNav } = useNavigationHistoryContext(); const { pushNav, removeNav } = useNavigationHistoryContext();
const [firstCreatedTask, setFirstCreatedTask] = useState<Task | null>(null); const [firstCreatedTask, setFirstCreatedTask] = useState<Task | null>(null);
const detailNavCloseRef = useRef<(() => void) | null>(null);
const detailTask = modalManager.detailTask const detailTask = modalManager.detailTask
? (() => { ? (() => {
const liveTask = tasks.find((task) => task.id === modalManager.detailTask?.id); 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 // Use the override handler if provided, otherwise fall back to modalManager.closeSettings
const handleSettingsClose = onSettingsClose ?? 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.handleDetailClose();
}, [deepLink, modalManager.closeDetailTask, removeNav]); detailNavCloseRef.current = null;
}, [deepLink, modalManager]);
const closeDetailWithNav = useCallback(() => {
removeNav(detailNavCloseRef.current ?? closeDetailFromHistory);
closeDetailFromHistory();
}, [closeDetailFromHistory, removeNav]);
const closeGroupWithNav = useCallback(() => { const closeGroupWithNav = useCallback(() => {
removeNav(modalManager.closeGroupModal); removeNav(modalManager.closeGroupModal);
@@ -234,9 +245,17 @@ export function AppModals({
tab?: Parameters<typeof modalManager.openDetailTask>[1], tab?: Parameters<typeof modalManager.openDetailTask>[1],
) => { ) => {
modalManager.openDetailTask(task, tab); 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) => { const openGroupModalWithNav = useCallback((groupId: string) => {

View File

@@ -292,6 +292,13 @@ vi.mock("../../components/TaskDetailModal", () => ({
</div> </div>
</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", () => ({ vi.mock("../../components/GitHubImportModal", () => ({
@@ -1705,6 +1712,89 @@ describe("App deep link handling", () => {
expect(window.history.replaceState).not.toHaveBeenCalled(); 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 () => { it("does not reopen deep-linked task after dismissal and re-render", async () => {
Object.defineProperty(window, "location", { Object.defineProperty(window, "location", {
configurable: true, configurable: true,

View File

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

View File

@@ -143,7 +143,6 @@ export function MainContent({
mainPanelDetailInitialTab, mainPanelDetailInitialTab,
closeTaskDetailMainPanel, closeTaskDetailMainPanel,
setMainPanelDetailTask, setMainPanelDetailTask,
setMainPanelDetailInitialTab,
mergeTask, mergeTask,
resetTask, resetTask,
duplicateTask, duplicateTask,
@@ -722,10 +721,7 @@ export function MainContent({
onBackToBoard={closeTaskDetailMainPanel} 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. */ /* 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(); }} onPopOut={(task) => { popOutTaskDetail(task); closeTaskDetailMainPanel(); }}
onOpenDetail={(value) => { onOpenDetail={(value) => openTaskDetailInMainPanel(value, "chat")}
setMainPanelDetailTask(value);
setMainPanelDetailInitialTab("chat");
}}
onMoveTask={moveTask} onMoveTask={moveTask}
onDeleteTask={deleteTask} onDeleteTask={deleteTask}
onMergeTask={mergeTask} onMergeTask={mergeTask}

View File

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