From 6f278cb6a60e9838b2f17c73c45316fcdf57bfd8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 9 Jan 2026 11:05:20 +0700 Subject: [PATCH] refactor: remove old git-diff and file-explorer screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete standalone /git-diff and /file-explorer routes - Update agent menu to open explorer sidebar instead of navigating - Update file path clicks in chat to open sidebar - Fix unnecessary re-renders in sidebar panes by using agents.has() instead of agents.get() 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .tasks/5f407d28.md | 2 +- packages/app/src/app/_layout.tsx | 2 - .../src/app/agent/[serverId]/[agentId].tsx | 28 +- packages/app/src/app/file-explorer.tsx | 1802 ----------------- packages/app/src/app/git-diff.tsx | 399 ---- .../app/src/components/agent-stream-view.tsx | 25 +- .../app/src/components/file-explorer-pane.tsx | 8 +- packages/app/src/components/git-diff-pane.tsx | 6 +- 8 files changed, 21 insertions(+), 2251 deletions(-) delete mode 100644 packages/app/src/app/file-explorer.tsx delete mode 100644 packages/app/src/app/git-diff.tsx diff --git a/.tasks/5f407d28.md b/.tasks/5f407d28.md index 92adedbff..b13ad8813 100644 --- a/.tasks/5f407d28.md +++ b/.tasks/5f407d28.md @@ -1,7 +1,7 @@ --- id: 5f407d28 title: Remove old git diff and file browser screens - keep only file explorer -status: open +status: done deps: [] created: 2026-01-08T16:21:35.362Z --- diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index d623ac4a5..0feabcd57 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -270,8 +270,6 @@ export default function RootLayout() { - - diff --git a/packages/app/src/app/agent/[serverId]/[agentId].tsx b/packages/app/src/app/agent/[serverId]/[agentId].tsx index 99c984a77..938b27c94 100644 --- a/packages/app/src/app/agent/[serverId]/[agentId].tsx +++ b/packages/app/src/app/agent/[serverId]/[agentId].tsx @@ -192,7 +192,7 @@ function AgentScreenContent({ addImagesRef.current = addImages; }, []); - const { isOpen: isExplorerOpen, toggle: toggleExplorer, open: openExplorer, close: closeExplorer } = useExplorerSidebarStore(); + const { isOpen: isExplorerOpen, toggle: toggleExplorer, open: openExplorer, close: closeExplorer, setActiveTab: setExplorerTab } = useExplorerSidebarStore(); const { translateX: explorerTranslateX, backdropOpacity: explorerBackdropOpacity, @@ -580,29 +580,15 @@ function AgentScreenContent({ const handleViewChanges = useCallback(() => { handleCloseMenu(); - if (resolvedAgentId) { - router.push({ - pathname: "/git-diff", - params: { - agentId: resolvedAgentId, - serverId: serverId, - }, - }); - } - }, [resolvedAgentId, serverId, router, handleCloseMenu]); + setExplorerTab("changes"); + openExplorer(); + }, [handleCloseMenu, setExplorerTab, openExplorer]); const handleBrowseFiles = useCallback(() => { handleCloseMenu(); - if (resolvedAgentId) { - router.push({ - pathname: "/file-explorer", - params: { - agentId: resolvedAgentId, - serverId: serverId, - }, - }); - } - }, [handleCloseMenu, resolvedAgentId, serverId, router]); + setExplorerTab("files"); + openExplorer(); + }, [handleCloseMenu, setExplorerTab, openExplorer]); const handleRefreshAgent = useCallback(() => { if (!resolvedAgentId || !refreshAgent) { diff --git a/packages/app/src/app/file-explorer.tsx b/packages/app/src/app/file-explorer.tsx deleted file mode 100644 index 672fa9753..000000000 --- a/packages/app/src/app/file-explorer.tsx +++ /dev/null @@ -1,1802 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - ActivityIndicator, - Alert, - FlatList, - Image as RNImage, - LayoutChangeEvent, - ListRenderItemInfo, - RefreshControl, - ViewToken, - NativeScrollEvent, - NativeSyntheticEvent, - Modal, - Platform, - Pressable, - ScrollView, - Text, - View, - BackHandler, - useWindowDimensions, -} from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { router, useFocusEffect, useLocalSearchParams } from "expo-router"; -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, - Download, - File, - FileText, - Folder, - Image as ImageIcon, - LayoutGrid, - List as ListIcon, - MoreVertical, - X, - XCircle, -} 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"; - -export default function FileExplorerScreen() { - const { - agentId, - path: pathParamRaw, - file: fileParamRaw, - serverId, - } = useLocalSearchParams<{ - agentId: string; - path?: string | string[]; - file?: string | string[]; - serverId?: string; - }>(); - const resolvedServerId = typeof serverId === "string" ? serverId : undefined; - const { connectionStates } = useDaemonConnections(); - - const session = useSessionStore((state) => - resolvedServerId ? state.sessions[resolvedServerId] : undefined - ); - - const connectionServerId = resolvedServerId ?? null; - const connection = connectionServerId ? connectionStates.get(connectionServerId) : null; - const serverLabel = connection?.daemon.label ?? connectionServerId ?? resolvedServerId ?? "Selected host"; - const connectionStatus = connection?.status ?? "idle"; - const connectionStatusLabel = formatConnectionStatus(connectionStatus); - const lastError = connection?.lastError ?? null; - - if (!session) { - return ( - - ); - } - - const routeServerId = resolvedServerId ?? session.serverId; - - return ( - - ); -} - -type FileExplorerContentProps = { - serverId: string; - agentId?: string; - pathParamRaw?: string | string[]; - fileParamRaw?: string | string[]; -}; - -type FileExplorerSessionUnavailableProps = { - agentId?: string; - serverId?: string; - serverLabel: string; - connectionStatus: ConnectionStatus; - connectionStatusLabel: string; - lastError: string | null; -}; - -function FileExplorerContent({ - serverId, - agentId, - pathParamRaw, - fileParamRaw, -}: FileExplorerContentProps) { - 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, setViewMode] = useState<"list" | "grid">("list"); - const [selectedEntryPath, setSelectedEntryPath] = useState(null); - const pendingPathParamRef = useRef(null); - const pendingFileParamRef = useRef(null); - const listScrollRef = useRef | null>(null); - const listScrollOffsetRef = useRef(0); - const scrollOffsetsByPathRef = useRef>(new Map()); - const pendingScrollRestoreRef = useRef(null); - const { width: windowWidth, height: windowHeight } = useWindowDimensions(); - - const normalizedPathParam = normalizePathParam(getFirstParam(pathParamRaw)); - const normalizedFileParam = normalizeFileParam(getFirstParam(fileParamRaw)); - const derivedDirectoryFromFile = normalizedFileParam - ? deriveDirectoryFromFile(normalizedFileParam) - : null; - const history = explorerState?.history ?? []; - const lastKnownDirectory = history[history.length - 1]; - const rememberedDirectory = explorerState?.lastVisitedPath; - const initialTargetDirectory = - normalizedPathParam ?? - derivedDirectoryFromFile ?? - rememberedDirectory ?? - lastKnownDirectory ?? - "."; - const currentPath = explorerState?.currentPath ?? "."; - const pendingRequest = explorerState?.pendingRequest ?? null; - const isExplorerLoading = explorerState?.isLoading ?? false; - const isListingLoading = Boolean( - isExplorerLoading && pendingRequest?.mode === "list" - ); - const pendingDirectoryPath = - isListingLoading && pendingRequest ? pendingRequest.path : null; - const activePath = pendingDirectoryPath ?? currentPath; - const directory = explorerState?.directories.get(activePath); - const entries = directory?.entries ?? []; - const showInitialListLoading = isListingLoading && entries.length === 0; - const showListLoadingBanner = isListingLoading && entries.length > 0; - const isPreviewLoading = Boolean( - isExplorerLoading && - pendingRequest?.mode === "file" && - pendingRequest?.path === selectedEntryPath - ); - const error = explorerState?.lastError ?? null; - const preview = selectedEntryPath - ? explorerState?.files.get(selectedEntryPath) - : null; - const shouldShowPreview = Boolean(selectedEntryPath); - const [thumbnailLoadingMap, setThumbnailLoadingMap] = useState>({}); - const viewabilityConfigRef = useRef({ - itemVisiblePercentThreshold: 10, - minimumViewTime: 0, - }); - - // Bottom sheet for file preview - const previewSheetRef = useRef(null); - const previewSnapPoints = useMemo(() => ["80%"], []); - - // Thumbnail queue state - allows up to MAX_CONCURRENT_THUMBNAILS in parallel - const MAX_CONCURRENT_THUMBNAILS = 2; - const thumbnailQueueRef = useRef([]); - const inFlightPathsRef = useRef>(new Set()); - const THUMBNAIL_TIMEOUT_MS = 15000; - const gridColumnCount = 2; - const listColumns = viewMode === "grid" ? gridColumnCount : 1; - const listKey = viewMode === "grid" ? `grid-${gridColumnCount}` : "list"; - const [menuEntry, setMenuEntry] = useState(null); - const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 }); - const [menuHeight, setMenuHeight] = useState(0); - const [isRefreshing, setIsRefreshing] = useState(false); - const [downloadToast, setDownloadToast] = useState<{ - status: "downloading" | "complete" | "error"; - fileName: string; - message?: string; - } | null>(null); - const downloadToastTimeoutRef = useRef | null>(null); - const agentIdRef = useRef(agentId); - const viewModeRef = useRef(viewMode); - const requestFilePreviewRef = useRef(requestFilePreview); - const explorerFilesRef = useRef(explorerState?.files); - const refreshPathRef = useRef(null); - const refreshStartedRef = useRef(false); - - useEffect(() => { - agentIdRef.current = agentId; - }, [agentId]); - - useEffect(() => { - viewModeRef.current = viewMode; - }, [viewMode]); - - useEffect(() => { - requestFilePreviewRef.current = requestFilePreview; - }, [requestFilePreview]); - - useEffect(() => { - explorerFilesRef.current = explorerState?.files; - }, [explorerState?.files]); - - // Process items from the thumbnail queue (up to MAX_CONCURRENT_THUMBNAILS in parallel) - const processNextThumbnail = useCallback(() => { - const currentAgentId = agentIdRef.current; - const currentRequestFilePreview = requestFilePreviewRef.current; - - if (!currentAgentId || !currentRequestFilePreview) { - return; - } - - // Fill up to max concurrent slots - while ( - inFlightPathsRef.current.size < MAX_CONCURRENT_THUMBNAILS && - thumbnailQueueRef.current.length > 0 - ) { - const path = thumbnailQueueRef.current.shift()!; - - // Skip if already loaded or already in flight - if (explorerFilesRef.current?.has(path) || inFlightPathsRef.current.has(path)) { - continue; - } - - inFlightPathsRef.current.add(path); - setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true })); - currentRequestFilePreview(currentAgentId, path); - - // Set up timeout to clean up stuck requests - setTimeout(() => { - if (inFlightPathsRef.current.has(path)) { - inFlightPathsRef.current.delete(path); - setThumbnailLoadingMap((prev) => { - const next = { ...prev }; - delete next[path]; - return next; - }); - processNextThumbnail(); - } - }, THUMBNAIL_TIMEOUT_MS); - } - }, []); - - // Enqueue a file preview request with optional priority - const enqueueFilePreview = useCallback( - (path: string, options?: { priority?: boolean }) => { - const currentAgentId = agentIdRef.current; - const currentRequestFilePreview = requestFilePreviewRef.current; - - if (!currentAgentId || !currentRequestFilePreview) { - return; - } - - // Already have this file cached - if (explorerFilesRef.current?.has(path)) { - return; - } - - if (options?.priority) { - // Priority request: clear queue entirely - thumbnailQueueRef.current = []; - - // If this path is already in flight, let it complete - if (inFlightPathsRef.current.has(path)) { - return; - } - - // Clear all in-flight thumbnails (their timeouts will clean up loading state) - if (inFlightPathsRef.current.size > 0) { - const abandonedPaths = Array.from(inFlightPathsRef.current); - setThumbnailLoadingMap((prev) => { - const next = { ...prev }; - abandonedPaths.forEach((p) => delete next[p]); - return next; - }); - inFlightPathsRef.current.clear(); - } - - // Fire immediately for priority requests - inFlightPathsRef.current.add(path); - setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true })); - currentRequestFilePreview(currentAgentId, path); - - // Set up timeout for priority requests too - setTimeout(() => { - if (inFlightPathsRef.current.has(path)) { - inFlightPathsRef.current.delete(path); - setThumbnailLoadingMap((prev) => { - const next = { ...prev }; - delete next[path]; - return next; - }); - processNextThumbnail(); - } - }, THUMBNAIL_TIMEOUT_MS); - - return; - } - - // Non-priority: add to queue if not already queued or in-flight - if ( - !thumbnailQueueRef.current.includes(path) && - !inFlightPathsRef.current.has(path) - ) { - thumbnailQueueRef.current.push(path); - processNextThumbnail(); - } - }, - [processNextThumbnail] - ); - - const handleViewableItemsChangedRef = useRef( - ({ viewableItems }: { viewableItems: Array }) => { - const currentViewMode = viewModeRef.current; - - if (currentViewMode !== "grid") { - return; - } - - viewableItems.forEach((token) => { - const item = token.item as ExplorerEntry | undefined; - if (!item || getEntryDisplayKind(item) !== "image") { - return; - } - enqueueFilePreviewRef.current?.(item.path); - }); - } - ); - - const enqueueFilePreviewRef = useRef(enqueueFilePreview); - useEffect(() => { - enqueueFilePreviewRef.current = enqueueFilePreview; - }, [enqueueFilePreview]); - - const restoreQueuedScrollOffset = useCallback(() => { - if (pendingScrollRestoreRef.current === null) { - return; - } - - if (!listScrollRef.current) { - return; - } - - const targetOffset = pendingScrollRestoreRef.current; - listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false }); - listScrollOffsetRef.current = targetOffset; - pendingScrollRestoreRef.current = null; - }, []); - - const queueScrollRestore = useCallback((offset: number) => { - pendingScrollRestoreRef.current = offset; - requestAnimationFrame(restoreQueuedScrollOffset); - }, [restoreQueuedScrollOffset]); - - useEffect(() => { - setSelectedEntryPath(null); - }, [activePath]); - - // Open/close preview sheet based on selection - useEffect(() => { - if (selectedEntryPath) { - previewSheetRef.current?.present(); - } else { - previewSheetRef.current?.dismiss(); - } - }, [selectedEntryPath]); - - useEffect(() => { - if (shouldShowPreview) { - return; - } - - const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? listScrollOffsetRef.current; - queueScrollRestore(savedOffset); - }, [activePath, queueScrollRestore, shouldShowPreview]); - - useEffect(() => { - const savedOffset = scrollOffsetsByPathRef.current.get(activePath) ?? 0; - listScrollOffsetRef.current = savedOffset; - queueScrollRestore(savedOffset); - }, [activePath, queueScrollRestore]); - - useEffect(() => { - if (!agentId || !initialTargetDirectory || !requestDirectoryListing) { - pendingPathParamRef.current = null; - return; - } - - if (pendingPathParamRef.current === initialTargetDirectory) { - return; - } - - pendingPathParamRef.current = initialTargetDirectory; - requestDirectoryListing(agentId, initialTargetDirectory); - }, [agentId, initialTargetDirectory, requestDirectoryListing]); - - useEffect(() => { - if (!agentId || !normalizedFileParam) { - pendingFileParamRef.current = null; - return; - } - - pendingFileParamRef.current = normalizedFileParam; - enqueueFilePreview(normalizedFileParam, { priority: true }); - }, [agentId, normalizedFileParam, enqueueFilePreview]); - - useEffect(() => { - if (!agentId) { - return; - } - - const targetFile = pendingFileParamRef.current; - if (!targetFile) { - return; - } - - const hasEntry = entries.some((entry) => entry.path === targetFile); - if (!hasEntry) { - return; - } - - setSelectedEntryPath(targetFile); - pendingFileParamRef.current = null; - }, [agentId, entries]); - - const handleEntryPress = useCallback( - (entry: ExplorerEntry) => { - if (!agentId || !requestDirectoryListing) { - return; - } - - if (entry.kind === "directory") { - setSelectedEntryPath(null); - requestDirectoryListing(agentId, entry.path); - return; - } - - setSelectedEntryPath(entry.path); - enqueueFilePreview(entry.path, { priority: true }); - }, - [agentId, requestDirectoryListing, enqueueFilePreview] - ); - - const handleCopyPath = useCallback(async (path: string) => { - await Clipboard.setStringAsync(path); - }, []); - - 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 showDownloadToast = useCallback( - (toast: { status: "downloading" | "complete" | "error"; fileName: string; message?: string }) => { - if (downloadToastTimeoutRef.current) { - clearTimeout(downloadToastTimeoutRef.current); - downloadToastTimeoutRef.current = null; - } - setDownloadToast(toast); - if (toast.status !== "downloading") { - downloadToastTimeoutRef.current = setTimeout(() => { - setDownloadToast(null); - }, 3000); - } - }, - [] - ); - - const handleDownloadEntry = useCallback( - async (entry: ExplorerEntry) => { - if (!agentId || !requestFileDownloadToken || entry.kind !== "file") { - return; - } - - const displayName = entry.name; - - 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; - } - - showDownloadToast({ status: "downloading", fileName: displayName }); - - const targetFile = resolveDownloadTargetFile(fileName); - const downloadedFile = await FSFile.downloadFileAsync( - downloadUrl, - targetFile, - downloadTarget.authHeader - ? { headers: { Authorization: downloadTarget.authHeader } } - : undefined - ); - - showDownloadToast({ status: "complete", fileName: displayName }); - - if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(downloadedFile.uri, { - mimeType: tokenResponse.mimeType ?? undefined, - dialogTitle: fileName ? `Share ${fileName}` : "Share file", - }); - } - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to download file."; - if (Platform.OS === "web") { - console.warn("[FileExplorer] Download failed:", message); - return; - } - showDownloadToast({ status: "error", fileName: displayName, message }); - } - }, - [agentId, daemonProfile, requestFileDownloadToken, showDownloadToast] - ); - - 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) => { - const offset = event.nativeEvent.contentOffset.y; - listScrollOffsetRef.current = offset; - scrollOffsetsByPathRef.current.set(activePath, offset); - }, - [activePath] - ); - - const handleCloseExplorer = useCallback(() => { - if (agentId) { - router.replace({ - pathname: "/agent/[serverId]/[agentId]", - params: { serverId, agentId }, - }); - return; - } - - router.back(); - }, [agentId, serverId]); - - const handleClosePreviewSheet = useCallback(() => { - setSelectedEntryPath(null); - }, []); - - const handlePreviewSheetChange = useCallback((index: number) => { - if (index === -1) { - setSelectedEntryPath(null); - } - }, []); - - const renderPreviewBackdrop = useCallback( - (props: React.ComponentProps) => ( - - ), - [] - ); - - const handleRetryDirectory = useCallback(() => { - if (!agentId || !requestDirectoryListing) { - return; - } - requestDirectoryListing(agentId, activePath); - }, [agentId, requestDirectoryListing, activePath]); - - const handleRefresh = useCallback(() => { - if (!agentId || !requestDirectoryListing) { - return; - } - refreshPathRef.current = activePath; - refreshStartedRef.current = false; - setIsRefreshing(true); - requestDirectoryListing(agentId, activePath, { recordHistory: false }); - }, [agentId, requestDirectoryListing, activePath]); - - useEffect(() => { - if (!isRefreshing) { - return; - } - - const refreshPath = refreshPathRef.current; - if (!refreshPath) { - return; - } - - const isMatchingList = - pendingRequest?.mode === "list" && pendingRequest?.path === refreshPath; - - if (isMatchingList) { - refreshStartedRef.current = true; - return; - } - - if (refreshStartedRef.current) { - setIsRefreshing(false); - refreshPathRef.current = null; - refreshStartedRef.current = false; - } - }, [isRefreshing, pendingRequest?.mode, pendingRequest?.path]); - - const handleBackNavigation = useCallback(() => { - if (!agentId) { - router.back(); - return true; - } - - if (shouldShowPreview) { - setSelectedEntryPath(null); - return true; - } - - if ((explorerState?.history?.length ?? 0) > 1 && navigateExplorerBack) { - navigateExplorerBack(agentId); - return true; - } - - handleCloseExplorer(); - return true; - }, [agentId, explorerState?.history?.length, handleCloseExplorer, navigateExplorerBack, shouldShowPreview]); - - useFocusEffect( - useCallback(() => { - const subscription = BackHandler.addEventListener("hardwareBackPress", handleBackNavigation); - return () => subscription.remove(); - }, [handleBackNavigation]) - ); - - const renderEntry = useCallback( - ({ item }: ListRenderItemInfo) => { - if (viewMode === "grid") { - const preview = explorerState?.files.get(item.path); - const isImage = getEntryDisplayKind(item) === "image"; - const isLoadingThumb = Boolean(thumbnailLoadingMap[item.path]); - return ( - handleEntryPress(item)} - > - - {isImage && preview?.content ? ( - - ) : isImage && isLoadingThumb ? ( - - ) : ( - renderEntryIcon(getEntryDisplayKind(item), theme.colors) - )} - - - {item.name} - - - {formatFileSize({ size: item.size })} - - - ); - } - - const displayKind = getEntryDisplayKind(item); - return ( - handleEntryPress(item)} - > - - - {renderEntryIcon(displayKind, theme.colors)} - - - {item.name} - - {item.kind.toUpperCase()} · {formatFileSize({ size: item.size })} ·{" "} - {formatModifiedTime({ value: item.modifiedAt })} - - - - handleOpenMenu(item, event)} - hitSlop={8} - style={styles.menuButton} - > - - - - ); - }, - [ - explorerState?.files, - handleEntryPress, - handleOpenMenu, - theme.colors, - thumbnailLoadingMap, - viewMode, - ] - ); - - const listHeaderComponent = useMemo(() => { - return ( - - - - - {showListLoadingBanner && ( - - - - Loading {formatDirectoryLabel(activePath)}... - - - )} - - ); - }, [activePath, showListLoadingBanner, viewMode]); - - // Watch for completed file previews and process queue - useEffect(() => { - if (!explorerState) { - return; - } - - // Check which in-flight requests have completed - const completedPaths: string[] = []; - for (const path of inFlightPathsRef.current) { - if (explorerState.files.has(path)) { - completedPaths.push(path); - } - } - - if (completedPaths.length === 0) { - return; - } - - // Remove completed paths from in-flight set - for (const path of completedPaths) { - inFlightPathsRef.current.delete(path); - } - - // Clear loading state for completed files - setThumbnailLoadingMap((prev) => { - const next = { ...prev }; - for (const path of completedPaths) { - delete next[path]; - } - return next; - }); - - // Schedule next batch processing after state update - queueMicrotask(() => { - processNextThumbnail(); - }); - }, [explorerState?.files.size, processNextThumbnail]); - - // Clear queue and loading state on path/view change - useEffect(() => { - thumbnailQueueRef.current = []; - inFlightPathsRef.current.clear(); - setThumbnailLoadingMap({}); - }, [activePath, viewMode]); - - if (!agent) { - return ( - - - - - } - /> - - Agent not found - - - ); - } - - return ( - - - - - } - /> - - - - {error ? ( - - {error} - - Retry - - - ) : showInitialListLoading ? ( - - - Loading directory... - - ) : entries.length === 0 ? ( - - Directory is empty - - ) : ( - item.path} - contentContainerStyle={ - viewMode === "grid" ? styles.gridContent : styles.entriesContent - } - columnWrapperStyle={ - viewMode === "grid" && listColumns > 1 - ? styles.gridColumnWrapper - : undefined - } - numColumns={listColumns} - key={listKey} - onScroll={handleListScroll} - scrollEventThrottle={16} - onLayout={restoreQueuedScrollOffset} - onContentSizeChange={restoreQueuedScrollOffset} - ListHeaderComponent={listHeaderComponent} - extraData={{ viewMode, thumbnailLoadingMap }} - initialNumToRender={20} - maxToRenderPerBatch={30} - windowSize={15} - refreshControl={ - - } - onViewableItemsChanged={handleViewableItemsChangedRef.current} - viewabilityConfig={viewabilityConfigRef.current} - /> - )} - - - - - - - {menuEntry && menuPosition ? ( - - { - handleCopyPath(menuEntry.path); - handleCloseMenu(); - }} - > - Copy Path - - {menuEntry.kind === "file" ? ( - { - handleCloseMenu(); - await handleDownloadEntry(menuEntry); - }} - > - Download - - ) : null} - - ) : null} - - - - - - - {selectedEntryPath?.split("/").pop() ?? "Preview"} - - - - - - {isPreviewLoading && !preview ? ( - - - Loading file... - - ) : !preview ? ( - - No preview available yet - - ) : preview.kind === "text" ? ( - - - {preview.content} - - - ) : preview.kind === "image" && preview.content ? ( - - - - ) : ( - - Binary preview unavailable - - {formatFileSize({ size: preview.size })} - - - )} - - - {downloadToast && ( - - - {downloadToast.status === "downloading" ? ( - - ) : downloadToast.status === "complete" ? ( - - ) : ( - - )} - - - {downloadToast.fileName} - - - {downloadToast.status === "downloading" - ? "Downloading..." - : downloadToast.status === "complete" - ? "Download complete" - : downloadToast.message ?? "Download failed"} - - - {downloadToast.status !== "downloading" && ( - setDownloadToast(null)} - hitSlop={8} - style={styles.downloadToastDismiss} - > - - - )} - - - )} - - ); -} - -function FileExplorerSessionUnavailable({ - agentId, - serverId, - serverLabel, - connectionStatus, - connectionStatusLabel, - lastError, -}: FileExplorerSessionUnavailableProps) { - const { theme } = useUnistyles(); - - const handleClose = useCallback(() => { - if (agentId && serverId) { - router.replace({ - pathname: "/agent/[serverId]/[agentId]", - params: { serverId, agentId }, - }); - return; - } - router.back(); - }, [agentId, serverId]); - - const isConnecting = connectionStatus === "connecting"; - - return ( - - - - - } - /> - - {isConnecting ? ( - <> - - Connecting to {serverLabel}... - We will load files once this host is online. - - ) : ( - <> - - {serverLabel} is currently {connectionStatusLabel.toLowerCase()}. - - - We'll reconnect automatically and load files once the host is back online. No action needed. - - {lastError ? {lastError} : null} - - )} - - - ); -} - -function ViewToggle({ - viewMode, - onChange, -}: { - viewMode: "list" | "grid"; - onChange: (mode: "list" | "grid") => void; -}) { - const { theme } = useUnistyles(); - - return ( - - onChange("list")} - > - - List - - onChange("grid")} - > - - Gallery - - - ); -} - -function formatDirectoryLabel(path: string): string { - return path === "." ? "workspace root" : path; -} - -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(); -} - -function getFirstParam(value?: string | string[]): string | null { - if (Array.isArray(value)) { - return value[0] ?? null; - } - return value ?? null; -} - -function normalizePathParam(value: string | null): string | null { - if (value === null) { - return null; - } - const trimmed = value.trim(); - if (!trimmed.length) { - return "."; - } - return trimmed.replace(/\\/g, "/"); -} - -function normalizeFileParam(value: string | null): string | null { - if (value === null) { - return null; - } - const trimmed = value.trim(); - if (!trimmed.length) { - return null; - } - return trimmed.replace(/\\/g, "/"); -} - -function deriveDirectoryFromFile(filePath: string): string { - const normalized = filePath.replace(/\\/g, "/"); - const lastSlash = normalized.lastIndexOf("/"); - if (lastSlash === -1) { - return "."; - } - const directory = normalized.slice(0, lastSlash); - return directory.length > 0 ? directory : "."; -} - -type EntryDisplayKind = "directory" | "image" | "text" | "other"; - -const IMAGE_EXTENSIONS = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "bmp", - "svg", - "webp", - "ico", -]); - -const TEXT_EXTENSIONS = new Set([ - "txt", - "md", - "markdown", - "ts", - "tsx", - "js", - "jsx", - "json", - "yml", - "yaml", - "toml", - "py", - "rb", - "go", - "rs", - "java", - "kt", - "c", - "cpp", - "cc", - "h", - "hpp", - "cs", - "swift", - "php", - "html", - "css", - "scss", - "less", - "xml", - "sh", - "bash", - "zsh", - "ini", - "cfg", - "conf", -]); - -function renderEntryIcon( - kind: EntryDisplayKind, - colors: { foreground: string; primary: string } -) { - const color = colors.foreground; - switch (kind) { - case "directory": - return ; - case "image": - return ; - case "text": - return ; - default: - return ; - } -} - -function getEntryDisplayKind(entry: ExplorerEntry): EntryDisplayKind { - if (entry.kind === "directory") { - return "directory"; - } - - const extension = getExtension(entry.name); - if (extension === null) { - return "other"; - } - - if (IMAGE_EXTENSIONS.has(extension)) { - return "image"; - } - - if (TEXT_EXTENSIONS.has(extension)) { - return "text"; - } - - return "other"; -} - -function getExtension(name: string): string | null { - const index = name.lastIndexOf("."); - if (index === -1 || index === name.length - 1) { - return 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(); -} - -function resolveDownloadTargetFile(fileName: string): FSFile { - const directory = Paths.cache ?? Paths.document; - if (!directory) { - throw new Error("No download directory available."); - } - - const safeName = sanitizeDownloadFileName(fileName); - const split = splitFileName(safeName); - let targetFile = new FSFile(directory, safeName); - let suffix = 1; - - while (targetFile.exists) { - targetFile = new FSFile(directory, `${split.base} (${suffix})${split.ext}`); - suffix += 1; - } - - return targetFile; -} - -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, - backgroundColor: theme.colors.background, - }, - content: { - flex: 1, - flexDirection: "column", - paddingHorizontal: theme.spacing[3], - paddingBottom: theme.spacing[3], - gap: theme.spacing[3], - }, - listSection: { - flex: 1, - }, - entriesContent: { - paddingBottom: theme.spacing[4], - }, - headerContainer: { - gap: theme.spacing[2], - paddingBottom: theme.spacing[2], - }, - headerRow: { - flexDirection: "row", - justifyContent: "flex-end", - }, - loadingBanner: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - }, - loadingBannerText: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.sm, - }, - previewWrapper: { - flex: 1, - gap: 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", - }, - 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, - textAlign: "center", - }, - errorDetails: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - textAlign: "center", - }, - offlineTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - textAlign: "center", - }, - offlineDescription: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.sm, - textAlign: "center", - }, - offlineDetails: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - textAlign: "center", - }, - emptyText: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.base, - textAlign: "center", - }, - entryRow: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - marginBottom: theme.spacing[1], - }, - directoryRow: { - backgroundColor: theme.colors.muted, - }, - fileRow: { - backgroundColor: theme.colors.card, - }, - selectedRow: { - borderColor: theme.colors.primary, - }, - entryInfo: { - flex: 1, - flexDirection: "row", - alignItems: "center", - columnGap: theme.spacing[2], - marginRight: theme.spacing[3], - }, - entryIcon: { - width: 28, - alignItems: "center", - }, - entryTextContainer: { - flex: 1, - }, - 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], - }, - 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, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - overflow: "hidden", - }, - viewToggleButton: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - }, - viewToggleActive: { - backgroundColor: theme.colors.muted, - }, - viewToggleText: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - closeButton: { - padding: theme.spacing[3], - borderRadius: theme.borderRadius.lg, - }, - textPreview: { - flex: 1, - }, - textPreviewContent: { - padding: theme.spacing[2], - }, - codeText: { - color: theme.colors.foreground, - fontFamily: "monospace", - fontSize: theme.fontSize.sm, - }, - gridContent: { - paddingBottom: theme.spacing[4], - paddingHorizontal: theme.spacing[1], - }, - gridColumnWrapper: { - justifyContent: "space-between", - marginBottom: theme.spacing[2], - }, - gridCard: { - flex: 1, - borderRadius: theme.borderRadius.lg, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - padding: theme.spacing[2], - gap: theme.spacing[2], - backgroundColor: theme.colors.card, - marginHorizontal: theme.spacing[1], - marginBottom: theme.spacing[1], - minWidth: 0, - }, - gridThumbnail: { - width: "100%", - aspectRatio: 1, - borderRadius: theme.borderRadius.md, - alignItems: "center", - justifyContent: "center", - overflow: "hidden", - backgroundColor: theme.colors.muted, - }, - gridImageBackground: { - backgroundColor: theme.colors.background, - }, - gridImage: { - width: "100%", - height: "100%", - }, - gridName: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - gridMeta: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - }, - imagePreviewContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - }, - image: { - width: "100%", - height: "100%", - }, - // Bottom sheet styles - sheetBackground: { - backgroundColor: theme.colors.card, - }, - handleIndicator: { - backgroundColor: theme.colors.palette.zinc[600], - }, - sheetHeader: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingHorizontal: theme.spacing[4], - paddingVertical: theme.spacing[3], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - sheetTitle: { - fontSize: theme.fontSize.lg, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - flex: 1, - }, - sheetCloseButton: { - padding: theme.spacing[2], - }, - sheetContent: { - flex: 1, - }, - sheetScrollContent: { - padding: theme.spacing[4], - }, - sheetCenterState: { - flex: 1, - alignItems: "center", - justifyContent: "center", - gap: theme.spacing[2], - padding: theme.spacing[4], - }, - sheetImageContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - padding: theme.spacing[4], - }, - sheetImage: { - width: "100%", - height: "100%", - }, - downloadToast: { - position: "absolute", - bottom: theme.spacing[4], - left: theme.spacing[4], - right: theme.spacing[4], - zIndex: 1000, - }, - downloadToastContent: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[3], - backgroundColor: theme.colors.card, - borderRadius: theme.borderRadius.lg, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[4], - shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.15, - shadowRadius: 8, - elevation: 8, - }, - downloadToastTextContainer: { - flex: 1, - gap: theme.spacing[1], - }, - downloadToastFileName: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - }, - downloadToastStatus: { - color: theme.colors.mutedForeground, - fontSize: theme.fontSize.xs, - }, - downloadToastDismiss: { - padding: theme.spacing[1], - }, -})); diff --git a/packages/app/src/app/git-diff.tsx b/packages/app/src/app/git-diff.tsx deleted file mode 100644 index 60b4ba15a..000000000 --- a/packages/app/src/app/git-diff.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { View, Text, ScrollView, ActivityIndicator } from "react-native"; -import { useLocalSearchParams } from "expo-router"; -import { StyleSheet } from "react-native-unistyles"; -import { BackHeader } from "@/components/headers/back-header"; -import type { ConnectionStatus } from "@/contexts/daemon-connections-context"; -import { useDaemonConnections } from "@/contexts/daemon-connections-context"; -import { formatConnectionStatus } from "@/utils/daemons"; -import { useSessionStore } from "@/stores/session-store"; - -interface ParsedDiffFile { - path: string; - lines: Array<{ - type: "add" | "remove" | "context" | "header"; - content: string; - }>; -} - -function parseDiff(diffText: string): ParsedDiffFile[] { - if (!diffText || diffText.trim().length === 0) { - return []; - } - - const files: ParsedDiffFile[] = []; - const sections = diffText.split(/^diff --git /m).filter(Boolean); - - for (const section of sections) { - const lines = section.split("\n"); - const firstLine = lines[0]; - - const pathMatch = firstLine.match(/a\/(.*?) b\//); - const path = pathMatch ? pathMatch[1] : "unknown"; - - const parsedLines: ParsedDiffFile["lines"] = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - if (line.startsWith("+++") || line.startsWith("---") || line.startsWith("@@") || line.startsWith("index ")) { - parsedLines.push({ type: "header", content: line }); - } else if (line.startsWith("+")) { - parsedLines.push({ type: "add", content: line }); - } else if (line.startsWith("-")) { - parsedLines.push({ type: "remove", content: line }); - } else { - parsedLines.push({ type: "context", content: line }); - } - } - - files.push({ path, lines: parsedLines }); - } - - return files; -} - -export default function GitDiffScreen() { - const { agentId, serverId } = useLocalSearchParams<{ agentId: string; serverId?: string }>(); - const resolvedServerId = typeof serverId === "string" ? serverId : undefined; - const { connectionStates } = useDaemonConnections(); - - const session = useSessionStore((state) => resolvedServerId ? state.sessions[resolvedServerId] : undefined); - - const connectionServerId = resolvedServerId ?? null; - const connection = connectionServerId ? connectionStates.get(connectionServerId) : null; - const serverLabel = connection?.daemon.label ?? connectionServerId ?? session?.serverId ?? "Current host"; - const connectionStatus = connection?.status ?? "idle"; - const connectionStatusLabel = formatConnectionStatus(connectionStatus); - const lastError = connection?.lastError ?? null; - - if (!session) { - return ( - - ); - } - - return ( - - ); -} - -function GitDiffContent({ - serverId, - agentId, -}: { - serverId: string; - agentId?: string; -}) { - const [isLoading, setIsLoading] = useState(true); - const hasRequestedRef = useRef(null); - - const agent = useSessionStore((state) => - agentId && serverId ? state.sessions[serverId]?.agents?.get(agentId) : undefined - ); - - const diffText = useSessionStore((state) => - agentId && serverId ? state.sessions[serverId]?.gitDiffs?.get(agentId) : undefined - ); - - const requestGitDiff = useSessionStore((state) => - serverId ? state.sessions[serverId]?.methods?.requestGitDiff : undefined - ); - - useEffect(() => { - if (!agentId || !requestGitDiff) { - setIsLoading(false); - return; - } - - // Prevent duplicate requests for the same agentId - if (hasRequestedRef.current === agentId) { - return; - } - hasRequestedRef.current = agentId; - - setIsLoading(true); - requestGitDiff(agentId); - - const timeout = setTimeout(() => { - setIsLoading(false); - }, 5000); - - return () => clearTimeout(timeout); - }, [agentId, requestGitDiff]); - - useEffect(() => { - if (diffText !== undefined) { - setIsLoading(false); - } - }, [diffText]); - - if (!agent) { - return ( - - - - Agent not found - - - ); - } - - const isError = diffText?.startsWith("Error:"); - const parsedFiles = isError || !diffText ? [] : parseDiff(diffText); - const hasChanges = parsedFiles.length > 0; - - return ( - - - - - {isLoading ? ( - - - Loading changes... - - ) : isError ? ( - - {diffText} - - ) : !hasChanges ? ( - - No changes - - ) : ( - parsedFiles.map((file, fileIndex) => ( - - - {file.path} - - - - {file.lines.map((line, lineIndex) => ( - - - {line.content} - - - ))} - - - - )) - )} - - - ); -} - -function SessionUnavailableState({ - serverLabel, - connectionStatus, - connectionStatusLabel, - lastError, -}: { - serverLabel: string; - connectionStatus: ConnectionStatus; - connectionStatusLabel: string; - lastError: string | null; -}) { - const isConnecting = connectionStatus === "connecting"; - - return ( - - - - {isConnecting ? ( - <> - - Connecting to {serverLabel}... - We will show changes once this session is online. - - ) : ( - <> - - {serverLabel} is currently {connectionStatusLabel.toLowerCase()}. - - - We'll reconnect automatically and show changes once the host is back online. No action needed. - - {lastError ? {lastError} : null} - - )} - - - ); -} - -const styles = StyleSheet.create((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.background, - }, - sessionStateContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - paddingTop: theme.spacing[16], - paddingHorizontal: theme.spacing[6], - gap: theme.spacing[3], - }, - scrollView: { - flex: 1, - }, - contentContainer: { - padding: theme.spacing[4], - paddingBottom: theme.spacing[8], - }, - loadingContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - paddingTop: theme.spacing[16], - gap: theme.spacing[4], - }, - loadingText: { - fontSize: theme.fontSize.base, - color: theme.colors.mutedForeground, - }, - errorContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - paddingTop: theme.spacing[16], - paddingHorizontal: theme.spacing[6], - }, - errorText: { - fontSize: theme.fontSize.base, - color: theme.colors.destructive, - textAlign: "center", - }, - statusText: { - marginTop: theme.spacing[3], - textAlign: "center", - fontSize: theme.fontSize.sm, - color: theme.colors.mutedForeground, - }, - errorDetails: { - marginTop: theme.spacing[2], - textAlign: "center", - fontSize: theme.fontSize.xs, - color: theme.colors.mutedForeground, - }, - offlineTitle: { - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - textAlign: "center", - }, - offlineDescription: { - fontSize: theme.fontSize.sm, - color: theme.colors.mutedForeground, - textAlign: "center", - }, - offlineDetails: { - fontSize: theme.fontSize.xs, - color: theme.colors.mutedForeground, - textAlign: "center", - }, - emptyContainer: { - flex: 1, - alignItems: "center", - justifyContent: "center", - paddingTop: theme.spacing[16], - }, - emptyText: { - fontSize: theme.fontSize.lg, - color: theme.colors.mutedForeground, - }, - fileSection: { - marginBottom: theme.spacing[6], - borderRadius: theme.borderRadius.lg, - overflow: "hidden", - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - width: "100%", - }, - fileHeader: { - backgroundColor: theme.colors.muted, - padding: theme.spacing[3], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - filePath: { - fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, - color: theme.colors.foreground, - fontFamily: "monospace", - }, - diffContent: { - backgroundColor: theme.colors.card, - }, - diffLinesContainer: { - width: "100%", - }, - diffLineContainer: { - width: "100%", - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - flexDirection: "row", - alignItems: "flex-start", - }, - diffLineText: { - fontSize: theme.fontSize.xs, - fontFamily: "monospace", - color: theme.colors.foreground, - flexShrink: 1, - flexWrap: "wrap", - width: "100%", - }, - addLineContainer: { - backgroundColor: theme.colors.palette.green[900], - }, - addLineText: { - color: theme.colors.palette.green[200], - }, - removeLineContainer: { - backgroundColor: theme.colors.palette.red[900], - }, - removeLineText: { - color: theme.colors.palette.red[200], - }, - headerLineContainer: { - backgroundColor: theme.colors.muted, - }, - headerLineText: { - color: theme.colors.mutedForeground, - }, - contextLineContainer: { - backgroundColor: theme.colors.card, - }, - contextLineText: { - color: theme.colors.mutedForeground, - }, -})); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index be2ad9e63..515a8154f 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -17,7 +17,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import Animated, { FadeIn, FadeOut, cancelAnimation, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming } from "react-native-reanimated"; import { ChevronDown } from "lucide-react-native"; -import { useRouter } from "expo-router"; +import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store"; import { AssistantMessage, UserMessage, @@ -72,7 +72,7 @@ export function AgentStreamView({ const isProgrammaticScrollRef = useRef(false); const isNearBottomRef = useRef(true); const isUserScrollingRef = useRef(false); - const router = useRouter(); + const { open: openExplorer, setActiveTab: setExplorerTab } = useExplorerSidebarStore(); // Get serverId (fallback to agent's serverId if not provided) const resolvedServerId = serverId ?? agent.serverId ?? ""; @@ -134,29 +134,16 @@ export function AgentStreamView({ requestFilePreviewOrInert(agentId, normalized.file); } - router.push({ - pathname: "/file-explorer", - params: { - agentId, - path: normalized.directory, - serverId: resolvedServerId, - ...(normalized.file ? { file: normalized.file } : {}), - ...(target.lineStart !== undefined - ? { lineStart: String(target.lineStart) } - : {}), - ...(target.lineEnd !== undefined - ? { lineEnd: String(target.lineEnd) } - : {}), - }, - }); + setExplorerTab("files"); + openExplorer(); }, [ agent.cwd, agentId, requestDirectoryListingOrInert, requestFilePreviewOrInert, - resolvedServerId, - router, + setExplorerTab, + openExplorer, ] ); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index e7429aa3e..2e9275512 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -70,10 +70,10 @@ export function FileExplorerPane({ const { connectionStates } = useDaemonConnections(); const daemonProfile = connectionStates.get(serverId)?.daemon; - const agent = useSessionStore((state) => + const agentExists = useSessionStore((state) => agentId && state.sessions[serverId] - ? state.sessions[serverId]?.agents.get(agentId) - : undefined + ? state.sessions[serverId]?.agents.has(agentId) + : false ); const explorerState = useSessionStore((state) => @@ -763,7 +763,7 @@ export function FileExplorerPane({ setThumbnailLoadingMap({}); }, [activePath, viewMode]); - if (!agent) { + if (!agentExists) { return ( Agent not found diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 46ab4293a..9ecbb753a 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -218,11 +218,11 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { agentId, }); - const agent = useSessionStore((state) => - state.sessions[serverId]?.agents?.get(agentId) + const agentExists = useSessionStore((state) => + state.sessions[serverId]?.agents?.has(agentId) ?? false ); - if (!agent) { + if (!agentExists) { return ( Agent not found