diff --git a/frontend/src/app/workspace/layout.tsx b/frontend/src/app/workspace/layout.tsx index 57a45be..417c933 100644 --- a/frontend/src/app/workspace/layout.tsx +++ b/frontend/src/app/workspace/layout.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useLayoutEffect, useState } from "react"; import { Toaster } from "sonner"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { CommandPalette } from "@/components/workspace/command-palette"; import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; import { getLocalSettings, useLocalSettings } from "@/core/settings"; @@ -39,6 +40,7 @@ export default function WorkspaceLayout({ {children} + ); diff --git a/frontend/src/components/workspace/command-palette.tsx b/frontend/src/components/workspace/command-palette.tsx new file mode 100644 index 0000000..3c6f0aa --- /dev/null +++ b/frontend/src/components/workspace/command-palette.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { + KeyboardIcon, + MessageSquarePlusIcon, + SettingsIcon, +} from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useMemo, useState } from "react"; + +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandShortcut, +} from "@/components/ui/command"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { useI18n } from "@/core/i18n/hooks"; +import { useGlobalShortcuts } from "@/hooks/use-global-shortcuts"; + +export function CommandPalette() { + const { t } = useI18n(); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [shortcutsOpen, setShortcutsOpen] = useState(false); + + const handleNewChat = useCallback(() => { + router.push("/workspace/chats/new"); + setOpen(false); + }, [router]); + + const handleOpenSettings = useCallback(() => { + router.push("/workspace/settings"); + setOpen(false); + }, [router]); + + const handleShowShortcuts = useCallback(() => { + setOpen(false); + setShortcutsOpen(true); + }, []); + + const shortcuts = useMemo( + () => [ + { key: "k", meta: true, action: () => setOpen((o) => !o) }, + { key: "n", meta: true, shift: true, action: handleNewChat }, + { key: ",", meta: true, action: handleOpenSettings }, + { key: "/", meta: true, action: handleShowShortcuts }, + ], + [handleNewChat, handleOpenSettings, handleShowShortcuts], + ); + + useGlobalShortcuts(shortcuts); + + const isMac = + typeof navigator !== "undefined" && navigator.userAgent.includes("Mac"); + const metaKey = isMac ? "⌘" : "Ctrl+"; + const shiftKey = isMac ? "⇧" : "Shift+"; + + return ( + <> + + + + {t.shortcuts.noResults} + + + + {t.sidebar.newChat} + {metaKey}{shiftKey}N + + + + {t.common.settings} + {metaKey}, + + + + {t.shortcuts.keyboardShortcuts} + {metaKey}/ + + + + + + + + + {t.shortcuts.keyboardShortcuts} + + {t.shortcuts.keyboardShortcutsDescription} + + +
+ {[ + { keys: `${metaKey}K`, label: t.shortcuts.openCommandPalette }, + { keys: `${metaKey}${shiftKey}N`, label: t.sidebar.newChat }, + { keys: `${metaKey}B`, label: t.shortcuts.toggleSidebar }, + { keys: `${metaKey},`, label: t.common.settings }, + { + keys: `${metaKey}/`, + label: t.shortcuts.keyboardShortcuts, + }, + ].map(({ keys, label }) => ( +
+ {label} + + {keys} + +
+ ))} +
+
+
+ + ); +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 9af241f..4a99dfd 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -274,6 +274,17 @@ export const enUS: Translations = { failed: "Subtask failed", }, + // Shortcuts + shortcuts: { + searchActions: "Search actions...", + noResults: "No results found.", + actions: "Actions", + keyboardShortcuts: "Keyboard Shortcuts", + keyboardShortcutsDescription: "Navigate DeerFlow faster with keyboard shortcuts.", + openCommandPalette: "Open Command Palette", + toggleSidebar: "Toggle Sidebar", + }, + // Settings settings: { title: "Settings", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 9d68901..b9525e4 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -211,6 +211,17 @@ export interface Translations { failed: string; }; + // Shortcuts + shortcuts: { + searchActions: string; + noResults: string; + actions: string; + keyboardShortcuts: string; + keyboardShortcutsDescription: string; + openCommandPalette: string; + toggleSidebar: string; + }; + // Settings settings: { title: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 0a33997..80ab890 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -261,6 +261,17 @@ export const zhCN: Translations = { failed: "子任务失败", }, + // Shortcuts + shortcuts: { + searchActions: "搜索操作...", + noResults: "未找到结果。", + actions: "操作", + keyboardShortcuts: "键盘快捷键", + keyboardShortcutsDescription: "使用键盘快捷键更快地操作 DeerFlow。", + openCommandPalette: "打开命令面板", + toggleSidebar: "切换侧边栏", + }, + // Settings settings: { title: "设置", diff --git a/frontend/src/hooks/use-global-shortcuts.ts b/frontend/src/hooks/use-global-shortcuts.ts new file mode 100644 index 0000000..f042358 --- /dev/null +++ b/frontend/src/hooks/use-global-shortcuts.ts @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect } from "react"; + +type ShortcutAction = () => void; + +interface Shortcut { + key: string; + meta: boolean; + shift?: boolean; + action: ShortcutAction; +} + +/** + * Register global keyboard shortcuts on window. + * Shortcuts are suppressed when focus is inside an input, textarea, or + * contentEditable element - except for Cmd+K which always fires. + */ +export function useGlobalShortcuts(shortcuts: Shortcut[]) { + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + const meta = event.metaKey || event.ctrlKey; + + for (const shortcut of shortcuts) { + if ( + event.key.toLowerCase() === shortcut.key.toLowerCase() && + meta === shortcut.meta && + (shortcut.shift ?? false) === event.shiftKey + ) { + // Allow Cmd+K even in inputs (standard command palette behavior) + if (shortcut.key !== "k") { + const target = event.target as HTMLElement; + const tag = target.tagName; + if ( + tag === "INPUT" || + tag === "TEXTAREA" || + target.isContentEditable + ) { + continue; + } + } + + event.preventDefault(); + shortcut.action(); + return; + } + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [shortcuts]); +}