mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -12,7 +12,7 @@ import {
|
||||
BottomSheetTextInput,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { X } from "lucide-react-native";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import {
|
||||
@@ -21,6 +21,37 @@ import {
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
|
||||
// Horizontal indent token shared by the sheet header (title, back arrow,
|
||||
// leading icon, search input icon) and any row primitive rendered inside the
|
||||
// sheet body. Rows whose leading icon should line up with the header must
|
||||
// match this padding.
|
||||
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
|
||||
|
||||
export interface SheetHeaderSearch {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
initialValue?: string;
|
||||
resetKey?: string | number;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
export interface SheetHeaderBack {
|
||||
onPress: () => void;
|
||||
label?: string;
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
|
||||
export interface SheetHeader {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
back?: SheetHeaderBack;
|
||||
leading?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
search?: SheetHeaderSearch;
|
||||
}
|
||||
|
||||
type EscHandler = () => void;
|
||||
const escStack: EscHandler[] = [];
|
||||
let escListenerAttached = false;
|
||||
@@ -72,23 +103,30 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.surface2,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingVertical: theme.spacing[4],
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
headerContainer: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
headerRow: {
|
||||
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
paddingVertical: theme.spacing[4],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
headerBackButton: {
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
headerLeadingSlot: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
headerTitleGroup: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: theme.fontSize.lg,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
@@ -96,52 +134,78 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
marginLeft: theme.spacing[3],
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
closeButton: {
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
searchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
paddingBottom: theme.spacing[3],
|
||||
},
|
||||
// Inline variants for InlineHeaderView inside the desktop Combobox popover.
|
||||
// Horizontal padding matches the model picker's row indent: the picker uses
|
||||
// children mode (desktopChildrenScrollContent, no scroll padding), so the
|
||||
// row content starts at item.paddingHorizontal = spacing[3].
|
||||
inlineHeaderRow: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
inlineSearchRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
inlineTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing[2],
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
desktopScroll: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopContent: {
|
||||
padding: theme.spacing[6],
|
||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
gap: theme.spacing[4],
|
||||
flexGrow: 1,
|
||||
},
|
||||
bottomSheetHeader: {
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
paddingTop: theme.spacing[4],
|
||||
paddingBottom: theme.spacing[3],
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.surface2,
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
bottomSheetContent: {
|
||||
padding: theme.spacing[6],
|
||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
bottomSheetStaticContent: {
|
||||
flex: 1,
|
||||
padding: theme.spacing[6],
|
||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
gap: theme.spacing[4],
|
||||
minHeight: 0,
|
||||
},
|
||||
desktopStaticContent: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
padding: theme.spacing[6],
|
||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
|
||||
|
||||
function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const combinedStyle = useMemo(
|
||||
@@ -158,14 +222,186 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
return <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 {
|
||||
title: string;
|
||||
/** Optional content rendered below the title in the header area. */
|
||||
subtitle?: ReactNode;
|
||||
header: SheetHeader;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
snapPoints?: string[];
|
||||
testID?: string;
|
||||
/** Override the max width of the desktop card. */
|
||||
@@ -176,12 +412,10 @@ export interface AdaptiveModalSheetProps {
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
title,
|
||||
subtitle,
|
||||
header,
|
||||
visible,
|
||||
onClose,
|
||||
children,
|
||||
headerActions,
|
||||
snapPoints,
|
||||
testID,
|
||||
desktopMaxWidth,
|
||||
@@ -190,7 +424,6 @@ export function AdaptiveModalSheet({
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const titleColor = theme.colors.foreground;
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.surface2 }),
|
||||
@@ -209,7 +442,6 @@ export function AdaptiveModalSheet({
|
||||
[],
|
||||
);
|
||||
|
||||
const titleStyle = useMemo(() => [styles.title, { color: titleColor }], [titleColor]);
|
||||
const desktopCardStyle = useMemo(
|
||||
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
|
||||
[desktopMaxWidth],
|
||||
@@ -237,18 +469,7 @@ export function AdaptiveModalSheet({
|
||||
keyboardBlurBehavior="restore"
|
||||
accessible={false}
|
||||
>
|
||||
<View style={styles.bottomSheetHeader} 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>
|
||||
<SheetHeaderView header={header} onClose={onClose} testID={testID} />
|
||||
{scrollable ? (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
@@ -266,18 +487,7 @@ export function AdaptiveModalSheet({
|
||||
|
||||
const cardInner = (
|
||||
<>
|
||||
<View style={styles.header}>
|
||||
<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>
|
||||
<SheetHeaderView header={header} onClose={onClose} />
|
||||
{scrollable ? (
|
||||
<ScrollView
|
||||
style={styles.desktopScroll}
|
||||
@@ -324,19 +534,3 @@ export function AdaptiveModalSheet({
|
||||
</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} />;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useCallback } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
|
||||
import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" };
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
option: {
|
||||
flexDirection: "row",
|
||||
@@ -62,7 +64,7 @@ export function AddHostMethodModal({
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Add connection"
|
||||
header={ADD_CONNECTION_HEADER}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
testID="add-host-method-modal"
|
||||
|
||||
@@ -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 { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
serializeConnectionUriForStorage,
|
||||
} from "@/utils/daemon-endpoints";
|
||||
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";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const DIRECT_CONNECTION_HEADER: SheetHeader = { title: "Direct connection" };
|
||||
|
||||
interface DirectConnectionDraft {
|
||||
host: string;
|
||||
@@ -279,6 +280,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const [advancedUri, setAdvancedUri] = useState("");
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const clearInput = useCallback(() => {
|
||||
setHost("");
|
||||
@@ -288,6 +290,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setIsPasswordVisible(false);
|
||||
setIsAdvancedOpen(false);
|
||||
setAdvancedUri("");
|
||||
bumpInputResetKey();
|
||||
}, []);
|
||||
|
||||
const connectIcon = useMemo(
|
||||
@@ -411,6 +414,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
setUseTls(next.useTls);
|
||||
setPassword(next.password);
|
||||
setErrorMessage("");
|
||||
bumpInputResetKey();
|
||||
} catch {
|
||||
setErrorMessage("");
|
||||
}
|
||||
@@ -422,7 +426,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Direct connection"
|
||||
header={DIRECT_CONNECTION_HEADER}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
testID="add-host-modal"
|
||||
@@ -436,6 +440,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
testID="direct-host-input"
|
||||
nativeID="direct-host-input"
|
||||
accessibilityLabel="Host"
|
||||
initialValue={host}
|
||||
resetKey={`direct-host-${inputResetKey}`}
|
||||
value={host}
|
||||
onChangeText={setHost}
|
||||
placeholder="localhost"
|
||||
@@ -454,6 +460,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
testID="direct-port-input"
|
||||
nativeID="direct-port-input"
|
||||
accessibilityLabel="Port"
|
||||
initialValue={port}
|
||||
resetKey={`direct-port-${inputResetKey}`}
|
||||
value={port}
|
||||
onChangeText={setPort}
|
||||
placeholder="6767"
|
||||
@@ -495,6 +503,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
testID="direct-password-input"
|
||||
nativeID="direct-password-input"
|
||||
accessibilityLabel="Password"
|
||||
initialValue={password}
|
||||
resetKey={`direct-password-${inputResetKey}`}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholder="Optional"
|
||||
@@ -537,6 +547,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
|
||||
testID="direct-host-uri-input"
|
||||
nativeID="direct-host-uri-input"
|
||||
accessibilityLabel="Connection URI"
|
||||
initialValue={advancedUri}
|
||||
resetKey={`direct-host-uri-${inputResetKey}`}
|
||||
value={advancedUri}
|
||||
onChangeText={setAdvancedUri}
|
||||
placeholder="tcp://localhost:6767?ssl=true"
|
||||
|
||||
@@ -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 { SvgXml } from "react-native-svg";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
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 {
|
||||
buildAcpProviderConfigPatch,
|
||||
@@ -26,6 +30,7 @@ type InstallState = "installed" | "available";
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const ACTION_BUTTON_STYLE = { width: 92 } as const;
|
||||
const MODAL_SNAP_POINTS = ["78%", "92%"];
|
||||
const ADD_PROVIDER_HEADER: SheetHeader = { title: "Add provider" };
|
||||
const SEARCH_ICON_SIZE = 16;
|
||||
const PROVIDER_FALLBACK_ICON_SIZE = 20;
|
||||
const PROVIDER_REMOTE_ICON_SIZE = 24;
|
||||
@@ -138,8 +143,15 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
|
||||
const { entries: providerEntries, refresh } = useProvidersSnapshot(serverId);
|
||||
const { patchConfig } = useDaemonConfig(serverId);
|
||||
const [search, setSearch] = useState("");
|
||||
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSearch("");
|
||||
bumpSearchResetKey();
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const installedProviderIds = useMemo(
|
||||
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
|
||||
[providerEntries],
|
||||
@@ -157,7 +169,7 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
|
||||
try {
|
||||
await patchConfig(buildAcpProviderConfigPatch(entry));
|
||||
await refresh([entry.id]);
|
||||
onClose();
|
||||
handleClose();
|
||||
} catch (installError) {
|
||||
Alert.alert(
|
||||
"Unable to install provider",
|
||||
@@ -167,14 +179,14 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
|
||||
setInstallingProviderId((current) => (current === entry.id ? null : current));
|
||||
}
|
||||
},
|
||||
[installingProviderId, onClose, patchConfig, refresh],
|
||||
[installingProviderId, handleClose, patchConfig, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Add provider"
|
||||
header={ADD_PROVIDER_HEADER}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onClose={handleClose}
|
||||
desktopMaxWidth={680}
|
||||
snapPoints={MODAL_SNAP_POINTS}
|
||||
testID="add-provider-modal"
|
||||
@@ -186,6 +198,8 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
|
||||
<AdaptiveTextInput
|
||||
testID="provider-catalog-search"
|
||||
accessibilityLabel="Search providers"
|
||||
initialValue={search}
|
||||
resetKey={`provider-catalog-search-${searchResetKey}`}
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search providers"
|
||||
@@ -216,7 +230,7 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
|
||||
) : null}
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button style={FLEX_ONE_STYLE} variant="secondary" onPress={onClose}>
|
||||
<Button style={FLEX_ONE_STYLE} variant="secondary" onPress={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import {
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type {
|
||||
AgentFeature,
|
||||
@@ -58,14 +58,13 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { getModeVisuals, type AgentModeColorTier } from "@server/server/agent/provider-manifest";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
getFeatureHighlightColor,
|
||||
getFeatureTooltip,
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from "@/components/agent-status-bar.utils";
|
||||
import { isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
@@ -184,21 +183,6 @@ const MODE_ICONS = {
|
||||
ShieldOff,
|
||||
ShieldQuestionMark,
|
||||
} as const;
|
||||
const ThemedShieldCheck = withUnistyles(ShieldCheck);
|
||||
const ThemedShieldAlert = withUnistyles(ShieldAlert);
|
||||
const ThemedShieldOff = withUnistyles(ShieldOff);
|
||||
const ThemedShieldQuestionMark = withUnistyles(ShieldQuestionMark);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
|
||||
const MODE_MENU_LEADING_ICONS = {
|
||||
ShieldCheck: <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() {
|
||||
return true;
|
||||
@@ -215,6 +199,16 @@ function resolveDisplayModel(
|
||||
return findOptionLabel(modelOptions, selectedModelId, "Select model");
|
||||
}
|
||||
|
||||
// Mobile status bar only — strip namespace prefix so providers like OpenCode
|
||||
// show "gpt-5.5" instead of "openrouter/gpt-5.5". Full label still appears in
|
||||
// the model picker.
|
||||
function shortModelLabel(label: string): string {
|
||||
const i = label.lastIndexOf("/");
|
||||
return i === -1 ? label : label.slice(i + 1);
|
||||
}
|
||||
|
||||
type ActiveSheet = "thinking" | "mode" | "features" | null;
|
||||
|
||||
function resolveHasAnyControl({
|
||||
providerOptions,
|
||||
modeOptions,
|
||||
@@ -274,18 +268,6 @@ function makeBadgePressableStyle(
|
||||
];
|
||||
}
|
||||
|
||||
function makeSheetPressableStyle(isDisabled: boolean) {
|
||||
return ({ pressed }: PressableStateCallbackType) => [
|
||||
styles.sheetSelect,
|
||||
pressed && styles.sheetSelectPressed,
|
||||
isDisabled && styles.disabledSheetSelect,
|
||||
];
|
||||
}
|
||||
|
||||
function makePrefsButtonStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.prefsButton, pressed && styles.prefsButtonPressed];
|
||||
}
|
||||
|
||||
function pickSheetModel({
|
||||
nextProviderId,
|
||||
modelId,
|
||||
@@ -436,27 +418,6 @@ function buildOpenChangeHandler(
|
||||
};
|
||||
}
|
||||
|
||||
function SheetModelTriggerView({
|
||||
selectedModelLabel,
|
||||
style,
|
||||
ProviderIcon,
|
||||
}: {
|
||||
selectedModelLabel: string;
|
||||
style: StyleProp<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(
|
||||
colorTier: AgentModeColorTier | undefined,
|
||||
palette: {
|
||||
@@ -508,7 +469,8 @@ function ControlledStatusBar({
|
||||
onModelSelectorOpen,
|
||||
}: ControlledAgentStatusBarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [prefsOpen, setPrefsOpen] = useState(false);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [activeSheet, setActiveSheet] = useState<ActiveSheet>(null);
|
||||
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null);
|
||||
|
||||
const providerAnchorRef = useRef<View>(null);
|
||||
@@ -526,7 +488,6 @@ function ControlledStatusBar({
|
||||
);
|
||||
|
||||
const displayProvider = findOptionLabel(providerOptions, selectedProviderId, "Provider");
|
||||
const displayMode = findOptionLabel(modeOptions, selectedModeId, "Default");
|
||||
const displayModel = resolveDisplayModel(isModelLoading, modelOptions, selectedModelId);
|
||||
const displayThinking = findOptionLabel(
|
||||
thinkingOptions,
|
||||
@@ -586,6 +547,18 @@ function ControlledStatusBar({
|
||||
),
|
||||
[provider, providerDefinitions, theme.colors.foreground],
|
||||
);
|
||||
const renderThinkingOption = useCallback(
|
||||
(args: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => (
|
||||
<ThinkingComboboxOption
|
||||
option={args.option}
|
||||
selected={args.selected}
|
||||
active={args.active}
|
||||
onPress={args.onPress}
|
||||
iconColor={theme.colors.foreground}
|
||||
/>
|
||||
),
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(selector: StatusSelector) =>
|
||||
@@ -659,16 +632,30 @@ function ControlledStatusBar({
|
||||
[canSelectMode, disabled, openSelector],
|
||||
);
|
||||
|
||||
const handleOpenPrefs = useCallback(() => {
|
||||
const handleOpenSheet = useCallback((sheet: Exclude<ActiveSheet, null>) => {
|
||||
Keyboard.dismiss();
|
||||
setPrefsOpen(true);
|
||||
setActiveSheet(sheet);
|
||||
}, []);
|
||||
|
||||
const handleClosePrefs = useCallback(() => {
|
||||
setPrefsOpen(false);
|
||||
const handleCloseSheet = useCallback(() => {
|
||||
setActiveSheet(null);
|
||||
}, []);
|
||||
|
||||
const prefsButtonStyle = makePrefsButtonStyle;
|
||||
const handleSelectThinkingAndClose = useCallback(
|
||||
(thinkingOptionId: string) => {
|
||||
onSelectThinkingOption?.(thinkingOptionId);
|
||||
setActiveSheet(null);
|
||||
},
|
||||
[onSelectThinkingOption],
|
||||
);
|
||||
|
||||
const handleSelectModeAndClose = useCallback(
|
||||
(modeId: string) => {
|
||||
onSelectMode?.(modeId);
|
||||
setActiveSheet(null);
|
||||
},
|
||||
[onSelectMode],
|
||||
);
|
||||
|
||||
const handleSheetModelSelect = useCallback(
|
||||
(nextProviderId: string, modelId: string) => {
|
||||
@@ -684,38 +671,13 @@ function ControlledStatusBar({
|
||||
[onSelectModel, onSelectProvider, onSelectProviderAndModel, provider],
|
||||
);
|
||||
|
||||
const sheetThinkingPressableStyle = useMemo(
|
||||
() => makeSheetPressableStyle(disabled || !canSelectThinking),
|
||||
[canSelectThinking, disabled],
|
||||
);
|
||||
|
||||
const sheetModePressableStyle = useMemo(
|
||||
() => makeSheetPressableStyle(disabled || !canSelectMode),
|
||||
[canSelectMode, disabled],
|
||||
);
|
||||
|
||||
const sheetSelectStyle = useMemo(
|
||||
() => [styles.sheetSelect, modelDisabled && styles.disabledSheetSelect],
|
||||
[modelDisabled],
|
||||
);
|
||||
const renderSheetModelTrigger = useCallback(
|
||||
({ selectedModelLabel }: { selectedModelLabel: string }) => (
|
||||
<SheetModelTriggerView
|
||||
selectedModelLabel={selectedModelLabel}
|
||||
style={sheetSelectStyle}
|
||||
ProviderIcon={ProviderIcon}
|
||||
/>
|
||||
),
|
||||
[ProviderIcon, sheetSelectStyle],
|
||||
);
|
||||
|
||||
if (!hasAnyControl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{platformIsWeb ? (
|
||||
{!isCompact ? (
|
||||
<DesktopStatusBarContent
|
||||
provider={provider}
|
||||
providerOptions={providerOptions}
|
||||
@@ -770,23 +732,19 @@ function ControlledStatusBar({
|
||||
handleModeOpenChange={handleModeOpenChange}
|
||||
handleOpenChange={handleOpenChange}
|
||||
renderModeOption={renderModeOption}
|
||||
renderThinkingOption={renderThinkingOption}
|
||||
/>
|
||||
) : (
|
||||
<SheetStatusBarContent
|
||||
provider={provider}
|
||||
modeOptions={modeOptions}
|
||||
selectedModeId={selectedModeId}
|
||||
selectedModelId={selectedModelId}
|
||||
thinkingOptions={thinkingOptions}
|
||||
selectedThinkingOptionId={selectedThinkingOptionId}
|
||||
features={features}
|
||||
onSetFeature={onSetFeature}
|
||||
onSelectMode={onSelectMode}
|
||||
onSelectThinkingOption={onSelectThinkingOption}
|
||||
onToggleFavoriteModel={onToggleFavoriteModel}
|
||||
onDropdownClose={onDropdownClose}
|
||||
onModelSelectorOpen={onModelSelectorOpen}
|
||||
providerDefinitions={providerDefinitions}
|
||||
favoriteKeys={favoriteKeys}
|
||||
disabled={disabled}
|
||||
isModelLoading={isModelLoading}
|
||||
@@ -797,24 +755,21 @@ function ControlledStatusBar({
|
||||
modelDisabled={modelDisabled}
|
||||
effectiveProviderDefinitions={effectiveProviderDefinitions}
|
||||
effectiveAllProviderModels={effectiveAllProviderModels}
|
||||
displayMode={displayMode}
|
||||
displayModel={displayModel}
|
||||
displayThinking={displayThinking}
|
||||
comboboxModeOptions={comboboxModeOptions}
|
||||
comboboxThinkingOptions={comboboxThinkingOptions}
|
||||
ModeIconComponent={ModeIconComponent}
|
||||
modeIconColor={modeIconColor}
|
||||
openSelector={openSelector}
|
||||
ProviderIcon={ProviderIcon}
|
||||
prefsOpen={prefsOpen}
|
||||
handleOpenPrefs={handleOpenPrefs}
|
||||
handleClosePrefs={handleClosePrefs}
|
||||
prefsButtonStyle={prefsButtonStyle}
|
||||
sheetThinkingPressableStyle={sheetThinkingPressableStyle}
|
||||
sheetModePressableStyle={sheetModePressableStyle}
|
||||
activeSheet={activeSheet}
|
||||
handleOpenSheet={handleOpenSheet}
|
||||
handleCloseSheet={handleCloseSheet}
|
||||
handleSheetModelSelect={handleSheetModelSelect}
|
||||
handleThinkingOpenChange={handleThinkingOpenChange}
|
||||
handleModeOpenChange={handleModeOpenChange}
|
||||
handleSelectThinkingAndClose={handleSelectThinkingAndClose}
|
||||
handleSelectModeAndClose={handleSelectModeAndClose}
|
||||
handleOpenChange={handleOpenChange}
|
||||
renderSheetModelTrigger={renderSheetModelTrigger}
|
||||
renderModeOption={renderModeOption}
|
||||
renderThinkingOption={renderThinkingOption}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -880,6 +835,12 @@ interface DesktopStatusBarContentProps {
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
renderThinkingOption: (args: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
}
|
||||
|
||||
const DESKTOP_SEARCH_THRESHOLD = 6;
|
||||
@@ -938,6 +899,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
|
||||
handleModeOpenChange,
|
||||
handleOpenChange,
|
||||
renderModeOption,
|
||||
renderThinkingOption,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -1033,6 +995,7 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
|
||||
onOpenChange={handleThinkingOpenChange}
|
||||
anchorRef={thinkingAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
renderOption={renderThinkingOption}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
@@ -1092,19 +1055,14 @@ function DesktopStatusBarContent(props: DesktopStatusBarContentProps) {
|
||||
|
||||
interface SheetStatusBarContentProps {
|
||||
provider: string;
|
||||
modeOptions?: StatusOption[];
|
||||
selectedModeId?: string;
|
||||
selectedModelId?: string;
|
||||
thinkingOptions?: StatusOption[];
|
||||
selectedThinkingOptionId?: string;
|
||||
features?: AgentFeature[];
|
||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
||||
onSelectMode?: (modeId: string) => void;
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||
onDropdownClose?: () => void;
|
||||
onModelSelectorOpen?: () => void;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
favoriteKeys: Set<string>;
|
||||
disabled: boolean;
|
||||
isModelLoading: boolean;
|
||||
@@ -1115,43 +1073,45 @@ interface SheetStatusBarContentProps {
|
||||
modelDisabled: boolean;
|
||||
effectiveProviderDefinitions: AgentProviderDefinition[];
|
||||
effectiveAllProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
displayMode: string;
|
||||
displayModel: string;
|
||||
displayThinking: string;
|
||||
comboboxModeOptions: ComboboxOption[];
|
||||
comboboxThinkingOptions: ComboboxOption[];
|
||||
ModeIconComponent: (typeof MODE_ICONS)[keyof typeof MODE_ICONS] | null;
|
||||
modeIconColor: string;
|
||||
openSelector: StatusSelector | null;
|
||||
ProviderIcon: ReturnType<typeof getProviderIcon> | null;
|
||||
prefsOpen: boolean;
|
||||
handleOpenPrefs: () => void;
|
||||
handleClosePrefs: () => void;
|
||||
prefsButtonStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
|
||||
sheetThinkingPressableStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
|
||||
sheetModePressableStyle: (state: PressableStateCallbackType) => StyleProp<ViewStyle>;
|
||||
activeSheet: ActiveSheet;
|
||||
handleOpenSheet: (sheet: Exclude<ActiveSheet, null>) => void;
|
||||
handleCloseSheet: () => void;
|
||||
handleSheetModelSelect: (providerId: string, modelId: string) => void;
|
||||
handleThinkingOpenChange: (open: boolean) => void;
|
||||
handleModeOpenChange: (open: boolean) => void;
|
||||
handleSelectThinkingAndClose: (thinkingOptionId: string) => void;
|
||||
handleSelectModeAndClose: (modeId: string) => void;
|
||||
handleOpenChange: (selector: StatusSelector) => (nextOpen: boolean) => void;
|
||||
renderSheetModelTrigger: (args: { selectedModelLabel: string }) => ReactElement;
|
||||
renderModeOption: (args: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
renderThinkingOption: (args: {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
}
|
||||
|
||||
function SheetStatusBarContent(props: SheetStatusBarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const {
|
||||
provider,
|
||||
modeOptions,
|
||||
selectedModeId,
|
||||
selectedModelId,
|
||||
thinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
features,
|
||||
onSetFeature,
|
||||
onSelectMode,
|
||||
onSelectThinkingOption,
|
||||
onToggleFavoriteModel,
|
||||
onDropdownClose,
|
||||
onModelSelectorOpen,
|
||||
providerDefinitions,
|
||||
favoriteKeys,
|
||||
disabled,
|
||||
isModelLoading,
|
||||
@@ -1162,133 +1122,194 @@ function SheetStatusBarContent(props: SheetStatusBarContentProps) {
|
||||
modelDisabled,
|
||||
effectiveProviderDefinitions,
|
||||
effectiveAllProviderModels,
|
||||
displayMode,
|
||||
displayModel,
|
||||
displayThinking,
|
||||
comboboxModeOptions,
|
||||
comboboxThinkingOptions,
|
||||
ModeIconComponent,
|
||||
modeIconColor,
|
||||
openSelector,
|
||||
ProviderIcon,
|
||||
prefsOpen,
|
||||
handleOpenPrefs,
|
||||
handleClosePrefs,
|
||||
prefsButtonStyle,
|
||||
sheetThinkingPressableStyle,
|
||||
sheetModePressableStyle,
|
||||
activeSheet,
|
||||
handleOpenSheet,
|
||||
handleCloseSheet,
|
||||
handleSheetModelSelect,
|
||||
handleThinkingOpenChange,
|
||||
handleModeOpenChange,
|
||||
handleSelectThinkingAndClose,
|
||||
handleSelectModeAndClose,
|
||||
handleOpenChange,
|
||||
renderSheetModelTrigger,
|
||||
renderModeOption,
|
||||
renderThinkingOption,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={handleOpenPrefs}
|
||||
style={prefsButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Agent preferences"
|
||||
testID="agent-preferences-button"
|
||||
>
|
||||
const thinkingAnchorRef = useRef<View | null>(null);
|
||||
const modeAnchorRef = useRef<View | null>(null);
|
||||
|
||||
const hasThinking = comboboxThinkingOptions.length > 0;
|
||||
const hasMode = Boolean(canSelectMode && comboboxModeOptions.length > 0);
|
||||
const hasFeatures = Boolean(features && features.length > 0);
|
||||
|
||||
const handleOpenThinking = useCallback(() => handleOpenSheet("thinking"), [handleOpenSheet]);
|
||||
const handleOpenMode = useCallback(() => handleOpenSheet("mode"), [handleOpenSheet]);
|
||||
const handleOpenFeatures = useCallback(() => handleOpenSheet("features"), [handleOpenSheet]);
|
||||
const handleThinkingSheetOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
handleOpenSheet("thinking");
|
||||
} else {
|
||||
handleCloseSheet();
|
||||
}
|
||||
},
|
||||
[handleCloseSheet, handleOpenSheet],
|
||||
);
|
||||
const handleModeSheetOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
handleOpenSheet("mode");
|
||||
} else {
|
||||
handleCloseSheet();
|
||||
}
|
||||
},
|
||||
[handleCloseSheet, handleOpenSheet],
|
||||
);
|
||||
|
||||
const renderModelTrigger = useCallback(
|
||||
({
|
||||
selectedModelLabel,
|
||||
}: {
|
||||
selectedModelLabel: string;
|
||||
onPress: () => void;
|
||||
disabled: boolean;
|
||||
isOpen: boolean;
|
||||
}) => (
|
||||
<View pointerEvents="none" style={styles.prefsButton} testID="agent-status-bar-model">
|
||||
{ProviderIcon ? (
|
||||
<ProviderIcon size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
) : null}
|
||||
<Text style={styles.prefsButtonText} numberOfLines={1}>
|
||||
{displayModel}
|
||||
{shortModelLabel(selectedModelLabel)}
|
||||
</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
|
||||
title="Preferences"
|
||||
visible={prefsOpen}
|
||||
onClose={handleClosePrefs}
|
||||
testID="agent-preferences-sheet"
|
||||
header={FEATURES_SHEET_HEADER}
|
||||
visible={activeSheet === "features"}
|
||||
onClose={handleCloseSheet}
|
||||
testID="agent-features-sheet"
|
||||
>
|
||||
{canSelectModel ? (
|
||||
<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) => (
|
||||
{(features ?? []).map((feature) => (
|
||||
<SheetFeatureItem
|
||||
key={`feature-${feature.id}`}
|
||||
feature={feature}
|
||||
@@ -1555,23 +1576,28 @@ function FeatureOptionMenuItem({
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingMenuItem({
|
||||
thinking,
|
||||
function ThinkingComboboxOption({
|
||||
option,
|
||||
selected,
|
||||
onSelectThinkingOption,
|
||||
active,
|
||||
onPress,
|
||||
iconColor,
|
||||
}: {
|
||||
thinking: StatusOption;
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
onSelectThinkingOption?: (thinkingOptionId: string) => void;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
iconColor: string;
|
||||
}) {
|
||||
const handleSelect = useCallback(() => {
|
||||
onSelectThinkingOption?.(thinking.id);
|
||||
}, [onSelectThinkingOption, thinking.id]);
|
||||
|
||||
const leadingSlot = useMemo(() => <Brain size={16} color={iconColor} />, [iconColor]);
|
||||
return (
|
||||
<DropdownMenuItem selected={selected} onSelect={handleSelect}>
|
||||
{thinking.label}
|
||||
</DropdownMenuItem>
|
||||
<ComboboxItem
|
||||
label={option.label}
|
||||
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 FEATURES_SHEET_HEADER: SheetHeader = { title: "Features" };
|
||||
|
||||
export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
agentId,
|
||||
@@ -1884,6 +1885,7 @@ export function DraftAgentStatusBar({
|
||||
disabled = false,
|
||||
}: DraftAgentStatusBarProps) {
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
|
||||
const mappedModeOptions = useMemo<StatusOption[]>(() => {
|
||||
if (modeOptions.length === 0) {
|
||||
@@ -1931,7 +1933,7 @@ export function DraftAgentStatusBar({
|
||||
[updatePreferences],
|
||||
);
|
||||
|
||||
if (platformIsWeb) {
|
||||
if (!isCompact) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CombinedModelSelector
|
||||
@@ -2045,9 +2047,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
prefsButtonPressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
prefsButtonText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"
|
||||
import {
|
||||
buildModelRows,
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
matchesSearch,
|
||||
resolveProviderLabel,
|
||||
} from "./combined-model-selector.utils";
|
||||
@@ -83,6 +84,34 @@ describe("combined model selector helpers", () => {
|
||||
expect(matchesSearch(row, "kimi gemini")).toBe(false);
|
||||
});
|
||||
|
||||
it("ranks model search results by fuzzy match quality", () => {
|
||||
const rows = [
|
||||
{
|
||||
favoriteKey: "openai:gpt-4.1",
|
||||
provider: "openai",
|
||||
providerLabel: "OpenAI",
|
||||
modelId: "gpt-4.1",
|
||||
modelLabel: "GPT-4.1",
|
||||
},
|
||||
{
|
||||
favoriteKey: "openai:gpt-5.4",
|
||||
provider: "openai",
|
||||
providerLabel: "OpenAI",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
},
|
||||
{
|
||||
favoriteKey: "google:gemini",
|
||||
provider: "google",
|
||||
providerLabel: "Google",
|
||||
modelId: "gemini",
|
||||
modelLabel: "Gemini",
|
||||
},
|
||||
];
|
||||
|
||||
expect(filterAndRankModelRows(rows, "gpt54").map((row) => row.modelId)).toEqual(["gpt-5.4"]);
|
||||
});
|
||||
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
expect(resolveProviderLabel(providerDefinitions, "codex")).toBe("Codex");
|
||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Ref } from "react";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { BottomSheetTextInput } from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetFlatList } from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb as platformIsWeb } from "@/constants/platform";
|
||||
import { ArrowLeft, ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||
import { ChevronDown, ChevronRight, Search, Star } from "lucide-react-native";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type { SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
const IS_WEB = platformIsWeb;
|
||||
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
@@ -45,19 +44,11 @@ function drillDownRowStyle({
|
||||
pressed && styles.drillDownRowPressed,
|
||||
];
|
||||
}
|
||||
|
||||
function backButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [
|
||||
styles.backButton,
|
||||
Boolean(hovered) && styles.backButtonHovered,
|
||||
pressed && styles.backButtonPressed,
|
||||
];
|
||||
}
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import {
|
||||
buildModelRows,
|
||||
buildSelectedTriggerLabel,
|
||||
matchesSearch,
|
||||
filterAndRankModelRows,
|
||||
resolveProviderLabel,
|
||||
type SelectorModelRow,
|
||||
} from "./combined-model-selector.utils";
|
||||
@@ -97,7 +88,6 @@ interface SelectorContentProps {
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
favoriteKeys: Set<string>;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
@@ -116,24 +106,6 @@ function normalizeSearchQuery(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function partitionRows(
|
||||
rows: SelectorModelRow[],
|
||||
favoriteKeys: Set<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(
|
||||
rows: SelectorModelRow[],
|
||||
favoriteKeys: Set<string>,
|
||||
@@ -375,55 +347,23 @@ function GroupProviderButton({
|
||||
|
||||
function GroupedProviderRows({
|
||||
groupedRows,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
onDrillDown,
|
||||
viewKind,
|
||||
}: {
|
||||
groupedRows: Array<{ providerId: string; providerLabel: string; rows: SelectorModelRow[] }>;
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
canSelectProvider: (provider: string) => boolean;
|
||||
onToggleFavorite?: (provider: string, modelId: string) => void;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
viewKind: SelectorView["kind"];
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
{groupedRows.map((group, index) => {
|
||||
const isInline = viewKind === "provider";
|
||||
|
||||
return (
|
||||
<View key={group.providerId}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
{isInline ? (
|
||||
<>
|
||||
{sortFavoritesFirst(group.rows, favoriteKeys).map((row) => (
|
||||
<SelectableModelRow
|
||||
key={row.favoriteKey}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<GroupProviderButton
|
||||
providerId={group.providerId}
|
||||
providerLabel={group.providerLabel}
|
||||
rowCount={group.rows.length}
|
||||
onDrillDown={onDrillDown}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
@@ -431,47 +371,65 @@ function GroupedProviderRows({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderSearchInput({
|
||||
value,
|
||||
onChangeText,
|
||||
autoFocus = false,
|
||||
function ProviderModelRows({
|
||||
rows,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
onToggleFavorite,
|
||||
normalizedQuery,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
autoFocus?: boolean;
|
||||
rows: SelectorModelRow[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<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 InputComponent = isMobile && isNative ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || !platformIsWeb || !inputRef.current) return () => {};
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [autoFocus]);
|
||||
|
||||
const inputStyle = useMemo(
|
||||
() => [styles.providerSearchInput, platformIsWeb && { outlineStyle: "none" }],
|
||||
[],
|
||||
const useVirtualizedList = isMobile && isNative;
|
||||
const displayRows = useMemo(
|
||||
() => (normalizedQuery ? rows : sortFavoritesFirst(rows, favoriteKeys)),
|
||||
[favoriteKeys, normalizedQuery, rows],
|
||||
);
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: SelectorModelRow }) => (
|
||||
<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 (
|
||||
<View style={styles.providerSearchContainer}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<InputComponent
|
||||
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>
|
||||
{displayRows.map((row) => (
|
||||
<View key={row.favoriteKey}>{renderItem({ item: row })}</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -483,7 +441,6 @@ function SelectorContent({
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
searchQuery,
|
||||
onSearchChange: _onSearchChange,
|
||||
favoriteKeys,
|
||||
onSelect,
|
||||
canSelectProvider,
|
||||
@@ -506,92 +463,61 @@ function SelectorContent({
|
||||
const normalizedQuery = useMemo(() => normalizeSearchQuery(searchQuery), [searchQuery]);
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() => scopedRows.filter((row) => matchesSearch(row, normalizedQuery)),
|
||||
() => filterAndRankModelRows(scopedRows, normalizedQuery),
|
||||
[normalizedQuery, scopedRows],
|
||||
);
|
||||
|
||||
const { favoriteRows, regularRows: _regularRows } = useMemo(
|
||||
() => partitionRows(visibleRows, favoriteKeys),
|
||||
const favoriteRows = useMemo(
|
||||
() => visibleRows.filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||
[favoriteKeys, visibleRows],
|
||||
);
|
||||
|
||||
// Group ALL visible rows by provider — favorites are a cross-cutting view,
|
||||
// not a partition. A model being favorited doesn't remove it from its provider.
|
||||
const allGroupedRows = useMemo(() => groupRowsByProvider(visibleRows), [visibleRows]);
|
||||
|
||||
// When searching at Level 1, filter grouped rows to only providers whose name or models match
|
||||
const filteredGroupedRows = useMemo(() => {
|
||||
if (view.kind === "provider" || !normalizedQuery) {
|
||||
return allGroupedRows;
|
||||
}
|
||||
return allGroupedRows.filter(
|
||||
(group) =>
|
||||
group.providerLabel.toLowerCase().includes(normalizedQuery) || group.rows.length > 0,
|
||||
);
|
||||
}, [allGroupedRows, normalizedQuery, view.kind]);
|
||||
|
||||
const hasResults = favoriteRows.length > 0 || filteredGroupedRows.length > 0;
|
||||
|
||||
return (
|
||||
<View>
|
||||
{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}
|
||||
const hasResults = favoriteRows.length > 0 || allGroupedRows.length > 0;
|
||||
const emptyState = (
|
||||
<View style={styles.emptyState}>
|
||||
<Search size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.emptyStateText}>No models match your search</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderBackButton({
|
||||
providerId,
|
||||
providerLabel,
|
||||
onBack,
|
||||
}: {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProviderIcon = getProviderIcon(providerId);
|
||||
if (view.kind === "provider") {
|
||||
if (visibleRows.length === 0) {
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
if (!onBack) {
|
||||
return null;
|
||||
return (
|
||||
<ProviderModelRows
|
||||
rows={visibleRows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={onSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
normalizedQuery={normalizedQuery}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPress={onBack} style={backButtonStyle}>
|
||||
<ArrowLeft size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.backButtonText}>{providerLabel}</Text>
|
||||
</Pressable>
|
||||
<View>
|
||||
<FavoritesSection
|
||||
favoriteRows={favoriteRows}
|
||||
selectedProvider={selectedProvider}
|
||||
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 [view, setView] = useState<SelectorView>({ kind: "all" });
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
// Single-provider mode: only one provider with models → skip Level 1 entirely
|
||||
const singleProviderView = useMemo<SelectorView | null>(() => {
|
||||
@@ -646,6 +573,7 @@ export function CombinedModelSelector({
|
||||
onOpen?.();
|
||||
} else {
|
||||
setSearchQuery("");
|
||||
bumpSearchResetKey();
|
||||
onClose?.();
|
||||
}
|
||||
},
|
||||
@@ -657,6 +585,7 @@ export function CombinedModelSelector({
|
||||
onSelect(provider, modelId);
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
bumpSearchResetKey();
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
@@ -731,32 +660,48 @@ export function CombinedModelSelector({
|
||||
const handleBackToAll = useCallback(() => {
|
||||
setView({ kind: "all" });
|
||||
setSearchQuery("");
|
||||
bumpSearchResetKey();
|
||||
}, []);
|
||||
|
||||
const handleDrillDown = useCallback((providerId: string, providerLabel: string) => {
|
||||
setView({ kind: "provider", providerId, providerLabel });
|
||||
}, []);
|
||||
|
||||
const stickyHeader = useMemo(
|
||||
() =>
|
||||
view.kind === "provider" ? (
|
||||
<View style={styles.level2Header}>
|
||||
{!singleProviderView ? (
|
||||
<ProviderBackButton
|
||||
providerId={view.providerId}
|
||||
providerLabel={view.providerLabel}
|
||||
onBack={handleBackToAll}
|
||||
/>
|
||||
) : null}
|
||||
<ProviderSearchInput
|
||||
value={searchQuery}
|
||||
onChangeText={setSearchQuery}
|
||||
autoFocus={platformIsWeb}
|
||||
/>
|
||||
</View>
|
||||
const handleSearchQueryChange = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
}, []);
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(() => {
|
||||
if (view.kind === "all") {
|
||||
return { title: "Select provider" };
|
||||
}
|
||||
const ProviderIconForView = getProviderIcon(view.providerId);
|
||||
return {
|
||||
title: view.providerLabel,
|
||||
leading: ProviderIconForView ? (
|
||||
<ProviderIconForView size={theme.iconSize.md} color={theme.colors.foreground} />
|
||||
) : undefined,
|
||||
[view, singleProviderView, handleBackToAll, searchQuery],
|
||||
);
|
||||
back: singleProviderView ? undefined : { onPress: handleBackToAll },
|
||||
search: {
|
||||
value: searchQuery,
|
||||
onChange: handleSearchQueryChange,
|
||||
initialValue: searchQuery,
|
||||
resetKey: `${view.providerId}:${searchResetKey}`,
|
||||
placeholder: "Search models...",
|
||||
autoFocus: platformIsWeb,
|
||||
testID: "model-search-input",
|
||||
},
|
||||
};
|
||||
}, [
|
||||
view,
|
||||
singleProviderView,
|
||||
handleBackToAll,
|
||||
handleSearchQueryChange,
|
||||
searchQuery,
|
||||
searchResetKey,
|
||||
theme.iconSize.md,
|
||||
theme.colors.foreground,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -799,8 +744,8 @@ export function CombinedModelSelector({
|
||||
desktopPlacement="top-start"
|
||||
desktopMinWidth={360}
|
||||
desktopFixedHeight={desktopFixedHeight}
|
||||
title="Select model"
|
||||
stickyHeader={stickyHeader}
|
||||
header={sheetHeader}
|
||||
mobileChildrenScrollEnabled={view.kind !== "provider" || !isNative}
|
||||
>
|
||||
{isContentReady ? (
|
||||
<SelectorContent
|
||||
@@ -810,7 +755,6 @@ export function CombinedModelSelector({
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
favoriteKeys={favoriteKeys}
|
||||
onSelect={handleSelect}
|
||||
canSelectProvider={canSelectProvider}
|
||||
@@ -913,27 +857,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
level2Header: {},
|
||||
backButton: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||
},
|
||||
backButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
backButtonText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
emptyState: {
|
||||
paddingVertical: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
@@ -943,6 +866,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
virtualizedModelList: {
|
||||
flex: 1,
|
||||
},
|
||||
virtualizedModelListContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingBottom: theme.spacing[8],
|
||||
},
|
||||
favoriteButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
@@ -966,17 +897,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
providerSearchContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
gap: theme.spacing[2],
|
||||
...(IS_WEB ? {} : { marginHorizontal: theme.spacing[1] }),
|
||||
},
|
||||
providerSearchInput: {
|
||||
flex: 1,
|
||||
paddingVertical: theme.spacing[3],
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||
|
||||
export type SelectorModelRow = FavoriteModelRow;
|
||||
|
||||
@@ -44,14 +45,33 @@ export function buildModelRows(
|
||||
}
|
||||
|
||||
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
|
||||
if (!normalizedQuery) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const haystack = [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
|
||||
const tokens = normalizedQuery.split(/\s+/).filter((token) => token.length > 0);
|
||||
return tokens.every((token) => haystack.includes(token));
|
||||
return scoreModelRow(row, normalizedQuery) !== null;
|
||||
}
|
||||
|
||||
function getModelRowSearchFields(row: SelectorModelRow): string[] {
|
||||
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
|
||||
}
|
||||
|
||||
export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) {
|
||||
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
|
||||
}
|
||||
|
||||
export function filterAndRankModelRows(
|
||||
rows: SelectorModelRow[],
|
||||
normalizedQuery: string,
|
||||
): SelectorModelRow[] {
|
||||
if (!normalizedQuery) return rows;
|
||||
const scored = rows
|
||||
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
|
||||
.filter((entry): entry is { row: SelectorModelRow; score: NonNullable<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);
|
||||
}
|
||||
|
||||
@@ -741,6 +741,7 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
|
||||
isAgentRunning: boolean;
|
||||
hasSendableContent: boolean;
|
||||
isProcessing: boolean;
|
||||
isCompact: boolean;
|
||||
cancelButton: ReactElement;
|
||||
}
|
||||
|
||||
@@ -750,10 +751,12 @@ function ComposerRightControlsSlot({
|
||||
isAgentRunning,
|
||||
hasSendableContent,
|
||||
isProcessing,
|
||||
isCompact,
|
||||
cancelButton,
|
||||
...voiceProps
|
||||
}: ComposerRightControlsSlotProps) {
|
||||
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent;
|
||||
const hideVoiceForCompactInput = isCompact && hasSendableContent;
|
||||
const showVoiceModeButton = !isVoiceModeForAgent && hasAgent && !hideVoiceForCompactInput;
|
||||
const shouldShowCancelButton = isAgentRunning && !hasSendableContent && !isProcessing;
|
||||
if (!showVoiceModeButton && !shouldShowCancelButton) return null;
|
||||
return (
|
||||
@@ -1401,6 +1404,7 @@ export function Composer({
|
||||
isAgentRunning={isAgentRunning}
|
||||
hasSendableContent={hasSendableContent}
|
||||
isProcessing={isProcessing}
|
||||
isCompact={isMobile}
|
||||
buttonIconSize={buttonIconSize}
|
||||
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
|
||||
isConnected={isConnected}
|
||||
@@ -1418,6 +1422,7 @@ export function Composer({
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isMobile,
|
||||
isProcessing,
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitching,
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useCallback, useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { buildKeyboardShortcutHelpSections } from "@/keyboard/keyboard-shortcuts";
|
||||
|
||||
const SNAP_POINTS: string[] = ["70%", "92%"];
|
||||
const SHORTCUTS_HEADER: SheetHeader = { title: "Shortcuts" };
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
const open = useKeyboardShortcutsStore((s) => s.shortcutsDialogOpen);
|
||||
@@ -25,7 +26,7 @@ export function KeyboardShortcutsDialog() {
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Shortcuts"
|
||||
header={SHORTCUTS_HEADER}
|
||||
visible={open}
|
||||
onClose={handleClose}
|
||||
testID="keyboard-shortcuts-dialog"
|
||||
|
||||
@@ -8,10 +8,11 @@ import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||
import { decodeOfferFragmentPayload, normalizeHostPort } from "@/utils/daemon-endpoints";
|
||||
import { connectToDaemon } from "@/utils/test-daemon-connection";
|
||||
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";
|
||||
|
||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||
const PAIR_LINK_HEADER: SheetHeader = { title: "Paste pairing link" };
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
helper: {
|
||||
@@ -169,7 +170,7 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Paste pairing link"
|
||||
header={PAIR_LINK_HEADER}
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
testID="pair-link-modal"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
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 { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -96,6 +100,7 @@ function CustomModelsSection(props: {
|
||||
const { theme } = useUnistyles();
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const [input, setInput] = useState("");
|
||||
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingModelId, setDeletingModelId] = useState<string | null>(null);
|
||||
@@ -136,7 +141,11 @@ function CustomModelsSection(props: {
|
||||
label: trimmedInput,
|
||||
},
|
||||
])
|
||||
.then(() => setInput(""))
|
||||
.then(() => {
|
||||
setInput("");
|
||||
bumpInputResetKey();
|
||||
return undefined;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to save model");
|
||||
})
|
||||
@@ -163,6 +172,8 @@ function CustomModelsSection(props: {
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={INLINE_ROW_STYLE}>
|
||||
<AdaptiveTextInput
|
||||
initialValue={input}
|
||||
resetKey={`custom-model-input-${inputResetKey}`}
|
||||
value={input}
|
||||
onChangeText={setInput}
|
||||
onSubmitEditing={handleAdd}
|
||||
@@ -245,6 +256,7 @@ export function ProviderDiagnosticSheet({
|
||||
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [queryResetKey, bumpQueryResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
|
||||
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
||||
const providerEntry = useMemo(
|
||||
@@ -348,6 +360,7 @@ export function ProviderDiagnosticSheet({
|
||||
} else {
|
||||
setDiagnostic(null);
|
||||
setQuery("");
|
||||
bumpQueryResetKey();
|
||||
}
|
||||
}, [visible, fetchDiagnostic]);
|
||||
|
||||
@@ -405,13 +418,17 @@ export function ProviderDiagnosticSheet({
|
||||
));
|
||||
}
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: providerLabel, actions: headerActions }),
|
||||
[providerLabel, headerActions],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title={providerLabel}
|
||||
header={sheetHeader}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={DIAGNOSTIC_SHEET_SNAP_POINTS}
|
||||
headerActions={headerActions}
|
||||
>
|
||||
<SettingsSection title="Diagnostic">
|
||||
<View style={settingsStyles.card}>
|
||||
@@ -434,6 +451,8 @@ export function ProviderDiagnosticSheet({
|
||||
<View style={INLINE_ROW_STYLE}>
|
||||
<Search size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<AdaptiveTextInput
|
||||
initialValue={query}
|
||||
resetKey={`provider-model-search-${queryResetKey}`}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search models"
|
||||
|
||||
@@ -121,6 +121,16 @@ describe("filterAndRankComboboxOptions", () => {
|
||||
const result = filterAndRankComboboxOptions(items, "202");
|
||||
expect(result.map((o) => o.id)).toEqual(["github-pr:202", "github-pr:1202"]);
|
||||
});
|
||||
|
||||
it("matches fuzzy character sequences after stronger substring matches", () => {
|
||||
const items = [
|
||||
{ id: "gpt-5.4", label: "GPT-5.4" },
|
||||
{ id: "gpt-4.1", label: "GPT-4.1" },
|
||||
{ id: "gemini", label: "Gemini" },
|
||||
];
|
||||
const result = filterAndRankComboboxOptions(items, "gpt54");
|
||||
expect(result.map((o) => o.id)).toEqual(["gpt-5.4"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combobox above-search ordering", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { compareMatchScores, type MatchScore, scoreMatch } from "../../utils/score-match";
|
||||
import { compareMatchScores, type MatchScore, scoreTextFields } from "../../utils/score-match";
|
||||
|
||||
export type ComboboxOptionKind = "directory" | "file";
|
||||
|
||||
@@ -12,18 +12,12 @@ export interface ComboboxOptionModel {
|
||||
const DESCRIPTION_FALLBACK_TIER = 99;
|
||||
|
||||
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
|
||||
const labelScore = scoreMatch(search, opt.label);
|
||||
const idScore = scoreMatch(search, opt.id);
|
||||
let best: MatchScore | null = null;
|
||||
if (labelScore) best = labelScore;
|
||||
if (idScore && (!best || compareMatchScores(idScore, best) < 0)) {
|
||||
best = idScore;
|
||||
}
|
||||
const best = scoreTextFields(search, [opt.label, opt.id]);
|
||||
if (best) return best;
|
||||
if (opt.description && opt.description.toLowerCase().includes(search)) {
|
||||
return { tier: DESCRIPTION_FALLBACK_TIER, offset: 0 };
|
||||
}
|
||||
return null;
|
||||
if (!opt.description) return null;
|
||||
const descriptionScore = scoreTextFields(search, [opt.description]);
|
||||
if (!descriptionScore) return null;
|
||||
return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
|
||||
}
|
||||
|
||||
export interface BuildVisibleComboboxOptionsInput {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactElement, ReactNode, Ref } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -18,7 +26,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import {
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
@@ -38,11 +45,17 @@ import {
|
||||
shouldShowCustomComboboxOption,
|
||||
} from "./combobox-options";
|
||||
import type { ComboboxOptionModel } from "./combobox-options";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "./isolated-bottom-sheet-modal";
|
||||
import {
|
||||
AdaptiveTextInput,
|
||||
InlineHeaderView,
|
||||
SheetHeaderView,
|
||||
type SheetHeader,
|
||||
} from "@/components/adaptive-modal-sheet";
|
||||
|
||||
const IS_WEB = isWeb;
|
||||
|
||||
@@ -69,6 +82,15 @@ export interface ComboboxProps {
|
||||
customValueKind?: "directory" | "file";
|
||||
optionsPosition?: "below-search" | "above-search";
|
||||
title?: string;
|
||||
/**
|
||||
* Structured header. When provided, replaces `title` + `stickyHeader` and
|
||||
* is rendered via the shared SheetHeaderView (mobile) / InlineHeaderView
|
||||
* (desktop). Built-in search (when `searchable=true` and no `header.search`)
|
||||
* is folded into the header so its magnifying glass aligns with the title
|
||||
* and any leading icon at the sheet's shared indent.
|
||||
*/
|
||||
header?: SheetHeader;
|
||||
mobileChildrenScrollEnabled?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
desktopPlacement?: "top-start" | "bottom-start";
|
||||
@@ -136,6 +158,7 @@ export interface SearchInputProps {
|
||||
onSubmitEditing?: () => void;
|
||||
autoFocus?: boolean;
|
||||
useBottomSheetInput?: boolean;
|
||||
resetKey?: string | number;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
@@ -145,10 +168,10 @@ export function SearchInput({
|
||||
onSubmitEditing,
|
||||
autoFocus = false,
|
||||
useBottomSheetInput = false,
|
||||
resetKey,
|
||||
}: SearchInputProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const InputComponent = useBottomSheetInput && isNative ? BottomSheetTextInput : TextInput;
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && IS_WEB && inputRef.current) {
|
||||
@@ -162,18 +185,35 @@ export function SearchInput({
|
||||
return (
|
||||
<View style={styles.searchInputContainer}>
|
||||
<Search size={16} color={theme.colors.foregroundMuted} />
|
||||
<InputComponent
|
||||
ref={inputRef as unknown as Ref<never>}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
/>
|
||||
{useBottomSheetInput ? (
|
||||
<AdaptiveTextInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={SEARCH_INPUT_STYLE}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
initialValue={value}
|
||||
resetKey={resetKey}
|
||||
value={value}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -593,12 +633,14 @@ function useDesktopFloatingUpdate(
|
||||
function useResetSearchOnOpen(
|
||||
isOpen: boolean,
|
||||
setSearchQueryWithCallback: (query: string) => void,
|
||||
bumpSearchResetKey: () => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSearchQueryWithCallback("");
|
||||
bumpSearchResetKey();
|
||||
}
|
||||
}, [isOpen, setSearchQueryWithCallback]);
|
||||
}, [isOpen, setSearchQueryWithCallback, bumpSearchResetKey]);
|
||||
}
|
||||
|
||||
interface DesktopResetSetters {
|
||||
@@ -919,9 +961,13 @@ interface MobileBodyProps {
|
||||
handleIndicatorStyle: { backgroundColor: string };
|
||||
titleColor: string;
|
||||
title: string;
|
||||
header: SheetHeader | undefined;
|
||||
onClose: () => void;
|
||||
stickyHeader: ReactNode;
|
||||
searchable: boolean;
|
||||
hasChildren: boolean;
|
||||
mobileChildrenScrollEnabled: boolean;
|
||||
searchResetKey: number;
|
||||
searchPlaceholder: string;
|
||||
searchQuery: string;
|
||||
setSearchQueryWithCallback: (query: string) => void;
|
||||
@@ -981,29 +1027,40 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
value={props.searchQuery}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
/>
|
||||
) : null}
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.comboboxScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
value={props.searchQuery}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.comboboxScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -1015,6 +1072,7 @@ interface DesktopBodyProps {
|
||||
shouldUseDesktopFade: boolean;
|
||||
desktopContainerStyle: unknown;
|
||||
handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
|
||||
header: SheetHeader | undefined;
|
||||
stickyHeader: ReactNode;
|
||||
searchable: boolean;
|
||||
searchPlaceholder: string;
|
||||
@@ -1036,12 +1094,13 @@ interface DesktopBodyProps {
|
||||
}
|
||||
|
||||
function DesktopComboboxChildrenBody(props: {
|
||||
header: SheetHeader | undefined;
|
||||
stickyHeader: ReactNode;
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
{props.stickyHeader}
|
||||
{props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.desktopChildrenScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
@@ -1055,6 +1114,7 @@ function DesktopComboboxChildrenBody(props: {
|
||||
}
|
||||
|
||||
function DesktopComboboxOptionsBody(props: {
|
||||
header: SheetHeader | undefined;
|
||||
stickyHeader: ReactNode;
|
||||
searchable: boolean;
|
||||
searchPlaceholder: string;
|
||||
@@ -1085,8 +1145,8 @@ function DesktopComboboxOptionsBody(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.stickyHeader}
|
||||
{props.searchable ? (
|
||||
{props.header ? <InlineHeaderView header={props.header} /> : props.stickyHeader}
|
||||
{props.header || !props.searchable ? null : (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
value={props.searchQuery}
|
||||
@@ -1095,7 +1155,7 @@ function DesktopComboboxOptionsBody(props: {
|
||||
autoFocus
|
||||
useBottomSheetInput={false}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
{props.effectiveOptionsPosition === "above-search" ? (
|
||||
<ScrollView
|
||||
ref={props.desktopOptionsScrollRef}
|
||||
@@ -1141,11 +1201,12 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
|
||||
onLayout={props.handleDesktopContentLayout}
|
||||
>
|
||||
{props.hasChildren ? (
|
||||
<DesktopComboboxChildrenBody stickyHeader={props.stickyHeader}>
|
||||
<DesktopComboboxChildrenBody header={props.header} stickyHeader={props.stickyHeader}>
|
||||
{props.children}
|
||||
</DesktopComboboxChildrenBody>
|
||||
) : (
|
||||
<DesktopComboboxOptionsBody
|
||||
header={props.header}
|
||||
stickyHeader={props.stickyHeader}
|
||||
searchable={props.searchable}
|
||||
searchPlaceholder={props.searchPlaceholder}
|
||||
@@ -1188,6 +1249,8 @@ export function Combobox({
|
||||
customValueKind,
|
||||
optionsPosition = "below-search",
|
||||
title = "Select",
|
||||
header,
|
||||
mobileChildrenScrollEnabled = true,
|
||||
open,
|
||||
onOpenChange,
|
||||
desktopPlacement = "top-start",
|
||||
@@ -1214,6 +1277,7 @@ export function Combobox({
|
||||
const [referenceTop, setReferenceTop] = useState<number | null>(null);
|
||||
const [referenceAtOrigin, setReferenceAtOrigin] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResetKey, bumpSearchResetKey] = useReducer((key: number) => key + 1, 0);
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const desktopOptionsScrollRef = useRef<ScrollView>(null);
|
||||
const [desktopContentWidth, setDesktopContentWidth] = useState<number | null>(null);
|
||||
@@ -1234,9 +1298,10 @@ export function Combobox({
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
setSearchQueryWithCallback("");
|
||||
bumpSearchResetKey();
|
||||
}, [setOpen, setSearchQueryWithCallback]);
|
||||
|
||||
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback);
|
||||
useResetSearchOnOpen(isOpen, setSearchQueryWithCallback, bumpSearchResetKey);
|
||||
|
||||
const collisionPadding = useMemo(computeCollisionPadding, []);
|
||||
|
||||
@@ -1465,9 +1530,13 @@ export function Combobox({
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
titleColor={titleColor}
|
||||
title={title}
|
||||
header={header}
|
||||
onClose={handleClose}
|
||||
stickyHeader={stickyHeader}
|
||||
searchable={searchable}
|
||||
hasChildren={hasChildren}
|
||||
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
|
||||
searchResetKey={searchResetKey}
|
||||
searchPlaceholder={effectiveSearchPlaceholder}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQueryWithCallback={setSearchQueryWithCallback}
|
||||
@@ -1494,6 +1563,7 @@ export function Combobox({
|
||||
shouldUseDesktopFade={shouldUseDesktopFade}
|
||||
desktopContainerStyle={desktopContainerStyle}
|
||||
handleDesktopContentLayout={handleDesktopContentLayout}
|
||||
header={header}
|
||||
stickyHeader={stickyHeader}
|
||||
searchable={searchable}
|
||||
searchPlaceholder={effectiveSearchPlaceholder}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Image, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
@@ -374,14 +374,18 @@ export function WorkspaceSetupDialog() {
|
||||
[iconSource, placeholderInitial, workspaceTitle],
|
||||
);
|
||||
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: "Create workspace", subtitle: subtitleContent }),
|
||||
[subtitleContent],
|
||||
);
|
||||
|
||||
if (!pendingWorkspaceSetup || !sourceDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Create workspace"
|
||||
subtitle={subtitleContent}
|
||||
header={sheetHeader}
|
||||
visible={true}
|
||||
onClose={handleClose}
|
||||
snapPoints={SNAP_POINTS}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -127,7 +127,7 @@ function DaemonLogsModal({ visible, onClose, daemonLogs }: DaemonLogsModalProps)
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
title="Daemon logs"
|
||||
header={DAEMON_LOGS_HEADER}
|
||||
testID="managed-daemon-logs-dialog"
|
||||
snapPoints={LOGS_MODAL_SNAP_POINTS}
|
||||
>
|
||||
@@ -158,7 +158,7 @@ function DaemonCliStatusModal({
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
title="Daemon status"
|
||||
header={DAEMON_STATUS_HEADER}
|
||||
testID="daemon-cli-status-dialog"
|
||||
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 LOGS_MODAL_SNAP_POINTS = ["70%", "92%"];
|
||||
const CLI_STATUS_MODAL_SNAP_POINTS = ["60%", "85%"];
|
||||
const DAEMON_LOGS_HEADER: SheetHeader = { title: "Daemon logs" };
|
||||
const DAEMON_STATUS_HEADER: SheetHeader = { title: "Daemon status" };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { PairDeviceSection } from "@/desktop/components/pair-device-section";
|
||||
|
||||
export interface PairDeviceModalProps {
|
||||
@@ -8,11 +8,12 @@ export interface PairDeviceModalProps {
|
||||
}
|
||||
|
||||
const SNAP_POINTS: string[] = ["82%", "94%"];
|
||||
const PAIR_DEVICE_HEADER: SheetHeader = { title: "Pair a device" };
|
||||
|
||||
export function PairDeviceModal({ visible, onClose, testID }: PairDeviceModalProps) {
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Pair a device"
|
||||
header={PAIR_DEVICE_HEADER}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
snapPoints={SNAP_POINTS}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Alert } from "@/components/ui/alert";
|
||||
import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
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 { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -1150,11 +1150,15 @@ function ScriptEditModal({ script, onChange, onCancel, onSave }: ScriptEditModal
|
||||
const showNameError = touched.name && validation.nameError;
|
||||
const showCommandError = touched.command && validation.commandError;
|
||||
const isService = script.type === SCRIPT_SERVICE_TYPE;
|
||||
const sheetHeader = useMemo<SheetHeader>(
|
||||
() => ({ title: script.name ? `Edit ${script.name}` : "New script" }),
|
||||
[script.name],
|
||||
);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
visible
|
||||
title={script.name ? `Edit ${script.name}` : "New script"}
|
||||
header={sheetHeader}
|
||||
onClose={onCancel}
|
||||
testID="script-edit-modal"
|
||||
desktopMaxWidth={560}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
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}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
serverId: string;
|
||||
onHostRemoved?: () => void;
|
||||
@@ -244,7 +248,7 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
|
||||
<AdaptiveModalSheet
|
||||
visible={isEditing}
|
||||
onClose={handleCancel}
|
||||
title="Rename host"
|
||||
header={RENAME_HOST_HEADER}
|
||||
testID="host-page-rename-modal"
|
||||
>
|
||||
<View style={styles.renameBody}>
|
||||
@@ -347,7 +351,7 @@ function ConnectionsSection({ host }: { host: HostProfile }) {
|
||||
|
||||
{pendingRemoveConnection ? (
|
||||
<AdaptiveModalSheet
|
||||
title="Remove connection"
|
||||
header={REMOVE_CONNECTION_HEADER}
|
||||
visible
|
||||
onClose={handleCloseConfirm}
|
||||
testID="remove-connection-confirm-modal"
|
||||
@@ -723,7 +727,7 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
||||
|
||||
{isConfirming ? (
|
||||
<AdaptiveModalSheet
|
||||
title="Remove host"
|
||||
header={REMOVE_HOST_HEADER}
|
||||
visible
|
||||
onClose={handleCloseConfirm}
|
||||
testID="remove-host-confirm-modal"
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { DaemonClient, FetchRecentProviderSessionEntry } from "@server/clie
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { IMPORTABLE_PROVIDERS } from "@server/shared/importable-providers";
|
||||
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 { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
@@ -13,6 +13,7 @@ import { formatTimeAgo } from "@/utils/time";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
|
||||
const IMPORTABLE_PROVIDER_IDS: Set<string> = new Set(IMPORTABLE_PROVIDERS);
|
||||
const IMPORT_SESSION_HEADER: SheetHeader = { title: "Import session" };
|
||||
const PER_PROVIDER_LIMIT = 15;
|
||||
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
|
||||
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
|
||||
@@ -435,7 +436,7 @@ export function WorkspaceImportSheet({
|
||||
<AdaptiveModalSheet
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
title="Import session"
|
||||
header={IMPORT_SESSION_HEADER}
|
||||
testID="workspace-import-sheet"
|
||||
desktopMaxWidth={560}
|
||||
snapPoints={IMPORT_SHEET_SNAP_POINTS}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface MatchScore {
|
||||
tier: number;
|
||||
offset: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
function isWordBoundaryChar(ch: string | undefined): boolean {
|
||||
@@ -8,19 +9,14 @@ function isWordBoundaryChar(ch: string | undefined): boolean {
|
||||
return !/[a-z0-9]/.test(ch);
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
function scoreSubstringMatch(query: string, text: string): MatchScore | null {
|
||||
let best: MatchScore | null = null;
|
||||
let pos = 0;
|
||||
while (pos <= t.length - q.length) {
|
||||
const found = t.indexOf(q, pos);
|
||||
while (pos <= text.length - query.length) {
|
||||
const found = text.indexOf(query, pos);
|
||||
if (found === -1) break;
|
||||
const before = found > 0 ? t[found - 1] : undefined;
|
||||
const after = t[found + q.length];
|
||||
const before = found > 0 ? text[found - 1] : undefined;
|
||||
const after = text[found + query.length];
|
||||
const startsAtBoundary = found === 0 || isWordBoundaryChar(before);
|
||||
const endsAtBoundary = after === undefined || isWordBoundaryChar(after);
|
||||
let tier: number;
|
||||
@@ -41,7 +37,57 @@ export function scoreMatch(query: string, text: string): MatchScore | null {
|
||||
return best;
|
||||
}
|
||||
|
||||
function scoreSubsequenceMatch(query: string, text: string): MatchScore | null {
|
||||
let queryIndex = 0;
|
||||
let firstIndex = -1;
|
||||
let lastIndex = -1;
|
||||
for (let textIndex = 0; textIndex < text.length && queryIndex < query.length; textIndex += 1) {
|
||||
if (text[textIndex] !== query[queryIndex]) continue;
|
||||
if (firstIndex === -1) firstIndex = textIndex;
|
||||
lastIndex = textIndex;
|
||||
queryIndex += 1;
|
||||
}
|
||||
|
||||
if (queryIndex !== query.length || firstIndex === -1) return null;
|
||||
return { tier: 5, offset: firstIndex, spread: lastIndex - firstIndex + 1 };
|
||||
}
|
||||
|
||||
export function scoreMatch(query: string, text: string): MatchScore | null {
|
||||
if (!query) return { tier: 0, offset: 0 };
|
||||
const q = query.toLowerCase();
|
||||
const t = text.toLowerCase();
|
||||
if (t === q) return { tier: 0, offset: 0 };
|
||||
|
||||
return scoreSubstringMatch(q, t) ?? scoreSubsequenceMatch(q, t);
|
||||
}
|
||||
|
||||
export function compareMatchScores(a: MatchScore, b: MatchScore): number {
|
||||
if (a.tier !== b.tier) return a.tier - b.tier;
|
||||
return a.offset - b.offset;
|
||||
if (a.offset !== b.offset) return a.offset - b.offset;
|
||||
return (a.spread ?? 0) - (b.spread ?? 0);
|
||||
}
|
||||
|
||||
export function scoreTextFields(query: string, fields: string[]): MatchScore | null {
|
||||
const tokens = query
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((token) => token.length > 0);
|
||||
if (tokens.length === 0) return { tier: 0, offset: 0, spread: 0 };
|
||||
|
||||
const aggregate: MatchScore = { tier: 0, offset: 0, spread: 0 };
|
||||
for (const token of tokens) {
|
||||
let best: MatchScore | null = null;
|
||||
for (const field of fields) {
|
||||
const score = scoreMatch(token, field);
|
||||
if (score && (!best || compareMatchScores(score, best) < 0)) {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
if (!best) return null;
|
||||
aggregate.tier += best.tier;
|
||||
aggregate.offset += best.offset;
|
||||
aggregate.spread = (aggregate.spread ?? 0) + (best.spread ?? token.length);
|
||||
}
|
||||
return aggregate;
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ function DocsLayout() {
|
||||
</button>
|
||||
</div>
|
||||
{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) => (
|
||||
<div key={group.name ?? "root"} className="space-y-1">
|
||||
{group.name && (
|
||||
|
||||
Reference in New Issue
Block a user