Prompt Input
The composer at web parity: provider/controller context, attachment chips over the native pickers, the + tools menu (bottom sheet — camera/photos/files rows through the picker seam, checkmarked consumer toggles), the model chip composed in-row, and the never-dead primary slot (voice → send → stop morph; PromptInputSubmit stays exported for direct use). Explicit send button is the ONLY submit path on a soft keyboard. A rejected onSubmit LEAVES THE TEXT AND ATTACHMENTS INTACT. expo-image-picker + expo-document-picker are opt-in install-time dependencies (the ambient boundary keeps typecheck green without them).
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/prompt-input.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/prompt-input.jsonDependencies
react-native-safe-area-contextexpo-document-pickerexpo-image-picker
https://reactnativereusables.com/r/nativewind/button.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://rnraielements.com/r/nativewind/input-group.json |
https://reactnativereusables.com/r/uniwind/button.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://rnraielements.com/r/uniwind/input-group.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
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,
};
/**
* 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 — 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
| Name | Type | Default | Description |
|---|---|---|---|
| onSubmit | (message: PromptInputMessage) => void | Promise<void> | — | — |
| onStop | (() => void) | undefined | — | — |
| status | ChatStatus | undefined | 'ready' | — |
| placeholder | string | undefined | 'What would you like to know?' | — |
| maxHeight | number | undefined | 140 | Grow to this many px, then scroll internally. |
| accept | string | undefined | — | Comma MIME list with image/* wildcards — routes the picker and filters the picks. |
| multiple | boolean | undefined | true | — |
| maxFiles | number | undefined | — | — |
| maxFileSize | number | undefined | — | Bytes. |
| onError | ((error: PromptInputError) => void) | undefined | — | — |
| children | React.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…"
/>
)