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

bbovenzi pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new adf79080fc8 [v3-3-test] Manually backport 68200 & 69883 (#69978)
adf79080fc8 is described below

commit adf79080fc8de9892a1b5d25117a15a07172fe84
Author: Brent Bovenzi <[email protected]>
AuthorDate: Thu Jul 16 13:29:47 2026 -0400

    [v3-3-test] Manually backport 68200 & 69883 (#69978)
    
    * 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
    
    (cherry picked from commit 70bb308b19acbae0f04075063f8546eb5bc0e200)
    
    * [v3-3-test] Clean up graph/grid UI (#69883)
    
    Backport of #69883 to v3-3-test. Applied on top of the #68200 backport,
    so the only branch-specific adaptation is dropping the showPresetFilters
    change to GridFilters.tsx — the Preset Filters feature (#68484) is not on
    v3-3-test.
    
    (cherry picked from commit 707b1ccf137269d488a8b9f7e7fbc9a32cd7b296)
    
    ---------
    
    Co-authored-by: Yeonguk Choo <[email protected]>
---
 .../src/airflow/ui/public/i18n/locales/ar/dag.json |   5 -
 .../src/airflow/ui/public/i18n/locales/ca/dag.json |   5 -
 .../src/airflow/ui/public/i18n/locales/de/dag.json |   5 -
 .../airflow/ui/public/i18n/locales/en/common.json  |  41 +++++
 .../src/airflow/ui/public/i18n/locales/en/dag.json |   5 -
 .../src/airflow/ui/public/i18n/locales/fr/dag.json |   5 -
 .../src/airflow/ui/public/i18n/locales/he/dag.json |   5 -
 .../src/airflow/ui/public/i18n/locales/hi/dag.json |   5 -
 .../src/airflow/ui/public/i18n/locales/hu/dag.json |   5 -
 .../src/airflow/ui/public/i18n/locales/ko/dag.json |   5 -
 .../src/airflow/ui/public/i18n/locales/pl/dag.json |   5 -
 .../src/airflow/ui/public/i18n/locales/tr/dag.json |   5 -
 .../airflow/ui/public/i18n/locales/zh-CN/dag.json  |   5 -
 .../ui/src/components/Clear/Run/ClearRunButton.tsx |  13 +-
 .../Clear/TaskInstance/ClearTaskInstanceButton.tsx |  13 +-
 .../FilterBar/filters/TextSearchFilter.tsx         |  13 +-
 .../airflow/ui/src/components/Graph/TaskNode.tsx   |   3 +-
 .../ui/src/components/Graph/elkGraphUtils.ts       |   2 +-
 .../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 +-
 .../src/airflow/ui/src/components/TaskName.tsx     |   2 -
 .../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        |  41 ++---
 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 +-
 .../ui/src/pages/GroupTaskInstance/Header.tsx      |   4 +-
 .../src/pages/TaskInstance/Logs/LogSearchInput.tsx |  13 +-
 .../ui/src/pages/TaskInstance/Logs/Logs.tsx        |  33 +++-
 .../src/pages/TaskInstance/Logs/TaskLogContent.tsx |  15 +-
 46 files changed, 965 insertions(+), 189 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ar/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/ar/dag.json
index 44d0b882563..6253b5c4a31 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ar/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ar/dag.json
@@ -118,11 +118,6 @@
     "viewInExternal": "عرض السجلات في {{name}} (المحاولة {{attempt}})",
     "warning": "تحذير"
   },
-  "navigation": {
-    "navigation": "التنقل: {{arrow}}",
-    "openGraphFilters": "مرشحات المهام: Ctrl+Shift+F",
-    "toggleGroup": "تبديل المجموعة: المسافة"
-  },
   "notFound": {
     "back": "رجوع",
     "backToDags": "العودة إلى Dags",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ca/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/ca/dag.json
index c90bc964f6d..3cd77f612a7 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ca/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ca/dag.json
@@ -106,11 +106,6 @@
     "viewInExternal": "Veure els registres a {{name}} (intent {{attempt}})",
     "warning": "ADVERTIMENT"
   },
-  "navigation": {
-    "navigation": "Navegació: Shift+{{arrow}}",
-    "openGraphFilters": "Filtres de tasques: Ctrl+Maj+F",
-    "toggleGroup": "Alternar grup: Espai"
-  },
   "notFound": {
     "back": "Enrere",
     "backToDags": "Tornar als Dags",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/de/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/de/dag.json
index 829a1aaac4d..d48f59856f6 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/de/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/de/dag.json
@@ -106,11 +106,6 @@
     "viewInExternal": "Protokoll in {{name}} (Versuch {{attempt}}) ansehen",
     "warning": "WARNING"
   },
-  "navigation": {
-    "navigation": "Navigation: Umschalttaste+{{arrow}}",
-    "openGraphFilters": "Task-Filter: Strg+Umschalt+F",
-    "toggleGroup": "Gruppen umschalten: Leertaste"
-  },
   "notFound": {
     "back": "Zurück",
     "backToDags": "Zurück zu Dags",
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 88a348e1de5..70e600b332d 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..907fba9e5ea 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
@@ -102,11 +102,6 @@
     "viewInExternal": "View logs in {{name}} (attempt {{attempt}})",
     "warning": "WARNING"
   },
-  "navigation": {
-    "navigation": "Navigation: Shift+{{arrow}}",
-    "openGraphFilters": "Task Filters: Ctrl+Shift+F",
-    "toggleGroup": "Toggle group: Space"
-  },
   "notFound": {
     "back": "Go Back",
     "backToDags": "Back to Dags",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/fr/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/fr/dag.json
index de7952e0220..b3a970fdf9d 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/fr/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/fr/dag.json
@@ -109,11 +109,6 @@
     "viewInExternal": "Voir les journaux dans {{name}} (tentative 
{{attempt}})",
     "warning": "AVERTISSEMENT"
   },
-  "navigation": {
-    "navigation": "Navigation : {{arrow}}",
-    "openGraphFilters": "Filtres de tâches : Ctrl+Maj+F",
-    "toggleGroup": "Basculer le groupe : Espace"
-  },
   "notFound": {
     "back": "Retour",
     "backToDags": "Retour aux Dags",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/he/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/he/dag.json
index 0214ee91102..29b902030b7 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/he/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/he/dag.json
@@ -109,11 +109,6 @@
     "viewInExternal": "צפה ברישומים ב-{{name}} (ניסיון {{attempt}})",
     "warning": "WARNING"
   },
-  "navigation": {
-    "navigation": "ניווט: {{arrow}}",
-    "openGraphFilters": "פילטרים משימות: Ctrl+Shift+F",
-    "toggleGroup": "החלפת קבוצה: רווח"
-  },
   "notFound": {
     "back": "חזור",
     "backToDags": "חזור ל-Dags",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/hi/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/hi/dag.json
index cbd6a675e78..8e436ab7a4b 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/hi/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/hi/dag.json
@@ -106,11 +106,6 @@
     "viewInExternal": "{{name}} में लॉग्स देखें (प्रयास {{attempt}})",
     "warning": "WARNING"
   },
-  "navigation": {
-    "navigation": "नेवीगेशन: Shift+{{arrow}}",
-    "openGraphFilters": "Task फ़िल्टर: Ctrl+Shift+F",
-    "toggleGroup": "ग्रुप टॉगल करें: Space"
-  },
   "notFound": {
     "back": "वापस जाएं",
     "backToDags": "डैग्स पर वापस जाएं",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/hu/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/hu/dag.json
index 62b7a4cb790..a4a8c1cb057 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/hu/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/hu/dag.json
@@ -106,11 +106,6 @@
     "viewInExternal": "Naplók megtekintése itt: {{name}} (próbálkozás: 
{{attempt}})",
     "warning": "FIGYELMEZTETÉS"
   },
-  "navigation": {
-    "navigation": "Navigáció: {{arrow}}",
-    "openGraphFilters": "Feladat szűrők: Ctrl+Shift+F",
-    "toggleGroup": "Csoport váltása: Szóköz"
-  },
   "notFound": {
     "back": "Vissza",
     "backToDags": "Vissza a Dag-ekhez",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/ko/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/ko/dag.json
index 30c09fb5804..9b71c510afd 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/ko/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/ko/dag.json
@@ -102,11 +102,6 @@
     "viewInExternal": "{{name}}에서 로그 보기 (시도 {{attempt}})",
     "warning": "경고"
   },
-  "navigation": {
-    "navigation": "탐색: Shift+{{arrow}}",
-    "openGraphFilters": "태스크 필터: Ctrl+Shift+F",
-    "toggleGroup": "그룹 전환: Space"
-  },
   "notFound": {
     "back": "뒤로",
     "backToDags": "Dags로 돌아가기",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/pl/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/pl/dag.json
index a8c500309f3..0f29dda9351 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/pl/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/pl/dag.json
@@ -112,11 +112,6 @@
     "viewInExternal": "Zobacz logi w {{name}} (próba {{attempt}})",
     "warning": "WARNING"
   },
-  "navigation": {
-    "navigation": "Przewiń: {{arrow}}",
-    "openGraphFilters": "Filtry zadań: Ctrl+Shift+F",
-    "toggleGroup": "Przełącz grupę: Space"
-  },
   "notFound": {
     "back": "Wróć",
     "backToDags": "Powrót do Dagów",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/tr/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/tr/dag.json
index 7770759b5e7..60b91f86bfd 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/tr/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/tr/dag.json
@@ -106,11 +106,6 @@
     "viewInExternal": "Günlükleri {{name}} içinde görüntüle (deneme 
{{attempt}})",
     "warning": "UYARI"
   },
-  "navigation": {
-    "navigation": "Gezin: {{arrow}}",
-    "openGraphFilters": "Görev Filtreleri: Ctrl+Shift+F",
-    "toggleGroup": "Grubu aç/kapat: Space"
-  },
   "notFound": {
     "back": "Geri Dön",
     "backToDags": "Dag'lere Geri Dön",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/dag.json
index 25dcfaf83c2..6a0c4613be0 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/zh-CN/dag.json
@@ -106,11 +106,6 @@
     "viewInExternal": "在 {{name}} 中查看日志(重试 {{attempt}})",
     "warning": "WARNING"
   },
-  "navigation": {
-    "navigation": "导航: {{arrow}}",
-    "openGraphFilters": "任务筛选器: Ctrl+Shift+F",
-    "toggleGroup": "展开/收起分组: 空格键"
-  },
   "notFound": {
     "back": "返回",
     "backToDags": "返回 Dags",
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/Graph/TaskNode.tsx 
b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
index 5d8affeca30..0efe7dbbb81 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/TaskNode.tsx
@@ -19,6 +19,7 @@
 import { Box, Button, Flex, HStack, LinkOverlay, Text } from 
"@chakra-ui/react";
 import type { NodeProps, Node as NodeType } from "@xyflow/react";
 import { useTranslation } from "react-i18next";
+import { AiOutlineGroup } from "react-icons/ai";
 
 import { TaskIcon } from "src/assets/TaskIcon";
 import { StateBadge } from "src/components/StateBadge";
@@ -112,7 +113,7 @@ export const TaskNode = ({
             width={`${width + (isSelected ? 4 : 0)}px`}
           >
             <HStack>
-              <TaskIcon />
+              {isGroup ? <AiOutlineGroup /> : <TaskIcon />}
               <LinkOverlay asChild>
                 <TaskLink
                   childCount={thisChildCount}
diff --git a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts 
b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
index 593dec17c9a..6e8548fbed1 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
+++ b/airflow-core/src/airflow/ui/src/components/Graph/elkGraphUtils.ts
@@ -366,7 +366,7 @@ export const generateElkGraph = ({
     }
 
     const label = `${node.label}${node.is_mapped ? "[1000]" : 
""}${node.children ? ` + ${node.children.length} tasks` : ""}`;
-    let width = getTextWidth(label, font);
+    let width = getTextWidth(label, font) + 16; // 16 for icons
     const hasStateBar = Boolean(node.is_mapped) || Boolean(node.children);
     let height = hasStateBar ? 90 : 80;
 
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/components/TaskName.tsx 
b/airflow-core/src/airflow/ui/src/components/TaskName.tsx
index 9349cb27a51..1f35b5bfc2e 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskName.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskName.tsx
@@ -55,7 +55,6 @@ export const TaskName = ({
         fontWeight="bold"
         overflow="hidden"
         textOverflow="ellipsis"
-        title={label}
         whiteSpace="nowrap"
         {...rest}
       >
@@ -71,7 +70,6 @@ export const TaskName = ({
       fontWeight="bold"
       overflow="hidden"
       textOverflow="ellipsis"
-      title={label}
       whiteSpace="nowrap"
       {...rest}
     >
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..6109d693190 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -24,15 +24,13 @@ import {
   Portal,
   Select,
   type SelectValueChangeDetails,
-  Text,
   VStack,
 } 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";
+import { LuChartGantt } from "react-icons/lu";
 import { MdOutlineAccountTree, MdSettings } from "react-icons/md";
 import type { ImperativePanelGroupHandle } from "react-resizable-panels";
 import { useParams } from "react-router-dom";
@@ -41,11 +39,13 @@ import { useLocalStorage } from "usehooks-ts";
 import { DagVersionSelect } from "src/components/DagVersionSelect";
 import { DirectionDropdown } from "src/components/Graph/DirectionDropdown";
 import { GraphTaskFilters } from "src/components/GraphTaskFilters";
-import { IconButton, Tooltip } from "src/components/ui";
+import { IconButton } from "src/components/ui";
 import { type ButtonGroupOption, ButtonGroupToggle } from 
"src/components/ui/ButtonGroupToggle";
 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 +187,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}>
@@ -308,30 +308,11 @@ export const PanelButtons = ({
         </Flex>
       </Flex>
 
-      {dagView === "graph" ? (
-        <Flex justifyContent="flex-end" mt={1}>
-          <Flex color="fg.muted" gap={2}>
-            <Tooltip 
content={<Text>{translate("dag:navigation.openGraphFilters")}</Text>} portalled>
-              <LuKeyboard />
-            </Tooltip>
-          </Flex>
-        </Flex>
-      ) : (
+      {dagView !== "graph" && (
         <Flex justifyContent="space-between" mt={1}>
           <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/GroupTaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
index 513a3452760..8b0e4e557e9 100644
--- a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
@@ -19,7 +19,7 @@
 import { Box } from "@chakra-ui/react";
 import type { ReactNode } from "react";
 import { useTranslation } from "react-i18next";
-import { MdOutlineTask } from "react-icons/md";
+import { AiOutlineGroup } from "react-icons/ai";
 
 import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
 import { ClearTaskInstanceButton } from "src/components/Clear";
@@ -61,7 +61,7 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: LightGridTaskI
             <MarkTaskGroupAsButton groupTaskInstance={taskInstance} 
isHotkeyEnabled />
           </>
         }
-        icon={<MdOutlineTask />}
+        icon={<AiOutlineGroup />}
         state={taskInstance.state}
         stats={stats}
         subTitle={<Time datetime={taskInstance.min_start_date} />}
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 e74dd9b62de..8f57b096e3c 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";
@@ -146,8 +147,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