diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index dd8139171..061f7ab49 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -70,6 +70,7 @@ export default function RootLayout() {
+
diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx
index e87b859a9..59fcdc53b 100644
--- a/packages/app/src/app/agent/[id].tsx
+++ b/packages/app/src/app/agent/[id].tsx
@@ -13,7 +13,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
-import { MoreVertical, GitBranch } from "lucide-react-native";
+import { MoreVertical, GitBranch, Folder } from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentInputArea } from "@/components/agent-input-area";
@@ -185,6 +185,13 @@ export default function AgentScreen() {
}
}, [id, router, handleCloseMenu]);
+ const handleBrowseFiles = useCallback(() => {
+ handleCloseMenu();
+ if (id) {
+ router.push(`/file-explorer?agentId=${id}`);
+ }
+ }, [handleCloseMenu, id, router]);
+
if (!agent) {
return (
@@ -257,6 +264,10 @@ export default function AgentScreen() {
View Changes
+
+
+ Browse Files
+
diff --git a/packages/app/src/app/file-explorer.tsx b/packages/app/src/app/file-explorer.tsx
new file mode 100644
index 000000000..0a79aa5d7
--- /dev/null
+++ b/packages/app/src/app/file-explorer.tsx
@@ -0,0 +1,360 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ ActivityIndicator,
+ Image,
+ Pressable,
+ ScrollView,
+ Text,
+ View,
+} from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+import { useLocalSearchParams } from "expo-router";
+import { BackHeader } from "@/components/headers/back-header";
+import { useSession, type ExplorerEntry } from "@/contexts/session-context";
+
+export default function FileExplorerScreen() {
+ const { agentId } = useLocalSearchParams<{ agentId: string }>();
+ const {
+ agents,
+ fileExplorer,
+ requestDirectoryListing,
+ requestFilePreview,
+ } = useSession();
+ const [currentPath, setCurrentPath] = useState(".");
+ const [selectedEntryPath, setSelectedEntryPath] = useState(null);
+
+ const agent = agentId ? agents.get(agentId) : undefined;
+ const explorerState = agentId ? fileExplorer.get(agentId) : undefined;
+ const directory = explorerState?.directories.get(currentPath);
+ const entries = directory?.entries ?? [];
+ const isLoading = explorerState?.isLoading ?? false;
+ const error = explorerState?.lastError ?? null;
+ const preview = selectedEntryPath
+ ? explorerState?.files.get(selectedEntryPath)
+ : null;
+
+ useEffect(() => {
+ if (!agentId) {
+ return;
+ }
+ setCurrentPath(".");
+ setSelectedEntryPath(null);
+ requestDirectoryListing(agentId, ".");
+ }, [agentId, requestDirectoryListing]);
+
+ const parentPath = useMemo(() => {
+ if (currentPath === ".") {
+ return null;
+ }
+ const segments = currentPath.split("/");
+ segments.pop();
+ const nextPath = segments.join("/");
+ return nextPath.length === 0 ? "." : nextPath;
+ }, [currentPath]);
+
+ const handleEntryPress = useCallback(
+ (entry: ExplorerEntry) => {
+ if (!agentId) {
+ return;
+ }
+
+ if (entry.kind === "directory") {
+ setCurrentPath(entry.path);
+ setSelectedEntryPath(null);
+ requestDirectoryListing(agentId, entry.path);
+ return;
+ }
+
+ setSelectedEntryPath(entry.path);
+ requestFilePreview(agentId, entry.path);
+ },
+ [agentId, requestDirectoryListing, requestFilePreview]
+ );
+
+ const handleNavigateUp = useCallback(() => {
+ if (!agentId || !parentPath) {
+ return;
+ }
+ setCurrentPath(parentPath);
+ setSelectedEntryPath(null);
+ requestDirectoryListing(agentId, parentPath);
+ }, [agentId, parentPath, requestDirectoryListing]);
+
+ if (!agent) {
+ return (
+
+
+
+ Agent not found
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Path
+
+ {currentPath}
+
+
+ {parentPath && (
+
+ Up
+
+ )}
+
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : isLoading && entries.length === 0 ? (
+
+
+ Loading...
+
+ ) : entries.length === 0 ? (
+
+ Directory is empty
+
+ ) : (
+
+ {entries.map((entry) => (
+ handleEntryPress(entry)}
+ >
+
+ {entry.name}
+
+ {entry.kind.toUpperCase()} ·{" "}
+ {formatFileSize({ size: entry.size })} ·{" "}
+ {formatModifiedTime({ value: entry.modifiedAt })}
+
+
+
+ {entry.kind === "directory" ? "Open" : "Preview"}
+
+
+ ))}
+
+ )}
+
+
+
+ {selectedEntryPath && isLoading && !preview ? (
+
+
+ Loading file...
+
+ ) : !selectedEntryPath ? (
+
+ Select a file to preview
+
+ ) : !preview ? (
+
+ No preview available yet
+
+ ) : preview.kind === "text" ? (
+
+
+ {preview.content}
+
+
+ ) : preview.kind === "image" && preview.content ? (
+
+
+
+ ) : (
+
+ Binary preview unavailable
+
+ {formatFileSize({ size: preview.size })}
+
+
+ )}
+
+
+
+ );
+}
+
+function formatFileSize({ size }: { size: number }): string {
+ if (size < 1024) {
+ return `${size} B`;
+ }
+ if (size < 1024 * 1024) {
+ return `${(size / 1024).toFixed(1)} KB`;
+ }
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+function formatModifiedTime({ value }: { value: string }): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+ return date.toLocaleString();
+}
+
+const styles = StyleSheet.create((theme) => ({
+ container: {
+ flex: 1,
+ backgroundColor: theme.colors.background,
+ },
+ headerRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: theme.spacing[4],
+ paddingVertical: theme.spacing[2],
+ gap: theme.spacing[3],
+ },
+ breadcrumbs: {
+ flex: 1,
+ },
+ breadcrumbLabel: {
+ color: theme.colors.mutedForeground,
+ fontSize: theme.fontSize.xs,
+ marginBottom: theme.spacing[1],
+ },
+ breadcrumbText: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.sm,
+ fontFamily: "monospace",
+ },
+ upButton: {
+ paddingHorizontal: theme.spacing[4],
+ paddingVertical: theme.spacing[2],
+ borderRadius: theme.borderRadius.lg,
+ backgroundColor: theme.colors.muted,
+ },
+ upButtonText: {
+ color: theme.colors.foreground,
+ fontWeight: theme.fontWeight.semibold,
+ },
+ content: {
+ flex: 1,
+ flexDirection: "column",
+ paddingHorizontal: theme.spacing[4],
+ paddingBottom: theme.spacing[4],
+ gap: theme.spacing[4],
+ },
+ listSection: {
+ flex: 1,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ borderRadius: theme.borderRadius.lg,
+ padding: theme.spacing[2],
+ },
+ previewSection: {
+ flex: 1,
+ borderWidth: theme.borderWidth[1],
+ borderColor: theme.colors.border,
+ borderRadius: theme.borderRadius.lg,
+ padding: theme.spacing[2],
+ },
+ centerState: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ gap: theme.spacing[2],
+ padding: theme.spacing[4],
+ },
+ loadingText: {
+ color: theme.colors.mutedForeground,
+ fontSize: theme.fontSize.sm,
+ },
+ errorText: {
+ color: theme.colors.destructive,
+ fontSize: theme.fontSize.base,
+ textAlign: "center",
+ },
+ emptyText: {
+ color: theme.colors.mutedForeground,
+ fontSize: theme.fontSize.base,
+ textAlign: "center",
+ },
+ entryRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingVertical: theme.spacing[3],
+ paddingHorizontal: theme.spacing[3],
+ borderRadius: theme.borderRadius.md,
+ marginBottom: theme.spacing[2],
+ },
+ directoryRow: {
+ backgroundColor: theme.colors.muted,
+ },
+ fileRow: {
+ backgroundColor: theme.colors.card,
+ },
+ selectedRow: {
+ borderWidth: theme.borderWidth[2],
+ borderColor: theme.colors.primary,
+ },
+ entryTextContainer: {
+ flex: 1,
+ marginRight: theme.spacing[3],
+ },
+ entryName: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.base,
+ fontWeight: theme.fontWeight.semibold,
+ },
+ entryMeta: {
+ color: theme.colors.mutedForeground,
+ fontSize: theme.fontSize.xs,
+ marginTop: theme.spacing[1],
+ },
+ entryAction: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.semibold,
+ },
+ textPreview: {
+ flex: 1,
+ },
+ textPreviewContent: {
+ padding: theme.spacing[2],
+ },
+ codeText: {
+ color: theme.colors.foreground,
+ fontFamily: "monospace",
+ fontSize: theme.fontSize.sm,
+ },
+ imagePreviewContainer: {
+ flex: 1,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ image: {
+ width: "100%",
+ height: "100%",
+ },
+}));
diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx
index 59aa0bd97..b842fddcc 100644
--- a/packages/app/src/contexts/session-context.tsx
+++ b/packages/app/src/contexts/session-context.tsx
@@ -81,6 +81,54 @@ export interface Command {
exitCode: number | null;
}
+export type ExplorerEntryKind = "file" | "directory";
+export type ExplorerFileKind = "text" | "image" | "binary";
+export type ExplorerEncoding = "utf-8" | "base64" | "none";
+
+export interface ExplorerEntry {
+ name: string;
+ path: string;
+ kind: ExplorerEntryKind;
+ size: number;
+ modifiedAt: string;
+}
+
+export interface ExplorerFile {
+ path: string;
+ kind: ExplorerFileKind;
+ encoding: ExplorerEncoding;
+ content?: string;
+ mimeType?: string;
+ size: number;
+ modifiedAt: string;
+}
+
+interface ExplorerDirectory {
+ path: string;
+ entries: ExplorerEntry[];
+}
+
+interface ExplorerRequestState {
+ path: string;
+ mode: "list" | "file";
+}
+
+export interface AgentFileExplorerState {
+ directories: Map;
+ files: Map;
+ isLoading: boolean;
+ lastError: string | null;
+ pendingRequest: ExplorerRequestState | null;
+}
+
+const createExplorerState = (): AgentFileExplorerState => ({
+ directories: new Map(),
+ files: new Map(),
+ isLoading: false,
+ lastError: null,
+ pendingRequest: null,
+});
+
interface SessionContextValue {
// WebSocket
@@ -119,6 +167,11 @@ interface SessionContextValue {
gitDiffs: Map;
requestGitDiff: (agentId: string) => void;
+ // File explorer
+ fileExplorer: Map;
+ requestDirectoryListing: (agentId: string, path: string) => void;
+ requestFilePreview: (agentId: string, path: string) => void;
+
// Helpers
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise;
@@ -172,6 +225,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
const [agentUpdates, setAgentUpdates] = useState