@assistant-ui/react-native 0.1.45 → 0.1.47

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 (79) hide show
  1. package/dist/index.d.ts +13 -4
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -3
  4. package/dist/primitives/actionBar/ActionBarCopy.d.ts +2 -2
  5. package/dist/primitives/actionBar/ActionBarCopy.d.ts.map +1 -1
  6. package/dist/primitives/actionBar/ActionBarCopy.js +2 -1
  7. package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
  8. package/dist/primitives/actionBar/ActionBarFeedbackNegative.d.ts +2 -2
  9. package/dist/primitives/actionBar/ActionBarFeedbackNegative.d.ts.map +1 -1
  10. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +2 -1
  11. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
  12. package/dist/primitives/actionBar/ActionBarFeedbackPositive.d.ts +2 -2
  13. package/dist/primitives/actionBar/ActionBarFeedbackPositive.d.ts.map +1 -1
  14. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +2 -1
  15. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
  16. package/dist/primitives/composer/ComposerInput.d.ts +1 -1
  17. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  18. package/dist/primitives/composer/ComposerInput.js +55 -38
  19. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  20. package/dist/primitives/message/MessageContent.d.ts.map +1 -1
  21. package/dist/primitives/message/MessageContent.js +13 -10
  22. package/dist/primitives/message/MessageContent.js.map +1 -1
  23. package/dist/primitives/message/MessageParts.d.ts +1 -0
  24. package/dist/primitives/message/MessageParts.d.ts.map +1 -1
  25. package/dist/primitives/message/MessageParts.js +22 -3
  26. package/dist/primitives/message/MessageParts.js.map +1 -1
  27. package/dist/primitives/message.d.ts +1 -0
  28. package/dist/primitives/message.d.ts.map +1 -1
  29. package/dist/primitives/message.js +4 -3
  30. package/dist/primitives/message.js.map +1 -1
  31. package/dist/primitives/messagePart.js +1 -1
  32. package/dist/primitives/thread/ThreadMessages.d.ts +7 -5
  33. package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
  34. package/dist/primitives/thread/ThreadMessages.js +109 -376
  35. package/dist/primitives/thread/ThreadMessages.js.map +1 -1
  36. package/dist/primitives/thread/ThreadRowsFlatList.d.ts +29 -0
  37. package/dist/primitives/thread/ThreadRowsFlatList.d.ts.map +1 -0
  38. package/dist/primitives/thread/ThreadRowsFlatList.js +226 -0
  39. package/dist/primitives/thread/ThreadRowsFlatList.js.map +1 -0
  40. package/dist/primitives/thread/flatListScroll.d.ts +52 -0
  41. package/dist/primitives/thread/flatListScroll.d.ts.map +1 -0
  42. package/dist/primitives/thread/flatListScroll.js +380 -0
  43. package/dist/primitives/thread/flatListScroll.js.map +1 -0
  44. package/dist/primitives/thread.d.ts +2 -0
  45. package/dist/primitives/thread.d.ts.map +1 -1
  46. package/dist/primitives/thread.js +5 -2
  47. package/dist/primitives/thread.js.map +1 -1
  48. package/dist/primitives/threadListItem/ThreadListItemTitle.d.ts +10 -0
  49. package/dist/primitives/threadListItem/ThreadListItemTitle.d.ts.map +1 -0
  50. package/dist/primitives/threadListItem/ThreadListItemTitle.js +47 -0
  51. package/dist/primitives/threadListItem/ThreadListItemTitle.js.map +1 -0
  52. package/dist/primitives/threadListItem.d.ts +1 -1
  53. package/dist/primitives/threadListItem.d.ts.map +1 -1
  54. package/dist/primitives/threadListItem.js +3 -3
  55. package/dist/primitives/threadListItem.js.map +1 -1
  56. package/package.json +8 -7
  57. package/src/index.ts +22 -1
  58. package/src/primitives/actionBar/ActionBar.test.tsx +45 -0
  59. package/src/primitives/actionBar/ActionBarCopy.tsx +12 -3
  60. package/src/primitives/actionBar/ActionBarFeedbackNegative.tsx +13 -3
  61. package/src/primitives/actionBar/ActionBarFeedbackPositive.tsx +13 -3
  62. package/src/primitives/composer/ComposerInput.test.tsx +64 -4
  63. package/src/primitives/composer/ComposerInput.tsx +13 -2
  64. package/src/primitives/message/MessageContent.runtime.test.tsx +35 -0
  65. package/src/primitives/message/MessageContent.test.tsx +68 -4
  66. package/src/primitives/message/MessageContent.tsx +19 -9
  67. package/src/primitives/message/MessageParts.test.tsx +68 -1
  68. package/src/primitives/message/MessageParts.tsx +17 -1
  69. package/src/primitives/message.test.tsx +74 -0
  70. package/src/primitives/message.ts +1 -0
  71. package/src/primitives/thread/ThreadMessages.test.tsx +28 -1
  72. package/src/primitives/thread/ThreadMessages.tsx +49 -343
  73. package/src/primitives/thread/ThreadRowsFlatList.test.tsx +253 -0
  74. package/src/primitives/thread/ThreadRowsFlatList.tsx +159 -0
  75. package/src/primitives/thread/flatListScroll.ts +467 -0
  76. package/src/primitives/thread.ts +5 -0
  77. package/src/primitives/threadListItem/ThreadListItemTitle.test.tsx +164 -0
  78. package/src/primitives/threadListItem/ThreadListItemTitle.tsx +23 -0
  79. package/src/primitives/threadListItem.ts +4 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-native",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "description": "React Native bindings for assistant-ui",
5
5
  "keywords": [
6
6
  "assistant",
@@ -31,11 +31,11 @@
31
31
  ],
32
32
  "sideEffects": false,
33
33
  "dependencies": {
34
- "@assistant-ui/core": "^0.3.23",
35
- "@assistant-ui/store": "^0.3.17",
36
- "@assistant-ui/tap": "^0.9.21",
37
- "assistant-cloud": "^0.2.5",
38
- "assistant-stream": "^0.3.47"
34
+ "@assistant-ui/core": "^0.3.25",
35
+ "@assistant-ui/store": "^0.3.18",
36
+ "@assistant-ui/tap": "^0.9.22",
37
+ "assistant-cloud": "^0.2.6",
38
+ "assistant-stream": "^0.3.49"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@types/react": "*",
@@ -53,7 +53,7 @@
53
53
  "@babel/core": "^7.29.7",
54
54
  "@types/react": "^19.3.0",
55
55
  "@types/react-dom": "^19.3.0",
56
- "jsdom": "^30.0.1",
56
+ "jsdom": "^30.1.2",
57
57
  "react": "^19.3.0",
58
58
  "react-dom": "^19.3.0",
59
59
  "react-native": "^0.87.1",
@@ -76,6 +76,7 @@
76
76
  "scripts": {
77
77
  "build": "aui-build",
78
78
  "test": "vitest run",
79
+ "test:coverage": "vitest run --coverage --coverage.reporter=text --coverage.reporter=html --coverage.reporter=json-summary",
79
80
  "typecheck": "tsc --noEmit",
80
81
  "test:peer-react18": "vitest run --config vitest.peer-react18.config.ts",
81
82
  "test:types:peer-react18": "aui-react18-types",
package/src/index.ts CHANGED
@@ -20,9 +20,13 @@ export type {
20
20
  ToolApprovalDisplay,
21
21
  ToolApprovalOption,
22
22
  ToolApprovalOptionKind,
23
+ ToolApprovalAnswer,
24
+ ToolApprovalQuestion,
25
+ ToolApprovalQuestionOption,
23
26
  ToolApprovalResponse,
24
27
  ToolCallMessagePart,
25
28
  ToolCallMessagePartStatus,
29
+ MessagePartTiming,
26
30
  ToolCallTiming,
27
31
  ToolModelContentPart,
28
32
  ImageMessagePart,
@@ -156,9 +160,13 @@ export {
156
160
  useExternalStoreRuntime,
157
161
  useExternalStoreSharedOptions,
158
162
  useExternalMessageConverter,
163
+ /** @deprecated Experimental since 2025-01-26. Not scheduled for removal; the API may change in any release. */
159
164
  convertExternalMessages as unstable_convertExternalMessages,
165
+ /** @deprecated Experimental since 2026-09-15. Not scheduled for removal; the API may change in any release. */
160
166
  createExternalMessageConversionCache as unstable_createExternalMessageConversionCache,
167
+ /** @deprecated Experimental since 2025-02-02. Not scheduled for removal; the API may change in any release. */
161
168
  createMessageConverter as unstable_createMessageConverter,
169
+ /** @deprecated Experimental since 2026-09-15. Not scheduled for removal; the API may change in any release. */
162
170
  type ExternalMessageConversionCache as Unstable_ExternalMessageConversionCache,
163
171
  type JoinStrategy,
164
172
  } from "@assistant-ui/core/react";
@@ -179,6 +187,11 @@ export * as SuggestionPrimitive from "./primitives/suggestion";
179
187
  export * as ErrorPrimitive from "./primitives/error";
180
188
 
181
189
  export { groupPartByType, type GroupByContext } from "@assistant-ui/core/react";
190
+ export {
191
+ createThreadRowsSelector,
192
+ type ThreadRow,
193
+ type ThreadRowsOptions,
194
+ } from "@assistant-ui/core/react";
182
195
  export { unstable_useThreadMessageIds } from "@assistant-ui/core/react";
183
196
 
184
197
  // Re-export shared providers from core/react
@@ -404,9 +417,17 @@ export type {
404
417
  ThreadSuggestion,
405
418
  ToolCallMessagePartMcpMetadata,
406
419
  ToolExecutionStatus,
420
+ DirectiveFormatter,
421
+ DirectiveSegment,
422
+ TriggerAdapter,
423
+ TriggerCategory,
424
+ TriggerItem,
407
425
  Unstable_DirectiveFormatter,
408
426
  Unstable_DirectiveSegment,
409
427
  Unstable_TriggerItem,
410
428
  VoiceSessionState,
411
429
  } from "@assistant-ui/core";
412
- export { unstable_defaultDirectiveFormatter } from "@assistant-ui/core";
430
+ export {
431
+ defaultDirectiveFormatter,
432
+ unstable_defaultDirectiveFormatter,
433
+ } from "@assistant-ui/core";
@@ -1,5 +1,6 @@
1
1
  import { act, type ReactNode } from "react";
2
2
  import { createRoot, type Root } from "react-dom/client";
3
+ import { Text } from "react-native";
3
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
5
  import { ActionBarCopy } from "./ActionBarCopy";
5
6
  import { ActionBarEdit } from "./ActionBarEdit";
@@ -222,6 +223,50 @@ describe("ActionBar", () => {
222
223
  return container.querySelector('[data-testid="t"]') as HTMLElement;
223
224
  };
224
225
 
226
+ const assertRenderState = async <TState extends object>(
227
+ render: (children: (state: TState) => ReactNode) => ReactNode,
228
+ expectedState: TState,
229
+ ) => {
230
+ let renderState: unknown;
231
+ await mount(
232
+ render((state) => {
233
+ renderState = state;
234
+ return <Text>label</Text>;
235
+ }),
236
+ );
237
+
238
+ expect(renderState).toMatchObject(expectedState);
239
+ };
240
+
241
+ it("ActionBarCopy passes Pressable state alongside isCopied", async () => {
242
+ await assertRenderState(
243
+ (children) => <ActionBarCopy testID="t">{children}</ActionBarCopy>,
244
+ { pressed: false, disabled: false, isCopied: false },
245
+ );
246
+ });
247
+
248
+ it("ActionBarFeedbackPositive passes Pressable state alongside isSubmitted", async () => {
249
+ await assertRenderState(
250
+ (children) => (
251
+ <ActionBarFeedbackPositive testID="t">
252
+ {children}
253
+ </ActionBarFeedbackPositive>
254
+ ),
255
+ { pressed: false, disabled: false, isSubmitted: false },
256
+ );
257
+ });
258
+
259
+ it("ActionBarFeedbackNegative passes Pressable state alongside isSubmitted", async () => {
260
+ await assertRenderState(
261
+ (children) => (
262
+ <ActionBarFeedbackNegative testID="t">
263
+ {children}
264
+ </ActionBarFeedbackNegative>
265
+ ),
266
+ { pressed: false, disabled: false, isSubmitted: false },
267
+ );
268
+ });
269
+
225
270
  for (const actionBar of actionBars) {
226
271
  describe(actionBar.name, () => {
227
272
  it("passes the effective disabled state to render children", async () => {
@@ -1,5 +1,9 @@
1
1
  import type { ReactNode } from "react";
2
- import { Pressable, type PressableProps } from "react-native";
2
+ import {
3
+ Pressable,
4
+ type PressableProps,
5
+ type PressableStateCallbackType,
6
+ } from "react-native";
3
7
  import {
4
8
  useActionBarCopy,
5
9
  type UseActionBarCopyOptions,
@@ -9,7 +13,12 @@ export type ActionBarCopyProps = Omit<PressableProps, "onPress" | "children"> &
9
13
  UseActionBarCopyOptions & {
10
14
  children:
11
15
  | ReactNode
12
- | ((props: { isCopied: boolean; disabled: boolean }) => ReactNode);
16
+ | ((
17
+ props: PressableStateCallbackType & {
18
+ isCopied: boolean;
19
+ disabled: boolean;
20
+ },
21
+ ) => ReactNode);
13
22
  };
14
23
 
15
24
  export const ActionBarCopy = ({
@@ -33,7 +42,7 @@ export const ActionBarCopy = ({
33
42
  {...pressableProps}
34
43
  >
35
44
  {typeof children === "function"
36
- ? children({ isCopied, disabled: isDisabled })
45
+ ? (state) => children({ ...state, isCopied, disabled: isDisabled })
37
46
  : children}
38
47
  </Pressable>
39
48
  );
@@ -1,5 +1,10 @@
1
1
  import type { ReactNode } from "react";
2
- import { Platform, Pressable, type PressableProps } from "react-native";
2
+ import {
3
+ Platform,
4
+ Pressable,
5
+ type PressableProps,
6
+ type PressableStateCallbackType,
7
+ } from "react-native";
3
8
  import { useActionBarFeedbackNegative } from "@assistant-ui/core/react";
4
9
 
5
10
  export type ActionBarFeedbackNegativeProps = Omit<
@@ -8,7 +13,12 @@ export type ActionBarFeedbackNegativeProps = Omit<
8
13
  > & {
9
14
  children:
10
15
  | ReactNode
11
- | ((props: { isSubmitted: boolean; disabled: boolean }) => ReactNode);
16
+ | ((
17
+ props: PressableStateCallbackType & {
18
+ isSubmitted: boolean;
19
+ disabled: boolean;
20
+ },
21
+ ) => ReactNode);
12
22
  };
13
23
 
14
24
  export const ActionBarFeedbackNegative = ({
@@ -30,7 +40,7 @@ export const ActionBarFeedbackNegative = ({
30
40
  {...pressableProps}
31
41
  >
32
42
  {typeof children === "function"
33
- ? children({ isSubmitted, disabled })
43
+ ? (state) => children({ ...state, isSubmitted, disabled })
34
44
  : children}
35
45
  </Pressable>
36
46
  );
@@ -1,5 +1,10 @@
1
1
  import type { ReactNode } from "react";
2
- import { Platform, Pressable, type PressableProps } from "react-native";
2
+ import {
3
+ Platform,
4
+ Pressable,
5
+ type PressableProps,
6
+ type PressableStateCallbackType,
7
+ } from "react-native";
3
8
  import { useActionBarFeedbackPositive } from "@assistant-ui/core/react";
4
9
 
5
10
  export type ActionBarFeedbackPositiveProps = Omit<
@@ -8,7 +13,12 @@ export type ActionBarFeedbackPositiveProps = Omit<
8
13
  > & {
9
14
  children:
10
15
  | ReactNode
11
- | ((props: { isSubmitted: boolean; disabled: boolean }) => ReactNode);
16
+ | ((
17
+ props: PressableStateCallbackType & {
18
+ isSubmitted: boolean;
19
+ disabled: boolean;
20
+ },
21
+ ) => ReactNode);
12
22
  };
13
23
 
14
24
  export const ActionBarFeedbackPositive = ({
@@ -30,7 +40,7 @@ export const ActionBarFeedbackPositive = ({
30
40
  {...pressableProps}
31
41
  >
32
42
  {typeof children === "function"
33
- ? children({ isSubmitted, disabled })
43
+ ? (state) => children({ ...state, isSubmitted, disabled })
34
44
  : children}
35
45
  </Pressable>
36
46
  );
@@ -8,7 +8,12 @@ const h = vi.hoisted(() => ({
8
8
  sendSpy: vi.fn<() => void>(),
9
9
  flushTapSyncSpy: vi.fn(<T,>(fn: () => T) => fn()),
10
10
  composerState: { text: "" },
11
- threadState: { isRunning: false, queue: false, voice: false },
11
+ threadState: {
12
+ isRunning: false,
13
+ queue: false,
14
+ voice: false,
15
+ isDisabled: false,
16
+ },
12
17
  platform: { os: "web" as "web" | "ios" | "android" },
13
18
  }));
14
19
 
@@ -28,12 +33,20 @@ vi.mock("@assistant-ui/store", () => {
28
33
  return {
29
34
  useAui: () => aui,
30
35
  useAuiState: <T,>(
31
- selector: (s: { composer: typeof h.composerState }) => T,
32
- ) => selector({ composer: h.composerState }),
36
+ selector: (s: {
37
+ composer: typeof h.composerState;
38
+ thread: { isDisabled: boolean };
39
+ }) => T,
40
+ ) =>
41
+ selector({
42
+ composer: h.composerState,
43
+ thread: { isDisabled: h.threadState.isDisabled },
44
+ }),
33
45
  };
34
46
  });
35
47
 
36
- vi.mock("@assistant-ui/tap", () => ({
48
+ vi.mock("@assistant-ui/tap", async (importOriginal) => ({
49
+ ...(await importOriginal<typeof import("@assistant-ui/tap")>()),
37
50
  flushTapSync: h.flushTapSyncSpy,
38
51
  }));
39
52
 
@@ -111,6 +124,7 @@ describe("ComposerInput", () => {
111
124
  h.threadState.isRunning = false;
112
125
  h.threadState.queue = false;
113
126
  h.threadState.voice = false;
127
+ h.threadState.isDisabled = false;
114
128
  h.platform.os = "web";
115
129
 
116
130
  container = document.createElement("div");
@@ -421,4 +435,50 @@ describe("ComposerInput", () => {
421
435
  expect(onKeyPress).toHaveBeenCalledTimes(1);
422
436
  });
423
437
  });
438
+
439
+ describe("disabled thread", () => {
440
+ beforeEach(() => {
441
+ h.threadState.isDisabled = true;
442
+ });
443
+
444
+ it("makes the input non-editable", async () => {
445
+ const input = await mount();
446
+ expect(input.disabled || input.readOnly).toBe(true);
447
+ });
448
+
449
+ it("does not let editable props override the disabled thread", async () => {
450
+ const input = await mount({ editable: true, readOnly: false });
451
+ expect(input.readOnly).toBe(true);
452
+ });
453
+
454
+ it("ignores text changes", async () => {
455
+ const input = await mount();
456
+ await act(async () => fireInput(input, "hello"));
457
+ expect(h.setText).not.toHaveBeenCalled();
458
+ });
459
+
460
+ it("does not submit on Enter", async () => {
461
+ const input = await mount();
462
+ await act(async () => fireKeyDown(input, { key: "Enter" }));
463
+ expect(h.sendSpy).not.toHaveBeenCalled();
464
+ });
465
+
466
+ it("forwards caller key handlers while blocking submission", async () => {
467
+ const onKeyPress = vi.fn();
468
+ const input = await mount({ onKeyPress });
469
+
470
+ await act(async () => {
471
+ fireKeyDown(input, { key: "Escape" });
472
+ fireKeyDown(input, { key: "Enter" });
473
+ });
474
+
475
+ expect(onKeyPress).toHaveBeenCalledTimes(2);
476
+ expect(h.sendSpy).not.toHaveBeenCalled();
477
+ });
478
+ });
479
+
480
+ it("preserves a caller-provided non-editable setting", async () => {
481
+ const input = await mount({ editable: false });
482
+ expect(input.readOnly).toBe(true);
483
+ });
424
484
  });
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
  import { Platform, TextInput, type TextInputProps } from "react-native";
3
+ import { composerInputDisabled } from "@assistant-ui/core/store/internal";
3
4
  import { useAui, useAuiState } from "@assistant-ui/store";
4
5
  import { flushTapSync } from "@assistant-ui/tap";
5
6
 
@@ -59,14 +60,19 @@ export const ComposerInput = ({
59
60
  onKeyPress: onKeyPressProp,
60
61
  numberOfLines,
61
62
  style,
63
+ editable,
64
+ readOnly,
62
65
  ...props
63
66
  }: ComposerInputProps) => {
64
67
  const aui = useAui();
65
68
  const text = useAuiState((s) => s.composer.text);
69
+ const isInputDisabled = useAuiState(composerInputDisabled);
66
70
  const inputRef = useRef<TextInputInstance>(null);
67
71
 
68
72
  const onChangeText = useCallback(
69
73
  (value: string) => {
74
+ if (isInputDisabled) return;
75
+
70
76
  if (Platform.OS === "web") {
71
77
  // Keep the controlled value in sync mid-IME so react-dom does not reset the textarea to a stale value
72
78
  flushTapSync(() => {
@@ -76,7 +82,7 @@ export const ComposerInput = ({
76
82
  }
77
83
  aui.composer.setText(value);
78
84
  },
79
- [aui],
85
+ [aui, isInputDisabled],
80
86
  );
81
87
 
82
88
  // Auto-resize textarea on web when text changes
@@ -89,6 +95,7 @@ export const ComposerInput = ({
89
95
  (e: TextInputKeyPressEvent) => {
90
96
  onKeyPressProp?.(e);
91
97
  if (e.isDefaultPrevented()) return;
98
+ if (isInputDisabled) return;
92
99
 
93
100
  if (Platform.OS !== "web") return;
94
101
  if (submitMode !== "enter") return;
@@ -113,9 +120,11 @@ export const ComposerInput = ({
113
120
  aui.composer.send();
114
121
  }
115
122
  },
116
- [aui, submitMode, onKeyPressProp],
123
+ [aui, isInputDisabled, submitMode, onKeyPressProp],
117
124
  );
118
125
 
126
+ const isReadOnly = isInputDisabled || editable === false || readOnly === true;
127
+
119
128
  return (
120
129
  <TextInput
121
130
  ref={inputRef}
@@ -123,6 +132,8 @@ export const ComposerInput = ({
123
132
  onChangeText={onChangeText}
124
133
  onKeyPress={onKeyPress}
125
134
  numberOfLines={numberOfLines ?? (Platform.OS === "web" ? 1 : undefined)}
135
+ editable={editable}
136
+ readOnly={isReadOnly}
126
137
  style={style}
127
138
  {...props}
128
139
  />
@@ -14,6 +14,41 @@ import { MessageContent } from "./MessageContent";
14
14
  (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
15
15
 
16
16
  describe("MessageContent with a runtime", () => {
17
+ it("renders a default image after a text part", async () => {
18
+ const image = "data:image/png;base64,iVBORw0KGgo=";
19
+ const App = () => {
20
+ const runtime = useExternalStoreRuntime({
21
+ messages: [
22
+ {
23
+ role: "assistant",
24
+ content: [
25
+ { type: "text", text: "lead" },
26
+ { type: "image", image },
27
+ ],
28
+ },
29
+ ] satisfies ThreadMessageLike[],
30
+ convertMessage: (value) => value,
31
+ onNew: async () => {},
32
+ });
33
+ return (
34
+ <AssistantRuntimeProvider runtime={runtime}>
35
+ <MessageByIndexProvider index={0}>
36
+ <MessageContent />
37
+ </MessageByIndexProvider>
38
+ </AssistantRuntimeProvider>
39
+ );
40
+ };
41
+ const container = document.createElement("div");
42
+ const root = createRoot(container);
43
+ try {
44
+ await act(async () => root.render(<App />));
45
+ expect(container.textContent).toContain("lead");
46
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(image);
47
+ } finally {
48
+ await act(async () => root.unmount());
49
+ }
50
+ });
51
+
17
52
  it.each(["tool-call", "data"] as const)(
18
53
  "renders derived status for registered %s UIs",
19
54
  async (type) => {
@@ -17,7 +17,10 @@ const h = vi.hoisted(() => ({
17
17
  return this.content;
18
18
  },
19
19
  },
20
- tools: { toolUIs: {} as Record<string, unknown> },
20
+ tools: {
21
+ toolUIs: {} as Record<string, unknown>,
22
+ mcpApp: undefined as { render: unknown } | undefined,
23
+ },
21
24
  dataRenderers: {
22
25
  renderers: {} as Record<string, unknown>,
23
26
  fallbacks: [] as unknown[],
@@ -25,7 +28,8 @@ const h = vi.hoisted(() => ({
25
28
  },
26
29
  }));
27
30
 
28
- vi.mock("@assistant-ui/store", () => {
31
+ vi.mock("@assistant-ui/store", async (importOriginal) => {
32
+ const actual = await importOriginal<typeof import("@assistant-ui/store")>();
29
33
  const message = Object.assign(() => message, {
30
34
  part: ({ index }: { index: number }) => ({
31
35
  addToolResult: (...args: unknown[]) => h.addToolResult(index, ...args),
@@ -38,6 +42,7 @@ vi.mock("@assistant-ui/store", () => {
38
42
  });
39
43
  const aui = { message };
40
44
  return {
45
+ ...actual,
41
46
  useAui: () => aui,
42
47
  useAuiState: <T,>(selector: (s: typeof h.state) => T) => selector(h.state),
43
48
  };
@@ -56,6 +61,7 @@ describe("MessageContent", () => {
56
61
  h.unstable_recordInteraction.mockReset();
57
62
  h.state.message.content = [];
58
63
  h.state.tools.toolUIs = {};
64
+ h.state.tools.mcpApp = undefined;
59
65
  h.state.dataRenderers.renderers = {};
60
66
  h.state.dataRenderers.fallbacks = [];
61
67
 
@@ -153,9 +159,8 @@ describe("MessageContent", () => {
153
159
  expect(container.textContent).toBe("");
154
160
  });
155
161
 
156
- it("renders null for optional parts when no renderer is provided", async () => {
162
+ it("renders null for reasoning, source, and file without renderers", async () => {
157
163
  h.state.message.content = [
158
- { type: "image", image: "x" },
159
164
  { type: "reasoning", text: "r" },
160
165
  { type: "source", sourceType: "url", id: "1", url: "u" },
161
166
  { type: "file", filename: "f" },
@@ -186,6 +191,7 @@ describe("MessageContent", () => {
186
191
  await mount({ renderImage, renderReasoning, renderSource, renderFile });
187
192
 
188
193
  expect(container.textContent).toBe("image-0reasoning-1source-2file-3");
194
+ expect(container.querySelector("img")).toBeNull();
189
195
  expect(renderImage).toHaveBeenCalledWith({
190
196
  part: h.state.message.content[0],
191
197
  index: 0,
@@ -262,6 +268,64 @@ describe("MessageContent", () => {
262
268
  });
263
269
  });
264
270
 
271
+ it("renders tools.mcpApp for a tool call with a ui:// resource", async () => {
272
+ const Mcp = vi.fn(() => <span data-testid="mcp">mcp</span>);
273
+ h.state.message.content = [
274
+ {
275
+ type: "tool-call",
276
+ toolName: "show_chart",
277
+ toolCallId: "c1",
278
+ mcp: { app: { resourceUri: "ui://chart" } },
279
+ },
280
+ ];
281
+ h.state.tools.mcpApp = { render: Mcp };
282
+ await mount();
283
+ expect(container.querySelector('[data-testid="mcp"]')?.textContent).toBe(
284
+ "mcp",
285
+ );
286
+ });
287
+
288
+ it("prefers a named tool UI over tools.mcpApp", async () => {
289
+ const NamedTool = vi.fn(() => <span data-testid="named">named</span>);
290
+ const Mcp = vi.fn(() => <span data-testid="mcp">mcp</span>);
291
+ h.state.message.content = [
292
+ {
293
+ type: "tool-call",
294
+ toolName: "show_chart",
295
+ toolCallId: "c1",
296
+ mcp: { app: { resourceUri: "ui://chart" } },
297
+ },
298
+ ];
299
+ h.state.tools.toolUIs = { show_chart: [{ render: NamedTool }] };
300
+ h.state.tools.mcpApp = { render: Mcp };
301
+ await mount();
302
+ expect(container.querySelector('[data-testid="named"]')).not.toBeNull();
303
+ expect(Mcp).not.toHaveBeenCalled();
304
+ });
305
+
306
+ it("does not use tools.mcpApp when the resource URI is not ui://", async () => {
307
+ const Mcp = vi.fn(() => <span data-testid="mcp">mcp</span>);
308
+ h.state.message.content = [
309
+ {
310
+ type: "tool-call",
311
+ toolName: "show_chart",
312
+ toolCallId: "c1",
313
+ mcp: { app: { resourceUri: "https://example.com/chart" } },
314
+ },
315
+ ];
316
+ h.state.tools.mcpApp = { render: Mcp };
317
+ const renderToolCall = vi.fn(({ part, index }): ReactElement => (
318
+ <span data-testid="fallback">
319
+ fallback:{String(part.toolName)}:{index}
320
+ </span>
321
+ ));
322
+ await mount({ renderToolCall });
323
+ expect(
324
+ container.querySelector('[data-testid="fallback"]'),
325
+ ).not.toBeNull();
326
+ expect(Mcp).not.toHaveBeenCalled();
327
+ });
328
+
265
329
  it("renders null when no renderer is registered and no fallback is given", async () => {
266
330
  h.state.message.content = [
267
331
  { type: "tool-call", toolName: "search", toolCallId: "c1" },
@@ -1,16 +1,21 @@
1
1
  import { type ReactElement, Fragment, useMemo } from "react";
2
2
  import { Text } from "react-native";
3
- import { getMessagePartKeys } from "@assistant-ui/core/internal";
4
3
  import type {
5
4
  ThreadUserMessagePart,
6
5
  ThreadAssistantMessagePart,
7
6
  MessagePartState,
8
7
  } from "@assistant-ui/core";
9
8
  import { useAui, useAuiState } from "@assistant-ui/store";
10
- import type {
11
- ToolCallMessagePartProps,
12
- DataMessagePartProps,
9
+ import {
10
+ PartByIndexProvider,
11
+ type ToolCallMessagePartProps,
12
+ type DataMessagePartProps,
13
13
  } from "@assistant-ui/core/react";
14
+ import { DefaultMessageImage } from "./MessageParts";
15
+ import {
16
+ getMessagePartKeys,
17
+ resolveToolRender,
18
+ } from "@assistant-ui/core/internal";
14
19
 
15
20
  type MessageContentPart = ThreadUserMessagePart | ThreadAssistantMessagePart;
16
21
  type MessageContentStatePart = MessagePartState;
@@ -69,9 +74,7 @@ const ToolUIDisplay = ({
69
74
  index: number;
70
75
  }) => {
71
76
  const aui = useAui();
72
- const Render = useAuiState(
73
- (s) => s.tools.toolUIs[part.toolName]?.[0]?.render,
74
- );
77
+ const Render = useAuiState((s) => resolveToolRender(s.tools, part));
75
78
 
76
79
  const partMethods = useMemo(() => aui.message.part({ index }), [aui, index]);
77
80
 
@@ -153,9 +156,16 @@ export const MessageContent = ({
153
156
  </Fragment>
154
157
  );
155
158
  case "image":
156
- if (!renderImage) return null;
157
159
  return (
158
- <Fragment key={key}>{renderImage({ part, index })}</Fragment>
160
+ <Fragment key={key}>
161
+ {renderImage ? (
162
+ renderImage({ part, index })
163
+ ) : (
164
+ <PartByIndexProvider index={index}>
165
+ <DefaultMessageImage />
166
+ </PartByIndexProvider>
167
+ )}
168
+ </Fragment>
159
169
  );
160
170
  case "reasoning":
161
171
  if (!renderReasoning) return null;