@assistant-ui/react 0.15.17 → 0.15.19

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 (169) hide show
  1. package/README.md +8 -2
  2. package/dist/context/providers/ThreadViewportProvider.js +2 -2
  3. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  4. package/dist/hooks/useToolCallElapsed.js.map +1 -1
  5. package/dist/index.d.ts +7 -4
  6. package/dist/index.js +5 -3
  7. package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
  8. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  9. package/dist/mcp-apps/McpAppRenderer.js +91 -47
  10. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  11. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  12. package/dist/mcp-apps/app-frame.js +57 -33
  13. package/dist/mcp-apps/app-frame.js.map +1 -1
  14. package/dist/mcp-apps/bridge.js +9 -4
  15. package/dist/mcp-apps/bridge.js.map +1 -1
  16. package/dist/mcp-apps/index.d.ts +2 -2
  17. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  18. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  19. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  20. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  21. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  22. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  23. package/dist/primitives/composer/ComposerInput.js +3 -8
  24. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  25. package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
  26. package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
  27. package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
  28. package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
  29. package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
  30. package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
  31. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
  32. package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
  33. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
  34. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
  35. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
  36. package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
  37. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  38. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  39. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  40. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  41. package/dist/primitives/composer/trigger/triggerNavigationResource.js +129 -141
  42. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  43. package/dist/primitives/message/MessageParts.js +2 -14
  44. package/dist/primitives/message/MessageParts.js.map +1 -1
  45. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  46. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  47. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  48. package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
  49. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  50. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  51. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  52. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  53. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  54. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  55. package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
  56. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  57. package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
  58. package/dist/sandbox-host/SandboxHost.js +36 -9
  59. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  60. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  61. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  62. package/dist/unstable/useMentionAdapter.d.ts +9 -3
  63. package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
  64. package/dist/unstable/useMentionAdapter.js +50 -43
  65. package/dist/unstable/useMentionAdapter.js.map +1 -1
  66. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  67. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  68. package/dist/unstable/useMessageStallDetection.js +16 -15
  69. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  70. package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
  71. package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
  72. package/dist/unstable/useModelContextSnapshot.js +61 -0
  73. package/dist/unstable/useModelContextSnapshot.js.map +1 -0
  74. package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
  75. package/dist/unstable/useSlashCommandAdapter.js +2 -8
  76. package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
  77. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
  78. package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
  79. package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
  80. package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
  81. package/dist/unstable/webmcp/convertTools.d.ts +21 -0
  82. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
  83. package/dist/unstable/webmcp/convertTools.js +161 -0
  84. package/dist/unstable/webmcp/convertTools.js.map +1 -0
  85. package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
  86. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
  87. package/dist/unstable/webmcp/useWebMcpProvider.js +106 -0
  88. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
  89. package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
  90. package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
  91. package/dist/unstable/webmcp/webmcp-host.js +29 -0
  92. package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
  93. package/dist/utils/Primitive.d.ts +10 -2
  94. package/dist/utils/Primitive.d.ts.map +1 -1
  95. package/dist/utils/Primitive.js +25 -6
  96. package/dist/utils/Primitive.js.map +1 -1
  97. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  98. package/dist/utils/getSelectionMessageId.js +17 -3
  99. package/dist/utils/getSelectionMessageId.js.map +1 -1
  100. package/package.json +13 -13
  101. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  102. package/src/hooks/useToolCallElapsed.ts +3 -0
  103. package/src/index.ts +14 -1
  104. package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
  105. package/src/mcp-apps/McpAppRenderer.tsx +147 -52
  106. package/src/mcp-apps/app-frame.test.tsx +437 -1
  107. package/src/mcp-apps/app-frame.tsx +76 -21
  108. package/src/mcp-apps/bridge.test.ts +98 -0
  109. package/src/mcp-apps/bridge.ts +14 -3
  110. package/src/mcp-apps/index.ts +5 -1
  111. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  112. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  113. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  114. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  115. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  116. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  117. package/src/primitives/composer/ComposerInput.tsx +5 -9
  118. package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
  119. package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
  120. package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
  121. package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
  122. package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
  123. package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
  124. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  125. package/src/primitives/composer/trigger/triggerNavigationResource.ts +8 -13
  126. package/src/primitives/message/MessageParts.tsx +1 -21
  127. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  128. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  129. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  130. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  131. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  132. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  133. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  134. package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
  135. package/src/sandbox-host/SandboxHost.test.tsx +160 -2
  136. package/src/sandbox-host/SandboxHost.tsx +50 -10
  137. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  138. package/src/tests/toolCallTiming.test.tsx +55 -1
  139. package/src/unstable/useComposerInputHistory.test.tsx +2 -1
  140. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  141. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  142. package/src/unstable/useMentionAdapter.test.tsx +383 -0
  143. package/src/unstable/useMentionAdapter.ts +94 -57
  144. package/src/unstable/useMessageStallDetection.ts +26 -19
  145. package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
  146. package/src/unstable/useModelContextSnapshot.ts +67 -0
  147. package/src/unstable/useSlashCommandAdapter.ts +2 -9
  148. package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
  149. package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
  150. package/src/unstable/webmcp/convertTools.test.ts +553 -0
  151. package/src/unstable/webmcp/convertTools.ts +260 -0
  152. package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
  153. package/src/unstable/webmcp/useWebMcpProvider.ts +148 -0
  154. package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
  155. package/src/unstable/webmcp/webmcp-host.ts +83 -0
  156. package/src/utils/Primitive.test.tsx +46 -1
  157. package/src/utils/Primitive.tsx +37 -7
  158. package/src/utils/getSelectionMessageId.test.ts +176 -1
  159. package/src/utils/getSelectionMessageId.ts +21 -3
  160. package/dist/utils/invokeCallbackSafely.d.ts +0 -5
  161. package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
  162. package/dist/utils/invokeCallbackSafely.js +0 -15
  163. package/dist/utils/invokeCallbackSafely.js.map +0 -1
  164. package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
  165. package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
  166. package/dist/utils/useToolArgsFieldStatus.js +0 -23
  167. package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
  168. package/src/utils/invokeCallbackSafely.ts +0 -11
  169. package/src/utils/useToolArgsFieldStatus.ts +0 -18
@@ -0,0 +1,109 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { Suspense, type ReactNode } from "react";
4
+ import { render, waitFor } from "@testing-library/react";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
+ import type { Unstable_TriggerItem } from "@assistant-ui/core";
7
+ import type { TriggerBehavior } from "./triggerSelectionResource";
8
+
9
+ const mocks = vi.hoisted(() => ({ register: vi.fn() }));
10
+
11
+ vi.mock("./TriggerPopover", async (importOriginal) => ({
12
+ ...(await importOriginal<typeof import("./TriggerPopover")>()),
13
+ useTriggerBehaviorRegistration: () => ({ register: mocks.register }),
14
+ }));
15
+
16
+ import { ComposerPrimitiveTriggerPopoverAction } from "./TriggerPopoverAction";
17
+ import { ComposerPrimitiveTriggerPopoverDirective } from "./TriggerPopoverDirective";
18
+
19
+ const item: Unstable_TriggerItem = {
20
+ id: "item-1",
21
+ type: "test",
22
+ label: "Item",
23
+ };
24
+
25
+ const verifyCommittedCallback = async (
26
+ renderBehavior: (callback: (item: Unstable_TriggerItem) => void) => ReactNode,
27
+ invoke: (behavior: TriggerBehavior) => void,
28
+ ) => {
29
+ let behavior: TriggerBehavior | undefined;
30
+ mocks.register.mockImplementation((next: TriggerBehavior) => {
31
+ behavior = next;
32
+ return () => {};
33
+ });
34
+
35
+ let suspend = false;
36
+ const suspended = new Promise<never>(() => {});
37
+ const renderProbe = vi.fn();
38
+ const Suspender = () => {
39
+ if (suspend) throw suspended;
40
+ return null;
41
+ };
42
+ const Probe = () => {
43
+ renderProbe();
44
+ return null;
45
+ };
46
+ const View = ({
47
+ callback,
48
+ }: {
49
+ callback: (item: Unstable_TriggerItem) => void;
50
+ }) => (
51
+ <Suspense fallback={null}>
52
+ {renderBehavior(callback)}
53
+ <Probe />
54
+ <Suspender />
55
+ </Suspense>
56
+ );
57
+
58
+ const committed = vi.fn();
59
+ const abandoned = vi.fn();
60
+ const view = render(<View callback={committed} />);
61
+
62
+ await waitFor(() => expect(behavior).toBeDefined());
63
+ renderProbe.mockClear();
64
+
65
+ suspend = true;
66
+ view.rerender(<View callback={abandoned} />);
67
+ await waitFor(() => expect(renderProbe).toHaveBeenCalled());
68
+
69
+ invoke(behavior!);
70
+ expect(committed).toHaveBeenCalledExactlyOnceWith(item);
71
+ expect(abandoned).not.toHaveBeenCalled();
72
+
73
+ suspend = false;
74
+ view.rerender(<View callback={abandoned} />);
75
+ invoke(behavior!);
76
+ expect(abandoned).toHaveBeenCalledExactlyOnceWith(item);
77
+ };
78
+
79
+ describe("TriggerPopover behavior callbacks", () => {
80
+ beforeEach(() => {
81
+ mocks.register.mockReset();
82
+ });
83
+
84
+ it("keeps the committed action callback during abandoned renders", async () => {
85
+ await verifyCommittedCallback(
86
+ (onExecute) => (
87
+ <ComposerPrimitiveTriggerPopoverAction onExecute={onExecute} />
88
+ ),
89
+ (behavior) => {
90
+ if (behavior.kind !== "action") throw new Error("Expected action");
91
+ behavior.onExecute(item);
92
+ },
93
+ );
94
+ });
95
+
96
+ it("keeps the committed directive callback during abandoned renders", async () => {
97
+ await verifyCommittedCallback(
98
+ (onInserted) => (
99
+ <ComposerPrimitiveTriggerPopoverDirective onInserted={onInserted} />
100
+ ),
101
+ (behavior) => {
102
+ if (behavior.kind !== "directive") {
103
+ throw new Error("Expected directive");
104
+ }
105
+ behavior.onInserted?.(item);
106
+ },
107
+ );
108
+ });
109
+ });
@@ -5,7 +5,7 @@ import type {
5
5
  Unstable_TriggerItem,
6
6
  } from "@assistant-ui/core";
7
7
  import { unstable_defaultDirectiveFormatter } from "@assistant-ui/core";
8
- import { useEffect, useRef, type FC } from "react";
8
+ import { useEffect, useInsertionEffect, useRef, type FC } from "react";
9
9
  import { useTriggerBehaviorRegistration } from "./TriggerPopover";
10
10
  import type { TriggerBehavior } from "./triggerSelectionResource";
11
11
 
@@ -39,7 +39,9 @@ export const ComposerPrimitiveTriggerPopoverDirective: FC<
39
39
  > = ({ formatter, onInserted }) => {
40
40
  const { register } = useTriggerBehaviorRegistration();
41
41
  const onInsertedRef = useRef(onInserted);
42
- onInsertedRef.current = onInserted;
42
+ useInsertionEffect(() => {
43
+ onInsertedRef.current = onInserted;
44
+ }, [onInserted]);
43
45
 
44
46
  useEffect(() => {
45
47
  const behavior: TriggerBehavior = {
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { matchesTriggerItemQuery } from "./matchesTriggerItemQuery";
3
+ import type { Unstable_TriggerItem } from "@assistant-ui/core";
4
+
5
+ const item = (
6
+ overrides: Partial<Unstable_TriggerItem>,
7
+ ): Unstable_TriggerItem => ({
8
+ id: "summarize",
9
+ type: "command",
10
+ label: "Summarize",
11
+ ...overrides,
12
+ });
13
+
14
+ describe("matchesTriggerItemQuery", () => {
15
+ it("matches every item on an empty query", () => {
16
+ expect(matchesTriggerItemQuery(item({}), "")).toBe(true);
17
+ });
18
+
19
+ it("matches case-insensitively against the id", () => {
20
+ expect(
21
+ matchesTriggerItemQuery(item({ id: "SummArize", label: "y" }), "marize"),
22
+ ).toBe(true);
23
+ });
24
+
25
+ it("matches case-insensitively against the label", () => {
26
+ expect(
27
+ matchesTriggerItemQuery(
28
+ item({ id: "x", label: "Translate Text" }),
29
+ "late",
30
+ ),
31
+ ).toBe(true);
32
+ });
33
+
34
+ it("matches against the description when present", () => {
35
+ expect(
36
+ matchesTriggerItemQuery(
37
+ item({ id: "x", label: "y", description: "Condense the thread" }),
38
+ "condense",
39
+ ),
40
+ ).toBe(true);
41
+ });
42
+
43
+ it("does not match when no field contains the query", () => {
44
+ expect(
45
+ matchesTriggerItemQuery(item({ description: "nothing here" }), "zzz"),
46
+ ).toBe(false);
47
+ });
48
+
49
+ it("does not throw on items without a description", () => {
50
+ expect(matchesTriggerItemQuery(item({}), "zzz")).toBe(false);
51
+ });
52
+ });
@@ -0,0 +1,13 @@
1
+ import type { Unstable_TriggerItem } from "@assistant-ui/core";
2
+
3
+ export function matchesTriggerItemQuery(
4
+ item: Unstable_TriggerItem,
5
+ lowerQuery: string,
6
+ ): boolean {
7
+ if (!lowerQuery) return true;
8
+ return (
9
+ item.id.toLowerCase().includes(lowerQuery) ||
10
+ item.label.toLowerCase().includes(lowerQuery) ||
11
+ (item.description?.toLowerCase().includes(lowerQuery) ?? false)
12
+ );
13
+ }
@@ -1,4 +1,4 @@
1
- import { useEffect, useEffectEvent, useState } from "react";
1
+ import { useEffectEvent, useState } from "react";
2
2
  import { resource } from "@assistant-ui/tap";
3
3
  import type {
4
4
  Unstable_TriggerCategory,
@@ -58,14 +58,20 @@ const useTriggerKeyboardResource = ({
58
58
  close: () => void;
59
59
  }): TriggerKeyboardResourceOutput => {
60
60
  const [highlightedIndex, setHighlightedIndex] = useState(0);
61
+ const [highlightScope, setHighlightScope] = useState({
62
+ navigableList,
63
+ isSearchMode,
64
+ activeCategoryId,
65
+ });
61
66
 
62
- useEffect(() => {
63
- setHighlightedIndex(0);
64
- }, [navigableList]);
65
-
66
- useEffect(() => {
67
+ if (
68
+ highlightScope.navigableList !== navigableList ||
69
+ highlightScope.isSearchMode !== isSearchMode ||
70
+ highlightScope.activeCategoryId !== activeCategoryId
71
+ ) {
72
+ setHighlightScope({ navigableList, isSearchMode, activeCategoryId });
67
73
  setHighlightedIndex(0);
68
- }, [isSearchMode, activeCategoryId]);
74
+ }
69
75
 
70
76
  const highlightIndex = useEffectEvent((index: number) => {
71
77
  if (index < 0 || index >= navigableList.length) return;
@@ -1,18 +1,11 @@
1
- import { useEffect, useEffectEvent, useMemo, useState } from "react";
1
+ import { useEffectEvent, useMemo, useState } from "react";
2
2
  import { resource } from "@assistant-ui/tap";
3
3
  import type {
4
4
  Unstable_TriggerAdapter,
5
5
  Unstable_TriggerCategory,
6
6
  Unstable_TriggerItem,
7
7
  } from "@assistant-ui/core";
8
-
9
- function matchesQuery(item: Unstable_TriggerItem, lower: string): boolean {
10
- return (
11
- item.id.toLowerCase().includes(lower) ||
12
- item.label.toLowerCase().includes(lower) ||
13
- (item.description?.toLowerCase().includes(lower) ?? false)
14
- );
15
- }
8
+ import { matchesTriggerItemQuery } from "./matchesTriggerItemQuery";
16
9
 
17
10
  export type TriggerNavigationResourceOutput = {
18
11
  /** Filtered categories visible in the list (empty in search mode). */
@@ -48,10 +41,12 @@ const useTriggerNavigationResource = ({
48
41
  open: boolean;
49
42
  }): TriggerNavigationResourceOutput => {
50
43
  const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
44
+ const [wasOpen, setWasOpen] = useState(open);
51
45
 
52
- useEffect(() => {
46
+ if (wasOpen !== open) {
47
+ setWasOpen(open);
53
48
  if (!open) setActiveCategoryId(null);
54
- }, [open]);
49
+ }
55
50
 
56
51
  const categories = useMemo<readonly Unstable_TriggerCategory[]>(() => {
57
52
  if (!open || !adapter) return [];
@@ -76,7 +71,7 @@ const useTriggerNavigationResource = ({
76
71
  const lower = query.toLowerCase();
77
72
  for (const cat of categories) {
78
73
  for (const item of adapter.categoryItems(cat.id)) {
79
- if (matchesQuery(item, lower)) {
74
+ if (matchesTriggerItemQuery(item, lower)) {
80
75
  all.push(item);
81
76
  }
82
77
  }
@@ -97,7 +92,7 @@ const useTriggerNavigationResource = ({
97
92
  if (isSearchMode) return searchResults ?? [];
98
93
  if (!query) return allItems;
99
94
  const lower = query.toLowerCase();
100
- return allItems.filter((item) => matchesQuery(item, lower));
95
+ return allItems.filter((item) => matchesTriggerItemQuery(item, lower));
101
96
  }, [allItems, query, isSearchMode, searchResults]);
102
97
 
103
98
  const navigableList = useMemo(() => {
@@ -43,29 +43,9 @@ export const MessagePrimitiveParts: FC<MessagePrimitiveParts.Props> = (
43
43
  const { components, ...rest } = props;
44
44
  const merged = components
45
45
  ? {
46
+ ...components,
46
47
  Text: components.Text ?? webDefaultComponents.Text,
47
48
  Image: components.Image ?? webDefaultComponents.Image,
48
- Reasoning:
49
- components.Reasoning ?? messagePartsDefaultComponents.Reasoning,
50
- Source: components.Source ?? messagePartsDefaultComponents.Source,
51
- File: components.File ?? messagePartsDefaultComponents.File,
52
- Unstable_Audio:
53
- components.Unstable_Audio ??
54
- messagePartsDefaultComponents.Unstable_Audio,
55
- ...("ChainOfThought" in components
56
- ? { ChainOfThought: components.ChainOfThought }
57
- : {
58
- tools: components.tools,
59
- data: components.data,
60
- ToolGroup:
61
- components.ToolGroup ?? messagePartsDefaultComponents.ToolGroup,
62
- ReasoningGroup:
63
- components.ReasoningGroup ??
64
- messagePartsDefaultComponents.ReasoningGroup,
65
- }),
66
- Empty: components.Empty,
67
- Quote: components.Quote,
68
- generativeUI: components.generativeUI,
69
49
  }
70
50
  : webDefaultComponents;
71
51
 
@@ -0,0 +1,93 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { render, screen } 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 { ThreadPrimitiveMessageByIndex } from "../thread/ThreadMessages";
11
+ import { MessagePrimitiveUnstable_PartsGroupedByParentId } from "./MessagePartsGrouped";
12
+
13
+ const Message = () => (
14
+ <MessagePrimitiveUnstable_PartsGroupedByParentId
15
+ components={{
16
+ Text: ({ text }) => <span>{text}</span>,
17
+ Group: ({ groupKey, indices, children }) => (
18
+ <section
19
+ data-testid="group"
20
+ data-parent={groupKey}
21
+ data-indices={indices.join(",")}
22
+ >
23
+ {children}
24
+ </section>
25
+ ),
26
+ }}
27
+ />
28
+ );
29
+
30
+ const Example = ({ content }: { content: ThreadMessageLike["content"] }) => {
31
+ const messages: ThreadMessageLike[] = [
32
+ { id: "message", role: "assistant", content },
33
+ ];
34
+ const runtime = useExternalStoreRuntime({
35
+ messages,
36
+ convertMessage: (message) => message,
37
+ onNew: async () => {},
38
+ });
39
+ return (
40
+ <AssistantRuntimeProvider runtime={runtime}>
41
+ <ThreadPrimitiveMessageByIndex index={0} components={{ Message }} />
42
+ </AssistantRuntimeProvider>
43
+ );
44
+ };
45
+
46
+ describe("MessagePrimitive.Unstable_PartsGroupedByParentId", () => {
47
+ it("keeps parent IDs separate from ungrouped parts across content updates", () => {
48
+ const { rerender } = render(
49
+ <Example
50
+ content={[
51
+ { type: "text", text: "standalone" },
52
+ { type: "text", text: "child", parentId: "__ungrouped_0" },
53
+ ]}
54
+ />,
55
+ );
56
+ expect(
57
+ screen.getAllByTestId("group").map((group) => ({
58
+ parent: group.getAttribute("data-parent"),
59
+ indices: group.getAttribute("data-indices"),
60
+ text: group.textContent,
61
+ })),
62
+ ).toEqual([
63
+ { parent: null, indices: "0", text: "standalone" },
64
+ { parent: "__ungrouped_0", indices: "1", text: "child" },
65
+ ]);
66
+
67
+ rerender(
68
+ <Example
69
+ content={[
70
+ { type: "text", text: "first", parentId: "__ungrouped_parent" },
71
+ { type: "text", text: "standalone" },
72
+ { type: "text", text: "last", parentId: "__ungrouped_parent" },
73
+ { type: "text", text: "numeric", parentId: "1" },
74
+ { type: "text", text: "empty", parentId: "" },
75
+ { type: "text", text: "trailing" },
76
+ ]}
77
+ />,
78
+ );
79
+ expect(
80
+ screen.getAllByTestId("group").map((group) => ({
81
+ parent: group.getAttribute("data-parent"),
82
+ indices: group.getAttribute("data-indices"),
83
+ text: group.textContent,
84
+ })),
85
+ ).toEqual([
86
+ { parent: "__ungrouped_parent", indices: "0,2", text: "firstlast" },
87
+ { parent: null, indices: "1", text: "standalone" },
88
+ { parent: "1", indices: "3", text: "numeric" },
89
+ { parent: "", indices: "4", text: "empty" },
90
+ { parent: null, indices: "5", text: "trailing" },
91
+ ]);
92
+ });
93
+ });
@@ -44,7 +44,7 @@ const groupMessagePartsByParentId: GroupingFunction = (
44
44
  parts: readonly any[],
45
45
  ): MessagePartGroup[] => {
46
46
  // Map maintains insertion order, so groups appear in order of first occurrence
47
- const groupMap = new Map<string, number[]>();
47
+ const groupMap = new Map<string | number, number[]>();
48
48
 
49
49
  // Process each part in order
50
50
  for (let i = 0; i < parts.length; i++) {
@@ -52,7 +52,7 @@ const groupMessagePartsByParentId: GroupingFunction = (
52
52
  const parentId = part?.parentId as string | undefined;
53
53
 
54
54
  // For parts without parentId, assign a unique group ID to maintain their position
55
- const groupId = parentId ?? `__ungrouped_${i}`;
55
+ const groupId = parentId ?? i;
56
56
 
57
57
  // Get or create the indices array for this group
58
58
  const indices = groupMap.get(groupId) ?? [];
@@ -64,7 +64,7 @@ const groupMessagePartsByParentId: GroupingFunction = (
64
64
  const groups: MessagePartGroup[] = [];
65
65
  for (const [groupId, indices] of groupMap) {
66
66
  // Extract parentId (undefined for ungrouped parts)
67
- const groupKey = groupId.startsWith("__ungrouped_") ? undefined : groupId;
67
+ const groupKey = typeof groupId === "string" ? groupId : undefined;
68
68
  groups.push({ groupKey, indices });
69
69
  }
70
70
 
@@ -94,15 +94,15 @@ export namespace MessagePrimitiveUnstable_PartsGrouped {
94
94
  * ```tsx
95
95
  * // Group by parent ID (default behavior)
96
96
  * groupingFunction={(parts) => {
97
- * const groups = new Map<string, number[]>();
97
+ * const groups = new Map<string | number, number[]>();
98
98
  * parts.forEach((part, i) => {
99
- * const key = part.parentId ?? `__ungrouped_${i}`;
99
+ * const key = part.parentId ?? i;
100
100
  * const indices = groups.get(key) ?? [];
101
101
  * indices.push(i);
102
102
  * groups.set(key, indices);
103
103
  * });
104
104
  * return Array.from(groups.entries()).map(([key, indices]) => ({
105
- * key: key.startsWith("__ungrouped_") ? undefined : key,
105
+ * groupKey: typeof key === "string" ? key : undefined,
106
106
  * indices
107
107
  * }));
108
108
  * }}
@@ -0,0 +1,69 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { createRef } from "react";
4
+ import { render, screen } from "@testing-library/react";
5
+ import { describe, expect, it } from "vitest";
6
+ import { TextMessagePartProvider } from "../../context/providers/TextMessagePartProvider";
7
+ import { MessagePartPrimitiveText } from "./MessagePartText";
8
+
9
+ describe("MessagePartPrimitive.Text", () => {
10
+ it("renders text in the supplied element and composes its props and refs", () => {
11
+ const ref = createRef<HTMLSpanElement>();
12
+ const targetRef = createRef<HTMLElement>();
13
+ const { rerender } = render(
14
+ <TextMessagePartProvider text="Hello">
15
+ <MessagePartPrimitiveText
16
+ smooth={false}
17
+ render={<mark ref={targetRef} className="target" />}
18
+ className="text"
19
+ ref={ref}
20
+ />
21
+ </TextMessagePartProvider>,
22
+ );
23
+
24
+ const text = screen.getByText("Hello");
25
+ expect(text.tagName).toBe("MARK");
26
+ expect(text.classList.contains("target")).toBe(true);
27
+ expect(text.classList.contains("text")).toBe(true);
28
+ expect(text.getAttribute("data-status")).toBe("complete");
29
+ expect(text.hasAttribute("render")).toBe(false);
30
+ expect(ref.current).toBe(text);
31
+ expect(targetRef.current).toBe(text);
32
+
33
+ rerender(
34
+ <TextMessagePartProvider text="Hello again" isRunning>
35
+ <MessagePartPrimitiveText smooth={false} render={<mark />} />
36
+ </TextMessagePartProvider>,
37
+ );
38
+
39
+ const updated = screen.getByText("Hello again");
40
+ expect(updated.tagName).toBe("MARK");
41
+ expect(updated.getAttribute("data-status")).toBe("running");
42
+ });
43
+
44
+ it("keeps the default span and explicit component behavior", () => {
45
+ const spanRef = createRef<HTMLSpanElement>();
46
+ const componentRef = createRef<HTMLElement>();
47
+ render(
48
+ <TextMessagePartProvider text="Hello">
49
+ <MessagePartPrimitiveText ref={spanRef} />
50
+ <MessagePartPrimitiveText component="p" ref={componentRef} />
51
+ <MessagePartPrimitiveText component="p" render={<mark />} />
52
+ <MessagePartPrimitiveText component="p" render={undefined} />
53
+ </TextMessagePartProvider>,
54
+ );
55
+
56
+ expect(spanRef.current?.tagName).toBe("SPAN");
57
+ expect(spanRef.current?.getAttribute("data-status")).toBe("complete");
58
+ expect(componentRef.current?.tagName).toBe("P");
59
+ expect(screen.getAllByText("Hello").map((text) => text.tagName)).toEqual([
60
+ "SPAN",
61
+ "P",
62
+ "MARK",
63
+ "P",
64
+ ]);
65
+ expect(
66
+ screen.getByText("Hello", { selector: "mark" }).hasAttribute("render"),
67
+ ).toBe(false);
68
+ });
69
+ });
@@ -1,11 +1,12 @@
1
1
  "use client";
2
2
 
3
- import type { Primitive } from "../../utils/Primitive";
3
+ import { Primitive, renderSlot } from "../../utils/Primitive";
4
4
  import {
5
5
  type ComponentRef,
6
6
  forwardRef,
7
7
  type ComponentPropsWithoutRef,
8
8
  type ElementType,
9
+ isValidElement,
9
10
  } from "react";
10
11
  import { useMessagePartText } from "./useMessagePartText";
11
12
  import { useSmooth, type SmoothOptions } from "../../utils/smooth/useSmooth";
@@ -26,6 +27,7 @@ export namespace MessagePartPrimitiveText {
26
27
  smooth?: boolean | SmoothOptions;
27
28
  /**
28
29
  * The HTML element or React component to render as.
30
+ * Ignored when a valid `render` element is supplied.
29
31
  * @default "span"
30
32
  */
31
33
  component?: ElementType;
@@ -51,14 +53,25 @@ export namespace MessagePartPrimitiveText {
51
53
  export const MessagePartPrimitiveText = forwardRef<
52
54
  MessagePartPrimitiveText.Element,
53
55
  MessagePartPrimitiveText.Props
54
- >(({ smooth = true, component: Component = "span", ...rest }, forwardedRef) => {
55
- const { text, status } = useSmooth(useMessagePartText(), smooth);
56
+ >(
57
+ (
58
+ { smooth = true, component: Component = Primitive.span, render, ...rest },
59
+ forwardedRef,
60
+ ) => {
61
+ const { text, status } = useSmooth(useMessagePartText(), smooth);
56
62
 
57
- return (
58
- <Component data-status={status.type} {...rest} ref={forwardedRef}>
59
- {text}
60
- </Component>
61
- );
62
- });
63
+ const mergedProps = {
64
+ "data-status": status.type,
65
+ ...rest,
66
+ ref: forwardedRef,
67
+ };
68
+
69
+ if (render && isValidElement(render)) {
70
+ return renderSlot(render, text, mergedProps);
71
+ }
72
+
73
+ return <Component {...mergedProps}>{text}</Component>;
74
+ },
75
+ );
63
76
 
64
77
  MessagePartPrimitiveText.displayName = "MessagePartPrimitive.Text";