mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Refactor command center and focus handling
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 }]}
|
||||
|
||||
192
packages/app/src/hooks/use-command-center.ts
Normal file
192
packages/app/src/hooks/use-command-center.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
packages/app/src/utils/command-center-focus-restore.ts
Normal file
16
packages/app/src/utils/command-center-focus-restore.ts
Normal 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;
|
||||
}
|
||||
|
||||
51
packages/app/src/utils/web-focus.ts
Normal file
51
packages/app/src/utils/web-focus.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user