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