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 { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; import { Archive, ChevronRight } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { HighlightedText } from "@/components/ui/highlighted-text"; import { StatusBadge, type StatusBadgeVariant } from "@/components/ui/status-badge"; import type { AgentSearchMatch } from "@getpaseo/protocol/messages"; import type { MatchRange } from "@getpaseo/protocol/search/text-match"; interface AgentListProps { agents: AggregatedAgent[]; showCheckoutInfo?: boolean; isRefreshing?: boolean; onRefresh?: () => void; selectedAgentId?: string; onAgentSelect?: () => void; listFooterComponent?: ReactElement | null; showAttentionIndicator?: boolean; showHostColumn?: boolean; /** * Where a search matched each row, keyed by `serverId:agentId`. Rows mark the * spans so the list can explain why a result is in it — the subsequence and * typo tiers match characters the eye would not find on its own. */ searchMatchesByAgentKey?: Record; /** * Renders one flat list in the given order instead of grouping by day. Day * headings claim the list is chronological, which is a lie once the caller * has ordered it by something else — relevance, for instance. */ flat?: 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 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 SessionBadge({ label, icon, tone = "neutral", }: { label: string; icon?: ReactElement; tone?: "neutral" | "warning" | "danger"; }) { let variant: StatusBadgeVariant = "muted"; if (tone === "warning") variant = "warning"; else if (tone === "danger") variant = "error"; return ; } function WorkspaceTitlePrefix({ visible, workspaceName, ranges, testID, iconSize, color, }: { visible: boolean; workspaceName: string; ranges?: readonly MatchRange[]; testID: string; iconSize: number; color: string; }) { if (!visible) { return null; } return ( <> ); } function SessionRowBadges({ agent, archivedIcon, pendingPermissionCount, showDesktopAttention, }: { agent: AggregatedAgent; archivedIcon: ReactElement; pendingPermissionCount: number; showDesktopAttention: boolean; }) { const { t } = useTranslation(); return ( <> {agent.archivedAt ? ( ) : null} {pendingPermissionCount > 0 ? ( ) : null} {showDesktopAttention ? ( ) : null} ); } function SessionRowTrailingAttention({ isMobile, showAttentionIndicator, requiresAttention, }: { isMobile: boolean; showAttentionIndicator: boolean; requiresAttention: boolean | undefined; }) { const { t } = useTranslation(); if (!isMobile || !showAttentionIndicator || !requiresAttention) { return null; } return ( ); } function SessionRow({ agent, searchMatches, isMobile, selectedAgentId, showAttentionIndicator, showHostColumn, onPress, onLongPress, }: { agent: AggregatedAgent; searchMatches?: readonly AgentSearchMatch[]; isMobile: boolean; selectedAgentId?: string; showAttentionIndicator: boolean; showHostColumn: 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 projectName = agent.projectPlacement?.projectName ?? ""; const branch = agent.projectPlacement?.checkout.currentBranch ?? ""; const workspaceName = agent.projectPlacement?.workspaceName ?? ""; const ProviderIcon = getProviderIcon(agent.provider); const pendingPermissionCount = agent.pendingPermissionCount ?? 0; const rangesFor = useCallback( (field: AgentSearchMatch["field"]) => searchMatches?.find((match) => match.field === field)?.ranges, [searchMatches], ); 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], ); const showDesktopAttention = !isMobile && showAttentionIndicator && Boolean(agent.requiresAttention); return ( {isMobile ? ( · · · {timeAgo} {showHostColumn && agent.serverLabel ? ( <> · {agent.serverLabel} ) : null} ) : null} {!isMobile ? ( {showHostColumn ? ( {agent.serverLabel} ) : null} {timeAgo} ) : null} ); } export function AgentList({ agents, isRefreshing = false, onRefresh, selectedAgentId, onAgentSelect, listFooterComponent, showAttentionIndicator = true, showHostColumn = false, searchMatchesByAgentKey, flat = false, }: 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?.(); navigateToAgent({ serverId, agentId, workspaceId: agent.workspaceId, pin: true, }); }, [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[] => { if (flat) { return agents.map((agent) => ({ type: "agent" as const, key: `${agent.serverId}:${agent.id}`, agent, })); } 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, flat]); const renderItem: ListRenderItem = useCallback( ({ item }) => { if (item.type === "header") { return ( {formatDateSectionLabel(t, item.section)} ); } return ( ); }, [ handleAgentLongPress, handleAgentPress, isMobile, searchMatchesByAgentKey, selectedAgentId, showAttentionIndicator, showHostColumn, 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, overflow: "hidden", }, rowTitleRow: { flexDirection: "row", alignItems: "center", flexWrap: "nowrap", gap: theme.spacing[2], overflow: "hidden", }, providerIconWrap: { width: theme.iconSize.md, alignItems: "center", justifyContent: "center", }, workspaceTitleText: { flexShrink: 0, maxWidth: 220, fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, 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, minWidth: 0, 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, }, rowColumns: { flexDirection: "row", alignItems: "center", flexShrink: 0, gap: theme.spacing[3], }, columnMeta: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 0, width: 132, }, columnMetaFixed: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 0, width: 72, textAlign: "right" as const, }, columnMetaHost: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 0, width: 120, marginLeft: theme.spacing[4], textAlign: "right" as const, }, 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, }, }));