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.
This commit is contained in:
Mohamed Boudra
2026-05-18 14:56:17 +07:00
parent 028839b3cb
commit 29ce6653fd
23 changed files with 1058 additions and 708 deletions

View File

@@ -12,7 +12,7 @@ import {
BottomSheetTextInput, BottomSheetTextInput,
type BottomSheetBackgroundProps, type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } 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 { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { import {
@@ -21,6 +21,37 @@ import {
} from "@/components/ui/isolated-bottom-sheet-modal"; } from "@/components/ui/isolated-bottom-sheet-modal";
import { isNative, isWeb } from "@/constants/platform"; 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; type EscHandler = () => void;
const escStack: EscHandler[] = []; const escStack: EscHandler[] = [];
let escListenerAttached = false; let escListenerAttached = false;
@@ -72,23 +103,30 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1, borderWidth: 1,
borderColor: theme.colors.surface2, borderColor: theme.colors.surface2,
}, },
header: { headerContainer: {
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[4],
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "space-between",
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2, 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: { headerTitleGroup: {
flex: 1, flex: 1,
gap: theme.spacing[2], gap: theme.spacing[1],
minWidth: 0, minWidth: 0,
}, },
title: { title: {
flex: 1,
fontSize: theme.fontSize.lg, fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
}, },
@@ -96,52 +134,78 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
marginLeft: theme.spacing[3],
marginRight: theme.spacing[2],
}, },
closeButton: { closeButton: {
padding: theme.spacing[2], padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg, 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: { desktopScroll: {
flexShrink: 1, flexShrink: 1,
minHeight: 0, minHeight: 0,
}, },
desktopContent: { desktopContent: {
padding: theme.spacing[6], padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4], gap: theme.spacing[4],
flexGrow: 1, 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: { bottomSheetContent: {
padding: theme.spacing[6], padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4], gap: theme.spacing[4],
}, },
bottomSheetStaticContent: { bottomSheetStaticContent: {
flex: 1, flex: 1,
padding: theme.spacing[6], padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4], gap: theme.spacing[4],
minHeight: 0, minHeight: 0,
}, },
desktopStaticContent: { desktopStaticContent: {
flexShrink: 1, flexShrink: 1,
minHeight: 0, minHeight: 0,
padding: theme.spacing[6], padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4], gap: theme.spacing[4],
}, },
})); }));
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
function SheetBackground({ style }: BottomSheetBackgroundProps) { function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const combinedStyle = useMemo( const combinedStyle = useMemo(
@@ -158,14 +222,186 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return <View style={combinedStyle} />; return <View style={combinedStyle} />;
} }
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<TextInput, AdaptiveTextInputProps>(
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 (
<BottomSheetTextInput
key={resetKey}
ref={ref as unknown as Ref<never>}
{...textInputProps}
/>
);
}
return <TextInput key={resetKey} ref={ref} {...textInputProps} />;
},
);
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 (
<View style={styles.headerContainer} testID={testID}>
<View style={styles.headerRow}>
{handleBackPress ? (
<Pressable
onPress={handleBackPress}
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
testID="sheet-header-back"
>
{({ pressed }) => (
<ArrowLeft
size={18}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<View style={styles.headerTitleGroup}>
<Text style={titleStyle} numberOfLines={1}>
{header.title}
</Text>
{header.subtitle}
</View>
{header.actions ? <View style={styles.headerActions}>{header.actions}</View> : null}
{showCloseButton ? (
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
{({ pressed }) => (
<X
size={16}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
</View>
{search ? (
<View style={styles.searchRow}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={search.initialValue}
resetKey={search.resetKey}
value={search.value}
onChangeText={handleSearchChange}
autoCapitalize="none"
autoCorrect={false}
autoFocus={search.autoFocus}
testID={search.testID}
/>
</View>
) : null}
</View>
);
}
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 (
<View>
{hasInlineRow ? (
<View style={styles.inlineHeaderRow}>
{handleBackPress ? (
<Pressable
onPress={handleBackPress}
hitSlop={8}
style={styles.headerBackButton}
accessibilityRole="button"
accessibilityLabel={back?.accessibilityLabel ?? back?.label ?? "Back"}
testID="sheet-header-back"
>
{({ pressed }) => (
<ArrowLeft
size={16}
color={pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
) : null}
{header.leading ? <View style={styles.headerLeadingSlot}>{header.leading}</View> : null}
<Text style={styles.inlineTitle} numberOfLines={1}>
{header.title}
</Text>
</View>
) : null}
{header.search ? (
<View style={styles.inlineSearchRow}>
<Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only
style={SEARCH_INPUT_STYLE}
placeholder={header.search.placeholder ?? "Search"}
placeholderTextColor={theme.colors.foregroundMuted}
initialValue={header.search.initialValue}
resetKey={header.search.resetKey}
value={header.search.value}
onChangeText={header.search.onChange}
autoCapitalize="none"
autoCorrect={false}
autoFocus={header.search.autoFocus}
testID={header.search.testID}
/>
</View>
) : null}
</View>
);
}
export interface AdaptiveModalSheetProps { export interface AdaptiveModalSheetProps {
title: string; header: SheetHeader;
/** Optional content rendered below the title in the header area. */
subtitle?: ReactNode;
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
children: ReactNode; children: ReactNode;
headerActions?: ReactNode;
snapPoints?: string[]; snapPoints?: string[];
testID?: string; testID?: string;
/** Override the max width of the desktop card. */ /** Override the max width of the desktop card. */
@@ -176,12 +412,10 @@ export interface AdaptiveModalSheetProps {
} }
export function AdaptiveModalSheet({ export function AdaptiveModalSheet({
title, header,
subtitle,
visible, visible,
onClose, onClose,
children, children,
headerActions,
snapPoints, snapPoints,
testID, testID,
desktopMaxWidth, desktopMaxWidth,
@@ -190,7 +424,6 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) { }: AdaptiveModalSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const titleColor = theme.colors.foreground;
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]); const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const handleIndicatorStyle = useMemo( const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }), () => ({ backgroundColor: theme.colors.surface2 }),
@@ -209,7 +442,6 @@ export function AdaptiveModalSheet({
[], [],
); );
const titleStyle = useMemo(() => [styles.title, { color: titleColor }], [titleColor]);
const desktopCardStyle = useMemo( const desktopCardStyle = useMemo(
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }], () => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
[desktopMaxWidth], [desktopMaxWidth],
@@ -237,18 +469,7 @@ export function AdaptiveModalSheet({
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
accessible={false} accessible={false}
> >
<View style={styles.bottomSheetHeader} testID={testID}> <SheetHeaderView header={header} onClose={onClose} testID={testID} />
<View style={styles.headerTitleGroup}>
<Text key={titleColor} style={titleStyle} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
{scrollable ? ( {scrollable ? (
<BottomSheetScrollView <BottomSheetScrollView
contentContainerStyle={styles.bottomSheetContent} contentContainerStyle={styles.bottomSheetContent}
@@ -266,18 +487,7 @@ export function AdaptiveModalSheet({
const cardInner = ( const cardInner = (
<> <>
<View style={styles.header}> <SheetHeaderView header={header} onClose={onClose} />
<View style={styles.headerTitleGroup}>
<Text key={titleColor} style={titleStyle} numberOfLines={1}>
{title}
</Text>
{subtitle}
</View>
{headerActions ? <View style={styles.headerActions}>{headerActions}</View> : null}
<Pressable accessibilityLabel="Close" style={styles.closeButton} onPress={onClose}>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
{scrollable ? ( {scrollable ? (
<ScrollView <ScrollView
style={styles.desktopScroll} style={styles.desktopScroll}
@@ -324,19 +534,3 @@ export function AdaptiveModalSheet({
</Modal> </Modal>
); );
} }
/**
* TextInput that automatically uses BottomSheetTextInput on mobile
* for proper keyboard dodging in AdaptiveModalSheet.
*/
export const AdaptiveTextInput = forwardRef<TextInput, TextInputProps>(
function AdaptiveTextInput(props, ref) {
const isMobile = useIsCompactFormFactor();
if (isMobile && isNative) {
return <BottomSheetTextInput ref={ref as unknown as Ref<never>} {...props} />;
}
return <TextInput ref={ref} {...props} />;
},
);

View File

@@ -2,9 +2,11 @@ import { useCallback } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native"; 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"; import { isNative } from "@/constants/platform";
const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" };
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
option: { option: {
flexDirection: "row", flexDirection: "row",
@@ -62,7 +64,7 @@ export function AddHostMethodModal({
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
title="Add connection" header={ADD_CONNECTION_HEADER}
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
testID="add-host-method-modal" testID="add-host-method-modal"

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useReducer, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native"; import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
@@ -11,10 +11,11 @@ import {
serializeConnectionUriForStorage, serializeConnectionUriForStorage,
} from "@/utils/daemon-endpoints"; } from "@/utils/daemon-endpoints";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection"; import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
const DIRECT_CONNECTION_HEADER: SheetHeader = { title: "Direct connection" };
interface DirectConnectionDraft { interface DirectConnectionDraft {
host: string; host: string;
@@ -279,6 +280,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
const [isPasswordVisible, setIsPasswordVisible] = useState(false); const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [advancedUri, setAdvancedUri] = useState(""); const [advancedUri, setAdvancedUri] = useState("");
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const clearInput = useCallback(() => { const clearInput = useCallback(() => {
setHost(""); setHost("");
@@ -288,6 +290,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible(false); setIsPasswordVisible(false);
setIsAdvancedOpen(false); setIsAdvancedOpen(false);
setAdvancedUri(""); setAdvancedUri("");
bumpInputResetKey();
}, []); }, []);
const connectIcon = useMemo( const connectIcon = useMemo(
@@ -411,6 +414,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setUseTls(next.useTls); setUseTls(next.useTls);
setPassword(next.password); setPassword(next.password);
setErrorMessage(""); setErrorMessage("");
bumpInputResetKey();
} catch { } catch {
setErrorMessage(""); setErrorMessage("");
} }
@@ -422,7 +426,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
title="Direct connection" header={DIRECT_CONNECTION_HEADER}
visible={visible} visible={visible}
onClose={handleClose} onClose={handleClose}
testID="add-host-modal" testID="add-host-modal"
@@ -436,6 +440,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-host-input" testID="direct-host-input"
nativeID="direct-host-input" nativeID="direct-host-input"
accessibilityLabel="Host" accessibilityLabel="Host"
initialValue={host}
resetKey={`direct-host-${inputResetKey}`}
value={host} value={host}
onChangeText={setHost} onChangeText={setHost}
placeholder="localhost" placeholder="localhost"
@@ -454,6 +460,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-port-input" testID="direct-port-input"
nativeID="direct-port-input" nativeID="direct-port-input"
accessibilityLabel="Port" accessibilityLabel="Port"
initialValue={port}
resetKey={`direct-port-${inputResetKey}`}
value={port} value={port}
onChangeText={setPort} onChangeText={setPort}
placeholder="6767" placeholder="6767"
@@ -495,6 +503,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-password-input" testID="direct-password-input"
nativeID="direct-password-input" nativeID="direct-password-input"
accessibilityLabel="Password" accessibilityLabel="Password"
initialValue={password}
resetKey={`direct-password-${inputResetKey}`}
value={password} value={password}
onChangeText={setPassword} onChangeText={setPassword}
placeholder="Optional" placeholder="Optional"
@@ -537,6 +547,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
testID="direct-host-uri-input" testID="direct-host-uri-input"
nativeID="direct-host-uri-input" nativeID="direct-host-uri-input"
accessibilityLabel="Connection URI" accessibilityLabel="Connection URI"
initialValue={advancedUri}
resetKey={`direct-host-uri-${inputResetKey}`}
value={advancedUri} value={advancedUri}
onChangeText={setAdvancedUri} onChangeText={setAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true" placeholder="tcp://localhost:6767?ssl=true"

View File

@@ -1,9 +1,13 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useReducer, useState } from "react";
import { Alert, Pressable, Text, View } from "react-native"; import { Alert, Pressable, Text, View } from "react-native";
import { SvgXml } from "react-native-svg"; import { SvgXml } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ExternalLink, PackagePlus, Search } from "lucide-react-native"; import { ExternalLink, PackagePlus, Search } from "lucide-react-native";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
buildAcpProviderConfigPatch, buildAcpProviderConfigPatch,
@@ -26,6 +30,7 @@ type InstallState = "installed" | "available";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
const ACTION_BUTTON_STYLE = { width: 92 } as const; const ACTION_BUTTON_STYLE = { width: 92 } as const;
const MODAL_SNAP_POINTS = ["78%", "92%"]; const MODAL_SNAP_POINTS = ["78%", "92%"];
const ADD_PROVIDER_HEADER: SheetHeader = { title: "Add provider" };
const SEARCH_ICON_SIZE = 16; const SEARCH_ICON_SIZE = 16;
const PROVIDER_FALLBACK_ICON_SIZE = 20; const PROVIDER_FALLBACK_ICON_SIZE = 20;
const PROVIDER_REMOTE_ICON_SIZE = 24; const PROVIDER_REMOTE_ICON_SIZE = 24;
@@ -138,8 +143,15 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId); const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId);
const { patchConfig } = useDaemonConfig(serverId); const { patchConfig } = useDaemonConfig(serverId);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null); const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
const handleClose = useCallback(() => {
setSearch("");
bumpSearchResetKey();
onClose();
}, [onClose]);
const installedProviderIds = useMemo( const installedProviderIds = useMemo(
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []), () => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
[providerEntries], [providerEntries],
@@ -157,7 +169,7 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
try { try {
await patchConfig(buildAcpProviderConfigPatch(entry)); await patchConfig(buildAcpProviderConfigPatch(entry));
await refresh([entry.id]); await refresh([entry.id]);
onClose(); handleClose();
} catch (installError) { } catch (installError) {
Alert.alert( Alert.alert(
"Unable to install provider", "Unable to install provider",
@@ -167,14 +179,14 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
setInstallingProviderId((current) => (current === entry.id ? null : current)); setInstallingProviderId((current) => (current === entry.id ? null : current));
} }
}, },
[installingProviderId, onClose, patchConfig, refresh], [installingProviderId, handleClose, patchConfig, refresh],
); );
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
title="Add provider" header={ADD_PROVIDER_HEADER}
visible={visible} visible={visible}
onClose={onClose} onClose={handleClose}
desktopMaxWidth={680} desktopMaxWidth={680}
snapPoints={MODAL_SNAP_POINTS} snapPoints={MODAL_SNAP_POINTS}
testID="add-provider-modal" testID="add-provider-modal"
@@ -186,6 +198,8 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
<AdaptiveTextInput <AdaptiveTextInput
testID="provider-catalog-search" testID="provider-catalog-search"
accessibilityLabel="Search providers" accessibilityLabel="Search providers"
initialValue={search}
resetKey={`provider-catalog-search-${searchResetKey}`}
value={search} value={search}
onChangeText={setSearch} onChangeText={setSearch}
placeholder="Search providers" placeholder="Search providers"
@@ -216,7 +230,7 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
) : null} ) : null}
<View style={styles.actions}> <View style={styles.actions}>
<Button style={FLEX_ONE_STYLE} variant="secondary" onPress={onClose}> <Button style={FLEX_ONE_STYLE} variant="secondary" onPress={handleClose}>
Cancel Cancel
</Button> </Button>
</View> </View>

View File

@@ -16,7 +16,7 @@ import {
type StyleProp, type StyleProp,
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { import {
@@ -48,7 +48,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { import type {
AgentFeature, AgentFeature,
@@ -58,14 +58,13 @@ import type {
} from "@server/server/agent/agent-sdk-types"; } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { getModeVisuals, type AgentModeColorTier } from "@server/server/agent/provider-manifest"; import { getModeVisuals, type AgentModeColorTier } from "@server/server/agent/provider-manifest";
import type { Theme } from "@/styles/theme";
import { import {
getFeatureHighlightColor, getFeatureHighlightColor,
getFeatureTooltip, getFeatureTooltip,
getStatusSelectorHint, getStatusSelectorHint,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/components/agent-status-bar.utils"; } 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 { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
@@ -184,21 +183,6 @@ const MODE_ICONS = {
ShieldOff, ShieldOff,
ShieldQuestionMark, ShieldQuestionMark,
} as const; } 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: <ThemedShieldCheck size={16} uniProps={foregroundColorMapping} />,
ShieldAlert: <ThemedShieldAlert size={16} uniProps={foregroundColorMapping} />,
ShieldOff: <ThemedShieldOff size={16} uniProps={foregroundColorMapping} />,
ShieldQuestionMark: <ThemedShieldQuestionMark size={16} uniProps={foregroundColorMapping} />,
} as const;
function alwaysTrue() { function alwaysTrue() {
return true; return true;
@@ -215,6 +199,16 @@ function resolveDisplayModel(
return findOptionLabel(modelOptions, selectedModelId, "Select model"); 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({ function resolveHasAnyControl({
providerOptions, providerOptions,
modeOptions, 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({ function pickSheetModel({
nextProviderId, nextProviderId,
modelId, modelId,
@@ -436,27 +418,6 @@ function buildOpenChangeHandler(
}; };
} }
function SheetModelTriggerView({
selectedModelLabel,
style,
ProviderIcon,
}: {
selectedModelLabel: string;
style: StyleProp<ViewStyle>;
ProviderIcon: ReturnType<typeof getProviderIcon> | null;
}) {
const { theme } = useUnistyles();
return (
<View style={style} pointerEvents="none" testID="agent-preferences-model">
{ProviderIcon ? (
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
) : null}
<Text style={styles.sheetSelectText}>{selectedModelLabel}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
);
}
function getModeIconColor( function getModeIconColor(
colorTier: AgentModeColorTier | undefined, colorTier: AgentModeColorTier | undefined,
palette: { palette: {
@@ -508,7 +469,8 @@ function ControlledStatusBar({
onModelSelectorOpen, onModelSelectorOpen,
}: ControlledAgentStatusBarProps) { }: ControlledAgentStatusBarProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [prefsOpen, setPrefsOpen] = useState(false); const isCompact = useIsCompactFormFactor();
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null); const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
const providerAnchorRef = useRef<View>(null); const providerAnchorRef = useRef<View>(null);
@@ -526,7 +488,6 @@ function ControlledStatusBar({
); );
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider"); const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default");
const displayModel = resolveDisplayModel(isModelLoading, modelOptions, selectedModelId); const displayModel = resolveDisplayModel(isModelLoading, modelOptions, selectedModelId);
const displayThinking = findOptionLabel( const displayThinking = findOptionLabel(
thinkingOptions, thinkingOptions,
@@ -586,6 +547,18 @@ function ControlledStatusBar({
), ),
[provider, providerDefinitions, theme.colors.foreground], [provider, providerDefinitions, theme.colors.foreground],
); );
const renderThinkingOption = useCallback(
(args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => (
<ThinkingComboboxOption
option={args.option}
selected={args.selected}
active={args.active}
onPress={args.onPress}
iconColor={theme.colors.foreground}
/>
),
[theme.colors.foreground],
);
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(selector: StatusSelector) => (selector: StatusSelector) =>
@@ -659,16 +632,30 @@ function ControlledStatusBar({
[canSelectMode, disabled, openSelector], [canSelectMode, disabled, openSelector],
); );
const handleOpenPrefs = useCallback(() => { const handleOpenSheet = useCallback((sheet: Exclude<ActiveSheet, null>) => {
Keyboard.dismiss(); Keyboard.dismiss();
setPrefsOpen(true); setActiveSheet(sheet);
}, []); }, []);
const handleClosePrefs = useCallback(() => { const handleCloseSheet = useCallback(() => {
setPrefsOpen(false); 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( const handleSheetModelSelect = useCallback(
(nextProviderId: string, modelId: string) => { (nextProviderId: string, modelId: string) => {
@@ -684,38 +671,13 @@ function ControlledStatusBar({
[onSelectModel, onSelectProvider, onSelectProviderAndModel, provider], [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 }) => (
<SheetModelTriggerView
selectedModelLabel={selectedModelLabel}
style={sheetSelectStyle}
ProviderIcon={ProviderIcon}
/>
),
[ProviderIcon, sheetSelectStyle],
);
if (!hasAnyControl) { if (!hasAnyControl) {
return null; return null;
} }
return ( return (
<View style={styles.container}> <View style={styles.container}>
{platformIsWeb ? ( {!isCompact ? (
<DesktopStatusBarContent <DesktopStatusBarContent
provider={provider} provider={provider}
providerOptions={providerOptions} providerOptions={providerOptions}
@@ -770,23 +732,19 @@ function ControlledStatusBar({
handleModeOpenChange={handleModeOpenChange} handleModeOpenChange={handleModeOpenChange}
handleOpenChange={handleOpenChange} handleOpenChange={handleOpenChange}
renderModeOption={renderModeOption} renderModeOption={renderModeOption}
renderThinkingOption={renderThinkingOption}
/> />
) : ( ) : (
<SheetStatusBarContent <SheetStatusBarContent
provider={provider} provider={provider}
modeOptions={modeOptions}
selectedModeId={selectedModeId} selectedModeId={selectedModeId}
selectedModelId={selectedModelId} selectedModelId={selectedModelId}
thinkingOptions={thinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId} selectedThinkingOptionId={selectedThinkingOptionId}
features={features} features={features}
onSetFeature={onSetFeature} onSetFeature={onSetFeature}
onSelectMode={onSelectMode}
onSelectThinkingOption={onSelectThinkingOption}
onToggleFavoriteModel={onToggleFavoriteModel} onToggleFavoriteModel={onToggleFavoriteModel}
onDropdownClose={onDropdownClose} onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen} onModelSelectorOpen={onModelSelectorOpen}
providerDefinitions={providerDefinitions}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
disabled={disabled} disabled={disabled}
isModelLoading={isModelLoading} isModelLoading={isModelLoading}
@@ -797,24 +755,21 @@ function ControlledStatusBar({
modelDisabled={modelDisabled} modelDisabled={modelDisabled}
effectiveProviderDefinitions={effectiveProviderDefinitions} effectiveProviderDefinitions={effectiveProviderDefinitions}
effectiveAllProviderModels={effectiveAllProviderModels} effectiveAllProviderModels={effectiveAllProviderModels}
displayMode={displayMode} comboboxModeOptions={comboboxModeOptions}
displayModel={displayModel} comboboxThinkingOptions={comboboxThinkingOptions}
displayThinking={displayThinking}
ModeIconComponent={ModeIconComponent} ModeIconComponent={ModeIconComponent}
modeIconColor={modeIconColor} modeIconColor={modeIconColor}
openSelector={openSelector} openSelector={openSelector}
ProviderIcon={ProviderIcon} ProviderIcon={ProviderIcon}
prefsOpen={prefsOpen} activeSheet={activeSheet}
handleOpenPrefs={handleOpenPrefs} handleOpenSheet={handleOpenSheet}
handleClosePrefs={handleClosePrefs} handleCloseSheet={handleCloseSheet}
prefsButtonStyle={prefsButtonStyle}
sheetThinkingPressableStyle={sheetThinkingPressableStyle}
sheetModePressableStyle={sheetModePressableStyle}
handleSheetModelSelect={handleSheetModelSelect} handleSheetModelSelect={handleSheetModelSelect}
handleThinkingOpenChange={handleThinkingOpenChange} handleSelectThinkingAndClose={handleSelectThinkingAndClose}
handleModeOpenChange={handleModeOpenChange} handleSelectModeAndClose={handleSelectModeAndClose}
handleOpenChange={handleOpenChange} handleOpenChange={handleOpenChange}
renderSheetModelTrigger={renderSheetModelTrigger} renderModeOption={renderModeOption}
renderThinkingOption={renderThinkingOption}
/> />
)} )}
</View> </View>
@@ -880,6 +835,12 @@ interface DesktopStatusBarContentProps {
active: boolean; active: boolean;
onPress: () => void; onPress: () => void;
}) => ReactElement; }) => ReactElement;
renderThinkingOption: (args: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => ReactElement;
} }
const DESKTOP_SEARCH_THRESHOLD = 6; const DESKTOP_SEARCH_THRESHOLD = 6;
@@ -938,6 +899,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
handleModeOpenChange, handleModeOpenChange,
handleOpenChange, handleOpenChange,
renderModeOption, renderModeOption,
renderThinkingOption,
} = props; } = props;
return ( return (
@@ -1033,6 +995,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
onOpenChange={handleThinkingOpenChange} onOpenChange={handleThinkingOpenChange}
anchorRef={thinkingAnchorRef} anchorRef={thinkingAnchorRef}
desktopPlacement="top-start" desktopPlacement="top-start"
renderOption={renderThinkingOption}
/> />
</> </>
) : null} ) : null}
@@ -1092,19 +1055,14 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
interface SheetStatusBarContentProps { interface SheetStatusBarContentProps {
provider: string; provider: string;
modeOptions?: StatusOption[];
selectedModeId?: string; selectedModeId?: string;
selectedModelId?: string; selectedModelId?: string;
thinkingOptions?: StatusOption[];
selectedThinkingOptionId?: string; selectedThinkingOptionId?: string;
features?: AgentFeature[]; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
onSelectMode?: (modeId: string) => void;
onSelectThinkingOption?: (thinkingOptionId: string) => void;
onToggleFavoriteModel?: (provider: string, modelId: string) => void; onToggleFavoriteModel?: (provider: string, modelId: string) => void;
onDropdownClose?: () => void; onDropdownClose?: () => void;
onModelSelectorOpen?: () => void; onModelSelectorOpen?: () => void;
providerDefinitions: AgentProviderDefinition[];
favoriteKeys: Set<string>; favoriteKeys: Set<string>;
disabled: boolean; disabled: boolean;
isModelLoading: boolean; isModelLoading: boolean;
@@ -1115,43 +1073,45 @@ interface SheetStatusBarContentProps {
modelDisabled: boolean; modelDisabled: boolean;
effectiveProviderDefinitions: AgentProviderDefinition[]; effectiveProviderDefinitions: AgentProviderDefinition[];
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>; effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
displayMode: string; comboboxModeOptions: ComboboxOption[];
displayModel: string; comboboxThinkingOptions: ComboboxOption[];
displayThinking: string;
ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null; ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null;
modeIconColor: string; modeIconColor: string;
openSelector: StatusSelector | null; openSelector: StatusSelector | null;
ProviderIcon: ReturnType<typeof getProviderIcon> | null; ProviderIcon: ReturnType<typeof getProviderIcon> | null;
prefsOpen: boolean; activeSheet: ActiveSheet;
handleOpenPrefs: () => void; handleOpenSheet: (sheet: Exclude<ActiveSheet, null>) => void;
handleClosePrefs: () => void; handleCloseSheet: () => void;
prefsButtonStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
sheetThinkingPressableStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
sheetModePressableStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
handleSheetModelSelect: (providerId: string, modelId: string) => void; handleSheetModelSelect: (providerId: string, modelId: string) => void;
handleThinkingOpenChange: (open: boolean) => void; handleSelectThinkingAndClose: (thinkingOptionId: string) => void;
handleModeOpenChange: (open: boolean) => void; handleSelectModeAndClose: (modeId: string) => void;
handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => 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) { function SheetStatusBarContent(props: SheetStatusBarContentProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { const {
provider, provider,
modeOptions,
selectedModeId, selectedModeId,
selectedModelId, selectedModelId,
thinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
features, features,
onSetFeature, onSetFeature,
onSelectMode,
onSelectThinkingOption,
onToggleFavoriteModel, onToggleFavoriteModel,
onDropdownClose, onDropdownClose,
onModelSelectorOpen, onModelSelectorOpen,
providerDefinitions,
favoriteKeys, favoriteKeys,
disabled, disabled,
isModelLoading, isModelLoading,
@@ -1162,133 +1122,194 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
modelDisabled, modelDisabled,
effectiveProviderDefinitions, effectiveProviderDefinitions,
effectiveAllProviderModels, effectiveAllProviderModels,
displayMode, comboboxModeOptions,
displayModel, comboboxThinkingOptions,
displayThinking,
ModeIconComponent, ModeIconComponent,
modeIconColor, modeIconColor,
openSelector, openSelector,
ProviderIcon, ProviderIcon,
prefsOpen, activeSheet,
handleOpenPrefs, handleOpenSheet,
handleClosePrefs, handleCloseSheet,
prefsButtonStyle,
sheetThinkingPressableStyle,
sheetModePressableStyle,
handleSheetModelSelect, handleSheetModelSelect,
handleThinkingOpenChange, handleSelectThinkingAndClose,
handleModeOpenChange, handleSelectModeAndClose,
handleOpenChange, handleOpenChange,
renderSheetModelTrigger, renderModeOption,
renderThinkingOption,
} = props; } = props;
return ( const thinkingAnchorRef = useRef<View | null>(null);
<> const modeAnchorRef = useRef<View | null>(null);
<Pressable
onPress={handleOpenPrefs} const hasThinking = comboboxThinkingOptions.length > 0;
style={prefsButtonStyle} const hasMode = Boolean(canSelectMode && comboboxModeOptions.length > 0);
accessibilityRole="button" const hasFeatures = Boolean(features && features.length > 0);
accessibilityLabel="Agent preferences"
testID="agent-preferences-button" 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;
}) => (
<View pointerEvents="none" style={styles.prefsButton} testID="agent-status-bar-model">
{ProviderIcon ? ( {ProviderIcon ? (
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} /> <ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
) : null} ) : null}
<Text style={styles.prefsButtonText} numberOfLines={1}> <Text style={styles.prefsButtonText} numberOfLines={1}>
{displayModel} {shortModelLabel(selectedModelLabel)}
</Text> </Text>
</Pressable> </View>
),
[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 ? (
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={handleSheetModelSelect}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
renderTrigger={renderModelTrigger}
/>
) : null}
{hasThinking ? (
<Pressable
ref={thinkingAnchorRef}
onPress={handleOpenThinking}
disabled={disabled || !canSelectThinking}
style={thinkingButtonStyle}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-status-bar-thinking"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
{hasMode ? (
<Pressable
ref={modeAnchorRef}
onPress={handleOpenMode}
disabled={disabled || !canSelectMode}
style={modeButtonStyle}
accessibilityRole="button"
accessibilityLabel={`Select agent mode (${selectedModeId ?? ""})`}
testID="agent-preferences-mode"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : (
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</Pressable>
) : null}
{hasFeatures ? (
<Pressable
onPress={handleOpenFeatures}
disabled={disabled}
style={featuresButtonStyle}
accessibilityRole="button"
accessibilityLabel="Open agent features"
testID="agent-status-bar-features"
>
<Settings2 size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
{hasThinking ? (
<Combobox
options={comboboxThinkingOptions}
value={selectedThinkingOptionId ?? ""}
onSelect={handleSelectThinkingAndClose}
searchable={false}
title="Thinking"
open={activeSheet === "thinking"}
onOpenChange={handleThinkingSheetOpenChange}
anchorRef={thinkingAnchorRef}
renderOption={renderThinkingOption}
/>
) : null}
{hasMode ? (
<Combobox
options={comboboxModeOptions}
value={selectedModeId ?? ""}
onSelect={handleSelectModeAndClose}
searchable={false}
title="Mode"
open={activeSheet === "mode"}
onOpenChange={handleModeSheetOpenChange}
renderOption={renderModeOption}
anchorRef={modeAnchorRef}
/>
) : null}
<AdaptiveModalSheet <AdaptiveModalSheet
title="Preferences" header={FEATURES_SHEET_HEADER}
visible={prefsOpen} visible={activeSheet === "features"}
onClose={handleClosePrefs} onClose={handleCloseSheet}
testID="agent-preferences-sheet" testID="agent-features-sheet"
> >
{canSelectModel ? ( {(features ?? []).map((feature) => (
<View style={styles.sheetSection}>
<CombinedModelSelector
providerDefinitions={effectiveProviderDefinitions}
allProviderModels={effectiveAllProviderModels}
selectedProvider={provider}
selectedModel={selectedModelId ?? ""}
canSelectProvider={canSelectProviderInModelMenu}
onSelect={handleSheetModelSelect}
favoriteKeys={favoriteKeys}
onToggleFavorite={onToggleFavoriteModel}
isLoading={isModelLoading}
disabled={modelDisabled}
onOpen={onModelSelectorOpen}
onClose={onDropdownClose}
renderTrigger={renderSheetModelTrigger}
/>
</View>
) : null}
{thinkingOptions && thinkingOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu
open={openSelector === "thinking"}
onOpenChange={handleThinkingOpenChange}
>
<DropdownMenuTrigger
disabled={disabled || !canSelectThinking}
style={sheetThinkingPressableStyle}
accessibilityRole="button"
accessibilityLabel="Select thinking option"
testID="agent-preferences-thinking"
>
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.sheetSelectText}>{displayThinking}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{thinkingOptions.map((thinking) => (
<ThinkingMenuItem
key={thinking.id}
thinking={thinking}
selected={thinking.id === selectedThinkingOptionId}
onSelectThinkingOption={onSelectThinkingOption}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{modeOptions && modeOptions.length > 0 ? (
<View style={styles.sheetSection}>
<DropdownMenu open={openSelector === "mode"} onOpenChange={handleModeOpenChange}>
<DropdownMenuTrigger
disabled={disabled || !canSelectMode}
style={sheetModePressableStyle}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
testID="agent-preferences-mode"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : null}
<Text style={styles.sheetSelectText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => (
<ModeMenuItem
key={mode.id}
mode={mode}
provider={provider}
providerDefinitions={providerDefinitions}
selected={mode.id === selectedModeId}
onSelectMode={onSelectMode}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</View>
) : null}
{features?.map((feature) => (
<SheetFeatureItem <SheetFeatureItem
key={`feature-${feature.id}`} key={`feature-${feature.id}`}
feature={feature} feature={feature}
@@ -1555,23 +1576,28 @@ function FeatureOptionMenuItem({
); );
} }
function ThinkingMenuItem({ function ThinkingComboboxOption({
thinking, option,
selected, selected,
onSelectThinkingOption, active,
onPress,
iconColor,
}: { }: {
thinking: StatusOption; option: ComboboxOption;
selected: boolean; selected: boolean;
onSelectThinkingOption?: (thinkingOptionId: string) => void; active: boolean;
onPress: () => void;
iconColor: string;
}) { }) {
const handleSelect = useCallback(() => { const leadingSlot = useMemo(() => <Brain size={16} color={iconColor} />, [iconColor]);
onSelectThinkingOption?.(thinking.id);
}, [onSelectThinkingOption, thinking.id]);
return ( return (
<DropdownMenuItem selected={selected} onSelect={handleSelect}> <ComboboxItem
{thinking.label} label={option.label}
</DropdownMenuItem> selected={selected}
active={active}
onPress={onPress}
leadingSlot={leadingSlot}
/>
); );
} }
@@ -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 (
<DropdownMenuItem selected={selected} onSelect={handleSelect} leading={leadingIcon}>
{mode.label}
</DropdownMenuItem>
);
}
const EMPTY_MODES: AgentMode[] = []; const EMPTY_MODES: AgentMode[] = [];
const FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" };
export const AgentStatusBar = memo(function AgentStatusBar({ export const AgentStatusBar = memo(function AgentStatusBar({
agentId, agentId,
@@ -1884,6 +1885,7 @@ export function DraftAgentStatusBar({
disabled = false, disabled = false,
}: DraftAgentStatusBarProps) { }: DraftAgentStatusBarProps) {
const { preferences, updatePreferences } = useFormPreferences(); const { preferences, updatePreferences } = useFormPreferences();
const isCompact = useIsCompactFormFactor();
const mappedModeOptions = useMemo<StatusOption[]>(() => { const mappedModeOptions = useMemo<StatusOption[]>(() => {
if (modeOptions.length === 0) { if (modeOptions.length === 0) {
@@ -1931,7 +1933,7 @@ export function DraftAgentStatusBar({
[updatePreferences], [updatePreferences],
); );
if (platformIsWeb) { if (!isCompact) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<CombinedModelSelector <CombinedModelSelector
@@ -2045,9 +2047,6 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius["2xl"], borderRadius: theme.borderRadius["2xl"],
}, },
prefsButtonPressed: {
backgroundColor: theme.colors.surface0,
},
prefsButtonText: { prefsButtonText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,

View File

@@ -3,6 +3,7 @@ import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"
import { import {
buildModelRows, buildModelRows,
buildSelectedTriggerLabel, buildSelectedTriggerLabel,
filterAndRankModelRows,
matchesSearch, matchesSearch,
resolveProviderLabel, resolveProviderLabel,
} from "./combined-model-selector.utils"; } from "./combined-model-selector.utils";
@@ -83,6 +84,34 @@ describe("combined model selector helpers", () => {
expect(matchesSearch(row, "kimi gemini")).toBe(false); 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", () => { it("keeps the selected trigger label model-only", () => {
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex"); expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4"); expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");

View File

@@ -1,21 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { Ref } from "react";
import { import {
View, View,
Text, Text,
TextInput,
Pressable, Pressable,
ActivityIndicator, ActivityIndicator,
type GestureResponderEvent, type GestureResponderEvent,
type PressableStateCallbackType, type PressableStateCallbackType,
} from "react-native"; } from "react-native";
import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb as platformIsWeb } from "@/constants/platform"; 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 { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
const IS_WEB = platformIsWeb; const IS_WEB = platformIsWeb;
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
@@ -45,19 +44,11 @@ function drillDownRowStyle({
pressed && styles.drillDownRowPressed, 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 { getProviderIcon } from "@/components/provider-icons";
import { import {
buildModelRows, buildModelRows,
buildSelectedTriggerLabel, buildSelectedTriggerLabel,
matchesSearch, filterAndRankModelRows,
resolveProviderLabel, resolveProviderLabel,
type SelectorModelRow, type SelectorModelRow,
} from "./combined-model-selector.utils"; } from "./combined-model-selector.utils";
@@ -97,7 +88,6 @@ interface SelectorContentProps {
selectedProvider: string; selectedProvider: string;
selectedModel: string; selectedModel: string;
searchQuery: string; searchQuery: string;
onSearchChange: (query: string) => void;
favoriteKeys: Set<string>; favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void; onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean; canSelectProvider: (provider: string) => boolean;
@@ -116,24 +106,6 @@ function normalizeSearchQuery(value: string): string {
return value.trim().toLowerCase(); return value.trim().toLowerCase();
} }
function partitionRows(
rows: SelectorModelRow[],
favoriteKeys: Set<string>,
): { 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( function sortFavoritesFirst(
rows: SelectorModelRow[], rows: SelectorModelRow[],
favoriteKeys: Set<string>, favoriteKeys: Set<string>,
@@ -375,55 +347,23 @@ function GroupProviderButton({
function GroupedProviderRows({ function GroupedProviderRows({
groupedRows, groupedRows,
selectedProvider,
selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
onDrillDown, onDrillDown,
viewKind,
}: { }: {
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>; groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
selectedProvider: string;
selectedModel: string;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
onDrillDown: (providerId: string, providerLabel: string) => void; onDrillDown: (providerId: string, providerLabel: string) => void;
viewKind: SelectorView["kind"];
}) { }) {
return ( return (
<View> <View>
{groupedRows.map((group, index) => { {groupedRows.map((group, index) => {
const isInline = viewKind === "provider";
return ( return (
<View key={group.providerId}> <View key={group.providerId}>
{index > 0 ? <View style={styles.separator} /> : null} {index > 0 ? <View style={styles.separator} /> : null}
{isInline ? ( <GroupProviderButton
<> providerId={group.providerId}
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => ( providerLabel={group.providerLabel}
<SelectableModelRow rowCount={group.rows.length}
key={row.favoriteKey} onDrillDown={onDrillDown}
row={row} />
isSelected={row.provider === selectedProvider && row.modelId === selectedModel}
isFavorite={favoriteKeys.has(row.favoriteKey)}
disabled={!canSelectProvider(row.provider)}
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
/>
))}
</>
) : (
<GroupProviderButton
providerId={group.providerId}
providerLabel={group.providerLabel}
rowCount={group.rows.length}
onDrillDown={onDrillDown}
/>
)}
</View> </View>
); );
})} })}
@@ -431,47 +371,65 @@ function GroupedProviderRows({
); );
} }
function ProviderSearchInput({ function ProviderModelRows({
value, rows,
onChangeText, selectedProvider,
autoFocus = false, selectedModel,
favoriteKeys,
onSelect,
canSelectProvider,
onToggleFavorite,
normalizedQuery,
}: { }: {
value: string; rows: SelectorModelRow[];
onChangeText: (text: string) => void; selectedProvider: string;
autoFocus?: boolean; selectedModel: string;
favoriteKeys: Set<string>;
onSelect: (provider: string, modelId: string) => void;
canSelectProvider: (provider: string) => boolean;
onToggleFavorite?: (provider: string, modelId: string) => void;
normalizedQuery: string;
}) { }) {
const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput; const useVirtualizedList = isMobile && isNative;
const displayRows = useMemo(
useEffect(() => { () => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)),
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {}; [favoriteKeys, normalizedQuery, rows],
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => clearTimeout(timer);
}, [autoFocus]);
const inputStyle = useMemo(
() => [styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }],
[],
); );
const renderItem = useCallback(
({ item }: { item: SelectorModelRow }) => (
<SelectableModelRow
row={item}
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
isFavorite={favoriteKeys.has(item.favoriteKey)}
disabled={!canSelectProvider(item.provider)}
onSelect={onSelect}
onToggleFavorite={onToggleFavorite}
/>
),
[canSelectProvider, favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
);
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
if (useVirtualizedList) {
return (
<BottomSheetFlatList
data={displayRows}
renderItem={renderItem}
keyExtractor={keyExtractor}
style={styles.virtualizedModelList}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.virtualizedModelListContent}
/>
);
}
return ( return (
<View style={styles.providerSearchContainer}> <View>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} /> {displayRows.map((row) => (
<InputComponent <View key={row.favoriteKey}>{renderItem({ item: row })}</View>
ref={inputRef as unknown as Ref<never>} ))}
// @ts-expect-error - outlineStyle is web-only
style={inputStyle}
placeholder="Search models..."
placeholderTextColor={theme.colors.foregroundMuted}
value={value}
onChangeText={onChangeText}
autoCapitalize="none"
autoCorrect={false}
/>
</View> </View>
); );
} }
@@ -483,7 +441,6 @@ function SelectorContent({
selectedProvider, selectedProvider,
selectedModel, selectedModel,
searchQuery, searchQuery,
onSearchChange: _onSearchChange,
favoriteKeys, favoriteKeys,
onSelect, onSelect,
canSelectProvider, canSelectProvider,
@@ -506,92 +463,61 @@ function SelectorContent({
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]); const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
const visibleRows = useMemo( const visibleRows = useMemo(
() => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)), () => filterAndRankModelRows(scopedRows, normalizedQuery),
[normalizedQuery, scopedRows], [normalizedQuery, scopedRows],
); );
const { favoriteRows, regularRows: _regularRows } = useMemo( const favoriteRows = useMemo(
() => partitionRows(visibleRows, favoriteKeys), () => visibleRows.filter((row) => favoriteKeys.has(row.favoriteKey)),
[favoriteKeys, visibleRows], [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]); const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0;
// When searching at Level 1, filter grouped rows to only providers whose name or models match const emptyState = (
const filteredGroupedRows = useMemo(() => { <View style={styles.emptyState}>
if (view.kind === "provider" || !normalizedQuery) { <Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
return allGroupedRows; <Text style={styles.emptyStateText}>No models match your search</Text>
}
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>
{view.kind === "all" ? (
<FavoritesSection
favoriteRows={favoriteRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
) : null}
{filteredGroupedRows.length > 0 ? (
<GroupedProviderRows
groupedRows={filteredGroupedRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
onDrillDown={onDrillDown}
viewKind={view.kind}
/>
) : null}
{!hasResults ? (
<View style={styles.emptyState}>
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.emptyStateText}>No models match your search</Text>
</View>
) : null}
</View> </View>
); );
}
function ProviderBackButton({ if (view.kind === "provider") {
providerId, if (visibleRows.length === 0) {
providerLabel, return emptyState;
onBack, }
}: {
providerId: string;
providerLabel: string;
onBack?: () => void;
}) {
const { theme } = useUnistyles();
const ProviderIcon = getProviderIcon(providerId);
if (!onBack) { return (
return null; <ProviderModelRows
rows={visibleRows}
selectedProvider={selectedProvider}
selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
normalizedQuery={normalizedQuery}
/>
);
} }
return ( return (
<Pressable onPress={onBack} style={backButtonStyle}> <View>
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <FavoritesSection
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> favoriteRows={favoriteRows}
<Text style={styles.backButtonText}>{providerLabel}</Text> selectedProvider={selectedProvider}
</Pressable> selectedModel={selectedModel}
favoriteKeys={favoriteKeys}
onSelect={onSelect}
canSelectProvider={canSelectProvider}
onToggleFavorite={onToggleFavorite}
/>
{allGroupedRows.length > 0 ? (
<GroupedProviderRows groupedRows={allGroupedRows} onDrillDown={onDrillDown} />
) : null}
{!hasResults ? emptyState : null}
</View>
); );
} }
@@ -616,6 +542,7 @@ export function CombinedModelSelector({
const [isContentReady, setIsContentReady] = useState(platformIsWeb); const [isContentReady, setIsContentReady] = useState(platformIsWeb);
const [view, setView] = useState<SelectorView>({ kind: "all" }); const [view, setView] = useState<SelectorView>({ kind: "all" });
const [searchQuery, setSearchQuery] = useState(""); 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 // Single-provider mode: only one provider with models → skip Level 1 entirely
const singleProviderView = useMemo<SelectorView | null>(() => { const singleProviderView = useMemo<SelectorView | null>(() => {
@@ -646,6 +573,7 @@ export function CombinedModelSelector({
onOpen?.(); onOpen?.();
} else { } else {
setSearchQuery(""); setSearchQuery("");
bumpSearchResetKey();
onClose?.(); onClose?.();
} }
}, },
@@ -657,6 +585,7 @@ export function CombinedModelSelector({
onSelect(provider, modelId); onSelect(provider, modelId);
setIsOpen(false); setIsOpen(false);
setSearchQuery(""); setSearchQuery("");
bumpSearchResetKey();
}, },
[onSelect], [onSelect],
); );
@@ -731,32 +660,48 @@ export function CombinedModelSelector({
const handleBackToAll = useCallback(() => { const handleBackToAll = useCallback(() => {
setView({ kind: "all" }); setView({ kind: "all" });
setSearchQuery(""); setSearchQuery("");
bumpSearchResetKey();
}, []); }, []);
const handleDrillDown = useCallback((providerId: string, providerLabel: string) => { const handleDrillDown = useCallback((providerId: string, providerLabel: string) => {
setView({ kind: "provider", providerId, providerLabel }); setView({ kind: "provider", providerId, providerLabel });
}, []); }, []);
const stickyHeader = useMemo( const handleSearchQueryChange = useCallback((value: string) => {
() => setSearchQuery(value);
view.kind === "provider" ? ( }, []);
<View style={styles.level2Header}>
{!singleProviderView ? ( const sheetHeader = useMemo<SheetHeader>(() => {
<ProviderBackButton if (view.kind === "all") {
providerId={view.providerId} return { title: "Select provider" };
providerLabel={view.providerLabel} }
onBack={handleBackToAll} const ProviderIconForView = getProviderIcon(view.providerId);
/> return {
) : null} title: view.providerLabel,
<ProviderSearchInput leading: ProviderIconForView ? (
value={searchQuery} <ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
onChangeText={setSearchQuery}
autoFocus={platformIsWeb}
/>
</View>
) : undefined, ) : 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 ( return (
<> <>
@@ -799,8 +744,8 @@ export function CombinedModelSelector({
desktopPlacement="top-start" desktopPlacement="top-start"
desktopMinWidth={360} desktopMinWidth={360}
desktopFixedHeight={desktopFixedHeight} desktopFixedHeight={desktopFixedHeight}
title="Select model" header={sheetHeader}
stickyHeader={stickyHeader} mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
> >
{isContentReady ? ( {isContentReady ? (
<SelectorContent <SelectorContent
@@ -810,7 +755,6 @@ export function CombinedModelSelector({
selectedProvider={selectedProvider} selectedProvider={selectedProvider}
selectedModel={selectedModel} selectedModel={selectedModel}
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery}
favoriteKeys={favoriteKeys} favoriteKeys={favoriteKeys}
onSelect={handleSelect} onSelect={handleSelect}
canSelectProvider={canSelectProvider} canSelectProvider={canSelectProvider}
@@ -913,27 +857,6 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted, 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: { emptyState: {
paddingVertical: theme.spacing[4], paddingVertical: theme.spacing[4],
alignItems: "center", alignItems: "center",
@@ -943,6 +866,14 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
virtualizedModelList: {
flex: 1,
},
virtualizedModelListContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[8],
},
favoriteButton: { favoriteButton: {
width: 24, width: 24,
height: 24, height: 24,
@@ -966,17 +897,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, 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,
},
})); }));

View File

@@ -1,6 +1,7 @@
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest"; import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences"; import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
export type SelectorModelRow = FavoriteModelRow; export type SelectorModelRow = FavoriteModelRow;
@@ -44,14 +45,33 @@ export function buildModelRows(
} }
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean { export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
if (!normalizedQuery) { return scoreModelRow(row, normalizedQuery) !== null;
return true; }
}
function getModelRowSearchFields(row: SelectorModelRow): string[] {
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""] return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
.join(" ") }
.toLowerCase();
export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) {
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0); return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
return tokens.every((token) => haystack.includes(token)); }
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<typeof entry.score> } =>
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);
} }

View File

@@ -741,6 +741,7 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
isAgentRunning: boolean; isAgentRunning: boolean;
hasSendableContent: boolean; hasSendableContent: boolean;
isProcessing: boolean; isProcessing: boolean;
isCompact: boolean;
cancelButton: ReactElement; cancelButton: ReactElement;
} }
@@ -750,10 +751,12 @@ function ComposerRightControlsSlot({
isAgentRunning, isAgentRunning,
hasSendableContent, hasSendableContent,
isProcessing, isProcessing,
isCompact,
cancelButton, cancelButton,
...voiceProps ...voiceProps
}: ComposerRightControlsSlotProps) { }: ComposerRightControlsSlotProps) {
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent; const hideVoiceForCompactInput = isCompact && hasSendableContent;
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent && !hideVoiceForCompactInput;
const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing; const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing;
if (!showVoiceModeButton && !shouldShowCancelButton) return null; if (!showVoiceModeButton && !shouldShowCancelButton) return null;
return ( return (
@@ -1401,6 +1404,7 @@ export function Composer({
isAgentRunning={isAgentRunning} isAgentRunning={isAgentRunning}
hasSendableContent={hasSendableContent} hasSendableContent={hasSendableContent}
isProcessing={isProcessing} isProcessing={isProcessing}
isCompact={isMobile}
buttonIconSize={buttonIconSize} buttonIconSize={buttonIconSize}
handleToggleRealtimeVoice={handleToggleRealtimeVoice} handleToggleRealtimeVoice={handleToggleRealtimeVoice}
isConnected={isConnected} isConnected={isConnected}
@@ -1418,6 +1422,7 @@ export function Composer({
hasSendableContent, hasSendableContent,
isAgentRunning, isAgentRunning,
isConnected, isConnected,
isMobile,
isProcessing, isProcessing,
isVoiceModeForAgent, isVoiceModeForAgent,
isVoiceSwitching, isVoiceSwitching,

View File

@@ -2,13 +2,14 @@ import { useCallback, useMemo } from "react";
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { getIsElectronRuntime } from "@/constants/layout"; 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 { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts"; import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
const SNAP_POINTS: string[] = ["70%", "92%"]; const SNAP_POINTS: string[] = ["70%", "92%"];
const SHORTCUTS_HEADER: SheetHeader = { title: "Shortcuts" };
export function KeyboardShortcutsDialog() { export function KeyboardShortcutsDialog() {
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen); const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
@@ -25,7 +26,7 @@ export function KeyboardShortcutsDialog() {
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
title="Shortcuts" header={SHORTCUTS_HEADER}
visible={open} visible={open}
onClose={handleClose} onClose={handleClose}
testID="keyboard-shortcuts-dialog" testID="keyboard-shortcuts-dialog"

View File

@@ -8,10 +8,11 @@ import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints"; import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
import { connectToDaemon } from "@/utils/test-daemon-connection"; import { connectToDaemon } from "@/utils/test-daemon-connection";
import { ConnectionOfferSchema } from "@server/shared/connection-offer"; import { ConnectionOfferSchema } from "@server/shared/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput } from "./adaptive-modal-sheet"; import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const FLEX_ONE_STYLE = { flex: 1 } as const; const FLEX_ONE_STYLE = { flex: 1 } as const;
const PAIR_LINK_HEADER: SheetHeader = { title: "Paste pairing link" };
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
helper: { helper: {
@@ -169,7 +170,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
title="Paste pairing link" header={PAIR_LINK_HEADER}
visible={visible} visible={visible}
onClose={handleClose} onClose={handleClose}
testID="pair-link-modal" testID="pair-link-modal"

View File

@@ -1,5 +1,5 @@
import { AlertCircle, RotateCw, Search, Trash2 } from "lucide-react-native"; import { AlertCircle, RotateCw, Search, Trash2 } from "lucide-react-native";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import { import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
@@ -9,7 +9,11 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet"; import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
@@ -96,6 +100,7 @@ function CustomModelsSection(props: {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { config, patchConfig } = useDaemonConfig(serverId); const { config, patchConfig } = useDaemonConfig(serverId);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deletingModelId, setDeletingModelId] = useState<string | null>(null); const [deletingModelId, setDeletingModelId] = useState<string | null>(null);
@@ -136,7 +141,11 @@ function CustomModelsSection(props: {
label: trimmedInput, label: trimmedInput,
}, },
]) ])
.then(() => setInput("")) .then(() => {
setInput("");
bumpInputResetKey();
return undefined;
})
.catch((err) => { .catch((err) => {
setError(err instanceof Error ? err.message : "Failed to save model"); setError(err instanceof Error ? err.message : "Failed to save model");
}) })
@@ -163,6 +172,8 @@ function CustomModelsSection(props: {
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
<View style={INLINE_ROW_STYLE}> <View style={INLINE_ROW_STYLE}>
<AdaptiveTextInput <AdaptiveTextInput
initialValue={input}
resetKey={`custom-model-input-${inputResetKey}`}
value={input} value={input}
onChangeText={setInput} onChangeText={setInput}
onSubmitEditing={handleAdd} onSubmitEditing={handleAdd}
@@ -245,6 +256,7 @@ export function ProviderDiagnosticSheet({
const [diagnostic, setDiagnostic] = useState<string | null>(null); const [diagnostic, setDiagnostic] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [queryResetKey, bumpQueryResetKey] = useReducer((key: number) => key + 1, 0);
const providerLabel = resolveProviderLabel(provider, snapshotEntries); const providerLabel = resolveProviderLabel(provider, snapshotEntries);
const providerEntry = useMemo( const providerEntry = useMemo(
@@ -348,6 +360,7 @@ export function ProviderDiagnosticSheet({
} else { } else {
setDiagnostic(null); setDiagnostic(null);
setQuery(""); setQuery("");
bumpQueryResetKey();
} }
}, [visible, fetchDiagnostic]); }, [visible, fetchDiagnostic]);
@@ -405,13 +418,17 @@ export function ProviderDiagnosticSheet({
)); ));
} }
const sheetHeader = useMemo<SheetHeader>(
() => ({ title: providerLabel, actions: headerActions }),
[providerLabel, headerActions],
);
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
title={providerLabel} header={sheetHeader}
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
snapPoints={DIAGNOSTIC_SHEET_SNAP_POINTS} snapPoints={DIAGNOSTIC_SHEET_SNAP_POINTS}
headerActions={headerActions}
> >
<SettingsSection title="Diagnostic"> <SettingsSection title="Diagnostic">
<View style={settingsStyles.card}> <View style={settingsStyles.card}>
@@ -434,6 +451,8 @@ export function ProviderDiagnosticSheet({
<View style={INLINE_ROW_STYLE}> <View style={INLINE_ROW_STYLE}>
<Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} /> <Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<AdaptiveTextInput <AdaptiveTextInput
initialValue={query}
resetKey={`provider-model-search-${queryResetKey}`}
value={query} value={query}
onChangeText={setQuery} onChangeText={setQuery}
placeholder="Search models" placeholder="Search models"

View File

@@ -121,6 +121,16 @@ describe("filterAndRankComboboxOptions", () => {
const result = filterAndRankComboboxOptions(items, "202"); const result = filterAndRankComboboxOptions(items, "202");
expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]); 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", () => { describe("combobox above-search ordering", () => {

View File

@@ -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"; export type ComboboxOptionKind = "directory" | "file";
@@ -12,18 +12,12 @@ export interface ComboboxOptionModel {
const DESCRIPTION_FALLBACK_TIER = 99; const DESCRIPTION_FALLBACK_TIER = 99;
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null { function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
const labelScore = scoreMatch(search, opt.label); const best = scoreTextFields(search, [opt.label, opt.id]);
const idScore = scoreMatch(search, opt.id);
let best: MatchScore | null = null;
if (labelScore) best = labelScore;
if (idScore && (!best || compareMatchScores(idScore, best) < 0)) {
best = idScore;
}
if (best) return best; if (best) return best;
if (opt.description && opt.description.toLowerCase().includes(search)) { if (!opt.description) return null;
return { tier: DESCRIPTION_FALLBACK_TIER, offset: 0 }; const descriptionScore = scoreTextFields(search, [opt.description]);
} if (!descriptionScore) return null;
return null; return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
} }
export interface BuildVisibleComboboxOptionsInput { export interface BuildVisibleComboboxOptionsInput {

View File

@@ -1,5 +1,13 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import {
import type { ReactElement, ReactNode, Ref } from "react"; useCallback,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import type { ReactElement, ReactNode } from "react";
import { import {
View, View,
Text, Text,
@@ -18,7 +26,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { import {
BottomSheetScrollView, BottomSheetScrollView,
BottomSheetBackdrop, BottomSheetBackdrop,
BottomSheetTextInput,
BottomSheetBackgroundProps, BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet"; } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
@@ -38,11 +45,17 @@ import {
shouldShowCustomComboboxOption, shouldShowCustomComboboxOption,
} from "./combobox-options"; } from "./combobox-options";
import type { ComboboxOptionModel } from "./combobox-options"; import type { ComboboxOptionModel } from "./combobox-options";
import { isNative, isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal"; } from "./isolated-bottom-sheet-modal";
import {
AdaptiveTextInput,
InlineHeaderView,
SheetHeaderView,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
const IS_WEB = isWeb; const IS_WEB = isWeb;
@@ -69,6 +82,15 @@ export interface ComboboxProps {
customValueKind?: "directory" | "file"; customValueKind?: "directory" | "file";
optionsPosition?: "below-search" | "above-search"; optionsPosition?: "below-search" | "above-search";
title?: string; 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; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
desktopPlacement?: "top-start" | "bottom-start"; desktopPlacement?: "top-start" | "bottom-start";
@@ -136,6 +158,7 @@ export interface SearchInputProps {
onSubmitEditing?: () => void; onSubmitEditing?: () => void;
autoFocus?: boolean; autoFocus?: boolean;
useBottomSheetInput?: boolean; useBottomSheetInput?: boolean;
resetKey?: string | number;
} }
export function SearchInput({ export function SearchInput({
@@ -145,10 +168,10 @@ export function SearchInput({
onSubmitEditing, onSubmitEditing,
autoFocus = false, autoFocus = false,
useBottomSheetInput = false, useBottomSheetInput = false,
resetKey,
}: SearchInputProps): ReactElement { }: SearchInputProps): ReactElement {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput;
useEffect(() => { useEffect(() => {
if (autoFocus && IS_WEB && inputRef.current) { if (autoFocus && IS_WEB && inputRef.current) {
@@ -162,18 +185,35 @@ export function SearchInput({
return ( return (
<View style={styles.searchInputContainer}> <View style={styles.searchInputContainer}>
<Search size={16} color={theme.colors.foregroundMuted} /> <Search size={16} color={theme.colors.foregroundMuted} />
<InputComponent {useBottomSheetInput ? (
ref={inputRef as unknown as Ref<never>} <AdaptiveTextInput
// @ts-expect-error - outlineStyle is web-only ref={inputRef}
style={SEARCH_INPUT_STYLE} // @ts-expect-error - outlineStyle is web-only
placeholder={placeholder} style={SEARCH_INPUT_STYLE}
placeholderTextColor={theme.colors.foregroundMuted} placeholder={placeholder}
value={value} placeholderTextColor={theme.colors.foregroundMuted}
onChangeText={onChangeText} initialValue={value}
autoCapitalize="none" resetKey={resetKey}
autoCorrect={false} value={value}
onSubmitEditing={onSubmitEditing} onChangeText={onChangeText}
/> autoCapitalize="none"
autoCorrect={false}
onSubmitEditing={onSubmitEditing}
/>
) : (
<TextInput
ref={inputRef}
// @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}
/>
)}
</View> </View>
); );
} }
@@ -593,12 +633,14 @@ function useDesktopFloatingUpdate(
function useResetSearchOnOpen( function useResetSearchOnOpen(
isOpen: boolean, isOpen: boolean,
setSearchQueryWithCallback: (query: string) => void, setSearchQueryWithCallback: (query: string) => void,
bumpSearchResetKey: () => void,
) { ) {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setSearchQueryWithCallback(""); setSearchQueryWithCallback("");
bumpSearchResetKey();
} }
}, [isOpen, setSearchQueryWithCallback]); }, [isOpen, setSearchQueryWithCallback, bumpSearchResetKey]);
} }
interface DesktopResetSetters { interface DesktopResetSetters {
@@ -919,9 +961,13 @@ interface MobileBodyProps {
handleIndicatorStyle: { backgroundColor: string }; handleIndicatorStyle: { backgroundColor: string };
titleColor: string; titleColor: string;
title: string; title: string;
header: SheetHeader | undefined;
onClose: () => void;
stickyHeader: ReactNode; stickyHeader: ReactNode;
searchable: boolean; searchable: boolean;
hasChildren: boolean; hasChildren: boolean;
mobileChildrenScrollEnabled: boolean;
searchResetKey: number;
searchPlaceholder: string; searchPlaceholder: string;
searchQuery: string; searchQuery: string;
setSearchQueryWithCallback: (query: string) => void; setSearchQueryWithCallback: (query: string) => void;
@@ -981,29 +1027,40 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
> >
<View style={styles.bottomSheetHeader}> {props.header ? (
<Text key={props.titleColor} style={comboboxTitleStyle}> <SheetHeaderView header={props.header} onClose={props.onClose} />
{props.title} ) : (
</Text> <>
</View> <View style={styles.bottomSheetHeader}>
{props.stickyHeader} <Text key={props.titleColor} style={comboboxTitleStyle}>
{!props.hasChildren && props.searchable ? ( {props.title}
<SearchInput </Text>
placeholder={props.searchPlaceholder} </View>
value={props.searchQuery} {props.stickyHeader}
onChangeText={props.setSearchQueryWithCallback} {!props.hasChildren && props.searchable ? (
onSubmitEditing={props.handleSubmitSearch} <SearchInput
autoFocus={false} placeholder={props.searchPlaceholder}
useBottomSheetInput value={props.searchQuery}
/> onChangeText={props.setSearchQueryWithCallback}
) : null} onSubmitEditing={props.handleSubmitSearch}
<BottomSheetScrollView autoFocus={false}
contentContainerStyle={styles.comboboxScrollContent} useBottomSheetInput
keyboardShouldPersistTaps="handled" resetKey={props.searchResetKey}
showsVerticalScrollIndicator={false} />
> ) : null}
{body} </>
</BottomSheetScrollView> )}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
contentContainerStyle={styles.comboboxScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
</IsolatedBottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -1015,6 +1072,7 @@ interface DesktopBodyProps {
shouldUseDesktopFade: boolean; shouldUseDesktopFade: boolean;
desktopContainerStyle: unknown; desktopContainerStyle: unknown;
handleDesktopContentLayout: (event: LayoutChangeEvent) => void; handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
header: SheetHeader | undefined;
stickyHeader: ReactNode; stickyHeader: ReactNode;
searchable: boolean; searchable: boolean;
searchPlaceholder: string; searchPlaceholder: string;
@@ -1036,12 +1094,13 @@ interface DesktopBodyProps {
} }
function DesktopComboboxChildrenBody(props: { function DesktopComboboxChildrenBody(props: {
header: SheetHeader | undefined;
stickyHeader: ReactNode; stickyHeader: ReactNode;
children: ReactNode; children: ReactNode;
}): ReactElement { }): ReactElement {
return ( return (
<> <>
{props.stickyHeader} {props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader}
<ScrollView <ScrollView
contentContainerStyle={styles.desktopChildrenScrollContent} contentContainerStyle={styles.desktopChildrenScrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
@@ -1055,6 +1114,7 @@ function DesktopComboboxChildrenBody(props: {
} }
function DesktopComboboxOptionsBody(props: { function DesktopComboboxOptionsBody(props: {
header: SheetHeader | undefined;
stickyHeader: ReactNode; stickyHeader: ReactNode;
searchable: boolean; searchable: boolean;
searchPlaceholder: string; searchPlaceholder: string;
@@ -1085,8 +1145,8 @@ function DesktopComboboxOptionsBody(props: {
return ( return (
<> <>
{props.stickyHeader} {props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader}
{props.searchable ? ( {props.header || !props.searchable ? null : (
<SearchInput <SearchInput
placeholder={props.searchPlaceholder} placeholder={props.searchPlaceholder}
value={props.searchQuery} value={props.searchQuery}
@@ -1095,7 +1155,7 @@ function DesktopComboboxOptionsBody(props: {
autoFocus autoFocus
useBottomSheetInput={false} useBottomSheetInput={false}
/> />
) : null} )}
{props.effectiveOptionsPosition === "above-search" ? ( {props.effectiveOptionsPosition === "above-search" ? (
<ScrollView <ScrollView
ref={props.desktopOptionsScrollRef} ref={props.desktopOptionsScrollRef}
@@ -1141,11 +1201,12 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
onLayout={props.handleDesktopContentLayout} onLayout={props.handleDesktopContentLayout}
> >
{props.hasChildren ? ( {props.hasChildren ? (
<DesktopComboboxChildrenBody stickyHeader={props.stickyHeader}> <DesktopComboboxChildrenBody header={props.header} stickyHeader={props.stickyHeader}>
{props.children} {props.children}
</DesktopComboboxChildrenBody> </DesktopComboboxChildrenBody>
) : ( ) : (
<DesktopComboboxOptionsBody <DesktopComboboxOptionsBody
header={props.header}
stickyHeader={props.stickyHeader} stickyHeader={props.stickyHeader}
searchable={props.searchable} searchable={props.searchable}
searchPlaceholder={props.searchPlaceholder} searchPlaceholder={props.searchPlaceholder}
@@ -1188,6 +1249,8 @@ export function Combobox({
customValueKind, customValueKind,
optionsPosition = "below-search", optionsPosition = "below-search",
title = "Select", title = "Select",
header,
mobileChildrenScrollEnabled = true,
open, open,
onOpenChange, onOpenChange,
desktopPlacement = "top-start", desktopPlacement = "top-start",
@@ -1214,6 +1277,7 @@ export function Combobox({
const [referenceTop, setReferenceTop] = useState<number | null>(null); const [referenceTop, setReferenceTop] = useState<number | null>(null);
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false); const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
const [activeIndex, setActiveIndex] = useState<number>(-1); const [activeIndex, setActiveIndex] = useState<number>(-1);
const desktopOptionsScrollRef = useRef<ScrollView>(null); const desktopOptionsScrollRef = useRef<ScrollView>(null);
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null); const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
@@ -1234,9 +1298,10 @@ export function Combobox({
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setOpen(false); setOpen(false);
setSearchQueryWithCallback(""); setSearchQueryWithCallback("");
bumpSearchResetKey();
}, [setOpen, setSearchQueryWithCallback]); }, [setOpen, setSearchQueryWithCallback]);
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback); useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
const collisionPadding = useMemo(computeCollisionPadding, []); const collisionPadding = useMemo(computeCollisionPadding, []);
@@ -1465,9 +1530,13 @@ export function Combobox({
handleIndicatorStyle={handleIndicatorStyle} handleIndicatorStyle={handleIndicatorStyle}
titleColor={titleColor} titleColor={titleColor}
title={title} title={title}
header={header}
onClose={handleClose}
stickyHeader={stickyHeader} stickyHeader={stickyHeader}
searchable={searchable} searchable={searchable}
hasChildren={hasChildren} hasChildren={hasChildren}
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
searchResetKey={searchResetKey}
searchPlaceholder={effectiveSearchPlaceholder} searchPlaceholder={effectiveSearchPlaceholder}
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQueryWithCallback={setSearchQueryWithCallback} setSearchQueryWithCallback={setSearchQueryWithCallback}
@@ -1494,6 +1563,7 @@ export function Combobox({
shouldUseDesktopFade={shouldUseDesktopFade} shouldUseDesktopFade={shouldUseDesktopFade}
desktopContainerStyle={desktopContainerStyle} desktopContainerStyle={desktopContainerStyle}
handleDesktopContentLayout={handleDesktopContentLayout} handleDesktopContentLayout={handleDesktopContentLayout}
header={header}
stickyHeader={stickyHeader} stickyHeader={stickyHeader}
searchable={searchable} searchable={searchable}
searchPlaceholder={effectiveSearchPlaceholder} searchPlaceholder={effectiveSearchPlaceholder}

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Image, Text, View } from "react-native"; import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id"; 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 { Composer } from "@/components/composer";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
@@ -374,14 +374,18 @@ export function WorkspaceSetupDialog() {
[iconSource, placeholderInitial, workspaceTitle], [iconSource, placeholderInitial, workspaceTitle],
); );
const sheetHeader = useMemo<SheetHeader>(
() => ({ title: "Create workspace", subtitle: subtitleContent }),
[subtitleContent],
);
if (!pendingWorkspaceSetup || !sourceDirectory) { if (!pendingWorkspaceSetup || !sourceDirectory) {
return null; return null;
} }
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
title="Create workspace" header={sheetHeader}
subtitle={subtitleContent}
visible={true} visible={true}
onClose={handleClose} onClose={handleClose}
snapPoints={SNAP_POINTS} snapPoints={SNAP_POINTS}

View File

@@ -5,7 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native"; import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
@@ -127,7 +127,7 @@ function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps)
<AdaptiveModalSheet <AdaptiveModalSheet
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
title="Daemon logs" header={DAEMON_LOGS_HEADER}
testID="managed-daemon-logs-dialog" testID="managed-daemon-logs-dialog"
snapPoints={LOGS_MODAL_SNAP_POINTS} snapPoints={LOGS_MODAL_SNAP_POINTS}
> >
@@ -158,7 +158,7 @@ function DaemonCliStatusModal({
<AdaptiveModalSheet <AdaptiveModalSheet
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
title="Daemon status" header={DAEMON_STATUS_HEADER}
testID="daemon-cli-status-dialog" testID="daemon-cli-status-dialog"
snapPoints={CLI_STATUS_MODAL_SNAP_POINTS} snapPoints={CLI_STATUS_MODAL_SNAP_POINTS}
> >
@@ -512,3 +512,5 @@ const LOADING_CARD_STYLE = [settingsStyles.card, styles.loadingCard];
const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder]; const ROW_WITH_BORDER_STYLE = [settingsStyles.row, settingsStyles.rowBorder];
const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"]; const LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"]; const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];
const DAEMON_LOGS_HEADER: SheetHeader = { title: "Daemon logs" };
const DAEMON_STATUS_HEADER: SheetHeader = { title: "Daemon status" };

View File

@@ -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"; import { PairDeviceSection } from "@/desktop/components/pair-device-section";
export interface PairDeviceModalProps { export interface PairDeviceModalProps {
@@ -8,11 +8,12 @@ export interface PairDeviceModalProps {
} }
const SNAP_POINTS: string[] = ["82%", "94%"]; const SNAP_POINTS: string[] = ["82%", "94%"];
const PAIR_DEVICE_HEADER: SheetHeader = { title: "Pair a device" };
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) { export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
title="Pair a device" header={PAIR_DEVICE_HEADER}
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
snapPoints={SNAP_POINTS} snapPoints={SNAP_POINTS}

View File

@@ -22,7 +22,7 @@ import { Alert } from "@/components/ui/alert";
import { ExternalLink } from "@/components/ui/external-link"; import { ExternalLink } from "@/components/ui/external-link";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
@@ -1150,11 +1150,15 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
const showNameError = touched.name && validation.nameError; const showNameError = touched.name && validation.nameError;
const showCommandError = touched.command && validation.commandError; const showCommandError = touched.command && validation.commandError;
const isService = script.type === SCRIPT_SERVICE_TYPE; const isService = script.type === SCRIPT_SERVICE_TYPE;
const sheetHeader = useMemo<SheetHeader>(
() => ({ title: script.name ? `Edit ${script.name}` : "New script" }),
[script.name],
);
return ( return (
<AdaptiveModalSheet <AdaptiveModalSheet
visible visible
title={script.name ? `Edit ${script.name}` : "New script"} header={sheetHeader}
onClose={onCancel} onClose={onCancel}
testID="script-edit-modal" testID="script-edit-modal"
desktopMaxWidth={560} desktopMaxWidth={560}

View File

@@ -18,7 +18,7 @@ import { confirmDialog } from "@/utils/confirm-dialog";
import { settingsStyles } from "@/styles/settings"; import { settingsStyles } from "@/styles/settings";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { SettingsSection } from "@/screens/settings/settings-section"; import { SettingsSection } from "@/screens/settings/settings-section";
@@ -68,6 +68,10 @@ function formatDaemonVersionBadge(version: string | null): string | null {
return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; return trimmed.startsWith("v") ? trimmed : `v${trimmed}`;
} }
const RENAME_HOST_HEADER: SheetHeader = { title: "Rename host" };
const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" };
const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" };
export interface HostPageProps { export interface HostPageProps {
serverId: string; serverId: string;
onHostRemoved?: () => void; onHostRemoved?: () => void;
@@ -244,7 +248,7 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
<AdaptiveModalSheet <AdaptiveModalSheet
visible={isEditing} visible={isEditing}
onClose={handleCancel} onClose={handleCancel}
title="Rename host" header={RENAME_HOST_HEADER}
testID="host-page-rename-modal" testID="host-page-rename-modal"
> >
<View style={styles.renameBody}> <View style={styles.renameBody}>
@@ -347,7 +351,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
{pendingRemoveConnection ? ( {pendingRemoveConnection ? (
<AdaptiveModalSheet <AdaptiveModalSheet
title="Remove connection" header={REMOVE_CONNECTION_HEADER}
visible visible
onClose={handleCloseConfirm} onClose={handleCloseConfirm}
testID="remove-connection-confirm-modal" testID="remove-connection-confirm-modal"
@@ -723,7 +727,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
{isConfirming ? ( {isConfirming ? (
<AdaptiveModalSheet <AdaptiveModalSheet
title="Remove host" header={REMOVE_HOST_HEADER}
visible visible
onClose={handleCloseConfirm} onClose={handleCloseConfirm}
testID="remove-host-confirm-modal" testID="remove-host-confirm-modal"

View File

@@ -5,7 +5,7 @@ import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/clie
import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers"; import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
@@ -13,6 +13,7 @@ import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS); const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
const IMPORT_SESSION_HEADER: SheetHeader = { title: "Import session" };
const PER_PROVIDER_LIMIT = 15; const PER_PROVIDER_LIMIT = 15;
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"]; const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
const DISABLED_ACCESSIBILITY_STATE = { disabled: true }; const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
@@ -435,7 +436,7 @@ export function WorkspaceImportSheet({
<AdaptiveModalSheet <AdaptiveModalSheet
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
title="Import session" header={IMPORT_SESSION_HEADER}
testID="workspace-import-sheet" testID="workspace-import-sheet"
desktopMaxWidth={560} desktopMaxWidth={560}
snapPoints={IMPORT_SHEET_SNAP_POINTS} snapPoints={IMPORT_SHEET_SNAP_POINTS}

View File

@@ -1,6 +1,7 @@
export interface MatchScore { export interface MatchScore {
tier: number; tier: number;
offset: number; offset: number;
spread?: number;
} }
function isWordBoundaryChar(ch: string | undefined): boolean { function isWordBoundaryChar(ch: string | undefined): boolean {
@@ -8,19 +9,14 @@ function isWordBoundaryChar(ch: string | undefined): boolean {
return !/[a-z0-9]/.test(ch); return !/[a-z0-9]/.test(ch);
} }
export function scoreMatch(query: string, text: string): MatchScore | null { function scoreSubstringMatch(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 };
let best: MatchScore | null = null; let best: MatchScore | null = null;
let pos = 0; let pos = 0;
while (pos <= t.length - q.length) { while (pos <= text.length - query.length) {
const found = t.indexOf(q, pos); const found = text.indexOf(query, pos);
if (found === -1) break; if (found === -1) break;
const before = found > 0 ? t[found - 1] : undefined; const before = found > 0 ? text[found - 1] : undefined;
const after = t[found + q.length]; const after = text[found + query.length];
const startsAtBoundary = found === 0 || isWordBoundaryChar(before); const startsAtBoundary = found === 0 || isWordBoundaryChar(before);
const endsAtBoundary = after === undefined || isWordBoundaryChar(after); const endsAtBoundary = after === undefined || isWordBoundaryChar(after);
let tier: number; let tier: number;
@@ -41,7 +37,57 @@ export function scoreMatch(query: string, text: string): MatchScore | null {
return best; 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 { export function compareMatchScores(a: MatchScore, b: MatchScore): number {
if (a.tier !== b.tier) return a.tier - b.tier; 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;
} }

View File

@@ -74,7 +74,7 @@ function DocsLayout() {
</button> </button>
</div> </div>
{mobileNavOpen && ( {mobileNavOpen && (
<nav className="border-t border-border px-4 py-4 space-y-4"> <nav className="border-t border-border px-4 py-4 space-y-4 max-h-[calc(100dvh-4rem)] overflow-y-auto">
{groups.map((group) => ( {groups.map((group) => (
<div key={group.name ?? "root"} className="space-y-1"> <div key={group.name ?? "root"} className="space-y-1">
{group.name && ( {group.name && (