Keyboard shortcut support (#3890)

* Add keyboard shortcuts help feature

* Minor lint fixes

* Enhance keyboard shortcuts feature by updating help shortcut and adding keyboard shortcuts button in Quick Links. Include new translation for keyboard shortcuts in locale.

* Added documentation of keyboard shortcuts

* refactor keyboard shortcuts to not render on every page (like login)
limit shortcuts to admins
update locales
remove keyboard shortcut button - move to resources
remove readme entry

* move translation key

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
This commit is contained in:
Aashish Anand 2025-05-27 14:25:39 -07:00 committed by GitHub
parent 07e5b70f13
commit 2cabb2fad8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 249 additions and 10 deletions

View File

@ -17,6 +17,7 @@ import { PfpProvider } from "./PfpContext";
import { LogoProvider } from "./LogoContext";
import { FullScreenLoader } from "./components/Preloader";
import { ThemeProvider } from "./ThemeContext";
import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp";
const Main = lazy(() => import("@/pages/Main"));
const InvitePage = lazy(() => import("@/pages/Invite"));
@ -269,6 +270,7 @@ export default function App() {
/>
</Routes>
<ToastContainer />
<KeyboardShortcutsHelp />
</I18nextProvider>
</PfpProvider>
</LogoProvider>

View File

@ -0,0 +1,60 @@
import React, { useEffect, useState } from "react";
import { X } from "@phosphor-icons/react";
import { useTranslation } from "react-i18next";
import {
SHORTCUTS,
isMac,
KEYBOARD_SHORTCUTS_HELP_EVENT,
} from "@/utils/keyboardShortcuts";
export default function KeyboardShortcutsHelp() {
const [isOpen, setIsOpen] = useState(false);
const { t } = useTranslation();
useEffect(() => {
window.addEventListener(KEYBOARD_SHORTCUTS_HELP_EVENT, () =>
setIsOpen((prev) => !prev)
);
return () => {
window.removeEventListener(KEYBOARD_SHORTCUTS_HELP_EVENT, () =>
setIsOpen(false)
);
};
}, []);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-auto bg-black bg-opacity-50 flex items-center justify-center">
<div className="relative bg-theme-bg-secondary rounded-lg p-6 max-w-2xl w-full mx-4">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-white">
{t("keyboard-shortcuts.title")}
</h2>
<button
onClick={() => setIsOpen(false)}
className="text-white hover:text-gray-300"
aria-label="Close"
>
<X size={24} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(SHORTCUTS).map(([key, shortcut]) => (
<div
key={key}
className="flex items-center justify-between p-3 bg-theme-bg-hover rounded-lg"
>
<span className="text-white">
{t(`keyboard-shortcuts.shortcuts.${shortcut.translationKey}`)}
</span>
<kbd className="px-2 py-1 bg-theme-bg-secondary text-white rounded border border-gray-600">
{isMac ? key : key.replace("⌘", "Ctrl")}
</kbd>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -7,6 +7,7 @@ import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants";
import { userFromStorage } from "@/utils/request";
import System from "@/models/system";
import UserMenu from "../UserMenu";
import { KeyboardShortcutWrapper } from "@/utils/keyboardShortcuts";
// Used only for Multi-user mode only as we permission specific pages based on auth role.
// When in single user mode we just bypass any authchecks.
@ -95,11 +96,15 @@ export function AdminRoute({ Component, hideUserMenu = false }) {
const user = userFromStorage();
return isAuthd && (user?.role === "admin" || !multiUserMode) ? (
hideUserMenu ? (
<Component />
) : (
<UserMenu>
<KeyboardShortcutWrapper>
<Component />
</UserMenu>
</KeyboardShortcutWrapper>
) : (
<KeyboardShortcutWrapper>
<UserMenu>
<Component />
</UserMenu>
</KeyboardShortcutWrapper>
)
) : (
<Navigate to={paths.home()} />
@ -119,9 +124,11 @@ export function ManagerRoute({ Component }) {
const user = userFromStorage();
return isAuthd && (user?.role !== "default" || !multiUserMode) ? (
<UserMenu>
<Component />
</UserMenu>
<KeyboardShortcutWrapper>
<UserMenu>
<Component />
</UserMenu>
</KeyboardShortcutWrapper>
) : (
<Navigate to={paths.home()} />
);
@ -136,9 +143,11 @@ export default function PrivateRoute({ Component }) {
}
return isAuthd ? (
<UserMenu>
<Component />
</UserMenu>
<KeyboardShortcutWrapper>
<UserMenu>
<Component />
</UserMenu>
</KeyboardShortcutWrapper>
) : (
<Navigate to={paths.login(true)} />
);

View File

@ -233,6 +233,7 @@ const TRANSLATIONS = {
docs: "Docs",
star: "Star on Github",
},
keyboardShortcuts: "Keyboard Shortcuts",
},
},
@ -981,6 +982,20 @@ const TRANSLATIONS = {
theme: "Theme Preference",
language: "Preferred language",
},
"keyboard-shortcuts": {
title: "Keyboard Shortcuts",
shortcuts: {
settings: "Open Settings",
workspaceSettings: "Open Current Workspace Settings",
home: "Go to Home",
workspaces: "Manage Workspaces",
apiKeys: "API Keys Settings",
llmPreferences: "LLM Preferences",
chatSettings: "Chat Settings",
help: "Show keyboard shortcuts help",
},
},
};
export default TRANSLATIONS;

View File

@ -1,9 +1,16 @@
import paths from "@/utils/paths";
import { ArrowCircleUpRight } from "@phosphor-icons/react";
import { useTranslation } from "react-i18next";
import { KEYBOARD_SHORTCUTS_HELP_EVENT } from "@/utils/keyboardShortcuts";
export default function Resources() {
const { t } = useTranslation();
const showKeyboardShortcuts = () => {
window.dispatchEvent(
new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, { detail: { show: true } })
);
};
return (
<div>
<h1 className="text-theme-home-text uppercase text-sm font-semibold mb-4">
@ -28,6 +35,13 @@ export default function Resources() {
{t("main-page.resources.links.star")}
<ArrowCircleUpRight weight="fill" size={16} />
</a>
<button
onClick={showKeyboardShortcuts}
className="text-theme-home-text text-sm flex items-center gap-x-2 hover:opacity-70"
>
{t("main-page.resources.keyboardShortcuts")}
<ArrowCircleUpRight weight="fill" size={16} />
</button>
</div>
</div>
);

View File

@ -0,0 +1,139 @@
import paths from "./paths";
import { useEffect, useState } from "react";
import { userFromStorage } from "./request";
export const KEYBOARD_SHORTCUTS_HELP_EVENT = "keyboard-shortcuts-help";
export const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
export const SHORTCUTS = {
"⌘ + ,": {
translationKey: "settings",
action: () => {
window.location.href = paths.settings.interface();
},
},
"⌘ + H": {
translationKey: "home",
action: () => {
window.location.href = paths.home();
},
},
"⌘ + I": {
translationKey: "workspaces",
action: () => {
window.location.href = paths.settings.workspaces();
},
},
"⌘ + K": {
translationKey: "apiKeys",
action: () => {
window.location.href = paths.settings.apiKeys();
},
},
"⌘ + L": {
translationKey: "llmPreferences",
action: () => {
window.location.href = paths.settings.llmPreference();
},
},
"⌘ + Shift + C": {
translationKey: "chatSettings",
action: () => {
window.location.href = paths.settings.chat();
},
},
"⌘ + Shift + ?": {
translationKey: "help",
action: () => {
window.dispatchEvent(
new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, {
detail: { show: true },
})
);
},
},
F1: {
translationKey: "help",
action: () => {
window.dispatchEvent(
new CustomEvent(KEYBOARD_SHORTCUTS_HELP_EVENT, {
detail: { show: true },
})
);
},
},
};
const LISTENERS = {};
const modifier = isMac ? "meta" : "ctrl";
for (const key in SHORTCUTS) {
const listenerKey = key
.replace("⌘", modifier)
.replaceAll(" ", "")
.toLowerCase();
LISTENERS[listenerKey] = SHORTCUTS[key].action;
}
// Convert keyboard event to shortcut key
function getShortcutKey(event) {
let key = "";
if (event.metaKey || event.ctrlKey) key += modifier + "+";
if (event.shiftKey) key += "shift+";
if (event.altKey) key += "alt+";
// Handle special keys
if (event.key === ",") key += ",";
// Handle question mark or slash for help shortcut
else if (event.key === "?" || event.key === "/") key += "?";
else if (event.key === "Control")
return ""; // Ignore Control key by itself
else if (event.key === "Shift")
return ""; // Ignore Shift key by itself
else key += event.key.toLowerCase();
return key;
}
// Initialize keyboard shortcuts
export function initKeyboardShortcuts() {
function handleKeyDown(event) {
const shortcutKey = getShortcutKey(event);
if (!shortcutKey) return;
const action = LISTENERS[shortcutKey];
if (action) {
event.preventDefault();
action();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}
function useKeyboardShortcuts() {
useEffect(() => {
// If there is a user and the user is not an admin do not register the event listener
// since some of the shortcuts are only available in multi-user mode as admin
const user = userFromStorage();
if (!!user && user?.role !== "admin") return;
function handleHelpEvent(e) {
setShowHelp(e.detail.show);
}
window.addEventListener(KEYBOARD_SHORTCUTS_HELP_EVENT, handleHelpEvent);
const cleanup = initKeyboardShortcuts();
return () => {
cleanup();
window.removeEventListener(
KEYBOARD_SHORTCUTS_HELP_EVENT,
handleHelpEvent
);
};
}, []);
return;
}
export function KeyboardShortcutWrapper({ children }) {
useKeyboardShortcuts();
return children;
}