Merge branch 'main' into keyword-nav-plan
# Conflicts: # packages/app/src/components/agent-input-area.tsx # packages/app/src/components/command-center.tsx # packages/app/src/hooks/use-global-keyboard-nav.ts
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
|||||||
Pressable,
|
Pressable,
|
||||||
Text,
|
Text,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Platform,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
@@ -70,6 +71,8 @@ export function AgentInputArea({
|
|||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
const isScreenFocused = useIsFocused();
|
const isScreenFocused = useIsFocused();
|
||||||
|
const focusChatInputRequest = useKeyboardNavStore((s) => s.focusChatInputRequest);
|
||||||
|
const clearFocusChatInputRequest = useKeyboardNavStore((s) => s.clearFocusChatInputRequest);
|
||||||
|
|
||||||
const client = useSessionStore(
|
const client = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.client ?? null
|
(state) => state.sessions[serverId]?.client ?? null
|
||||||
@@ -100,8 +103,7 @@ export function AgentInputArea({
|
|||||||
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
|
||||||
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
|
||||||
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
|
const [commandSelectedIndex, setCommandSelectedIndex] = useState(0);
|
||||||
const focusChatInputRequest = useKeyboardNavStore((s) => s.focusChatInputRequest);
|
const lastHandledFocusRequestIdRef = useRef<number | null>(null);
|
||||||
const clearFocusChatInputRequest = useKeyboardNavStore((s) => s.clearFocusChatInputRequest);
|
|
||||||
|
|
||||||
// Command autocomplete logic
|
// Command autocomplete logic
|
||||||
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
|
||||||
@@ -356,6 +358,41 @@ export function AgentInputArea({
|
|||||||
saveDraftInput(agentId, { text: userInput, images: selectedImages });
|
saveDraftInput(agentId, { text: userInput, images: selectedImages });
|
||||||
}, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]);
|
}, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]);
|
||||||
|
|
||||||
|
// When switching agents from the command center, auto-focus the input on web.
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS !== "web") return;
|
||||||
|
if (!isScreenFocused) return;
|
||||||
|
if (!focusChatInputRequest) return;
|
||||||
|
|
||||||
|
const currentKey = `${serverId}:${agentId}`;
|
||||||
|
if (focusChatInputRequest.agentKey !== currentKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastHandledFocusRequestIdRef.current === focusChatInputRequest.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastHandledFocusRequestIdRef.current = focusChatInputRequest.id;
|
||||||
|
|
||||||
|
return focusWithRetries({
|
||||||
|
focus: () => messageInputRef.current?.focus(),
|
||||||
|
isFocused: () => {
|
||||||
|
const el = messageInputRef.current?.getNativeElement?.() ?? null;
|
||||||
|
const active =
|
||||||
|
typeof document !== "undefined" ? document.activeElement : null;
|
||||||
|
return Boolean(el) && active === el;
|
||||||
|
},
|
||||||
|
onSuccess: () => clearFocusChatInputRequest(),
|
||||||
|
onTimeout: () => clearFocusChatInputRequest(),
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
agentId,
|
||||||
|
clearFocusChatInputRequest,
|
||||||
|
focusChatInputRequest,
|
||||||
|
isScreenFocused,
|
||||||
|
serverId,
|
||||||
|
]);
|
||||||
|
|
||||||
const keyboardAnimatedStyle = useAnimatedStyle(() => {
|
const keyboardAnimatedStyle = useAnimatedStyle(() => {
|
||||||
"worklet";
|
"worklet";
|
||||||
const absoluteHeight = Math.abs(keyboardHeight.value);
|
const absoluteHeight = Math.abs(keyboardHeight.value);
|
||||||
@@ -402,27 +439,6 @@ export function AgentInputArea({
|
|||||||
queueMessage(payload.text, payload.images);
|
queueMessage(payload.text, payload.images);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const req = focusChatInputRequest;
|
|
||||||
if (!req) return;
|
|
||||||
if (req.agentKey !== `${serverId}:${agentId}`) return;
|
|
||||||
|
|
||||||
return focusWithRetries({
|
|
||||||
focus: () => messageInputRef.current?.focus(),
|
|
||||||
isFocused: () => {
|
|
||||||
const el = messageInputRef.current?.getNativeElement?.() ?? null;
|
|
||||||
const active =
|
|
||||||
typeof document !== "undefined" ? document.activeElement : null;
|
|
||||||
return Boolean(el) && active === el;
|
|
||||||
},
|
|
||||||
onSuccess: () => clearFocusChatInputRequest(),
|
|
||||||
onTimeout: () => {
|
|
||||||
// Don't keep stealing focus forever; allow other interactions.
|
|
||||||
clearFocusChatInputRequest();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [agentId, clearFocusChatInputRequest, focusChatInputRequest, serverId]);
|
|
||||||
|
|
||||||
// Handle command selection from autocomplete
|
// Handle command selection from autocomplete
|
||||||
const handleCommandSelect = useCallback(
|
const handleCommandSelect = useCallback(
|
||||||
(cmd: { name: string; description: string; argumentHint: string }) => {
|
(cmd: { name: string; description: string; argumentHint: string }) => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const IS_WEB = Platform.OS === "web";
|
const IS_WEB = Platform.OS === "web";
|
||||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||||
|
const dropdownMaxWidth = IS_WEB ? 360 : undefined;
|
||||||
|
|
||||||
// Select only the specific agent (not all agents)
|
// Select only the specific agent (not all agents)
|
||||||
const agent = useSessionStore((state) =>
|
const agent = useSessionStore((state) =>
|
||||||
@@ -101,6 +102,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
side="top"
|
side="top"
|
||||||
align="start"
|
align="start"
|
||||||
|
maxWidth={dropdownMaxWidth}
|
||||||
testID="agent-mode-menu"
|
testID="agent-mode-menu"
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel>Mode</DropdownMenuLabel>
|
<DropdownMenuLabel>Mode</DropdownMenuLabel>
|
||||||
@@ -138,7 +140,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="top" align="start" testID="agent-model-menu">
|
<DropdownMenuContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
maxWidth={dropdownMaxWidth}
|
||||||
|
testID="agent-model-menu"
|
||||||
|
>
|
||||||
<DropdownMenuLabel>Model</DropdownMenuLabel>
|
<DropdownMenuLabel>Model</DropdownMenuLabel>
|
||||||
{models?.map((model) => {
|
{models?.map((model) => {
|
||||||
const isActive = model.id === agent.model;
|
const isActive = model.id === agent.model;
|
||||||
@@ -178,7 +185,12 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
|
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
|
||||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="top" align="start" testID="agent-thinking-menu">
|
<DropdownMenuContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
maxWidth={dropdownMaxWidth}
|
||||||
|
testID="agent-thinking-menu"
|
||||||
|
>
|
||||||
<DropdownMenuLabel>Thinking</DropdownMenuLabel>
|
<DropdownMenuLabel>Thinking</DropdownMenuLabel>
|
||||||
{thinkingOptions.map((opt) => {
|
{thinkingOptions.map((opt) => {
|
||||||
const isActive = opt.id === selectedThinkingId;
|
const isActive = opt.id === selectedThinkingId;
|
||||||
@@ -331,15 +343,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
container: {
|
container: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[3],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
modeBadge: {
|
modeBadge: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[1],
|
||||||
borderRadius: theme.borderRadius["2xl"],
|
borderRadius: theme.borderRadius["2xl"],
|
||||||
},
|
},
|
||||||
modeBadgePressed: {
|
modeBadgePressed: {
|
||||||
@@ -348,8 +360,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
modeBadgeText: {
|
modeBadgeText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.normal,
|
||||||
textTransform: "capitalize",
|
|
||||||
},
|
},
|
||||||
prefsButton: {
|
prefsButton: {
|
||||||
width: 32,
|
width: 32,
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ export function AgentStreamView({
|
|||||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId)
|
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
|
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
|
||||||
// tracked in react-native-reanimated#8422.
|
// tracked in react-native-reanimated#8422.
|
||||||
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
const shouldDisableEntryExitAnimations = Platform.OS === "android";
|
||||||
@@ -123,8 +124,12 @@ export function AgentStreamView({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestDirectoryListing(agentId, normalized.directory);
|
requestDirectoryListing(agentId, normalized.directory, {
|
||||||
|
recordHistory: false,
|
||||||
|
setCurrentPath: false,
|
||||||
|
});
|
||||||
if (normalized.file) {
|
if (normalized.file) {
|
||||||
|
selectExplorerEntry(agentId, normalized.file);
|
||||||
requestFilePreview(agentId, normalized.file);
|
requestFilePreview(agentId, normalized.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +141,7 @@ export function AgentStreamView({
|
|||||||
agentId,
|
agentId,
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
requestFilePreview,
|
requestFilePreview,
|
||||||
|
selectExplorerEntry,
|
||||||
setExplorerTab,
|
setExplorerTab,
|
||||||
openFileExplorer,
|
openFileExplorer,
|
||||||
]
|
]
|
||||||
@@ -206,37 +212,46 @@ export function AgentStreamView({
|
|||||||
const tightGap = theme.spacing[1]; // 4px
|
const tightGap = theme.spacing[1]; // 4px
|
||||||
const looseGap = theme.spacing[4]; // 16px
|
const looseGap = theme.spacing[4]; // 16px
|
||||||
|
|
||||||
// In inverted lists, marginBottom renders as visual gap ABOVE the item.
|
// The FlatList is inverted, but each row is re-inverted by RN, so `marginBottom` still
|
||||||
// Index 0 is visually at the bottom, so use the item ABOVE (index + 1) to compute spacing.
|
// manifests as the gap *below* the row in the final visual layout. Compute spacing
|
||||||
const getGapAbove = useCallback(
|
// against the item visually below (index - 1 in our inverted list).
|
||||||
|
const getGapBelow = useCallback(
|
||||||
(item: StreamItem, index: number) => {
|
(item: StreamItem, index: number) => {
|
||||||
const aboveItem = flatListData[index + 1];
|
const belowItem = flatListData[index - 1];
|
||||||
if (!aboveItem) {
|
if (!belowItem) {
|
||||||
// This is the topmost item; nothing above it visually.
|
// This is the bottommost (newest) item; nothing below it visually.
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same type groups get tight gap (4px)
|
// Same type groups get tight gap (4px)
|
||||||
if (isUserMessageItem(item) && isUserMessageItem(aboveItem)) {
|
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
||||||
return tightGap;
|
return tightGap;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isToolSequenceItem(item) && isToolSequenceItem(aboveItem)) {
|
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
|
||||||
return tightGap;
|
return tightGap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep tool sequences visually connected to the preceding user message / tasks.
|
// Give user messages more breathing room before tool sequences.
|
||||||
|
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
|
||||||
|
return looseGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep tool sequences visually connected to the preceding user/assistant message.
|
||||||
if (
|
if (
|
||||||
isToolSequenceItem(item) &&
|
(item.kind === "user_message" || item.kind === "assistant_message") &&
|
||||||
(aboveItem.kind === "user_message" ||
|
isToolSequenceItem(belowItem)
|
||||||
aboveItem.kind === "assistant_message" ||
|
|
||||||
aboveItem.kind === "todo_list")
|
|
||||||
) {
|
) {
|
||||||
return tightGap;
|
return tightGap;
|
||||||
}
|
}
|
||||||
|
|
||||||
// And keep assistant messages visually connected to tool sequences (symmetry).
|
// Keep todo lists visually connected to the following tool sequence (symmetry).
|
||||||
if (item.kind === "assistant_message" && isToolSequenceItem(aboveItem)) {
|
if (item.kind === "todo_list" && isToolSequenceItem(belowItem)) {
|
||||||
|
return tightGap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep tool sequences visually connected to the assistant response (symmetry).
|
||||||
|
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
|
||||||
return tightGap;
|
return tightGap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +389,7 @@ export function AgentStreamView({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gapAbove = getGapAbove(item, index);
|
const gapBelow = getGapBelow(item, index);
|
||||||
|
|
||||||
// Check if this is the end of a turn (before a user message or end of stream when not running)
|
// Check if this is the end of a turn (before a user message or end of stream when not running)
|
||||||
// In inverted list: index-1 is the next item (newer in time)
|
// In inverted list: index-1 is the next item (newer in time)
|
||||||
@@ -387,14 +402,14 @@ export function AgentStreamView({
|
|||||||
const getContent = () => collectTurnContent(index);
|
const getContent = () => collectTurnContent(index);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapAbove }]}>
|
<View style={[stylesheet.streamItemWrapper, { marginBottom: gapBelow }]}>
|
||||||
{content}
|
{content}
|
||||||
{isEndOfTurn ? <TurnCopyButton getContent={getContent} /> : null}
|
{isEndOfTurn ? <TurnCopyButton getContent={getContent} /> : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
getGapAbove,
|
getGapBelow,
|
||||||
renderStreamItemContent,
|
renderStreamItemContent,
|
||||||
flatListData,
|
flatListData,
|
||||||
agent.status,
|
agent.status,
|
||||||
@@ -490,7 +505,16 @@ export function AgentStreamView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.contentWrapper}>
|
<View style={stylesheet.contentWrapper}>
|
||||||
<View style={stylesheet.listHeaderContent}>
|
<View
|
||||||
|
style={[
|
||||||
|
stylesheet.listHeaderContent,
|
||||||
|
// In an inverted FlatList, the header is rendered at the visual bottom, so its
|
||||||
|
// top edge can sit directly against the newest timeline item. Add a small
|
||||||
|
// padding to prevent badges (e.g. Thinking/Setup) from butting up against
|
||||||
|
// the last user message when the streaming head is present.
|
||||||
|
hasHeadItems ? { paddingTop: tightGap } : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
{hasPermissions ? (
|
{hasPermissions ? (
|
||||||
<View style={stylesheet.permissionsContainer}>
|
<View style={stylesheet.permissionsContainer}>
|
||||||
{pendingPermissionItems.map((permission) => (
|
{pendingPermissionItems.map((permission) => (
|
||||||
@@ -528,6 +552,7 @@ export function AgentStreamView({
|
|||||||
client,
|
client,
|
||||||
streamHead,
|
streamHead,
|
||||||
renderStreamItemContent,
|
renderStreamItemContent,
|
||||||
|
tightGap,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const flatListExtraData = useMemo(
|
const flatListExtraData = useMemo(
|
||||||
@@ -538,6 +563,16 @@ export function AgentStreamView({
|
|||||||
[pendingPermissionItems.length, showWorkingIndicator]
|
[pendingPermissionItems.length, showWorkingIndicator]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// FlatList's ListHeaderComponent renders at the *bottom* when inverted.
|
||||||
|
// Without explicit spacing, the newest "head" rows (like Thinking/Setup tool badges)
|
||||||
|
// can butt up directly against the most recent stream item.
|
||||||
|
const headerGapStyle = useMemo(() => {
|
||||||
|
if (!listHeaderComponent) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { marginBottom: tightGap };
|
||||||
|
}, [listHeaderComponent, tightGap]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCallSheetProvider>
|
<ToolCallSheetProvider>
|
||||||
<View style={stylesheet.container}>
|
<View style={stylesheet.container}>
|
||||||
@@ -547,6 +582,7 @@ export function AgentStreamView({
|
|||||||
data={flatListData}
|
data={flatListData}
|
||||||
renderItem={renderStreamItem}
|
renderItem={renderStreamItem}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item) => item.id}
|
||||||
|
ListHeaderComponentStyle={headerGapStyle}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingVertical: 0,
|
paddingVertical: 0,
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { View, Text, Pressable, Platform } from "react-native";
|
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
@@ -8,12 +8,11 @@ import Animated, {
|
|||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
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 {
|
import {
|
||||||
usePanelStore,
|
usePanelStore,
|
||||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||||
type ViewMode,
|
|
||||||
type ExplorerTab,
|
type ExplorerTab,
|
||||||
} from "@/stores/panel-store";
|
} from "@/stores/panel-store";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
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 { GitDiffPane } from "./git-diff-pane";
|
||||||
import { FileExplorerPane } from "./file-explorer-pane";
|
import { FileExplorerPane } from "./file-explorer-pane";
|
||||||
|
|
||||||
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
|
||||||
interface ExplorerSidebarProps {
|
interface ExplorerSidebarProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -38,10 +39,22 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||||
const explorerViewMode = usePanelStore((state) => state.explorerViewMode);
|
|
||||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
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
|
// Derive isOpen from the unified panel state
|
||||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
@@ -133,16 +146,20 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
// Dragging left (negative translationX) increases width
|
// Dragging left (negative translationX) increases width
|
||||||
const newWidth = startWidthRef.current - event.translationX;
|
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(
|
const clampedWidth = Math.max(
|
||||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||||
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, newWidth)
|
Math.min(maxWidth, newWidth)
|
||||||
);
|
);
|
||||||
resizeWidth.value = clampedWidth;
|
resizeWidth.value = clampedWidth;
|
||||||
})
|
})
|
||||||
.onEnd(() => {
|
.onEnd(() => {
|
||||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||||
}),
|
}),
|
||||||
[isMobile, explorerWidth, resizeWidth, setExplorerWidth]
|
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth]
|
||||||
);
|
);
|
||||||
|
|
||||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
@@ -185,8 +202,6 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
fileViewMode={explorerViewMode}
|
|
||||||
onFileViewModeChange={setExplorerViewMode}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -219,8 +234,6 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
fileViewMode={explorerViewMode}
|
|
||||||
onFileViewModeChange={setExplorerViewMode}
|
|
||||||
isMobile={false}
|
isMobile={false}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -234,8 +247,6 @@ interface SidebarContentProps {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
fileViewMode: ViewMode;
|
|
||||||
onFileViewModeChange: (mode: ViewMode) => void;
|
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,8 +257,6 @@ function SidebarContent({
|
|||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
cwd,
|
cwd,
|
||||||
fileViewMode,
|
|
||||||
onFileViewModeChange,
|
|
||||||
isMobile,
|
isMobile,
|
||||||
}: SidebarContentProps) {
|
}: SidebarContentProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -292,9 +301,6 @@ function SidebarContent({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.headerRightSection}>
|
<View style={styles.headerRightSection}>
|
||||||
{effectiveTab === "files" && (
|
|
||||||
<ViewToggle viewMode={fileViewMode} onChange={onFileViewModeChange} />
|
|
||||||
)}
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||||
<X size={18} color={theme.colors.foregroundMuted} />
|
<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) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
backdrop: {
|
backdrop: {
|
||||||
...StyleSheet.absoluteFillObject,
|
...StyleSheet.absoluteFillObject,
|
||||||
@@ -423,17 +402,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
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
@@ -126,6 +126,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const voice = useVoiceOptional();
|
const voice = useVoiceOptional();
|
||||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||||
|
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||||
const textInputRef = useRef<
|
const textInputRef = useRef<
|
||||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||||
@@ -458,6 +459,13 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
return;
|
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
|
// Cmd+D or Ctrl+D: start dictation or submit if already dictating
|
||||||
if ((metaKey || ctrlKey) && key === "d") {
|
if ((metaKey || ctrlKey) && key === "d") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import * as Clipboard from "expo-clipboard";
|
|||||||
import type { TodoEntry } from "@/types/stream";
|
import type { TodoEntry } from "@/types/stream";
|
||||||
import { extractPrincipalParam } from "@/utils/tool-call-parsers";
|
import { extractPrincipalParam } from "@/utils/tool-call-parsers";
|
||||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
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 { resolveToolCallPreview } from "./tool-call-preview";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import {
|
import {
|
||||||
@@ -213,13 +215,6 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface InlinePathTarget {
|
|
||||||
raw: string;
|
|
||||||
path: string;
|
|
||||||
lineStart?: number;
|
|
||||||
lineEnd?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AssistantMessageProps {
|
interface AssistantMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
timestamp: number;
|
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({
|
export const AssistantMessage = memo(function AssistantMessage({
|
||||||
message,
|
message,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -504,7 +409,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const resolvedDisableOuterSpacing =
|
const resolvedDisableOuterSpacing =
|
||||||
useDisableOuterSpacing(disableOuterSpacing);
|
useDisableOuterSpacing(disableOuterSpacing);
|
||||||
const lastPathRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||||
|
|
||||||
@@ -579,7 +483,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
) => {
|
) => {
|
||||||
const content = node.content ?? "";
|
const content = node.content ?? "";
|
||||||
const parsed = onInlinePathPress
|
const parsed = onInlinePathPress
|
||||||
? parseInlinePathToken(content, lastPathRef)
|
? parseInlinePathToken(content)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
|
|||||||
@@ -241,6 +241,7 @@ const createExplorerState = () => ({
|
|||||||
currentPath: ".",
|
currentPath: ".",
|
||||||
history: ["."],
|
history: ["."],
|
||||||
lastVisitedPath: ".",
|
lastVisitedPath: ".",
|
||||||
|
selectedEntryPath: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const pushHistory = (history: string[], path: string): string[] => {
|
const pushHistory = (history: string[], path: string): string[] => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function createExplorerState(): AgentFileExplorerState {
|
|||||||
currentPath: ".",
|
currentPath: ".",
|
||||||
history: ["."],
|
history: ["."],
|
||||||
lastVisitedPath: ".",
|
lastVisitedPath: ".",
|
||||||
|
selectedEntryPath: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,18 +41,30 @@ export function useFileExplorerActions(serverId: string) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const requestDirectoryListing = useCallback(
|
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 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) => ({
|
updateExplorerState(agentId, (state) => ({
|
||||||
...state,
|
...state,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
pendingRequest: { path: normalizedPath, mode: "list" },
|
pendingRequest: { path: normalizedPath, mode: "list" },
|
||||||
currentPath: normalizedPath,
|
...(shouldSetCurrentPath
|
||||||
history: shouldRecordHistory ? pushHistory(state.history, normalizedPath) : state.history,
|
? {
|
||||||
lastVisitedPath: normalizedPath,
|
currentPath: normalizedPath,
|
||||||
|
history: shouldRecordHistory
|
||||||
|
? pushHistory(state.history, normalizedPath)
|
||||||
|
: state.history,
|
||||||
|
lastVisitedPath: normalizedPath,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -104,6 +117,7 @@ export function useFileExplorerActions(serverId: string) {
|
|||||||
updateExplorerState(agentId, (state) => ({
|
updateExplorerState(agentId, (state) => ({
|
||||||
...state,
|
...state,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
lastError: null,
|
||||||
pendingRequest: { path: normalizedPath, mode: "file" },
|
pendingRequest: { path: normalizedPath, mode: "file" },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -165,42 +179,20 @@ export function useFileExplorerActions(serverId: string) {
|
|||||||
[client]
|
[client]
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigateExplorerBack = useCallback(
|
const selectExplorerEntry = useCallback(
|
||||||
(agentId: string) => {
|
(agentId: string, path: string | null) => {
|
||||||
let targetPath: string | null = null;
|
updateExplorerState(agentId, (state) => ({
|
||||||
|
...state,
|
||||||
updateExplorerState(agentId, (state) => {
|
selectedEntryPath: path,
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
[requestDirectoryListing, updateExplorerState]
|
[updateExplorerState]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requestDirectoryListing,
|
requestDirectoryListing,
|
||||||
requestFilePreview,
|
requestFilePreview,
|
||||||
requestFileDownloadToken,
|
requestFileDownloadToken,
|
||||||
navigateExplorerBack,
|
selectExplorerEntry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export function useGlobalKeyboardNav({
|
|||||||
(event.metaKey || event.ctrlKey) &&
|
(event.metaKey || event.ctrlKey) &&
|
||||||
(event.code === "KeyE" || lowerKey === "e")
|
(event.code === "KeyE" || lowerKey === "e")
|
||||||
) {
|
) {
|
||||||
|
// Same double-toggle issue as Cmd+B when focus is inside a text input.
|
||||||
if (isEditableTarget(event)) {
|
if (isEditableTarget(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,3 +44,4 @@ export const useKeyboardNavStore = create<KeyboardNavState>((set, get) => ({
|
|||||||
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
||||||
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,16 @@ interface DesktopSidebarState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ExplorerTab = "changes" | "files";
|
export type ExplorerTab = "changes" | "files";
|
||||||
export type ViewMode = "list" | "grid";
|
|
||||||
export type SortOption = "name" | "modified" | "size";
|
export type SortOption = "name" | "modified" | "size";
|
||||||
|
|
||||||
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
|
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = Platform.OS === "web" ? 520 : 400;
|
||||||
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
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;
|
||||||
|
|
||||||
|
export const DEFAULT_EXPLORER_FILES_SPLIT_RATIO = 0.38;
|
||||||
|
export const MIN_EXPLORER_FILES_SPLIT_RATIO = 0.2;
|
||||||
|
export const MAX_EXPLORER_FILES_SPLIT_RATIO = 0.8;
|
||||||
|
|
||||||
interface PanelState {
|
interface PanelState {
|
||||||
// Mobile: which panel is currently shown
|
// Mobile: which panel is currently shown
|
||||||
@@ -45,8 +49,8 @@ interface PanelState {
|
|||||||
// File explorer settings (shared between mobile/desktop)
|
// File explorer settings (shared between mobile/desktop)
|
||||||
explorerTab: ExplorerTab;
|
explorerTab: ExplorerTab;
|
||||||
explorerWidth: number;
|
explorerWidth: number;
|
||||||
explorerViewMode: ViewMode;
|
|
||||||
explorerSortOption: SortOption;
|
explorerSortOption: SortOption;
|
||||||
|
explorerFilesSplitRatio: number;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
openAgentList: () => void;
|
openAgentList: () => void;
|
||||||
@@ -58,12 +62,23 @@ interface PanelState {
|
|||||||
// File explorer settings actions
|
// File explorer settings actions
|
||||||
setExplorerTab: (tab: ExplorerTab) => void;
|
setExplorerTab: (tab: ExplorerTab) => void;
|
||||||
setExplorerWidth: (width: number) => void;
|
setExplorerWidth: (width: number) => void;
|
||||||
setExplorerViewMode: (mode: ViewMode) => void;
|
|
||||||
setExplorerSortOption: (option: SortOption) => void;
|
setExplorerSortOption: (option: SortOption) => void;
|
||||||
|
setExplorerFilesSplitRatio: (ratio: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampNumber(value: number, min: number, max: number): number {
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return min;
|
||||||
|
}
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampWidth(width: number): number {
|
function clampWidth(width: number): number {
|
||||||
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, width));
|
return clampNumber(width, MIN_EXPLORER_SIDEBAR_WIDTH, MAX_EXPLORER_SIDEBAR_WIDTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampExplorerFilesSplitRatio(ratio: number): number {
|
||||||
|
return clampNumber(ratio, MIN_EXPLORER_FILES_SPLIT_RATIO, MAX_EXPLORER_FILES_SPLIT_RATIO);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
|
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
|
||||||
@@ -83,8 +98,8 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
// File explorer defaults
|
// File explorer defaults
|
||||||
explorerTab: "changes",
|
explorerTab: "changes",
|
||||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||||
explorerViewMode: "list",
|
|
||||||
explorerSortOption: "name",
|
explorerSortOption: "name",
|
||||||
|
explorerFilesSplitRatio: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||||
|
|
||||||
openAgentList: () =>
|
openAgentList: () =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -139,19 +154,48 @@ export const usePanelStore = create<PanelState>()(
|
|||||||
|
|
||||||
setExplorerTab: (tab) => set({ explorerTab: tab }),
|
setExplorerTab: (tab) => set({ explorerTab: tab }),
|
||||||
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
|
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
|
||||||
setExplorerViewMode: (mode) => set({ explorerViewMode: mode }),
|
|
||||||
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
||||||
|
setExplorerFilesSplitRatio: (ratio) =>
|
||||||
|
set({
|
||||||
|
explorerFilesSplitRatio: Number.isFinite(ratio)
|
||||||
|
? clampExplorerFilesSplitRatio(ratio)
|
||||||
|
: DEFAULT_EXPLORER_FILES_SPLIT_RATIO,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "panel-state",
|
name: "panel-state",
|
||||||
|
version: 2,
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
migrate: (persistedState, version) => {
|
||||||
|
const state = persistedState as Partial<PanelState> & Record<string, unknown>;
|
||||||
|
|
||||||
|
if (version < 2) {
|
||||||
|
if (
|
||||||
|
Platform.OS === "web" &&
|
||||||
|
typeof state.explorerWidth === "number" &&
|
||||||
|
state.explorerWidth === 400
|
||||||
|
) {
|
||||||
|
state.explorerWidth = DEFAULT_EXPLORER_SIDEBAR_WIDTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof state.explorerFilesSplitRatio !== "number") {
|
||||||
|
state.explorerFilesSplitRatio = DEFAULT_EXPLORER_FILES_SPLIT_RATIO;
|
||||||
|
} else {
|
||||||
|
state.explorerFilesSplitRatio = clampExplorerFilesSplitRatio(
|
||||||
|
state.explorerFilesSplitRatio
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state as PanelState;
|
||||||
|
},
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
mobileView: state.mobileView,
|
mobileView: state.mobileView,
|
||||||
desktop: state.desktop,
|
desktop: state.desktop,
|
||||||
explorerTab: state.explorerTab,
|
explorerTab: state.explorerTab,
|
||||||
explorerWidth: state.explorerWidth,
|
explorerWidth: state.explorerWidth,
|
||||||
explorerViewMode: state.explorerViewMode,
|
|
||||||
explorerSortOption: state.explorerSortOption,
|
explorerSortOption: state.explorerSortOption,
|
||||||
|
explorerFilesSplitRatio: state.explorerFilesSplitRatio,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -181,12 +225,12 @@ export function usePanelState(isMobile: boolean) {
|
|||||||
// Explorer settings
|
// Explorer settings
|
||||||
explorerTab: store.explorerTab,
|
explorerTab: store.explorerTab,
|
||||||
explorerWidth: store.explorerWidth,
|
explorerWidth: store.explorerWidth,
|
||||||
explorerViewMode: store.explorerViewMode,
|
|
||||||
explorerSortOption: store.explorerSortOption,
|
explorerSortOption: store.explorerSortOption,
|
||||||
|
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
||||||
setExplorerTab: store.setExplorerTab,
|
setExplorerTab: store.setExplorerTab,
|
||||||
setExplorerWidth: store.setExplorerWidth,
|
setExplorerWidth: store.setExplorerWidth,
|
||||||
setExplorerViewMode: store.setExplorerViewMode,
|
|
||||||
setExplorerSortOption: store.setExplorerSortOption,
|
setExplorerSortOption: store.setExplorerSortOption,
|
||||||
|
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +253,11 @@ export function usePanelState(isMobile: boolean) {
|
|||||||
// Explorer settings
|
// Explorer settings
|
||||||
explorerTab: store.explorerTab,
|
explorerTab: store.explorerTab,
|
||||||
explorerWidth: store.explorerWidth,
|
explorerWidth: store.explorerWidth,
|
||||||
explorerViewMode: store.explorerViewMode,
|
|
||||||
explorerSortOption: store.explorerSortOption,
|
explorerSortOption: store.explorerSortOption,
|
||||||
|
explorerFilesSplitRatio: store.explorerFilesSplitRatio,
|
||||||
setExplorerTab: store.setExplorerTab,
|
setExplorerTab: store.setExplorerTab,
|
||||||
setExplorerWidth: store.setExplorerWidth,
|
setExplorerWidth: store.setExplorerWidth,
|
||||||
setExplorerViewMode: store.setExplorerViewMode,
|
|
||||||
setExplorerSortOption: store.setExplorerSortOption,
|
setExplorerSortOption: store.setExplorerSortOption,
|
||||||
|
setExplorerFilesSplitRatio: store.setExplorerFilesSplitRatio,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export interface AgentFileExplorerState {
|
|||||||
currentPath: string;
|
currentPath: string;
|
||||||
history: string[];
|
history: string[];
|
||||||
lastVisitedPath: string;
|
lastVisitedPath: string;
|
||||||
|
selectedEntryPath: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DaemonConnectionSnapshot {
|
export interface DaemonConnectionSnapshot {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user