Refactor command center and focus handling

This commit is contained in:
Mohamed Boudra
2026-02-04 12:51:06 +07:00
parent 48cecdb6b5
commit 1b3b8bba93
8 changed files with 314 additions and 256 deletions
@@ -29,6 +29,7 @@ import { CommandAutocomplete } from "./command-autocomplete";
import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query"; import { useAgentCommandsQuery } from "@/hooks/use-agent-commands-query";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { focusWithRetries } from "@/utils/web-focus";
type QueuedMessage = { type QueuedMessage = {
id: string; id: string;
@@ -406,41 +407,20 @@ export function AgentInputArea({
if (!req) return; if (!req) return;
if (req.agentKey !== `${serverId}:${agentId}`) return; if (req.agentKey !== `${serverId}:${agentId}`) return;
let cancelled = false; return focusWithRetries({
const deadlineMs = Date.now() + 1500; focus: () => messageInputRef.current?.focus(),
isFocused: () => {
const tryFocus = () => { const el = messageInputRef.current?.getNativeElement?.() ?? null;
if (cancelled) return; const active =
const ref = messageInputRef.current; typeof document !== "undefined" ? document.activeElement : null;
ref?.focus(); return Boolean(el) && active === el;
},
const el = ref?.getNativeElement?.() ?? null; onSuccess: () => clearFocusChatInputRequest(),
const active = typeof document !== "undefined" ? document.activeElement : null; onTimeout: () => {
const didFocus = !!el && active === el;
if (didFocus) {
clearFocusChatInputRequest();
return;
}
if (Date.now() >= deadlineMs) {
// Don't keep stealing focus forever; allow other interactions. // Don't keep stealing focus forever; allow other interactions.
clearFocusChatInputRequest(); clearFocusChatInputRequest();
return; },
}
requestAnimationFrame(() => {
requestAnimationFrame(tryFocus);
});
};
requestAnimationFrame(() => {
requestAnimationFrame(tryFocus);
}); });
return () => {
cancelled = true;
};
}, [agentId, clearFocusChatInputRequest, focusChatInputRequest, serverId]); }, [agentId, clearFocusChatInputRequest, focusChatInputRequest, serverId]);
// Handle command selection from autocomplete // Handle command selection from autocomplete
+18 -182
View File
@@ -1,4 +1,3 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
Modal, Modal,
Pressable, Pressable,
@@ -9,191 +8,28 @@ import {
Platform, Platform,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { router, usePathname } from "expo-router"; import { useCommandCenter } from "@/hooks/use-command-center";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path"; import { shortenPath } from "@/utils/shorten-path";
import { useSessionStore } from "@/stores/session-store";
import { AgentStatusDot } from "@/components/agent-status-dot"; import { AgentStatusDot } from "@/components/agent-status-dot";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string { function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`; 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() { export function CommandCenter() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const pathname = usePathname(); const {
const { agents } = useAggregatedAgents(); open,
const open = useKeyboardNavStore((s) => s.commandCenterOpen); inputRef,
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen); query,
const requestFocusChatInput = useKeyboardNavStore((s) => s.requestFocusChatInput); setQuery,
const takeFocusRestoreElement = useKeyboardNavStore((s) => s.takeFocusRestoreElement); activeIndex,
const inputRef = useRef<TextInput>(null); results,
const didNavigateRef = useRef(false); handleClose,
const prevOpenRef = useRef(open); handleSelect,
const [query, setQuery] = useState(""); } = useCommandCenter();
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]);
if (Platform.OS !== "web") return null; if (Platform.OS !== "web") return null;
@@ -252,12 +88,12 @@ export function CommandCenter() {
<View style={styles.rowContent}> <View style={styles.rowContent}>
<View style={styles.rowTitle}> <View style={styles.rowTitle}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} /> <AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text <Text
style={[styles.title, { color: theme.colors.foreground }]} style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1} numberOfLines={1}
> >
{agent.title || "New agent"} {agent.title || "New agent"}
</Text> </Text>
</View> </View>
<Text <Text
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]} style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
@@ -0,0 +1,192 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TextInput } from "react-native";
import { router, usePathname } from "expo-router";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import {
clearCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "@/utils/command-center-focus-restore";
import { focusWithRetries } from "@/utils/web-focus";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): 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<TextInput>(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,
};
}
@@ -4,6 +4,7 @@ import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout"; import { getIsTauri } from "@/constants/layout";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts"; import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
export function useGlobalKeyboardNav({ export function useGlobalKeyboardNav({
enabled, enabled,
@@ -137,7 +138,7 @@ export function useGlobalKeyboardNav({
(target instanceof HTMLElement ? target : null); (target instanceof HTMLElement ? target : null);
const active = document.activeElement; const active = document.activeElement;
const activeEl = active instanceof HTMLElement ? active : null; const activeEl = active instanceof HTMLElement ? active : null;
s.setFocusRestoreElement( setCommandCenterFocusRestoreElement(
(targetEl as HTMLElement | null) ?? activeEl ?? null (targetEl as HTMLElement | null) ?? activeEl ?? null
); );
} }
@@ -1,7 +1,6 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQueries, type UseQueryOptions } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey, checkoutStatusQueryKey,
type CheckoutStatusPayload, type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query"; } from "@/hooks/use-checkout-status-query";
@@ -23,32 +22,32 @@ export interface SidebarSectionData {
} }
export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSectionData[] { export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSectionData[] {
const checkoutCacheQueries = useQueries({ const queryClient = useQueryClient();
queries: agents.map( const [checkoutCacheBump, setCheckoutCacheBump] = useState(0);
(agent): UseQueryOptions<CheckoutStatusPayload> => ({
queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd), // Re-render when checkout status cache updates so grouping can switch from cwd→remote.
enabled: false, useEffect(() => {
staleTime: CHECKOUT_STATUS_STALE_TIME, const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
queryFn: async (): Promise<CheckoutStatusPayload> => { const queryKey = event?.query?.queryKey;
throw new Error("checkout status cache-only query should not run"); if (!Array.isArray(queryKey) || queryKey[0] !== "checkoutStatus") {
}, return;
}) }
), setCheckoutCacheBump((v) => v + 1);
}); });
return unsubscribe;
}, [queryClient]);
const remoteUrlByAgentKey = useMemo(() => { const remoteUrlByAgentKey = useMemo(() => {
const result = new Map<string, string | null>(); const result = new Map<string, string | null>();
for (let i = 0; i < agents.length; i++) { for (const agent of agents) {
const agent = agents[i]; const checkout =
if (!agent) { queryClient.getQueryData<CheckoutStatusPayload>(
continue; checkoutStatusQueryKey(agent.serverId, agent.cwd)
} ) ?? null;
const checkout = checkoutCacheQueries[i]?.data ?? null; result.set(`${agent.serverId}:${agent.id}`, checkout?.remoteUrl ?? null);
const remoteUrl = checkout?.remoteUrl ?? null;
result.set(`${agent.serverId}:${agent.id}`, remoteUrl);
} }
return result; return result;
}, [agents, checkoutCacheQueries]); }, [agents, checkoutCacheBump, queryClient]);
const projectOrder = useSectionOrderStore((state) => state.projectOrder); const projectOrder = useSectionOrderStore((state) => state.projectOrder);
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder); const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
@@ -100,4 +99,3 @@ export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSecti
return sections; return sections;
} }
@@ -12,14 +12,6 @@ interface KeyboardNavState {
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */ /** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
sidebarShortcutAgentKeys: string[]; 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. */ /** Web-only request to focus the MessageInput for the selected agent. */
focusChatInputRequest: FocusChatInputRequest | null; focusChatInputRequest: FocusChatInputRequest | null;
requestFocusChatInput: (agentKey: string) => void; requestFocusChatInput: (agentKey: string) => void;
@@ -38,14 +30,6 @@ export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
cmdOrCtrlDown: false, cmdOrCtrlDown: false,
sidebarShortcutAgentKeys: [], sidebarShortcutAgentKeys: [],
focusRestoreElement: null,
setFocusRestoreElement: (el) => set({ focusRestoreElement: el }),
takeFocusRestoreElement: () => {
const el = get().focusRestoreElement;
set({ focusRestoreElement: null });
return el;
},
focusChatInputRequest: null, focusChatInputRequest: null,
requestFocusChatInput: (agentKey) => { requestFocusChatInput: (agentKey) => {
const prev = get().focusChatInputRequest; const prev = get().focusChatInputRequest;
@@ -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;
}
+51
View File
@@ -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;
};
}