Install

npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/prompt-input.json

Dependencies

https://reactnativereusables.com/r/nativewind/button.json
https://reactnativereusables.com/r/nativewind/icon.json
https://rnraielements.com/r/nativewind/input-group.json

AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92

Source

prompt-input.tsx
import { Button } from '@/registry/nativewind/components/ui/button'; import { Icon } from '@/registry/nativewind/components/ui/icon'; import { InputGroup, InputGroupActions, InputGroupInput, } from '@/registry/nativewind/components/ui/input-group'; import { Item, ItemContent, ItemMedia, ItemTitle } from '@/registry/nativewind/components/ui/item'; import { Sheet, SheetContent, SheetTitle, } from '@/registry/nativewind/components/ui/sheet'; import { Text, TextClassContext } from '@/registry/nativewind/components/ui/text'; import { cn } from '@/registry/nativewind/lib/utils'; import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; import { ArrowUpIcon, CheckIcon, FileIcon, PaperclipIcon, PlusIcon, SquareIcon, XIcon, type LucideIcon, } from 'lucide-react-native'; import * as React from 'react'; import { ActivityIndicator, Image, ScrollView, View, type ViewProps } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { type PickerKind, type PromptInputAttachmentData, type PromptInputError, type PromptInputMessage, imageAssetToAttachment, makeAttachmentId, resolvePickerKind, stripIds, validateAttachments, } from './prompt-input.logic'; import { getMediaCategory } from './attachments.logic'; /** * PromptInput — the composer, at web parity for composition (behavior spec: * design/refine/prompt-input-web-behavior-spec.md; visuals: prompt-input-design-spec.md). * * THE SUBMIT CONTRACT IS DELIBERATELY DIFFERENT. The web version is Enter-submits, * Shift+Enter-newline, with an IME-composition guard. On a soft keyboard none of that * exists: Enter is a newline and there is no modifier. So an explicit SEND BUTTON is * the ONLY submit path, and the ready-state icon is ArrowUp (the mobile idiom) where * the web renders CornerDownLeft (a keyboard glyph that means nothing under a thumb). * That is a real behavioural change, made once, on purpose. * * WHAT IS PRESERVED, because it is the rule that protects the user: * a resolved onSubmit clears the text AND the attachments; an onSubmit that THROWS * OR REJECTS leaves both exactly where they were. Losing someone's typed message on * a network failure is the failure this exists to prevent. * * Composition (web parity — the consumer composes the field): * <PromptInput onSubmit status onStop accept …> * <PromptInputHeader><PromptInputAttachments /></PromptInputHeader> ← first row * <PromptInputBody> * <PromptInputTextarea /> * <PromptInputFooter> * <PromptInputTools> * <PromptInputToolsMenuTrigger /> ← the + (menu content lives below) * <ModelSelectorTrigger … /> ← the model chip (consumer composes) * </PromptInputTools> * <PromptInputPrimary renderVoice={…} /> ← never-dead slot (morph table) * </PromptInputFooter> * </PromptInputBody> * <PromptInputToolsMenu open onOpenChange> ← portals; render it anywhere * <PromptInputToolsMenuItem icon={CameraIcon} label="Camera" … /> * </PromptInputToolsMenu> * </PromptInput> * The Header moved from the old dock slot to the field's first row (the web keeps its * header INSIDE the InputGroup, order-first) — that is what puts the chip row above * the textarea and inside the border, per the design spec §3. * * Dual-mode: wrap in <PromptInputProvider> to lift text + attachments into context * (usePromptInputController from anywhere under it); render bare for self-managed * state. Validation and the picker launcher live in the component either way, so the * provider's raw add is never reachable unvalidated. * * NOT PORTED, on the record (brief §non-goals): the ActionMenu dropdown parts (the * tools menu composes the house Sheet directly — PromptInputToolsMenu), AddScreenshot * (getDisplayMedia has no RN equivalent), HoverCard/Command/Tabs (@-palette is * hover/keyboard-shaped; out of PRD scope), the referenced-sources context, and the * PromptInputSelect parts (model switching composes the existing model-selector * organism — no duplicate select parts). * * Dependency note: expo-image-picker + expo-document-picker are opt-in install-time * dependencies recorded on the registry item (webview precedent); the ambient type * boundary in the registry package keeps typecheck green without them installed. */ export type ChatStatus = 'ready' | 'submitted' | 'streaming' | 'error'; export type { PickerKind, PromptInputAttachmentData, PromptInputError, PromptInputMessage, } from './prompt-input.logic'; /* ------------------------------------------------------------------ context -- */ type PromptInputTextInput = { value: string; setInput: (v: string) => void; clear: () => void; }; /** The provider's RAW attachments — no validation. PromptInput wraps it; nothing else reaches it. */ type PromptInputAttachmentsState = { files: PromptInputAttachmentData[]; add: (files: PromptInputAttachmentData[]) => void; remove: (id: string) => void; clear: () => void; }; type PromptInputController = { textInput: PromptInputTextInput; attachments: PromptInputAttachmentsState; /** PromptInput registers its picker launcher; an external trigger can then open it. */ __registerPicker: (open: ((kind?: PickerKind) => void) | null) => void; __openPicker: (kind?: PickerKind) => void; }; const ControllerContext = React.createContext<PromptInputController | null>(null); /** * Lifts text + attachments into plain useState context — the web original's shape, * no store. Dual-mode PromptInput detects this context; nothing else has to. */ function PromptInputProvider({ initialInput = '', children, }: { initialInput?: string; children: React.ReactNode; }) { const [text, setText] = React.useState(initialInput); const [files, setFiles] = React.useState<PromptInputAttachmentData[]>([]); const pickerRef = React.useRef<((kind?: PickerKind) => void) | null>(null); const value = React.useMemo<PromptInputController>( () => ({ textInput: { value: text, setInput: setText, clear: () => setText('') }, attachments: { files, add: (incoming) => setFiles((prev) => [...prev, ...incoming]), remove: (id) => setFiles((prev) => prev.filter((f) => f.id !== id)), clear: () => setFiles([]), }, __registerPicker: (open) => { pickerRef.current = open; }, __openPicker: (kind) => { pickerRef.current?.(kind); }, }), [text, files], ); return <ControllerContext.Provider value={value}>{children}</ControllerContext.Provider>; } /** Actionable, or the lift silently does nothing and the consumer debugs by staring. */ function usePromptInputController(): PromptInputController { const ctx = React.useContext(ControllerContext); if (!ctx) { throw new Error( 'usePromptInputController must be used inside <PromptInputProvider>. Wrap the component that needs the lifted state: <PromptInputProvider>…</PromptInputProvider>.', ); } return ctx; } /** What PromptInput provides to its subtree — the raw state plus its own machinery. */ type PromptInputAttachmentsValue = PromptInputAttachmentsState & { /** Validated add (accept / maxFileSize / maxFiles) in BOTH modes — raw adds escape here. */ add: (files: PromptInputAttachmentData[]) => void; /** Opens a native picker; routes kind 'media' | 'file' from `accept` when omitted. */ openPicker: (kind?: PickerKind) => void; }; const AttachmentsContext = React.createContext<PromptInputAttachmentsValue | null>(null); function usePromptInputAttachments(): PromptInputAttachmentsValue { const ctx = React.useContext(AttachmentsContext); if (!ctx) { throw new Error( 'usePromptInputAttachments must be used inside <PromptInput>. Attachment parts read the composer\'s state; wrap them in <PromptInput>…</PromptInput>.', ); } return ctx; } /** The field's text + submit machinery, local or provider-backed — Submit and Textarea read it. */ type PromptInputFieldValue = { text: string; setText: (v: string) => void; submit: () => void; busy: boolean; status: ChatStatus; onStop?: () => void; placeholder: string; maxHeight: number; }; const FieldContext = React.createContext<PromptInputFieldValue | null>(null); function usePromptInputField(): PromptInputFieldValue { const ctx = React.useContext(FieldContext); if (!ctx) { throw new Error( 'PromptInput sub-components must be used inside <PromptInput>. Wrap them in <PromptInput>…</PromptInput>.', ); } return ctx; } /* --------------------------------------------------------------------- root -- */ type PromptInputProps = Omit<ViewProps, 'children'> & { onSubmit: (message: PromptInputMessage) => void | Promise<void>; onStop?: () => void; status?: ChatStatus; placeholder?: string; /** Grow to this many px, then scroll internally. */ maxHeight?: number; /** Comma MIME list with image/* wildcards — routes the picker and filters the picks. */ accept?: string; multiple?: boolean; maxFiles?: number; /** Bytes. */ maxFileSize?: number; onError?: (error: PromptInputError) => void; children?: React.ReactNode; }; function PromptInput({ onSubmit, onStop, status = 'ready', placeholder = 'What would you like to know?', maxHeight = 140, accept, multiple = true, maxFiles, maxFileSize, onError, className, children, ...props }: PromptInputProps) { const controller = React.useContext(ControllerContext); const insets = useSafeAreaInsets(); // Dual-mode: text and attachments live in the provider when there is one, in local // state when there is not. Everything below is written against the resolved view. const [localText, setLocalText] = React.useState(''); const text = controller ? controller.textInput.value : localText; const setText = controller ? controller.textInput.setInput : setLocalText; const [localFiles, setLocalFiles] = React.useState<PromptInputAttachmentData[]>([]); const files = controller ? controller.attachments.files : localFiles; const rawAdd = controller ? controller.attachments.add : (incoming: PromptInputAttachmentData[]) => setLocalFiles((prev) => [...prev, ...incoming]); const remove = controller ? controller.attachments.remove : (id: string) => setLocalFiles((prev) => prev.filter((f) => f.id !== id)); const clear = controller ? controller.attachments.clear : () => setLocalFiles([]); const [busy, setBusy] = React.useState(false); // The ONE validated add, both modes — validation wraps whatever owns the state. const add = React.useCallback( (candidates: PromptInputAttachmentData[]) => { const { accepted, errors } = validateAttachments(candidates, { accept, maxFileSize, maxFiles, currentCount: files.length, }); for (const error of errors) onError?.(error); if (accepted.length > 0) rawAdd(accepted); }, [accept, maxFileSize, maxFiles, onError, rawAdd, files.length], ); const openPicker = React.useCallback( async (kind?: PickerKind) => { // Explicit kinds (the + menu's rows) route by NAME; only the omitted kind is // derived from `accept` — resolvePickerKind never returns 'camera', so an // untargeted open can never land in the camera roll. const route = kind ?? resolvePickerKind(accept); let candidates: PromptInputAttachmentData[] = []; if (route === 'media' || route === 'camera') { if (route === 'camera') { // PERMISSION IS NOT CAPABILITY: a denied camera is a CANCELED pick — // nothing reaches the validated add(), so denial can never surface as a // fake success. Same early return as result.canceled below. const permission = await ImagePicker.requestCameraPermissionsAsync(); if (!permission.granted) return; } // The camera is single-shot by nature (no allowsMultipleSelection); both // paths map through prompt-input.logic's ONE asset mapper, so a shot is // byte-identical to a library pick and rides the SAME validated add(). const result = route === 'camera' ? await ImagePicker.launchCameraAsync({ mediaTypes: ['images', 'videos'], quality: 1, }) : await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images', 'videos'], allowsMultipleSelection: multiple, selectionLimit: multiple ? undefined : 1, quality: 1, }); if (result.canceled) return; candidates = result.assets.map(imageAssetToAttachment); } else { const result = await DocumentPicker.getDocumentAsync({ multiple }); if (result.canceled) return; candidates = result.assets.map((asset) => ({ id: makeAttachmentId(), mediaType: asset.mimeType ?? '', filename: asset.name, url: asset.uri, size: asset.size ?? undefined, })); } add(candidates); }, [accept, multiple, add], ); // Provider-lifted triggers (web's __registerFileInput seam): an external consumer // calls controller.__openPicker and lands HERE, validated like any other entry. React.useEffect(() => { if (!controller) return; controller.__registerPicker(openPicker); return () => controller.__registerPicker(null); }, [controller, openPicker]); const streaming = status === 'submitted' || status === 'streaming'; const submit = React.useCallback(() => { if (busy || streaming) return; const value = text.trim(); if (!value) return; setBusy(true); Promise.resolve() .then(() => onSubmit({ text: value, files: stripIds(files) })) .then(() => { // Only on success. A rejection leaves the text AND the attachments exactly // where they were — the retry-friendly contract, byte-verbatim from the // wave-3 component this refactor replaces. setText(''); clear(); }) .catch(() => { // Intentionally swallowed here: the caller owns error reporting, and the // user's message must survive. Re-clearing or re-throwing would lose it. }) .finally(() => setBusy(false)); }, [busy, streaming, text, files, onSubmit, setText, clear]); const fieldValue = React.useMemo<PromptInputFieldValue>( () => ({ text, setText, submit, busy, status, onStop, placeholder, maxHeight }), [text, setText, submit, busy, status, onStop, placeholder, maxHeight], ); const attachmentsValue = React.useMemo<PromptInputAttachmentsValue>( () => ({ files, add, remove, clear, openPicker }), [files, add, remove, clear, openPicker], ); return ( <View className={cn('gap-2 bg-background px-4 pt-2', className)} // The home indicator and the Android gesture bar both sit here. style={{ paddingBottom: insets.bottom || 8 }} {...props} > <FieldContext.Provider value={fieldValue}> <AttachmentsContext.Provider value={attachmentsValue}> <InputGroup className="flex-col items-stretch gap-2 rounded-xl px-2 py-2"> {children} </InputGroup> </AttachmentsContext.Provider> </FieldContext.Provider> </View> ); } /* -------------------------------------------------------------------- parts -- */ /** First row inside the field — attachment chips, a model trigger, queued messages. */ function PromptInputHeader({ className, ...props }: ViewProps) { return <View className={cn('flex-row items-center gap-2', className)} {...props} />; } /** Layout passthrough (the web's display:contents, as a View — RN has no contents). */ function PromptInputBody({ className, ...props }: ViewProps) { return <View className={cn('flex-col gap-2', className)} {...props} />; } /** * Provider-controlled or self-managed — it cannot tell and does not care: the value * arrives resolved through field context. Auto-grow to a cap, then scroll internally — * the RN equivalent of the web original's field-sizing CSS. */ function PromptInputTextarea({ className, ...props }: React.ComponentProps<typeof InputGroupInput>) { const field = usePromptInputField(); const [height, setHeight] = React.useState(0); // The original reset the measured height on submit; the extracted part achieves the // same instant re-collapse by watching for the cleared value. React.useEffect(() => { if (field.text === '') setHeight(0); }, [field.text]); return ( <InputGroupInput multiline placeholder={field.placeholder} value={field.text} onChangeText={field.setText} onContentSizeChange={(e) => setHeight(e.nativeEvent.contentSize.height)} style={{ height: Math.min(Math.max(40, height), field.maxHeight) }} // flex-none is load-bearing: the base atom ships flex-1 (its ROW shell needs // grow/basis-0), but in this column flex-basis-0 makes Yoga size the field to // its CONTENT height (one 16px empty line), which both ignores the inline // height below and CLIPS the placeholder to that sliver — invisible on device // (measured: host 320x16 with the style-height right there in the fiber). // Height here is style-driven per the design spec; flex-1's grow/shrink must // not fight it. className="max-h-40 px-2 py-2 flex-none" accessibilityLabel="Message" {...props} /> ); } /** * Addon row. `justify-between` is not a class here — the submit carries ml-auto, which * pins it right when a cluster is present and costs nothing when it is not. */ function PromptInputFooter({ className, ...props }: ViewProps) { return <View className={cn('flex-row items-center', className)} {...props} />; } /** Left cluster — a plain container, no state, no default children. */ function PromptInputTools({ className, ...props }: ViewProps) { return <InputGroupActions className={cn('gap-1', className)} {...props} />; } /** * Ghost icon button, 44pt. `active` is the RN adaptation of the web's variant swap * (globe toggled on): bg-accent + foreground icon + selected. NO built-in toggle * state — the consumer owns it, exactly as the web's docs example does. * Icon color rides TextClassContext (muted at rest, foreground when active); children * icons never re-specify it. */ function PromptInputButton({ active = false, className, children, ...props }: React.ComponentProps<typeof Button> & { active?: boolean }) { return ( <TextClassContext.Provider value={cn('text-muted-foreground', active && 'text-foreground')}> <Button variant="ghost" size="icon" className={cn('size-11', active && 'bg-accent', className)} accessibilityState={{ selected: active }} {...props} > {children} </Button> </TextClassContext.Provider> ); } /** * The paperclip. Opens the picker the `accept` prop routes to (media, or documents). * KEPT for direct-open consumers and web parity — it leaves the DEFAULT pose in favor * of the + menu (PromptInputToolsMenuTrigger), but nothing about it changed. */ function PromptInputActionAddAttachments({ className, ...props }: Omit<React.ComponentProps<typeof PromptInputButton>, 'onPress' | 'children'>) { const { openPicker } = usePromptInputAttachments(); return ( <PromptInputButton onPress={() => openPicker()} accessibilityLabel="Add photos and files" className={className} {...props} > <Icon as={PaperclipIcon} size={20} /> </PromptInputButton> ); } /* ------------------------------------------------------------- tools menu -- */ /** * The + menu — the composer's ONE rest entry point (progressive disclosure): a house * bottom Sheet whose rows are CONSUMER CHILDREN. The menu is content-agnostic — it * knows nothing about cameras, files or web search; it renders whatever Item rows you * give it. A dialog with no title is an a11y hole (command precedent), so a * SheetTitle always renders. * * Controlled only: the trigger sits in the tools ROW (next to the model chip) while * this portals full-width from the bottom edge — one `open` state, owned by the * consumer, wires both. THE PORTAL BREAKS REACT CONTEXT: the rows render inside the * portaled SheetContent, where no provider above this component is visible (measured * on device — a context-reading Item threw the moment the sheet opened). So rows are * DUMB: they render exactly what you pass and fire your onPress; they never * auto-close. Close the menu inside your own onPress via your own onOpenChange * (fire your handler first, then close — a handler that opens a picker must not * race the sheet's exit). Anything overlay-shaped opened FROM these rows must * target the sheet's own portal host (pass `portalHostName` and hand it to the * nested overlay's `portalHost`), or it renders silently behind the sheet. */ type PromptInputToolsMenuProps = { open: boolean; onOpenChange: (open: boolean) => void; /** The sheet's screen-reader title. */ title?: string; /** Unique host name — set it when you nest an overlay inside the rows. */ portalHostName?: string; className?: string; children?: React.ReactNode; }; function PromptInputToolsMenu({ open, onOpenChange, title = 'Add content and tools', portalHostName, className, children, }: PromptInputToolsMenuProps) { return ( <Sheet open={open} onOpenChange={onOpenChange}> <SheetContent side="bottom" portalHostName={portalHostName} className={className}> <SheetTitle className="text-base font-semibold text-foreground">{title}</SheetTitle> <View className="gap-1">{children}</View> </SheetContent> </Sheet> ); } /** * The +: the single always-visible tools affordance. A 44pt ghost button (the * house floor) that opens the controlled menu — `expanded` is the consumer's menu * `open`, fed straight into accessibilityState. */ type PromptInputToolsMenuTriggerProps = Omit< React.ComponentProps<typeof PromptInputButton>, 'children' | 'onPress' > & { onPress: () => void; /** Pass the menu's `open` — it drives accessibilityState.expanded. */ expanded?: boolean; }; function PromptInputToolsMenuTrigger({ expanded = false, className, ...props }: PromptInputToolsMenuTriggerProps) { return ( <PromptInputButton accessibilityLabel="Add content and tools" accessibilityState={{ expanded }} className={className} {...props} > <Icon as={PlusIcon} size={20} /> </PromptInputButton> ); } /** * One menu row — ChatGPT's icon-chip anatomy: the icon in a muted 32px chip, the * label, then the check-trailing-row pattern from model-selector's renderRow (a * reserved 16px slot keeps rows from shifting when `selected` flips). The toggle * STATE is the consumer's; the menu only displays it. Press fires the consumer's * handler first, then closes the sheet — ordering matters: a handler that opens a * picker must not race the sheet's exit. */ type PromptInputToolsMenuItemProps = { icon: LucideIcon; label: string; /** Renders the trailing check (toggle state is consumer-owned). */ selected?: boolean; onPress: () => void; className?: string; }; function PromptInputToolsMenuItem({ icon, label, selected = false, onPress, className, }: PromptInputToolsMenuItemProps) { // Dumb row on purpose — see the portal note on PromptInputToolsMenu: the sheet's // portal boundary carries no React context, so this row can neither see nor close // the menu. Fire the consumer's handler; the consumer closes via its own // onOpenChange inside that handler. return ( <Item variant={selected ? 'muted' : 'default'} onPress={onPress} className={className} > <ItemMedia> <View className="size-8 items-center justify-center rounded-md bg-muted"> <Icon as={icon} size={16} className="text-muted-foreground" /> </View> </ItemMedia> <ItemContent> <ItemTitle>{label}</ItemTitle> </ItemContent> {selected ? ( <Icon as={CheckIcon} size={16} className="ml-auto" /> ) : ( <View className="ml-auto size-4" /> )} </Item> ); } /** * Horizontal chip row — the house law (scroll, never wrap; wrap would stretch the * field's height under the open keyboard). Null when empty: zero height, no seam. */ function PromptInputAttachments({ contentContainerClassName, ...props }: React.ComponentProps<typeof ScrollView>) { const { files, remove } = usePromptInputAttachments(); if (files.length === 0) return null; return ( <ScrollView horizontal showsHorizontalScrollIndicator={false} keyboardShouldPersistTaps="handled" contentContainerClassName={cn('flex-row gap-2 py-1', contentContainerClassName)} {...props} > {files.map((file) => ( <PromptInputAttachment key={file.id} file={file} onRemove={() => remove(file.id)} /> ))} </ScrollView> ); } /** The chip — one anatomy; the image thumbnail and the file-icon box swap places. */ function PromptInputAttachment({ file, onRemove, className, }: { file: PromptInputAttachmentData; onRemove: () => void; className?: string; }) { const isImage = getMediaCategory(file.mediaType) === 'image'; return ( <View className={cn( 'flex-row items-center gap-2 rounded-md border border-border bg-muted p-1', className, )} > {isImage ? ( <Image source={{ uri: file.url }} className="size-8 rounded-md bg-muted" /> ) : ( <View className="size-8 items-center justify-center rounded-md bg-muted"> <Icon as={FileIcon} size={16} className="text-muted-foreground" /> </View> )} <Text numberOfLines={1} className="max-w-24 truncate text-sm text-foreground"> {file.filename} </Text> <Button variant="ghost" size="icon" className="size-8" // Slop, not a bigger button: the 44pt target without stretching the 40px chip. hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} onPress={onRemove} accessibilityLabel={`Remove ${file.filename}`} > <Icon as={XIcon} size={16} className="text-muted-foreground" /> </Button> </View> ); } /** * Status-aware submit. The status arrives from the ROOT's props (context) — the * consumer sets it once on PromptInput and every part reads it, the RN divergence * from the web's per-button status prop. * * ready → ArrowUp (the explicit-send idiom — see header), submitted → spinner, * streaming → stop, error → X, non-interactive. Disabled when there is nothing to * send and nothing is generating (the web docs' disabled={!text && !status}). */ function PromptInputSubmit({ className, ...props }: Omit<React.ComponentProps<typeof Button>, 'children' | 'onPress'>) { const field = usePromptInputField(); const isGenerating = field.status === 'submitted' || field.status === 'streaming'; const canSend = field.text.trim().length > 0 && !field.busy; const disabled = field.status === 'error' ? true : isGenerating ? false : !canSend; return ( <Button variant="default" size="icon" className={cn('ml-auto size-11 rounded-xl', className)} disabled={disabled} onPress={isGenerating && field.onStop ? field.onStop : field.submit} accessibilityLabel={isGenerating ? 'Stop generating' : 'Send message'} {...props} > {field.status === 'submitted' ? ( <ActivityIndicator size="small" /> ) : field.status === 'streaming' ? ( <Icon as={SquareIcon} size={16} /> ) : field.status === 'error' ? ( <Icon as={XIcon} size={16} /> ) : ( <Icon as={ArrowUpIcon} size={20} /> )} </Button> ); } /** * The primary slot, right side, FIXED at 44pt — the never-dead control (the spec's * morph table). Every state is an in-place swap inside the fixed slot, so the * keyboard-avoided field never reflows (AC-1/AC-2 deterministic): * * streaming/error/submitted or text present → the EXACT PromptInputSubmit branches * (delegated verbatim — zero duplication of the status map); * empty + renderVoice composed → the consumer's voice affordance; * empty + no voice → today's disabled send arrow. * * `renderVoice` mirrors SpeechInput's caller-supplied recorder/transcribe contract: * the registry stays engineless and never fake-listens. The voice affordance must * present as the filled primary circle (SpeechInput's idle pill IS the reference). * PromptInputSubmit stays exported and unchanged — this is an additive part. */ type PromptInputPrimaryProps = Omit<React.ComponentProps<typeof Button>, 'children' | 'onPress'> & { /** The empty-field voice affordance — composed by the consumer, engineless. */ renderVoice?: () => React.ReactNode; }; function PromptInputPrimary({ renderVoice, className, ...props }: PromptInputPrimaryProps) { const field = usePromptInputField(); const isGenerating = field.status === 'submitted' || field.status === 'streaming'; // Voice owns the slot ONLY while there is nothing to send and nothing in flight — // the moment text exists, submit is the fastest path back (the morph table's // "text ready → send" row wins without a re-render boundary). const voiceOwns = renderVoice != null && !isGenerating && field.status !== 'error' && !field.busy && field.text.trim().length === 0; return ( <View className={cn('ml-auto size-11 items-center justify-center', className)}> {voiceOwns ? ( renderVoice() ) : ( // Delegation, not reimplementation: its own ml-auto/size-11 are inert inside // this fixed slot — the status map, the stop wiring, the disabled logic are // byte-identical to the standalone part. <PromptInputSubmit {...props} /> )} </View> ); } export { PromptInput, PromptInputProvider, usePromptInputController, usePromptInputAttachments, PromptInputHeader, PromptInputBody, PromptInputTextarea, PromptInputFooter, PromptInputTools, PromptInputButton, PromptInputActionAddAttachments, PromptInputToolsMenu, PromptInputToolsMenuTrigger, PromptInputToolsMenuItem, PromptInputAttachments, PromptInputAttachment, PromptInputSubmit, PromptInputPrimary, };
prompt-input.logic.ts
/** * PromptInput — pure logic. Zero react-native imports, so the Vitest tier owns it (see * the header of attachments.logic.ts for why component files themselves cannot load * under Node). * * This is the attachment half of the web original's prompt-input, extracted from the * DOM it lived in. Three contracts come across verbatim (behavior spec, validated by * prompt-input.test.ts): * * 1. Validation order is accept → size → count, and only an accept stage that * rejects EVERY file aborts the add. Size drops over-cap files and keeps going; * count caps SILENTLY to remaining capacity and reports only when something was * actually dropped. Web parity, including the asymmetry (accept aborts on * all-rejected, size reports on any-dropped) — the asymmetry is the web's. * * 2. On submit the record's `id` is stripped — the consumer-facing message carries * content, not internal bookkeeping. * * 3. The RN divergence, declared: records carry file:// (or content://) URIs from * the native pickers. The web original creates blob: URLs and converts them to * data: URLs on submit; on RN the consumer reads the files, so there is nothing * to convert and nothing to revoke — no URL.createObjectURL, no revokeObjectURL. * * The pickers themselves are NOT here — this module stays dependency-free. The * component owns the expo-image-picker / expo-document-picker calls (recorded as * install-time dependencies on the registry item, web-preview/webview precedent). * The asset→record mapping for the image-picker family (library AND camera) IS here: * both launch paths share it, so the camera kind can only ever produce records the * existing validation pipeline already governs. */ /** One pending attachment — the composer chip row and the submit message exchange this. */ export type PromptInputAttachmentData = { id: string; /** MIME type, or '' when the picker could not tell — renders as the generic file chip. */ mediaType: string; filename: string; /** A file:// or content:// URI from the native picker. No blob:, no data:. */ url: string; /** Bytes, when the picker measured them. */ size?: number; }; /** The consumer-facing file shape inside PromptInputMessage — the `id` is stripped. */ export type PromptInputMessageFile = Omit<PromptInputAttachmentData, 'id'>; /** The object onSubmit receives — the web original's PromptInputMessage. */ export type PromptInputMessage = { text: string; files: PromptInputMessageFile[] }; /** The web original's onError vocabulary, unchanged. */ export type PromptInputErrorCode = 'max_files' | 'max_file_size' | 'accept'; export type PromptInputError = { code: PromptInputErrorCode; message: string }; /** Which native picker a picker-opening affordance launches. 'camera' is * EXPLICIT-KIND ONLY — resolvePickerKind never derives it; the + menu's Camera row * asks for the camera by name. */ export type PickerKind = 'media' | 'file' | 'camera'; /** * expo-image-picker's asset shape, narrowed to the members the mapping touches. The * metadata trio is OPTIONAL because the real Expo 57 ImagePickerAsset declares it * optional (fileName?: string | null, mimeType?: string, fileSize?: number) — the * picker may leave any of them unmeasured, so a required field here breaks every * consumer compiling against the SDK. The registry's ambient boundary still declares * the trio required-but-nullable; this superset accepts that too (required is * assignable to optional). Both launch paths (library and camera) map through ONE * function so a camera shot produces byte-identical records to a library pick and * lands in the SAME validated add() pipeline. */ export type ImagePickerAssetShape = { uri: string; fileName?: string | null; mimeType?: string | null; fileSize?: number | null; }; /** The record-side twin of the web's file-input mapping: uri→url, name→filename, * mime→mediaType, size passthrough — with the pickers' nulls collapsed to the record's * empties ('' media type, 'Image' fallback, no size). */ export function imageAssetToAttachment(asset: ImagePickerAssetShape): PromptInputAttachmentData { return { id: makeAttachmentId(), mediaType: asset.mimeType ?? '', filename: asset.fileName ?? 'Image', url: asset.uri, size: asset.fileSize ?? undefined, }; } let idCounter = 0; /** A collision-proof-enough id for records that live in one in-memory array. */ export function makeAttachmentId(): string { idCounter += 1; return `pi-${Date.now().toString(36)}-${idCounter}`; } /** * The web file input's accept grammar, narrowed to what the spec pins: a comma list of * MIME types with `image/*` wildcards. (The browser's extension forms — ".pdf" — have * no native-picker meaning and are not supported.) Case-insensitive on both sides. */ export function acceptsMimeType(mediaType: string, accept: string): boolean { const needle = mediaType.trim().toLowerCase(); if (!needle) return false; return accept.split(',').some((raw) => { const entry = raw.trim().toLowerCase(); if (!entry) return false; if (entry.endsWith('/*')) return needle.startsWith(`${entry.slice(0, -1)}`); return needle === entry; }); } /** * The paperclip route: 'media' (expo-image-picker) unless accept excludes image/* — * a consumer who declared "PDFs only" gets the document picker, and one hidden file * input becomes two native pickers. */ export function resolvePickerKind(accept?: string): PickerKind { if (!accept) return 'media'; const entries = accept.split(',').map((e) => e.trim().toLowerCase()).filter(Boolean); if (entries.length === 0) return 'media'; return entries.some((e) => e === 'image/*' || e.startsWith('image/')) ? 'media' : 'file'; } const filterOut = <T>( files: T[], keep: (f: T) => boolean, ): { accepted: T[]; rejected: T[] } => { const accepted: T[] = []; const rejected: T[] = []; for (const f of files) (keep(f) ? accepted : rejected).push(f); return { accepted, rejected }; }; /** * The full pipeline, in the web's order: accept → size → count. Errors come back in * stage order; the component fires onError for each (the web's "once per stage"). An * all-rejected accept stage short-circuits and nothing later runs. */ export function validateAttachments( files: PromptInputAttachmentData[], opts: { accept?: string; /** Bytes. Over-cap files drop; the rest continue. */ maxFileSize?: number; /** Total board capacity; the incoming batch caps to what is left. */ maxFiles?: number; /** How many are already attached — remaining capacity is maxFiles − currentCount. */ currentCount?: number; }, ): { accepted: PromptInputAttachmentData[]; errors: PromptInputError[] } { const errors: PromptInputError[] = []; // 1. accept — the aborting stage: every file mismatched means the batch was meant // for a different composer; add nothing, say so once. let survivors = files; if (opts.accept) { const accept_ = opts.accept; const { accepted } = filterOut(files, (f) => acceptsMimeType(f.mediaType, accept_)); if (files.length > 0 && accepted.length === 0) { return { accepted: [], errors: [ { code: 'accept', message: 'No files match the accepted types.', }, ], }; } survivors = accepted; } // 2. size — drop and continue: one oversized file must not punish the others. if (opts.maxFileSize != null) { const { accepted, rejected } = filterOut( survivors, (f) => f.size == null || f.size <= (opts.maxFileSize as number), ); if (rejected.length > 0) { errors.push({ code: 'max_file_size', message: `${rejected.length} file(s) exceeded the ${opts.maxFileSize} byte limit and were not added.`, }); } survivors = accepted; } // 3. count — the silent cap: fill the remaining slots, report only the overflow. if (opts.maxFiles != null) { const remaining = Math.max(0, opts.maxFiles - (opts.currentCount ?? 0)); if (survivors.length > remaining) { errors.push({ code: 'max_files', message: `Only ${opts.maxFiles} file(s) can be attached — ${survivors.length - remaining} were not added.`, }); survivors = survivors.slice(0, remaining); } } return { accepted: survivors, errors }; } /** The submit boundary: consumer-facing files carry no internal id. */ export function stripIds(files: PromptInputAttachmentData[]): PromptInputMessageFile[] { return files.map(({ id: _id, ...rest }) => rest); }
attachments.logic.ts
/** * Attachments — pure logic. Zero react-native imports, so the Vitest tier owns it (see * the header of sources.logic.ts for why component files themselves cannot load under * Node). * * AttachmentData is the data-schema doc's own shape (11-technical-requirements/ * 03-data-schema.md), declared locally per message.tsx precedent — the registry is * stateless about the model and takes no runtime dependency on any SDK. It is * deliberately narrower than the web original's FileUIPart | SourceDocumentUIPart * union: `sources` are their own component in this registry, so the source-document * variant (and upstream's `source` media category, which exists only to serve it) has * no carrier here. * * The per-file upload state is an RN ADDITION over the web original, declared: a * desktop drag-and-drop lands instantly, but a phone upload takes visible seconds, and * a chip that looks complete while its bytes are still in flight is lying. UC-CHAT-03 * AC-3 makes the chip removable "before sending", which is exactly the uploading window. * The caller owns the lifecycle (queue precedent — caller state, caller mutations); * this is only the display seam for it. */ /** The data-schema doc's attachment shape — what prompt-input and attachments exchange. */ export type AttachmentData = { id: string; name: string; mimeType: string; uri?: string; sizeBytes?: number; }; /** The caller-owned transport lifecycle of one file, rendered by the chip. */ export type AttachmentState = 'uploading' | 'done' | 'error'; /** Upstream's media categories minus `source` — no source-document variant exists here. */ export type AttachmentMediaCategory = 'image' | 'video' | 'audio' | 'document' | 'unknown'; export const ATTACHMENT_VARIANTS = ['grid', 'inline', 'list'] as const; export type AttachmentVariant = (typeof ATTACHMENT_VARIANTS)[number]; /** * The preview affordance for a mimeType — the data-schema doc names this exact * function, pure and Vitest-testable. Same prefix ladder as the web original's * getMediaCategory, reading `mimeType` directly instead of a part object. * No-throw: an empty or unrecognized type degrades to `unknown` (the paperclip), * which is the web original's own fallback. */ export function getMediaCategory(mimeType: string): AttachmentMediaCategory { if (mimeType.startsWith('image/')) return 'image'; if (mimeType.startsWith('video/')) return 'video'; if (mimeType.startsWith('audio/')) return 'audio'; if (mimeType.startsWith('application/') || mimeType.startsWith('text/')) return 'document'; return 'unknown'; } /** * The chip's display label — the web original's getAttachmentLabel with the field * renames the data-schema shape dictates (`name` for `filename`). A file with no name * falls back to "Image" when it is one, "Attachment" otherwise, byte-verbatim. */ export function getAttachmentLabel( data: Pick<AttachmentData, 'name' | 'mimeType'>, ): string { if (data.name) return data.name; return getMediaCategory(data.mimeType) === 'image' ? 'Image' : 'Attachment'; } /** * The one-line state label, or undefined for `done` — a finished upload needs no * announcement; the chip itself is the proof. Uploading and failing DO (color is never * the sole channel — lib/status.ts rule 1). */ export function attachmentStateLabel(state: AttachmentState): string | undefined { if (state === 'uploading') return 'Uploading'; if (state === 'error') return 'Upload failed'; return undefined; } /** * A human byte size for the chip's meta line — the data-schema shape carries * `sizeBytes` and a file chip without its size is opaque on a phone. 1024-based, one * decimal under 10 of a unit, none above. Undefined when the caller supplied no size: * a missing measurement renders as nothing, never as "0 B". */ export function formatAttachmentSize(sizeBytes: number | undefined): string | undefined { if (sizeBytes === undefined || Number.isNaN(sizeBytes)) return undefined; if (sizeBytes < 1024) return `${Math.round(sizeBytes)} B`; if (sizeBytes < 1024 ** 2) { const kb = sizeBytes / 1024; return `${kb < 10 ? Math.round(kb * 10) / 10 : Math.round(kb)} KB`; } const mb = sizeBytes / 1024 ** 2; return `${mb < 10 ? Math.round(mb * 10) / 10 : Math.round(mb)} MB`; }

Props

NameTypeDefaultDescription
onSubmit(message: PromptInputMessage) => void | Promise<void>——
onStop(() => void) | undefined——
statusChatStatus | undefined'ready'—
placeholderstring | undefined'What would you like to know?'—
maxHeightnumber | undefined140Grow to this many px, then scroll internally.
acceptstring | undefined—Comma MIME list with image/* wildcards — routes the picker and filters the picks.
multipleboolean | undefinedtrue—
maxFilesnumber | undefined——
maxFileSizenumber | undefined—Bytes.
onError((error: PromptInputError) => void) | undefined——
childrenReact.ReactNode——

Theming

Themed via bg-background, bg-accent, text-muted-foreground, text-foreground, bg-muted, border-border.

Usage

(o) => ( <PromptInput onSubmit={() => {}} status={(o.status as 'ready' | 'submitted' | 'streaming' | 'error') ?? 'ready'} placeholder="Ask about the registry…" /> )