diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx
index e8c6b8077..d1ae7f5e7 100644
--- a/packages/app/src/components/adaptive-modal-sheet.tsx
+++ b/packages/app/src/components/adaptive-modal-sheet.tsx
@@ -12,7 +12,7 @@ import {
BottomSheetTextInput,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
-import { X } from "lucide-react-native";
+import { ArrowLeft, Search, X } from "lucide-react-native";
import { FileDropZone } from "@/components/file-drop-zone";
import type { ImageAttachment } from "@/components/message-input";
import {
@@ -21,6 +21,37 @@ import {
} from "@/components/ui/isolated-bottom-sheet-modal";
import { isNative, isWeb } from "@/constants/platform";
+// Horizontal indent token shared by the sheet header (title, back arrow,
+// leading icon, search input icon) and any row primitive rendered inside the
+// sheet body. Rows whose leading icon should line up with the header must
+// match this padding.
+export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
+
+export interface SheetHeaderSearch {
+ value: string;
+ onChange: (value: string) => void;
+ initialValue?: string;
+ resetKey?: string | number;
+ placeholder?: string;
+ autoFocus?: boolean;
+ testID?: string;
+}
+
+export interface SheetHeaderBack {
+ onPress: () => void;
+ label?: string;
+ accessibilityLabel?: string;
+}
+
+export interface SheetHeader {
+ title: string;
+ subtitle?: ReactNode;
+ back?: SheetHeaderBack;
+ leading?: ReactNode;
+ actions?: ReactNode;
+ search?: SheetHeaderSearch;
+}
+
type EscHandler = () => void;
const escStack: EscHandler[] = [];
let escListenerAttached = false;
@@ -72,23 +103,30 @@ const styles = StyleSheet.create((theme) => ({
borderWidth: 1,
borderColor: theme.colors.surface2,
},
- header: {
- paddingHorizontal: theme.spacing[6],
- paddingVertical: theme.spacing[4],
- flexDirection: "row",
- alignItems: "flex-start",
- justifyContent: "space-between",
+ headerContainer: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.surface2,
- gap: theme.spacing[3],
+ },
+ headerRow: {
+ paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
+ paddingVertical: theme.spacing[4],
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
+ headerBackButton: {
+ borderRadius: theme.borderRadius.lg,
+ },
+ headerLeadingSlot: {
+ alignItems: "center",
+ justifyContent: "center",
},
headerTitleGroup: {
flex: 1,
- gap: theme.spacing[2],
+ gap: theme.spacing[1],
minWidth: 0,
},
title: {
- flex: 1,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.medium,
},
@@ -96,52 +134,78 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
- marginLeft: theme.spacing[3],
- marginRight: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
- backgroundColor: theme.colors.surface2,
+ },
+ searchRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ paddingHorizontal: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
+ paddingBottom: theme.spacing[3],
+ },
+ // Inline variants for InlineHeaderView inside the desktop Combobox popover.
+ // Horizontal padding matches the model picker's row indent: the picker uses
+ // children mode (desktopChildrenScrollContent, no scroll padding), so the
+ // row content starts at item.paddingHorizontal = spacing[3].
+ inlineHeaderRow: {
+ paddingHorizontal: theme.spacing[3],
+ paddingVertical: theme.spacing[2],
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
+ inlineSearchRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ paddingHorizontal: theme.spacing[3],
+ paddingVertical: theme.spacing[2],
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.border,
+ },
+ inlineTitle: {
+ fontSize: theme.fontSize.sm,
+ fontWeight: theme.fontWeight.medium,
+ color: theme.colors.foreground,
+ },
+ searchInput: {
+ flex: 1,
+ paddingVertical: theme.spacing[2],
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.sm,
},
desktopScroll: {
flexShrink: 1,
minHeight: 0,
},
desktopContent: {
- padding: theme.spacing[6],
+ padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
flexGrow: 1,
},
- bottomSheetHeader: {
- paddingHorizontal: theme.spacing[6],
- paddingTop: theme.spacing[4],
- paddingBottom: theme.spacing[3],
- flexDirection: "row",
- justifyContent: "space-between",
- alignItems: "flex-start",
- borderBottomWidth: 1,
- borderBottomColor: theme.colors.surface2,
- gap: theme.spacing[3],
- },
bottomSheetContent: {
- padding: theme.spacing[6],
+ padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
bottomSheetStaticContent: {
flex: 1,
- padding: theme.spacing[6],
+ padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
minHeight: 0,
},
desktopStaticContent: {
flexShrink: 1,
minHeight: 0,
- padding: theme.spacing[6],
+ padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
gap: theme.spacing[4],
},
}));
+const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }];
+
function SheetBackground({ style }: BottomSheetBackgroundProps) {
const { theme } = useUnistyles();
const combinedStyle = useMemo(
@@ -158,14 +222,186 @@ function SheetBackground({ style }: BottomSheetBackgroundProps) {
return ;
}
+export type AdaptiveTextInputProps = TextInputProps & {
+ initialValue?: string;
+ resetKey?: string | number;
+};
+
+// React Native controlled TextInput can replay stale JS values during fast input
+// and visibly flicker/cursor-jump. Keep the rendered text native-owned; callers
+// can seed it once with initialValue and remount with resetKey for real resets.
+// See https://github.com/facebook/react-native/issues/44157
+export const AdaptiveTextInput = forwardRef(
+ function AdaptiveTextInputInner(props, ref) {
+ const isMobile = useIsCompactFormFactor();
+ const { value: _value, initialValue, resetKey, defaultValue, ...inputProps } = props;
+ const textInputProps = {
+ ...inputProps,
+ defaultValue: initialValue ?? defaultValue,
+ };
+
+ if (isMobile && isNative) {
+ return (
+ }
+ {...textInputProps}
+ />
+ );
+ }
+ return ;
+ },
+);
+
+export function SheetHeaderView({
+ header,
+ onClose,
+ showCloseButton = true,
+ testID,
+}: {
+ header: SheetHeader;
+ onClose: () => void;
+ showCloseButton?: boolean;
+ testID?: string;
+}) {
+ const { theme } = useUnistyles();
+ const titleStyle = useMemo(
+ () => [styles.title, { color: theme.colors.foreground }],
+ [theme.colors.foreground],
+ );
+ const back = header.back;
+ const handleBackPress = back?.onPress;
+ const search = header.search;
+ const handleSearchChange = useCallback(
+ (value: string) => {
+ search?.onChange(value);
+ },
+ [search],
+ );
+
+ return (
+
+
+ {handleBackPress ? (
+
+ {({ pressed }) => (
+
+ )}
+
+ ) : null}
+ {header.leading ? {header.leading} : null}
+
+
+ {header.title}
+
+ {header.subtitle}
+
+ {header.actions ? {header.actions} : null}
+ {showCloseButton ? (
+
+ {({ pressed }) => (
+
+ )}
+
+ ) : null}
+
+ {search ? (
+
+
+
+
+ ) : null}
+
+ );
+}
+
+export function InlineHeaderView({ header }: { header: SheetHeader }) {
+ const { theme } = useUnistyles();
+ const back = header.back;
+ const handleBackPress = back?.onPress;
+ const hasInlineRow = Boolean(handleBackPress || header.leading);
+ if (!hasInlineRow && !header.search) return null;
+ return (
+
+ {hasInlineRow ? (
+
+ {handleBackPress ? (
+
+ {({ pressed }) => (
+
+ )}
+
+ ) : null}
+ {header.leading ? {header.leading} : null}
+
+ {header.title}
+
+
+ ) : null}
+ {header.search ? (
+
+
+
+
+ ) : null}
+
+ );
+}
+
export interface AdaptiveModalSheetProps {
- title: string;
- /** Optional content rendered below the title in the header area. */
- subtitle?: ReactNode;
+ header: SheetHeader;
visible: boolean;
onClose: () => void;
children: ReactNode;
- headerActions?: ReactNode;
snapPoints?: string[];
testID?: string;
/** Override the max width of the desktop card. */
@@ -176,12 +412,10 @@ export interface AdaptiveModalSheetProps {
}
export function AdaptiveModalSheet({
- title,
- subtitle,
+ header,
visible,
onClose,
children,
- headerActions,
snapPoints,
testID,
desktopMaxWidth,
@@ -190,7 +424,6 @@ export function AdaptiveModalSheet({
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
- const titleColor = theme.colors.foreground;
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.surface2 }),
@@ -209,7 +442,6 @@ export function AdaptiveModalSheet({
[],
);
- const titleStyle = useMemo(() => [styles.title, { color: titleColor }], [titleColor]);
const desktopCardStyle = useMemo(
() => [styles.desktopCard, desktopMaxWidth != null && { maxWidth: desktopMaxWidth }],
[desktopMaxWidth],
@@ -237,18 +469,7 @@ export function AdaptiveModalSheet({
keyboardBlurBehavior="restore"
accessible={false}
>
-
-
-
- {title}
-
- {subtitle}
-
- {headerActions ? {headerActions} : null}
-
-
-
-
+
{scrollable ? (
-
-
-
- {title}
-
- {subtitle}
-
- {headerActions ? {headerActions} : null}
-
-
-
-
+
{scrollable ? (
);
}
-
-/**
- * TextInput that automatically uses BottomSheetTextInput on mobile
- * for proper keyboard dodging in AdaptiveModalSheet.
- */
-export const AdaptiveTextInput = forwardRef(
- function AdaptiveTextInput(props, ref) {
- const isMobile = useIsCompactFormFactor();
-
- if (isMobile && isNative) {
- return } {...props} />;
- }
-
- return ;
- },
-);
diff --git a/packages/app/src/components/add-host-method-modal.tsx b/packages/app/src/components/add-host-method-modal.tsx
index cfbd255fc..449870e2d 100644
--- a/packages/app/src/components/add-host-method-modal.tsx
+++ b/packages/app/src/components/add-host-method-modal.tsx
@@ -2,9 +2,11 @@ import { useCallback } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste } from "lucide-react-native";
-import { AdaptiveModalSheet } from "./adaptive-modal-sheet";
+import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { isNative } from "@/constants/platform";
+const ADD_CONNECTION_HEADER: SheetHeader = { title: "Add connection" };
+
const styles = StyleSheet.create((theme) => ({
option: {
flexDirection: "row",
@@ -62,7 +64,7 @@ export function AddHostMethodModal({
return (
key + 1, 0);
const clearInput = useCallback(() => {
setHost("");
@@ -288,6 +290,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsPasswordVisible(false);
setIsAdvancedOpen(false);
setAdvancedUri("");
+ bumpInputResetKey();
}, []);
const connectIcon = useMemo(
@@ -411,6 +414,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
setUseTls(next.useTls);
setPassword(next.password);
setErrorMessage("");
+ bumpInputResetKey();
} catch {
setErrorMessage("");
}
@@ -422,7 +426,7 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
return (
key + 1, 0);
const [installingProviderId, setInstallingProviderId] = useState(null);
+ const handleClose = useCallback(() => {
+ setSearch("");
+ bumpSearchResetKey();
+ onClose();
+ }, [onClose]);
+
const installedProviderIds = useMemo(
() => new Set(providerEntries?.map((entry) => entry.provider) ?? []),
[providerEntries],
@@ -157,7 +169,7 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
try {
await patchConfig(buildAcpProviderConfigPatch(entry));
await refresh([entry.id]);
- onClose();
+ handleClose();
} catch (installError) {
Alert.alert(
"Unable to install provider",
@@ -167,14 +179,14 @@ export function AddProviderModal({ serverId, visible, onClose }: AddProviderModa
setInstallingProviderId((current) => (current === entry.id ? null : current));
}
},
- [installingProviderId, onClose, patchConfig, refresh],
+ [installingProviderId, handleClose, patchConfig, refresh],
);
return (
-
{mobileNavOpen && (
-