From 29ce6653fd34d595df41730b4907dec292210a07 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 18 May 2026 14:56:17 +0700 Subject: [PATCH] Improve mobile selectors and sheet inputs Keep sheet text inputs native-owned to avoid React Native controlled input flicker during fast typing. Add reset keys for fields that need programmatic clears, and move model, mode, and thinking pickers onto shared combobox rows with fuzzy ranked search and native virtualization. --- .../src/components/adaptive-modal-sheet.tsx | 348 ++++++++--- .../src/components/add-host-method-modal.tsx | 6 +- .../app/src/components/add-host-modal.tsx | 18 +- .../app/src/components/add-provider-modal.tsx | 28 +- .../app/src/components/agent-status-bar.tsx | 553 +++++++++--------- .../combined-model-selector.test.ts | 29 + .../components/combined-model-selector.tsx | 388 +++++------- .../combined-model-selector.utils.ts | 40 +- packages/app/src/components/composer.tsx | 7 +- .../components/keyboard-shortcuts-dialog.tsx | 5 +- .../app/src/components/pair-link-modal.tsx | 5 +- .../components/provider-diagnostic-sheet.tsx | 29 +- .../components/ui/combobox-options.test.ts | 10 + .../app/src/components/ui/combobox-options.ts | 18 +- packages/app/src/components/ui/combobox.tsx | 164 ++++-- .../src/components/workspace-setup-dialog.tsx | 10 +- .../components/desktop-updates-section.tsx | 8 +- .../desktop/components/pair-device-modal.tsx | 5 +- .../src/screens/project-settings-screen.tsx | 8 +- .../app/src/screens/settings/host-page.tsx | 12 +- .../workspace/workspace-import-sheet.tsx | 5 +- packages/app/src/utils/score-match.ts | 68 ++- packages/website/src/routes/docs.tsx | 2 +- 23 files changed, 1058 insertions(+), 708 deletions(-) diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index e8c6b8077..d1ae7f5e7 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -12,7 +12,7 @@ import { BottomSheetTextInput, type BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; -import { X } from "lucide-react-native"; +import { ArrowLeft, Search, X } from "lucide-react-native"; import { FileDropZone } from "@/components/file-drop-zone"; import type { ImageAttachment } from "@/components/message-input"; import { @@ -21,6 +21,37 @@ import { } from "@/components/ui/isolated-bottom-sheet-modal"; import { isNative, isWeb } from "@/constants/platform"; +// Horizontal indent token shared by the sheet header (title, back arrow, +// leading icon, search input icon) and any row primitive rendered inside the +// sheet body. Rows whose leading icon should line up with the header must +// match this padding. +export const SHEET_HORIZONTAL_PADDING_SCALE = 6; + +export interface SheetHeaderSearch { + value: string; + onChange: (value: string) => void; + initialValue?: string; + resetKey?: string | number; + placeholder?: string; + autoFocus?: boolean; + testID?: string; +} + +export interface SheetHeaderBack { + onPress: () => void; + label?: string; + accessibilityLabel?: string; +} + +export interface SheetHeader { + title: string; + subtitle?: ReactNode; + back?: SheetHeaderBack; + leading?: ReactNode; + actions?: ReactNode; + search?: SheetHeaderSearch; +} + type EscHandler = () => void; const escStack: EscHandler[] = []; let escListenerAttached = false; @@ -72,23 +103,30 @@ const styles = StyleSheet.create((theme) => ({ borderWidth: 1, borderColor: theme.colors.surface2, }, - header: { - paddingHorizontal: theme.spacing[6], - paddingVertical: theme.spacing[4], - flexDirection: "row", - alignItems: "flex-start", - justifyContent: "space-between", + headerContainer: { borderBottomWidth: 1, borderBottomColor: theme.colors.surface2, - gap: theme.spacing[3], + }, + headerRow: { + paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], + paddingVertical: theme.spacing[4], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + headerBackButton: { + borderRadius: theme.borderRadius.lg, + }, + headerLeadingSlot: { + alignItems: "center", + justifyContent: "center", }, headerTitleGroup: { flex: 1, - gap: theme.spacing[2], + gap: theme.spacing[1], minWidth: 0, }, title: { - flex: 1, fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.medium, }, @@ -96,52 +134,78 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - marginLeft: theme.spacing[3], - marginRight: theme.spacing[2], }, closeButton: { padding: theme.spacing[2], borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface2, + }, + searchRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], + paddingBottom: theme.spacing[3], + }, + // Inline variants for InlineHeaderView inside the desktop Combobox popover. + // Horizontal padding matches the model picker's row indent: the picker uses + // children mode (desktopChildrenScrollContent, no scroll padding), so the + // row content starts at item.paddingHorizontal = spacing[3]. + inlineHeaderRow: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + inlineSearchRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + borderBottomWidth: 1, + borderBottomColor: theme.colors.border, + }, + inlineTitle: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, + searchInput: { + flex: 1, + paddingVertical: theme.spacing[2], + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, }, desktopScroll: { flexShrink: 1, minHeight: 0, }, desktopContent: { - padding: theme.spacing[6], + padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], gap: theme.spacing[4], flexGrow: 1, }, - bottomSheetHeader: { - paddingHorizontal: theme.spacing[6], - paddingTop: theme.spacing[4], - paddingBottom: theme.spacing[3], - flexDirection: "row", - justifyContent: "space-between", - alignItems: "flex-start", - borderBottomWidth: 1, - borderBottomColor: theme.colors.surface2, - gap: theme.spacing[3], - }, bottomSheetContent: { - padding: theme.spacing[6], + padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], gap: theme.spacing[4], }, bottomSheetStaticContent: { flex: 1, - padding: theme.spacing[6], + padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], gap: theme.spacing[4], minHeight: 0, }, desktopStaticContent: { flexShrink: 1, minHeight: 0, - padding: theme.spacing[6], + padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], gap: theme.spacing[4], }, })); +const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }]; + function SheetBackground({ style }: BottomSheetBackgroundProps) { const { theme } = useUnistyles(); const combinedStyle = useMemo( @@ -158,14 +222,186 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) { return ; } +export type AdaptiveTextInputProps = TextInputProps & { + initialValue?: string; + resetKey?: string | number; +}; + +// React Native controlled TextInput can replay stale JS values during fast input +// and visibly flicker/cursor-jump. Keep the rendered text native-owned; callers +// can seed it once with initialValue and remount with resetKey for real resets. +// See https://github.com/facebook/react-native/issues/44157 +export const AdaptiveTextInput = forwardRef( + function AdaptiveTextInputInner(props, ref) { + const isMobile = useIsCompactFormFactor(); + const { value: _value, initialValue, resetKey, defaultValue, ...inputProps } = props; + const textInputProps = { + ...inputProps, + defaultValue: initialValue ?? defaultValue, + }; + + if (isMobile && isNative) { + return ( + } + {...textInputProps} + /> + ); + } + return ; + }, +); + +export function SheetHeaderView({ + header, + onClose, + showCloseButton = true, + testID, +}: { + header: SheetHeader; + onClose: () => void; + showCloseButton?: boolean; + testID?: string; +}) { + const { theme } = useUnistyles(); + const titleStyle = useMemo( + () => [styles.title, { color: theme.colors.foreground }], + [theme.colors.foreground], + ); + const back = header.back; + const handleBackPress = back?.onPress; + const search = header.search; + const handleSearchChange = useCallback( + (value: string) => { + search?.onChange(value); + }, + [search], + ); + + return ( + + + {handleBackPress ? ( + + {({ pressed }) => ( + + )} + + ) : null} + {header.leading ? {header.leading} : null} + + + {header.title} + + {header.subtitle} + + {header.actions ? {header.actions} : null} + {showCloseButton ? ( + + {({ pressed }) => ( + + )} + + ) : null} + + {search ? ( + + + + + ) : null} + + ); +} + +export function InlineHeaderView({ header }: { header: SheetHeader }) { + const { theme } = useUnistyles(); + const back = header.back; + const handleBackPress = back?.onPress; + const hasInlineRow = Boolean(handleBackPress || header.leading); + if (!hasInlineRow && !header.search) return null; + return ( + + {hasInlineRow ? ( + + {handleBackPress ? ( + + {({ pressed }) => ( + + )} + + ) : null} + {header.leading ? {header.leading} : null} + + {header.title} + + + ) : null} + {header.search ? ( + + + + + ) : null} + + ); +} + export interface AdaptiveModalSheetProps { - title: string; - /** Optional content rendered below the title in the header area. */ - subtitle?: ReactNode; + header: SheetHeader; visible: boolean; onClose: () => void; children: ReactNode; - headerActions?: ReactNode; snapPoints?: string[]; testID?: string; /** Override the max width of the desktop card. */ @@ -176,12 +412,10 @@ export interface AdaptiveModalSheetProps { } export function AdaptiveModalSheet({ - title, - subtitle, + header, visible, onClose, children, - headerActions, snapPoints, testID, desktopMaxWidth, @@ -190,7 +424,6 @@ export function AdaptiveModalSheet({ }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); const isMobile = useIsCompactFormFactor(); - const titleColor = theme.colors.foreground; const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const handleIndicatorStyle = useMemo( () => ({ backgroundColor: theme.colors.surface2 }), @@ -209,7 +442,6 @@ export function AdaptiveModalSheet({ [], ); - const titleStyle = useMemo(() => [styles.title, { color: titleColor }], [titleColor]); const desktopCardStyle = useMemo( () => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }], [desktopMaxWidth], @@ -237,18 +469,7 @@ export function AdaptiveModalSheet({ keyboardBlurBehavior="restore" accessible={false} > - - - - {title} - - {subtitle} - - {headerActions ? {headerActions} : null} - - - - + {scrollable ? ( - - - - {title} - - {subtitle} - - {headerActions ? {headerActions} : null} - - - - + {scrollable ? ( ); } - -/** - * TextInput that automatically uses BottomSheetTextInput on mobile - * for proper keyboard dodging in AdaptiveModalSheet. - */ -export const AdaptiveTextInput = forwardRef( - function AdaptiveTextInput(props, ref) { - const isMobile = useIsCompactFormFactor(); - - if (isMobile && isNative) { - return } {...props} />; - } - - return ; - }, -); diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx index cfbd255fc..449870e2d 100644 --- a/packages/app/src/components/add-host-method-modal.tsx +++ b/packages/app/src/components/add-host-method-modal.tsx @@ -2,9 +2,11 @@ import { useCallback } from "react"; import { Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; -import { AdaptiveModalSheet } from "./adaptive-modal-sheet"; +import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet"; import { isNative } from "@/constants/platform"; +const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" }; + const styles = StyleSheet.create((theme) => ({ option: { flexDirection: "row", @@ -62,7 +64,7 @@ export function AddHostMethodModal({ return ( key + 1, 0); const clearInput = useCallback(() => { setHost(""); @@ -288,6 +290,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod setIsPasswordVisible(false); setIsAdvancedOpen(false); setAdvancedUri(""); + bumpInputResetKey(); }, []); const connectIcon = useMemo( @@ -411,6 +414,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod setUseTls(next.useTls); setPassword(next.password); setErrorMessage(""); + bumpInputResetKey(); } catch { setErrorMessage(""); } @@ -422,7 +426,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod return ( key + 1, 0); const [installingProviderId, setInstallingProviderId] = useState(null); + const handleClose = useCallback(() => { + setSearch(""); + bumpSearchResetKey(); + onClose(); + }, [onClose]); + const installedProviderIds = useMemo( () => new Set(providerEntries?.map((entry) => entry.provider) ?? []), [providerEntries], @@ -157,7 +169,7 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa try { await patchConfig(buildAcpProviderConfigPatch(entry)); await refresh([entry.id]); - onClose(); + handleClose(); } catch (installError) { Alert.alert( "Unable to install provider", @@ -167,14 +179,14 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa setInstallingProviderId((current) => (current === entry.id ? null : current)); } }, - [installingProviderId, onClose, patchConfig, refresh], + [installingProviderId, handleClose, patchConfig, refresh], ); return ( - diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index 93941fab2..05320cebe 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -16,7 +16,7 @@ import { type StyleProp, type ViewStyle, } from "react-native"; -import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { @@ -48,7 +48,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; -import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { AgentFeature, @@ -58,14 +58,13 @@ import type { } from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import { getModeVisuals, type AgentModeColorTier } from "@server/server/agent/provider-manifest"; -import type { Theme } from "@/styles/theme"; import { getFeatureHighlightColor, getFeatureTooltip, getStatusSelectorHint, resolveAgentModelSelection, } from "@/components/agent-status-bar.utils"; -import { isWeb as platformIsWeb } from "@/constants/platform"; +import { useIsCompactFormFactor } from "@/constants/layout"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; @@ -184,21 +183,6 @@ const MODE_ICONS = { ShieldOff, ShieldQuestionMark, } as const; -const ThemedShieldCheck = withUnistyles(ShieldCheck); -const ThemedShieldAlert = withUnistyles(ShieldAlert); -const ThemedShieldOff = withUnistyles(ShieldOff); -const ThemedShieldQuestionMark = withUnistyles(ShieldQuestionMark); - -const foregroundColorMapping = (theme: Theme) => ({ - color: theme.colors.foreground, -}); - -const MODE_MENU_LEADING_ICONS = { - ShieldCheck: , - ShieldAlert: , - ShieldOff: , - ShieldQuestionMark: , -} as const; function alwaysTrue() { return true; @@ -215,6 +199,16 @@ function resolveDisplayModel( return findOptionLabel(modelOptions, selectedModelId, "Select model"); } +// Mobile status bar only — strip namespace prefix so providers like OpenCode +// show "gpt-5.5" instead of "openrouter/gpt-5.5". Full label still appears in +// the model picker. +function shortModelLabel(label: string): string { + const i = label.lastIndexOf("/"); + return i === -1 ? label : label.slice(i + 1); +} + +type ActiveSheet = "thinking" | "mode" | "features" | null; + function resolveHasAnyControl({ providerOptions, modeOptions, @@ -274,18 +268,6 @@ function makeBadgePressableStyle( ]; } -function makeSheetPressableStyle(isDisabled: boolean) { - return ({ pressed }: PressableStateCallbackType) => [ - styles.sheetSelect, - pressed && styles.sheetSelectPressed, - isDisabled && styles.disabledSheetSelect, - ]; -} - -function makePrefsButtonStyle({ pressed }: PressableStateCallbackType) { - return [styles.prefsButton, pressed && styles.prefsButtonPressed]; -} - function pickSheetModel({ nextProviderId, modelId, @@ -436,27 +418,6 @@ function buildOpenChangeHandler( }; } -function SheetModelTriggerView({ - selectedModelLabel, - style, - ProviderIcon, -}: { - selectedModelLabel: string; - style: StyleProp; - ProviderIcon: ReturnType | null; -}) { - const { theme } = useUnistyles(); - return ( - - {ProviderIcon ? ( - - ) : null} - {selectedModelLabel} - - - ); -} - function getModeIconColor( colorTier: AgentModeColorTier | undefined, palette: { @@ -508,7 +469,8 @@ function ControlledStatusBar({ onModelSelectorOpen, }: ControlledAgentStatusBarProps) { const { theme } = useUnistyles(); - const [prefsOpen, setPrefsOpen] = useState(false); + const isCompact = useIsCompactFormFactor(); + const [activeSheet, setActiveSheet] = useState(null); const [openSelector, setOpenSelector] = useState(null); const providerAnchorRef = useRef(null); @@ -526,7 +488,6 @@ function ControlledStatusBar({ ); const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider"); - const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default"); const displayModel = resolveDisplayModel(isModelLoading, modelOptions, selectedModelId); const displayThinking = findOptionLabel( thinkingOptions, @@ -586,6 +547,18 @@ function ControlledStatusBar({ ), [provider, providerDefinitions, theme.colors.foreground], ); + const renderThinkingOption = useCallback( + (args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => ( + + ), + [theme.colors.foreground], + ); const handleOpenChange = useCallback( (selector: StatusSelector) => @@ -659,16 +632,30 @@ function ControlledStatusBar({ [canSelectMode, disabled, openSelector], ); - const handleOpenPrefs = useCallback(() => { + const handleOpenSheet = useCallback((sheet: Exclude) => { Keyboard.dismiss(); - setPrefsOpen(true); + setActiveSheet(sheet); }, []); - const handleClosePrefs = useCallback(() => { - setPrefsOpen(false); + const handleCloseSheet = useCallback(() => { + setActiveSheet(null); }, []); - const prefsButtonStyle = makePrefsButtonStyle; + const handleSelectThinkingAndClose = useCallback( + (thinkingOptionId: string) => { + onSelectThinkingOption?.(thinkingOptionId); + setActiveSheet(null); + }, + [onSelectThinkingOption], + ); + + const handleSelectModeAndClose = useCallback( + (modeId: string) => { + onSelectMode?.(modeId); + setActiveSheet(null); + }, + [onSelectMode], + ); const handleSheetModelSelect = useCallback( (nextProviderId: string, modelId: string) => { @@ -684,38 +671,13 @@ function ControlledStatusBar({ [onSelectModel, onSelectProvider, onSelectProviderAndModel, provider], ); - const sheetThinkingPressableStyle = useMemo( - () => makeSheetPressableStyle(disabled || !canSelectThinking), - [canSelectThinking, disabled], - ); - - const sheetModePressableStyle = useMemo( - () => makeSheetPressableStyle(disabled || !canSelectMode), - [canSelectMode, disabled], - ); - - const sheetSelectStyle = useMemo( - () => [styles.sheetSelect, modelDisabled && styles.disabledSheetSelect], - [modelDisabled], - ); - const renderSheetModelTrigger = useCallback( - ({ selectedModelLabel }: { selectedModelLabel: string }) => ( - - ), - [ProviderIcon, sheetSelectStyle], - ); - if (!hasAnyControl) { return null; } return ( - {platformIsWeb ? ( + {!isCompact ? ( ) : ( )} @@ -880,6 +835,12 @@ interface DesktopStatusBarContentProps { active: boolean; onPress: () => void; }) => ReactElement; + renderThinkingOption: (args: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => ReactElement; } const DESKTOP_SEARCH_THRESHOLD = 6; @@ -938,6 +899,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) { handleModeOpenChange, handleOpenChange, renderModeOption, + renderThinkingOption, } = props; return ( @@ -1033,6 +995,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) { onOpenChange={handleThinkingOpenChange} anchorRef={thinkingAnchorRef} desktopPlacement="top-start" + renderOption={renderThinkingOption} /> ) : null} @@ -1092,19 +1055,14 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) { interface SheetStatusBarContentProps { provider: string; - modeOptions?: StatusOption[]; selectedModeId?: string; selectedModelId?: string; - thinkingOptions?: StatusOption[]; selectedThinkingOptionId?: string; features?: AgentFeature[]; onSetFeature?: (featureId: string, value: unknown) => void; - onSelectMode?: (modeId: string) => void; - onSelectThinkingOption?: (thinkingOptionId: string) => void; onToggleFavoriteModel?: (provider: string, modelId: string) => void; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; - providerDefinitions: AgentProviderDefinition[]; favoriteKeys: Set; disabled: boolean; isModelLoading: boolean; @@ -1115,43 +1073,45 @@ interface SheetStatusBarContentProps { modelDisabled: boolean; effectiveProviderDefinitions: AgentProviderDefinition[]; effectiveAllProviderModels: Map; - displayMode: string; - displayModel: string; - displayThinking: string; + comboboxModeOptions: ComboboxOption[]; + comboboxThinkingOptions: ComboboxOption[]; ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null; modeIconColor: string; openSelector: StatusSelector | null; ProviderIcon: ReturnType | null; - prefsOpen: boolean; - handleOpenPrefs: () => void; - handleClosePrefs: () => void; - prefsButtonStyle: (state: PressableStateCallbackType) => StyleProp; - sheetThinkingPressableStyle: (state: PressableStateCallbackType) => StyleProp; - sheetModePressableStyle: (state: PressableStateCallbackType) => StyleProp; + activeSheet: ActiveSheet; + handleOpenSheet: (sheet: Exclude) => void; + handleCloseSheet: () => void; handleSheetModelSelect: (providerId: string, modelId: string) => void; - handleThinkingOpenChange: (open: boolean) => void; - handleModeOpenChange: (open: boolean) => void; + handleSelectThinkingAndClose: (thinkingOptionId: string) => void; + handleSelectModeAndClose: (modeId: string) => void; handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => void; - renderSheetModelTrigger: (args: { selectedModelLabel: string }) => ReactElement; + renderModeOption: (args: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => ReactElement; + renderThinkingOption: (args: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => ReactElement; } function SheetStatusBarContent(props: SheetStatusBarContentProps) { const { theme } = useUnistyles(); const { provider, - modeOptions, selectedModeId, selectedModelId, - thinkingOptions, selectedThinkingOptionId, features, onSetFeature, - onSelectMode, - onSelectThinkingOption, onToggleFavoriteModel, onDropdownClose, onModelSelectorOpen, - providerDefinitions, favoriteKeys, disabled, isModelLoading, @@ -1162,133 +1122,194 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) { modelDisabled, effectiveProviderDefinitions, effectiveAllProviderModels, - displayMode, - displayModel, - displayThinking, + comboboxModeOptions, + comboboxThinkingOptions, ModeIconComponent, modeIconColor, openSelector, ProviderIcon, - prefsOpen, - handleOpenPrefs, - handleClosePrefs, - prefsButtonStyle, - sheetThinkingPressableStyle, - sheetModePressableStyle, + activeSheet, + handleOpenSheet, + handleCloseSheet, handleSheetModelSelect, - handleThinkingOpenChange, - handleModeOpenChange, + handleSelectThinkingAndClose, + handleSelectModeAndClose, handleOpenChange, - renderSheetModelTrigger, + renderModeOption, + renderThinkingOption, } = props; - return ( - <> - + const thinkingAnchorRef = useRef(null); + const modeAnchorRef = useRef(null); + + const hasThinking = comboboxThinkingOptions.length > 0; + const hasMode = Boolean(canSelectMode && comboboxModeOptions.length > 0); + const hasFeatures = Boolean(features && features.length > 0); + + const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]); + const handleOpenMode = useCallback(() => handleOpenSheet("mode"), [handleOpenSheet]); + const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]); + const handleThinkingSheetOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + handleOpenSheet("thinking"); + } else { + handleCloseSheet(); + } + }, + [handleCloseSheet, handleOpenSheet], + ); + const handleModeSheetOpenChange = useCallback( + (nextOpen: boolean) => { + if (nextOpen) { + handleOpenSheet("mode"); + } else { + handleCloseSheet(); + } + }, + [handleCloseSheet, handleOpenSheet], + ); + + const renderModelTrigger = useCallback( + ({ + selectedModelLabel, + }: { + selectedModelLabel: string; + onPress: () => void; + disabled: boolean; + isOpen: boolean; + }) => ( + {ProviderIcon ? ( ) : null} - {displayModel} + {shortModelLabel(selectedModelLabel)} - + + ), + [ProviderIcon, theme.iconSize.lg, theme.colors.foregroundMuted], + ); + + const thinkingButtonStyle = makeBadgePressableStyle( + styles.modeIconBadge, + styles.disabledBadge, + disabled || !canSelectThinking, + activeSheet === "thinking", + ); + const modeButtonStyle = makeBadgePressableStyle( + styles.modeIconBadge, + styles.disabledBadge, + disabled || !canSelectMode, + activeSheet === "mode", + ); + const featuresButtonStyle = makeBadgePressableStyle( + styles.modeIconBadge, + styles.disabledBadge, + disabled, + activeSheet === "features", + ); + + return ( + <> + {canSelectModel ? ( + + ) : null} + + {hasThinking ? ( + + + + ) : null} + + {hasMode ? ( + + {ModeIconComponent ? ( + + ) : ( + + )} + + ) : null} + + {hasFeatures ? ( + + + + ) : null} + + {hasThinking ? ( + + ) : null} + + {hasMode ? ( + + ) : null} - {canSelectModel ? ( - - - - ) : null} - - {thinkingOptions && thinkingOptions.length > 0 ? ( - - - - - {displayThinking} - - - - {thinkingOptions.map((thinking) => ( - - ))} - - - - ) : null} - - {modeOptions && modeOptions.length > 0 ? ( - - - - {ModeIconComponent ? ( - - ) : null} - {displayMode} - - - - {modeOptions.map((mode) => ( - - ))} - - - - ) : null} - - {features?.map((feature) => ( + {(features ?? []).map((feature) => ( void; + active: boolean; + onPress: () => void; + iconColor: string; }) { - const handleSelect = useCallback(() => { - onSelectThinkingOption?.(thinking.id); - }, [onSelectThinkingOption, thinking.id]); - + const leadingSlot = useMemo(() => , [iconColor]); return ( - - {thinking.label} - + ); } @@ -1609,33 +1635,8 @@ function ModeComboboxOption({ ); } -function ModeMenuItem({ - mode, - provider, - providerDefinitions, - selected, - onSelectMode, -}: { - mode: StatusOption; - provider: string; - providerDefinitions: AgentProviderDefinition[]; - selected: boolean; - onSelectMode?: (modeId: string) => void; -}) { - const visuals = getModeVisuals(provider, mode.id, providerDefinitions); - const leadingIcon = visuals?.icon ? MODE_MENU_LEADING_ICONS[visuals.icon] : null; - const handleSelect = useCallback(() => { - onSelectMode?.(mode.id); - }, [mode.id, onSelectMode]); - - return ( - - {mode.label} - - ); -} - const EMPTY_MODES: AgentMode[] = []; +const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" }; export const AgentStatusBar = memo(function AgentStatusBar({ agentId, @@ -1884,6 +1885,7 @@ export function DraftAgentStatusBar({ disabled = false, }: DraftAgentStatusBarProps) { const { preferences, updatePreferences } = useFormPreferences(); + const isCompact = useIsCompactFormFactor(); const mappedModeOptions = useMemo(() => { if (modeOptions.length === 0) { @@ -1931,7 +1933,7 @@ export function DraftAgentStatusBar({ [updatePreferences], ); - if (platformIsWeb) { + if (!isCompact) { return ( ({ paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius["2xl"], }, - prefsButtonPressed: { - backgroundColor: theme.colors.surface0, - }, prefsButtonText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/components/combined-model-selector.test.ts b/packages/app/src/components/combined-model-selector.test.ts index 50c57e07c..41d06b6ff 100644 --- a/packages/app/src/components/combined-model-selector.test.ts +++ b/packages/app/src/components/combined-model-selector.test.ts @@ -3,6 +3,7 @@ import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types" import { buildModelRows, buildSelectedTriggerLabel, + filterAndRankModelRows, matchesSearch, resolveProviderLabel, } from "./combined-model-selector.utils"; @@ -83,6 +84,34 @@ describe("combined model selector helpers", () => { expect(matchesSearch(row, "kimi gemini")).toBe(false); }); + it("ranks model search results by fuzzy match quality", () => { + const rows = [ + { + favoriteKey: "openai:gpt-4.1", + provider: "openai", + providerLabel: "OpenAI", + modelId: "gpt-4.1", + modelLabel: "GPT-4.1", + }, + { + favoriteKey: "openai:gpt-5.4", + provider: "openai", + providerLabel: "OpenAI", + modelId: "gpt-5.4", + modelLabel: "GPT-5.4", + }, + { + favoriteKey: "google:gemini", + provider: "google", + providerLabel: "Google", + modelId: "gemini", + modelLabel: "Gemini", + }, + ]; + + expect(filterAndRankModelRows(rows, "gpt54").map((row) => row.modelId)).toEqual(["gpt-5.4"]); + }); + it("keeps the selected trigger label model-only", () => { expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex"); expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4"); diff --git a/packages/app/src/components/combined-model-selector.tsx b/packages/app/src/components/combined-model-selector.tsx index ae14bf594..081a6b760 100644 --- a/packages/app/src/components/combined-model-selector.tsx +++ b/packages/app/src/components/combined-model-selector.tsx @@ -1,21 +1,20 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { Ref } from "react"; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { View, Text, - TextInput, Pressable, ActivityIndicator, type GestureResponderEvent, type PressableStateCallbackType, } from "react-native"; -import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; +import { BottomSheetFlatList } from "@gorhom/bottom-sheet"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative, isWeb as platformIsWeb } from "@/constants/platform"; -import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native"; +import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native"; import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; +import type { SheetHeader } from "@/components/adaptive-modal-sheet"; const IS_WEB = platformIsWeb; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; @@ -45,19 +44,11 @@ function drillDownRowStyle({ pressed && styles.drillDownRowPressed, ]; } - -function backButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) { - return [ - styles.backButton, - Boolean(hovered) && styles.backButtonHovered, - pressed && styles.backButtonPressed, - ]; -} import { getProviderIcon } from "@/components/provider-icons"; import { buildModelRows, buildSelectedTriggerLabel, - matchesSearch, + filterAndRankModelRows, resolveProviderLabel, type SelectorModelRow, } from "./combined-model-selector.utils"; @@ -97,7 +88,6 @@ interface SelectorContentProps { selectedProvider: string; selectedModel: string; searchQuery: string; - onSearchChange: (query: string) => void; favoriteKeys: Set; onSelect: (provider: string, modelId: string) => void; canSelectProvider: (provider: string) => boolean; @@ -116,24 +106,6 @@ function normalizeSearchQuery(value: string): string { return value.trim().toLowerCase(); } -function partitionRows( - rows: SelectorModelRow[], - favoriteKeys: Set, -): { favoriteRows: SelectorModelRow[]; regularRows: SelectorModelRow[] } { - const favoriteRows: SelectorModelRow[] = []; - const regularRows: SelectorModelRow[] = []; - - for (const row of rows) { - if (favoriteKeys.has(row.favoriteKey)) { - favoriteRows.push(row); - continue; - } - regularRows.push(row); - } - - return { favoriteRows, regularRows }; -} - function sortFavoritesFirst( rows: SelectorModelRow[], favoriteKeys: Set, @@ -375,55 +347,23 @@ function GroupProviderButton({ function GroupedProviderRows({ groupedRows, - selectedProvider, - selectedModel, - favoriteKeys, - onSelect, - canSelectProvider, - onToggleFavorite, onDrillDown, - viewKind, }: { groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>; - selectedProvider: string; - selectedModel: string; - favoriteKeys: Set; - onSelect: (provider: string, modelId: string) => void; - canSelectProvider: (provider: string) => boolean; - onToggleFavorite?: (provider: string, modelId: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void; - viewKind: SelectorView["kind"]; }) { return ( {groupedRows.map((group, index) => { - const isInline = viewKind === "provider"; - return ( {index > 0 ? : null} - {isInline ? ( - <> - {sortFavoritesFirst(group.rows, favoriteKeys).map((row) => ( - - ))} - - ) : ( - - )} + ); })} @@ -431,47 +371,65 @@ function GroupedProviderRows({ ); } -function ProviderSearchInput({ - value, - onChangeText, - autoFocus = false, +function ProviderModelRows({ + rows, + selectedProvider, + selectedModel, + favoriteKeys, + onSelect, + canSelectProvider, + onToggleFavorite, + normalizedQuery, }: { - value: string; - onChangeText: (text: string) => void; - autoFocus?: boolean; + rows: SelectorModelRow[]; + selectedProvider: string; + selectedModel: string; + favoriteKeys: Set; + onSelect: (provider: string, modelId: string) => void; + canSelectProvider: (provider: string) => boolean; + onToggleFavorite?: (provider: string, modelId: string) => void; + normalizedQuery: string; }) { - const { theme } = useUnistyles(); - const inputRef = useRef(null); const isMobile = useIsCompactFormFactor(); - const InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput; - - useEffect(() => { - if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {}; - const timer = setTimeout(() => { - inputRef.current?.focus(); - }, 50); - return () => clearTimeout(timer); - }, [autoFocus]); - - const inputStyle = useMemo( - () => [styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }], - [], + const useVirtualizedList = isMobile && isNative; + const displayRows = useMemo( + () => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)), + [favoriteKeys, normalizedQuery, rows], ); + const renderItem = useCallback( + ({ item }: { item: SelectorModelRow }) => ( + + ), + [canSelectProvider, favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider], + ); + const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []); + + if (useVirtualizedList) { + return ( + + ); + } return ( - - - } - // @ts-expect-error - outlineStyle is web-only - style={inputStyle} - placeholder="Search models..." - placeholderTextColor={theme.colors.foregroundMuted} - value={value} - onChangeText={onChangeText} - autoCapitalize="none" - autoCorrect={false} - /> + + {displayRows.map((row) => ( + {renderItem({ item: row })} + ))} ); } @@ -483,7 +441,6 @@ function SelectorContent({ selectedProvider, selectedModel, searchQuery, - onSearchChange: _onSearchChange, favoriteKeys, onSelect, canSelectProvider, @@ -506,92 +463,61 @@ function SelectorContent({ const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); const visibleRows = useMemo( - () => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)), + () => filterAndRankModelRows(scopedRows, normalizedQuery), [normalizedQuery, scopedRows], ); - const { favoriteRows, regularRows: _regularRows } = useMemo( - () => partitionRows(visibleRows, favoriteKeys), + const favoriteRows = useMemo( + () => visibleRows.filter((row) => favoriteKeys.has(row.favoriteKey)), [favoriteKeys, visibleRows], ); - // Group ALL visible rows by provider — favorites are a cross-cutting view, - // not a partition. A model being favorited doesn't remove it from its provider. const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]); - - // When searching at Level 1, filter grouped rows to only providers whose name or models match - const filteredGroupedRows = useMemo(() => { - if (view.kind === "provider" || !normalizedQuery) { - return allGroupedRows; - } - return allGroupedRows.filter( - (group) => - group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0, - ); - }, [allGroupedRows, normalizedQuery, view.kind]); - - const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0; - - return ( - - {view.kind === "all" ? ( - - ) : null} - - {filteredGroupedRows.length > 0 ? ( - - ) : null} - - {!hasResults ? ( - - - No models match your search - - ) : null} + const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0; + const emptyState = ( + + + No models match your search ); -} -function ProviderBackButton({ - providerId, - providerLabel, - onBack, -}: { - providerId: string; - providerLabel: string; - onBack?: () => void; -}) { - const { theme } = useUnistyles(); - const ProviderIcon = getProviderIcon(providerId); + if (view.kind === "provider") { + if (visibleRows.length === 0) { + return emptyState; + } - if (!onBack) { - return null; + return ( + + ); } return ( - - - - {providerLabel} - + + + + {allGroupedRows.length > 0 ? ( + + ) : null} + + {!hasResults ? emptyState : null} + ); } @@ -616,6 +542,7 @@ export function CombinedModelSelector({ const [isContentReady, setIsContentReady] = useState(platformIsWeb); const [view, setView] = useState({ kind: "all" }); const [searchQuery, setSearchQuery] = useState(""); + const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); // Single-provider mode: only one provider with models → skip Level 1 entirely const singleProviderView = useMemo(() => { @@ -646,6 +573,7 @@ export function CombinedModelSelector({ onOpen?.(); } else { setSearchQuery(""); + bumpSearchResetKey(); onClose?.(); } }, @@ -657,6 +585,7 @@ export function CombinedModelSelector({ onSelect(provider, modelId); setIsOpen(false); setSearchQuery(""); + bumpSearchResetKey(); }, [onSelect], ); @@ -731,32 +660,48 @@ export function CombinedModelSelector({ const handleBackToAll = useCallback(() => { setView({ kind: "all" }); setSearchQuery(""); + bumpSearchResetKey(); }, []); const handleDrillDown = useCallback((providerId: string, providerLabel: string) => { setView({ kind: "provider", providerId, providerLabel }); }, []); - const stickyHeader = useMemo( - () => - view.kind === "provider" ? ( - - {!singleProviderView ? ( - - ) : null} - - + const handleSearchQueryChange = useCallback((value: string) => { + setSearchQuery(value); + }, []); + + const sheetHeader = useMemo(() => { + if (view.kind === "all") { + return { title: "Select provider" }; + } + const ProviderIconForView = getProviderIcon(view.providerId); + return { + title: view.providerLabel, + leading: ProviderIconForView ? ( + ) : undefined, - [view, singleProviderView, handleBackToAll, searchQuery], - ); + back: singleProviderView ? undefined : { onPress: handleBackToAll }, + search: { + value: searchQuery, + onChange: handleSearchQueryChange, + initialValue: searchQuery, + resetKey: `${view.providerId}:${searchResetKey}`, + placeholder: "Search models...", + autoFocus: platformIsWeb, + testID: "model-search-input", + }, + }; + }, [ + view, + singleProviderView, + handleBackToAll, + handleSearchQueryChange, + searchQuery, + searchResetKey, + theme.iconSize.md, + theme.colors.foreground, + ]); return ( <> @@ -799,8 +744,8 @@ export function CombinedModelSelector({ desktopPlacement="top-start" desktopMinWidth={360} desktopFixedHeight={desktopFixedHeight} - title="Select model" - stickyHeader={stickyHeader} + header={sheetHeader} + mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative} > {isContentReady ? ( ({ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, - level2Header: {}, - backButton: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, - ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), - }, - backButtonHovered: { - backgroundColor: theme.colors.surface2, - }, - backButtonPressed: { - backgroundColor: theme.colors.surface2, - }, - backButtonText: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, emptyState: { paddingVertical: theme.spacing[4], alignItems: "center", @@ -943,6 +866,14 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, + virtualizedModelList: { + flex: 1, + }, + virtualizedModelListContent: { + paddingHorizontal: theme.spacing[2], + paddingTop: theme.spacing[1], + paddingBottom: theme.spacing[8], + }, favoriteButton: { width: 24, height: 24, @@ -966,17 +897,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - providerSearchContainer: { - flexDirection: "row", - alignItems: "center", - paddingHorizontal: theme.spacing[3], - gap: theme.spacing[2], - ...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }), - }, - providerSearchInput: { - flex: 1, - paddingVertical: theme.spacing[3], - color: theme.colors.foreground, - fontSize: theme.fontSize.sm, - }, })); diff --git a/packages/app/src/components/combined-model-selector.utils.ts b/packages/app/src/components/combined-model-selector.utils.ts index 9f6e806a6..3cea39d62 100644 --- a/packages/app/src/components/combined-model-selector.utils.ts +++ b/packages/app/src/components/combined-model-selector.utils.ts @@ -1,6 +1,7 @@ import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences"; +import { compareMatchScores, scoreTextFields } from "@/utils/score-match"; export type SelectorModelRow = FavoriteModelRow; @@ -44,14 +45,33 @@ export function buildModelRows( } export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean { - if (!normalizedQuery) { - return true; - } - - const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""] - .join(" ") - .toLowerCase(); - - const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0); - return tokens.every((token) => haystack.includes(token)); + return scoreModelRow(row, normalizedQuery) !== null; +} + +function getModelRowSearchFields(row: SelectorModelRow): string[] { + return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]; +} + +export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) { + return scoreTextFields(normalizedQuery, getModelRowSearchFields(row)); +} + +export function filterAndRankModelRows( + rows: SelectorModelRow[], + normalizedQuery: string, +): SelectorModelRow[] { + if (!normalizedQuery) return rows; + const scored = rows + .map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) })) + .filter((entry): entry is { row: SelectorModelRow; score: NonNullable } => + Boolean(entry.score), + ); + + scored.sort((a, b) => { + const cmp = compareMatchScores(a.score, b.score); + if (cmp !== 0) return cmp; + return a.row.modelLabel.localeCompare(b.row.modelLabel); + }); + + return scored.map((entry) => entry.row); } diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 888214b77..1914bae49 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -741,6 +741,7 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps { isAgentRunning: boolean; hasSendableContent: boolean; isProcessing: boolean; + isCompact: boolean; cancelButton: ReactElement; } @@ -750,10 +751,12 @@ function ComposerRightControlsSlot({ isAgentRunning, hasSendableContent, isProcessing, + isCompact, cancelButton, ...voiceProps }: ComposerRightControlsSlotProps) { - const showVoiceModeButton = !isVoiceModeForAgent && hasAgent; + const hideVoiceForCompactInput = isCompact && hasSendableContent; + const showVoiceModeButton = !isVoiceModeForAgent && hasAgent && !hideVoiceForCompactInput; const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing; if (!showVoiceModeButton && !shouldShowCancelButton) return null; return ( @@ -1401,6 +1404,7 @@ export function Composer({ isAgentRunning={isAgentRunning} hasSendableContent={hasSendableContent} isProcessing={isProcessing} + isCompact={isMobile} buttonIconSize={buttonIconSize} handleToggleRealtimeVoice={handleToggleRealtimeVoice} isConnected={isConnected} @@ -1418,6 +1422,7 @@ export function Composer({ hasSendableContent, isAgentRunning, isConnected, + isMobile, isProcessing, isVoiceModeForAgent, isVoiceSwitching, diff --git a/packages/app/src/components/keyboard-shortcuts-dialog.tsx b/packages/app/src/components/keyboard-shortcuts-dialog.tsx index 566730721..78f6e1250 100644 --- a/packages/app/src/components/keyboard-shortcuts-dialog.tsx +++ b/packages/app/src/components/keyboard-shortcuts-dialog.tsx @@ -2,13 +2,14 @@ import { useCallback, useMemo } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { getIsElectronRuntime } from "@/constants/layout"; -import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Shortcut } from "@/components/ui/shortcut"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts"; const SNAP_POINTS: string[] = ["70%", "92%"]; +const SHORTCUTS_HEADER: SheetHeader = { title: "Shortcuts" }; export function KeyboardShortcutsDialog() { const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen); @@ -25,7 +26,7 @@ export function KeyboardShortcutsDialog() { return ( ({ helper: { @@ -169,7 +170,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM return ( key + 1, 0); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); const [deletingModelId, setDeletingModelId] = useState(null); @@ -136,7 +141,11 @@ function CustomModelsSection(props: { label: trimmedInput, }, ]) - .then(() => setInput("")) + .then(() => { + setInput(""); + bumpInputResetKey(); + return undefined; + }) .catch((err) => { setError(err instanceof Error ? err.message : "Failed to save model"); }) @@ -163,6 +172,8 @@ function CustomModelsSection(props: { (null); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(""); + const [queryResetKey, bumpQueryResetKey] = useReducer((key: number) => key + 1, 0); const providerLabel = resolveProviderLabel(provider, snapshotEntries); const providerEntry = useMemo( @@ -348,6 +360,7 @@ export function ProviderDiagnosticSheet({ } else { setDiagnostic(null); setQuery(""); + bumpQueryResetKey(); } }, [visible, fetchDiagnostic]); @@ -405,13 +418,17 @@ export function ProviderDiagnosticSheet({ )); } + const sheetHeader = useMemo( + () => ({ title: providerLabel, actions: headerActions }), + [providerLabel, headerActions], + ); + return ( @@ -434,6 +451,8 @@ export function ProviderDiagnosticSheet({ { const result = filterAndRankComboboxOptions(items, "202"); expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]); }); + + it("matches fuzzy character sequences after stronger substring matches", () => { + const items = [ + { id: "gpt-5.4", label: "GPT-5.4" }, + { id: "gpt-4.1", label: "GPT-4.1" }, + { id: "gemini", label: "Gemini" }, + ]; + const result = filterAndRankComboboxOptions(items, "gpt54"); + expect(result.map((o) => o.id)).toEqual(["gpt-5.4"]); + }); }); describe("combobox above-search ordering", () => { diff --git a/packages/app/src/components/ui/combobox-options.ts b/packages/app/src/components/ui/combobox-options.ts index 06cfc107f..7ec8545d5 100644 --- a/packages/app/src/components/ui/combobox-options.ts +++ b/packages/app/src/components/ui/combobox-options.ts @@ -1,4 +1,4 @@ -import { compareMatchScores, type MatchScore, scoreMatch } from "../../utils/score-match"; +import { compareMatchScores, type MatchScore, scoreTextFields } from "../../utils/score-match"; export type ComboboxOptionKind = "directory" | "file"; @@ -12,18 +12,12 @@ export interface ComboboxOptionModel { const DESCRIPTION_FALLBACK_TIER = 99; function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null { - const labelScore = scoreMatch(search, opt.label); - const idScore = scoreMatch(search, opt.id); - let best: MatchScore | null = null; - if (labelScore) best = labelScore; - if (idScore && (!best || compareMatchScores(idScore, best) < 0)) { - best = idScore; - } + const best = scoreTextFields(search, [opt.label, opt.id]); if (best) return best; - if (opt.description && opt.description.toLowerCase().includes(search)) { - return { tier: DESCRIPTION_FALLBACK_TIER, offset: 0 }; - } - return null; + if (!opt.description) return null; + const descriptionScore = scoreTextFields(search, [opt.description]); + if (!descriptionScore) return null; + return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER }; } export interface BuildVisibleComboboxOptionsInput { diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 94e180365..1a1794963 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -1,5 +1,13 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import type { ReactElement, ReactNode, Ref } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; +import type { ReactElement, ReactNode } from "react"; import { View, Text, @@ -18,7 +26,6 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { BottomSheetScrollView, BottomSheetBackdrop, - BottomSheetTextInput, BottomSheetBackgroundProps, } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; @@ -38,11 +45,17 @@ import { shouldShowCustomComboboxOption, } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options"; -import { isNative, isWeb } from "@/constants/platform"; +import { isWeb } from "@/constants/platform"; import { IsolatedBottomSheetModal, useIsolatedBottomSheetVisibility, } from "./isolated-bottom-sheet-modal"; +import { + AdaptiveTextInput, + InlineHeaderView, + SheetHeaderView, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; const IS_WEB = isWeb; @@ -69,6 +82,15 @@ export interface ComboboxProps { customValueKind?: "directory" | "file"; optionsPosition?: "below-search" | "above-search"; title?: string; + /** + * Structured header. When provided, replaces `title` + `stickyHeader` and + * is rendered via the shared SheetHeaderView (mobile) / InlineHeaderView + * (desktop). Built-in search (when `searchable=true` and no `header.search`) + * is folded into the header so its magnifying glass aligns with the title + * and any leading icon at the sheet's shared indent. + */ + header?: SheetHeader; + mobileChildrenScrollEnabled?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; desktopPlacement?: "top-start" | "bottom-start"; @@ -136,6 +158,7 @@ export interface SearchInputProps { onSubmitEditing?: () => void; autoFocus?: boolean; useBottomSheetInput?: boolean; + resetKey?: string | number; } export function SearchInput({ @@ -145,10 +168,10 @@ export function SearchInput({ onSubmitEditing, autoFocus = false, useBottomSheetInput = false, + resetKey, }: SearchInputProps): ReactElement { const { theme } = useUnistyles(); const inputRef = useRef(null); - const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput; useEffect(() => { if (autoFocus && IS_WEB && inputRef.current) { @@ -162,18 +185,35 @@ export function SearchInput({ return ( - } - // @ts-expect-error - outlineStyle is web-only - style={SEARCH_INPUT_STYLE} - placeholder={placeholder} - placeholderTextColor={theme.colors.foregroundMuted} - value={value} - onChangeText={onChangeText} - autoCapitalize="none" - autoCorrect={false} - onSubmitEditing={onSubmitEditing} - /> + {useBottomSheetInput ? ( + + ) : ( + + )} ); } @@ -593,12 +633,14 @@ function useDesktopFloatingUpdate( function useResetSearchOnOpen( isOpen: boolean, setSearchQueryWithCallback: (query: string) => void, + bumpSearchResetKey: () => void, ) { useEffect(() => { if (isOpen) { setSearchQueryWithCallback(""); + bumpSearchResetKey(); } - }, [isOpen, setSearchQueryWithCallback]); + }, [isOpen, setSearchQueryWithCallback, bumpSearchResetKey]); } interface DesktopResetSetters { @@ -919,9 +961,13 @@ interface MobileBodyProps { handleIndicatorStyle: { backgroundColor: string }; titleColor: string; title: string; + header: SheetHeader | undefined; + onClose: () => void; stickyHeader: ReactNode; searchable: boolean; hasChildren: boolean; + mobileChildrenScrollEnabled: boolean; + searchResetKey: number; searchPlaceholder: string; searchQuery: string; setSearchQueryWithCallback: (query: string) => void; @@ -981,29 +1027,40 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { keyboardBehavior="extend" keyboardBlurBehavior="restore" > - - - {props.title} - - - {props.stickyHeader} - {!props.hasChildren && props.searchable ? ( - - ) : null} - - {body} - + {props.header ? ( + + ) : ( + <> + + + {props.title} + + + {props.stickyHeader} + {!props.hasChildren && props.searchable ? ( + + ) : null} + + )} + {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( + body + ) : ( + + {body} + + )} ); } @@ -1015,6 +1072,7 @@ interface DesktopBodyProps { shouldUseDesktopFade: boolean; desktopContainerStyle: unknown; handleDesktopContentLayout: (event: LayoutChangeEvent) => void; + header: SheetHeader | undefined; stickyHeader: ReactNode; searchable: boolean; searchPlaceholder: string; @@ -1036,12 +1094,13 @@ interface DesktopBodyProps { } function DesktopComboboxChildrenBody(props: { + header: SheetHeader | undefined; stickyHeader: ReactNode; children: ReactNode; }): ReactElement { return ( <> - {props.stickyHeader} + {props.header ? : props.stickyHeader} - {props.stickyHeader} - {props.searchable ? ( + {props.header ? : props.stickyHeader} + {props.header || !props.searchable ? null : ( - ) : null} + )} {props.effectiveOptionsPosition === "above-search" ? ( {props.hasChildren ? ( - + {props.children} ) : ( (null); const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0); const [activeIndex, setActiveIndex] = useState(-1); const desktopOptionsScrollRef = useRef(null); const [desktopContentWidth, setDesktopContentWidth] = useState(null); @@ -1234,9 +1298,10 @@ export function Combobox({ const handleClose = useCallback(() => { setOpen(false); setSearchQueryWithCallback(""); + bumpSearchResetKey(); }, [setOpen, setSearchQueryWithCallback]); - useResetSearchOnOpen(isOpen, setSearchQueryWithCallback); + useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey); const collisionPadding = useMemo(computeCollisionPadding, []); @@ -1465,9 +1530,13 @@ export function Combobox({ handleIndicatorStyle={handleIndicatorStyle} titleColor={titleColor} title={title} + header={header} + onClose={handleClose} stickyHeader={stickyHeader} searchable={searchable} hasChildren={hasChildren} + mobileChildrenScrollEnabled={mobileChildrenScrollEnabled} + searchResetKey={searchResetKey} searchPlaceholder={effectiveSearchPlaceholder} searchQuery={searchQuery} setSearchQueryWithCallback={setSearchQueryWithCallback} @@ -1494,6 +1563,7 @@ export function Combobox({ shouldUseDesktopFade={shouldUseDesktopFade} desktopContainerStyle={desktopContainerStyle} handleDesktopContentLayout={handleDesktopContentLayout} + header={header} stickyHeader={stickyHeader} searchable={searchable} searchPlaceholder={effectiveSearchPlaceholder} diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index cbd751fde..f6a54a3df 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Image, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { createNameId } from "mnemonic-id"; -import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Composer } from "@/components/composer"; import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; @@ -374,14 +374,18 @@ export function WorkspaceSetupDialog() { [iconSource, placeholderInitial, workspaceTitle], ); + const sheetHeader = useMemo( + () => ({ title: "Create workspace", subtitle: subtitleContent }), + [subtitleContent], + ); + if (!pendingWorkspaceSetup || !sourceDirectory) { return null; } return ( @@ -158,7 +158,7 @@ function DaemonCliStatusModal({ @@ -512,3 +512,5 @@ const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard]; const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"]; const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"]; +const DAEMON_LOGS_HEADER: SheetHeader = { title: "Daemon logs" }; +const DAEMON_STATUS_HEADER: SheetHeader = { title: "Daemon status" }; diff --git a/packages/app/src/desktop/components/pair-device-modal.tsx b/packages/app/src/desktop/components/pair-device-modal.tsx index 548ca557f..570e48c64 100644 --- a/packages/app/src/desktop/components/pair-device-modal.tsx +++ b/packages/app/src/desktop/components/pair-device-modal.tsx @@ -1,4 +1,4 @@ -import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { PairDeviceSection } from "@/desktop/components/pair-device-section"; export interface PairDeviceModalProps { @@ -8,11 +8,12 @@ export interface PairDeviceModalProps { } const SNAP_POINTS: string[] = ["82%", "94%"]; +const PAIR_DEVICE_HEADER: SheetHeader = { title: "Pair a device" }; export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) { return ( ( + () => ({ title: script.name ? `Edit ${script.name}` : "New script" }), + [script.name], + ); return ( void; @@ -244,7 +248,7 @@ export function HostRenameButton({ host }: { host: HostProfile }) { @@ -347,7 +351,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) { {pendingRemoveConnection ? ( = new Set(IMPORTABLE_PROVIDERS); +const IMPORT_SESSION_HEADER: SheetHeader = { title: "Import session" }; const PER_PROVIDER_LIMIT = 15; const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"]; const DISABLED_ACCESSIBILITY_STATE = { disabled: true }; @@ -435,7 +436,7 @@ export function WorkspaceImportSheet({ 0 ? t[found - 1] : undefined; - const after = t[found + q.length]; + const before = found > 0 ? text[found - 1] : undefined; + const after = text[found + query.length]; const startsAtBoundary = found === 0 || isWordBoundaryChar(before); const endsAtBoundary = after === undefined || isWordBoundaryChar(after); let tier: number; @@ -41,7 +37,57 @@ export function scoreMatch(query: string, text: string): MatchScore | null { return best; } +function scoreSubsequenceMatch(query: string, text: string): MatchScore | null { + let queryIndex = 0; + let firstIndex = -1; + let lastIndex = -1; + for (let textIndex = 0; textIndex < text.length && queryIndex < query.length; textIndex += 1) { + if (text[textIndex] !== query[queryIndex]) continue; + if (firstIndex === -1) firstIndex = textIndex; + lastIndex = textIndex; + queryIndex += 1; + } + + if (queryIndex !== query.length || firstIndex === -1) return null; + return { tier: 5, offset: firstIndex, spread: lastIndex - firstIndex + 1 }; +} + +export function scoreMatch(query: string, text: string): MatchScore | null { + if (!query) return { tier: 0, offset: 0 }; + const q = query.toLowerCase(); + const t = text.toLowerCase(); + if (t === q) return { tier: 0, offset: 0 }; + + return scoreSubstringMatch(q, t) ?? scoreSubsequenceMatch(q, t); +} + export function compareMatchScores(a: MatchScore, b: MatchScore): number { if (a.tier !== b.tier) return a.tier - b.tier; - return a.offset - b.offset; + if (a.offset !== b.offset) return a.offset - b.offset; + return (a.spread ?? 0) - (b.spread ?? 0); +} + +export function scoreTextFields(query: string, fields: string[]): MatchScore | null { + const tokens = query + .trim() + .toLowerCase() + .split(/\s+/) + .filter((token) => token.length > 0); + if (tokens.length === 0) return { tier: 0, offset: 0, spread: 0 }; + + const aggregate: MatchScore = { tier: 0, offset: 0, spread: 0 }; + for (const token of tokens) { + let best: MatchScore | null = null; + for (const field of fields) { + const score = scoreMatch(token, field); + if (score && (!best || compareMatchScores(score, best) < 0)) { + best = score; + } + } + if (!best) return null; + aggregate.tier += best.tier; + aggregate.offset += best.offset; + aggregate.spread = (aggregate.spread ?? 0) + (best.spread ?? token.length); + } + return aggregate; } diff --git a/packages/website/src/routes/docs.tsx b/packages/website/src/routes/docs.tsx index 78e7a5349..77d1ccb1f 100644 --- a/packages/website/src/routes/docs.tsx +++ b/packages/website/src/routes/docs.tsx @@ -74,7 +74,7 @@ function DocsLayout() { {mobileNavOpen && ( -