/** * AIInput — the composer a person types a prompt into. * * ```tsx * * * * } onPress={openSheet} /> * * } onPress={record} /> * * * * ``` * * ## The field grows, and then it stops * * It opens one line tall and follows what is typed into it up to `maxRows`, * after which it holds that height and scrolls its own content. Five rows is * the default because a composer that keeps growing eventually pushes its own * send button off the bottom of the screen, and the row that carries the * actions stays pinned to the bottom of the box either way. * * ## It does not record anything * * There is a recording state, and it draws a live meter, but nothing here * touches the microphone. The app owns the recorder — the permission prompt, * the session, the platform quirks — and passes back a `level` between 0 and 1 * along with a `status`. Pass a Reanimated shared value and a recorder * reporting every 30ms never re-renders anything above this component. * * That division is what keeps the composer free of an audio dependency. * * ## Glass is iOS 26 and above * * The surfaces ask for the system material and get it where it exists. Below * iOS 26, on Android, and for anyone with Reduce Transparency switched on, * they are drawn as solid token surfaces instead. Both are finished looks; see * `Glass` for why nothing is faked on the platforms without it. * * ## Where the props come from * * The root takes `ViewProps`. `Field` takes `TextInputProps`, so * `keyboardType`, `maxLength`, `autoFocus` and the rest work as they always * do. Every part takes `className`. */ import { Children, createContext, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from 'react'; import { AccessibilityInfo, findNodeHandle, Pressable, TextInput, View, useWindowDimensions, type LayoutChangeEvent, type TextInputProps, type ViewProps, } from 'react-native'; import Animated, { Easing, FadeOut, useAnimatedStyle, useSharedValue, withTiming, type EntryExitAnimationFunction, type SharedValue, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { tv } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { useDirection } from '../../hooks/use-direction'; import { AudioLinesIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, IconColorProvider, MicIcon, SendArrowIcon, XIcon, } from '../../icons'; import { hasNativeUI } from '../../native'; import { AnimatedPressable } from '../../primitives/animated-pressable'; import { Glass } from '../../primitives/glass'; import { KeyboardAvoider } from '../../primitives/keyboard-avoider'; import { Text } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { BottomSheet } from '../bottom-sheet'; import { Button } from '../button'; import { Soundwave } from '../soundwave'; import { Switch } from '../switch'; import { AI_INPUT_METRICS, growthBounds, type AIInputSize } from './ai-input-growth'; /** What the app is doing, which is what the trailing button offers to do next. */ export type AIInputStatus = 'ready' | 'recording' | 'submitted' | 'streaming'; /** Corner radius per size, in points. The material rounds itself to these. */ const RADIUS = { sm: 22, md: 26, lg: 30 } as const; /** * Circular control diameter per size. * * Deliberately under the 44pt touch minimum: these sit in a row inside a card * with padding around it, and the card's own padding carries the target the * finger actually gets. A 44pt circle here makes a toolbar that is taller than * the field above it. */ const CONTROL = { sm: 26, md: 30, lg: 34 } as const; /** * The row a platform-drawn control needs, and the space kept under it. * * `Button` frames a native icon button at 44pt — the number that ends the * measurement chain rather than one that sets a look. A row shorter than that * is a row its controls hang out of, over whatever is above them, and a row * with no definite height at all is one they never lay out against: a hosted * view only measures where something above it is fixed on both axes. * * The drawn controls are smaller and are laid out by the row itself, so this * applies only when the controls have been handed over. */ const NATIVE_ROW_HEIGHT = 48; const NATIVE_ROW_FOOT = 4; const EASE = Easing.out(Easing.cubic); const HEIGHT_DURATION = 240; const ENTER_DURATION = 220; const EXIT_DURATION = 140; /** * How far a pushed screen travels in, as a fraction of the sheet's width. * * A third rather than the whole width: the sheet is not a screen, and a slide * the full width of it reads as the app navigating rather than as this surface * going one level deeper. */ const SLIDE_FRACTION = 0.33; const aiInputVariants = tv({ slots: { /* * The card carries a shadow because the material does not carry an edge. * * Glass lifts its own edge against what is behind it, and over a light * background there is nothing for it to lift against — the composer reads * as a faint rectangle on white. The shadow is what separates it, and it * is the same one everywhere rather than a light-mode special case: on a * dark background a black shadow costs nothing and shows nothing. */ root: 'w-full shadow-md', field: 'w-full bg-transparent px-4 font-normal text-foreground', // On one line the field is what takes up the slack between the controls, // and the row's own padding already stands it off the edge. fieldInline: 'flex-1 bg-transparent px-2 font-normal text-foreground', /* * Centred, not bottom-aligned. * * The stacked toolbar sits under the field, so it aligns to the bottom. * Here the field is *between* the controls, and its box is not the height * this component computes: a multiline TextInput carries a vertical inset * of the platform's own on top of any padding given to it. Bottom-aligning * a box whose real height is unknown puts its text below the controls * beside it by however much that inset is — which is what kept happening. * * Centring does not need to know the height. Whatever the field's box * turns out to be, its middle lines up with theirs, and the text sits in * the middle of it. */ row: 'w-full flex-row items-center gap-1.5 p-2', toolbar: 'w-full flex-row items-center gap-2 px-2 pb-2', spacer: 'flex-1', pill: 'flex-row items-center gap-1.5 px-3', recording: 'w-full flex-row items-center gap-3 px-2 pb-2', meter: 'flex-1', }, variants: { size: { sm: { toolbar: 'gap-1.5' }, md: {}, lg: { toolbar: 'gap-2.5' }, }, disabled: { true: { root: 'opacity-60' }, false: {}, }, }, defaultVariants: { size: 'md', disabled: false }, }); /* -------------------------------------------------------------------------- */ /* Root */ /* -------------------------------------------------------------------------- */ interface AIInputContextValue { value: string; setValue: (value: string) => void; status: AIInputStatus; size: AIInputSize; level?: number | SharedValue; disabled: boolean; native: boolean; minRows: number; maxRows: number; focused: boolean; setFocused: (focused: boolean) => void; submit: () => void; stop: () => void; canVoice: boolean; canStop: boolean; recordCancel: () => void; recordConfirm: () => void; voice: () => void; } const AIInputContext = createContext(null); function useAIInput(component: string): AIInputContextValue { const context = useContext(AIInputContext); if (!context) throw new Error(`${component} must be used within an `); return context; } /** * The composer's context if there is one, and nothing if there is not. * * The controls that only want a size and an enabled state work anywhere — a * sheet header, a voice-mode toolbar, a row of their own. Those surfaces * render through a portal or as their own screen, so a root wrapped around the * composer is not above them, and demanding one would make a button throw for * being used where it was designed to be used. */ function useAIInputOptional(): AIInputContextValue | null { return useContext(AIInputContext); } export interface AIInputProps extends Omit { children?: ReactNode; className?: string; /** The prompt, when the app owns it. */ value?: string; /** Called on every keystroke. */ onValueChange?: (value: string) => void; /** The prompt to start with, when the composer owns it. */ defaultValue?: string; /** * What the app is doing. `ready` offers to send, `streaming` offers to stop, * and `recording` swaps the toolbar for the meter and its two decisions. */ status?: AIInputStatus; /** Called with the prompt when it is sent. The composer does not clear itself. */ onSubmit?: (value: string) => void; /** Called when the trailing button is pressed while `streaming`. */ onStop?: () => void; /** Called when the voice button is pressed on an empty composer. */ onVoice?: () => void; /** Called when a recording is thrown away. */ onRecordCancel?: () => void; /** Called when a recording is accepted. */ onRecordConfirm?: () => void; /** * Input level, 0–1, from the app's own recorder. Pass a shared value to keep * metering off the JS thread entirely. Omitted, the meter animates plausible * motion so a screen can be built before any audio exists. */ level?: number | SharedValue; /** Type scale and control size. */ size?: AIInputSize; /** Nothing can be typed, pressed or sent. */ disabled?: boolean; /** * Draw the toolbar's controls as the platform's own buttons, in the system * material — Liquid Glass on iOS 26, the platform's ordinary button style * below it and on Android. * * The platform owns their colour, metrics and shape when this is on, so * `className` and the theme tokens no longer reach them. The card behind * them is still ours, and still glass. Needs the optional `@expo/ui`; * without it the drawn controls are used and nothing breaks. */ native?: boolean; /** Rows the empty field is tall. */ minRows?: number; /** Rows the field grows to before it holds that height and scrolls. */ maxRows?: number; /** Lift the composer clear of the software keyboard. */ avoidKeyboard?: boolean; /** How far above the bottom edge the composer already sits. */ keyboardBottomInset?: number; /** * Gap to leave between the composer and the top of the keyboard. A composer * resting directly on the keys reads as part of them. */ keyboardGap?: number; } function AIInputRoot({ children, className, value: valueProp, onValueChange, defaultValue = '', status = 'ready', onSubmit, onStop, onVoice, onRecordCancel, onRecordConfirm, level, size = 'md', disabled = false, native = false, minRows = 1, maxRows = 5, avoidKeyboard = true, keyboardBottomInset = 0, keyboardGap = 8, style, ...props }: AIInputProps) { const [internalValue, setInternalValue] = useState(defaultValue); const isControlled = valueProp !== undefined; const value = isControlled ? valueProp : internalValue; const [focused, setFocused] = useState(false); const setValue = useCallback( (next: string) => { if (!isControlled) setInternalValue(next); onValueChange?.(next); }, [isControlled, onValueChange] ); const submit = useCallback(() => { const trimmed = value.trim(); if (!trimmed) return; onSubmit?.(value); }, [onSubmit, value]); /* * Whether the controls can actually be handed over. * * `native` is a request, not a fact: the toolkit is an optional peer, and * without it `Button` quietly draws its own control instead — at the size * *it* uses, which is not the size this row is built around. Asking for a * platform control and getting a 44pt drawn one in a row shaped for a 30pt * one is how the buttons ended up over the field. * * So the request is resolved against what is installed, once, and every part * reads the answer rather than the ask. */ const platformControls = native && hasNativeUI(); const context = useMemo( () => ({ value, setValue, status, size, level, disabled, native: platformControls, minRows, maxRows, focused, setFocused, submit, canVoice: onVoice !== undefined, canStop: onStop !== undefined, stop: () => onStop?.(), recordCancel: () => onRecordCancel?.(), recordConfirm: () => onRecordConfirm?.(), voice: () => onVoice?.(), }), [ value, setValue, status, size, level, disabled, platformControls, minRows, maxRows, focused, submit, onStop, onVoice, onRecordCancel, onRecordConfirm, onVoice, ] ); const slots = aiInputVariants({ size, disabled }); /* * Whether the card has to keep room at its foot for a row it does not lay * out. * * A handed-over control is a hosted native view, and a hosted view settles * where the platform puts it rather than where the row it is in ends up — * which, in a card that is also sizing itself to the field above, is over * that field. Reserving the row's height here and pinning the row into the * reservation takes the question away: the field's box stops where the * reservation starts, so a control that draws itself somewhere unexpected * has nothing above it left to cover. * * Read from the children rather than tracked in state, so the card and the * row agree on the first render instead of the second. */ const hasNativeRow = platformControls && Children.toArray(children).some( (child) => isValidElement(child) && (child.type === AIInputToolbar || child.type === AIInputRecording) ); const surface = ( {children} ); /* * The keyboard hook stays behind a component boundary rather than an * `enabled` flag. Calling it at all has global consequences — without the * keyboard controller installed it falls back to Reanimated's * useAnimatedKeyboard, which switches Android out of adjustResize for the * whole app. A composer that was told not to avoid the keyboard must not do * that to every other screen in the app. */ return ( {avoidKeyboard ? ( {surface} ) : ( surface )} ); } /* -------------------------------------------------------------------------- */ /* Field */ /* -------------------------------------------------------------------------- */ export interface AIInputFieldProps extends Omit { className?: string; } function AIInputField({ className, placeholder = 'Ask anything', onFocus, onBlur, style, ...props }: AIInputFieldProps) { const context = useAIInput('AIInput.Field'); const { value, setValue, size, disabled, minRows, maxRows, setFocused } = context; const metrics = AI_INPUT_METRICS[size]; const placeholderColor = useCSSVariable('--color-muted-foreground'); const bounds = growthBounds(metrics, minRows, maxRows); const inline = useContext(AIInputInlineContext); const handleFocus = useCallback>( (event) => { setFocused(true); onFocus?.(event); }, [onFocus, setFocused] ); const handleBlur = useCallback>( (event) => { setFocused(false); onBlur?.(event); }, [onBlur, setFocused] ); /* * Type metrics go through `style` rather than through classes, because the * height is computed from them and a line height set in two places is a line * height that will disagree with itself. */ /* * On one line the field's first row has to be exactly as tall as the buttons * beside it. * * The row aligns to the bottom, so that a field which has grown keeps its * controls at the foot of the box rather than floating them beside the * middle of the text. That alignment is only correct if an *ungrown* field * is the same height as the controls — otherwise its extra padding pushes * the whole row down inside the card, which is a composer whose contents sit * low in it. So the padding is whatever centres one line in a control. */ /* * On one line the field states no line height at all. * * A line height above the font's own exists to separate lines from each * other, and iOS spends the surplus *above* the glyphs rather than splitting * it — so a single line set to 22 over 16pt type sits several points below * the buttons beside it, placeholder and value alike. With none set, the * platform lays the text out in the font's own line box, which sits where a * line of that font is supposed to. * * The box is then exactly one control tall with the natural line centred in * it. `NATURAL_LEADING` is what a line of a given size actually occupies — * it only has to be close, since it decides a padding either side rather * than the height itself. */ const lineHeight = inline ? undefined : metrics.lineHeight; const padding = inline ? 0 : metrics.padding; const boxStyle = useMemo( () => ({ /* * A floor and a ceiling, and no height. The field sizes itself to its * own content between them, which the layout engine does without anyone * measuring anything — and once it reaches the ceiling, a multiline * field scrolls its content instead of growing. * * Deriving a height from `onContentSizeChange` instead is a loop whose * answer differs by platform: one reports the height of the text, the * other the height of the box that was just set from it, and on that one * the field can never grow past the height it opened at. */ minHeight: inline ? undefined : bounds.minHeight, maxHeight: bounds.maxHeight, fontSize: metrics.fontSize, lineHeight, paddingTop: padding, paddingBottom: padding, /* * Android puts the caret in the middle of a multiline box unless told * otherwise, which is wrong for a field that grows downward — and right * for one that is a single line between two buttons. */ textAlignVertical: inline ? ('center' as const) : ('top' as const), }), [bounds.minHeight, bounds.maxHeight, inline, lineHeight, metrics.fontSize, padding] ); const slots = aiInputVariants({ size }); return ( ); } /* -------------------------------------------------------------------------- */ /* Toolbar and its controls */ /* -------------------------------------------------------------------------- */ /** * True inside `AIInput.Row` — the parts that lay out differently on one line * read it rather than taking a prop each, so a caller rearranges the composer * by moving one component instead of setting a flag on three. */ const AIInputInlineContext = createContext(false); export interface AIInputRowProps extends ViewProps { className?: string; } /** * The field and its controls on a single line. * * The composer at its smallest: one pill above the keyboard with the field * between the controls rather than above them. It suits a bar that is always * on screen, where the stacked composer suits one that is the focus of it. * * The field still grows — it just grows the row. * * ```tsx * * * } /> * * } /> * * * * ``` */ function AIInputRow({ className, children, ...props }: AIInputRowProps) { const { size } = useAIInput('AIInput.Row'); const slots = aiInputVariants({ size }); return ( {children} ); } export interface AIInputToolbarProps extends ViewProps { className?: string; } function AIInputToolbar({ className, children, style, ...props }: AIInputToolbarProps) { const { size, native } = useAIInput('AIInput.Toolbar'); const slots = aiInputVariants({ size }); return ( {children} ); } /** Pushes everything after it to the trailing end of the toolbar. */ function AIInputSpacer({ className, ...props }: ViewProps) { return ; } export interface AIInputActionProps extends Omit, Pick { className?: string; /** Names the control. It is a circle with a glyph in it; nothing else says what it does. */ label: string; icon: ReactNode; onPress?: () => void; disabled?: boolean; /** Control size. Inherited from the composer when it is inside one. */ size?: AIInputSize; /** * Draw it as the platform's own button, in the system material. Inherited * from the composer when it is inside one. The platform owns its colour and * shape, so `className` stops reaching it. */ native?: boolean; } function AIInputAction({ className, label, icon, onPress, disabled, size: sizeProp, native, ...props }: AIInputActionProps) { const context = useAIInputOptional(); const scale = sizeProp ?? context?.size ?? 'md'; const size = CONTROL[scale]; const isDisabled = disabled ?? context?.disabled ?? false; const isNative = (native ?? context?.native ?? false) && hasNativeUI(); /* * The platform's own button, in its own material. `Button` already knows how * to ask for that — the circular border shape, the glass style, and the * hosting an icon inside a native control needs — so this is a handoff * rather than a second implementation of it. */ if (isNative) { return ( ); } return ( {icon} ); } export interface AIInputPillProps extends Omit { className?: string; /** The current value — a model name, a mode, a project. */ label: ReactNode; /** A second, quieter value beside it. */ detail?: ReactNode; /** A glyph after the labels, for a pill that opens a list. */ indicator?: ReactNode; /** * The platform's name for `indicator`, used when the pill is handed over. * * A native button takes a glyph as a name from the system's symbol set, not * as an element — an element would have to be hosted, and a hosted view * inside a labelled button has no width anything can resolve. So the two are * separate props rather than one: `indicator` is what the drawn pill * renders, this is what the handed-over one asks the platform for. * * A pill with an `indicator` and no symbol is still handed over, and has no * glyph on it. */ indicatorSymbol?: string; onPress?: () => void; disabled?: boolean; /** Names the control when the label alone does not say what changing it does. */ accessibilityLabel?: string; /** Control size. Inherited from the composer when it is inside one. */ size?: AIInputSize; /** * Draw it as the platform's own button, in the system material. * * The platform is given text and a symbol name, never elements: a hosted * view inside a labelled native button has no width anything can resolve. So * a `label` or `detail` that is not a string is drawn here whatever this * says, and an `indicator` reaches the platform only through * `indicatorSymbol`. * * A handed-over pill is one label in the platform's own type, so `detail` * stops reading as the quieter half of the pair. */ native?: boolean; } function AIInputPill({ className, label, detail, indicator, indicatorSymbol, onPress, disabled, accessibilityLabel, size: sizeProp, native, ...props }: AIInputPillProps) { const context = useAIInputOptional(); const size = sizeProp ?? context?.size ?? 'md'; const height = CONTROL[size]; const isDisabled = disabled ?? context?.disabled ?? false; const slots = aiInputVariants({ size }); /* * Only text goes to the platform. Passing elements makes it host them, and a * hosted view inside a labelled button leaves the width unresolved — which * is not an exception anything here could catch but a crash in native code. * * `detail` is text in every case that matters, so it goes over as part of the * label rather than keeping the pill drawn; the platform sets one label in * one weight, which is the same trade every handed-over control makes. * `indicator` is an element and never goes, which is what `indicatorSymbol` * is for. */ const isNative = (native ?? context?.native ?? false) && hasNativeUI() && typeof label === 'string' && (detail === undefined || typeof detail === 'string'); if (isNative) { return ( ); } return ( {typeof label === 'string' ? ( {label} ) : ( label )} {typeof detail === 'string' ? ( {detail} ) : ( detail )} {indicator} ); } export interface AIInputSubmitProps extends Omit { className?: string; /** Names the button in its send state. */ sendLabel?: string; /** Names it in its voice state, which is what an empty composer offers. */ voiceLabel?: string; /** Names it while the model is answering. */ stopLabel?: string; /** Draw it as the platform's own button, in the system material. */ native?: boolean; } /** * The trailing button, which is three buttons wearing one shape. * * Empty and ready, it opens voice mode; with something typed, it sends; while * the model is answering, it stops. One control rather than three because only * one of them is ever the thing to do, and a row of dimmed siblings is a row * of things that look broken. */ function AIInputSubmit({ className, sendLabel = 'Send', voiceLabel = 'Voice mode', stopLabel = 'Stop', native, ...props }: AIInputSubmitProps) { const context = useAIInput('AIInput.Submit'); const { value, status, size, disabled, submit, stop, voice, canVoice, canStop } = context; const diameter = CONTROL[size] + 2; const streaming = status === 'streaming' || status === 'submitted'; const hasText = value.trim().length > 0; const onSurface = useCSSVariable('--color-primary-foreground'); const onSolid = useCSSVariable('--color-background'); const mode = streaming ? 'stop' : hasText ? 'send' : 'voice'; const label = mode === 'stop' ? stopLabel : mode === 'send' ? sendLabel : voiceLabel; const onPress = mode === 'stop' ? stop : mode === 'send' ? submit : voice; const isNative = (native ?? context.native) && hasNativeUI(); /* * Nothing to send is not something to press. * * Empty, the button offers voice mode — but only if the app took `onVoice`; * with nothing wired to it, it is a live-looking control that does nothing. * Same for stop. So the button is disabled whenever the thing it is offering * cannot happen, which for an empty composer with no voice mode means it * stays inert until something is typed. */ const inert = disabled || (mode === 'send' ? !hasText : mode === 'stop' ? !canStop : !canVoice); const glyph = mode === 'send' ? ( ) : mode === 'stop' ? ( ) : ( ); /* * The platform's own button, tinted. `Button` maps a primary variant onto * `glassProminent`, which is the one that keeps an accent fill — drawing the * material by hand over a plain button throws that fill away, and this is * the one control in the row that is supposed to be filled. */ if (isNative) { return ( ); } return ( {glyph} ); } export interface AIInputRecordingProps extends Omit { className?: string; cancelLabel?: string; confirmLabel?: string; /** Draw the two decisions as the platform's own buttons. */ native?: boolean; } /** * What the toolbar becomes while a recording is running: throw it away, watch * it, or keep it. Render it in place of `AIInput.Toolbar` when `status` is * `recording`. */ function AIInputRecording({ className, cancelLabel = 'Discard recording', confirmLabel = 'Use recording', native, ...props }: AIInputRecordingProps) { const context = useAIInput('AIInput.Recording'); const { size, level, recordCancel, recordConfirm, native: contextNative } = context; const control = CONTROL[size]; const slots = aiInputVariants({ size }); const onPrimary = useCSSVariable('--color-primary-foreground'); const isNative = (native ?? contextNative) && hasNativeUI(); const meter = ( ); if (isNative) { return ( {meter} ); } return ( {meter} ); } /* -------------------------------------------------------------------------- */ /* Sheet */ /* -------------------------------------------------------------------------- */ interface AIInputSheetContextValue { /** Screen ids, root first. The last one is what is on screen. */ stack: string[]; push: (id: string) => void; pop: () => void; close: () => void; } const AIInputSheetContext = createContext(null); function useAIInputSheet(component: string): AIInputSheetContextValue { const context = useContext(AIInputSheetContext); if (!context) throw new Error(`${component} must be used within an `); return context; } export interface AIInputSheetScreenProps extends ViewProps { className?: string; /** Names this screen. `AIInput.Sheet.Row`'s `to` pushes the screen with this id. */ id: string; /** Centred in the header, and the first thing a screen reader reaches. */ title?: ReactNode; /** * A control at the trailing end of the header — a second action the screen * offers. The leading end is the sheet's, and is a close button on the root * screen and a back button on every screen pushed onto it. */ trailing?: ReactNode; } function AIInputSheetScreen({ className, id, title, trailing, children, ...props }: AIInputSheetScreenProps) { void id; void title; void trailing; return ( {children} ); } type ScreenElement = ReactElement; function screensOf(children: ReactNode): ScreenElement[] { return Children.toArray(children).filter( (child): child is ScreenElement => isValidElement(child) && child.type === AIInputSheetScreen ); } export interface AIInputSheetProps { children?: ReactNode; className?: string; open?: boolean; onOpenChange?: (open: boolean) => void; defaultOpen?: boolean; /** Which screen opens first. Defaults to the first one given. */ initialScreen?: string; /** Called whenever the screen on top changes, pushed or popped. */ onScreenChange?: (id: string) => void; /** Frost the screen behind the sheet instead of dimming it. */ blur?: boolean; /** How tall the sheet opens. `auto` sizes to the screen currently on top. */ size?: 'auto' | 'half' | 'full'; /** * Float the sheet clear of the screen edges instead of docking it to the * bottom. On by default: the surface is a material, and a material reads as * laid over the app when there is app visible around all four of its edges. * Docked, its bottom edge is the screen's, and there is nothing behind it * there to refract. */ detached?: boolean; } /** * The sheet the composer's controls open, and the screens it goes on to. * * A row that leads somewhere pushes a screen onto this sheet rather than * opening a second one over it. The sheet stays where it is, the header's * close button becomes a back button, and the body slides — which is what * makes going one level deeper feel like the same surface rather than another * one landing on top of it. * * ```tsx * * * * * * * * … * * ``` */ function AIInputSheet({ children, className, open: openProp, onOpenChange, defaultOpen = false, initialScreen, onScreenChange, blur = false, size = 'auto', detached = true, }: AIInputSheetProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const isControlled = openProp !== undefined; const open = isControlled ? openProp : internalOpen; const screens = screensOf(children); const rootId = initialScreen ?? screens[0]?.props.id ?? ''; const [stack, setStack] = useState(rootId ? [rootId] : []); const setOpen = useCallback( (next: boolean) => { if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange] ); /* * A closed sheet forgets where it was. Reopening onto a screen somebody * pushed and then dismissed is a sheet that answers a question nobody asked * again. Done on the way out rather than the way in, where the reset would * be visible for a frame before the sheet arrived. */ useEffect(() => { if (!open && rootId) setStack([rootId]); }, [open, rootId]); const push = useCallback( (id: string) => { setStack((current) => (current[current.length - 1] === id ? current : [...current, id])); onScreenChange?.(id); }, [onScreenChange] ); const pop = useCallback(() => { setStack((current) => { if (current.length <= 1) return current; const next = current.slice(0, -1); const top = next[next.length - 1]; if (top) onScreenChange?.(top); return next; }); }, [onScreenChange]); const close = useCallback(() => setOpen(false), [setOpen]); const context = useMemo( () => ({ stack, push, pop, close }), [stack, push, pop, close] ); const activeId = stack[stack.length - 1]; const active = screens.find((screen) => screen.props.id === activeId) ?? screens[0]; const depth = stack.length; const insets = useSafeAreaInsets(); // A sheet with no screens has nothing to show and no title to head it with. // Below every hook, so the count does not change with the children. if (!active) return null; return ( {/* * Inside the content, not around the sheet. `BottomSheet.Content` * renders through a portal, so everything below it mounts under the * portal host rather than here — a provider wrapped around the sheet * is not an ancestor of the header that reads it. */} {/* * A solid surface, not a material. * * A sheet covers most of the screen, so there is almost nothing * behind it left to refract — the material reads as a grey wash over * a blur of nothing rather than as glass, and the controls sitting on * it lose their own material to it. The composer keeps the glass, * because a bar floating over a page is the case the material is for. * * Four rounded corners when it floats, two when it is docked: the * bottom edge of a docked sheet is the screen's edge, and rounding a * corner there rounds nothing. */} {active} ); } function AIInputSheetHeader({ depth, title, trailing, }: { depth: number; title?: ReactNode; trailing?: ReactNode; }) { const { pop, close } = useAIInputSheet('AIInput.Sheet'); const rtl = useDirection() === 'rtl'; const nested = depth > 1; const Back = rtl ? ChevronRightIcon : ChevronLeftIcon; return ( {nested ? : } {/* * Centred against the sheet rather than against what is left over, so the * title does not shift when the trailing action appears on one screen and * not the next. It takes no touches, so it cannot bury the buttons at * either end the way an absolutely positioned full-width title would. */} {typeof title === 'string' ? ( {title} ) : ( title )} {trailing} ); } function AIInputSheetBody({ activeId, depth, className, children, }: { activeId?: string; depth: number; className?: string; children: ReactNode; }) { const { width } = useWindowDimensions(); const height = useSharedValue(-1); const paneRef = useRef(null); /* * Whether the screen is lifted out of the flow onto its own layer. * * Not on the first layout, which is what a wizard inside a fixed frame can * afford. A sheet sized to its content is still sliding up while its first * screen measures, and switching the screen to absolute mid-flight takes the * height out from under the sheet and puts it back a frame later — which is * the sheet arriving with a stutter in it. * * So the first screen stays in the flow and the sheet opens around a real * height. The layer is only needed once two screens have to overlap, which * is the moment somebody navigates, and by then the height below is already * recorded to travel from. */ const [layered, setLayered] = useState(false); /* * Which way the screen slides, worked out from the move rather than from * whatever caused it — a row, a back button and a caller pushing directly * are the same move to the reader, and only the change in depth says which * way it went. * * Read during render, not in an effect: the arriving screen's animation is * fixed when it mounts, and an effect runs after that, which would leave * every transition playing the direction of the one before it. */ const previousDepth = useRef(depth); const direction: 1 | -1 = depth >= previousDepth.current ? 1 : -1; const navigated = useRef(false); useEffect(() => { if (depth !== previousDepth.current) { previousDepth.current = depth; navigated.current = true; } }, [depth]); // The first navigation is what needs two screens on top of each other. useEffect(() => { if (navigated.current) setLayered(true); }, [activeId]); // Move the reader onto the screen that just arrived, rather than leaving // focus on the row that is no longer there. useEffect(() => { if (!navigated.current) return; const node = paneRef.current; if (!node) return; const tag = findNodeHandle(node); if (tag != null) AccessibilityInfo.setAccessibilityFocus(tag); }, [activeId]); /* * Until the first screen has been measured the pane stays in the flow, so a * sheet sized to its content has a real height on the first pass. Absolute * from the start would measure zero — the container's only child would * contribute nothing — and the sheet would open around a screen nobody can * see. */ const onPaneLayout = useCallback( (event: LayoutChangeEvent) => { const next = event.nativeEvent.layout.height; if (next <= 0) return; // Recorded whether or not it is being used yet, so the first push has a // height to travel from rather than growing out of nothing. if (layered) { height.value = withTiming(next, { duration: HEIGHT_DURATION, easing: EASE }); return; } height.value = next; }, [layered, height] ); const heightStyle = useAnimatedStyle(() => height.value < 0 ? {} : { height: height.value } ); /* * Written out rather than taken from the stock builders, which carry an * initial opacity and nothing else — the distance is the part worth * controlling here, and none of them lets it be set. */ const offset = width > 0 ? width * SLIDE_FRACTION : 60; const entering = useCallback(() => { 'worklet'; return { initialValues: { opacity: 0, transform: [{ translateX: direction * offset }] }, animations: { opacity: withTiming(1, { duration: ENTER_DURATION, easing: EASE }), transform: [{ translateX: withTiming(0, { duration: ENTER_DURATION, easing: EASE }) }], }, }; }, [direction, offset]); return ( {children} ); } /* -------------------------------------------------------------------------- */ /* Sheet content */ /* -------------------------------------------------------------------------- */ export interface AIInputSheetGroupProps extends ViewProps { className?: string; /** A line under the group, for what the rows in it mean. */ footnote?: ReactNode; } /** A card of related rows, with a hairline between each pair. */ function AIInputSheetGroup({ className, footnote, children, ...props }: AIInputSheetGroupProps) { const rows = Children.toArray(children); return ( {rows.map((row, index) => ( {index > 0 ? : null} {row} ))} {typeof footnote === 'string' ? ( {footnote} ) : ( footnote )} ); } export interface AIInputSheetRowProps extends Omit { className?: string; label: ReactNode; /** A quieter line under the label. */ description?: ReactNode; /** A glyph at the leading end. */ icon?: ReactNode; /** The current setting, shown at the trailing end. */ value?: ReactNode; /** * Push the screen with this id when the row is pressed. A row with one shows * a chevron, because a row that leads somewhere should say so before it is * pressed. */ to?: string; onPress?: () => void; disabled?: boolean; } function AIInputSheetRow({ className, label, description, icon, value, to, onPress, disabled = false, ...props }: AIInputSheetRowProps) { const { push } = useAIInputSheet('AIInput.Sheet.Row'); const rtl = useDirection() === 'rtl'; const Chevron = rtl ? ChevronLeftIcon : ChevronRightIcon; const handlePress = useCallback(() => { onPress?.(); if (to) push(to); }, [onPress, push, to]); const body = ( <> {icon} {typeof label === 'string' ? {label} : label} {typeof description === 'string' ? ( {description} ) : ( description )} {typeof value === 'string' ? ( {value} ) : ( value )} {to ? : null} ); const interactive = Boolean(to || onPress); if (!interactive) { return ( {body} ); } return ( {body} ); } export interface AIInputSheetToggleProps extends Omit { className?: string; label: ReactNode; description?: ReactNode; icon?: ReactNode; value: boolean; onValueChange?: (value: boolean) => void; disabled?: boolean; } function AIInputSheetToggle({ className, label, description, icon, value, onValueChange, disabled = false, ...props }: AIInputSheetToggleProps) { return ( {icon} {typeof label === 'string' ? {label} : label} {typeof description === 'string' ? ( {description} ) : ( description )} ); } export interface AIInputSheetChoiceProps extends Omit { className?: string; label: ReactNode; description?: ReactNode; /** A pill beside the label — what the choice costs, or what it needs. */ badge?: ReactNode; selected?: boolean; onPress?: () => void; disabled?: boolean; } /** One of a set, with a tick on the one in force. */ function AIInputSheetChoice({ className, label, description, badge, selected = false, onPress, disabled = false, ...props }: AIInputSheetChoiceProps) { const tint = useCSSVariable('--color-primary'); return ( {typeof label === 'string' ? ( {label} ) : ( label )} {badge} {typeof description === 'string' ? ( {description} ) : ( description )} {selected ? ( ) : null} ); } /* -------------------------------------------------------------------------- */ /* Voice mode */ /* -------------------------------------------------------------------------- */ export interface AIInputVoiceModeProps extends Omit { className?: string; /** The controls in the bottom row, before the close button. */ children?: ReactNode; /** What the app is doing, which is what the wave behind everything shows. */ state?: 'idle' | 'listening' | 'thinking' | 'speaking'; /** Input level, 0–1. A shared value keeps metering off the JS thread. */ level?: number | SharedValue; /** A line above the microphone — a greeting, or what it is waiting for. */ title?: ReactNode; onMicPress?: () => void; micLabel?: string; onClose?: () => void; closeLabel?: string; /** Type scale for the controls in the bottom row. */ size?: AIInputSize; /** Draw the bottom row's controls as the platform's own, in its material. */ native?: boolean; } /** * The screen a voice conversation happens on: no field, no keyboard, one * microphone and a way out. * * It is a surface rather than an overlay — render it as its own route or * inside a modal of your own. It provides the composer's context, so * `AIInput.Action` and `AIInput.Pill` work in the bottom row without an * `AIInput` around them. */ function AIInputVoiceMode({ className, children, state = 'listening', level, title, onMicPress, micLabel = 'Mute', onClose, closeLabel = 'End voice mode', size = 'md', native = false, ...props }: AIInputVoiceModeProps) { const insets = useSafeAreaInsets(); const onSolid = useCSSVariable('--color-background'); const control = CONTROL[size] + 4; const context = useMemo( () => ({ value: '', setValue: () => {}, status: 'ready', size, level, disabled: false, native: native && hasNativeUI(), minRows: 1, maxRows: 5, focused: false, setFocused: () => {}, submit: () => {}, stop: () => {}, canVoice: false, canStop: false, recordCancel: () => {}, recordConfirm: () => {}, voice: () => {}, }), [level, native, size] ); return ( {/* Behind everything, and taking no touches: the wash that says the screen is listening without putting a control in the way of it. */} {typeof title === 'string' ? ( {title} ) : ( title )} {children} {native && hasNativeUI() ? ( ) : ( )} ); } /* -------------------------------------------------------------------------- */ AIInputRoot.displayName = 'AIInput'; AIInputField.displayName = 'AIInput.Field'; AIInputRow.displayName = 'AIInput.Row'; AIInputToolbar.displayName = 'AIInput.Toolbar'; AIInputSpacer.displayName = 'AIInput.Spacer'; AIInputAction.displayName = 'AIInput.Action'; AIInputPill.displayName = 'AIInput.Pill'; AIInputSubmit.displayName = 'AIInput.Submit'; AIInputRecording.displayName = 'AIInput.Recording'; AIInputSheetScreen.displayName = 'AIInput.Sheet.Screen'; AIInputSheetGroup.displayName = 'AIInput.Sheet.Group'; AIInputSheetRow.displayName = 'AIInput.Sheet.Row'; AIInputSheetToggle.displayName = 'AIInput.Sheet.Toggle'; AIInputSheetChoice.displayName = 'AIInput.Sheet.Choice'; AIInputSheet.displayName = 'AIInput.Sheet'; AIInputVoiceMode.displayName = 'AIInput.VoiceMode'; const Sheet = Object.assign(AIInputSheet, { Screen: AIInputSheetScreen, Group: AIInputSheetGroup, Row: AIInputSheetRow, Toggle: AIInputSheetToggle, Choice: AIInputSheetChoice, }); export const AIInput = Object.assign(AIInputRoot, { Field: AIInputField, Row: AIInputRow, Toolbar: AIInputToolbar, Spacer: AIInputSpacer, Action: AIInputAction, Pill: AIInputPill, Submit: AIInputSubmit, Recording: AIInputRecording, Sheet, VoiceMode: AIInputVoiceMode, });