mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-02 22:02:13 +08:00
feat(frontend): add Cmd+K command palette and keyboard shortcuts (#1230)
* feat(frontend): add Cmd+K command palette and keyboard shortcuts Wire up the existing shadcn/ui Command component as a global command palette. Adds a useGlobalShortcuts hook for Cmd+K (palette), Cmd+Shift+N (new chat), Cmd+, (settings), and Cmd+/ (shortcuts help overlay). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(frontend): address Copilot review feedback on command palette - Normalize event.key with toLowerCase() for reliable Shift+key matching - Replace dead deerflow:open-settings event with router.push navigation - Use platform-appropriate Shift label (Shift+ on Windows/Linux, glyph on Mac) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
@@ -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({
|
||||
<WorkspaceSidebar />
|
||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<CommandPalette />
|
||||
<Toaster position="top-center" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
125
frontend/src/components/workspace/command-palette.tsx
Normal file
125
frontend/src/components/workspace/command-palette.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder={t.shortcuts.searchActions} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t.shortcuts.noResults}</CommandEmpty>
|
||||
<CommandGroup heading={t.shortcuts.actions}>
|
||||
<CommandItem onSelect={handleNewChat}>
|
||||
<MessageSquarePlusIcon className="mr-2 h-4 w-4" />
|
||||
{t.sidebar.newChat}
|
||||
<CommandShortcut>{metaKey}{shiftKey}N</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenSettings}>
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
{t.common.settings}
|
||||
<CommandShortcut>{metaKey},</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleShowShortcuts}>
|
||||
<KeyboardIcon className="mr-2 h-4 w-4" />
|
||||
{t.shortcuts.keyboardShortcuts}
|
||||
<CommandShortcut>{metaKey}/</CommandShortcut>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
|
||||
<Dialog open={shortcutsOpen} onOpenChange={setShortcutsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.shortcuts.keyboardShortcuts}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.shortcuts.keyboardShortcutsDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={keys} className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<kbd className="bg-muted text-muted-foreground rounded px-2 py-0.5 font-mono text-xs">
|
||||
{keys}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -261,6 +261,17 @@ export const zhCN: Translations = {
|
||||
failed: "子任务失败",
|
||||
},
|
||||
|
||||
// Shortcuts
|
||||
shortcuts: {
|
||||
searchActions: "搜索操作...",
|
||||
noResults: "未找到结果。",
|
||||
actions: "操作",
|
||||
keyboardShortcuts: "键盘快捷键",
|
||||
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 DeerFlow。",
|
||||
openCommandPalette: "打开命令面板",
|
||||
toggleSidebar: "切换侧边栏",
|
||||
},
|
||||
|
||||
// Settings
|
||||
settings: {
|
||||
title: "设置",
|
||||
|
||||
53
frontend/src/hooks/use-global-shortcuts.ts
Normal file
53
frontend/src/hooks/use-global-shortcuts.ts
Normal file
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user