diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index bc06592e3..0af043711 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -4,9 +4,9 @@ import { Pressable, Modal, RefreshControl, - FlatList, + SectionList, type ViewToken, - type ListRenderItem, + type SectionListRenderItem, } from "react-native"; import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; import { router, usePathname } from "expo-router"; @@ -36,6 +36,40 @@ interface AgentListProps { listFooterComponent?: ReactElement | null; } +interface AgentListSection { + key: string; + title: string; + data: AggregatedAgent[]; +} + +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"; +} + export function AgentList({ agents, isRefreshing = false, @@ -213,9 +247,37 @@ export function AgentList({ ] ); - const renderAgentItem = useCallback>( - ({ item: agent }) => , - [AgentListRow] + const sections = useMemo((): AgentListSection[] => { + 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: AgentListSection[] = []; + for (const label of order) { + const data = buckets.get(label); + if (!data || data.length === 0) { + continue; + } + result.push({ key: `date:${label}`, title: label, data }); + } + return result; + }, [agents]); + + const renderAgentItem: SectionListRenderItem = + useCallback(({ item: agent }) => , [AgentListRow]); + + const renderSectionHeader = useCallback( + ({ section }: { section: AgentListSection }) => ( + + {section.title} + + ), + [] ); const keyExtractor = useCallback( @@ -225,12 +287,14 @@ export function AgentList({ return ( <> - ({ paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, + sectionHeader: { + paddingVertical: theme.spacing[2], + marginTop: theme.spacing[2], + }, + sectionTitle: { + fontSize: theme.fontSize.sm, + fontWeight: "500", + color: theme.colors.foregroundMuted, + textAlign: "left", + }, agentItem: { paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx index 0206cde61..0ae915e9e 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -20,13 +20,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useQueries, useQueryClient, type UseQueryOptions } from "@tanstack/react-query"; import { ChevronDown, ChevronRight } from "lucide-react-native"; import { formatTimeAgo } from "@/utils/time"; -import { shortenPath } from "@/utils/shorten-path"; -import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info"; import { groupAgents, + parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl, type ProjectGroup, - type DateGroup, } from "@/utils/agent-grouping"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; @@ -42,13 +40,12 @@ import { } from "@/utils/navigation-timing"; type SectionType = - | { type: "project"; data: ProjectGroup } - | { type: "date"; data: DateGroup }; + | { type: "project"; data: ProjectGroup }; interface SectionData { key: string; title: string; - type: "project" | "date"; + type: "project"; data: AggregatedAgent[]; /** For project sections, the first agent's serverId (to lookup checkout status) */ firstAgentServerId?: string; @@ -68,14 +65,12 @@ interface GroupedAgentListProps { interface SectionHeaderProps { section: SectionData; isCollapsed: boolean; - agentCount: number; onToggle: () => void; } function SectionHeader({ section, isCollapsed, - agentCount, onToggle, }: SectionHeaderProps) { const { theme } = useUnistyles(); @@ -90,7 +85,14 @@ function SectionHeader({ // Derive display title: prefer repo name from remote URL, fallback to path-based name let displayTitle = section.title; if (section.type === "project" && checkout?.isGit && checkout.remoteUrl) { - const repoName = parseRepoShortNameFromRemoteUrl(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; } @@ -105,24 +107,17 @@ function SectionHeader({ onPress={onToggle} > - {isCollapsed ? ( - - ) : ( - - )} {displayTitle} - {agentCount} + + {isCollapsed ? ( + + ) : ( + + )} + ); } @@ -241,7 +236,7 @@ export function GroupedAgentList({ }, [agents, checkoutCacheQueries]); // Group agents - const { activeGroups, inactiveGroups } = useMemo( + const { activeGroups } = useMemo( () => groupAgents(agents, { getRemoteUrl: (agent) => @@ -268,19 +263,8 @@ export function GroupedAgentList({ }); } - for (const group of inactiveGroups) { - const sectionKey = `date:${group.label}`; - const isCollapsed = collapsedSections.has(sectionKey); - result.push({ - key: sectionKey, - title: group.label, - type: "date", - data: isCollapsed ? [] : group.agents, - }); - } - return result; - }, [activeGroups, inactiveGroups, collapsedSections]); + }, [activeGroups, collapsedSections]); const viewabilityConfig = useMemo( () => ({ itemVisiblePercentThreshold: 30 }), @@ -338,8 +322,9 @@ export function GroupedAgentList({ agentId: agent.id, }); const checkout = checkoutQuery.data ?? null; - const projectPath = deriveProjectPath(agent.cwd, checkout); - const branchLabel = deriveBranchLabel(checkout); + const activeBranchLabel = checkout?.isGit + ? (checkout.currentBranch ?? checkout.baseRef ?? "git") + : null; return ( - {shortenPath(projectPath)} - {branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo} + {activeBranchLabel ? `${activeBranchLabel} · ${timeAgo}` : timeAgo} )} @@ -391,31 +375,21 @@ export function GroupedAgentList({ ); const renderItem: SectionListRenderItem = - useCallback( - ({ item: agent }) => , - [AgentListRow] - ); + useCallback(({ item: agent }) => , [AgentListRow]); const renderSectionHeader = useCallback( ({ section }: { section: SectionData }) => { const isCollapsed = collapsedSections.has(section.key); - const agentCount = - section.type === "project" - ? activeGroups.find((g) => `project:${g.projectKey}` === section.key) - ?.agents.length ?? 0 - : inactiveGroups.find((g) => `date:${g.label}` === section.key) - ?.agents.length ?? 0; return ( toggleSection(section.key)} /> ); }, - [collapsedSections, activeGroups, inactiveGroups, toggleSection] + [collapsedSections, toggleSection] ); const keyExtractor = useCallback( @@ -521,7 +495,8 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "space-between", paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[2], + paddingHorizontal: theme.spacing[4] + theme.spacing[2], + marginHorizontal: -theme.spacing[4], marginTop: theme.spacing[2], borderRadius: theme.borderRadius.md, }, @@ -531,22 +506,21 @@ const styles = StyleSheet.create((theme) => ({ sectionHeaderLeft: { flexDirection: "row", alignItems: "center", + justifyContent: "flex-start", flex: 1, minWidth: 0, }, - chevron: { - marginRight: theme.spacing[1], + sectionHeaderRight: { + alignItems: "center", + justifyContent: "center", + marginLeft: theme.spacing[2], }, sectionTitle: { fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, + fontWeight: "500", color: theme.colors.foregroundMuted, flex: 1, - }, - sectionCount: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - marginLeft: theme.spacing[2], + textAlign: "left", }, agentItem: { paddingVertical: theme.spacing[2], diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 2c0d343ad..e5bd873e5 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -9,7 +9,7 @@ import Animated, { } from "react-native-reanimated"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { Plus, Settings } from "lucide-react-native"; +import { ChevronRight, Plus, Settings } from "lucide-react-native"; import { router } from "expo-router"; import { usePanelStore } from "@/stores/panel-store"; import { GroupedAgentList } from "./grouped-agent-list"; @@ -75,7 +75,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { () => sortedAgents.slice(0, SIDEBAR_AGENT_LIMIT), [sortedAgents] ); - const hasMore = agents.length > SIDEBAR_AGENT_LIMIT; const handleClose = useCallback(() => { closeToAgent(); @@ -171,20 +170,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", })); - const viewMoreButton = hasMore ? ( + const viewMoreButton = ( [ - styles.newAgentButton, + style={({ hovered, pressed }) => [ styles.viewMoreButton, - hovered && styles.newAgentButtonHovered, + (hovered || pressed) && styles.viewMoreButtonHovered, ]} onPress={handleViewMore} > - View More + All agents + - ) : null; + ); // Render mobile sidebar // On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll @@ -354,12 +353,25 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: theme.spacing[2], }, viewMoreButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "flex-start", + gap: theme.spacing[1], paddingVertical: theme.spacing[2], + paddingHorizontal: 0, + borderWidth: 0, + backgroundColor: "transparent", + alignSelf: "flex-start", + transitionProperty: "opacity", + transitionDuration: "150ms", + }, + viewMoreButtonHovered: { + opacity: 0.8, }, viewMoreButtonText: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, - color: theme.colors.foreground, + color: theme.colors.foregroundMuted, }, sidebarFooter: { paddingHorizontal: theme.spacing[4], diff --git a/packages/app/src/utils/agent-grouping.ts b/packages/app/src/utils/agent-grouping.ts index be3cc6b27..8f7ebe8d9 100644 --- a/packages/app/src/utils/agent-grouping.ts +++ b/packages/app/src/utils/agent-grouping.ts @@ -204,7 +204,7 @@ export interface GroupedAgents { inactiveGroups: DateGroup[]; } -const ACTIVE_GRACE_PERIOD_MS = 5 * 60 * 1000; // 5 minutes +const ACTIVE_GRACE_PERIOD_MS = 15 * 60 * 1000; // 15 minutes interface GroupAgentsOptions { /**