import { View, Text, Pressable, Modal, RefreshControl, FlatList, type ListRenderItem, type PressableStateCallbackType, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useCallback, useMemo, useState, type ReactElement } from "react"; import { router, type Href } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { formatTimeAgo } from "@/utils/time"; import { shortenPath } from "@/utils/shorten-path"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; import { Archive } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { buildHostAgentDetailRoute } from "@/utils/host-routes"; import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import type { Agent } from "@/stores/session-store"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; interface AgentListProps { agents: AggregatedAgent[]; showCheckoutInfo?: boolean; isRefreshing?: boolean; onRefresh?: () => void; selectedAgentId?: string; onAgentSelect?: () => void; listFooterComponent?: ReactElement | null; showAttentionIndicator?: boolean; } type FlatListItem = | { type: "header"; key: string; title: string } | { type: "agent"; key: string; agent: AggregatedAgent }; function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent { return { serverId: agent.serverId, id: agent.id, provider: agent.provider, status: agent.status, createdAt: agent.createdAt, updatedAt: agent.lastActivityAt, lastUserMessageAt: null, lastActivityAt: agent.lastActivityAt, capabilities: { supportsStreaming: false, supportsSessionPersistence: false, supportsDynamicModes: false, supportsMcpServers: false, supportsReasoningStream: false, supportsToolInvocations: false, }, currentModeId: null, availableModes: [], pendingPermissions: [], persistence: null, runtimeInfo: { provider: agent.provider, sessionId: null, }, title: agent.title, cwd: agent.cwd, model: null, thinkingOptionId: null, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason, attentionTimestamp: agent.attentionTimestamp, archivedAt: agent.archivedAt, labels: agent.labels, }; } function rememberArchivedAgentDetail(agent: AggregatedAgent) { if (!agent.archivedAt) { return; } useSessionStore.getState().setAgentDetails(agent.serverId, (previous) => { const existing = previous.get(agent.id); const next = new Map(previous); next.set(agent.id, { ...buildHistoricalAgentDetail(agent), ...existing, archivedAt: existing?.archivedAt ?? agent.archivedAt, cwd: existing?.cwd ?? agent.cwd, }); return next; }); } function deriveDateSectionLabel(lastActivityAt: Date): string { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterdayStart = new Date(todayStart.getTime() - 24 * 60 * 60 * 1000); const activityStart = new Date( lastActivityAt.getFullYear(), lastActivityAt.getMonth(), lastActivityAt.getDate(), ); if (activityStart.getTime() >= todayStart.getTime()) { return "Today"; } if (activityStart.getTime() >= yesterdayStart.getTime()) { return "Yesterday"; } const diffTime = todayStart.getTime() - activityStart.getTime(); const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); if (diffDays <= 7) { return "This week"; } if (diffDays <= 30) { return "This month"; } return "Older"; } function formatStatusLabel(status: AggregatedAgent["status"]): string { switch (status) { case "initializing": return "Starting"; case "idle": return "Idle"; case "running": return "Running"; case "error": return "Error"; case "closed": return "Closed"; default: return status; } } function SessionBadge({ label, icon, tone = "neutral", }: { label: string; icon?: ReactElement; tone?: "neutral" | "warning" | "danger"; }) { const badgeStyle = useMemo( () => [ styles.badge, tone === "warning" && styles.badgeWarning, tone === "danger" && styles.badgeDanger, ], [tone], ); const badgeTextStyle = useMemo( () => [ styles.badgeText, tone === "warning" && styles.badgeTextWarning, tone === "danger" && styles.badgeTextDanger, ], [tone], ); return ( {icon} {label} ); } function SessionRow({ agent, isMobile, selectedAgentId, showAttentionIndicator, onPress, onLongPress, }: { agent: AggregatedAgent; isMobile: boolean; selectedAgentId?: string; showAttentionIndicator: boolean; onPress: (agent: AggregatedAgent) => void; onLongPress: (agent: AggregatedAgent) => void; }) { const { theme } = useUnistyles(); const timeAgo = formatTimeAgo(agent.lastActivityAt); const agentKey = `${agent.serverId}:${agent.id}`; const isSelected = selectedAgentId === agentKey; const statusLabel = formatStatusLabel(agent.status); const projectPath = shortenPath(agent.cwd); const ProviderIcon = getProviderIcon(agent.provider); const pressableStyle = useCallback( ({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.row, isSelected && styles.rowSelected, Boolean(hovered) && styles.rowHovered, pressed && styles.rowPressed, ], [isSelected], ); const handlePress = useCallback(() => onPress(agent), [onPress, agent]); const handleLongPress = useCallback(() => onLongPress(agent), [onLongPress, agent]); const sessionTitleStyle = useMemo( () => [styles.sessionTitle, isSelected && styles.sessionTitleHighlighted], [isSelected], ); const archivedIcon = useMemo( () => , [theme.fontSize.xs, theme.colors.foregroundMuted], ); return ( {agent.title || "New session"} {agent.archivedAt ? : null} {(agent.pendingPermissionCount ?? 0) > 0 ? ( ) : null} {!isMobile && showAttentionIndicator && agent.requiresAttention ? ( ) : null} {isMobile && ( {projectPath} · {statusLabel} · {timeAgo} {agent.serverLabel ? ( <> · {agent.serverLabel} ) : null} )} {!isMobile && ( <> {projectPath} {statusLabel} {timeAgo} )} {isMobile && showAttentionIndicator && agent.requiresAttention ? ( ) : null} ); } export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAgentId, onAgentSelect, listFooterComponent, showAttentionIndicator = true, }: AgentListProps) { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const [actionAgent, setActionAgent] = useState(null); const isMobile = useIsCompactFormFactor(); const { archiveAgent } = useArchiveAgent(); 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( (agent: AggregatedAgent) => { if (isActionSheetVisible) { return; } const serverId = agent.serverId; const agentId = agent.id; const workspaceId = resolveWorkspaceIdByExecutionDirectory({ workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(), workspaceDirectory: agent.cwd, }); onAgentSelect?.(); if (!workspaceId) { router.navigate(buildHostAgentDetailRoute(serverId, agentId) as Href); return; } rememberArchivedAgentDetail(agent); const route = prepareWorkspaceTab({ serverId, workspaceId, target: { kind: "agent", agentId }, pin: Boolean(agent.archivedAt), }); router.navigate(route); }, [isActionSheetVisible, onAgentSelect], ); const handleAgentLongPress = useCallback( (agent: AggregatedAgent) => { const isRunning = agent.status === "running" || agent.status === "initializing"; if (isRunning) { setActionAgent(agent); return; } const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null; if (!client) { setActionAgent(agent); return; } void archiveAgent({ serverId: agent.serverId, agentId: agent.id }).catch(() => {}); }, [archiveAgent], ); const handleCloseActionSheet = useCallback(() => { setActionAgent(null); }, []); const handleArchiveAgent = useCallback(() => { if (!actionAgent || !actionClient) { return; } // Timeout errors are swallowed — the daemon will still process the archive void archiveAgent({ serverId: actionAgent.serverId, agentId: actionAgent.id }).catch(() => {}); setActionAgent(null); }, [actionAgent, actionClient, archiveAgent]); const flatItems = useMemo((): FlatListItem[] => { const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const; const buckets = new Map(); for (const agent of agents) { const label = deriveDateSectionLabel(agent.lastActivityAt); const existing = buckets.get(label) ?? []; existing.push(agent); buckets.set(label, existing); } const result: FlatListItem[] = []; for (const label of order) { const data = buckets.get(label); if (!data || data.length === 0) { continue; } result.push({ type: "header", key: `header:${label}`, title: label }); for (const agent of data) { result.push({ type: "agent", key: `${agent.serverId}:${agent.id}`, agent }); } } return result; }, [agents]); const renderItem: ListRenderItem = useCallback( ({ item }) => { if (item.type === "header") { return ( {item.title} ); } return ( ); }, [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator], ); const keyExtractor = useCallback((item: FlatListItem) => item.key, []); const refreshColors = useMemo( () => [theme.colors.foregroundMuted], [theme.colors.foregroundMuted], ); const sheetContainerStyle = useMemo( () => [styles.sheetContainer, { paddingBottom: Math.max(insets.bottom, theme.spacing[6]) }], [insets.bottom, theme.spacing], ); const sheetArchiveTextStyle = useMemo( () => [styles.sheetArchiveText, isActionDaemonUnavailable && styles.sheetArchiveTextDisabled], [isActionDaemonUnavailable], ); const refreshControl = useMemo( () => onRefresh ? ( ) : undefined, [onRefresh, isRefreshing, theme.colors.foregroundMuted, refreshColors], ); return ( <> {isActionDaemonUnavailable ? "Host offline" : "This agent is still running. Archiving it will stop the agent."} Cancel Archive ); } const styles = StyleSheet.create((theme) => ({ list: { flex: 1, minHeight: 0, }, listContent: { paddingHorizontal: { xs: theme.spacing[3], md: theme.spacing[6], }, paddingTop: theme.spacing[4], paddingBottom: theme.spacing[6], gap: theme.spacing[1], }, sectionHeading: { marginTop: theme.spacing[2], flexDirection: "row", alignItems: "center", gap: theme.spacing[3], paddingHorizontal: theme.spacing[3], marginBottom: theme.spacing[2], }, sectionTitle: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, color: theme.colors.foregroundMuted, }, row: { flexDirection: "row", alignItems: "center", paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: { xs: theme.borderRadius.lg, md: 0, }, marginBottom: { xs: theme.spacing[1], md: 0, }, }, rowContent: { flex: 1, minWidth: 0, }, rowTitleRow: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: theme.spacing[2], }, providerIconWrap: { width: theme.iconSize.md, alignItems: "center", justifyContent: "center", }, rowMetaRow: { flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: theme.spacing[1], marginTop: 2, }, rowTrailing: { marginLeft: theme.spacing[2], }, rowSelected: { backgroundColor: theme.colors.surface2, }, rowHovered: { backgroundColor: theme.colors.surface1, }, rowPressed: { backgroundColor: theme.colors.surface2, }, sessionTitle: { flexShrink: 1, fontSize: theme.fontSize.sm, fontWeight: "400", color: theme.colors.foreground, opacity: 0.86, }, sessionTitleHighlighted: { opacity: 1, }, sessionMetaText: { maxWidth: "100%", fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, sessionMetaSeparator: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, opacity: 0.7, }, columnMeta: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 1, minWidth: 60, maxWidth: 200, marginLeft: theme.spacing[4], }, columnMetaFixed: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 0, width: 72, textAlign: "right" as const, }, badge: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface2, }, badgeWarning: { backgroundColor: "rgba(245, 158, 11, 0.12)", }, badgeDanger: { backgroundColor: "rgba(239, 68, 68, 0.14)", }, badgeText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.medium, color: theme.colors.foregroundMuted, }, badgeTextWarning: { color: theme.colors.palette.amber[500], }, badgeTextDanger: { color: theme.colors.palette.red[300], }, 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, }, })); const SHEET_CANCEL_BUTTON_STYLE = [styles.sheetButton, styles.sheetCancelButton]; const SHEET_ARCHIVE_BUTTON_STYLE = [styles.sheetButton, styles.sheetArchiveButton];