aix 0.6.2 → 0.7.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.
package/README.md CHANGED
@@ -1,12 +1,32 @@
1
- # aix
1
+ <img src="https://github.com/vercel/aix/blob/main/Aix.png?raw=true"
2
+ alt="aix" width="1600" height="900" />
2
3
 
3
- Primitives for building beautiful AI chat apps with React Native.
4
+ # AIX
4
5
 
5
- > aix is currently in alpha preview. The API will change, and it is not yet feature complete.
6
+ UI Primitives for building AI apps in React Native.
6
7
 
7
- We're rewriting the engine that powers the chat experience in the v0 mobile app with a focus on native feel.
8
+ > aix is currently in alpha preview. The API is likely to change.
8
9
 
9
- aix is a native module with UIKit with Nitro Modules.
10
+ ## Features
11
+
12
+ - Start a chat scrolled to end on the first frame
13
+ - Animate scrolling to new messages when they send
14
+ - Float messages to the top of the screen with automated "blank size" handling
15
+ - Animate message content as it streams
16
+ - Keyboard handling out-of-the-box with no external dependencies
17
+ - Support for absolute-positioned composers
18
+ - Detect "is scrolled near end" for ScrollToEnd buttons
19
+
20
+ To learn about the motivation behind AIX, you can read our blog post on
21
+ [How we built the v0 iOS app](https://vercel.com/blog/how-we-built-the-v0-ios-app).
22
+ AIX is an opinionated, feature-complete, and extensible way to implement every
23
+ single feature mentioned in that blog post.
24
+
25
+ When building AIX, we started by copying the code from v0 into a separate
26
+ repository. However, as we worked to make it flexible for use cases outside of
27
+ our own app, we decided to rewrite it from scratch in native code. What you see
28
+ here is a Nitro Module which handles all business logic in UIKit. We plan on
29
+ adding support for Android as well and welcome contributions.
10
30
 
11
31
  ## Installation
12
32
 
@@ -14,16 +34,15 @@ aix is a native module with UIKit with Nitro Modules.
14
34
  npm i aix react-native-nitro-modules
15
35
  ```
16
36
 
17
- Next, rebuild your native app. For Expo users, run `npx expo prebuild` and rebuild.
37
+ Next, rebuild your native app. For Expo users, run `npx expo prebuild` and
38
+ rebuild.
39
+
40
+ - For a full example, see the [example app](./react-native-aix/example/App.tsx).
18
41
 
19
42
  ## Usage
20
43
 
21
44
  Wrap your `ScrollView` with `Aix`, and wrap your messages with `AixCell`.
22
45
 
23
- <details>
24
- <summary>Click here to view a full example</summary>
25
- </details>
26
-
27
46
  ```tsx
28
47
  import { Aix, AixCell } from 'aix'
29
48
  import { Message } from 'path/to/your/message'
@@ -31,10 +50,14 @@ import { Composer } from 'path/to/your/composer'
31
50
 
32
51
  export function ChatScreen({ messages }) {
33
52
  return (
34
- <Aix style={{ flex: 1 }}>
53
+ <Aix style={{ flex: 1 }} shouldStartAtEnd>
35
54
  <ScrollView>
36
55
  {messages.map((message) => (
37
- <AixCell key={message.id} index={index} isLast={index === messages.length - 1}>
56
+ <AixCell
57
+ key={message.id}
58
+ index={index}
59
+ isLast={index === messages.length - 1}
60
+ >
38
61
  <Message message={message} />
39
62
  </AixCell>
40
63
  ))}
@@ -44,44 +67,123 @@ export function ChatScreen({ messages }) {
44
67
  }
45
68
  ```
46
69
 
47
- To add a floating composer which lets content scroll under it, you can use the `AixFooter` and `KeyboardStickyView` from `react-native-keyboard-controller`:
70
+ ### Add a floating composer
71
+
72
+ To add a floating composer which lets content scroll under it, you can use the
73
+ `AixFooter`. Pair it with `Aix.scrollOnFooterSizeUpdate` to ensure content
74
+ scrolls under the footer when it changes size.
48
75
 
49
76
  ```tsx
50
77
  import { Aix, AixCell, AixFooter } from 'aix'
51
- import { KeyboardStickyView } from 'react-native-keyboard-controller'
52
78
 
53
79
  export function ChatScreen({ messages }) {
80
+ const { bottom } = useSafeAreaInsets()
81
+
54
82
  return (
55
- <Aix style={{ flex: 1 }}>
83
+ <Aix
84
+ style={{ flex: 1 }}
85
+ shouldStartAtEnd
86
+ scrollOnFooterSizeUpdate={{
87
+ enabled: true,
88
+ scrolledToEndThreshold: 100,
89
+ animated: false,
90
+ }}
91
+ >
56
92
  <ScrollView>
57
93
  {messages.map((message) => (
58
- <AixCell key={message.id} index={index} isLast={index === messages.length - 1}>
94
+ <AixCell
95
+ key={message.id}
96
+ index={index}
97
+ isLast={index === messages.length - 1}
98
+ >
59
99
  <Message message={message} />
60
100
  </AixCell>
61
101
  ))}
62
102
  </ScrollView>
63
103
 
64
- <KeyboardStickyView offset={{ opened: 0, closed: -bottomInsetPadding }}>
65
- <AixFooter fixInput style={{ position: 'absolute', inset: 0, top: 'auto' }}>
66
- <Composer />
67
- </AixFooter>
68
- </KeyboardStickyView>
104
+ <AixFooter
105
+ style={{ position: 'absolute', inset: 0, top: 'auto' }}
106
+ stickToKeyboard={{
107
+ enabled: true,
108
+ offset: {
109
+ whenKeyboardOpen: 0,
110
+ whenKeyboardClosed: -bottom,
111
+ },
112
+ }}
113
+ >
114
+ <Composer />
115
+ </AixFooter>
69
116
  </Aix>
70
117
  )
71
118
  }
72
119
  ```
73
120
 
74
- ## TODOs
121
+ ### Send a message
75
122
 
76
- - [ ] Android support
77
- - [ ] LegendList support
78
- - [ ] FlashList support
123
+ When sending a message, you will likely want to scroll to it after it gets added
124
+ to the list.
125
+
126
+ Simply call `aix.current?.scrollToIndexWhenBlankSizeReady(index)` in your submit
127
+ handler.
128
+
129
+ The `index` you pass should correspond to the newest message in the list. For AI
130
+ chats, this is typically the next assistant message index.
131
+
132
+ ```tsx
133
+ import { Keyboard } from 'react-native'
134
+ import { useAixRef } from 'aix'
135
+
136
+ function Chat() {
137
+ const aix = useAixRef()
138
+
139
+ const onSubmit = () => {
140
+ aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
141
+ requestAnimationFrame(Keyboard.dismiss)
142
+ }
143
+
144
+ return <Aix ref={aix}>{/* ... */}</Aix>
145
+ }
146
+ ```
147
+
148
+ ### Add a scroll to end button
149
+
150
+ You can use `onScrolledNearEndChange` to show a "scroll to end" button when the
151
+ user scrolls away from the bottom:
152
+
153
+ ```tsx
154
+ import { Aix, useAixRef } from 'aix'
155
+ import { useState } from 'react'
156
+ import { Button } from 'react-native'
157
+
158
+ function Chat() {
159
+ const aix = useAixRef()
160
+ const [isNearEnd, setIsNearEnd] = useState(false)
161
+
162
+ return (
163
+ <Aix
164
+ ref={aix}
165
+ scrollEndReachedThreshold={200}
166
+ onScrolledNearEndChange={setIsNearEnd}
167
+ >
168
+ {/* ScrollView and messages... */}
169
+
170
+ {!isNearEnd && (
171
+ <Button
172
+ onPress={() => aix.current?.scrollToEnd(true)}
173
+ title='Scroll to end'
174
+ />
175
+ )}
176
+ </Aix>
177
+ )
178
+ }
179
+ ```
79
180
 
80
181
  ## API Reference
81
182
 
82
183
  ### `Aix`
83
184
 
84
- The main container component that provides keyboard-aware behavior and manages scrolling for chat interfaces.
185
+ The main container component that provides keyboard-aware behavior and manages
186
+ scrolling for chat interfaces.
85
187
 
86
188
  #### Props
87
189
 
@@ -107,7 +209,11 @@ const aix = useAixRef()
107
209
  aix.current?.scrollToEnd(animated)
108
210
 
109
211
  // Scroll to a specific index when the blank size is ready
110
- aix.current?.scrollToIndexWhenBlankSizeReady(index, animated, waitForKeyboardToEnd)
212
+ aix.current?.scrollToIndexWhenBlankSizeReady(
213
+ index,
214
+ animated,
215
+ waitForKeyboardToEnd
216
+ )
111
217
  ```
112
218
 
113
219
  | Method | Parameters | Description |
@@ -119,7 +225,8 @@ aix.current?.scrollToIndexWhenBlankSizeReady(index, animated, waitForKeyboardToE
119
225
 
120
226
  ### `AixCell`
121
227
 
122
- A wrapper component for each message in the list. It communicates cell position and dimensions to the parent `Aix` component.
228
+ A wrapper component for each message in the list. It communicates cell position
229
+ and dimensions to the parent `Aix` component.
123
230
 
124
231
  #### Props
125
232
 
@@ -132,177 +239,26 @@ A wrapper component for each message in the list. It communicates cell position
132
239
 
133
240
  ### `AixFooter`
134
241
 
135
- A footer component for floating composers that allows content to scroll underneath it. The footer's height is automatically tracked for proper scroll offset calculations.
136
-
137
- #### Important Notes
138
-
139
- - **Do not apply vertical padding** (`padding`, `paddingBottom`) directly to `AixFooter`. Apply padding to a child view instead.
140
- - Position the footer absolutely at the bottom of the `Aix` container.
141
-
142
- #### Recipe
143
-
144
- ```tsx
145
- <AixFooter
146
- fixInput
147
- stickToKeyboard={{
148
- enabled: true,
149
- offset: {
150
- whenKeyboardOpen: 0,
151
- whenKeyboardClosed: bottomSafeAreaInset,
152
- },
153
- }}
154
- style={{ position: 'absolute', inset: 0, top: 'auto' }}
155
- >
156
- <Composer />
157
- </AixFooter>
158
- ```
159
-
160
- #### Props
161
-
162
- | Prop | Type | Default | Description |
163
- | ----------------- | -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
164
- | `fixInput` | `boolean` | - | Patches the `TextInput` inside the footer to disable scroll bouncing, hide scroll indicators, enable interactive keyboard dismiss, and add swipe-up-to-focus. Recommended for multiline inputs. This may move to `AixInputWrapper` in the future. |
165
- | `stickToKeyboard` | `AixStickToKeyboard` | - | Controls whether the footer translates upward with the keyboard. See shape below. |
166
-
167
- Also accepts all standard React Native `View` props.
168
-
169
- **`AixStickToKeyboard`**
170
-
171
- | Field | Type | Required | Description |
172
- | --------------------------- | --------- | -------- | --------------------------------------------------------------------------------------------- |
173
- | `enabled` | `boolean` | Yes | Whether the footer should translate upward with the keyboard. |
174
- | `offset.whenKeyboardOpen` | `number` | No | Additional vertical offset applied when the keyboard is fully open. |
175
- | `offset.whenKeyboardClosed` | `number` | No | Additional vertical offset applied when the keyboard is closed (e.g. bottom safe area inset). |
176
-
177
- ---
178
-
179
- ### `AixDropzone`
180
-
181
- A wrapper component that enables drag-and-drop file support for your chat interface. Users can drop images, documents, and other files onto the zone.
182
-
183
- On iOS, this uses a native drop interaction handler. On other platforms, it renders its children without drop support.
184
-
185
- #### Recipe
186
-
187
- Wrap your chat screen with `AixDropzone` to handle dropped files:
188
-
189
- ```tsx
190
- import { useState } from 'react'
191
- import { AixDropzone, Aix, type AixInputWrapperOnPasteEvent } from 'aix'
192
-
193
- function ChatScreen() {
194
- const [attachments, setAttachments] = useState<AixInputWrapperOnPasteEvent[]>([])
195
-
196
- return (
197
- <AixDropzone
198
- onDrop={(events) => {
199
- setAttachments((prev) => [...prev, ...events])
200
- }}
201
- >
202
- <Aix shouldStartAtEnd style={{ flex: 1 }}>
203
- {/* ScrollView, messages, footer... */}
204
- </Aix>
205
- </AixDropzone>
206
- )
207
- }
208
- ```
209
-
210
- #### Props
211
-
212
- | Prop | Type | Default | Description |
213
- | ---------- | ------------------------------------------------- | ------- | -------------------------------------------------------------------------------------- |
214
- | `onDrop` | `(events: AixInputWrapperOnPasteEvent[]) => void` | - | Called when the user drops files onto the zone. Each event describes one dropped item. |
215
- | `children` | `ReactNode` | - | Content to render inside the drop zone. |
216
-
217
- **`AixInputWrapperOnPasteEvent`**
218
-
219
- | Field | Type | Description |
220
- | --------------- | --------- | ------------------------------------------------- |
221
- | `type` | `string` | The kind of item (e.g. `"image"`). |
222
- | `filePath` | `string` | Local file path to the dropped or pasted content. |
223
- | `fileExtension` | `string?` | File extension (e.g. `"png"`, `"pdf"`). |
224
- | `fileName` | `string?` | Original file name. |
225
-
226
- ---
227
-
228
- ### `AixInputWrapper`
229
-
230
- A wrapper for your `TextInput` that intercepts paste events, letting users paste images and files from their clipboard into the composer.
231
-
232
- On iOS, this uses a native view that overrides paste behavior to capture rich content. On other platforms, it renders its children without paste interception.
233
-
234
- #### Recipe
235
-
236
- Wrap your `TextInput` with `AixInputWrapper` to handle pasted images and files:
237
-
238
- ```tsx
239
- import { useState } from 'react'
240
- import { TextInput } from 'react-native'
241
- import { AixInputWrapper, type AixInputWrapperOnPasteEvent } from 'aix'
242
-
243
- function Composer() {
244
- const [attachments, setAttachments] = useState<AixInputWrapperOnPasteEvent[]>([])
245
-
246
- return (
247
- <AixInputWrapper
248
- editMenuDefaultActions={['paste']}
249
- onPaste={(events) => {
250
- setAttachments((prev) => [...prev, ...events])
251
- }}
252
- >
253
- <TextInput placeholder='Type a message...' multiline />
254
- </AixInputWrapper>
255
- )
256
- }
257
- ```
258
-
259
- Each event in `onPaste` has the same [`AixInputWrapperOnPasteEvent`](#aixinputwrapperonpasteevent) shape described above.
242
+ A footer component for floating composers that allows content to scroll
243
+ underneath it. The footer's height is automatically tracked for proper scroll
244
+ offset calculations.
260
245
 
261
246
  #### Props
262
247
 
263
- | Prop | Type | Default | Description |
264
- | ------------------------ | ------------------------------------------------- | ------- | -------------------------------------------------------------------------------- |
265
- | `onPaste` | `(events: AixInputWrapperOnPasteEvent[]) => void` | - | Called when the user pastes rich content (images, files) into the wrapped input. |
266
- | `pasteConfiguration` | `string[]` | - | UTI types to accept for paste events. |
267
- | `editMenuDefaultActions` | `string[]` | - | Which default edit menu actions to show (e.g. `['paste']`). |
268
- | `maxLines` | `number` | - | Maximum number of visible lines for the wrapped input. |
269
- | `maxChars` | `number` | - | Maximum character count for the wrapped input. |
270
- | `children` | `ReactNode` | - | Should contain your `TextInput`. |
271
-
272
- Also accepts all standard React Native `View` props.
273
-
274
- ---
275
-
276
- ### `TextFadeInStaggeredIfStreaming`
277
-
278
- Animates text children with a staggered word-by-word fade-in effect. Each word fades in over 500ms, staggered 32ms apart. A pool system limits concurrent animations for smooth performance.
248
+ Accepts all standard React Native `View` props.
279
249
 
280
- The `disabled` state is captured on mount. If the component mounts with `disabled={true}`, animation stays off even if `disabled` later becomes `false`. This prevents completed messages from re-animating on re-render.
250
+ #### Important Notes
281
251
 
282
- #### Recipe
252
+ - **Do not apply vertical padding** (`padding`, `paddingBottom`) directly to
253
+ `AixFooter`. Apply padding to a child view instead.
254
+ - Position the footer absolutely at the bottom of the `Aix` container:
283
255
 
284
256
  ```tsx
285
- import { Text } from 'react-native'
286
- import { TextFadeInStaggeredIfStreaming } from 'aix'
287
-
288
- function AssistantMessage({ content, isStreaming }) {
289
- return (
290
- <Text>
291
- <TextFadeInStaggeredIfStreaming disabled={!isStreaming}>
292
- {content}
293
- </TextFadeInStaggeredIfStreaming>
294
- </Text>
295
- )
296
- }
257
+ <AixFooter style={{ position: 'absolute', inset: 0, top: 'auto' }}>
258
+ <Composer />
259
+ </AixFooter>
297
260
  ```
298
261
 
299
- #### Props
300
-
301
- | Prop | Type | Required | Description |
302
- | ---------- | ----------- | -------- | ----------------------------------------------------------------------------------------------------------------------- |
303
- | `disabled` | `boolean` | Yes | Whether to disable the fade-in animation. Pass `true` for completed messages, `false` for currently-streaming messages. |
304
- | `children` | `ReactNode` | - | Text content to animate. String children are split by word and each word fades in individually. |
305
-
306
262
  ---
307
263
 
308
264
  ### `useAixRef`
@@ -327,91 +283,19 @@ function Chat({ messages }) {
327
283
  }
328
284
  ```
329
285
 
330
- ---
331
-
332
- ### `useContentInsetHandler`
333
-
334
- A hook for receiving content inset updates from the native scroll view. Use this when you want to apply content insets yourself (e.g. via Reanimated shared values) instead of letting `Aix` apply them natively.
335
-
336
- #### Recipe
337
-
338
- Apply content insets with Reanimated for custom control:
339
-
340
- ```tsx
341
- import { Aix, useContentInsetHandler } from 'aix'
342
- import { useSharedValue, useAnimatedProps } from 'react-native-reanimated'
343
- import Animated from 'react-native-reanimated'
344
-
345
- function Chat() {
346
- const bottomInset = useSharedValue<number | null>(null)
347
-
348
- const contentInsetHandler = useContentInsetHandler((insets) => {
349
- 'worklet'
350
- bottomInset.set(insets.bottom ?? null)
351
- })
352
-
353
- const animatedScrollViewProps = useAnimatedProps(() => ({
354
- contentInset: {
355
- top: 0,
356
- bottom: bottomInset.get() ?? 0,
357
- left: 0,
358
- right: 0,
359
- },
360
- }))
361
-
362
- return (
363
- <Aix
364
- shouldStartAtEnd
365
- shouldApplyContentInsets={false}
366
- onWillApplyContentInsets={contentInsetHandler}
367
- >
368
- <Animated.ScrollView animatedProps={animatedScrollViewProps}>
369
- {/* messages... */}
370
- </Animated.ScrollView>
371
- </Aix>
372
- )
373
- }
374
- ```
375
-
376
- #### Parameters
377
-
378
- | Parameter | Type | Description |
379
- | -------------- | ------------------------------------ | ------------------------------------------------------------------------ |
380
- | `handler` | `(insets: AixContentInsets) => void` | Callback receiving computed content insets. Can be a Reanimated worklet. |
381
- | `dependencies` | `unknown[]` | Dependency array for the callback (default: `[]`). |
382
-
383
- **`AixContentInsets`**
286
+ ### Rules
384
287
 
385
- | Field | Type | Description |
386
- | -------- | --------- | --------------------- |
387
- | `top` | `number?` | Top content inset. |
388
- | `left` | `number?` | Left content inset. |
389
- | `bottom` | `number?` | Bottom content inset. |
390
- | `right` | `number?` | Right content inset. |
288
+ - Do **NOT** apply padding to `contentContainerStyle`. Instead, use padding on
289
+ children directly.
290
+ - Do **NOT** apply padding to `AixFooter`. Instead, use padding on its children
391
291
 
392
292
  ---
393
293
 
394
- ### Scroll to End Button
395
-
396
- You can use `onScrolledNearEndChange` to show a "scroll to end" button when the user scrolls away from the bottom:
397
-
398
- ```tsx
399
- import { Aix, useAixRef } from 'aix'
400
- import { useState } from 'react'
401
-
402
- function Chat() {
403
- const aix = useAixRef()
404
- const [isNearEnd, setIsNearEnd] = useState(false)
405
-
406
- return (
407
- <Aix ref={aix} scrollEndReachedThreshold={200} onScrolledNearEndChange={setIsNearEnd}>
408
- {/* ScrollView and messages... */}
294
+ ## TODOs
409
295
 
410
- {!isNearEnd && <Button onPress={() => aix.current?.scrollToEnd(true)} />}
411
- </Aix>
412
- )
413
- }
414
- ```
296
+ - [ ] Android support
297
+ - [ ] LegendList support
298
+ - [ ] FlashList support
415
299
 
416
300
  ## Requirements
417
301
 
@@ -432,14 +432,29 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
432
432
  private func calculateBlankSize(keyboardHeight: CGFloat, additionalContentInsetBottom: CGFloat) -> CGFloat {
433
433
  guard let scrollView, let blankView else { return 0 }
434
434
 
435
- let cellBeforeBlankView = getCell(index: Int(blankView.index) - 1)
436
- let cellBeforeBlankViewHeight = cellBeforeBlankView?.view.frame.height ?? 0
435
+ let startIndex: Int
436
+ let endIndex = Int(blankView.index) - 1
437
+ if let penultimateCellIndex {
438
+ startIndex = Int(penultimateCellIndex)
439
+ } else {
440
+ startIndex = endIndex
441
+ }
442
+
443
+ var cellsBeforeBlankViewHeight: CGFloat = 0
444
+ if startIndex <= endIndex {
445
+ for i in startIndex...endIndex {
446
+ if let cell = getCell(index: i) {
447
+ cellsBeforeBlankViewHeight += cell.view.frame.height
448
+ }
449
+ }
450
+ }
451
+
437
452
  let blankViewHeight = blankView.view.frame.height
438
453
 
439
454
  // Calculate visible area above all bottom chrome (keyboard, composer, additional insets)
440
455
  // The blank size fills the remaining space so the last message can scroll to the top
441
456
  let visibleAreaHeight = scrollView.bounds.height - keyboardHeight - composerHeight - additionalContentInsetBottom
442
- let inset = visibleAreaHeight - blankViewHeight - cellBeforeBlankViewHeight
457
+ let inset = visibleAreaHeight - blankViewHeight - cellsBeforeBlankViewHeight
443
458
  return max(0, inset)
444
459
  }
445
460
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aix",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "author": "Fernando Rojo",
5
5
  "repository": {
6
6
  "type": "git",