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:
Mohamed Boudra
2026-02-04 13:01:17 +07:00
15 changed files with 1012 additions and 1084 deletions
@@ -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();
+3 -99
View File
@@ -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 }),
})); }));
+57 -13
View File
@@ -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,
}; };
} }
+1
View File
@@ -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();
});
});
+81
View File
@@ -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,
};
}