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}"