Merge pull request #44 from timothyjlaurent/timothyjlaurent/browser-back-button-spa-integration

feat: enable browser back-button navigation within the SPA dashboard
This commit is contained in:
gsxdsm
2026-05-05 15:29:47 -07:00
committed by GitHub
6 changed files with 116 additions and 110 deletions

View File

@@ -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<typeof modalManager.openDetailTask>[1], opts?: { origin?: DetailTaskOrigin }) => {
// History-aware modal open handlers — push nav entries for back-navigation.
const openDetailTask = useCallback((task: Task | TaskDetail, tab?: Parameters<typeof modalManager.openDetailTask>[1], opts?: { origin?: DetailTaskOrigin }) => {
modalManager.openDetailTask(task, tab, opts);
pushNav({ type: "modal", close: modalManager.closeDetailTask });
}, [modalManager, pushNav]);
const openSettingsWithHistory = useCallback((section?: Parameters<typeof modalManager.openSettings>[0]) => {
const openSettingsWithNav = useCallback((section?: Parameters<typeof modalManager.openSettings>[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) => (
<TaskCard
task={task}
projectId={currentProject?.id}
onOpenDetail={isMobile ? (value: 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() {
<DocumentsView
projectId={currentProject?.id}
addToast={addToast}
onOpenDetail={isMobile ? openDetailTaskWithHistory : modalManager.openDetailTask}
onOpenDetail={openDetailTask}
/>
</Suspense>
</PageErrorBoundary>
@@ -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() {
<>
<Header
isElectron={isElectron}
onOpenSettings={isMobile ? openSettingsWithHistory : handleOpenSettings}
onOpenGitHubImport={isMobile ? openGitHubImportWithHistory : modalManager.openGitHubImport}
onOpenPlanning={isMobile ? openPlanningWithHistory : modalManager.openPlanning}
onResumePlanning={isMobile ? resumePlanningWithHistory : modalManager.resumePlanning}
onOpenSettings={openSettingsWithNav}
onOpenGitHubImport={openGitHubImportWithNav}
onOpenPlanning={openPlanningWithNav}
onResumePlanning={resumePlanningWithNav}
activePlanningSessionCount={bgPlanningSessions.length}
onOpenUsage={isMobile ? openUsageWithHistory : modalManager.openUsage}
onOpenActivityLog={isMobile ? openActivityLogWithHistory : modalManager.openActivityLog}
onOpenSystemStats={isMobile ? openSystemStatsWithHistory : modalManager.openSystemStats}
onOpenUsage={openUsageWithNav}
onOpenActivityLog={openActivityLogWithNav}
onOpenSystemStats={openSystemStatsWithNav}
onOpenMailbox={() => 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}
/>
<AuthTokenRecoveryDialog open={authTokenRecoveryOpen} />
{shellApi && (

View File

@@ -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();

View File

@@ -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

View File

@@ -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,