This is an automated email from the ASF dual-hosted git repository.

pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 70bb308b19a Add keyboard shortcut help dialog to discover available 
shortcuts (? key) (#68200)
70bb308b19a is described below

commit 70bb308b19acbae0f04075063f8546eb5bc0e200
Author: Yeonguk Choo <[email protected]>
AuthorDate: Fri Jun 26 01:00:55 2026 +0900

    Add keyboard shortcut help dialog to discover available shortcuts (? key) 
(#68200)
    
    * feat: keyboard shortcuts help dialog (press ?)
    
    * remove unnecessary useCallback and useMemo hooks
    
    * drop grid keyboard shortcut tooltip
    
    * centralize shortcut definitions in a single catalog
    
    * use nesting to reuse concept translation keys
    
    * register code viewer fullscreen shortcut in the catalog
---
 .../airflow/ui/public/i18n/locales/en/common.json  |  41 +++++
 .../src/airflow/ui/public/i18n/locales/en/dag.json |   4 +-
 .../ui/src/components/Clear/Run/ClearRunButton.tsx |  13 +-
 .../Clear/TaskInstance/ClearTaskInstanceButton.tsx |  13 +-
 .../FilterBar/filters/TextSearchFilter.tsx         |  13 +-
 .../airflow/ui/src/components/GraphTaskFilters.tsx |   9 +-
 .../KeyboardShortcuts/KeyboardShortcutsModal.tsx   | 106 ++++++++++++
 .../KeyboardShortcuts/formatShortcutCombo.test.ts  |  41 +++++
 .../KeyboardShortcuts/formatShortcutCombo.ts       |  66 ++++++++
 .../ui/src/components/KeyboardShortcuts/index.ts   |  20 +++
 .../src/components/MarkAs/Run/MarkRunAsButton.tsx  |  23 +--
 .../MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx     |  23 +--
 .../TaskInstance/MarkTaskInstanceAsButton.tsx      |  23 +--
 .../src/airflow/ui/src/components/SearchBar.tsx    |  13 +-
 .../src/components/SearchDags/SearchDagsButton.tsx |  15 +-
 .../ui/src/context/keyboardShortcuts/Context.ts    |  58 +++++++
 .../keyboardShortcuts/ShortcutRegistryProvider.tsx |  45 ++++++
 .../ui/src/context/keyboardShortcuts/index.ts      |  22 +++
 .../ui/src/context/keyboardShortcuts/shortcuts.ts  | 179 +++++++++++++++++++++
 .../keyboardShortcuts/useShortcutRegistry.ts       |  23 +++
 .../src/hooks/navigation/useKeyboardNavigation.ts  |  19 ++-
 .../src/airflow/ui/src/hooks/useShortcut.test.tsx  | 111 +++++++++++++
 .../src/airflow/ui/src/hooks/useShortcut.ts        |  76 +++++++++
 .../src/airflow/ui/src/layouts/BaseLayout.tsx      |   2 +
 .../ui/src/layouts/Details/PanelButtons.tsx        |  26 +--
 airflow-core/src/airflow/ui/src/main.tsx           |   5 +-
 .../src/airflow/ui/src/pages/Dag/Code/Code.tsx     |  14 +-
 .../ui/src/pages/Dag/Overview/FailedLogs.tsx       |   8 +-
 .../src/pages/TaskInstance/Logs/LogSearchInput.tsx |  13 +-
 .../ui/src/pages/TaskInstance/Logs/Logs.tsx        |  33 +++-
 .../src/pages/TaskInstance/Logs/TaskLogContent.tsx |  15 +-
 31 files changed, 958 insertions(+), 114 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 7b5f37c9cfd..d8e490fa451 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -226,6 +226,47 @@
   },
   "selectLanguage": "Select Language",
   "selected": "Selected",
+  "shortcuts": {
+    "categories": {
+      "code": "Code",
+      "dagView": "Dag View",
+      "filters": "Filters",
+      "global": "Global",
+      "logs": "Logs",
+      "navigation": "Navigation",
+      "runActions": "Run & Task Actions",
+      "search": "Search"
+    },
+    "descriptions": {
+      "clearRun": "Clear $t(dagRun_one)",
+      "clearTaskInstance": "Clear $t(taskInstance_one)",
+      "downloadLogs": "Download logs",
+      "focusFilterSearch": "Focus filter search",
+      "focusLogSearch": "Search logs",
+      "focusSearch": "Focus search",
+      "markRunFailed": "Mark $t(dagRun_one) as failed",
+      "markRunSuccess": "Mark $t(dagRun_one) as success",
+      "markTaskFailed": "Mark $t(task_one) as failed",
+      "markTaskGroupFailed": "Mark $t(taskGroup_one) as failed",
+      "markTaskGroupSuccess": "Mark $t(taskGroup_one) as success",
+      "markTaskSuccess": "Mark $t(task_one) as success",
+      "navigateTasks": "Navigate $t(task_other)",
+      "openGraphFilters": "Open graph filters",
+      "scrollBottom": "Scroll to bottom",
+      "scrollTop": "Scroll to top",
+      "searchDags": "Search $t(dag_other)",
+      "showHelp": "Show $t(shortcuts.title)",
+      "toggleExpand": "Expand or collapse all groups",
+      "toggleFullscreen": "Toggle fullscreen",
+      "toggleGraphGrid": "Toggle graph / grid view",
+      "toggleSource": "Toggle source",
+      "toggleTaskGroup": "Expand or collapse $t(taskGroup_one)",
+      "toggleTimestamp": "Toggle timestamps",
+      "toggleWrap": "Toggle $t(wrap.wrap)"
+    },
+    "empty": "No keyboard shortcuts are available on this page.",
+    "title": "Keyboard Shortcuts"
+  },
   "showDetailsPanel": "Show Details Panel",
   "signedInAs": "Signed in as",
   "source": {
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index b51add8d5ec..ab1e332ebae 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -103,9 +103,7 @@
     "warning": "WARNING"
   },
   "navigation": {
-    "navigation": "Navigation: Shift+{{arrow}}",
-    "openGraphFilters": "Task Filters: Ctrl+Shift+F",
-    "toggleGroup": "Toggle group: Space"
+    "openGraphFilters": "Task Filters: Ctrl+Shift+F"
   },
   "notFound": {
     "back": "Go Back",
diff --git 
a/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx 
b/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx
index 6cbc3aa08b0..c4c040a1c1b 100644
--- a/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunButton.tsx
@@ -17,12 +17,13 @@
  * under the License.
  */
 import { useDisclosure } from "@chakra-ui/react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { CgRedo } from "react-icons/cg";
 
 import type { DAGRunResponse } from "openapi/requests/types.gen";
 import { IconButton } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 
 import ClearRunDialog from "./ClearRunDialog";
 
@@ -35,13 +36,13 @@ const ClearRunButton = ({ dagRun, isHotkeyEnabled = false 
}: Props) => {
   const { onClose, onOpen, open } = useDisclosure();
   const { t: translate } = useTranslation();
 
-  useHotkeys(
-    "shift+c",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.runActions.clearRun,
+    callback: () => {
       onOpen();
     },
-    { enabled: isHotkeyEnabled },
-  );
+    options: { enabled: isHotkeyEnabled },
+  });
 
   return (
     <>
diff --git 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
index 84fc78c1e68..07bfc56f80c 100644
--- 
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceButton.tsx
@@ -17,13 +17,14 @@
  * under the License.
  */
 import { useDisclosure } from "@chakra-ui/react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { CgRedo } from "react-icons/cg";
 
 import type { LightGridTaskInstanceSummary, TaskInstanceResponse } from 
"openapi/requests/types.gen";
 import { ClearGroupTaskInstanceDialog } from 
"src/components/Clear/TaskInstance/ClearGroupTaskInstanceDialog";
 import { IconButton } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 
 import ClearTaskInstanceDialog from "./ClearTaskInstanceDialog";
 
@@ -58,17 +59,17 @@ const ClearTaskInstanceButton = ({
 
   const selectedInstance = taskInstance ?? groupTaskInstance;
 
-  useHotkeys(
-    "shift+c",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.runActions.clearTaskInstance,
+    callback: () => {
       if (onOpen && selectedInstance) {
         onOpen(selectedInstance);
       } else {
         onOpenInternal();
       }
     },
-    { enabled: isHotkeyEnabled },
-  );
+    options: { enabled: isHotkeyEnabled },
+  });
 
   const label = allMapped
     ? isHotkeyEnabled
diff --git 
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
 
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
index 32bfa292318..09f1c5d3ee4 100644
--- 
a/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/FilterBar/filters/TextSearchFilter.tsx
@@ -18,11 +18,12 @@
  */
 import { HStack } from "@chakra-ui/react";
 import { useRef } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { LuRegex } from "react-icons/lu";
 
 import { AdvancedSearchToggle } from "src/components/AdvancedSearchToggle";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
 import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
+import { useShortcut } from "src/hooks/useShortcut";
 
 import { InputWithAddon } from "../../ui";
 import { FilterPill } from "../FilterPill";
@@ -42,15 +43,15 @@ export const TextSearchFilter = ({ filter, onChange, 
onRemove }: FilterPluginPro
     onChange(newValue || undefined);
   };
 
-  useHotkeys(
-    "mod+k",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.search.focusFilterSearch,
+    callback: () => {
       if (!filter.config.hotkeyDisabled) {
         hotkeyInputRef.current?.focus();
       }
     },
-    { enabled: !filter.config.hotkeyDisabled, preventDefault: true },
-  );
+    options: { enabled: !filter.config.hotkeyDisabled, preventDefault: true },
+  });
 
   const isAdvanced = showAdvancedToggle && advanced.enabled;
   const stringValue = hasValue && typeof filter.value === "string" ? 
filter.value : "";
diff --git a/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx 
b/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx
index e6515458f48..208f9a7b149 100644
--- a/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx
+++ b/airflow-core/src/airflow/ui/src/components/GraphTaskFilters.tsx
@@ -25,7 +25,6 @@ import {
   VStack,
 } from "@chakra-ui/react";
 import { useEffect, useRef, useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { FiSearch } from "react-icons/fi";
 import { useParams, useSearchParams } from "react-router-dom";
@@ -39,6 +38,8 @@ import { NumberInputField, NumberInputRoot } from 
"src/components/ui/NumberInput
 import { SearchParamsKeys } from "src/constants/searchParams";
 import { taskInstanceStateOptions } from "src/constants/stateOptions";
 import { useGroups } from "src/context/groups";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 
 export const GraphTaskFilters = () => {
   const { t: translate } = useTranslation(["dag", "tasks"]);
@@ -140,7 +141,11 @@ export const GraphTaskFilters = () => {
 
   const [isOpen, setIsOpen] = useState(false);
 
-  useHotkeys("mod+shift+f", () => setIsOpen(true), { preventDefault: true });
+  useShortcut({
+    ...SHORTCUTS.filters.openGraphFilters,
+    callback: () => setIsOpen(true),
+    options: { preventDefault: true },
+  });
 
   const panelTitle = translate("dag:panel.graphFilters.title");
 
diff --git 
a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/KeyboardShortcutsModal.tsx
 
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/KeyboardShortcutsModal.tsx
new file mode 100644
index 00000000000..6ff477c626c
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/KeyboardShortcutsModal.tsx
@@ -0,0 +1,106 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Box, Flex, HStack, Heading, Kbd, Text, VStack } from 
"@chakra-ui/react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { Dialog } from "src/components/ui";
+import {
+  SHORTCUT_CATEGORIES,
+  SHORTCUTS,
+  type ShortcutEntry,
+  useShortcutRegistry,
+} from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
+import { getMetaKey } from "src/utils";
+
+import { formatShortcutCombo } from "./formatShortcutCombo";
+
+const buildGroups = (shortcuts: ReadonlyArray<ShortcutEntry>) =>
+  SHORTCUT_CATEGORIES.map((category) => {
+    const seen = new Set<string>();
+    const items = shortcuts
+      .filter((entry) => entry.category === category)
+      .filter((entry) => {
+        const key = `${entry.keys.join("+")}|${entry.description}`;
+
+        if (seen.has(key)) {
+          return false;
+        }
+        seen.add(key);
+
+        return true;
+      });
+
+    return { category, items };
+  }).filter((group) => group.items.length > 0);
+
+export const KeyboardShortcutsModal = () => {
+  const { t: translate } = useTranslation("common");
+  const { shortcuts } = useShortcutRegistry();
+  const [open, setOpen] = useState(false);
+  const metaKey = getMetaKey();
+
+  const toggle = () => setOpen((prev) => !prev);
+
+  useShortcut({
+    ...SHORTCUTS.global.showHelp,
+    callback: toggle,
+  });
+
+  const groups = buildGroups(shortcuts);
+
+  return (
+    <Dialog.Root lazyMount onOpenChange={(event) => setOpen(event.open)} 
open={open} size="md">
+      <Dialog.Content backdrop>
+        <Dialog.Header>{translate("shortcuts.title")}</Dialog.Header>
+        <Dialog.CloseTrigger />
+        <Dialog.Body>
+          {groups.length === 0 ? (
+            <Text color="fg.muted">{translate("shortcuts.empty")}</Text>
+          ) : (
+            <VStack align="stretch" gap={4}>
+              {groups.map(({ category, items }) => (
+                <Box key={category}>
+                  <Heading color="fg.muted" mb={2} size="xs" 
textTransform="uppercase">
+                    {translate(`shortcuts.categories.${category}`)}
+                  </Heading>
+                  <VStack align="stretch" gap={1}>
+                    {items.map((entry) => (
+                      <Flex alignItems="center" gap={4} 
justifyContent="space-between" key={entry.id}>
+                        <Text fontSize="sm">{entry.description}</Text>
+                        <HStack gap={1}>
+                          {entry.keys.map((combo) => (
+                            <Kbd key={combo} size="sm">
+                              {formatShortcutCombo(combo, metaKey)}
+                            </Kbd>
+                          ))}
+                        </HStack>
+                      </Flex>
+                    ))}
+                  </VStack>
+                </Box>
+              ))}
+            </VStack>
+          )}
+        </Dialog.Body>
+      </Dialog.Content>
+    </Dialog.Root>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.test.ts
 
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.test.ts
new file mode 100644
index 00000000000..e128fc376f6
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.test.ts
@@ -0,0 +1,41 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { describe, expect, it } from "vitest";
+
+import { formatShortcutCombo } from "./formatShortcutCombo";
+
+describe("formatShortcutCombo", () => {
+  it.each([
+    ["w", "⌘", "W"],
+    ["mod+k", "⌘", "⌘ K"],
+    ["mod+k", "Ctrl", "Ctrl K"],
+    ["mod+shift+f", "⌘", "⌘ ⇧ F"],
+    ["mod+ArrowUp", "Ctrl", "Ctrl ↑"],
+    ["shift+ArrowDown", "⌘", "⇧ ↓"],
+    ["space", "⌘", "Space"],
+    ["/", "⌘", "/"],
+  ])("formats %s (meta=%s) as %s", (combo, metaKey, expected) => {
+    expect(formatShortcutCombo(combo, metaKey)).toBe(expected);
+  });
+
+  it("renders the ? alias for the help shortcut regardless of casing", () => {
+    expect(formatShortcutCombo("shift+Slash", "⌘")).toBe("?");
+    expect(formatShortcutCombo("shift+slash", "Ctrl")).toBe("?");
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.ts
 
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.ts
new file mode 100644
index 00000000000..4819a84f5a1
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/formatShortcutCombo.ts
@@ -0,0 +1,66 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Symbols for keys whose name is not the desired display label.
+const KEY_SYMBOLS: Record<string, string> = {
+  alt: "⌥",
+  arrowdown: "↓",
+  arrowleft: "←",
+  arrowright: "→",
+  arrowup: "↑",
+  ctrl: "Ctrl",
+  shift: "⇧",
+  slash: "/",
+  space: "Space",
+};
+
+// Combos bound to one key for matching reasons but better shown as a single 
glyph.
+// `shift+Slash` is how react-hotkeys-hook reliably matches the "?" key.
+const COMBO_ALIASES: Record<string, string> = {
+  "shift+slash": "?",
+};
+
+/**
+ * Turn a `react-hotkeys-hook` combo such as `"mod+shift+f"` into a 
human-readable
+ * label like `"⌘ ⇧ F"`. `metaKey` is the platform's meta key symbol 
(`⌘`/`Ctrl`).
+ */
+export const formatShortcutCombo = (combo: string, metaKey: string): string => 
{
+  const alias = COMBO_ALIASES[combo.toLowerCase()];
+
+  if (alias !== undefined) {
+    return alias;
+  }
+
+  return combo
+    .split("+")
+    .map((part) => {
+      const lower = part.toLowerCase();
+
+      if (lower === "mod") {
+        return metaKey;
+      }
+
+      if (lower in KEY_SYMBOLS) {
+        return KEY_SYMBOLS[lower];
+      }
+
+      return part.length === 1 ? part.toUpperCase() : part;
+    })
+    .join(" ");
+};
diff --git 
a/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/index.ts 
b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/index.ts
new file mode 100644
index 00000000000..f98aa5f41b7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/KeyboardShortcuts/index.ts
@@ -0,0 +1,20 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+export { KeyboardShortcutsModal } from "./KeyboardShortcutsModal";
+export { formatShortcutCombo } from "./formatShortcutCombo";
diff --git 
a/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx 
b/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx
index e933ec88838..b89946aa352 100644
--- a/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/MarkAs/Run/MarkRunAsButton.tsx
@@ -18,7 +18,6 @@
  */
 import { Box, HStack, useDisclosure } from "@chakra-ui/react";
 import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { FiX } from "react-icons/fi";
 import { LuCheck } from "react-icons/lu";
@@ -26,6 +25,8 @@ import { LuCheck } from "react-icons/lu";
 import type { DagRunMutableStates, DAGRunResponse } from 
"openapi/requests/types.gen";
 import { StateBadge } from "src/components/StateBadge";
 import { IconButton, Menu, Tooltip } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 
 import { allowedStates } from "../utils";
 import MarkRunAsDialog from "./MarkRunAsDialog";
@@ -40,23 +41,23 @@ const MarkRunAsButton = ({ dagRun, isHotkeyEnabled = false 
}: Props) => {
   const [state, setState] = useState<DagRunMutableStates>("success");
   const { t: translate } = useTranslation();
 
-  useHotkeys(
-    "shift+f",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.runActions.markRunFailed,
+    callback: () => {
       setState("failed");
       onOpen();
     },
-    { enabled: isHotkeyEnabled },
-  );
+    options: { enabled: isHotkeyEnabled },
+  });
 
-  useHotkeys(
-    "shift+s",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.runActions.markRunSuccess,
+    callback: () => {
       setState("success");
       onOpen();
     },
-    { enabled: isHotkeyEnabled },
-  );
+    options: { enabled: isHotkeyEnabled },
+  });
 
   const label = translate("dags:runAndTaskActions.markAs.button", {
     type: translate("dagRun_one"),
diff --git 
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx
 
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx
index fa5cd2322ae..9cc44162fb0 100644
--- 
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskGroup/MarkTaskGroupAsButton.tsx
@@ -18,7 +18,6 @@
  */
 import { Box, HStack, useDisclosure } from "@chakra-ui/react";
 import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { FiX } from "react-icons/fi";
 import { LuCheck } from "react-icons/lu";
@@ -26,6 +25,8 @@ import { LuCheck } from "react-icons/lu";
 import type { LightGridTaskInstanceSummary, TaskInstanceState } from 
"openapi/requests/types.gen";
 import { StateBadge } from "src/components/StateBadge";
 import { IconButton, Menu, Tooltip } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 
 import { allowedStates } from "../utils";
 import MarkTaskGroupAsDialog from "./MarkTaskGroupAsDialog";
@@ -40,23 +41,23 @@ const MarkTaskGroupAsButton = ({ groupTaskInstance, 
isHotkeyEnabled = false }: P
   const { t: translate } = useTranslation();
   const [state, setState] = useState<TaskInstanceState>("success");
 
-  useHotkeys(
-    "shift+f",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.runActions.markTaskGroupFailed,
+    callback: () => {
       setState("failed");
       onOpen();
     },
-    { enabled: isHotkeyEnabled },
-  );
+    options: { enabled: isHotkeyEnabled },
+  });
 
-  useHotkeys(
-    "shift+s",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.runActions.markTaskGroupSuccess,
+    callback: () => {
       setState("success");
       onOpen();
     },
-    { enabled: isHotkeyEnabled },
-  );
+    options: { enabled: isHotkeyEnabled },
+  });
 
   const label = translate("dags:runAndTaskActions.markAs.button", {
     type: translate("taskGroup_one"),
diff --git 
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
 
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
index 20680890aa7..5c235474ac8 100644
--- 
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
+++ 
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsButton.tsx
@@ -18,7 +18,6 @@
  */
 import { Box, HStack, useDisclosure } from "@chakra-ui/react";
 import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { FiX } from "react-icons/fi";
 import { LuCheck } from "react-icons/lu";
@@ -26,6 +25,8 @@ import { LuCheck } from "react-icons/lu";
 import type { TaskInstanceResponse, TaskInstanceState } from 
"openapi/requests/types.gen";
 import { StateBadge } from "src/components/StateBadge";
 import { IconButton, Menu, Tooltip } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 
 import { allowedStates } from "../utils";
 import MarkTaskInstanceAsDialog from "./MarkTaskInstanceAsDialog";
@@ -41,23 +42,23 @@ const MarkTaskInstanceAsButton = ({ isHotkeyEnabled = 
false, taskInstance }: Pro
 
   const [state, setState] = useState<TaskInstanceState>("success");
 
-  useHotkeys(
-    "shift+f",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.runActions.markTaskFailed,
+    callback: () => {
       setState("failed");
       onOpen();
     },
-    { enabled: isHotkeyEnabled },
-  );
+    options: { enabled: isHotkeyEnabled },
+  });
 
-  useHotkeys(
-    "shift+s",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.runActions.markTaskSuccess,
+    callback: () => {
       setState("success");
       onOpen();
     },
-    { enabled: isHotkeyEnabled },
-  );
+    options: { enabled: isHotkeyEnabled },
+  });
 
   const label = translate("dags:runAndTaskActions.markAs.button", {
     type: translate("taskInstance_one"),
diff --git a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx 
b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
index cf51c59981a..57981808c62 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
+++ b/airflow-core/src/airflow/ui/src/components/SearchBar.tsx
@@ -18,12 +18,13 @@
  */
 import { CloseButton, HStack, Input, InputGroup, Kbd, type InputGroupProps } 
from "@chakra-ui/react";
 import { useEffect, useRef, useState, type ChangeEvent } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { FiSearch } from "react-icons/fi";
 import { useDebouncedCallback } from "use-debounce";
 
 import { AdvancedSearchToggle, type AdvancedSearchToggleProps } from 
"src/components/AdvancedSearchToggle";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 import { getMetaKey } from "src/utils";
 
 const debounceDelay = 200;
@@ -74,13 +75,13 @@ export const SearchBar = ({
     onChange("");
   };
 
-  useHotkeys(
-    "mod+k",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.search.focusSearch,
+    callback: () => {
       searchRef.current?.focus();
     },
-    { enabled: !hotkeyDisabled, preventDefault: true },
-  );
+    options: { enabled: !hotkeyDisabled, preventDefault: true },
+  });
 
   const inputGroup = (
     <InputGroup
diff --git 
a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsButton.tsx 
b/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsButton.tsx
index 2080ca100c7..f1f473ddca6 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsButton.tsx
@@ -18,11 +18,12 @@
  */
 import { Button, Box, Kbd } from "@chakra-ui/react";
 import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { MdSearch } from "react-icons/md";
 
 import { Dialog } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 import { getMetaKey } from "src/utils";
 
 import { SearchDags } from "./SearchDags";
@@ -36,14 +37,14 @@ export const SearchDagsButton = () => {
     setIsOpen(false);
   };
 
-  useHotkeys(
-    "mod+k",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.search.searchDags,
+    callback: () => {
       setIsOpen(true);
     },
-    [isOpen],
-    { preventDefault: true },
-  );
+    dependencies: [isOpen],
+    options: { preventDefault: true },
+  });
 
   return (
     <Box>
diff --git 
a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/Context.ts 
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/Context.ts
new file mode 100644
index 00000000000..e4320ed9c06
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/Context.ts
@@ -0,0 +1,58 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { createContext } from "react";
+
+// Display order of shortcut categories in the help modal.
+export const SHORTCUT_CATEGORIES = [
+  "global",
+  "navigation",
+  "dagView",
+  "search",
+  "filters",
+  "logs",
+  "code",
+  "runActions",
+] as const;
+
+export type ShortcutCategory = (typeof SHORTCUT_CATEGORIES)[number];
+
+export type ShortcutEntry = {
+  readonly category: ShortcutCategory;
+  readonly description: string;
+  readonly id: string;
+  readonly keys: ReadonlyArray<string>;
+};
+
+export type ShortcutRegistryContextValue = {
+  readonly register: (entry: ShortcutEntry) => void;
+  readonly shortcuts: ReadonlyArray<ShortcutEntry>;
+  readonly unregister: (id: string) => void;
+};
+
+// No-op default so components using shortcuts can render without the provider
+// (e.g. in isolated unit tests). The hotkeys still work; they just aren't
+// listed in the help dialog. The real app wraps the tree in
+// ShortcutRegistryProvider, so the dialog is populated there.
+const NOOP_REGISTRY: ShortcutRegistryContextValue = {
+  register: () => undefined,
+  shortcuts: [],
+  unregister: () => undefined,
+};
+
+export const ShortcutRegistryContext = 
createContext<ShortcutRegistryContextValue>(NOOP_REGISTRY);
diff --git 
a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/ShortcutRegistryProvider.tsx
 
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/ShortcutRegistryProvider.tsx
new file mode 100644
index 00000000000..645a5a5da19
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/ShortcutRegistryProvider.tsx
@@ -0,0 +1,45 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState, type PropsWithChildren } from "react";
+
+import { ShortcutRegistryContext, type ShortcutEntry } from "./Context";
+
+/**
+ * Holds the set of keyboard shortcuts that are currently mounted and enabled.
+ *
+ * Each `useShortcut` call registers itself here on mount and removes itself on
+ * unmount, mirroring the lifecycle of the underlying `react-hotkeys-hook`
+ * binding. The help modal renders from this registry, so it always reflects 
the
+ * shortcuts actually available on the current page.
+ */
+export const ShortcutRegistryProvider = ({ children }: PropsWithChildren) => {
+  const [shortcuts, setShortcuts] = useState<ReadonlyArray<ShortcutEntry>>([]);
+
+  const register = (entry: ShortcutEntry) => {
+    setShortcuts((prev) => [...prev.filter((item) => item.id !== entry.id), 
entry]);
+  };
+
+  const unregister = (id: string) => {
+    setShortcuts((prev) => prev.filter((item) => item.id !== id));
+  };
+
+  const value = { register, shortcuts, unregister };
+
+  return <ShortcutRegistryContext.Provider 
value={value}>{children}</ShortcutRegistryContext.Provider>;
+};
diff --git a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/index.ts 
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/index.ts
new file mode 100644
index 00000000000..b2634450013
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/index.ts
@@ -0,0 +1,22 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+export { SHORTCUT_CATEGORIES, type ShortcutCategory, type ShortcutEntry } from 
"./Context";
+export { SHORTCUTS, type ShortcutDefinition } from "./shortcuts";
+export { ShortcutRegistryProvider } from "./ShortcutRegistryProvider";
+export { useShortcutRegistry } from "./useShortcutRegistry";
diff --git 
a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts 
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
new file mode 100644
index 00000000000..1271037a99f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
@@ -0,0 +1,179 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { Keys } from "react-hotkeys-hook";
+
+import type { ShortcutCategory } from "./Context";
+
+/**
+ * Static definition of a keyboard shortcut: its key combination, the category 
it
+ * belongs to in the help modal, and the `common` i18n key for its description.
+ *
+ * The runtime callback and any dynamic options (e.g. `enabled`) stay at the 
call
+ * site, since they depend on component state.
+ */
+export type ShortcutDefinition = {
+  readonly category: ShortcutCategory;
+  readonly descriptionKey: string;
+  readonly keys: Keys;
+};
+
+// Every category must be present, and each entry's `category` field must match
+// the group it sits in — both are enforced at compile time.
+type CategorizedShortcuts = {
+  readonly [Category in ShortcutCategory]: Record<
+    string,
+    { readonly category: Category } & ShortcutDefinition
+  >;
+};
+
+/**
+ * Single source of truth for every keyboard shortcut in the UI, grouped by the
+ * category shown in the help modal.
+ *
+ * Each `useShortcut` call spreads one of these entries (e.g.
+ * `...SHORTCUTS.logs.toggleWrap`) instead of redeclaring its keys, category 
and
+ * description inline, so maintainers can see every shortcut that already 
exists
+ * in one place and avoid clashing key bindings. Description text lives in
+ * `common.json` under the referenced `descriptionKey`.
+ */
+export const SHORTCUTS = {
+  code: {
+    toggleFullscreen: {
+      category: "code",
+      descriptionKey: "shortcuts.descriptions.toggleFullscreen",
+      keys: "f",
+    },
+    toggleWrap: { category: "code", descriptionKey: 
"shortcuts.descriptions.toggleWrap", keys: "w" },
+  },
+  dagView: {
+    toggleGraphGrid: {
+      category: "dagView",
+      descriptionKey: "shortcuts.descriptions.toggleGraphGrid",
+      keys: "g",
+    },
+  },
+  filters: {
+    openGraphFilters: {
+      category: "filters",
+      descriptionKey: "shortcuts.descriptions.openGraphFilters",
+      keys: "mod+shift+f",
+    },
+  },
+  global: {
+    showHelp: {
+      category: "global",
+      descriptionKey: "shortcuts.descriptions.showHelp",
+      keys: "shift+Slash",
+    },
+  },
+  logs: {
+    downloadLogs: { category: "logs", descriptionKey: 
"shortcuts.descriptions.downloadLogs", keys: "d" },
+    focusLogSearch: {
+      category: "logs",
+      descriptionKey: "shortcuts.descriptions.focusLogSearch",
+      keys: "/",
+    },
+    scrollBottom: {
+      category: "logs",
+      descriptionKey: "shortcuts.descriptions.scrollBottom",
+      keys: "mod+ArrowDown",
+    },
+    scrollTop: { category: "logs", descriptionKey: 
"shortcuts.descriptions.scrollTop", keys: "mod+ArrowUp" },
+    toggleExpand: { category: "logs", descriptionKey: 
"shortcuts.descriptions.toggleExpand", keys: "e" },
+    toggleFullscreen: {
+      category: "logs",
+      descriptionKey: "shortcuts.descriptions.toggleFullscreen",
+      keys: "f",
+    },
+    toggleSource: { category: "logs", descriptionKey: 
"shortcuts.descriptions.toggleSource", keys: "s" },
+    toggleTimestamp: {
+      category: "logs",
+      descriptionKey: "shortcuts.descriptions.toggleTimestamp",
+      keys: "t",
+    },
+    toggleWrap: { category: "logs", descriptionKey: 
"shortcuts.descriptions.toggleWrap", keys: "w" },
+  },
+  navigation: {
+    navigateTasks: {
+      category: "navigation",
+      descriptionKey: "shortcuts.descriptions.navigateTasks",
+      keys: ["shift+ArrowDown", "shift+ArrowUp", "shift+ArrowLeft", 
"shift+ArrowRight"],
+    },
+    toggleTaskGroup: {
+      category: "navigation",
+      descriptionKey: "shortcuts.descriptions.toggleTaskGroup",
+      keys: "space",
+    },
+  },
+  runActions: {
+    clearRun: {
+      category: "runActions",
+      descriptionKey: "shortcuts.descriptions.clearRun",
+      keys: "shift+c",
+    },
+    clearTaskInstance: {
+      category: "runActions",
+      descriptionKey: "shortcuts.descriptions.clearTaskInstance",
+      keys: "shift+c",
+    },
+    markRunFailed: {
+      category: "runActions",
+      descriptionKey: "shortcuts.descriptions.markRunFailed",
+      keys: "shift+f",
+    },
+    markRunSuccess: {
+      category: "runActions",
+      descriptionKey: "shortcuts.descriptions.markRunSuccess",
+      keys: "shift+s",
+    },
+    markTaskFailed: {
+      category: "runActions",
+      descriptionKey: "shortcuts.descriptions.markTaskFailed",
+      keys: "shift+f",
+    },
+    markTaskGroupFailed: {
+      category: "runActions",
+      descriptionKey: "shortcuts.descriptions.markTaskGroupFailed",
+      keys: "shift+f",
+    },
+    markTaskGroupSuccess: {
+      category: "runActions",
+      descriptionKey: "shortcuts.descriptions.markTaskGroupSuccess",
+      keys: "shift+s",
+    },
+    markTaskSuccess: {
+      category: "runActions",
+      descriptionKey: "shortcuts.descriptions.markTaskSuccess",
+      keys: "shift+s",
+    },
+  },
+  search: {
+    focusFilterSearch: {
+      category: "search",
+      descriptionKey: "shortcuts.descriptions.focusFilterSearch",
+      keys: "mod+k",
+    },
+    focusSearch: {
+      category: "search",
+      descriptionKey: "shortcuts.descriptions.focusSearch",
+      keys: "mod+k",
+    },
+    searchDags: { category: "search", descriptionKey: 
"shortcuts.descriptions.searchDags", keys: "mod+k" },
+  },
+} satisfies CategorizedShortcuts;
diff --git 
a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/useShortcutRegistry.ts
 
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/useShortcutRegistry.ts
new file mode 100644
index 00000000000..ff35397696a
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/useShortcutRegistry.ts
@@ -0,0 +1,23 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useContext } from "react";
+
+import { ShortcutRegistryContext, type ShortcutRegistryContextValue } from 
"./Context";
+
+export const useShortcutRegistry = (): ShortcutRegistryContextValue => 
useContext(ShortcutRegistryContext);
diff --git 
a/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts 
b/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts
index 5a99d9cc8f8..cfeb0976c3c 100644
--- a/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/navigation/useKeyboardNavigation.ts
@@ -16,12 +16,11 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useHotkeys } from "react-hotkeys-hook";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 
 import type { ArrowKey, NavigationDirection } from "./types";
 
-const ARROW_KEYS = ["shift+ArrowDown", "shift+ArrowUp", "shift+ArrowLeft", 
"shift+ArrowRight"] as const;
-
 type Props = {
   enabled?: boolean;
   onNavigate: (direction: NavigationDirection) => void;
@@ -55,7 +54,17 @@ export const useKeyboardNavigation = ({ enabled = true, 
onNavigate, onToggleGrou
 
   const hotkeyOptions = { enabled, preventDefault: true };
 
-  useHotkeys(ARROW_KEYS.join(","), handleNormalKeyPress, hotkeyOptions, 
[onNavigate]);
+  useShortcut({
+    ...SHORTCUTS.navigation.navigateTasks,
+    callback: handleNormalKeyPress,
+    dependencies: [onNavigate],
+    options: hotkeyOptions,
+  });
 
-  useHotkeys("space", () => onToggleGroup?.(), hotkeyOptions, [onToggleGroup]);
+  useShortcut({
+    ...SHORTCUTS.navigation.toggleTaskGroup,
+    callback: () => onToggleGroup?.(),
+    dependencies: [onToggleGroup],
+    options: hotkeyOptions,
+  });
 };
diff --git a/airflow-core/src/airflow/ui/src/hooks/useShortcut.test.tsx 
b/airflow-core/src/airflow/ui/src/hooks/useShortcut.test.tsx
new file mode 100644
index 00000000000..45e1f5bf6a8
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useShortcut.test.tsx
@@ -0,0 +1,111 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { renderHook } from "@testing-library/react";
+import type { PropsWithChildren } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+  ShortcutRegistryProvider,
+  type ShortcutEntry,
+  useShortcutRegistry,
+} from "src/context/keyboardShortcuts";
+
+import { useShortcut } from "./useShortcut";
+
+const mockTranslate = vi.fn((key: string) => {
+  const translations: Record<string, string> = {
+    "shortcuts.descriptions.toggleWrap": "Toggle Wrap",
+  };
+
+  return translations[key] ?? key;
+});
+
+vi.mock("react-i18next", () => ({
+  useTranslation: () => ({
+    i18n: { language: "en" },
+    // eslint-disable-next-line id-length
+    t: mockTranslate,
+  }),
+}));
+
+const wrapper = ({ children }: PropsWithChildren) => (
+  <ShortcutRegistryProvider>{children}</ShortcutRegistryProvider>
+);
+
+const renderShortcut = (initialEnabled = true) =>
+  renderHook(
+    ({ enabled }: { enabled: boolean }): ReadonlyArray<ShortcutEntry> => {
+      useShortcut({
+        callback: vi.fn(),
+        category: "logs",
+        descriptionKey: "shortcuts.descriptions.toggleWrap",
+        keys: "w",
+        options: { enabled },
+      });
+
+      return useShortcutRegistry().shortcuts;
+    },
+    { initialProps: { enabled: initialEnabled }, wrapper },
+  );
+
+describe("useShortcut", () => {
+  it("registers the shortcut while mounted", () => {
+    const { result } = renderShortcut();
+
+    expect(result.current).toHaveLength(1);
+    expect(result.current[0]).toMatchObject({
+      category: "logs",
+      description: "Toggle Wrap",
+      keys: ["w"],
+    });
+  });
+
+  it("removes the shortcut when it becomes disabled", () => {
+    const { rerender, result } = renderShortcut(true);
+
+    expect(result.current).toHaveLength(1);
+    rerender({ enabled: false });
+    expect(result.current).toHaveLength(0);
+  });
+
+  it("does not register a disabled shortcut", () => {
+    const { result } = renderShortcut(false);
+
+    expect(result.current).toHaveLength(0);
+  });
+
+  it("registers each combo of a multi-key shortcut for display", () => {
+    const { result } = renderHook(
+      (): ReadonlyArray<ShortcutEntry> => {
+        useShortcut({
+          callback: vi.fn(),
+          category: "navigation",
+          descriptionKey: "shortcuts.descriptions.navigateTasks",
+          keys: ["shift+ArrowUp", "shift+ArrowDown"],
+        });
+
+        return useShortcutRegistry().shortcuts;
+      },
+      { wrapper },
+    );
+
+    expect(result.current).toHaveLength(1);
+    expect(result.current[0]?.keys).toEqual(["shift+ArrowUp", 
"shift+ArrowDown"]);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/hooks/useShortcut.ts 
b/airflow-core/src/airflow/ui/src/hooks/useShortcut.ts
new file mode 100644
index 00000000000..548389657d5
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useShortcut.ts
@@ -0,0 +1,76 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { type DependencyList, useEffect, useId } from "react";
+import { type HotkeyCallback, type Options, useHotkeys } from 
"react-hotkeys-hook";
+import { useTranslation } from "react-i18next";
+
+import { type ShortcutDefinition, useShortcutRegistry } from 
"src/context/keyboardShortcuts";
+
+type UseShortcutParams = {
+  readonly callback: HotkeyCallback;
+  readonly dependencies?: DependencyList;
+  readonly options?: Options;
+} & ShortcutDefinition;
+
+/**
+ * Thin wrapper around `react-hotkeys-hook`'s `useHotkeys` that also publishes 
the
+ * shortcut to the keyboard-shortcut registry so it shows up in the help modal.
+ *
+ * The static `category`, `descriptionKey` and `keys` come from a `SHORTCUTS`
+ * definition (spread in at the call site); `callback`, `options` and
+ * `dependencies` stay at the call site since they depend on component state. 
The
+ * shortcut is registered only while it is enabled, matching when the hotkey is
+ * actually bound.
+ */
+export const useShortcut = ({
+  callback,
+  category,
+  dependencies,
+  descriptionKey,
+  keys,
+  options,
+}: UseShortcutParams) => {
+  const id = useId();
+  const { t: translate } = useTranslation("common");
+  const { register, unregister } = useShortcutRegistry();
+
+  const ref = useHotkeys(keys, callback, options, dependencies);
+
+  const description = translate(descriptionKey);
+
+  const keyList: Array<string> = typeof keys === "string" ? [keys] : [...keys];
+  // A `false` literal means the hotkey is not bound; a function trigger is 
evaluated
+  // per event but the binding still exists, so we treat it as enabled here.
+  const isEnabled = options?.enabled !== false;
+  const keyListId = keyList.join(",");
+
+  useEffect(() => {
+    if (!isEnabled) {
+      return undefined;
+    }
+
+    register({ category, description, id, keys: keyList });
+
+    return () => unregister(id);
+    // `keyListId` stands in for `keyList`'s identity to avoid re-running on 
each render.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [category, description, id, isEnabled, keyListId, register, unregister]);
+
+  return ref;
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx 
b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
index e06c92bc0e5..5664e702a33 100644
--- a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
@@ -23,6 +23,7 @@ import { Outlet } from "react-router-dom";
 
 import { usePluginServiceGetPlugins } from "openapi/queries";
 import type { ReactAppResponse } from "openapi/requests/types.gen";
+import { KeyboardShortcutsModal } from "src/components/KeyboardShortcuts";
 import { ReactPlugin } from "src/pages/ReactPlugin";
 import { useConfig } from "src/queries/useConfig";
 import { DocumentTitleProvider } from "src/utils";
@@ -101,6 +102,7 @@ export const BaseLayout = ({ children }: PropsWithChildren) 
=> {
     <LocaleProvider locale={i18n.language || "en"}>
       <DocumentTitleProvider>
         <Box display="flex" flexDirection="column" h="100vh">
+          <KeyboardShortcutsModal />
           <Nav />
           <Box
             _ltr={{ ml: 16 }}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
index 2d550c20941..5c34dde8bd3 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -29,7 +29,6 @@ import {
 } from "@chakra-ui/react";
 import { useReactFlow } from "@xyflow/react";
 import { useEffect, useRef } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { FiGrid } from "react-icons/fi";
 import { LuChartGantt, LuKeyboard } from "react-icons/lu";
@@ -46,6 +45,8 @@ import { type ButtonGroupOption, ButtonGroupToggle } from 
"src/components/ui/But
 import type { DagView } from "src/constants/dagView";
 import { dependenciesKey } from "src/constants/localStorage";
 import type { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 import { useContainerWidth } from "src/utils/useContainerWidth";
 
 import { DagRunSelect } from "./DagRunSelect";
@@ -187,17 +188,17 @@ export const PanelButtons = ({
     }
   };
 
-  useHotkeys(
-    "g",
-    () => {
+  useShortcut({
+    ...SHORTCUTS.dagView.toggleGraphGrid,
+    callback: () => {
       const newView = dagView === "graph" ? "grid" : "graph";
 
       setDagView(newView);
       handleFocus(newView);
     },
-    [dagView],
-    { preventDefault: true },
-  );
+    dependencies: [dagView],
+    options: { preventDefault: true },
+  });
 
   return (
     <Box bg="bg" pr={4} ref={containerRef} width="100%" zIndex={1}>
@@ -321,17 +322,6 @@ export const PanelButtons = ({
           <GridFilters />
           <Flex color="fg.muted" gap={2} justifyContent="flex-end" mt={1}>
             <RunTypeLegend />
-            <Tooltip
-              content={
-                <Box>
-                  <Text>{translate("dag:navigation.navigation", { arrow: 
"↑↓←→" })}</Text>
-                  <Text>{translate("dag:navigation.toggleGroup")}</Text>
-                </Box>
-              }
-              portalled
-            >
-              <LuKeyboard />
-            </Tooltip>
           </Flex>
         </Flex>
       )}
diff --git a/airflow-core/src/airflow/ui/src/main.tsx 
b/airflow-core/src/airflow/ui/src/main.tsx
index ef24cb75b29..e1ef1be5257 100644
--- a/airflow-core/src/airflow/ui/src/main.tsx
+++ b/airflow-core/src/airflow/ui/src/main.tsx
@@ -31,6 +31,7 @@ import * as ReactJSXRuntime from "react/jsx-runtime";
 import type { HTTPExceptionResponse } from "openapi/requests/types.gen";
 import { ChakraCustomProvider } from "src/context/ChakraCustomProvider";
 import { ColorModeProvider } from "src/context/colorMode";
+import { ShortcutRegistryProvider } from "src/context/keyboardShortcuts";
 import { TimezoneProvider } from "src/context/timezone";
 import { router } from "src/router";
 import { getNextHref, getRedirectPath } from "src/utils/links.ts";
@@ -101,7 +102,9 @@ createRoot(document.querySelector("#root") as 
HTMLDivElement).render(
         <ChakraCustomProvider>
           <ColorModeProvider>
             <TimezoneProvider>
-              <RouterProvider router={router} />
+              <ShortcutRegistryProvider>
+                <RouterProvider router={router} />
+              </ShortcutRegistryProvider>
             </TimezoneProvider>
           </ColorModeProvider>
         </ChakraCustomProvider>
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx
index 65622d58239..66988187031 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Code/Code.tsx
@@ -18,7 +18,6 @@
  */
 import { Box, Button, Heading, HStack, Link, VStack } from "@chakra-ui/react";
 import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { MdOutlineDifference, MdOutlineOpenInFull, MdWrapText } from 
"react-icons/md";
 import { useParams } from "react-router-dom";
@@ -38,7 +37,9 @@ import Time from "src/components/Time";
 import { Dialog, IconButton, ProgressBar } from "src/components/ui";
 import { LazyClipboard } from "src/components/ui/LazyClipboard";
 import { useMonacoTheme } from "src/context/colorMode";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
 import useSelectedVersion from "src/hooks/useSelectedVersion";
+import { useShortcut } from "src/hooks/useShortcut";
 import { useConfig } from "src/queries/useConfig";
 import { renderDuration } from "src/utils";
 
@@ -118,8 +119,15 @@ export const Code = () => {
 
   const { beforeMount, theme } = useMonacoTheme();
 
-  useHotkeys("w", toggleWrap);
-  useHotkeys("f", toggleFullscreen);
+  useShortcut({
+    ...SHORTCUTS.code.toggleFullscreen,
+    callback: toggleFullscreen,
+  });
+
+  useShortcut({
+    ...SHORTCUTS.code.toggleWrap,
+    callback: toggleWrap,
+  });
 
   const editorOptions: EditorProps["options"] = {
     automaticLayout: true,
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/FailedLogs.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/FailedLogs.tsx
index 9d3bde42a6a..fa05f2720ef 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/FailedLogs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/FailedLogs.tsx
@@ -18,11 +18,12 @@
  */
 import { Flex, Heading, Button } from "@chakra-ui/react";
 import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 
 import type { TaskInstanceCollectionResponse } from 
"openapi/requests/types.gen";
 import { Tooltip } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 import { useConfig } from "src/queries/useConfig";
 
 import { TaskLogPreview } from "./TaskLogPreview";
@@ -40,7 +41,10 @@ const FailedLogs = ({
 
   const toggleWrap = () => setWrap(!wrap);
 
-  useHotkeys("w", toggleWrap);
+  useShortcut({
+    ...SHORTCUTS.logs.toggleWrap,
+    callback: toggleWrap,
+  });
 
   if (taskLogs === undefined || taskLogs.length <= 0) {
     return undefined;
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
index 75224efc79a..40f9933f48d 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
@@ -18,11 +18,12 @@
  */
 import { CloseButton, HStack, Input, InputGroup, Text } from 
"@chakra-ui/react";
 import { useRef, type KeyboardEvent } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { FiChevronDown, FiChevronUp, FiSearch } from "react-icons/fi";
 
 import { IconButton } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 
 export type LogSearchInputProps = {
   readonly currentMatchIndex: number;
@@ -44,14 +45,14 @@ export const LogSearchInput = ({
   const { t: translate } = useTranslation("dag");
   const searchInputRef = useRef<HTMLInputElement>(null);
 
-  useHotkeys(
-    "/",
-    (event) => {
+  useShortcut({
+    ...SHORTCUTS.logs.focusLogSearch,
+    callback: (event) => {
       event.preventDefault();
       searchInputRef.current?.focus();
     },
-    { enableOnFormTags: false },
-  );
+    options: { enableOnFormTags: false },
+  });
 
   const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
     if (event.key === "Enter") {
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
index b9a2d61f08c..fc52982e803 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
@@ -18,7 +18,6 @@
  */
 import { Box, Heading } from "@chakra-ui/react";
 import { useState } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { useParams, useSearchParams } from "react-router-dom";
 import { useLocalStorage } from "usehooks-ts";
@@ -27,6 +26,8 @@ import { useTaskInstanceServiceGetMappedTaskInstance } from 
"openapi/queries";
 import { Dialog } from "src/components/ui";
 import { LOG_SHOW_SOURCE_KEY, LOG_SHOW_TIMESTAMP_KEY, LOG_WRAP_KEY } from 
"src/constants/localStorage";
 import { SearchParamsKeys } from "src/constants/searchParams";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 import { useConfig } from "src/queries/useConfig";
 import { useLogs } from "src/queries/useLogs";
 
@@ -160,12 +161,30 @@ export const Logs = () => {
   const toggleFullscreen = () => setFullscreen(!fullscreen);
   const toggleExpanded = () => setExpanded((act) => !act);
 
-  useHotkeys("w", toggleWrap);
-  useHotkeys("f", toggleFullscreen);
-  useHotkeys("e", toggleExpanded);
-  useHotkeys("t", toggleTimestamp);
-  useHotkeys("s", toggleSource);
-  useHotkeys("d", downloadLogs);
+  useShortcut({
+    ...SHORTCUTS.logs.toggleWrap,
+    callback: toggleWrap,
+  });
+  useShortcut({
+    ...SHORTCUTS.logs.toggleFullscreen,
+    callback: toggleFullscreen,
+  });
+  useShortcut({
+    ...SHORTCUTS.logs.toggleExpand,
+    callback: toggleExpanded,
+  });
+  useShortcut({
+    ...SHORTCUTS.logs.toggleTimestamp,
+    callback: toggleTimestamp,
+  });
+  useShortcut({
+    ...SHORTCUTS.logs.toggleSource,
+    callback: toggleSource,
+  });
+  useShortcut({
+    ...SHORTCUTS.logs.downloadLogs,
+    callback: downloadLogs,
+  });
 
   const onOpenChange = () => {
     setFullscreen(false);
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
index 21583e19764..62fa6b9bfc2 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
@@ -19,10 +19,11 @@
 import { Box, Code, VStack } from "@chakra-ui/react";
 import { useVirtualizer } from "@tanstack/react-virtual";
 import { useLayoutEffect, useRef, useCallback, useEffect } from "react";
-import { useHotkeys } from "react-hotkeys-hook";
 
 import { ErrorAlert } from "src/components/ErrorAlert";
 import { ProgressBar } from "src/components/ui";
+import { SHORTCUTS } from "src/context/keyboardShortcuts";
+import { useShortcut } from "src/hooks/useShortcut";
 import type { ParsedLogEntry } from "src/queries/useLogs";
 
 import { HighlightedText } from "./HighlightedText";
@@ -148,8 +149,16 @@ export const TaskLogContent = ({
     }
   };
 
-  useHotkeys("mod+ArrowDown", () => handleScrollTo("bottom"), { enabled: 
!isLoading });
-  useHotkeys("mod+ArrowUp", () => handleScrollTo("top"), { enabled: !isLoading 
});
+  useShortcut({
+    ...SHORTCUTS.logs.scrollBottom,
+    callback: () => handleScrollTo("bottom"),
+    options: { enabled: !isLoading },
+  });
+  useShortcut({
+    ...SHORTCUTS.logs.scrollTop,
+    callback: () => handleScrollTo("top"),
+    options: { enabled: !isLoading },
+  });
 
   return (
     <Box display="flex" flexDirection="column" flexGrow={1} h="100%" 
minHeight={0} position="relative">

Reply via email to