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:
7
.changeset/fn-6964-dashboard-back-navigation.md
Normal file
7
.changeset/fn-6964-dashboard-back-navigation.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user