FN-7553: add dedicated keyboard shortcuts settings section

Adds a standalone Keyboard Shortcuts settings section with an easier-to-use capture input and support for more shortcut actions.
- Introduce a new KeyboardShortcutsSection with a dedicated ShortcutCaptureInput component for recording key combos
- Split keyboard-shortcut settings out of the general settings section into their own settings tab
- Extend the shortcut schema/types and useDashboardKeyboardShortcuts hook to support additional actions
- Update settings save-split, section-keys, and defaults/parity tests to cover the new section
- Update dashboard docs and add a changeset for the new settings section

Files changed:
 .changeset/fn-7553-keyboard-shortcuts-section.md   |   7 +
 docs/dashboard-guide.md                            |  22 ++-
 packages/core/src/__tests__/global-settings.test.ts     |   9 +-
 packages/core/src/__tests__/settings-defaults.test.ts   |   4 +
 packages/core/src/__tests__/settings-parity.test.ts     |   2 +-
 packages/core/src/settings-schema.ts               |   6 +-
 packages/core/src/types.ts                         |  12 ++
 packages/dashboard/app/App.tsx                     |  26 +++-
 packages/dashboard/app/__tests__/App.keyboard-shortcuts.test.tsx  |  47 ++++++-
 packages/dashboard/app/components/SettingsModal.css     |  56 ++++++--
 packages/dashboard/app/components/SettingsModal.tsx     |  30 +++-
 packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx |  63 ---------
 packages/dashboard/app/components/__tests__/SettingsModal.keyboardShortcuts.test.tsx |  156 +++++++++++++++++++++
 packages/dashboard/app/components/settings/__tests__/section-keys.test.ts |   1 +
 packages/dashboard/app/components/settings/save-split.ts |   6 +-
 packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx |  38 -----
 packages/dashboard/app/components/settings/sections/KeyboardShortcutsSection.tsx |  78 +++++++++++
 packages/dashboard/app/components/settings/sections/ShortcutCaptureInput.tsx |  118 ++++++++++++++++
 packages/dashboard/app/components/settings/sections/__tests__/KeyboardShortcutsSection.test.tsx |  113 +++++++++++++++
 packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx |  73 +++++++++-
 packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts |  38 ++++-
 packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts |  37 ++++-
 packages/dashboard/app/utils/keyboardShortcuts.ts  |  54 ++++++-
 23 files changed, 854 insertions(+), 142 deletions(-)

Fusion-Task-Id: FN-7553

Fusion-Task-Lineage: 51c1ad69-fbd3-45ce-8d2d-45d9962d7b76

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 20:07:54 -07:00
parent 3d58260e1a
commit f992e6aefa
23 changed files with 854 additions and 142 deletions

View File

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

View File

@@ -30,15 +30,25 @@ Both actions are irreversible; there is no undo after confirming. The dialog clo
## Keyboard shortcuts
<!-- FNXC:DashboardShortcuts 2026-07-04-00:00: Dashboard keyboard shortcuts are configurable global operator preferences. The docs must state the defaults, editable-field safety guard, duplicate/invalid save behavior, and one-popup Escape semantics so operators know why Space/Terminal/Escape act differently in text fields than on the board. -->
Open **Settings → General → Keyboard shortcuts** to configure dashboard-wide shortcut bindings. Defaults are:
<!--
FNXC:DashboardShortcuts 2026-07-04-00:00:
Dashboard keyboard shortcuts are configurable global operator preferences. The docs must state the defaults, editable-field safety guard, duplicate/invalid save behavior, and one-popup Escape semantics so operators know why Space/Terminal/Escape act differently in text fields than on the board.
- **Quick Chat:** `Space`
- **Terminal:** <kbd>Ctrl+`</kbd>
FNXC:DashboardShortcuts 2026-07-04-12:00:
FN-7553 promotes shortcuts to a dedicated Settings section (Keyboard Shortcuts, moved out of General), adds a press-to-record capture control, and adds four more configurable actions (Open Files, Open Settings, Open Command Center, New Task) grouped into categories. The docs must state the new location, the capture control's record/manual/clear/Escape-cancels behavior, and the full action list with defaults so operators can find and rebind every shortcut, not just the original two.
-->
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 (<kbd>Ctrl+`</kbd>), 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.

View File

@@ -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 () => {

View File

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

View File

@@ -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<string, unknown>).dashboardKeyboardShortcuts).toBeUndefined();
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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%;
}
}

View File

@@ -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 (
<KeyboardShortcutsSection
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
/>
);
case "global-models":
return (
<GlobalModelsSection

View File

@@ -608,69 +608,6 @@ describe("SettingsModal", () => {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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,

View File

@@ -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<typeof import("../../api")>(), {
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(<SettingsModal onClose={() => {}} 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(<SettingsModal onClose={() => {}} 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<string, unknown>;
expect((globalPayload.dashboardKeyboardShortcuts as Record<string, string>).openFiles).toBe("Alt+E");
if (mockUpdateSettings.mock.calls.length > 0) {
const projectPayload = mockUpdateSettings.mock.calls[0]?.[0] as Record<string, unknown>;
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(<SettingsModal onClose={() => {}} 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(<SettingsModal onClose={() => {}} 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<string, unknown>;
expect((globalPayload.dashboardKeyboardShortcuts as Record<string, string>).newTask).toBe("");
});
});

View File

@@ -19,6 +19,7 @@ const EXPECTED_KEY_OWNING_SECTIONS: Record<string, "global" | "project"> = {
notifications: "global",
experimental: "global",
"global-general": "global",
"keyboard-shortcuts": "global",
"global-models": "global",
"node-sync": "global",
"research-global": "global",

View File

@@ -106,7 +106,6 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"gitlabAuthTokenType",
"language",
"dismissModalsOnOutsideClick",
"dashboardKeyboardShortcuts",
"persistAgentToolOutput",
"persistAgentThinkingLogPermanent",
"persistAgentThinkingLogEphemeral",
@@ -115,6 +114,11 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"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",

View File

@@ -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<GlobalSettings, "gitlabEnabled" | "gitlabInstanceUrl" | "gitlabApiBaseUrl" | "gitlabAuthToken" | "gitlabAuthTokenType"> | 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}
<h4 className="settings-section-heading">{t("settings.globalGeneral.general", "General")}</h4>
@@ -82,27 +65,6 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalSetting
</div>
</details>
<CliBinaryPanel />
{/*
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.
*/}
<div className="form-group settings-keyboard-shortcuts" data-testid="keyboard-shortcuts-settings">
<h5 className="settings-section-heading">{t("settings.globalGeneral.keyboardShortcuts", "Keyboard shortcuts")}</h5>
<p className="settings-description">{t("settings.globalGeneral.keyboardShortcutsHint", "Configure global dashboard shortcuts. Shortcuts are ignored while typing in inputs, editors, chat composers, and terminal fields.")}</p>
<div className="settings-keyboard-shortcuts__grid">
<div className="form-group">
<label htmlFor="dashboardShortcutQuickChat">{t("settings.globalGeneral.quickChatShortcut", "Quick Chat shortcut")}</label>
<input id="dashboardShortcutQuickChat" className="input" value={shortcutValues.quickChat} placeholder={DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS.quickChat} aria-invalid={!quickChatShortcut.valid || undefined} aria-describedby="dashboardShortcutQuickChatHint" onChange={(e) => updateShortcut("quickChat", e.target.value)}/>
<small id="dashboardShortcutQuickChatHint">{quickChatShortcut.valid ? t("settings.globalGeneral.quickChatShortcutHint", "Default: Space. Leave blank to disable Quick Chat keyboard opening.") : quickChatShortcut.error}</small>
</div>
<div className="form-group">
<label htmlFor="dashboardShortcutTerminal">{t("settings.globalGeneral.terminalShortcut", "Terminal shortcut")}</label>
<input id="dashboardShortcutTerminal" className="input" value={shortcutValues.terminal} placeholder={DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS.terminal} aria-invalid={!terminalShortcut.valid || undefined} aria-describedby="dashboardShortcutTerminalHint" onChange={(e) => updateShortcut("terminal", e.target.value)}/>
<small id="dashboardShortcutTerminalHint">{terminalShortcut.valid ? t("settings.globalGeneral.terminalShortcutHint", "Default: Ctrl+`. Leave blank to disable Terminal keyboard opening.") : terminalShortcut.error}</small>
</div>
</div>
{shortcutValidationMessage && <small className="settings-description error-text" role="alert">{shortcutValidationMessage}</small>}
</div>
<div className="form-group">
<label htmlFor="dismissModalsOnOutsideClick" className="checkbox-label">
<input id="dismissModalsOnOutsideClick" type="checkbox" checked={form.dismissModalsOnOutsideClick === true} onChange={(e) => setForm((f) => ({ ...f, dismissModalsOnOutsideClick: e.target.checked }))}/>{t("settings.globalGeneral.dismissModalsByClickingOutside", " Dismiss modals by clicking outside ")}</label>

View File

@@ -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}
<h4 className="settings-section-heading">{t("settings.keyboardShortcuts.title", "Keyboard Shortcuts")}</h4>
<p className="settings-description">{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.")}</p>
<div className="form-group settings-keyboard-shortcuts" data-testid="keyboard-shortcuts-settings">
{SHORTCUT_CATEGORIES.map((category) => (
<div className="shortcut-category" key={category.id}>
<h5 className="settings-section-heading">{t(`settings.keyboardShortcuts.category.${category.id}`, category.label)}</h5>
{category.actions.map((action) => {
const parsed = normalizeKeyboardShortcut(shortcutValues[action]);
const inputId = `dashboardShortcut-${action}`;
const hintId = `${inputId}Hint`;
return (
<div className="shortcut-row" key={action}>
<label htmlFor={inputId}>{t(`settings.keyboardShortcuts.action.${action}`, getShortcutActionLabel(action))}</label>
<ShortcutCaptureInput
id={inputId}
value={shortcutValues[action]}
defaultValue={DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS[action]}
invalid={!parsed.valid}
describedById={hintId}
onChange={(value) => updateShortcut(action, value)}
/>
<small id={hintId}>
{parsed.valid
? t("settings.keyboardShortcuts.rowHint", "Default: {{default}}. Leave blank to disable.", { default: DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS[action] })
: parsed.error}
</small>
</div>
);
})}
</div>
))}
{shortcutValidationMessage && (
<small className="settings-description shortcut-conflict-banner" role="alert">{shortcutValidationMessage}</small>
)}
</div>
</>
);
}
export default KeyboardShortcutsSection;

View File

@@ -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 (
<div className="shortcut-capture" data-shortcuts-ignore="true">
<input
id={id}
className={`input shortcut-capture__input${invalid ? " shortcut-capture__input--invalid" : ""}`}
value={recording ? t("settings.keyboardShortcuts.recording", "Press keys… (Esc to cancel)") : value}
placeholder={defaultValue}
readOnly={recording}
aria-invalid={invalid || undefined}
aria-describedby={describedById}
data-shortcuts-ignore="true"
onFocus={(event) => {
if (recording) event.currentTarget.blur();
}}
onChange={(event) => onChange(event.target.value)}
/>
<button
type="button"
className={`btn btn-icon shortcut-capture__record${recording ? " shortcut-capture__record--active" : ""}`}
aria-pressed={recording}
title={recording ? t("settings.keyboardShortcuts.recordingTitle", "Recording… press Escape to cancel") : t("settings.keyboardShortcuts.recordTitle", "Record a new shortcut")}
onClick={() => (recording ? stopRecording() : startRecording())}
>
{recording ? t("settings.keyboardShortcuts.recordingLabel", "Recording…") : t("settings.keyboardShortcuts.recordLabel", "Record")}
</button>
<button
type="button"
className="btn btn-icon shortcut-capture__clear"
title={t("settings.keyboardShortcuts.clearTitle", "Disable this shortcut")}
onClick={() => {
if (recording) stopRecording();
onChange("");
}}
>
{t("settings.keyboardShortcuts.clearLabel", "Clear")}
</button>
</div>
);
}
export default ShortcutCaptureInput;

View File

@@ -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(
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={onChange} />,
);
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(
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={onChange} />,
);
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(
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={onChange} />,
);
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(
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: /clear/i }));
expect(onChange).toHaveBeenCalledWith("");
});
it("supports manual typing as a fallback", () => {
const onChange = vi.fn();
render(
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={onChange} />,
);
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(
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={onChange} />,
);
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(
<ShortcutCaptureInput id="test-shortcut" value="Ctrl+E" defaultValue="Ctrl+E" invalid={false} describedById="test-hint" onChange={vi.fn()} />,
);
expect(container.querySelector('[data-shortcuts-ignore="true"]')).toBeTruthy();
});
});

View File

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

View File

@@ -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]);
}

View File

@@ -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", () => {

View File

@@ -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<Record<DashboardShortcutAction, string>>;
/*
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<DashboardKeyboardShortcutMap> = {
quickChat: "Space",
terminal: "Ctrl+`",
openFiles: "Ctrl+E",
openSettings: "Ctrl+,",
openCommandCenter: "Ctrl+K",
newTask: "Ctrl+Shift+N",
};
const ACTION_LABELS: Record<DashboardShortcutAction, string> = {
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<DashboardKeyboardShortcutMap> {
return {
quickChat: settings?.quickChat ?? DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS.quickChat,
terminal: settings?.terminal ?? DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS.terminal,
};
const resolved = {} as Required<DashboardKeyboardShortcutMap>;
(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[] {