feat(app): add @ workspace file autocomplete in agent chat

This commit is contained in:
Mohamed Boudra
2026-02-18 13:25:55 +07:00
parent b1955cf74d
commit d30bb84741
16 changed files with 7993 additions and 7680 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+83 -67
View File
@@ -1,25 +1,27 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from 'react'
import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from "react-native"; import { ScrollView, Text, View, Pressable, type LayoutChangeEvent } from 'react-native'
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { Theme } from "@/styles/theme"; import { File, Folder } from 'lucide-react-native'
import { getAutocompleteScrollOffset } from "./autocomplete-utils"; import { Theme } from '@/styles/theme'
import { getAutocompleteScrollOffset } from './autocomplete-utils'
export interface AutocompleteOption { export interface AutocompleteOption {
id: string; id: string
label: string; label: string
detail?: string; detail?: string
description?: string; description?: string
kind?: 'command' | 'file' | 'directory'
} }
interface AutocompleteProps { interface AutocompleteProps {
options: readonly AutocompleteOption[]; options: readonly AutocompleteOption[]
selectedIndex: number; selectedIndex: number
onSelect: (option: AutocompleteOption) => void; onSelect: (option: AutocompleteOption) => void
isLoading?: boolean; isLoading?: boolean
errorMessage?: string; errorMessage?: string
loadingText?: string; loadingText?: string
emptyText?: string; emptyText?: string
maxHeight?: number; maxHeight?: number
} }
export function Autocomplete({ export function Autocomplete({
@@ -28,23 +30,24 @@ export function Autocomplete({
onSelect, onSelect,
isLoading = false, isLoading = false,
errorMessage, errorMessage,
loadingText = "Loading...", loadingText = 'Loading...',
emptyText = "No results found", emptyText = 'No results found',
maxHeight = 220, maxHeight = 220,
}: AutocompleteProps) { }: AutocompleteProps) {
const scrollRef = useRef<ScrollView>(null); const { theme } = useUnistyles()
const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map()); const scrollRef = useRef<ScrollView>(null)
const viewportHeightRef = useRef(0); const rowLayoutsRef = useRef<Map<number, { top: number; height: number }>>(new Map())
const scrollOffsetRef = useRef(0); const viewportHeightRef = useRef(0)
const scrollOffsetRef = useRef(0)
const ensureActiveItemVisible = useCallback(() => { const ensureActiveItemVisible = useCallback(() => {
if (selectedIndex < 0) { if (selectedIndex < 0) {
return; return
} }
const layout = rowLayoutsRef.current.get(selectedIndex); const layout = rowLayoutsRef.current.get(selectedIndex)
if (!layout) { if (!layout) {
return; return
} }
const nextOffset = getAutocompleteScrollOffset({ const nextOffset = getAutocompleteScrollOffset({
@@ -52,60 +55,60 @@ export function Autocomplete({
viewportHeight: viewportHeightRef.current, viewportHeight: viewportHeightRef.current,
itemTop: layout.top, itemTop: layout.top,
itemHeight: layout.height, itemHeight: layout.height,
}); })
if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) { if (Math.abs(nextOffset - scrollOffsetRef.current) < 1) {
return; return
} }
scrollOffsetRef.current = nextOffset; scrollOffsetRef.current = nextOffset
scrollRef.current?.scrollTo({ y: nextOffset, animated: false }); scrollRef.current?.scrollTo({ y: nextOffset, animated: false })
}, [selectedIndex]); }, [selectedIndex])
const pinToBottom = useCallback(() => { const pinToBottom = useCallback(() => {
scrollRef.current?.scrollToEnd({ animated: false }); scrollRef.current?.scrollToEnd({ animated: false })
requestAnimationFrame(() => { requestAnimationFrame(() => {
scrollRef.current?.scrollToEnd({ animated: false }); scrollRef.current?.scrollToEnd({ animated: false })
}); })
}, []); }, [])
useEffect(() => { useEffect(() => {
rowLayoutsRef.current.clear(); rowLayoutsRef.current.clear()
scrollOffsetRef.current = 0; scrollOffsetRef.current = 0
}, [options]); }, [options])
useEffect(() => { useEffect(() => {
if (options.length === 0) { if (options.length === 0) {
return; return
} }
pinToBottom(); pinToBottom()
}, [options, pinToBottom]); }, [options, pinToBottom])
useEffect(() => { useEffect(() => {
const raf = requestAnimationFrame(ensureActiveItemVisible); const raf = requestAnimationFrame(ensureActiveItemVisible)
return () => { return () => {
cancelAnimationFrame(raf); cancelAnimationFrame(raf)
}; }
}, [ensureActiveItemVisible, options.length]); }, [ensureActiveItemVisible, options.length])
const handleScrollViewLayout = useCallback( const handleScrollViewLayout = useCallback(
(event: LayoutChangeEvent) => { (event: LayoutChangeEvent) => {
viewportHeightRef.current = event.nativeEvent.layout.height; viewportHeightRef.current = event.nativeEvent.layout.height
ensureActiveItemVisible(); ensureActiveItemVisible()
}, },
[ensureActiveItemVisible] [ensureActiveItemVisible]
); )
const handleRowLayout = useCallback( const handleRowLayout = useCallback(
(index: number, event: LayoutChangeEvent) => { (index: number, event: LayoutChangeEvent) => {
rowLayoutsRef.current.set(index, { rowLayoutsRef.current.set(index, {
top: event.nativeEvent.layout.y, top: event.nativeEvent.layout.y,
height: event.nativeEvent.layout.height, height: event.nativeEvent.layout.height,
}); })
ensureActiveItemVisible(); ensureActiveItemVisible()
}, },
[ensureActiveItemVisible] [ensureActiveItemVisible]
); )
if (isLoading) { if (isLoading) {
return ( return (
@@ -114,7 +117,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>{loadingText}</Text> <Text style={styles.emptyText}>{loadingText}</Text>
</View> </View>
</View> </View>
); )
} }
if (errorMessage) { if (errorMessage) {
@@ -124,7 +127,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>Error: {errorMessage}</Text> <Text style={styles.emptyText}>Error: {errorMessage}</Text>
</View> </View>
</View> </View>
); )
} }
if (options.length === 0) { if (options.length === 0) {
@@ -134,7 +137,7 @@ export function Autocomplete({
<Text style={styles.emptyText}>{emptyText}</Text> <Text style={styles.emptyText}>{emptyText}</Text>
</View> </View>
</View> </View>
); )
} }
return ( return (
@@ -144,7 +147,7 @@ export function Autocomplete({
onLayout={handleScrollViewLayout} onLayout={handleScrollViewLayout}
onContentSizeChange={pinToBottom} onContentSizeChange={pinToBottom}
onScroll={(event) => { onScroll={(event) => {
scrollOffsetRef.current = event.nativeEvent.contentOffset.y; scrollOffsetRef.current = event.nativeEvent.contentOffset.y
}} }}
scrollEventThrottle={16} scrollEventThrottle={16}
style={styles.scrollView} style={styles.scrollView}
@@ -152,7 +155,7 @@ export function Autocomplete({
keyboardShouldPersistTaps="always" keyboardShouldPersistTaps="always"
> >
{options.map((option, index) => { {options.map((option, index) => {
const isSelected = index === selectedIndex; const isSelected = index === selectedIndex
return ( return (
<Pressable <Pressable
key={option.id} key={option.id}
@@ -163,12 +166,19 @@ export function Autocomplete({
(hovered || pressed || isSelected) && styles.itemActive, (hovered || pressed || isSelected) && styles.itemActive,
]} ]}
> >
{option.kind === 'directory' || option.kind === 'file' ? (
<View style={styles.itemLeading}>
{option.kind === 'directory' ? (
<Folder size={14} color={theme.colors.foregroundMuted} />
) : (
<File size={14} color={theme.colors.foregroundMuted} />
)}
</View>
) : null}
<View style={styles.itemMain}> <View style={styles.itemMain}>
<View style={styles.itemHeader}> <View style={styles.itemHeader}>
<Text style={styles.itemLabel}>{option.label}</Text> <Text style={styles.itemLabel}>{option.label}</Text>
{option.detail ? ( {option.detail ? <Text style={styles.itemDetail}>{option.detail}</Text> : null}
<Text style={styles.itemDetail}>{option.detail}</Text>
) : null}
</View> </View>
{option.description ? ( {option.description ? (
<Text style={styles.itemDescription} numberOfLines={1}> <Text style={styles.itemDescription} numberOfLines={1}>
@@ -177,11 +187,11 @@ export function Autocomplete({
) : null} ) : null}
</View> </View>
</Pressable> </Pressable>
); )
})} })}
</ScrollView> </ScrollView>
</View> </View>
); )
} }
const styles = StyleSheet.create(((theme: Theme) => ({ const styles = StyleSheet.create(((theme: Theme) => ({
@@ -190,7 +200,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
overflow: "hidden", overflow: 'hidden',
}, },
scrollView: { scrollView: {
flexGrow: 0, flexGrow: 0,
@@ -200,12 +210,18 @@ const styles = StyleSheet.create(((theme: Theme) => ({
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
}, },
item: { item: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
minHeight: 36, minHeight: 36,
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
}, },
itemLeading: {
width: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: theme.spacing[1],
},
itemActive: { itemActive: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
@@ -214,8 +230,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
minWidth: 0, minWidth: 0,
}, },
itemHeader: { itemHeader: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
itemLabel: { itemLabel: {
@@ -240,4 +256,4 @@ const styles = StyleSheet.create(((theme: Theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
})) as any) as Record<string, any>; })) as any) as Record<string, any>
@@ -1,42 +1,44 @@
export type ComboboxOptionKind = 'directory' | 'file'
export interface ComboboxOptionModel { export interface ComboboxOptionModel {
id: string; id: string
label: string; label: string
description?: string; description?: string
kind?: "directory"; kind?: ComboboxOptionKind
} }
export interface BuildVisibleComboboxOptionsInput { export interface BuildVisibleComboboxOptionsInput {
options: ComboboxOptionModel[]; options: ComboboxOptionModel[]
searchQuery: string; searchQuery: string
searchable: boolean; searchable: boolean
allowCustomValue: boolean; allowCustomValue: boolean
customValuePrefix: string; customValuePrefix: string
customValueDescription?: string; customValueDescription?: string
customValueKind?: "directory"; customValueKind?: ComboboxOptionKind
} }
export function shouldShowCustomComboboxOption(input: { export function shouldShowCustomComboboxOption(input: {
options: ComboboxOptionModel[]; options: ComboboxOptionModel[]
searchQuery: string; searchQuery: string
searchable: boolean; searchable: boolean
allowCustomValue: boolean; allowCustomValue: boolean
}): boolean { }): boolean {
const sanitizedSearchValue = input.searchQuery.trim(); const sanitizedSearchValue = input.searchQuery.trim()
if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) { if (!input.searchable || !input.allowCustomValue || sanitizedSearchValue.length === 0) {
return false; return false
} }
return !input.options.some( return !input.options.some(
(opt) => (opt) =>
opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() || opt.id.toLowerCase() === sanitizedSearchValue.toLowerCase() ||
opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase() opt.label.toLowerCase() === sanitizedSearchValue.toLowerCase()
); )
} }
export function buildVisibleComboboxOptions( export function buildVisibleComboboxOptions(
input: BuildVisibleComboboxOptionsInput input: BuildVisibleComboboxOptionsInput
): ComboboxOptionModel[] { ): ComboboxOptionModel[] {
const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ""; const normalizedSearch = input.searchable ? input.searchQuery.trim().toLowerCase() : ''
const filteredOptions = normalizedSearch const filteredOptions = normalizedSearch
? input.options.filter( ? input.options.filter(
(opt) => (opt) =>
@@ -44,52 +46,50 @@ export function buildVisibleComboboxOptions(
opt.id.toLowerCase().includes(normalizedSearch) || opt.id.toLowerCase().includes(normalizedSearch) ||
opt.description?.toLowerCase().includes(normalizedSearch) opt.description?.toLowerCase().includes(normalizedSearch)
) )
: input.options; : input.options
const sanitizedSearchValue = input.searchQuery.trim(); const sanitizedSearchValue = input.searchQuery.trim()
const showCustomOption = shouldShowCustomComboboxOption({ const showCustomOption = shouldShowCustomComboboxOption({
options: input.options, options: input.options,
searchQuery: input.searchQuery, searchQuery: input.searchQuery,
searchable: input.searchable, searchable: input.searchable,
allowCustomValue: input.allowCustomValue, allowCustomValue: input.allowCustomValue,
}); })
const visibleOptions: ComboboxOptionModel[] = []; const visibleOptions: ComboboxOptionModel[] = []
if (showCustomOption) { if (showCustomOption) {
const trimmedPrefix = input.customValuePrefix.trim(); const trimmedPrefix = input.customValuePrefix.trim()
const customLabel = const customLabel =
trimmedPrefix.length > 0 trimmedPrefix.length > 0 ? `${trimmedPrefix} "${sanitizedSearchValue}"` : sanitizedSearchValue
? `${trimmedPrefix} "${sanitizedSearchValue}"`
: sanitizedSearchValue;
visibleOptions.push({ visibleOptions.push({
id: sanitizedSearchValue, id: sanitizedSearchValue,
label: customLabel, label: customLabel,
description: input.customValueDescription, description: input.customValueDescription,
kind: input.customValueKind, kind: input.customValueKind,
}); })
} }
visibleOptions.push(...filteredOptions); visibleOptions.push(...filteredOptions)
return visibleOptions; return visibleOptions
} }
export function orderVisibleComboboxOptions( export function orderVisibleComboboxOptions(
visibleOptions: ComboboxOptionModel[], visibleOptions: ComboboxOptionModel[],
optionsPosition: "below-search" | "above-search" optionsPosition: 'below-search' | 'above-search'
): ComboboxOptionModel[] { ): ComboboxOptionModel[] {
if (optionsPosition !== "above-search") { if (optionsPosition !== 'above-search') {
return visibleOptions; return visibleOptions
} }
return [...visibleOptions].reverse(); return [...visibleOptions].reverse()
} }
export function getComboboxFallbackIndex( export function getComboboxFallbackIndex(
itemCount: number, itemCount: number,
optionsPosition: "below-search" | "above-search" optionsPosition: 'below-search' | 'above-search'
): number { ): number {
if (itemCount <= 0) { if (itemCount <= 0) {
return -1; return -1
} }
return optionsPosition === "above-search" ? itemCount - 1 : 0; return optionsPosition === 'above-search' ? itemCount - 1 : 0
} }
+271 -274
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from 'react'
import { import {
View, View,
Text, Text,
@@ -10,87 +10,88 @@ import {
Platform, Platform,
StatusBar, StatusBar,
useWindowDimensions, useWindowDimensions,
} from "react-native"; } from 'react-native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { import {
BottomSheetModal, BottomSheetModal,
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetTextInput, BottomSheetTextInput,
BottomSheetBackgroundProps, BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from '@gorhom/bottom-sheet'
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'
import { Check, Folder, Search } from "lucide-react-native"; import { Check, File, Folder, Search } from 'lucide-react-native'
import { flip, offset as floatingOffset, shift, size as floatingSize, useFloating } from "@floating-ui/react-native"; import {
import { getNextActiveIndex } from "./combobox-keyboard"; flip,
offset as floatingOffset,
shift,
size as floatingSize,
useFloating,
} from '@floating-ui/react-native'
import { getNextActiveIndex } from './combobox-keyboard'
import { import {
buildVisibleComboboxOptions, buildVisibleComboboxOptions,
getComboboxFallbackIndex, getComboboxFallbackIndex,
orderVisibleComboboxOptions, orderVisibleComboboxOptions,
shouldShowCustomComboboxOption, shouldShowCustomComboboxOption,
} from "./combobox-options"; } from './combobox-options'
import type { ComboboxOptionModel } from "./combobox-options"; import type { ComboboxOptionModel } from './combobox-options'
const IS_WEB = Platform.OS === "web"; const IS_WEB = Platform.OS === 'web'
export type ComboboxOption = ComboboxOptionModel; export type ComboboxOption = ComboboxOptionModel
export interface ComboboxProps { export interface ComboboxProps {
options: ComboboxOption[]; options: ComboboxOption[]
value: string; value: string
onSelect: (id: string) => void; onSelect: (id: string) => void
onSearchQueryChange?: (query: string) => void; onSearchQueryChange?: (query: string) => void
searchable?: boolean; searchable?: boolean
placeholder?: string; placeholder?: string
searchPlaceholder?: string; searchPlaceholder?: string
emptyText?: string; emptyText?: string
allowCustomValue?: boolean; allowCustomValue?: boolean
customValuePrefix?: string; customValuePrefix?: string
customValueDescription?: string; customValueDescription?: string
customValueKind?: "directory"; customValueKind?: 'directory' | 'file'
optionsPosition?: "below-search" | "above-search"; optionsPosition?: 'below-search' | 'above-search'
title?: string; title?: string
open?: boolean; open?: boolean
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void
desktopPlacement?: "top-start" | "bottom-start"; desktopPlacement?: 'top-start' | 'bottom-start'
/** /**
* Prevents an initial frame at 0,0 by hiding desktop content until floating * Prevents an initial frame at 0,0 by hiding desktop content until floating
* coordinates resolve. This intentionally disables fade enter/exit animation * coordinates resolve. This intentionally disables fade enter/exit animation
* for that combobox instance to avoid animation overriding hidden opacity. * for that combobox instance to avoid animation overriding hidden opacity.
*/ */
desktopPreventInitialFlash?: boolean; desktopPreventInitialFlash?: boolean
anchorRef: React.RefObject<View | null>; anchorRef: React.RefObject<View | null>
children?: ReactNode; children?: ReactNode
} }
function toNumericStyleValue(value: unknown): number | null { function toNumericStyleValue(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
return value; return value
} }
if (typeof value === "string") { if (typeof value === 'string') {
const parsed = Number.parseFloat(value); const parsed = Number.parseFloat(value)
if (Number.isFinite(parsed)) { if (Number.isFinite(parsed)) {
return parsed; return parsed
} }
} }
return null; return null
} }
function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) { function ComboboxSheetBackground({ style }: BottomSheetBackgroundProps) {
return ( return <Animated.View pointerEvents="none" style={[style, styles.bottomSheetBackground]} />
<Animated.View
pointerEvents="none"
style={[style, styles.bottomSheetBackground]}
/>
);
} }
interface SearchInputProps { interface SearchInputProps {
placeholder: string; placeholder: string
value: string; value: string
onChangeText: (text: string) => void; onChangeText: (text: string) => void
onSubmitEditing?: () => void; onSubmitEditing?: () => void
autoFocus?: boolean; autoFocus?: boolean
} }
function SearchInput({ function SearchInput({
@@ -100,18 +101,18 @@ function SearchInput({
onSubmitEditing, onSubmitEditing,
autoFocus = false, autoFocus = false,
}: SearchInputProps): ReactElement { }: SearchInputProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles()
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null)
const InputComponent = Platform.OS === "web" ? TextInput : BottomSheetTextInput; const InputComponent = Platform.OS === 'web' ? TextInput : BottomSheetTextInput
useEffect(() => { useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) { if (autoFocus && IS_WEB && inputRef.current) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
inputRef.current?.focus(); inputRef.current?.focus()
}, 50); }, 50)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} }
}, [autoFocus]); }, [autoFocus])
return ( return (
<View style={styles.searchInputContainer}> <View style={styles.searchInputContainer}>
@@ -119,7 +120,7 @@ function SearchInput({
<InputComponent <InputComponent
ref={inputRef as any} ref={inputRef as any}
// @ts-expect-error - outlineStyle is web-only // @ts-expect-error - outlineStyle is web-only
style={[styles.searchInput, IS_WEB && { outlineStyle: "none" }]} style={[styles.searchInput, IS_WEB && { outlineStyle: 'none' }]}
placeholder={placeholder} placeholder={placeholder}
placeholderTextColor={theme.colors.foregroundMuted} placeholderTextColor={theme.colors.foregroundMuted}
value={value} value={value}
@@ -129,17 +130,17 @@ function SearchInput({
onSubmitEditing={onSubmitEditing} onSubmitEditing={onSubmitEditing}
/> />
</View> </View>
); )
} }
export interface ComboboxItemProps { export interface ComboboxItemProps {
label: string; label: string
description?: string; description?: string
kind?: "directory"; kind?: 'directory' | 'file'
selected?: boolean; selected?: boolean
active?: boolean; active?: boolean
onPress: () => void; onPress: () => void
testID?: string; testID?: string
} }
export function ComboboxItem({ export function ComboboxItem({
@@ -151,7 +152,7 @@ export function ComboboxItem({
onPress, onPress,
testID, testID,
}: ComboboxItemProps): ReactElement { }: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles()
return ( return (
<Pressable <Pressable
testID={testID} testID={testID}
@@ -163,15 +164,23 @@ export function ComboboxItem({
active && styles.comboboxItemActive, active && styles.comboboxItemActive,
]} ]}
> >
{kind === "directory" ? ( {kind === 'directory' || kind === 'file' ? (
<View style={styles.comboboxItemLeadingSlot}> <View style={styles.comboboxItemLeadingSlot}>
<Folder size={16} color={theme.colors.foregroundMuted} /> {kind === 'directory' ? (
<Folder size={16} color={theme.colors.foregroundMuted} />
) : (
<File size={16} color={theme.colors.foregroundMuted} />
)}
</View> </View>
) : null} ) : null}
<View style={styles.comboboxItemContent}> <View style={styles.comboboxItemContent}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>{label}</Text> <Text numberOfLines={1} style={styles.comboboxItemLabel}>
{label}
</Text>
{description ? ( {description ? (
<Text numberOfLines={2} style={styles.comboboxItemDescription}>{description}</Text> <Text numberOfLines={2} style={styles.comboboxItemDescription}>
{description}
</Text>
) : null} ) : null}
</View> </View>
{selected ? ( {selected ? (
@@ -180,11 +189,15 @@ export function ComboboxItem({
</View> </View>
) : null} ) : null}
</Pressable> </Pressable>
); )
} }
export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement { export function ComboboxEmpty({ children }: { children: ReactNode }): ReactElement {
return <Text testID="combobox-empty-text" style={styles.emptyText}>{children}</Text>; return (
<Text testID="combobox-empty-text" style={styles.emptyText}>
{children}
</Text>
)
} }
export function Combobox({ export function Combobox({
@@ -193,202 +206,196 @@ export function Combobox({
onSelect, onSelect,
onSearchQueryChange, onSearchQueryChange,
searchable = true, searchable = true,
placeholder = "Search...", placeholder = 'Search...',
searchPlaceholder, searchPlaceholder,
emptyText = "No options match your search.", emptyText = 'No options match your search.',
allowCustomValue = false, allowCustomValue = false,
customValuePrefix = "Use", customValuePrefix = 'Use',
customValueDescription, customValueDescription,
customValueKind, customValueKind,
optionsPosition = "below-search", optionsPosition = 'below-search',
title = "Select", title = 'Select',
open, open,
onOpenChange, onOpenChange,
desktopPlacement = "top-start", desktopPlacement = 'top-start',
desktopPreventInitialFlash = true, desktopPreventInitialFlash = true,
anchorRef, anchorRef,
children, children,
}: ComboboxProps): ReactElement { }: ComboboxProps): ReactElement {
const isMobile = const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const effectiveOptionsPosition = isMobile ? 'below-search' : optionsPosition
const effectiveOptionsPosition =
isMobile ? "below-search" : optionsPosition;
const isDesktopAboveSearch = const isDesktopAboveSearch =
!isMobile && Platform.OS === "web" && effectiveOptionsPosition === "above-search"; !isMobile && Platform.OS === 'web' && effectiveOptionsPosition === 'above-search'
const { height: windowHeight } = useWindowDimensions(); const { height: windowHeight } = useWindowDimensions()
const bottomSheetRef = useRef<BottomSheetModal>(null); const bottomSheetRef = useRef<BottomSheetModal>(null)
const snapPoints = useMemo(() => ["60%", "90%"], []); const snapPoints = useMemo(() => ['60%', '90%'], [])
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null); const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(
const [referenceWidth, setReferenceWidth] = useState<number | null>(null); null
const [referenceTop, setReferenceTop] = useState<number | null>(null); )
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); const [referenceWidth, setReferenceWidth] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState(""); const [referenceTop, setReferenceTop] = useState<number | null>(null)
const [activeIndex, setActiveIndex] = useState<number>(-1); const [referenceAtOrigin, setReferenceAtOrigin] = useState(false)
const desktopOptionsScrollRef = useRef<ScrollView>(null); const [searchQuery, setSearchQuery] = useState('')
const [activeIndex, setActiveIndex] = useState<number>(-1)
const desktopOptionsScrollRef = useRef<ScrollView>(null)
const isControlled = typeof open === "boolean"; const isControlled = typeof open === 'boolean'
const [internalOpen, setInternalOpen] = useState(false); const [internalOpen, setInternalOpen] = useState(false)
const isOpen = isControlled ? open : internalOpen; const isOpen = isControlled ? open : internalOpen
const setOpen = useCallback( const setOpen = useCallback(
(nextOpen: boolean) => { (nextOpen: boolean) => {
if (!isControlled) { if (!isControlled) {
setInternalOpen(nextOpen); setInternalOpen(nextOpen)
} }
onOpenChange?.(nextOpen); onOpenChange?.(nextOpen)
}, },
[isControlled, onOpenChange] [isControlled, onOpenChange]
); )
const setSearchQueryWithCallback = useCallback( const setSearchQueryWithCallback = useCallback(
(nextQuery: string) => { (nextQuery: string) => {
setSearchQuery(nextQuery); setSearchQuery(nextQuery)
onSearchQueryChange?.(nextQuery); onSearchQueryChange?.(nextQuery)
}, },
[onSearchQueryChange] [onSearchQueryChange]
); )
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setOpen(false); setOpen(false)
setSearchQueryWithCallback(""); setSearchQueryWithCallback('')
}, [setOpen, setSearchQueryWithCallback]); }, [setOpen, setSearchQueryWithCallback])
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setSearchQueryWithCallback(""); setSearchQueryWithCallback('')
} }
}, [isOpen, setSearchQueryWithCallback]); }, [isOpen, setSearchQueryWithCallback])
const collisionPadding = useMemo(() => { const collisionPadding = useMemo(() => {
const basePadding = 16; const basePadding = 16
if (Platform.OS !== "android") return basePadding; if (Platform.OS !== 'android') return basePadding
const statusBarHeight = StatusBar.currentHeight ?? 0; const statusBarHeight = StatusBar.currentHeight ?? 0
return Math.max(basePadding, statusBarHeight + basePadding); return Math.max(basePadding, statusBarHeight + basePadding)
}, []); }, [])
const middleware = useMemo( const middleware = useMemo(
() => [ () => [
floatingOffset(Platform.OS === "web" ? 0 : 4), floatingOffset(Platform.OS === 'web' ? 0 : 4),
...(Platform.OS === "web" ? [] : [flip({ padding: collisionPadding })]), ...(Platform.OS === 'web' ? [] : [flip({ padding: collisionPadding })]),
...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]), ...(isDesktopAboveSearch ? [] : [shift({ padding: collisionPadding })]),
floatingSize({ floatingSize({
padding: collisionPadding, padding: collisionPadding,
apply({ availableWidth, availableHeight, rects }) { apply({ availableWidth, availableHeight, rects }) {
setAvailableSize((prev) => { setAvailableSize((prev) => {
const next = { width: availableWidth, height: availableHeight }; const next = { width: availableWidth, height: availableHeight }
if (!prev) return next; if (!prev) return next
if (prev.width === next.width && prev.height === next.height) return prev; if (prev.width === next.width && prev.height === next.height) return prev
return next; return next
}); })
setReferenceWidth((prev) => { setReferenceWidth((prev) => {
const next = rects.reference.width; const next = rects.reference.width
if (prev === next) return prev; if (prev === next) return prev
return next; return next
}); })
}, },
}), }),
], ],
[collisionPadding, isDesktopAboveSearch] [collisionPadding, isDesktopAboveSearch]
); )
const { refs, floatingStyles, update } = useFloating({ const { refs, floatingStyles, update } = useFloating({
placement: Platform.OS === "web" ? desktopPlacement : "bottom-start", placement: Platform.OS === 'web' ? desktopPlacement : 'bottom-start',
middleware, middleware,
sameScrollView: false, sameScrollView: false,
elements: { elements: {
reference: anchorRef.current ?? undefined, reference: anchorRef.current ?? undefined,
}, },
}); })
useEffect(() => { useEffect(() => {
if (!isOpen || isMobile) { if (!isOpen || isMobile) {
setAvailableSize(null); setAvailableSize(null)
setReferenceWidth(null); setReferenceWidth(null)
return; return
} }
const raf = requestAnimationFrame(() => void update()); const raf = requestAnimationFrame(() => void update())
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf)
}, [desktopPlacement, isMobile, update, isOpen]); }, [desktopPlacement, isMobile, update, isOpen])
useEffect(() => { useEffect(() => {
if (!isOpen || isMobile) { if (!isOpen || isMobile) {
setReferenceAtOrigin(false); setReferenceAtOrigin(false)
setReferenceTop(null); setReferenceTop(null)
return; return
} }
const referenceEl = anchorRef.current; const referenceEl = anchorRef.current
if (!referenceEl) { if (!referenceEl) {
setReferenceAtOrigin(false); setReferenceAtOrigin(false)
setReferenceTop(null); setReferenceTop(null)
return; return
} }
const measure = () => { const measure = () => {
referenceEl.measureInWindow((x, y) => { referenceEl.measureInWindow((x, y) => {
setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1); setReferenceAtOrigin(Math.abs(x) <= 1 && Math.abs(y) <= 1)
setReferenceTop((prev) => (prev === y ? prev : y)); setReferenceTop((prev) => (prev === y ? prev : y))
}); })
}; }
measure(); measure()
const raf = requestAnimationFrame(measure); const raf = requestAnimationFrame(measure)
return () => cancelAnimationFrame(raf); return () => cancelAnimationFrame(raf)
}, [anchorRef, isMobile, isOpen, searchQuery, windowHeight]); }, [anchorRef, isMobile, isOpen, searchQuery, windowHeight])
const floatingTop = toNumericStyleValue(floatingStyles.top); const floatingTop = toNumericStyleValue(floatingStyles.top)
const floatingLeft = toNumericStyleValue(floatingStyles.left); const floatingLeft = toNumericStyleValue(floatingStyles.left)
const desktopAboveSearchBottom = const desktopAboveSearchBottom =
isDesktopAboveSearch && referenceTop !== null isDesktopAboveSearch && referenceTop !== null
? Math.max(windowHeight - referenceTop, collisionPadding) ? Math.max(windowHeight - referenceTop, collisionPadding)
: null; : null
const hasResolvedDesktopPosition = const hasResolvedDesktopPosition =
referenceWidth !== null && referenceWidth !== null &&
floatingLeft !== null && floatingLeft !== null &&
(isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) && (isDesktopAboveSearch ? desktopAboveSearchBottom !== null : floatingTop !== null) &&
((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin); ((floatingTop ?? 0) !== 0 || floatingLeft !== 0 || referenceAtOrigin)
const shouldHideDesktopContent = const shouldHideDesktopContent = desktopPreventInitialFlash && !hasResolvedDesktopPosition
desktopPreventInitialFlash && !hasResolvedDesktopPosition; const shouldUseDesktopFade = !desktopPreventInitialFlash
const shouldUseDesktopFade = !desktopPreventInitialFlash;
const desktopPositionStyle = isDesktopAboveSearch const desktopPositionStyle = isDesktopAboveSearch
? { ? {
left: floatingLeft ?? 0, left: floatingLeft ?? 0,
bottom: desktopAboveSearchBottom ?? 0, bottom: desktopAboveSearchBottom ?? 0,
} }
: floatingStyles; : floatingStyles
useEffect(() => { useEffect(() => {
if (!isMobile) return; if (!isMobile) return
if (isOpen) { if (isOpen) {
bottomSheetRef.current?.present(); bottomSheetRef.current?.present()
} else { } else {
bottomSheetRef.current?.dismiss(); bottomSheetRef.current?.dismiss()
} }
}, [isOpen, isMobile]); }, [isOpen, isMobile])
const handleSheetChange = useCallback( const handleSheetChange = useCallback(
(index: number) => { (index: number) => {
if (index === -1) { if (index === -1) {
handleClose(); handleClose()
} }
}, },
[handleClose] [handleClose]
); )
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => ( (props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop <BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} opacity={0.45} />
{...props}
disappearsOnIndex={-1}
appearsOnIndex={0}
opacity={0.45}
/>
), ),
[] []
); )
const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ""; const normalizedSearch = searchable ? searchQuery.trim().toLowerCase() : ''
const sanitizedSearchValue = searchQuery.trim(); const sanitizedSearchValue = searchQuery.trim()
const showCustomOption = useMemo( const showCustomOption = useMemo(
() => () =>
shouldShowCustomComboboxOption({ shouldShowCustomComboboxOption({
@@ -398,7 +405,7 @@ export function Combobox({
allowCustomValue, allowCustomValue,
}), }),
[allowCustomValue, options, searchQuery, searchable] [allowCustomValue, options, searchQuery, searchable]
); )
const visibleOptions = useMemo( const visibleOptions = useMemo(
() => () =>
@@ -420,139 +427,132 @@ export function Combobox({
searchQuery, searchQuery,
searchable, searchable,
] ]
); )
const orderedVisibleOptions = useMemo( const orderedVisibleOptions = useMemo(
() => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition), () => orderVisibleComboboxOptions(visibleOptions, effectiveOptionsPosition),
[effectiveOptionsPosition, visibleOptions] [effectiveOptionsPosition, visibleOptions]
); )
const pinDesktopOptionsToBottom = useCallback(() => { const pinDesktopOptionsToBottom = useCallback(() => {
if (isMobile || effectiveOptionsPosition !== "above-search") { if (isMobile || effectiveOptionsPosition !== 'above-search') {
return; return
} }
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
requestAnimationFrame(() => { requestAnimationFrame(() => {
desktopOptionsScrollRef.current?.scrollToEnd({ animated: false }); desktopOptionsScrollRef.current?.scrollToEnd({ animated: false })
}); })
}, [effectiveOptionsPosition, isMobile]); }, [effectiveOptionsPosition, isMobile])
const handleDesktopOptionsContentSizeChange = useCallback(() => { const handleDesktopOptionsContentSizeChange = useCallback(() => {
if (!isOpen) { if (!isOpen) {
return; return
} }
pinDesktopOptionsToBottom(); pinDesktopOptionsToBottom()
}, [isOpen, pinDesktopOptionsToBottom]); }, [isOpen, pinDesktopOptionsToBottom])
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
return; return
} }
pinDesktopOptionsToBottom(); pinDesktopOptionsToBottom()
}, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom]); }, [isOpen, orderedVisibleOptions, pinDesktopOptionsToBottom])
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isOpen || isMobile) { if (!isOpen || isMobile) {
return; return
} }
void update(); void update()
}, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update]); }, [isOpen, isMobile, orderedVisibleOptions.length, searchQuery, update])
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return
if (!IS_WEB && isMobile) return; if (!IS_WEB && isMobile) return
if (orderedVisibleOptions.length === 0) { if (orderedVisibleOptions.length === 0) {
setActiveIndex(-1); setActiveIndex(-1)
return; return
} }
const fallbackIndex = getComboboxFallbackIndex( const fallbackIndex = getComboboxFallbackIndex(
orderedVisibleOptions.length, orderedVisibleOptions.length,
effectiveOptionsPosition effectiveOptionsPosition
); )
if (normalizedSearch) { if (normalizedSearch) {
setActiveIndex(fallbackIndex); setActiveIndex(fallbackIndex)
return; return
} }
const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value); const selectedIndex = orderedVisibleOptions.findIndex((opt) => opt.id === value)
setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex); setActiveIndex(selectedIndex >= 0 ? selectedIndex : fallbackIndex)
}, [ }, [effectiveOptionsPosition, isMobile, isOpen, normalizedSearch, value, orderedVisibleOptions])
effectiveOptionsPosition,
isMobile,
isOpen,
normalizedSearch,
value,
orderedVisibleOptions,
]);
const handleSelect = useCallback( const handleSelect = useCallback(
(id: string) => { (id: string) => {
onSelect(id); onSelect(id)
handleClose(); handleClose()
}, },
[handleClose, onSelect] [handleClose, onSelect]
); )
const handleSubmitSearch = useCallback(() => { const handleSubmitSearch = useCallback(() => {
if (showCustomOption) { if (showCustomOption) {
handleSelect(sanitizedSearchValue); handleSelect(sanitizedSearchValue)
} }
}, [handleSelect, sanitizedSearchValue, showCustomOption]); }, [handleSelect, sanitizedSearchValue, showCustomOption])
const handleDesktopKey = useCallback( const handleDesktopKey = useCallback(
(key: "ArrowDown" | "ArrowUp" | "Enter" | "Escape", event?: KeyboardEvent) => { (key: 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape', event?: KeyboardEvent) => {
if (!isOpen) return; if (!isOpen) return
if (!IS_WEB && isMobile) return; if (!IS_WEB && isMobile) return
if (key === "ArrowDown" || key === "ArrowUp") { if (key === 'ArrowDown' || key === 'ArrowUp') {
event?.preventDefault(); event?.preventDefault()
setActiveIndex((currentIndex) => setActiveIndex((currentIndex) =>
getNextActiveIndex({ getNextActiveIndex({
currentIndex, currentIndex,
itemCount: orderedVisibleOptions.length, itemCount: orderedVisibleOptions.length,
key, key,
}) })
); )
return; return
} }
if (key === "Enter") { if (key === 'Enter') {
if (orderedVisibleOptions.length === 0) return; if (orderedVisibleOptions.length === 0) return
event?.preventDefault(); event?.preventDefault()
const index = const index =
activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0; activeIndex >= 0 && activeIndex < orderedVisibleOptions.length ? activeIndex : 0
handleSelect(orderedVisibleOptions[index]!.id); handleSelect(orderedVisibleOptions[index]!.id)
return; return
} }
if (key === "Escape") { if (key === 'Escape') {
event?.preventDefault(); event?.preventDefault()
handleClose(); handleClose()
} }
}, },
[activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions] [activeIndex, handleClose, handleSelect, isMobile, isOpen, orderedVisibleOptions]
); )
useEffect(() => { useEffect(() => {
if (!IS_WEB || !isOpen) return; if (!IS_WEB || !isOpen) return
const handler = (event: KeyboardEvent) => { const handler = (event: KeyboardEvent) => {
const key = event.key; const key = event.key
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") { if (key !== 'ArrowDown' && key !== 'ArrowUp' && key !== 'Enter' && key !== 'Escape') {
return; return
} }
handleDesktopKey(key, event); handleDesktopKey(key, event)
}; }
// react-native-web's TextInput can stop propagation on key events, so listen in capture phase. // react-native-web's TextInput can stop propagation on key events, so listen in capture phase.
window.addEventListener("keydown", handler, true); window.addEventListener('keydown', handler, true)
return () => { return () => {
window.removeEventListener("keydown", handler, true); window.removeEventListener('keydown', handler, true)
}; }
}, [handleDesktopKey, isOpen]); }, [handleDesktopKey, isOpen])
const searchInput = ( const searchInput = (
<SearchInput <SearchInput
@@ -562,7 +562,7 @@ export function Combobox({
onSubmitEditing={handleSubmitSearch} onSubmitEditing={handleSubmitSearch}
autoFocus={!isMobile} autoFocus={!isMobile}
/> />
); )
const optionsList = ( const optionsList = (
<> <>
@@ -582,17 +582,17 @@ export function Combobox({
<ComboboxEmpty>{emptyText}</ComboboxEmpty> <ComboboxEmpty>{emptyText}</ComboboxEmpty>
)} )}
</> </>
); )
const defaultContent = ( const defaultContent = (
<> <>
{effectiveOptionsPosition === "above-search" ? optionsList : null} {effectiveOptionsPosition === 'above-search' ? optionsList : null}
{searchable ? searchInput : null} {searchable ? searchInput : null}
{effectiveOptionsPosition === "below-search" ? optionsList : null} {effectiveOptionsPosition === 'below-search' ? optionsList : null}
</> </>
); )
const content = children ?? defaultContent; const content = children ?? defaultContent
if (isMobile) { if (isMobile) {
return ( return (
@@ -620,18 +620,13 @@ export function Combobox({
{content} {content}
</BottomSheetScrollView> </BottomSheetScrollView>
</BottomSheetModal> </BottomSheetModal>
); )
} }
if (!isOpen) return <></>; if (!isOpen) return <></>
return ( return (
<Modal <Modal transparent animationType="none" visible={isOpen} onRequestClose={handleClose}>
transparent
animationType="none"
visible={isOpen}
onRequestClose={handleClose}
>
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}> <View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopOverlay}>
<Pressable style={styles.desktopBackdrop} onPress={handleClose} /> <Pressable style={styles.desktopBackdrop} onPress={handleClose} />
<Animated.View <Animated.View
@@ -641,13 +636,15 @@ export function Combobox({
style={[ style={[
styles.desktopContainer, styles.desktopContainer,
{ {
position: "absolute", position: 'absolute',
minWidth: referenceWidth ?? 200, minWidth: referenceWidth ?? 200,
maxWidth: 400, maxWidth: 400,
}, },
desktopPositionStyle, desktopPositionStyle,
shouldHideDesktopContent ? { opacity: 0 } : null, shouldHideDesktopContent ? { opacity: 0 } : null,
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null, typeof availableSize?.height === 'number'
? { maxHeight: Math.min(availableSize.height, 400) }
: null,
]} ]}
ref={refs.setFloating} ref={refs.setFloating}
collapsable={false} collapsable={false}
@@ -664,7 +661,7 @@ export function Combobox({
</ScrollView> </ScrollView>
) : ( ) : (
<> <>
{effectiveOptionsPosition === "above-search" ? ( {effectiveOptionsPosition === 'above-search' ? (
<ScrollView <ScrollView
ref={desktopOptionsScrollRef} ref={desktopOptionsScrollRef}
contentContainerStyle={[ contentContainerStyle={[
@@ -680,7 +677,7 @@ export function Combobox({
</ScrollView> </ScrollView>
) : null} ) : null}
{searchable ? searchInput : null} {searchable ? searchInput : null}
{effectiveOptionsPosition === "below-search" ? ( {effectiveOptionsPosition === 'below-search' ? (
<ScrollView <ScrollView
contentContainerStyle={styles.desktopScrollContent} contentContainerStyle={styles.desktopScrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
@@ -695,13 +692,13 @@ export function Combobox({
</Animated.View> </Animated.View>
</View> </View>
</Modal> </Modal>
); )
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
searchInputContainer: { searchInputContainer: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
@@ -719,8 +716,8 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
comboboxItem: { comboboxItem: {
flexDirection: "row", flexDirection: 'row',
alignItems: "center", alignItems: 'center',
minHeight: 36, minHeight: 36,
gap: theme.spacing[2], gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
@@ -744,9 +741,9 @@ const styles = StyleSheet.create((theme) => ({
}, },
comboboxItemTrailingSlot: { comboboxItemTrailingSlot: {
width: 16, width: 16,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
marginLeft: "auto", marginLeft: 'auto',
}, },
comboboxItemContent: { comboboxItemContent: {
flex: 1, flex: 1,
@@ -754,8 +751,8 @@ const styles = StyleSheet.create((theme) => ({
}, },
comboboxItemLeadingSlot: { comboboxItemLeadingSlot: {
width: 16, width: 16,
alignItems: "center", alignItems: 'center',
justifyContent: "center", justifyContent: 'center',
}, },
comboboxItemLabel: { comboboxItemLabel: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -774,8 +771,8 @@ const styles = StyleSheet.create((theme) => ({
}, },
bottomSheetBackground: { bottomSheetBackground: {
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius["2xl"], borderTopLeftRadius: theme.borderRadius['2xl'],
borderTopRightRadius: theme.borderRadius["2xl"], borderTopRightRadius: theme.borderRadius['2xl'],
}, },
bottomSheetHandle: { bottomSheetHandle: {
backgroundColor: theme.colors.palette.zinc[600], backgroundColor: theme.colors.palette.zinc[600],
@@ -788,7 +785,7 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground, color: theme.colors.foreground,
textAlign: "left", textAlign: 'left',
}, },
comboboxScrollContent: { comboboxScrollContent: {
paddingBottom: theme.spacing[8], paddingBottom: theme.spacing[8],
@@ -799,7 +796,7 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
}, },
desktopBackdrop: { desktopBackdrop: {
position: "absolute", position: 'absolute',
top: 0, top: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
@@ -810,13 +807,13 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.border, borderColor: theme.colors.border,
shadowColor: "#000", shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2, shadowOpacity: 0.2,
shadowRadius: 8, shadowRadius: 8,
elevation: 8, elevation: 8,
maxHeight: 400, maxHeight: 400,
overflow: "hidden", overflow: 'hidden',
}, },
desktopScroll: { desktopScroll: {
flexShrink: 1, flexShrink: 1,
@@ -827,6 +824,6 @@ const styles = StyleSheet.create((theme) => ({
}, },
desktopScrollContentAboveSearch: { desktopScrollContentAboveSearch: {
flexGrow: 1, flexGrow: 1,
justifyContent: "flex-end", justifyContent: 'flex-end',
}, },
})); }))
+227 -62
View File
@@ -1,125 +1,290 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from 'react'
import type { AutocompleteOption } from "@/components/ui/autocomplete"; import { useQuery } from '@tanstack/react-query'
import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query"; import type { AutocompleteOption } from '@/components/ui/autocomplete'
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; import { useAgentCommandsQuery, type DraftCommandConfig } from './use-agent-commands-query'
import { useAutocomplete } from "./use-autocomplete"; import { orderAutocompleteOptions } from '@/components/ui/autocomplete-utils'
import { useAutocomplete } from './use-autocomplete'
import { useSessionStore } from '@/stores/session-store'
import {
applyFileMentionReplacement,
findActiveFileMention,
type FileMentionRange,
} from '@/utils/file-mention-autocomplete'
interface UseAgentAutocompleteInput { interface UseAgentAutocompleteInput {
userInput: string; userInput: string
setUserInput: (nextValue: string) => void; cursorIndex: number
serverId: string; setUserInput: (nextValue: string) => void
agentId: string; serverId: string
draftConfig?: DraftCommandConfig; agentId: string
onAutocompleteApplied?: () => void; draftConfig?: DraftCommandConfig
onAutocompleteApplied?: () => void
} }
type AgentAutocompleteOption =
| (AutocompleteOption & { type: 'command' })
| (AutocompleteOption & {
type: 'workspace_entry'
entryPath: string
mention: FileMentionRange
})
interface AgentAutocompleteResult { interface AgentAutocompleteResult {
isVisible: boolean; isVisible: boolean
options: AutocompleteOption[]; options: AutocompleteOption[]
selectedIndex: number; selectedIndex: number
isLoading: boolean; isLoading: boolean
errorMessage?: string; errorMessage?: string
onSelectOption: (option: AutocompleteOption) => void; loadingText: string
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean; emptyText: string
onSelectOption: (option: AutocompleteOption) => void
onKeyPress: (event: { key: string; preventDefault: () => void }) => boolean
}
interface DirectorySuggestionEntry {
path: string
kind: 'file' | 'directory'
} }
function normalizeDraftCommandConfig( function normalizeDraftCommandConfig(
draftConfig?: DraftCommandConfig draftConfig?: DraftCommandConfig
): DraftCommandConfig | undefined { ): DraftCommandConfig | undefined {
if (!draftConfig) { if (!draftConfig) {
return undefined; return undefined
} }
const cwd = draftConfig.cwd.trim(); const cwd = draftConfig.cwd.trim()
if (!cwd) { if (!cwd) {
return undefined; return undefined
} }
const modeId = draftConfig.modeId?.trim() ?? ""; const modeId = draftConfig.modeId?.trim() ?? ''
const model = draftConfig.model?.trim() ?? ""; const model = draftConfig.model?.trim() ?? ''
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ''
return { return {
provider: draftConfig.provider, provider: draftConfig.provider,
cwd, cwd,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(model ? { model } : {}), ...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}),
}; }
} }
export function useAgentAutocomplete( function mapDirectorySuggestionsToEntries(payload: {
input: UseAgentAutocompleteInput entries?: Array<{ path: string; kind: string }>
): AgentAutocompleteResult { directories?: string[]
}): DirectorySuggestionEntry[] {
if (Array.isArray(payload.entries) && payload.entries.length > 0) {
return payload.entries.flatMap((entry) => {
if (
!entry ||
typeof entry.path !== 'string' ||
(entry.kind !== 'file' && entry.kind !== 'directory')
) {
return []
}
return [{ path: entry.path, kind: entry.kind }]
})
}
return (payload.directories ?? []).map((path) => ({
path,
kind: 'directory' as const,
}))
}
export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAutocompleteResult {
const { const {
userInput, userInput,
cursorIndex,
setUserInput, setUserInput,
serverId, serverId,
agentId, agentId,
draftConfig, draftConfig,
onAutocompleteApplied, onAutocompleteApplied,
} = input; } = input
const showAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); const showCommandAutocomplete = userInput.startsWith('/') && !userInput.includes(' ')
const filterQuery = showAutocomplete ? userInput.slice(1) : ""; const commandFilterQuery = showCommandAutocomplete ? userInput.slice(1) : ''
const activeFileMention = useMemo(
() =>
findActiveFileMention({
text: userInput,
cursorIndex,
}),
[cursorIndex, userInput]
)
const showFileAutocomplete = activeFileMention !== null
const fileFilterQuery = activeFileMention?.query ?? ''
const normalizedDraftConfig = useMemo( const normalizedDraftConfig = useMemo(
() => normalizeDraftCommandConfig(draftConfig), () => normalizeDraftCommandConfig(draftConfig),
[draftConfig] [draftConfig]
); )
const isRealAgent = Boolean(agentId) && !agentId.startsWith("__"); const isRealAgent = Boolean(agentId) && !agentId.startsWith('__')
const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig; const queryDraftConfig = isRealAgent ? undefined : normalizedDraftConfig
const canLoadOptions = Boolean(serverId) && (isRealAgent || !!queryDraftConfig); const canLoadCommands = Boolean(serverId) && (isRealAgent || !!queryDraftConfig)
const isVisible = showAutocomplete && canLoadOptions;
const { commands, isLoading, isError, error } = useAgentCommandsQuery({ const agentCwd = useSessionStore(
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? ''
)
const autocompleteCwd = useMemo(() => {
if (isRealAgent) {
return agentCwd.trim()
}
return queryDraftConfig?.cwd ?? ''
}, [agentCwd, isRealAgent, queryDraftConfig])
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null)
const isConnected = useSessionStore(
(state) => state.sessions[serverId]?.connection.isConnected ?? false
)
const mode: 'command' | 'file' | null = showFileAutocomplete
? 'file'
: showCommandAutocomplete
? 'command'
: null
const isVisible =
mode === 'command'
? canLoadCommands
: mode === 'file'
? Boolean(serverId) && autocompleteCwd.length > 0
: false
const {
commands,
isLoading: isCommandsLoading,
isError,
error,
} = useAgentCommandsQuery({
serverId, serverId,
agentId, agentId,
enabled: canLoadOptions, enabled: mode === 'command' && canLoadCommands,
draftConfig: queryDraftConfig, draftConfig: queryDraftConfig,
}); })
const options = useMemo<AutocompleteOption[]>(() => { const fileSuggestionsQuery = useQuery({
queryKey: ['directorySuggestions', serverId, autocompleteCwd, fileFilterQuery, true, true],
queryFn: async (): Promise<DirectorySuggestionEntry[]> => {
if (!client) {
throw new Error('Daemon client unavailable')
}
const response = await client.getDirectorySuggestions({
cwd: autocompleteCwd,
query: fileFilterQuery,
limit: 50,
includeFiles: true,
includeDirectories: true,
})
if (response.error) {
throw new Error(response.error)
}
return mapDirectorySuggestionsToEntries(response)
},
enabled:
mode === 'file' &&
Boolean(serverId) &&
autocompleteCwd.length > 0 &&
Boolean(client) &&
isConnected,
retry: false,
staleTime: 15_000,
})
const options = useMemo<AgentAutocompleteOption[]>(() => {
if (!isVisible) { if (!isVisible) {
return []; return []
} }
const filterLower = filterQuery.toLowerCase(); if (mode === 'command') {
const matches = commands.filter((cmd) => const filterLower = commandFilterQuery.toLowerCase()
cmd.name.toLowerCase().includes(filterLower) const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower))
); const orderedMatches = orderAutocompleteOptions(matches)
const orderedMatches = orderAutocompleteOptions(matches); return orderedMatches.map((cmd) => ({
return orderedMatches.map((cmd) => ({ type: 'command' as const,
id: cmd.name, id: cmd.name,
label: `/${cmd.name}`, label: `/${cmd.name}`,
detail: cmd.argumentHint || undefined, detail: cmd.argumentHint || undefined,
description: cmd.description, description: cmd.description,
})); kind: 'command',
}, [commands, filterQuery, isVisible]); }))
}
if (mode === 'file' && activeFileMention) {
return (fileSuggestionsQuery.data ?? []).map((entry) => ({
type: 'workspace_entry' as const,
id: `${entry.kind}:${entry.path}`,
label: entry.path,
detail: entry.kind === 'directory' ? 'Directory' : 'File',
kind: entry.kind,
entryPath: entry.path,
mention: activeFileMention,
}))
}
return []
}, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode])
const onSelectOption = useCallback( const onSelectOption = useCallback(
(option: AutocompleteOption) => { (option: AutocompleteOption) => {
setUserInput(`/${option.id} `); const selected = option as AgentAutocompleteOption
onAutocompleteApplied?.(); if (selected.type === 'command') {
setUserInput(`/${selected.id} `)
onAutocompleteApplied?.()
return
}
const nextInput = applyFileMentionReplacement({
text: userInput,
mention: selected.mention,
relativePath: selected.entryPath,
})
setUserInput(nextInput)
onAutocompleteApplied?.()
}, },
[onAutocompleteApplied, setUserInput] [onAutocompleteApplied, setUserInput, userInput]
); )
const { selectedIndex, onKeyPress } = useAutocomplete({ const { selectedIndex, onKeyPress } = useAutocomplete({
isVisible, isVisible,
options, options,
query: filterQuery, query: mode === 'command' ? commandFilterQuery : fileFilterQuery,
onSelectOption, onSelectOption,
onEscape: () => setUserInput(""), onEscape: mode === 'command' ? () => setUserInput('') : undefined,
}); })
const isLoading =
mode === 'command'
? isCommandsLoading
: mode === 'file'
? fileSuggestionsQuery.isPending || fileSuggestionsQuery.isLoading
: false
const errorMessage =
mode === 'command'
? isError
? (error?.message ?? 'Failed to load')
: undefined
: mode === 'file'
? fileSuggestionsQuery.error instanceof Error
? fileSuggestionsQuery.error.message
: undefined
: undefined
const loadingText = mode === 'file' ? 'Searching workspace...' : 'Loading commands...'
const emptyText = mode === 'file' ? 'No files or directories found' : 'No commands found'
return { return {
isVisible, isVisible,
options, options,
selectedIndex, selectedIndex,
isLoading, isLoading,
errorMessage: isError ? (error?.message ?? "Failed to load") : undefined, errorMessage,
loadingText,
emptyText,
onSelectOption, onSelectOption,
onKeyPress, onKeyPress,
}; }
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { applyFileMentionReplacement, findActiveFileMention } from './file-mention-autocomplete'
describe('findActiveFileMention', () => {
it('detects mentions at the start of input', () => {
const mention = findActiveFileMention({
text: '@src/components',
cursorIndex: '@src/components'.length,
})
expect(mention).toEqual({
start: 0,
end: '@src/components'.length,
query: 'src/components',
})
})
it('detects mentions in the middle of input using cursor position', () => {
const text = 'read "@src/com" before merging'
const cursorIndex = text.indexOf('"') + 9
const mention = findActiveFileMention({
text,
cursorIndex,
})
expect(mention).toEqual({
start: text.indexOf('@'),
end: cursorIndex,
query: 'src/com',
})
})
it('returns null when cursor is outside the mention token', () => {
const text = 'please review @src/components now'
const mention = findActiveFileMention({
text,
cursorIndex: text.length,
})
expect(mention).toBeNull()
})
})
describe('applyFileMentionReplacement', () => {
it('replaces only the active @query segment with a quoted relative path', () => {
const text = 'open @src/com next'
const next = applyFileMentionReplacement({
text,
mention: { start: 5, end: 13, query: 'src/com' },
relativePath: 'src/components/chat.tsx',
})
expect(next).toBe('open "src/components/chat.tsx" next')
})
it('escapes double quotes in replacement path', () => {
const text = '@foo'
const next = applyFileMentionReplacement({
text,
mention: { start: 0, end: 4, query: 'foo' },
relativePath: 'src/"quoted".ts',
})
expect(next).toBe('"src/\\"quoted\\".ts"')
})
})
@@ -0,0 +1,48 @@
export interface FileMentionRange {
start: number
end: number
query: string
}
interface FindActiveFileMentionInput {
text: string
cursorIndex: number
}
interface ApplyFileMentionReplacementInput {
text: string
mention: FileMentionRange
relativePath: string
}
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/
export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null {
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length))
const beforeCursor = input.text.slice(0, clampedCursor)
for (
let atIndex = beforeCursor.lastIndexOf('@');
atIndex >= 0;
atIndex = beforeCursor.lastIndexOf('@', atIndex - 1)
) {
const query = beforeCursor.slice(atIndex + 1)
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
continue
}
return {
start: atIndex,
end: clampedCursor,
query,
}
}
return null
}
export function applyFileMentionReplacement(input: ApplyFileMentionReplacementInput): string {
const safePath = input.relativePath.replace(/"/g, '\\"')
const before = input.text.slice(0, input.mention.start)
const after = input.text.slice(input.mention.end)
return `${before}"${safePath}"${after}`
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from 'vitest'
import { import {
AgentStreamMessageSchema, AgentStreamMessageSchema,
@@ -6,172 +6,176 @@ import {
SessionInboundMessageSchema, SessionInboundMessageSchema,
SessionOutboundMessageSchema, SessionOutboundMessageSchema,
WSOutboundMessageSchema, WSOutboundMessageSchema,
} from "./messages.js"; } from './messages.js'
describe("shared messages stream parsing", () => { describe('shared messages stream parsing', () => {
it("parses representative fetch_agent_timeline_response payload", () => { it('parses representative fetch_agent_timeline_response payload', () => {
const parsed = FetchAgentTimelineResponseMessageSchema.parse({ const parsed = FetchAgentTimelineResponseMessageSchema.parse({
type: "fetch_agent_timeline_response", type: 'fetch_agent_timeline_response',
payload: { payload: {
requestId: "req-1", requestId: 'req-1',
agentId: "agent_live", agentId: 'agent_live',
direction: "tail", direction: 'tail',
projection: "projected", projection: 'projected',
epoch: "epoch-1", epoch: 'epoch-1',
reset: false, reset: false,
staleCursor: false, staleCursor: false,
gap: false, gap: false,
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 }, window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
startCursor: { epoch: "epoch-1", seq: 1 }, startCursor: { epoch: 'epoch-1', seq: 1 },
endCursor: { epoch: "epoch-1", seq: 2 }, endCursor: { epoch: 'epoch-1', seq: 2 },
hasOlder: false, hasOlder: false,
hasNewer: false, hasNewer: false,
entries: [ entries: [
{ {
provider: "codex", provider: 'codex',
item: { type: "assistant_message", text: "hello" }, item: { type: 'assistant_message', text: 'hello' },
timestamp: "2026-02-08T20:10:00.000Z", timestamp: '2026-02-08T20:10:00.000Z',
seqStart: 1, seqStart: 1,
seqEnd: 2, seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }], sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
collapsed: ["assistant_merge"], collapsed: ['assistant_merge'],
}, },
], ],
error: null, error: null,
}, },
}); })
expect(parsed.payload.entries).toHaveLength(1); expect(parsed.payload.entries).toHaveLength(1)
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message"); expect(parsed.payload.entries[0]?.item.type).toBe('assistant_message')
}); })
it("parses representative agent_stream tool_call event", () => { it('parses representative agent_stream tool_call event', () => {
const parsed = AgentStreamMessageSchema.parse({ const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream", type: 'agent_stream',
payload: { payload: {
agentId: "agent_live", agentId: 'agent_live',
timestamp: "2026-02-08T20:10:00.000Z", timestamp: '2026-02-08T20:10:00.000Z',
event: { event: {
type: "timeline", type: 'timeline',
provider: "claude", provider: 'claude',
item: { item: {
type: "tool_call", type: 'tool_call',
callId: "call_live", callId: 'call_live',
name: "shell", name: 'shell',
status: "running", status: 'running',
detail: { detail: {
type: "shell", type: 'shell',
command: "ls", command: 'ls',
}, },
error: null, error: null,
}, },
}, },
}, },
}); })
expect(parsed.payload.event.type).toBe("timeline"); expect(parsed.payload.event.type).toBe('timeline')
if (parsed.payload.event.type === "timeline") { if (parsed.payload.event.type === 'timeline') {
expect(parsed.payload.event.item.type).toBe("tool_call"); expect(parsed.payload.event.item.type).toBe('tool_call')
if (parsed.payload.event.item.type === "tool_call") { if (parsed.payload.event.item.type === 'tool_call') {
expect(parsed.payload.event.item.status).toBe("running"); expect(parsed.payload.event.item.status).toBe('running')
} }
} }
}); })
it("rejects removed initialize_agent_request inbound payload", () => { it('rejects removed initialize_agent_request inbound payload', () => {
const parsed = SessionInboundMessageSchema.safeParse({ const parsed = SessionInboundMessageSchema.safeParse({
type: "initialize_agent_request", type: 'initialize_agent_request',
agentId: "agent-legacy", agentId: 'agent-legacy',
requestId: "req-legacy-1", requestId: 'req-legacy-1',
}); })
expect(parsed.success).toBe(false); expect(parsed.success).toBe(false)
}); })
it("rejects removed initialize_agent_request outbound payload", () => { it('rejects removed initialize_agent_request outbound payload', () => {
const parsed = SessionOutboundMessageSchema.safeParse({ const parsed = SessionOutboundMessageSchema.safeParse({
type: "initialize_agent_request", type: 'initialize_agent_request',
payload: { payload: {
requestId: "req-legacy-1", requestId: 'req-legacy-1',
agentId: "agent-legacy", agentId: 'agent-legacy',
agentStatus: "running", agentStatus: 'running',
timelineSize: 12, timelineSize: 12,
}, },
}); })
expect(parsed.success).toBe(false); expect(parsed.success).toBe(false)
}); })
it("parses directory suggestions request and response payloads", () => { it('parses directory suggestions request and response payloads', () => {
const requestParsed = SessionInboundMessageSchema.safeParse({ const requestParsed = SessionInboundMessageSchema.safeParse({
type: "directory_suggestions_request", type: 'directory_suggestions_request',
query: "proj", query: 'proj',
cwd: '/tmp/project',
includeFiles: true,
includeDirectories: true,
limit: 20, limit: 20,
requestId: "req-dir-1", requestId: 'req-dir-1',
}); })
expect(requestParsed.success).toBe(true); expect(requestParsed.success).toBe(true)
const responseParsed = SessionOutboundMessageSchema.safeParse({ const responseParsed = SessionOutboundMessageSchema.safeParse({
type: "directory_suggestions_response", type: 'directory_suggestions_response',
payload: { payload: {
directories: ["/Users/test/projects/paseo"], directories: ['/Users/test/projects/paseo'],
entries: [{ path: '/Users/test/projects/paseo', kind: 'directory' }],
error: null, error: null,
requestId: "req-dir-1", requestId: 'req-dir-1',
}, },
}); })
expect(responseParsed.success).toBe(true); expect(responseParsed.success).toBe(true)
}); })
it("rejects websocket envelope for removed agent_stream_snapshot message type", () => { it('rejects websocket envelope for removed agent_stream_snapshot message type', () => {
const fixture = { const fixture = {
type: "agent_stream_snapshot", type: 'agent_stream_snapshot',
payload: { payload: {
agentId: "agent-legacy", agentId: 'agent-legacy',
events: [], events: [],
}, },
}; }
const wrapped = WSOutboundMessageSchema.safeParse({ const wrapped = WSOutboundMessageSchema.safeParse({
type: "session", type: 'session',
message: fixture, message: fixture,
}); })
expect(wrapped.success).toBe(false); expect(wrapped.success).toBe(false)
}); })
it("rejects removed legacy git diff request messages", () => { it('rejects removed legacy git diff request messages', () => {
const gitDiffParsed = SessionInboundMessageSchema.safeParse({ const gitDiffParsed = SessionInboundMessageSchema.safeParse({
type: "git_diff_request", type: 'git_diff_request',
agentId: "agent-1", agentId: 'agent-1',
requestId: "req-1", requestId: 'req-1',
}); })
expect(gitDiffParsed.success).toBe(false); expect(gitDiffParsed.success).toBe(false)
const highlightedParsed = SessionInboundMessageSchema.safeParse({ const highlightedParsed = SessionInboundMessageSchema.safeParse({
type: "highlighted_diff_request", type: 'highlighted_diff_request',
agentId: "agent-1", agentId: 'agent-1',
requestId: "req-2", requestId: 'req-2',
}); })
expect(highlightedParsed.success).toBe(false); expect(highlightedParsed.success).toBe(false)
}); })
it("rejects removed legacy git diff response messages", () => { it('rejects removed legacy git diff response messages', () => {
const gitDiffParsed = SessionOutboundMessageSchema.safeParse({ const gitDiffParsed = SessionOutboundMessageSchema.safeParse({
type: "git_diff_response", type: 'git_diff_response',
payload: { payload: {
agentId: "agent-1", agentId: 'agent-1',
diff: "", diff: '',
error: null, error: null,
requestId: "req-1", requestId: 'req-1',
}, },
}); })
expect(gitDiffParsed.success).toBe(false); expect(gitDiffParsed.success).toBe(false)
const highlightedParsed = SessionOutboundMessageSchema.safeParse({ const highlightedParsed = SessionOutboundMessageSchema.safeParse({
type: "highlighted_diff_response", type: 'highlighted_diff_response',
payload: { payload: {
agentId: "agent-1", agentId: 'agent-1',
files: [], files: [],
error: null, error: null,
requestId: "req-2", requestId: 'req-2',
}, },
}); })
expect(highlightedParsed.success).toBe(false); expect(highlightedParsed.success).toBe(false)
}); })
}); })
File diff suppressed because it is too large Load Diff
@@ -1,165 +1,237 @@
import { import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
mkdtempSync, import { tmpdir } from 'node:os'
mkdirSync, import path from 'node:path'
realpathSync, import { afterEach, beforeEach, describe, expect, it } from 'vitest'
rmSync, import { searchHomeDirectories, searchWorkspaceEntries } from './directory-suggestions.js'
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { searchHomeDirectories } from "./directory-suggestions.js";
describe("searchHomeDirectories", () => { describe('searchHomeDirectories', () => {
let tempRoot: string; let tempRoot: string
let homeDir: string; let homeDir: string
let outsideDir: string; let outsideDir: string
beforeEach(() => { beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), "directory-suggestions-"))); tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'directory-suggestions-')))
homeDir = path.join(tempRoot, "home"); homeDir = path.join(tempRoot, 'home')
outsideDir = path.join(tempRoot, "outside"); outsideDir = path.join(tempRoot, 'outside')
mkdirSync(homeDir, { recursive: true }); mkdirSync(homeDir, { recursive: true })
mkdirSync(outsideDir, { recursive: true }); mkdirSync(outsideDir, { recursive: true })
mkdirSync(path.join(homeDir, "projects", "paseo"), { recursive: true }); mkdirSync(path.join(homeDir, 'projects', 'paseo'), { recursive: true })
mkdirSync(path.join(homeDir, "projects", "playground"), { recursive: true }); mkdirSync(path.join(homeDir, 'projects', 'playground'), { recursive: true })
mkdirSync(path.join(homeDir, "documents", "plans"), { recursive: true }); mkdirSync(path.join(homeDir, 'documents', 'plans'), { recursive: true })
mkdirSync(path.join(homeDir, ".hidden", "cache"), { recursive: true }); mkdirSync(path.join(homeDir, '.hidden', 'cache'), { recursive: true })
writeFileSync(path.join(homeDir, "projects", "README.md"), "not a directory\n"); writeFileSync(path.join(homeDir, 'projects', 'README.md'), 'not a directory\n')
mkdirSync(path.join(outsideDir, "outside-match"), { recursive: true }); mkdirSync(path.join(outsideDir, 'outside-match'), { recursive: true })
symlinkSync(path.join(outsideDir, "outside-match"), path.join(homeDir, "outside-link")); symlinkSync(path.join(outsideDir, 'outside-match'), path.join(homeDir, 'outside-link'))
}); })
afterEach(() => { afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true }); rmSync(tempRoot, { recursive: true, force: true })
}); })
it("returns an empty list for blank queries", async () => { it('returns an empty list for blank queries', async () => {
await expect( await expect(
searchHomeDirectories({ searchHomeDirectories({
homeDir, homeDir,
query: " ", query: ' ',
limit: 10, limit: 10,
}) })
).resolves.toEqual([]); ).resolves.toEqual([])
}); })
it("returns only existing directories", async () => { it('returns only existing directories', async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "proj", query: 'proj',
limit: 10, limit: 10,
}); })
expect(results).toContain(path.join(homeDir, "projects")); expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, "projects", "paseo")); expect(results).toContain(path.join(homeDir, 'projects', 'paseo'))
expect(results).not.toContain(path.join(homeDir, "projects", "README.md")); expect(results).not.toContain(path.join(homeDir, 'projects', 'README.md'))
}); })
it("supports home-relative path query syntax", async () => { it('supports home-relative path query syntax', async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "~/projects/pa", query: '~/projects/pa',
limit: 10, limit: 10,
}); })
expect(results).toEqual([ expect(results).toEqual([path.join(homeDir, 'projects', 'paseo')])
path.join(homeDir, "projects", "paseo"), })
]);
});
it("prioritizes exact segment matches before segment-prefix matches", async () => { it('prioritizes exact segment matches before segment-prefix matches', async () => {
const exactSegmentPath = path.join( const exactSegmentPath = path.join(homeDir, 'something', 'faro', 'something-else')
homeDir, const prefixSegmentPath = path.join(homeDir, 'something', 'somethingelse', 'faro-bla')
"something", mkdirSync(exactSegmentPath, { recursive: true })
"faro", mkdirSync(prefixSegmentPath, { recursive: true })
"something-else"
);
const prefixSegmentPath = path.join(
homeDir,
"something",
"somethingelse",
"faro-bla"
);
mkdirSync(exactSegmentPath, { recursive: true });
mkdirSync(prefixSegmentPath, { recursive: true });
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "faro", query: 'faro',
limit: 30, limit: 30,
}); })
const exactIndex = results.indexOf(exactSegmentPath); const exactIndex = results.indexOf(exactSegmentPath)
const prefixIndex = results.indexOf(prefixSegmentPath); const prefixIndex = results.indexOf(prefixSegmentPath)
expect(exactIndex).toBeGreaterThanOrEqual(0); expect(exactIndex).toBeGreaterThanOrEqual(0)
expect(prefixIndex).toBeGreaterThanOrEqual(0); expect(prefixIndex).toBeGreaterThanOrEqual(0)
expect(exactIndex).toBeLessThan(prefixIndex); expect(exactIndex).toBeLessThan(prefixIndex)
}); })
it("prioritizes partial matches that appear earlier in the path", async () => { it('prioritizes partial matches that appear earlier in the path', async () => {
const earlierPath = path.join(homeDir, "farofoo"); const earlierPath = path.join(homeDir, 'farofoo')
const laterPath = path.join(homeDir, "x", "y", "farofoo"); const laterPath = path.join(homeDir, 'x', 'y', 'farofoo')
mkdirSync(earlierPath, { recursive: true }); mkdirSync(earlierPath, { recursive: true })
mkdirSync(laterPath, { recursive: true }); mkdirSync(laterPath, { recursive: true })
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "arofo", query: 'arofo',
limit: 30, limit: 30,
}); })
const earlierIndex = results.indexOf(earlierPath); const earlierIndex = results.indexOf(earlierPath)
const laterIndex = results.indexOf(laterPath); const laterIndex = results.indexOf(laterPath)
expect(earlierIndex).toBeGreaterThanOrEqual(0); expect(earlierIndex).toBeGreaterThanOrEqual(0)
expect(laterIndex).toBeGreaterThanOrEqual(0); expect(laterIndex).toBeGreaterThanOrEqual(0)
expect(earlierIndex).toBeLessThan(laterIndex); expect(earlierIndex).toBeLessThan(laterIndex)
}); })
it("returns home-root suggestions when query is '~'", async () => { it("returns home-root suggestions when query is '~'", async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "~", query: '~',
limit: 20, limit: 20,
}); })
expect(results).toContain(path.join(homeDir, "projects")); expect(results).toContain(path.join(homeDir, 'projects'))
expect(results).toContain(path.join(homeDir, "documents")); expect(results).toContain(path.join(homeDir, 'documents'))
expect(results).not.toContain(path.join(homeDir, ".hidden")); expect(results).not.toContain(path.join(homeDir, '.hidden'))
}); })
it("does not return hidden directories during tree search", async () => { it('does not return hidden directories during tree search', async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "cache", query: 'cache',
limit: 20, limit: 20,
}); })
expect(results).not.toContain(path.join(homeDir, ".hidden", "cache")); expect(results).not.toContain(path.join(homeDir, '.hidden', 'cache'))
}); })
it("does not return paths that escape home through symlinks", async () => { it('does not return paths that escape home through symlinks', async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "outside", query: 'outside',
limit: 20, limit: 20,
}); })
expect(results).not.toContain(path.join(homeDir, "outside-link")); expect(results).not.toContain(path.join(homeDir, 'outside-link'))
expect(results).not.toContain(path.join(outsideDir, "outside-match")); expect(results).not.toContain(path.join(outsideDir, 'outside-match'))
}); })
it("respects the result limit", async () => { it('respects the result limit', async () => {
const results = await searchHomeDirectories({ const results = await searchHomeDirectories({
homeDir, homeDir,
query: "p", query: 'p',
limit: 1, limit: 1,
}); })
expect(results).toHaveLength(1); expect(results).toHaveLength(1)
}); })
}); })
describe('searchWorkspaceEntries', () => {
let tempRoot: string
let workspaceDir: string
let outsideDir: string
beforeEach(() => {
tempRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'workspace-suggestions-')))
workspaceDir = path.join(tempRoot, 'workspace')
outsideDir = path.join(tempRoot, 'outside')
mkdirSync(path.join(workspaceDir, 'src', 'components'), {
recursive: true,
})
mkdirSync(path.join(workspaceDir, 'docs'), { recursive: true })
mkdirSync(path.join(outsideDir, 'escaped'), { recursive: true })
writeFileSync(path.join(workspaceDir, 'README.md'), '# paseo\n')
writeFileSync(
path.join(workspaceDir, 'src', 'components', 'chat-input.tsx'),
'export const ChatInput = null;\n'
)
writeFileSync(path.join(workspaceDir, 'docs', 'notes.md'), 'notes\n')
symlinkSync(path.join(outsideDir, 'escaped'), path.join(workspaceDir, 'escaped-link'))
})
afterEach(() => {
rmSync(tempRoot, { recursive: true, force: true })
})
it('returns relative file and directory suggestions for workspace queries', async () => {
const results = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'chat',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(results).toContainEqual({
path: 'src/components/chat-input.tsx',
kind: 'file',
})
expect(results.some((entry) => entry.path === path.join(workspaceDir, 'src'))).toBe(false)
})
it('filters entries by kind', async () => {
const dirsOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src',
limit: 20,
includeFiles: false,
includeDirectories: true,
})
expect(dirsOnly.some((entry) => entry.kind === 'file')).toBe(false)
expect(dirsOnly.some((entry) => entry.path === 'src')).toBe(true)
const filesOnly = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'readme',
limit: 20,
includeFiles: true,
includeDirectories: false,
})
expect(filesOnly).toEqual([{ path: 'README.md', kind: 'file' }])
})
it('supports path-style queries and does not escape cwd through symlinks', async () => {
const pathResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'src/co',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(pathResults).toContainEqual({
path: 'src/components',
kind: 'directory',
})
const escapedResults = await searchWorkspaceEntries({
cwd: workspaceDir,
query: 'escaped',
limit: 20,
includeFiles: true,
includeDirectories: true,
})
expect(escapedResults.some((entry) => entry.path.includes('escaped-link'))).toBe(false)
})
})
File diff suppressed because it is too large Load Diff