Merge branch 'streamline-file-explorer'

This commit is contained in:
Mohamed Boudra
2026-02-04 10:35:13 +07:00
12 changed files with 726 additions and 1023 deletions
@@ -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,
]
@@ -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}
/>
</Animated.View>
@@ -219,8 +234,6 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
serverId={serverId}
agentId={agentId}
cwd={cwd}
fileViewMode={explorerViewMode}
onFileViewModeChange={setExplorerViewMode}
isMobile={false}
/>
</Animated.View>
@@ -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({
</Pressable>
</View>
<View style={styles.headerRightSection}>
{effectiveTab === "files" && (
<ViewToggle viewMode={fileViewMode} onChange={onFileViewModeChange} />
)}
{isMobile && (
<Pressable onPress={onClose} style={styles.closeButton}>
<X size={18} color={theme.colors.foregroundMuted} />
@@ -316,33 +322,6 @@ function SidebarContent({
);
}
function ViewToggle({
viewMode,
onChange,
}: {
viewMode: ViewMode;
onChange: (mode: ViewMode) => void;
}) {
const { theme } = useUnistyles();
return (
<View style={styles.viewToggleContainer}>
<Pressable
style={[styles.viewToggleButton, viewMode === "list" && styles.viewToggleActive]}
onPress={() => onChange("list")}
>
<ListIcon size={14} color={theme.colors.foreground} />
</Pressable>
<Pressable
style={[styles.viewToggleButton, viewMode === "grid" && styles.viewToggleActive]}
onPress={() => onChange("grid")}
>
<LayoutGrid size={14} color={theme.colors.foreground} />
</Pressable>
</View>
);
}
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,
},
}));
File diff suppressed because it is too large Load Diff
@@ -121,6 +121,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
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<MessageInputRef, MessageInputProps>(
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();
+3 -99
View File
@@ -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<string | null>
): 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<string | null>(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) {