diff --git a/packages/app/src/components/agent-details-sheet.tsx b/packages/app/src/components/agent-details-sheet.tsx deleted file mode 100644 index 050e38c06..000000000 --- a/packages/app/src/components/agent-details-sheet.tsx +++ /dev/null @@ -1,187 +0,0 @@ -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[4], - 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/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index ec9957841..5c39752f9 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -424,12 +424,20 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { - Size - {formatFileSize({ size: entry.size })} + + Size + + + {formatFileSize({ size: entry.size })} + - Modified - {formatTimeAgo(new Date(entry.modifiedAt))} + + Modified + + + {formatTimeAgo(new Date(entry.modifiedAt))} + @@ -1132,11 +1140,15 @@ const styles = StyleSheet.create((theme) => ({ contextMetaLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, + flexShrink: 0, }, contextMetaValue: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, fontWeight: theme.fontWeight.medium, + flex: 1, + minWidth: 0, + textAlign: "right", }, previewHeaderText: { flex: 1, diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 40069995d..6dce8be56 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -15,7 +15,19 @@ import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gest import { StyleSheet, useUnistyles } from "react-native-unistyles"; import AsyncStorage from "@react-native-async-storage/async-storage"; import * as Linking from "expo-linking"; -import { Archive, ChevronDown, ChevronRight, GitBranch, MoreVertical, ListChevronsDownUp, ListChevronsUpDown } from "lucide-react-native"; +import { + Archive, + ChevronDown, + ChevronRight, + GitBranch, + GitCommitHorizontal, + GitMerge, + ListChevronsDownUp, + ListChevronsUpDown, + MoreVertical, + RefreshCcw, + Upload, +} from "lucide-react-native"; import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store"; import { useCheckoutDiffQuery, @@ -37,6 +49,7 @@ import { DropdownMenuTrigger, type ActionStatus, } from "@/components/ui/dropdown-menu"; +import { GitHubIcon } from "@/components/icons/github-icon"; // ============================================================================= // Git Actions Data Structure @@ -856,6 +869,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { successLabel: "Committed", disabled: commitDisabled, status: commitStatus, + icon: , handler: handleCommit, }); @@ -869,6 +883,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { disabled: pushDisabled, status: pushStatus, description: !hasRemote ? "No remote configured" : undefined, + icon: , handler: handlePush, }); } @@ -883,6 +898,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { successLabel: "View PR", disabled: false, status: "idle", + icon: , handler: () => openURLInNewTab(prUrl), }); } @@ -896,6 +912,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { successLabel: "PR Created", disabled: prDisabled, status: prCreateStatus, + icon: , handler: handleCreatePr, }); } @@ -910,6 +927,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { disabled: mergeDisabled, status: mergeStatus, description: hasUncommittedChanges ? "Requires clean working tree" : undefined, + icon: , handler: handleMergeBranch, }); } @@ -924,6 +942,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { disabled: mergeFromBaseDisabled, status: mergeFromBaseStatus, description: hasUncommittedChanges ? "Requires clean working tree" : undefined, + icon: , handler: handleMergeFromBase, }); } @@ -994,6 +1013,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { commitDisabled, pushDisabled, prDisabled, mergeDisabled, mergeFromBaseDisabled, archiveDisabled, commitStatus, pushStatus, prCreateStatus, mergeStatus, mergeFromBaseStatus, archiveStatus, handleCommit, handlePush, handleCreatePr, handleMergeBranch, handleMergeFromBase, handleArchiveWorktree, + theme.colors.foregroundMuted, ]); // Helper to get display label based on status @@ -1033,7 +1053,10 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { {gitActions.primary.status === "pending" ? ( ) : ( - {getActionDisplayLabel(gitActions.primary)} + + {gitActions.primary.icon} + {getActionDisplayLabel(gitActions.primary)} + )} {gitActions.secondary.length > 0 ? ( @@ -1054,6 +1077,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { {needsSeparator && index > 0 ? : null} @@ -1280,6 +1304,12 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontWeight: theme.fontWeight.medium, }, + splitButtonContent: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[2], + }, splitButtonSpinner: { height: theme.fontSize.xs * 1.5, width: theme.fontSize.xs * 1.5, @@ -1298,6 +1328,9 @@ const styles = StyleSheet.create((theme) => ({ justifyContent: "center", borderRadius: theme.borderRadius.md, }, + overflowMenuButton: { + marginRight: -theme.spacing[2], + }, menuOverlay: { flex: 1, }, diff --git a/packages/app/src/components/icons/github-icon.tsx b/packages/app/src/components/icons/github-icon.tsx new file mode 100644 index 000000000..135112712 --- /dev/null +++ b/packages/app/src/components/icons/github-icon.tsx @@ -0,0 +1,14 @@ +import Svg, { Path } from "react-native-svg"; + +interface GitHubIconProps { + size?: number; + color?: string; +} + +export function GitHubIcon({ size = 16, color = "currentColor" }: GitHubIconProps) { + return ( + + + + ); +} diff --git a/packages/app/src/contexts/toast-context.tsx b/packages/app/src/contexts/toast-context.tsx index 8c09aceb9..5e1ec360c 100644 --- a/packages/app/src/contexts/toast-context.tsx +++ b/packages/app/src/contexts/toast-context.tsx @@ -25,11 +25,11 @@ import { CheckCircle2, AlertTriangle } from "lucide-react-native"; type ToastVariant = "default" | "success" | "error"; export type ToastShowOptions = { + icon?: ReactNode; variant?: ToastVariant; durationMs?: number; /** - * On Android we prefer the OS toast by default. - * Set to false to force the in-app toast. + * Set to true to use OS toast on Android. */ nativeAndroid?: boolean; testID?: string; @@ -37,14 +37,16 @@ export type ToastShowOptions = { type ToastState = { id: number; - message: string; + content: ReactNode; + nativeMessage: string | null; + icon?: ReactNode; variant: ToastVariant; durationMs: number; testID?: string; }; export type ToastApi = { - show: (message: string, options?: ToastShowOptions) => void; + show: (content: ReactNode, options?: ToastShowOptions) => void; copied: (label?: string) => void; error: (message: string) => void; }; @@ -66,23 +68,26 @@ export function ToastProvider({ children }: { children: ReactNode }) { const idRef = useRef(0); const show = useCallback( - (message: string, options?: ToastShowOptions) => { - const resolvedMessage = message.trim(); - if (!resolvedMessage) return; + (content: ReactNode, options?: ToastShowOptions) => { + const nativeMessage = + typeof content === "string" + ? content.trim() + : null; + if (!content || nativeMessage === "") return; const variant = options?.variant ?? "default"; const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS; - const nativeAndroid = options?.nativeAndroid ?? true; + const nativeAndroid = options?.nativeAndroid ?? false; - if (Platform.OS === "android" && nativeAndroid) { + if (Platform.OS === "android" && nativeAndroid && nativeMessage) { const duration = durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG; ToastAndroid.showWithGravity( - resolvedMessage, + nativeMessage, duration, - ToastAndroid.BOTTOM + ToastAndroid.TOP ); return; } @@ -90,7 +95,9 @@ export function ToastProvider({ children }: { children: ReactNode }) { idRef.current += 1; setToast({ id: idRef.current, - message: resolvedMessage, + content, + nativeMessage, + icon: options?.icon, variant, durationMs, testID: options?.testID, @@ -103,7 +110,10 @@ export function ToastProvider({ children }: { children: ReactNode }) { () => ({ show, copied: (label?: string) => - show(label ? `Copied ${label}` : "Copied", { variant: "success" }), + show(label ? `Copied ${label}` : "Copied", { + variant: "success", + icon: , + }), error: (message: string) => show(message, { variant: "error", durationMs: 3200 }), }), [show] @@ -127,7 +137,7 @@ function ToastViewport({ const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); const opacity = useRef(new Animated.Value(0)).current; - const translateY = useRef(new Animated.Value(8)).current; + const translateY = useRef(new Animated.Value(-8)).current; const timeoutRef = useRef | null>(null); const clearTimer = useCallback(() => { @@ -147,7 +157,7 @@ function ToastViewport({ useNativeDriver: true, }), Animated.timing(translateY, { - toValue: 8, + toValue: -8, duration: 140, easing: Easing.out(Easing.quad), useNativeDriver: true, @@ -163,13 +173,13 @@ function ToastViewport({ if (!toast) { clearTimer(); opacity.setValue(0); - translateY.setValue(8); + translateY.setValue(-8); return; } clearTimer(); opacity.setValue(0); - translateY.setValue(8); + translateY.setValue(-8); Animated.parallel([ Animated.timing(opacity, { @@ -200,11 +210,13 @@ function ToastViewport({ } const icon = - toast.variant === "success" ? ( + toast.icon ?? ( + toast.variant === "success" ? ( ) : toast.variant === "error" ? ( - ) : null; + ) : null + ); const content = ( @@ -212,8 +224,10 @@ function ToastViewport({ testID={toast.testID ?? "app-toast"} style={[ styles.toast, + toast.variant === "success" ? styles.toastSuccess : null, + toast.variant === "error" ? styles.toastError : null, { - marginBottom: theme.spacing[4] + insets.bottom, + marginTop: theme.spacing[2] + insets.top, opacity, transform: [{ translateY }], }, @@ -221,16 +235,22 @@ function ToastViewport({ accessibilityRole="alert" > {icon ? {icon} : null} - - {toast.message} - + {typeof toast.content === "string" ? ( + + {toast.content} + + ) : ( + + {toast.content} + + )} ); @@ -248,18 +268,18 @@ const styles = StyleSheet.create((theme) => ({ position: "absolute", left: theme.spacing[4], right: theme.spacing[4], - bottom: 0, + top: 0, zIndex: OVERLAY_Z.toast, alignItems: "center", }, toast: { - width: "100%", - maxWidth: 520, + alignSelf: "center", + maxWidth: "92%", flexDirection: "row", alignItems: "center", gap: theme.spacing[3], - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.surface0, + borderRadius: theme.borderRadius.full, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, paddingVertical: theme.spacing[3], @@ -270,15 +290,25 @@ const styles = StyleSheet.create((theme) => ({ shadowRadius: 8, elevation: 8, }, + toastSuccess: { + borderColor: theme.colors.border, + }, + toastError: { + borderColor: theme.colors.destructive, + }, iconSlot: { alignItems: "center", justifyContent: "center", }, + contentSlot: { + flexShrink: 1, + minWidth: 0, + }, message: { - flex: 1, + flexShrink: 1, color: theme.colors.foreground, fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.medium, + fontWeight: theme.fontWeight.normal, }, 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 6ae466a2e..e4722283d 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -10,6 +10,7 @@ import { AppState, } from "react-native"; import { useRouter } from "expo-router"; +import * as Clipboard from "expo-clipboard"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; @@ -28,14 +29,13 @@ import { Folder, RotateCcw, PanelRight, - Info, + CheckCircle2, } from "lucide-react-native"; import { MenuHeader } from "@/components/headers/menu-header"; import { BackHeader } from "@/components/headers/back-header"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; 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"; @@ -63,6 +63,7 @@ import { shortenPath } from "@/utils/shorten-path"; import { deriveBranchLabel, deriveProjectPath } from "@/utils/agent-display-info"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useAgentInitialization } from "@/hooks/use-agent-initialization"; +import { useToast } from "@/contexts/toast-context"; import { DropdownMenu, DropdownMenuContent, @@ -178,9 +179,9 @@ function AgentScreenContent({ agentId, }: AgentScreenContentProps) { const { theme } = useUnistyles(); + const toast = useToast(); const insets = useSafeAreaInsets(); const router = useRouter(); - const [detailsOpen, setDetailsOpen] = useState(false); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); @@ -471,6 +472,11 @@ function AgentScreenContent({ }, [resolvedAgentId, serverId, shouldUseOptimisticStream]); const effectiveAgent = agent ?? placeholderAgent; + const providerLabel = (effectiveAgent?.provider ?? "Provider").replace(/^\w/, (m) => + m.toUpperCase() + ); + const providerSessionId = + effectiveAgent?.runtimeInfo?.sessionId ?? effectiveAgent?.persistence?.sessionId ?? null; // Header subtitle: project path + branch (matching agent list row format) const headerProjectPath = effectiveAgent @@ -584,16 +590,6 @@ function AgentScreenContent({ } }, [resolvedAgentId, agent?.status, client]); - const handleViewChanges = useCallback(() => { - setExplorerTab("changes"); - openFileExplorer(); - }, [setExplorerTab, openFileExplorer]); - - const handleBrowseFiles = useCallback(() => { - setExplorerTab("files"); - openFileExplorer(); - }, [setExplorerTab, openFileExplorer]); - const handleRefreshAgent = useCallback(() => { if (!resolvedAgentId) { return; @@ -603,6 +599,24 @@ function AgentScreenContent({ }); }, [resolvedAgentId, refreshAgent]); + const handleCopyMeta = useCallback( + async (label: string, value: string | null | undefined) => { + if (!value) { + return; + } + try { + await Clipboard.setStringAsync(value); + toast.show(`Copied ${label}`, { + variant: "success", + icon: , + }); + } catch { + toast.error("Copy failed"); + } + }, + [theme.colors.primary, toast] + ); + if (!effectiveAgent) { return ( @@ -677,19 +691,39 @@ function AgentScreenContent({ - - Directory + [ + styles.menuMetaRow, + (hovered || pressed) && styles.menuMetaRowActive, + ]} + onPress={() => { + void handleCopyMeta("Directory", effectiveAgent.cwd); + }} + > + + Directory + {shortenPath(effectiveAgent.cwd)} - + - - Model + [ + styles.menuMetaRow, + (hovered || pressed) && styles.menuMetaRowActive, + ]} + onPress={() => { + void handleCopyMeta("Model", modelDisplayValue); + }} + > + + Model + {modelDisplayValue} - + {checkout?.isGit && checkout.currentBranch && checkout.currentBranch !== "HEAD" ? ( - - Branch - - {checkoutStatusQuery.isFetching ? ( - <> - - Fetching… - - ) : ( - - {checkout.currentBranch} - - )} - - + [ + styles.menuMetaRow, + (hovered || pressed) && styles.menuMetaRowActive, + ]} + onPress={() => { + if (checkoutStatusQuery.isFetching) { + return; + } + void handleCopyMeta("Branch", checkout.currentBranch); + }} + > + + Branch + + + {checkoutStatusQuery.isFetching ? "Fetching…" : checkout.currentBranch} + + ) : null} + + [ + styles.menuMetaRow, + (hovered || pressed) && styles.menuMetaRowActive, + ]} + onPress={() => { + void handleCopyMeta("Paseo ID", effectiveAgent.id); + }} + > + + Paseo ID + + + {effectiveAgent.id} + + + + [ + styles.menuMetaRow, + providerSessionId && (hovered || pressed) && styles.menuMetaRowActive, + ]} + disabled={!providerSessionId} + onPress={() => { + void handleCopyMeta(`${providerLabel} ID`, providerSessionId); + }} + > + + {providerLabel} ID + + + {providerSessionId ?? "Not available"} + + - } - onSelect={handleViewChanges} - > - View changes - - } - onSelect={handleBrowseFiles} - > - Browse files - } disabled={isInitializing} @@ -755,16 +821,6 @@ function AgentScreenContent({ > {isInitializing ? "Refreshing..." : "Refresh"} - - - - } - onSelect={() => setDetailsOpen(true)} - > - Details - @@ -797,13 +853,6 @@ function AgentScreenContent({ - setDetailsOpen(false)} - agentId={effectiveAgent.id} - persistenceSessionId={effectiveAgent.persistence?.sessionId ?? null} - /> - {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */} {!isMobile && isExplorerOpen && resolvedAgentId && ( @@ -978,31 +1027,30 @@ const styles = StyleSheet.create((theme) => ({ borderRadius: theme.borderRadius.lg, }, menuMetaContainer: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - gap: theme.spacing[2], + paddingVertical: theme.spacing[1], }, menuMetaRow: { - gap: theme.spacing[1], + minHeight: 32, + paddingHorizontal: theme.spacing[3], + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[3], + }, + menuMetaRowActive: { + backgroundColor: theme.colors.surface2, }, menuMetaLabel: { - fontSize: theme.fontSize.xs, + fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, - letterSpacing: 0.5, - textTransform: "uppercase", + flexShrink: 0, }, menuMetaValue: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, - }, - menuMetaValueRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - menuMetaPendingText: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, + flex: 1, + minWidth: 0, + textAlign: "right", }, menuMetaValueError: { color: theme.colors.destructive,