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 && ( -