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);