refactor(app): unified panel state machine for mobile sidebars
Replace separate sidebar stores with a unified panel store that uses a discriminated union state machine on mobile. This makes it impossible for both the agent list and file explorer sidebars to be open at the same time on mobile. Mobile state machine (mobileView): - 'agent': Main agent view (no overlay panel) - 'agent-list': Agent list sidebar (left overlay) - 'file-explorer': File explorer sidebar (right overlay) Desktop retains independent boolean toggles since sidebars sit alongside content rather than overlaying it. Key changes: - Created stores/panel-store.ts with unified state - Deleted stores/sidebar-store.ts and explorer-sidebar-store.ts - Updated all components to use new panel store - Animation contexts derive isOpen from unified state
This commit is contained in:
@@ -17,7 +17,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState, useEffect, type ReactNode, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { SlidingSidebar } from "@/components/sliding-sidebar";
|
||||
import { useSidebarStore } from "@/stores/sidebar-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { runOnJS, interpolate, Extrapolation, useSharedValue } from "react-native-reanimated";
|
||||
import {
|
||||
SidebarAnimationProvider,
|
||||
@@ -54,21 +54,28 @@ interface AppContainerProps {
|
||||
|
||||
function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { isOpen, open, toggle } = useSidebarStore();
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const openAgentList = usePanelStore((state) => state.openAgentList);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const horizontalScroll = useHorizontalScrollOptional();
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
|
||||
// Cmd+B to toggle sidebar (web only)
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
|
||||
event.preventDefault();
|
||||
toggle();
|
||||
toggleAgentList();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggle]);
|
||||
}, [toggleAgentList]);
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -77,8 +84,6 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
animateToClose,
|
||||
isGesturing,
|
||||
} = useSidebarAnimation();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
// Track initial touch position for manual activation
|
||||
const touchStartX = useSharedValue(0);
|
||||
@@ -135,7 +140,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
||||
if (shouldOpen) {
|
||||
animateToOpen();
|
||||
runOnJS(open)();
|
||||
runOnJS(openAgentList)();
|
||||
} else {
|
||||
animateToClose();
|
||||
}
|
||||
@@ -143,7 +148,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
||||
.onFinalize(() => {
|
||||
isGesturing.value = false;
|
||||
}),
|
||||
[isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, open, isGesturing, horizontalScroll?.isAnyScrolledRight, touchStartX]
|
||||
[isMobile, isOpen, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, openAgentList, isGesturing, horizontalScroll?.isAnyScrolledRight, touchStartX]
|
||||
);
|
||||
|
||||
const content = (
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
ExplorerSidebarAnimationProvider,
|
||||
useExplorerSidebarAnimation,
|
||||
} from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { ConnectionStatus } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
@@ -188,7 +188,19 @@ function AgentScreenContent({
|
||||
addImagesRef.current = addImages;
|
||||
}, []);
|
||||
|
||||
const { isOpen: isExplorerOpen, toggle: toggleExplorer, open: openExplorer, close: closeExplorer, setActiveTab: setExplorerTab } = useExplorerSidebarStore();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer);
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
|
||||
// Derive isExplorerOpen from the unified panel state
|
||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const {
|
||||
translateX: explorerTranslateX,
|
||||
backdropOpacity: explorerBackdropOpacity,
|
||||
@@ -197,8 +209,6 @@ function AgentScreenContent({
|
||||
animateToClose: animateExplorerToClose,
|
||||
isGesturing: isExplorerGesturing,
|
||||
} = useExplorerSidebarAnimation();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
@@ -239,7 +249,7 @@ function AgentScreenContent({
|
||||
const shouldOpen = event.translationX < -explorerWindowWidth / 3 || event.velocityX < -500;
|
||||
if (shouldOpen) {
|
||||
animateExplorerToOpen();
|
||||
runOnJS(openExplorer)();
|
||||
runOnJS(openFileExplorer)();
|
||||
} else {
|
||||
animateExplorerToClose();
|
||||
}
|
||||
@@ -255,7 +265,7 @@ function AgentScreenContent({
|
||||
explorerBackdropOpacity,
|
||||
animateExplorerToOpen,
|
||||
animateExplorerToClose,
|
||||
openExplorer,
|
||||
openFileExplorer,
|
||||
isExplorerGesturing,
|
||||
]
|
||||
);
|
||||
@@ -266,14 +276,14 @@ function AgentScreenContent({
|
||||
|
||||
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
if (isExplorerOpen) {
|
||||
closeExplorer();
|
||||
closeToAgent();
|
||||
return true; // Prevent default back navigation
|
||||
}
|
||||
return false; // Let default back navigation happen
|
||||
});
|
||||
|
||||
return () => handler.remove();
|
||||
}, [isExplorerOpen, closeExplorer]);
|
||||
}, [isExplorerOpen, closeToAgent]);
|
||||
|
||||
const resolvedAgentId = agentId;
|
||||
|
||||
@@ -565,14 +575,14 @@ function AgentScreenContent({
|
||||
const handleViewChanges = useCallback(() => {
|
||||
handleCloseMenu();
|
||||
setExplorerTab("changes");
|
||||
openExplorer();
|
||||
}, [handleCloseMenu, setExplorerTab, openExplorer]);
|
||||
openFileExplorer();
|
||||
}, [handleCloseMenu, setExplorerTab, openFileExplorer]);
|
||||
|
||||
const handleBrowseFiles = useCallback(() => {
|
||||
handleCloseMenu();
|
||||
setExplorerTab("files");
|
||||
openExplorer();
|
||||
}, [handleCloseMenu, setExplorerTab, openExplorer]);
|
||||
openFileExplorer();
|
||||
}, [handleCloseMenu, setExplorerTab, openFileExplorer]);
|
||||
|
||||
const handleRefreshAgent = useCallback(() => {
|
||||
if (!resolvedAgentId || !refreshAgent) {
|
||||
@@ -637,7 +647,7 @@ function AgentScreenContent({
|
||||
title={agent.title || "Agent"}
|
||||
rightContent={
|
||||
<View style={styles.headerRightContent}>
|
||||
<Pressable onPress={toggleExplorer} style={styles.menuButton}>
|
||||
<Pressable onPress={toggleFileExplorer} style={styles.menuButton}>
|
||||
{isMobile ? (
|
||||
<Folder
|
||||
size={16}
|
||||
|
||||
@@ -28,7 +28,6 @@ import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-ag
|
||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { generateMessageId } from "@/types/stream";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import type {
|
||||
AgentProvider,
|
||||
@@ -379,7 +378,6 @@ export default function HomeScreen() {
|
||||
}
|
||||
}, [isNonGitDirectory, useWorktree]);
|
||||
|
||||
const pendingRequestIdRef = useRef<string | null>(null);
|
||||
const sessionMethods = useSessionStore((state) =>
|
||||
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
|
||||
);
|
||||
@@ -446,17 +444,32 @@ export default function HomeScreen() {
|
||||
: undefined;
|
||||
|
||||
void persistFormPreferences();
|
||||
|
||||
const requestId = generateMessageId();
|
||||
pendingRequestIdRef.current = requestId;
|
||||
setIsLoading(true);
|
||||
createAgent({
|
||||
config,
|
||||
initialPrompt: trimmedPrompt,
|
||||
images,
|
||||
git: gitOptions,
|
||||
requestId,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await createAgent({
|
||||
config,
|
||||
initialPrompt: trimmedPrompt,
|
||||
images,
|
||||
git: gitOptions,
|
||||
});
|
||||
|
||||
// Success - clear prompt and navigate to agent
|
||||
setPromptText("");
|
||||
const agentId = (result as { id?: string })?.id;
|
||||
if (agentId && selectedServerId) {
|
||||
router.replace({
|
||||
pathname: "/agent/[serverId]/[agentId]",
|
||||
params: { serverId: selectedServerId, agentId },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to create agent";
|
||||
setErrorMessage(message);
|
||||
throw error; // Re-throw so AgentInputArea knows it failed
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
useWorktree,
|
||||
@@ -470,6 +483,7 @@ export default function HomeScreen() {
|
||||
isNonGitDirectory,
|
||||
modeOptions,
|
||||
persistFormPreferences,
|
||||
router,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
@@ -479,54 +493,6 @@ export default function HomeScreen() {
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionClient) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = sessionClient.on("status", (message) => {
|
||||
if (message.type !== "status") {
|
||||
return;
|
||||
}
|
||||
const payload = message.payload as {
|
||||
status: string;
|
||||
agentId?: string;
|
||||
requestId?: string;
|
||||
error?: string;
|
||||
};
|
||||
const expectedRequestId = pendingRequestIdRef.current;
|
||||
if (!expectedRequestId || payload.requestId !== expectedRequestId) {
|
||||
return;
|
||||
}
|
||||
if (payload.status === "agent_create_failed") {
|
||||
pendingRequestIdRef.current = null;
|
||||
setIsLoading(false);
|
||||
setErrorMessage(payload.error ?? "Failed to create agent");
|
||||
return;
|
||||
}
|
||||
if (payload.status !== "agent_created" || !payload.agentId) {
|
||||
return;
|
||||
}
|
||||
if (!selectedServerId) {
|
||||
pendingRequestIdRef.current = null;
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
pendingRequestIdRef.current = null;
|
||||
setIsLoading(false);
|
||||
router.replace({
|
||||
pathname: "/agent/[serverId]/[agentId]",
|
||||
params: {
|
||||
serverId: selectedServerId,
|
||||
agentId: payload.agentId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [router, selectedServerId, sessionClient]);
|
||||
|
||||
return (
|
||||
<FileDropZone onFilesDropped={handleFilesDropped}>
|
||||
<View style={styles.container}>
|
||||
|
||||
@@ -4,13 +4,22 @@ import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Modal,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
useWindowDimensions,
|
||||
ScrollView,
|
||||
Platform,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetModal,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ChevronDown, ChevronRight, Pencil, Check, X } from "lucide-react-native";
|
||||
import { theme as defaultTheme } from "@/styles/theme";
|
||||
import type {
|
||||
@@ -156,34 +165,78 @@ interface DropdownSheetProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function DropdownSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
style={[style, styles.bottomSheetBackground]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DropdownSheet({
|
||||
title,
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
}: DropdownSheetProps): ReactElement {
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.present();
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.45}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={DropdownSheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<View style={styles.dropdownSheetOverlay}>
|
||||
<Pressable style={styles.dropdownSheetBackdrop} onPress={onClose} />
|
||||
<View style={styles.dropdownSheetContainer}>
|
||||
<View style={styles.dropdownSheetHandle} />
|
||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.dropdownSheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
||||
</View>
|
||||
</Modal>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.dropdownSheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,6 +258,8 @@ export function AdaptiveSelect({
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -240,29 +295,62 @@ export function AdaptiveSelect({
|
||||
});
|
||||
}, [visible, isMobile, anchorRef, windowWidth, windowHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.present();
|
||||
} else {
|
||||
bottomSheetRef.current?.dismiss();
|
||||
}
|
||||
}, [visible, isMobile]);
|
||||
|
||||
const handleSheetChange = useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.45}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
onChange={handleSheetChange}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={DropdownSheetBackground}
|
||||
handleIndicatorStyle={styles.bottomSheetHandle}
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<View style={styles.dropdownSheetOverlay}>
|
||||
<Pressable style={styles.dropdownSheetBackdrop} onPress={onClose} />
|
||||
<View style={styles.dropdownSheetContainer}>
|
||||
<View style={styles.dropdownSheetHandle} />
|
||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.dropdownSheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text style={styles.dropdownSheetTitle}>{title}</Text>
|
||||
</View>
|
||||
</Modal>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.dropdownSheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -332,7 +420,6 @@ export function ComboSelect({
|
||||
}: ComboSelectProps): ReactElement {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const selectedOption = options.find((opt) => opt.id === value);
|
||||
@@ -347,7 +434,6 @@ export function ComboSelect({
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQuery("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -400,16 +486,29 @@ export function ComboSelect({
|
||||
onClose={handleClose}
|
||||
anchorRef={anchorRef}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder={`Search ${label.toLowerCase()}...`}
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{Platform.OS === "web" ? (
|
||||
<TextInput
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder={`Search ${label.toLowerCase()}...`}
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<BottomSheetTextInput
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder={`Search ${label.toLowerCase()}...`}
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
{showCustomOption ? (
|
||||
<View style={styles.dropdownSheetList}>
|
||||
<Pressable
|
||||
@@ -876,7 +975,6 @@ export function WorkingDirectoryDropdown({
|
||||
}: WorkingDirectoryDropdownProps): ReactElement {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||
@@ -885,7 +983,6 @@ export function WorkingDirectoryDropdown({
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQuery("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -929,16 +1026,29 @@ export function WorkingDirectoryDropdown({
|
||||
onClose={handleClose}
|
||||
anchorRef={anchorRef}
|
||||
>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder="/path/to/project"
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
{Platform.OS === "web" ? (
|
||||
<TextInput
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder="/path/to/project"
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<BottomSheetTextInput
|
||||
style={styles.dropdownSearchInput}
|
||||
placeholder="/path/to/project"
|
||||
placeholderTextColor={defaultTheme.colors.foregroundMuted}
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
{!hasSuggestedPaths && !showCustomOption ? (
|
||||
<Text style={styles.helperText}>
|
||||
We'll suggest directories from agents on this host once they exist.
|
||||
@@ -1208,6 +1318,18 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[2],
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
bottomSheetHandle: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
dropdownSheetOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: "flex-end",
|
||||
@@ -1244,7 +1366,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "center",
|
||||
marginBottom: theme.spacing[4],
|
||||
},
|
||||
dropdownSheetScrollContent: {
|
||||
paddingBottom: theme.spacing[8],
|
||||
|
||||
@@ -231,14 +231,19 @@ export function AgentInputArea({
|
||||
return;
|
||||
}
|
||||
|
||||
const isControlled = value !== undefined;
|
||||
const isControlledLocal = value !== undefined;
|
||||
setSelectedImages([]);
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
await submitMessage(trimmedMessage, imageAttachments);
|
||||
// Clear input only after successful submission
|
||||
if (isControlled) {
|
||||
// For controlled inputs with onSubmitMessage, the parent handles clearing
|
||||
// because agent creation is async (WebSocket) and errors come back later
|
||||
if (onSubmitMessageRef.current) {
|
||||
// Parent manages input state - don't clear here
|
||||
// Parent will clear on success via onChangeText
|
||||
} else if (isControlledLocal) {
|
||||
onChangeText?.("");
|
||||
} else {
|
||||
setUserInput("");
|
||||
@@ -284,8 +289,13 @@ export function AgentInputArea({
|
||||
}
|
||||
}, [isAgentRunning, isConnected]);
|
||||
|
||||
// Hydrate draft only when switching agents
|
||||
// Hydrate draft only when switching agents (uncontrolled mode only)
|
||||
const isControlled = value !== undefined;
|
||||
useEffect(() => {
|
||||
// Skip draft hydration for controlled inputs - parent manages state
|
||||
if (isControlled) {
|
||||
return;
|
||||
}
|
||||
const draft = getDraftInput(agentId);
|
||||
if (!draft) {
|
||||
setUserInput("");
|
||||
@@ -295,7 +305,7 @@ export function AgentInputArea({
|
||||
|
||||
setUserInput(draft.text);
|
||||
setSelectedImages(draft.images as ImageAttachment[]);
|
||||
}, [agentId, getDraftInput]);
|
||||
}, [agentId, getDraftInput, isControlled]);
|
||||
|
||||
// Persist drafts into the shared session store with change detection to avoid redundant work
|
||||
useEffect(() => {
|
||||
|
||||
@@ -29,7 +29,7 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import {
|
||||
AssistantMessage,
|
||||
UserMessage,
|
||||
@@ -87,8 +87,8 @@ export function AgentStreamView({
|
||||
const hasAutoScrolledOnce = useRef(false);
|
||||
const isNearBottomRef = useRef(true);
|
||||
const streamItemCountRef = useRef(0);
|
||||
const { open: openExplorer, setActiveTab: setExplorerTab } =
|
||||
useExplorerSidebarStore();
|
||||
const openFileExplorer = usePanelStore((state) => state.openFileExplorer);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? agent.serverId ?? "";
|
||||
@@ -142,7 +142,7 @@ export function AgentStreamView({
|
||||
}
|
||||
|
||||
setExplorerTab("files");
|
||||
openExplorer();
|
||||
openFileExplorer();
|
||||
},
|
||||
[
|
||||
agent.cwd,
|
||||
@@ -150,7 +150,7 @@ export function AgentStreamView({
|
||||
requestDirectoryListingOrInert,
|
||||
requestFilePreviewOrInert,
|
||||
setExplorerTab,
|
||||
openExplorer,
|
||||
openFileExplorer,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { X, GitBranch, Folder, LayoutGrid, List as ListIcon } from "lucide-react-native";
|
||||
import {
|
||||
useExplorerSidebarStore,
|
||||
usePanelStore,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
type ViewMode,
|
||||
} from "@/stores/explorer-sidebar-store";
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
@@ -30,8 +30,21 @@ interface ExplorerSidebarProps {
|
||||
export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isOpen, activeTab, width, viewMode, close, setActiveTab, setWidth, setViewMode } =
|
||||
useExplorerSidebarStore();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const explorerViewMode = usePanelStore((state) => state.explorerViewMode);
|
||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const setExplorerViewMode = usePanelStore((state) => state.setExplorerViewMode);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const {
|
||||
translateX,
|
||||
backdropOpacity,
|
||||
@@ -42,22 +55,19 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
closeGestureRef,
|
||||
} = useExplorerSidebarAnimation();
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
// For resize drag, track the starting width
|
||||
const startWidthRef = useRef(width);
|
||||
const resizeWidth = useSharedValue(width);
|
||||
const startWidthRef = useRef(explorerWidth);
|
||||
const resizeWidth = useSharedValue(explorerWidth);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
close();
|
||||
}, [close]);
|
||||
closeToAgent();
|
||||
}, [closeToAgent]);
|
||||
|
||||
const handleTabPress = useCallback(
|
||||
(tab: ExplorerTab) => {
|
||||
setActiveTab(tab);
|
||||
setExplorerTab(tab);
|
||||
},
|
||||
[setActiveTab]
|
||||
[setExplorerTab]
|
||||
);
|
||||
|
||||
// Swipe gesture to close (swipe right on mobile)
|
||||
@@ -116,8 +126,8 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
.enabled(!isMobile)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onStart(() => {
|
||||
startWidthRef.current = width;
|
||||
resizeWidth.value = width;
|
||||
startWidthRef.current = explorerWidth;
|
||||
resizeWidth.value = explorerWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
// Dragging left (negative translationX) increases width
|
||||
@@ -129,9 +139,9 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
resizeWidth.value = clampedWidth;
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setWidth)(resizeWidth.value);
|
||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||
}),
|
||||
[isMobile, width, resizeWidth, setWidth]
|
||||
[isMobile, explorerWidth, resizeWidth, setExplorerWidth]
|
||||
);
|
||||
|
||||
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
|
||||
@@ -168,13 +178,13 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
pointerEvents="auto"
|
||||
>
|
||||
<SidebarContent
|
||||
activeTab={activeTab}
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleClose}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
fileViewMode={viewMode}
|
||||
onFileViewModeChange={setViewMode}
|
||||
fileViewMode={explorerViewMode}
|
||||
onFileViewModeChange={setExplorerViewMode}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -201,13 +211,13 @@ export function ExplorerSidebar({ serverId, agentId }: ExplorerSidebarProps) {
|
||||
</GestureDetector>
|
||||
|
||||
<SidebarContent
|
||||
activeTab={activeTab}
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleClose}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
fileViewMode={viewMode}
|
||||
onFileViewModeChange={setViewMode}
|
||||
fileViewMode={explorerViewMode}
|
||||
onFileViewModeChange={setExplorerViewMode}
|
||||
isMobile={false}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
@@ -46,9 +46,9 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||
import type { DaemonProfile } from "@/contexts/daemon-registry-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
useExplorerSidebarStore,
|
||||
usePanelStore,
|
||||
type SortOption,
|
||||
} from "@/stores/explorer-sidebar-store";
|
||||
} from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
|
||||
const MAX_CONCURRENT_THUMBNAILS = 2;
|
||||
@@ -90,7 +90,9 @@ export function FileExplorerPane({
|
||||
const requestFilePreview = methods?.requestFilePreview;
|
||||
const requestFileDownloadToken = methods?.requestFileDownloadToken;
|
||||
const navigateExplorerBack = methods?.navigateExplorerBack;
|
||||
const { viewMode, sortOption, setSortOption } = useExplorerSidebarStore();
|
||||
const viewMode = usePanelStore((state) => state.explorerViewMode);
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
const [selectedEntryPath, setSelectedEntryPath] = useState<string | null>(null);
|
||||
const listScrollRef = useRef<FlatList<ExplorerEntry> | null>(null);
|
||||
const listScrollOffsetRef = useRef(0);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Pressable, Text } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Menu, PanelLeft } from "lucide-react-native";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { useSidebarStore } from "@/stores/sidebar-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
title?: string;
|
||||
@@ -12,10 +12,13 @@ interface MenuHeaderProps {
|
||||
|
||||
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { isOpen, toggle } = useSidebarStore();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
const MenuIcon = isMobile ? Menu : PanelLeft;
|
||||
const menuIconColor = !isMobile && isOpen
|
||||
? theme.colors.foreground
|
||||
@@ -25,7 +28,7 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
|
||||
<ScreenHeader
|
||||
left={
|
||||
<>
|
||||
<Pressable onPress={toggle} style={styles.menuButton}>
|
||||
<Pressable onPress={toggleAgentList} style={styles.menuButton}>
|
||||
<MenuIcon size={16} color={menuIconColor} />
|
||||
</Pressable>
|
||||
{title && (
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useDictation } from "@/hooks/use-dictation";
|
||||
import { DictationOverlay } from "./dictation-controls";
|
||||
import type { DaemonClientV2 } from "@server/client/daemon-client-v2";
|
||||
import type { SessionContextValue } from "@/contexts/session-context";
|
||||
import { useSidebarStore } from "@/stores/sidebar-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
export interface ImageAttachment {
|
||||
uri: string;
|
||||
@@ -117,7 +117,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
ref
|
||||
) {
|
||||
const { theme } = useUnistyles();
|
||||
const toggleSidebar = useSidebarStore((state) => state.toggle);
|
||||
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||
const textInputRef = useRef<
|
||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||
@@ -397,7 +397,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
// Cmd+B or Ctrl+B: toggle sidebar
|
||||
if ((metaKey || ctrlKey) && event.nativeEvent.key === "b") {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
toggleAgentList();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -649,6 +649,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="assistant-message"
|
||||
style={[
|
||||
assistantMessageStylesheet.container,
|
||||
!resolvedDisableOuterSpacing &&
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { Plus, Settings } from "lucide-react-native";
|
||||
import { router } from "expo-router";
|
||||
import { useSidebarStore } from "@/stores/sidebar-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { AgentList } from "./agent-list";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
@@ -26,7 +26,15 @@ interface SlidingSidebarProps {
|
||||
export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { isOpen, close } = useSidebarStore();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const closeToAgent = usePanelStore((state) => state.closeToAgent);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
|
||||
const { agents, isRevalidating, refreshAll } = useAggregatedAgents();
|
||||
const {
|
||||
translateX,
|
||||
@@ -52,9 +60,6 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
}
|
||||
}, [isRevalidating, isManualRefresh]);
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
|
||||
const sortedAgents = useMemo(() => {
|
||||
return [...agents].sort((a, b) => {
|
||||
if (a.requiresAttention && !b.requiresAttention) return -1;
|
||||
@@ -70,15 +75,15 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
const hasMore = agents.length > SIDEBAR_AGENT_LIMIT;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
close();
|
||||
}, [close]);
|
||||
closeToAgent();
|
||||
}, [closeToAgent]);
|
||||
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleCreateAgentMobile = useCallback(() => {
|
||||
close();
|
||||
closeToAgent();
|
||||
router.push("/");
|
||||
}, [close]);
|
||||
}, [closeToAgent]);
|
||||
|
||||
// Desktop: just navigate, don't close
|
||||
const handleCreateAgentDesktop = useCallback(() => {
|
||||
@@ -87,9 +92,9 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
close();
|
||||
closeToAgent();
|
||||
router.push("/settings");
|
||||
}, [close]);
|
||||
}, [closeToAgent]);
|
||||
|
||||
// Desktop: just navigate, don't close
|
||||
const handleSettingsDesktop = useCallback(() => {
|
||||
@@ -101,17 +106,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
||||
const handleAgentSelectMobile = useCallback(() => {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
close();
|
||||
}, [close, translateX, backdropOpacity, windowWidth]);
|
||||
closeToAgent();
|
||||
}, [closeToAgent, translateX, backdropOpacity, windowWidth]);
|
||||
|
||||
const handleViewMore = useCallback(() => {
|
||||
if (isMobile) {
|
||||
translateX.value = -windowWidth;
|
||||
backdropOpacity.value = 0;
|
||||
}
|
||||
close();
|
||||
closeToAgent();
|
||||
router.push("/agents");
|
||||
}, [backdropOpacity, close, isMobile, translateX, windowWidth]);
|
||||
}, [backdropOpacity, closeToAgent, isMobile, translateX, windowWidth]);
|
||||
|
||||
// Close gesture (swipe left to close when sidebar is open)
|
||||
const closeGesture = Gesture.Pan()
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { type GestureType } from "react-native-gesture-handler";
|
||||
import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
const ANIMATION_DURATION = 220;
|
||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||
@@ -26,7 +27,13 @@ const ExplorerSidebarAnimationContext = createContext<ExplorerSidebarAnimationCo
|
||||
|
||||
export function ExplorerSidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const { isOpen } = useExplorerSidebarStore();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
// Right sidebar: closed = +windowWidth (off-screen right), open = 0
|
||||
const translateX = useSharedValue(isOpen ? 0 : windowWidth);
|
||||
|
||||
@@ -362,10 +362,11 @@ export interface SessionContextValue {
|
||||
createAgent: (options: {
|
||||
config: any;
|
||||
initialPrompt: string;
|
||||
images?: Array<{ uri: string; mimeType?: string }>;
|
||||
git?: any;
|
||||
worktreeName?: string;
|
||||
requestId?: string;
|
||||
}) => void;
|
||||
}) => Promise<unknown>;
|
||||
setAgentMode: (agentId: string, modeId: string) => void;
|
||||
respondToPermission: (
|
||||
agentId: string,
|
||||
@@ -2077,20 +2078,14 @@ export function SessionProvider({
|
||||
error
|
||||
);
|
||||
}
|
||||
void client
|
||||
.createAgent({
|
||||
config,
|
||||
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
|
||||
...(imagesData && imagesData.length > 0
|
||||
? { images: imagesData }
|
||||
: {}),
|
||||
...(git ? { git } : {}),
|
||||
...(worktreeName ? { worktreeName } : {}),
|
||||
...(requestId ? { requestId } : {}),
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[Session] Failed to create agent:", error);
|
||||
});
|
||||
return client.createAgent({
|
||||
config,
|
||||
...(trimmedPrompt ? { initialPrompt: trimmedPrompt } : {}),
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
...(git ? { git } : {}),
|
||||
...(worktreeName ? { worktreeName } : {}),
|
||||
...(requestId ? { requestId } : {}),
|
||||
});
|
||||
},
|
||||
[encodeImages, client]
|
||||
);
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
Easing,
|
||||
type SharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { useSidebarStore } from "@/stores/sidebar-store";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
const ANIMATION_DURATION = 220;
|
||||
const ANIMATION_EASING = Easing.bezier(0.25, 0.1, 0.25, 1);
|
||||
@@ -24,7 +25,13 @@ const SidebarAnimationContext = createContext<SidebarAnimationContextValue | nul
|
||||
|
||||
export function SidebarAnimationProvider({ children }: { children: ReactNode }) {
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const { isOpen } = useSidebarStore();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
|
||||
// Derive isOpen from the unified panel state
|
||||
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
|
||||
|
||||
// Initialize based on current state
|
||||
const translateX = useSharedValue(isOpen ? 0 : -windowWidth);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
const GIT_DIFF_STALE_TIME = 30_000;
|
||||
|
||||
@@ -22,7 +23,12 @@ export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) {
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
const { isOpen, activeTab } = useExplorerSidebarStore();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: gitDiffQueryKey(serverId, agentId),
|
||||
@@ -40,14 +46,14 @@ export function useGitDiffQuery({ serverId, agentId }: UseGitDiffQueryOptions) {
|
||||
|
||||
// Revalidate when sidebar opens with "changes" tab active
|
||||
useEffect(() => {
|
||||
if (!isOpen || activeTab !== "changes" || !agentId) {
|
||||
if (!isOpen || explorerTab !== "changes" || !agentId) {
|
||||
return;
|
||||
}
|
||||
// Invalidate to trigger background refetch (shows stale data while fetching)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: gitDiffQueryKey(serverId, agentId),
|
||||
});
|
||||
}, [isOpen, activeTab, serverId, agentId, queryClient]);
|
||||
}, [isOpen, explorerTab, serverId, agentId, queryClient]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useExplorerSidebarStore } from "@/stores/explorer-sidebar-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import type { HighlightedDiffResponse } from "@server/shared/messages";
|
||||
import { getNowMs, isPerfLoggingEnabled, measurePayload, perfLog } from "@/utils/perf";
|
||||
|
||||
@@ -30,7 +31,12 @@ export function useHighlightedDiffQuery({ serverId, agentId }: UseHighlightedDif
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
const { isOpen, activeTab } = useExplorerSidebarStore();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const explorerTab = usePanelStore((state) => state.explorerTab);
|
||||
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: highlightedDiffQueryKey(serverId, agentId),
|
||||
@@ -80,14 +86,14 @@ export function useHighlightedDiffQuery({ serverId, agentId }: UseHighlightedDif
|
||||
|
||||
// Revalidate when sidebar opens with "changes" tab active
|
||||
useEffect(() => {
|
||||
if (!isOpen || activeTab !== "changes" || !agentId) {
|
||||
if (!isOpen || explorerTab !== "changes" || !agentId) {
|
||||
return;
|
||||
}
|
||||
// Invalidate to trigger background refetch (shows stale data while fetching)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: highlightedDiffQueryKey(serverId, agentId),
|
||||
});
|
||||
}, [isOpen, activeTab, serverId, agentId, queryClient]);
|
||||
}, [isOpen, explorerTab, serverId, agentId, queryClient]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
return query.refetch();
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
type ExplorerTab = "changes" | "files";
|
||||
export type ViewMode = "list" | "grid";
|
||||
export type SortOption = "name" | "modified" | "size";
|
||||
|
||||
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
|
||||
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
||||
export const MAX_EXPLORER_SIDEBAR_WIDTH = 800;
|
||||
|
||||
const DEFAULT_OPEN = Platform.OS === "web";
|
||||
|
||||
interface ExplorerSidebarState {
|
||||
isOpen: boolean;
|
||||
activeTab: ExplorerTab;
|
||||
width: number;
|
||||
viewMode: ViewMode;
|
||||
sortOption: SortOption;
|
||||
toggle: () => void;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
setActiveTab: (tab: ExplorerTab) => void;
|
||||
setWidth: (width: number) => void;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setSortOption: (option: SortOption) => void;
|
||||
}
|
||||
|
||||
function clampWidth(width: number): number {
|
||||
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, width));
|
||||
}
|
||||
|
||||
export const useExplorerSidebarStore = create<ExplorerSidebarState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isOpen: DEFAULT_OPEN,
|
||||
activeTab: "changes",
|
||||
width: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
viewMode: "list",
|
||||
sortOption: "name",
|
||||
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
setWidth: (width) => set({ width: clampWidth(width) }),
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setSortOption: (option) => set({ sortOption: option }),
|
||||
}),
|
||||
{
|
||||
name: "explorer-sidebar-state",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
isOpen: state.isOpen,
|
||||
activeTab: state.activeTab,
|
||||
width: state.width,
|
||||
viewMode: state.viewMode,
|
||||
sortOption: state.sortOption,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export function useExplorerSidebar() {
|
||||
return useExplorerSidebarStore();
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
/**
|
||||
* Mobile panel state machine.
|
||||
*
|
||||
* On mobile, exactly one panel can be visible at a time:
|
||||
* - 'agent': Main agent view (no overlay panel)
|
||||
* - 'agent-list': Agent list sidebar (left overlay)
|
||||
* - 'file-explorer': File explorer sidebar (right overlay)
|
||||
*
|
||||
* This makes impossible states unrepresentable - you cannot have both
|
||||
* sidebars open at the same time on mobile.
|
||||
*/
|
||||
type MobilePanelView = "agent" | "agent-list" | "file-explorer";
|
||||
|
||||
/**
|
||||
* Desktop sidebar state.
|
||||
*
|
||||
* On desktop, sidebars are independent toggleable panels that don't overlay
|
||||
* the main content - they sit alongside it. Both can be open simultaneously.
|
||||
*/
|
||||
interface DesktopSidebarState {
|
||||
agentListOpen: boolean;
|
||||
fileExplorerOpen: boolean;
|
||||
}
|
||||
|
||||
type ExplorerTab = "changes" | "files";
|
||||
export type ViewMode = "list" | "grid";
|
||||
export type SortOption = "name" | "modified" | "size";
|
||||
|
||||
export const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 400;
|
||||
export const MIN_EXPLORER_SIDEBAR_WIDTH = 280;
|
||||
export const MAX_EXPLORER_SIDEBAR_WIDTH = 800;
|
||||
|
||||
interface PanelState {
|
||||
// Mobile: which panel is currently shown
|
||||
mobileView: MobilePanelView;
|
||||
|
||||
// Desktop: independent sidebar toggles
|
||||
desktop: DesktopSidebarState;
|
||||
|
||||
// File explorer settings (shared between mobile/desktop)
|
||||
explorerTab: ExplorerTab;
|
||||
explorerWidth: number;
|
||||
explorerViewMode: ViewMode;
|
||||
explorerSortOption: SortOption;
|
||||
|
||||
// Actions
|
||||
openAgentList: () => void;
|
||||
openFileExplorer: () => void;
|
||||
closeToAgent: () => void;
|
||||
toggleAgentList: () => void;
|
||||
toggleFileExplorer: () => void;
|
||||
|
||||
// File explorer settings actions
|
||||
setExplorerTab: (tab: ExplorerTab) => void;
|
||||
setExplorerWidth: (width: number) => void;
|
||||
setExplorerViewMode: (mode: ViewMode) => void;
|
||||
setExplorerSortOption: (option: SortOption) => void;
|
||||
}
|
||||
|
||||
function clampWidth(width: number): number {
|
||||
return Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, width));
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP_OPEN = Platform.OS === "web";
|
||||
|
||||
export const usePanelStore = create<PanelState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
// Mobile always starts at agent view
|
||||
mobileView: "agent",
|
||||
|
||||
// Desktop defaults based on platform
|
||||
desktop: {
|
||||
agentListOpen: DEFAULT_DESKTOP_OPEN,
|
||||
fileExplorerOpen: DEFAULT_DESKTOP_OPEN,
|
||||
},
|
||||
|
||||
// File explorer defaults
|
||||
explorerTab: "changes",
|
||||
explorerWidth: DEFAULT_EXPLORER_SIDEBAR_WIDTH,
|
||||
explorerViewMode: "list",
|
||||
explorerSortOption: "name",
|
||||
|
||||
openAgentList: () =>
|
||||
set((state) => ({
|
||||
mobileView: "agent-list",
|
||||
desktop: { ...state.desktop, agentListOpen: true },
|
||||
})),
|
||||
|
||||
openFileExplorer: () =>
|
||||
set((state) => ({
|
||||
mobileView: "file-explorer",
|
||||
desktop: { ...state.desktop, fileExplorerOpen: true },
|
||||
})),
|
||||
|
||||
closeToAgent: () =>
|
||||
set((state) => ({
|
||||
mobileView: "agent",
|
||||
// On desktop, closing depends on which panel triggered it
|
||||
// This is called when closing via gesture/backdrop, so we close the currently active mobile panel
|
||||
desktop: {
|
||||
agentListOpen:
|
||||
state.mobileView === "agent-list" ? false : state.desktop.agentListOpen,
|
||||
fileExplorerOpen:
|
||||
state.mobileView === "file-explorer" ? false : state.desktop.fileExplorerOpen,
|
||||
},
|
||||
})),
|
||||
|
||||
toggleAgentList: () =>
|
||||
set((state) => {
|
||||
// Mobile: toggle between agent and agent-list
|
||||
const newMobileView = state.mobileView === "agent-list" ? "agent" : "agent-list";
|
||||
return {
|
||||
mobileView: newMobileView,
|
||||
desktop: {
|
||||
...state.desktop,
|
||||
agentListOpen: !state.desktop.agentListOpen,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
toggleFileExplorer: () =>
|
||||
set((state) => {
|
||||
// Mobile: toggle between agent and file-explorer
|
||||
const newMobileView = state.mobileView === "file-explorer" ? "agent" : "file-explorer";
|
||||
return {
|
||||
mobileView: newMobileView,
|
||||
desktop: {
|
||||
...state.desktop,
|
||||
fileExplorerOpen: !state.desktop.fileExplorerOpen,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
setExplorerTab: (tab) => set({ explorerTab: tab }),
|
||||
setExplorerWidth: (width) => set({ explorerWidth: clampWidth(width) }),
|
||||
setExplorerViewMode: (mode) => set({ explorerViewMode: mode }),
|
||||
setExplorerSortOption: (option) => set({ explorerSortOption: option }),
|
||||
}),
|
||||
{
|
||||
name: "panel-state",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
mobileView: state.mobileView,
|
||||
desktop: state.desktop,
|
||||
explorerTab: state.explorerTab,
|
||||
explorerWidth: state.explorerWidth,
|
||||
explorerViewMode: state.explorerViewMode,
|
||||
explorerSortOption: state.explorerSortOption,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Hook that provides platform-aware panel state.
|
||||
*
|
||||
* On mobile, uses the state machine (mobileView).
|
||||
* On desktop, uses independent booleans (desktop.agentListOpen, desktop.fileExplorerOpen).
|
||||
*
|
||||
* @param isMobile - Whether the current breakpoint is mobile
|
||||
*/
|
||||
export function usePanelState(isMobile: boolean) {
|
||||
const store = usePanelStore();
|
||||
|
||||
if (isMobile) {
|
||||
return {
|
||||
isAgentListOpen: store.mobileView === "agent-list",
|
||||
isFileExplorerOpen: store.mobileView === "file-explorer",
|
||||
openAgentList: store.openAgentList,
|
||||
openFileExplorer: store.openFileExplorer,
|
||||
closeAgentList: store.closeToAgent,
|
||||
closeFileExplorer: store.closeToAgent,
|
||||
toggleAgentList: store.toggleAgentList,
|
||||
toggleFileExplorer: store.toggleFileExplorer,
|
||||
// Explorer settings
|
||||
explorerTab: store.explorerTab,
|
||||
explorerWidth: store.explorerWidth,
|
||||
explorerViewMode: store.explorerViewMode,
|
||||
explorerSortOption: store.explorerSortOption,
|
||||
setExplorerTab: store.setExplorerTab,
|
||||
setExplorerWidth: store.setExplorerWidth,
|
||||
setExplorerViewMode: store.setExplorerViewMode,
|
||||
setExplorerSortOption: store.setExplorerSortOption,
|
||||
};
|
||||
}
|
||||
|
||||
// Desktop: independent toggles
|
||||
return {
|
||||
isAgentListOpen: store.desktop.agentListOpen,
|
||||
isFileExplorerOpen: store.desktop.fileExplorerOpen,
|
||||
openAgentList: store.openAgentList,
|
||||
openFileExplorer: store.openFileExplorer,
|
||||
closeAgentList: () =>
|
||||
usePanelStore.setState((state) => ({
|
||||
desktop: { ...state.desktop, agentListOpen: false },
|
||||
})),
|
||||
closeFileExplorer: () =>
|
||||
usePanelStore.setState((state) => ({
|
||||
desktop: { ...state.desktop, fileExplorerOpen: false },
|
||||
})),
|
||||
toggleAgentList: store.toggleAgentList,
|
||||
toggleFileExplorer: store.toggleFileExplorer,
|
||||
// Explorer settings
|
||||
explorerTab: store.explorerTab,
|
||||
explorerWidth: store.explorerWidth,
|
||||
explorerViewMode: store.explorerViewMode,
|
||||
explorerSortOption: store.explorerSortOption,
|
||||
setExplorerTab: store.setExplorerTab,
|
||||
setExplorerWidth: store.setExplorerWidth,
|
||||
setExplorerViewMode: store.setExplorerViewMode,
|
||||
setExplorerSortOption: store.setExplorerSortOption,
|
||||
};
|
||||
}
|
||||
@@ -196,7 +196,7 @@ export interface SessionState {
|
||||
git?: any;
|
||||
worktreeName?: string;
|
||||
requestId?: string;
|
||||
}) => Promise<void>;
|
||||
}) => Promise<unknown>;
|
||||
setAgentMode: (agentId: string, modeId: string) => void;
|
||||
respondToPermission: (agentId: string, requestId: string, response: any) => void;
|
||||
ensureAgentIsInitialized: (agentId: string) => Promise<void>;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { persist, createJSONStorage } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
interface SidebarState {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_OPEN = Platform.OS === "web";
|
||||
|
||||
export const useSidebarStore = create<SidebarState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isOpen: DEFAULT_OPEN,
|
||||
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
}),
|
||||
{
|
||||
name: "sidebar-state",
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({ isOpen: state.isOpen }),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export function useSidebar() {
|
||||
return useSidebarStore();
|
||||
}
|
||||
Reference in New Issue
Block a user