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