From d839716e0cfd50ab1402df185cd72fb25c39e827 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Feb 2026 11:50:39 +0700 Subject: [PATCH] Fix Cmd+K focus restore and focus input --- .../app/src/components/agent-input-area.tsx | 16 ++++++++ .../app/src/components/command-center.tsx | 34 ++++++++++++++++- .../app/src/hooks/use-global-keyboard-nav.ts | 23 ++++++++++++ packages/app/src/stores/keyboard-nav-store.ts | 37 ++++++++++++++++++- 4 files changed, 106 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 43eb98b44..56292bde9 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -28,6 +28,7 @@ import { Theme } from "@/styles/theme"; import { CommandAutocomplete } from "./command-autocomplete"; import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query"; import { encodeImages } from "@/utils/encode-images"; +import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; type QueuedMessage = { id: string; @@ -98,6 +99,8 @@ export function AgentInputArea({ const [selectedImages, setSelectedImages] = useState([]); const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [commandSelectedIndex, setCommandSelectedIndex] = useState(0); + const focusChatInputRequest = useKeyboardNavStore((s) => s.focusChatInputRequest); + const clearFocusChatInputRequest = useKeyboardNavStore((s) => s.clearFocusChatInputRequest); // Command autocomplete logic const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); @@ -398,6 +401,19 @@ export function AgentInputArea({ queueMessage(payload.text, payload.images); }, []); + useEffect(() => { + const req = focusChatInputRequest; + if (!req) return; + if (req.agentKey !== `${serverId}:${agentId}`) return; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + messageInputRef.current?.focus(); + clearFocusChatInputRequest(); + }); + }); + }, [agentId, clearFocusChatInputRequest, focusChatInputRequest, serverId]); + // Handle command selection from autocomplete const handleCommandSelect = useCallback( (cmd: { name: string; description: string; argumentHint: string }) => { diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index 8849f3e4c..441c6671d 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -48,7 +48,11 @@ export function CommandCenter() { const { agents } = useAggregatedAgents(); const open = useKeyboardNavStore((s) => s.commandCenterOpen); const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen); + const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput); + const takeFocusRestoreElement = useKeyboardNavStore((s) => s.takeFocusRestoreElement); const inputRef = useRef(null); + const didNavigateRef = useRef(false); + const prevOpenRef = useRef(open); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); @@ -59,17 +63,39 @@ export function CommandCenter() { }, [agents, query]); useEffect(() => { + const prevOpen = prevOpenRef.current; + prevOpenRef.current = open; + if (!open) { setQuery(""); setActiveIndex(0); + + if (prevOpen && !didNavigateRef.current) { + const el = takeFocusRestoreElement(); + if (el && el.isConnected) { + // Modal unmount can steal focus; restore on next tick. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + try { + el.focus(); + } catch { + // ignore + } + }); + }); + } + } + return; } + didNavigateRef.current = false; + const id = setTimeout(() => { inputRef.current?.focus(); }, 0); return () => clearTimeout(id); - }, [open]); + }, [open, takeFocusRestoreElement]); useEffect(() => { if (!open) return; @@ -84,16 +110,20 @@ export function CommandCenter() { const handleSelect = useCallback( (agent: AggregatedAgent) => { + didNavigateRef.current = true; const session = useSessionStore.getState().sessions[agent.serverId]; session?.client?.clearAgentAttention(agent.id); const shouldReplace = pathname.startsWith("/agent/"); const navigate = shouldReplace ? router.replace : router.push; + requestFocusChatInput(`${agent.serverId}:${agent.id}`); + // Don't restore focus back to the prior element after we navigate. + takeFocusRestoreElement(); setOpen(false); navigate(`/agent/${agent.serverId}/${agent.id}` as any); }, - [pathname, setOpen] + [pathname, requestFocusChatInput, setOpen, takeFocusRestoreElement] ); useEffect(() => { diff --git a/packages/app/src/hooks/use-global-keyboard-nav.ts b/packages/app/src/hooks/use-global-keyboard-nav.ts index 7aa35e682..d85cc121f 100644 --- a/packages/app/src/hooks/use-global-keyboard-nav.ts +++ b/packages/app/src/hooks/use-global-keyboard-nav.ts @@ -34,6 +34,17 @@ export function useGlobalKeyboardNav({ return true; }; + const isEditableTarget = (event: KeyboardEvent): boolean => { + const target = event.target; + if (!(target instanceof Element)) return false; + + if ((target as HTMLElement).isContentEditable) return true; + const tag = target.tagName.toLowerCase(); + if (tag === "input" || tag === "textarea") return true; + + return false; + }; + const parseShortcutDigit = (event: KeyboardEvent): number | null => { const code = event.code ?? ""; if (code.startsWith("Digit")) { @@ -89,6 +100,11 @@ export function useGlobalKeyboardNav({ (event.metaKey || event.ctrlKey) && (event.code === "KeyB" || lowerKey === "b") ) { + // The MessageInput already handles Cmd+B inside editable fields. If we also + // handle it globally, it can double-toggle and look like it "doesn't work". + if (isEditableTarget(event)) { + return; + } event.preventDefault(); toggleAgentList(); return; @@ -101,6 +117,9 @@ export function useGlobalKeyboardNav({ (event.metaKey || event.ctrlKey) && (event.code === "KeyE" || lowerKey === "e") ) { + if (isEditableTarget(event)) { + return; + } event.preventDefault(); toggleFileExplorer(); return; @@ -110,6 +129,10 @@ export function useGlobalKeyboardNav({ if ((event.metaKey || event.ctrlKey) && lowerKey === "k") { event.preventDefault(); const s = useKeyboardNavStore.getState(); + if (!s.commandCenterOpen) { + const active = document.activeElement; + s.setFocusRestoreElement(active instanceof HTMLElement ? active : null); + } s.setCommandCenterOpen(!s.commandCenterOpen); return; } diff --git a/packages/app/src/stores/keyboard-nav-store.ts b/packages/app/src/stores/keyboard-nav-store.ts index 52763e464..d704758f8 100644 --- a/packages/app/src/stores/keyboard-nav-store.ts +++ b/packages/app/src/stores/keyboard-nav-store.ts @@ -1,5 +1,10 @@ import { create } from "zustand"; +type FocusChatInputRequest = { + id: number; + agentKey: string; +}; + interface KeyboardNavState { commandCenterOpen: boolean; altDown: boolean; @@ -7,6 +12,19 @@ interface KeyboardNavState { /** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */ sidebarShortcutAgentKeys: string[]; + /** + * Web-only focus restore element used by the command center. Stored when opening + * Cmd/Ctrl+K so we can restore focus when closing without navigating. + */ + focusRestoreElement: HTMLElement | null; + setFocusRestoreElement: (el: HTMLElement | null) => void; + takeFocusRestoreElement: () => HTMLElement | null; + + /** Web-only request to focus the MessageInput for the selected agent. */ + focusChatInputRequest: FocusChatInputRequest | null; + requestFocusChatInput: (agentKey: string) => void; + clearFocusChatInputRequest: () => void; + setCommandCenterOpen: (open: boolean) => void; setAltDown: (down: boolean) => void; setCmdOrCtrlDown: (down: boolean) => void; @@ -14,16 +32,31 @@ interface KeyboardNavState { resetModifiers: () => void; } -export const useKeyboardNavStore = create((set) => ({ +export const useKeyboardNavStore = create((set, get) => ({ commandCenterOpen: false, altDown: false, cmdOrCtrlDown: false, sidebarShortcutAgentKeys: [], + focusRestoreElement: null, + setFocusRestoreElement: (el) => set({ focusRestoreElement: el }), + takeFocusRestoreElement: () => { + const el = get().focusRestoreElement; + set({ focusRestoreElement: null }); + return el; + }, + + focusChatInputRequest: null, + requestFocusChatInput: (agentKey) => { + const prev = get().focusChatInputRequest; + const id = (prev?.id ?? 0) + 1; + set({ focusChatInputRequest: { id, agentKey } }); + }, + clearFocusChatInputRequest: () => set({ focusChatInputRequest: null }), + setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), setAltDown: (down) => set({ altDown: down }), setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }), setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }), resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }), })); -