diff --git a/packages/app/e2e/agent-details-sheet.spec.ts b/packages/app/e2e/agent-details-sheet.spec.ts
new file mode 100644
index 000000000..75943844a
--- /dev/null
+++ b/packages/app/e2e/agent-details-sheet.spec.ts
@@ -0,0 +1,37 @@
+import { test, expect } from "./fixtures";
+import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
+import { createTempGitRepo } from "./helpers/workspace";
+
+test("agent details sheet shows IDs and copy toast", async ({ page }) => {
+ const repo = await createTempGitRepo();
+ const prompt = "Respond with exactly: Hello";
+
+ try {
+ await gotoHome(page);
+ await setWorkingDirectory(page, repo.path);
+ await ensureHostSelected(page);
+ await createAgent(page, prompt);
+
+ await page.getByTestId("agent-overflow-menu").click();
+ await page.getByTestId("agent-menu-details").click();
+
+ await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
+
+ await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
+ await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
+ "Not available"
+ );
+
+ await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
+ await expect(
+ page.getByTestId("agent-details-persistence-session-id-value")
+ ).not.toHaveText("Not available");
+
+ await page.getByTestId("agent-details-agent-id").click();
+ await expect(page.getByTestId("app-toast")).toBeVisible();
+ await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
+ } finally {
+ await repo.cleanup();
+ }
+});
+
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index 78d16b0a4..3e8435120 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -20,6 +20,7 @@ import { Platform } from "react-native";
import * as Linking from "expo-linking";
import { SlidingSidebar } from "@/components/sliding-sidebar";
import { DownloadToast } from "@/components/download-toast";
+import { ToastProvider } from "@/contexts/toast-context";
import { usePanelStore } from "@/stores/panel-store";
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
import {
@@ -338,26 +339,28 @@ export default function RootLayout() {
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/app/src/components/agent-details-sheet.tsx b/packages/app/src/components/agent-details-sheet.tsx
new file mode 100644
index 000000000..97f3a08f4
--- /dev/null
+++ b/packages/app/src/components/agent-details-sheet.tsx
@@ -0,0 +1,187 @@
+import { useCallback, useMemo } from "react";
+import { Pressable, Text, View } from "react-native";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { Copy, Info } from "lucide-react-native";
+import * as Clipboard from "expo-clipboard";
+import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
+import { Fonts } from "@/constants/theme";
+import { useToast } from "@/contexts/toast-context";
+
+export type AgentDetailsSheetProps = {
+ visible: boolean;
+ onClose: () => void;
+ agentId: string;
+ persistenceSessionId: string | null;
+};
+
+export function AgentDetailsSheet({
+ visible,
+ onClose,
+ agentId,
+ persistenceSessionId,
+}: AgentDetailsSheetProps) {
+ const toast = useToast();
+
+ const details = useMemo(
+ () => [
+ {
+ label: "Agent ID",
+ value: agentId,
+ testID: "agent-details-agent-id",
+ copyLabel: "agent id",
+ },
+ {
+ label: "Persistence session ID",
+ value: persistenceSessionId,
+ testID: "agent-details-persistence-session-id",
+ copyLabel: "session id",
+ },
+ ],
+ [agentId, persistenceSessionId]
+ );
+
+ const handleCopy = useCallback(
+ async (value: string | null, label: string) => {
+ if (!value) return;
+ try {
+ await Clipboard.setStringAsync(value);
+ toast.copied(label);
+ } catch {
+ toast.error("Copy failed");
+ }
+ },
+ [toast]
+ );
+
+ return (
+
+ {details.map((row) => (
+ void handleCopy(row.value, row.copyLabel)}
+ testID={row.testID}
+ disabled={!row.value}
+ />
+ ))}
+
+ );
+}
+
+function DetailRow({
+ label,
+ value,
+ onCopy,
+ disabled,
+ testID,
+}: {
+ label: string;
+ value: string | null;
+ onCopy: () => void;
+ disabled?: boolean;
+ testID: string;
+}) {
+ const { theme } = useUnistyles();
+
+ return (
+ [
+ styles.row,
+ disabled ? styles.rowDisabled : null,
+ pressed && !disabled ? styles.rowPressed : null,
+ ]}
+ >
+
+
+
+ {label}
+
+
+
+ Copy
+
+
+
+
+ {value ?? "Not available"}
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ row: {
+ borderRadius: theme.borderRadius.lg,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.surface2,
+ backgroundColor: theme.colors.surface1,
+ paddingHorizontal: theme.spacing[5],
+ paddingVertical: theme.spacing[4],
+ gap: theme.spacing[3],
+ },
+ rowPressed: {
+ backgroundColor: theme.colors.surface2,
+ },
+ rowDisabled: {
+ opacity: 0.65,
+ },
+ rowTop: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: theme.spacing[3],
+ },
+ labelRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ flex: 1,
+ },
+ label: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.semibold,
+ flex: 1,
+ },
+ copyPill: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ paddingHorizontal: theme.spacing[3],
+ paddingVertical: theme.spacing[2],
+ borderRadius: theme.borderRadius.full,
+ backgroundColor: theme.colors.surface2,
+ },
+ copyText: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.xs,
+ fontWeight: theme.fontWeight.medium,
+ },
+ value: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.sm,
+ fontFamily: Fonts.mono,
+ },
+ valueEmpty: {
+ color: theme.colors.foregroundMuted,
+ fontFamily: Fonts.sans,
+ },
+}));
diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx
index 7e074ebb4..0206cde61 100644
--- a/packages/app/src/components/grouped-agent-list.tsx
+++ b/packages/app/src/components/grouped-agent-list.tsx
@@ -17,7 +17,7 @@ import {
} from "react";
import { router, usePathname } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import { useQueryClient } from "@tanstack/react-query";
+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";
@@ -32,6 +32,7 @@ import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import {
CHECKOUT_STATUS_STALE_TIME,
+ type CheckoutStatusPayload,
checkoutStatusQueryKey,
useCheckoutStatusCacheOnly,
} from "@/hooks/use-checkout-status-query";
@@ -210,10 +211,43 @@ export function GroupedAgentList({
});
}, []);
+ // Subscribe to checkout status cache entries so project grouping can react
+ // to remote URL updates (e.g. git worktrees in different directories).
+ const checkoutCacheQueries = useQueries({
+ queries: agents.map(
+ (agent): UseQueryOptions => ({
+ queryKey: checkoutStatusQueryKey(agent.serverId, agent.id),
+ enabled: false,
+ staleTime: CHECKOUT_STATUS_STALE_TIME,
+ queryFn: async (): Promise => {
+ throw new Error("checkout status cache-only query should not run");
+ },
+ })
+ ),
+ });
+
+ const remoteUrlByAgentKey = useMemo(() => {
+ const result = new Map();
+ for (let i = 0; i < agents.length; i++) {
+ const agent = agents[i];
+ if (!agent) {
+ continue;
+ }
+ const checkout = checkoutCacheQueries[i]?.data ?? null;
+ const remoteUrl = checkout?.remoteUrl ?? null;
+ result.set(`${agent.serverId}:${agent.id}`, remoteUrl);
+ }
+ return result;
+ }, [agents, checkoutCacheQueries]);
+
// Group agents
const { activeGroups, inactiveGroups } = useMemo(
- () => groupAgents(agents),
- [agents]
+ () =>
+ groupAgents(agents, {
+ getRemoteUrl: (agent) =>
+ remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null,
+ }),
+ [agents, remoteUrlByAgentKey]
);
// Build sections for SectionList
diff --git a/packages/app/src/contexts/toast-context.tsx b/packages/app/src/contexts/toast-context.tsx
new file mode 100644
index 000000000..4fefe99c7
--- /dev/null
+++ b/packages/app/src/contexts/toast-context.tsx
@@ -0,0 +1,277 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import {
+ Animated,
+ Easing,
+ Platform,
+ Text,
+ ToastAndroid,
+ View,
+} from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { CheckCircle2, AlertTriangle } from "lucide-react-native";
+
+type ToastVariant = "default" | "success" | "error";
+
+export type ToastShowOptions = {
+ variant?: ToastVariant;
+ durationMs?: number;
+ /**
+ * On Android we prefer the OS toast by default.
+ * Set to false to force the in-app toast.
+ */
+ nativeAndroid?: boolean;
+ testID?: string;
+};
+
+type ToastState = {
+ id: number;
+ message: string;
+ variant: ToastVariant;
+ durationMs: number;
+ testID?: string;
+};
+
+export type ToastApi = {
+ show: (message: string, options?: ToastShowOptions) => void;
+ copied: (label?: string) => void;
+ error: (message: string) => void;
+};
+
+const DEFAULT_DURATION_MS = 2200;
+
+const ToastContext = createContext(null);
+
+export function useToast(): ToastApi {
+ const value = useContext(ToastContext);
+ if (!value) {
+ throw new Error("useToast must be used within ToastProvider");
+ }
+ return value;
+}
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [toast, setToast] = useState(null);
+ const idRef = useRef(0);
+
+ const show = useCallback(
+ (message: string, options?: ToastShowOptions) => {
+ const resolvedMessage = message.trim();
+ if (!resolvedMessage) return;
+
+ const variant = options?.variant ?? "default";
+ const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
+ const nativeAndroid = options?.nativeAndroid ?? true;
+
+ if (Platform.OS === "android" && nativeAndroid) {
+ const duration =
+ durationMs <= 2500
+ ? ToastAndroid.SHORT
+ : ToastAndroid.LONG;
+ ToastAndroid.showWithGravity(
+ resolvedMessage,
+ duration,
+ ToastAndroid.BOTTOM
+ );
+ return;
+ }
+
+ idRef.current += 1;
+ setToast({
+ id: idRef.current,
+ message: resolvedMessage,
+ variant,
+ durationMs,
+ testID: options?.testID,
+ });
+ },
+ []
+ );
+
+ const api = useMemo(
+ () => ({
+ show,
+ copied: (label?: string) =>
+ show(label ? `Copied ${label}` : "Copied", { variant: "success" }),
+ error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
+ }),
+ [show]
+ );
+
+ return (
+
+ {children}
+ setToast(null)} />
+
+ );
+}
+
+function ToastViewport({
+ toast,
+ onDismiss,
+}: {
+ toast: ToastState | null;
+ onDismiss: () => void;
+}) {
+ const { theme } = useUnistyles();
+ const insets = useSafeAreaInsets();
+ const opacity = useRef(new Animated.Value(0)).current;
+ const translateY = useRef(new Animated.Value(8)).current;
+ const timeoutRef = useRef | null>(null);
+
+ const clearTimer = useCallback(() => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ }, []);
+
+ const animateOut = useCallback(() => {
+ clearTimer();
+ Animated.parallel([
+ Animated.timing(opacity, {
+ toValue: 0,
+ duration: 140,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(translateY, {
+ toValue: 8,
+ duration: 140,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ ]).start(({ finished }) => {
+ if (finished) {
+ onDismiss();
+ }
+ });
+ }, [clearTimer, onDismiss, opacity, translateY]);
+
+ useEffect(() => {
+ if (!toast) {
+ clearTimer();
+ opacity.setValue(0);
+ translateY.setValue(8);
+ return;
+ }
+
+ clearTimer();
+ opacity.setValue(0);
+ translateY.setValue(8);
+
+ Animated.parallel([
+ Animated.timing(opacity, {
+ toValue: 1,
+ duration: 140,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ Animated.timing(translateY, {
+ toValue: 0,
+ duration: 140,
+ easing: Easing.out(Easing.quad),
+ useNativeDriver: true,
+ }),
+ ]).start();
+
+ timeoutRef.current = setTimeout(() => {
+ animateOut();
+ }, toast.durationMs);
+
+ return () => {
+ clearTimer();
+ };
+ }, [animateOut, clearTimer, opacity, toast, translateY]);
+
+ if (!toast) {
+ return null;
+ }
+
+ const icon =
+ toast.variant === "success" ? (
+
+ ) : toast.variant === "error" ? (
+
+ ) : null;
+
+ return (
+
+
+ {icon ? {icon} : null}
+
+ {toast.message}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ position: "absolute",
+ left: theme.spacing[4],
+ right: theme.spacing[4],
+ bottom: 0,
+ zIndex: 1100,
+ alignItems: "center",
+ },
+ toast: {
+ width: "100%",
+ maxWidth: 520,
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[3],
+ backgroundColor: theme.colors.surface2,
+ borderRadius: theme.borderRadius.lg,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ paddingVertical: theme.spacing[3],
+ paddingHorizontal: theme.spacing[4],
+ shadowColor: "#000",
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.15,
+ shadowRadius: 8,
+ elevation: 8,
+ },
+ iconSlot: {
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ message: {
+ flex: 1,
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.medium,
+ },
+ messageError: {
+ color: theme.colors.foreground,
+ },
+}));
diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx
index d0706bebc..58b8bc923 100644
--- a/packages/app/src/screens/agent/agent-ready-screen.tsx
+++ b/packages/app/src/screens/agent/agent-ready-screen.tsx
@@ -28,11 +28,13 @@ import {
Folder,
RotateCcw,
PanelRight,
+ Info,
} from "lucide-react-native";
import { MenuHeader } from "@/components/headers/menu-header";
import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
+import { AgentDetailsSheet } from "@/components/agent-details-sheet";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
@@ -179,6 +181,7 @@ function AgentScreenContent({
const { theme } = useUnistyles();
const insets = useSafeAreaInsets();
const router = useRouter();
+ const [detailsOpen, setDetailsOpen] = useState(false);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
@@ -734,6 +737,16 @@ function AgentScreenContent({
>
{isInitializing ? "Refreshing..." : "Refresh"}
+
+
+
+ }
+ onSelect={() => setDetailsOpen(true)}
+ >
+ Details
+
@@ -773,6 +786,13 @@ function AgentScreenContent({
+ setDetailsOpen(false)}
+ agentId={effectiveAgent.id}
+ persistenceSessionId={effectiveAgent.persistence?.sessionId ?? null}
+ />
+
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{!isMobile && isExplorerOpen && resolvedAgentId && (
diff --git a/packages/app/src/utils/agent-grouping.test.ts b/packages/app/src/utils/agent-grouping.test.ts
new file mode 100644
index 000000000..54fd231e2
--- /dev/null
+++ b/packages/app/src/utils/agent-grouping.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+import { deriveRemoteProjectKey, groupAgents } from "./agent-grouping";
+import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
+
+function makeAgent(overrides: Partial = {}): AggregatedAgent {
+ const now = new Date();
+ return {
+ id: overrides.id ?? "a1",
+ serverId: overrides.serverId ?? "s1",
+ serverLabel: (overrides as any).serverLabel ?? "server",
+ title: overrides.title ?? null,
+ status: overrides.status ?? ("running" as AggregatedAgent["status"]),
+ lastActivityAt: overrides.lastActivityAt ?? now,
+ cwd: overrides.cwd ?? "/tmp/repo",
+ provider: overrides.provider ?? ("openai" as any),
+ requiresAttention: overrides.requiresAttention ?? false,
+ attentionReason: overrides.attentionReason ?? null,
+ attentionTimestamp: overrides.attentionTimestamp ?? null,
+ parentAgentId: overrides.parentAgentId ?? null,
+ } as AggregatedAgent;
+}
+
+describe("deriveRemoteProjectKey", () => {
+ it("normalizes GitHub SSH and HTTPS to the same key", () => {
+ const ssh = "git@github.com:owner/repo.git";
+ const https = "https://github.com/owner/repo";
+ expect(deriveRemoteProjectKey(ssh)).toBe("remote:github.com/owner/repo");
+ expect(deriveRemoteProjectKey(https)).toBe("remote:github.com/owner/repo");
+ });
+
+ it("includes host for non-GitHub remotes", () => {
+ const gitlab = "git@gitlab.example.com:group/repo.git";
+ expect(deriveRemoteProjectKey(gitlab)).toBe(
+ "remote:gitlab.example.com/group/repo"
+ );
+ });
+});
+
+describe("groupAgents", () => {
+ it("groups active agents by remote URL when available", () => {
+ const agents = [
+ makeAgent({ id: "a1", cwd: "/Users/me/dev/paseo" }),
+ makeAgent({ id: "a2", cwd: "/Users/me/dev/paseo-fix/worktree" }),
+ ];
+
+ const { activeGroups } = groupAgents(agents, {
+ getRemoteUrl: () => "git@github.com:moboudra/paseo.git",
+ });
+
+ expect(activeGroups).toHaveLength(1);
+ expect(activeGroups[0]?.agents.map((a) => a.id).sort()).toEqual([
+ "a1",
+ "a2",
+ ]);
+ });
+
+ it("falls back to cwd grouping when remote URL is unavailable", () => {
+ const agents = [
+ makeAgent({ id: "a1", cwd: "/Users/me/dev/paseo" }),
+ makeAgent({ id: "a2", cwd: "/Users/me/dev/paseo-fix/worktree" }),
+ ];
+
+ const { activeGroups } = groupAgents(agents, {
+ getRemoteUrl: () => null,
+ });
+
+ expect(activeGroups).toHaveLength(2);
+ });
+});
diff --git a/packages/app/src/utils/agent-grouping.ts b/packages/app/src/utils/agent-grouping.ts
index 136a37acf..c88fceb6d 100644
--- a/packages/app/src/utils/agent-grouping.ts
+++ b/packages/app/src/utils/agent-grouping.ts
@@ -15,6 +15,71 @@ export function deriveProjectKey(cwd: string): string {
return cwd;
}
+/**
+ * Produces a stable grouping key from a git remote URL.
+ *
+ * Waterfall:
+ * - Prefer a GitHub key (normalizes SSH/HTTPS to the same key).
+ * - Fallback to a generic host/path key (still normalized across SSH/HTTPS).
+ */
+export function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
+ if (!remoteUrl) {
+ return null;
+ }
+
+ const trimmed = remoteUrl.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ // Support the common forms:
+ // - git@github.com:owner/repo.git
+ // - https://github.com/owner/repo(.git)
+ // - ssh://git@github.com/owner/repo(.git)
+ let host: string | null = null;
+ let path: string | null = null;
+
+ // SSH scp-like form: user@host:owner/repo(.git)
+ const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
+ if (scpLike) {
+ host = scpLike[1] ?? null;
+ path = scpLike[2] ?? null;
+ } else if (trimmed.includes("://")) {
+ try {
+ const parsed = new URL(trimmed);
+ host = parsed.hostname || null;
+ path = parsed.pathname ? parsed.pathname.replace(/^\//, "") : null;
+ } catch {
+ // Fall through to best-effort parsing below.
+ }
+ }
+
+ if (!host || !path) {
+ return null;
+ }
+
+ let cleanedPath = path.trim().replace(/^\/+/, "").replace(/\/+$/, "");
+ if (cleanedPath.endsWith(".git")) {
+ cleanedPath = cleanedPath.slice(0, -4);
+ }
+
+ // Best-effort: owner/repo is the common case.
+ // If the path is longer (e.g. groups/subgroups/repo), still keep it.
+ if (!cleanedPath.includes("/")) {
+ return null;
+ }
+
+ const cleanedHost = host.toLowerCase();
+
+ // GitHub normalization: treat github.com as a special "well-known" host to
+ // match the intended UX: group by repo even across different local worktrees.
+ if (cleanedHost === "github.com") {
+ return `remote:github.com/${cleanedPath}`;
+ }
+
+ return `remote:${cleanedHost}/${cleanedPath}`;
+}
+
/**
* Extracts the repo name from a git remote URL.
* Examples:
@@ -137,11 +202,22 @@ export interface GroupedAgents {
const ACTIVE_GRACE_PERIOD_MS = 5 * 60 * 1000; // 5 minutes
+interface GroupAgentsOptions {
+ /**
+ * Optional function to read a remote URL for an agent.
+ * If present and a remote URL is available, agents are grouped by remote.
+ */
+ getRemoteUrl?: (agent: AggregatedAgent) => string | null;
+}
+
/**
* 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 {
+export function groupAgents(
+ agents: AggregatedAgent[],
+ options?: GroupAgentsOptions
+): GroupedAgents {
const activeAgents: AggregatedAgent[] = [];
const inactiveAgents: AggregatedAgent[] = [];
const now = Date.now();
@@ -163,7 +239,10 @@ export function groupAgents(agents: AggregatedAgent[]): GroupedAgents {
// Group active agents by project
const projectMap = new Map();
for (const agent of activeAgents) {
- const projectKey = deriveProjectKey(agent.cwd);
+ const remoteKey = deriveRemoteProjectKey(
+ options?.getRemoteUrl?.(agent) ?? null
+ );
+ const projectKey = remoteKey ?? deriveProjectKey(agent.cwd);
const existing = projectMap.get(projectKey) || [];
existing.push(agent);
projectMap.set(projectKey, existing);