Update files

This commit is contained in:
Mohamed Boudra
2026-02-04 10:52:36 +07:00
parent 8f1637ee55
commit 4887592ebe
11 changed files with 775 additions and 132 deletions
@@ -0,0 +1,150 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import { usePathname, useRouter } from "expo-router";
import { getIsTauri } from "@/constants/layout";
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
export function useGlobalKeyboardNav({
enabled,
isMobile,
toggleAgentList,
}: {
enabled: boolean;
isMobile: boolean;
toggleAgentList: () => void;
}) {
const router = useRouter();
const pathname = usePathname();
const resetModifiers = useKeyboardNavStore((s) => s.resetModifiers);
useEffect(() => {
if (!enabled) return;
if (Platform.OS !== "web") return;
if (isMobile) return;
const isTauri = getIsTauri();
const shouldHandle = () => {
if (typeof document === "undefined") return false;
if (document.visibilityState !== "visible") return false;
if (!document.hasFocus()) return false;
return true;
};
const parseShortcutDigit = (event: KeyboardEvent): number | null => {
const code = event.code ?? "";
if (code.startsWith("Digit")) {
const n = Number(code.slice("Digit".length));
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
}
if (code.startsWith("Numpad")) {
const n = Number(code.slice("Numpad".length));
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
}
const key = event.key ?? "";
if (key >= "1" && key <= "9") {
return Number(key);
}
return null;
};
const navigateToSidebarShortcut = (digit: number) => {
const state = useKeyboardNavStore.getState();
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
if (!targetKey) {
return;
}
const parsed = parseSidebarAgentKey(targetKey);
if (!parsed) {
return;
}
const { serverId, agentId } = parsed;
const shouldReplace = pathname.startsWith("/agent/");
const navigate = shouldReplace ? router.replace : router.push;
navigate(`/agent/${serverId}/${agentId}` as any);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
}
const key = event.key ?? "";
const lowerKey = key.toLowerCase();
if (key === "Alt") {
useKeyboardNavStore.getState().setAltDown(true);
}
if (isTauri && (key === "Meta" || key === "Control")) {
useKeyboardNavStore.getState().setCmdOrCtrlDown(true);
}
// Cmd+B: toggle sidebar
if ((event.metaKey || event.ctrlKey) && lowerKey === "b") {
event.preventDefault();
toggleAgentList();
return;
}
// Cmd+K: command center
if ((event.metaKey || event.ctrlKey) && lowerKey === "k") {
event.preventDefault();
const s = useKeyboardNavStore.getState();
s.setCommandCenterOpen(!s.commandCenterOpen);
return;
}
// Number switching: ignore while command center is open.
if (useKeyboardNavStore.getState().commandCenterOpen) {
return;
}
const digit = parseShortcutDigit(event);
if (!digit) {
return;
}
// Alt/Option+number: always (web + Tauri)
if (event.altKey) {
event.preventDefault();
navigateToSidebarShortcut(digit);
return;
}
// Cmd/Ctrl+number: Tauri only (avoid browser tab switching)
if (isTauri && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
navigateToSidebarShortcut(digit);
}
};
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key ?? "";
if (key === "Alt") {
useKeyboardNavStore.getState().setAltDown(false);
}
if (isTauri && (key === "Meta" || key === "Control")) {
useKeyboardNavStore.getState().setCmdOrCtrlDown(false);
}
};
const handleBlurOrHide = () => {
resetModifiers();
};
// react-native-web can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
return () => {
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
document.removeEventListener("visibilitychange", handleBlurOrHide);
};
}, [enabled, isMobile, pathname, resetModifiers, router, toggleAgentList]);
}
@@ -0,0 +1,103 @@
import { useEffect, useMemo } from "react";
import { useQueries, type UseQueryOptions } from "@tanstack/react-query";
import {
CHECKOUT_STATUS_STALE_TIME,
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { groupAgents } from "@/utils/agent-grouping";
import { useSectionOrderStore, sortProjectsByStoredOrder } from "@/stores/section-order-store";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
export interface SidebarSectionData {
key: string;
projectKey: string;
title: string;
agents: AggregatedAgent[];
/** For project sections, the first agent's serverId (to lookup checkout status) */
firstAgentServerId?: string;
/** For project sections, the first agent's id (to lookup checkout status) */
firstAgentId?: string;
/** Working directory for the project (from first agent) */
workingDir?: string;
}
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 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);
}
return result;
}, [agents, checkoutCacheQueries]);
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
const { activeGroups } = useMemo(
() =>
groupAgents(agents, {
getRemoteUrl: (agent) =>
remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null,
}),
[agents, remoteUrlByAgentKey]
);
const sortedGroups = useMemo(
() => sortProjectsByStoredOrder(activeGroups, projectOrder),
[activeGroups, projectOrder]
);
const sections: SidebarSectionData[] = useMemo(() => {
const result: SidebarSectionData[] = [];
for (const group of sortedGroups) {
const sectionKey = `project:${group.projectKey}`;
const firstAgent = group.agents[0];
result.push({
key: sectionKey,
projectKey: group.projectKey,
title: group.projectName,
agents: group.agents,
firstAgentServerId: firstAgent?.serverId,
firstAgentId: firstAgent?.id,
workingDir: firstAgent?.cwd,
});
}
return result;
}, [sortedGroups]);
// Sync section order when new projects appear.
useEffect(() => {
const currentKeys = sections.map((s) => s.projectKey);
const storedKeys = new Set(projectOrder);
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
if (newKeys.length > 0) {
setProjectOrder([...projectOrder, ...newKeys]);
}
}, [projectOrder, sections, setProjectOrder]);
return sections;
}