@assistant-ui/react-native 0.1.43 → 0.1.45

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 (234) hide show
  1. package/LICENSE +1 -1
  2. package/dist/context/AssistantContext.d.ts +2 -2
  3. package/dist/context/AssistantContext.d.ts.map +1 -0
  4. package/dist/context/providers/AttachmentByIndexProvider.d.ts +2 -2
  5. package/dist/context/providers/AttachmentByIndexProvider.d.ts.map +1 -0
  6. package/dist/context/providers/RuntimeAdapterProvider.d.ts +2 -2
  7. package/dist/context/providers/RuntimeAdapterProvider.d.ts.map +1 -0
  8. package/dist/context/providers/ThreadListItemRuntimeProvider.d.ts +2 -2
  9. package/dist/context/providers/ThreadListItemRuntimeProvider.d.ts.map +1 -0
  10. package/dist/index.d.ts +54 -23
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +4 -4
  13. package/dist/internal.d.ts +4 -3
  14. package/dist/internal.d.ts.map +1 -0
  15. package/dist/primitives/actionBar/ActionBarCopy.d.ts +7 -9
  16. package/dist/primitives/actionBar/ActionBarCopy.d.ts.map +1 -1
  17. package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
  18. package/dist/primitives/actionBar/ActionBarEdit.d.ts +5 -7
  19. package/dist/primitives/actionBar/ActionBarEdit.d.ts.map +1 -1
  20. package/dist/primitives/actionBar/ActionBarEdit.js.map +1 -1
  21. package/dist/primitives/actionBar/ActionBarFeedbackNegative.d.ts +6 -8
  22. package/dist/primitives/actionBar/ActionBarFeedbackNegative.d.ts.map +1 -1
  23. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +28 -20
  24. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
  25. package/dist/primitives/actionBar/ActionBarFeedbackPositive.d.ts +6 -8
  26. package/dist/primitives/actionBar/ActionBarFeedbackPositive.d.ts.map +1 -1
  27. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +28 -20
  28. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
  29. package/dist/primitives/actionBar/ActionBarReload.d.ts +5 -7
  30. package/dist/primitives/actionBar/ActionBarReload.d.ts.map +1 -1
  31. package/dist/primitives/actionBar/ActionBarReload.js.map +1 -1
  32. package/dist/primitives/actionBar/ActionBarSpeak.d.ts +7 -9
  33. package/dist/primitives/actionBar/ActionBarSpeak.d.ts.map +1 -1
  34. package/dist/primitives/actionBar/ActionBarSpeak.js.map +1 -1
  35. package/dist/primitives/actionBar/ActionBarStopSpeaking.d.ts +7 -9
  36. package/dist/primitives/actionBar/ActionBarStopSpeaking.d.ts.map +1 -1
  37. package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
  38. package/dist/primitives/actionBar.d.ts +7 -12
  39. package/dist/primitives/actionBar.d.ts.map +1 -1
  40. package/dist/primitives/attachment/AttachmentName.d.ts +2 -4
  41. package/dist/primitives/attachment/AttachmentName.d.ts.map +1 -1
  42. package/dist/primitives/attachment/AttachmentName.js.map +1 -1
  43. package/dist/primitives/attachment/AttachmentRemove.d.ts +2 -4
  44. package/dist/primitives/attachment/AttachmentRemove.d.ts.map +1 -1
  45. package/dist/primitives/attachment/AttachmentRemove.js.map +1 -1
  46. package/dist/primitives/attachment/AttachmentRoot.d.ts +3 -5
  47. package/dist/primitives/attachment/AttachmentRoot.d.ts.map +1 -1
  48. package/dist/primitives/attachment/AttachmentRoot.js.map +1 -1
  49. package/dist/primitives/attachment/AttachmentThumb.d.ts +2 -4
  50. package/dist/primitives/attachment/AttachmentThumb.d.ts.map +1 -1
  51. package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
  52. package/dist/primitives/attachment.d.ts +4 -9
  53. package/dist/primitives/attachment.d.ts.map +1 -1
  54. package/dist/primitives/branchPicker/BranchPickerCount.d.ts +1 -3
  55. package/dist/primitives/branchPicker/BranchPickerCount.d.ts.map +1 -1
  56. package/dist/primitives/branchPicker/BranchPickerCount.js.map +1 -1
  57. package/dist/primitives/branchPicker/BranchPickerNext.d.ts +5 -7
  58. package/dist/primitives/branchPicker/BranchPickerNext.d.ts.map +1 -1
  59. package/dist/primitives/branchPicker/BranchPickerNext.js.map +1 -1
  60. package/dist/primitives/branchPicker/BranchPickerNumber.d.ts +1 -3
  61. package/dist/primitives/branchPicker/BranchPickerNumber.d.ts.map +1 -1
  62. package/dist/primitives/branchPicker/BranchPickerNumber.js.map +1 -1
  63. package/dist/primitives/branchPicker/BranchPickerPrevious.d.ts +5 -7
  64. package/dist/primitives/branchPicker/BranchPickerPrevious.d.ts.map +1 -1
  65. package/dist/primitives/branchPicker/BranchPickerPrevious.js.map +1 -1
  66. package/dist/primitives/branchPicker.d.ts +4 -9
  67. package/dist/primitives/branchPicker.d.ts.map +1 -1
  68. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.d.ts +2 -4
  69. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.d.ts.map +1 -1
  70. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
  71. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.d.ts +3 -5
  72. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.d.ts.map +1 -1
  73. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.js.map +1 -1
  74. package/dist/primitives/chainOfThought.d.ts +3 -8
  75. package/dist/primitives/chainOfThought.d.ts.map +1 -1
  76. package/dist/primitives/composer/ComposerAddAttachment.d.ts +2 -4
  77. package/dist/primitives/composer/ComposerAddAttachment.d.ts.map +1 -1
  78. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  79. package/dist/primitives/composer/ComposerAttachments.d.ts +1 -3
  80. package/dist/primitives/composer/ComposerAttachments.d.ts.map +1 -1
  81. package/dist/primitives/composer/ComposerCancel.d.ts +2 -4
  82. package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
  83. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  84. package/dist/primitives/composer/ComposerIf.d.ts +2 -2
  85. package/dist/primitives/composer/ComposerIf.d.ts.map +1 -0
  86. package/dist/primitives/composer/ComposerInput.d.ts +8 -10
  87. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  88. package/dist/primitives/composer/ComposerInput.js +1 -1
  89. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  90. package/dist/primitives/composer/ComposerQueue.d.ts +2 -2
  91. package/dist/primitives/composer/ComposerQueue.d.ts.map +1 -0
  92. package/dist/primitives/composer/ComposerQuote.d.ts +3 -5
  93. package/dist/primitives/composer/ComposerQuote.d.ts.map +1 -1
  94. package/dist/primitives/composer/ComposerQuote.js.map +1 -1
  95. package/dist/primitives/composer/ComposerQuoteDismiss.d.ts +2 -4
  96. package/dist/primitives/composer/ComposerQuoteDismiss.d.ts.map +1 -1
  97. package/dist/primitives/composer/ComposerQuoteDismiss.js.map +1 -1
  98. package/dist/primitives/composer/ComposerQuoteText.d.ts +3 -5
  99. package/dist/primitives/composer/ComposerQuoteText.d.ts.map +1 -1
  100. package/dist/primitives/composer/ComposerQuoteText.js.map +1 -1
  101. package/dist/primitives/composer/ComposerRoot.d.ts +3 -5
  102. package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
  103. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  104. package/dist/primitives/composer/ComposerSend.d.ts +2 -4
  105. package/dist/primitives/composer/ComposerSend.d.ts.map +1 -1
  106. package/dist/primitives/composer/ComposerSend.js.map +1 -1
  107. package/dist/primitives/composer.d.ts +11 -16
  108. package/dist/primitives/composer.d.ts.map +1 -1
  109. package/dist/primitives/error/ErrorMessage.d.ts +5 -7
  110. package/dist/primitives/error/ErrorMessage.d.ts.map +1 -1
  111. package/dist/primitives/error/ErrorMessage.js.map +1 -1
  112. package/dist/primitives/error/ErrorRoot.d.ts +5 -7
  113. package/dist/primitives/error/ErrorRoot.d.ts.map +1 -1
  114. package/dist/primitives/error/ErrorRoot.js.map +1 -1
  115. package/dist/primitives/error/index.d.ts +2 -7
  116. package/dist/primitives/error/index.d.ts.map +1 -1
  117. package/dist/primitives/message/MessageAttachments.d.ts +1 -3
  118. package/dist/primitives/message/MessageAttachments.d.ts.map +1 -1
  119. package/dist/primitives/message/MessageContent.d.ts +46 -47
  120. package/dist/primitives/message/MessageContent.d.ts.map +1 -1
  121. package/dist/primitives/message/MessageContent.js +70 -54
  122. package/dist/primitives/message/MessageContent.js.map +1 -1
  123. package/dist/primitives/message/MessageIf.d.ts +7 -9
  124. package/dist/primitives/message/MessageIf.d.ts.map +1 -1
  125. package/dist/primitives/message/MessageIf.js.map +1 -1
  126. package/dist/primitives/message/MessageParts.d.ts +4 -6
  127. package/dist/primitives/message/MessageParts.d.ts.map +1 -1
  128. package/dist/primitives/message/MessageParts.js.map +1 -1
  129. package/dist/primitives/message/MessageRoot.d.ts +3 -5
  130. package/dist/primitives/message/MessageRoot.d.ts.map +1 -1
  131. package/dist/primitives/message/MessageRoot.js.map +1 -1
  132. package/dist/primitives/message.d.ts +6 -11
  133. package/dist/primitives/message.d.ts.map +1 -1
  134. package/dist/primitives/messagePart/MessagePartImage.d.ts +4 -6
  135. package/dist/primitives/messagePart/MessagePartImage.d.ts.map +1 -1
  136. package/dist/primitives/messagePart/MessagePartImage.js.map +1 -1
  137. package/dist/primitives/messagePart/MessagePartInProgress.d.ts +2 -2
  138. package/dist/primitives/messagePart/MessagePartInProgress.d.ts.map +1 -0
  139. package/dist/primitives/messagePart/MessagePartText.d.ts +4 -6
  140. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  141. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  142. package/dist/primitives/messagePart.d.ts +4 -9
  143. package/dist/primitives/messagePart.d.ts.map +1 -1
  144. package/dist/primitives/queueItem/QueueItemRemove.d.ts +2 -4
  145. package/dist/primitives/queueItem/QueueItemRemove.d.ts.map +1 -1
  146. package/dist/primitives/queueItem/QueueItemRemove.js.map +1 -1
  147. package/dist/primitives/queueItem/QueueItemSteer.d.ts +2 -4
  148. package/dist/primitives/queueItem/QueueItemSteer.d.ts.map +1 -1
  149. package/dist/primitives/queueItem/QueueItemSteer.js.map +1 -1
  150. package/dist/primitives/queueItem/QueueItemText.d.ts +3 -5
  151. package/dist/primitives/queueItem/QueueItemText.d.ts.map +1 -1
  152. package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
  153. package/dist/primitives/queueItem.d.ts +3 -8
  154. package/dist/primitives/queueItem.d.ts.map +1 -1
  155. package/dist/primitives/suggestion/SuggestionDescription.d.ts +3 -5
  156. package/dist/primitives/suggestion/SuggestionDescription.d.ts.map +1 -1
  157. package/dist/primitives/suggestion/SuggestionDescription.js.map +1 -1
  158. package/dist/primitives/suggestion/SuggestionTitle.d.ts +3 -5
  159. package/dist/primitives/suggestion/SuggestionTitle.d.ts.map +1 -1
  160. package/dist/primitives/suggestion/SuggestionTitle.js.map +1 -1
  161. package/dist/primitives/suggestion/SuggestionTrigger.d.ts +13 -15
  162. package/dist/primitives/suggestion/SuggestionTrigger.d.ts.map +1 -1
  163. package/dist/primitives/suggestion/SuggestionTrigger.js +1 -1
  164. package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
  165. package/dist/primitives/suggestion.d.ts +3 -8
  166. package/dist/primitives/suggestion.d.ts.map +1 -1
  167. package/dist/primitives/thread/ThreadEmpty.d.ts +2 -4
  168. package/dist/primitives/thread/ThreadEmpty.d.ts.map +1 -1
  169. package/dist/primitives/thread/ThreadEmpty.js.map +1 -1
  170. package/dist/primitives/thread/ThreadIf.d.ts +4 -6
  171. package/dist/primitives/thread/ThreadIf.d.ts.map +1 -1
  172. package/dist/primitives/thread/ThreadIf.js.map +1 -1
  173. package/dist/primitives/thread/ThreadMessages.d.ts +37 -37
  174. package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
  175. package/dist/primitives/thread/ThreadMessages.js +70 -58
  176. package/dist/primitives/thread/ThreadMessages.js.map +1 -1
  177. package/dist/primitives/thread/ThreadRoot.d.ts +3 -5
  178. package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
  179. package/dist/primitives/thread/ThreadRoot.js.map +1 -1
  180. package/dist/primitives/thread/ThreadSuggestion.d.ts +15 -17
  181. package/dist/primitives/thread/ThreadSuggestion.d.ts.map +1 -1
  182. package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
  183. package/dist/primitives/thread.d.ts +8 -11
  184. package/dist/primitives/thread.d.ts.map +1 -1
  185. package/dist/primitives/threadList/ThreadListItems.d.ts +6 -8
  186. package/dist/primitives/threadList/ThreadListItems.d.ts.map +1 -1
  187. package/dist/primitives/threadList/ThreadListItems.js +1 -1
  188. package/dist/primitives/threadList/ThreadListItems.js.map +1 -1
  189. package/dist/primitives/threadList/ThreadListNew.d.ts +5 -7
  190. package/dist/primitives/threadList/ThreadListNew.d.ts.map +1 -1
  191. package/dist/primitives/threadList/ThreadListNew.js +21 -17
  192. package/dist/primitives/threadList/ThreadListNew.js.map +1 -1
  193. package/dist/primitives/threadList/ThreadListRoot.d.ts +3 -5
  194. package/dist/primitives/threadList/ThreadListRoot.d.ts.map +1 -1
  195. package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
  196. package/dist/primitives/threadList.d.ts +3 -8
  197. package/dist/primitives/threadList.d.ts.map +1 -1
  198. package/dist/primitives/threadListItem/ThreadListItemArchive.d.ts +2 -4
  199. package/dist/primitives/threadListItem/ThreadListItemArchive.d.ts.map +1 -1
  200. package/dist/primitives/threadListItem/ThreadListItemArchive.js.map +1 -1
  201. package/dist/primitives/threadListItem/ThreadListItemDelete.d.ts +2 -4
  202. package/dist/primitives/threadListItem/ThreadListItemDelete.d.ts.map +1 -1
  203. package/dist/primitives/threadListItem/ThreadListItemDelete.js.map +1 -1
  204. package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts +3 -5
  205. package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
  206. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  207. package/dist/primitives/threadListItem/ThreadListItemTrigger.d.ts +5 -7
  208. package/dist/primitives/threadListItem/ThreadListItemTrigger.d.ts.map +1 -1
  209. package/dist/primitives/threadListItem/ThreadListItemTrigger.js +21 -17
  210. package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
  211. package/dist/primitives/threadListItem/ThreadListItemUnarchive.d.ts +2 -4
  212. package/dist/primitives/threadListItem/ThreadListItemUnarchive.d.ts.map +1 -1
  213. package/dist/primitives/threadListItem/ThreadListItemUnarchive.js.map +1 -1
  214. package/dist/primitives/threadListItem.d.ts +6 -11
  215. package/dist/primitives/threadListItem.d.ts.map +1 -1
  216. package/dist/runtimes/useLocalRuntime.d.ts +2 -2
  217. package/dist/runtimes/useLocalRuntime.d.ts.map +1 -0
  218. package/dist/runtimes/useRemoteThreadListRuntime.d.ts +2 -2
  219. package/dist/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -0
  220. package/package.json +12 -9
  221. package/src/index.ts +90 -1
  222. package/src/primitives/actionBar/ActionBar.test.tsx +76 -0
  223. package/src/primitives/actionBar/ActionBarFeedbackNegative.tsx +4 -1
  224. package/src/primitives/actionBar/ActionBarFeedbackPositive.tsx +4 -1
  225. package/src/primitives/composer/ComposerInput.test.tsx +17 -1
  226. package/src/primitives/composer/ComposerInput.tsx +6 -1
  227. package/src/primitives/message/MessageContent.test.tsx +110 -4
  228. package/src/primitives/message/MessageContent.tsx +8 -4
  229. package/src/primitives/thread/ThreadMessages.test.tsx +111 -5
  230. package/src/primitives/thread/ThreadMessages.tsx +41 -11
  231. package/src/primitives/threadList/ThreadListNew.test.tsx +35 -8
  232. package/src/primitives/threadList/ThreadListNew.tsx +4 -1
  233. package/src/primitives/threadListItem/ThreadListItemTrigger.test.tsx +32 -6
  234. package/src/primitives/threadListItem/ThreadListItemTrigger.tsx +4 -1
@@ -1,4 +1,4 @@
1
- import { act, type ReactElement } from "react";
1
+ import { act, useState, type ReactElement } from "react";
2
2
  import { createRoot, type Root } from "react-dom/client";
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { MessageContent } from "./MessageContent";
@@ -9,6 +9,7 @@ const h = vi.hoisted(() => ({
9
9
  addToolResult: vi.fn(),
10
10
  resumeToolCall: vi.fn(),
11
11
  respondToToolApproval: vi.fn(),
12
+ unstable_recordInteraction: vi.fn(),
12
13
  state: {
13
14
  message: {
14
15
  content: [] as AnyPart[],
@@ -17,7 +18,10 @@ const h = vi.hoisted(() => ({
17
18
  },
18
19
  },
19
20
  tools: { toolUIs: {} as Record<string, unknown> },
20
- dataRenderers: { renderers: {} as Record<string, unknown> },
21
+ dataRenderers: {
22
+ renderers: {} as Record<string, unknown>,
23
+ fallbacks: [] as unknown[],
24
+ },
21
25
  },
22
26
  }));
23
27
 
@@ -28,6 +32,8 @@ vi.mock("@assistant-ui/store", () => {
28
32
  resumeToolCall: (...args: unknown[]) => h.resumeToolCall(index, ...args),
29
33
  respondToToolApproval: (...args: unknown[]) =>
30
34
  h.respondToToolApproval(index, ...args),
35
+ unstable_recordInteraction: (...args: unknown[]) =>
36
+ h.unstable_recordInteraction(index, ...args),
31
37
  }),
32
38
  });
33
39
  const aui = { message };
@@ -47,9 +53,11 @@ describe("MessageContent", () => {
47
53
  h.addToolResult.mockReset();
48
54
  h.resumeToolCall.mockReset();
49
55
  h.respondToToolApproval.mockReset();
56
+ h.unstable_recordInteraction.mockReset();
50
57
  h.state.message.content = [];
51
58
  h.state.tools.toolUIs = {};
52
59
  h.state.dataRenderers.renderers = {};
60
+ h.state.dataRenderers.fallbacks = [];
53
61
 
54
62
  container = document.createElement("div");
55
63
  document.body.appendChild(container);
@@ -71,6 +79,51 @@ describe("MessageContent", () => {
71
79
  });
72
80
  };
73
81
 
82
+ it("keeps a text seed while streaming and resets it for a replacement id", async () => {
83
+ const SeededText = ({ text }: { text: string }) => {
84
+ const [seed] = useState(text);
85
+ return <span>{`${seed}:${text}`}</span>;
86
+ };
87
+ const renderText: NonNullable<
88
+ Parameters<typeof MessageContent>[0]["renderText"]
89
+ > = ({ part }) => <SeededText text={part.text} />;
90
+
91
+ h.state.message.content = [{ type: "text", id: "p1", text: "old" }];
92
+ await mount({ renderText });
93
+ h.state.message.content = [
94
+ { type: "text", id: "p1", text: "old streamed" },
95
+ ];
96
+ await mount({ renderText });
97
+ expect(container.textContent).toBe("old:old streamed");
98
+
99
+ h.state.message.content = [{ type: "text", id: "p2", text: "new" }];
100
+ await mount({ renderText });
101
+ expect(container.textContent).toBe("new:new");
102
+ });
103
+
104
+ it("keeps each seed with its id when identified parts swap", async () => {
105
+ const SeededText = ({ text }: { text: string }) => {
106
+ const [seed] = useState(text);
107
+ return <span>{`${seed}:${text}`}</span>;
108
+ };
109
+ const renderText: NonNullable<
110
+ Parameters<typeof MessageContent>[0]["renderText"]
111
+ > = ({ part }) => <SeededText text={part.text} />;
112
+ h.state.message.content = [
113
+ { type: "text", id: "p1", text: "first" },
114
+ { type: "text", id: "p2", text: "second" },
115
+ ];
116
+ await mount({ renderText });
117
+ h.state.message.content = [
118
+ { type: "text", id: "p2", text: "second updated" },
119
+ { type: "text", id: "p1", text: "first updated" },
120
+ ];
121
+ await mount({ renderText });
122
+ expect(
123
+ Array.from(container.querySelectorAll("span"), (el) => el.textContent),
124
+ ).toEqual(["second:second updated", "first:first updated"]);
125
+ });
126
+
74
127
  it("renders a text part through the default text renderer", async () => {
75
128
  h.state.message.content = [{ type: "text", text: "hello world" }];
76
129
  await mount();
@@ -152,6 +205,10 @@ describe("MessageContent", () => {
152
205
  (props.addResult as () => void)();
153
206
  (props.resume as () => void)();
154
207
  (props.respondToApproval as () => void)();
208
+ (props.unstable_recordInteraction as (input: unknown) => void)({
209
+ type: "action",
210
+ payload: { choice: "retry" },
211
+ });
155
212
  return <span data-testid="tool">tool:{String(props.toolName)}</span>;
156
213
  });
157
214
  h.state.tools.toolUIs = { search: [{ render: ToolRender }] };
@@ -163,6 +220,10 @@ describe("MessageContent", () => {
163
220
  expect(h.addToolResult).toHaveBeenCalledWith(0);
164
221
  expect(h.resumeToolCall).toHaveBeenCalledWith(0);
165
222
  expect(h.respondToToolApproval).toHaveBeenCalledWith(0);
223
+ expect(h.unstable_recordInteraction).toHaveBeenCalledWith(0, {
224
+ type: "action",
225
+ payload: { choice: "retry" },
226
+ });
166
227
  });
167
228
 
168
229
  it("picks the first registration when multiple are registered", async () => {
@@ -218,7 +279,7 @@ describe("MessageContent", () => {
218
279
  const DataRender = vi.fn((props: Record<string, unknown>) => (
219
280
  <span data-testid="data">data:{String(props.name)}</span>
220
281
  ));
221
- h.state.dataRenderers.renderers = { chart: DataRender };
282
+ h.state.dataRenderers.renderers = { chart: [DataRender] };
222
283
 
223
284
  await mount();
224
285
 
@@ -251,6 +312,51 @@ describe("MessageContent", () => {
251
312
  expect(el?.textContent).toBe("fallback:chart:0");
252
313
  });
253
314
 
315
+ it("uses dataRenderers.fallbacks[0] before renderData when no named renderer matches", async () => {
316
+ h.state.message.content = [{ type: "data", name: "chart", data: {} }];
317
+ const DataFallback = vi.fn((props: Record<string, unknown>) => (
318
+ <span data-testid="gfallback">global:{String(props.name)}</span>
319
+ ));
320
+ h.state.dataRenderers.fallbacks = [DataFallback];
321
+ const renderData = vi.fn(({ part, index }): ReactElement => (
322
+ <span data-testid="dfallback">
323
+ fallback:{String(part.name)}:{index}
324
+ </span>
325
+ ));
326
+ await mount({ renderData });
327
+
328
+ expect(
329
+ container.querySelector('[data-testid="gfallback"]')?.textContent,
330
+ ).toBe("global:chart");
331
+ expect(DataFallback.mock.calls[0]?.[0]).toEqual({
332
+ type: "data",
333
+ name: "chart",
334
+ data: {},
335
+ });
336
+ expect(renderData).not.toHaveBeenCalled();
337
+ });
338
+
339
+ it("prefers a named data renderer over dataRenderers.fallbacks", async () => {
340
+ h.state.message.content = [{ type: "data", name: "chart", data: {} }];
341
+ const DataRender = vi.fn((props: Record<string, unknown>) => (
342
+ <span data-testid="data">data:{String(props.name)}</span>
343
+ ));
344
+ const DataFallback = vi.fn(() => (
345
+ <span data-testid="gfallback">global-fallback</span>
346
+ ));
347
+ h.state.dataRenderers.renderers = { chart: [DataRender] };
348
+ h.state.dataRenderers.fallbacks = [DataFallback];
349
+ await mount({
350
+ renderData: () => <span data-testid="dfallback">render prop</span>,
351
+ });
352
+
353
+ expect(container.querySelector('[data-testid="data"]')?.textContent).toBe(
354
+ "data:chart",
355
+ );
356
+ expect(DataFallback).not.toHaveBeenCalled();
357
+ expect(container.querySelector('[data-testid="dfallback"]')).toBeNull();
358
+ });
359
+
254
360
  it("renders null when no data renderer is registered and no fallback is given", async () => {
255
361
  h.state.message.content = [{ type: "data", name: "chart", data: {} }];
256
362
  await mount();
@@ -268,7 +374,7 @@ describe("MessageContent", () => {
268
374
  t: [{ render: () => <span>[tool]</span> }],
269
375
  };
270
376
  h.state.dataRenderers.renderers = {
271
- d: () => <span>[data]</span>,
377
+ d: [() => <span>[data]</span>],
272
378
  };
273
379
  await mount();
274
380
 
@@ -1,5 +1,6 @@
1
1
  import { type ReactElement, Fragment, useMemo } from "react";
2
2
  import { Text } from "react-native";
3
+ import { getMessagePartKeys } from "@assistant-ui/core/internal";
3
4
  import type {
4
5
  ThreadUserMessagePart,
5
6
  ThreadAssistantMessagePart,
@@ -81,6 +82,9 @@ const ToolUIDisplay = ({
81
82
  addResult={partMethods.addToolResult}
82
83
  resume={partMethods.resumeToolCall}
83
84
  respondToApproval={partMethods.respondToToolApproval}
85
+ {...(partMethods.unstable_recordInteraction && {
86
+ unstable_recordInteraction: partMethods.unstable_recordInteraction,
87
+ })}
84
88
  />
85
89
  );
86
90
  }
@@ -103,9 +107,8 @@ const DataUIDisplay = ({
103
107
  index: number;
104
108
  }) => {
105
109
  const Render = useAuiState((s) => {
106
- const renders = s.dataRenderers.renderers[part.name];
107
- if (Array.isArray(renders)) return renders[0];
108
- return renders;
110
+ const named = s.dataRenderers.renderers[part.name]?.[0];
111
+ return named ?? s.dataRenderers.fallbacks[0];
109
112
  });
110
113
  if (Render) return <Render {...(part as DataMessagePartProps)} />;
111
114
  if (Fallback) return <Fallback part={part} index={index} />;
@@ -122,11 +125,12 @@ export const MessageContent = ({
122
125
  renderData,
123
126
  }: MessageContentProps) => {
124
127
  const content = useAuiState((s) => s.message.parts);
128
+ const partKeys = getMessagePartKeys(content);
125
129
 
126
130
  return (
127
131
  <>
128
132
  {content.map((part, index) => {
129
- const key = `${part.type}-${index}`;
133
+ const key = partKeys[index];
130
134
  switch (part.type) {
131
135
  case "text":
132
136
  return (
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import type { FlatList, FlatListProps } from "react-native";
5
5
  import type { ThreadMessage } from "@assistant-ui/core";
6
+ import type { MessageState } from "@assistant-ui/core/store";
6
7
  import { ThreadMessages, ThreadMessagesFlatList } from "./ThreadMessages";
7
8
 
8
9
  type Msg = { id: string; role: string };
@@ -12,7 +13,7 @@ const h = vi.hoisted(() => ({
12
13
  thread: { messages: [] as Msg[] },
13
14
  message: { role: "user" as string, composer: { isEditing: false } },
14
15
  },
15
- itemState: { role: "user" } as { role: string },
16
+ itemState: { role: "user" } as { role: string; parts?: unknown[] },
16
17
  events: {} as Record<string, Set<() => void>>,
17
18
  flatListProps: null as Record<string, unknown> | null,
18
19
  scrollToOffset: vi.fn(),
@@ -303,13 +304,15 @@ describe("ThreadMessages", () => {
303
304
  describe("children mode", () => {
304
305
  it("renders via the children render prop", async () => {
305
306
  h.state.thread.messages = [{ id: "1", role: "user" }];
306
- h.itemState = { role: "user" };
307
- const children = vi.fn(({ message }: { message: { role: string } }) => (
308
- <span data-testid="child">child:{message.role}</span>
307
+ h.itemState = { role: "user", parts: [] };
308
+ const children = vi.fn(({ message }: { message: MessageState }) => (
309
+ <span data-testid="child">
310
+ child:{message.role}:{message.parts.length}
311
+ </span>
309
312
  ));
310
313
  await mount({ children });
311
314
  const el = container.querySelector('[data-testid="child"]');
312
- expect(el?.textContent).toBe("child:user");
315
+ expect(el?.textContent).toBe("child:user:0");
313
316
  expect(children).toHaveBeenCalled();
314
317
  });
315
318
  });
@@ -420,6 +423,109 @@ describe("ThreadMessages", () => {
420
423
  expect(getFlatListProps().onStartReached).toBeUndefined();
421
424
  });
422
425
 
426
+ it("loads one page when start reached fires again before rerender", async () => {
427
+ const loadMore = vi.fn();
428
+ const callerOnStartReached = vi.fn();
429
+
430
+ await mountFlatList({
431
+ components: messageComponents,
432
+ history: { hasMore: true, isLoadingMore: false, loadMore },
433
+ onStartReached: callerOnStartReached,
434
+ });
435
+ const installedOnStartReached = getFlatListProps().onStartReached;
436
+
437
+ installedOnStartReached?.({ distanceFromStart: 0 });
438
+ installedOnStartReached?.({ distanceFromStart: 0 });
439
+
440
+ expect(loadMore).toHaveBeenCalledOnce();
441
+ expect(callerOnStartReached).toHaveBeenCalledTimes(2);
442
+ });
443
+
444
+ it("loads again after a commit without a loading transition", async () => {
445
+ const loadMore = vi.fn();
446
+ const history = { hasMore: true, isLoadingMore: false, loadMore };
447
+
448
+ await mountFlatList({ components: messageComponents, history });
449
+ getFlatListProps().onStartReached?.({ distanceFromStart: 0 });
450
+
451
+ await mountFlatList({ components: messageComponents, history });
452
+ getFlatListProps().onStartReached?.({ distanceFromStart: 0 });
453
+
454
+ expect(loadMore).toHaveBeenCalledTimes(2);
455
+ });
456
+
457
+ it("does not carry a request latch to another history source", async () => {
458
+ const firstLoadMore = vi.fn();
459
+ const secondLoadMore = vi.fn();
460
+
461
+ await mountFlatList({
462
+ components: messageComponents,
463
+ history: {
464
+ hasMore: true,
465
+ isLoadingMore: false,
466
+ loadMore: firstLoadMore,
467
+ },
468
+ });
469
+ getFlatListProps().onStartReached?.({ distanceFromStart: 0 });
470
+
471
+ await mountFlatList({
472
+ components: messageComponents,
473
+ history: {
474
+ hasMore: true,
475
+ isLoadingMore: false,
476
+ loadMore: secondLoadMore,
477
+ },
478
+ });
479
+ getFlatListProps().onStartReached?.({ distanceFromStart: 0 });
480
+
481
+ expect(firstLoadMore).toHaveBeenCalledOnce();
482
+ expect(secondLoadMore).toHaveBeenCalledOnce();
483
+ });
484
+
485
+ it("loads again after the previous history request settles", async () => {
486
+ const loadMore = vi.fn();
487
+
488
+ await mountFlatList({
489
+ components: messageComponents,
490
+ history: { hasMore: true, isLoadingMore: false, loadMore },
491
+ });
492
+ getFlatListProps().onStartReached?.({ distanceFromStart: 0 });
493
+
494
+ await mountFlatList({
495
+ components: messageComponents,
496
+ history: { hasMore: true, isLoadingMore: true, loadMore },
497
+ });
498
+ await mountFlatList({
499
+ components: messageComponents,
500
+ history: { hasMore: true, isLoadingMore: false, loadMore },
501
+ });
502
+ getFlatListProps().onStartReached?.({ distanceFromStart: 0 });
503
+
504
+ expect(loadMore).toHaveBeenCalledTimes(2);
505
+ });
506
+
507
+ it("allows retrying when loadMore throws synchronously", async () => {
508
+ const loadError = new Error("load failed");
509
+ const loadMore = vi
510
+ .fn()
511
+ .mockImplementationOnce(() => {
512
+ throw loadError;
513
+ })
514
+ .mockImplementationOnce(() => undefined);
515
+
516
+ await mountFlatList({
517
+ components: messageComponents,
518
+ history: { hasMore: true, isLoadingMore: false, loadMore },
519
+ });
520
+ const onStartReached = getFlatListProps().onStartReached;
521
+
522
+ expect(() => onStartReached?.({ distanceFromStart: 0 })).toThrow(
523
+ loadError,
524
+ );
525
+ expect(() => onStartReached?.({ distanceFromStart: 0 })).not.toThrow();
526
+ expect(loadMore).toHaveBeenCalledTimes(2);
527
+ });
528
+
423
529
  it("defaults the history threshold and preserves a caller override", async () => {
424
530
  const history = {
425
531
  hasMore: true,
@@ -17,7 +17,8 @@ import {
17
17
  type NativeScrollEvent,
18
18
  type NativeSyntheticEvent,
19
19
  } from "react-native";
20
- import type { MessageState, ThreadMessage } from "@assistant-ui/core";
20
+ import type { ThreadMessage } from "@assistant-ui/core";
21
+ import type { MessageState } from "@assistant-ui/core/store";
21
22
  import {
22
23
  RenderChildrenWithAccessor,
23
24
  useAuiEvent,
@@ -82,6 +83,42 @@ export type ThreadMessagesProps = ThreadMessagesFlatListProps;
82
83
  const DEFAULT_SYSTEM_MESSAGE = () => null;
83
84
  const AT_BOTTOM_THRESHOLD = 4;
84
85
 
86
+ const useHistoryLoad = (
87
+ history: ThreadMessagesFlatListProps["history"],
88
+ onStartReached: FlatListProps<ThreadMessage>["onStartReached"],
89
+ ) => {
90
+ const loadRequestedRef = useRef(false);
91
+ const hasMore = history?.hasMore ?? false;
92
+ const isLoadingMore = history?.isLoadingMore ?? false;
93
+
94
+ // The latch is scoped to one commit so a no-op load cannot disable paging.
95
+ useEffect(() => {
96
+ loadRequestedRef.current = isLoadingMore;
97
+ });
98
+
99
+ const handleStartReached = useCallback<
100
+ NonNullable<FlatListProps<ThreadMessage>["onStartReached"]>
101
+ >(
102
+ (info) => {
103
+ onStartReached?.(info);
104
+ if (loadRequestedRef.current) return;
105
+ loadRequestedRef.current = true;
106
+ try {
107
+ history?.loadMore();
108
+ } catch (error) {
109
+ loadRequestedRef.current = false;
110
+ throw error;
111
+ }
112
+ },
113
+ [history, onStartReached],
114
+ );
115
+
116
+ return {
117
+ canLoadMore: hasMore && !isLoadingMore,
118
+ handleStartReached,
119
+ };
120
+ };
121
+
85
122
  const getComponent = (
86
123
  components: MessageComponents,
87
124
  role: ThreadMessage["role"],
@@ -495,16 +532,9 @@ export const ThreadMessagesFlatList = forwardRef<
495
532
  [handleAutoScrollContentSizeChange, onContentSizeChange],
496
533
  );
497
534
 
498
- const canLoadMore = history?.hasMore && !history.isLoadingMore;
499
-
500
- const handleStartReached = useCallback<
501
- NonNullable<FlatListProps<ThreadMessage>["onStartReached"]>
502
- >(
503
- (info) => {
504
- onStartReached?.(info);
505
- history?.loadMore();
506
- },
507
- [history, onStartReached],
535
+ const { canLoadMore, handleStartReached } = useHistoryLoad(
536
+ history,
537
+ onStartReached,
508
538
  );
509
539
 
510
540
  return (
@@ -6,6 +6,7 @@ import { ThreadListNew } from "./ThreadListNew";
6
6
  const h = vi.hoisted(() => ({
7
7
  switchToNewThread: vi.fn<() => void>(),
8
8
  state: { threads: { newThreadId: "new", mainThreadId: "other" } },
9
+ platform: { os: "web" },
9
10
  pressableProps: null as Record<string, unknown> | null,
10
11
  }));
11
12
 
@@ -41,7 +42,16 @@ vi.mock("react-native", async (importOriginal) => {
41
42
  );
42
43
  });
43
44
 
44
- return { ...actual, Pressable: PressableMock };
45
+ return {
46
+ ...actual,
47
+ Platform: {
48
+ ...actual.Platform,
49
+ get OS() {
50
+ return h.platform.os;
51
+ },
52
+ },
53
+ Pressable: PressableMock,
54
+ };
45
55
  });
46
56
 
47
57
  (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
@@ -58,6 +68,7 @@ describe("ThreadListNew", () => {
58
68
  beforeEach(() => {
59
69
  h.switchToNewThread.mockReset();
60
70
  h.state.threads = { newThreadId: "new", mainThreadId: "other" };
71
+ h.platform.os = "web";
61
72
  h.pressableProps = null;
62
73
 
63
74
  container = document.createElement("div");
@@ -110,39 +121,55 @@ describe("ThreadListNew", () => {
110
121
  expect(h.switchToNewThread).not.toHaveBeenCalled();
111
122
  });
112
123
 
113
- it("marks itself selected while the new thread is the current one", async () => {
124
+ it("marks itself current on the web while the new thread is current", async () => {
114
125
  h.state.threads = { newThreadId: "new", mainThreadId: "new" };
115
126
 
116
- await mount();
127
+ const el = await mount();
117
128
 
129
+ expect(el.getAttribute("aria-current")).toBe("true");
130
+ expect(el.hasAttribute("aria-selected")).toBe(false);
118
131
  expect(accessibilityState()).toMatchObject({ selected: true });
119
132
  });
120
133
 
121
- it("is not selected while another thread is current", async () => {
122
- await mount();
134
+ it("is not current while another thread is current", async () => {
135
+ const el = await mount();
123
136
 
137
+ expect(el.hasAttribute("aria-current")).toBe(false);
124
138
  expect(accessibilityState()).toMatchObject({ selected: false });
125
139
  });
126
140
 
141
+ it("marks itself selected without aria-current on native", async () => {
142
+ h.platform.os = "ios";
143
+ h.state.threads = { newThreadId: "new", mainThreadId: "new" };
144
+
145
+ const el = await mount();
146
+
147
+ expect(el.hasAttribute("aria-current")).toBe(false);
148
+ expect(accessibilityState()).toMatchObject({ selected: true });
149
+ });
150
+
127
151
  it("keeps other accessibility state the caller passes", async () => {
128
152
  h.state.threads = { newThreadId: "new", mainThreadId: "new" };
129
153
 
130
- await mount({ accessibilityState: { busy: true } });
154
+ const el = await mount({ accessibilityState: { busy: true } });
131
155
 
156
+ expect(el.getAttribute("aria-current")).toBe("true");
132
157
  expect(accessibilityState()).toMatchObject({ selected: true, busy: true });
133
158
  });
134
159
 
135
160
  it("lets the caller override the selected state", async () => {
136
161
  h.state.threads = { newThreadId: "new", mainThreadId: "new" };
137
162
 
138
- await mount({ accessibilityState: { selected: false } });
163
+ const el = await mount({ accessibilityState: { selected: false } });
139
164
 
165
+ expect(el.hasAttribute("aria-current")).toBe(false);
140
166
  expect(accessibilityState()).toMatchObject({ selected: false });
141
167
  });
142
168
 
143
169
  it("lets the caller select a control the state reports inactive", async () => {
144
- await mount({ accessibilityState: { selected: true } });
170
+ const el = await mount({ accessibilityState: { selected: true } });
145
171
 
172
+ expect(el.getAttribute("aria-current")).toBe("true");
146
173
  expect(accessibilityState()).toMatchObject({ selected: true });
147
174
  });
148
175
 
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import {
3
+ Platform,
3
4
  Pressable,
4
5
  type PressableProps,
5
6
  type PressableStateCallbackType,
@@ -27,12 +28,14 @@ export const ThreadListNew = ({
27
28
  (s) => s.threads.newThreadId === s.threads.mainThreadId,
28
29
  );
29
30
  const { switchToNewThread } = useThreadListNew();
31
+ const selected = accessibilityState?.selected ?? isActive;
30
32
 
31
33
  return (
32
34
  <Pressable
33
35
  onPress={switchToNewThread}
34
36
  accessibilityRole="button"
35
- accessibilityState={{ selected: isActive, ...accessibilityState }}
37
+ accessibilityState={{ ...accessibilityState, selected }}
38
+ aria-current={Platform.OS === "web" && selected ? "true" : undefined}
36
39
  {...pressableProps}
37
40
  >
38
41
  {typeof children === "function"
@@ -9,6 +9,7 @@ const h = vi.hoisted(() => ({
9
9
  threads: { mainThreadId: "thread-1" },
10
10
  threadListItem: { id: "thread-1" },
11
11
  },
12
+ platform: { os: "web" },
12
13
  pressableProps: null as Record<string, unknown> | null,
13
14
  }));
14
15
 
@@ -37,7 +38,16 @@ vi.mock("react-native", async (importOriginal) => {
37
38
  { ...props, ref },
38
39
  );
39
40
  });
40
- return { ...actual, Pressable: PressableMock };
41
+ return {
42
+ ...actual,
43
+ Platform: {
44
+ ...actual.Platform,
45
+ get OS() {
46
+ return h.platform.os;
47
+ },
48
+ },
49
+ Pressable: PressableMock,
50
+ };
41
51
  });
42
52
 
43
53
  (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
@@ -55,6 +65,7 @@ describe("ThreadListItemTrigger", () => {
55
65
  h.switchTo.mockReset();
56
66
  h.state.threads.mainThreadId = "thread-1";
57
67
  h.state.threadListItem.id = "thread-1";
68
+ h.platform.os = "web";
58
69
  h.pressableProps = null;
59
70
 
60
71
  container = document.createElement("div");
@@ -107,23 +118,38 @@ describe("ThreadListItemTrigger", () => {
107
118
  expect(h.switchTo).not.toHaveBeenCalled();
108
119
  });
109
120
 
110
- it("marks the current thread selected", async () => {
111
- await mount();
121
+ it("marks the current thread current on the web", async () => {
122
+ const el = await mount();
112
123
 
124
+ expect(el.getAttribute("aria-current")).toBe("true");
125
+ expect(el.hasAttribute("aria-selected")).toBe(false);
113
126
  expect(accessibilityState()).toMatchObject({ selected: true });
114
127
  });
115
128
 
116
- it("does not mark another thread selected", async () => {
129
+ it("does not mark another thread current", async () => {
117
130
  h.state.threads.mainThreadId = "thread-2";
118
131
 
119
- await mount();
132
+ const el = await mount();
120
133
 
134
+ expect(el.hasAttribute("aria-current")).toBe(false);
121
135
  expect(accessibilityState()).toMatchObject({ selected: false });
122
136
  });
123
137
 
138
+ it("marks the current thread selected without aria-current on native", async () => {
139
+ h.platform.os = "ios";
140
+
141
+ const el = await mount();
142
+
143
+ expect(el.hasAttribute("aria-current")).toBe(false);
144
+ expect(accessibilityState()).toMatchObject({ selected: true });
145
+ });
146
+
124
147
  it("keeps caller accessibility state overrides", async () => {
125
- await mount({ accessibilityState: { busy: true, selected: false } });
148
+ const el = await mount({
149
+ accessibilityState: { busy: true, selected: false },
150
+ });
126
151
 
152
+ expect(el.hasAttribute("aria-current")).toBe(false);
127
153
  expect(accessibilityState()).toMatchObject({ busy: true, selected: false });
128
154
  });
129
155
 
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import {
3
+ Platform,
3
4
  Pressable,
4
5
  type PressableProps,
5
6
  type PressableStateCallbackType,
@@ -27,12 +28,14 @@ export const ThreadListItemTrigger = ({
27
28
  (s) => s.threads.mainThreadId === s.threadListItem.id,
28
29
  );
29
30
  const { switchTo } = useThreadListItemTrigger();
31
+ const selected = accessibilityState?.selected ?? isActive;
30
32
 
31
33
  return (
32
34
  <Pressable
33
35
  onPress={switchTo}
34
36
  accessibilityRole="button"
35
- accessibilityState={{ selected: isActive, ...accessibilityState }}
37
+ accessibilityState={{ ...accessibilityState, selected }}
38
+ aria-current={Platform.OS === "web" && selected ? "true" : undefined}
36
39
  {...pressableProps}
37
40
  >
38
41
  {typeof children === "function"