diff --git a/.changeset/browser-back-button-spa-integration.md b/.changeset/browser-back-button-spa-integration.md new file mode 100644 index 000000000..933b91e0c --- /dev/null +++ b/.changeset/browser-back-button-spa-integration.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Enable browser back-button navigation within the SPA dashboard. Previously, the back button would leave the dashboard entirely. Now it dismisses the top modal or reverts to the previous view, matching standard SPA behavior on both desktop and mobile. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 5840e0d4b..25e3606eb 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -32,8 +32,8 @@ jobs: - name: Install Bun uses: oven-sh/setup-bun@v2 - - name: Build plugins - run: pnpm -r --filter './plugins/**' build + - name: Build plugins and core packages + run: pnpm build - name: Lint run: pnpm lint diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index c06f4d1b9..20b773620 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -227,8 +227,8 @@ function AppInner() { const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; - // Navigation history for mobile back button / iOS swipe-back. - const { pushNav, replaceCurrent } = useNavigationHistory({ enabled: isMobile }); + // Navigation history for browser back button (desktop + mobile). + const { pushNav, replaceCurrent } = useNavigationHistory({ enabled: true }); // View state must be defined before useTasks since useTasks depends on taskView for SSE gating const { viewMode, setViewMode, taskView, handleChangeTaskView } = useViewState({ @@ -245,7 +245,7 @@ function AppInner() { const { views: pluginDashboardViews } = usePluginDashboardViews(currentProject?.id); - // History-aware view change handler — pushes nav entry on mobile. + // History-aware view change handler — pushes nav entry on back-navigation stack. const handleTaskViewChange = useCallback((newView: TaskView) => { if (newView === "missions") { setMissionResumeSessionId(undefined); @@ -254,7 +254,6 @@ function AppInner() { } const previousView = taskView; handleChangeTaskView(newView); - // pushNav reads enabledRef internally; isMobile not needed in deps. if (previousView !== newView) { pushNav({ type: "view", revert: () => handleChangeTaskView(previousView) }); } @@ -498,7 +497,7 @@ function AppInner() { const { handleSelectProject, handleViewAllProjects, - handleOpenSettings, + handleOpenSettings: _handleOpenSettings, handleAddProject, handleSetupComplete, handleModelOnboardingComplete, @@ -614,13 +613,13 @@ function AppInner() { [workflowSteps], ); - const handleOpenNodes = useCallback(() => { + const _handleOpenNodes = useCallback(() => { if (!nodesEnabled) return; setNodesOpen((prev) => !prev); }, [nodesEnabled]); // History-aware nodes toggle — pushes nav entry only when opening - const handleOpenNodesWithHistory = useCallback(() => { + const handleOpenNodesWithNav = useCallback(() => { if (!nodesEnabled) return; if (!nodesOpen) { setNodesOpen(true); @@ -630,50 +629,48 @@ function AppInner() { } }, [nodesEnabled, nodesOpen, pushNav]); - // History-aware modal open handlers — push nav entries on mobile only. - // Desktop (isMobile=false): pushNav/replaceCurrent are no-ops. - const openDetailTaskWithHistory = useCallback((task: Task | TaskDetail, tab?: Parameters[1], opts?: { origin?: DetailTaskOrigin }) => { + // History-aware modal open handlers — push nav entries for back-navigation. + const openDetailTask = useCallback((task: Task | TaskDetail, tab?: Parameters[1], opts?: { origin?: DetailTaskOrigin }) => { modalManager.openDetailTask(task, tab, opts); pushNav({ type: "modal", close: modalManager.closeDetailTask }); }, [modalManager, pushNav]); - const openSettingsWithHistory = useCallback((section?: Parameters[0]) => { + const openSettingsWithNav = useCallback((section?: Parameters[0]) => { modalManager.openSettings(section); pushNav({ type: "modal", close: handleSettingsClose }); }, [modalManager, pushNav, handleSettingsClose]); - const openNewTaskWithHistory = useCallback(() => { + const openNewTaskWithNav = useCallback(() => { modalManager.openNewTask(); pushNav({ type: "modal", close: modalManager.closeNewTask }); }, [modalManager, pushNav]); - const openPlanningWithHistory = useCallback(() => { + const openPlanningWithNav = useCallback(() => { modalManager.openPlanning(); pushNav({ type: "modal", close: modalManager.closePlanning }); }, [modalManager, pushNav]); - const openPlanningWithInitialPlanWithHistory = useCallback((initialPlan: string) => { + const openPlanningWithInitialPlanWithNav = useCallback((initialPlan: string) => { modalManager.openPlanningWithInitialPlan(initialPlan); pushNav({ type: "modal", close: modalManager.closePlanning }); }, [modalManager, pushNav]); - const resumePlanningWithHistory = useCallback(() => { + const resumePlanningWithNav = useCallback(() => { modalManager.resumePlanning(); pushNav({ type: "modal", close: modalManager.closePlanning }); }, [modalManager, pushNav]); - const openSubtaskBreakdownWithHistory = useCallback((description: string) => { + const openSubtaskBreakdownWithNav = useCallback((description: string) => { modalManager.openSubtaskBreakdown(description); pushNav({ type: "modal", close: modalManager.closeSubtask }); }, [modalManager, pushNav]); - const openGitHubImportWithHistory = useCallback(() => { + const openGitHubImportWithNav = useCallback(() => { modalManager.openGitHubImport(); pushNav({ type: "modal", close: modalManager.closeGitHubImport }); }, [modalManager, pushNav]); - const toggleTerminalWithHistory = useCallback(() => { - // Only push if terminal is currently closed (opening) + const toggleTerminalWithNav = useCallback(() => { if (!modalManager.terminalOpen) { modalManager.toggleTerminal(); pushNav({ type: "modal", close: modalManager.closeTerminal }); @@ -682,59 +679,59 @@ function AppInner() { } }, [modalManager, pushNav]); - const openFilesWithHistory = useCallback(() => { + const openFilesWithNav = useCallback(() => { modalManager.openFiles(); pushNav({ type: "modal", close: modalManager.closeFiles }); }, [modalManager, pushNav]); - const openTodosWithHistory = useCallback(() => { + const openTodosWithNav = useCallback(() => { modalManager.openTodos(); pushNav({ type: "modal", close: modalManager.closeTodos }); }, [modalManager, pushNav]); - const openActivityLogWithHistory = useCallback(() => { + const openActivityLogWithNav = useCallback(() => { modalManager.openActivityLog(); pushNav({ type: "modal", close: modalManager.closeActivityLog }); }, [modalManager, pushNav]); - const openGitManagerWithHistory = useCallback(() => { + const openGitManagerWithNav = useCallback(() => { modalManager.openGitManager(); pushNav({ type: "modal", close: modalManager.closeGitManager }); }, [modalManager, pushNav]); - const openSystemStatsWithHistory = useCallback(() => { + const openSystemStatsWithNav = useCallback(() => { modalManager.openSystemStats(); pushNav({ type: "modal", close: modalManager.closeSystemStats }); }, [modalManager, pushNav]); - const openSchedulesWithHistory = useCallback(() => { + const openSchedulesWithNav = useCallback(() => { modalManager.openSchedules(); pushNav({ type: "modal", close: modalManager.closeSchedules }); }, [modalManager, pushNav]); - const openScriptsWithHistory = useCallback(() => { + const openScriptsWithNav = useCallback(() => { modalManager.openScripts(); pushNav({ type: "modal", close: modalManager.closeScripts }); }, [modalManager, pushNav]); - const openWorkflowStepsWithHistory = useCallback(() => { + const openWorkflowStepsWithNav = useCallback(() => { modalManager.openWorkflowSteps(); pushNav({ type: "modal", close: modalManager.closeWorkflowSteps }); }, [modalManager, pushNav]); - const openUsageWithHistory = useCallback((anchorRect?: DOMRect | null) => { + const openUsageWithNav = useCallback((anchorRect?: DOMRect | null) => { modalManager.openUsage(anchorRect); pushNav({ type: "modal", close: modalManager.closeUsage }); }, [modalManager, pushNav]); // Modal-to-modal transition: scripts -> terminal uses replaceCurrent - const runScriptWithHistory = useCallback(async (name: string, command: string) => { + const runScriptWithNav = useCallback(async (name: string, command: string) => { await modalManager.runScript(name, command); replaceCurrent({ type: "modal", close: modalManager.closeTerminal }); }, [modalManager, replaceCurrent]); // Modal-to-modal transition: settings -> onboarding uses replaceCurrent - const reopenOnboardingWithHistory = useCallback(() => { + const reopenOnboardingWithNav = useCallback(() => { modalManager.closeSettings(); modalManager.openModelOnboarding(); replaceCurrent({ type: "modal", close: modalManager.closeModelOnboarding }); @@ -878,14 +875,12 @@ function AppInner() { projectId: currentProject?.id, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, - openTaskDetail: isMobile - ? (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTaskWithHistory(task, initialTab) - : (task: Task | TaskDetail, initialTab?: DetailTaskTab) => modalManager.openDetailTask(task, initialTab), + openTaskDetail: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => openDetailTask(task, initialTab), renderTaskCard: (task: Task | TaskDetail) => ( openDetailTaskWithHistory(value) : (value: Task | TaskDetail) => modalManager.openDetailTask(value)} + onOpenDetail={(value: Task | TaskDetail) => openDetailTask(value)} addToast={addToast} workflowStepNameLookup={workflowStepNameLookup} /> @@ -964,7 +959,7 @@ function AppInner() { projectId={currentProject?.id} onSelectTask={(taskId) => { const task = tasks.find((t) => t.id === taskId); - if (task) (isMobile ? openDetailTaskWithHistory : modalManager.openDetailTask)(task as TaskDetail); + if (task) openDetailTask(task as TaskDetail); }} availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))} resumeSessionId={missionResumeSessionId} @@ -998,7 +993,7 @@ function AppInner() { @@ -1076,12 +1071,12 @@ function AppInner() { maxConcurrent={maxConcurrent} onMoveTask={moveTask} onPauseTask={pauseTask} - onOpenDetail={isMobile ? openDetailTaskWithHistory : modalManager.openDetailTask} + onOpenDetail={openDetailTask} addToast={addToast} onQuickCreate={handleBoardQuickCreate} - onNewTask={isMobile ? openNewTaskWithHistory : modalManager.openNewTask} - onPlanningMode={isMobile ? openPlanningWithInitialPlanWithHistory : modalManager.openPlanningWithInitialPlan} - onSubtaskBreakdown={isMobile ? openSubtaskBreakdownWithHistory : modalManager.openSubtaskBreakdown} + onNewTask={openNewTaskWithNav} + onPlanningMode={openPlanningWithInitialPlanWithNav} + onSubtaskBreakdown={openSubtaskBreakdownWithNav} autoMerge={autoMerge} onToggleAutoMerge={toggleAutoMerge} globalPaused={globalPaused} @@ -1119,13 +1114,13 @@ function AppInner() { onMergeTask={mergeTask} onResetTask={resetTask} onDuplicateTask={duplicateTask} - onOpenDetail={isMobile ? (task, options) => openDetailTaskWithHistory(task, undefined, options) : (task, options) => modalManager.openDetailTask(task, undefined, options)} + onOpenDetail={(task, options) => openDetailTask(task, undefined, options)} addToast={addToast} globalPaused={globalPaused} - onNewTask={isMobile ? openNewTaskWithHistory : modalManager.openNewTask} + onNewTask={openNewTaskWithNav} onQuickCreate={handleBoardQuickCreate} - onPlanningMode={isMobile ? openPlanningWithInitialPlanWithHistory : modalManager.openPlanningWithInitialPlan} - onSubtaskBreakdown={isMobile ? openSubtaskBreakdownWithHistory : modalManager.openSubtaskBreakdown} + onPlanningMode={openPlanningWithInitialPlanWithNav} + onSubtaskBreakdown={openSubtaskBreakdownWithNav} availableModels={availableModels} favoriteProviders={favoriteProviders} favoriteModels={favoriteModels} @@ -1160,27 +1155,27 @@ function AppInner() { <>
handleTaskViewChange("mailbox")} mailboxUnreadCount={mailboxUnreadCount} - onOpenSchedules={isMobile ? openSchedulesWithHistory : modalManager.openSchedules} - onOpenGitManager={isMobile ? openGitManagerWithHistory : modalManager.openGitManager} - onOpenNodes={isMobile ? handleOpenNodesWithHistory : handleOpenNodes} + onOpenSchedules={openSchedulesWithNav} + onOpenGitManager={openGitManagerWithNav} + onOpenNodes={handleOpenNodesWithNav} showNodesButton={nodesEnabled} - onOpenWorkflowSteps={isMobile ? openWorkflowStepsWithHistory : modalManager.openWorkflowSteps} - onOpenScripts={isMobile ? openScriptsWithHistory : modalManager.openScripts} - onRunScript={isMobile ? runScriptWithHistory : modalManager.runScript} - onToggleTerminal={isMobile ? toggleTerminalWithHistory : modalManager.toggleTerminal} - onOpenFiles={isMobile ? openFilesWithHistory : modalManager.openFiles} + onOpenWorkflowSteps={openWorkflowStepsWithNav} + onOpenScripts={openScriptsWithNav} + onRunScript={runScriptWithNav} + onToggleTerminal={toggleTerminalWithNav} + onOpenFiles={openFilesWithNav} filesOpen={modalManager.filesOpen} - onOpenTodos={isMobile ? openTodosWithHistory : modalManager.openTodos} + onOpenTodos={openTodosWithNav} todosOpen={modalManager.todosOpen} todosEnabled={todosEnabled} globalPaused={globalPaused} @@ -1285,27 +1280,27 @@ function AppInner() { footerVisible={viewMode === "project" && !!currentProject} modalOpen={modalManager.anyModalOpen} keyboardOpen={mobileKeyboardOpen} - onOpenSettings={isMobile ? openSettingsWithHistory : handleOpenSettings} - onOpenActivityLog={isMobile ? openActivityLogWithHistory : modalManager.openActivityLog} - onOpenSystemStats={isMobile ? openSystemStatsWithHistory : modalManager.openSystemStats} + onOpenSettings={openSettingsWithNav} + onOpenActivityLog={openActivityLogWithNav} + onOpenSystemStats={openSystemStatsWithNav} onOpenMailbox={() => handleTaskViewChange("mailbox")} - onOpenNodes={isMobile ? handleOpenNodesWithHistory : handleOpenNodes} + onOpenNodes={handleOpenNodesWithNav} mailboxUnreadCount={mailboxUnreadCount} - onOpenGitManager={isMobile ? openGitManagerWithHistory : modalManager.openGitManager} - onOpenWorkflowSteps={isMobile ? openWorkflowStepsWithHistory : modalManager.openWorkflowSteps} - onOpenSchedules={isMobile ? openSchedulesWithHistory : modalManager.openSchedules} - onOpenScripts={isMobile ? openScriptsWithHistory : modalManager.openScripts} - onToggleTerminal={isMobile ? toggleTerminalWithHistory : modalManager.toggleTerminal} - onOpenFiles={isMobile ? openFilesWithHistory : modalManager.openFiles} - onOpenTodos={isMobile ? openTodosWithHistory : modalManager.openTodos} + onOpenGitManager={openGitManagerWithNav} + onOpenWorkflowSteps={openWorkflowStepsWithNav} + onOpenSchedules={openSchedulesWithNav} + onOpenScripts={openScriptsWithNav} + onToggleTerminal={toggleTerminalWithNav} + onOpenFiles={openFilesWithNav} + onOpenTodos={openTodosWithNav} todosOpen={modalManager.todosOpen} - onOpenGitHubImport={isMobile ? openGitHubImportWithHistory : modalManager.openGitHubImport} - onOpenPlanning={isMobile ? openPlanningWithHistory : modalManager.openPlanning} - onResumePlanning={isMobile ? resumePlanningWithHistory : modalManager.resumePlanning} + onOpenGitHubImport={openGitHubImportWithNav} + onOpenPlanning={openPlanningWithNav} + onResumePlanning={resumePlanningWithNav} activePlanningSessionCount={bgPlanningSessions.length} - onOpenUsage={isMobile ? () => openUsageWithHistory(null) : () => modalManager.openUsage(null)} + onOpenUsage={() => openUsageWithNav(null)} onViewAllProjects={handleViewAllProjects} - onRunScript={isMobile ? runScriptWithHistory : modalManager.runScript} + onRunScript={runScriptWithNav} projectId={currentProject?.id} showSkillsTab={skillsEnabled} experimentalFeatures={{ @@ -1357,10 +1352,7 @@ function AppInner() { deepLink={{ handleDetailClose }} settings={{ prAuthAvailable, themeMode, colorTheme, dashboardFontScalePct, setThemeMode, setColorTheme, setDashboardFontScalePct }} onSettingsClose={handleSettingsClose} - onReopenOnboarding={isMobile ? reopenOnboardingWithHistory : () => { - modalManager.closeSettings(); - modalManager.openModelOnboarding(); - }} + onReopenOnboarding={reopenOnboardingWithNav} /> {shellApi && ( diff --git a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx index 9bf52dc96..b9ee1b592 100644 --- a/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx +++ b/packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx @@ -1167,8 +1167,11 @@ describe("PlanningModeModal", () => { expect(screen.getByText("What is the scope?")).toBeDefined(); }); - fireEvent.click(screen.getByText("Medium")); - fireEvent.click(screen.getByRole("button", { name: "Continue" })); + const mediumOption = await screen.findByText("Medium"); + fireEvent.click(mediumOption); + + const continueBtn = await screen.findByRole("button", { name: "Continue" }); + fireEvent.click(continueBtn); await waitFor(() => { expect(screen.getByText("What are the key requirements?")).toBeDefined(); diff --git a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx index 825c9f1df..ba2ec4345 100644 --- a/packages/dashboard/app/components/__tests__/navigation-history.test.tsx +++ b/packages/dashboard/app/components/__tests__/navigation-history.test.tsx @@ -1,11 +1,11 @@ /** - * Integration tests for mobile back-navigation (Android back button / iOS swipe-back). + * Integration tests for browser back-navigation within the SPA. * * Verifies that the useNavigationHistory hook integration in App.tsx correctly: - * - Does NOT push history entries when opening modals on desktop - * - Does NOT dismiss modals on popstate on desktop - * - Pushes history entries when opening modals on mobile - * - Dismisses modals on popstate on mobile + * - Pushes history entries when opening modals (both desktop and mobile) + * - Dismisses modals on popstate (both desktop and mobile) + * - Pushes history entries when changing views + * - Reverts view changes on popstate */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; @@ -375,10 +375,11 @@ describe("Navigation history integration", () => { return result; } - // 1. Desktop: opening Settings does NOT push history entry - it("does not push history entry when opening Settings modal on desktop", async () => { + // 1. Desktop: opening Settings pushes a history entry + it("pushes history entry when opening Settings modal on desktop", async () => { await renderAppAndWait(); + const pushCallsBefore = (window.history.pushState as any).mock.calls.length; const settingsBtn = screen.getByTitle("Settings"); fireEvent.click(settingsBtn); @@ -386,12 +387,12 @@ describe("Navigation history integration", () => { expect(screen.getByTestId("settings-modal")).toBeTruthy(); }); - // On desktop, pushState should NOT be called for modal opens - expect(window.history.pushState).not.toHaveBeenCalled(); + // Back-button nav is enabled on desktop too — pushState called for the modal open + expect((window.history.pushState as any).mock.calls.length).toBeGreaterThan(pushCallsBefore); }); - // 2. Desktop: popstate does NOT dismiss modals - it("does not dismiss Settings modal on popstate in desktop mode", async () => { + // 2. Desktop: popstate dismisses modals + it("dismisses Settings modal on popstate in desktop mode", async () => { await renderAppAndWait(); const settingsBtn = screen.getByTitle("Settings"); @@ -404,18 +405,21 @@ describe("Navigation history integration", () => { // Simulate back button dispatchPopState({ navIndex: 0 }); - // Settings modal should still be open - expect(screen.getByTestId("settings-modal")).toBeTruthy(); + // Settings modal should be dismissed + await waitFor(() => { + expect(screen.queryByTestId("settings-modal")).toBeNull(); + }); }); - // 3. Desktop: view changes do NOT push history entries - it("does not push history entry for view changes on desktop", async () => { + // 3. Desktop: view changes push history entries + it("pushes history entry for view changes on desktop", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); const taskViewStorageKey = scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID); localStorage.setItem(taskViewStorageKey, "board"); await renderAppAndWait(); + const pushCallsBefore = (window.history.pushState as any).mock.calls.length; // Switch to agents view const agentsTab = screen.queryByTitle("Agents"); if (!agentsTab) return; @@ -425,12 +429,12 @@ describe("Navigation history integration", () => { expect(screen.getByTestId("agents-view")).toBeTruthy(); }); - // pushState should NOT have been called for view changes on desktop - expect(window.history.pushState).not.toHaveBeenCalled(); + // pushState should have been called for the view change + expect((window.history.pushState as any).mock.calls.length).toBeGreaterThan(pushCallsBefore); }); - // 4. Desktop: popstate does NOT revert view changes - it("does not revert view change on popstate in desktop mode", async () => { + // 4. Desktop: popstate reverts view changes + it("reverts view change on popstate in desktop mode", async () => { localStorage.setItem("kb-dashboard-view-mode", "project"); const taskViewStorageKey = scopedKey("kb-dashboard-task-view", DEFAULT_PROJECT_ID); localStorage.setItem(taskViewStorageKey, "board"); @@ -448,8 +452,10 @@ describe("Navigation history integration", () => { // Simulate back button dispatchPopState({ navIndex: 0 }); - // Agents view should still be showing (desktop ignores popstate for navigation) - expect(screen.getByTestId("agents-view")).toBeTruthy(); + // Agents view should be reverted (board view shown instead) + await waitFor(() => { + expect(screen.queryByTestId("agents-view")).toBeNull(); + }); }); // 5. Verify useNavigationHistory is called with enabled=true on mobile diff --git a/packages/dashboard/app/hooks/useNavigationHistory.ts b/packages/dashboard/app/hooks/useNavigationHistory.ts index dab6302f3..35375d3d8 100644 --- a/packages/dashboard/app/hooks/useNavigationHistory.ts +++ b/packages/dashboard/app/hooks/useNavigationHistory.ts @@ -31,12 +31,12 @@ export interface UseNavigationHistoryResult { * Centralized back-navigation hook that integrates the browser History API * (`pushState`/`popstate`) with modal and view state machines. * - * On mobile, every modal open and view change pushes a history entry so that - * the Android hardware back button and iOS swipe-back gesture dismiss the - * top modal or revert to the previous view. + * Every modal open and view change pushes a history entry so that the + * browser back button dismisses the top modal or reverts to the previous + * view. Works on both desktop and mobile. * - * On desktop (`enabled: false`), all operations are no-ops and no `popstate` - * listener is registered, leaving desktop behavior completely unchanged. + * When `enabled` is false, all operations are no-ops and no `popstate` + * listener is registered. */ export function useNavigationHistory( options: UseNavigationHistoryOptions,