import { useEffect } from "react"; import { Platform } from "react-native"; import { usePathname, useRouter } from "expo-router"; import { getIsTauri } from "@/constants/layout"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts"; export function useGlobalKeyboardNav({ enabled, isMobile, toggleAgentList, selectedAgentId, toggleFileExplorer, }: { enabled: boolean; isMobile: boolean; toggleAgentList: () => void; selectedAgentId?: string; toggleFileExplorer?: () => void; }) { const router = useRouter(); const pathname = usePathname(); const resetModifiers = useKeyboardNavStore((s) => s.resetModifiers); useEffect(() => { if (!enabled) return; if (Platform.OS !== "web") return; if (isMobile) return; const isTauri = getIsTauri(); const shouldHandle = () => { if (typeof document === "undefined") return false; if (document.visibilityState !== "visible") return false; return true; }; const parseShortcutDigit = (event: KeyboardEvent): number | null => { const code = event.code ?? ""; if (code.startsWith("Digit")) { const n = Number(code.slice("Digit".length)); return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null; } if (code.startsWith("Numpad")) { const n = Number(code.slice("Numpad".length)); return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null; } const key = event.key ?? ""; if (key >= "1" && key <= "9") { return Number(key); } return null; }; const navigateToSidebarShortcut = (digit: number) => { const state = useKeyboardNavStore.getState(); const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null; if (!targetKey) { return; } const parsed = parseSidebarAgentKey(targetKey); if (!parsed) { return; } const { serverId, agentId } = parsed; const shouldReplace = pathname.startsWith("/agent/"); const navigate = shouldReplace ? router.replace : router.push; navigate(`/agent/${serverId}/${agentId}` as any); }; const handleKeyDown = (event: KeyboardEvent) => { if (!shouldHandle()) { return; } const key = event.key ?? ""; const lowerKey = key.toLowerCase(); const target = event.target as unknown; const isEditableTarget = typeof HTMLElement !== "undefined" && target instanceof HTMLElement && (target.tagName === "TEXTAREA" || target.tagName === "INPUT" || target.isContentEditable); if (key === "Alt") { useKeyboardNavStore.getState().setAltDown(true); } if (isTauri && (key === "Meta" || key === "Control")) { useKeyboardNavStore.getState().setCmdOrCtrlDown(true); } // Cmd+B: toggle sidebar if ( (event.metaKey || event.ctrlKey) && (event.code === "KeyB" || lowerKey === "b") ) { // When focus is in the chat input, MessageInput handles Cmd+B (and prevents default) // via TextInput onKeyPress. If we also toggle here, it flips twice and appears "broken". if (isEditableTarget) { return; } event.preventDefault(); toggleAgentList(); return; } // Cmd+E: toggle explorer sidebar (only when an agent is selected) if ( selectedAgentId && toggleFileExplorer && (event.metaKey || event.ctrlKey) && (event.code === "KeyE" || lowerKey === "e") ) { // Same double-toggle issue as Cmd+B when focus is inside a text input. if (isEditableTarget) { return; } event.preventDefault(); toggleFileExplorer(); return; } // Cmd+K: command center if ((event.metaKey || event.ctrlKey) && lowerKey === "k") { event.preventDefault(); const s = useKeyboardNavStore.getState(); s.setCommandCenterOpen(!s.commandCenterOpen); return; } // Number switching: ignore while command center is open. if (useKeyboardNavStore.getState().commandCenterOpen) { return; } const digit = parseShortcutDigit(event); if (!digit) { return; } // Alt/Option+number: always (web + Tauri) if (event.altKey) { event.preventDefault(); navigateToSidebarShortcut(digit); return; } // Cmd/Ctrl+number: Tauri only (avoid browser tab switching) if (isTauri && (event.metaKey || event.ctrlKey)) { event.preventDefault(); navigateToSidebarShortcut(digit); } }; const handleKeyUp = (event: KeyboardEvent) => { const key = event.key ?? ""; if (key === "Alt") { useKeyboardNavStore.getState().setAltDown(false); } if (isTauri && (key === "Meta" || key === "Control")) { useKeyboardNavStore.getState().setCmdOrCtrlDown(false); } }; const handleBlurOrHide = () => { resetModifiers(); }; // react-native-web can stop propagation on key events, so listen in capture phase. window.addEventListener("keydown", handleKeyDown, true); window.addEventListener("keyup", handleKeyUp, true); window.addEventListener("blur", handleBlurOrHide); document.addEventListener("visibilitychange", handleBlurOrHide); return () => { window.removeEventListener("keydown", handleKeyDown, true); window.removeEventListener("keyup", handleKeyUp, true); window.removeEventListener("blur", handleBlurOrHide); document.removeEventListener("visibilitychange", handleBlurOrHide); }; }, [ enabled, isMobile, pathname, resetModifiers, router, selectedAgentId, toggleAgentList, toggleFileExplorer, ]); }