diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index eef34749b..2c7afad03 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -123,6 +123,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen); const openAgentList = usePanelStore((state) => state.openAgentList); const toggleAgentList = usePanelStore((state) => state.toggleAgentList); + const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const horizontalScroll = useHorizontalScrollOptional(); const isMobile = @@ -134,7 +135,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { : desktopAgentListOpen : false; - // Cmd+B to toggle sidebar (web only) + // Cmd+B to toggle agent list sidebar, Cmd+E to toggle explorer sidebar (web only) useEffect(() => { if (!chromeEnabled) return; if (Platform.OS !== "web") return; @@ -142,11 +143,20 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { if ((event.metaKey || event.ctrlKey) && event.key === "b") { event.preventDefault(); toggleAgentList(); + return; + } + if ( + selectedAgentId && + (event.metaKey || event.ctrlKey) && + (event.code === "KeyE" || event.key.toLowerCase() === "e") + ) { + event.preventDefault(); + toggleFileExplorer(); } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [chromeEnabled, toggleAgentList]); + }, [chromeEnabled, selectedAgentId, toggleAgentList, toggleFileExplorer]); const { translateX, backdropOpacity, diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 2c3be4a9d..573364019 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -95,7 +95,8 @@ export function AgentStreamView({ state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId) ); - const { requestDirectoryListing, requestFilePreview } = useFileExplorerActions(resolvedServerId); + const { requestDirectoryListing, requestFilePreview, selectExplorerEntry } = + useFileExplorerActions(resolvedServerId); // Keep entry/exit animations off on Android due to RN dispatchDraw crashes // tracked in react-native-reanimated#8422. const shouldDisableEntryExitAnimations = Platform.OS === "android"; @@ -123,8 +124,12 @@ export function AgentStreamView({ return; } - requestDirectoryListing(agentId, normalized.directory); + requestDirectoryListing(agentId, normalized.directory, { + recordHistory: false, + setCurrentPath: false, + }); if (normalized.file) { + selectExplorerEntry(agentId, normalized.file); requestFilePreview(agentId, normalized.file); } @@ -136,6 +141,7 @@ export function AgentStreamView({ agentId, requestDirectoryListing, requestFilePreview, + selectExplorerEntry, setExplorerTab, openFileExplorer, ] diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 0dfe2c044..f2331db5c 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -1,5 +1,5 @@ -import { useCallback, useMemo, useRef } from "react"; -import { View, Text, Pressable, Platform } from "react-native"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { useAnimatedStyle, @@ -8,12 +8,11 @@ import Animated, { } from "react-native-reanimated"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { X, LayoutGrid, List as ListIcon } from "lucide-react-native"; +import { X } from "lucide-react-native"; import { usePanelStore, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH, - type ViewMode, type ExplorerTab, } from "@/stores/panel-store"; import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; @@ -22,6 +21,8 @@ import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { GitDiffPane } from "./git-diff-pane"; import { FileExplorerPane } from "./file-explorer-pane"; +const MIN_CHAT_WIDTH = 400; + interface ExplorerSidebarProps { serverId: string; agentId: string; @@ -38,10 +39,22 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps const closeToAgent = usePanelStore((state) => state.closeToAgent); const explorerTab = usePanelStore((state) => state.explorerTab); const explorerWidth = usePanelStore((state) => state.explorerWidth); - const explorerViewMode = usePanelStore((state) => state.explorerViewMode); const setExplorerTab = usePanelStore((state) => state.setExplorerTab); const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); - const setExplorerViewMode = usePanelStore((state) => state.setExplorerViewMode); + const { width: viewportWidth } = useWindowDimensions(); + + useEffect(() => { + if (isMobile) { + return; + } + const maxWidth = Math.max( + MIN_EXPLORER_SIDEBAR_WIDTH, + Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH) + ); + if (explorerWidth > maxWidth) { + setExplorerWidth(maxWidth); + } + }, [explorerWidth, isMobile, setExplorerWidth, viewportWidth]); // Derive isOpen from the unified panel state const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; @@ -133,16 +146,20 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps .onUpdate((event) => { // Dragging left (negative translationX) increases width const newWidth = startWidthRef.current - event.translationX; + const maxWidth = Math.max( + MIN_EXPLORER_SIDEBAR_WIDTH, + Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH) + ); const clampedWidth = Math.max( MIN_EXPLORER_SIDEBAR_WIDTH, - Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, newWidth) + Math.min(maxWidth, newWidth) ); resizeWidth.value = clampedWidth; }) .onEnd(() => { runOnJS(setExplorerWidth)(resizeWidth.value); }), - [isMobile, explorerWidth, resizeWidth, setExplorerWidth] + [isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth] ); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ @@ -185,8 +202,6 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps serverId={serverId} agentId={agentId} cwd={cwd} - fileViewMode={explorerViewMode} - onFileViewModeChange={setExplorerViewMode} isMobile={isMobile} /> @@ -219,8 +234,6 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps serverId={serverId} agentId={agentId} cwd={cwd} - fileViewMode={explorerViewMode} - onFileViewModeChange={setExplorerViewMode} isMobile={false} /> @@ -234,8 +247,6 @@ interface SidebarContentProps { serverId: string; agentId: string; cwd: string; - fileViewMode: ViewMode; - onFileViewModeChange: (mode: ViewMode) => void; isMobile: boolean; } @@ -246,8 +257,6 @@ function SidebarContent({ serverId, agentId, cwd, - fileViewMode, - onFileViewModeChange, isMobile, }: SidebarContentProps) { const { theme } = useUnistyles(); @@ -292,9 +301,6 @@ function SidebarContent({ - {effectiveTab === "files" && ( - - )} {isMobile && ( @@ -316,33 +322,6 @@ function SidebarContent({ ); } -function ViewToggle({ - viewMode, - onChange, -}: { - viewMode: ViewMode; - onChange: (mode: ViewMode) => void; -}) { - const { theme } = useUnistyles(); - - return ( - - onChange("list")} - > - - - onChange("grid")} - > - - - - ); -} - const styles = StyleSheet.create((theme) => ({ backdrop: { ...StyleSheet.absoluteFillObject, @@ -423,17 +402,4 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, }, - viewToggleContainer: { - flexDirection: "row", - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - overflow: "hidden", - }, - viewToggleButton: { - padding: theme.spacing[2], - }, - viewToggleActive: { - backgroundColor: theme.colors.surface2, - }, })); diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index ccd94113b..d1ab5cf45 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -5,10 +5,6 @@ import { Image as RNImage, LayoutChangeEvent, ListRenderItemInfo, - RefreshControl, - ViewToken, - NativeScrollEvent, - NativeSyntheticEvent, Modal, Pressable, ScrollView as RNScrollView, @@ -26,56 +22,53 @@ import { BottomSheetBackdrop, } from "@gorhom/bottom-sheet"; import { - ArrowLeft, - ChevronDown, File, FileText, Folder, + FolderOpen, Image as ImageIcon, MoreVertical, X, } from "lucide-react-native"; -import type { ExplorerEntry } from "@/stores/session-store"; +import type { ExplorerEntry, ExplorerFile } from "@/stores/session-store"; import { useDaemonConnections } from "@/contexts/daemon-connections-context"; import { useSessionStore } from "@/stores/session-store"; import { useDownloadStore } from "@/stores/download-store"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; -import { - usePanelStore, - type SortOption, -} from "@/stores/panel-store"; +import { usePanelStore, type SortOption } from "@/stores/panel-store"; import { formatTimeAgo } from "@/utils/time"; -const MAX_CONCURRENT_THUMBNAILS = 2; -const THUMBNAIL_TIMEOUT_MS = 15000; - const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: "name", label: "Name" }, { value: "modified", label: "Modified" }, { value: "size", label: "Size" }, ]; +const INDENT_PER_LEVEL = 12; + interface FileExplorerPaneProps { serverId: string; agentId: string; } -export function FileExplorerPane({ - serverId, - agentId, -}: FileExplorerPaneProps) { +interface TreeRow { + entry: ExplorerEntry; + depth: number; +} + +export function FileExplorerPane({ serverId, agentId }: FileExplorerPaneProps) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const { connectionStates } = useDaemonConnections(); const daemonProfile = connectionStates.get(serverId)?.daemon; - const agentExists = useSessionStore((state) => agentId && state.sessions[serverId] ? state.sessions[serverId]?.agents.has(agentId) : false ); - const explorerState = useSessionStore((state) => agentId && state.sessions[serverId] ? state.sessions[serverId]?.fileExplorer.get(agentId) @@ -86,256 +79,74 @@ export function FileExplorerPane({ requestDirectoryListing, requestFilePreview, requestFileDownloadToken, - navigateExplorerBack, + selectExplorerEntry, } = useFileExplorerActions(serverId); - const viewMode = usePanelStore((state) => state.explorerViewMode); const sortOption = usePanelStore((state) => state.explorerSortOption); const setSortOption = usePanelStore((state) => state.setExplorerSortOption); - const [selectedEntryPath, setSelectedEntryPath] = useState(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 history = explorerState?.history ?? []; - const lastKnownDirectory = history[history.length - 1]; - const rememberedDirectory = explorerState?.lastVisitedPath; - const initialTargetDirectory = rememberedDirectory ?? lastKnownDirectory ?? "."; - const currentPath = explorerState?.currentPath ?? "."; + const directories = explorerState?.directories ?? new Map(); + const files = explorerState?.files ?? new Map(); 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 rawEntries = directory?.entries ?? []; - const entries = useMemo(() => { - const sorted = [...rawEntries]; - sorted.sort((a, b) => { - // Directories always come first - if (a.kind !== b.kind) { - return a.kind === "directory" ? -1 : 1; - } - switch (sortOption) { - case "name": - return a.name.localeCompare(b.name); - case "modified": - return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); - case "size": - return b.size - a.size; - default: - return 0; - } - }); - return sorted; - }, [rawEntries, sortOption]); - const showInitialListLoading = isListingLoading && entries.length === 0; + const error = explorerState?.lastError ?? null; + const selectedEntryPath = explorerState?.selectedEntryPath ?? null; + + const preview = selectedEntryPath ? files.get(selectedEntryPath) : null; 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(() => ["70%", "95%"], []); + const isDirectoryLoading = useCallback( + (path: string) => + Boolean( + isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === path + ), + [isExplorerLoading, pendingRequest?.mode, pendingRequest?.path] + ); - // Thumbnail queue state - const thumbnailQueueRef = useRef([]); - const inFlightPathsRef = useRef>(new Set()); - - // Responsive gallery columns based on container width - const [containerWidth, setContainerWidth] = useState(0); - const gridColumnCount = containerWidth > 0 && containerWidth >= 400 ? 4 : 3; - const listColumns = viewMode === "grid" ? gridColumnCount : 1; - const listKey = viewMode === "grid" ? `grid-${gridColumnCount}` : "list"; + const [expandedPaths, setExpandedPaths] = useState>(() => new Set(["."])); const [menuEntry, setMenuEntry] = useState(null); const [menuAnchor, setMenuAnchor] = useState({ top: 0, left: 0 }); const [menuHeight, setMenuHeight] = useState(0); - const [isRefreshing, setIsRefreshing] = useState(false); - const startDownload = useDownloadStore((state) => state.startDownload); - 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); + + // Bottom sheet for file preview (mobile) + const previewSheetRef = useRef(null); + const previewSnapPoints = useMemo(() => ["70%", "95%"], []); + const hasInitializedRef = 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 - const processNextThumbnail = useCallback(() => { - const currentAgentId = agentIdRef.current; - const currentRequestFilePreview = requestFilePreviewRef.current; - - if (!currentAgentId || !currentRequestFilePreview) { + if (!agentId || !requestDirectoryListing) { return; } - - while ( - inFlightPathsRef.current.size < MAX_CONCURRENT_THUMBNAILS && - thumbnailQueueRef.current.length > 0 - ) { - const path = thumbnailQueueRef.current.shift()!; - - if (explorerFilesRef.current?.has(path) || inFlightPathsRef.current.has(path)) { - continue; - } - - inFlightPathsRef.current.add(path); - setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true })); - currentRequestFilePreview(currentAgentId, path); - - 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 - const enqueueFilePreview = useCallback( - (path: string, options?: { priority?: boolean }) => { - const currentAgentId = agentIdRef.current; - const currentRequestFilePreview = requestFilePreviewRef.current; - - if (!currentAgentId || !currentRequestFilePreview) { - return; - } - - if (explorerFilesRef.current?.has(path)) { - return; - } - - if (options?.priority) { - thumbnailQueueRef.current = []; - - if (inFlightPathsRef.current.has(path)) { - return; - } - - 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(); - } - - inFlightPathsRef.current.add(path); - setThumbnailLoadingMap((prev) => ({ ...prev, [path]: true })); - currentRequestFilePreview(currentAgentId, path); - - 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; - } - - 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) { + if (hasInitializedRef.current) { return; } + hasInitializedRef.current = true; + requestDirectoryListing(agentId, ".", { recordHistory: false, setCurrentPath: false }); + }, [agentId, requestDirectoryListing]); - if (!listScrollRef.current) { + // Expand ancestor directories when a file is selected (e.g., from an inline path click) + useEffect(() => { + if (!agentId || !selectedEntryPath || !requestDirectoryListing) { return; } + const parentDir = getParentDirectory(selectedEntryPath); + const ancestors = getAncestorDirectories(parentDir); - const targetOffset = pendingScrollRestoreRef.current; - listScrollRef.current.scrollToOffset({ offset: targetOffset, animated: false }); - listScrollOffsetRef.current = targetOffset; - pendingScrollRestoreRef.current = null; - }, []); + setExpandedPaths((prev) => { + const next = new Set(prev); + ancestors.forEach((path) => next.add(path)); + return next; + }); - const queueScrollRestore = useCallback((offset: number) => { - pendingScrollRestoreRef.current = offset; - requestAnimationFrame(restoreQueuedScrollOffset); - }, [restoreQueuedScrollOffset]); - - useEffect(() => { - setSelectedEntryPath(null); - }, [activePath]); + ancestors.forEach((path) => { + if (!directories.has(path)) { + requestDirectoryListing(agentId, path, { recordHistory: false, setCurrentPath: false }); + } + }); + }, [agentId, directories, requestDirectoryListing, selectedEntryPath]); // Open/close preview sheet based on selection useEffect(() => { @@ -349,51 +160,58 @@ export function FileExplorerPane({ } }, [isMobile, selectedEntryPath]); - useEffect(() => { - if (shouldShowPreview) { + const handleClosePreview = useCallback(() => { + if (!agentId) { return; } + selectExplorerEntry(agentId, null); + }, [agentId, selectExplorerEntry]); - 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]); - - // Initial directory listing request - useEffect(() => { - if (!agentId || !requestDirectoryListing) { - return; - } - - if (hasInitializedRef.current) { - return; - } - hasInitializedRef.current = true; - - requestDirectoryListing(agentId, initialTargetDirectory); - }, [agentId, initialTargetDirectory, requestDirectoryListing]); - - const handleEntryPress = useCallback( + const handleToggleDirectory = useCallback( (entry: ExplorerEntry) => { if (!agentId || !requestDirectoryListing) { return; } - if (entry.kind === "directory") { - setSelectedEntryPath(null); - requestDirectoryListing(agentId, entry.path); + const isExpanded = expandedPaths.has(entry.path); + const nextExpanded = !isExpanded; + setExpandedPaths((prev) => { + const next = new Set(prev); + if (isExpanded) { + next.delete(entry.path); + } else { + next.add(entry.path); + } + return next; + }); + + if (nextExpanded && !directories.has(entry.path)) { + requestDirectoryListing(agentId, entry.path, { recordHistory: false, setCurrentPath: false }); + } + }, + [agentId, directories, expandedPaths, requestDirectoryListing] + ); + + const handleOpenFile = useCallback( + (entry: ExplorerEntry) => { + if (!agentId || !requestFilePreview) { return; } - - setSelectedEntryPath(entry.path); - enqueueFilePreview(entry.path, { priority: true }); + selectExplorerEntry(agentId, entry.path); + requestFilePreview(agentId, entry.path); }, - [agentId, requestDirectoryListing, enqueueFilePreview] + [agentId, requestFilePreview, selectExplorerEntry] + ); + + const handleEntryPress = useCallback( + (entry: ExplorerEntry) => { + if (entry.kind === "directory") { + handleToggleDirectory(entry); + return; + } + handleOpenFile(entry); + }, + [handleOpenFile, handleToggleDirectory] ); const handleCopyPath = useCallback(async (path: string) => { @@ -420,6 +238,7 @@ export function FileExplorerPane({ setMenuHeight((current) => (current === height ? current : height)); }, []); + const startDownload = useDownloadStore((state) => state.startDownload); const handleDownloadEntry = useCallback( (entry: ExplorerEntry) => { if (!agentId || !requestFileDownloadToken || entry.kind !== "file") { @@ -453,31 +272,113 @@ export function FileExplorerPane({ ); 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]); + }, [menuAnchor.left, menuAnchor.top, menuEntry, menuHeight, theme.spacing, windowHeight, windowWidth]); - const handleListScroll = useCallback( - (event: NativeSyntheticEvent) => { - const offset = event.nativeEvent.contentOffset.y; - listScrollOffsetRef.current = offset; - scrollOffsetsByPathRef.current.set(activePath, offset); + const handleSortCycle = useCallback(() => { + const currentIndex = SORT_OPTIONS.findIndex((opt) => opt.value === sortOption); + const nextIndex = (currentIndex + 1) % SORT_OPTIONS.length; + setSortOption(SORT_OPTIONS[nextIndex].value); + }, [sortOption, setSortOption]); + + const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name"; + + const treeRows = useMemo(() => { + const rootDirectory = directories.get("."); + if (!rootDirectory) { + return []; + } + return buildTreeRows({ + directories, + expandedPaths, + sortOption, + path: ".", + depth: 0, + }); + }, [directories, expandedPaths, sortOption]); + + const showInitialLoading = + !directories.has(".") && + Boolean(isExplorerLoading && pendingRequest?.mode === "list" && pendingRequest?.path === "."); + + const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath); + + const renderTreeRow = useCallback( + ({ item }: ListRenderItemInfo) => { + const entry = item.entry; + const depth = item.depth; + const displayKind = getEntryDisplayKind(entry); + const isDirectory = entry.kind === "directory"; + const isExpanded = isDirectory && expandedPaths.has(entry.path); + const isSelected = selectedEntryPath === entry.path; + const loading = isDirectory && isDirectoryLoading(entry.path); + + return ( + handleEntryPress(entry)} + style={({ hovered, pressed }) => [ + styles.entryRow, + { paddingLeft: theme.spacing[2] + depth * INDENT_PER_LEVEL }, + (hovered || pressed || isSelected) && styles.entryRowActive, + ]} + > + + + {loading ? ( + + ) : ( + renderEntryIcon(isDirectory ? "directory" : displayKind, { + foreground: theme.colors.foregroundMuted, + primary: theme.colors.primary, + directoryOpen: isExpanded, + }) + )} + + + {entry.name} + + + handleOpenMenu(entry, event)} + hitSlop={8} + style={({ hovered, pressed }) => [ + styles.menuButton, + (hovered || pressed) && styles.menuButtonActive, + ]} + > + + + + ); }, - [activePath] + [ + expandedPaths, + handleEntryPress, + handleOpenMenu, + isDirectoryLoading, + selectedEntryPath, + theme.colors, + theme.spacing, + ] ); - const handleContainerLayout = useCallback((event: LayoutChangeEvent) => { - const { width } = event.nativeEvent.layout; - setContainerWidth(width); - }, []); + const listHeaderComponent = useMemo(() => { + return ( + + + {currentSortLabel} + + + ); + }, [currentSortLabel, handleSortCycle]); - const handleClosePreviewSheet = useCallback(() => { - setSelectedEntryPath(null); - }, []); - - const handlePreviewSheetChange = useCallback((index: number) => { - if (index === -1) { - setSelectedEntryPath(null); - } - }, []); + const handlePreviewSheetChange = useCallback( + (index: number) => { + if (index === -1) { + handleClosePreview(); + } + }, + [handleClosePreview] + ); const renderPreviewBackdrop = useCallback( (props: React.ComponentProps) => ( @@ -491,209 +392,6 @@ export function FileExplorerPane({ [] ); - const inlinePreviewTitle = useMemo(() => { - return selectedEntryPath?.split("/").pop() ?? "Preview"; - }, [selectedEntryPath]); - - const shouldShowInlinePreview = !isMobile && Boolean(selectedEntryPath); - - 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 handleNavigateBack = useCallback(() => { - if (!agentId || !navigateExplorerBack) { - return; - } - - if ((explorerState?.history?.length ?? 0) > 1) { - navigateExplorerBack(agentId); - } - }, [agentId, explorerState?.history?.length, navigateExplorerBack]); - - 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} - - - handleOpenMenu(item, event)} - hitSlop={8} - style={styles.menuButton} - > - - - - ); - }, - [ - explorerState?.files, - handleEntryPress, - handleOpenMenu, - theme.colors, - thumbnailLoadingMap, - viewMode, - ] - ); - - const handleSortCycle = useCallback(() => { - const currentIndex = SORT_OPTIONS.findIndex((opt) => opt.value === sortOption); - const nextIndex = (currentIndex + 1) % SORT_OPTIONS.length; - setSortOption(SORT_OPTIONS[nextIndex].value); - }, [sortOption, setSortOption]); - - const currentSortLabel = SORT_OPTIONS.find((opt) => opt.value === sortOption)?.label ?? "Name"; - - const listHeaderComponent = useMemo(() => { - const canGoBack = (explorerState?.history?.length ?? 0) > 1; - return ( - - - - {canGoBack && ( - - - - )} - - {formatDirectoryLabel(activePath)} - - - - {currentSortLabel} - - - - - ); - }, [activePath, currentSortLabel, explorerState?.history?.length, handleNavigateBack, handleSortCycle]); - - // Watch for completed file previews and process queue - useEffect(() => { - if (!explorerState) { - return; - } - - const completedPaths: string[] = []; - for (const path of inFlightPathsRef.current) { - if (explorerState.files.has(path)) { - completedPaths.push(path); - } - } - - if (completedPaths.length === 0) { - return; - } - - for (const path of completedPaths) { - inFlightPathsRef.current.delete(path); - } - - setThumbnailLoadingMap((prev) => { - const next = { ...prev }; - for (const path of completedPaths) { - delete next[path]; - } - return next; - }); - - 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 (!agentExists) { return ( @@ -703,130 +401,90 @@ export function FileExplorerPane({ } return ( - - - - {shouldShowInlinePreview ? ( - - - - - - - - - {inlinePreviewTitle} - - {selectedEntryPath ? ( - - {selectedEntryPath} - - ) : null} - - - - {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 })} - - - )} - - ) : error ? ( - - {error} - - - Retry - - {activePath !== "." && ( - requestDirectoryListing?.(agentId, ".")} - > - Go to workspace - - )} - - - ) : showInitialListLoading ? ( - - - Loading directory... - - ) : entries.length === 0 ? ( - - Directory is empty - - ) : ( - item.path} - contentContainerStyle={ - viewMode === "grid" ? styles.gridContent : styles.entriesContent + + {error ? ( + + {error} + { + if (agentId) { + requestDirectoryListing(agentId, ".", { + recordHistory: false, + setCurrentPath: false, + }); } - 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} - /> - )} + }} + > + Retry + - + ) : showInitialLoading ? ( + + + Loading files… + + ) : treeRows.length === 0 ? ( + + No files + + ) : ( + + + row.entry.path} + contentContainerStyle={styles.entriesContent} + ListHeaderComponent={listHeaderComponent} + initialNumToRender={24} + maxToRenderPerBatch={40} + windowSize={12} + /> + + + {shouldShowInlinePreview ? ( + + + + { + if (selectedEntryPath) { + void Clipboard.setStringAsync(selectedEntryPath); + } + }} + style={({ hovered, pressed }) => [ + styles.previewHeaderRow, + (hovered || pressed) && styles.previewHeaderRowHovered, + ]} + > + + {selectedEntryPath?.split("/").pop() ?? "Preview"} + + {isPreviewLoading ? ( + + ) : null} + + [ + styles.iconButton, + (hovered || pressed) && styles.previewHeaderRowHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Close preview" + > + + + + + + + + ) : null} + + )} { handleCloseMenu(); - await handleDownloadEntry(menuEntry); + handleDownloadEntry(menuEntry); }} > Download @@ -899,56 +557,95 @@ export function FileExplorerPane({ {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 })} - - - )} + ) : null} ); } -function formatDirectoryLabel(path: string): string { - return path === "." ? "workspace root" : path; +function FilePreviewBody({ + preview, + isLoading, + variant, +}: { + preview: ExplorerFile | null; + isLoading: boolean; + variant: "inline" | "sheet"; +}) { + if (isLoading && !preview) { + return ( + + + Loading file… + + ); + } + + if (!preview) { + return ( + + No preview available + + ); + } + + if (preview.kind === "text") { + if (variant === "sheet") { + return ( + + + {preview.content} + + + ); + } + return ( + + + {preview.content} + + + ); + } + + if (preview.kind === "image" && preview.content) { + if (variant === "sheet") { + return ( + + + + ); + } + return ( + + + + ); + } + + return ( + + Binary preview unavailable + {formatFileSize({ size: preview.size })} + + ); } function formatFileSize({ size }: { size: number }): string { @@ -1015,12 +712,16 @@ const TEXT_EXTENSIONS = new Set([ function renderEntryIcon( kind: EntryDisplayKind, - colors: { foreground: string; primary: string } + colors: { foreground: string; primary: string; directoryOpen?: boolean } ) { const color = colors.foreground; switch (kind) { case "directory": - return ; + return colors.directoryOpen ? ( + + ) : ( + + ); case "image": return ; case "text": @@ -1059,68 +760,138 @@ function getExtension(name: string): string | null { return name.slice(index + 1).toLowerCase(); } +function sortEntries(entries: ExplorerEntry[], sortOption: SortOption): ExplorerEntry[] { + const sorted = [...entries]; + sorted.sort((a, b) => { + if (a.kind !== b.kind) { + return a.kind === "directory" ? -1 : 1; + } + switch (sortOption) { + case "name": + return a.name.localeCompare(b.name); + case "modified": + return new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime(); + case "size": + return b.size - a.size; + default: + return 0; + } + }); + return sorted; +} + +function buildTreeRows({ + directories, + expandedPaths, + sortOption, + path, + depth, +}: { + directories: Map; + expandedPaths: Set; + sortOption: SortOption; + path: string; + depth: number; +}): TreeRow[] { + const directory = directories.get(path); + if (!directory) { + return []; + } + + const rows: TreeRow[] = []; + const entries = sortEntries(directory.entries, sortOption); + + for (const entry of entries) { + rows.push({ entry, depth }); + if (entry.kind === "directory" && expandedPaths.has(entry.path)) { + rows.push( + ...buildTreeRows({ + directories, + expandedPaths, + sortOption, + path: entry.path, + depth: depth + 1, + }) + ); + } + } + + return rows; +} + +function getParentDirectory(path: string): string { + const normalized = path.replace(/\/+$/, ""); + if (!normalized || normalized === ".") { + return "."; + } + const lastSlash = normalized.lastIndexOf("/"); + if (lastSlash === -1) { + return "."; + } + const dir = normalized.slice(0, lastSlash); + return dir.length > 0 ? dir : "."; +} + +function getAncestorDirectories(directory: string): string[] { + const trimmed = directory.replace(/^\.\/+/, "").replace(/\/+$/, ""); + if (!trimmed || trimmed === ".") { + return ["."]; + } + + const parts = trimmed.split("/").filter(Boolean); + const ancestors: string[] = ["."]; + let acc = ""; + for (const part of parts) { + acc = acc ? `${acc}/${part}` : part; + ancestors.push(acc); + } + return ancestors; +} + const styles = StyleSheet.create((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.surface0, }, - content: { + desktopSplit: { flex: 1, - flexDirection: "column", - paddingHorizontal: theme.spacing[3], - paddingBottom: theme.spacing[3], - gap: theme.spacing[3], + flexDirection: "row", + minHeight: 0, }, - listSection: { + treePane: { flex: 1, + minWidth: 0, }, - entriesContent: { - paddingBottom: theme.spacing[4], + treePaneWithPreview: { + borderRightWidth: 1, + borderRightColor: theme.colors.border, + }, + previewPane: { + flex: 1, + minWidth: 0, }, headerContainer: { - gap: theme.spacing[2], - paddingBottom: theme.spacing[2], - }, - headerRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - }, - pathContainer: { - flex: 1, - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - marginRight: theme.spacing[2], - }, - pathText: { - flex: 1, - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - fontFamily: Fonts.mono, - }, - backButton: { - padding: theme.spacing[1], - }, - backButtonText: { - fontSize: theme.fontSize.lg, - color: theme.colors.foreground, + paddingHorizontal: theme.spacing[2], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], }, sortButton: { + alignSelf: "flex-end", flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.md, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, - backgroundColor: theme.colors.surface2, }, sortButtonText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, + entriesContent: { + paddingBottom: theme.spacing[4], + }, centerState: { flex: 1, alignItems: "center", @@ -1137,30 +908,14 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, textAlign: "center", }, - errorActions: { - flexDirection: "row", - gap: theme.spacing[2], - }, 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, - }, - goToWorkspaceButton: { borderRadius: theme.borderRadius.full, borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[1], }, - goToWorkspaceButtonText: { + retryButtonText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.semibold, @@ -1174,14 +929,10 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", justifyContent: "space-between", - paddingVertical: theme.spacing[1], - paddingLeft: theme.spacing[2], - borderRadius: theme.borderRadius.md, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - marginBottom: theme.spacing[1], + paddingVertical: theme.spacing[2], + paddingRight: theme.spacing[2], }, - entryRowBackground: { + entryRowActive: { backgroundColor: theme.colors.surface2, }, entryInfo: { @@ -1206,6 +957,9 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, + menuButtonActive: { + backgroundColor: theme.colors.surface2, + }, menuOverlay: { flex: 1, }, @@ -1246,89 +1000,61 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.semibold, }, + previewHeaderContainer: { + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + previewHeaderInner: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[2], + }, + previewHeaderRow: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + borderRadius: theme.borderRadius.md, + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + }, + previewHeaderRowHovered: { + backgroundColor: theme.colors.surface2, + }, + previewHeaderText: { + flex: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, + iconButton: { + width: 32, + height: 32, + borderRadius: theme.borderRadius.md, + alignItems: "center", + justifyContent: "center", + }, + previewContent: { + flex: 1, + }, codeText: { color: theme.colors.foreground, fontFamily: Fonts.mono, fontSize: theme.fontSize.sm, flexShrink: 0, }, - gridContent: { - paddingBottom: theme.spacing[4], - paddingHorizontal: theme.spacing[1], - }, - gridColumnWrapper: { - justifyContent: "space-between", - marginBottom: theme.spacing[2], - }, - gridCard: { + previewImageScrollContent: { 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.surface2, - 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.surface2, }, - gridImageBackground: { - backgroundColor: theme.colors.surface0, - }, - gridImage: { + previewImage: { width: "100%", - height: "100%", - }, - gridName: { - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - }, - gridMeta: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - inlinePreviewContainer: { - flex: 1, - borderRadius: theme.borderRadius.lg, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface2, - overflow: "hidden", - }, - inlinePreviewHeader: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - inlinePreviewBackButton: { - padding: theme.spacing[1], - }, - inlinePreviewTitleContainer: { - flex: 1, - minWidth: 0, - }, - inlinePreviewTitle: { - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, - }, - inlinePreviewSubtitle: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - fontFamily: Fonts.mono, + aspectRatio: 1, }, sheetBackground: { backgroundColor: theme.colors.surface2, @@ -1354,12 +1080,6 @@ const styles = StyleSheet.create((theme) => ({ sheetCloseButton: { padding: theme.spacing[2], }, - sheetContent: { - flex: 1, - }, - sheetScrollContent: { - padding: theme.spacing[4], - }, sheetCenterState: { flex: 1, alignItems: "center", @@ -1367,14 +1087,4 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], padding: theme.spacing[4], }, - sheetImageScrollContent: { - flex: 1, - alignItems: "center", - justifyContent: "center", - padding: theme.spacing[4], - }, - sheetImage: { - width: "100%", - aspectRatio: 1, - }, })); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index c68be2662..ddf6f8fed 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -121,6 +121,7 @@ export const MessageInput = forwardRef( const { theme } = useUnistyles(); const voice = useVoiceOptional(); const toggleAgentList = usePanelStore((state) => state.toggleAgentList); + const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const textInputRef = useRef< TextInput | (TextInput & { getNativeRef?: () => unknown }) | null @@ -442,6 +443,13 @@ export const MessageInput = forwardRef( return; } + // Cmd+E or Ctrl+E: toggle explorer sidebar + if ((metaKey || ctrlKey) && key === "e") { + event.preventDefault(); + toggleFileExplorer(); + return; + } + // Cmd+D or Ctrl+D: start dictation or submit if already dictating if ((metaKey || ctrlKey) && key === "d") { event.preventDefault(); diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 1512c455b..08bb8ba6c 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -54,6 +54,8 @@ import * as Clipboard from "expo-clipboard"; import type { TodoEntry } from "@/types/stream"; import { extractPrincipalParam } from "@/utils/tool-call-parsers"; import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf"; +import { parseInlinePathToken, type InlinePathTarget } from "@/utils/inline-path"; +export type { InlinePathTarget } from "@/utils/inline-path"; import { resolveToolCallPreview } from "./tool-call-preview"; import { useToolCallSheet } from "./tool-call-sheet"; import { @@ -213,13 +215,6 @@ export const UserMessage = memo(function UserMessage({ ); }); -export interface InlinePathTarget { - raw: string; - path: string; - lineStart?: number; - lineEnd?: number; -} - interface AssistantMessageProps { message: string; timestamp: number; @@ -405,96 +400,6 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ }, })); -function isLikelyPathToken(value: string): boolean { - if (!value || value.length > 300) { - return false; - } - - if (/\s/.test(value)) { - return false; - } - - const hasSlash = value.includes("/") || value.includes("\\"); - const hasExtension = /\.[a-zA-Z0-9]{1,8}$/.test(value); - - if (!hasSlash && !hasExtension) { - return false; - } - - const looksLikeDir = - value.endsWith("/") || value.startsWith("./") || value.startsWith("../"); - - return hasExtension || looksLikeDir || value.includes("/"); -} - -function normalizeInlinePathValue(value: string): string | null { - const trimmed = value - .trim() - .replace(/^['"`]/, "") - .replace(/['"`]$/, ""); - if (!trimmed) { - return null; - } - - return trimmed.replace(/\\/g, "/"); -} - -function parseInlinePathToken( - value: string, - lastPathRef: React.MutableRefObject -): InlinePathTarget | null { - const rawValue = value ?? ""; - const trimmed = rawValue.trim(); - if (!trimmed) { - return null; - } - - const rangeOnlyMatch = trimmed.match(/^:([0-9]+)(?:-([0-9]+))?$/); - if (rangeOnlyMatch) { - const basePath = lastPathRef.current; - if (!basePath) { - return null; - } - const lineStart = parseInt(rangeOnlyMatch[1], 10); - const lineEnd = rangeOnlyMatch[2] - ? parseInt(rangeOnlyMatch[2], 10) - : undefined; - return { - raw: rawValue, - path: basePath, - lineStart, - lineEnd, - }; - } - - const pathMatch = trimmed.match(/^(.*?)(?::([0-9]+)(?:-([0-9]+))?)?$/); - if (!pathMatch) { - return null; - } - - const basePath = pathMatch[1]?.trim(); - if (!basePath || !isLikelyPathToken(basePath)) { - return null; - } - - const normalizedPath = normalizeInlinePathValue(basePath); - if (!normalizedPath) { - return null; - } - - lastPathRef.current = normalizedPath; - - const lineStart = pathMatch[2] ? parseInt(pathMatch[2], 10) : undefined; - const lineEnd = pathMatch[3] ? parseInt(pathMatch[3], 10) : undefined; - - return { - raw: rawValue, - path: normalizedPath, - lineStart, - lineEnd, - }; -} - export const AssistantMessage = memo(function AssistantMessage({ message, timestamp, @@ -504,7 +409,6 @@ export const AssistantMessage = memo(function AssistantMessage({ const { theme } = useUnistyles(); const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing); - const lastPathRef = useRef(null); const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); @@ -579,7 +483,7 @@ export const AssistantMessage = memo(function AssistantMessage({ ) => { const content = node.content ?? ""; const parsed = onInlinePathPress - ? parseInlinePathToken(content, lastPathRef) + ? parseInlinePathToken(content) : null; if (!parsed) { diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index ae31a7de2..33c15462f 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -241,6 +241,7 @@ const createExplorerState = () => ({ currentPath: ".", history: ["."], lastVisitedPath: ".", + selectedEntryPath: null, }); const pushHistory = (history: string[], path: string): string[] => { diff --git a/packages/app/src/contexts/voice-context.tsx b/packages/app/src/contexts/voice-context.tsx index 87220f1f5..38c222e92 100644 --- a/packages/app/src/contexts/voice-context.tsx +++ b/packages/app/src/contexts/voice-context.tsx @@ -127,14 +127,6 @@ export function VoiceProvider({ children }: VoiceProviderProps) { detectionGracePeriod: 200, }); - // Update voice detection flags whenever they change - useEffect(() => { - activeSession?.methods?.setVoiceDetectionFlags( - realtimeAudio.isDetecting, - realtimeAudio.isSpeaking - ); - }, [activeSession?.methods, realtimeAudio.isDetecting, realtimeAudio.isSpeaking]); - useEffect(() => { realtimeSessionRef.current = activeSession; }, [activeSession]); @@ -196,7 +188,6 @@ export function VoiceProvider({ children }: VoiceProviderProps) { const session = realtimeSessionRef.current; session?.audioPlayer?.stop(); await realtimeAudio.stop(); - session?.methods?.setVoiceDetectionFlags(false, false); setIsVoiceMode(false); setActiveServerId(null); console.log("[Voice] Mode disabled"); diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index ea449c82d..e3b37794a 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -11,6 +11,7 @@ function createExplorerState(): AgentFileExplorerState { currentPath: ".", history: ["."], lastVisitedPath: ".", + selectedEntryPath: null, }; } @@ -40,18 +41,30 @@ export function useFileExplorerActions(serverId: string) { ); const requestDirectoryListing = useCallback( - (agentId: string, path: string, options?: { recordHistory?: boolean }) => { + ( + agentId: string, + path: string, + options?: { recordHistory?: boolean; setCurrentPath?: boolean } + ) => { const normalizedPath = path && path.length > 0 ? path : "."; - const shouldRecordHistory = options?.recordHistory ?? true; + const shouldSetCurrentPath = options?.setCurrentPath ?? true; + const shouldRecordHistory = + options?.recordHistory ?? (shouldSetCurrentPath ? true : false); updateExplorerState(agentId, (state) => ({ ...state, isLoading: true, lastError: null, pendingRequest: { path: normalizedPath, mode: "list" }, - currentPath: normalizedPath, - history: shouldRecordHistory ? pushHistory(state.history, normalizedPath) : state.history, - lastVisitedPath: normalizedPath, + ...(shouldSetCurrentPath + ? { + currentPath: normalizedPath, + history: shouldRecordHistory + ? pushHistory(state.history, normalizedPath) + : state.history, + lastVisitedPath: normalizedPath, + } + : {}), })); if (!client) { @@ -104,6 +117,7 @@ export function useFileExplorerActions(serverId: string) { updateExplorerState(agentId, (state) => ({ ...state, isLoading: true, + lastError: null, pendingRequest: { path: normalizedPath, mode: "file" }, })); @@ -165,42 +179,20 @@ export function useFileExplorerActions(serverId: string) { [client] ); - const navigateExplorerBack = useCallback( - (agentId: string) => { - let targetPath: string | null = null; - - updateExplorerState(agentId, (state) => { - if (state.history.length <= 1) { - return state; - } - const nextHistory = state.history.slice(0, -1); - targetPath = nextHistory[nextHistory.length - 1] ?? "."; - return { - ...state, - isLoading: true, - lastError: null, - pendingRequest: { path: targetPath, mode: "list" }, - currentPath: targetPath, - history: nextHistory, - lastVisitedPath: targetPath, - }; - }); - - if (!targetPath) { - return null; - } - - requestDirectoryListing(agentId, targetPath, { recordHistory: false }); - return targetPath; + const selectExplorerEntry = useCallback( + (agentId: string, path: string | null) => { + updateExplorerState(agentId, (state) => ({ + ...state, + selectedEntryPath: path, + })); }, - [requestDirectoryListing, updateExplorerState] + [updateExplorerState] ); return { requestDirectoryListing, requestFilePreview, requestFileDownloadToken, - navigateExplorerBack, + selectExplorerEntry, }; } - diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index 53479a13a..754f819e6 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -28,12 +28,12 @@ interface DesktopSidebarState { } export type ExplorerTab = "changes" | "files"; -export type ViewMode = "list" | "grid"; export type SortOption = "name" | "modified" | "size"; export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400; export const MIN_EXPLORER_SIDEBAR_WIDTH = 280; -export const MAX_EXPLORER_SIDEBAR_WIDTH = 800; +// Upper bound is intentionally generous; desktop resizing enforces a min-chat-width constraint. +export const MAX_EXPLORER_SIDEBAR_WIDTH = 2000; interface PanelState { // Mobile: which panel is currently shown @@ -45,7 +45,6 @@ interface PanelState { // File explorer settings (shared between mobile/desktop) explorerTab: ExplorerTab; explorerWidth: number; - explorerViewMode: ViewMode; explorerSortOption: SortOption; // Actions @@ -58,7 +57,6 @@ interface PanelState { // File explorer settings actions setExplorerTab: (tab: ExplorerTab) => void; setExplorerWidth: (width: number) => void; - setExplorerViewMode: (mode: ViewMode) => void; setExplorerSortOption: (option: SortOption) => void; } @@ -83,7 +81,6 @@ export const usePanelStore = create()( // File explorer defaults explorerTab: "changes", explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH, - explorerViewMode: "list", explorerSortOption: "name", openAgentList: () => @@ -139,7 +136,6 @@ export const usePanelStore = create()( setExplorerTab: (tab) => set({ explorerTab: tab }), setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }), - setExplorerViewMode: (mode) => set({ explorerViewMode: mode }), setExplorerSortOption: (option) => set({ explorerSortOption: option }), }), { @@ -150,7 +146,6 @@ export const usePanelStore = create()( desktop: state.desktop, explorerTab: state.explorerTab, explorerWidth: state.explorerWidth, - explorerViewMode: state.explorerViewMode, explorerSortOption: state.explorerSortOption, }), } @@ -181,11 +176,9 @@ export function usePanelState(isMobile: boolean) { // Explorer settings explorerTab: store.explorerTab, explorerWidth: store.explorerWidth, - explorerViewMode: store.explorerViewMode, explorerSortOption: store.explorerSortOption, setExplorerTab: store.setExplorerTab, setExplorerWidth: store.setExplorerWidth, - setExplorerViewMode: store.setExplorerViewMode, setExplorerSortOption: store.setExplorerSortOption, }; } @@ -209,11 +202,9 @@ export function usePanelState(isMobile: boolean) { // Explorer settings explorerTab: store.explorerTab, explorerWidth: store.explorerWidth, - explorerViewMode: store.explorerViewMode, explorerSortOption: store.explorerSortOption, setExplorerTab: store.setExplorerTab, setExplorerWidth: store.setExplorerWidth, - setExplorerViewMode: store.setExplorerViewMode, setExplorerSortOption: store.setExplorerSortOption, }; } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 37552812d..44dd8d864 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -137,6 +137,7 @@ export interface AgentFileExplorerState { currentPath: string; history: string[]; lastVisitedPath: string; + selectedEntryPath: string | null; } export interface DaemonConnectionSnapshot { diff --git a/packages/app/src/utils/inline-path.test.ts b/packages/app/src/utils/inline-path.test.ts new file mode 100644 index 000000000..9569c25d2 --- /dev/null +++ b/packages/app/src/utils/inline-path.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { parseInlinePathToken } from "./inline-path"; + +describe("parseInlinePathToken", () => { + it("returns null for plain paths (no line)", () => { + expect(parseInlinePathToken("src/app.ts")).toBeNull(); + expect(parseInlinePathToken("README.md")).toBeNull(); + }); + + it("parses filename:line", () => { + expect(parseInlinePathToken("src/app.ts:12")).toEqual({ + raw: "src/app.ts:12", + path: "src/app.ts", + lineStart: 12, + lineEnd: undefined, + }); + }); + + it("parses filename:lineStart-lineEnd", () => { + expect(parseInlinePathToken("src/app.ts:12-20")).toEqual({ + raw: "src/app.ts:12-20", + path: "src/app.ts", + lineStart: 12, + lineEnd: 20, + }); + }); + + it("rejects range-only :line tokens", () => { + expect(parseInlinePathToken(":12")).toBeNull(); + expect(parseInlinePathToken(":12-20")).toBeNull(); + }); +}); + diff --git a/packages/app/src/utils/inline-path.ts b/packages/app/src/utils/inline-path.ts new file mode 100644 index 000000000..605b87231 --- /dev/null +++ b/packages/app/src/utils/inline-path.ts @@ -0,0 +1,81 @@ +export interface InlinePathTarget { + raw: string; + path: string; + lineStart?: number; + lineEnd?: number; +} + +function normalizePathToken(value: string): string | null { + const trimmed = value + .trim() + .replace(/^['"`]/, "") + .replace(/['"`]$/, ""); + + if (!trimmed) { + return null; + } + + return trimmed.replace(/\\/g, "/"); +} + +/** + * Strict VSCode-style markers only. + * + * Supported: + * - `filename:linenumber` + * - `filename:lineStart-lineEnd` + * + * Not supported (by design): + * - plain `filename` (no line) + * - `:linenumber` (range-only) + */ +export function parseInlinePathToken(value: string): InlinePathTarget | null { + const rawValue = value ?? ""; + const trimmed = rawValue.trim(); + if (!trimmed) { + return null; + } + + const match = trimmed.match(/^(.+?):([0-9]+)(?:-([0-9]+))?$/); + if (!match) { + return null; + } + + const basePathRaw = match[1]?.trim(); + if (!basePathRaw) { + return null; + } + + // Avoid accidentally treating URLs as file paths. + if (basePathRaw.includes("://")) { + return null; + } + + const normalizedPath = normalizePathToken(basePathRaw); + if (!normalizedPath) { + return null; + } + + const lineStart = parseInt(match[2], 10); + if (!Number.isFinite(lineStart) || lineStart <= 0) { + return null; + } + + const lineEnd = match[3] ? parseInt(match[3], 10) : undefined; + if (lineEnd !== undefined) { + if (!Number.isFinite(lineEnd) || lineEnd <= 0) { + return null; + } + if (lineEnd < lineStart) { + return null; + } + } + + return { + raw: rawValue, + path: normalizedPath, + lineStart, + lineEnd, + }; +} +