panelui-native 0.75.0 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/lib/module/components/ai-input/ai-input-growth.js +62 -0
  2. package/lib/module/components/ai-input/ai-input-growth.js.map +1 -0
  3. package/lib/module/components/ai-input/index.js +1568 -0
  4. package/lib/module/components/ai-input/index.js.map +1 -0
  5. package/lib/module/components/alert/index.js +1 -1
  6. package/lib/module/components/alert/index.js.map +1 -1
  7. package/lib/module/components/avatar/avatar-source.js +15 -0
  8. package/lib/module/components/avatar/avatar-source.js.map +1 -0
  9. package/lib/module/components/avatar/index.js +32 -31
  10. package/lib/module/components/avatar/index.js.map +1 -1
  11. package/lib/module/components/bottom-sheet/index.js +3 -2
  12. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  13. package/lib/module/components/button-group/index.js +2 -3
  14. package/lib/module/components/button-group/index.js.map +1 -1
  15. package/lib/module/components/carousel/index.js +10 -7
  16. package/lib/module/components/carousel/index.js.map +1 -1
  17. package/lib/module/components/collapsible/index.js +1 -1
  18. package/lib/module/components/collapsible/index.js.map +1 -1
  19. package/lib/module/components/drawer/index.js +18 -14
  20. package/lib/module/components/drawer/index.js.map +1 -1
  21. package/lib/module/components/field/index.js +7 -8
  22. package/lib/module/components/field/index.js.map +1 -1
  23. package/lib/module/components/label/index.js +1 -1
  24. package/lib/module/components/label/index.js.map +1 -1
  25. package/lib/module/components/marquee/index.js +36 -11
  26. package/lib/module/components/marquee/index.js.map +1 -1
  27. package/lib/module/components/marquee/marquee-math.js +22 -6
  28. package/lib/module/components/marquee/marquee-math.js.map +1 -1
  29. package/lib/module/components/plan/index.js +2 -1
  30. package/lib/module/components/plan/index.js.map +1 -1
  31. package/lib/module/components/plot/index.js +27 -9
  32. package/lib/module/components/plot/index.js.map +1 -1
  33. package/lib/module/components/search-bar/index.js +11 -3
  34. package/lib/module/components/search-bar/index.js.map +1 -1
  35. package/lib/module/components/split-view/index.js +26 -27
  36. package/lib/module/components/split-view/index.js.map +1 -1
  37. package/lib/module/components/split-view/split-view-lifecycle.js +32 -0
  38. package/lib/module/components/split-view/split-view-lifecycle.js.map +1 -0
  39. package/lib/module/components/splitter/index.js +7 -13
  40. package/lib/module/components/splitter/index.js.map +1 -1
  41. package/lib/module/components/splitter/splitter-math.js +14 -0
  42. package/lib/module/components/splitter/splitter-math.js.map +1 -1
  43. package/lib/module/components/tabs/index.js +1 -1
  44. package/lib/module/components/tabs/index.js.map +1 -1
  45. package/lib/module/components/timeline/index.js +156 -42
  46. package/lib/module/components/timeline/index.js.map +1 -1
  47. package/lib/module/components/timeline/timeline-geometry.js +11 -0
  48. package/lib/module/components/timeline/timeline-geometry.js.map +1 -1
  49. package/lib/module/components/toast/index.js +1 -1
  50. package/lib/module/components/toast/index.js.map +1 -1
  51. package/lib/module/components/tooltip/index.js +2 -2
  52. package/lib/module/components/tooltip/index.js.map +1 -1
  53. package/lib/module/components/tree/index.js +2 -2
  54. package/lib/module/components/tree/index.js.map +1 -1
  55. package/lib/module/components/typography/index.js +2 -1
  56. package/lib/module/components/typography/index.js.map +1 -1
  57. package/lib/module/components/waterfall-chart/index.js +22 -3
  58. package/lib/module/components/waterfall-chart/index.js.map +1 -1
  59. package/lib/module/hooks/use-disclosure.js +10 -8
  60. package/lib/module/hooks/use-disclosure.js.map +1 -1
  61. package/lib/module/icons/index.js +135 -0
  62. package/lib/module/icons/index.js.map +1 -1
  63. package/lib/module/index.js +5 -1
  64. package/lib/module/index.js.map +1 -1
  65. package/lib/module/primitives/controllable-state.js +61 -0
  66. package/lib/module/primitives/controllable-state.js.map +1 -0
  67. package/lib/module/primitives/finite-chart.js +15 -0
  68. package/lib/module/primitives/finite-chart.js.map +1 -0
  69. package/lib/module/primitives/glass.js +136 -0
  70. package/lib/module/primitives/glass.js.map +1 -0
  71. package/lib/module/primitives/scrim.js +8 -1
  72. package/lib/module/primitives/scrim.js.map +1 -1
  73. package/lib/typescript/src/components/ai-input/ai-input-growth.d.ts +59 -0
  74. package/lib/typescript/src/components/ai-input/ai-input-growth.d.ts.map +1 -0
  75. package/lib/typescript/src/components/ai-input/index.d.ts +423 -0
  76. package/lib/typescript/src/components/ai-input/index.d.ts.map +1 -0
  77. package/lib/typescript/src/components/avatar/avatar-source.d.ts +4 -0
  78. package/lib/typescript/src/components/avatar/avatar-source.d.ts.map +1 -0
  79. package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
  80. package/lib/typescript/src/components/bottom-sheet/index.d.ts +10 -1
  81. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  82. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -1
  84. package/lib/typescript/src/components/marquee/index.d.ts +15 -6
  85. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/marquee/marquee-math.d.ts +6 -0
  87. package/lib/typescript/src/components/marquee/marquee-math.d.ts.map +1 -1
  88. package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/plot/index.d.ts +2 -1
  90. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  91. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  92. package/lib/typescript/src/components/split-view/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/split-view/split-view-lifecycle.d.ts +14 -0
  94. package/lib/typescript/src/components/split-view/split-view-lifecycle.d.ts.map +1 -0
  95. package/lib/typescript/src/components/splitter/index.d.ts +2 -2
  96. package/lib/typescript/src/components/splitter/index.d.ts.map +1 -1
  97. package/lib/typescript/src/components/splitter/splitter-math.d.ts +2 -0
  98. package/lib/typescript/src/components/splitter/splitter-math.d.ts.map +1 -1
  99. package/lib/typescript/src/components/timeline/index.d.ts +24 -2
  100. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/timeline/timeline-geometry.d.ts +2 -0
  102. package/lib/typescript/src/components/timeline/timeline-geometry.d.ts.map +1 -1
  103. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  104. package/lib/typescript/src/components/waterfall-chart/index.d.ts +2 -1
  105. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -1
  106. package/lib/typescript/src/hooks/use-disclosure.d.ts.map +1 -1
  107. package/lib/typescript/src/icons/index.d.ts +18 -0
  108. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  109. package/lib/typescript/src/index.d.ts +3 -1
  110. package/lib/typescript/src/index.d.ts.map +1 -1
  111. package/lib/typescript/src/primitives/controllable-state.d.ts +33 -0
  112. package/lib/typescript/src/primitives/controllable-state.d.ts.map +1 -0
  113. package/lib/typescript/src/primitives/finite-chart.d.ts +5 -0
  114. package/lib/typescript/src/primitives/finite-chart.d.ts.map +1 -0
  115. package/lib/typescript/src/primitives/glass.d.ts +43 -0
  116. package/lib/typescript/src/primitives/glass.d.ts.map +1 -0
  117. package/lib/typescript/src/primitives/scrim.d.ts +7 -0
  118. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -1
  119. package/package.json +7 -2
  120. package/src/components/ai-input/ai-input-growth.ts +65 -0
  121. package/src/components/ai-input/index.tsx +1937 -0
  122. package/src/components/alert/index.tsx +1 -1
  123. package/src/components/avatar/avatar-source.ts +18 -0
  124. package/src/components/avatar/index.tsx +35 -27
  125. package/src/components/bottom-sheet/index.tsx +15 -3
  126. package/src/components/button-group/index.tsx +1 -1
  127. package/src/components/carousel/index.tsx +10 -11
  128. package/src/components/collapsible/index.tsx +1 -1
  129. package/src/components/drawer/index.tsx +24 -20
  130. package/src/components/field/index.tsx +4 -4
  131. package/src/components/label/index.tsx +1 -1
  132. package/src/components/marquee/index.tsx +56 -12
  133. package/src/components/marquee/marquee-math.ts +26 -3
  134. package/src/components/plan/index.tsx +6 -2
  135. package/src/components/plot/index.tsx +37 -11
  136. package/src/components/search-bar/index.tsx +9 -3
  137. package/src/components/split-view/index.tsx +24 -33
  138. package/src/components/split-view/split-view-lifecycle.ts +43 -0
  139. package/src/components/splitter/index.tsx +7 -11
  140. package/src/components/splitter/splitter-math.ts +28 -0
  141. package/src/components/tabs/index.tsx +1 -1
  142. package/src/components/timeline/index.tsx +189 -34
  143. package/src/components/timeline/timeline-geometry.ts +11 -0
  144. package/src/components/toast/index.tsx +6 -1
  145. package/src/components/tooltip/index.tsx +1 -1
  146. package/src/components/tree/index.tsx +2 -2
  147. package/src/components/typography/index.tsx +2 -2
  148. package/src/components/waterfall-chart/index.tsx +37 -4
  149. package/src/hooks/use-disclosure.ts +7 -12
  150. package/src/icons/index.tsx +106 -0
  151. package/src/index.ts +27 -0
  152. package/src/primitives/controllable-state.ts +112 -0
  153. package/src/primitives/finite-chart.ts +14 -0
  154. package/src/primitives/glass.tsx +171 -0
  155. package/src/primitives/scrim.tsx +7 -1
@@ -0,0 +1,1937 @@
1
+ /**
2
+ * AIInput — the composer a person types a prompt into.
3
+ *
4
+ * ```tsx
5
+ * <AIInput value={value} onValueChange={setValue} onSubmit={send}>
6
+ * <AIInput.Field placeholder="Chat with the model" />
7
+ * <AIInput.Toolbar>
8
+ * <AIInput.Action label="Add" icon={<PlusIcon />} onPress={openSheet} />
9
+ * <AIInput.Pill label="Sonnet 4.6" detail="High" onPress={openModels} />
10
+ * <AIInput.Action label="Dictate" icon={<MicIcon />} onPress={record} />
11
+ * <AIInput.Submit />
12
+ * </AIInput.Toolbar>
13
+ * </AIInput>
14
+ * ```
15
+ *
16
+ * ## The field grows, and then it stops
17
+ *
18
+ * It opens one line tall and follows what is typed into it up to `maxRows`,
19
+ * after which it holds that height and scrolls its own content. Five rows is
20
+ * the default because a composer that keeps growing eventually pushes its own
21
+ * send button off the bottom of the screen, and the row that carries the
22
+ * actions stays pinned to the bottom of the box either way.
23
+ *
24
+ * ## It does not record anything
25
+ *
26
+ * There is a recording state, and it draws a live meter, but nothing here
27
+ * touches the microphone. The app owns the recorder — the permission prompt,
28
+ * the session, the platform quirks — and passes back a `level` between 0 and 1
29
+ * along with a `status`. Pass a Reanimated shared value and a recorder
30
+ * reporting every 30ms never re-renders anything above this component.
31
+ *
32
+ * That division is what keeps the composer free of an audio dependency.
33
+ *
34
+ * ## Glass is iOS 26 and above
35
+ *
36
+ * The surfaces ask for the system material and get it where it exists. Below
37
+ * iOS 26, on Android, and for anyone with Reduce Transparency switched on,
38
+ * they are drawn as solid token surfaces instead. Both are finished looks; see
39
+ * `Glass` for why nothing is faked on the platforms without it.
40
+ *
41
+ * ## Where the props come from
42
+ *
43
+ * The root takes `ViewProps`. `Field` takes `TextInputProps`, so
44
+ * `keyboardType`, `maxLength`, `autoFocus` and the rest work as they always
45
+ * do. Every part takes `className`.
46
+ */
47
+ import {
48
+ Children,
49
+ createContext,
50
+ isValidElement,
51
+ useCallback,
52
+ useContext,
53
+ useEffect,
54
+ useMemo,
55
+ useRef,
56
+ useState,
57
+ type ReactElement,
58
+ type ReactNode,
59
+ } from 'react';
60
+ import {
61
+ AccessibilityInfo,
62
+ findNodeHandle,
63
+ Pressable,
64
+ TextInput,
65
+ View,
66
+ useWindowDimensions,
67
+ type LayoutChangeEvent,
68
+ type TextInputProps,
69
+ type ViewProps,
70
+ } from 'react-native';
71
+ import Animated, {
72
+ Easing,
73
+ FadeOut,
74
+ useAnimatedStyle,
75
+ useSharedValue,
76
+ withTiming,
77
+ type EntryExitAnimationFunction,
78
+ type SharedValue,
79
+ } from 'react-native-reanimated';
80
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
81
+ import { tv } from 'tailwind-variants';
82
+ import { useCSSVariable } from 'uniwind';
83
+ import { useDirection } from '../../hooks/use-direction';
84
+ import {
85
+ AudioLinesIcon,
86
+ CheckIcon,
87
+ ChevronLeftIcon,
88
+ ChevronRightIcon,
89
+ IconColorProvider,
90
+ MicIcon,
91
+ SendArrowIcon,
92
+ XIcon,
93
+ } from '../../icons';
94
+ import { hasNativeUI } from '../../native';
95
+ import { AnimatedPressable } from '../../primitives/animated-pressable';
96
+ import { Glass } from '../../primitives/glass';
97
+ import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
98
+ import { Text } from '../../primitives/text';
99
+ import { cn } from '../../utils/cn';
100
+ import { BottomSheet } from '../bottom-sheet';
101
+ import { Button } from '../button';
102
+ import { Soundwave } from '../soundwave';
103
+ import { Switch } from '../switch';
104
+ import { AI_INPUT_METRICS, growthBounds, type AIInputSize } from './ai-input-growth';
105
+
106
+ /** What the app is doing, which is what the trailing button offers to do next. */
107
+ export type AIInputStatus = 'ready' | 'recording' | 'submitted' | 'streaming';
108
+
109
+ /** Corner radius per size, in points. The material rounds itself to these. */
110
+ const RADIUS = { sm: 22, md: 26, lg: 30 } as const;
111
+
112
+ /**
113
+ * Circular control diameter per size.
114
+ *
115
+ * Deliberately under the 44pt touch minimum: these sit in a row inside a card
116
+ * with padding around it, and the card's own padding carries the target the
117
+ * finger actually gets. A 44pt circle here makes a toolbar that is taller than
118
+ * the field above it.
119
+ */
120
+ const CONTROL = { sm: 26, md: 30, lg: 34 } as const;
121
+
122
+ /**
123
+ * The row a platform-drawn control needs, and the space kept under it.
124
+ *
125
+ * `Button` frames a native icon button at 44pt — the number that ends the
126
+ * measurement chain rather than one that sets a look. A row shorter than that
127
+ * is a row its controls hang out of, over whatever is above them, and a row
128
+ * with no definite height at all is one they never lay out against: a hosted
129
+ * view only measures where something above it is fixed on both axes.
130
+ *
131
+ * The drawn controls are smaller and are laid out by the row itself, so this
132
+ * applies only when the controls have been handed over.
133
+ */
134
+ const NATIVE_ROW_HEIGHT = 48;
135
+ const NATIVE_ROW_FOOT = 4;
136
+
137
+ const EASE = Easing.out(Easing.cubic);
138
+ const HEIGHT_DURATION = 240;
139
+ const ENTER_DURATION = 220;
140
+ const EXIT_DURATION = 140;
141
+
142
+ /**
143
+ * How far a pushed screen travels in, as a fraction of the sheet's width.
144
+ *
145
+ * A third rather than the whole width: the sheet is not a screen, and a slide
146
+ * the full width of it reads as the app navigating rather than as this surface
147
+ * going one level deeper.
148
+ */
149
+ const SLIDE_FRACTION = 0.33;
150
+
151
+ const aiInputVariants = tv({
152
+ slots: {
153
+ root: 'w-full',
154
+ field: 'w-full bg-transparent px-4 font-normal text-foreground',
155
+ // On one line the field is what takes up the slack between the controls,
156
+ // and the row's own padding already stands it off the edge.
157
+ fieldInline: 'flex-1 bg-transparent px-2 font-normal text-foreground',
158
+ /*
159
+ * Centred, not bottom-aligned.
160
+ *
161
+ * The stacked toolbar sits under the field, so it aligns to the bottom.
162
+ * Here the field is *between* the controls, and its box is not the height
163
+ * this component computes: a multiline TextInput carries a vertical inset
164
+ * of the platform's own on top of any padding given to it. Bottom-aligning
165
+ * a box whose real height is unknown puts its text below the controls
166
+ * beside it by however much that inset is — which is what kept happening.
167
+ *
168
+ * Centring does not need to know the height. Whatever the field's box
169
+ * turns out to be, its middle lines up with theirs, and the text sits in
170
+ * the middle of it.
171
+ */
172
+ row: 'w-full flex-row items-center gap-1.5 p-2',
173
+ toolbar: 'w-full flex-row items-center gap-2 px-2 pb-2',
174
+ spacer: 'flex-1',
175
+ pill: 'flex-row items-center gap-1.5 px-3',
176
+ recording: 'w-full flex-row items-center gap-3 px-2 pb-2',
177
+ meter: 'flex-1',
178
+ },
179
+ variants: {
180
+ size: {
181
+ sm: { toolbar: 'gap-1.5' },
182
+ md: {},
183
+ lg: { toolbar: 'gap-2.5' },
184
+ },
185
+ disabled: {
186
+ true: { root: 'opacity-60' },
187
+ false: {},
188
+ },
189
+ },
190
+ defaultVariants: { size: 'md', disabled: false },
191
+ });
192
+
193
+ /* -------------------------------------------------------------------------- */
194
+ /* Root */
195
+ /* -------------------------------------------------------------------------- */
196
+
197
+ interface AIInputContextValue {
198
+ value: string;
199
+ setValue: (value: string) => void;
200
+ status: AIInputStatus;
201
+ size: AIInputSize;
202
+ level?: number | SharedValue<number>;
203
+ disabled: boolean;
204
+ native: boolean;
205
+ minRows: number;
206
+ maxRows: number;
207
+ focused: boolean;
208
+ setFocused: (focused: boolean) => void;
209
+ submit: () => void;
210
+ stop: () => void;
211
+ canVoice: boolean;
212
+ canStop: boolean;
213
+ recordCancel: () => void;
214
+ recordConfirm: () => void;
215
+ voice: () => void;
216
+ }
217
+
218
+ const AIInputContext = createContext<AIInputContextValue | null>(null);
219
+
220
+ function useAIInput(component: string): AIInputContextValue {
221
+ const context = useContext(AIInputContext);
222
+ if (!context) throw new Error(`${component} must be used within an <AIInput>`);
223
+ return context;
224
+ }
225
+
226
+ /**
227
+ * The composer's context if there is one, and nothing if there is not.
228
+ *
229
+ * The controls that only want a size and an enabled state work anywhere — a
230
+ * sheet header, a voice-mode toolbar, a row of their own. Those surfaces
231
+ * render through a portal or as their own screen, so a root wrapped around the
232
+ * composer is not above them, and demanding one would make a button throw for
233
+ * being used where it was designed to be used.
234
+ */
235
+ function useAIInputOptional(): AIInputContextValue | null {
236
+ return useContext(AIInputContext);
237
+ }
238
+
239
+ export interface AIInputProps extends Omit<ViewProps, 'children'> {
240
+ children?: ReactNode;
241
+ className?: string;
242
+ /** The prompt, when the app owns it. */
243
+ value?: string;
244
+ /** Called on every keystroke. */
245
+ onValueChange?: (value: string) => void;
246
+ /** The prompt to start with, when the composer owns it. */
247
+ defaultValue?: string;
248
+ /**
249
+ * What the app is doing. `ready` offers to send, `streaming` offers to stop,
250
+ * and `recording` swaps the toolbar for the meter and its two decisions.
251
+ */
252
+ status?: AIInputStatus;
253
+ /** Called with the prompt when it is sent. The composer does not clear itself. */
254
+ onSubmit?: (value: string) => void;
255
+ /** Called when the trailing button is pressed while `streaming`. */
256
+ onStop?: () => void;
257
+ /** Called when the voice button is pressed on an empty composer. */
258
+ onVoice?: () => void;
259
+ /** Called when a recording is thrown away. */
260
+ onRecordCancel?: () => void;
261
+ /** Called when a recording is accepted. */
262
+ onRecordConfirm?: () => void;
263
+ /**
264
+ * Input level, 0–1, from the app's own recorder. Pass a shared value to keep
265
+ * metering off the JS thread entirely. Omitted, the meter animates plausible
266
+ * motion so a screen can be built before any audio exists.
267
+ */
268
+ level?: number | SharedValue<number>;
269
+ /** Type scale and control size. */
270
+ size?: AIInputSize;
271
+ /** Nothing can be typed, pressed or sent. */
272
+ disabled?: boolean;
273
+ /**
274
+ * Draw the toolbar's controls as the platform's own buttons, in the system
275
+ * material — Liquid Glass on iOS 26, the platform's ordinary button style
276
+ * below it and on Android.
277
+ *
278
+ * The platform owns their colour, metrics and shape when this is on, so
279
+ * `className` and the theme tokens no longer reach them. The card behind
280
+ * them is still ours, and still glass. Needs the optional `@expo/ui`;
281
+ * without it the drawn controls are used and nothing breaks.
282
+ */
283
+ native?: boolean;
284
+ /** Rows the empty field is tall. */
285
+ minRows?: number;
286
+ /** Rows the field grows to before it holds that height and scrolls. */
287
+ maxRows?: number;
288
+ /** Lift the composer clear of the software keyboard. */
289
+ avoidKeyboard?: boolean;
290
+ /** How far above the bottom edge the composer already sits. */
291
+ keyboardBottomInset?: number;
292
+ /**
293
+ * Gap to leave between the composer and the top of the keyboard. A composer
294
+ * resting directly on the keys reads as part of them.
295
+ */
296
+ keyboardGap?: number;
297
+ }
298
+
299
+ function AIInputRoot({
300
+ children,
301
+ className,
302
+ value: valueProp,
303
+ onValueChange,
304
+ defaultValue = '',
305
+ status = 'ready',
306
+ onSubmit,
307
+ onStop,
308
+ onVoice,
309
+ onRecordCancel,
310
+ onRecordConfirm,
311
+ level,
312
+ size = 'md',
313
+ disabled = false,
314
+ native = false,
315
+ minRows = 1,
316
+ maxRows = 5,
317
+ avoidKeyboard = true,
318
+ keyboardBottomInset = 0,
319
+ keyboardGap = 8,
320
+ style,
321
+ ...props
322
+ }: AIInputProps) {
323
+ const [internalValue, setInternalValue] = useState(defaultValue);
324
+ const isControlled = valueProp !== undefined;
325
+ const value = isControlled ? valueProp : internalValue;
326
+ const [focused, setFocused] = useState(false);
327
+
328
+ const setValue = useCallback(
329
+ (next: string) => {
330
+ if (!isControlled) setInternalValue(next);
331
+ onValueChange?.(next);
332
+ },
333
+ [isControlled, onValueChange]
334
+ );
335
+
336
+ const submit = useCallback(() => {
337
+ const trimmed = value.trim();
338
+ if (!trimmed) return;
339
+ onSubmit?.(value);
340
+ }, [onSubmit, value]);
341
+
342
+ /*
343
+ * Whether the controls can actually be handed over.
344
+ *
345
+ * `native` is a request, not a fact: the toolkit is an optional peer, and
346
+ * without it `Button` quietly draws its own control instead — at the size
347
+ * *it* uses, which is not the size this row is built around. Asking for a
348
+ * platform control and getting a 44pt drawn one in a row shaped for a 30pt
349
+ * one is how the buttons ended up over the field.
350
+ *
351
+ * So the request is resolved against what is installed, once, and every part
352
+ * reads the answer rather than the ask.
353
+ */
354
+ const platformControls = native && hasNativeUI();
355
+
356
+ const context = useMemo<AIInputContextValue>(
357
+ () => ({
358
+ value,
359
+ setValue,
360
+ status,
361
+ size,
362
+ level,
363
+ disabled,
364
+ native: platformControls,
365
+ minRows,
366
+ maxRows,
367
+ focused,
368
+ setFocused,
369
+ submit,
370
+ canVoice: onVoice !== undefined,
371
+ canStop: onStop !== undefined,
372
+ stop: () => onStop?.(),
373
+ recordCancel: () => onRecordCancel?.(),
374
+ recordConfirm: () => onRecordConfirm?.(),
375
+ voice: () => onVoice?.(),
376
+ }),
377
+ [
378
+ value,
379
+ setValue,
380
+ status,
381
+ size,
382
+ level,
383
+ disabled,
384
+ platformControls,
385
+ minRows,
386
+ maxRows,
387
+ focused,
388
+ submit,
389
+ onStop,
390
+ onVoice,
391
+ onRecordCancel,
392
+ onRecordConfirm,
393
+ onVoice,
394
+ ]
395
+ );
396
+
397
+ const slots = aiInputVariants({ size, disabled });
398
+
399
+ /*
400
+ * Whether the card has to keep room at its foot for a row it does not lay
401
+ * out.
402
+ *
403
+ * A handed-over control is a hosted native view, and a hosted view settles
404
+ * where the platform puts it rather than where the row it is in ends up —
405
+ * which, in a card that is also sizing itself to the field above, is over
406
+ * that field. Reserving the row's height here and pinning the row into the
407
+ * reservation takes the question away: the field's box stops where the
408
+ * reservation starts, so a control that draws itself somewhere unexpected
409
+ * has nothing above it left to cover.
410
+ *
411
+ * Read from the children rather than tracked in state, so the card and the
412
+ * row agree on the first render instead of the second.
413
+ */
414
+ const hasNativeRow =
415
+ platformControls &&
416
+ Children.toArray(children).some(
417
+ (child) =>
418
+ isValidElement(child) &&
419
+ (child.type === AIInputToolbar || child.type === AIInputRecording)
420
+ );
421
+
422
+ const surface = (
423
+ <Glass
424
+ radius={RADIUS[size]}
425
+ fallbackClassName="border border-border bg-card"
426
+ {...props}
427
+ className={slots.root({ className })}
428
+ style={[hasNativeRow ? { paddingBottom: NATIVE_ROW_HEIGHT } : null, style]}
429
+ >
430
+ {children}
431
+ </Glass>
432
+ );
433
+
434
+ /*
435
+ * The keyboard hook stays behind a component boundary rather than an
436
+ * `enabled` flag. Calling it at all has global consequences — without the
437
+ * keyboard controller installed it falls back to Reanimated's
438
+ * useAnimatedKeyboard, which switches Android out of adjustResize for the
439
+ * whole app. A composer that was told not to avoid the keyboard must not do
440
+ * that to every other screen in the app.
441
+ */
442
+ return (
443
+ <AIInputContext.Provider value={context}>
444
+ {avoidKeyboard ? (
445
+ <KeyboardAvoider
446
+ active={focused}
447
+ mode="dock"
448
+ // Docking travels the composer up by the keyboard's height less the
449
+ // inset it already sits above. Claiming to sit a little lower than it
450
+ // does is what leaves a gap once it arrives.
451
+ bottomInset={keyboardBottomInset - keyboardGap}
452
+ className="w-full"
453
+ >
454
+ {surface}
455
+ </KeyboardAvoider>
456
+ ) : (
457
+ surface
458
+ )}
459
+ </AIInputContext.Provider>
460
+ );
461
+ }
462
+
463
+ /* -------------------------------------------------------------------------- */
464
+ /* Field */
465
+ /* -------------------------------------------------------------------------- */
466
+
467
+ export interface AIInputFieldProps
468
+ extends Omit<TextInputProps, 'value' | 'onChangeText' | 'multiline'> {
469
+ className?: string;
470
+ }
471
+
472
+ function AIInputField({
473
+ className,
474
+ placeholder = 'Ask anything',
475
+ onFocus,
476
+ onBlur,
477
+ style,
478
+ ...props
479
+ }: AIInputFieldProps) {
480
+ const context = useAIInput('AIInput.Field');
481
+ const { value, setValue, size, disabled, minRows, maxRows, setFocused } = context;
482
+ const metrics = AI_INPUT_METRICS[size];
483
+ const placeholderColor = useCSSVariable('--color-muted-foreground');
484
+ const bounds = growthBounds(metrics, minRows, maxRows);
485
+ const inline = useContext(AIInputInlineContext);
486
+
487
+ const handleFocus = useCallback<NonNullable<TextInputProps['onFocus']>>(
488
+ (event) => {
489
+ setFocused(true);
490
+ onFocus?.(event);
491
+ },
492
+ [onFocus, setFocused]
493
+ );
494
+
495
+ const handleBlur = useCallback<NonNullable<TextInputProps['onBlur']>>(
496
+ (event) => {
497
+ setFocused(false);
498
+ onBlur?.(event);
499
+ },
500
+ [onBlur, setFocused]
501
+ );
502
+
503
+ /*
504
+ * Type metrics go through `style` rather than through classes, because the
505
+ * height is computed from them and a line height set in two places is a line
506
+ * height that will disagree with itself.
507
+ */
508
+ /*
509
+ * On one line the field's first row has to be exactly as tall as the buttons
510
+ * beside it.
511
+ *
512
+ * The row aligns to the bottom, so that a field which has grown keeps its
513
+ * controls at the foot of the box rather than floating them beside the
514
+ * middle of the text. That alignment is only correct if an *ungrown* field
515
+ * is the same height as the controls — otherwise its extra padding pushes
516
+ * the whole row down inside the card, which is a composer whose contents sit
517
+ * low in it. So the padding is whatever centres one line in a control.
518
+ */
519
+ /*
520
+ * On one line the field states no line height at all.
521
+ *
522
+ * A line height above the font's own exists to separate lines from each
523
+ * other, and iOS spends the surplus *above* the glyphs rather than splitting
524
+ * it — so a single line set to 22 over 16pt type sits several points below
525
+ * the buttons beside it, placeholder and value alike. With none set, the
526
+ * platform lays the text out in the font's own line box, which sits where a
527
+ * line of that font is supposed to.
528
+ *
529
+ * The box is then exactly one control tall with the natural line centred in
530
+ * it. `NATURAL_LEADING` is what a line of a given size actually occupies —
531
+ * it only has to be close, since it decides a padding either side rather
532
+ * than the height itself.
533
+ */
534
+ const lineHeight = inline ? undefined : metrics.lineHeight;
535
+ const padding = inline ? 0 : metrics.padding;
536
+
537
+ const boxStyle = useMemo(
538
+ () => ({
539
+ /*
540
+ * A floor and a ceiling, and no height. The field sizes itself to its
541
+ * own content between them, which the layout engine does without anyone
542
+ * measuring anything — and once it reaches the ceiling, a multiline
543
+ * field scrolls its content instead of growing.
544
+ *
545
+ * Deriving a height from `onContentSizeChange` instead is a loop whose
546
+ * answer differs by platform: one reports the height of the text, the
547
+ * other the height of the box that was just set from it, and on that one
548
+ * the field can never grow past the height it opened at.
549
+ */
550
+ minHeight: inline ? undefined : bounds.minHeight,
551
+ maxHeight: bounds.maxHeight,
552
+ fontSize: metrics.fontSize,
553
+ lineHeight,
554
+ paddingTop: padding,
555
+ paddingBottom: padding,
556
+ /*
557
+ * Android puts the caret in the middle of a multiline box unless told
558
+ * otherwise, which is wrong for a field that grows downward — and right
559
+ * for one that is a single line between two buttons.
560
+ */
561
+ textAlignVertical: inline ? ('center' as const) : ('top' as const),
562
+ }),
563
+ [bounds.minHeight, bounds.maxHeight, inline, lineHeight, metrics.fontSize, padding]
564
+ );
565
+
566
+ const slots = aiInputVariants({ size });
567
+
568
+ return (
569
+ <TextInput
570
+ {...props}
571
+ multiline
572
+ value={value}
573
+ onChangeText={setValue}
574
+ placeholder={placeholder}
575
+ placeholderTextColor={typeof placeholderColor === 'string' ? placeholderColor : undefined}
576
+ editable={!disabled}
577
+ /*
578
+ * `scrollEnabled` is deliberately left alone.
579
+ *
580
+ * Driving it from the measurement is a loop: switching it on changes
581
+ * what the platform reports as the content size, which changes the
582
+ * answer to whether it should be on, and the field resizes for ever. A
583
+ * multiline field scrolls once its height is fixed anyway, so there is
584
+ * nothing to drive — the clamp below is the whole mechanism.
585
+ */
586
+ onFocus={handleFocus}
587
+ onBlur={handleBlur}
588
+ className={inline ? slots.fieldInline({ className }) : slots.field({ className })}
589
+ style={[boxStyle, style]}
590
+ />
591
+ );
592
+ }
593
+
594
+ /* -------------------------------------------------------------------------- */
595
+ /* Toolbar and its controls */
596
+ /* -------------------------------------------------------------------------- */
597
+
598
+ /**
599
+ * True inside `AIInput.Row` — the parts that lay out differently on one line
600
+ * read it rather than taking a prop each, so a caller rearranges the composer
601
+ * by moving one component instead of setting a flag on three.
602
+ */
603
+ const AIInputInlineContext = createContext(false);
604
+
605
+ export interface AIInputRowProps extends ViewProps {
606
+ className?: string;
607
+ }
608
+
609
+ /**
610
+ * The field and its controls on a single line.
611
+ *
612
+ * The composer at its smallest: one pill above the keyboard with the field
613
+ * between the controls rather than above them. It suits a bar that is always
614
+ * on screen, where the stacked composer suits one that is the focus of it.
615
+ *
616
+ * The field still grows — it just grows the row.
617
+ *
618
+ * ```tsx
619
+ * <AIInput>
620
+ * <AIInput.Row>
621
+ * <AIInput.Action label="Add" icon={<PlusIcon />} />
622
+ * <AIInput.Field placeholder="Ask anything" />
623
+ * <AIInput.Action label="Dictate" icon={<MicIcon />} />
624
+ * <AIInput.Submit />
625
+ * </AIInput.Row>
626
+ * </AIInput>
627
+ * ```
628
+ */
629
+ function AIInputRow({ className, children, ...props }: AIInputRowProps) {
630
+ const { size } = useAIInput('AIInput.Row');
631
+ const slots = aiInputVariants({ size });
632
+ return (
633
+ <AIInputInlineContext.Provider value>
634
+ <View {...props} className={slots.row({ className })}>
635
+ {children}
636
+ </View>
637
+ </AIInputInlineContext.Provider>
638
+ );
639
+ }
640
+
641
+ export interface AIInputToolbarProps extends ViewProps {
642
+ className?: string;
643
+ }
644
+
645
+ function AIInputToolbar({ className, children, style, ...props }: AIInputToolbarProps) {
646
+ const { size, native } = useAIInput('AIInput.Toolbar');
647
+ const slots = aiInputVariants({ size });
648
+ return (
649
+ <View
650
+ {...props}
651
+ accessibilityRole="toolbar"
652
+ className={cn(slots.toolbar({ className }), native && 'absolute inset-x-0 bottom-0')}
653
+ style={[
654
+ native ? { height: NATIVE_ROW_HEIGHT, paddingBottom: NATIVE_ROW_FOOT } : null,
655
+ style,
656
+ ]}
657
+ >
658
+ {children}
659
+ </View>
660
+ );
661
+ }
662
+
663
+ /** Pushes everything after it to the trailing end of the toolbar. */
664
+ function AIInputSpacer({ className, ...props }: ViewProps) {
665
+ return <View {...props} className={cn('flex-1', className)} />;
666
+ }
667
+
668
+ export interface AIInputActionProps
669
+ extends Omit<ViewProps, 'children'>,
670
+ Pick<ViewProps, 'accessibilityHint'> {
671
+ className?: string;
672
+ /** Names the control. It is a circle with a glyph in it; nothing else says what it does. */
673
+ label: string;
674
+ icon: ReactNode;
675
+ onPress?: () => void;
676
+ disabled?: boolean;
677
+ /** Control size. Inherited from the composer when it is inside one. */
678
+ size?: AIInputSize;
679
+ /**
680
+ * Draw it as the platform's own button, in the system material. Inherited
681
+ * from the composer when it is inside one. The platform owns its colour and
682
+ * shape, so `className` stops reaching it.
683
+ */
684
+ native?: boolean;
685
+ }
686
+
687
+ function AIInputAction({
688
+ className,
689
+ label,
690
+ icon,
691
+ onPress,
692
+ disabled,
693
+ size: sizeProp,
694
+ native,
695
+ ...props
696
+ }: AIInputActionProps) {
697
+ const context = useAIInputOptional();
698
+ const scale = sizeProp ?? context?.size ?? 'md';
699
+ const size = CONTROL[scale];
700
+ const isDisabled = disabled ?? context?.disabled ?? false;
701
+ const isNative = (native ?? context?.native ?? false) && hasNativeUI();
702
+
703
+ /*
704
+ * The platform's own button, in its own material. `Button` already knows how
705
+ * to ask for that — the circular border shape, the glass style, and the
706
+ * hosting an icon inside a native control needs — so this is a handoff
707
+ * rather than a second implementation of it.
708
+ */
709
+ if (isNative) {
710
+ return (
711
+ <View {...props} className={className}>
712
+ <Button
713
+ native
714
+ glass
715
+ variant="secondary"
716
+ size="icon"
717
+ accessibilityLabel={label}
718
+ disabled={isDisabled}
719
+ onPress={onPress}
720
+ >
721
+ {icon}
722
+ </Button>
723
+ </View>
724
+ );
725
+ }
726
+
727
+ return (
728
+ <Glass
729
+ variant="clear"
730
+ radius={size / 2}
731
+ fallbackClassName="bg-muted"
732
+ {...props}
733
+ className={className}
734
+ style={{ width: size, height: size }}
735
+ >
736
+ <AnimatedPressable
737
+ accessibilityRole="button"
738
+ accessibilityLabel={label}
739
+ accessibilityState={{ disabled: isDisabled }}
740
+ disabled={isDisabled}
741
+ onPress={onPress}
742
+ // The dim goes on the content rather than on the material. Fading a
743
+ // system material degrades it, and at zero it stops drawing entirely.
744
+ className={cn('h-full w-full items-center justify-center', isDisabled && 'opacity-40')}
745
+ >
746
+ {icon}
747
+ </AnimatedPressable>
748
+ </Glass>
749
+ );
750
+ }
751
+
752
+ export interface AIInputPillProps extends Omit<ViewProps, 'children'> {
753
+ className?: string;
754
+ /** The current value — a model name, a mode, a project. */
755
+ label: ReactNode;
756
+ /** A second, quieter value beside it. */
757
+ detail?: ReactNode;
758
+ /** A glyph after the labels, for a pill that opens a list. */
759
+ indicator?: ReactNode;
760
+ onPress?: () => void;
761
+ disabled?: boolean;
762
+ /** Names the control when the label alone does not say what changing it does. */
763
+ accessibilityLabel?: string;
764
+ /** Control size. Inherited from the composer when it is inside one. */
765
+ size?: AIInputSize;
766
+ /**
767
+ * Draw it as the platform's own button, in the system material. Only a
768
+ * string `label` can be handed over — a hosted view inside a native button
769
+ * has no width anything can resolve — so a pill given elements is drawn
770
+ * here whatever this says.
771
+ */
772
+ native?: boolean;
773
+ }
774
+
775
+ function AIInputPill({
776
+ className,
777
+ label,
778
+ detail,
779
+ indicator,
780
+ onPress,
781
+ disabled,
782
+ accessibilityLabel,
783
+ size: sizeProp,
784
+ native,
785
+ ...props
786
+ }: AIInputPillProps) {
787
+ const context = useAIInputOptional();
788
+ const size = sizeProp ?? context?.size ?? 'md';
789
+ const height = CONTROL[size];
790
+ const isDisabled = disabled ?? context?.disabled ?? false;
791
+ const slots = aiInputVariants({ size });
792
+
793
+ /*
794
+ * Only a plain string goes to the platform. Passing elements makes it host
795
+ * them, and a hosted view inside a labelled button leaves the width
796
+ * unresolved — which is not an exception anything here could catch but a
797
+ * crash in native code. A pill with a `detail` or an `indicator` is drawn.
798
+ */
799
+ const isNative =
800
+ (native ?? context?.native ?? false) &&
801
+ hasNativeUI() &&
802
+ typeof label === 'string' &&
803
+ detail === undefined &&
804
+ indicator === undefined;
805
+
806
+ if (isNative) {
807
+ return (
808
+ <View {...props} className={className}>
809
+ <Button
810
+ native
811
+ glass
812
+ variant="secondary"
813
+ size="sm"
814
+ accessibilityLabel={accessibilityLabel}
815
+ disabled={isDisabled}
816
+ onPress={onPress}
817
+ >
818
+ {label as string}
819
+ </Button>
820
+ </View>
821
+ );
822
+ }
823
+
824
+ return (
825
+ <Glass
826
+ variant="clear"
827
+ radius={height / 2}
828
+ fallbackClassName="bg-muted"
829
+ {...props}
830
+ className={className}
831
+ style={{ height }}
832
+ >
833
+ <AnimatedPressable
834
+ accessibilityRole="button"
835
+ accessibilityLabel={accessibilityLabel}
836
+ accessibilityState={{ disabled: isDisabled }}
837
+ disabled={isDisabled}
838
+ onPress={onPress}
839
+ className={slots.pill({ className: cn('h-full', isDisabled && 'opacity-40') })}
840
+ >
841
+ {typeof label === 'string' ? (
842
+ <Text size="sm" weight="medium">
843
+ {label}
844
+ </Text>
845
+ ) : (
846
+ label
847
+ )}
848
+ {typeof detail === 'string' ? (
849
+ <Text size="sm" muted>
850
+ {detail}
851
+ </Text>
852
+ ) : (
853
+ detail
854
+ )}
855
+ {indicator}
856
+ </AnimatedPressable>
857
+ </Glass>
858
+ );
859
+ }
860
+
861
+ export interface AIInputSubmitProps extends Omit<ViewProps, 'children'> {
862
+ className?: string;
863
+ /** Names the button in its send state. */
864
+ sendLabel?: string;
865
+ /** Names it in its voice state, which is what an empty composer offers. */
866
+ voiceLabel?: string;
867
+ /** Names it while the model is answering. */
868
+ stopLabel?: string;
869
+ /** Draw it as the platform's own button, in the system material. */
870
+ native?: boolean;
871
+ }
872
+
873
+ /**
874
+ * The trailing button, which is three buttons wearing one shape.
875
+ *
876
+ * Empty and ready, it opens voice mode; with something typed, it sends; while
877
+ * the model is answering, it stops. One control rather than three because only
878
+ * one of them is ever the thing to do, and a row of dimmed siblings is a row
879
+ * of things that look broken.
880
+ */
881
+ function AIInputSubmit({
882
+ className,
883
+ sendLabel = 'Send',
884
+ voiceLabel = 'Voice mode',
885
+ stopLabel = 'Stop',
886
+ native,
887
+ ...props
888
+ }: AIInputSubmitProps) {
889
+ const context = useAIInput('AIInput.Submit');
890
+ const { value, status, size, disabled, submit, stop, voice, canVoice, canStop } = context;
891
+ const diameter = CONTROL[size] + 2;
892
+ const streaming = status === 'streaming' || status === 'submitted';
893
+ const hasText = value.trim().length > 0;
894
+
895
+ const onSurface = useCSSVariable('--color-primary-foreground');
896
+ const onSolid = useCSSVariable('--color-background');
897
+
898
+ const mode = streaming ? 'stop' : hasText ? 'send' : 'voice';
899
+ const label = mode === 'stop' ? stopLabel : mode === 'send' ? sendLabel : voiceLabel;
900
+ const onPress = mode === 'stop' ? stop : mode === 'send' ? submit : voice;
901
+ const isNative = (native ?? context.native) && hasNativeUI();
902
+
903
+ /*
904
+ * Nothing to send is not something to press.
905
+ *
906
+ * Empty, the button offers voice mode — but only if the app took `onVoice`;
907
+ * with nothing wired to it, it is a live-looking control that does nothing.
908
+ * Same for stop. So the button is disabled whenever the thing it is offering
909
+ * cannot happen, which for an empty composer with no voice mode means it
910
+ * stays inert until something is typed.
911
+ */
912
+ const inert =
913
+ disabled ||
914
+ (mode === 'send' ? !hasText : mode === 'stop' ? !canStop : !canVoice);
915
+
916
+ const glyph =
917
+ mode === 'send' ? (
918
+ <SendArrowIcon size={18} />
919
+ ) : mode === 'stop' ? (
920
+ <View className="rounded-sm bg-primary-foreground" style={{ width: 11, height: 11 }} />
921
+ ) : (
922
+ <AudioLinesIcon size={18} />
923
+ );
924
+
925
+ /*
926
+ * The platform's own button, tinted. `Button` maps a primary variant onto
927
+ * `glassProminent`, which is the one that keeps an accent fill — drawing the
928
+ * material by hand over a plain button throws that fill away, and this is
929
+ * the one control in the row that is supposed to be filled.
930
+ */
931
+ if (isNative) {
932
+ return (
933
+ <View {...props} className={className}>
934
+ <Button
935
+ native
936
+ glass
937
+ variant="primary"
938
+ size="icon"
939
+ accessibilityLabel={label}
940
+ disabled={inert}
941
+ onPress={onPress}
942
+ >
943
+ {glyph}
944
+ </Button>
945
+ </View>
946
+ );
947
+ }
948
+
949
+ return (
950
+ <View
951
+ {...props}
952
+ className={cn(
953
+ 'overflow-hidden rounded-full',
954
+ mode === 'voice' ? 'bg-foreground' : 'bg-primary',
955
+ className
956
+ )}
957
+ style={{ width: diameter, height: diameter }}
958
+ >
959
+ <AnimatedPressable
960
+ accessibilityRole="button"
961
+ accessibilityLabel={label}
962
+ accessibilityState={{ disabled: inert }}
963
+ disabled={inert}
964
+ onPress={onPress}
965
+ className={cn('h-full w-full items-center justify-center', inert && 'opacity-40')}
966
+ >
967
+ <IconColorProvider
968
+ color={
969
+ mode === 'voice'
970
+ ? typeof onSolid === 'string'
971
+ ? onSolid
972
+ : undefined
973
+ : typeof onSurface === 'string'
974
+ ? onSurface
975
+ : undefined
976
+ }
977
+ >
978
+ {glyph}
979
+ </IconColorProvider>
980
+ </AnimatedPressable>
981
+ </View>
982
+ );
983
+ }
984
+
985
+ export interface AIInputRecordingProps extends Omit<ViewProps, 'children'> {
986
+ className?: string;
987
+ cancelLabel?: string;
988
+ confirmLabel?: string;
989
+ /** Draw the two decisions as the platform's own buttons. */
990
+ native?: boolean;
991
+ }
992
+
993
+ /**
994
+ * What the toolbar becomes while a recording is running: throw it away, watch
995
+ * it, or keep it. Render it in place of `AIInput.Toolbar` when `status` is
996
+ * `recording`.
997
+ */
998
+ function AIInputRecording({
999
+ className,
1000
+ cancelLabel = 'Discard recording',
1001
+ confirmLabel = 'Use recording',
1002
+ native,
1003
+ ...props
1004
+ }: AIInputRecordingProps) {
1005
+ const context = useAIInput('AIInput.Recording');
1006
+ const { size, level, recordCancel, recordConfirm, native: contextNative } = context;
1007
+ const control = CONTROL[size];
1008
+ const slots = aiInputVariants({ size });
1009
+ const onPrimary = useCSSVariable('--color-primary-foreground');
1010
+ const isNative = (native ?? contextNative) && hasNativeUI();
1011
+
1012
+ const meter = (
1013
+ <View className={slots.meter()}>
1014
+ <Soundwave
1015
+ variant="bars"
1016
+ mode="scrolling"
1017
+ state="listening"
1018
+ level={level}
1019
+ centered
1020
+ barWidth={2.5}
1021
+ height={control}
1022
+ />
1023
+ </View>
1024
+ );
1025
+
1026
+ if (isNative) {
1027
+ return (
1028
+ <View
1029
+ {...props}
1030
+ className={cn(slots.recording({ className }), 'absolute inset-x-0 bottom-0')}
1031
+ style={{ height: NATIVE_ROW_HEIGHT, paddingBottom: NATIVE_ROW_FOOT }}
1032
+ >
1033
+ <Button
1034
+ native
1035
+ glass
1036
+ variant="secondary"
1037
+ size="icon"
1038
+ accessibilityLabel={cancelLabel}
1039
+ onPress={recordCancel}
1040
+ >
1041
+ <XIcon size={16} />
1042
+ </Button>
1043
+ {meter}
1044
+ <Button
1045
+ native
1046
+ glass
1047
+ variant="primary"
1048
+ size="icon"
1049
+ accessibilityLabel={confirmLabel}
1050
+ onPress={recordConfirm}
1051
+ >
1052
+ <CheckIcon size={16} />
1053
+ </Button>
1054
+ </View>
1055
+ );
1056
+ }
1057
+
1058
+ return (
1059
+ <View {...props} className={slots.recording({ className })}>
1060
+ <Glass
1061
+ variant="clear"
1062
+ radius={control / 2}
1063
+ fallbackClassName="bg-muted"
1064
+ style={{ width: control, height: control }}
1065
+ >
1066
+ <AnimatedPressable
1067
+ accessibilityRole="button"
1068
+ accessibilityLabel={cancelLabel}
1069
+ onPress={recordCancel}
1070
+ className="h-full w-full items-center justify-center"
1071
+ >
1072
+ <XIcon size={16} />
1073
+ </AnimatedPressable>
1074
+ </Glass>
1075
+
1076
+ {meter}
1077
+
1078
+ <View
1079
+ className="overflow-hidden rounded-full bg-primary"
1080
+ style={{ width: control + 4, height: control + 4 }}
1081
+ >
1082
+ <AnimatedPressable
1083
+ accessibilityRole="button"
1084
+ accessibilityLabel={confirmLabel}
1085
+ onPress={recordConfirm}
1086
+ className="h-full w-full items-center justify-center"
1087
+ >
1088
+ <CheckIcon
1089
+ size={18}
1090
+ color={typeof onPrimary === 'string' ? onPrimary : undefined}
1091
+ />
1092
+ </AnimatedPressable>
1093
+ </View>
1094
+ </View>
1095
+ );
1096
+ }
1097
+
1098
+ /* -------------------------------------------------------------------------- */
1099
+ /* Sheet */
1100
+ /* -------------------------------------------------------------------------- */
1101
+
1102
+ interface AIInputSheetContextValue {
1103
+ /** Screen ids, root first. The last one is what is on screen. */
1104
+ stack: string[];
1105
+ push: (id: string) => void;
1106
+ pop: () => void;
1107
+ close: () => void;
1108
+ }
1109
+
1110
+ const AIInputSheetContext = createContext<AIInputSheetContextValue | null>(null);
1111
+
1112
+ function useAIInputSheet(component: string): AIInputSheetContextValue {
1113
+ const context = useContext(AIInputSheetContext);
1114
+ if (!context) throw new Error(`${component} must be used within an <AIInput.Sheet>`);
1115
+ return context;
1116
+ }
1117
+
1118
+ export interface AIInputSheetScreenProps extends ViewProps {
1119
+ className?: string;
1120
+ /** Names this screen. `AIInput.Sheet.Row`'s `to` pushes the screen with this id. */
1121
+ id: string;
1122
+ /** Centred in the header, and the first thing a screen reader reaches. */
1123
+ title?: ReactNode;
1124
+ /**
1125
+ * A control at the trailing end of the header — a second action the screen
1126
+ * offers. The leading end is the sheet's, and is a close button on the root
1127
+ * screen and a back button on every screen pushed onto it.
1128
+ */
1129
+ trailing?: ReactNode;
1130
+ }
1131
+
1132
+ function AIInputSheetScreen({ className, id, title, trailing, children, ...props }: AIInputSheetScreenProps) {
1133
+ void id;
1134
+ void title;
1135
+ void trailing;
1136
+ return (
1137
+ <View {...props} className={cn('gap-4 pb-2', className)}>
1138
+ {children}
1139
+ </View>
1140
+ );
1141
+ }
1142
+
1143
+ type ScreenElement = ReactElement<AIInputSheetScreenProps>;
1144
+
1145
+ function screensOf(children: ReactNode): ScreenElement[] {
1146
+ return Children.toArray(children).filter(
1147
+ (child): child is ScreenElement =>
1148
+ isValidElement(child) && child.type === AIInputSheetScreen
1149
+ );
1150
+ }
1151
+
1152
+ export interface AIInputSheetProps {
1153
+ children?: ReactNode;
1154
+ className?: string;
1155
+ open?: boolean;
1156
+ onOpenChange?: (open: boolean) => void;
1157
+ defaultOpen?: boolean;
1158
+ /** Which screen opens first. Defaults to the first one given. */
1159
+ initialScreen?: string;
1160
+ /** Called whenever the screen on top changes, pushed or popped. */
1161
+ onScreenChange?: (id: string) => void;
1162
+ /** Frost the screen behind the sheet instead of dimming it. */
1163
+ blur?: boolean;
1164
+ /** How tall the sheet opens. `auto` sizes to the screen currently on top. */
1165
+ size?: 'auto' | 'half' | 'full';
1166
+ /**
1167
+ * Float the sheet clear of the screen edges instead of docking it to the
1168
+ * bottom. On by default: the surface is a material, and a material reads as
1169
+ * laid over the app when there is app visible around all four of its edges.
1170
+ * Docked, its bottom edge is the screen's, and there is nothing behind it
1171
+ * there to refract.
1172
+ */
1173
+ detached?: boolean;
1174
+ }
1175
+
1176
+ /**
1177
+ * The sheet the composer's controls open, and the screens it goes on to.
1178
+ *
1179
+ * A row that leads somewhere pushes a screen onto this sheet rather than
1180
+ * opening a second one over it. The sheet stays where it is, the header's
1181
+ * close button becomes a back button, and the body slides — which is what
1182
+ * makes going one level deeper feel like the same surface rather than another
1183
+ * one landing on top of it.
1184
+ *
1185
+ * ```tsx
1186
+ * <AIInput.Sheet open={open} onOpenChange={setOpen}>
1187
+ * <AIInput.Sheet.Screen id="root" title="Add to chat">
1188
+ * <AIInput.Sheet.Group>
1189
+ * <AIInput.Sheet.Row label="Add to project" value="None" to="project" />
1190
+ * <AIInput.Sheet.Toggle label="Web search" value={web} onValueChange={setWeb} />
1191
+ * </AIInput.Sheet.Group>
1192
+ * </AIInput.Sheet.Screen>
1193
+ * <AIInput.Sheet.Screen id="project" title="Add to project">…</AIInput.Sheet.Screen>
1194
+ * </AIInput.Sheet>
1195
+ * ```
1196
+ */
1197
+ function AIInputSheet({
1198
+ children,
1199
+ className,
1200
+ open: openProp,
1201
+ onOpenChange,
1202
+ defaultOpen = false,
1203
+ initialScreen,
1204
+ onScreenChange,
1205
+ blur = false,
1206
+ size = 'auto',
1207
+ detached = true,
1208
+ }: AIInputSheetProps) {
1209
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
1210
+ const isControlled = openProp !== undefined;
1211
+ const open = isControlled ? openProp : internalOpen;
1212
+
1213
+ const screens = screensOf(children);
1214
+ const rootId = initialScreen ?? screens[0]?.props.id ?? '';
1215
+ const [stack, setStack] = useState<string[]>(rootId ? [rootId] : []);
1216
+
1217
+ const setOpen = useCallback(
1218
+ (next: boolean) => {
1219
+ if (!isControlled) setInternalOpen(next);
1220
+ onOpenChange?.(next);
1221
+ },
1222
+ [isControlled, onOpenChange]
1223
+ );
1224
+
1225
+ /*
1226
+ * A closed sheet forgets where it was. Reopening onto a screen somebody
1227
+ * pushed and then dismissed is a sheet that answers a question nobody asked
1228
+ * again. Done on the way out rather than the way in, where the reset would
1229
+ * be visible for a frame before the sheet arrived.
1230
+ */
1231
+ useEffect(() => {
1232
+ if (!open && rootId) setStack([rootId]);
1233
+ }, [open, rootId]);
1234
+
1235
+ const push = useCallback(
1236
+ (id: string) => {
1237
+ setStack((current) => (current[current.length - 1] === id ? current : [...current, id]));
1238
+ onScreenChange?.(id);
1239
+ },
1240
+ [onScreenChange]
1241
+ );
1242
+
1243
+ const pop = useCallback(() => {
1244
+ setStack((current) => {
1245
+ if (current.length <= 1) return current;
1246
+ const next = current.slice(0, -1);
1247
+ const top = next[next.length - 1];
1248
+ if (top) onScreenChange?.(top);
1249
+ return next;
1250
+ });
1251
+ }, [onScreenChange]);
1252
+
1253
+ const close = useCallback(() => setOpen(false), [setOpen]);
1254
+
1255
+ const context = useMemo<AIInputSheetContextValue>(
1256
+ () => ({ stack, push, pop, close }),
1257
+ [stack, push, pop, close]
1258
+ );
1259
+
1260
+ const activeId = stack[stack.length - 1];
1261
+ const active = screens.find((screen) => screen.props.id === activeId) ?? screens[0];
1262
+ const depth = stack.length;
1263
+
1264
+ const insets = useSafeAreaInsets();
1265
+
1266
+ // A sheet with no screens has nothing to show and no title to head it with.
1267
+ // Below every hook, so the count does not change with the children.
1268
+ if (!active) return null;
1269
+
1270
+ return (
1271
+ <BottomSheet open={open} onOpenChange={setOpen}>
1272
+ <BottomSheet.Content
1273
+ blur={blur}
1274
+ size={size}
1275
+ detached={detached}
1276
+ showClose={false}
1277
+ showGrabber={false}
1278
+ className="border-0 bg-transparent px-0 pt-0 shadow-none"
1279
+ // The sheet's own bottom padding would sit outside the material and
1280
+ // leave a band of backdrop under it. The glass takes the inset instead.
1281
+ style={{ paddingBottom: 0 }}
1282
+ >
1283
+ {/*
1284
+ * Inside the content, not around the sheet. `BottomSheet.Content`
1285
+ * renders through a portal, so everything below it mounts under the
1286
+ * portal host rather than here — a provider wrapped around the sheet
1287
+ * is not an ancestor of the header that reads it.
1288
+ */}
1289
+ <AIInputSheetContext.Provider value={context}>
1290
+ {/*
1291
+ * A solid surface, not a material.
1292
+ *
1293
+ * A sheet covers most of the screen, so there is almost nothing
1294
+ * behind it left to refract — the material reads as a grey wash over
1295
+ * a blur of nothing rather than as glass, and the controls sitting on
1296
+ * it lose their own material to it. The composer keeps the glass,
1297
+ * because a bar floating over a page is the case the material is for.
1298
+ *
1299
+ * Four rounded corners when it floats, two when it is docked: the
1300
+ * bottom edge of a docked sheet is the screen's edge, and rounding a
1301
+ * corner there rounds nothing.
1302
+ */}
1303
+ <View
1304
+ className={cn(
1305
+ 'overflow-hidden border border-border bg-popover px-4 pt-2',
1306
+ detached ? 'rounded-3xl' : 'rounded-t-3xl border-b-0'
1307
+ )}
1308
+ // A floating sheet's own bottom margin already clears the home
1309
+ // indicator, so it takes plain padding rather than stacking the
1310
+ // inset on top of the gap.
1311
+ style={{ paddingBottom: detached ? 16 : Math.max(insets.bottom, 16) }}
1312
+ >
1313
+ <View className="mb-2 self-center">
1314
+ <View className="h-1 w-10 rounded-full bg-muted-foreground/40" />
1315
+ </View>
1316
+ <AIInputSheetHeader
1317
+ depth={depth}
1318
+ title={active.props.title}
1319
+ trailing={active.props.trailing}
1320
+ />
1321
+ <AIInputSheetBody activeId={activeId} depth={depth} className={className}>
1322
+ {active}
1323
+ </AIInputSheetBody>
1324
+ </View>
1325
+ </AIInputSheetContext.Provider>
1326
+ </BottomSheet.Content>
1327
+ </BottomSheet>
1328
+ );
1329
+ }
1330
+
1331
+ function AIInputSheetHeader({
1332
+ depth,
1333
+ title,
1334
+ trailing,
1335
+ }: {
1336
+ depth: number;
1337
+ title?: ReactNode;
1338
+ trailing?: ReactNode;
1339
+ }) {
1340
+ const { pop, close } = useAIInputSheet('AIInput.Sheet');
1341
+ const rtl = useDirection() === 'rtl';
1342
+ const nested = depth > 1;
1343
+ const Back = rtl ? ChevronRightIcon : ChevronLeftIcon;
1344
+
1345
+ return (
1346
+ <View className="h-11 flex-row items-center">
1347
+ <Glass
1348
+ variant="clear"
1349
+ radius={17}
1350
+ fallbackClassName="bg-muted"
1351
+ style={{ width: 34, height: 34 }}
1352
+ >
1353
+ <AnimatedPressable
1354
+ accessibilityRole="button"
1355
+ accessibilityLabel={nested ? 'Back' : 'Close'}
1356
+ onPress={nested ? pop : close}
1357
+ className="h-full w-full items-center justify-center"
1358
+ >
1359
+ {nested ? <Back size={20} /> : <XIcon size={18} />}
1360
+ </AnimatedPressable>
1361
+ </Glass>
1362
+
1363
+ {/*
1364
+ * Centred against the sheet rather than against what is left over, so the
1365
+ * title does not shift when the trailing action appears on one screen and
1366
+ * not the next. It takes no touches, so it cannot bury the buttons at
1367
+ * either end the way an absolutely positioned full-width title would.
1368
+ */}
1369
+ <View pointerEvents="none" className="absolute inset-x-0 items-center">
1370
+ {typeof title === 'string' ? (
1371
+ <Text size="xl" weight="semibold" numberOfLines={1}>
1372
+ {title}
1373
+ </Text>
1374
+ ) : (
1375
+ title
1376
+ )}
1377
+ </View>
1378
+
1379
+ <View className="flex-1" />
1380
+ {trailing}
1381
+ </View>
1382
+ );
1383
+ }
1384
+
1385
+ function AIInputSheetBody({
1386
+ activeId,
1387
+ depth,
1388
+ className,
1389
+ children,
1390
+ }: {
1391
+ activeId?: string;
1392
+ depth: number;
1393
+ className?: string;
1394
+ children: ReactNode;
1395
+ }) {
1396
+ const { width } = useWindowDimensions();
1397
+ const height = useSharedValue(-1);
1398
+ const paneRef = useRef<View>(null);
1399
+
1400
+ /*
1401
+ * Whether the screen is lifted out of the flow onto its own layer.
1402
+ *
1403
+ * Not on the first layout, which is what a wizard inside a fixed frame can
1404
+ * afford. A sheet sized to its content is still sliding up while its first
1405
+ * screen measures, and switching the screen to absolute mid-flight takes the
1406
+ * height out from under the sheet and puts it back a frame later — which is
1407
+ * the sheet arriving with a stutter in it.
1408
+ *
1409
+ * So the first screen stays in the flow and the sheet opens around a real
1410
+ * height. The layer is only needed once two screens have to overlap, which
1411
+ * is the moment somebody navigates, and by then the height below is already
1412
+ * recorded to travel from.
1413
+ */
1414
+ const [layered, setLayered] = useState(false);
1415
+
1416
+ /*
1417
+ * Which way the screen slides, worked out from the move rather than from
1418
+ * whatever caused it — a row, a back button and a caller pushing directly
1419
+ * are the same move to the reader, and only the change in depth says which
1420
+ * way it went.
1421
+ *
1422
+ * Read during render, not in an effect: the arriving screen's animation is
1423
+ * fixed when it mounts, and an effect runs after that, which would leave
1424
+ * every transition playing the direction of the one before it.
1425
+ */
1426
+ const previousDepth = useRef(depth);
1427
+ const direction: 1 | -1 = depth >= previousDepth.current ? 1 : -1;
1428
+ const navigated = useRef(false);
1429
+
1430
+ useEffect(() => {
1431
+ if (depth !== previousDepth.current) {
1432
+ previousDepth.current = depth;
1433
+ navigated.current = true;
1434
+ }
1435
+ }, [depth]);
1436
+
1437
+ // The first navigation is what needs two screens on top of each other.
1438
+ useEffect(() => {
1439
+ if (navigated.current) setLayered(true);
1440
+ }, [activeId]);
1441
+
1442
+ // Move the reader onto the screen that just arrived, rather than leaving
1443
+ // focus on the row that is no longer there.
1444
+ useEffect(() => {
1445
+ if (!navigated.current) return;
1446
+ const node = paneRef.current;
1447
+ if (!node) return;
1448
+ const tag = findNodeHandle(node);
1449
+ if (tag != null) AccessibilityInfo.setAccessibilityFocus(tag);
1450
+ }, [activeId]);
1451
+
1452
+ /*
1453
+ * Until the first screen has been measured the pane stays in the flow, so a
1454
+ * sheet sized to its content has a real height on the first pass. Absolute
1455
+ * from the start would measure zero — the container's only child would
1456
+ * contribute nothing — and the sheet would open around a screen nobody can
1457
+ * see.
1458
+ */
1459
+ const onPaneLayout = useCallback(
1460
+ (event: LayoutChangeEvent) => {
1461
+ const next = event.nativeEvent.layout.height;
1462
+ if (next <= 0) return;
1463
+ // Recorded whether or not it is being used yet, so the first push has a
1464
+ // height to travel from rather than growing out of nothing.
1465
+ if (layered) {
1466
+ height.value = withTiming(next, { duration: HEIGHT_DURATION, easing: EASE });
1467
+ return;
1468
+ }
1469
+ height.value = next;
1470
+ },
1471
+ [layered, height]
1472
+ );
1473
+
1474
+ const heightStyle = useAnimatedStyle(() =>
1475
+ height.value < 0 ? {} : { height: height.value }
1476
+ );
1477
+
1478
+ /*
1479
+ * Written out rather than taken from the stock builders, which carry an
1480
+ * initial opacity and nothing else — the distance is the part worth
1481
+ * controlling here, and none of them lets it be set.
1482
+ */
1483
+ const offset = width > 0 ? width * SLIDE_FRACTION : 60;
1484
+ const entering = useCallback<EntryExitAnimationFunction>(() => {
1485
+ 'worklet';
1486
+ return {
1487
+ initialValues: { opacity: 0, transform: [{ translateX: direction * offset }] },
1488
+ animations: {
1489
+ opacity: withTiming(1, { duration: ENTER_DURATION, easing: EASE }),
1490
+ transform: [{ translateX: withTiming(0, { duration: ENTER_DURATION, easing: EASE }) }],
1491
+ },
1492
+ };
1493
+ }, [direction, offset]);
1494
+
1495
+ return (
1496
+ <Animated.View
1497
+ style={layered ? heightStyle : undefined}
1498
+ className={cn('w-full overflow-hidden', className)}
1499
+ >
1500
+ <Animated.View
1501
+ key={activeId ?? '__empty__'}
1502
+ entering={layered ? entering : undefined}
1503
+ exiting={FadeOut.duration(EXIT_DURATION)}
1504
+ className={layered ? 'absolute inset-x-0 top-0' : 'w-full'}
1505
+ >
1506
+ <View ref={paneRef} onLayout={onPaneLayout} className="w-full pt-2">
1507
+ {children}
1508
+ </View>
1509
+ </Animated.View>
1510
+ </Animated.View>
1511
+ );
1512
+ }
1513
+
1514
+ /* -------------------------------------------------------------------------- */
1515
+ /* Sheet content */
1516
+ /* -------------------------------------------------------------------------- */
1517
+
1518
+ export interface AIInputSheetGroupProps extends ViewProps {
1519
+ className?: string;
1520
+ /** A line under the group, for what the rows in it mean. */
1521
+ footnote?: ReactNode;
1522
+ }
1523
+
1524
+ /** A card of related rows, with a hairline between each pair. */
1525
+ function AIInputSheetGroup({ className, footnote, children, ...props }: AIInputSheetGroupProps) {
1526
+ const rows = Children.toArray(children);
1527
+ return (
1528
+ <View className="gap-2">
1529
+ <View {...props} className={cn('overflow-hidden rounded-[20px] bg-muted/50', className)}>
1530
+ {rows.map((row, index) => (
1531
+ <View key={index}>
1532
+ {index > 0 ? <View className="ms-4 h-px bg-border" /> : null}
1533
+ {row}
1534
+ </View>
1535
+ ))}
1536
+ </View>
1537
+ {typeof footnote === 'string' ? (
1538
+ <Text size="sm" muted className="px-2">
1539
+ {footnote}
1540
+ </Text>
1541
+ ) : (
1542
+ footnote
1543
+ )}
1544
+ </View>
1545
+ );
1546
+ }
1547
+
1548
+ export interface AIInputSheetRowProps extends Omit<ViewProps, 'children'> {
1549
+ className?: string;
1550
+ label: ReactNode;
1551
+ /** A quieter line under the label. */
1552
+ description?: ReactNode;
1553
+ /** A glyph at the leading end. */
1554
+ icon?: ReactNode;
1555
+ /** The current setting, shown at the trailing end. */
1556
+ value?: ReactNode;
1557
+ /**
1558
+ * Push the screen with this id when the row is pressed. A row with one shows
1559
+ * a chevron, because a row that leads somewhere should say so before it is
1560
+ * pressed.
1561
+ */
1562
+ to?: string;
1563
+ onPress?: () => void;
1564
+ disabled?: boolean;
1565
+ }
1566
+
1567
+ function AIInputSheetRow({
1568
+ className,
1569
+ label,
1570
+ description,
1571
+ icon,
1572
+ value,
1573
+ to,
1574
+ onPress,
1575
+ disabled = false,
1576
+ ...props
1577
+ }: AIInputSheetRowProps) {
1578
+ const { push } = useAIInputSheet('AIInput.Sheet.Row');
1579
+ const rtl = useDirection() === 'rtl';
1580
+ const Chevron = rtl ? ChevronLeftIcon : ChevronRightIcon;
1581
+
1582
+ const handlePress = useCallback(() => {
1583
+ onPress?.();
1584
+ if (to) push(to);
1585
+ }, [onPress, push, to]);
1586
+
1587
+ const body = (
1588
+ <>
1589
+ {icon}
1590
+ <View className="flex-1 gap-0.5">
1591
+ {typeof label === 'string' ? <Text size="base">{label}</Text> : label}
1592
+ {typeof description === 'string' ? (
1593
+ <Text size="sm" muted>
1594
+ {description}
1595
+ </Text>
1596
+ ) : (
1597
+ description
1598
+ )}
1599
+ </View>
1600
+ {typeof value === 'string' ? (
1601
+ <Text size="base" muted>
1602
+ {value}
1603
+ </Text>
1604
+ ) : (
1605
+ value
1606
+ )}
1607
+ {to ? <Chevron size={18} /> : null}
1608
+ </>
1609
+ );
1610
+
1611
+ const interactive = Boolean(to || onPress);
1612
+ if (!interactive) {
1613
+ return (
1614
+ <View {...props} className={cn('flex-row items-center gap-3 px-4 py-4', className)}>
1615
+ {body}
1616
+ </View>
1617
+ );
1618
+ }
1619
+
1620
+ return (
1621
+ <AnimatedPressable
1622
+ pressScale={1}
1623
+ pressOpacity={0.6}
1624
+ {...props}
1625
+ accessibilityRole="button"
1626
+ accessibilityState={{ disabled }}
1627
+ disabled={disabled}
1628
+ onPress={handlePress}
1629
+ className={cn('flex-row items-center gap-3 px-4 py-4', disabled && 'opacity-40', className)}
1630
+ >
1631
+ {body}
1632
+ </AnimatedPressable>
1633
+ );
1634
+ }
1635
+
1636
+ export interface AIInputSheetToggleProps extends Omit<ViewProps, 'children'> {
1637
+ className?: string;
1638
+ label: ReactNode;
1639
+ description?: ReactNode;
1640
+ icon?: ReactNode;
1641
+ value: boolean;
1642
+ onValueChange?: (value: boolean) => void;
1643
+ disabled?: boolean;
1644
+ }
1645
+
1646
+ function AIInputSheetToggle({
1647
+ className,
1648
+ label,
1649
+ description,
1650
+ icon,
1651
+ value,
1652
+ onValueChange,
1653
+ disabled = false,
1654
+ ...props
1655
+ }: AIInputSheetToggleProps) {
1656
+ return (
1657
+ <View {...props} className={cn('flex-row items-center gap-3 px-4 py-2.5', className)}>
1658
+ {icon}
1659
+ <View className="flex-1 gap-0.5">
1660
+ {typeof label === 'string' ? <Text size="base">{label}</Text> : label}
1661
+ {typeof description === 'string' ? (
1662
+ <Text size="sm" muted>
1663
+ {description}
1664
+ </Text>
1665
+ ) : (
1666
+ description
1667
+ )}
1668
+ </View>
1669
+ <Switch
1670
+ value={value}
1671
+ onValueChange={onValueChange}
1672
+ disabled={disabled}
1673
+ label={typeof label === 'string' ? label : undefined}
1674
+ />
1675
+ </View>
1676
+ );
1677
+ }
1678
+
1679
+ export interface AIInputSheetChoiceProps extends Omit<ViewProps, 'children'> {
1680
+ className?: string;
1681
+ label: ReactNode;
1682
+ description?: ReactNode;
1683
+ /** A pill beside the label — what the choice costs, or what it needs. */
1684
+ badge?: ReactNode;
1685
+ selected?: boolean;
1686
+ onPress?: () => void;
1687
+ disabled?: boolean;
1688
+ }
1689
+
1690
+ /** One of a set, with a tick on the one in force. */
1691
+ function AIInputSheetChoice({
1692
+ className,
1693
+ label,
1694
+ description,
1695
+ badge,
1696
+ selected = false,
1697
+ onPress,
1698
+ disabled = false,
1699
+ ...props
1700
+ }: AIInputSheetChoiceProps) {
1701
+ const tint = useCSSVariable('--color-primary');
1702
+
1703
+ return (
1704
+ <AnimatedPressable
1705
+ pressScale={1}
1706
+ pressOpacity={0.6}
1707
+ {...props}
1708
+ accessibilityRole="radio"
1709
+ accessibilityState={{ checked: selected, disabled }}
1710
+ disabled={disabled}
1711
+ onPress={onPress}
1712
+ className={cn(
1713
+ 'flex-row items-start gap-3 px-4 py-4',
1714
+ disabled && 'opacity-40',
1715
+ className
1716
+ )}
1717
+ >
1718
+ <View className="flex-1 gap-0.5">
1719
+ <View className="flex-row items-center gap-2">
1720
+ {typeof label === 'string' ? (
1721
+ <Text size="lg" weight="medium">
1722
+ {label}
1723
+ </Text>
1724
+ ) : (
1725
+ label
1726
+ )}
1727
+ {badge}
1728
+ </View>
1729
+ {typeof description === 'string' ? (
1730
+ <Text size="sm" muted>
1731
+ {description}
1732
+ </Text>
1733
+ ) : (
1734
+ description
1735
+ )}
1736
+ </View>
1737
+ {selected ? (
1738
+ <CheckIcon size={20} color={typeof tint === 'string' ? tint : undefined} />
1739
+ ) : null}
1740
+ </AnimatedPressable>
1741
+ );
1742
+ }
1743
+
1744
+ /* -------------------------------------------------------------------------- */
1745
+ /* Voice mode */
1746
+ /* -------------------------------------------------------------------------- */
1747
+
1748
+ export interface AIInputVoiceModeProps extends Omit<ViewProps, 'children'> {
1749
+ className?: string;
1750
+ /** The controls in the bottom row, before the close button. */
1751
+ children?: ReactNode;
1752
+ /** What the app is doing, which is what the wave behind everything shows. */
1753
+ state?: 'idle' | 'listening' | 'thinking' | 'speaking';
1754
+ /** Input level, 0–1. A shared value keeps metering off the JS thread. */
1755
+ level?: number | SharedValue<number>;
1756
+ /** A line above the microphone — a greeting, or what it is waiting for. */
1757
+ title?: ReactNode;
1758
+ onMicPress?: () => void;
1759
+ micLabel?: string;
1760
+ onClose?: () => void;
1761
+ closeLabel?: string;
1762
+ /** Type scale for the controls in the bottom row. */
1763
+ size?: AIInputSize;
1764
+ /** Draw the bottom row's controls as the platform's own, in its material. */
1765
+ native?: boolean;
1766
+ }
1767
+
1768
+ /**
1769
+ * The screen a voice conversation happens on: no field, no keyboard, one
1770
+ * microphone and a way out.
1771
+ *
1772
+ * It is a surface rather than an overlay — render it as its own route or
1773
+ * inside a modal of your own. It provides the composer's context, so
1774
+ * `AIInput.Action` and `AIInput.Pill` work in the bottom row without an
1775
+ * `AIInput` around them.
1776
+ */
1777
+ function AIInputVoiceMode({
1778
+ className,
1779
+ children,
1780
+ state = 'listening',
1781
+ level,
1782
+ title,
1783
+ onMicPress,
1784
+ micLabel = 'Mute',
1785
+ onClose,
1786
+ closeLabel = 'End voice mode',
1787
+ size = 'md',
1788
+ native = false,
1789
+ ...props
1790
+ }: AIInputVoiceModeProps) {
1791
+ const insets = useSafeAreaInsets();
1792
+ const onSolid = useCSSVariable('--color-background');
1793
+ const control = CONTROL[size] + 4;
1794
+
1795
+ const context = useMemo<AIInputContextValue>(
1796
+ () => ({
1797
+ value: '',
1798
+ setValue: () => {},
1799
+ status: 'ready',
1800
+ size,
1801
+ level,
1802
+ disabled: false,
1803
+ native: native && hasNativeUI(),
1804
+ minRows: 1,
1805
+ maxRows: 5,
1806
+ focused: false,
1807
+ setFocused: () => {},
1808
+ submit: () => {},
1809
+ stop: () => {},
1810
+ canVoice: false,
1811
+ canStop: false,
1812
+ recordCancel: () => {},
1813
+ recordConfirm: () => {},
1814
+ voice: () => {},
1815
+ }),
1816
+ [level, native, size]
1817
+ );
1818
+
1819
+ return (
1820
+ <AIInputContext.Provider value={context}>
1821
+ <View {...props} className={cn('flex-1 bg-background', className)}>
1822
+ {/* Behind everything, and taking no touches: the wash that says the
1823
+ screen is listening without putting a control in the way of it. */}
1824
+ <Soundwave
1825
+ variant="ambient"
1826
+ state={state}
1827
+ level={level}
1828
+ pointerEvents="none"
1829
+ className="absolute inset-0"
1830
+ />
1831
+
1832
+ <View className="flex-1 items-center justify-center gap-8 px-6">
1833
+ {typeof title === 'string' ? (
1834
+ <Text size="xl" className="text-center">
1835
+ {title}
1836
+ </Text>
1837
+ ) : (
1838
+ title
1839
+ )}
1840
+
1841
+ <View className="items-center gap-5">
1842
+ <Soundwave variant="pills" state={state} level={level} height={28} />
1843
+ <Glass
1844
+ variant="clear"
1845
+ radius={44}
1846
+ fallbackClassName="border border-border bg-muted"
1847
+ style={{ width: 88, height: 88 }}
1848
+ >
1849
+ <AnimatedPressable
1850
+ accessibilityRole="button"
1851
+ accessibilityLabel={micLabel}
1852
+ onPress={onMicPress}
1853
+ className="h-full w-full items-center justify-center"
1854
+ >
1855
+ <MicIcon size={32} />
1856
+ </AnimatedPressable>
1857
+ </Glass>
1858
+ </View>
1859
+ </View>
1860
+
1861
+ <View
1862
+ className="flex-row items-center gap-3 px-5"
1863
+ style={{ paddingBottom: Math.max(insets.bottom, 20) }}
1864
+ >
1865
+ {children}
1866
+ <View className="flex-1" />
1867
+ {native && hasNativeUI() ? (
1868
+ <Button
1869
+ native
1870
+ glass
1871
+ variant="primary"
1872
+ size="icon"
1873
+ accessibilityLabel={closeLabel}
1874
+ onPress={onClose}
1875
+ >
1876
+ <XIcon size={16} />
1877
+ </Button>
1878
+ ) : (
1879
+ <View
1880
+ className="overflow-hidden rounded-full bg-foreground"
1881
+ style={{ width: control, height: control }}
1882
+ >
1883
+ <AnimatedPressable
1884
+ accessibilityRole="button"
1885
+ accessibilityLabel={closeLabel}
1886
+ onPress={onClose}
1887
+ className="h-full w-full items-center justify-center"
1888
+ >
1889
+ <XIcon size={18} color={typeof onSolid === 'string' ? onSolid : undefined} />
1890
+ </AnimatedPressable>
1891
+ </View>
1892
+ )}
1893
+ </View>
1894
+ </View>
1895
+ </AIInputContext.Provider>
1896
+ );
1897
+ }
1898
+
1899
+ /* -------------------------------------------------------------------------- */
1900
+
1901
+ AIInputRoot.displayName = 'AIInput';
1902
+ AIInputField.displayName = 'AIInput.Field';
1903
+ AIInputRow.displayName = 'AIInput.Row';
1904
+ AIInputToolbar.displayName = 'AIInput.Toolbar';
1905
+ AIInputSpacer.displayName = 'AIInput.Spacer';
1906
+ AIInputAction.displayName = 'AIInput.Action';
1907
+ AIInputPill.displayName = 'AIInput.Pill';
1908
+ AIInputSubmit.displayName = 'AIInput.Submit';
1909
+ AIInputRecording.displayName = 'AIInput.Recording';
1910
+ AIInputSheetScreen.displayName = 'AIInput.Sheet.Screen';
1911
+ AIInputSheetGroup.displayName = 'AIInput.Sheet.Group';
1912
+ AIInputSheetRow.displayName = 'AIInput.Sheet.Row';
1913
+ AIInputSheetToggle.displayName = 'AIInput.Sheet.Toggle';
1914
+ AIInputSheetChoice.displayName = 'AIInput.Sheet.Choice';
1915
+ AIInputSheet.displayName = 'AIInput.Sheet';
1916
+ AIInputVoiceMode.displayName = 'AIInput.VoiceMode';
1917
+
1918
+ const Sheet = Object.assign(AIInputSheet, {
1919
+ Screen: AIInputSheetScreen,
1920
+ Group: AIInputSheetGroup,
1921
+ Row: AIInputSheetRow,
1922
+ Toggle: AIInputSheetToggle,
1923
+ Choice: AIInputSheetChoice,
1924
+ });
1925
+
1926
+ export const AIInput = Object.assign(AIInputRoot, {
1927
+ Field: AIInputField,
1928
+ Row: AIInputRow,
1929
+ Toolbar: AIInputToolbar,
1930
+ Spacer: AIInputSpacer,
1931
+ Action: AIInputAction,
1932
+ Pill: AIInputPill,
1933
+ Submit: AIInputSubmit,
1934
+ Recording: AIInputRecording,
1935
+ Sheet,
1936
+ VoiceMode: AIInputVoiceMode,
1937
+ });