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
);
}
-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,
+ };
+}
+