diff --git a/.changeset/fn-8069-shortcut-toggle.md b/.changeset/fn-8069-shortcut-toggle.md new file mode 100644 index 0000000000..d05d3dff92 --- /dev/null +++ b/.changeset/fn-8069-shortcut-toggle.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Dashboard keyboard shortcuts now toggle — re-press a shortcut to close its interface. +category: feature +dev: Shortcut handlers in useDashboardKeyboardShortcuts + App.tsx now dispatch toggle callbacks (open on first press, close/revert on re-press). Modal-backed shortcuts use existing nav-aware closers; view-backed shortcuts (Settings/Command Center) retain the exact revert callback pushed by handleTaskViewChange, removeNav it, and restore the captured prior view (not board), so both shortcut-opened and UI-opened views close without leaking a browser-back entry. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 478300b8fb..c13f95e8ab 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -58,6 +58,9 @@ Leave a shortcut field blank to disable that action. Settings validates each sho Shortcut handling is intentionally guarded. Fusion ignores global shortcuts while focus is inside inputs, textareas, selects, contenteditable editors, chat composers, task fields, Settings fields, search boxes, and terminal input, so typing Space or shortcut letters never opens another surface unexpectedly. Hardware keyboards on desktop, tablet, and mobile use the same bindings when focus is on the page/body. Open Files, Open Settings, Open Command Center, and New Task each reuse the dashboard's existing navigation entry points (the same handlers as their header/sidebar buttons), so no shortcut opens a second/duplicate destination. + +Every configured action toggles its interface: press once to open Quick Chat, Terminal, Files, Settings, Command Center, or New Task, then press the same binding again to close it. For Settings and Command Center, the second press returns to the view that was active before the surface opened. + Press `Escape` to close the current/topmost dashboard popup. Popped-out task windows and floating Quick Chat close before fixed app modals such as Terminal, Settings, Files, or Task Detail, and only one surface closes per key press. Nested editors and menus that already handle Escape keep first ownership by preventing the global handler. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index f88e129bbc..1ef38e29c8 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -48,6 +48,7 @@ import { useAuthOnboarding } from "./hooks/useAuthOnboarding"; import { useMobileKeyboard } from "./hooks/useMobileKeyboard"; import { isIOS, useMobileKeyboardViewportLock, useMobileViewportRestoreReset } from "./hooks/useMobileScrollLock"; import { computeMobileBarKeyboardFlags } from "./utils/mobileBarKeyboardFlags"; +import { closeViewShortcut, retainViewNavRevert } from "./utils/dashboardShortcutToggles"; import { useSetupReadiness } from "./hooks/useSetupReadiness"; import { useGithubSetupWarningDelay } from "./hooks/useGithubSetupWarningDelay"; import { useUpdateCheck } from "./hooks/useUpdateCheck"; @@ -410,6 +411,7 @@ function AppInner() { // Navigation history for browser back button (desktop + mobile). const { pushNav, replaceCurrent, removeNav } = useNavigationHistory({ enabled: true }); + const viewNavRevertRef = useRef(new Map void)[]>()); // View state must be defined before useTasks since useTasks depends on taskView for SSE gating const { viewMode, setViewMode, taskView, setTaskView, handleChangeTaskView } = useViewState({ @@ -439,7 +441,10 @@ function AppInner() { return null; }, [rawPluginDashboardViews]); - // History-aware view change handler — pushes nav entry on back-navigation stack. + /* + FNXC:DashboardShortcuts 2026-07-16-00:00: + FN-8069 makes Settings and Command Center shortcuts true toggles. Retain the exact view-revert callback pushed to navigation history so a shortcut can remove that identity-matched entry and restore the captured prior view for shortcut and Header/MobileNavBar opens alike; the callback deletes itself for shortcut close and Browser Back paths (Runfusion/Fusion#2118). + */ const handleTaskViewChange = useCallback((newView: TaskView) => { if (newView === "missions") { setMissionResumeSessionId(undefined); @@ -452,7 +457,13 @@ function AppInner() { const previousView = taskView; handleChangeTaskView(newView); if (previousView !== newView) { - pushNav({ type: "view", revert: () => handleChangeTaskView(previousView) }); + const revert = retainViewNavRevert( + newView, + previousView, + viewNavRevertRef.current, + handleChangeTaskView, + ); + pushNav({ type: "view", revert }); } }, [handleChangeTaskView, taskView, pushNav]); @@ -1033,6 +1044,16 @@ function AppInner() { pushNav({ type: "modal", close: modalManager.closeNewTask }); }, [modalManager, pushNav]); + const closeFilesWithNav = useCallback(() => { + removeNav(modalManager.closeFiles); + modalManager.closeFiles(); + }, [modalManager, removeNav]); + + const closeNewTaskWithNav = useCallback(() => { + removeNav(modalManager.closeNewTask); + modalManager.closeNewTask(); + }, [modalManager, removeNav]); + /* FNXC:Navigation 2026-06-21-00:00: FN-6886 keeps the existing planning payload setters but routes every programmatic Planning Mode entry point to the docked `planning` view instead of pushing a modal overlay history entry. @@ -1127,19 +1148,28 @@ function AppInner() { pushNav({ type: "modal", close: modalManager.closeFiles }); }, [modalManager, pushNav]); + const closeViewShortcutWithNav = useCallback((view: TaskView) => { + closeViewShortcut( + view, + viewNavRevertRef.current, + removeNav, + () => handleChangeTaskView("board"), + ); + }, [handleChangeTaskView, removeNav]); + /* - FNXC:DashboardShortcuts 2026-07-04-00:00: - FN-7553 wires openFiles/openSettings/openCommandCenter/newTask into the same global listener as the base quickChat/terminal actions (FN-7494/FN-7507), reusing openFilesWithNav, openSettingsWithNav, openCommandCenterWithNav, and openNewTaskWithNav so no second nav destination is introduced. + FNXC:DashboardShortcuts 2026-07-16-00:00: + All six configurable shortcuts toggle. Modal toggles retain their existing nav-aware closer; Settings and Command Center remove and invoke the exact retained history revert, restoring the captured prior view rather than Board. This applies equally to shortcut and existing Header/MobileNavBar opens without duplicate destinations or replayable history entries (Runfusion/Fusion#2118). */ useDashboardKeyboardShortcuts({ shortcuts: dashboardKeyboardShortcuts, - openQuickChat: () => setQuickChatOpen(true), + toggleQuickChat: () => setQuickChatOpen((open) => !open), toggleTerminal: toggleTerminalWithNav, closeTopmostPopup: closeTopmostPopupForShortcut, - openFiles: () => openFilesWithNav(), - openSettings: () => openSettingsWithNav(), - openCommandCenter: openCommandCenterWithNav, - openNewTask: openNewTaskWithNav, + toggleFiles: () => modalManager.filesOpen ? closeFilesWithNav() : openFilesWithNav(), + toggleSettings: () => taskView === "settings" ? closeViewShortcutWithNav("settings") : openSettingsWithNav(), + toggleCommandCenter: () => taskView === "command-center" ? closeViewShortcutWithNav("command-center") : openCommandCenterWithNav(), + toggleNewTask: () => modalManager.newTaskModalOpen ? closeNewTaskWithNav() : openNewTaskWithNav(), }); const openFileInBrowser = useCallback((path: string, opts?: { workspace?: string; line?: number; col?: number }) => { diff --git a/packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx b/packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx index d9c50a89b6..a24c137ec5 100644 --- a/packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx +++ b/packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx @@ -1,20 +1,25 @@ -import { renderHook } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { closeTopmostDashboardPopupForShortcut } from "../App"; import { useDashboardKeyboardShortcuts } from "../hooks/useDashboardKeyboardShortcuts"; +import { useNavigationHistory } from "../hooks/useNavigationHistory"; +import { closeViewShortcut, retainViewNavRevert } from "../utils/dashboardShortcutToggles"; function baseHandlers() { return { - openFiles: vi.fn(), - openSettings: vi.fn(), - openCommandCenter: vi.fn(), - openNewTask: vi.fn(), + toggleFiles: vi.fn(), + toggleSettings: vi.fn(), + toggleCommandCenter: vi.fn(), + toggleNewTask: vi.fn(), }; } /* FNXC:DashboardShortcuts 2026-07-04-12:02: FN-7507 closes the FN-7494 Code Review gap by proving the dashboard shortcut/Escape invariants at the App-owned seam without rendering every lazy dashboard surface. The hook assertions cover settings-to-document key handling, while closeTopmostDashboardPopupForShortcut covers the App shell's one-popup Escape ordering. + +FNXC:DashboardShortcuts 2026-07-16-00:00: +FN-8069 adds live navigation-history coverage for the App helper that retains and closes Settings/Command Center view entries. It verifies callback identity removal, prior-view restoration, and Browser Back self-cleanup rather than relying on dispatcher spies alone (Runfusion/Fusion#2118). */ function press(init: KeyboardEventInit, target: Document | HTMLElement = document) { const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); @@ -24,29 +29,60 @@ function press(init: KeyboardEventInit, target: Document | HTMLElement = documen describe("App dashboard keyboard shortcuts", () => { it("opens Quick Chat with the default Space binding from document focus", () => { - const openQuickChat = vi.fn(); + const toggleQuickChat = vi.fn(); renderHook(() => useDashboardKeyboardShortcuts({ - ...baseHandlers(), openQuickChat, toggleTerminal: vi.fn() })); + ...baseHandlers(), toggleQuickChat, toggleTerminal: vi.fn() })); const event = press({ key: " " }); - expect(openQuickChat).toHaveBeenCalledTimes(1); + expect(toggleQuickChat).toHaveBeenCalledTimes(1); expect(event.defaultPrevented).toBe(true); }); + it("dispatches every default shortcut twice so App toggle callbacks own both directions", () => { + const toggleQuickChat = vi.fn(); + const toggleTerminal = vi.fn(); + const handlers = { + toggleQuickChat, + toggleTerminal, + toggleFiles: vi.fn(), + toggleSettings: vi.fn(), + toggleCommandCenter: vi.fn(), + toggleNewTask: vi.fn(), + }; + renderHook(() => useDashboardKeyboardShortcuts(handlers)); + + const bindings: KeyboardEventInit[] = [ + { key: " " }, + { key: "`", ctrlKey: true }, + { key: "e", ctrlKey: true }, + { key: ",", ctrlKey: true }, + { key: "k", ctrlKey: true }, + { key: "n", ctrlKey: true, shiftKey: true }, + ]; + for (const binding of bindings) { + press(binding); + press(binding); + } + + for (const handler of Object.values(handlers)) { + expect(handler).toHaveBeenCalledTimes(2); + } + }); + it("uses configured Terminal bindings and leaves disabled bindings inert", () => { - const openQuickChat = vi.fn(); + const toggleQuickChat = vi.fn(); const toggleTerminal = vi.fn(); renderHook(() => useDashboardKeyboardShortcuts({ ...baseHandlers(), shortcuts: { quickChat: "", terminal: "Alt+T" }, - openQuickChat, + toggleQuickChat, toggleTerminal, })); const disabledQuickChatEvent = press({ key: " " }); - expect(openQuickChat).not.toHaveBeenCalled(); + expect(toggleQuickChat).not.toHaveBeenCalled(); expect(disabledQuickChatEvent.defaultPrevented).toBe(false); const terminalEvent = press({ key: "t", altKey: true }); @@ -55,14 +91,14 @@ describe("App dashboard keyboard shortcuts", () => { }); it("does not capture Space or Escape while an editable field owns the key", () => { - const openQuickChat = vi.fn(); + const toggleQuickChat = vi.fn(); const closeTopmostPopup = vi.fn(() => true); const input = document.createElement("input"); document.body.append(input); renderHook(() => useDashboardKeyboardShortcuts({ ...baseHandlers(), - openQuickChat, + toggleQuickChat, toggleTerminal: vi.fn(), closeTopmostPopup, })); @@ -71,7 +107,7 @@ describe("App dashboard keyboard shortcuts", () => { const spaceEvent = press({ key: " " }, input); const escapeEvent = press({ key: "Escape" }, input); - expect(openQuickChat).not.toHaveBeenCalled(); + expect(toggleQuickChat).not.toHaveBeenCalled(); expect(closeTopmostPopup).not.toHaveBeenCalled(); expect(spaceEvent.defaultPrevented).toBe(false); expect(escapeEvent.defaultPrevented).toBe(false); @@ -80,12 +116,12 @@ describe("App dashboard keyboard shortcuts", () => { }); it("lets nested handlers keep default-prevented shortcut events", () => { - const openQuickChat = vi.fn(); + const toggleQuickChat = vi.fn(); const closeTopmostPopup = vi.fn(() => true); renderHook(() => useDashboardKeyboardShortcuts({ ...baseHandlers(), - openQuickChat, + toggleQuickChat, toggleTerminal: vi.fn(), closeTopmostPopup, })); @@ -98,7 +134,7 @@ describe("App dashboard keyboard shortcuts", () => { Object.defineProperty(menuEscape, "defaultPrevented", { value: true }); document.dispatchEvent(menuEscape); - expect(openQuickChat).not.toHaveBeenCalled(); + expect(toggleQuickChat).not.toHaveBeenCalled(); expect(closeTopmostPopup).not.toHaveBeenCalled(); }); @@ -159,7 +195,7 @@ describe("App dashboard keyboard shortcuts", () => { renderHook(() => useDashboardKeyboardShortcuts({ ...baseHandlers(), - openQuickChat: vi.fn(), + toggleQuickChat: vi.fn(), toggleTerminal: vi.fn(), closeTopmostPopup, })); @@ -171,35 +207,105 @@ describe("App dashboard keyboard shortcuts", () => { expect(handled.defaultPrevented).toBe(true); expect(unhandled.defaultPrevented).toBe(false); }); - it("dispatches the FN-7553 openFiles/openSettings/openCommandCenter/newTask actions and ignores editable targets", () => { - const openFiles = vi.fn(); - const openSettings = vi.fn(); - const openCommandCenter = vi.fn(); - const openNewTask = vi.fn(); + it("dispatches the FN-7553 toggleFiles/toggleSettings/toggleCommandCenter/newTask actions and ignores editable targets", () => { + const toggleFiles = vi.fn(); + const toggleSettings = vi.fn(); + const toggleCommandCenter = vi.fn(); + const toggleNewTask = vi.fn(); const input = document.createElement("input"); document.body.append(input); renderHook(() => useDashboardKeyboardShortcuts({ - openQuickChat: vi.fn(), + toggleQuickChat: vi.fn(), toggleTerminal: vi.fn(), - openFiles, - openSettings, - openCommandCenter, - openNewTask, + toggleFiles, + toggleSettings, + toggleCommandCenter, + toggleNewTask, })); press({ key: "e", ctrlKey: true }); press({ key: ",", ctrlKey: true }); press({ key: "k", ctrlKey: true }); press({ key: "n", ctrlKey: true, shiftKey: true }); - expect(openFiles).toHaveBeenCalledTimes(1); - expect(openSettings).toHaveBeenCalledTimes(1); - expect(openCommandCenter).toHaveBeenCalledTimes(1); - expect(openNewTask).toHaveBeenCalledTimes(1); + expect(toggleFiles).toHaveBeenCalledTimes(1); + expect(toggleSettings).toHaveBeenCalledTimes(1); + expect(toggleCommandCenter).toHaveBeenCalledTimes(1); + expect(toggleNewTask).toHaveBeenCalledTimes(1); + + press({ key: "e", ctrlKey: true }); + press({ key: ",", ctrlKey: true }); + press({ key: "k", ctrlKey: true }); + press({ key: "n", ctrlKey: true, shiftKey: true }); + expect(toggleFiles).toHaveBeenCalledTimes(2); + expect(toggleSettings).toHaveBeenCalledTimes(2); + expect(toggleCommandCenter).toHaveBeenCalledTimes(2); + expect(toggleNewTask).toHaveBeenCalledTimes(2); input.focus(); press({ key: "e", ctrlKey: true }, input); - expect(openFiles).toHaveBeenCalledTimes(1); + expect(toggleFiles).toHaveBeenCalledTimes(2); input.remove(); }); + + it("keeps invalid bindings inert without preventing their key event", () => { + const toggleQuickChat = vi.fn(); + renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), + shortcuts: { quickChat: "Ctrl+Alt" }, + toggleQuickChat, + toggleTerminal: vi.fn(), + })); + + const event = press({ key: "a", ctrlKey: true, altKey: true }); + expect(toggleQuickChat).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it("removes the exact retained view entry and restores the prior view", () => { + const { result } = renderHook(() => useNavigationHistory({ enabled: true })); + const reverts = new Map void)[]>(); + const restoreView = vi.fn(); + const revert = retainViewNavRevert("settings", "list", reverts, restoreView); + const removeNav = vi.fn(result.current.removeNav); + result.current.pushNav({ type: "view", revert }); + + expect(closeViewShortcut("settings", reverts, removeNav, vi.fn())).toBe(true); + expect(removeNav).toHaveBeenCalledWith(revert); + expect(restoreView).toHaveBeenCalledWith("list"); + expect(reverts.has("settings")).toBe(false); + }); + + it("self-cleans retained view callbacks when Browser Back closes a view", () => { + const { result } = renderHook(() => useNavigationHistory({ enabled: true })); + const reverts = new Map void)[]>(); + const restoreView = vi.fn(); + const revert = retainViewNavRevert("command-center", "list", reverts, restoreView); + result.current.pushNav({ type: "view", revert }); + + act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex: 0 } }))); + + expect(restoreView).toHaveBeenCalledWith("list"); + expect(reverts.has("command-center")).toBe(false); + }); + + it("preserves an earlier Settings entry after closing a later Settings shortcut", () => { + const reverts = new Map void)[]>(); + const restoreView = vi.fn(); + const removeNav = vi.fn(); + const firstSettingsRevert = retainViewNavRevert("settings", "list", reverts, restoreView); + const boardRevert = retainViewNavRevert("board", "settings", reverts, restoreView); + const secondSettingsRevert = retainViewNavRevert("settings", "board", reverts, restoreView); + + expect(closeViewShortcut("settings", reverts, removeNav, vi.fn())).toBe(true); + expect(removeNav).toHaveBeenCalledWith(secondSettingsRevert); + expect(restoreView).toHaveBeenLastCalledWith("board"); + expect(reverts.get("settings")).toEqual([firstSettingsRevert]); + + boardRevert(); + expect(closeViewShortcut("settings", reverts, removeNav, vi.fn())).toBe(true); + expect(removeNav).toHaveBeenLastCalledWith(firstSettingsRevert); + expect(restoreView).toHaveBeenLastCalledWith("list"); + expect(reverts.has("settings")).toBe(false); + }); }); diff --git a/packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx b/packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx index 3efd431fdb..088b36524b 100644 --- a/packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx +++ b/packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx @@ -4,10 +4,10 @@ import { useDashboardKeyboardShortcuts } from "../useDashboardKeyboardShortcuts" function baseHandlers() { return { - openFiles: vi.fn(), - openSettings: vi.fn(), - openCommandCenter: vi.fn(), - openNewTask: vi.fn(), + toggleFiles: vi.fn(), + toggleSettings: vi.fn(), + toggleCommandCenter: vi.fn(), + toggleNewTask: vi.fn(), }; } @@ -18,29 +18,29 @@ function press(init: KeyboardEventInit, target: Document | HTMLElement = documen } describe("useDashboardKeyboardShortcuts", () => { - it("opens Quick Chat with the default Space binding from document focus", () => { - const openQuickChat = vi.fn(); + it("dispatches the Quick Chat toggle with the default Space binding from document focus", () => { + const toggleQuickChat = vi.fn(); renderHook(() => useDashboardKeyboardShortcuts({ - ...baseHandlers(), openQuickChat, toggleTerminal: vi.fn() })); + ...baseHandlers(), toggleQuickChat, toggleTerminal: vi.fn() })); const event = press({ key: " " }); - expect(openQuickChat).toHaveBeenCalledTimes(1); + expect(toggleQuickChat).toHaveBeenCalledTimes(1); expect(event.defaultPrevented).toBe(true); }); - it("opens Terminal with custom shortcuts and honors disabled actions", () => { - const openQuickChat = vi.fn(); + it("dispatches Terminal toggles with custom shortcuts and honors disabled actions", () => { + const toggleQuickChat = vi.fn(); const toggleTerminal = vi.fn(); renderHook(() => useDashboardKeyboardShortcuts({ ...baseHandlers(), shortcuts: { quickChat: "", terminal: "Alt+T" }, - openQuickChat, + toggleQuickChat, toggleTerminal, })); press({ key: " " }); - expect(openQuickChat).not.toHaveBeenCalled(); + expect(toggleQuickChat).not.toHaveBeenCalled(); const event = press({ key: "t", altKey: true }); expect(toggleTerminal).toHaveBeenCalledTimes(1); @@ -48,13 +48,13 @@ describe("useDashboardKeyboardShortcuts", () => { }); it("ignores shortcuts from editable and interactive targets", () => { - const openQuickChat = vi.fn(); + const toggleQuickChat = vi.fn(); const toggleTerminal = vi.fn(); const input = document.createElement("input"); const button = document.createElement("button"); document.body.append(input, button); renderHook(() => useDashboardKeyboardShortcuts({ - ...baseHandlers(), openQuickChat, toggleTerminal })); + ...baseHandlers(), toggleQuickChat, toggleTerminal })); input.focus(); press({ key: " " }, input); @@ -62,29 +62,29 @@ describe("useDashboardKeyboardShortcuts", () => { button.focus(); press({ key: " " }, button); - expect(openQuickChat).not.toHaveBeenCalled(); + expect(toggleQuickChat).not.toHaveBeenCalled(); expect(toggleTerminal).not.toHaveBeenCalled(); input.remove(); button.remove(); }); it("does not handle default-prevented nested menu events", () => { - const openQuickChat = vi.fn(); + const toggleQuickChat = vi.fn(); renderHook(() => useDashboardKeyboardShortcuts({ - ...baseHandlers(), openQuickChat, toggleTerminal: vi.fn() })); + ...baseHandlers(), toggleQuickChat, toggleTerminal: vi.fn() })); const event = new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }); Object.defineProperty(event, "defaultPrevented", { value: true }); document.dispatchEvent(event); - expect(openQuickChat).not.toHaveBeenCalled(); + expect(toggleQuickChat).not.toHaveBeenCalled(); }); it("delegates Escape to the topmost popup closer once", () => { const closeTopmostPopup = vi.fn(() => true); renderHook(() => useDashboardKeyboardShortcuts({ ...baseHandlers(), - openQuickChat: vi.fn(), + toggleQuickChat: vi.fn(), toggleTerminal: vi.fn(), closeTopmostPopup, })); @@ -101,7 +101,7 @@ describe("useDashboardKeyboardShortcuts", () => { document.body.appendChild(input); renderHook(() => useDashboardKeyboardShortcuts({ ...baseHandlers(), - openQuickChat: vi.fn(), + toggleQuickChat: vi.fn(), toggleTerminal: vi.fn(), closeTopmostPopup, })); @@ -116,50 +116,50 @@ describe("useDashboardKeyboardShortcuts", () => { }); describe("FN-7553 new actions", () => { - it("dispatches openFiles, openSettings, openCommandCenter, and newTask on their default bindings", () => { - const openFiles = vi.fn(); - const openSettings = vi.fn(); - const openCommandCenter = vi.fn(); - const openNewTask = vi.fn(); + it("dispatches Files, Settings, Command Center, and New Task toggles on their default bindings", () => { + const toggleFiles = vi.fn(); + const toggleSettings = vi.fn(); + const toggleCommandCenter = vi.fn(); + const toggleNewTask = vi.fn(); renderHook(() => useDashboardKeyboardShortcuts({ - openQuickChat: vi.fn(), + toggleQuickChat: vi.fn(), toggleTerminal: vi.fn(), - openFiles, - openSettings, - openCommandCenter, - openNewTask, + toggleFiles, + toggleSettings, + toggleCommandCenter, + toggleNewTask, })); const filesEvent = press({ key: "e", ctrlKey: true }); - expect(openFiles).toHaveBeenCalledTimes(1); + expect(toggleFiles).toHaveBeenCalledTimes(1); expect(filesEvent.defaultPrevented).toBe(true); press({ key: ",", ctrlKey: true }); - expect(openSettings).toHaveBeenCalledTimes(1); + expect(toggleSettings).toHaveBeenCalledTimes(1); press({ key: "k", ctrlKey: true }); - expect(openCommandCenter).toHaveBeenCalledTimes(1); + expect(toggleCommandCenter).toHaveBeenCalledTimes(1); press({ key: "n", ctrlKey: true, shiftKey: true }); - expect(openNewTask).toHaveBeenCalledTimes(1); + expect(toggleNewTask).toHaveBeenCalledTimes(1); }); it("no-ops new actions when their binding is disabled and ignores editable targets", () => { - const openFiles = vi.fn(); + const toggleFiles = vi.fn(); const input = document.createElement("input"); document.body.appendChild(input); renderHook(() => useDashboardKeyboardShortcuts({ shortcuts: { openFiles: "" }, - openQuickChat: vi.fn(), + toggleQuickChat: vi.fn(), toggleTerminal: vi.fn(), - openFiles, - openSettings: vi.fn(), - openCommandCenter: vi.fn(), - openNewTask: vi.fn(), + toggleFiles, + toggleSettings: vi.fn(), + toggleCommandCenter: vi.fn(), + toggleNewTask: vi.fn(), })); press({ key: "e", ctrlKey: true }); - expect(openFiles).not.toHaveBeenCalled(); + expect(toggleFiles).not.toHaveBeenCalled(); input.focus(); press({ key: "k", ctrlKey: true }, input); diff --git a/packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts b/packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts index 46133fda47..07918fff25 100644 --- a/packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts +++ b/packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts @@ -8,17 +8,17 @@ import { } from "../utils/keyboardShortcuts"; export interface DashboardKeyboardShortcutHandlers { - openQuickChat: () => void; + /* + FNXC:DashboardShortcuts 2026-07-16-00:00: + FN-8069 requires every configurable dashboard shortcut to toggle its surface. App owns state and navigation history, so this listener only dispatches the toggle callbacks; a re-press closes modals or restores the view that was active before Settings or Command Center opened (Runfusion/Fusion#2118). + */ + toggleQuickChat: () => void; toggleTerminal: () => void; closeTopmostPopup?: () => boolean; - /* - FNXC:DashboardShortcuts 2026-07-04-00:00: - FN-7553 adds four more configurable actions. Each handler reuses an existing App nav callback (openFilesWithNav, openSettingsWithNav, a thin command-center nav wrapper, openNewTaskWithNav) so this hook never introduces a second/duplicate nav destination — it only dispatches to whatever the caller already uses for its header/sidebar entry points. - */ - openFiles: () => void; - openSettings: () => void; - openCommandCenter: () => void; - openNewTask: () => void; + toggleFiles: () => void; + toggleSettings: () => void; + toggleCommandCenter: () => void; + toggleNewTask: () => void; } export interface UseDashboardKeyboardShortcutsOptions extends DashboardKeyboardShortcutHandlers { @@ -33,13 +33,13 @@ The global dashboard listener only handles document-level shortcuts after target export function useDashboardKeyboardShortcuts({ shortcuts, enabled = true, - openQuickChat, + toggleQuickChat, toggleTerminal, closeTopmostPopup, - openFiles, - openSettings, - openCommandCenter, - openNewTask, + toggleFiles, + toggleSettings, + toggleCommandCenter, + toggleNewTask, }: UseDashboardKeyboardShortcutsOptions): void { useEffect(() => { if (!enabled || typeof document === "undefined") return; @@ -62,7 +62,7 @@ export function useDashboardKeyboardShortcuts({ if (shortcutMatchesEvent(resolved.quickChat, event)) { event.preventDefault(); - openQuickChat(); + toggleQuickChat(); return; } @@ -74,29 +74,29 @@ export function useDashboardKeyboardShortcuts({ if (shortcutMatchesEvent(resolved.openFiles, event)) { event.preventDefault(); - openFiles(); + toggleFiles(); return; } if (shortcutMatchesEvent(resolved.openSettings, event)) { event.preventDefault(); - openSettings(); + toggleSettings(); return; } if (shortcutMatchesEvent(resolved.openCommandCenter, event)) { event.preventDefault(); - openCommandCenter(); + toggleCommandCenter(); return; } if (shortcutMatchesEvent(resolved.newTask, event)) { event.preventDefault(); - openNewTask(); + toggleNewTask(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [closeTopmostPopup, enabled, openCommandCenter, openFiles, openNewTask, openQuickChat, openSettings, shortcuts, toggleTerminal]); + }, [closeTopmostPopup, enabled, shortcuts, toggleCommandCenter, toggleFiles, toggleNewTask, toggleQuickChat, toggleSettings, toggleTerminal]); } diff --git a/packages/dashboard/app/utils/dashboardShortcutToggles.ts b/packages/dashboard/app/utils/dashboardShortcutToggles.ts new file mode 100644 index 0000000000..4a57d4ccc4 --- /dev/null +++ b/packages/dashboard/app/utils/dashboardShortcutToggles.ts @@ -0,0 +1,41 @@ +/* +FNXC:DashboardShortcuts 2026-07-16-00:00: +FN-8069 requires view-backed dashboard shortcuts to remove the exact navigation-history callback that opened their view, then restore the captured prior view. Retain a callback stack per view so repeated Settings/Command Center entries each preserve their own history; keep this identity-sensitive lifecycle outside App's render body for live navigation-history regression coverage (Runfusion/Fusion#2118). +*/ +export function retainViewNavRevert( + view: TView, + previousView: TView, + reverts: Map void)[]>, + restoreView: (view: TView) => void, +): () => void { + const revert = () => { + const viewReverts = reverts.get(view); + if (viewReverts) { + const index = viewReverts.lastIndexOf(revert); + if (index !== -1) viewReverts.splice(index, 1); + if (viewReverts.length === 0) reverts.delete(view); + } + restoreView(previousView); + }; + const viewReverts = reverts.get(view) ?? []; + viewReverts.push(revert); + reverts.set(view, viewReverts); + return revert; +} + +export function closeViewShortcut( + view: TView, + reverts: Map void)[]>, + removeNav: (revert: () => void) => void, + onMissingRevert: () => void, +): boolean { + const viewReverts = reverts.get(view); + const revert = viewReverts?.at(-1); + if (!revert) { + onMissingRevert(); + return false; + } + removeNav(revert); + revert(); + return true; +}