diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index af5fa3688..4daaca700 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -29,6 +29,7 @@ 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"; +import { focusWithRetries } from "@/utils/web-focus"; type QueuedMessage = { id: string; @@ -406,41 +407,20 @@ export function AgentInputArea({ if (!req) return; if (req.agentKey !== `${serverId}:${agentId}`) return; - let cancelled = false; - const deadlineMs = Date.now() + 1500; - - const tryFocus = () => { - if (cancelled) return; - const ref = messageInputRef.current; - ref?.focus(); - - const el = ref?.getNativeElement?.() ?? null; - const active = typeof document !== "undefined" ? document.activeElement : null; - const didFocus = !!el && active === el; - - if (didFocus) { - clearFocusChatInputRequest(); - return; - } - - if (Date.now() >= deadlineMs) { + return focusWithRetries({ + focus: () => messageInputRef.current?.focus(), + isFocused: () => { + const el = messageInputRef.current?.getNativeElement?.() ?? null; + const active = + typeof document !== "undefined" ? document.activeElement : null; + return Boolean(el) && active === el; + }, + onSuccess: () => clearFocusChatInputRequest(), + onTimeout: () => { // Don't keep stealing focus forever; allow other interactions. clearFocusChatInputRequest(); - return; - } - - requestAnimationFrame(() => { - requestAnimationFrame(tryFocus); - }); - }; - - requestAnimationFrame(() => { - requestAnimationFrame(tryFocus); + }, }); - - return () => { - cancelled = true; - }; }, [agentId, clearFocusChatInputRequest, focusChatInputRequest, serverId]); // Handle command selection from autocomplete diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx index afa19d242..416b99fdd 100644 --- a/packages/app/src/components/command-center.tsx +++ b/packages/app/src/components/command-center.tsx @@ -1,4 +1,3 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Modal, Pressable, @@ -9,191 +8,28 @@ import { Platform, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { router, usePathname } from "expo-router"; -import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; -import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { useCommandCenter } from "@/hooks/use-command-center"; +import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; -import { useSessionStore } from "@/stores/session-store"; import { AgentStatusDot } from "@/components/agent-status-dot"; function agentKey(agent: Pick): string { return `${agent.serverId}:${agent.id}`; } -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(); - const host = agent.serverLabel.toLowerCase(); - return title.includes(q) || cwd.includes(q) || host.includes(q); -} - -function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { - 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(); -} - export function CommandCenter() { const { theme } = useUnistyles(); - const pathname = usePathname(); - 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); - - const results = useMemo(() => { - const filtered = agents.filter((agent) => isMatch(agent, query)); - filtered.sort(sortAgents); - return filtered; - }, [agents, query]); - - const agentKeyFromPathname = useMemo(() => { - const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/); - if (!match) return null; - return `${match[1]}:${match[2]}`; - }, [pathname]); - - useEffect(() => { - const prevOpen = prevOpenRef.current; - prevOpenRef.current = open; - - if (!open) { - setQuery(""); - setActiveIndex(0); - - if (prevOpen && !didNavigateRef.current) { - const el = takeFocusRestoreElement(); - const deadlineMs = Date.now() + 1500; - const tryRestore = () => { - if (el && el.isConnected) { - try { - el.focus(); - } catch { - // ignore - } - } - - if ( - el && - typeof document !== "undefined" && - document.activeElement === el - ) { - return; - } - - if (Date.now() >= deadlineMs) { - // If we failed to restore focus to the original element (RN web can - // remount textareas), fall back to focusing the chat input for the - // current agent route. - if (agentKeyFromPathname) { - requestFocusChatInput(agentKeyFromPathname); - } - return; - } - - requestAnimationFrame(() => { - requestAnimationFrame(tryRestore); - }); - }; - - // Modal unmount can steal focus; restore on next tick (and retry). - requestAnimationFrame(() => { - requestAnimationFrame(tryRestore); - }); - } - - return; - } - - didNavigateRef.current = false; - - const id = setTimeout(() => { - inputRef.current?.focus(); - }, 0); - return () => clearTimeout(id); - }, [agentKeyFromPathname, open, requestFocusChatInput, takeFocusRestoreElement]); - - useEffect(() => { - if (!open) return; - if (activeIndex >= results.length) { - setActiveIndex(results.length > 0 ? results.length - 1 : 0); - } - }, [activeIndex, open, results.length]); - - const handleClose = useCallback(() => { - setOpen(false); - }, [setOpen]); - - 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, requestFocusChatInput, setOpen, takeFocusRestoreElement] - ); - - useEffect(() => { - if (!open) return; - - const handler = (event: KeyboardEvent) => { - const key = event.key; - if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") { - return; - } - if (key === "Escape") { - event.preventDefault(); - handleClose(); - return; - } - if (key === "Enter") { - if (results.length === 0) return; - event.preventDefault(); - const index = Math.max(0, Math.min(activeIndex, results.length - 1)); - handleSelect(results[index]!); - return; - } - if (key === "ArrowDown" || key === "ArrowUp") { - if (results.length === 0) return; - event.preventDefault(); - setActiveIndex((current) => { - const delta = key === "ArrowDown" ? 1 : -1; - const next = current + delta; - if (next < 0) return results.length - 1; - if (next >= results.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); - }, [activeIndex, handleClose, handleSelect, open, results]); + const { + open, + inputRef, + query, + setQuery, + activeIndex, + results, + handleClose, + handleSelect, + } = useCommandCenter(); if (Platform.OS !== "web") return null; @@ -252,12 +88,12 @@ export function CommandCenter() { - - {agent.title || "New agent"} - + + {agent.title || "New agent"} + ): string { + return `${agent.serverId}:${agent.id}`; +} + +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(); + const host = agent.serverLabel.toLowerCase(); + return title.includes(q) || cwd.includes(q) || host.includes(q); +} + +function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number { + 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(); +} + +function parseAgentKeyFromPathname(pathname: string): string | null { + const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/); + if (!match) return null; + return `${match[1]}:${match[2]}`; +} + +export function useCommandCenter() { + const pathname = usePathname(); + const { agents } = useAggregatedAgents(); + const open = useKeyboardNavStore((s) => s.commandCenterOpen); + const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen); + const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput); + const inputRef = useRef(null); + const didNavigateRef = useRef(false); + const prevOpenRef = useRef(open); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + + const results = useMemo(() => { + const filtered = agents.filter((agent) => isMatch(agent, query)); + filtered.sort(sortAgents); + return filtered; + }, [agents, query]); + + const agentKeyFromPathname = useMemo( + () => parseAgentKeyFromPathname(pathname), + [pathname] + ); + + const handleClose = useCallback(() => { + setOpen(false); + }, [setOpen]); + + 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(agentKey(agent)); + // Don't restore focus back to the prior element after we navigate. + clearCommandCenterFocusRestoreElement(); + setOpen(false); + navigate(`/agent/${agent.serverId}/${agent.id}` as any); + }, + [pathname, requestFocusChatInput, setOpen] + ); + + 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: () => { + if (agentKeyFromPathname) { + requestFocusChatInput(agentKeyFromPathname); + } + }, + }); + return cancel; + } + + return; + } + + didNavigateRef.current = false; + + const id = setTimeout(() => { + inputRef.current?.focus(); + }, 0); + return () => clearTimeout(id); + }, [agentKeyFromPathname, open, requestFocusChatInput]); + + useEffect(() => { + if (!open) return; + if (activeIndex >= results.length) { + setActiveIndex(results.length > 0 ? results.length - 1 : 0); + } + }, [activeIndex, open, results.length]); + + useEffect(() => { + if (!open) return; + + const handler = (event: KeyboardEvent) => { + const key = event.key; + if ( + key !== "ArrowDown" && + key !== "ArrowUp" && + key !== "Enter" && + key !== "Escape" + ) { + return; + } + + if (key === "Escape") { + event.preventDefault(); + handleClose(); + return; + } + + if (key === "Enter") { + if (results.length === 0) return; + event.preventDefault(); + const index = Math.max(0, Math.min(activeIndex, results.length - 1)); + handleSelect(results[index]!); + return; + } + + if (key === "ArrowDown" || key === "ArrowUp") { + if (results.length === 0) return; + event.preventDefault(); + setActiveIndex((current) => { + const delta = key === "ArrowDown" ? 1 : -1; + const next = current + delta; + if (next < 0) return results.length - 1; + if (next >= results.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); + }, [activeIndex, handleClose, handleSelect, open, results]); + + return { + open, + inputRef, + query, + setQuery, + activeIndex, + setActiveIndex, + results, + handleClose, + handleSelect, + }; +} + diff --git a/packages/app/src/hooks/use-global-keyboard-nav.ts b/packages/app/src/hooks/use-global-keyboard-nav.ts index 2c1a06199..9a699d026 100644 --- a/packages/app/src/hooks/use-global-keyboard-nav.ts +++ b/packages/app/src/hooks/use-global-keyboard-nav.ts @@ -4,6 +4,7 @@ import { usePathname, useRouter } from "expo-router"; import { getIsTauri } from "@/constants/layout"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts"; +import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; export function useGlobalKeyboardNav({ enabled, @@ -137,7 +138,7 @@ export function useGlobalKeyboardNav({ (target instanceof HTMLElement ? target : null); const active = document.activeElement; const activeEl = active instanceof HTMLElement ? active : null; - s.setFocusRestoreElement( + setCommandCenterFocusRestoreElement( (targetEl as HTMLElement | null) ?? activeEl ?? null ); } diff --git a/packages/app/src/hooks/use-sidebar-agent-sections.ts b/packages/app/src/hooks/use-sidebar-agent-sections.ts index 8446177d6..3d9a078dd 100644 --- a/packages/app/src/hooks/use-sidebar-agent-sections.ts +++ b/packages/app/src/hooks/use-sidebar-agent-sections.ts @@ -1,7 +1,6 @@ -import { useEffect, useMemo } from "react"; -import { useQueries, type UseQueryOptions } from "@tanstack/react-query"; +import { useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { - CHECKOUT_STATUS_STALE_TIME, checkoutStatusQueryKey, type CheckoutStatusPayload, } from "@/hooks/use-checkout-status-query"; @@ -23,32 +22,32 @@ export interface SidebarSectionData { } export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSectionData[] { - const checkoutCacheQueries = useQueries({ - queries: agents.map( - (agent): UseQueryOptions => ({ - queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd), - enabled: false, - staleTime: CHECKOUT_STATUS_STALE_TIME, - queryFn: async (): Promise => { - throw new Error("checkout status cache-only query should not run"); - }, - }) - ), - }); + const queryClient = useQueryClient(); + const [checkoutCacheBump, setCheckoutCacheBump] = useState(0); + + // Re-render when checkout status cache updates so grouping can switch from cwd→remote. + useEffect(() => { + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + const queryKey = event?.query?.queryKey; + if (!Array.isArray(queryKey) || queryKey[0] !== "checkoutStatus") { + return; + } + setCheckoutCacheBump((v) => v + 1); + }); + return unsubscribe; + }, [queryClient]); const remoteUrlByAgentKey = useMemo(() => { const result = new Map(); - for (let i = 0; i < agents.length; i++) { - const agent = agents[i]; - if (!agent) { - continue; - } - const checkout = checkoutCacheQueries[i]?.data ?? null; - const remoteUrl = checkout?.remoteUrl ?? null; - result.set(`${agent.serverId}:${agent.id}`, remoteUrl); + for (const agent of agents) { + const checkout = + queryClient.getQueryData( + checkoutStatusQueryKey(agent.serverId, agent.cwd) + ) ?? null; + result.set(`${agent.serverId}:${agent.id}`, checkout?.remoteUrl ?? null); } return result; - }, [agents, checkoutCacheQueries]); + }, [agents, checkoutCacheBump, queryClient]); const projectOrder = useSectionOrderStore((state) => state.projectOrder); const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder); @@ -100,4 +99,3 @@ export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSecti return sections; } - diff --git a/packages/app/src/stores/keyboard-nav-store.ts b/packages/app/src/stores/keyboard-nav-store.ts index d704758f8..ba01b3f69 100644 --- a/packages/app/src/stores/keyboard-nav-store.ts +++ b/packages/app/src/stores/keyboard-nav-store.ts @@ -12,14 +12,6 @@ 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; @@ -38,14 +30,6 @@ export const useKeyboardNavStore = create((set, get) => ({ 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; diff --git a/packages/app/src/utils/command-center-focus-restore.ts b/packages/app/src/utils/command-center-focus-restore.ts new file mode 100644 index 000000000..3dea52b93 --- /dev/null +++ b/packages/app/src/utils/command-center-focus-restore.ts @@ -0,0 +1,16 @@ +let focusRestoreElement: HTMLElement | null = null; + +export function setCommandCenterFocusRestoreElement(el: HTMLElement | null): void { + focusRestoreElement = el; +} + +export function takeCommandCenterFocusRestoreElement(): HTMLElement | null { + const el = focusRestoreElement; + focusRestoreElement = null; + return el; +} + +export function clearCommandCenterFocusRestoreElement(): void { + focusRestoreElement = null; +} + diff --git a/packages/app/src/utils/web-focus.ts b/packages/app/src/utils/web-focus.ts new file mode 100644 index 000000000..e8f853803 --- /dev/null +++ b/packages/app/src/utils/web-focus.ts @@ -0,0 +1,51 @@ +type FocusWithRetriesOptions = { + focus: () => void; + isFocused: () => boolean; + timeoutMs?: number; + onSuccess?: () => void; + onTimeout?: () => void; +}; + +export function focusWithRetries({ + focus, + isFocused, + timeoutMs = 1500, + onSuccess, + onTimeout, +}: FocusWithRetriesOptions): () => void { + let cancelled = false; + const deadlineMs = Date.now() + timeoutMs; + + const tick = () => { + if (cancelled) return; + + try { + focus(); + } catch { + // ignore + } + + if (isFocused()) { + onSuccess?.(); + return; + } + + if (Date.now() >= deadlineMs) { + onTimeout?.(); + return; + } + + requestAnimationFrame(() => { + requestAnimationFrame(tick); + }); + }; + + requestAnimationFrame(() => { + requestAnimationFrame(tick); + }); + + return () => { + cancelled = true; + }; +} +