Add read-only file explorer
This commit is contained in:
@@ -70,6 +70,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" />
|
||||||
<Stack.Screen name="audio-test" />
|
<Stack.Screen name="audio-test" />
|
||||||
<Stack.Screen name="git-diff" />
|
<Stack.Screen name="git-diff" />
|
||||||
|
<Stack.Screen name="file-explorer" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<GlobalFooter />
|
<GlobalFooter />
|
||||||
</AppContainer>
|
</AppContainer>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||||
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
import ReanimatedAnimated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 { BackHeader } from "@/components/headers/back-header";
|
||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { AgentInputArea } from "@/components/agent-input-area";
|
import { AgentInputArea } from "@/components/agent-input-area";
|
||||||
@@ -185,6 +185,13 @@ export default function AgentScreen() {
|
|||||||
}
|
}
|
||||||
}, [id, router, handleCloseMenu]);
|
}, [id, router, handleCloseMenu]);
|
||||||
|
|
||||||
|
const handleBrowseFiles = useCallback(() => {
|
||||||
|
handleCloseMenu();
|
||||||
|
if (id) {
|
||||||
|
router.push(`/file-explorer?agentId=${id}`);
|
||||||
|
}
|
||||||
|
}, [handleCloseMenu, id, router]);
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -257,6 +264,10 @@ export default function AgentScreen() {
|
|||||||
<GitBranch size={20} color={theme.colors.foreground} />
|
<GitBranch size={20} color={theme.colors.foreground} />
|
||||||
<Text style={styles.menuItemText}>View Changes</Text>
|
<Text style={styles.menuItemText}>View Changes</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<Pressable onPress={handleBrowseFiles} style={styles.menuItem}>
|
||||||
|
<Folder size={20} color={theme.colors.foreground} />
|
||||||
|
<Text style={styles.menuItemText}>Browse Files</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<BackHeader title="Files" />
|
||||||
|
<View style={styles.centerState}>
|
||||||
|
<Text style={styles.errorText}>Agent not found</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<BackHeader title="Files" />
|
||||||
|
<View style={styles.headerRow}>
|
||||||
|
<View style={styles.breadcrumbs}>
|
||||||
|
<Text style={styles.breadcrumbLabel}>Path</Text>
|
||||||
|
<ScrollView horizontal>
|
||||||
|
<Text style={styles.breadcrumbText}>{currentPath}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
{parentPath && (
|
||||||
|
<Pressable style={styles.upButton} onPress={handleNavigateUp}>
|
||||||
|
<Text style={styles.upButtonText}>Up</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.listSection}>
|
||||||
|
{error ? (
|
||||||
|
<View style={styles.centerState}>
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
</View>
|
||||||
|
) : isLoading && entries.length === 0 ? (
|
||||||
|
<View style={styles.centerState}>
|
||||||
|
<ActivityIndicator size="small" />
|
||||||
|
<Text style={styles.loadingText}>Loading...</Text>
|
||||||
|
</View>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<View style={styles.centerState}>
|
||||||
|
<Text style={styles.emptyText}>Directory is empty</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<Pressable
|
||||||
|
key={entry.path}
|
||||||
|
style={[
|
||||||
|
styles.entryRow,
|
||||||
|
entry.kind === "directory"
|
||||||
|
? styles.directoryRow
|
||||||
|
: styles.fileRow,
|
||||||
|
selectedEntryPath === entry.path && styles.selectedRow,
|
||||||
|
]}
|
||||||
|
onPress={() => handleEntryPress(entry)}
|
||||||
|
>
|
||||||
|
<View style={styles.entryTextContainer}>
|
||||||
|
<Text style={styles.entryName}>{entry.name}</Text>
|
||||||
|
<Text style={styles.entryMeta}>
|
||||||
|
{entry.kind.toUpperCase()} ·{" "}
|
||||||
|
{formatFileSize({ size: entry.size })} ·{" "}
|
||||||
|
{formatModifiedTime({ value: entry.modifiedAt })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.entryAction}>
|
||||||
|
{entry.kind === "directory" ? "Open" : "Preview"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.previewSection}>
|
||||||
|
{selectedEntryPath && isLoading && !preview ? (
|
||||||
|
<View style={styles.centerState}>
|
||||||
|
<ActivityIndicator size="small" />
|
||||||
|
<Text style={styles.loadingText}>Loading file...</Text>
|
||||||
|
</View>
|
||||||
|
) : !selectedEntryPath ? (
|
||||||
|
<View style={styles.centerState}>
|
||||||
|
<Text style={styles.emptyText}>Select a file to preview</Text>
|
||||||
|
</View>
|
||||||
|
) : !preview ? (
|
||||||
|
<View style={styles.centerState}>
|
||||||
|
<Text style={styles.emptyText}>No preview available yet</Text>
|
||||||
|
</View>
|
||||||
|
) : preview.kind === "text" ? (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.textPreview}
|
||||||
|
horizontal={false}
|
||||||
|
contentContainerStyle={styles.textPreviewContent}
|
||||||
|
>
|
||||||
|
<ScrollView horizontal>
|
||||||
|
<Text style={styles.codeText}>{preview.content}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</ScrollView>
|
||||||
|
) : preview.kind === "image" && preview.content ? (
|
||||||
|
<View style={styles.imagePreviewContainer}>
|
||||||
|
<Image
|
||||||
|
source={{
|
||||||
|
uri: `data:${preview.mimeType ?? "image/png"};base64,${
|
||||||
|
preview.content
|
||||||
|
}`,
|
||||||
|
}}
|
||||||
|
style={styles.image}
|
||||||
|
resizeMode="contain"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.centerState}>
|
||||||
|
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
||||||
|
<Text style={styles.entryMeta}>
|
||||||
|
{formatFileSize({ size: preview.size })}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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%",
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -81,6 +81,54 @@ export interface Command {
|
|||||||
exitCode: number | null;
|
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<string, ExplorerDirectory>;
|
||||||
|
files: Map<string, ExplorerFile>;
|
||||||
|
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 {
|
interface SessionContextValue {
|
||||||
// WebSocket
|
// WebSocket
|
||||||
@@ -119,6 +167,11 @@ interface SessionContextValue {
|
|||||||
gitDiffs: Map<string, string>;
|
gitDiffs: Map<string, string>;
|
||||||
requestGitDiff: (agentId: string) => void;
|
requestGitDiff: (agentId: string) => void;
|
||||||
|
|
||||||
|
// File explorer
|
||||||
|
fileExplorer: Map<string, AgentFileExplorerState>;
|
||||||
|
requestDirectoryListing: (agentId: string, path: string) => void;
|
||||||
|
requestFilePreview: (agentId: string, path: string) => void;
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
initializeAgent: (params: { agentId: string; requestId?: string }) => void;
|
||||||
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise<void>;
|
||||||
@@ -172,6 +225,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
const [agentUpdates, setAgentUpdates] = useState<Map<string, AgentUpdate[]>>(new Map());
|
const [agentUpdates, setAgentUpdates] = useState<Map<string, AgentUpdate[]>>(new Map());
|
||||||
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
|
const [pendingPermissions, setPendingPermissions] = useState<Map<string, PendingPermission>>(new Map());
|
||||||
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
|
const [gitDiffs, setGitDiffs] = useState<Map<string, string>>(new Map());
|
||||||
|
const [fileExplorer, setFileExplorer] = useState<Map<string, AgentFileExplorerState>>(new Map());
|
||||||
|
|
||||||
|
const updateExplorerState = useCallback(
|
||||||
|
(agentId: string, updater: (state: AgentFileExplorerState) => AgentFileExplorerState) => {
|
||||||
|
setFileExplorer((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const current = next.get(agentId) ?? createExplorerState();
|
||||||
|
next.set(agentId, updater(current));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// WebSocket message handlers
|
// WebSocket message handlers
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -613,6 +679,47 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unsubFileExplorer = ws.on("file_explorer_response", (message) => {
|
||||||
|
if (message.type !== "file_explorer_response") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { agentId, directory, file, mode, error } = message.payload;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"[Session] File explorer response for agent:",
|
||||||
|
agentId,
|
||||||
|
mode,
|
||||||
|
error ? `(error: ${error})` : ""
|
||||||
|
);
|
||||||
|
|
||||||
|
updateExplorerState(agentId, (state) => {
|
||||||
|
const nextState: AgentFileExplorerState = {
|
||||||
|
...state,
|
||||||
|
isLoading: false,
|
||||||
|
lastError: error ?? null,
|
||||||
|
pendingRequest: null,
|
||||||
|
directories: state.directories,
|
||||||
|
files: state.files,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
if (mode === "list" && directory) {
|
||||||
|
const directories = new Map(state.directories);
|
||||||
|
directories.set(directory.path, directory);
|
||||||
|
nextState.directories = directories;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "file" && file) {
|
||||||
|
const files = new Map(state.files);
|
||||||
|
files.set(file.path, file);
|
||||||
|
nextState.files = files;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubSessionState();
|
unsubSessionState();
|
||||||
unsubAgentCreated();
|
unsubAgentCreated();
|
||||||
@@ -626,8 +733,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
unsubChunk();
|
unsubChunk();
|
||||||
unsubTranscription();
|
unsubTranscription();
|
||||||
unsubGitDiff();
|
unsubGitDiff();
|
||||||
|
unsubFileExplorer();
|
||||||
};
|
};
|
||||||
}, [ws, audioPlayer, setIsPlayingAudio]);
|
}, [ws, audioPlayer, setIsPlayingAudio, updateExplorerState]);
|
||||||
|
|
||||||
const initializeAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
const initializeAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => {
|
||||||
setInitializingAgents((prev) => {
|
setInitializingAgents((prev) => {
|
||||||
@@ -808,6 +916,48 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
ws.send(msg);
|
ws.send(msg);
|
||||||
}, [ws]);
|
}, [ws]);
|
||||||
|
|
||||||
|
const requestDirectoryListing = useCallback((agentId: string, path: string) => {
|
||||||
|
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||||
|
updateExplorerState(agentId, (state) => ({
|
||||||
|
...state,
|
||||||
|
isLoading: true,
|
||||||
|
lastError: null,
|
||||||
|
pendingRequest: { path: normalizedPath, mode: "list" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const msg: WSInboundMessage = {
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "file_explorer_request",
|
||||||
|
agentId,
|
||||||
|
path: normalizedPath,
|
||||||
|
mode: "list",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ws.send(msg);
|
||||||
|
}, [updateExplorerState, ws]);
|
||||||
|
|
||||||
|
const requestFilePreview = useCallback((agentId: string, path: string) => {
|
||||||
|
const normalizedPath = path && path.length > 0 ? path : ".";
|
||||||
|
updateExplorerState(agentId, (state) => ({
|
||||||
|
...state,
|
||||||
|
isLoading: true,
|
||||||
|
lastError: null,
|
||||||
|
pendingRequest: { path: normalizedPath, mode: "file" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const msg: WSInboundMessage = {
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "file_explorer_request",
|
||||||
|
agentId,
|
||||||
|
path: normalizedPath,
|
||||||
|
mode: "file",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ws.send(msg);
|
||||||
|
}, [updateExplorerState, ws]);
|
||||||
|
|
||||||
const value: SessionContextValue = {
|
const value: SessionContextValue = {
|
||||||
ws,
|
ws,
|
||||||
audioPlayer,
|
audioPlayer,
|
||||||
@@ -831,6 +981,9 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
|
|||||||
setPendingPermissions,
|
setPendingPermissions,
|
||||||
gitDiffs,
|
gitDiffs,
|
||||||
requestGitDiff,
|
requestGitDiff,
|
||||||
|
fileExplorer,
|
||||||
|
requestDirectoryListing,
|
||||||
|
requestFilePreview,
|
||||||
initializeAgent,
|
initializeAgent,
|
||||||
sendAgentMessage,
|
sendAgentMessage,
|
||||||
sendAgentAudio,
|
sendAgentAudio,
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { promises as fs } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export type ExplorerEntryKind = "file" | "directory";
|
||||||
|
export type ExplorerFileKind = "text" | "image" | "binary";
|
||||||
|
export type ExplorerEncoding = "utf-8" | "base64" | "none";
|
||||||
|
|
||||||
|
export interface ListDirectoryParams {
|
||||||
|
root: string;
|
||||||
|
relativePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReadFileParams {
|
||||||
|
root: string;
|
||||||
|
relativePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileExplorerEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
kind: ExplorerEntryKind;
|
||||||
|
size: number;
|
||||||
|
modifiedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileExplorerDirectory {
|
||||||
|
path: string;
|
||||||
|
entries: FileExplorerEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileExplorerFile {
|
||||||
|
path: string;
|
||||||
|
kind: ExplorerFileKind;
|
||||||
|
encoding: ExplorerEncoding;
|
||||||
|
content?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
size: number;
|
||||||
|
modifiedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_EXTENSIONS = new Set([
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".js",
|
||||||
|
".jsx",
|
||||||
|
".json",
|
||||||
|
".md",
|
||||||
|
".txt",
|
||||||
|
".yml",
|
||||||
|
".yaml",
|
||||||
|
".css",
|
||||||
|
".scss",
|
||||||
|
".html",
|
||||||
|
".mjs",
|
||||||
|
".cjs",
|
||||||
|
".sh",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ScopedPathParams {
|
||||||
|
root: string;
|
||||||
|
relativePath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EntryPayloadParams {
|
||||||
|
root: string;
|
||||||
|
targetPath: string;
|
||||||
|
name: string;
|
||||||
|
kind: ExplorerEntryKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDirectoryEntries({
|
||||||
|
root,
|
||||||
|
relativePath = ".",
|
||||||
|
}: ListDirectoryParams): Promise<FileExplorerDirectory> {
|
||||||
|
const directoryPath = await resolveScopedPath({ root, relativePath });
|
||||||
|
const stats = await fs.stat(directoryPath);
|
||||||
|
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new Error("Requested path is not a directory");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dirents = await fs.readdir(directoryPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
const entries = await Promise.all(
|
||||||
|
dirents.map(async (dirent) => {
|
||||||
|
const targetPath = path.join(directoryPath, dirent.name);
|
||||||
|
const kind: ExplorerEntryKind = dirent.isDirectory()
|
||||||
|
? "directory"
|
||||||
|
: "file";
|
||||||
|
return buildEntryPayload({ root, targetPath, name: dirent.name, kind });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (a.kind !== b.kind) {
|
||||||
|
return a.kind === "directory" ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: normalizeRelativePath({ root, targetPath: directoryPath }),
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath,
|
||||||
|
}: ReadFileParams): Promise<FileExplorerFile> {
|
||||||
|
const filePath = await resolveScopedPath({ root, relativePath });
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
throw new Error("Requested path is not a file");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const basePayload = {
|
||||||
|
path: normalizeRelativePath({ root, targetPath: filePath }),
|
||||||
|
size: stats.size,
|
||||||
|
modifiedAt: stats.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (TEXT_EXTENSIONS.has(ext)) {
|
||||||
|
const content = await fs.readFile(filePath, "utf-8");
|
||||||
|
return {
|
||||||
|
...basePayload,
|
||||||
|
kind: "text",
|
||||||
|
encoding: "utf-8",
|
||||||
|
content,
|
||||||
|
mimeType: "text/plain",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ext in IMAGE_MIME_TYPES) {
|
||||||
|
const buffer = await fs.readFile(filePath);
|
||||||
|
return {
|
||||||
|
...basePayload,
|
||||||
|
kind: "image",
|
||||||
|
encoding: "base64",
|
||||||
|
content: buffer.toString("base64"),
|
||||||
|
mimeType: IMAGE_MIME_TYPES[ext],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...basePayload,
|
||||||
|
kind: "binary",
|
||||||
|
encoding: "none",
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveScopedPath({
|
||||||
|
root,
|
||||||
|
relativePath = ".",
|
||||||
|
}: ScopedPathParams): Promise<string> {
|
||||||
|
const normalizedRoot = path.resolve(root);
|
||||||
|
const requestedPath = path.resolve(normalizedRoot, relativePath);
|
||||||
|
const relative = path.relative(normalizedRoot, requestedPath);
|
||||||
|
|
||||||
|
if (
|
||||||
|
relative === "" ||
|
||||||
|
(!relative.startsWith("..") && !path.isAbsolute(relative))
|
||||||
|
) {
|
||||||
|
return requestedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Access outside of agent workspace is not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildEntryPayload({
|
||||||
|
root,
|
||||||
|
targetPath,
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
}: EntryPayloadParams): Promise<FileExplorerEntry> {
|
||||||
|
const stats = await fs.stat(targetPath);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: normalizeRelativePath({ root, targetPath }),
|
||||||
|
kind,
|
||||||
|
size: stats.size,
|
||||||
|
modifiedAt: stats.mtime.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath({
|
||||||
|
root,
|
||||||
|
targetPath,
|
||||||
|
}: {
|
||||||
|
root: string;
|
||||||
|
targetPath: string;
|
||||||
|
}): string {
|
||||||
|
const normalizedRoot = path.resolve(root);
|
||||||
|
const normalizedTarget = path.resolve(targetPath);
|
||||||
|
const relative = path.relative(normalizedRoot, normalizedTarget);
|
||||||
|
return relative === "" ? "." : relative.split(path.sep).join("/");
|
||||||
|
}
|
||||||
@@ -127,6 +127,36 @@ export const GitDiffRequestSchema = z.object({
|
|||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const FileExplorerEntrySchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
kind: z.enum(["file", "directory"]),
|
||||||
|
size: z.number(),
|
||||||
|
modifiedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FileExplorerFileSchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
kind: z.enum(["text", "image", "binary"]),
|
||||||
|
encoding: z.enum(["utf-8", "base64", "none"]),
|
||||||
|
content: z.string().optional(),
|
||||||
|
mimeType: z.string().optional(),
|
||||||
|
size: z.number(),
|
||||||
|
modifiedAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const FileExplorerDirectorySchema = z.object({
|
||||||
|
path: z.string(),
|
||||||
|
entries: z.array(FileExplorerEntrySchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileExplorerRequestSchema = z.object({
|
||||||
|
type: z.literal("file_explorer_request"),
|
||||||
|
agentId: z.string(),
|
||||||
|
path: z.string().optional(),
|
||||||
|
mode: z.enum(["list", "file"]),
|
||||||
|
});
|
||||||
|
|
||||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
UserTextMessageSchema,
|
UserTextMessageSchema,
|
||||||
RealtimeAudioChunkMessageSchema,
|
RealtimeAudioChunkMessageSchema,
|
||||||
@@ -143,6 +173,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SetAgentModeMessageSchema,
|
SetAgentModeMessageSchema,
|
||||||
AgentPermissionResponseMessageSchema,
|
AgentPermissionResponseMessageSchema,
|
||||||
GitDiffRequestSchema,
|
GitDiffRequestSchema,
|
||||||
|
FileExplorerRequestSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
||||||
@@ -336,6 +367,18 @@ export const GitDiffResponseSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const FileExplorerResponseSchema = z.object({
|
||||||
|
type: z.literal("file_explorer_response"),
|
||||||
|
payload: z.object({
|
||||||
|
agentId: z.string(),
|
||||||
|
path: z.string(),
|
||||||
|
mode: z.enum(["list", "file"]),
|
||||||
|
directory: FileExplorerDirectorySchema.nullable(),
|
||||||
|
file: FileExplorerFileSchema.nullable(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
ActivityLogMessageSchema,
|
ActivityLogMessageSchema,
|
||||||
AssistantChunkMessageSchema,
|
AssistantChunkMessageSchema,
|
||||||
@@ -354,6 +397,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
AgentPermissionRequestMessageSchema,
|
AgentPermissionRequestMessageSchema,
|
||||||
AgentPermissionResolvedMessageSchema,
|
AgentPermissionResolvedMessageSchema,
|
||||||
GitDiffResponseSchema,
|
GitDiffResponseSchema,
|
||||||
|
FileExplorerResponseSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type SessionOutboundMessage = z.infer<
|
export type SessionOutboundMessage = z.infer<
|
||||||
@@ -392,6 +436,8 @@ export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
|
|||||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||||
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
export type GitDiffRequest = z.infer<typeof GitDiffRequestSchema>;
|
||||||
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
export type GitDiffResponse = z.infer<typeof GitDiffResponseSchema>;
|
||||||
|
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||||
|
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WebSocket Level Messages (wraps session messages)
|
// WebSocket Level Messages (wraps session messages)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
SessionInboundMessage,
|
SessionInboundMessage,
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
|
FileExplorerRequest,
|
||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
import { getSystemPrompt } from "./agent/system-prompt.js";
|
import { getSystemPrompt } from "./agent/system-prompt.js";
|
||||||
import { getAllTools } from "./agent/llm-openai.js";
|
import { getAllTools } from "./agent/llm-openai.js";
|
||||||
@@ -35,6 +36,10 @@ import {
|
|||||||
isTitleGeneratorInitialized,
|
isTitleGeneratorInitialized,
|
||||||
} from "../services/agent-title-generator.js";
|
} from "../services/agent-title-generator.js";
|
||||||
import { expandTilde } from "./terminal-mcp/tmux.js";
|
import { expandTilde } from "./terminal-mcp/tmux.js";
|
||||||
|
import {
|
||||||
|
listDirectoryEntries,
|
||||||
|
readExplorerFile,
|
||||||
|
} from "./file-explorer/service.js";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -522,6 +527,10 @@ export class Session {
|
|||||||
case "git_diff_request":
|
case "git_diff_request":
|
||||||
await this.handleGitDiffRequest(msg.agentId);
|
await this.handleGitDiffRequest(msg.agentId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "file_explorer_request":
|
||||||
|
await this.handleFileExplorerRequest(msg);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -1076,6 +1085,91 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle read-only file explorer requests scoped to an agent's cwd
|
||||||
|
*/
|
||||||
|
private async handleFileExplorerRequest(
|
||||||
|
request: FileExplorerRequest
|
||||||
|
): Promise<void> {
|
||||||
|
const { agentId, path: requestedPath = ".", mode } = request;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Handling file explorer request for agent ${agentId} (${mode} ${requestedPath})`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agents = this.agentManager.listAgents();
|
||||||
|
const agent = agents.find((a) => a.id === agentId);
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
this.emit({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
path: requestedPath,
|
||||||
|
mode,
|
||||||
|
directory: null,
|
||||||
|
file: null,
|
||||||
|
error: `Agent not found: ${agentId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "list") {
|
||||||
|
const directory = await listDirectoryEntries({
|
||||||
|
root: agent.cwd,
|
||||||
|
relativePath: requestedPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
path: directory.path,
|
||||||
|
mode,
|
||||||
|
directory,
|
||||||
|
file: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const file = await readExplorerFile({
|
||||||
|
root: agent.cwd,
|
||||||
|
relativePath: requestedPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
path: file.path,
|
||||||
|
mode,
|
||||||
|
directory: null,
|
||||||
|
file,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[Session ${this.clientId}] Failed to fulfill file explorer request for agent ${agentId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "file_explorer_response",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
path: requestedPath,
|
||||||
|
mode,
|
||||||
|
directory: null,
|
||||||
|
file: null,
|
||||||
|
error: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send current session state (live agents and commands) to client
|
* Send current session state (live agents and commands) to client
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user