Update files

This commit is contained in:
Mohamed Boudra
2026-02-06 22:41:20 +07:00
parent 2d74b5407c
commit 1a01e836bc
6 changed files with 268 additions and 318 deletions

View File

@@ -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 (
<AdaptiveModalSheet
title="Agent details"
visible={visible}
onClose={onClose}
testID="agent-details-sheet"
snapPoints={["45%", "70%"]}
>
{details.map((row) => (
<DetailRow
key={row.label}
label={row.label}
value={row.value}
onCopy={() => void handleCopy(row.value, row.copyLabel)}
testID={row.testID}
disabled={!row.value}
/>
))}
</AdaptiveModalSheet>
);
}
function DetailRow({
label,
value,
onCopy,
disabled,
testID,
}: {
label: string;
value: string | null;
onCopy: () => void;
disabled?: boolean;
testID: string;
}) {
const { theme } = useUnistyles();
return (
<Pressable
testID={testID}
accessibilityRole="button"
accessibilityLabel={`${label} copy`}
accessibilityHint={disabled ? "Not available" : "Copies to clipboard"}
disabled={disabled}
onPress={onCopy}
style={({ pressed }) => [
styles.row,
disabled ? styles.rowDisabled : null,
pressed && !disabled ? styles.rowPressed : null,
]}
>
<View style={styles.rowTop}>
<View style={styles.labelRow}>
<Info size={16} color={theme.colors.foregroundMuted} />
<Text style={styles.label}>{label}</Text>
</View>
<View style={styles.copyPill}>
<Copy size={14} color={theme.colors.foregroundMuted} />
<Text style={styles.copyText}>Copy</Text>
</View>
</View>
<Text
testID={`${testID}-value`}
style={[styles.value, !value ? styles.valueEmpty : null]}
numberOfLines={2}
ellipsizeMode="middle"
>
{value ?? "Not available"}
</Text>
</Pressable>
);
}
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,
},
}));

View File

@@ -424,12 +424,20 @@ export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) {
<DropdownMenuContent align="end" width={220}>
<View style={styles.contextMetaBlock}>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel}>Size</Text>
<Text style={styles.contextMetaValue}>{formatFileSize({ size: entry.size })}</Text>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
Size
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatFileSize({ size: entry.size })}
</Text>
</View>
<View style={styles.contextMetaRow}>
<Text style={styles.contextMetaLabel}>Modified</Text>
<Text style={styles.contextMetaValue}>{formatTimeAgo(new Date(entry.modifiedAt))}</Text>
<Text style={styles.contextMetaLabel} numberOfLines={1}>
Modified
</Text>
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
{formatTimeAgo(new Date(entry.modifiedAt))}
</Text>
</View>
</View>
<DropdownMenuSeparator />
@@ -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,

View File

@@ -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: <GitCommitHorizontal size={16} color={theme.colors.foregroundMuted} />,
handler: handleCommit,
});
@@ -869,6 +883,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
disabled: pushDisabled,
status: pushStatus,
description: !hasRemote ? "No remote configured" : undefined,
icon: <Upload size={16} color={theme.colors.foregroundMuted} />,
handler: handlePush,
});
}
@@ -883,6 +898,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
successLabel: "View PR",
disabled: false,
status: "idle",
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
handler: () => openURLInNewTab(prUrl),
});
}
@@ -896,6 +912,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
successLabel: "PR Created",
disabled: prDisabled,
status: prCreateStatus,
icon: <GitHubIcon size={16} color={theme.colors.foregroundMuted} />,
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: <GitMerge size={16} color={theme.colors.foregroundMuted} />,
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: <RefreshCcw size={16} color={theme.colors.foregroundMuted} />,
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" ? (
<ActivityIndicator size="small" color={theme.colors.foreground} style={styles.splitButtonSpinner} />
) : (
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
<View style={styles.splitButtonContent}>
{gitActions.primary.icon}
<Text style={styles.splitButtonText}>{getActionDisplayLabel(gitActions.primary)}</Text>
</View>
)}
</Pressable>
{gitActions.secondary.length > 0 ? (
@@ -1054,6 +1077,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
{needsSeparator && index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
leading={action.icon}
disabled={action.disabled}
status={action.status}
pendingLabel={action.pendingLabel}
@@ -1077,7 +1101,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
<DropdownMenuTrigger
testID="changes-overflow-menu"
hitSlop={8}
style={styles.iconButton}
style={[styles.iconButton, styles.overflowMenuButton]}
accessibilityRole="button"
accessibilityLabel="More actions"
>
@@ -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,
},

View File

@@ -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 (
<Svg width={size} height={size} viewBox="0 -0.5 25 25" fill={color}>
<Path d="m12.301 0h.093c2.242 0 4.34.613 6.137 1.68l-.055-.031c1.871 1.094 3.386 2.609 4.449 4.422l.031.058c1.04 1.769 1.654 3.896 1.654 6.166 0 5.406-3.483 10-8.327 11.658l-.087.026c-.063.02-.135.031-.209.031-.162 0-.312-.054-.433-.144l.002.001c-.128-.115-.208-.281-.208-.466 0-.005 0-.01 0-.014v.001q0-.048.008-1.226t.008-2.154c.007-.075.011-.161.011-.249 0-.792-.323-1.508-.844-2.025.618-.061 1.176-.163 1.718-.305l-.076.017c.573-.16 1.073-.373 1.537-.642l-.031.017c.508-.28.938-.636 1.292-1.058l.006-.007c.372-.476.663-1.036.84-1.645l.009-.035c.209-.683.329-1.468.329-2.281 0-.045 0-.091-.001-.136v.007c0-.022.001-.047.001-.072 0-1.248-.482-2.383-1.269-3.23l.003.003c.168-.44.265-.948.265-1.479 0-.649-.145-1.263-.404-1.814l.011.026c-.115-.022-.246-.035-.381-.035-.334 0-.649.078-.929.216l.012-.005c-.568.21-1.054.448-1.512.726l.038-.022-.609.384c-.922-.264-1.981-.416-3.075-.416s-2.153.152-3.157.436l.081-.02q-.256-.176-.681-.433c-.373-.214-.814-.421-1.272-.595l-.066-.022c-.293-.154-.64-.244-1.009-.244-.124 0-.246.01-.364.03l.013-.002c-.248.524-.393 1.139-.393 1.788 0 .531.097 1.04.275 1.509l-.01-.029c-.785.844-1.266 1.979-1.266 3.227 0 .025 0 .051.001.076v-.004c-.001.039-.001.084-.001.13 0 .809.12 1.591.344 2.327l-.015-.057c.189.643.476 1.202.85 1.693l-.009-.013c.354.435.782.793 1.267 1.062l.022.011c.432.252.933.465 1.46.614l.046.011c.466.125 1.024.227 1.595.284l.046.004c-.431.428-.718 1-.784 1.638l-.001.012c-.207.101-.448.183-.699.236l-.021.004c-.256.051-.549.08-.85.08-.022 0-.044 0-.066 0h.003c-.394-.008-.756-.136-1.055-.348l.006.004c-.371-.259-.671-.595-.881-.986l-.007-.015c-.198-.336-.459-.614-.768-.827l-.009-.006c-.225-.169-.49-.301-.776-.38l-.016-.004-.32-.048c-.023-.002-.05-.003-.077-.003-.14 0-.273.028-.394.077l.007-.003q-.128.072-.08.184c.039.086.087.16.145.225l-.001-.001c.061.072.13.135.205.19l.003.002.112.08c.283.148.516.354.693.603l.004.006c.191.237.359.505.494.792l.01.024.16.368c.135.402.38.738.7.981l.005.004c.3.234.662.402 1.057.478l.016.002c.33.064.714.104 1.106.112h.007c.045.002.097.002.15.002.261 0 .517-.021.767-.062l-.027.004.368-.064q0 .609.008 1.418t.008.873v.014c0 .185-.08.351-.208.466h-.001c-.119.089-.268.143-.431.143-.075 0-.147-.011-.214-.032l.005.001c-4.929-1.689-8.409-6.283-8.409-11.69 0-2.268.612-4.393 1.681-6.219l-.032.058c1.094-1.871 2.609-3.386 4.422-4.449l.058-.031c1.739-1.034 3.835-1.645 6.073-1.645h.098-.005zm-7.64 17.666q.048-.112-.112-.192-.16-.048-.208.032-.048.112.112.192.144.096.208-.032zm.497.545q.112-.08-.032-.256-.16-.144-.256-.048-.112.08.032.256.159.157.256.047zm.48.72q.144-.112 0-.304-.128-.208-.272-.096-.144.08 0 .288t.272.112zm.672.673q.128-.128-.064-.304-.192-.192-.32-.048-.144.128.064.304.192.192.32.044zm.913.4q.048-.176-.208-.256-.24-.064-.304.112t.208.24q.24.097.304-.096zm1.009.08q0-.208-.272-.176-.256 0-.256.176 0 .208.272.176.256.001.256-.175zm.929-.16q-.032-.176-.288-.144-.256.048-.224.24t.288.128.225-.224z" />
</Svg>
);
}

View File

@@ -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: <CheckCircle2 size={18} />,
}),
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<ReturnType<typeof setTimeout> | 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" ? (
<CheckCircle2 size={18} color={theme.colors.primary} />
) : toast.variant === "error" ? (
<AlertTriangle size={18} color={theme.colors.destructive} />
) : null;
) : null
);
const content = (
<View style={styles.container} pointerEvents="box-none">
@@ -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 ? <View style={styles.iconSlot}>{icon}</View> : null}
<Text
testID="app-toast-message"
style={[
styles.message,
toast.variant === "error" ? styles.messageError : null,
]}
numberOfLines={2}
>
{toast.message}
</Text>
{typeof toast.content === "string" ? (
<Text
testID="app-toast-message"
style={[
styles.message,
toast.variant === "error" ? styles.messageError : null,
]}
numberOfLines={2}
>
{toast.content}
</Text>
) : (
<View testID="app-toast-message" style={styles.contentSlot}>
{toast.content}
</View>
)}
</Animated.View>
</View>
);
@@ -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,

View File

@@ -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: <CheckCircle2 size={16} color={theme.colors.primary} />,
});
} catch {
toast.error("Copy failed");
}
},
[theme.colors.primary, toast]
);
if (!effectiveAgent) {
return (
<View style={styles.container} testID="agent-not-found">
@@ -677,19 +691,39 @@ function AgentScreenContent({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
<View style={styles.menuMetaContainer}>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Directory</Text>
<Pressable
style={({ hovered, pressed }) => [
styles.menuMetaRow,
(hovered || pressed) && styles.menuMetaRowActive,
]}
onPress={() => {
void handleCopyMeta("Directory", effectiveAgent.cwd);
}}
>
<Text style={styles.menuMetaLabel} numberOfLines={1}>
Directory
</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={2}
numberOfLines={1}
ellipsizeMode="middle"
>
{shortenPath(effectiveAgent.cwd)}
</Text>
</View>
</Pressable>
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Model</Text>
<Pressable
style={({ hovered, pressed }) => [
styles.menuMetaRow,
(hovered || pressed) && styles.menuMetaRowActive,
]}
onPress={() => {
void handleCopyMeta("Model", modelDisplayValue);
}}
>
<Text style={styles.menuMetaLabel} numberOfLines={1}>
Model
</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={1}
@@ -697,48 +731,80 @@ function AgentScreenContent({
>
{modelDisplayValue}
</Text>
</View>
</Pressable>
{checkout?.isGit && checkout.currentBranch && checkout.currentBranch !== "HEAD" ? (
<View style={styles.menuMetaRow}>
<Text style={styles.menuMetaLabel}>Branch</Text>
<View style={styles.menuMetaValueRow}>
{checkoutStatusQuery.isFetching ? (
<>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<Text style={styles.menuMetaPendingText}>Fetching</Text>
</>
) : (
<Text
style={styles.menuMetaValue}
numberOfLines={1}
ellipsizeMode="middle"
>
{checkout.currentBranch}
</Text>
)}
</View>
</View>
<Pressable
style={({ hovered, pressed }) => [
styles.menuMetaRow,
(hovered || pressed) && styles.menuMetaRowActive,
]}
onPress={() => {
if (checkoutStatusQuery.isFetching) {
return;
}
void handleCopyMeta("Branch", checkout.currentBranch);
}}
>
<Text style={styles.menuMetaLabel} numberOfLines={1}>
Branch
</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={1}
ellipsizeMode="middle"
>
{checkoutStatusQuery.isFetching ? "Fetching…" : checkout.currentBranch}
</Text>
</Pressable>
) : null}
<Pressable
style={({ hovered, pressed }) => [
styles.menuMetaRow,
(hovered || pressed) && styles.menuMetaRowActive,
]}
onPress={() => {
void handleCopyMeta("Paseo ID", effectiveAgent.id);
}}
>
<Text style={styles.menuMetaLabel} numberOfLines={1}>
Paseo ID
</Text>
<Text
style={styles.menuMetaValue}
numberOfLines={1}
ellipsizeMode="middle"
>
{effectiveAgent.id}
</Text>
</Pressable>
<Pressable
style={({ hovered, pressed }) => [
styles.menuMetaRow,
providerSessionId && (hovered || pressed) && styles.menuMetaRowActive,
]}
disabled={!providerSessionId}
onPress={() => {
void handleCopyMeta(`${providerLabel} ID`, providerSessionId);
}}
>
<Text style={styles.menuMetaLabel} numberOfLines={1}>
{providerLabel} ID
</Text>
<Text
style={[styles.menuMetaValue, !providerSessionId && styles.menuMetaValueError]}
numberOfLines={1}
ellipsizeMode="middle"
>
{providerSessionId ?? "Not available"}
</Text>
</Pressable>
</View>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={<GitBranch size={16} color={theme.colors.foreground} />}
onSelect={handleViewChanges}
>
View changes
</DropdownMenuItem>
<DropdownMenuItem
leading={<Folder size={16} color={theme.colors.foreground} />}
onSelect={handleBrowseFiles}
>
Browse files
</DropdownMenuItem>
<DropdownMenuItem
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
disabled={isInitializing}
@@ -755,16 +821,6 @@ function AgentScreenContent({
>
{isInitializing ? "Refreshing..." : "Refresh"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
testID="agent-menu-details"
leading={<Info size={16} color={theme.colors.foreground} />}
onSelect={() => setDetailsOpen(true)}
>
Details
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</View>
@@ -797,13 +853,6 @@ function AgentScreenContent({
</View>
</FileDropZone>
<AgentDetailsSheet
visible={detailsOpen}
onClose={() => setDetailsOpen(false)}
agentId={effectiveAgent.id}
persistenceSessionId={effectiveAgent.persistence?.sessionId ?? null}
/>
{/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
{!isMobile && isExplorerOpen && resolvedAgentId && (
<ExplorerSidebar serverId={serverId} agentId={resolvedAgentId} cwd={effectiveAgent.cwd} />
@@ -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,