feat(file-explorer): add file download with share sheet support

This commit is contained in:
Mohamed Boudra
2026-01-01 13:07:51 +07:00
parent 57a86d011a
commit 63247f73b3
9 changed files with 471 additions and 105 deletions
+1
View File
@@ -39,6 +39,7 @@
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.13",
"expo-sharing": "^14.0.8",
"expo-splash-screen": "~31.0.10",
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7",
+372 -67
View File
@@ -1,12 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ActivityIndicator,
Alert,
FlatList,
Image as RNImage,
LayoutChangeEvent,
ListRenderItemInfo,
ViewToken,
NativeScrollEvent,
NativeSyntheticEvent,
Modal,
Platform,
Pressable,
ScrollView,
Text,
@@ -17,21 +21,23 @@ import {
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { router, useFocusEffect, useLocalSearchParams } from "expo-router";
import * as Clipboard from "expo-clipboard";
import * as FileSystem from "expo-file-system";
import * as Sharing from "expo-sharing";
import {
Copy,
Check,
File,
FileText,
Folder,
Image as ImageIcon,
LayoutGrid,
List as ListIcon,
MoreVertical,
X,
} from "lucide-react-native";
import { BackHeader } from "@/components/headers/back-header";
import type { ExplorerEntry } from "@/stores/session-store";
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import type { DaemonProfile } from "@/contexts/daemon-registry-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useSessionStore } from "@/stores/session-store";
@@ -109,6 +115,8 @@ function FileExplorerContent({
fileParamRaw,
}: FileExplorerContentProps) {
const { theme } = useUnistyles();
const { connectionStates } = useDaemonConnections();
const daemonProfile = connectionStates.get(serverId)?.daemon;
const agent = useSessionStore((state) =>
agentId && state.sessions[serverId]
@@ -125,18 +133,17 @@ function FileExplorerContent({
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, setViewMode] = useState<"list" | "grid">("list");
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
const pendingPathParamRef = useRef<string | null>(null);
const pendingFileParamRef = useRef<string | null>(null);
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const listScrollRef = useRef<FlatList<ExplorerEntry> | null>(null);
const listScrollOffsetRef = useRef(0);
const scrollOffsetsByPathRef = useRef<Map<string, number>>(new Map());
const pendingScrollRestoreRef = useRef<number | null>(null);
const { width: windowWidth } = useWindowDimensions();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const normalizedPathParam = normalizePathParam(getFirstParam(pathParamRaw));
const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw));
@@ -196,6 +203,60 @@ function FileExplorerContent({
}, [windowWidth]);
const listColumns = viewMode === "grid" ? gridColumnCount : 1;
const listKey = viewMode === "grid" ? `grid-${gridColumnCount}` : "list";
const [menuEntry, setMenuEntry] = useState<ExplorerEntry | null>(null);
const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 });
const [menuHeight, setMenuHeight] = useState(0);
const agentIdRef = useRef(agentId);
const viewModeRef = useRef(viewMode);
const requestFilePreviewRef = useRef(requestFilePreview);
const explorerFilesRef = useRef(explorerState?.files);
useEffect(() => {
agentIdRef.current = agentId;
}, [agentId]);
useEffect(() => {
viewModeRef.current = viewMode;
}, [viewMode]);
useEffect(() => {
requestFilePreviewRef.current = requestFilePreview;
}, [requestFilePreview]);
useEffect(() => {
explorerFilesRef.current = explorerState?.files;
}, [explorerState?.files]);
const handleViewableItemsChangedRef = useRef(
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
const currentAgentId = agentIdRef.current;
const currentViewMode = viewModeRef.current;
const currentRequestFilePreview = requestFilePreviewRef.current;
if (!currentAgentId || currentViewMode !== "grid" || !currentRequestFilePreview) {
return;
}
viewableItems.forEach((token) => {
const item = token.item as ExplorerEntry | undefined;
if (!item) {
return;
}
if (getEntryDisplayKind(item) !== "image") {
return;
}
const hasPreview = explorerFilesRef.current?.get(item.path);
if (hasPreview || pendingThumbnailPathsRef.current.has(item.path)) {
return;
}
pendingThumbnailPathsRef.current.add(item.path);
setThumbnailLoadingMap((prev) => ({ ...prev, [item.path]: true }));
currentRequestFilePreview(currentAgentId, item.path);
});
}
);
const restoreQueuedScrollOffset = useCallback(() => {
if (pendingScrollRestoreRef.current === null) {
@@ -236,10 +297,6 @@ function FileExplorerContent({
queueScrollRestore(savedOffset);
}, [activePath, queueScrollRestore]);
useEffect(() => {
setCopiedPath(null);
}, [activePath]);
useEffect(() => {
if (!agentId || !initialTargetDirectory || !requestDirectoryListing) {
pendingPathParamRef.current = null;
@@ -303,24 +360,103 @@ function FileExplorerContent({
const handleCopyPath = useCallback(async (path: string) => {
await Clipboard.setStringAsync(path);
setCopiedPath(path);
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
copyTimeoutRef.current = setTimeout(() => {
setCopiedPath(null);
copyTimeoutRef.current = null;
}, 1500);
}, []);
useEffect(() => {
return () => {
if (copyTimeoutRef.current) {
clearTimeout(copyTimeoutRef.current);
}
};
const handleOpenMenu = useCallback((entry: ExplorerEntry, event: any) => {
event.stopPropagation();
const { pageX, pageY } = event.nativeEvent ?? {};
setMenuAnchor({
left: typeof pageX === "number" ? pageX : 0,
top: typeof pageY === "number" ? pageY : 0,
});
setMenuEntry(entry);
}, []);
const handleCloseMenu = useCallback(() => {
setMenuEntry(null);
setMenuHeight(0);
}, []);
const handleMenuLayout = useCallback((event: LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
setMenuHeight((current) => (current === height ? current : height));
}, []);
const handleDownloadEntry = useCallback(
async (entry: ExplorerEntry) => {
if (!agentId || !requestFileDownloadToken || entry.kind !== "file") {
return;
}
try {
const tokenResponse = await requestFileDownloadToken(agentId, entry.path);
if (tokenResponse.error || !tokenResponse.token) {
throw new Error(tokenResponse.error ?? "Failed to request download token.");
}
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
if (!downloadTarget.baseUrl) {
throw new Error("Download host is unavailable.");
}
const fileName = tokenResponse.fileName ?? entry.name;
const downloadUrl = buildDownloadUrl(
downloadTarget.baseUrl,
tokenResponse.token,
Platform.OS === "web" ? downloadTarget.authCredentials : null
);
if (Platform.OS === "web") {
triggerBrowserDownload(downloadUrl, fileName);
return;
}
const targetUri = await resolveDownloadTargetUri(fileName);
const downloadResult = await FileSystem.downloadAsync(
downloadUrl,
targetUri,
downloadTarget.authHeader
? { headers: { Authorization: downloadTarget.authHeader } }
: undefined
);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(downloadResult.uri, {
mimeType: tokenResponse.mimeType ?? undefined,
dialogTitle: fileName ? `Share ${fileName}` : "Share file",
});
} else {
Alert.alert("Download complete", `Saved to ${downloadResult.uri}`);
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to download file.";
if (Platform.OS === "web") {
console.warn("[FileExplorer] Download failed:", message);
return;
}
Alert.alert("Download failed", message);
}
},
[agentId, daemonProfile, requestFileDownloadToken]
);
const menuPosition = useMemo(() => {
if (!menuEntry) {
return null;
}
const menuWidth = 180;
const horizontalPadding = theme.spacing[2];
const verticalPadding = theme.spacing[2];
const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding);
const maxTop = Math.max(verticalPadding, windowHeight - menuHeight - verticalPadding);
const left = Math.min(
Math.max(menuAnchor.left - menuWidth + horizontalPadding, horizontalPadding),
maxLeft
);
const top = Math.min(Math.max(menuAnchor.top + verticalPadding, verticalPadding), maxTop);
return { top, left, width: menuWidth };
}, [menuEntry, menuAnchor.left, menuAnchor.top, menuHeight, theme.spacing, windowHeight, windowWidth]);
const handleListScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offset = event.nativeEvent.contentOffset.y;
@@ -342,6 +478,13 @@ function FileExplorerContent({
router.back();
}, [agentId, serverId]);
const handleRetryDirectory = useCallback(() => {
if (!agentId || !requestDirectoryListing) {
return;
}
requestDirectoryListing(agentId, activePath);
}, [agentId, requestDirectoryListing, activePath]);
const handleBackNavigation = useCallback(() => {
if (!agentId) {
router.back();
@@ -429,27 +572,19 @@ function FileExplorerContent({
</View>
</View>
<Pressable
onPress={(event) => {
event.stopPropagation();
handleCopyPath(item.path);
}}
onPress={(event) => handleOpenMenu(item, event)}
hitSlop={8}
style={styles.copyButton}
style={styles.menuButton}
>
{copiedPath === item.path ? (
<Check size={16} color={theme.colors.primary} />
) : (
<Copy size={16} color={theme.colors.foreground} />
)}
<MoreVertical size={16} color={theme.colors.foreground} />
</Pressable>
</Pressable>
);
},
[
copiedPath,
explorerState?.files,
handleCopyPath,
handleEntryPress,
handleOpenMenu,
theme.colors,
thumbnailLoadingMap,
viewMode,
@@ -474,35 +609,6 @@ function FileExplorerContent({
);
}, [activePath, showListLoadingBanner, viewMode]);
const handleViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: Array<ViewToken> }) => {
if (!agentId || viewMode !== "grid" || !requestFilePreview) {
return;
}
viewableItems.forEach((token) => {
const item = token.item as ExplorerEntry | undefined;
if (!item) {
return;
}
if (getEntryDisplayKind(item) !== "image") {
return;
}
const hasPreview = explorerState?.files.get(item.path);
if (hasPreview || pendingThumbnailPathsRef.current.has(item.path)) {
return;
}
pendingThumbnailPathsRef.current.add(item.path);
setThumbnailLoadingMap((prev) => ({ ...prev, [item.path]: true }));
requestFilePreview(agentId, item.path);
});
},
[agentId, explorerState?.files, requestFilePreview, viewMode]
);
useEffect(() => {
if (!explorerState) {
return;
@@ -607,6 +713,9 @@ function FileExplorerContent({
{error ? (
<View style={styles.centerState}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={handleRetryDirectory}>
<Text style={styles.retryButtonText}>Retry</Text>
</Pressable>
</View>
) : showInitialListLoading ? (
<View style={styles.centerState}>
@@ -638,17 +747,63 @@ function FileExplorerContent({
onLayout={restoreQueuedScrollOffset}
onContentSizeChange={restoreQueuedScrollOffset}
ListHeaderComponent={listHeaderComponent}
extraData={{ copiedPath, viewMode, thumbnailLoadingMap }}
extraData={{ viewMode, thumbnailLoadingMap }}
initialNumToRender={20}
maxToRenderPerBatch={30}
windowSize={10}
onViewableItemsChanged={handleViewableItemsChanged}
onViewableItemsChanged={handleViewableItemsChangedRef.current}
viewabilityConfig={viewabilityConfigRef.current}
/>
)}
</View>
)}
</View>
<Modal
visible={Boolean(menuEntry)}
animationType="fade"
transparent={true}
onRequestClose={handleCloseMenu}
>
<View style={styles.menuOverlay}>
<Pressable style={styles.menuBackdrop} onPress={handleCloseMenu} />
{menuEntry && menuPosition ? (
<View
style={[
styles.entryMenu,
{
position: "absolute",
top: menuPosition.top,
left: menuPosition.left,
width: menuPosition.width,
},
]}
onLayout={handleMenuLayout}
>
<Pressable
style={styles.entryMenuItem}
onPress={() => {
handleCopyPath(menuEntry.path);
handleCloseMenu();
}}
>
<Text style={styles.entryMenuText}>Copy Path</Text>
</Pressable>
{menuEntry.kind === "file" ? (
<Pressable
style={styles.entryMenuItem}
onPress={async () => {
handleCloseMenu();
await handleDownloadEntry(menuEntry);
}}
>
<Text style={styles.entryMenuText}>Download</Text>
</Pressable>
) : null}
</View>
) : null}
</View>
</Modal>
</View>
);
}
@@ -898,6 +1053,120 @@ function getExtension(name: string): string | null {
return name.slice(index + 1).toLowerCase();
}
type DownloadTarget = {
baseUrl: string | null;
authHeader: string | null;
authCredentials: { username: string; password: string } | null;
};
function resolveDaemonDownloadTarget(daemon?: DaemonProfile): DownloadTarget {
const rawUrl = daemon?.restUrl ?? daemon?.wsUrl;
if (!rawUrl) {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
return { baseUrl: null, authHeader: null, authCredentials: null };
}
if (parsed.protocol === "ws:") {
parsed.protocol = "http:";
} else if (parsed.protocol === "wss:") {
parsed.protocol = "https:";
}
let authCredentials: { username: string; password: string } | null = null;
if (parsed.username || parsed.password) {
authCredentials = {
username: decodeURIComponent(parsed.username),
password: decodeURIComponent(parsed.password),
};
parsed.username = "";
parsed.password = "";
}
parsed.pathname = parsed.pathname.replace(/\/ws\/?$/, "/");
const baseUrl = parsed.origin;
const authHeader = authCredentials
? `Basic ${btoa(`${authCredentials.username}:${authCredentials.password}`)}`
: null;
return { baseUrl, authHeader, authCredentials };
}
function buildDownloadUrl(
baseUrl: string,
token: string,
authCredentials: { username: string; password: string } | null
): string {
const url = new URL("/api/files/download", baseUrl);
url.searchParams.set("token", token);
if (authCredentials) {
url.username = authCredentials.username;
url.password = authCredentials.password;
}
return url.toString();
}
function triggerBrowserDownload(url: string, fileName: string) {
if (typeof document === "undefined") {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener");
}
return;
}
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
}
async function resolveDownloadTargetUri(fileName: string): Promise<string> {
const directory = FileSystem.Paths.cache?.uri ?? FileSystem.Paths.document?.uri;
if (!directory) {
throw new Error("No download directory available.");
}
const safeName = sanitizeDownloadFileName(fileName);
const split = splitFileName(safeName);
let targetUri = `${directory}${safeName}`;
let suffix = 1;
while ((await FileSystem.getInfoAsync(targetUri)).exists) {
targetUri = `${directory}${split.base} (${suffix})${split.ext}`;
suffix += 1;
}
return targetUri;
}
function sanitizeDownloadFileName(fileName: string): string {
const trimmed = fileName.trim();
if (!trimmed) {
return "download";
}
return trimmed.replace(/[\\/:*?"<>|]+/g, "_");
}
function splitFileName(fileName: string): { base: string; ext: string } {
const lastDot = fileName.lastIndexOf(".");
if (lastDot <= 0) {
return { base: fileName, ext: "" };
}
return {
base: fileName.slice(0, lastDot),
ext: fileName.slice(lastDot),
};
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
@@ -962,6 +1231,18 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
textAlign: "center",
},
retryButton: {
borderRadius: theme.borderRadius.full,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.primary,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
},
retryButtonText: {
color: theme.colors.primary,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
statusText: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
@@ -1037,13 +1318,37 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
marginTop: theme.spacing[1],
},
copyButton: {
menuButton: {
width: 36,
height: 36,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
},
menuOverlay: {
flex: 1,
},
menuBackdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.2)",
},
entryMenu: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.card,
padding: theme.spacing[1],
},
entryMenuItem: {
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
entryMenuText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
viewToggleContainer: {
flexDirection: "row",
borderRadius: theme.borderRadius.full,
@@ -60,6 +60,11 @@ type FileExplorerResponseMessage = Extract<
{ type: "file_explorer_response" }
>;
type FileDownloadTokenResponseMessage = Extract<
SessionOutboundMessage,
{ type: "file_download_token_response" }
>;
type StatusMessage = Extract<SessionOutboundMessage, { type: "status" }>;
const SESSION_SNAPSHOT_STORAGE_PREFIX = "@paseo:session-snapshot:";
@@ -173,6 +178,7 @@ export interface SessionContextValue {
requestGitDiff: (agentId: string) => void;
requestDirectoryListing: (agentId: string, path: string, options?: { recordHistory?: boolean }) => void;
requestFilePreview: (agentId: string, path: string) => void;
requestFileDownloadToken: (agentId: string, path: string) => Promise<FileDownloadTokenResponseMessage["payload"]>;
navigateExplorerBack: (agentId: string) => string | null;
requestProviderModels: (provider: any, options?: { cwd?: string }) => void;
restartServer: (reason?: string) => void;
@@ -503,6 +509,40 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
keepPreviousData: false,
});
const fileDownloadTokenRequest = useDaemonRequest<
{ agentId: string; path: string },
FileDownloadTokenResponseMessage["payload"],
FileDownloadTokenResponseMessage
>({
ws,
responseType: "file_download_token_response",
buildRequest: ({ params, requestId }) => ({
type: "session",
message: {
type: "file_download_token_request",
agentId: params?.agentId ?? "",
path: params?.path ?? "",
requestId,
},
}),
matchResponse: (message, context) => {
if (message.payload.requestId) {
return message.payload.requestId === context.requestId;
}
return (
message.payload.agentId === context.params?.agentId &&
message.payload.path === context.params?.path
);
},
getRequestKey: (params) =>
params ? `${params.agentId}:download:${params.path}` : "default",
selectData: (message) => message.payload,
extractError: (message) =>
message.payload.error ? new Error(message.payload.error) : null,
timeoutMs: 10000,
keepPreviousData: false,
});
const initializeAgentRpc = useSessionRpc({
ws,
requestType: "initialize_agent_request",
@@ -1635,6 +1675,13 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
});
}, [filePreviewRequest, updateExplorerState]);
const requestFileDownloadToken = useCallback(
(agentId: string, path: string) => {
return fileDownloadTokenRequest.execute({ agentId, path });
},
[fileDownloadTokenRequest]
);
const navigateExplorerBack = useCallback((agentId: string) => {
let targetPath: string | null = null;
@@ -1721,6 +1768,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
requestGitDiff,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
navigateExplorerBack,
requestProviderModels,
restartServer,
@@ -1744,6 +1792,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
requestGitDiff,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
navigateExplorerBack,
requestProviderModels,
restartServer,
@@ -1769,6 +1818,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
requestGitDiff,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
navigateExplorerBack,
requestProviderModels,
restartServer,
@@ -1788,6 +1838,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid
requestGitDiff,
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
navigateExplorerBack,
requestProviderModels,
restartServer,
+2 -1
View File
@@ -18,7 +18,7 @@ import type {
AgentUsage,
AgentPersistenceHandle,
} from "@server/server/agent/agent-sdk-types";
import type { GitSetupOptions } from "@server/server/messages";
import type { FileDownloadTokenResponse, GitSetupOptions } from "@server/server/messages";
import { isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
// Re-export types that were in session-context
@@ -177,6 +177,7 @@ export interface SessionState {
requestGitDiff: (agentId: string) => void;
requestDirectoryListing: (agentId: string, path: string, options?: { recordHistory?: boolean }) => void;
requestFilePreview: (agentId: string, path: string) => void;
requestFileDownloadToken: (agentId: string, path: string) => Promise<FileDownloadTokenResponse["payload"]>;
navigateExplorerBack: (agentId: string) => string | null;
requestProviderModels: (provider: any, options?: { cwd?: string }) => void;
restartServer: (reason?: string) => void;
+3
View File
@@ -89,6 +89,9 @@ export async function createPaseoDaemon(
realm: authRealm,
});
app.use((req, res, next) => {
if (req.path === "/api/files/download") {
return next();
}
if (agentMcpBearerToken && req.path.startsWith(agentMcpRoute)) {
const authHeader = req.header("authorization") ?? "";
if (authHeader.startsWith("Bearer ")) {
@@ -56,6 +56,12 @@ const TEXT_EXTENSIONS = new Set([
".sh",
]);
const TEXT_MIME_TYPES: Record<string, string> = {
".json": "application/json",
};
const DEFAULT_TEXT_MIME_TYPE = "text/plain";
const IMAGE_MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
@@ -140,7 +146,7 @@ export async function readExplorerFile({
kind: "text",
encoding: "utf-8",
content,
mimeType: "text/plain",
mimeType: TEXT_MIME_TYPES[ext] ?? DEFAULT_TEXT_MIME_TYPE,
};
}
@@ -182,7 +188,7 @@ export async function getDownloadableFileInfo({
const ext = path.extname(filePath).toLowerCase();
const mimeType = TEXT_EXTENSIONS.has(ext)
? "text/plain"
? TEXT_MIME_TYPES[ext] ?? DEFAULT_TEXT_MIME_TYPE
: ext in IMAGE_MIME_TYPES
? IMAGE_MIME_TYPES[ext]
: "application/octet-stream";
+2
View File
@@ -487,6 +487,7 @@ export const FileDownloadTokenRequestSchema = z.object({
type: z.literal("file_download_token_request"),
agentId: z.string(),
path: z.string(),
requestId: z.string().optional(),
});
export const ClearAgentAttentionMessageSchema = z.object({
@@ -761,6 +762,7 @@ export const FileDownloadTokenResponseSchema = z.object({
mimeType: z.string().nullable(),
size: z.number().nullable(),
error: z.string().nullable(),
requestId: z.string().optional(),
}),
});
+4 -1
View File
@@ -2098,7 +2098,7 @@ export class Session {
private async handleFileDownloadTokenRequest(
request: FileDownloadTokenRequest
): Promise<void> {
const { agentId, path: requestedPath } = request;
const { agentId, path: requestedPath, requestId } = request;
console.log(
`[Session ${this.clientId}] Handling file download token request for agent ${agentId} (${requestedPath})`
@@ -2119,6 +2119,7 @@ export class Session {
mimeType: null,
size: null,
error: `Agent not found: ${agentId}`,
...(requestId ? { requestId } : {}),
},
});
return;
@@ -2148,6 +2149,7 @@ export class Session {
mimeType: entry.mimeType,
size: entry.size,
error: null,
...(requestId ? { requestId } : {}),
},
});
} catch (error: any) {
@@ -2165,6 +2167,7 @@ export class Session {
mimeType: null,
size: null,
error: error.message,
...(requestId ? { requestId } : {}),
},
});
}