From 2f23d2260d903acdf56c8fc8bcef56065cc2478c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 4 Jul 2026 11:38:42 -0700 Subject: [PATCH] FN-7494: add configurable dashboard shortcuts Add configurable dashboard keyboard shortcuts for opening Quick Chat and Terminal. - Add global settings defaults, schemas, persistence, and Settings UI controls for dashboard shortcuts. - Register safe document-level shortcut handling with editable-target guards and Escape popup dismissal. - Normalize shortcut strings, detect disabled/conflicting bindings, and document operator behavior. - Cover shortcut parsing, dashboard listener behavior, and settings persistence with tests. Files changed: .changeset/fn-7494-keyboard-shortcuts.md | 7 + docs/dashboard-guide.md | 14 ++ .../core/src/__tests__/global-settings.test.ts | 20 +++ .../core/src/__tests__/settings-defaults.test.ts | 10 ++ .../core/src/__tests__/settings-parity.test.ts | 10 ++ packages/core/src/__tests__/store-settings.test.ts | 10 ++ packages/core/src/settings-schema.ts | 8 + packages/core/src/types.ts | 12 ++ packages/dashboard/app/App.tsx | 52 ++++++ .../dashboard/app/components/SettingsModal.css | 21 +++ .../dashboard/app/components/SettingsModal.tsx | 11 ++ .../__tests__/SettingsModal.general.test.tsx | 63 ++++++++ .../app/components/settings/save-split.ts | 1 + .../settings/sections/GlobalGeneralSection.tsx | 38 +++++ .../useDashboardKeyboardShortcuts.test.tsx | 101 ++++++++++++ packages/dashboard/app/hooks/useAppSettings.ts | 8 +- .../app/hooks/useDashboardKeyboardShortcuts.ts | 66 ++++++++ .../app/utils/__tests__/keyboardShortcuts.test.ts | 76 +++++++++ packages/dashboard/app/utils/keyboardShortcuts.ts | 175 +++++++++++++++++++++ 19 files changed, 702 insertions(+), 1 deletion(-) Fusion-Task-Id: FN-7494 Fusion-Task-Lineage: 30445f4d-c0c5-4657-bd79-fc2acaf3c37d Co-authored-by: Fusion (runfusion.ai) --- .changeset/fn-7494-keyboard-shortcuts.md | 7 + docs/dashboard-guide.md | 14 ++ .../src/__tests__/global-settings.test.ts | 20 ++ .../src/__tests__/settings-defaults.test.ts | 10 + .../src/__tests__/settings-parity.test.ts | 10 + .../core/src/__tests__/store-settings.test.ts | 10 + packages/core/src/settings-schema.ts | 8 + packages/core/src/types.ts | 12 ++ packages/dashboard/app/App.tsx | 52 ++++++ .../app/components/SettingsModal.css | 21 +++ .../app/components/SettingsModal.tsx | 11 ++ .../__tests__/SettingsModal.general.test.tsx | 63 +++++++ .../app/components/settings/save-split.ts | 1 + .../sections/GlobalGeneralSection.tsx | 38 ++++ .../useDashboardKeyboardShortcuts.test.tsx | 101 ++++++++++ .../dashboard/app/hooks/useAppSettings.ts | 8 +- .../hooks/useDashboardKeyboardShortcuts.ts | 66 +++++++ .../utils/__tests__/keyboardShortcuts.test.ts | 76 ++++++++ .../dashboard/app/utils/keyboardShortcuts.ts | 175 ++++++++++++++++++ 19 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 .changeset/fn-7494-keyboard-shortcuts.md create mode 100644 packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx create mode 100644 packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts create mode 100644 packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts create mode 100644 packages/dashboard/app/utils/keyboardShortcuts.ts diff --git a/.changeset/fn-7494-keyboard-shortcuts.md b/.changeset/fn-7494-keyboard-shortcuts.md new file mode 100644 index 0000000000..2a8e2b18be --- /dev/null +++ b/.changeset/fn-7494-keyboard-shortcuts.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add configurable dashboard shortcuts for Quick Chat and Terminal. +category: feature +dev: Adds global shortcut settings, guarded runtime key handling, and Escape popup dismissal. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 95e9fbd375..665879dbe4 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -13,6 +13,20 @@ When Fusion detects a newer `@runfusion/fusion` release, the Settings modal foot Use **Search settings** at the top of Settings to find the section that contains a setting by name or keyword. The same search works in the Settings modal and embedded Settings page, filters both the desktop section list and mobile section picker, and only searches sections currently visible for enabled feature flags. +## Keyboard shortcuts + + +Open **Settings → General → Keyboard shortcuts** to configure dashboard-wide shortcut bindings. Defaults are: + +- **Quick Chat:** `Space` +- **Terminal:** Ctrl+` + +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. + +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. + +Press `Escape` to close the current/topmost dashboard popup. Floating Quick Chat and popped-out task windows 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. + ## Mobile/PWA app icons The installed mobile/PWA home-screen icons are generated from `packages/dashboard/app/public/logo.svg` by the desktop icon generator. When the Fusion brand mark changes, run `pnpm --filter @fusion/desktop generate:icons` so `packages/dashboard/app/public/icons/icon-192.png` and `packages/dashboard/app/public/icons/icon-512.png` stay aligned with the canonical logo. Also bump `CACHE_NAME` in `packages/dashboard/app/public/sw.js` whenever those icon assets change so installed PWAs refresh the cached launcher images. diff --git a/packages/core/src/__tests__/global-settings.test.ts b/packages/core/src/__tests__/global-settings.test.ts index 0b841a3920..11ea0a59ea 100644 --- a/packages/core/src/__tests__/global-settings.test.ts +++ b/packages/core/src/__tests__/global-settings.test.ts @@ -276,6 +276,26 @@ describe("GlobalSettingsStore", () => { expect(settings.testMode).toBe(true); }); + it("round-trips dashboard keyboard shortcuts including disabled values", async () => { + await store.init(); + + await store.updateSettings({ dashboardKeyboardShortcuts: { quickChat: "", terminal: "Alt+T" } }); + + const settings = await store.getSettings(); + expect(settings.dashboardKeyboardShortcuts).toEqual({ quickChat: "", terminal: "Alt+T" }); + }); + + it("restores dashboard keyboard shortcut defaults when cleared", async () => { + await store.init(); + await store.updateSettings({ dashboardKeyboardShortcuts: { quickChat: "Meta+K", terminal: "" } }); + + // @ts-expect-error - null is intentionally used to clear field (null-as-delete) + await store.updateSettings({ dashboardKeyboardShortcuts: null }); + + const settings = await store.getSettings(); + expect(settings.dashboardKeyboardShortcuts).toEqual({ quickChat: "Space", terminal: "Ctrl+`" }); + }); + it("creates directory if missing", async () => { const nested = join(dir, "auto", "create"); const nestedStore = new GlobalSettingsStore(nested); diff --git a/packages/core/src/__tests__/settings-defaults.test.ts b/packages/core/src/__tests__/settings-defaults.test.ts index 231ec34c73..3818942d00 100644 --- a/packages/core/src/__tests__/settings-defaults.test.ts +++ b/packages/core/src/__tests__/settings-defaults.test.ts @@ -28,6 +28,16 @@ describe("settings defaults invariants", () => { expect(DEFAULT_PROJECT_SETTINGS.worktreesDir).toBeUndefined(); }); + it("defaults dashboard keyboard shortcuts globally", () => { + expect(DEFAULT_GLOBAL_SETTINGS.dashboardKeyboardShortcuts).toEqual({ + quickChat: "Space", + terminal: "Ctrl+`", + }); + expect(GLOBAL_SETTINGS_KEYS).toContain("dashboardKeyboardShortcuts"); + expect(PROJECT_SETTINGS_KEYS).not.toContain("dashboardKeyboardShortcuts"); + expect("dashboardKeyboardShortcuts" in DEFAULT_PROJECT_SETTINGS).toBe(false); + }); + it("graduates workflow runtime defaults out of experimental flags", () => { expect(DEFAULT_GLOBAL_SETTINGS.experimentalFeatures.workflowColumns).toBeUndefined(); expect(DEFAULT_GLOBAL_SETTINGS.experimentalFeatures.workflowGraphExecutor).toBeUndefined(); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 8531ad9f6b..7b2d0c507d 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -419,6 +419,16 @@ describe("settings key parity", () => { expect((DEFAULT_PROJECT_SETTINGS as Record).experimentalFeatures).toBeUndefined(); }); + it("keeps dashboard keyboard shortcuts scoped to global settings only", () => { + const globalKeys = GLOBAL_SETTINGS_KEYS as readonly string[]; + const projectKeys = PROJECT_SETTINGS_KEYS as readonly string[]; + + expect(projectKeys).not.toContain("dashboardKeyboardShortcuts"); + expect(globalKeys).toContain("dashboardKeyboardShortcuts"); + expect(DEFAULT_GLOBAL_SETTINGS.dashboardKeyboardShortcuts).toEqual({ quickChat: "Space", terminal: "Ctrl+`" }); + expect((DEFAULT_PROJECT_SETTINGS as Record).dashboardKeyboardShortcuts).toBeUndefined(); + }); + it("only intentional shared keys appear in both global and project scopes", () => { const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]); const overlap = (GLOBAL_SETTINGS_KEYS as readonly string[]).filter((key) => projectKeySet.has(key)); diff --git a/packages/core/src/__tests__/store-settings.test.ts b/packages/core/src/__tests__/store-settings.test.ts index 37d94f430d..9e56c0f441 100644 --- a/packages/core/src/__tests__/store-settings.test.ts +++ b/packages/core/src/__tests__/store-settings.test.ts @@ -20,6 +20,16 @@ describe("TaskStore", () => { expect(settings.defaultModelId).toBe("claude-sonnet-4-5"); }); + it("persists dashboard keyboard shortcuts via global settings scope", async () => { + await harness.store().updateGlobalSettings({ dashboardKeyboardShortcuts: { quickChat: "Meta+K", terminal: "" } }); + const settings = await harness.store().getSettings(); + expect(settings.dashboardKeyboardShortcuts).toEqual({ quickChat: "Meta+K", terminal: "" }); + + const { global, project } = await harness.store().getSettingsByScope(); + expect(global.dashboardKeyboardShortcuts).toEqual({ quickChat: "Meta+K", terminal: "" }); + expect((project as Record).dashboardKeyboardShortcuts).toBeUndefined(); + }); + it("default settings do not include model fields", async () => { const settings = await harness.store().getSettings(); expect(settings.defaultProvider).toBeUndefined(); diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 6bb6ce397b..a950b20a60 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -76,6 +76,14 @@ export const DEFAULT_GLOBAL_SETTINGS = { shadcnCustomColors: undefined, 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. + */ + dashboardKeyboardShortcuts: { + quickChat: "Space", + terminal: "Ctrl+`", + }, + /* FNXC:ModalDismissal 2026-06-29-00:00: Fixed dashboard modals must ignore backdrop clicks by default so accidental outside taps do not discard in-progress form state. Operators can globally opt in to the legacy outside-click dismissal behavior. */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6b5a2a4cf2..0cf9f76c8b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3008,6 +3008,13 @@ export interface McpServersSettings { servers?: McpServerDefinition[]; } +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; +} + export interface GlobalSettings { /** Theme mode preference: dark, light, or system (follows OS). Default: "dark". */ themeMode?: ThemeMode; @@ -3017,6 +3024,11 @@ export interface GlobalSettings { shadcnCustomColors?: Record; /** Dashboard font size scale percentage. Bounded to 85-125. Default: 100. */ dashboardFontScalePct?: number; + /** + * FNXC:DashboardShortcuts 2026-07-04-00:00: + * Dashboard keyboard shortcuts are global operator preferences because they control browser UI affordances, not project execution policy. Defaults keep Space for Quick Chat and Ctrl+` for Terminal; blank values intentionally disable an action. + */ + dashboardKeyboardShortcuts?: DashboardKeyboardShortcuts; /** * FNXC:ModalDismissal 2026-06-29-00:00: * Modal backdrop dismissal is a global operator preference, not project policy. Default false keeps fixed modal overlays from closing on accidental outside clicks unless the operator opts in. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 1c5f81388f..d902f106c3 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -40,6 +40,7 @@ import { ConfirmDialogProvider } from "./hooks/useConfirm"; import { useTheme } from "./hooks/useTheme"; import { useModalManager, type DetailTaskOrigin, type DetailTaskTab } from "./hooks/useModalManager"; import { useAppSettings } from "./hooks/useAppSettings"; +import { useDashboardKeyboardShortcuts } from "./hooks/useDashboardKeyboardShortcuts"; import { ModalDismissPreferenceProvider } from "./hooks/useOverlayDismiss"; import { useDeepLink } from "./hooks/useDeepLink"; import { useFavorites } from "./hooks/useFavorites"; @@ -572,6 +573,7 @@ function AppInner() { taskDetailChatFirst, quickChatButtonMode, quickChatCloseOnOutsideClick, + dashboardKeyboardShortcuts, dismissModalsOnOutsideClick, maxTotalRetriesBeforeFail, prAuthAvailable, @@ -952,6 +954,56 @@ function AppInner() { } }, [closeTerminalWithNav, modalManager, pushNav]); + const closeTopmostPopupForShortcut = useCallback(() => { + /* + FNXC:DashboardShortcuts 2026-07-04-00:00: + Escape should close only one visible dashboard popup per key press. Floating user surfaces close before fixed app modals so a Quick Chat or task popout on top does not accidentally dismiss the underlying Terminal, Settings, or Task Detail modal. + */ + if (quickChatOpen) { + setQuickChatOpen(false); + return true; + } + const lastPoppedOutTask = poppedOutTasks[poppedOutTasks.length - 1]; + if (lastPoppedOutTask) { + closePoppedOutTask(lastPoppedOutTask.id); + return true; + } + if (modalManager.terminalOpen) { + closeTerminalWithNav(); + return true; + } + const modalClosers: Array<[boolean, () => void]> = [ + [modalManager.filesOpen, modalManager.closeFiles], + [modalManager.workflowEditorOpen, modalManager.closeWorkflowEditor], + [modalManager.gitManagerOpen, modalManager.closeGitManager], + [modalManager.activityLogOpen, modalManager.closeActivityLog], + [modalManager.scriptsOpen, modalManager.closeScripts], + [modalManager.agentsOpen, modalManager.closeAgents], + [modalManager.usageOpen, modalManager.closeUsage], + [modalManager.schedulesOpen, modalManager.closeSchedules], + [modalManager.githubImportOpen, modalManager.closeGitHubImport], + [modalManager.settingsOpen, modalManager.closeSettings], + [Boolean(modalManager.detailTask), modalManager.closeDetailTask], + [Boolean(modalManager.groupModalGroupId), modalManager.closeGroupModal], + [modalManager.isSubtaskOpen, modalManager.closeSubtask], + [modalManager.isPlanningOpen, modalManager.closePlanning], + [modalManager.newTaskModalOpen, modalManager.closeNewTask], + [modalManager.setupWizardOpen, modalManager.closeSetupWizard], + [modalManager.modelOnboardingOpen, modalManager.closeModelOnboarding], + ]; + const match = modalClosers.find(([open]) => open); + if (!match) return false; + match[1](); + return true; + }, [closePoppedOutTask, closeTerminalWithNav, modalManager, poppedOutTasks, quickChatOpen]); + + useDashboardKeyboardShortcuts({ + shortcuts: dashboardKeyboardShortcuts, + openQuickChat: () => setQuickChatOpen(true), + toggleTerminal: toggleTerminalWithNav, + closeTopmostPopup: closeTopmostPopupForShortcut, + }); + const openFilesWithNav = useCallback((workspace?: string, initialFile?: string | null) => { modalManager.openFiles(workspace, initialFile); pushNav({ type: "modal", close: modalManager.closeFiles }); diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index e627a5993d..c1f9450be8 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -2492,3 +2492,24 @@ The header row wraps so the badge drops below the heading on narrow widths inste padding: var(--space-sm); } } + +/* +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. +*/ +.settings-keyboard-shortcuts__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-md); +} + +.settings-keyboard-shortcuts__grid > .form-group { + margin-top: 0; + padding: 0; +} + +@media (max-width: 768px) { + .settings-keyboard-shortcuts__grid { + grid-template-columns: 1fr; + } +} diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 8fa9067710..a46bfdf957 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -10,6 +10,7 @@ import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } fro import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api"; import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api"; import { splitSettingsSave } from "./settings/save-split"; +import { describeShortcutValidation, normalizeKeyboardShortcut } from "../utils/keyboardShortcuts"; import type { SectionSaveHandler } from "./settings/sections/context"; import { AppearanceSection } from "./settings/sections/AppearanceSection"; import { ExperimentalSection } from "./settings/sections/ExperimentalSection"; @@ -2620,6 +2621,12 @@ export function SettingsModal({ } setResearchLimitError(null); + const shortcutValidationError = describeShortcutValidation(form.dashboardKeyboardShortcuts ?? {}); + if (shortcutValidationError) { + addToast(shortcutValidationError, "error"); + return; + } + setIsSaving(true); try { const normalizedWorktreeCopyFiles = normalizeWorktreeCopyFilesForSave(form.worktreeCopyFiles); @@ -2654,6 +2661,10 @@ 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, + }, gitlabEnabled: gitlabFormForSave.gitlabEnabled, gitlabInstanceUrl: gitlabFormForSave.gitlabInstanceUrl?.trim() || undefined, gitlabApiBaseUrl: gitlabFormForSave.gitlabApiBaseUrl?.trim() || undefined, diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx index c4a2388890..05b389086a 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.general.test.tsx @@ -608,6 +608,69 @@ 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; + 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/settings/save-split.ts b/packages/dashboard/app/components/settings/save-split.ts index 4693bb25ed..bc9d4dd249 100644 --- a/packages/dashboard/app/components/settings/save-split.ts +++ b/packages/dashboard/app/components/settings/save-split.ts @@ -80,6 +80,7 @@ const GLOBAL_SECTION_KEYS: Record> = { "gitlabAuthTokenType", "language", "dismissModalsOnOutsideClick", + "dashboardKeyboardShortcuts", "persistAgentToolOutput", "persistAgentThinkingLogPermanent", "persistAgentThinkingLogEphemeral", diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx index ffe6ac8700..3b9be8372c 100644 --- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx +++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx @@ -5,6 +5,12 @@ 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; @@ -16,6 +22,17 @@ 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")}

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

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

+
+
+ + updateShortcut("quickChat", e.target.value)}/> + {quickChatShortcut.valid ? t("settings.globalGeneral.quickChatShortcutHint", "Default: Space. Leave blank to disable Quick Chat keyboard opening.") : quickChatShortcut.error} +
+
+ + updateShortcut("terminal", e.target.value)}/> + {terminalShortcut.valid ? t("settings.globalGeneral.terminalShortcutHint", "Default: Ctrl+`. Leave blank to disable Terminal keyboard opening.") : terminalShortcut.error} +
+
+ {shortcutValidationMessage && {shortcutValidationMessage}} +
diff --git a/packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx b/packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx new file mode 100644 index 0000000000..07e12b750f --- /dev/null +++ b/packages/dashboard/app/hooks/__tests__/useDashboardKeyboardShortcuts.test.tsx @@ -0,0 +1,101 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useDashboardKeyboardShortcuts } from "../useDashboardKeyboardShortcuts"; + +function press(init: KeyboardEventInit, target: Document | HTMLElement = document) { + const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); + target.dispatchEvent(event); + return event; +} + +describe("useDashboardKeyboardShortcuts", () => { + it("opens Quick Chat with the default Space binding from document focus", () => { + const openQuickChat = vi.fn(); + renderHook(() => useDashboardKeyboardShortcuts({ openQuickChat, toggleTerminal: vi.fn() })); + + const event = press({ key: " " }); + + expect(openQuickChat).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + }); + + it("opens Terminal with custom shortcuts and honors disabled actions", () => { + const openQuickChat = vi.fn(); + const toggleTerminal = vi.fn(); + renderHook(() => useDashboardKeyboardShortcuts({ + shortcuts: { quickChat: "", terminal: "Alt+T" }, + openQuickChat, + toggleTerminal, + })); + + press({ key: " " }); + expect(openQuickChat).not.toHaveBeenCalled(); + + const event = press({ key: "t", altKey: true }); + expect(toggleTerminal).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + }); + + it("ignores shortcuts from editable and interactive targets", () => { + const openQuickChat = vi.fn(); + const toggleTerminal = vi.fn(); + const input = document.createElement("input"); + const button = document.createElement("button"); + document.body.append(input, button); + renderHook(() => useDashboardKeyboardShortcuts({ openQuickChat, toggleTerminal })); + + input.focus(); + press({ key: " " }, input); + press({ key: "`", ctrlKey: true }, input); + button.focus(); + press({ key: " " }, button); + + expect(openQuickChat).not.toHaveBeenCalled(); + expect(toggleTerminal).not.toHaveBeenCalled(); + input.remove(); + button.remove(); + }); + + it("does not handle default-prevented nested menu events", () => { + const openQuickChat = vi.fn(); + renderHook(() => useDashboardKeyboardShortcuts({ openQuickChat, toggleTerminal: vi.fn() })); + + const event = new KeyboardEvent("keydown", { key: " ", bubbles: true, cancelable: true }); + Object.defineProperty(event, "defaultPrevented", { value: true }); + document.dispatchEvent(event); + + expect(openQuickChat).not.toHaveBeenCalled(); + }); + + it("delegates Escape to the topmost popup closer once", () => { + const closeTopmostPopup = vi.fn(() => true); + renderHook(() => useDashboardKeyboardShortcuts({ + openQuickChat: vi.fn(), + toggleTerminal: vi.fn(), + closeTopmostPopup, + })); + + const event = press({ key: "Escape" }); + + expect(closeTopmostPopup).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(true); + }); + + it("does not globally close popups when Escape originates from text-entry targets", () => { + const closeTopmostPopup = vi.fn(() => true); + const input = document.createElement("input"); + document.body.appendChild(input); + renderHook(() => useDashboardKeyboardShortcuts({ + openQuickChat: vi.fn(), + toggleTerminal: vi.fn(), + closeTopmostPopup, + })); + + input.focus(); + const inputEvent = press({ key: "Escape" }, input); + + expect(closeTopmostPopup).not.toHaveBeenCalled(); + expect(inputEvent.defaultPrevented).toBe(false); + input.remove(); + }); +}); diff --git a/packages/dashboard/app/hooks/useAppSettings.ts b/packages/dashboard/app/hooks/useAppSettings.ts index 24149075af..5384ed5711 100644 --- a/packages/dashboard/app/hooks/useAppSettings.ts +++ b/packages/dashboard/app/hooks/useAppSettings.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { fetchConfig, fetchSettings, updateSettings, updateGlobalSettings } from "../api"; -import type { ProjectSettings } from "@fusion/core"; +import type { GlobalSettings, ProjectSettings } from "@fusion/core"; import { setAutoReloadEnabled } from "../versionCheck"; +import { DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS, resolveDashboardKeyboardShortcuts, type DashboardKeyboardShortcutMap } from "../utils/keyboardShortcuts"; export type QuickChatButtonMode = "floating" | "footer" | "off"; export type PlanApprovalMode = NonNullable; @@ -30,6 +31,7 @@ export interface UseAppSettingsResult { taskDetailChatFirst: boolean; quickChatButtonMode: QuickChatButtonMode; quickChatCloseOnOutsideClick: boolean; + dashboardKeyboardShortcuts: Required; dismissModalsOnOutsideClick: boolean; showQuickChatFAB: boolean; maxTotalRetriesBeforeFail: number; @@ -76,6 +78,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { const [taskDetailChatFirst, setTaskDetailChatFirst] = useState(false); const [quickChatButtonMode, setQuickChatButtonMode] = useState("off"); const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true); + const [dashboardKeyboardShortcuts, setDashboardKeyboardShortcuts] = useState>(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS); const [dismissModalsOnOutsideClick, setDismissModalsOnOutsideClick] = useState(false); const [showQuickChatFAB, setShowQuickChatFAB] = useState(false); const [maxTotalRetriesBeforeFail, setMaxTotalRetriesBeforeFail] = useState(25); @@ -140,6 +143,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { : "off"; setQuickChatButtonMode(nextQuickChatButtonMode); setQuickChatCloseOnOutsideClick(settings.quickChatCloseOnOutsideClick !== false); + setDashboardKeyboardShortcuts(resolveDashboardKeyboardShortcuts((settings as GlobalSettings).dashboardKeyboardShortcuts)); setDismissModalsOnOutsideClick(settings.dismissModalsOnOutsideClick === true); setShowQuickChatFAB(nextQuickChatButtonMode === "floating"); setMaxTotalRetriesBeforeFail(settings.maxTotalRetriesBeforeFail ?? 25); @@ -182,6 +186,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { setOpenMobileTasksInPopup(false); setTaskDetailChatFirst(false); setQuickChatCloseOnOutsideClick(true); + setDashboardKeyboardShortcuts(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS); setDismissModalsOnOutsideClick(false); setPlanApprovalMode("workflow"); setTodosEnabled(true); @@ -313,6 +318,7 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult { taskDetailChatFirst, quickChatButtonMode, quickChatCloseOnOutsideClick, + dashboardKeyboardShortcuts, dismissModalsOnOutsideClick, showQuickChatFAB, maxTotalRetriesBeforeFail, diff --git a/packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts b/packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts new file mode 100644 index 0000000000..9fdb881beb --- /dev/null +++ b/packages/dashboard/app/hooks/useDashboardKeyboardShortcuts.ts @@ -0,0 +1,66 @@ +import { useEffect } from "react"; +import { + isEditableShortcutTarget, + isTextEntryShortcutTarget, + resolveDashboardKeyboardShortcuts, + shortcutMatchesEvent, + type DashboardKeyboardShortcutMap, +} from "../utils/keyboardShortcuts"; + +export interface DashboardKeyboardShortcutHandlers { + openQuickChat: () => void; + toggleTerminal: () => void; + closeTopmostPopup?: () => boolean; +} + +export interface UseDashboardKeyboardShortcutsOptions extends DashboardKeyboardShortcutHandlers { + shortcuts?: DashboardKeyboardShortcutMap | null; + enabled?: boolean; +} + +/* +FNXC:DashboardShortcuts 2026-07-04-00:00: +The global dashboard listener only handles document-level shortcuts after target/editable guards and default-prevented checks. This lets chat composers, task editors, Settings inputs, terminal fields, and nested widgets keep ownership of typed keys and Escape while the dashboard still opens high-value interfaces from page focus. +*/ +export function useDashboardKeyboardShortcuts({ + shortcuts, + enabled = true, + openQuickChat, + toggleTerminal, + closeTopmostPopup, +}: UseDashboardKeyboardShortcutsOptions): void { + useEffect(() => { + if (!enabled || typeof document === "undefined") return; + const resolved = resolveDashboardKeyboardShortcuts(shortcuts); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + + if (event.key === "Escape") { + if (isTextEntryShortcutTarget(event.target)) return; + if (closeTopmostPopup?.()) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + } + return; + } + + if (isEditableShortcutTarget(event.target)) return; + + if (shortcutMatchesEvent(resolved.quickChat, event)) { + event.preventDefault(); + openQuickChat(); + return; + } + + if (shortcutMatchesEvent(resolved.terminal, event)) { + event.preventDefault(); + toggleTerminal(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [closeTopmostPopup, enabled, openQuickChat, shortcuts, toggleTerminal]); +} diff --git a/packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts b/packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts new file mode 100644 index 0000000000..5841d8d223 --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/keyboardShortcuts.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS, + describeShortcutValidation, + findShortcutConflicts, + isEditableShortcutTarget, + isTextEntryShortcutTarget, + normalizeKeyboardShortcut, + resolveDashboardKeyboardShortcuts, + shortcutMatchesEvent, +} from "../keyboardShortcuts"; + +function keydown(init: KeyboardEventInit): KeyboardEvent { + return new KeyboardEvent("keydown", init); +} + +describe("keyboard shortcut utilities", () => { + it("normalizes defaults, Space, Escape, modifiers, and disabled values", () => { + expect(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS).toEqual({ quickChat: "Space", terminal: "Ctrl+`" }); + 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" }); + expect(normalizeKeyboardShortcut("cmd+k")).toMatchObject({ valid: true, normalized: "Meta+K", key: "K" }); + expect(normalizeKeyboardShortcut("Control + Shift + p")).toMatchObject({ valid: true, normalized: "Ctrl+Shift+P", key: "P" }); + }); + + it("rejects invalid strings and duplicate modifiers", () => { + expect(normalizeKeyboardShortcut("Ctrl+Alt").valid).toBe(false); + expect(normalizeKeyboardShortcut("Ctrl+Ctrl+K").valid).toBe(false); + expect(normalizeKeyboardShortcut("Ctrl+K+P").valid).toBe(false); + expect(describeShortcutValidation({ quickChat: "Ctrl+Alt", terminal: "Ctrl+`" })).toContain("Quick Chat shortcut is invalid"); + }); + + it("detects duplicate populated shortcut combinations while ignoring disabled actions", () => { + expect(findShortcutConflicts({ quickChat: "Ctrl+K", terminal: "Control+k" })).toEqual([ + { shortcut: "Ctrl+K", actions: ["quickChat", "terminal"], labels: ["Quick Chat", "Terminal"] }, + ]); + expect(findShortcutConflicts({ quickChat: "", terminal: "" })).toEqual([]); + expect(describeShortcutValidation({ quickChat: "Ctrl+K", terminal: "Control+k" })).toContain("both use Ctrl+K"); + }); + + it("matches printable, Space, Escape, and modifier keydown events", () => { + expect(shortcutMatchesEvent("Space", keydown({ key: " " }))).toBe(true); + expect(shortcutMatchesEvent("Escape", keydown({ key: "Escape" }))).toBe(true); + expect(shortcutMatchesEvent("Ctrl+`", keydown({ key: "`", ctrlKey: true }))).toBe(true); + expect(shortcutMatchesEvent("Meta+K", keydown({ key: "k", metaKey: true }))).toBe(true); + expect(shortcutMatchesEvent("Ctrl+K", keydown({ key: "k" }))).toBe(false); + expect(shortcutMatchesEvent("", keydown({ key: " " }))).toBe(false); + }); + + 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" }); + }); + + it("identifies editable and interactive targets that should not be captured by global shortcuts", () => { + const input = document.createElement("input"); + input.type = "text"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + const editor = document.createElement("div"); + editor.setAttribute("contenteditable", "true"); + const ignored = document.createElement("div"); + ignored.setAttribute("data-shortcuts-ignore", "true"); + + expect(isEditableShortcutTarget(input)).toBe(true); + expect(isEditableShortcutTarget(checkbox)).toBe(true); + expect(isEditableShortcutTarget(editor)).toBe(true); + expect(isEditableShortcutTarget(ignored)).toBe(true); + expect(isEditableShortcutTarget(document.createElement("button"))).toBe(true); + expect(isEditableShortcutTarget(document.createElement("div"))).toBe(false); + expect(isTextEntryShortcutTarget(input)).toBe(true); + expect(isTextEntryShortcutTarget(checkbox)).toBe(false); + expect(isTextEntryShortcutTarget(document.createElement("button"))).toBe(false); + }); +}); diff --git a/packages/dashboard/app/utils/keyboardShortcuts.ts b/packages/dashboard/app/utils/keyboardShortcuts.ts new file mode 100644 index 0000000000..e8d8d70fb1 --- /dev/null +++ b/packages/dashboard/app/utils/keyboardShortcuts.ts @@ -0,0 +1,175 @@ +export type DashboardShortcutAction = "quickChat" | "terminal"; + +export type DashboardKeyboardShortcutMap = Partial>; + +export const DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS: Required = { + quickChat: "Space", + terminal: "Ctrl+`", +}; + +const ACTION_LABELS: Record = { + quickChat: "Quick Chat", + terminal: "Terminal", +}; + +const MODIFIER_ORDER = ["Ctrl", "Alt", "Shift", "Meta"] as const; +type ShortcutModifier = (typeof MODIFIER_ORDER)[number]; + +export interface NormalizedShortcut { + input: string; + normalized: string; + display: string; + key: string; + modifiers: Record; + disabled: boolean; + valid: boolean; + error?: string; +} + +export interface ShortcutConflict { + shortcut: string; + actions: DashboardShortcutAction[]; + labels: string[]; +} + +function titleKey(key: string): string { + if (key === " ") return "Space"; + if (key.length === 1) return key === "`" ? "`" : key.toUpperCase(); + const lower = key.toLowerCase(); + if (lower === "space" || lower === "spacebar") return "Space"; + if (lower === "esc") return "Escape"; + if (lower === "arrowup") return "ArrowUp"; + if (lower === "arrowdown") return "ArrowDown"; + if (lower === "arrowleft") return "ArrowLeft"; + if (lower === "arrowright") return "ArrowRight"; + return key.slice(0, 1).toUpperCase() + key.slice(1); +} + +function emptyModifiers(): Record { + return { Ctrl: false, Alt: false, Shift: false, Meta: false }; +} + +function modifierForToken(token: string): ShortcutModifier | null { + const lower = token.toLowerCase(); + if (lower === "ctrl" || lower === "control" || lower === "cmdorctrl" || lower === "mod") return "Ctrl"; + if (lower === "alt" || lower === "option") return "Alt"; + if (lower === "shift") return "Shift"; + if (lower === "meta" || lower === "cmd" || lower === "command" || lower === "super") return "Meta"; + return null; +} + +/* +FNXC:DashboardShortcuts 2026-07-04-00:00: +Shortcut parsing is shared by Settings validation and the App runtime so persisted labels, duplicate detection, and keydown matching cannot diverge. Empty strings are valid disabled bindings; unsupported strings are invalid and must not install runtime listeners. +*/ +export function normalizeKeyboardShortcut(value: unknown): NormalizedShortcut { + const input = typeof value === "string" ? value : ""; + const trimmed = input.trim(); + if (!trimmed) { + return { input, normalized: "", display: "Disabled", key: "", modifiers: emptyModifiers(), disabled: true, valid: true }; + } + + const parts = trimmed.split("+").map((part) => part.trim()).filter(Boolean); + if (parts.length === 0) { + return { input, normalized: "", display: "Disabled", key: "", modifiers: emptyModifiers(), disabled: true, valid: true }; + } + + const modifiers = emptyModifiers(); + let key = ""; + for (const part of parts) { + const modifier = modifierForToken(part); + if (modifier) { + if (modifiers[modifier]) { + return { input, normalized: trimmed, display: trimmed, key: "", modifiers, disabled: false, valid: false, error: `Duplicate modifier ${modifier}.` }; + } + modifiers[modifier] = true; + continue; + } + if (key) { + return { input, normalized: trimmed, display: trimmed, key: "", modifiers, disabled: false, valid: false, error: "Use one non-modifier key per shortcut." }; + } + key = titleKey(part); + } + + if (!key) { + return { input, normalized: trimmed, display: trimmed, key: "", modifiers, disabled: false, valid: false, error: "Add a key after the modifier." }; + } + + if (key.length !== 1 && !/^(Space|Escape|Enter|Tab|Backspace|Delete|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|F\d{1,2})$/.test(key)) { + return { input, normalized: trimmed, display: trimmed, key, modifiers, disabled: false, valid: false, error: "Use a printable key, Space, Escape, arrows, Tab, Enter, Delete, Backspace, or F1-F12." }; + } + + const normalizedParts: string[] = MODIFIER_ORDER.filter((modifier) => modifiers[modifier]); + normalizedParts.push(key); + const normalized = normalizedParts.join("+"); + return { input, normalized, display: normalized, key, modifiers, disabled: false, valid: true }; +} + +export function resolveDashboardKeyboardShortcuts(settings: DashboardKeyboardShortcutMap | null | undefined): Required { + return { + quickChat: settings?.quickChat ?? DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS.quickChat, + terminal: settings?.terminal ?? DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS.terminal, + }; +} + +export function findShortcutConflicts(shortcuts: DashboardKeyboardShortcutMap): ShortcutConflict[] { + const seen = new Map(); + (Object.keys(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS) as DashboardShortcutAction[]).forEach((action) => { + const parsed = normalizeKeyboardShortcut(shortcuts[action] ?? ""); + if (!parsed.valid || parsed.disabled) return; + const actions = seen.get(parsed.normalized) ?? []; + actions.push(action); + seen.set(parsed.normalized, actions); + }); + + return Array.from(seen.entries()) + .filter(([, actions]) => actions.length > 1) + .map(([shortcut, actions]) => ({ shortcut, actions, labels: actions.map((action) => ACTION_LABELS[action]) })); +} + +/* +FNXC:DashboardShortcuts 2026-07-04-00:00: +Space and interface-opening shortcuts must ignore both text-entry and interactive controls so they do not steal typing or button/menu activation. Escape uses the narrower text-entry guard so document-level popup dismissal still works from ordinary controls while editors and terminal input keep ownership. +*/ +const TEXT_ENTRY_SHORTCUT_TARGET_SELECTOR = "input, textarea, select, [contenteditable=''], [contenteditable='true'], [role='textbox'], [data-shortcuts-ignore='true']"; +const INTERACTIVE_SHORTCUT_TARGET_SELECTOR = `${TEXT_ENTRY_SHORTCUT_TARGET_SELECTOR}, button, a[href], summary, [role='button'], [role='link'], [role='checkbox'], [role='radio'], [role='switch'], [role='tab'], [role='menuitem']`; + +function closestShortcutTarget(target: EventTarget | null, selector: string): Element | null { + if (typeof Element === "undefined" || !(target instanceof Element)) return null; + return target.closest(selector); +} + +export function isTextEntryShortcutTarget(target: EventTarget | null): boolean { + const editable = closestShortcutTarget(target, TEXT_ENTRY_SHORTCUT_TARGET_SELECTOR); + if (!editable) return false; + if (editable instanceof HTMLInputElement) { + const type = editable.type.toLowerCase(); + return !["button", "checkbox", "color", "file", "image", "radio", "range", "reset", "submit"].includes(type); + } + return true; +} + +export function isEditableShortcutTarget(target: EventTarget | null): boolean { + return Boolean(closestShortcutTarget(target, INTERACTIVE_SHORTCUT_TARGET_SELECTOR)); +} + +export function shortcutMatchesEvent(shortcut: string | undefined, event: KeyboardEvent): boolean { + const parsed = normalizeKeyboardShortcut(shortcut ?? ""); + if (!parsed.valid || parsed.disabled) return false; + const eventKey = event.key === " " ? "Space" : titleKey(event.key); + return eventKey === parsed.key + && event.ctrlKey === parsed.modifiers.Ctrl + && event.altKey === parsed.modifiers.Alt + && event.shiftKey === parsed.modifiers.Shift + && event.metaKey === parsed.modifiers.Meta; +} + +export function describeShortcutValidation(shortcuts: DashboardKeyboardShortcutMap): string | null { + const invalid = (Object.keys(DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS) as DashboardShortcutAction[]) + .map((action) => ({ action, parsed: normalizeKeyboardShortcut(shortcuts[action] ?? "") })) + .find(({ parsed }) => !parsed.valid); + if (invalid) return `${ACTION_LABELS[invalid.action]} shortcut is invalid: ${invalid.parsed.error ?? "Use a supported key combination."}`; + const conflict = findShortcutConflicts(shortcuts)[0]; + if (conflict) return `${conflict.labels.join(" and ")} both use ${conflict.shortcut}. Choose unique shortcuts or disable one.`; + return null; +}