@assistant-ui/react 0.15.22 → 0.15.24

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 (162) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +44 -23
  3. package/dist/assistant-transport.js.map +1 -1
  4. package/dist/cloud-renderer/CloudRendererHost.js.map +1 -1
  5. package/dist/context/providers/MessageProvider.js.map +1 -1
  6. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  7. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  8. package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
  9. package/dist/mcp-apps/app-frame.js.map +1 -1
  10. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
  11. package/dist/model-context/makeAssistantVisible.js.map +1 -1
  12. package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
  13. package/dist/primitives/actionBar/ActionBarEdit.js.map +1 -1
  14. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  15. package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
  16. package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
  17. package/dist/primitives/actionBar/ActionBarReload.js.map +1 -1
  18. package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
  19. package/dist/primitives/actionBar/ActionBarSpeak.js.map +1 -1
  20. package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
  21. package/dist/primitives/actionBar/useActionBarFloatStatus.js.map +1 -1
  22. package/dist/primitives/actionBarMore/ActionBarMoreContent.js.map +1 -1
  23. package/dist/primitives/actionBarMore/ActionBarMoreItem.js.map +1 -1
  24. package/dist/primitives/actionBarMore/ActionBarMoreRoot.js.map +1 -1
  25. package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js.map +1 -1
  26. package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js.map +1 -1
  27. package/dist/primitives/actionBarMore/scope.js.map +1 -1
  28. package/dist/primitives/assistantModal/AssistantModalAnchor.js.map +1 -1
  29. package/dist/primitives/assistantModal/AssistantModalContent.js.map +1 -1
  30. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  31. package/dist/primitives/assistantModal/AssistantModalTrigger.js.map +1 -1
  32. package/dist/primitives/assistantModal/popoverRenderPrimitives.js.map +1 -1
  33. package/dist/primitives/assistantModal/scope.js.map +1 -1
  34. package/dist/primitives/attachment/AttachmentName.js.map +1 -1
  35. package/dist/primitives/attachment/AttachmentRemove.js.map +1 -1
  36. package/dist/primitives/attachment/AttachmentRoot.js.map +1 -1
  37. package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
  38. package/dist/primitives/branchPicker/BranchPickerCount.js.map +1 -1
  39. package/dist/primitives/branchPicker/BranchPickerNext.js.map +1 -1
  40. package/dist/primitives/branchPicker/BranchPickerNumber.js.map +1 -1
  41. package/dist/primitives/branchPicker/BranchPickerPrevious.js.map +1 -1
  42. package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
  43. package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
  44. package/dist/primitives/chainOfThought/ChainOfThoughtRoot.js.map +1 -1
  45. package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
  46. package/dist/primitives/composer/ComposerCancel.js.map +1 -1
  47. package/dist/primitives/composer/ComposerDictate.js.map +1 -1
  48. package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
  49. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  50. package/dist/primitives/composer/ComposerInput.js +3 -2
  51. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  52. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  53. package/dist/primitives/composer/ComposerQuote.js.map +1 -1
  54. package/dist/primitives/composer/ComposerRoot.js.map +1 -1
  55. package/dist/primitives/composer/ComposerSend.js.map +1 -1
  56. package/dist/primitives/composer/ComposerStopDictation.js.map +1 -1
  57. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  58. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  59. package/dist/primitives/composer/trigger/TriggerPopoverBack.js.map +1 -1
  60. package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
  61. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  62. package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
  63. package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
  64. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  65. package/dist/primitives/composer/trigger/index.js.map +1 -1
  66. package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
  67. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  68. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  69. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  70. package/dist/primitives/composer/useComposerInputState.js.map +1 -1
  71. package/dist/primitives/dropdownMenuRenderPrimitives.js.map +1 -1
  72. package/dist/primitives/error/ErrorMessage.js.map +1 -1
  73. package/dist/primitives/error/ErrorRoot.js.map +1 -1
  74. package/dist/primitives/message/MessageIf.d.ts.map +1 -1
  75. package/dist/primitives/message/MessageIf.js +6 -3
  76. package/dist/primitives/message/MessageIf.js.map +1 -1
  77. package/dist/primitives/message/MessageParts.js.map +1 -1
  78. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  79. package/dist/primitives/message/MessagePartsGrouped.js +42 -17
  80. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  81. package/dist/primitives/message/MessageRoot.js.map +1 -1
  82. package/dist/primitives/messagePart/MessagePartImage.js.map +1 -1
  83. package/dist/primitives/queueItem/QueueItemRemove.js.map +1 -1
  84. package/dist/primitives/queueItem/QueueItemSteer.js.map +1 -1
  85. package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
  86. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  87. package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
  88. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  89. package/dist/primitives/suggestion/SuggestionDescription.js.map +1 -1
  90. package/dist/primitives/suggestion/SuggestionTitle.js.map +1 -1
  91. package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
  92. package/dist/primitives/thread/ThreadIf.js.map +1 -1
  93. package/dist/primitives/thread/ThreadRoot.d.ts +3 -2
  94. package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
  95. package/dist/primitives/thread/ThreadRoot.js +49 -27
  96. package/dist/primitives/thread/ThreadRoot.js.map +1 -1
  97. package/dist/primitives/thread/ThreadScrollToBottom.js.map +1 -1
  98. package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
  99. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  100. package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
  101. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
  102. package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -1
  103. package/dist/primitives/threadList/ThreadListNew.js.map +1 -1
  104. package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
  105. package/dist/primitives/threadListItem/ThreadListItemArchive.js.map +1 -1
  106. package/dist/primitives/threadListItem/ThreadListItemDelete.js.map +1 -1
  107. package/dist/primitives/threadListItem/ThreadListItemRoot.d.ts.map +1 -1
  108. package/dist/primitives/threadListItem/ThreadListItemRoot.js +1 -0
  109. package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
  110. package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
  111. package/dist/primitives/threadListItem/ThreadListItemUnarchive.js.map +1 -1
  112. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.d.ts.map +1 -1
  113. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +1 -0
  114. package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
  115. package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js.map +1 -1
  116. package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
  117. package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js.map +1 -1
  118. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.d.ts.map +1 -1
  119. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +1 -0
  120. package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
  121. package/dist/primitives/threadListItemMore/scope.js.map +1 -1
  122. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  123. package/dist/unstable/useModelContextSnapshot.js.map +1 -1
  124. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +1 -0
  125. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -1
  126. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +27 -39
  127. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -1
  128. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  129. package/dist/unstable/webmcp/useWebMcpProvider.js +22 -13
  130. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  131. package/dist/utils/Primitive.js.map +1 -1
  132. package/dist/utils/createActionButton.js.map +1 -1
  133. package/dist/utils/hooks/useManagedRef.js.map +1 -1
  134. package/dist/utils/hooks/useMediaQuery.js.map +1 -1
  135. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  136. package/dist/utils/hooks/useOnScrollToBottom.js.map +1 -1
  137. package/dist/utils/hooks/useSizeHandle.js.map +1 -1
  138. package/dist/utils/isCompositionKey.d.ts +2 -0
  139. package/dist/utils/isCompositionKey.d.ts.map +1 -0
  140. package/dist/utils/isCompositionKey.js +6 -0
  141. package/dist/utils/isCompositionKey.js.map +1 -0
  142. package/dist/utils/smooth/SmoothContext.js.map +1 -1
  143. package/package.json +15 -12
  144. package/src/primitives/composer/ComposerInput.test.tsx +33 -1
  145. package/src/primitives/composer/ComposerInput.tsx +3 -2
  146. package/src/primitives/message/MessageIf.test.tsx +72 -0
  147. package/src/primitives/message/MessageIf.ts +8 -3
  148. package/src/primitives/message/MessagePartsGrouped.test.tsx +271 -1
  149. package/src/primitives/message/MessagePartsGrouped.tsx +14 -6
  150. package/src/primitives/thread/ThreadRoot.test.tsx +163 -0
  151. package/src/primitives/thread/ThreadRoot.tsx +33 -3
  152. package/src/primitives/threadListItem/ThreadListItemRoot.tsx +2 -0
  153. package/src/primitives/threadListItem/ThreadListKeyboardNav.test.tsx +65 -0
  154. package/src/primitives/threadListItemMore/ThreadListItemMoreContent.tsx +7 -0
  155. package/src/primitives/threadListItemMore/ThreadListItemMoreTrigger.tsx +2 -0
  156. package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +8 -13
  157. package/src/tests/remote-thread-list-test-helpers.ts +15 -0
  158. package/src/tests/threadListItemIsRunning.test.tsx +10 -7
  159. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +9 -8
  160. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +36 -2
  161. package/src/unstable/webmcp/useWebMcpProvider.ts +40 -13
  162. package/src/utils/isCompositionKey.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react",
3
- "version": "0.15.22",
3
+ "version": "0.15.24",
4
4
  "description": "Open-source TypeScript/React library for building production-grade AI chat experiences",
5
5
  "keywords": [
6
6
  "ai",
@@ -55,14 +55,14 @@
55
55
  ],
56
56
  "sideEffects": false,
57
57
  "dependencies": {
58
- "@assistant-ui/core": "^0.3.21",
59
- "@assistant-ui/store": "^0.3.15",
60
- "@assistant-ui/tap": "^0.9.19",
61
- "assistant-cloud": "^0.2.3",
62
- "assistant-stream": "^0.3.45",
58
+ "@assistant-ui/core": "^0.3.23",
59
+ "@assistant-ui/store": "^0.3.17",
60
+ "@assistant-ui/tap": "^0.9.21",
61
+ "assistant-cloud": "^0.2.5",
62
+ "assistant-stream": "^0.3.47",
63
63
  "radix-ui": "^1.6.7",
64
64
  "react-textarea-autosize": "^8.5.9",
65
- "safe-content-frame": "^0.0.31",
65
+ "safe-content-frame": "^0.0.32",
66
66
  "zod": "^4.6.5",
67
67
  "zustand": "^5.0.15"
68
68
  },
@@ -81,17 +81,18 @@
81
81
  }
82
82
  },
83
83
  "devDependencies": {
84
- "@assistant-ui/vite": "0.0.19",
85
- "@assistant-ui/x-buildutils": "0.0.29",
84
+ "@assistant-ui/vite": "0.0.20",
85
+ "@assistant-ui/x-buildutils": "0.0.30",
86
+ "@assistant-ui/x-react18": "0.0.0",
86
87
  "@testing-library/react": "^16.3.3",
87
88
  "@types/json-schema": "^7.0.15",
88
- "@types/node": "^26.5.1",
89
+ "@types/node": "^26.6.3",
89
90
  "@types/react": "^19.3.0",
90
91
  "@types/react-dom": "^19.3.0",
91
92
  "jsdom": "^30.0.1",
92
93
  "react": "^19.3.0",
93
94
  "react-dom": "^19.3.0",
94
- "vitest": "^5.0.1"
95
+ "vitest": "^5.0.3"
95
96
  },
96
97
  "publishConfig": {
97
98
  "access": "public",
@@ -110,6 +111,8 @@
110
111
  "build": "aui-build",
111
112
  "test": "vitest run",
112
113
  "typecheck": "tsc --noEmit",
113
- "test:watch": "vitest"
114
+ "test:peer-react18": "vitest run --config vitest.peer-react18.config.ts",
115
+ "test:watch": "vitest",
116
+ "test:types:peer-react18": "aui-react18-types"
114
117
  }
115
118
  }
@@ -133,6 +133,7 @@ const fireKeyDown = (
133
133
  ctrlKey?: boolean;
134
134
  metaKey?: boolean;
135
135
  isComposing?: boolean;
136
+ keyCode?: number;
136
137
  } = {},
137
138
  ): KeyboardEvent => {
138
139
  const event = new KeyboardEvent("keydown", {
@@ -143,6 +144,7 @@ const fireKeyDown = (
143
144
  ctrlKey: opts.ctrlKey ?? false,
144
145
  metaKey: opts.metaKey ?? false,
145
146
  isComposing: opts.isComposing ?? false,
147
+ keyCode: opts.keyCode ?? 0,
146
148
  });
147
149
  textarea.dispatchEvent(event);
148
150
  return event;
@@ -441,6 +443,20 @@ describe("ComposerPrimitiveInput", () => {
441
443
  expect(event.defaultPrevented).toBe(false);
442
444
  });
443
445
 
446
+ it("ignores the Enter that commits an IME composition after compositionend (Safari)", async () => {
447
+ const textarea = await mount();
448
+
449
+ let event!: KeyboardEvent;
450
+ await act(async () => {
451
+ fireCompositionStart(textarea);
452
+ fireCompositionEnd(textarea, "日本語");
453
+ event = fireKeyDown(textarea, { key: "Enter", keyCode: 229 });
454
+ });
455
+
456
+ expect(requestSubmitSpy).not.toHaveBeenCalled();
457
+ expect(event.defaultPrevented).toBe(false);
458
+ });
459
+
444
460
  it("submitMode='ctrlEnter' submits on Cmd/Ctrl+Enter but not on plain Enter", async () => {
445
461
  const textarea = await mount({ submitMode: "ctrlEnter" });
446
462
 
@@ -566,13 +582,14 @@ describe("ComposerPrimitiveInput", () => {
566
582
  describe("escape behavior", () => {
567
583
  const fireEscape = (
568
584
  textarea: HTMLTextAreaElement,
569
- opts: { isComposing?: boolean } = {},
585
+ opts: { isComposing?: boolean; keyCode?: number } = {},
570
586
  ): KeyboardEvent => {
571
587
  const event = new KeyboardEvent("keydown", {
572
588
  bubbles: true,
573
589
  cancelable: true,
574
590
  key: "Escape",
575
591
  isComposing: opts.isComposing ?? false,
592
+ keyCode: opts.keyCode ?? 0,
576
593
  });
577
594
  textarea.dispatchEvent(event);
578
595
  escapeKeydownHandler?.(event);
@@ -604,6 +621,21 @@ describe("ComposerPrimitiveInput", () => {
604
621
  expect(cancelSpy).not.toHaveBeenCalled();
605
622
  expect(event.defaultPrevented).toBe(false);
606
623
  });
624
+
625
+ it("ignores the Escape that cancels an IME composition after compositionend (Safari)", async () => {
626
+ composerState.canCancel = true;
627
+ const textarea = await mount();
628
+
629
+ let event!: KeyboardEvent;
630
+ await act(async () => {
631
+ fireCompositionStart(textarea);
632
+ fireCompositionEnd(textarea, "");
633
+ event = fireEscape(textarea, { keyCode: 229 });
634
+ });
635
+
636
+ expect(cancelSpy).not.toHaveBeenCalled();
637
+ expect(event.defaultPrevented).toBe(false);
638
+ });
607
639
  });
608
640
 
609
641
  describe("paste attachments", () => {
@@ -22,6 +22,7 @@ import { useEscapeKeydown } from "radix-ui/internal";
22
22
  import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
23
23
  import { useMediaQuery } from "../../utils/hooks/useMediaQuery";
24
24
  import { renderSlot } from "../../utils/Primitive";
25
+ import { isCompositionKey } from "../../utils/isCompositionKey";
25
26
  import { useAui } from "@assistant-ui/store";
26
27
  import { flushTapSync } from "@assistant-ui/tap";
27
28
  import { useComposerInputPluginRegistryOptional } from "./ComposerInputPluginContext";
@@ -200,7 +201,7 @@ export const ComposerPrimitiveInput = forwardRef<
200
201
  if (!textareaRef.current?.contains(e.target as Node)) return;
201
202
 
202
203
  // ignore IME composition events
203
- if (e.isComposing) return;
204
+ if (isCompositionKey(e)) return;
204
205
 
205
206
  // Let registered plugins (mention, slash command, etc.) handle Escape first
206
207
  if (pluginRegistry) {
@@ -222,7 +223,7 @@ export const ComposerPrimitiveInput = forwardRef<
222
223
  if (isDisabled) return;
223
224
 
224
225
  // ignore IME composition events
225
- if (e.nativeEvent.isComposing) return;
226
+ if (isCompositionKey(e.nativeEvent)) return;
226
227
 
227
228
  // Let registered plugins (mention, slash command, etc.) handle keyboard events first
228
229
  if (pluginRegistry) {
@@ -0,0 +1,72 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { render } from "@testing-library/react";
4
+ import { describe, expect, it } from "vitest";
5
+ import type { ThreadMessageLike } from "@assistant-ui/core";
6
+ import {
7
+ AssistantRuntimeProvider,
8
+ useExternalStoreRuntime,
9
+ } from "@assistant-ui/core/react";
10
+ import { useAuiState } from "@assistant-ui/store";
11
+ import { ThreadPrimitiveMessages } from "../thread/ThreadMessages";
12
+ import { ThreadPrimitiveRoot } from "../thread/ThreadRoot";
13
+ import { MessagePrimitiveIf } from "./MessageIf";
14
+
15
+ const messages: ThreadMessageLike[] = [
16
+ { id: "u", role: "user", content: [{ type: "text", text: "u" }] },
17
+ { id: "a", role: "assistant", content: [{ type: "text", text: "a" }] },
18
+ { id: "s", role: "system", content: [{ type: "text", text: "s" }] },
19
+ ];
20
+
21
+ const roles = ["user", "assistant", "system"] as const;
22
+ type Role = (typeof roles)[number];
23
+
24
+ const roleFilter = (role: Role, value: boolean | undefined) =>
25
+ ({ [role]: value }) as MessagePrimitiveIf.Props;
26
+
27
+ const renderRoles = (filter: MessagePrimitiveIf.Props) => {
28
+ const Message = () => {
29
+ const role = useAuiState((s) => s.message.role);
30
+ return (
31
+ <MessagePrimitiveIf {...filter}>
32
+ <span data-testid={`shown-${role}`} />
33
+ </MessagePrimitiveIf>
34
+ );
35
+ };
36
+ const Example = () => {
37
+ const runtime = useExternalStoreRuntime({
38
+ messages,
39
+ convertMessage: (message) => message,
40
+ onNew: async () => {},
41
+ });
42
+ return (
43
+ <AssistantRuntimeProvider runtime={runtime}>
44
+ <ThreadPrimitiveRoot>
45
+ <ThreadPrimitiveMessages components={{ Message }} />
46
+ </ThreadPrimitiveRoot>
47
+ </AssistantRuntimeProvider>
48
+ );
49
+ };
50
+ const { queryByTestId } = render(<Example />);
51
+ return roles.filter((role) => queryByTestId(`shown-${role}`) !== null);
52
+ };
53
+
54
+ describe("MessagePrimitive.If role filters", () => {
55
+ it.each(roles)("%s={true} renders only that role", (role) => {
56
+ expect(renderRoles(roleFilter(role, true))).toEqual([role]);
57
+ });
58
+
59
+ it.each(roles)("%s={false} renders every other role", (role) => {
60
+ expect(renderRoles(roleFilter(role, false))).toEqual(
61
+ roles.filter((r) => r !== role),
62
+ );
63
+ });
64
+
65
+ it.each(roles)("%s={undefined} renders every role", (role) => {
66
+ expect(renderRoles(roleFilter(role, undefined))).toEqual([...roles]);
67
+ });
68
+
69
+ it("combines a true filter with a false filter", () => {
70
+ expect(renderRoles({ assistant: false, system: false })).toEqual(["user"]);
71
+ });
72
+ });
@@ -34,9 +34,14 @@ const useMessageIf = (props: UseMessageIfProps) => {
34
34
 
35
35
  if (props.hasBranches === true && branchCount < 2) return false;
36
36
 
37
- if (props.user && role !== "user") return false;
38
- if (props.assistant && role !== "assistant") return false;
39
- if (props.system && role !== "system") return false;
37
+ if (props.user === true && role !== "user") return false;
38
+ if (props.user === false && role === "user") return false;
39
+
40
+ if (props.assistant === true && role !== "assistant") return false;
41
+ if (props.assistant === false && role === "assistant") return false;
42
+
43
+ if (props.system === true && role !== "system") return false;
44
+ if (props.system === false && role === "system") return false;
40
45
 
41
46
  if (props.lastOrHover === true && !isHovering && !isLast) return false;
42
47
  if (props.last !== undefined && props.last !== isLast) return false;
@@ -1,7 +1,13 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { render, screen, waitFor } from "@testing-library/react";
4
- import { useEffect, type FC, type ReactNode } from "react";
4
+ import {
5
+ useEffect,
6
+ useState,
7
+ type FC,
8
+ type ReactNode,
9
+ type PropsWithChildren,
10
+ } from "react";
5
11
  import { describe, expect, it } from "vitest";
6
12
  import type { ThreadMessageLike } from "@assistant-ui/core";
7
13
  import {
@@ -63,10 +69,12 @@ const Example = ({
63
69
  content,
64
70
  Message: MessageComponent = Message,
65
71
  extra,
72
+ isRunning = false,
66
73
  }: {
67
74
  content: ThreadMessageLike["content"];
68
75
  Message?: FC;
69
76
  extra?: ReactNode;
77
+ isRunning?: boolean;
70
78
  }) => {
71
79
  const messages: ThreadMessageLike[] = [
72
80
  { id: "message", role: "assistant", content },
@@ -74,6 +82,7 @@ const Example = ({
74
82
  const runtime = useExternalStoreRuntime({
75
83
  messages,
76
84
  convertMessage: (message) => message,
85
+ isRunning,
77
86
  onNew: async () => {},
78
87
  });
79
88
  return (
@@ -88,6 +97,267 @@ const Example = ({
88
97
  };
89
98
 
90
99
  describe("MessagePrimitive.Unstable_PartsGroupedByParentId", () => {
100
+ it.each(["tool-call", "reasoning"] as const)(
101
+ "keeps wrapper and mounted state when a tool call is appended after %s",
102
+ (firstType) => {
103
+ let mounts = 0;
104
+ const Group = ({ children }: PropsWithChildren) => {
105
+ const [mount] = useState(() => ++mounts);
106
+ return <section data-mount={mount}>{children}</section>;
107
+ };
108
+ const Text = ({ text }: { text: string }) => {
109
+ const [seed] = useState(text);
110
+ return <span>{`${seed}:${text}`}</span>;
111
+ };
112
+ const Tool = ({ argsText }: { argsText: string }) => (
113
+ <Text text={argsText} />
114
+ );
115
+ const Message = partsMessage({
116
+ Reasoning: Text,
117
+ Group,
118
+ tools: { Fallback: Tool },
119
+ });
120
+ const first =
121
+ firstType === "tool-call"
122
+ ? {
123
+ type: "tool-call" as const,
124
+ toolCallId: "t2",
125
+ toolName: "task",
126
+ args: {},
127
+ argsText: "draft",
128
+ parentId: "parent",
129
+ }
130
+ : {
131
+ type: "reasoning" as const,
132
+ id: "r1",
133
+ text: "draft",
134
+ parentId: "parent",
135
+ };
136
+ const view = render(
137
+ <Example Message={Message} content={[first]} isRunning />,
138
+ );
139
+ const wrapper = view.container.querySelector("section");
140
+ const streamed =
141
+ first.type === "tool-call"
142
+ ? { ...first, argsText: "streamed" }
143
+ : { ...first, text: "streamed" };
144
+ const appended = {
145
+ type: "tool-call" as const,
146
+ toolCallId: "t1",
147
+ toolName: "task",
148
+ args: {},
149
+ argsText: "appended",
150
+ parentId: "parent",
151
+ };
152
+ view.rerender(
153
+ <Example Message={Message} content={[streamed, appended]} isRunning />,
154
+ );
155
+ expect(view.container.querySelector("section")).toBe(wrapper);
156
+ expect(wrapper?.dataset.mount).toBe("1");
157
+ expect(
158
+ Array.from(
159
+ view.container.querySelectorAll("span"),
160
+ (el) => el.textContent,
161
+ ),
162
+ ).toEqual(["draft:streamed", "appended:appended"]);
163
+ view.rerender(
164
+ <Example
165
+ Message={Message}
166
+ content={[
167
+ streamed,
168
+ { ...appended, argsText: "updated" },
169
+ { ...appended, toolCallId: "t0", argsText: "last" },
170
+ ]}
171
+ isRunning
172
+ />,
173
+ );
174
+ expect(view.container.querySelector("section")).toBe(wrapper);
175
+ expect(
176
+ Array.from(
177
+ view.container.querySelectorAll("span"),
178
+ (el) => el.textContent,
179
+ ),
180
+ ).toEqual(["draft:streamed", "appended:updated", "last:last"]);
181
+ },
182
+ );
183
+
184
+ it("keeps parent names and part ids with key delimiters separate", () => {
185
+ let mounts = 0;
186
+ const Group = ({ children }: PropsWithChildren) => {
187
+ const [mount] = useState(() => ++mounts);
188
+ return <section data-mount={mount}>{children}</section>;
189
+ };
190
+ const Text = ({ text }: { text: string }) => {
191
+ const [seed] = useState(text);
192
+ return <span>{`${seed}:${text}`}</span>;
193
+ };
194
+ const Message = partsMessage({ Text, Group });
195
+ const first = {
196
+ type: "text" as const,
197
+ id: "x-id:text:y",
198
+ text: "first",
199
+ parentId: "a",
200
+ };
201
+ const second = {
202
+ type: "text" as const,
203
+ id: "y",
204
+ text: "second",
205
+ parentId: "a-id:text:x",
206
+ };
207
+ const view = render(
208
+ <Example Message={Message} content={[first, second]} />,
209
+ );
210
+ view.rerender(
211
+ <Example
212
+ Message={Message}
213
+ content={[
214
+ { ...second, text: "second updated" },
215
+ { ...first, text: "first updated" },
216
+ ]}
217
+ />,
218
+ );
219
+ expect(
220
+ Array.from(
221
+ view.container.querySelectorAll("section"),
222
+ (el) => el.dataset.mount,
223
+ ),
224
+ ).toEqual(["2", "1"]);
225
+ expect(
226
+ Array.from(
227
+ view.container.querySelectorAll("span"),
228
+ (el) => el.textContent,
229
+ ),
230
+ ).toEqual(["second:second updated", "first:first updated"]);
231
+ });
232
+
233
+ it.each([undefined, "parent"])(
234
+ "keeps wrapper and leaf state when later identified parts swap with parent=%s",
235
+ (parentId) => {
236
+ let mounts = 0;
237
+ const Group = ({ children }: PropsWithChildren) => {
238
+ const [mount] = useState(() => ++mounts);
239
+ return <section data-mount={mount}>{children}</section>;
240
+ };
241
+ const Text = ({ text }: { text: string }) => {
242
+ const [seed] = useState(text);
243
+ return <span>{`${seed}:${text}`}</span>;
244
+ };
245
+ const Message = partsMessage({ Text, Group });
246
+ const first = {
247
+ type: "text" as const,
248
+ id: "p1",
249
+ text: "first",
250
+ ...(parentId !== undefined && { parentId }),
251
+ };
252
+ const second = {
253
+ type: "text" as const,
254
+ id: "p2",
255
+ text: "second",
256
+ ...(parentId !== undefined && { parentId }),
257
+ };
258
+ const third = {
259
+ ...second,
260
+ id: "p3",
261
+ text: "third",
262
+ };
263
+ const view = render(
264
+ <Example Message={Message} content={[first, second, third]} />,
265
+ );
266
+ view.rerender(
267
+ <Example
268
+ Message={Message}
269
+ content={[
270
+ { ...first, text: "first updated" },
271
+ { ...third, text: "third updated" },
272
+ { ...second, text: "second updated" },
273
+ ]}
274
+ />,
275
+ );
276
+ expect(
277
+ Array.from(
278
+ view.container.querySelectorAll("span"),
279
+ (el) => el.textContent,
280
+ ),
281
+ ).toEqual([
282
+ "first:first updated",
283
+ "third:third updated",
284
+ "second:second updated",
285
+ ]);
286
+ expect(
287
+ Array.from(
288
+ view.container.querySelectorAll("section"),
289
+ (el) => el.dataset.mount,
290
+ ),
291
+ ).toEqual(parentId === undefined ? ["1", "3", "2"] : ["1"]);
292
+ },
293
+ );
294
+
295
+ it("keys unidentified groups by index and keeps parent names separate", () => {
296
+ let mounts = 0;
297
+ const Group = ({
298
+ groupKey,
299
+ children,
300
+ }: PropsWithChildren<{ groupKey: string | undefined }>) => {
301
+ const [mount] = useState(() => ++mounts);
302
+ return (
303
+ <section data-mount={mount} data-parent={groupKey}>
304
+ {children}
305
+ </section>
306
+ );
307
+ };
308
+ const Message = partsMessage({ Group });
309
+ const parts = [
310
+ { type: "text" as const, text: "first" },
311
+ { type: "text" as const, text: "second" },
312
+ ];
313
+ const view = render(<Example Message={Message} content={parts} />);
314
+ view.rerender(
315
+ <Example
316
+ Message={Message}
317
+ content={[
318
+ { type: "text", text: "parented", parentId: "ungrouped" },
319
+ ...parts,
320
+ ]}
321
+ />,
322
+ );
323
+ expect(
324
+ Array.from(
325
+ view.container.querySelectorAll("section"),
326
+ (el) => el.dataset.mount,
327
+ ),
328
+ ).toEqual(["3", "2", "4"]);
329
+ });
330
+
331
+ it("keeps a text seed while streaming and resets it for a replacement id", () => {
332
+ const SeededText = ({ text }: { text: string }) => {
333
+ const [seed] = useState(text);
334
+ return <span>{`${seed}:${text}`}</span>;
335
+ };
336
+ const Message = partsMessage({ Text: SeededText });
337
+ const view = render(
338
+ <Example
339
+ Message={Message}
340
+ content={[{ type: "text", id: "p1", text: "old" }]}
341
+ />,
342
+ );
343
+
344
+ view.rerender(
345
+ <Example
346
+ Message={Message}
347
+ content={[{ type: "text", id: "p1", text: "old streamed" }]}
348
+ />,
349
+ );
350
+ expect(view.container.textContent).toBe("old:old streamed");
351
+
352
+ view.rerender(
353
+ <Example
354
+ Message={Message}
355
+ content={[{ type: "text", id: "p2", text: "new" }]}
356
+ />,
357
+ );
358
+ expect(view.container.textContent).toBe("new:new");
359
+ });
360
+
91
361
  it("keeps parent IDs separate from ungrouped parts across content updates", () => {
92
362
  const { rerender } = render(
93
363
  <Example
@@ -27,6 +27,7 @@ import type {
27
27
  } from "@assistant-ui/core/react";
28
28
  import { MessagePartPrimitiveInProgress } from "../messagePart/MessagePartInProgress";
29
29
  import type { MessagePartStatus } from "@assistant-ui/core";
30
+ import { getMessagePartKeys } from "@assistant-ui/core/internal";
30
31
 
31
32
  type MessagePartGroup = {
32
33
  groupKey: string | undefined;
@@ -73,15 +74,16 @@ const groupMessagePartsByParentId: GroupingFunction = (
73
74
 
74
75
  const useMessagePartsGrouped = (
75
76
  groupingFunction: GroupingFunction,
76
- ): MessagePartGroup[] => {
77
+ ): { groups: MessagePartGroup[]; partKeys: string[] } => {
77
78
  const parts = useAuiState((s) => s.message.parts);
78
79
 
79
- return useMemo(() => {
80
+ const groups = useMemo(() => {
80
81
  if (parts.length === 0) {
81
82
  return [];
82
83
  }
83
84
  return groupingFunction(parts);
84
85
  }, [parts, groupingFunction]);
86
+ return { groups, partKeys: getMessagePartKeys(parts) };
85
87
  };
86
88
 
87
89
  export namespace MessagePrimitiveUnstable_PartsGrouped {
@@ -463,7 +465,8 @@ export const MessagePrimitiveUnstable_PartsGrouped: FC<
463
465
  MessagePrimitiveUnstable_PartsGrouped.Props
464
466
  > = ({ groupingFunction, components }) => {
465
467
  const contentLength = useAuiState((s) => s.message.parts.length);
466
- const messageGroups = useMessagePartsGrouped(groupingFunction);
468
+ const { groups: messageGroups, partKeys } =
469
+ useMessagePartsGrouped(groupingFunction);
467
470
 
468
471
  const partsElements = useMemo(() => {
469
472
  if (contentLength === 0) {
@@ -472,16 +475,21 @@ export const MessagePrimitiveUnstable_PartsGrouped: FC<
472
475
 
473
476
  return messageGroups.map((group, groupIndex) => {
474
477
  const GroupComponent = components?.Group ?? defaultComponents.Group;
478
+ const identity = partKeys[group.indices[0]!];
475
479
 
476
480
  return (
477
481
  <GroupComponent
478
- key={`group-${groupIndex}-${group.groupKey ?? "ungrouped"}`}
482
+ key={JSON.stringify([
483
+ "group",
484
+ group.groupKey ?? null,
485
+ identity?.includes(":") ? `id:${identity}` : groupIndex,
486
+ ])}
479
487
  groupKey={group.groupKey}
480
488
  indices={group.indices}
481
489
  >
482
490
  {group.indices.map((partIndex) => (
483
491
  <MessagePart
484
- key={partIndex}
492
+ key={partKeys[partIndex]}
485
493
  partIndex={partIndex}
486
494
  components={components}
487
495
  />
@@ -489,7 +497,7 @@ export const MessagePrimitiveUnstable_PartsGrouped: FC<
489
497
  </GroupComponent>
490
498
  );
491
499
  });
492
- }, [messageGroups, components, contentLength]);
500
+ }, [messageGroups, components, contentLength, partKeys]);
493
501
 
494
502
  return <>{partsElements}</>;
495
503
  };