import { View, Text, Pressable, Modal, Image, Platform, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useCallback, useMemo, useState, useEffect, useRef, type ReactElement, type MutableRefObject, } from "react"; import { router, usePathname } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { type GestureType } from "react-native-gesture-handler"; import { useQueryClient } from "@tanstack/react-query"; import { Archive, ChevronDown, ChevronRight, Plus } from "lucide-react-native"; import { DraggableList, type DraggableRenderItemInfo, } from "./draggable-list"; import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; import { CHECKOUT_STATUS_STALE_TIME, checkoutStatusQueryKey, useCheckoutStatusCacheOnly, } from "@/hooks/use-checkout-status-query"; import { buildAgentNavigationKey, startNavigationTiming, } from "@/utils/navigation-timing"; import { useSectionOrderStore, } 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"; import { AgentStatusDot } from "@/components/agent-status-dot"; type SectionData = SidebarSectionData; interface GroupedAgentListProps { agents: AggregatedAgent[]; isRefreshing?: boolean; onRefresh?: () => void; selectedAgentId?: string; onAgentSelect?: () => void; listFooterComponent?: ReactElement | null; /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ parentGestureRef?: MutableRefObject; } interface SectionHeaderProps { section: SectionData; isCollapsed: boolean; onToggle: () => void; onCreateAgent: (workingDir: string) => void; onDrag: () => void; isDragging: boolean; } function SectionHeader({ section, isCollapsed, onToggle, onCreateAgent, onDrag, isDragging, }: SectionHeaderProps) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); // For project sections, try to get repo name from checkout status const checkoutQuery = useCheckoutStatusCacheOnly({ serverId: section.firstAgentServerId ?? "", cwd: section.workingDir ?? "", }); const checkout = checkoutQuery.data ?? null; // Get project icon const iconQuery = useProjectIconQuery({ serverId: section.firstAgentServerId ?? "", cwd: section.workingDir ?? "", }); const icon = iconQuery.icon; // Derive display title: prefer repo name from remote URL, fallback to path-based name let displayTitle = section.title; if (checkout?.isGit && checkout.remoteUrl) { const isGitHubRemote = checkout.remoteUrl.includes("github.com") || checkout.remoteUrl.includes("git@github.com:"); const repoName = isGitHubRemote ? parseRepoNameFromRemoteUrl(checkout.remoteUrl) : parseRepoShortNameFromRemoteUrl(checkout.remoteUrl); if (repoName) { displayTitle = repoName; } } const createAgentWorkingDir = (checkout?.isPaseoOwnedWorktree ? checkout.mainRepoRoot : null) ?? section.workingDir; const handleCreatePress = useCallback( (e: { stopPropagation: () => void }) => { e.stopPropagation(); if (createAgentWorkingDir) { onCreateAgent(createAgentWorkingDir); } }, [onCreateAgent, createAgentWorkingDir] ); return ( [ styles.sectionHeader, isHovered && styles.sectionHeaderHovered, pressed && styles.sectionHeaderPressed, !isCollapsed && styles.sectionHeaderExpanded, isDragging && styles.sectionHeaderDragging, Platform.OS === "web" && ({ cursor: "grab" } as any), ]} onPress={onToggle} onLongPress={onDrag} delayLongPress={200} onHoverIn={() => setIsHovered(true)} onHoverOut={() => setIsHovered(false)} > {isCollapsed ? ( ) : ( )} {icon ? ( ) : ( {(section.workingDir?.split("/").pop() ?? displayTitle).charAt(0).toUpperCase()} )} {displayTitle} {createAgentWorkingDir && ( true} onStartShouldSetResponderCapture={() => true} > {({ hovered, pressed }) => ( )} )} ); } interface GroupedAgentRowProps { agent: AggregatedAgent; isSelected: boolean; shortcutNumber: number | null; onPress: () => void; onLongPress: () => void; onArchive: (e: { stopPropagation: () => void }) => void; } function GroupedAgentRow({ agent, isSelected, shortcutNumber, onPress, onLongPress, onArchive, }: GroupedAgentRowProps) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const [isArchiveHovered, setIsArchiveHovered] = useState(false); const [isArchiveConfirmVisible, setIsArchiveConfirmVisible] = useState(false); const hoverOutTimeoutRef = useRef | null>(null); const clearHoverOutTimeout = useCallback(() => { if (!hoverOutTimeoutRef.current) { return; } clearTimeout(hoverOutTimeoutRef.current); hoverOutTimeoutRef.current = null; }, []); useEffect(() => { return () => clearHoverOutTimeout(); }, [clearHoverOutTimeout]); const handleHoverIn = useCallback(() => { clearHoverOutTimeout(); setIsHovered(true); }, [clearHoverOutTimeout]); const handleHoverOut = useCallback(() => { clearHoverOutTimeout(); hoverOutTimeoutRef.current = setTimeout(() => { setIsHovered(false); setIsArchiveHovered(false); setIsArchiveConfirmVisible(false); }, 50); }, [clearHoverOutTimeout]); const checkoutQuery = useCheckoutStatusCacheOnly({ serverId: agent.serverId, cwd: agent.cwd, }); const checkout = checkoutQuery.data ?? null; const activeBranchLabel = checkout?.isGit ? ((checkout.currentBranch && checkout.currentBranch !== "HEAD" ? checkout.currentBranch : null) ?? checkout.baseRef ?? "git") : null; const canArchive = agent.status !== "running" && !agent.requiresAttention; const showArchive = canArchive && shortcutNumber === null && (isHovered || isArchiveHovered || isArchiveConfirmVisible); return ( [ styles.agentItem, !isSelected && styles.agentItemUnselected, isSelected && styles.agentItemSelected, isHovered && styles.agentItemHovered, pressed && styles.agentItemPressed, ]} onPress={onPress} onPressIn={() => { if (Platform.OS !== "web") { return; } handleHoverIn(); }} onLongPress={onLongPress} onHoverIn={handleHoverIn} onHoverOut={handleHoverOut} testID={`agent-row-${agent.serverId}-${agent.id}`} > {agent.title || "New agent"} {shortcutNumber !== null ? ( {shortcutNumber} ) : showArchive ? ( { clearHoverOutTimeout(); setIsHovered(true); setIsArchiveHovered(true); }} onHoverOut={() => { setIsArchiveHovered(false); }} onPress={(e) => { e.stopPropagation(); if (!isArchiveConfirmVisible) { setIsArchiveConfirmVisible(true); return; } onArchive(e); setIsArchiveConfirmVisible(false); }} testID={ isArchiveConfirmVisible ? `agent-archive-confirm-${agent.serverId}-${agent.id}` : `agent-archive-${agent.serverId}-${agent.id}` } > {({ hovered: archiveHovered }) => isArchiveConfirmVisible ? ( Confirm ) : ( ) } ) : activeBranchLabel ? ( {activeBranchLabel} ) : null} ); } export function GroupedAgentList({ agents, isRefreshing = false, onRefresh, selectedAgentId, onAgentSelect, listFooterComponent, parentGestureRef, }: GroupedAgentListProps) { const { theme } = useUnistyles(); const pathname = usePathname(); const queryClient = useQueryClient(); const insets = useSafeAreaInsets(); const [actionAgent, setActionAgent] = useState(null); 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 ); const isActionSheetVisible = actionAgent !== null; const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); const handleAgentPress = useCallback( (serverId: string, agentId: string) => { if (isActionSheetVisible) { return; } // Clear attention flag when opening agent const session = useSessionStore.getState().sessions[serverId]; if (session?.client) { session.client.clearAgentAttention(agentId); } const navigationKey = buildAgentNavigationKey(serverId, agentId); startNavigationTiming(navigationKey, { from: "home", to: "agent", params: { serverId, agentId }, }); const shouldReplace = pathname.startsWith("/agent/"); const navigate = shouldReplace ? router.replace : router.push; onAgentSelect?.(); navigate(`/agent/${serverId}/${agentId}` as any); }, [isActionSheetVisible, pathname, onAgentSelect] ); const handleAgentLongPress = useCallback((agent: AggregatedAgent) => { setActionAgent(agent); }, []); const handleCloseActionSheet = useCallback(() => { setActionAgent(null); }, []); const handleArchiveFromSheet = useCallback(() => { if (!actionAgent || !actionClient) { return; } void actionClient.archiveAgent(actionAgent.id); setActionAgent(null); }, [actionAgent, actionClient]); const handleCreateAgentInProject = useCallback( (workingDir: string) => { onAgentSelect?.(); router.push(`/agent?workingDir=${encodeURIComponent(workingDir)}` as any); }, [onAgentSelect] ); // Prefetch checkout status for all agents in the sidebar. // The sidebar shows a limited number of agents, so we fetch all of them upfront // to ensure project grouping (by remote URL) is stable from the start. useEffect(() => { for (const agent of agents) { const session = useSessionStore.getState().sessions[agent.serverId]; const client = session?.client ?? null; const isConnected = session?.connection.isConnected ?? false; if (!client || !isConnected) { continue; } const queryKey = checkoutStatusQueryKey(agent.serverId, agent.cwd); const queryState = queryClient.getQueryState(queryKey); const isFetching = queryState?.fetchStatus === "fetching"; const isFresh = typeof queryState?.dataUpdatedAt === "number" && Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME; if (isFetching || isFresh) { continue; } void queryClient.prefetchQuery({ queryKey, queryFn: async () => await client.getCheckoutStatus(agent.cwd), staleTime: CHECKOUT_STATUS_STALE_TIME, }).catch((error) => { console.warn("[checkout_status] prefetch failed", error); }); } }, [agents, queryClient]); // Section order from store const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder); const sections: SectionData[] = useSidebarAgentSections(agents); const handleDragEnd = useCallback( (newData: SectionData[]) => { const newOrder = newData.map((section) => section.projectKey); setProjectOrder(newOrder); }, [setProjectOrder] ); const handleArchiveAgent = useCallback( (e: { stopPropagation: () => void }, agent: AggregatedAgent) => { e.stopPropagation(); const session = useSessionStore.getState().sessions[agent.serverId]; const client = session?.client ?? null; if (client) { void client.archiveAgent(agent.id).catch((error) => { console.warn("[archive_agent] failed", error); }); } }, [] ); const renderSection = useCallback( ({ item: section, drag, isActive }: DraggableRenderItemInfo) => { const isCollapsed = collapsedProjectKeys.has(section.projectKey); return ( toggleProjectCollapsed(section.projectKey)} onCreateAgent={handleCreateAgentInProject} onDrag={drag} isDragging={isActive} /> {!isCollapsed && section.agents.map((agent) => ( handleAgentPress(agent.serverId, agent.id)} onLongPress={() => handleAgentLongPress(agent)} onArchive={(e) => handleArchiveAgent(e, agent)} /> ))} ); }, [ collapsedProjectKeys, handleAgentLongPress, handleAgentPress, handleArchiveAgent, handleCreateAgentInProject, selectedAgentId, showShortcutBadges, shortcutIndexByAgentKey, toggleProjectCollapsed, ] ); const keyExtractor = useCallback( (section: SectionData) => section.key, [] ); return ( <> {isActionDaemonUnavailable ? "Host offline" : "Archive this agent?"} Cancel Archive ); } const styles = StyleSheet.create((theme) => ({ list: { flex: 1, }, listContent: { paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, sectionHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], marginTop: theme.spacing[2], borderRadius: theme.borderRadius.md, }, sectionHeaderHovered: { backgroundColor: theme.colors.surface1, }, sectionHeaderPressed: { backgroundColor: theme.colors.surface2, }, sectionHeaderExpanded: { marginBottom: theme.spacing[2], }, sectionHeaderDragging: { backgroundColor: theme.colors.surface2, }, sectionContainer: { marginHorizontal: theme.spacing[2], marginBottom: theme.spacing[2], }, sectionDragging: { opacity: 0.9, transform: [{ scale: 1.02 }], }, chevron: { opacity: 0.5, }, sectionHeaderLeft: { flexDirection: "row", alignItems: "center", justifyContent: "flex-start", flex: 1, minWidth: 0, gap: theme.spacing[2], }, projectIcon: { width: 16, height: 16, borderRadius: theme.borderRadius.sm, }, projectIconPlaceholder: { width: 16, height: 16, borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", }, projectIconPlaceholderText: { fontSize: 10, fontWeight: "500", color: theme.colors.foregroundMuted, }, sectionHeaderRight: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[2], marginLeft: theme.spacing[2], }, createAgentButton: { width: 18, height: 18, alignItems: "center", justifyContent: "center", }, sectionTitle: { fontSize: theme.fontSize.sm, fontWeight: "normal", color: theme.colors.foregroundMuted, flex: 1, textAlign: "left", }, agentItem: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], marginLeft: theme.spacing[1], borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[1], }, agentItemUnselected: { opacity: 0.75, }, agentItemSelected: { backgroundColor: theme.colors.surface2, }, agentItemHovered: { backgroundColor: theme.colors.surface1, }, agentItemPressed: { backgroundColor: theme.colors.surface2, }, agentContent: { flex: 1, gap: theme.spacing[0], }, row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, agentTitle: { flex: 1, fontSize: theme.fontSize.base, fontWeight: "400", color: theme.colors.foreground, opacity: 0.8, }, archiveButton: { alignItems: "center", justifyContent: "center", marginLeft: theme.spacing[1], }, branchBadge: { minWidth: 20, maxWidth: "40%", height: 20, paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.md, borderWidth: 1, borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", }, branchBadgeText: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, lineHeight: 12, }, archiveConfirmBadge: { minWidth: 76, maxWidth: 9999, flexShrink: 0, }, archiveConfirmText: { color: theme.colors.foreground, }, archiveConfirmTextHovered: { opacity: 0.9, }, agentTitleHighlighted: { color: theme.colors.foreground, opacity: 1, }, secondaryRow: { fontSize: theme.fontSize.sm, fontWeight: "300", color: theme.colors.foregroundMuted, }, sheetOverlay: { flex: 1, justifyContent: "flex-end", }, sheetBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, backgroundColor: "rgba(0,0,0,0.35)", }, sheetContainer: { backgroundColor: theme.colors.surface2, borderTopLeftRadius: theme.borderRadius["2xl"], borderTopRightRadius: theme.borderRadius["2xl"], paddingHorizontal: theme.spacing[6], paddingTop: theme.spacing[4], gap: theme.spacing[4], }, sheetHandle: { alignSelf: "center", width: 40, height: 4, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.foregroundMuted, opacity: 0.3, }, sheetTitle: { fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, textAlign: "center", }, sheetButtonRow: { flexDirection: "row", gap: theme.spacing[3], }, sheetButton: { flex: 1, borderRadius: theme.borderRadius.lg, paddingVertical: theme.spacing[4], alignItems: "center", justifyContent: "center", }, sheetArchiveButton: { backgroundColor: theme.colors.primary, }, sheetArchiveText: { color: theme.colors.primaryForeground, fontWeight: theme.fontWeight.semibold, fontSize: theme.fontSize.base, }, sheetArchiveTextDisabled: { opacity: 0.5, }, sheetCancelButton: { backgroundColor: theme.colors.surface1, }, sheetCancelText: { color: theme.colors.foreground, fontWeight: theme.fontWeight.semibold, fontSize: theme.fontSize.base, }, }));