diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx
index 63bf8aac3..7c8ad2532 100644
--- a/packages/app/src/app/agent/[serverId]/[agentId].tsx
+++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx
@@ -9,6 +9,7 @@ import {
LayoutChangeEvent,
ScrollView,
Platform,
+ BackHandler,
} from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
@@ -17,8 +18,12 @@ import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller
import ReanimatedAnimated, {
useAnimatedStyle,
useSharedValue,
+ runOnJS,
+ interpolate,
+ Extrapolation,
} from "react-native-reanimated";
-import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
MoreVertical,
GitBranch,
@@ -28,12 +33,19 @@ import {
Users,
ChevronRight,
PlusIcon,
+ PanelRightOpen,
} 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 { ImportAgentModal } from "@/components/create-agent-modal";
+import { ExplorerSidebar } from "@/components/explorer-sidebar";
+import {
+ ExplorerSidebarAnimationProvider,
+ useExplorerSidebarAnimation,
+} from "@/contexts/explorer-sidebar-animation-context";
+import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { formatConnectionStatus } from "@/utils/daemons";
@@ -143,10 +155,12 @@ export default function AgentScreen() {
}
return (
-
+
+
+
);
}
@@ -170,6 +184,84 @@ function AgentScreenContent({
const menuButtonRef = useRef(null);
const [showImportAgentModal, setShowImportAgentModal] = useState(false);
+ const { isOpen: isExplorerOpen, toggle: toggleExplorer, open: openExplorer, close: closeExplorer } = useExplorerSidebarStore();
+ const {
+ translateX: explorerTranslateX,
+ backdropOpacity: explorerBackdropOpacity,
+ windowWidth: explorerWindowWidth,
+ animateToOpen: animateExplorerToOpen,
+ animateToClose: animateExplorerToClose,
+ isGesturing: isExplorerGesturing,
+ } = useExplorerSidebarAnimation();
+ const isMobile =
+ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
+
+ // Swipe-left gesture to open explorer sidebar on mobile
+ const explorerOpenGesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .enabled(isMobile && !isExplorerOpen)
+ // Only activate after 15px horizontal movement to the left (negative)
+ .activeOffsetX(-15)
+ // Fail if 10px vertical movement happens first (allow vertical scroll)
+ .failOffsetY([-10, 10])
+ .onStart(() => {
+ isExplorerGesturing.value = true;
+ })
+ .onUpdate((event) => {
+ // Right sidebar: start from closed position (+windowWidth) and move towards 0
+ // Swiping left means negative translationX
+ const newTranslateX = Math.max(0, explorerWindowWidth + event.translationX);
+ explorerTranslateX.value = newTranslateX;
+ explorerBackdropOpacity.value = interpolate(
+ newTranslateX,
+ [explorerWindowWidth, 0],
+ [0, 1],
+ Extrapolation.CLAMP
+ );
+ })
+ .onEnd((event) => {
+ isExplorerGesturing.value = false;
+ // Open if dragged more than 1/3 of window or fast swipe left
+ const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
+ if (shouldOpen) {
+ animateExplorerToOpen();
+ runOnJS(openExplorer)();
+ } else {
+ animateExplorerToClose();
+ }
+ })
+ .onFinalize(() => {
+ isExplorerGesturing.value = false;
+ }),
+ [
+ isMobile,
+ isExplorerOpen,
+ explorerWindowWidth,
+ explorerTranslateX,
+ explorerBackdropOpacity,
+ animateExplorerToOpen,
+ animateExplorerToClose,
+ openExplorer,
+ isExplorerGesturing,
+ ]
+ );
+
+ // Handle hardware back button - close explorer sidebar first, then navigate back
+ useEffect(() => {
+ if (Platform.OS === "web") return;
+
+ const handler = BackHandler.addEventListener("hardwareBackPress", () => {
+ if (isExplorerOpen) {
+ closeExplorer();
+ return true; // Prevent default back navigation
+ }
+ return false; // Let default back navigation happen
+ });
+
+ return () => handler.remove();
+ }, [isExplorerOpen, closeExplorer]);
+
const resolvedAgentId = agentId;
// Select only the specific agent
@@ -573,50 +665,62 @@ function AgentScreenContent({
);
}
- return (
- <>
+ const mainContent = (
+
{/* Header */}
-
-
+
+
+
+
+
+
+
+
}
/>
- {/* Content Area with Keyboard Animation */}
-
-
- {isInitializing ? (
-
-
+
+ {isInitializing ? (
+
+
+ Loading agent...
+
+ ) : (
+
- Loading agent...
-
- ) : (
-
- )}
-
-
+ )}
+
+
- {/* Agent Input Area */}
- {!isInitializing && agent && resolvedAgentId && (
-
- )}
+ {/* Agent Input Area */}
+ {!isInitializing && agent && resolvedAgentId && (
+
+ )}
{/* Dropdown Menu */}
+
+
+ {/* Explorer Sidebar - Desktop: inline, Mobile: overlay */}
+ {!isMobile && isExplorerOpen && resolvedAgentId && (
+
+ )}
+ );
+
+ return (
+ <>
+ {isMobile ? (
+
+ {mainContent}
+
+ ) : (
+ mainContent
+ )}
+
+ {/* Mobile Explorer Sidebar Overlay */}
+ {isMobile && resolvedAgentId && (
+
+ )}
+
{importAgentModal}
>
);
@@ -853,10 +980,19 @@ function AgentSessionUnavailableState({
}
const styles = StyleSheet.create((theme) => ({
+ outerContainer: {
+ flex: 1,
+ flexDirection: "row",
+ backgroundColor: theme.colors.background,
+ },
container: {
flex: 1,
backgroundColor: theme.colors.background,
},
+ headerRightContent: {
+ flexDirection: "row",
+ alignItems: "center",
+ },
contentContainer: {
flex: 1,
overflow: "hidden",
diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx
new file mode 100644
index 000000000..f5db7eab6
--- /dev/null
+++ b/packages/app/src/components/explorer-sidebar.tsx
@@ -0,0 +1,442 @@
+import { useCallback, useMemo, useRef, useState } from "react";
+import { View, Text, Pressable, Platform } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+import Animated, {
+ useAnimatedStyle,
+ useSharedValue,
+ runOnJS,
+} from "react-native-reanimated";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
+import { X, GitBranch, Folder, LayoutGrid, List as ListIcon } from "lucide-react-native";
+import {
+ useExplorerSidebarStore,
+ MIN_EXPLORER_SIDEBAR_WIDTH,
+ MAX_EXPLORER_SIDEBAR_WIDTH,
+} from "@/stores/explorer-sidebar-store";
+import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
+import { GitDiffPane } from "./git-diff-pane";
+import { FileExplorerPane } from "./file-explorer-pane";
+
+type ViewMode = "list" | "grid";
+
+type ExplorerTab = "changes" | "files";
+
+interface ExplorerSidebarProps {
+ serverId: string;
+ agentId: string;
+}
+
+export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
+ const { theme } = useUnistyles();
+ const insets = useSafeAreaInsets();
+ const { isOpen, activeTab, width, close, setActiveTab, setWidth } =
+ useExplorerSidebarStore();
+ const {
+ translateX,
+ backdropOpacity,
+ windowWidth,
+ animateToOpen,
+ animateToClose,
+ isGesturing,
+ closeGestureRef,
+ } = useExplorerSidebarAnimation();
+
+ const isMobile =
+ UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
+
+ // File explorer view mode state
+ const [fileViewMode, setFileViewMode] = useState("list");
+
+ // For resize drag, track the starting width
+ const startWidthRef = useRef(width);
+ const resizeWidth = useSharedValue(width);
+
+ const handleClose = useCallback(() => {
+ close();
+ }, [close]);
+
+ const handleTabPress = useCallback(
+ (tab: ExplorerTab) => {
+ setActiveTab(tab);
+ },
+ [setActiveTab]
+ );
+
+ // Swipe gesture to close (swipe right on mobile)
+ const closeGesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .withRef(closeGestureRef)
+ .enabled(isMobile && isOpen)
+ // Only activate after 15px horizontal movement (creates deadzone for taps)
+ .activeOffsetX([-15, 15])
+ .failOffsetY([-10, 10])
+ .onStart(() => {
+ isGesturing.value = true;
+ })
+ .onUpdate((event) => {
+ // Right sidebar: swipe right to close (positive translationX)
+ const newTranslateX = Math.max(0, Math.min(windowWidth, event.translationX));
+ translateX.value = newTranslateX;
+ const progress = 1 - newTranslateX / windowWidth;
+ backdropOpacity.value = Math.max(0, Math.min(1, progress));
+ })
+ .onEnd((event) => {
+ isGesturing.value = false;
+ const shouldClose =
+ event.translationX > windowWidth / 3 || event.velocityX > 500;
+ if (shouldClose) {
+ animateToClose();
+ runOnJS(handleClose)();
+ } else {
+ animateToOpen();
+ }
+ })
+ .onFinalize(() => {
+ isGesturing.value = false;
+ }),
+ [
+ isMobile,
+ isOpen,
+ windowWidth,
+ translateX,
+ backdropOpacity,
+ animateToOpen,
+ animateToClose,
+ handleClose,
+ isGesturing,
+ closeGestureRef,
+ ]
+ );
+
+ // Desktop resize gesture (drag left edge)
+ const resizeGesture = useMemo(
+ () =>
+ Gesture.Pan()
+ .enabled(!isMobile)
+ .hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
+ .onStart(() => {
+ startWidthRef.current = width;
+ resizeWidth.value = width;
+ })
+ .onUpdate((event) => {
+ // Dragging left (negative translationX) increases width
+ const newWidth = startWidthRef.current - event.translationX;
+ const clampedWidth = Math.max(
+ MIN_EXPLORER_SIDEBAR_WIDTH,
+ Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, newWidth)
+ );
+ resizeWidth.value = clampedWidth;
+ })
+ .onEnd(() => {
+ runOnJS(setWidth)(resizeWidth.value);
+ }),
+ [isMobile, width, resizeWidth, setWidth]
+ );
+
+ const sidebarAnimatedStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: translateX.value }],
+ }));
+
+ const backdropAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: backdropOpacity.value,
+ pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
+ }));
+
+ const resizeAnimatedStyle = useAnimatedStyle(() => ({
+ width: resizeWidth.value,
+ }));
+
+ // Mobile: full-screen overlay with gesture
+ const overlayPointerEvents = Platform.OS === "web" ? "auto" : "box-none";
+
+ if (isMobile) {
+ return (
+
+ {/* Backdrop */}
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Desktop: fixed width sidebar with resize handle
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+ {/* Resize handle on left edge */}
+
+
+
+
+
+
+
+
+ );
+}
+
+interface SidebarContentProps {
+ activeTab: ExplorerTab;
+ onTabPress: (tab: ExplorerTab) => void;
+ onClose: () => void;
+ serverId: string;
+ agentId: string;
+ fileViewMode: ViewMode;
+ onFileViewModeChange: (mode: ViewMode) => void;
+ isMobile: boolean;
+}
+
+function SidebarContent({
+ activeTab,
+ onTabPress,
+ onClose,
+ serverId,
+ agentId,
+ fileViewMode,
+ onFileViewModeChange,
+ isMobile,
+}: SidebarContentProps) {
+ const { theme } = useUnistyles();
+
+ return (
+
+ {/* Header with tabs and close button */}
+
+
+ onTabPress("changes")}
+ >
+
+
+ Changes
+
+
+ onTabPress("files")}
+ >
+
+
+ Files
+
+
+
+
+ {activeTab === "files" && (
+
+ )}
+ {isMobile && (
+
+
+
+ )}
+
+
+
+ {/* Content based on active tab */}
+
+ {activeTab === "changes" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function ViewToggle({
+ viewMode,
+ onChange,
+}: {
+ viewMode: ViewMode;
+ onChange: (mode: ViewMode) => void;
+}) {
+ const { theme } = useUnistyles();
+
+ return (
+
+ onChange("list")}
+ >
+
+
+ onChange("grid")}
+ >
+
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ backdrop: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: "rgba(0, 0, 0, 0.5)",
+ },
+ backdropPressable: {
+ flex: 1,
+ },
+ mobileSidebar: {
+ position: "absolute",
+ top: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: theme.colors.background,
+ overflow: "hidden",
+ },
+ desktopSidebar: {
+ flexDirection: "row",
+ borderLeftWidth: 1,
+ borderLeftColor: theme.colors.border,
+ backgroundColor: theme.colors.background,
+ },
+ resizeHandle: {
+ width: 8,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ resizeHandleInner: {
+ width: 2,
+ height: 32,
+ backgroundColor: theme.colors.border,
+ borderRadius: 1,
+ opacity: 0.5,
+ },
+ sidebarContent: {
+ flex: 1,
+ minHeight: 0,
+ overflow: "hidden",
+ },
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: theme.spacing[3],
+ paddingVertical: theme.spacing[2],
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.border,
+ },
+ tabsContainer: {
+ flexDirection: "row",
+ gap: theme.spacing[1],
+ },
+ tab: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ paddingVertical: theme.spacing[2],
+ paddingHorizontal: theme.spacing[3],
+ borderRadius: theme.borderRadius.md,
+ },
+ tabActive: {
+ backgroundColor: theme.colors.muted,
+ },
+ tabText: {
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.normal,
+ color: theme.colors.mutedForeground,
+ },
+ tabTextActive: {
+ color: theme.colors.foreground,
+ },
+ headerRightSection: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
+ closeButton: {
+ padding: theme.spacing[2],
+ borderRadius: theme.borderRadius.md,
+ },
+ contentArea: {
+ flex: 1,
+ minHeight: 0,
+ },
+ viewToggleContainer: {
+ flexDirection: "row",
+ borderRadius: theme.borderRadius.md,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ overflow: "hidden",
+ },
+ viewToggleButton: {
+ padding: theme.spacing[2],
+ },
+ viewToggleActive: {
+ backgroundColor: theme.colors.muted,
+ },
+}));
diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx
new file mode 100644
index 000000000..e7429aa3e
--- /dev/null
+++ b/packages/app/src/components/file-explorer-pane.tsx
@@ -0,0 +1,1516 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ ActivityIndicator,
+ FlatList,
+ Image as RNImage,
+ LayoutChangeEvent,
+ ListRenderItemInfo,
+ RefreshControl,
+ ViewToken,
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ Modal,
+ Platform,
+ Pressable,
+ ScrollView,
+ Text,
+ View,
+ useWindowDimensions,
+} from "react-native";
+import { StyleSheet, useUnistyles } from "react-native-unistyles";
+import * as Clipboard from "expo-clipboard";
+import { File as FSFile, Paths } from "expo-file-system";
+import * as Sharing from "expo-sharing";
+import {
+ BottomSheetModal,
+ BottomSheetScrollView,
+ BottomSheetBackdrop,
+ BottomSheetView,
+} from "@gorhom/bottom-sheet";
+import {
+ Check,
+ ChevronDown,
+ Download,
+ File,
+ FileText,
+ Folder,
+ Image as ImageIcon,
+ MoreVertical,
+ X,
+ XCircle,
+} from "lucide-react-native";
+import type { ExplorerEntry } from "@/stores/session-store";
+import { useDaemonConnections } from "@/contexts/daemon-connections-context";
+import type { DaemonProfile } from "@/contexts/daemon-registry-context";
+import { useSessionStore } from "@/stores/session-store";
+import {
+ useExplorerSidebarStore,
+ type SortOption,
+} from "@/stores/explorer-sidebar-store";
+
+const MAX_CONCURRENT_THUMBNAILS = 2;
+const THUMBNAIL_TIMEOUT_MS = 15000;
+
+const SORT_OPTIONS: { value: SortOption; label: string }[] = [
+ { value: "name", label: "Name" },
+ { value: "modified", label: "Modified" },
+ { value: "size", label: "Size" },
+];
+
+interface FileExplorerPaneProps {
+ serverId: string;
+ agentId: string;
+}
+
+export function FileExplorerPane({
+ serverId,
+ agentId,
+}: FileExplorerPaneProps) {
+ const { theme } = useUnistyles();
+ const { connectionStates } = useDaemonConnections();
+ const daemonProfile = connectionStates.get(serverId)?.daemon;
+
+ const agent = useSessionStore((state) =>
+ agentId && state.sessions[serverId]
+ ? state.sessions[serverId]?.agents.get(agentId)
+ : undefined
+ );
+
+ const explorerState = useSessionStore((state) =>
+ agentId && state.sessions[serverId]
+ ? state.sessions[serverId]?.fileExplorer.get(agentId)
+ : undefined
+ );
+
+ const methods = useSessionStore((state) => state.sessions[serverId]?.methods);
+ const requestDirectoryListing = methods?.requestDirectoryListing;
+ const requestFilePreview = methods?.requestFilePreview;
+ const requestFileDownloadToken = methods?.requestFileDownloadToken;
+ const navigateExplorerBack = methods?.navigateExplorerBack;
+ const { viewMode, sortOption, setSortOption } = useExplorerSidebarStore();
+ const [selectedEntryPath, setSelectedEntryPath] = useState(null);
+ const listScrollRef = useRef | null>(null);
+ const listScrollOffsetRef = useRef(0);
+ const scrollOffsetsByPathRef = useRef