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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 11:38:42 -07:00
parent 68f5153e17
commit 2f23d2260d
19 changed files with 702 additions and 1 deletions

View File

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

View File

@@ -13,6 +13,20 @@ When Fusion detects a newer `@runfusion/fusion` release, the Settings modal foot
<!-- FNXC:SettingsSearchDocs 2026-07-04-00:00: Settings search is section-discovery, not a global command palette. Document that it filters visible Settings sections by section names and setting keywords while preserving feature-gated hidden sections. -->
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
<!-- 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:
- **Quick Chat:** `Space`
- **Terminal:** <kbd>Ctrl+`</kbd>
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.

View File

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

View File

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

View File

@@ -419,6 +419,16 @@ describe("settings key parity", () => {
expect((DEFAULT_PROJECT_SETTINGS as Record<string, unknown>).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<string, unknown>).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));

View File

@@ -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<string, unknown>).dashboardKeyboardShortcuts).toBeUndefined();
});
it("default settings do not include model fields", async () => {
const settings = await harness.store().getSettings();
expect(settings.defaultProvider).toBeUndefined();

View File

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

View File

@@ -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<string, string>;
/** 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.

View File

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

View File

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

View File

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

View File

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

@@ -80,6 +80,7 @@ const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"gitlabAuthTokenType",
"language",
"dismissModalsOnOutsideClick",
"dashboardKeyboardShortcuts",
"persistAgentToolOutput",
"persistAgentThinkingLogPermanent",
"persistAgentThinkingLogEphemeral",

View File

@@ -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<GlobalSettings, "gitlabEnabled" | "gitlabInstanceUrl" | "gitlabApiBaseUrl" | "gitlabAuthToken" | "gitlabAuthTokenType"> | 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}
<h4 className="settings-section-heading">{t("settings.globalGeneral.general", "General")}</h4>
@@ -64,6 +81,27 @@ 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,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();
});
});

View File

@@ -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<ProjectSettings["planApprovalMode"]>;
@@ -30,6 +31,7 @@ export interface UseAppSettingsResult {
taskDetailChatFirst: boolean;
quickChatButtonMode: QuickChatButtonMode;
quickChatCloseOnOutsideClick: boolean;
dashboardKeyboardShortcuts: Required<DashboardKeyboardShortcutMap>;
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<QuickChatButtonMode>("off");
const [quickChatCloseOnOutsideClick, setQuickChatCloseOnOutsideClick] = useState(true);
const [dashboardKeyboardShortcuts, setDashboardKeyboardShortcuts] = useState<Required<DashboardKeyboardShortcutMap>>(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,

View File

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

View File

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

View File

@@ -0,0 +1,175 @@
export type DashboardShortcutAction = "quickChat" | "terminal";
export type DashboardKeyboardShortcutMap = Partial<Record<DashboardShortcutAction, string>>;
export const DEFAULT_DASHBOARD_KEYBOARD_SHORTCUTS: Required<DashboardKeyboardShortcutMap> = {
quickChat: "Space",
terminal: "Ctrl+`",
};
const ACTION_LABELS: Record<DashboardShortcutAction, string> = {
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<ShortcutModifier, boolean>;
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<ShortcutModifier, boolean> {
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<DashboardKeyboardShortcutMap> {
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<string, DashboardShortcutAction[]>();
(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;
}