diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx new file mode 100644 index 000000000..7e074ebb4 --- /dev/null +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -0,0 +1,625 @@ +import { + View, + Text, + Pressable, + Modal, + RefreshControl, + SectionList, + type ViewToken, + type SectionListRenderItem, +} from "react-native"; +import { + useCallback, + useMemo, + useRef, + useState, + type ReactElement, +} from "react"; +import { router, usePathname } from "expo-router"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useQueryClient } 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, + parseRepoShortNameFromRemoteUrl, + type ProjectGroup, + type DateGroup, +} from "@/utils/agent-grouping"; +import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { useSessionStore } from "@/stores/session-store"; +import { + CHECKOUT_STATUS_STALE_TIME, + checkoutStatusQueryKey, + useCheckoutStatusCacheOnly, +} from "@/hooks/use-checkout-status-query"; +import { + buildAgentNavigationKey, + startNavigationTiming, +} from "@/utils/navigation-timing"; + +type SectionType = + | { type: "project"; data: ProjectGroup } + | { type: "date"; data: DateGroup }; + +interface SectionData { + key: string; + title: string; + type: "project" | "date"; + data: AggregatedAgent[]; + /** For project sections, the first agent's serverId (to lookup checkout status) */ + firstAgentServerId?: string; + /** For project sections, the first agent's id (to lookup checkout status) */ + firstAgentId?: string; +} + +interface GroupedAgentListProps { + agents: AggregatedAgent[]; + isRefreshing?: boolean; + onRefresh?: () => void; + selectedAgentId?: string; + onAgentSelect?: () => void; + listFooterComponent?: ReactElement | null; +} + +interface SectionHeaderProps { + section: SectionData; + isCollapsed: boolean; + agentCount: number; + onToggle: () => void; +} + +function SectionHeader({ + section, + isCollapsed, + agentCount, + onToggle, +}: SectionHeaderProps) { + const { theme } = useUnistyles(); + + // For project sections, try to get repo name from checkout status + const checkoutQuery = useCheckoutStatusCacheOnly({ + serverId: section.firstAgentServerId ?? "", + agentId: section.firstAgentId ?? "", + }); + const checkout = checkoutQuery.data ?? null; + + // 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); + if (repoName) { + displayTitle = repoName; + } + } + + return ( + [ + styles.sectionHeader, + hovered && styles.sectionHeaderHovered, + ]} + onPress={onToggle} + > + + {isCollapsed ? ( + + ) : ( + + )} + + {displayTitle} + + + {agentCount} + + ); +} + +export function GroupedAgentList({ + agents, + isRefreshing = false, + onRefresh, + selectedAgentId, + onAgentSelect, + listFooterComponent, +}: GroupedAgentListProps) { + const { theme } = useUnistyles(); + const pathname = usePathname(); + const queryClient = useQueryClient(); + const [actionAgent, setActionAgent] = useState(null); + const [collapsedSections, setCollapsedSections] = useState>( + new Set() + ); + + // Get the methods for the specific server + const methods = useSessionStore((state) => + actionAgent?.serverId + ? state.sessions[actionAgent.serverId]?.methods + : undefined + ); + const deleteAgent = methods?.deleteAgent; + + const isActionSheetVisible = actionAgent !== null; + const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !methods); + + const handleAgentPress = useCallback( + (serverId: string, agentId: string) => { + if (isActionSheetVisible) { + return; + } + + // Clear attention flag when opening agent + const session = useSessionStore.getState().sessions[serverId]; + if (session?.client) { + session.client.clearAgentAttention(agentId); + } + + const navigationKey = buildAgentNavigationKey(serverId, agentId); + startNavigationTiming(navigationKey, { + from: "home", + to: "agent", + params: { serverId, agentId }, + }); + + const shouldReplace = pathname.startsWith("/agent/"); + const navigate = shouldReplace ? router.replace : router.push; + + onAgentSelect?.(); + + navigate(`/agent/${serverId}/${agentId}` as any); + }, + [isActionSheetVisible, pathname, onAgentSelect] + ); + + const handleAgentLongPress = useCallback((agent: AggregatedAgent) => { + setActionAgent(agent); + }, []); + + const handleCloseActionSheet = useCallback(() => { + setActionAgent(null); + }, []); + + const handleDeleteAgent = useCallback(() => { + if (!actionAgent || !deleteAgent) { + return; + } + deleteAgent(actionAgent.id); + setActionAgent(null); + }, [actionAgent, deleteAgent]); + + const toggleSection = useCallback((sectionKey: string) => { + setCollapsedSections((prev) => { + const next = new Set(prev); + if (next.has(sectionKey)) { + next.delete(sectionKey); + } else { + next.add(sectionKey); + } + return next; + }); + }, []); + + // Group agents + const { activeGroups, inactiveGroups } = useMemo( + () => groupAgents(agents), + [agents] + ); + + // Build sections for SectionList + const sections: SectionData[] = useMemo(() => { + const result: SectionData[] = []; + + for (const group of activeGroups) { + const sectionKey = `project:${group.projectKey}`; + const isCollapsed = collapsedSections.has(sectionKey); + const firstAgent = group.agents[0]; + result.push({ + key: sectionKey, + title: group.projectName, + type: "project", + data: isCollapsed ? [] : group.agents, + firstAgentServerId: firstAgent?.serverId, + firstAgentId: firstAgent?.id, + }); + } + + 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]); + + const viewabilityConfig = useMemo( + () => ({ itemVisiblePercentThreshold: 30 }), + [] + ); + + const onViewableItemsChanged = useRef( + ({ viewableItems }: { viewableItems: Array }) => { + for (const token of viewableItems) { + const agent = token.item as AggregatedAgent | undefined; + if (!agent) { + continue; + } + + const session = useSessionStore.getState().sessions[agent.serverId]; + const client = session?.client ?? null; + const isConnected = session?.connection.isConnected ?? false; + if (!client || !isConnected) { + continue; + } + + const queryKey = checkoutStatusQueryKey(agent.serverId, agent.id); + const queryState = queryClient.getQueryState(queryKey); + const isFetching = queryState?.fetchStatus === "fetching"; + const isFresh = + typeof queryState?.dataUpdatedAt === "number" && + Date.now() - queryState.dataUpdatedAt < CHECKOUT_STATUS_STALE_TIME; + if (isFetching || isFresh) { + continue; + } + + void queryClient.prefetchQuery({ + queryKey, + queryFn: async () => await client.getCheckoutStatus(agent.id), + staleTime: CHECKOUT_STATUS_STALE_TIME, + }); + } + } + ); + + const AgentListRow = useCallback( + ({ agent }: { agent: AggregatedAgent }) => { + const timeAgo = formatTimeAgo(agent.lastActivityAt); + const agentKey = `${agent.serverId}:${agent.id}`; + const isSelected = selectedAgentId === agentKey; + const isRunning = agent.status === "running"; + const statusColor = isRunning + ? theme.colors.palette.blue[500] + : agent.requiresAttention + ? theme.colors.success + : null; + + const checkoutQuery = useCheckoutStatusCacheOnly({ + serverId: agent.serverId, + agentId: agent.id, + }); + const checkout = checkoutQuery.data ?? null; + const projectPath = deriveProjectPath(agent.cwd, checkout); + const branchLabel = deriveBranchLabel(checkout); + + return ( + [ + styles.agentItem, + isSelected && styles.agentItemSelected, + hovered && styles.agentItemHovered, + pressed && styles.agentItemPressed, + ]} + onPress={() => handleAgentPress(agent.serverId, agent.id)} + onLongPress={() => handleAgentLongPress(agent)} + testID={`agent-row-${agent.serverId}-${agent.id}`} + > + {({ hovered }) => ( + + + {statusColor && ( + + )} + + {agent.title || "New Agent"} + + + + + {shortenPath(projectPath)} + {branchLabel ? ` · ${branchLabel}` : ""} · {timeAgo} + + + )} + + ); + }, + [ + handleAgentLongPress, + handleAgentPress, + selectedAgentId, + theme.colors.palette.blue, + theme.colors.success, + ] + ); + + const renderItem: SectionListRenderItem = + 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] + ); + + const keyExtractor = useCallback( + (agent: AggregatedAgent) => `${agent.serverId}:${agent.id}`, + [] + ); + + return ( + <> + + ) : undefined + } + /> + + + + + + + + {actionAgent?.title || "Delete agent"} + + + {isActionDaemonUnavailable + ? "This host is offline—actions will be available once it reconnects automatically." + : "Removing this agent only deletes it from Paseo. Claude/Codex keeps the original project."} + + + + {isActionDaemonUnavailable ? "Host offline" : "Delete agent"} + + + + Cancel + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + list: { + flex: 1, + minHeight: 0, + }, + listContent: { + paddingHorizontal: theme.spacing[4], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[4], + }, + sectionHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + marginTop: theme.spacing[2], + borderRadius: theme.borderRadius.md, + }, + sectionHeaderHovered: { + backgroundColor: theme.colors.surface1, + }, + sectionHeaderLeft: { + flexDirection: "row", + alignItems: "center", + flex: 1, + minWidth: 0, + }, + chevron: { + marginRight: theme.spacing[1], + }, + sectionTitle: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + flex: 1, + }, + sectionCount: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + marginLeft: theme.spacing[2], + }, + agentItem: { + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + marginBottom: theme.spacing[1], + }, + agentItemSelected: { + backgroundColor: theme.colors.surface2, + }, + agentItemHovered: { + backgroundColor: theme.colors.surface1, + }, + agentItemPressed: { + backgroundColor: theme.colors.surface2, + }, + agentContent: { + flex: 1, + gap: theme.spacing[0], + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + agentTitle: { + flex: 1, + fontSize: theme.fontSize.base, + fontWeight: { + xs: "400", + md: "300", + }, + color: theme.colors.foreground, + opacity: 0.8, + }, + agentTitleHighlighted: { + color: theme.colors.foreground, + opacity: 1, + }, + secondaryRow: { + fontSize: theme.fontSize.sm, + fontWeight: "300", + color: theme.colors.foregroundMuted, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + }, + 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], + paddingBottom: theme.spacing[6], + gap: theme.spacing[4], + }, + sheetHandle: { + alignSelf: "center", + width: 40, + height: 4, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface2, + }, + sheetTitle: { + fontSize: theme.fontSize.lg, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + }, + sheetSubtitle: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + sheetButton: { + borderRadius: theme.borderRadius.lg, + paddingVertical: theme.spacing[3], + alignItems: "center", + justifyContent: "center", + }, + sheetDeleteButton: { + backgroundColor: theme.colors.destructive, + }, + sheetDeleteText: { + color: theme.colors.destructiveForeground, + fontWeight: theme.fontWeight.semibold, + }, + sheetDeleteTextDisabled: { + opacity: 0.5, + }, + sheetCancelButton: { + backgroundColor: theme.colors.surface2, + }, + sheetCancelText: { + color: theme.colors.foreground, + fontWeight: theme.fontWeight.semibold, + }, +})); diff --git a/packages/app/src/components/sliding-sidebar.tsx b/packages/app/src/components/sliding-sidebar.tsx index 856021905..2c0d343ad 100644 --- a/packages/app/src/components/sliding-sidebar.tsx +++ b/packages/app/src/components/sliding-sidebar.tsx @@ -12,7 +12,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl import { Plus, Settings } from "lucide-react-native"; import { router } from "expo-router"; import { usePanelStore } from "@/stores/panel-store"; -import { AgentList } from "./agent-list"; +import { GroupedAgentList } from "./grouped-agent-list"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useSidebarAnimation } from "@/contexts/sidebar-animation-context"; import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window"; @@ -223,7 +223,7 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) { {/* Middle: scrollable agent list */} - {/* Middle: scrollable agent list */} - { - if (open && triggerRef.current) { - measureElement(triggerRef.current).then((rect) => { - // On Android with statusBarTranslucent, measureInWindow returns coordinates - // from screen top, but Modal content area also starts from screen top. - // However, the measured y doesn't account for status bar offset correctly, - // so we need to add it (same as react-native-popover-view does) - const statusBarAdjustment = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; - const adjustedRect = { - ...rect, - y: rect.y + statusBarAdjustment, - }; - setTriggerRect(adjustedRect); - }); - } else { + if (!open || !triggerRef.current) { setTriggerRect(null); setContentSize(null); setPosition(null); + return; } + + // Capture status bar height synchronously before async measurement. + // This avoids race conditions where StatusBar.currentHeight could change + // or return null if read after the component re-renders. + const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; + let cancelled = false; + + measureElement(triggerRef.current).then((rect) => { + if (cancelled) return; + // On Android with statusBarTranslucent, measureInWindow returns coordinates + // relative to below the status bar, but Modal content starts from screen top. + // Add status bar height to align coordinate systems (same as react-native-popover-view). + setTriggerRect({ + ...rect, + y: rect.y + statusBarHeight, + }); + }); + + return () => { + cancelled = true; + }; }, [open, triggerRef]); // Calculate position when we have both measurements diff --git a/packages/app/src/contexts/daemon-registry-context.tsx b/packages/app/src/contexts/daemon-registry-context.tsx index 08ee9e85e..d26e1d09a 100644 --- a/packages/app/src/contexts/daemon-registry-context.tsx +++ b/packages/app/src/contexts/daemon-registry-context.tsx @@ -224,71 +224,14 @@ function createProfile(label: string, endpoint: string): HostProfile { }; } -type EnvDaemonConfig = { - label?: string; - endpoint?: string; - wsUrl?: string; -}; +const LOCAL_DAEMON_LABEL = "Local"; -function parseEnvDaemonDefaults(): HostProfile[] { - const envDaemons = ((): EnvDaemonConfig[] => { - // Primary: allow a JSON array string like - // EXPO_PUBLIC_DAEMONS='[{"label":"Host","endpoint":"10.0.0.1:6767"}]' - const jsonList = process.env.EXPO_PUBLIC_DAEMONS; - if (jsonList) { - try { - const parsed = JSON.parse(jsonList) as unknown; - if (Array.isArray(parsed) && parsed.length > 0) { - return parsed as EnvDaemonConfig[]; - } - } catch (error) { - console.warn("[DaemonRegistry] Failed to parse EXPO_PUBLIC_DAEMONS:", error); - } - } - - // Fallback: single host via EXPO_PUBLIC_DAEMON_WS_URL (+ optional label/rest URL) - const singleWsUrl = process.env.EXPO_PUBLIC_DAEMON_WS_URL; - if (singleWsUrl) { - return [ - { - label: process.env.EXPO_PUBLIC_DAEMON_LABEL, - wsUrl: singleWsUrl, - }, - ]; - } - - return []; - })(); - - const timestamp = new Date().toISOString(); - return envDaemons - .map((entry): HostProfile | null => { - const endpoint = (() => { - if (typeof entry.endpoint === "string" && entry.endpoint.trim().length > 0) { - return entry.endpoint.trim(); - } - if (typeof entry.wsUrl === "string" && entry.wsUrl.trim().length > 0) { - try { - return extractHostPortFromWebSocketUrl(entry.wsUrl.trim()); - } catch { - return null; - } - } - return null; - })(); - - if (!endpoint) return null; - - return { - id: generateDaemonId(), - label: entry.label?.trim() || deriveLabelFromEndpoint(endpoint), - endpoints: [normalizeHostPort(endpoint)], - createdAt: timestamp, - updatedAt: timestamp, - metadata: null, - }; - }) - .filter((entry): entry is HostProfile => entry !== null); +function getLocalDaemonEndpoint(): string | null { + const endpoint = process.env.EXPO_PUBLIC_LOCAL_DAEMON; + if (!endpoint || endpoint.trim().length === 0) { + return null; + } + return endpoint.trim(); } type LegacyDaemonProfile = { @@ -327,28 +270,54 @@ function migrateLegacyToHostProfile(legacy: LegacyDaemonProfile): HostProfile { }; } +function upsertLocalDaemon(hosts: HostProfile[], localEndpoint: string): HostProfile[] { + const result = [...hosts]; + const now = new Date().toISOString(); + const existingIndex = result.findIndex((host) => host.label === LOCAL_DAEMON_LABEL); + + if (existingIndex !== -1) { + result[existingIndex] = { + ...result[existingIndex], + endpoints: [normalizeHostPort(localEndpoint)], + updatedAt: now, + }; + } else { + result.unshift({ + id: generateDaemonId(), + label: LOCAL_DAEMON_LABEL, + endpoints: [normalizeHostPort(localEndpoint)], + createdAt: now, + updatedAt: now, + metadata: null, + }); + } + + return result; +} + async function loadDaemonRegistryFromStorage(): Promise { try { - // When env vars define a default daemon, always reset to that daemon only. - // This ensures the app uses the configured daemon regardless of stored state. - const envDefaults = parseEnvDaemonDefaults(); - if (envDefaults.length > 0) { - await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(envDefaults)); - return envDefaults; - } + const localEndpoint = getLocalDaemonEndpoint(); const stored = await AsyncStorage.getItem(REGISTRY_STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored) as unknown; if (Array.isArray(parsed)) { - if (parsed.length === 0) { + if (parsed.length === 0 && !localEndpoint) { return []; } + const hasLegacy = parsed.some((entry) => isLegacyDaemonProfile(entry)); const hasNew = parsed.some((entry) => isHostProfile(entry)); if (hasNew && !hasLegacy) { - return parsed as HostProfile[]; + const hosts = parsed as HostProfile[]; + if (localEndpoint) { + const merged = upsertLocalDaemon(hosts, localEndpoint); + await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(merged)); + return merged; + } + return hosts; } const migrated = parsed @@ -380,8 +349,9 @@ async function loadDaemonRegistryFromStorage(): Promise { .filter((entry): entry is HostProfile => entry !== null); if (migrated.length > 0) { - await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated)); - return migrated; + const merged = localEndpoint ? upsertLocalDaemon(migrated, localEndpoint) : migrated; + await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(merged)); + return merged; } } } @@ -393,12 +363,18 @@ async function loadDaemonRegistryFromStorage(): Promise { if (legacyUrl) { const endpoint = extractHostPortFromWebSocketUrl(legacyUrl); const migrated = [createProfile("Primary Host", endpoint)]; - await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(migrated)); - return migrated; + const merged = localEndpoint ? upsertLocalDaemon(migrated, localEndpoint) : migrated; + await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(merged)); + return merged; } } - // No implicit localhost fallback: a fresh install starts with zero hosts. + if (localEndpoint) { + const hosts = upsertLocalDaemon([], localEndpoint); + await AsyncStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(hosts)); + return hosts; + } + return []; } catch (error) { console.error("[DaemonRegistry] Failed to load daemon registry", error); diff --git a/packages/app/src/utils/agent-grouping.ts b/packages/app/src/utils/agent-grouping.ts new file mode 100644 index 000000000..136a37acf --- /dev/null +++ b/packages/app/src/utils/agent-grouping.ts @@ -0,0 +1,215 @@ +import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; + +/** + * Derives the project key for grouping agents. + * For worktrees, returns the parent repo path. + * For regular repos/directories, returns the cwd. + */ +export function deriveProjectKey(cwd: string): string { + const worktreeMarker = ".paseo/worktrees/"; + const idx = cwd.indexOf(worktreeMarker); + if (idx !== -1) { + // Return parent repo path (before .paseo/worktrees/) + return cwd.slice(0, idx).replace(/\/$/, ""); + } + return cwd; +} + +/** + * Extracts the repo name from a git remote URL. + * Examples: + * git@github.com:anthropics/claude-code.git -> anthropics/claude-code + * https://github.com/anthropics/claude-code.git -> anthropics/claude-code + * https://github.com/anthropics/claude-code -> anthropics/claude-code + */ +export function parseRepoNameFromRemoteUrl( + remoteUrl: string | null +): string | null { + if (!remoteUrl) { + return null; + } + + let cleaned = remoteUrl; + + // Handle SSH format: git@github.com:owner/repo.git + if (cleaned.startsWith("git@")) { + const colonIdx = cleaned.indexOf(":"); + if (colonIdx !== -1) { + cleaned = cleaned.slice(colonIdx + 1); + } + } + // Handle HTTPS format: https://github.com/owner/repo.git + else if (cleaned.includes("://")) { + const urlPath = cleaned.split("://")[1]; + if (urlPath) { + // Remove host (e.g., github.com/) + const slashIdx = urlPath.indexOf("/"); + if (slashIdx !== -1) { + cleaned = urlPath.slice(slashIdx + 1); + } + } + } + + // Remove .git suffix + if (cleaned.endsWith(".git")) { + cleaned = cleaned.slice(0, -4); + } + + // Should be in format owner/repo now + if (cleaned.includes("/")) { + return cleaned; + } + + return null; +} + +/** + * Extracts just the repo name (without owner) from a remote URL. + * Examples: + * git@github.com:anthropics/claude-code.git -> claude-code + */ +export function parseRepoShortNameFromRemoteUrl( + remoteUrl: string | null +): string | null { + const fullName = parseRepoNameFromRemoteUrl(remoteUrl); + if (!fullName) { + return null; + } + const parts = fullName.split("/"); + return parts[parts.length - 1] || null; +} + +/** + * Extracts the project name from a path (last segment). + */ +export function deriveProjectName(projectKey: string): string { + const segments = projectKey.split("/").filter(Boolean); + return segments[segments.length - 1] || projectKey; +} + +/** + * Determines the date group label for an agent based on lastActivityAt. + */ +export function deriveDateGroup(lastActivityAt: Date): string { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); + const activityDate = new Date( + lastActivityAt.getFullYear(), + lastActivityAt.getMonth(), + lastActivityAt.getDate() + ); + + if (activityDate.getTime() >= today.getTime()) { + return "Recent"; + } + if (activityDate.getTime() >= yesterday.getTime()) { + return "Yesterday"; + } + + const diffTime = today.getTime() - activityDate.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 interface ProjectGroup { + projectKey: string; + projectName: string; + agents: AggregatedAgent[]; +} + +export interface DateGroup { + label: string; + agents: AggregatedAgent[]; +} + +export interface GroupedAgents { + activeGroups: ProjectGroup[]; + inactiveGroups: DateGroup[]; +} + +const ACTIVE_GRACE_PERIOD_MS = 5 * 60 * 1000; // 5 minutes + +/** + * Groups agents into active (by project) and inactive (by date) sections. + * Active = running, requires attention, or had activity within the last 5 minutes. + */ +export function groupAgents(agents: AggregatedAgent[]): GroupedAgents { + const activeAgents: AggregatedAgent[] = []; + const inactiveAgents: AggregatedAgent[] = []; + const now = Date.now(); + + for (const agent of agents) { + const isRunningOrAttention = + agent.status === "running" || agent.requiresAttention; + const isRecentlyActive = + now - agent.lastActivityAt.getTime() < ACTIVE_GRACE_PERIOD_MS; + const isActive = isRunningOrAttention || isRecentlyActive; + + if (isActive) { + activeAgents.push(agent); + } else { + inactiveAgents.push(agent); + } + } + + // Group active agents by project + const projectMap = new Map(); + for (const agent of activeAgents) { + const projectKey = deriveProjectKey(agent.cwd); + const existing = projectMap.get(projectKey) || []; + existing.push(agent); + projectMap.set(projectKey, existing); + } + + // Sort agents within each project by lastActivityAt (newest first) + const activeGroups: ProjectGroup[] = []; + for (const [projectKey, projectAgents] of projectMap) { + projectAgents.sort( + (a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime() + ); + activeGroups.push({ + projectKey, + projectName: deriveProjectName(projectKey), + agents: projectAgents, + }); + } + + // Sort project groups by most recent activity + activeGroups.sort((a, b) => { + const aRecent = a.agents[0]?.lastActivityAt.getTime() ?? 0; + const bRecent = b.agents[0]?.lastActivityAt.getTime() ?? 0; + return bRecent - aRecent; + }); + + // Group inactive agents by date + const dateMap = new Map(); + for (const agent of inactiveAgents) { + const dateLabel = deriveDateGroup(agent.lastActivityAt); + const existing = dateMap.get(dateLabel) || []; + existing.push(agent); + dateMap.set(dateLabel, existing); + } + + // Sort agents within each date group by lastActivityAt (newest first) + const dateOrder = ["Recent", "Yesterday", "This week", "This month", "Older"]; + const inactiveGroups: DateGroup[] = []; + for (const label of dateOrder) { + const dateAgents = dateMap.get(label); + if (dateAgents && dateAgents.length > 0) { + dateAgents.sort( + (a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime() + ); + inactiveGroups.push({ label, agents: dateAgents }); + } + } + + return { activeGroups, inactiveGroups }; +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0083289ea..dd56d5a5b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2769,6 +2769,7 @@ export class Session { baseRef: null, aheadBehind: null, hasRemote: false, + remoteUrl: null, isPaseoOwnedWorktree: false, error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, requestId, @@ -2792,6 +2793,7 @@ export class Session { baseRef: null, aheadBehind: null, hasRemote: false, + remoteUrl: null, isPaseoOwnedWorktree: false, error: null, requestId, @@ -2813,6 +2815,7 @@ export class Session { baseRef: status.baseRef, aheadBehind: status.aheadBehind ?? null, hasRemote: status.hasRemote, + remoteUrl: status.remoteUrl, isPaseoOwnedWorktree: true, error: null, requestId, @@ -2833,6 +2836,7 @@ export class Session { baseRef: status.baseRef ?? null, aheadBehind: status.aheadBehind ?? null, hasRemote: status.hasRemote, + remoteUrl: status.remoteUrl, isPaseoOwnedWorktree: false, error: null, requestId, @@ -2851,6 +2855,7 @@ export class Session { baseRef: null, aheadBehind: null, hasRemote: false, + remoteUrl: null, isPaseoOwnedWorktree: false, error: this.toCheckoutError(error), requestId, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index cba072668..57fc61a2a 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1056,6 +1056,7 @@ const CheckoutStatusNotGitSchema = CheckoutStatusCommonSchema.extend({ baseRef: z.null(), aheadBehind: z.null(), hasRemote: z.boolean(), + remoteUrl: z.null(), }); const CheckoutStatusGitNonPaseoSchema = CheckoutStatusCommonSchema.extend({ @@ -1067,6 +1068,7 @@ const CheckoutStatusGitNonPaseoSchema = CheckoutStatusCommonSchema.extend({ baseRef: z.string().nullable(), aheadBehind: AheadBehindSchema.nullable(), hasRemote: z.boolean(), + remoteUrl: z.string().nullable(), }); const CheckoutStatusGitPaseoSchema = CheckoutStatusCommonSchema.extend({ @@ -1078,6 +1080,7 @@ const CheckoutStatusGitPaseoSchema = CheckoutStatusCommonSchema.extend({ baseRef: z.string(), aheadBehind: AheadBehindSchema.nullable(), hasRemote: z.boolean(), + remoteUrl: z.string().nullable(), }); export const CheckoutStatusResponseSchema = z.object({ diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index db867d76c..f84fd45c3 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -71,6 +71,7 @@ export type CheckoutStatusGitNonPaseo = { baseRef: string | null; aheadBehind: AheadBehind | null; hasRemote: boolean; + remoteUrl: string | null; isPaseoOwnedWorktree: false; }; @@ -82,6 +83,7 @@ export type CheckoutStatusGitPaseo = { baseRef: string; aheadBehind: AheadBehind | null; hasRemote: boolean; + remoteUrl: string | null; isPaseoOwnedWorktree: true; }; @@ -257,18 +259,24 @@ async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Pro return stdout.trim().length > 0; } -async function hasOriginRemote(cwd: string): Promise { +async function getOriginRemoteUrl(cwd: string): Promise { try { const { stdout } = await execAsync("git config --get remote.origin.url", { cwd, env: READ_ONLY_GIT_ENV, }); - return stdout.trim().length > 0; + const url = stdout.trim(); + return url.length > 0 ? url : null; } catch { - return false; + return null; } } +async function hasOriginRemote(cwd: string): Promise { + const url = await getOriginRemoteUrl(cwd); + return url !== null; +} + async function resolveBaseRef(repoRoot: string): Promise { try { const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", { @@ -429,7 +437,8 @@ export async function getCheckoutStatus( const currentBranch = await getCurrentBranch(cwd); const isDirty = await isWorkingTreeDirty(cwd, repoInfo.type); - const hasRemote = await hasOriginRemote(repoInfo.path); + const remoteUrl = await getOriginRemoteUrl(repoInfo.path); + const hasRemote = remoteUrl !== null; const configured = await getConfiguredBaseRefForCwd(cwd, context); const baseRef = configured.baseRef ?? (await resolveBaseRef(repoInfo.path)); const aheadBehind = @@ -444,6 +453,7 @@ export async function getCheckoutStatus( baseRef: configured.baseRef, aheadBehind, hasRemote, + remoteUrl, isPaseoOwnedWorktree: true, }; } @@ -456,6 +466,7 @@ export async function getCheckoutStatus( baseRef, aheadBehind, hasRemote, + remoteUrl, isPaseoOwnedWorktree: false, }; } diff --git a/scripts/dev.sh b/scripts/dev.sh index 02a115300..c3a137218 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -17,7 +17,7 @@ METRO_PORT=$(get-port 8081 8082 8083 8084 8085 8086 8087) CORS_ORIGINS="http://localhost:${METRO_PORT},http://127.0.0.1:${METRO_PORT}" # Configure app to auto-connect to this daemon -EXPO_DAEMONS="[{\"label\":\"localhost\",\"endpoint\":\"localhost:${DAEMON_PORT}\"}]" +LOCAL_DAEMON="localhost:${DAEMON_PORT}" echo "══════════════════════════════════════════════════════" echo " Paseo Dev" @@ -33,9 +33,9 @@ export PASEO_CORS_ORIGINS="${CORS_ORIGINS}" # Run both with concurrently # BROWSER=none prevents auto-opening browser -# EXPO_PUBLIC_DAEMONS configures the app to auto-connect to this daemon +# EXPO_PUBLIC_LOCAL_DAEMON configures the app to auto-connect to this daemon concurrently \ --names "daemon,metro" \ --prefix-colors "cyan,magenta" \ "npm run dev:server" \ - "BROWSER=none EXPO_PUBLIC_DAEMONS='${EXPO_DAEMONS}' npm run start --workspace=@paseo/app -- --port ${METRO_PORT}" + "BROWSER=none EXPO_PUBLIC_LOCAL_DAEMON='${LOCAL_DAEMON}' npm run start --workspace=@paseo/app -- --port ${METRO_PORT}"