diff --git a/.changeset/fn-7553-keyboard-shortcuts-section.md b/.changeset/fn-7553-keyboard-shortcuts-section.md new file mode 100644 index 0000000000..79259dd4d5 --- /dev/null +++ b/.changeset/fn-7553-keyboard-shortcuts-section.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add a dedicated Keyboard Shortcuts settings section with click-to-record capture and more configurable actions. +category: feature +dev: Relocates dashboardKeyboardShortcuts into its own settings section, adds a ShortcutCaptureInput recorder, and extends DashboardShortcutAction with openFiles/openSettings/openCommandCenter/newTask actions wired into existing App nav handlers. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 63bcdadbbe..7398348be0 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -30,15 +30,25 @@ Both actions are irreversible; there is no undo after confirming. The dialog clo ## Keyboard shortcuts - -Open **Settings → General → Keyboard shortcuts** to configure dashboard-wide shortcut bindings. Defaults are: + +Open **Settings → Keyboard Shortcuts** (its own dedicated section, no longer under General) to configure dashboard-wide shortcut bindings. Actions are grouped by category: -Leave a shortcut field blank to disable that action. Settings validates each shortcut before saving: unsupported key strings are marked invalid, and duplicate populated shortcuts (for example binding both Quick Chat and Terminal to `Ctrl+K`) are rejected until one binding changes or is disabled. +- **Communication:** Quick Chat (`Space`) +- **Workspace:** Terminal (Ctrl+`), Open Files (`Ctrl+E`) +- **Navigation:** Open Command Center (`Ctrl+K`), Open Settings (`Ctrl+,`) +- **Tasks:** New Task (`Ctrl+Shift+N`) -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. +Each row uses a press-to-record capture control: click **Record**, then press the key combination you want — it fills in automatically. Manual typing remains supported as a fallback. **Clear** disables that action (blank = disabled). While recording, pressing `Escape` cancels the recording instead of binding Escape, so Escape stays permanently reserved for the dashboard's topmost-popup-close shortcut; the capture control also never leaks the recorded keystroke to the global shortcut listener while it is focused/recording. + +Leave a shortcut field blank to disable that action. Settings validates each shortcut before saving: unsupported key strings are marked invalid, and duplicate populated shortcuts across ANY two actions (for example binding both Quick Chat and Open Command Center to `Ctrl+K`) are rejected until one binding changes or is disabled. + +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. 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/core/src/__tests__/global-settings.test.ts b/packages/core/src/__tests__/global-settings.test.ts index 11ea0a59ea..c8d3e27370 100644 --- a/packages/core/src/__tests__/global-settings.test.ts +++ b/packages/core/src/__tests__/global-settings.test.ts @@ -293,7 +293,14 @@ describe("GlobalSettingsStore", () => { await store.updateSettings({ dashboardKeyboardShortcuts: null }); const settings = await store.getSettings(); - expect(settings.dashboardKeyboardShortcuts).toEqual({ quickChat: "Space", terminal: "Ctrl+`" }); + expect(settings.dashboardKeyboardShortcuts).toEqual({ + quickChat: "Space", + terminal: "Ctrl+`", + openFiles: "Ctrl+E", + openSettings: "Ctrl+,", + openCommandCenter: "Ctrl+K", + newTask: "Ctrl+Shift+N", + }); }); it("creates directory if missing", async () => { diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 3818942d00..5f36210648 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -32,6 +32,10 @@ describe("settings defaults invariants", () => { expect(DEFAULT_GLOBAL_SETTINGS.dashboardKeyboardShortcuts).toEqual({ quickChat: "Space", terminal: "Ctrl+`", + openFiles: "Ctrl+E", + openSettings: "Ctrl+,", + openCommandCenter: "Ctrl+K", + newTask: "Ctrl+Shift+N", }); expect(GLOBAL_SETTINGS_KEYS).toContain("dashboardKeyboardShortcuts"); expect(PROJECT_SETTINGS_KEYS).not.toContain("dashboardKeyboardShortcuts"); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 7b2d0c507d..902829ef53 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -425,7 +425,7 @@ describe("settings key parity", () => { expect(projectKeys).not.toContain("dashboardKeyboardShortcuts"); expect(globalKeys).toContain("dashboardKeyboardShortcuts"); - expect(DEFAULT_GLOBAL_SETTINGS.dashboardKeyboardShortcuts).toEqual({ quickChat: "Space", terminal: "Ctrl+`" }); + expect(DEFAULT_GLOBAL_SETTINGS.dashboardKeyboardShortcuts).toEqual({ quickChat: "Space", terminal: "Ctrl+`", openFiles: "Ctrl+E", openSettings: "Ctrl+,", openCommandCenter: "Ctrl+K", newTask: "Ctrl+Shift+N" }); expect((DEFAULT_PROJECT_SETTINGS as Record).dashboardKeyboardShortcuts).toBeUndefined(); }); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index a950b20a60..714bc159a4 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -77,11 +77,15 @@ export const DEFAULT_GLOBAL_SETTINGS = { dashboardFontScalePct: 100, /* FNXC:DashboardShortcuts 2026-07-04-00:00: - Global dashboard shortcuts must hydrate with documented safe defaults even when old settings files are missing the object. Space opens Quick Chat; Ctrl+` opens Terminal without colliding with common browser find/search accelerators. Empty strings are preserved so operators can disable an action. + Global dashboard shortcuts must hydrate with documented safe defaults even when old settings files are missing the object. Space opens Quick Chat; Ctrl+` opens Terminal without colliding with common browser find/search accelerators. FN-7553 adds openFiles (Ctrl+E), openSettings (Ctrl+,), openCommandCenter (Ctrl+K), and newTask (Ctrl+Shift+N) — chosen to avoid colliding with the base two or each other. Empty strings are preserved so operators can disable an action. */ dashboardKeyboardShortcuts: { quickChat: "Space", terminal: "Ctrl+`", + openFiles: "Ctrl+E", + openSettings: "Ctrl+,", + openCommandCenter: "Ctrl+K", + newTask: "Ctrl+Shift+N", }, /* FNXC:ModalDismissal 2026-06-29-00:00: diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7e354ac5d6..674891e498 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3041,11 +3041,23 @@ export interface McpServersSettings { servers?: McpServerDefinition[]; } +/* +FNXC:DashboardShortcuts 2026-07-04-00:00: +FN-7553 adds four more configurable actions on top of the FN-7494/FN-7507 base (quickChat, terminal), each reusing an existing App navigation handler (no new nav destinations). All fields share blank-to-disable semantics: an empty string disables that action's runtime listener. +*/ export interface DashboardKeyboardShortcuts { /** Opens the dashboard Quick Chat surface. Empty string disables this shortcut. Default: "Space". */ quickChat?: string; /** Opens or toggles the dashboard Terminal surface. Empty string disables this shortcut. Default: "Ctrl+`". */ terminal?: string; + /** Opens the dashboard Files browser. Empty string disables this shortcut. Default: "Ctrl+E". */ + openFiles?: string; + /** Opens the dashboard Settings view. Empty string disables this shortcut. Default: "Ctrl+,". */ + openSettings?: string; + /** Opens the dashboard Command Center view. Empty string disables this shortcut. Default: "Ctrl+K". */ + openCommandCenter?: string; + /** Opens the New Task modal. Empty string disables this shortcut. Default: "Ctrl+Shift+N". */ + newTask?: string; } export interface GlobalSettings { diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index de82a6c8dd..0493e1ac5c 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -941,6 +941,14 @@ function AppInner() { handleTaskViewChange("settings"); }, [modalManager, handleTaskViewChange]); + /* + FNXC:DashboardShortcuts 2026-07-04-00:00: + FN-7553's openCommandCenter shortcut reuses the same handleTaskViewChange nav-history owner as openSettingsWithNav/openPlanningWithNav above, so Command Center never gets a second/duplicate nav destination beyond the existing Header/LeftSidebarNav/MobileNavBar "command-center" view entries. + */ + const openCommandCenterWithNav = useCallback(() => { + handleTaskViewChange("command-center"); + }, [handleTaskViewChange]); + const openNewTaskWithNav = useCallback(() => { modalManager.openNewTask(); pushNav({ type: "modal", close: modalManager.closeNewTask }); @@ -1035,18 +1043,26 @@ function AppInner() { ); }, [closePoppedOutTask, closeTerminalWithNav, modalManager, poppedOutTasks, quickChatOpen]); + const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => { + modalManager.openFiles(workspace, initialFile); + pushNav({ type: "modal", close: modalManager.closeFiles }); + }, [modalManager, pushNav]); + + /* + 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. + */ useDashboardKeyboardShortcuts({ shortcuts: dashboardKeyboardShortcuts, openQuickChat: () => setQuickChatOpen(true), toggleTerminal: toggleTerminalWithNav, closeTopmostPopup: closeTopmostPopupForShortcut, + openFiles: () => openFilesWithNav(), + openSettings: () => openSettingsWithNav(), + openCommandCenter: openCommandCenterWithNav, + openNewTask: openNewTaskWithNav, }); - const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => { - modalManager.openFiles(workspace, initialFile); - pushNav({ type: "modal", close: modalManager.closeFiles }); - }, [modalManager, pushNav]); - const openFileInBrowser = useCallback((path: string, opts?: { workspace?: string; line?: number; col?: number }) => { modalManager.openFiles(opts?.workspace, path); pushNav({ type: "modal", close: modalManager.closeFiles }); diff --git a/packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx b/packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx index f9d443e411..aae6979634 100644 --- a/packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx +++ b/packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx @@ -3,6 +3,15 @@ import { describe, expect, it, vi } from "vitest"; import { closeTopmostDashboardPopupForShortcut } from "../App"; import { useDashboardKeyboardShortcuts } from "../hooks/useDashboardKeyboardShortcuts"; +function baseHandlers() { + return { + openFiles: vi.fn(), + openSettings: vi.fn(), + openCommandCenter: vi.fn(), + openNewTask: 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. @@ -17,7 +26,8 @@ describe("App dashboard keyboard shortcuts", () => { it("opens Quick Chat with the default Space binding from document focus", () => { const openQuickChat = vi.fn(); - renderHook(() => useDashboardKeyboardShortcuts({ openQuickChat, toggleTerminal: vi.fn() })); + renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), openQuickChat, toggleTerminal: vi.fn() })); const event = press({ key: " " }); expect(openQuickChat).toHaveBeenCalledTimes(1); @@ -29,6 +39,7 @@ describe("App dashboard keyboard shortcuts", () => { const toggleTerminal = vi.fn(); renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), shortcuts: { quickChat: "", terminal: "Alt+T" }, openQuickChat, toggleTerminal, @@ -50,6 +61,7 @@ describe("App dashboard keyboard shortcuts", () => { document.body.append(input); renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), openQuickChat, toggleTerminal: vi.fn(), closeTopmostPopup, @@ -72,6 +84,7 @@ describe("App dashboard keyboard shortcuts", () => { const closeTopmostPopup = vi.fn(() => true); renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), openQuickChat, toggleTerminal: vi.fn(), closeTopmostPopup, @@ -143,6 +156,7 @@ describe("App dashboard keyboard shortcuts", () => { .mockReturnValueOnce(false); renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), openQuickChat: vi.fn(), toggleTerminal: vi.fn(), closeTopmostPopup, @@ -155,4 +169,35 @@ 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(); + const input = document.createElement("input"); + document.body.append(input); + + renderHook(() => useDashboardKeyboardShortcuts({ + openQuickChat: vi.fn(), + toggleTerminal: vi.fn(), + openFiles, + openSettings, + openCommandCenter, + openNewTask, + })); + + 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); + + input.focus(); + press({ key: "e", ctrlKey: true }, input); + expect(openFiles).toHaveBeenCalledTimes(1); + input.remove(); + }); }); diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index 15c1655abc..dbc288c1b0 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -2528,21 +2528,59 @@ The header row wraps so the badge drops below the heading on narrow widths inste /* FNXC:DashboardShortcuts 2026-07-04-00:00: -Keyboard shortcut fields need to sit as a compact pair on desktop while wrapping naturally in the Settings modal and embedded Settings view on mobile. Use token spacing and existing input styling so validation states do not create orphaned labels or button shells. +FN-7553's dedicated Keyboard Shortcuts section groups every action under a category heading and gives each row a press-to-record capture control (input + Record + Clear) instead of the old bare text input. Token spacing/colors only; wraps to a single column under 768px so the record/clear buttons never overflow the row on mobile. */ -.settings-keyboard-shortcuts__grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--space-md); +.shortcut-category { + margin-top: var(--space-lg); } -.settings-keyboard-shortcuts__grid > .form-group { +.shortcut-category:first-of-type { margin-top: 0; - padding: 0; +} + +.shortcut-row { + display: flex; + flex-direction: column; + gap: var(--space-xs); + padding: var(--space-sm) 0; + border-bottom: 1px solid var(--color-border); +} + +.shortcut-row:last-child { + border-bottom: 0; +} + +.shortcut-capture { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.shortcut-capture__input { + flex: 1 1 auto; + min-width: 0; +} + +.shortcut-capture__input--invalid { + border-color: var(--color-error); +} + +.shortcut-capture__record--active { + color: var(--color-warning); +} + +.shortcut-conflict-banner { + color: var(--color-error); } @media (max-width: 768px) { - .settings-keyboard-shortcuts__grid { - grid-template-columns: 1fr; + .shortcut-capture { + flex-direction: column; + align-items: stretch; + } + + .shortcut-capture__input { + width: 100%; } } diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 5f34e5167a..d3a272175a 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -16,13 +16,20 @@ import { getResetIneligibleReason, getSectionKeyEntry, } from "./settings/section-keys"; -import { describeShortcutValidation, normalizeKeyboardShortcut } from "../utils/keyboardShortcuts"; +import { + describeShortcutValidation, + normalizeKeyboardShortcut, + resolveDashboardKeyboardShortcuts, + type DashboardShortcutAction, +} from "../utils/keyboardShortcuts"; +import type { DashboardKeyboardShortcutMap } from "../utils/keyboardShortcuts"; import type { SectionSaveHandler } from "./settings/sections/context"; import { AppearanceSection } from "./settings/sections/AppearanceSection"; import { ExperimentalSection } from "./settings/sections/ExperimentalSection"; import { NodeSyncSection } from "./settings/sections/NodeSyncSection"; import { NotificationsSection } from "./settings/sections/NotificationsSection"; import { GlobalGeneralSection } from "./settings/sections/GlobalGeneralSection"; +import { KeyboardShortcutsSection } from "./settings/sections/KeyboardShortcutsSection"; import { ResearchGlobalSection } from "./settings/sections/ResearchGlobalSection"; import { RemoteSection } from "./settings/sections/RemoteSection"; import { GlobalMcpSection } from "./settings/sections/GlobalMcpSection"; @@ -346,6 +353,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ // Global group (shared across all Fusion projects) { id: "__global_header", label: "Global", labelKey: "settings.nav.globalHeader", scope: undefined, isGroupHeader: true }, { id: "global-general", label: "General", labelKey: "settings.nav.globalGeneral", scope: "global", searchableText: ["global defaults", "modal outside dismiss", "agent logs", "persist tool output", "thinking logs", "GitLab instance URL", "global tracking repo"] }, + { id: "keyboard-shortcuts", label: "Keyboard Shortcuts", labelKey: "settings.nav.keyboardShortcuts", scope: "global", searchableText: ["keyboard shortcuts", "hotkeys", "quick chat shortcut", "terminal shortcut", "open files", "open settings", "command center", "new task shortcut", "record shortcut"] }, { id: "authentication", label: "Authentication", labelKey: "settings.nav.authentication", scope: undefined, icon: Globe, searchableText: ["login", "OAuth", "API key", "custom providers", "Anthropic", "OpenAI", "provider credentials"] }, { id: "appearance", label: "Appearance", labelKey: "settings.nav.appearance", scope: "global", searchableText: ["theme", "color", "sidebar", "dock", "task popup", "open tasks as popups", "quick chat"] }, { id: "notifications", label: "Notifications", labelKey: "settings.nav.notifications", scope: "global", searchableText: ["ntfy", "webhook", "events", "failure notifications", "sticky", "toast"] }, @@ -2733,10 +2741,14 @@ export function SettingsModal({ maxAutoMergeRetries: resolveMaxAutoMergeRetriesForSettingsForm(form), taskPrefix: form.taskPrefix?.trim() || undefined, githubTrackingDefaultRepo: form.githubTrackingDefaultRepo?.trim() || undefined, - dashboardKeyboardShortcuts: { - quickChat: normalizeKeyboardShortcut(form.dashboardKeyboardShortcuts?.quickChat ?? "").normalized, - terminal: normalizeKeyboardShortcut(form.dashboardKeyboardShortcuts?.terminal ?? "").normalized, - }, + /* + FNXC:DashboardShortcuts 2026-07-04-00:00: + FN-7553 normalizes every declared shortcut action (derived from resolveDashboardKeyboardShortcuts' key set) on save, not just quickChat/terminal, so newly-added actions get the same trim/normalize-before-persist treatment. + */ + dashboardKeyboardShortcuts: Object.fromEntries( + (Object.entries(resolveDashboardKeyboardShortcuts(form.dashboardKeyboardShortcuts)) as [DashboardShortcutAction, string][]) + .map(([action, shortcut]) => [action, normalizeKeyboardShortcut(shortcut).normalized]), + ) as DashboardKeyboardShortcutMap, gitlabEnabled: gitlabFormForSave.gitlabEnabled, gitlabInstanceUrl: gitlabFormForSave.gitlabInstanceUrl?.trim() || undefined, gitlabApiBaseUrl: gitlabFormForSave.gitlabApiBaseUrl?.trim() || undefined, @@ -3145,6 +3157,14 @@ export function SettingsModal({ globalTrackingRepoError={globalTrackingRepoError} /> ); + case "keyboard-shortcuts": + return ( + + ); case "global-models": return ( { expect(screen.getByText(/Projects inherit this value when they do not set a project default tracking repo/i)).toBeInTheDocument(); }); - it("renders default keyboard shortcuts and saves normalized valid edits globally", async () => { - renderModal({ initialSection: "global-general" }); - await waitForSettingsModalReady(); - - expect(screen.getByRole("textbox", { name: "Quick Chat shortcut" })).toHaveValue("Space"); - expect(screen.getByRole("textbox", { name: "Terminal shortcut" })).toHaveValue("Ctrl+`"); - - await settingsModalUser.clear(screen.getByRole("textbox", { name: "Quick Chat shortcut" })); - await settingsModalUser.type(screen.getByRole("textbox", { name: "Quick Chat shortcut" }), "meta+k"); - await settingsModalUser.clear(screen.getByRole("textbox", { name: "Terminal shortcut" })); - await settingsModalUser.type(screen.getByRole("textbox", { name: "Terminal shortcut" }), "Alt+T"); - await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); - - await waitFor(() => expect(mockUpdateGlobalSettings).toHaveBeenCalled()); - const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record; - expect(globalPayload.dashboardKeyboardShortcuts).toEqual({ quickChat: "Meta+K", terminal: "Alt+T" }); - if (mockUpdateSettings.mock.calls.length > 0) { - const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record; - expect(projectPayload.dashboardKeyboardShortcuts).toBeUndefined(); - } - }); - - it("blocks duplicate and invalid keyboard shortcuts before saving", async () => { - const addToast = vi.fn(); - renderModal({ initialSection: "global-general", addToast }); - await waitForSettingsModalReady(); - - await settingsModalUser.clear(screen.getByRole("textbox", { name: "Quick Chat shortcut" })); - await settingsModalUser.type(screen.getByRole("textbox", { name: "Quick Chat shortcut" }), "Ctrl+K"); - await settingsModalUser.clear(screen.getByRole("textbox", { name: "Terminal shortcut" })); - await settingsModalUser.type(screen.getByRole("textbox", { name: "Terminal shortcut" }), "Control+K"); - - expect(screen.getByRole("alert")).toHaveTextContent("both use Ctrl+K"); - await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); - expect(addToast).toHaveBeenCalledWith(expect.stringContaining("both use Ctrl+K"), "error"); - expect(mockUpdateGlobalSettings).not.toHaveBeenCalled(); - - await settingsModalUser.clear(screen.getByRole("textbox", { name: "Terminal shortcut" })); - await settingsModalUser.type(screen.getByRole("textbox", { name: "Terminal shortcut" }), "Ctrl+Alt"); - expect(screen.getByRole("textbox", { name: "Terminal shortcut" })).toHaveAttribute("aria-invalid", "true"); - await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); - expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Terminal shortcut is invalid"), "error"); - expect(mockUpdateGlobalSettings).not.toHaveBeenCalled(); - }); - - it("allows disabling keyboard shortcuts with blank values", async () => { - renderModal({ initialSection: "global-general" }); - await waitForSettingsModalReady(); - - await settingsModalUser.clear(screen.getByRole("textbox", { name: "Quick Chat shortcut" })); - await settingsModalUser.clear(screen.getByRole("textbox", { name: "Terminal shortcut" })); - await settingsModalUser.click(screen.getByRole("button", { name: "Save" })); - - await waitFor(() => expect(mockUpdateGlobalSettings).toHaveBeenCalled()); - const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record; - expect(globalPayload.dashboardKeyboardShortcuts).toEqual({ quickChat: "", terminal: "" }); - }); - - it("keeps the keyboard shortcut layout responsive", () => { - expect(settingsModalCss).toMatch(/\.settings-keyboard-shortcuts__grid\s*\{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);/); - expect(settingsModalCss).toMatch(/@media \(max-width: 768px\)\s*\{[^}]*\.settings-keyboard-shortcuts__grid\s*\{[^}]*grid-template-columns:\s*1fr;/s); - }); - it("reflects persisted checked value from global settings", async () => { mockFetchSettings.mockResolvedValue({ ...defaultSettings, diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.keyboardShortcuts.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.keyboardShortcuts.test.tsx new file mode 100644 index 0000000000..e66b2fbf5f --- /dev/null +++ b/packages/dashboard/app/components/__tests__/SettingsModal.keyboardShortcuts.test.tsx @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { SettingsModal } from "../SettingsModal"; + +/* +FNXC:DashboardShortcuts 2026-07-04-00:00: +FN-7553 relocates the shortcut-save/validation coverage that used to live in +SettingsModal.general.test.tsx (initialSection="global-general") to the new +dedicated "keyboard-shortcuts" section. Mirrors SettingsModal.testMode.test.tsx's +minimal standalone harness rather than the large shared general-test-harness. +*/ +const mockFetchSettings = vi.fn(); +const mockFetchSettingsByScope = vi.fn(); +const mockUpdateSettings = vi.fn(); +const mockUpdateGlobalSettings = vi.fn(); + +vi.mock("../../api", async (importOriginal) => { + const { createDashboardApiMock } = await import("../../test/mockApi"); + return createDashboardApiMock(() => importOriginal(), { + fetchSettings: (...args: unknown[]) => mockFetchSettings(...args), + fetchSettingsByScope: (...args: unknown[]) => mockFetchSettingsByScope(...args), + updateSettings: (...args: unknown[]) => mockUpdateSettings(...args), + updateGlobalSettings: (...args: unknown[]) => mockUpdateGlobalSettings(...args), + }); +}); + +vi.mock("../../hooks/useMemoryBackendStatus", () => ({ + useMemoryBackendStatus: () => ({ status: null, capabilities: null, loading: false, error: null, refresh: vi.fn() }), +})); +vi.mock("../../hooks/useViewportMode", () => ({ + MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)", + useViewportMode: () => "desktop", + getViewportMode: () => "desktop", + isMobileViewport: () => false, +})); +vi.mock("../../hooks/useMobileKeyboard", () => ({ + useMobileKeyboard: () => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false }), +})); +vi.mock("../../hooks/useMobileScrollLock", () => ({ + useMobileScrollLock: vi.fn(), + useMobileKeyboardViewportLock: vi.fn(), + useMobileViewportRestoreReset: vi.fn(), +})); +vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn() }) })); +vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({ + useWorkspaceFileBrowser: () => ({ entries: [], currentPath: ".", setPath: vi.fn(), loading: false, error: null, refresh: vi.fn() }), +})); +vi.mock("../../hooks/useWorktrunkInstallStatus", () => ({ + useWorktrunkInstallStatus: () => ({ status: "idle", requestInstall: vi.fn() }), +})); + +function buildSettings() { + return { + autoMerge: true, + testMode: false, + maxConcurrent: 2, + maxTriageConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 15000, + heartbeatMultiplier: 1, + groupOverlappingFiles: true, + overlapIgnorePaths: [], + mergeStrategy: "direct", + mergeIntegrationWorktree: "reuse-task-worktree", + recycleWorktrees: false, + executorAllowSiblingBranchRename: false, + worktreeNaming: "random", + worktreesDir: "", + worktrunk: { enabled: false, binaryPath: "", onFailure: "fail" }, + includeTaskIdInCommit: true, + ntfyEnabled: false, + failureNotificationMode: "sticky-only", + failureNotificationDelayMs: 30000, + webhookEnabled: false, + experimentalFeatures: {}, + dashboardKeyboardShortcuts: { + quickChat: "Space", + terminal: "Ctrl+`", + openFiles: "Ctrl+E", + openSettings: "Ctrl+,", + openCommandCenter: "Ctrl+K", + newTask: "Ctrl+Shift+N", + }, + }; +} + +describe("SettingsModal Keyboard Shortcuts section", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchSettings.mockResolvedValue(buildSettings()); + mockFetchSettingsByScope.mockResolvedValue({ global: buildSettings(), project: {} }); + }); + + it("renders all six actions with their documented defaults, grouped by category", async () => { + render( {}} addToast={() => {}} initialSection="keyboard-shortcuts" />); + + expect(await screen.findByRole("textbox", { name: "Quick Chat" })).toHaveValue("Space"); + expect(screen.getByRole("textbox", { name: "Terminal" })).toHaveValue("Ctrl+`"); + expect(screen.getByRole("textbox", { name: "Open Files" })).toHaveValue("Ctrl+E"); + expect(screen.getByRole("textbox", { name: "Open Settings" })).toHaveValue("Ctrl+,"); + expect(screen.getByRole("textbox", { name: "Open Command Center" })).toHaveValue("Ctrl+K"); + expect(screen.getByRole("textbox", { name: "New Task" })).toHaveValue("Ctrl+Shift+N"); + + expect(screen.getByText("Communication")).toBeInTheDocument(); + expect(screen.getByText("Workspace")).toBeInTheDocument(); + expect(screen.getByText("Navigation")).toBeInTheDocument(); + expect(screen.getByText("Tasks")).toBeInTheDocument(); + }); + + it("saves normalized manual edits for the new actions to global settings only", async () => { + const user = userEvent.setup(); + render( {}} addToast={() => {}} initialSection="keyboard-shortcuts" />); + + const openFilesInput = await screen.findByRole("textbox", { name: "Open Files" }); + await user.clear(openFilesInput); + await user.type(openFilesInput, "alt+e"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mockUpdateGlobalSettings).toHaveBeenCalled()); + const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record; + expect((globalPayload.dashboardKeyboardShortcuts as Record).openFiles).toBe("Alt+E"); + if (mockUpdateSettings.mock.calls.length > 0) { + const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record; + expect(projectPayload.dashboardKeyboardShortcuts).toBeUndefined(); + } + }); + + it("detects a duplicate conflict across the base and a new action and blocks save", async () => { + const addToast = vi.fn(); + const user = userEvent.setup(); + render( {}} addToast={addToast} initialSection="keyboard-shortcuts" />); + + const openFilesInput = await screen.findByRole("textbox", { name: "Open Files" }); + await user.clear(openFilesInput); + await user.type(openFilesInput, "Ctrl+K"); + + expect(screen.getByRole("alert")).toHaveTextContent("both use Ctrl+K"); + await user.click(screen.getByRole("button", { name: "Save" })); + expect(addToast).toHaveBeenCalledWith(expect.stringContaining("both use Ctrl+K"), "error"); + expect(mockUpdateGlobalSettings).not.toHaveBeenCalled(); + }); + + it("allows disabling a new action with a blank value", async () => { + const user = userEvent.setup(); + render( {}} addToast={() => {}} initialSection="keyboard-shortcuts" />); + + const newTaskInput = await screen.findByRole("textbox", { name: "New Task" }); + await user.clear(newTaskInput); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(mockUpdateGlobalSettings).toHaveBeenCalled()); + const globalPayload = mockUpdateGlobalSettings.mock.calls[0]?.[0] as Record; + expect((globalPayload.dashboardKeyboardShortcuts as Record).newTask).toBe(""); + }); +}); diff --git a/packages/dashboard/app/components/settings/__tests__/section-keys.test.ts b/packages/dashboard/app/components/settings/__tests__/section-keys.test.ts index fdd18c34af..af9f382685 100644 --- a/packages/dashboard/app/components/settings/__tests__/section-keys.test.ts +++ b/packages/dashboard/app/components/settings/__tests__/section-keys.test.ts @@ -19,6 +19,7 @@ const EXPECTED_KEY_OWNING_SECTIONS: Record = { notifications: "global", experimental: "global", "global-general": "global", + "keyboard-shortcuts": "global", "global-models": "global", "node-sync": "global", "research-global": "global", diff --git a/packages/dashboard/app/components/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index 81527ad5a4..90ba78ab02 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -106,7 +106,6 @@ export const GLOBAL_SECTION_KEYS: Record> = { "gitlabAuthTokenType", "language", "dismissModalsOnOutsideClick", - "dashboardKeyboardShortcuts", "persistAgentToolOutput", "persistAgentThinkingLogPermanent", "persistAgentThinkingLogEphemeral", @@ -115,6 +114,11 @@ export const GLOBAL_SECTION_KEYS: Record> = { "updateCheckFrequency", "autoReloadOnVersionChange", ]), + /* + FNXC:DashboardShortcuts 2026-07-04-00:00: + FN-7553 moves `dashboardKeyboardShortcuts` ownership out of "global-general" into its own dedicated section so the new Keyboard Shortcuts settings section (not General) owns save/reset for this key. + */ + "keyboard-shortcuts": new Set(["dashboardKeyboardShortcuts"]), "global-mcp": new Set(["mcpServers"]), "global-models": new Set([ "defaultProvider", diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index 054334bb5a..d52645c7b5 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -5,12 +5,6 @@ import { TrackingRepoSelect, type TrackingRepoOption } from "../../TrackingRepoS import { CliBinaryPanel } from "../../CliBinaryPanel"; import type { SectionBaseProps } from "./context"; import { useTranslation } from "react-i18next"; -import { - DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS, - describeShortcutValidation, - normalizeKeyboardShortcut, - resolveDashboardKeyboardShortcuts, -} from "../../../utils/keyboardShortcuts"; export interface GlobalGeneralSectionProps extends SectionBaseProps { scopeBanner: ReactNode; globalSettings: Pick | null; @@ -22,17 +16,6 @@ export interface GlobalGeneralSectionProps extends SectionBaseProps { export function GlobalGeneralSection({ scopeBanner, form, setForm, globalSettings, onGlobalGitlabSettingsChange, globalTrackingRepoOptions, globalTrackingRepoLoading, globalTrackingRepoError, }: GlobalGeneralSectionProps) { const { t } = useTranslation("app"); const globalGitlab = globalSettings ?? form; - const shortcutValues = resolveDashboardKeyboardShortcuts(form.dashboardKeyboardShortcuts); - const shortcutValidationMessage = describeShortcutValidation(shortcutValues); - const quickChatShortcut = normalizeKeyboardShortcut(shortcutValues.quickChat); - const terminalShortcut = normalizeKeyboardShortcut(shortcutValues.terminal); - const updateShortcut = (action: "quickChat" | "terminal", value: string) => setForm((f) => ({ - ...f, - dashboardKeyboardShortcuts: { - ...resolveDashboardKeyboardShortcuts(f.dashboardKeyboardShortcuts), - [action]: value, - }, - })); return (<> {scopeBanner}

{t("settings.globalGeneral.general", "General")}

@@ -82,27 +65,6 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalSetting - {/* - FNXC:DashboardShortcuts 2026-07-04-00:00: - Keyboard shortcut controls live in Global General because shortcuts open user-interface surfaces for the current browser/operator, not project execution behavior. Blank inputs intentionally disable the action; duplicate or invalid populated shortcuts are blocked before save. - */} -
-
{t("settings.globalGeneral.keyboardShortcuts", "Keyboard shortcuts")}
-

{t("settings.globalGeneral.keyboardShortcutsHint", "Configure global dashboard shortcuts. Shortcuts are ignored while typing in inputs, editors, chat composers, and terminal fields.")}

-
-
- - updateShortcut("quickChat", e.target.value)}/> - {quickChatShortcut.valid ? t("settings.globalGeneral.quickChatShortcutHint", "Default: Space. Leave blank to disable Quick Chat keyboard opening.") : quickChatShortcut.error} -
-
- - updateShortcut("terminal", e.target.value)}/> - {terminalShortcut.valid ? t("settings.globalGeneral.terminalShortcutHint", "Default: Ctrl+`. Leave blank to disable Terminal keyboard opening.") : terminalShortcut.error} -
-
- {shortcutValidationMessage && {shortcutValidationMessage}} -
diff --git a/packages/dashboard/app/components/settings/sections/KeyboardShortcutsSection.tsx b/packages/dashboard/app/components/settings/sections/KeyboardShortcutsSection.tsx new file mode 100644 index 0000000000..f3279210cb --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/KeyboardShortcutsSection.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { SectionBaseProps } from "./context"; +import { ShortcutCaptureInput } from "./ShortcutCaptureInput"; +import { + DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS, + SHORTCUT_CATEGORIES, + describeShortcutValidation, + getShortcutActionLabel, + normalizeKeyboardShortcut, + resolveDashboardKeyboardShortcuts, + type DashboardShortcutAction, +} from "../../../utils/keyboardShortcuts"; + +export interface KeyboardShortcutsSectionProps extends SectionBaseProps { + scopeBanner: ReactNode; +} + +/* +FNXC:DashboardShortcuts 2026-07-04-00:00: +FN-7553 promotes keyboard shortcuts from two bare inputs buried in Global General to their own dedicated settings section, grouped by category (Communication/Workspace/Navigation/Tasks from SHORTCUT_CATEGORIES) with a press-to-record capture control per row. `dashboardKeyboardShortcuts` ownership moved here from `global-general` (save-split.ts GLOBAL_SECTION_KEYS + section-keys.ts) so exactly one section owns the key for save/reset. +*/ +export function KeyboardShortcutsSection({ scopeBanner, form, setForm }: KeyboardShortcutsSectionProps) { + const { t } = useTranslation("app"); + const shortcutValues = resolveDashboardKeyboardShortcuts(form.dashboardKeyboardShortcuts); + const shortcutValidationMessage = describeShortcutValidation(shortcutValues); + + const updateShortcut = (action: DashboardShortcutAction, value: string) => setForm((f) => ({ + ...f, + dashboardKeyboardShortcuts: { + ...resolveDashboardKeyboardShortcuts(f.dashboardKeyboardShortcuts), + [action]: value, + }, + })); + + return ( + <> + {scopeBanner} +

{t("settings.keyboardShortcuts.title", "Keyboard Shortcuts")}

+

{t("settings.keyboardShortcuts.hint", "Configure global dashboard shortcuts. Click Record and press a combination, or type one manually. Shortcuts are ignored while typing in inputs, editors, chat composers, and terminal fields. Leave blank to disable an action.")}

+
+ {SHORTCUT_CATEGORIES.map((category) => ( +
+
{t(`settings.keyboardShortcuts.category.${category.id}`, category.label)}
+ {category.actions.map((action) => { + const parsed = normalizeKeyboardShortcut(shortcutValues[action]); + const inputId = `dashboardShortcut-${action}`; + const hintId = `${inputId}Hint`; + return ( +
+ + updateShortcut(action, value)} + /> + + {parsed.valid + ? t("settings.keyboardShortcuts.rowHint", "Default: {{default}}. Leave blank to disable.", { default: DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS[action] }) + : parsed.error} + +
+ ); + })} +
+ ))} + {shortcutValidationMessage && ( + {shortcutValidationMessage} + )} +
+ + ); +} + +export default KeyboardShortcutsSection; diff --git a/packages/dashboard/app/components/settings/sections/ShortcutCaptureInput.tsx b/packages/dashboard/app/components/settings/sections/ShortcutCaptureInput.tsx new file mode 100644 index 0000000000..94ea657ee5 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/ShortcutCaptureInput.tsx @@ -0,0 +1,118 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { normalizeKeyboardShortcut } from "../../../utils/keyboardShortcuts"; + +export interface ShortcutCaptureInputProps { + id: string; + value: string; + defaultValue: string; + invalid: boolean; + describedById: string; + onChange: (value: string) => void; +} + +/* +FNXC:DashboardShortcuts 2026-07-04-00:00: +FN-7553 replaces "type the exact string" shortcut inputs with press-to-record capture. Clicking Record arms a one-shot document keydown listener that normalizes the next combination via the shared `normalizeKeyboardShortcut` parser (same parser the runtime hook and validation use, so what you record is guaranteed to match at runtime). Manual typing remains supported as a fallback for operators who already know the syntax. Escape while recording CANCELS recording rather than binding Escape \u2014 this keeps Escape permanently reserved for the dashboard's topmost-popup-close shortcut and gives the operator an obvious way to back out of recording without disabling the field. The recording surface carries `data-shortcuts-ignore="true"` so the global dashboard listener's editable-target guard always excludes it while focused/recording. +*/ +export function ShortcutCaptureInput({ id, value, defaultValue, invalid, describedById, onChange }: ShortcutCaptureInputProps) { + const { t } = useTranslation("app"); + const [recording, setRecording] = useState(false); + const cleanupRef = useRef<(() => void) | null>(null); + + const stopRecording = useCallback(() => { + cleanupRef.current?.(); + cleanupRef.current = null; + setRecording(false); + }, []); + + /* + FNXC:DashboardShortcuts 2026-07-04-01:30: + Recording arms a capture-phase document keydown listener that unconditionally + preventDefault/stopPropagation's the next keydown anywhere in the app. If the + operator closes Settings (or navigates to a different settings section) while + "Record" is still armed — without pressing Escape or a key — this component + unmounts but the listener previously stayed attached forever, hijacking the + very next keystroke app-wide. Clean up on unmount so an abandoned recording + session cannot leak a stray global listener. + */ + useEffect(() => () => { + cleanupRef.current?.(); + cleanupRef.current = null; + }, []); + + const startRecording = useCallback(() => { + if (typeof document === "undefined") return; + setRecording(true); + + const handleKeyDown = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.key === "Escape") { + stopRecording(); + return; + } + + // A bare modifier keydown (e.g. just pressing Ctrl) isn't a complete + // combination yet; keep recording until a non-modifier key arrives. + if (["Control", "Alt", "Shift", "Meta"].includes(event.key)) return; + + const parts: string[] = []; + if (event.ctrlKey) parts.push("Ctrl"); + if (event.altKey) parts.push("Alt"); + if (event.shiftKey) parts.push("Shift"); + if (event.metaKey) parts.push("Meta"); + parts.push(event.key === " " ? "Space" : event.key); + const captured = normalizeKeyboardShortcut(parts.join("+")); + if (captured.valid && !captured.disabled) { + onChange(captured.normalized); + } + stopRecording(); + }; + + document.addEventListener("keydown", handleKeyDown, { capture: true }); + cleanupRef.current = () => document.removeEventListener("keydown", handleKeyDown, { capture: true }); + }, [onChange, stopRecording]); + + return ( +
+ { + if (recording) event.currentTarget.blur(); + }} + onChange={(event) => onChange(event.target.value)} + /> + + +
+ ); +} + +export default ShortcutCaptureInput; diff --git a/packages/dashboard/app/components/settings/sections/__tests__/KeyboardShortcutsSection.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/KeyboardShortcutsSection.test.tsx new file mode 100644 index 0000000000..1b409a6899 --- /dev/null +++ b/packages/dashboard/app/components/settings/sections/__tests__/KeyboardShortcutsSection.test.tsx @@ -0,0 +1,113 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ShortcutCaptureInput } from "../ShortcutCaptureInput"; + +/* +FNXC:DashboardShortcuts 2026-07-04-00:00: +FN-7553 covers the press-to-record capture control in isolation before the +dedicated KeyboardShortcutsSection (which composes one row per action from +this control) lands in Step 4. Recording must fill the value from a real +keydown, must not leak the recorded combination to the document-level +dashboard shortcut listener, Escape must cancel (not bind), and Clear must +disable (blank) the value. +*/ +describe("ShortcutCaptureInput", () => { + it("fills the value from a recorded key combination", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /record/i })); + fireEvent.keyDown(document, { key: "k", ctrlKey: true }); + + expect(onChange).toHaveBeenCalledWith("Ctrl+K"); + }); + + it("cancels recording on Escape instead of binding Escape", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /record/i })); + fireEvent.keyDown(document, { key: "Escape" }); + + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: /record/i })).toBeInTheDocument(); + }); + + it("does not leak the recorded keystroke to a separate global document listener", () => { + const onChange = vi.fn(); + const globalListener = vi.fn(); + document.addEventListener("keydown", globalListener); + + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /record/i })); + const event = fireEvent.keyDown(document, { key: "k", ctrlKey: true, cancelable: true }); + + // The capture listener runs in the capture phase and calls + // stopPropagation, so a bubble-phase document listener (matching how the + // dashboard shortcut hook attaches) never observes the recorded keydown. + expect(globalListener).not.toHaveBeenCalled(); + expect(event).toBe(false); + + document.removeEventListener("keydown", globalListener); + }); + + it("clears (disables) the value via the Clear action", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /clear/i })); + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("supports manual typing as a fallback", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Alt+F" } }); + expect(onChange).toHaveBeenCalledWith("Alt+F"); + }); + + /* + FNXC:DashboardShortcuts 2026-07-04-01:30: + Regression for an abandoned-recording leak: unmounting while armed (e.g. the + operator closes Settings or switches sections without pressing a key) must + tear down the capture-phase document listener. Otherwise the very next + keydown anywhere in the app gets swallowed and silently fires a stale + onChange. + */ + it("tears down the capture listener on unmount while still recording", () => { + const onChange = vi.fn(); + const globalListener = vi.fn(); + document.addEventListener("keydown", globalListener); + + const { unmount } = render( + , + ); + fireEvent.click(screen.getByRole("button", { name: /record/i })); + unmount(); + + fireEvent.keyDown(document, { key: "k", ctrlKey: true, cancelable: true }); + + expect(onChange).not.toHaveBeenCalled(); + expect(globalListener).toHaveBeenCalledTimes(1); + + document.removeEventListener("keydown", globalListener); + }); + + it("marks the capture surface with data-shortcuts-ignore so the global guard excludes it", () => { + const { container } = render( + , + ); + expect(container.querySelector('[data-shortcuts-ignore="true"]')).toBeTruthy(); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx b/packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx index 07e12b750f..3efd431fdb 100644 --- a/packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx +++ b/packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx @@ -2,6 +2,15 @@ import { renderHook } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { useDashboardKeyboardShortcuts } from "../useDashboardKeyboardShortcuts"; +function baseHandlers() { + return { + openFiles: vi.fn(), + openSettings: vi.fn(), + openCommandCenter: vi.fn(), + openNewTask: vi.fn(), + }; +} + function press(init: KeyboardEventInit, target: Document | HTMLElement = document) { const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); target.dispatchEvent(event); @@ -11,7 +20,8 @@ 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(); - renderHook(() => useDashboardKeyboardShortcuts({ openQuickChat, toggleTerminal: vi.fn() })); + renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), openQuickChat, toggleTerminal: vi.fn() })); const event = press({ key: " " }); @@ -23,6 +33,7 @@ describe("useDashboardKeyboardShortcuts", () => { const openQuickChat = vi.fn(); const toggleTerminal = vi.fn(); renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), shortcuts: { quickChat: "", terminal: "Alt+T" }, openQuickChat, toggleTerminal, @@ -42,7 +53,8 @@ describe("useDashboardKeyboardShortcuts", () => { const input = document.createElement("input"); const button = document.createElement("button"); document.body.append(input, button); - renderHook(() => useDashboardKeyboardShortcuts({ openQuickChat, toggleTerminal })); + renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), openQuickChat, toggleTerminal })); input.focus(); press({ key: " " }, input); @@ -58,7 +70,8 @@ describe("useDashboardKeyboardShortcuts", () => { it("does not handle default-prevented nested menu events", () => { const openQuickChat = vi.fn(); - renderHook(() => useDashboardKeyboardShortcuts({ openQuickChat, toggleTerminal: vi.fn() })); + renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), openQuickChat, toggleTerminal: vi.fn() })); const event = new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }); Object.defineProperty(event, "defaultPrevented", { value: true }); @@ -70,6 +83,7 @@ describe("useDashboardKeyboardShortcuts", () => { it("delegates Escape to the topmost popup closer once", () => { const closeTopmostPopup = vi.fn(() => true); renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), openQuickChat: vi.fn(), toggleTerminal: vi.fn(), closeTopmostPopup, @@ -86,6 +100,7 @@ describe("useDashboardKeyboardShortcuts", () => { const input = document.createElement("input"); document.body.appendChild(input); renderHook(() => useDashboardKeyboardShortcuts({ + ...baseHandlers(), openQuickChat: vi.fn(), toggleTerminal: vi.fn(), closeTopmostPopup, @@ -99,3 +114,55 @@ describe("useDashboardKeyboardShortcuts", () => { input.remove(); }); }); + +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(); + renderHook(() => useDashboardKeyboardShortcuts({ + openQuickChat: vi.fn(), + toggleTerminal: vi.fn(), + openFiles, + openSettings, + openCommandCenter, + openNewTask, + })); + + const filesEvent = press({ key: "e", ctrlKey: true }); + expect(openFiles).toHaveBeenCalledTimes(1); + expect(filesEvent.defaultPrevented).toBe(true); + + press({ key: ",", ctrlKey: true }); + expect(openSettings).toHaveBeenCalledTimes(1); + + press({ key: "k", ctrlKey: true }); + expect(openCommandCenter).toHaveBeenCalledTimes(1); + + press({ key: "n", ctrlKey: true, shiftKey: true }); + expect(openNewTask).toHaveBeenCalledTimes(1); + }); + + it("no-ops new actions when their binding is disabled and ignores editable targets", () => { + const openFiles = vi.fn(); + const input = document.createElement("input"); + document.body.appendChild(input); + renderHook(() => useDashboardKeyboardShortcuts({ + shortcuts: { openFiles: "" }, + openQuickChat: vi.fn(), + toggleTerminal: vi.fn(), + openFiles, + openSettings: vi.fn(), + openCommandCenter: vi.fn(), + openNewTask: vi.fn(), + })); + + press({ key: "e", ctrlKey: true }); + expect(openFiles).not.toHaveBeenCalled(); + + input.focus(); + press({ key: "k", ctrlKey: true }, input); + input.remove(); + }); +}); diff --git a/packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts b/packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts index 9fdb881beb..46133fda47 100644 --- a/packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts +++ b/packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts @@ -11,6 +11,14 @@ export interface DashboardKeyboardShortcutHandlers { openQuickChat: () => 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; } export interface UseDashboardKeyboardShortcutsOptions extends DashboardKeyboardShortcutHandlers { @@ -28,6 +36,10 @@ export function useDashboardKeyboardShortcuts({ openQuickChat, toggleTerminal, closeTopmostPopup, + openFiles, + openSettings, + openCommandCenter, + openNewTask, }: UseDashboardKeyboardShortcutsOptions): void { useEffect(() => { if (!enabled || typeof document === "undefined") return; @@ -57,10 +69,34 @@ export function useDashboardKeyboardShortcuts({ if (shortcutMatchesEvent(resolved.terminal, event)) { event.preventDefault(); toggleTerminal(); + return; + } + + if (shortcutMatchesEvent(resolved.openFiles, event)) { + event.preventDefault(); + openFiles(); + return; + } + + if (shortcutMatchesEvent(resolved.openSettings, event)) { + event.preventDefault(); + openSettings(); + return; + } + + if (shortcutMatchesEvent(resolved.openCommandCenter, event)) { + event.preventDefault(); + openCommandCenter(); + return; + } + + if (shortcutMatchesEvent(resolved.newTask, event)) { + event.preventDefault(); + openNewTask(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [closeTopmostPopup, enabled, openQuickChat, shortcuts, toggleTerminal]); + }, [closeTopmostPopup, enabled, openCommandCenter, openFiles, openNewTask, openQuickChat, openSettings, shortcuts, toggleTerminal]); } diff --git a/packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts b/packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts index 0197dd958f..c21264bbc2 100644 --- a/packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts +++ b/packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS, + SHORTCUT_CATEGORIES, describeShortcutValidation, findShortcutConflicts, + getShortcutActionLabel, isEditableShortcutTarget, isTextEntryShortcutTarget, normalizeKeyboardShortcut, @@ -16,7 +18,14 @@ function keydown(init: KeyboardEventInit): KeyboardEvent { describe("keyboard shortcut utilities", () => { it("normalizes defaults, Space, Escape, modifiers, and disabled values", () => { - expect(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS).toEqual({ quickChat: "Space", terminal: "Ctrl+`" }); + expect(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS).toEqual({ + quickChat: "Space", + terminal: "Ctrl+`", + openFiles: "Ctrl+E", + openSettings: "Ctrl+,", + openCommandCenter: "Ctrl+K", + newTask: "Ctrl+Shift+N", + }); expect(normalizeKeyboardShortcut(" ").disabled).toBe(true); expect(normalizeKeyboardShortcut("Space")).toMatchObject({ valid: true, normalized: "Space", key: "Space" }); expect(normalizeKeyboardShortcut("Esc")).toMatchObject({ valid: true, normalized: "Escape", key: "Escape" }); @@ -51,7 +60,31 @@ describe("keyboard shortcut utilities", () => { it("resolves missing settings to documented defaults", () => { expect(resolveDashboardKeyboardShortcuts(undefined)).toEqual(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS); - expect(resolveDashboardKeyboardShortcuts({ quickChat: "", terminal: "Alt+T" })).toEqual({ quickChat: "", terminal: "Alt+T" }); + expect(resolveDashboardKeyboardShortcuts({ quickChat: "", terminal: "Alt+T" })).toEqual({ + ...DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS, + quickChat: "", + terminal: "Alt+T", + }); + }); + + it("covers every action in categories, labels, and default-conflict-free bindings (FN-7553)", () => { + const categorizedActions = SHORTCUT_CATEGORIES.flatMap((category) => category.actions).sort(); + const allActions = Object.keys(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS).sort(); + expect(categorizedActions).toEqual(allActions); + allActions.forEach((action) => { + expect(getShortcutActionLabel(action as keyof typeof DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS)).toBeTruthy(); + }); + expect(findShortcutConflicts(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS)).toEqual([]); + expect(describeShortcutValidation(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS)).toBeNull(); + }); + + it("resolves, matches, and validates each new FN-7553 action", () => { + expect(resolveDashboardKeyboardShortcuts({ openFiles: "" })).toMatchObject({ openFiles: "" }); + expect(shortcutMatchesEvent("Ctrl+E", keydown({ key: "e", ctrlKey: true }))).toBe(true); + expect(shortcutMatchesEvent("Ctrl+,", keydown({ key: ",", ctrlKey: true }))).toBe(true); + expect(shortcutMatchesEvent("Ctrl+K", keydown({ key: "k", ctrlKey: true }))).toBe(true); + expect(shortcutMatchesEvent("Ctrl+Shift+N", keydown({ key: "n", ctrlKey: true, shiftKey: true }))).toBe(true); + expect(describeShortcutValidation({ openFiles: "" })).toBeNull(); }); it("identifies editable and interactive targets that should not be captured by global shortcuts", () => { diff --git a/packages/dashboard/app/utils/keyboardShortcuts.ts b/packages/dashboard/app/utils/keyboardShortcuts.ts index e8d8d70fb1..64b21c08ef 100644 --- a/packages/dashboard/app/utils/keyboardShortcuts.ts +++ b/packages/dashboard/app/utils/keyboardShortcuts.ts @@ -1,17 +1,60 @@ -export type DashboardShortcutAction = "quickChat" | "terminal"; +/* +FNXC:DashboardShortcuts 2026-07-04-00:00: +FN-7553 adds four more configurable actions (openFiles, openSettings, openCommandCenter, newTask) on top of the FN-7494/FN-7507 base (quickChat, terminal). Every helper below (resolve/conflict/validate) derives its action list from DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS' keys instead of a hardcoded pair, so future actions only need an entry in the three maps below plus a category assignment. +*/ +export type DashboardShortcutAction = + | "quickChat" + | "terminal" + | "openFiles" + | "openSettings" + | "openCommandCenter" + | "newTask"; export type DashboardKeyboardShortcutMap = Partial>; +/* +FNXC:DashboardShortcuts 2026-07-04-00:00: +New defaults were chosen to avoid colliding with the existing Space/Ctrl+` bindings and with each other: Ctrl+E (open Files), Ctrl+, (open Settings, mirrors the common OS/app "preferences" comma-accelerator), Ctrl+K (open Command Center, the conventional command-palette binding), Ctrl+Shift+N (new Task, avoids the browser-reserved plain Ctrl+N "new window"). +*/ export const DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS: Required = { quickChat: "Space", terminal: "Ctrl+`", + openFiles: "Ctrl+E", + openSettings: "Ctrl+,", + openCommandCenter: "Ctrl+K", + newTask: "Ctrl+Shift+N", }; const ACTION_LABELS: Record = { quickChat: "Quick Chat", terminal: "Terminal", + openFiles: "Open Files", + openSettings: "Open Settings", + openCommandCenter: "Open Command Center", + newTask: "New Task", }; +export interface DashboardShortcutCategory { + id: string; + label: string; + actions: DashboardShortcutAction[]; +} + +/* +FNXC:DashboardShortcuts 2026-07-04-00:00: +Category grouping backs the dedicated Keyboard Shortcuts settings section (FN-7553) so actions render under headings instead of one flat list. This is UI-only metadata; resolution/conflict/validation logic never depends on category membership. +*/ +export const SHORTCUT_CATEGORIES: DashboardShortcutCategory[] = [ + { id: "communication", label: "Communication", actions: ["quickChat"] }, + { id: "workspace", label: "Workspace", actions: ["terminal", "openFiles"] }, + { id: "navigation", label: "Navigation", actions: ["openCommandCenter", "openSettings"] }, + { id: "tasks", label: "Tasks", actions: ["newTask"] }, +]; + +export function getShortcutActionLabel(action: DashboardShortcutAction): string { + return ACTION_LABELS[action]; +} + const MODIFIER_ORDER = ["Ctrl", "Alt", "Shift", "Meta"] as const; type ShortcutModifier = (typeof MODIFIER_ORDER)[number]; @@ -106,10 +149,11 @@ export function normalizeKeyboardShortcut(value: unknown): NormalizedShortcut { } export function resolveDashboardKeyboardShortcuts(settings: DashboardKeyboardShortcutMap | null | undefined): Required { - return { - quickChat: settings?.quickChat ?? DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS.quickChat, - terminal: settings?.terminal ?? DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS.terminal, - }; + const resolved = {} as Required; + (Object.keys(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS) as DashboardShortcutAction[]).forEach((action) => { + resolved[action] = settings?.[action] ?? DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS[action]; + }); + return resolved; } export function findShortcutConflicts(shortcuts: DashboardKeyboardShortcutMap): ShortcutConflict[] {