Files
paseo/packages/app/src/components/agent-form/agent-form-dropdowns.tsx
Mohamed Boudra ed50c168ad 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
2026-01-16 16:19:49 +07:00

1645 lines
44 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
Text,
Pressable,
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 {
AgentMode,
AgentModelDefinition,
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
type DropdownTriggerRenderProps = {
label: string;
value: string;
placeholder: string;
onPress: () => void;
disabled?: boolean;
errorMessage?: string | null;
warningMessage?: string | null;
helperText?: string | null;
};
type DropdownTriggerRenderer = (props: DropdownTriggerRenderProps) => ReactNode;
interface DropdownFieldProps {
label: string;
value: string;
placeholder: string;
onPress: () => void;
disabled?: boolean;
errorMessage?: string | null;
warningMessage?: string | null;
helperText?: string | null;
renderTrigger?: DropdownTriggerRenderer;
}
export function DropdownField({
label,
value,
placeholder,
onPress,
disabled,
errorMessage,
warningMessage,
helperText,
renderTrigger,
}: DropdownFieldProps): ReactElement {
if (renderTrigger) {
return (
<>
{renderTrigger({
label,
value,
placeholder,
onPress,
disabled,
errorMessage,
warningMessage,
helperText,
})}
</>
);
}
return (
<View style={styles.formSection}>
<Text style={styles.label}>{label}</Text>
<Pressable
onPress={onPress}
disabled={disabled}
style={[styles.dropdownControl, disabled && styles.dropdownControlDisabled]}
>
<Text
style={value ? styles.dropdownValue : styles.dropdownPlaceholder}
numberOfLines={1}
>
{value || placeholder}
</Text>
<ChevronDown size={16} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
{!errorMessage && helperText ? (
<Text style={styles.helperText}>{helperText}</Text>
) : null}
</View>
);
}
interface SelectFieldProps {
label: string;
value: string;
placeholder?: string;
onPress: () => void;
disabled?: boolean;
errorMessage?: string | null;
warningMessage?: string | null;
helperText?: string | null;
controlRef?: React.RefObject<View | null>;
}
export function SelectField({
label,
value,
placeholder,
onPress,
disabled,
errorMessage,
warningMessage,
helperText,
controlRef,
}: SelectFieldProps): ReactElement {
return (
<View style={styles.selectFieldContainer}>
<Pressable
ref={controlRef}
onPress={onPress}
disabled={disabled}
style={[styles.selectFieldControl, disabled && styles.selectFieldControlDisabled]}
>
<View style={styles.selectFieldContent}>
<Text style={styles.selectFieldLabel}>{label}</Text>
<Text
style={value ? styles.selectFieldValue : styles.selectFieldPlaceholder}
numberOfLines={1}
>
{value || placeholder || "Select..."}
</Text>
</View>
<ChevronRight size={20} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
{!errorMessage && !warningMessage && helperText ? (
<Text style={styles.helperText}>{helperText}</Text>
) : null}
</View>
);
}
interface DropdownSheetProps {
title: string;
visible: boolean;
onClose: () => void;
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 (
<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.bottomSheetHeader}>
<Text style={styles.dropdownSheetTitle}>{title}</Text>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.dropdownSheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
</BottomSheetModal>
);
}
interface AdaptiveSelectProps {
title: string;
visible: boolean;
onClose: () => void;
children: ReactNode;
anchorRef: React.RefObject<View | null>;
}
export function AdaptiveSelect({
title,
visible,
onClose,
children,
anchorRef,
}: AdaptiveSelectProps): ReactElement {
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,
width: 0,
});
useEffect(() => {
if (!visible || isMobile) {
return;
}
const anchor = anchorRef.current;
if (!anchor) {
return;
}
anchor.measureInWindow((x, y, width, height) => {
const verticalOffset = 4;
const horizontalMargin = 16;
const maxDropdownHeight = 400;
let top = y + height + verticalOffset;
const left = Math.max(horizontalMargin, Math.min(x, windowWidth - width - horizontalMargin));
if (top + maxDropdownHeight > windowHeight - horizontalMargin) {
top = y - maxDropdownHeight - verticalOffset;
if (top < horizontalMargin) {
top = y + height + verticalOffset;
}
}
setDropdownPosition({ top, left, width });
});
}, [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 (
<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.bottomSheetHeader}>
<Text style={styles.dropdownSheetTitle}>{title}</Text>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.dropdownSheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</BottomSheetScrollView>
</BottomSheetModal>
);
}
return (
<Modal
transparent
animationType="fade"
visible={visible}
onRequestClose={onClose}
>
<View style={styles.desktopDropdownOverlay}>
<Pressable style={styles.desktopDropdownBackdrop} onPress={onClose} />
<View
style={[
styles.desktopDropdownContainer,
{
position: "absolute",
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
minWidth: 200,
},
]}
>
<ScrollView
contentContainerStyle={styles.desktopDropdownScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopDropdownScroll}
>
{children}
</ScrollView>
</View>
</View>
</Modal>
);
}
interface ComboSelectOption {
id: string;
label: string;
description?: string;
}
interface ComboSelectProps {
label: string;
title: string;
value: string;
options: ComboSelectOption[];
placeholder?: string;
disabled?: boolean;
allowCustomValue?: boolean;
isLoading?: boolean;
onSelect: (id: string) => void;
}
export function ComboSelect({
label,
title,
value,
options,
placeholder,
disabled,
allowCustomValue = false,
isLoading,
onSelect,
}: ComboSelectProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const [searchQuery, setSearchQuery] = useState("");
const selectedOption = options.find((opt) => opt.id === value);
const displayValue = selectedOption?.label ?? (value || "");
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => {
setIsOpen(false);
setSearchQuery("");
}, []);
useEffect(() => {
if (isOpen) {
setSearchQuery("");
}
}, [isOpen]);
const normalizedSearch = searchQuery.trim().toLowerCase();
const filteredOptions = useMemo(() => {
if (!normalizedSearch) {
return options;
}
return options.filter(
(opt) =>
opt.label.toLowerCase().includes(normalizedSearch) ||
opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch)
);
}, [options, normalizedSearch]);
const hasMatches = filteredOptions.length > 0;
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption =
allowCustomValue &&
sanitizedSearchValue.length > 0 &&
!options.some(
(opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
);
const handleSelect = useCallback(
(id: string) => {
onSelect(id);
handleClose();
},
[handleClose, onSelect]
);
return (
<>
<CompactSelectField
label={label}
value={displayValue}
placeholder={placeholder}
onPress={handleOpen}
disabled={disabled}
isLoading={isLoading}
controlRef={anchorRef}
/>
<AdaptiveSelect
title={title}
visible={isOpen}
onClose={handleClose}
anchorRef={anchorRef}
>
{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
style={styles.dropdownSheetOption}
onPress={() => handleSelect(sanitizedSearchValue)}
>
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
{`Use "${sanitizedSearchValue}"`}
</Text>
</Pressable>
</View>
) : null}
{hasMatches ? (
<View style={styles.dropdownSheetList}>
{filteredOptions.map((opt) => {
const isSelected = opt.id === value;
return (
<Pressable
key={opt.id}
style={[
styles.dropdownSheetOption,
isSelected && styles.dropdownSheetOptionSelected,
]}
onPress={() => handleSelect(opt.id)}
>
<Text style={styles.dropdownSheetOptionLabel}>{opt.label}</Text>
{opt.description ? (
<Text style={styles.dropdownSheetOptionDescription}>
{opt.description}
</Text>
) : null}
</Pressable>
);
})}
</View>
) : !showCustomOption ? (
<Text style={styles.helperText}>No options match your search.</Text>
) : null}
{isLoading ? (
<View style={styles.dropdownSheetLoading}>
<ActivityIndicator size="small" color={defaultTheme.colors.foreground} />
</View>
) : null}
</AdaptiveSelect>
</>
);
}
interface CompactSelectFieldProps {
label: string;
value: string;
placeholder?: string;
onPress: () => void;
disabled?: boolean;
isLoading?: boolean;
controlRef?: React.RefObject<View | null>;
}
function CompactSelectField({
label,
value,
placeholder,
onPress,
disabled,
isLoading,
controlRef,
}: CompactSelectFieldProps): ReactElement {
return (
<Pressable
ref={controlRef}
onPress={onPress}
disabled={disabled}
style={[styles.compactSelectControl, disabled && styles.compactSelectControlDisabled]}
>
<Text style={styles.compactSelectLabel}>{label}</Text>
{isLoading ? (
<ActivityIndicator size="small" color={defaultTheme.colors.foregroundMuted} />
) : (
<Text
style={value ? styles.compactSelectValue : styles.compactSelectPlaceholder}
numberOfLines={1}
>
{value || placeholder || "Select..."}
</Text>
)}
</Pressable>
);
}
interface AgentConfigRowProps {
providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider;
onSelectProvider: (provider: AgentProvider) => void;
modeOptions: AgentMode[];
selectedMode: string;
onSelectMode: (modeId: string) => void;
models: AgentModelDefinition[];
selectedModel: string;
isModelLoading: boolean;
onSelectModel: (modelId: string) => void;
disabled?: boolean;
}
export function AgentConfigRow({
providerDefinitions,
selectedProvider,
onSelectProvider,
modeOptions,
selectedMode,
onSelectMode,
models,
selectedModel,
isModelLoading,
onSelectModel,
disabled,
}: AgentConfigRowProps): ReactElement {
const providerOptions: ComboSelectOption[] = useMemo(
() =>
providerDefinitions.map((def) => ({
id: def.id,
label: def.label,
description: def.description,
})),
[providerDefinitions]
);
const modeSelectOptions: ComboSelectOption[] = useMemo(() => {
if (modeOptions.length === 0) {
return [{ id: "", label: "Default", description: "Provider default mode" }];
}
return modeOptions.map((mode) => ({
id: mode.id,
label: mode.label,
description: mode.description,
}));
}, [modeOptions]);
const modelSelectOptions: ComboSelectOption[] = useMemo(() => {
const opts: ComboSelectOption[] = [
{ id: "", label: "Auto", description: "Provider default model" },
];
for (const model of models) {
opts.push({
id: model.id,
label: model.label,
description: model.description,
});
}
return opts;
}, [models]);
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
return (
<View style={styles.agentConfigRow}>
<View style={styles.agentConfigColumn}>
<ComboSelect
label="PROVIDER"
title="Select Provider"
value={selectedProvider}
options={providerOptions}
placeholder="Select..."
disabled={disabled}
onSelect={onSelectProvider}
/>
</View>
<View style={styles.agentConfigColumn}>
<ComboSelect
label="MODEL"
title="Select Model"
value={selectedModel}
options={modelSelectOptions}
placeholder="Auto"
disabled={disabled}
isLoading={isModelLoading}
onSelect={onSelectModel}
/>
</View>
<View style={styles.agentConfigColumn}>
<ComboSelect
label="MODE"
title="Select Mode"
value={effectiveSelectedMode}
options={modeSelectOptions}
placeholder="Default"
disabled={disabled || modeOptions.length === 0}
onSelect={onSelectMode}
/>
</View>
</View>
);
}
interface AssistantDropdownProps {
providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider;
disabled: boolean;
onSelect: (provider: AgentProvider) => void;
}
export function AssistantDropdown({
providerDefinitions,
selectedProvider,
disabled,
onSelect,
}: AssistantDropdownProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const selectedDefinition = providerDefinitions.find(
(definition) => definition.id === selectedProvider
);
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => setIsOpen(false), []);
return (
<>
<SelectField
label="AGENT"
value={selectedDefinition?.label ?? ""}
placeholder="Select assistant"
onPress={handleOpen}
disabled={disabled}
controlRef={anchorRef}
/>
<AdaptiveSelect
title="Choose Assistant"
visible={isOpen}
onClose={handleClose}
anchorRef={anchorRef}
>
{providerDefinitions.map((definition) => {
const isSelected = definition.id === selectedProvider;
return (
<Pressable
key={definition.id}
style={[
styles.dropdownSheetOption,
isSelected && styles.dropdownSheetOptionSelected,
]}
onPress={() => {
onSelect(definition.id);
handleClose();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>{definition.label}</Text>
{definition.description ? (
<Text style={styles.dropdownSheetOptionDescription}>
{definition.description}
</Text>
) : null}
</Pressable>
);
})}
</AdaptiveSelect>
</>
);
}
interface PermissionsDropdownProps {
modeOptions: AgentMode[];
selectedMode: string;
disabled: boolean;
onSelect: (modeId: string) => void;
}
export function PermissionsDropdown({
modeOptions,
selectedMode,
disabled,
onSelect,
}: PermissionsDropdownProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const hasOptions = modeOptions.length > 0;
const selectedModeLabel = hasOptions
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
modeOptions[0]?.label ??
"Default"
: "Automatic";
const handleOpen = useCallback(() => {
if (hasOptions) {
setIsOpen(true);
}
}, [hasOptions]);
const handleClose = useCallback(() => setIsOpen(false), []);
return (
<>
<SelectField
label="PERMISSIONS"
value={selectedModeLabel}
placeholder={hasOptions ? "Select permissions" : "Automatic"}
onPress={handleOpen}
disabled={disabled || !hasOptions}
helperText={
hasOptions
? undefined
: "This assistant does not expose selectable permissions."
}
controlRef={anchorRef}
/>
{hasOptions ? (
<AdaptiveSelect
title="Permissions"
visible={isOpen}
onClose={handleClose}
anchorRef={anchorRef}
>
{modeOptions.map((mode) => {
const isSelected = mode.id === selectedMode;
return (
<Pressable
key={mode.id}
style={[
styles.dropdownSheetOption,
isSelected && styles.dropdownSheetOptionSelected,
]}
onPress={() => {
onSelect(mode.id);
handleClose();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>{mode.label}</Text>
{mode.description ? (
<Text style={styles.dropdownSheetOptionDescription}>
{mode.description}
</Text>
) : null}
</Pressable>
);
})}
</AdaptiveSelect>
) : null}
</>
);
}
interface ModelDropdownProps {
models: AgentModelDefinition[];
selectedModel: string;
isLoading: boolean;
error: string | null;
onSelect: (modelId: string) => void;
onClear: () => void;
onRefresh: () => void;
}
export function ModelDropdown({
models,
selectedModel,
isLoading,
error,
onSelect,
onClear,
onRefresh,
}: ModelDropdownProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const selectedLabel = selectedModel
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
: "Automatic";
const placeholder = isLoading && models.length === 0 ? "Loading..." : "Automatic";
const helperText = error
? undefined
: isLoading
? "Fetching available models..."
: models.length === 0
? "This assistant did not expose selectable models."
: undefined;
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => setIsOpen(false), []);
return (
<>
<SelectField
label="MODEL"
value={selectedLabel}
placeholder={placeholder}
onPress={handleOpen}
disabled={false}
errorMessage={error ?? undefined}
helperText={helperText}
controlRef={anchorRef}
/>
<AdaptiveSelect title="Model" visible={isOpen} onClose={handleClose} anchorRef={anchorRef}>
<Pressable
style={styles.dropdownSheetOption}
onPress={() => {
onClear();
handleClose();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>
Automatic (provider default)
</Text>
<Text style={styles.dropdownSheetOptionDescription}>
Let the assistant pick the recommended model.
</Text>
</Pressable>
{models.map((model) => {
const isSelected = model.id === selectedModel;
return (
<Pressable
key={model.id}
style={[
styles.dropdownSheetOption,
isSelected && styles.dropdownSheetOptionSelected,
]}
onPress={() => {
onSelect(model.id);
handleClose();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>{model.label}</Text>
{model.description ? (
<Text style={styles.dropdownSheetOptionDescription}>
{model.description}
</Text>
) : null}
</Pressable>
);
})}
<Pressable
style={styles.dropdownSheetOption}
onPress={() => {
onRefresh();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>Refresh models</Text>
<Text style={styles.dropdownSheetOptionDescription}>
Request the latest catalog from the provider.
</Text>
</Pressable>
{isLoading ? (
<View style={styles.dropdownSheetLoading}>
<ActivityIndicator size="small" color={defaultTheme.colors.foreground} />
</View>
) : null}
</AdaptiveSelect>
</>
);
}
interface WorkingDirectoryDropdownProps {
workingDir: string;
errorMessage: string;
disabled: boolean;
suggestedPaths: string[];
onSelectPath: (value: string) => void;
}
export function WorkingDirectoryDropdown({
workingDir,
errorMessage,
disabled,
suggestedPaths,
onSelectPath,
}: WorkingDirectoryDropdownProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const [searchQuery, setSearchQuery] = useState("");
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => setIsOpen(false), []);
useEffect(() => {
if (isOpen) {
setSearchQuery("");
}
}, [isOpen]);
const normalizedSearch = searchQuery.trim().toLowerCase();
const filteredPaths = useMemo(() => {
if (!normalizedSearch) {
return suggestedPaths;
}
return suggestedPaths.filter((path) =>
path.toLowerCase().includes(normalizedSearch)
);
}, [suggestedPaths, normalizedSearch]);
const hasSuggestedPaths = suggestedPaths.length > 0;
const hasMatches = filteredPaths.length > 0;
const sanitizedSearchValue = searchQuery.trim();
const showCustomOption = sanitizedSearchValue.length > 0;
const handleSelect = useCallback(
(path: string) => {
onSelectPath(path);
handleClose();
},
[handleClose, onSelectPath]
);
return (
<>
<SelectField
label="WORKING DIRECTORY"
value={workingDir}
placeholder="/path/to/project"
onPress={handleOpen}
disabled={disabled}
errorMessage={errorMessage || undefined}
controlRef={anchorRef}
/>
<AdaptiveSelect
title="Working Directory"
visible={isOpen}
onClose={handleClose}
anchorRef={anchorRef}
>
{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.
</Text>
) : null}
{showCustomOption ? (
<View style={styles.dropdownSheetList}>
<Pressable
key="working-dir-custom-option"
style={styles.dropdownSheetOption}
onPress={() => handleSelect(sanitizedSearchValue)}
>
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
{`Use "${sanitizedSearchValue}"`}
</Text>
<Text style={styles.dropdownSheetOptionDescription}>
Launch the agent in this directory
</Text>
</Pressable>
</View>
) : null}
{hasMatches ? (
<View style={styles.dropdownSheetList}>
{filteredPaths.map((path) => {
const isActive = path === workingDir;
return (
<Pressable
key={path}
style={[
styles.dropdownSheetOption,
isActive && styles.dropdownSheetOptionSelected,
]}
onPress={() => handleSelect(path)}
>
<Text style={styles.dropdownSheetOptionLabel} numberOfLines={1}>
{path}
</Text>
</Pressable>
);
})}
</View>
) : hasSuggestedPaths ? (
<Text style={styles.helperText}>
No agent directories match your search.
</Text>
) : null}
</AdaptiveSelect>
</>
);
}
interface ToggleRowProps {
label: string;
description?: string;
value: boolean;
onToggle: (value: boolean) => void;
disabled?: boolean;
}
export function ToggleRow({
label,
description,
value,
onToggle,
disabled,
}: ToggleRowProps): ReactElement {
return (
<Pressable
onPress={() => {
if (!disabled) {
onToggle(!value);
}
}}
style={[styles.toggleRow, disabled && styles.toggleRowDisabled]}
>
<View
style={[
styles.checkbox,
value && styles.checkboxChecked,
disabled && styles.checkboxDisabled,
]}
>
{value ? <View style={styles.checkboxDot} /> : null}
</View>
<View style={styles.toggleTextContainer}>
<Text style={styles.toggleLabel}>{label}</Text>
{description ? (
<Text style={styles.helperText}>{description}</Text>
) : null}
</View>
</Pressable>
);
}
export interface GitOptionsSectionProps {
useWorktree: boolean;
onUseWorktreeChange: (value: boolean) => void;
worktreeSlug: string;
currentBranch: string | null;
baseBranch: string;
onBaseBranchChange: (value: string) => void;
branches: Array<{ name: string; isCurrent: boolean }>;
status: "idle" | "loading" | "ready" | "error";
repoError: string | null;
gitValidationError: string | null;
baseBranchError: string | null;
}
export function GitOptionsSection({
useWorktree,
onUseWorktreeChange,
worktreeSlug,
currentBranch,
baseBranch,
onBaseBranchChange,
branches,
status,
repoError,
gitValidationError,
baseBranchError,
}: GitOptionsSectionProps): ReactElement {
const isLoading = status === "loading";
const [isEditingBranch, setIsEditingBranch] = useState(false);
const [editedBranch, setEditedBranch] = useState(baseBranch);
const inputRef = useRef<TextInput>(null);
useEffect(() => {
setEditedBranch(baseBranch);
}, [baseBranch]);
useEffect(() => {
if (isEditingBranch) {
inputRef.current?.focus();
}
}, [isEditingBranch]);
const handleStartEdit = useCallback(() => {
setEditedBranch(baseBranch);
setIsEditingBranch(true);
}, [baseBranch]);
const handleConfirmEdit = useCallback(() => {
const trimmed = editedBranch.trim();
if (trimmed) {
onBaseBranchChange(trimmed);
}
setIsEditingBranch(false);
}, [editedBranch, onBaseBranchChange]);
const handleCancelEdit = useCallback(() => {
setEditedBranch(baseBranch);
setIsEditingBranch(false);
}, [baseBranch]);
const displayBranch = baseBranch || currentBranch || "HEAD";
return (
<View style={styles.gitOptionsContainer}>
<Pressable
onPress={() => onUseWorktreeChange(!useWorktree)}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
<View style={[styles.checkbox, useWorktree && styles.checkboxChecked]}>
{useWorktree ? <View style={styles.checkboxDot} /> : null}
</View>
<View style={styles.worktreeToggleContent}>
<Text style={styles.worktreeToggleLabel}>Create worktree</Text>
<Text style={styles.worktreeToggleDescription}>
{isLoading
? "Inspecting repository…"
: useWorktree && worktreeSlug
? `Will create: ${worktreeSlug}`
: currentBranch
? `Run isolated from ${currentBranch}`
: "Run in an isolated directory"}
</Text>
</View>
</Pressable>
{useWorktree ? (
<View style={styles.baseBranchRow}>
<Text style={styles.baseBranchLabel}>Base branch:</Text>
{isEditingBranch ? (
<View style={styles.baseBranchEditRow}>
<TextInput
ref={inputRef}
style={styles.baseBranchInput}
value={editedBranch}
onChangeText={setEditedBranch}
autoCapitalize="none"
autoCorrect={false}
placeholder="branch name"
placeholderTextColor={defaultTheme.colors.foregroundMuted}
onSubmitEditing={handleConfirmEdit}
/>
<Pressable onPress={handleConfirmEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<Check size={16} color={defaultTheme.colors.palette.green[500]} />
</Pressable>
<Pressable onPress={handleCancelEdit} hitSlop={8} style={styles.baseBranchIconButton}>
<X size={16} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
</View>
) : (
<Pressable onPress={handleStartEdit} style={styles.baseBranchValueRow}>
<Text style={styles.baseBranchValue}>{displayBranch}</Text>
<Pencil size={14} color={defaultTheme.colors.foregroundMuted} />
</Pressable>
)}
</View>
) : null}
{baseBranchError ? (
<Text style={styles.errorText}>{baseBranchError}</Text>
) : null}
{repoError ? (
<Text style={styles.errorText}>{repoError}</Text>
) : null}
{gitValidationError ? (
<Text style={styles.errorText}>{gitValidationError}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
formSection: {
gap: theme.spacing[3],
},
label: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.semibold,
},
dropdownControl: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
dropdownControlDisabled: {
opacity: theme.opacity[50],
},
dropdownValue: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
dropdownPlaceholder: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
dropdownSearchInput: {
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
paddingHorizontal: theme.spacing[3],
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",
},
dropdownSheetBackdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: theme.colors.palette.gray[900],
opacity: 0.45,
},
dropdownSheetContainer: {
backgroundColor: theme.colors.surface2,
borderTopLeftRadius: theme.borderRadius["2xl"],
borderTopRightRadius: theme.borderRadius["2xl"],
paddingTop: theme.spacing[4],
paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[6] + theme.spacing[2],
maxHeight: 560,
width: "100%",
},
dropdownSheetHandle: {
width: 56,
height: 4,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.border,
alignSelf: "center",
marginBottom: theme.spacing[3],
},
dropdownSheetTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
dropdownSheetScrollContent: {
paddingBottom: theme.spacing[8],
paddingHorizontal: theme.spacing[1],
},
dropdownSheetList: {
marginTop: theme.spacing[3],
},
dropdownSheetOption: {
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
marginBottom: theme.spacing[2],
},
dropdownSheetOptionSelected: {
borderColor: theme.colors.palette.blue[400],
backgroundColor: "rgba(59, 130, 246, 0.18)",
},
dropdownSheetOptionLabel: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.semibold,
},
dropdownSheetOptionDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
marginTop: theme.spacing[1],
},
dropdownSheetLoading: {
alignItems: "center",
paddingVertical: theme.spacing[4],
},
errorText: {
color: theme.colors.palette.red[500],
fontSize: theme.fontSize.sm,
},
warningText: {
color: theme.colors.palette.orange[500],
fontSize: theme.fontSize.sm,
},
helperText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
selectorColumn: {
flex: 1,
gap: theme.spacing[3],
},
selectorColumnFull: {
width: "100%",
},
toggleRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
toggleRowDisabled: {
opacity: theme.opacity[50],
},
checkbox: {
width: 22,
height: 22,
borderRadius: theme.borderRadius.sm,
borderWidth: theme.borderWidth[2],
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
checkboxChecked: {
borderColor: theme.colors.palette.blue[500],
backgroundColor: theme.colors.palette.blue[500],
},
checkboxDisabled: {
borderColor: theme.colors.border,
},
checkboxDot: {
width: 10,
height: 10,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.white,
},
toggleTextContainer: {
flex: 1,
gap: theme.spacing[1],
},
toggleLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
input: {
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
dropdownLoading: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
selectFieldContainer: {
gap: theme.spacing[2],
},
selectFieldControl: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
selectFieldControlDisabled: {
opacity: theme.opacity[50],
},
selectFieldContent: {
flex: 1,
gap: theme.spacing[1],
},
selectFieldLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
textTransform: "uppercase",
letterSpacing: 0.5,
},
selectFieldValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
selectFieldPlaceholder: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
},
gitOptionsContainer: {
gap: theme.spacing[3],
},
worktreeToggle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
worktreeToggleDisabled: {
opacity: theme.opacity[50],
},
worktreeToggleContent: {
flex: 1,
gap: theme.spacing[1],
},
worktreeToggleLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
worktreeToggleDescription: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
baseBranchRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[4],
},
baseBranchLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
baseBranchValueRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
baseBranchValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
baseBranchEditRow: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
baseBranchInput: {
flex: 1,
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
baseBranchIconButton: {
padding: theme.spacing[1],
},
desktopDropdownOverlay: {
flex: 1,
},
desktopDropdownBackdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
desktopDropdownContainer: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
maxHeight: 400,
},
desktopDropdownScroll: {
maxHeight: 400,
},
desktopDropdownScrollContent: {
padding: theme.spacing[2],
},
agentConfigRow: {
flexDirection: "row",
gap: theme.spacing[2],
},
agentConfigColumn: {
flex: 1,
},
compactSelectControl: {
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
gap: theme.spacing[1],
},
compactSelectControlDisabled: {
opacity: theme.opacity[50],
},
compactSelectLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
textTransform: "uppercase",
letterSpacing: 0.5,
},
compactSelectValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
compactSelectPlaceholder: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
}));