import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TextInput } from "react-native"; import { router, usePathname, type Href } from "expo-router"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { useHosts } from "@/runtime/host-runtime"; import { useAllAgentsList } from "@/hooks/use-all-agents-list"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useOpenProjectPicker } from "@/hooks/use-open-project-picker"; import { clearCommandCenterFocusRestoreElement, takeCommandCenterFocusRestoreElement, } from "@/utils/command-center-focus-restore"; import { buildHostOpenProjectRoute, buildHostWorkspaceAgentRoute, buildHostSettingsRoute, parseHostAgentRouteFromPathname, parseServerIdFromPathname, } from "@/utils/host-routes"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { focusWithRetries } from "@/utils/web-focus"; const EMPTY_AGENTS: AggregatedAgent[] = []; const EMPTY_ACTION_ITEMS: CommandCenterActionItem[] = []; const EMPTY_COMMAND_CENTER_ITEMS: CommandCenterItem[] = []; function isMatch(agent: AggregatedAgent, query: string): boolean { if (!query) return true; const q = query.toLowerCase(); const title = (agent.title ?? "New agent").toLowerCase(); const cwd = agent.cwd.toLowerCase(); return title.includes(q) || cwd.includes(q); } function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0; const rightNeedsInput = (right.pendingPermissionCount ?? 0) > 0 ? 1 : 0; if (leftNeedsInput !== rightNeedsInput) return rightNeedsInput - leftNeedsInput; const leftAttention = left.requiresAttention ? 1 : 0; const rightAttention = right.requiresAttention ? 1 : 0; if (leftAttention !== rightAttention) return rightAttention - leftAttention; const leftRunning = left.status === "running" ? 1 : 0; const rightRunning = right.status === "running" ? 1 : 0; if (leftRunning !== rightRunning) return rightRunning - leftRunning; return right.lastActivityAt.getTime() - left.lastActivityAt.getTime(); } type CommandCenterActionDefinition = { id: string; title: string; icon?: "plus" | "settings"; shortcutKeys?: ShortcutKey[]; keywords: string[]; buildRoute: (params: { newAgentRoute: Href; settingsRoute: Href }) => Href; }; const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [ { id: "new-agent", title: "Open project", icon: "plus", shortcutKeys: ["mod", "shift", "O"], keywords: ["open", "project", "folder", "workspace", "repo"], buildRoute: ({ newAgentRoute }) => newAgentRoute, }, { id: "settings", title: "Settings", icon: "settings", keywords: ["settings", "preferences", "config", "configuration"], buildRoute: ({ settingsRoute }) => settingsRoute, }, ]; function matchesActionQuery( query: string, action: CommandCenterActionDefinition ): boolean { const normalized = query.trim().toLowerCase(); if (!normalized) return true; if (action.title.toLowerCase().includes(normalized)) { return true; } return action.keywords.some((keyword) => keyword.includes(normalized)); } export type CommandCenterActionItem = { kind: "action"; id: string; title: string; icon?: "plus" | "settings"; route: Href; shortcutKeys?: ShortcutKey[]; }; export type CommandCenterItem = | { kind: "action"; action: CommandCenterActionItem; } | { kind: "agent"; agent: AggregatedAgent; }; export function useCommandCenter() { const pathname = usePathname(); const daemons = useHosts(); const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen); const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen); const inputRef = useRef(null); const didNavigateRef = useRef(false); const prevOpenRef = useRef(open); const activeIndexRef = useRef(0); const itemsRef = useRef([]); const handleCloseRef = useRef<() => void>(() => undefined); const handleSelectItemRef = useRef<(item: CommandCenterItem) => void>(() => undefined); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); const activeServerId = useMemo(() => { if (!open) { return null; } const serverIdFromPath = parseServerIdFromPathname(pathname); if (serverIdFromPath) { const routeMatch = daemons.find((entry) => entry.serverId === serverIdFromPath); if (routeMatch) { return routeMatch.serverId; } } return daemons[0]?.serverId ?? null; }, [daemons, open, pathname]); const { agents } = useAllAgentsList({ serverId: activeServerId, }); const agentResults = useMemo(() => { if (!open || agents.length === 0) { return EMPTY_AGENTS; } const filtered = agents.filter((agent) => isMatch(agent, query)); filtered.sort(sortAgents); return filtered; }, [agents, open, query]); const newAgentRoute = useMemo(() => { const serverIdFromPath = activeServerId; return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/"; }, [activeServerId]); const settingsRoute = useMemo(() => { const serverIdFromPath = activeServerId; return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/"; }, [activeServerId]); const actionItems = useMemo(() => { if (!open) { return EMPTY_ACTION_ITEMS; } return COMMAND_CENTER_ACTIONS.filter((action) => matchesActionQuery(query, action) ).map((action) => ({ kind: "action", id: action.id, title: action.title, icon: action.icon, route: action.buildRoute({ newAgentRoute, settingsRoute }), shortcutKeys: action.shortcutKeys, })); }, [newAgentRoute, open, query, settingsRoute]); const items = useMemo(() => { if (!open) { return EMPTY_COMMAND_CENTER_ITEMS; } const next: CommandCenterItem[] = []; for (const action of actionItems) { next.push({ kind: "action", action, }); } for (const agent of agentResults) { next.push({ kind: "agent", agent, }); } return next; }, [actionItems, agentResults, open]); const handleClose = useCallback(() => { setOpen(false); }, [setOpen]); const handleSelectAgent = useCallback( (agent: AggregatedAgent) => { didNavigateRef.current = true; const shouldReplace = Boolean(parseHostAgentRouteFromPathname(pathname)); const navigate = shouldReplace ? router.replace : router.push; // Don't restore focus back to the prior element after we navigate. clearCommandCenterFocusRestoreElement(); setOpen(false); const route: Href = buildHostWorkspaceAgentRoute( agent.serverId, agent.cwd, agent.id ) as Href; navigate(route); }, [pathname, setOpen] ); const openProjectPicker = useOpenProjectPicker(activeServerId); const handleSelectAction = useCallback((action: CommandCenterActionItem) => { clearCommandCenterFocusRestoreElement(); setOpen(false); if (action.id === "new-agent") { void openProjectPicker(); return; } didNavigateRef.current = true; router.push(action.route); }, [openProjectPicker, setOpen]); const handleSelectItem = useCallback( (item: CommandCenterItem) => { if (item.kind === "action") { handleSelectAction(item.action); return; } handleSelectAgent(item.agent); }, [handleSelectAction, handleSelectAgent] ); useEffect(() => { activeIndexRef.current = activeIndex; }, [activeIndex]); useEffect(() => { itemsRef.current = items; }, [items]); useEffect(() => { handleCloseRef.current = handleClose; }, [handleClose]); useEffect(() => { handleSelectItemRef.current = handleSelectItem; }, [handleSelectItem]); useEffect(() => { const prevOpen = prevOpenRef.current; prevOpenRef.current = open; if (!open) { setQuery(""); setActiveIndex(0); if (prevOpen && !didNavigateRef.current) { const el = takeCommandCenterFocusRestoreElement(); const isFocused = () => Boolean(el) && typeof document !== "undefined" && document.activeElement === el; const cancel = focusWithRetries({ focus: () => el?.focus(), isFocused, onTimeout: () => { keyboardActionDispatcher.dispatch({ id: "message-input.focus", scope: "message-input", }); }, }); return cancel; } return; } didNavigateRef.current = false; const id = setTimeout(() => { inputRef.current?.focus(); }, 0); return () => clearTimeout(id); }, [open]); useEffect(() => { if (!open) return; if (activeIndex >= items.length) { setActiveIndex(items.length > 0 ? items.length - 1 : 0); } }, [activeIndex, items.length, open]); useEffect(() => { if (!open) return; const handler = (event: KeyboardEvent) => { const currentItems = itemsRef.current; const key = event.key; if ( key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape" ) { return; } if (key === "Escape") { event.preventDefault(); handleCloseRef.current(); return; } if (key === "Enter") { if (currentItems.length === 0) return; event.preventDefault(); const index = Math.max( 0, Math.min(activeIndexRef.current, currentItems.length - 1) ); handleSelectItemRef.current(currentItems[index]!); return; } if (key === "ArrowDown" || key === "ArrowUp") { if (currentItems.length === 0) return; event.preventDefault(); setActiveIndex((current) => { const delta = key === "ArrowDown" ? 1 : -1; const next = current + delta; if (next < 0) return currentItems.length - 1; if (next >= currentItems.length) return 0; return next; }); } }; // react-native-web can stop propagation on key events, so listen in capture phase. window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); }, [open]); return { open, inputRef, query, setQuery, activeIndex, setActiveIndex, items, handleClose, handleSelectItem, }; }