feat: add bottom sheet modal for agent creation UI

This commit is contained in:
Mohamed Boudra
2025-10-23 11:53:38 +02:00
parent 85a0e46ba9
commit 542707ecdd
4 changed files with 237 additions and 185 deletions

40
package-lock.json generated
View File

@@ -4335,6 +4335,45 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@gorhom/bottom-sheet": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.6.tgz",
"integrity": "sha512-vmruJxdiUGDg+ZYcDmS30XDhq/h/+QkINOI5LY/uGjx8cPGwgJW0H6AB902gNTKtccbiKe/rr94EwdmIEz+LAQ==",
"license": "MIT",
"dependencies": {
"@gorhom/portal": "1.0.14",
"invariant": "^2.2.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-native": "*",
"react": "*",
"react-native": "*",
"react-native-gesture-handler": ">=2.16.1",
"react-native-reanimated": ">=3.16.0 || >=4.0.0-"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-native": {
"optional": true
}
}
},
"node_modules/@gorhom/portal": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@gorhom/portal/-/portal-1.0.14.tgz",
"integrity": "sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==",
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.1"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
@@ -20485,6 +20524,7 @@
"version": "1.0.0",
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",

View File

@@ -12,6 +12,7 @@
},
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"@gorhom/bottom-sheet": "^5.2.6",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",

View File

@@ -2,6 +2,7 @@ import { Stack } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { KeyboardProvider } from "react-native-keyboard-controller";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { SessionProvider } from "@/contexts/session-context";
import { RealtimeProvider } from "@/contexts/realtime-context";
import { useSettings } from "@/hooks/use-settings";
@@ -37,25 +38,27 @@ export default function RootLayout() {
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<KeyboardProvider>
<ProvidersWrapper>
<Stack
screenOptions={{
headerShown: false,
animation: "slide_from_right",
animationDuration: 250,
gestureEnabled: true,
gestureDirection: "horizontal",
fullScreenGestureEnabled: true,
animationMatchesGesture: true,
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="orchestrator" />
<Stack.Screen name="agent/[id]" />
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
</Stack>
</ProvidersWrapper>
<BottomSheetModalProvider>
<ProvidersWrapper>
<Stack
screenOptions={{
headerShown: false,
animation: "slide_from_right",
animationDuration: 250,
gestureEnabled: true,
gestureDirection: "horizontal",
fullScreenGestureEnabled: true,
animationMatchesGesture: true,
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="orchestrator" />
<Stack.Screen name="agent/[id]" />
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
</Stack>
</ProvidersWrapper>
</BottomSheetModalProvider>
</KeyboardProvider>
</SafeAreaProvider>
</GestureHandlerRootView>

View File

@@ -1,16 +1,20 @@
import { useState } from "react";
import {
Modal,
View,
Text,
Pressable,
TextInput,
ScrollView,
} from "react-native";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated";
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
import { View, Text, Pressable, ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { X } from "lucide-react-native";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import {
BottomSheetModal,
BottomSheetBackdrop,
BottomSheetView,
BottomSheetScrollView,
BottomSheetTextInput,
BottomSheetFooter,
} from "@gorhom/bottom-sheet";
import type {
BottomSheetBackdropProps,
BottomSheetFooterProps,
} from "@gorhom/bottom-sheet";
import { StyleSheet } from "react-native-unistyles";
import { theme as defaultTheme } from "@/styles/theme";
import { useRecentPaths } from "@/hooks/use-recent-paths";
@@ -39,25 +43,68 @@ export function CreateAgentModal({
onClose,
onCreateAgent,
}: CreateAgentModalProps) {
const bottomSheetRef = useRef<BottomSheetModal>(null);
const insets = useSafeAreaInsets();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const { recentPaths, addRecentPath } = useRecentPaths();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const [workingDir, setWorkingDir] = useState("");
const [selectedMode, setSelectedMode] = useState("plan");
const [errorMessage, setErrorMessage] = useState("");
// Keyboard animation
const bottomInset = insets.bottom;
const animatedKeyboardStyle = useAnimatedStyle(() => {
const snapPoints = useMemo(() => ["90%"], []);
// Keyboard animation for footer
const animatedFooterStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const padding = Math.max(0, absoluteHeight - bottomInset);
const padding = Math.max(0, absoluteHeight - insets.bottom);
return {
paddingBottom: padding,
};
});
const renderBackdrop = useMemo(
() => (props: BottomSheetBackdropProps) =>
(
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.5}
/>
),
[]
);
const renderFooter = useCallback(
(props: BottomSheetFooterProps) => (
<BottomSheetFooter {...props} style={animatedFooterStyle}>
<Animated.View
style={[styles.footer, { paddingBottom: insets.bottom }]}
>
<Pressable
style={[
styles.createButton,
!workingDir.trim() && styles.createButtonDisabled,
]}
onPress={handleCreate}
disabled={!workingDir.trim()}
>
<Text style={styles.createButtonText}>Create Agent</Text>
</Pressable>
</Animated.View>
</BottomSheetFooter>
),
[insets.bottom, workingDir, animatedFooterStyle]
);
useEffect(() => {
if (isVisible) {
bottomSheetRef.current?.present();
}
}, [isVisible]);
async function handleCreate() {
if (!workingDir.trim()) {
setErrorMessage("Working directory is required");
@@ -79,6 +126,10 @@ export function CreateAgentModal({
}
function handleClose() {
bottomSheetRef.current?.dismiss();
}
function handleDismiss() {
setWorkingDir("");
setSelectedMode("plan");
setErrorMessage("");
@@ -86,174 +137,131 @@ export function CreateAgentModal({
}
return (
<Modal
visible={isVisible}
animationType="slide"
transparent={true}
onRequestClose={handleClose}
<BottomSheetModal
ref={bottomSheetRef}
snapPoints={snapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
onDismiss={handleDismiss}
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.handleIndicator}
backdropComponent={renderBackdrop}
footerComponent={renderFooter}
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
android_keyboardInputMode="adjustResize"
topInset={insets.top}
>
<View style={styles.modalOverlay}>
<Pressable style={styles.modalBackdrop} onPress={handleClose} />
<ReanimatedAnimated.View
style={[styles.modalContent, animatedKeyboardStyle]}
<BottomSheetView style={styles.sheetContent}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Create New Agent</Text>
</View>
{/* Form */}
<BottomSheetScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<View style={styles.modalInner}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Create New Agent</Text>
<Pressable onPress={handleClose}>
<X size={24} color="white" />
</Pressable>
</View>
{/* Working Directory Input */}
<View style={styles.formSection}>
<Text style={styles.label}>Working Directory</Text>
<BottomSheetTextInput
style={styles.input}
placeholder="/path/to/project"
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={workingDir}
onChangeText={(text) => {
setWorkingDir(text);
setErrorMessage("");
}}
autoCapitalize="none"
autoCorrect={false}
/>
{errorMessage && (
<Text style={styles.errorText}>{errorMessage}</Text>
)}
{/* Form */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{/* Working Directory Input */}
<View style={styles.formSection}>
<Text style={styles.label}>Working Directory</Text>
<TextInput
style={styles.input}
placeholder="/path/to/project"
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={workingDir}
onChangeText={(text) => {
setWorkingDir(text);
setErrorMessage("");
}}
autoCapitalize="none"
autoCorrect={false}
/>
{errorMessage ? (
<Text style={styles.errorText}>{errorMessage}</Text>
) : (
<Text style={styles.helperText}>
Absolute path to the project directory
</Text>
)}
{/* Recent Paths Chips */}
{recentPaths.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.recentPathsContainer}
keyboardShouldPersistTaps="handled"
{/* Recent Paths Chips */}
{recentPaths.length > 0 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.recentPathsContainer}
keyboardShouldPersistTaps="handled"
>
{recentPaths.map((path) => (
<Pressable
key={path}
style={styles.recentPathChip}
onPress={() => setWorkingDir(path)}
>
{recentPaths.map((path) => (
<Pressable
key={path}
style={styles.recentPathChip}
onPress={() => setWorkingDir(path)}
>
<Text style={styles.recentPathChipText} numberOfLines={1}>
{path}
</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
<Text style={styles.recentPathChipText} numberOfLines={1}>
{path}
</Text>
</Pressable>
))}
</ScrollView>
)}
</View>
{/* Mode Selector */}
<View style={styles.formSection}>
<Text style={styles.label}>Mode</Text>
<View style={styles.modeContainer}>
{MODES.map((mode) => (
<Pressable
key={mode.value}
onPress={() => setSelectedMode(mode.value)}
{/* Mode Selector */}
<View style={styles.formSection}>
<Text style={styles.label}>Mode</Text>
<View style={styles.modeContainer}>
{MODES.map((mode) => (
<Pressable
key={mode.value}
onPress={() => setSelectedMode(mode.value)}
style={[
styles.modeOption,
selectedMode === mode.value && styles.modeOptionSelected,
]}
>
<View style={styles.modeOptionContent}>
<View
style={[
styles.modeOption,
selectedMode === mode.value &&
styles.modeOptionSelected,
styles.radioOuter,
selectedMode === mode.value
? styles.radioOuterSelected
: styles.radioOuterUnselected,
]}
>
<View style={styles.modeOptionContent}>
<View
style={[
styles.radioOuter,
selectedMode === mode.value
? styles.radioOuterSelected
: styles.radioOuterUnselected,
]}
>
{selectedMode === mode.value && (
<View style={styles.radioInner} />
)}
</View>
<View style={styles.modeTextContainer}>
<Text style={styles.modeLabel}>{mode.label}</Text>
<Text style={styles.modeDescription}>
{mode.description}
</Text>
</View>
</View>
</Pressable>
))}
</View>
</View>
</ScrollView>
{/* Fixed Footer with Create Button */}
<View
style={[
styles.footer,
{ paddingBottom: Math.max(insets.bottom, 16) },
]}
>
<Pressable
style={[
styles.createButton,
!workingDir.trim() && styles.createButtonDisabled,
]}
onPress={handleCreate}
disabled={!workingDir.trim()}
>
<Text style={styles.createButtonText}>Create Agent</Text>
</Pressable>
{selectedMode === mode.value && (
<View style={styles.radioInner} />
)}
</View>
<View style={styles.modeTextContainer}>
<Text style={styles.modeLabel}>{mode.label}</Text>
<Text style={styles.modeDescription}>
{mode.description}
</Text>
</View>
</View>
</Pressable>
))}
</View>
</View>
</ReanimatedAnimated.View>
</View>
</Modal>
</BottomSheetScrollView>
</BottomSheetView>
</BottomSheetModal>
);
}
const styles = StyleSheet.create((theme) => ({
modalOverlay: {
flex: 1,
justifyContent: "flex-end",
},
modalBackdrop: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.5)",
zIndex: 1,
},
modalContent: {
sheetBackground: {
backgroundColor: theme.colors.card,
borderTopLeftRadius: theme.spacing[6],
borderTopRightRadius: theme.spacing[6],
minHeight: "80%",
zIndex: 2,
elevation: 5,
},
modalInner: {
handleIndicator: {
backgroundColor: theme.colors.border,
},
sheetContent: {
flex: 1,
minHeight: 0,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
justifyContent: "center",
padding: theme.spacing[6],
borderBottomWidth: theme.borderWidth[1],
borderBottomColor: theme.colors.border,
@@ -268,7 +276,7 @@ const styles = StyleSheet.create((theme) => ({
},
scrollContent: {
padding: theme.spacing[6],
paddingBottom: theme.spacing[4],
paddingBottom: theme.spacing[6],
},
formSection: {
marginBottom: theme.spacing[6],