aix 0.7.1-alpha.8 → 0.8.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
@@ -5,6 +5,7 @@ alt="aix" width="1600" height="900" />
5
5
 
6
6
  UI Primitives for building AI apps in React Native.
7
7
 
8
+ > [!WARNING]
8
9
  > aix is currently in alpha preview. The API is likely to change.
9
10
 
10
11
  ## Features
@@ -121,27 +122,35 @@ export function ChatScreen({ messages }) {
121
122
  ### Send a message
122
123
 
123
124
  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.
125
+ to the list. Use the `scrollToIndex` prop to declaratively trigger an animated
126
+ scroll, and `onDidScrollToIndex` to reset the state when the scroll completes.
128
127
 
129
128
  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.
129
+ chats, this is typically the next assistant message index. Use a nullish value (`null | undefined`) to indicate
130
+ no scroll target.
131
131
 
132
132
  ```tsx
133
133
  import { Keyboard } from 'react-native'
134
- import { useAixRef } from 'aix'
134
+ import { useState } from 'react'
135
135
 
136
136
  function Chat() {
137
- const aix = useAixRef()
137
+ const [scrollToIndex, setScrollToIndex] = useState<number | null>(null)
138
138
 
139
139
  const onSubmit = () => {
140
- aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
140
+ const newMessageIndex = messages.length + 1
141
+ setScrollToIndex(newMessageIndex)
142
+ sendMessage(message)
141
143
  requestAnimationFrame(Keyboard.dismiss)
142
144
  }
143
145
 
144
- return <Aix ref={aix}>{/* ... */}</Aix>
146
+ return (
147
+ <Aix
148
+ scrollToIndex={scrollToIndex}
149
+ onDidScrollToIndex={() => setScrollToIndex(null)}
150
+ >
151
+ {/* ... */}
152
+ </Aix>
153
+ )
145
154
  }
146
155
  ```
147
156
 
@@ -197,6 +206,8 @@ scrolling for chat interfaces.
197
206
  | `additionalScrollIndicatorInsets` | `object` | - | Additional insets for the scroll indicator, added to existing safe area insets. Applied to `verticalScrollIndicatorInsets` on iOS. |
198
207
  | `mainScrollViewID` | `string` | - | The `nativeID` of the scroll view to use. If provided, will search for a scroll view with this `accessibilityIdentifier`. |
199
208
  | `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. |
209
+ | `scrollToIndex` | `number` | - | When set, triggers an animated scroll to this cell index. Use a nullish value (`null` or `undefined`) to indicate no scroll target. After the scroll completes, `onDidScrollToIndex` is called. Reset after receiving the callback. |
210
+ | `onDidScrollToIndex` | `() => void` | - | Called when the animated scroll to `scrollToIndex` completes. Use this to clear the `scrollToIndex` state. |
200
211
 
201
212
  #### Ref Methods
202
213
 
@@ -207,19 +218,14 @@ const aix = useAixRef()
207
218
 
208
219
  // Scroll to the end of the content
209
220
  aix.current?.scrollToEnd(animated)
210
-
211
- // Scroll to a specific index when the blank size is ready
212
- aix.current?.scrollToIndexWhenBlankSizeReady(
213
- index,
214
- animated,
215
- waitForKeyboardToEnd
216
- )
217
221
  ```
218
222
 
219
- | Method | Parameters | Description |
220
- | --------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------- |
221
- | `scrollToEnd` | `animated?: boolean` | Scrolls to the end of the content. |
222
- | `scrollToIndexWhenBlankSizeReady` | `index: number, animated?: boolean, waitForKeyboardToEnd?: boolean` | Scrolls to a specific cell index once the blank size calculation is ready. |
223
+ | Method | Parameters | Description |
224
+ | ------------- | -------------------- | ---------------------------------- |
225
+ | `scrollToEnd` | `animated?: boolean` | Scrolls to the end of the content. |
226
+
227
+ > **Note:** For scrolling to a specific message index, prefer using the
228
+ > declarative `scrollToIndex` prop instead of imperative methods.
223
229
 
224
230
  ---
225
231
 
@@ -258,31 +264,8 @@ Accepts all standard React Native `View` props.
258
264
  <Composer />
259
265
  </AixFooter>
260
266
  ```
261
-
262
267
  ---
263
268
 
264
- ### `useAixRef`
265
-
266
- A hook that returns a ref to access imperative methods on the `Aix` component.
267
-
268
- ```tsx
269
- import { useAixRef } from 'aix'
270
-
271
- function Chat({ messages }) {
272
- const aix = useAixRef()
273
- const send = useSendMessage()
274
-
275
- const handleSend = () => {
276
- // Scroll to end after sending a message
277
- send(message)
278
- aix.current?.scrollToIndexWhenBlankSizeReady(messages.length + 1, true)
279
- requestAnimationFrame(Keyboard.dismiss)
280
- }
281
-
282
- return <Aix ref={aix}>{/* ... */}</Aix>
283
- }
284
- ```
285
-
286
269
  ### Rules
287
270
 
288
271
  - Do **NOT** apply padding to `contentContainerStyle`. Instead, use padding on
@@ -326,7 +326,7 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
326
326
  }
327
327
 
328
328
  private func calculateBlankSize(keyboardHeight: CGFloat, additionalContentInsetBottom: CGFloat) -> CGFloat {
329
- guard let scrollView, let blankView else {
329
+ guard let scrollView, let blankView else {
330
330
  return lastCalculatedBlankSize
331
331
  }
332
332
 
@@ -646,7 +646,15 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
646
646
  return
647
647
  }
648
648
 
649
- // Skip when animated scroll is in progress to avoid interfering
649
+ // Check if this blankView matches the scrollToIndex target
650
+ // This handles the case where isLast prop updates after cell registration
651
+ if let target = scrollToIndexTarget, target == index {
652
+ print("[Aix] reportBlankViewSizeChange - scrollToIndex target matches")
653
+ performScrollToIndex()
654
+ return
655
+ }
656
+
657
+ // Skip regular inset updates when animated scroll is in progress to avoid interfering
650
658
  if scrollToIndexTarget != nil {
651
659
  print("[Aix] reportBlankViewSizeChange - skipping, scrollToIndex active")
652
660
  return
@@ -740,47 +748,47 @@ class HybridAix: HybridAixSpec, AixContext, KeyboardNotificationsDelegate {
740
748
  cells.setObject(cell, forKey: NSNumber(value: cell.index))
741
749
 
742
750
  if cell.isLast {
743
- print("[Aix] registerCell - this is blankView, setting and reporting size")
751
+ print("[Aix] registerCell - this is blankView")
744
752
  blankView = cell
753
+
754
+ if let target = scrollToIndexTarget, target == Int(cell.index) {
755
+ print("[Aix] registerCell - scrollToIndex target matches!")
756
+ performScrollToIndex()
757
+ return
758
+ }
759
+
745
760
  let currentSize = cell.view.bounds.size
746
761
  reportBlankViewSizeChange(size: currentSize, index: Int(cell.index))
747
762
  } else if !didScrollToEndInitially {
748
- // During initial mount, check if all cells are now registered
749
763
  print("[Aix] registerCell - during initial mount, checking completion")
750
764
  tryCompleteInitialLayout()
751
765
  } else {
752
- // After initial layout, apply insets for blankSize compensation
753
766
  print("[Aix] registerCell - post-initial, applying insets")
754
767
 
755
- // Only perform animated scroll when scrollToIndex is explicitly set.
756
- // This happens when a new user message is added (becomes penultimate cell).
757
768
  if let target = scrollToIndexTarget, let blankView, target == Int(blankView.index) {
758
- let isKeyboardTransitioning = startEvent != nil
759
769
  print("[Aix] registerCell - scrollToIndex target matches")
760
-
761
- applyAllInsets()
762
-
763
- if isKeyboardTransitioning {
764
- // Keyboard is animating - defer our scroll to avoid conflicts
765
- // handleKeyboardDidMove will execute the scroll when keyboard animation ends
766
- print("[Aix] registerCell - deferring animated scroll until keyboard animation completes")
767
- pendingAnimatedScroll = true
768
- // onDidScrollToIndex will be called when keyboard animation completes
769
- } else {
770
- // No keyboard animation - scroll immediately
771
- // Call onDidScrollToIndex after animation completes so scrollToIndexTarget
772
- // stays set during animation (used by skip logic in reportCellHeightChange)
773
- scrollToEndInternal(animated: true) { [weak self] in
774
- self?.onDidScrollToIndex?()
775
- }
776
- }
770
+ performScrollToIndex()
777
771
  } else if scrollToIndexTarget == nil {
778
- // Only apply insets when no animated scroll is active
779
772
  applyAllInsets()
780
773
  }
781
774
  }
782
775
  }
783
776
 
777
+ /// Performs animated scroll to end with proper keyboard transition handling.
778
+ /// If keyboard is animating, defers scroll until animation completes.
779
+ private func performScrollToIndex() {
780
+ applyAllInsets()
781
+
782
+ if startEvent != nil {
783
+ print("[Aix] performScrollToIndex - deferring until keyboard animation completes")
784
+ pendingAnimatedScroll = true
785
+ } else {
786
+ scrollToEndInternal(animated: true) { [weak self] in
787
+ self?.onDidScrollToIndex?()
788
+ }
789
+ }
790
+ }
791
+
784
792
  func unregisterCell(_ cell: HybridAixCellView) {
785
793
  cells.removeObject(forKey: NSNumber(value: cell.index))
786
794
 
@@ -21,15 +21,15 @@ class HybridAixComposer: HybridAixComposerSpec {
21
21
  if let ctx = cachedAixContext {
22
22
  applyKeyboardTransform(height: ctx.keyboardHeight, heightWhenOpen: ctx.keyboardHeightWhenOpen, animated: false)
23
23
  }
24
+ // Re-apply text input fixes (content inset adjustment depends on stickToKeyboard)
25
+ applyTextInputFixes()
24
26
  }
25
27
  }
26
28
 
27
29
  var fixInput: Bool? = nil {
28
30
  didSet {
29
31
  cachedTextInput = nil
30
- if fixInput == true {
31
- resolveTextInput()
32
- }
32
+ applyTextInputFixes()
33
33
  }
34
34
  }
35
35
 
@@ -141,8 +141,8 @@ class HybridAixComposer: HybridAixComposerSpec {
141
141
  applyKeyboardTransform(height: ctx.keyboardHeight, heightWhenOpen: ctx.keyboardHeightWhenOpen, animated: false)
142
142
  }
143
143
 
144
- // Resolve text input once the hierarchy is connected
145
- resolveTextInput()
144
+ // Apply text input fixes once the hierarchy is connected
145
+ applyTextInputFixes()
146
146
  }
147
147
 
148
148
  /// Called when the view is about to be removed from superview
@@ -179,12 +179,20 @@ class HybridAixComposer: HybridAixComposerSpec {
179
179
  return input
180
180
  }
181
181
 
182
- /// Resolve the text input and apply patches
183
- private func resolveTextInput() {
184
- print("[Aix] resolveTextInput: fixInput=\(fixInput), textInput=\(textInput)")
185
- guard fixInput == true, let input = textInput else { return }
182
+ /// Apply fixes to the text input based on current props
183
+ private func applyTextInputFixes() {
184
+ guard let input = textInput else { return }
186
185
  guard let scrollView = input as? UIScrollView else { return }
187
- print("[Aix] resolveTextInput: scrollView=\(scrollView)")
186
+
187
+ // When stickToKeyboard is enabled, the footer uses transforms to position above keyboard.
188
+ // iOS calculates safe area based on frame (not visual position), so it incorrectly adds
189
+ // bottom safe area insets. Disable automatic adjustment since we manage positioning.
190
+ if stickToKeyboard?.enabled == true {
191
+ scrollView.contentInsetAdjustmentBehavior = .never
192
+ }
193
+
194
+ // Apply fixInput patches
195
+ guard fixInput == true else { return }
188
196
  scrollView.showsVerticalScrollIndicator = false
189
197
  scrollView.showsHorizontalScrollIndicator = false
190
198
  scrollView.bounces = false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aix",
3
- "version": "0.7.1-alpha.8",
3
+ "version": "0.8.0",
4
4
  "author": "Fernando Rojo",
5
5
  "repository": {
6
6
  "type": "git",