diff --git a/src/App.tsx b/src/App.tsx index 17eecb645..04ec854eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import { useProviderActions } from "@/hooks/useProviderActions"; import { useProxyStatus } from "@/hooks/useProxyStatus"; import { useLastValidValue } from "@/hooks/useLastValidValue"; import { extractErrorMessage } from "@/utils/errorUtils"; +import { isTextEditableTarget } from "@/utils/domUtils"; import { cn } from "@/lib/utils"; import { isWindows, isLinux } from "@/lib/platform"; import { AppSwitcher } from "@/components/AppSwitcher"; @@ -291,18 +292,40 @@ function App() { checkEnvOnSwitch(); }, [activeApp]); + // 全局键盘快捷键 + const currentViewRef = useRef(currentView); + useEffect(() => { - const handleGlobalShortcut = (event: KeyboardEvent) => { - if (event.key !== "," || !(event.metaKey || event.ctrlKey)) { + currentViewRef.current = currentView; + }, [currentView]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Cmd/Ctrl + , 打开设置 + if (event.key === "," && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + setCurrentView("settings"); return; } + + // ESC 键返回 + if (event.key !== "Escape" || event.defaultPrevented) return; + + // 如果有模态框打开(通过 overflow hidden 判断),则不处理全局 ESC,交给模态框处理 + if (document.body.style.overflow === "hidden") return; + + const view = currentViewRef.current; + if (view === "providers") return; + + if (isTextEditableTarget(event.target)) return; + event.preventDefault(); - setCurrentView("settings"); + setCurrentView(view === "skillsDiscovery" ? "skills" : "providers"); }; - window.addEventListener("keydown", handleGlobalShortcut); + window.addEventListener("keydown", handleKeyDown); return () => { - window.removeEventListener("keydown", handleGlobalShortcut); + window.removeEventListener("keydown", handleKeyDown); }; }, []); diff --git a/src/components/common/FullScreenPanel.tsx b/src/components/common/FullScreenPanel.tsx index ecd6e37d3..d4df73ed8 100644 --- a/src/components/common/FullScreenPanel.tsx +++ b/src/components/common/FullScreenPanel.tsx @@ -4,6 +4,7 @@ import { motion, AnimatePresence } from "framer-motion"; import { ArrowLeft } from "lucide-react"; import { Button } from "@/components/ui/button"; import { isWindows, isLinux } from "@/lib/platform"; +import { isTextEditableTarget } from "@/utils/domUtils"; interface FullScreenPanelProps { isOpen: boolean; @@ -37,6 +38,39 @@ export const FullScreenPanel: React.FC = ({ }; }, [isOpen]); + // ESC 键关闭面板 + const onCloseRef = React.useRef(onClose); + + React.useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + React.useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + // 子组件(例如 Radix 的 Select/Dialog/Dropdown)如果已经消费了 ESC,就不要再关闭整个面板 + if (event.defaultPrevented) { + return; + } + + if (isTextEditableTarget(event.target)) { + return; // 让输入框自己处理 ESC(比如清空、失焦等) + } + + event.stopPropagation(); // 阻止事件继续冒泡到 window,避免触发 App.tsx 的全局监听 + onCloseRef.current(); + } + }; + + // 使用冒泡阶段监听,让子组件(如 Radix UI)优先处理 ESC + window.addEventListener("keydown", handleKeyDown, false); + return () => { + window.removeEventListener("keydown", handleKeyDown, false); + }; + }, [isOpen]); + return createPortal( {isOpen && ( diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 07d7122dc..b852a10c4 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; interface UseModelStateProps { settingsConfig: string; @@ -19,12 +19,27 @@ export function useModelState({ const [defaultSonnetModel, setDefaultSonnetModel] = useState(""); const [defaultOpusModel, setDefaultOpusModel] = useState(""); + const isUserEditingRef = useRef(false); + const lastConfigRef = useRef(settingsConfig); + // 初始化读取:读新键;若缺失,按兼容优先级回退 // Haiku: DEFAULT_HAIKU || SMALL_FAST || MODEL // Sonnet: DEFAULT_SONNET || MODEL || SMALL_FAST // Opus: DEFAULT_OPUS || MODEL || SMALL_FAST // 仅在 settingsConfig 变化时同步一次(表单加载/切换预设时) useEffect(() => { + if (lastConfigRef.current === settingsConfig) { + return; + } + + if (isUserEditingRef.current) { + isUserEditingRef.current = false; + lastConfigRef.current = settingsConfig; + return; + } + + lastConfigRef.current = settingsConfig; + try { const cfg = settingsConfig ? JSON.parse(settingsConfig) : {}; const env = cfg?.env || {}; @@ -71,6 +86,8 @@ export function useModelState({ | "ANTHROPIC_DEFAULT_OPUS_MODEL", value: string, ) => { + isUserEditingRef.current = true; + if (field === "ANTHROPIC_MODEL") setClaudeModel(value); if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value); if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL") diff --git a/src/utils/domUtils.ts b/src/utils/domUtils.ts new file mode 100644 index 000000000..e74de4830 --- /dev/null +++ b/src/utils/domUtils.ts @@ -0,0 +1,11 @@ +export function isTextEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + + const tagName = target.tagName; + return ( + tagName === "INPUT" || + tagName === "TEXTAREA" || + tagName === "SELECT" || + target.isContentEditable + ); +}