diff --git a/.changeset/fn-6964-dashboard-back-navigation.md b/.changeset/fn-6964-dashboard-back-navigation.md new file mode 100644 index 0000000000..e426ac6b01 --- /dev/null +++ b/.changeset/fn-6964-dashboard-back-navigation.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 176ed7fe65..1b0d4f73a6 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 719907ad55..18f87389ce 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -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, diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 956da05470..c33fba9d1a 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -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(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[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) => { diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx index 390d12bb95..c7083a248a 100644 --- a/packages/dashboard/app/components/__tests__/App.test.tsx +++ b/packages/dashboard/app/components/__tests__/App.test.tsx @@ -292,6 +292,13 @@ vi.mock("../../components/TaskDetailModal", () => ({ ), + TaskDetailContent: ({ task, onBackToBoard, onOpenDetail }: { task: { id: string; title?: string }; onBackToBoard?: () => void; onOpenDetail?: (task: { id: string; title?: string }) => void }) => ( +
+ +

{task.title ?? task.id}

+ +
+ ), })); 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(); + 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(); + 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, diff --git a/packages/dashboard/app/components/__tests__/AppModals.test.tsx b/packages/dashboard/app/components/__tests__/AppModals.test.tsx index d245d65e12..ddca365b2a 100644 --- a/packages/dashboard/app/components/__tests__/AppModals.test.tsx +++ b/packages/dashboard/app/components/__tests__/AppModals.test.tsx @@ -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( , ); @@ -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 () => { diff --git a/packages/dashboard/app/components/dashboard/MainContent.tsx b/packages/dashboard/app/components/dashboard/MainContent.tsx index e154783086..8ea6715dc1 100644 --- a/packages/dashboard/app/components/dashboard/MainContent.tsx +++ b/packages/dashboard/app/components/dashboard/MainContent.tsx @@ -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} diff --git a/packages/dashboard/app/components/dashboard/types.ts b/packages/dashboard/app/components/dashboard/types.ts index ab32cecc37..67a4bef9e5 100644 --- a/packages/dashboard/app/components/dashboard/types.ts +++ b/packages/dashboard/app/components/dashboard/types.ts @@ -198,7 +198,6 @@ export interface MainContentProps { mainPanelDetailInitialTab: DetailTaskTab; closeTaskDetailMainPanel: () => void; setMainPanelDetailTask: Dispatch>; - setMainPanelDetailInitialTab: (tab: DetailTaskTab) => void; mergeTask: (id: string) => Promise; resetTask: (id: string) => Promise; duplicateTask: (id: string) => Promise;