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