diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index eef34749b..d03b263e6 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -32,8 +32,10 @@ import { HorizontalScrollProvider, useHorizontalScrollOptional, } from "@/contexts/horizontal-scroll-context"; -import { getIsTauriMac } from "@/constants/layout"; +import { getIsTauri, getIsTauriMac } from "@/constants/layout"; import { useTrafficLightPadding } from "@/utils/tauri-window"; +import { CommandCenter } from "@/components/command-center"; +import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav"; function PushNotificationRouter() { const router = useRouter(); @@ -134,19 +136,11 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { : desktopAgentListOpen : false; - // Cmd+B to toggle sidebar (web only) - useEffect(() => { - if (!chromeEnabled) return; - if (Platform.OS !== "web") return; - function handleKeyDown(event: KeyboardEvent) { - if ((event.metaKey || event.ctrlKey) && event.key === "b") { - event.preventDefault(); - toggleAgentList(); - } - } - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [chromeEnabled, toggleAgentList]); + useGlobalKeyboardNav({ + enabled: chromeEnabled, + isMobile, + toggleAgentList, + }); const { translateX, backdropOpacity, @@ -249,6 +243,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { {isMobile && chromeEnabled && } + ); diff --git a/packages/app/src/components/command-center.tsx b/packages/app/src/components/command-center.tsx new file mode 100644 index 000000000..b1dc8cd04 --- /dev/null +++ b/packages/app/src/components/command-center.tsx @@ -0,0 +1,273 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Modal, + Pressable, + ScrollView, + Text, + TextInput, + View, + 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 { formatTimeAgo } from "@/utils/time"; +import { shortenPath } from "@/utils/shorten-path"; +import { useSessionStore } from "@/stores/session-store"; + +function agentKey(agent: Pick): 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 inputRef = useRef(null); + 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]); + + useEffect(() => { + if (!open) { + setQuery(""); + setActiveIndex(0); + return; + } + + const id = setTimeout(() => { + inputRef.current?.focus(); + }, 0); + return () => clearTimeout(id); + }, [open]); + + 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) => { + const session = useSessionStore.getState().sessions[agent.serverId]; + session?.client?.clearAgentAttention(agent.id); + + const shouldReplace = pathname.startsWith("/agent/"); + const navigate = shouldReplace ? router.replace : router.push; + + setOpen(false); + navigate(`/agent/${agent.serverId}/${agent.id}` as any); + }, + [pathname, setOpen] + ); + + 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; + + return ( + + + + + + + + + + + {results.length === 0 ? ( + + No matches + + ) : ( + results.map((agent, index) => { + const active = index === activeIndex; + return ( + [ + styles.row, + (hovered || pressed || active) && { + backgroundColor: theme.colors.surface1, + }, + ]} + onPress={() => handleSelect(agent)} + > + + + {agent.title || "New agent"} + + + {agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)} + + + + ); + }) + )} + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + overlay: { + flex: 1, + justifyContent: "flex-start", + alignItems: "center", + paddingTop: theme.spacing[12], + }, + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "rgba(0, 0, 0, 0.5)", + }, + panel: { + width: 640, + maxWidth: "92%", + maxHeight: "80%", + borderWidth: 1, + borderRadius: theme.borderRadius.lg, + overflow: "hidden", + shadowColor: "#000", + shadowOpacity: 0.4, + shadowRadius: 24, + shadowOffset: { width: 0, height: 12 }, + }, + header: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + borderBottomWidth: 1, + }, + input: { + fontSize: theme.fontSize.lg, + paddingVertical: theme.spacing[1], + outlineStyle: "none", + } as any, + results: { + flexGrow: 0, + }, + resultsContent: { + paddingVertical: theme.spacing[2], + }, + row: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[3], + }, + rowContent: { + gap: 2, + }, + title: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + }, + subtitle: { + fontSize: theme.fontSize.sm, + }, + emptyText: { + paddingHorizontal: theme.spacing[4], + paddingVertical: theme.spacing[4], + fontSize: theme.fontSize.base, + }, +})); diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx index 247e5be7b..fadee8131 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -17,25 +17,20 @@ import { import { router, usePathname } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { type GestureType } from "react-native-gesture-handler"; -import { useQueries, useQueryClient, type UseQueryOptions } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import { Archive, ChevronDown, ChevronRight, Plus } from "lucide-react-native"; import { DraggableList, type DraggableRenderItemInfo, } from "./draggable-list"; import { formatTimeAgo } from "@/utils/time"; -import { - groupAgents, - parseRepoNameFromRemoteUrl, - parseRepoShortNameFromRemoteUrl, -} from "@/utils/agent-grouping"; +import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; import { - type CheckoutStatusPayload, + CHECKOUT_STATUS_STALE_TIME, checkoutStatusQueryKey, useCheckoutStatusCacheOnly, - CHECKOUT_STATUS_STALE_TIME, } from "@/hooks/use-checkout-status-query"; import { buildAgentNavigationKey, @@ -43,22 +38,14 @@ import { } from "@/utils/navigation-timing"; import { useSectionOrderStore, - sortProjectsByStoredOrder, } from "@/stores/section-order-store"; import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; +import { useSidebarAgentSections, type SidebarSectionData } from "@/hooks/use-sidebar-agent-sections"; +import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; +import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; +import { getIsTauri } from "@/constants/layout"; -interface SectionData { - 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; -} +type SectionData = SidebarSectionData; interface GroupedAgentListProps { agents: AggregatedAgent[]; @@ -209,9 +196,24 @@ export function GroupedAgentList({ const queryClient = useQueryClient(); const insets = useSafeAreaInsets(); const [actionAgent, setActionAgent] = useState(null); - const [collapsedSections, setCollapsedSections] = useState>( - new Set() - ); + + const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys); + const toggleProjectCollapsed = useSidebarCollapsedSectionsStore((s) => s.toggleProjectCollapsed); + + const altDown = useKeyboardNavStore((s) => s.altDown); + const cmdOrCtrlDown = useKeyboardNavStore((s) => s.cmdOrCtrlDown); + const sidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.sidebarShortcutAgentKeys); + const isTauri = getIsTauri(); + const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown); + const shortcutIndexByAgentKey = useMemo(() => { + const map = new Map(); + for (let i = 0; i < sidebarShortcutAgentKeys.length; i++) { + const key = sidebarShortcutAgentKeys[i]; + if (!key) continue; + map.set(key, i + 1); + } + return map; + }, [sidebarShortcutAgentKeys]); const actionClient = useSessionStore((state) => actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null @@ -265,18 +267,6 @@ export function GroupedAgentList({ setActionAgent(null); }, [actionAgent, actionClient]); - const toggleSection = useCallback((sectionKey: string) => { - setCollapsedSections((prev) => { - const next = new Set(prev); - if (next.has(sectionKey)) { - next.delete(sectionKey); - } else { - next.add(sectionKey); - } - return next; - }); - }, []); - const handleCreateAgentInProject = useCallback( (workingDir: string) => { onAgentSelect?.(); @@ -315,87 +305,10 @@ export function GroupedAgentList({ } }, [agents, queryClient]); - // Subscribe to checkout status cache entries so project grouping can react - // to remote URL updates (e.g. git worktrees in different directories). - const checkoutCacheQueries = useQueries({ - queries: agents.map( - (agent): UseQueryOptions => ({ - queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd), - enabled: false, - staleTime: CHECKOUT_STATUS_STALE_TIME, - queryFn: async (): Promise => { - throw new Error("checkout status cache-only query should not run"); - }, - }) - ), - }); - - const remoteUrlByAgentKey = useMemo(() => { - const result = new Map(); - 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]); - // Section order from store - const projectOrder = useSectionOrderStore((state) => state.projectOrder); const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder); - // Group agents - const { activeGroups } = useMemo( - () => - groupAgents(agents, { - getRemoteUrl: (agent) => - remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null, - }), - [agents, remoteUrlByAgentKey] - ); - - // Sort groups by persisted order - const sortedGroups = useMemo( - () => sortProjectsByStoredOrder(activeGroups, projectOrder), - [activeGroups, projectOrder] - ); - - // Build sections for DraggableFlatList - const sections: SectionData[] = useMemo(() => { - const result: SectionData[] = []; - - 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) { - // Add new projects at the end of the stored order - setProjectOrder([...projectOrder, ...newKeys]); - } - }, [sections, projectOrder, setProjectOrder]); + const sections: SectionData[] = useSidebarAgentSections(agents); const handleDragEnd = useCallback( (newData: SectionData[]) => { @@ -424,6 +337,8 @@ export function GroupedAgentList({ const agentKey = `${agent.serverId}:${agent.id}`; const isSelected = selectedAgentId === agentKey; const isRunning = agent.status === "running"; + const shortcutNumber = + showShortcutBadges ? (shortcutIndexByAgentKey.get(agentKey) ?? null) : null; const statusColor = isRunning ? theme.colors.palette.blue[500] : agent.requiresAttention @@ -476,7 +391,13 @@ export function GroupedAgentList({ > {agent.title || "New agent"} - {isHovered && canArchive ? ( + {shortcutNumber !== null ? ( + + + {shortcutNumber} + + + ) : isHovered && canArchive ? ( handleArchiveAgent(e, agent)} @@ -512,6 +433,8 @@ export function GroupedAgentList({ handleAgentPress, handleArchiveAgent, selectedAgentId, + showShortcutBadges, + shortcutIndexByAgentKey, theme.colors.foreground, theme.colors.foregroundMuted, theme.colors.palette.blue, @@ -521,14 +444,14 @@ export function GroupedAgentList({ const renderSection = useCallback( ({ item: section, drag, isActive }: DraggableRenderItemInfo) => { - const isCollapsed = collapsedSections.has(section.key); + const isCollapsed = collapsedProjectKeys.has(section.projectKey); return ( toggleSection(section.key)} + onToggle={() => toggleProjectCollapsed(section.projectKey)} onCreateAgent={handleCreateAgentInProject} onDrag={drag} isDragging={isActive} @@ -540,7 +463,7 @@ export function GroupedAgentList({ ); }, - [AgentListRow, collapsedSections, handleCreateAgentInProject, toggleSection] + [AgentListRow, collapsedProjectKeys, handleCreateAgentInProject, toggleProjectCollapsed] ); const keyExtractor = useCallback( diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 7d4a75682..c7485576e 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -19,6 +19,10 @@ import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-wind import { useVoice } from "@/contexts/voice-context"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { VoicePanel } from "./voice-panel"; +import { useSidebarAgentSections } from "@/hooks/use-sidebar-agent-sections"; +import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; +import { useKeyboardNavStore } from "@/stores/keyboard-nav-store"; +import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts"; const DESKTOP_SIDEBAR_WIDTH = 320; const SIDEBAR_AGENT_LIMIT = 15; @@ -83,6 +87,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { [sortedAgents] ); + const sidebarSections = useSidebarAgentSections(limitedAgents); + const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys); + const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys); + const sidebarShortcutAgentKeys = useMemo(() => { + return deriveSidebarShortcutAgentKeys(sidebarSections, collapsedProjectKeys, 9); + }, [collapsedProjectKeys, sidebarSections]); + + useEffect(() => { + setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys); + }, [setSidebarShortcutAgentKeys, sidebarShortcutAgentKeys]); + const handleClose = useCallback(() => { closeToAgent(); }, [closeToAgent]); diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index acb7677c9..391c880de 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -16,6 +16,13 @@ export const MAX_CONTENT_WIDTH = 820; export const TAURI_TRAFFIC_LIGHT_WIDTH = 78; export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56; +// Check if running in Tauri desktop app (any OS) +function isTauri(): boolean { + if (Platform.OS !== "web") return false; + if (typeof window === "undefined") return false; + return "__TAURI__" in window; +} + // Check if running in Tauri desktop app on macOS function isTauriMac(): boolean { if (Platform.OS !== "web") return false; @@ -28,6 +35,7 @@ function isTauriMac(): boolean { // Cached result - only cache true, keep checking if false (in case __TAURI__ loads later) let _isTauriMacCached: boolean | null = null; +let _isTauriCached: boolean | null = null; export function getIsTauriMac(): boolean { if (_isTauriMacCached === true) { @@ -40,6 +48,17 @@ export function getIsTauriMac(): boolean { return result; } +export function getIsTauri(): boolean { + if (_isTauriCached === true) { + return true; + } + const result = isTauri(); + if (result) { + _isTauriCached = true; + } + return result; +} + // Get traffic light padding values (only non-zero on Tauri macOS) export function getTrafficLightPadding(): { left: number; top: number } { if (!getIsTauriMac()) { diff --git a/packages/app/src/hooks/use-global-keyboard-nav.ts b/packages/app/src/hooks/use-global-keyboard-nav.ts new file mode 100644 index 000000000..c6592d91b --- /dev/null +++ b/packages/app/src/hooks/use-global-keyboard-nav.ts @@ -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]); +} + diff --git a/packages/app/src/hooks/use-sidebar-agent-sections.ts b/packages/app/src/hooks/use-sidebar-agent-sections.ts new file mode 100644 index 000000000..8446177d6 --- /dev/null +++ b/packages/app/src/hooks/use-sidebar-agent-sections.ts @@ -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 => ({ + queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd), + enabled: false, + staleTime: CHECKOUT_STATUS_STALE_TIME, + queryFn: async (): Promise => { + throw new Error("checkout status cache-only query should not run"); + }, + }) + ), + }); + + const remoteUrlByAgentKey = useMemo(() => { + const result = new Map(); + 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; +} + diff --git a/packages/app/src/stores/keyboard-nav-store.ts b/packages/app/src/stores/keyboard-nav-store.ts new file mode 100644 index 000000000..52763e464 --- /dev/null +++ b/packages/app/src/stores/keyboard-nav-store.ts @@ -0,0 +1,29 @@ +import { create } from "zustand"; + +interface KeyboardNavState { + commandCenterOpen: boolean; + altDown: boolean; + cmdOrCtrlDown: boolean; + /** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */ + sidebarShortcutAgentKeys: string[]; + + setCommandCenterOpen: (open: boolean) => void; + setAltDown: (down: boolean) => void; + setCmdOrCtrlDown: (down: boolean) => void; + setSidebarShortcutAgentKeys: (keys: string[]) => void; + resetModifiers: () => void; +} + +export const useKeyboardNavStore = create((set) => ({ + commandCenterOpen: false, + altDown: false, + cmdOrCtrlDown: false, + sidebarShortcutAgentKeys: [], + + setCommandCenterOpen: (open) => set({ commandCenterOpen: open }), + setAltDown: (down) => set({ altDown: down }), + setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }), + setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }), + resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }), +})); + diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store.ts b/packages/app/src/stores/sidebar-collapsed-sections-store.ts new file mode 100644 index 000000000..b801f7a3d --- /dev/null +++ b/packages/app/src/stores/sidebar-collapsed-sections-store.ts @@ -0,0 +1,34 @@ +import { create } from "zustand"; + +interface SidebarCollapsedSectionsState { + collapsedProjectKeys: Set; + toggleProjectCollapsed: (projectKey: string) => void; + setProjectCollapsed: (projectKey: string, collapsed: boolean) => void; +} + +export const useSidebarCollapsedSectionsStore = create( + (set) => ({ + collapsedProjectKeys: new Set(), + toggleProjectCollapsed: (projectKey) => + set((state) => { + const next = new Set(state.collapsedProjectKeys); + if (next.has(projectKey)) { + next.delete(projectKey); + } else { + next.add(projectKey); + } + return { collapsedProjectKeys: next }; + }), + setProjectCollapsed: (projectKey, collapsed) => + set((state) => { + const next = new Set(state.collapsedProjectKeys); + if (collapsed) { + next.add(projectKey); + } else { + next.delete(projectKey); + } + return { collapsedProjectKeys: next }; + }), + }) +); + diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts new file mode 100644 index 000000000..a7e931bf6 --- /dev/null +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { deriveSidebarShortcutAgentKeys, parseSidebarAgentKey } from "./sidebar-shortcuts"; + +describe("parseSidebarAgentKey", () => { + it("parses serverId and agentId", () => { + expect(parseSidebarAgentKey("server:agent")).toEqual({ serverId: "server", agentId: "agent" }); + }); + + it("returns null for invalid keys", () => { + expect(parseSidebarAgentKey("")).toBeNull(); + expect(parseSidebarAgentKey("no-separator")).toBeNull(); + expect(parseSidebarAgentKey(":agent")).toBeNull(); + expect(parseSidebarAgentKey("server:")).toBeNull(); + }); +}); + +describe("deriveSidebarShortcutAgentKeys", () => { + it("skips collapsed projects and preserves visual order", () => { + const sections = [ + { + projectKey: "p1", + agents: [ + { serverId: "s", id: "a1" }, + { serverId: "s", id: "a2" }, + ], + }, + { + projectKey: "p2", + agents: [ + { serverId: "s", id: "b1" }, + { serverId: "s", id: "b2" }, + ], + }, + ]; + + expect(deriveSidebarShortcutAgentKeys(sections, new Set(["p2"]), 9)).toEqual([ + "s:a1", + "s:a2", + ]); + }); + + it("limits to 9", () => { + const sections = [ + { + projectKey: "p1", + agents: Array.from({ length: 20 }, (_, i) => ({ serverId: "s", id: `a${i + 1}` })), + }, + ]; + + expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)).toHaveLength(9); + expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)[0]).toBe("s:a1"); + expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)[8]).toBe("s:a9"); + }); +}); + diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts new file mode 100644 index 000000000..0f8250220 --- /dev/null +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -0,0 +1,46 @@ +export interface SidebarShortcutSection { + projectKey: string; + agents: Array<{ serverId: string; id: string }>; +} + +export function parseSidebarAgentKey( + key: string +): { serverId: string; agentId: string } | null { + const sep = key.indexOf(":"); + if (sep === -1) { + return null; + } + const serverId = key.slice(0, sep); + const agentId = key.slice(sep + 1); + if (!serverId || !agentId) { + return null; + } + return { serverId, agentId }; +} + +export function deriveSidebarShortcutAgentKeys( + sections: SidebarShortcutSection[], + collapsedProjectKeys: ReadonlySet, + limit = 9 +): string[] { + const keys: string[] = []; + const max = Math.max(0, Math.floor(limit)); + if (max === 0) { + return keys; + } + + for (const section of sections) { + if (collapsedProjectKeys.has(section.projectKey)) { + continue; + } + for (const agent of section.agents) { + keys.push(`${agent.serverId}:${agent.id}`); + if (keys.length >= max) { + return keys; + } + } + } + + return keys; +} +