Update files

This commit is contained in:
Mohamed Boudra
2026-01-25 20:40:00 +07:00
parent d1a47c22f9
commit be235e14d7
4 changed files with 138 additions and 78 deletions
+81 -7
View File
@@ -4,9 +4,9 @@ import {
Pressable, Pressable,
Modal, Modal,
RefreshControl, RefreshControl,
FlatList, SectionList,
type ViewToken, type ViewToken,
type ListRenderItem, type SectionListRenderItem,
} from "react-native"; } from "react-native";
import { useCallback, useMemo, useRef, useState, type ReactElement } from "react"; import { useCallback, useMemo, useRef, useState, type ReactElement } from "react";
import { router, usePathname } from "expo-router"; import { router, usePathname } from "expo-router";
@@ -36,6 +36,40 @@ interface AgentListProps {
listFooterComponent?: ReactElement | null; 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({ export function AgentList({
agents, agents,
isRefreshing = false, isRefreshing = false,
@@ -213,9 +247,37 @@ export function AgentList({
] ]
); );
const renderAgentItem = useCallback<ListRenderItem<AggregatedAgent>>( const sections = useMemo((): AgentListSection[] => {
({ item: agent }) => <AgentListRow agent={agent} />, const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
[AgentListRow] const buckets = new Map<string, AggregatedAgent[]>();
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<AggregatedAgent, AgentListSection> =
useCallback(({ item: agent }) => <AgentListRow agent={agent} />, [AgentListRow]);
const renderSectionHeader = useCallback(
({ section }: { section: AgentListSection }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{section.title}</Text>
</View>
),
[]
); );
const keyExtractor = useCallback( const keyExtractor = useCallback(
@@ -225,12 +287,14 @@ export function AgentList({
return ( return (
<> <>
<FlatList <SectionList
data={agents} sections={sections}
style={styles.list} style={styles.list}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
renderItem={renderAgentItem} renderItem={renderAgentItem}
renderSectionHeader={renderSectionHeader}
stickySectionHeadersEnabled={false}
extraData={selectedAgentId} extraData={selectedAgentId}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
@@ -315,6 +379,16 @@ const styles = StyleSheet.create((theme) => ({
paddingTop: theme.spacing[2], paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4], 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: { agentItem: {
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
@@ -20,13 +20,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQueries, useQueryClient, type UseQueryOptions } from "@tanstack/react-query"; import { useQueries, useQueryClient, type UseQueryOptions } from "@tanstack/react-query";
import { ChevronDown, ChevronRight } from "lucide-react-native"; import { ChevronDown, ChevronRight } from "lucide-react-native";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info";
import { import {
groupAgents, groupAgents,
parseRepoNameFromRemoteUrl,
parseRepoShortNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl,
type ProjectGroup, type ProjectGroup,
type DateGroup,
} from "@/utils/agent-grouping"; } from "@/utils/agent-grouping";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -42,13 +40,12 @@ import {
} from "@/utils/navigation-timing"; } from "@/utils/navigation-timing";
type SectionType = type SectionType =
| { type: "project"; data: ProjectGroup } | { type: "project"; data: ProjectGroup };
| { type: "date"; data: DateGroup };
interface SectionData { interface SectionData {
key: string; key: string;
title: string; title: string;
type: "project" | "date"; type: "project";
data: AggregatedAgent[]; data: AggregatedAgent[];
/** For project sections, the first agent's serverId (to lookup checkout status) */ /** For project sections, the first agent's serverId (to lookup checkout status) */
firstAgentServerId?: string; firstAgentServerId?: string;
@@ -68,14 +65,12 @@ interface GroupedAgentListProps {
interface SectionHeaderProps { interface SectionHeaderProps {
section: SectionData; section: SectionData;
isCollapsed: boolean; isCollapsed: boolean;
agentCount: number;
onToggle: () => void; onToggle: () => void;
} }
function SectionHeader({ function SectionHeader({
section, section,
isCollapsed, isCollapsed,
agentCount,
onToggle, onToggle,
}: SectionHeaderProps) { }: SectionHeaderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -90,7 +85,14 @@ function SectionHeader({
// Derive display title: prefer repo name from remote URL, fallback to path-based name // Derive display title: prefer repo name from remote URL, fallback to path-based name
let displayTitle = section.title; let displayTitle = section.title;
if (section.type === "project" && checkout?.isGit && checkout.remoteUrl) { 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) { if (repoName) {
displayTitle = repoName; displayTitle = repoName;
} }
@@ -105,24 +107,17 @@ function SectionHeader({
onPress={onToggle} onPress={onToggle}
> >
<View style={styles.sectionHeaderLeft}> <View style={styles.sectionHeaderLeft}>
{isCollapsed ? (
<ChevronRight
size={14}
color={theme.colors.foregroundMuted}
style={styles.chevron}
/>
) : (
<ChevronDown
size={14}
color={theme.colors.foregroundMuted}
style={styles.chevron}
/>
)}
<Text style={styles.sectionTitle} numberOfLines={1}> <Text style={styles.sectionTitle} numberOfLines={1}>
{displayTitle} {displayTitle}
</Text> </Text>
</View> </View>
<Text style={styles.sectionCount}>{agentCount}</Text> <View style={styles.sectionHeaderRight}>
{isCollapsed ? (
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
) : (
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable> </Pressable>
); );
} }
@@ -241,7 +236,7 @@ export function GroupedAgentList({
}, [agents, checkoutCacheQueries]); }, [agents, checkoutCacheQueries]);
// Group agents // Group agents
const { activeGroups, inactiveGroups } = useMemo( const { activeGroups } = useMemo(
() => () =>
groupAgents(agents, { groupAgents(agents, {
getRemoteUrl: (agent) => 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; return result;
}, [activeGroups, inactiveGroups, collapsedSections]); }, [activeGroups, collapsedSections]);
const viewabilityConfig = useMemo( const viewabilityConfig = useMemo(
() => ({ itemVisiblePercentThreshold: 30 }), () => ({ itemVisiblePercentThreshold: 30 }),
@@ -338,8 +322,9 @@ export function GroupedAgentList({
agentId: agent.id, agentId: agent.id,
}); });
const checkout = checkoutQuery.data ?? null; const checkout = checkoutQuery.data ?? null;
const projectPath = deriveProjectPath(agent.cwd, checkout); const activeBranchLabel = checkout?.isGit
const branchLabel = deriveBranchLabel(checkout); ? (checkout.currentBranch ?? checkout.baseRef ?? "git")
: null;
return ( return (
<Pressable <Pressable
@@ -373,8 +358,7 @@ export function GroupedAgentList({
</View> </View>
<Text style={styles.secondaryRow} numberOfLines={1}> <Text style={styles.secondaryRow} numberOfLines={1}>
{shortenPath(projectPath)} {activeBranchLabel ? `${activeBranchLabel} · ${timeAgo}` : timeAgo}
{branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo}
</Text> </Text>
</View> </View>
)} )}
@@ -391,31 +375,21 @@ export function GroupedAgentList({
); );
const renderItem: SectionListRenderItem<AggregatedAgent, SectionData> = const renderItem: SectionListRenderItem<AggregatedAgent, SectionData> =
useCallback( useCallback(({ item: agent }) => <AgentListRow agent={agent} />, [AgentListRow]);
({ item: agent }) => <AgentListRow agent={agent} />,
[AgentListRow]
);
const renderSectionHeader = useCallback( const renderSectionHeader = useCallback(
({ section }: { section: SectionData }) => { ({ section }: { section: SectionData }) => {
const isCollapsed = collapsedSections.has(section.key); 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 ( return (
<SectionHeader <SectionHeader
section={section} section={section}
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
agentCount={agentCount}
onToggle={() => toggleSection(section.key)} onToggle={() => toggleSection(section.key)}
/> />
); );
}, },
[collapsedSections, activeGroups, inactiveGroups, toggleSection] [collapsedSections, toggleSection]
); );
const keyExtractor = useCallback( const keyExtractor = useCallback(
@@ -521,7 +495,8 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[4] + theme.spacing[2],
marginHorizontal: -theme.spacing[4],
marginTop: theme.spacing[2], marginTop: theme.spacing[2],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
}, },
@@ -531,22 +506,21 @@ const styles = StyleSheet.create((theme) => ({
sectionHeaderLeft: { sectionHeaderLeft: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
justifyContent: "flex-start",
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
chevron: { sectionHeaderRight: {
marginRight: theme.spacing[1], alignItems: "center",
justifyContent: "center",
marginLeft: theme.spacing[2],
}, },
sectionTitle: { sectionTitle: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium, fontWeight: "500",
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
flex: 1, flex: 1,
}, textAlign: "left",
sectionCount: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginLeft: theme.spacing[2],
}, },
agentItem: { agentItem: {
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
@@ -9,7 +9,7 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; 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 { router } from "expo-router";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { GroupedAgentList } from "./grouped-agent-list"; import { GroupedAgentList } from "./grouped-agent-list";
@@ -75,7 +75,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
() => sortedAgents.slice(0, SIDEBAR_AGENT_LIMIT), () => sortedAgents.slice(0, SIDEBAR_AGENT_LIMIT),
[sortedAgents] [sortedAgents]
); );
const hasMore = agents.length > SIDEBAR_AGENT_LIMIT;
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
closeToAgent(); closeToAgent();
@@ -171,20 +170,20 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
})); }));
const viewMoreButton = hasMore ? ( const viewMoreButton = (
<View style={styles.viewMoreContainer}> <View style={styles.viewMoreContainer}>
<Pressable <Pressable
style={({ hovered }) => [ style={({ hovered, pressed }) => [
styles.newAgentButton,
styles.viewMoreButton, styles.viewMoreButton,
hovered && styles.newAgentButtonHovered, (hovered || pressed) && styles.viewMoreButtonHovered,
]} ]}
onPress={handleViewMore} onPress={handleViewMore}
> >
<Text style={styles.viewMoreButtonText}>View More</Text> <Text style={styles.viewMoreButtonText}>All agents</Text>
<ChevronRight size={16} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
</View> </View>
) : null; );
// Render mobile sidebar // Render mobile sidebar
// On web, use "auto" instead of "box-none" because web's pointer-events: none blocks scroll // 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], paddingTop: theme.spacing[2],
}, },
viewMoreButton: { viewMoreButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[1],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: 0,
borderWidth: 0,
backgroundColor: "transparent",
alignSelf: "flex-start",
transitionProperty: "opacity",
transitionDuration: "150ms",
},
viewMoreButtonHovered: {
opacity: 0.8,
}, },
viewMoreButtonText: { viewMoreButtonText: {
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground, color: theme.colors.foregroundMuted,
}, },
sidebarFooter: { sidebarFooter: {
paddingHorizontal: theme.spacing[4], paddingHorizontal: theme.spacing[4],
+1 -1
View File
@@ -204,7 +204,7 @@ export interface GroupedAgents {
inactiveGroups: DateGroup[]; inactiveGroups: DateGroup[];
} }
const ACTIVE_GRACE_PERIOD_MS = 5 * 60 * 1000; // 5 minutes const ACTIVE_GRACE_PERIOD_MS = 15 * 60 * 1000; // 15 minutes
interface GroupAgentsOptions { interface GroupAgentsOptions {
/** /**