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.keyboardShortcutsHint", "Configure global dashboard shortcuts. Shortcuts are ignored while typing in inputs, editors, chat composers, and terminal fields.")}
{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.")}