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 { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; 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 { navigateToAgent } from "@/utils/navigate-to-agent"; 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 DateSectionKey = "today" | "yesterday" | "thisWeek" | "thisMonth" | "older"; const DATE_SECTION_ORDER = [ "today", "yesterday", "thisWeek", "thisMonth", "older", ] as const satisfies readonly DateSectionKey[]; type FlatListItem = | { type: "header"; key: string; section: DateSectionKey } | { 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, parentAgentId: null, }; } 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 deriveDateSectionKey(lastActivityAt: Date): DateSectionKey { 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 "thisWeek"; } if (diffDays <= 30) { return "thisMonth"; } return "older"; } function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string { switch (section) { case "today": return t("agentList.dateSections.today"); case "yesterday": return t("agentList.dateSections.yesterday"); case "thisWeek": return t("agentList.dateSections.thisWeek"); case "thisMonth": return t("agentList.dateSections.thisMonth"); case "older": return t("agentList.dateSections.older"); } } function formatStatusLabel(t: TFunction, status: AggregatedAgent["status"]): string { switch (status) { case "initializing": return t("agentList.status.initializing"); case "idle": return t("agentList.status.idle"); case "running": return t("agentList.status.running"); case "error": return t("agentList.status.error"); case "closed": return t("agentList.status.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 { t } = useTranslation(); const timeAgo = formatTimeAgo(agent.lastActivityAt); const agentKey = `${agent.serverId}:${agent.id}`; const isSelected = selectedAgentId === agentKey; const statusLabel = formatStatusLabel(t, agent.status); const projectPath = shortenPath(agent.cwd); const ProviderIcon = getProviderIcon(agent.provider); const pendingPermissionCount = agent.pendingPermissionCount ?? 0; 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 || t("agentList.fallbackTitle")} {agent.archivedAt ? ( ) : null} {pendingPermissionCount > 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 { t } = useTranslation(); 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; onAgentSelect?.(); rememberArchivedAgentDetail(agent); navigateToAgent({ serverId, agentId, pin: Boolean(agent.archivedAt), }); }, [isActionSheetVisible, onAgentSelect], ); const handleAgentLongPress = useCallback( (agent: AggregatedAgent) => { const isRunning = agent.status === "running"; 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 buckets = new Map(); for (const agent of agents) { const section = deriveDateSectionKey(agent.lastActivityAt); const existing = buckets.get(section) ?? []; existing.push(agent); buckets.set(section, existing); } const result: FlatListItem[] = []; for (const section of DATE_SECTION_ORDER) { const data = buckets.get(section); if (!data || data.length === 0) { continue; } result.push({ type: "header", key: `header:${section}`, section }); 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 ( {formatDateSectionLabel(t, item.section)} ); } return ( ); }, [handleAgentLongPress, handleAgentPress, isMobile, selectedAgentId, showAttentionIndicator, t], ); 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 ? t("agentList.archiveSheet.hostOffline") : t("agentList.archiveSheet.runningAgent")} {t("common.actions.cancel")} {t("agentList.archiveSheet.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];