aix 0.6.1 → 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,24 +34,23 @@ 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
- import { Aix, AixCell } from 'aix';
29
- import { Message } from 'path/to/your/message';
30
- import { Composer } from 'path/to/your/composer';
47
+ import { Aix, AixCell } from 'aix'
48
+ import { Message } from 'path/to/your/message'
49
+ 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
56
  <AixCell
@@ -44,19 +63,32 @@ export function ChatScreen({ messages }) {
44
63
  ))}
45
64
  </ScrollView>
46
65
  </Aix>
47
- );
66
+ )
48
67
  }
49
68
  ```
50
69
 
51
- 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.
52
75
 
53
76
  ```tsx
54
- import { Aix, AixCell, AixFooter } from 'aix';
55
- import { KeyboardStickyView } from 'react-native-keyboard-controller';
77
+ import { Aix, AixCell, AixFooter } from 'aix'
56
78
 
57
79
  export function ChatScreen({ messages }) {
80
+ const { bottom } = useSafeAreaInsets()
81
+
58
82
  return (
59
- <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
+ >
60
92
  <ScrollView>
61
93
  {messages.map((message) => (
62
94
  <AixCell
@@ -69,78 +101,147 @@ export function ChatScreen({ messages }) {
69
101
  ))}
70
102
  </ScrollView>
71
103
 
72
- <KeyboardStickyView offset={{ opened: 0, closed: -bottomInsetPadding }}>
73
- <AixFooter style={{ position: 'absolute', inset: 0, top: 'auto'}}>
74
- <Composer />
75
- </AixFooter>
76
- </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>
77
116
  </Aix>
78
- );
117
+ )
79
118
  }
80
119
  ```
81
120
 
82
- ## TODOs
121
+ ### Send a message
83
122
 
84
- - [ ] Android support
85
- - [ ] LegendList support
86
- - [ ] 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
+ ```
87
180
 
88
181
  ## API Reference
89
182
 
90
183
  ### `Aix`
91
184
 
92
- 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.
93
187
 
94
188
  #### Props
95
189
 
96
- | Prop | Type | Default | Description |
97
- |------|------|---------|-------------|
98
- | `shouldStartAtEnd` | `boolean` | - | Whether the scroll view should start scrolled to the end of the content. |
99
- | `scrollOnFooterSizeUpdate` | `object` | `{ enabled: true, scrolledToEndThreshold: 100, animated: false }` | Control the behavior of scrolling when the footer size changes. By default, changing the height of the footer will shift content up in the scroll view. |
100
- | `scrollEndReachedThreshold` | `number` | `max(blankSize, 200)` | The number of pixels from the bottom of the scroll view to the end of the content that is considered "near the end". Used by `onScrolledNearEndChange` and to determine if content should shift up when keyboard opens. |
101
- | `onScrolledNearEndChange` | `(isNearEnd: boolean) => void` | - | Callback fired when the scroll position transitions between "near end" and "not near end" states. Reactive to user scrolling, content size changes, parent size changes, and keyboard height changes. Uses `scrollEndReachedThreshold` to determine the threshold. |
102
- | `additionalContentInsets` | `object` | - | Additional content insets applied when keyboard is open or closed. Shape: `{ top?: { whenKeyboardOpen, whenKeyboardClosed }, bottom?: { whenKeyboardOpen, whenKeyboardClosed } }` |
103
- | `additionalScrollIndicatorInsets` | `object` | - | Additional insets for the scroll indicator, added to existing safe area insets. Applied to `verticalScrollIndicatorInsets` on iOS. |
104
- | `mainScrollViewID` | `string` | - | The `nativeID` of the scroll view to use. If provided, will search for a scroll view with this `accessibilityIdentifier`. |
105
- | `penultimateCellIndex` | `number` | - | The index of the second-to-last message (typically the last user message in AI chat apps). Used to determine which message will be scrolled into view. Useful when you have custom message types like timestamps in your list. |
190
+ | Prop | Type | Default | Description |
191
+ | --------------------------------- | ------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
192
+ | `shouldStartAtEnd` | `boolean` | - | Whether the scroll view should start scrolled to the end of the content. |
193
+ | `scrollOnFooterSizeUpdate` | `object` | `{ enabled: true, scrolledToEndThreshold: 100, animated: false }` | Control the behavior of scrolling when the footer size changes. By default, changing the height of the footer will shift content up in the scroll view. |
194
+ | `scrollEndReachedThreshold` | `number` | `max(blankSize, 200)` | The number of pixels from the bottom of the scroll view to the end of the content that is considered "near the end". Used by `onScrolledNearEndChange` and to determine if content should shift up when keyboard opens. |
195
+ | `onScrolledNearEndChange` | `(isNearEnd: boolean) => void` | - | Callback fired when the scroll position transitions between "near end" and "not near end" states. Reactive to user scrolling, content size changes, parent size changes, and keyboard height changes. Uses `scrollEndReachedThreshold` to determine the threshold. |
196
+ | `additionalContentInsets` | `object` | - | Additional content insets applied when keyboard is open or closed. Shape: `{ top?: { whenKeyboardOpen, whenKeyboardClosed }, bottom?: { whenKeyboardOpen, whenKeyboardClosed } }` |
197
+ | `additionalScrollIndicatorInsets` | `object` | - | Additional insets for the scroll indicator, added to existing safe area insets. Applied to `verticalScrollIndicatorInsets` on iOS. |
198
+ | `mainScrollViewID` | `string` | - | The `nativeID` of the scroll view to use. If provided, will search for a scroll view with this `accessibilityIdentifier`. |
199
+ | `penultimateCellIndex` | `number` | - | The index of the second-to-last message (typically the last user message in AI chat apps). Used to determine which message will be scrolled into view. Useful when you have custom message types like timestamps in your list. |
106
200
 
107
201
  #### Ref Methods
108
202
 
109
203
  Access these methods via `useAixRef()`:
110
204
 
111
205
  ```tsx
112
- const aix = useAixRef();
206
+ const aix = useAixRef()
113
207
 
114
208
  // Scroll to the end of the content
115
- aix.current?.scrollToEnd(animated);
209
+ aix.current?.scrollToEnd(animated)
116
210
 
117
211
  // Scroll to a specific index when the blank size is ready
118
- aix.current?.scrollToIndexWhenBlankSizeReady(index, animated, waitForKeyboardToEnd);
212
+ aix.current?.scrollToIndexWhenBlankSizeReady(
213
+ index,
214
+ animated,
215
+ waitForKeyboardToEnd
216
+ )
119
217
  ```
120
218
 
121
- | Method | Parameters | Description |
122
- |--------|------------|-------------|
123
- | `scrollToEnd` | `animated?: boolean` | Scrolls to the end of the content. |
219
+ | Method | Parameters | Description |
220
+ | --------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
221
+ | `scrollToEnd` | `animated?: boolean` | Scrolls to the end of the content. |
124
222
  | `scrollToIndexWhenBlankSizeReady` | `index: number, animated?: boolean, waitForKeyboardToEnd?: boolean` | Scrolls to a specific cell index once the blank size calculation is ready. |
125
223
 
126
224
  ---
127
225
 
128
226
  ### `AixCell`
129
227
 
130
- 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.
131
230
 
132
231
  #### Props
133
232
 
134
- | Prop | Type | Required | Description |
135
- |------|------|----------|-------------|
136
- | `index` | `number` | Yes | The index of this cell in the message list. |
137
- | `isLast` | `boolean` | Yes | Whether this cell is the last item in the list. Used for scroll positioning and animations. |
233
+ | Prop | Type | Required | Description |
234
+ | -------- | --------- | -------- | ------------------------------------------------------------------------------------------- |
235
+ | `index` | `number` | Yes | The index of this cell in the message list. |
236
+ | `isLast` | `boolean` | Yes | Whether this cell is the last item in the list. Used for scroll positioning and animations. |
138
237
 
139
238
  ---
140
239
 
141
240
  ### `AixFooter`
142
241
 
143
- 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.
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.
144
245
 
145
246
  #### Props
146
247
 
@@ -148,7 +249,8 @@ Accepts all standard React Native `View` props.
148
249
 
149
250
  #### Important Notes
150
251
 
151
- - **Do not apply vertical padding** (`padding`, `paddingBottom`) directly to `AixFooter`. Apply padding to a child view instead.
252
+ - **Do not apply vertical padding** (`padding`, `paddingBottom`) directly to
253
+ `AixFooter`. Apply padding to a child view instead.
152
254
  - Position the footer absolutely at the bottom of the `Aix` container:
153
255
 
154
256
  ```tsx
@@ -164,57 +266,38 @@ Accepts all standard React Native `View` props.
164
266
  A hook that returns a ref to access imperative methods on the `Aix` component.
165
267
 
166
268
  ```tsx
167
- import { useAixRef } from 'aix';
269
+ import { useAixRef } from 'aix'
168
270
 
169
271
  function Chat({ messages }) {
170
- const aix = useAixRef();
272
+ const aix = useAixRef()
171
273
  const send = useSendMessage()
172
-
274
+
173
275
  const handleSend = () => {
174
276
  // Scroll to end after sending a message
175
- send(message);
176
- aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true);
177
- requestAnimationFrame(Keyboard.dismiss);
178
- };
179
-
180
- return <Aix ref={aix}>{/* ... */}</Aix>;
277
+ send(message)
278
+ aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
279
+ requestAnimationFrame(Keyboard.dismiss)
280
+ }
281
+
282
+ return <Aix ref={aix}>{/* ... */}</Aix>
181
283
  }
182
284
  ```
183
285
 
184
- ---
185
-
186
- ### Scroll to End Button
187
-
188
- You can use `onScrolledNearEndChange` to show a "scroll to end" button when the user scrolls away from the bottom:
189
-
190
- ```tsx
191
- import { Aix, useAixRef } from 'aix';
192
- import { useState } from 'react';
193
-
194
- function Chat() {
195
- const aix = useAixRef();
196
- const [isNearEnd, setIsNearEnd] = useState(false);
286
+ ### Rules
197
287
 
198
- return (
199
- <Aix
200
- ref={aix}
201
- scrollEndReachedThreshold={200}
202
- onScrolledNearEndChange={setIsNearEnd}
203
- >
204
- {/* ScrollView and messages... */}
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
205
291
 
206
- {!isNearEnd && (
207
- <Button onPress={() => aix.current?.scrollToEnd(true)} />
208
- )}
209
- </Aix>
210
- );
211
- }
212
- ```
292
+ ---
213
293
 
214
294
  ## TODOs
215
295
 
296
+ - [ ] Android support
297
+ - [ ] LegendList support
298
+ - [ ] FlashList support
216
299
 
217
300
  ## Requirements
218
301
 
219
302
  - React Native v0.78.0 or higher
220
- - Node 18.0.0 or higher
303
+ - Node 18.0.0 or higher
@@ -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
 
@@ -154,6 +154,7 @@ class HybridAixCellView: HybridAixCellViewSpec {
154
154
  private func updateRegistration() {
155
155
  guard let ctx = getAixContext() else { return }
156
156
  ctx.registerCell(self)
157
+ updateBlankViewStatus()
157
158
  }
158
159
 
159
160
  /// Update blank view status (called when isLast changes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aix",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "author": "Fernando Rojo",
5
5
  "repository": {
6
6
  "type": "git",