mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-28 00:04:47 +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 { Toaster } from "sonner";
|
||||||
|
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
|
import { CommandPalette } from "@/components/workspace/command-palette";
|
||||||
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
|
||||||
import { getLocalSettings, useLocalSettings } from "@/core/settings";
|
import { getLocalSettings, useLocalSettings } from "@/core/settings";
|
||||||
|
|
||||||
@@ -39,6 +40,7 @@ export default function WorkspaceLayout({
|
|||||||
<WorkspaceSidebar />
|
<WorkspaceSidebar />
|
||||||
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
<SidebarInset className="min-w-0">{children}</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
<CommandPalette />
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
</QueryClientProvider>
|
</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",
|
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
|
||||||
settings: {
|
settings: {
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
|
|||||||
@@ -211,6 +211,17 @@ export interface Translations {
|
|||||||
failed: string;
|
failed: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shortcuts
|
||||||
|
shortcuts: {
|
||||||
|
searchActions: string;
|
||||||
|
noResults: string;
|
||||||
|
actions: string;
|
||||||
|
keyboardShortcuts: string;
|
||||||
|
keyboardShortcutsDescription: string;
|
||||||
|
openCommandPalette: string;
|
||||||
|
toggleSidebar: string;
|
||||||
|
};
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -261,6 +261,17 @@ export const zhCN: Translations = {
|
|||||||
failed: "子任务失败",
|
failed: "子任务失败",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Shortcuts
|
||||||
|
shortcuts: {
|
||||||
|
searchActions: "搜索操作...",
|
||||||
|
noResults: "未找到结果。",
|
||||||
|
actions: "操作",
|
||||||
|
keyboardShortcuts: "键盘快捷键",
|
||||||
|
keyboardShortcutsDescription: "使用键盘快捷键更快地操作 DeerFlow。",
|
||||||
|
openCommandPalette: "打开命令面板",
|
||||||
|
toggleSidebar: "切换侧边栏",
|
||||||
|
},
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
settings: {
|
settings: {
|
||||||
title: "设置",
|
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