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

View File

@@ -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

View File

@@ -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<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();
}
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<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(() => {
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() {
<View style={styles.rowContent}>
<View style={styles.rowTitle}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
</View>
<Text
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}

View File

@@ -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,
};
}

View File

@@ -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
);
}

View File

@@ -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<CheckoutStatusPayload> => ({
queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd),
enabled: false,
staleTime: CHECKOUT_STATUS_STALE_TIME,
queryFn: async (): Promise<CheckoutStatusPayload> => {
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<string, string | null>();
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<CheckoutStatusPayload>(
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;
}

View File

@@ -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<KeyboardNavState>((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;

View File

@@ -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;
}

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;
};
}