Update files

This commit is contained in:
Mohamed Boudra
2026-01-28 21:57:31 +07:00
parent d158dcf77a
commit 2c138312b2
16 changed files with 1107 additions and 646 deletions
@@ -1,13 +1,16 @@
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import {
Modal,
Platform,
Pressable,
ScrollView,
Text,
View,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
import {
BottomSheetModal,
BottomSheetBackdrop,
@@ -18,11 +21,13 @@ import { X } from "lucide-react-native";
const styles = StyleSheet.create((theme) => ({
desktopOverlay: {
flex: 1,
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.55)",
justifyContent: "center",
alignItems: "center",
padding: theme.spacing[6],
zIndex: OVERLAY_Z.modal,
pointerEvents: "auto" as const,
},
desktopCard: {
width: "100%",
@@ -184,6 +189,42 @@ export function AdaptiveModalSheet({
);
}
const desktopContent = (
<View style={styles.desktopOverlay} testID={testID}>
<Pressable
accessibilityLabel="Dismiss"
style={{ ...StyleSheet.absoluteFillObject }}
onPress={onClose}
/>
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</View>
</View>
);
// On web, use portal to overlay root for consistent stacking with toasts
if (Platform.OS === "web" && typeof document !== "undefined") {
if (!visible) return null;
return createPortal(desktopContent, getOverlayRoot());
}
return (
<Modal
transparent
@@ -192,33 +233,7 @@ export function AdaptiveModalSheet({
onRequestClose={onClose}
hardwareAccelerated
>
<View style={styles.desktopOverlay} testID={testID}>
<Pressable
accessibilityLabel="Dismiss"
style={{ ...StyleSheet.absoluteFillObject }}
onPress={onClose}
/>
<View style={styles.desktopCard}>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
<Pressable
accessibilityLabel="Close"
style={styles.closeButton}
onPress={onClose}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
<ScrollView
style={styles.desktopScroll}
contentContainerStyle={styles.desktopContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</View>
</View>
{desktopContent}
</Modal>
);
}
@@ -4,30 +4,19 @@ import {
View,
Text,
Pressable,
Modal,
TextInput,
ActivityIndicator,
ScrollView,
Platform,
StatusBar,
} from "react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { StyleSheet, useUnistyles } 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 {
flip,
offset as floatingOffset,
shift,
size as floatingSize,
useFloating,
} from "@floating-ui/react-native";
import { theme as defaultTheme } from "@/styles/theme";
import type {
AgentMode,
@@ -35,6 +24,7 @@ import type {
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
type DropdownTriggerRenderProps = {
label: string;
@@ -256,177 +246,8 @@ export function DropdownSheet({
);
}
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 bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const collisionPadding = useMemo(() => {
const basePadding = 16;
if (Platform.OS !== "android") return basePadding;
const statusBarHeight = StatusBar.currentHeight ?? 0;
return Math.max(basePadding, statusBarHeight + basePadding);
}, []);
const middleware = useMemo(
() => [
floatingOffset({ mainAxis: 4 }),
flip({ padding: collisionPadding }),
// Avoid `crossAxis: true` here: per Floating UI docs it can cause overlap with the reference.
shift({ padding: collisionPadding }),
floatingSize({
padding: collisionPadding,
apply({ availableWidth, availableHeight, rects }) {
setAvailableSize((prev) => {
const next = { width: availableWidth, height: availableHeight };
if (!prev) return next;
if (prev.width === next.width && prev.height === next.height) return prev;
return next;
});
setReferenceWidth((prev) => {
const next = rects.reference.width;
if (prev === next) return prev;
return next;
});
},
}),
],
[collisionPadding]
);
const { refs, floatingStyles, update } = useFloating({
placement: "bottom-start",
middleware,
sameScrollView: false,
elements: {
reference: anchorRef.current ?? undefined,
},
});
useEffect(() => {
if (!visible || isMobile) {
setAvailableSize(null);
setReferenceWidth(null);
return;
}
const raf = requestAnimationFrame(() => update());
return () => cancelAnimationFrame(raf);
}, [isMobile, update, visible]);
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 ref={refs.setOffsetParent} collapsable={false} style={styles.desktopDropdownOverlay}>
<Pressable style={styles.desktopDropdownBackdrop} onPress={onClose} />
<View
style={[
styles.desktopDropdownContainer,
{
position: "absolute",
minWidth: 200,
width: referenceWidth ?? undefined,
},
floatingStyles,
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
typeof availableSize?.width === "number" ? { maxWidth: availableSize.width } : null,
]}
ref={refs.setFloating}
collapsable={false}
onLayout={() => update()}
>
<ScrollView
contentContainerStyle={styles.desktopDropdownScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopDropdownScroll}
>
{children}
</ScrollView>
</View>
</View>
</Modal>
);
}
// Re-export ComboboxItem as SelectOption for backwards compatibility
const SelectOption = ComboboxItem;
interface ComboSelectOption {
id: string;
@@ -459,54 +280,12 @@ export function ComboSelect({
}: 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]
);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
return (
<>
@@ -519,79 +298,17 @@ export function ComboSelect({
isLoading={isLoading}
controlRef={anchorRef}
/>
<AdaptiveSelect
<Combobox
options={options}
value={value}
onSelect={onSelect}
searchPlaceholder={`Search ${label.toLowerCase()}...`}
allowCustomValue={allowCustomValue}
title={title}
visible={isOpen}
onClose={handleClose}
open={isOpen}
onOpenChange={handleOpenChange}
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>
/>
</>
);
}
@@ -761,8 +478,18 @@ export function AssistantDropdown({
(definition) => definition.id === selectedProvider
);
const options = useMemo(
() =>
providerDefinitions.map((def) => ({
id: def.id,
label: def.label,
description: def.description,
})),
[providerDefinitions]
);
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => setIsOpen(false), []);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
return (
<>
@@ -774,36 +501,15 @@ export function AssistantDropdown({
disabled={disabled}
controlRef={anchorRef}
/>
<AdaptiveSelect
<Combobox
options={options}
value={selectedProvider}
onSelect={(id) => onSelect(id as AgentProvider)}
title="Choose assistant"
visible={isOpen}
onClose={handleClose}
open={isOpen}
onOpenChange={handleOpenChange}
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>
/>
</>
);
}
@@ -831,12 +537,22 @@ export function PermissionsDropdown({
"Default"
: "Automatic";
const options = useMemo(
() =>
modeOptions.map((mode) => ({
id: mode.id,
label: mode.label,
description: mode.description,
})),
[modeOptions]
);
const handleOpen = useCallback(() => {
if (hasOptions) {
setIsOpen(true);
}
}, [hasOptions]);
const handleClose = useCallback(() => setIsOpen(false), []);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
return (
<>
@@ -854,36 +570,15 @@ export function PermissionsDropdown({
controlRef={anchorRef}
/>
{hasOptions ? (
<AdaptiveSelect
<Combobox
options={options}
value={selectedMode}
onSelect={onSelect}
title="Permissions"
visible={isOpen}
onClose={handleClose}
open={isOpen}
onOpenChange={handleOpenChange}
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}
</>
);
@@ -923,8 +618,36 @@ export function ModelDropdown({
? "This assistant did not expose selectable models."
: undefined;
const options = useMemo(() => {
const opts: ComboSelectOption[] = [
{
id: "",
label: "Automatic (provider default)",
description: "Let the assistant pick the recommended model.",
},
];
for (const model of models) {
opts.push({
id: model.id,
label: model.label,
description: model.description,
});
}
return opts;
}, [models]);
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => setIsOpen(false), []);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
const handleSelect = useCallback(
(id: string) => {
if (id === "") {
onClear();
} else {
onSelect(id);
}
},
[onClear, onSelect]
);
return (
<>
@@ -938,61 +661,15 @@ export function ModelDropdown({
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>
<Combobox
options={options}
value={selectedModel}
onSelect={handleSelect}
title="Model"
open={isOpen}
onOpenChange={handleOpenChange}
anchorRef={anchorRef}
/>
</>
);
}
@@ -1014,46 +691,18 @@ export function WorkingDirectoryDropdown({
}: 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]
const options = useMemo(
() => suggestedPaths.map((path) => ({ id: path, label: path })),
[suggestedPaths]
);
const handleSubmitSearch = useCallback(() => {
if (!showCustomOption) {
return;
}
handleSelect(sanitizedSearchValue);
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleOpenChange = useCallback((open: boolean) => setIsOpen(open), []);
const emptyText = suggestedPaths.length > 0
? "No agent directories match your search."
: "We'll suggest directories from agents on this host once they exist.";
return (
<>
@@ -1068,85 +717,20 @@ export function WorkingDirectoryDropdown({
controlRef={anchorRef}
testID="working-directory-select"
/>
<AdaptiveSelect
<Combobox
options={options}
value={workingDir}
onSelect={onSelectPath}
searchPlaceholder="/path/to/project"
emptyText={emptyText}
allowCustomValue
customValuePrefix="Use"
customValueDescription="Launch the agent in this directory"
title="Working directory"
visible={isOpen}
onClose={handleClose}
open={isOpen}
onOpenChange={handleOpenChange}
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
onSubmitEditing={handleSubmitSearch}
/>
) : (
<BottomSheetTextInput
style={styles.dropdownSearchInput}
placeholder="/path/to/project"
placeholderTextColor={defaultTheme.colors.foregroundMuted}
value={searchQuery}
onChangeText={setSearchQuery}
autoCapitalize="none"
autoCorrect={false}
autoFocus
onSubmitEditing={handleSubmitSearch}
/>
)}
{!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"
testID="working-directory-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>
/>
</>
);
}
@@ -1343,16 +927,15 @@ export function GitOptionsSection({
onClose={() => setIsWorktreeSheetOpen(false)}
>
{worktreeOptions.map((option) => (
<Pressable
<SelectOption
key={option.path}
style={styles.dropdownSheetOption}
label={option.label}
selected={option.path === selectedWorktreePath}
onPress={() => {
onSelectWorktreePath(option.path);
setIsWorktreeSheetOpen(false);
}}
>
<Text style={styles.dropdownSheetOptionLabel}>{option.label}</Text>
</Pressable>
/>
))}
</DropdownSheet>
</>
@@ -1438,15 +1021,6 @@ const styles = StyleSheet.create((theme) => ({
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"],
@@ -1719,22 +1293,23 @@ const styles = StyleSheet.create((theme) => ({
left: 0,
},
desktopDropdownContainer: {
backgroundColor: theme.colors.surface2,
backgroundColor: theme.colors.surface0,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
maxHeight: 400,
overflow: "hidden",
},
desktopDropdownScroll: {
maxHeight: 400,
},
desktopDropdownScrollContent: {
padding: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
agentConfigRow: {
flexDirection: "row",
@@ -69,6 +69,8 @@ export function DraggableList<T>({
ListEmptyComponent={ListEmptyComponent}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
simultaneousHandlers={simultaneousHandlers}
// Higher activationDistance prevents drag from interfering with nested onLongPress handlers
activationDistance={20}
// @ts-expect-error - waitFor is supported by RNGH FlatList but not typed in DraggableFlatList
waitFor={waitFor}
refreshControl={
@@ -147,11 +147,24 @@ function SectionHeader({
onHoverOut={() => setIsHovered(false)}
>
<View style={styles.sectionHeaderLeft}>
{icon && (
<View style={styles.chevron}>
{isCollapsed ? (
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
) : (
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
)}
</View>
{icon ? (
<Image
source={{ uri: `data:${icon.mimeType};base64,${icon.data}` }}
style={styles.projectIcon}
/>
) : (
<View style={styles.projectIconPlaceholder}>
<Text style={styles.projectIconPlaceholderText}>
{(section.workingDir?.split("/").pop() ?? displayTitle).charAt(0).toUpperCase()}
</Text>
</View>
)}
<Text style={styles.sectionTitle} numberOfLines={1}>
{displayTitle}
@@ -162,22 +175,18 @@ function SectionHeader({
<Pressable
style={styles.createAgentButton}
onPress={handleCreatePress}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(true)}
hitSlop={20}
onStartShouldSetResponder={() => true}
onStartShouldSetResponderCapture={() => true}
>
{({ hovered }) => (
{({ hovered, pressed }) => (
<Plus
size={14}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
size={18}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
)}
{isCollapsed ? (
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
) : (
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
)}
</View>
</Pressable>
);
@@ -271,9 +280,10 @@ export function GroupedAgentList({
const handleCreateAgentInProject = useCallback(
(workingDir: string) => {
onAgentSelect?.();
router.push(`/agent?workingDir=${encodeURIComponent(workingDir)}` as any);
},
[]
[onAgentSelect]
);
// Prefetch checkout status for all agents in the sidebar.
@@ -436,6 +446,7 @@ export function GroupedAgentList({
<Pressable
style={({ pressed }) => [
styles.agentItem,
!isSelected && styles.agentItemUnselected,
isSelected && styles.agentItemSelected,
isHovered && styles.agentItemHovered,
pressed && styles.agentItemPressed,
@@ -462,9 +473,9 @@ export function GroupedAgentList({
>
{agent.title || "New agent"}
</Text>
{isHovered && canArchive && (
{isHovered && canArchive ? (
<Pressable
style={styles.archiveButton}
style={styles.branchBadge}
onPress={(e) => handleArchiveAgent(e, agent)}
onHoverIn={() => setIsHovered(true)}
onHoverOut={() => setIsHovered(true)}
@@ -472,7 +483,7 @@ export function GroupedAgentList({
>
{({ hovered: archiveHovered }) => (
<Archive
size={14}
size={12}
color={
archiveHovered
? theme.colors.foreground
@@ -481,12 +492,14 @@ export function GroupedAgentList({
/>
)}
</Pressable>
)}
) : activeBranchLabel ? (
<View style={styles.branchBadge}>
<Text style={styles.branchBadgeText} numberOfLines={1}>
{activeBranchLabel}
</Text>
</View>
) : null}
</View>
<Text style={styles.secondaryRow} numberOfLines={1}>
{activeBranchLabel ? `${activeBranchLabel} · ${timeAgo}` : timeAgo}
</Text>
</View>
</Pressable>
);
@@ -508,7 +521,7 @@ export function GroupedAgentList({
const isCollapsed = collapsedSections.has(section.key);
return (
<View style={isActive && styles.sectionDragging}>
<View style={[styles.sectionContainer, isActive && styles.sectionDragging]}>
<SectionHeader
section={section}
isCollapsed={isCollapsed}
@@ -624,14 +637,20 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
sectionHeaderExpanded: {
marginBottom: theme.spacing[1],
marginBottom: theme.spacing[2],
},
sectionHeaderDragging: {
backgroundColor: theme.colors.surface2,
},
sectionContainer: {
marginBottom: theme.spacing[2],
},
sectionDragging: {
opacity: 0.9,
},
chevron: {
opacity: 0.5,
},
sectionHeaderLeft: {
flexDirection: "row",
alignItems: "center",
@@ -645,6 +664,20 @@ const styles = StyleSheet.create((theme) => ({
height: 16,
borderRadius: theme.borderRadius.sm,
},
projectIconPlaceholder: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
projectIconPlaceholderText: {
fontSize: 10,
fontWeight: "500",
color: theme.colors.foregroundMuted,
},
sectionHeaderRight: {
flexDirection: "row",
alignItems: "center",
@@ -653,8 +686,8 @@ const styles = StyleSheet.create((theme) => ({
marginLeft: theme.spacing[2],
},
createAgentButton: {
width: 14,
height: 14,
width: 18,
height: 18,
alignItems: "center",
justifyContent: "center",
},
@@ -672,6 +705,9 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
},
agentItemUnselected: {
opacity: 0.75,
},
agentItemSelected: {
backgroundColor: theme.colors.surface2,
},
@@ -705,6 +741,21 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
marginLeft: theme.spacing[1],
},
branchBadge: {
minWidth: 20,
height: 20,
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
branchBadgeText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
lineHeight: 12,
},
agentTitleHighlighted: {
color: theme.colors.foreground,
opacity: 1,
@@ -7,11 +7,10 @@ import { usePanelStore } from "@/stores/panel-store";
interface MenuHeaderProps {
title?: string;
subtitle?: string;
rightContent?: ReactNode;
}
export function MenuHeader({ title, subtitle, rightContent }: MenuHeaderProps) {
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -33,16 +32,9 @@ export function MenuHeader({ title, subtitle, rightContent }: MenuHeaderProps) {
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
</Pressable>
{title && (
<View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
{subtitle && (
<Text style={styles.subtitle} numberOfLines={1}>
{subtitle}
</Text>
)}
</View>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
)}
</>
}
@@ -63,11 +55,8 @@ const styles = StyleSheet.create((theme) => ({
},
borderRadius: theme.borderRadius.lg,
},
titleContainer: {
flex: 1,
gap: theme.spacing[0],
},
title: {
flex: 1,
fontSize: theme.fontSize.base,
fontWeight: {
xs: "400",
@@ -75,9 +64,4 @@ const styles = StyleSheet.create((theme) => ({
},
color: theme.colors.foreground,
},
subtitle: {
fontSize: 12,
fontWeight: "300",
color: theme.colors.foregroundMuted,
},
}));
+542
View File
@@ -0,0 +1,542 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactElement, ReactNode } from "react";
import {
View,
Text,
Pressable,
Modal,
TextInput,
ScrollView,
Platform,
StatusBar,
} from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import {
BottomSheetModal,
BottomSheetScrollView,
BottomSheetBackdrop,
BottomSheetTextInput,
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, Search } from "lucide-react-native";
import {
flip,
offset as floatingOffset,
shift,
size as floatingSize,
useFloating,
} from "@floating-ui/react-native";
const IS_WEB = Platform.OS === "web";
export interface ComboboxOption {
id: string;
label: string;
description?: string;
}
export interface ComboboxProps {
options: ComboboxOption[];
value: string;
onSelect: (id: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
allowCustomValue?: boolean;
customValuePrefix?: string;
customValueDescription?: string;
title?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
anchorRef: React.RefObject<View | null>;
children?: ReactNode;
}
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return (
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
}
interface SearchInputProps {
placeholder: string;
value: string;
onChangeText: (text: string) => void;
onSubmitEditing?: () => void;
autoFocus?: boolean;
}
function SearchInput({
placeholder,
value,
onChangeText,
onSubmitEditing,
autoFocus = false,
}: SearchInputProps): ReactElement {
const { theme } = useUnistyles();
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput;
return (
<View style={styles.searchInputContainer}>
<Search size={16} color={theme.colors.foregroundMuted} />
<InputComponent
// @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]}
placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
autoFocus={autoFocus}
onSubmitEditing={onSubmitEditing}
/>
</View>
);
}
export interface ComboboxItemProps {
label: string;
description?: string;
selected?: boolean;
onPress: () => void;
testID?: string;
}
export function ComboboxItem({
label,
description,
selected,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles();
return (
<Pressable
testID={testID}
onPress={onPress}
style={({ pressed }) => [
styles.comboboxItem,
pressed && styles.comboboxItemPressed,
]}
>
<View style={styles.comboboxItemCheckSlot}>
{selected ? <Check size={16} color={theme.colors.foreground} /> : null}
</View>
<View style={styles.comboboxItemContent}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text>
{description ? (
<Text numberOfLines={2} style={styles.comboboxItemDescription}>{description}</Text>
) : null}
</View>
</Pressable>
);
}
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
return <Text style={styles.emptyText}>{children}</Text>;
}
export function Combobox({
options,
value,
onSelect,
placeholder = "Search...",
searchPlaceholder,
emptyText = "No options match your search.",
allowCustomValue = false,
customValuePrefix = "Use",
customValueDescription,
title = "Select",
open,
onOpenChange,
anchorRef,
children,
}: ComboboxProps): ReactElement {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const bottomSheetRef = useRef<BottomSheetModal>(null);
const snapPoints = useMemo(() => ["60%", "90%"], []);
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const isControlled = typeof open === "boolean";
const [internalOpen, setInternalOpen] = useState(false);
const isOpen = isControlled ? open : internalOpen;
const setOpen = useCallback(
(nextOpen: boolean) => {
if (!isControlled) {
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen);
},
[isControlled, onOpenChange]
);
const handleClose = useCallback(() => {
setOpen(false);
setSearchQuery("");
}, [setOpen]);
useEffect(() => {
if (isOpen) {
setSearchQuery("");
}
}, [isOpen]);
const collisionPadding = useMemo(() => {
const basePadding = 16;
if (Platform.OS !== "android") return basePadding;
const statusBarHeight = StatusBar.currentHeight ?? 0;
return Math.max(basePadding, statusBarHeight + basePadding);
}, []);
const middleware = useMemo(
() => [
floatingOffset({ mainAxis: 4 }),
flip({ padding: collisionPadding }),
shift({ padding: collisionPadding }),
floatingSize({
padding: collisionPadding,
apply({ availableWidth, availableHeight, rects }) {
setAvailableSize((prev) => {
const next = { width: availableWidth, height: availableHeight };
if (!prev) return next;
if (prev.width === next.width && prev.height === next.height) return prev;
return next;
});
setReferenceWidth((prev) => {
const next = rects.reference.width;
if (prev === next) return prev;
return next;
});
},
}),
],
[collisionPadding]
);
const { refs, floatingStyles, update } = useFloating({
placement: "bottom-start",
middleware,
sameScrollView: false,
elements: {
reference: anchorRef.current ?? undefined,
},
});
useEffect(() => {
if (!isOpen || isMobile) {
setAvailableSize(null);
setReferenceWidth(null);
return;
}
const raf = requestAnimationFrame(() => update());
return () => cancelAnimationFrame(raf);
}, [isMobile, update, isOpen]);
useEffect(() => {
if (!isMobile) return;
if (isOpen) {
bottomSheetRef.current?.present();
} else {
bottomSheetRef.current?.dismiss();
}
}, [isOpen, isMobile]);
const handleSheetChange = useCallback(
(index: number) => {
if (index === -1) {
handleClose();
}
},
[handleClose]
);
const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
),
[]
);
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]
);
const handleSubmitSearch = useCallback(() => {
if (showCustomOption) {
handleSelect(sanitizedSearchValue);
}
}, [handleSelect, sanitizedSearchValue, showCustomOption]);
const searchInput = (
<SearchInput
placeholder={searchPlaceholder ?? placeholder}
value={searchQuery}
onChangeText={setSearchQuery}
onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile}
/>
);
const optionsList = (
<>
{showCustomOption ? (
<ComboboxItem
label={`${customValuePrefix} "${sanitizedSearchValue}"`}
description={customValueDescription}
onPress={() => handleSelect(sanitizedSearchValue)}
/>
) : null}
{hasMatches ? (
filteredOptions.map((opt) => (
<ComboboxItem
key={opt.id}
label={opt.label}
description={opt.description}
selected={opt.id === value}
onPress={() => handleSelect(opt.id)}
/>
))
) : !showCustomOption ? (
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
) : null}
</>
);
const content = children ?? (
<>
{searchInput}
{optionsList}
</>
);
if (isMobile) {
return (
<BottomSheetModal
ref={bottomSheetRef}
snapPoints={snapPoints}
index={0}
enableDynamicSizing={false}
onChange={handleSheetChange}
backdropComponent={renderBackdrop}
enablePanDownToClose
backgroundComponent={ComboboxSheetBackground}
handleIndicatorStyle={styles.bottomSheetHandle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<View style={styles.bottomSheetHeader}>
<Text style={styles.comboboxTitle}>{title}</Text>
</View>
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{content}
</BottomSheetScrollView>
</BottomSheetModal>
);
}
if (!isOpen) return <></>;
return (
<Modal
transparent
animationType="none"
visible={isOpen}
onRequestClose={handleClose}
>
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={handleClose} />
<Animated.View
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
style={[
styles.desktopContainer,
{
position: "absolute",
minWidth: 200,
width: referenceWidth ?? undefined,
},
floatingStyles,
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
typeof availableSize?.width === "number" ? { maxWidth: availableSize.width } : null,
]}
ref={refs.setFloating}
collapsable={false}
onLayout={() => update()}
>
{children ? (
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{content}
</ScrollView>
) : (
<>
{searchInput}
<ScrollView
contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopScroll}
>
{optionsList}
</ScrollView>
</>
)}
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
searchInputContainer: {
flexDirection: "row",
alignItems: "center",
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
paddingHorizontal: theme.spacing[3],
marginBottom: theme.spacing[1],
gap: theme.spacing[2],
},
searchInput: {
flex: 1,
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
comboboxItem: {
flexDirection: "row",
alignItems: "center",
minHeight: 36,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
comboboxItemPressed: {
backgroundColor: theme.colors.surface2,
},
comboboxItemCheckSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
comboboxItemContent: {
flex: 1,
flexShrink: 1,
},
comboboxItemLabel: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
comboboxItemDescription: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
emptyText: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
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],
},
comboboxTitle: {
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.semibold,
color: theme.colors.foreground,
textAlign: "center",
},
comboboxScrollContent: {
paddingBottom: theme.spacing[8],
paddingHorizontal: theme.spacing[1],
},
desktopOverlay: {
flex: 1,
},
desktopBackdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
desktopContainer: {
backgroundColor: theme.colors.surface0,
borderRadius: theme.borderRadius.lg,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
maxHeight: 400,
overflow: "hidden",
},
desktopScroll: {
maxHeight: 400,
},
desktopScrollContent: {
paddingVertical: theme.spacing[1],
},
}));