@assistant-ui/react 0.15.18 → 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 (100) hide show
  1. package/README.md +7 -1
  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 +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/mcp-apps/app-frame.d.ts.map +1 -1
  8. package/dist/mcp-apps/app-frame.js +17 -12
  9. package/dist/mcp-apps/app-frame.js.map +1 -1
  10. package/dist/mcp-apps/bridge.js +7 -1
  11. package/dist/mcp-apps/bridge.js.map +1 -1
  12. package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
  13. package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
  14. package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
  15. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  16. package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
  17. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  18. package/dist/primitives/composer/ComposerInput.js +3 -8
  19. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  20. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  21. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
  22. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  23. package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
  24. package/dist/primitives/composer/trigger/triggerNavigationResource.js +127 -137
  25. package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
  26. package/dist/primitives/message/MessageParts.js +2 -14
  27. package/dist/primitives/message/MessageParts.js.map +1 -1
  28. package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
  29. package/dist/primitives/message/MessagePartsGrouped.js +2 -2
  30. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  31. package/dist/primitives/messagePart/MessagePartText.d.ts +3 -1
  32. package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
  33. package/dist/primitives/messagePart/MessagePartText.js +13 -36
  34. package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
  35. package/dist/primitives/reasoning/useScrollLock.js +3 -1
  36. package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
  37. package/dist/sandbox-host/SandboxHost.js +22 -4
  38. package/dist/sandbox-host/SandboxHost.js.map +1 -1
  39. package/dist/unstable/useLiveCompletionAdapter.js +24 -2
  40. package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
  41. package/dist/unstable/useMentionAdapter.js +2 -2
  42. package/dist/unstable/useMentionAdapter.js.map +1 -1
  43. package/dist/unstable/useMessageStallDetection.d.ts +4 -5
  44. package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
  45. package/dist/unstable/useMessageStallDetection.js +16 -15
  46. package/dist/unstable/useMessageStallDetection.js.map +1 -1
  47. package/dist/unstable/webmcp/convertTools.d.ts.map +1 -1
  48. package/dist/unstable/webmcp/convertTools.js +54 -3
  49. package/dist/unstable/webmcp/convertTools.js.map +1 -1
  50. package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
  51. package/dist/unstable/webmcp/useWebMcpProvider.js +2 -1
  52. package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
  53. package/dist/utils/Primitive.d.ts +14 -6
  54. package/dist/utils/Primitive.d.ts.map +1 -1
  55. package/dist/utils/Primitive.js +25 -6
  56. package/dist/utils/Primitive.js.map +1 -1
  57. package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
  58. package/dist/utils/getSelectionMessageId.js +17 -3
  59. package/dist/utils/getSelectionMessageId.js.map +1 -1
  60. package/package.json +11 -11
  61. package/src/context/providers/ThreadViewportProvider.tsx +2 -2
  62. package/src/hooks/useToolCallElapsed.ts +3 -0
  63. package/src/index.ts +1 -1
  64. package/src/mcp-apps/app-frame.test.tsx +189 -0
  65. package/src/mcp-apps/app-frame.tsx +31 -22
  66. package/src/mcp-apps/bridge.test.ts +98 -0
  67. package/src/mcp-apps/bridge.ts +7 -1
  68. package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
  69. package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
  70. package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
  71. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
  72. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
  73. package/src/primitives/composer/ComposerInput.test.tsx +22 -0
  74. package/src/primitives/composer/ComposerInput.tsx +5 -9
  75. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
  76. package/src/primitives/composer/trigger/triggerNavigationResource.ts +5 -3
  77. package/src/primitives/message/MessageParts.tsx +1 -21
  78. package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
  79. package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
  80. package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
  81. package/src/primitives/messagePart/MessagePartText.tsx +22 -9
  82. package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
  83. package/src/primitives/reasoning/useScrollLock.ts +15 -1
  84. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
  85. package/src/sandbox-host/SandboxHost.test.tsx +40 -0
  86. package/src/sandbox-host/SandboxHost.tsx +24 -4
  87. package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
  88. package/src/tests/toolCallTiming.test.tsx +55 -1
  89. package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
  90. package/src/unstable/useLiveCompletionAdapter.ts +38 -8
  91. package/src/unstable/useMentionAdapter.test.tsx +47 -1
  92. package/src/unstable/useMentionAdapter.ts +2 -2
  93. package/src/unstable/useMessageStallDetection.ts +26 -19
  94. package/src/unstable/webmcp/convertTools.test.ts +181 -21
  95. package/src/unstable/webmcp/convertTools.ts +87 -7
  96. package/src/unstable/webmcp/useWebMcpProvider.ts +2 -4
  97. package/src/utils/Primitive.test.tsx +46 -1
  98. package/src/utils/Primitive.tsx +37 -7
  99. package/src/utils/getSelectionMessageId.test.ts +176 -1
  100. package/src/utils/getSelectionMessageId.ts +21 -3
@@ -1,9 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { type FC, useEffect, useState } from "react";
3
+ import { type FC, useState } from "react";
4
4
  import { Popover as PopoverPrimitive } from "radix-ui";
5
5
  import { type ScopedProps, usePopoverScope } from "./scope";
6
- import { useAui } from "@assistant-ui/store";
6
+ import { useAuiEvent } from "@assistant-ui/store";
7
7
 
8
8
  export namespace AssistantModalPrimitiveRoot {
9
9
  export type Props = PopoverPrimitive.PopoverProps & {
@@ -11,54 +11,34 @@ export namespace AssistantModalPrimitiveRoot {
11
11
  };
12
12
  }
13
13
 
14
- const useAssistantModalOpenState = ({
15
- defaultOpen = false,
16
- unstable_openOnRunStart = true,
17
- }: {
18
- defaultOpen?: boolean | undefined;
19
- unstable_openOnRunStart?: boolean | undefined;
20
- }) => {
21
- const state = useState(defaultOpen);
22
-
23
- const [, setOpen] = state;
24
- const aui = useAui();
25
- useEffect(() => {
26
- if (!unstable_openOnRunStart) return undefined;
27
-
28
- return aui.on("thread.runStart", () => {
29
- setOpen(true);
30
- });
31
- }, [unstable_openOnRunStart, aui, setOpen]);
32
-
33
- return state;
34
- };
35
-
36
14
  export const AssistantModalPrimitiveRoot: FC<
37
15
  AssistantModalPrimitiveRoot.Props
38
16
  > = ({
39
17
  __scopeAssistantModal,
40
- defaultOpen,
41
- unstable_openOnRunStart,
18
+ defaultOpen = false,
19
+ unstable_openOnRunStart = true,
42
20
  open,
43
21
  onOpenChange,
44
22
  ...rest
45
23
  }: ScopedProps<AssistantModalPrimitiveRoot.Props>) => {
46
24
  const scope = usePopoverScope(__scopeAssistantModal);
47
25
 
48
- const [modalOpen, setOpen] = useAssistantModalOpenState({
49
- defaultOpen,
50
- unstable_openOnRunStart,
51
- });
26
+ const [modalOpen, setOpen] = useState(defaultOpen);
27
+ const isOpen = open ?? modalOpen;
52
28
 
53
29
  const openChangeHandler = (open: boolean) => {
54
30
  onOpenChange?.(open);
55
31
  setOpen(open);
56
32
  };
57
33
 
34
+ useAuiEvent("thread.runStart", () => {
35
+ if (unstable_openOnRunStart && !isOpen) openChangeHandler(true);
36
+ });
37
+
58
38
  return (
59
39
  <PopoverPrimitive.Root
60
40
  {...scope}
61
- open={open === undefined ? modalOpen : open}
41
+ open={isOpen}
62
42
  onOpenChange={openChangeHandler}
63
43
  {...rest}
64
44
  />
@@ -1,7 +1,6 @@
1
1
  import { renderToStaticMarkup } from "react-dom/server";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import { AssistantModalPrimitiveAnchor } from "./AssistantModalAnchor";
4
- import { AssistantModalPrimitiveContent } from "./AssistantModalContent";
5
4
  import { AssistantModalPrimitiveRoot } from "./AssistantModalRoot";
6
5
  import { AssistantModalPrimitiveTrigger } from "./AssistantModalTrigger";
7
6
 
@@ -9,9 +8,7 @@ vi.mock("@assistant-ui/store", async (importOriginal) => {
9
8
  const actual = await importOriginal<typeof import("@assistant-ui/store")>();
10
9
  return {
11
10
  ...actual,
12
- useAui: () => ({
13
- on: () => () => {},
14
- }),
11
+ useAuiEvent: () => {},
15
12
  };
16
13
  });
17
14
 
@@ -65,16 +62,4 @@ describe("AssistantModalPrimitive render props", () => {
65
62
  expect(renderHtml).toContain('class="anchor"');
66
63
  expect(renderHtml).toContain("Anchor");
67
64
  });
68
-
69
- it("accepts render on Trigger, Anchor, and Content", () => {
70
- const tree = (
71
- <AssistantModalPrimitiveRoot open>
72
- <AssistantModalPrimitiveTrigger render={<button type="button" />} />
73
- <AssistantModalPrimitiveAnchor render={<div />} />
74
- <AssistantModalPrimitiveContent render={<div />} />
75
- </AssistantModalPrimitiveRoot>
76
- );
77
-
78
- expect(tree).toBeTruthy();
79
- });
80
65
  });
@@ -74,6 +74,49 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
74
74
  vi.restoreAllMocks();
75
75
  });
76
76
 
77
+ it("composes a render element and keeps the drag props attached", async () => {
78
+ await act(async () => {
79
+ root.render(
80
+ <ComposerPrimitiveAttachmentDropzone
81
+ data-testid="dropzone"
82
+ render={<section className="child" />}
83
+ className="parent"
84
+ >
85
+ <div>outer</div>
86
+ </ComposerPrimitiveAttachmentDropzone>,
87
+ );
88
+ });
89
+
90
+ const dropzone = container.querySelector(
91
+ "section[data-testid='dropzone']",
92
+ ) as HTMLElement;
93
+ expect(dropzone).not.toBeNull();
94
+ expect(dropzone.textContent).toBe("outer");
95
+ expect(dropzone.className).toContain("parent");
96
+ expect(dropzone.className).toContain("child");
97
+
98
+ await act(async () => {
99
+ dropzone.dispatchEvent(createDragEvent("dragenter", ["Files"]));
100
+ });
101
+
102
+ expect(dropzone.getAttribute("data-dragging")).toBe("true");
103
+ });
104
+
105
+ it("falls back to the render element's own children", async () => {
106
+ await act(async () => {
107
+ root.render(
108
+ <ComposerPrimitiveAttachmentDropzone
109
+ data-testid="dropzone"
110
+ render={<section>fallback</section>}
111
+ />,
112
+ );
113
+ });
114
+
115
+ expect(
116
+ container.querySelector("section[data-testid='dropzone']")?.textContent,
117
+ ).toBe("fallback");
118
+ });
119
+
77
120
  it("starts all dropped attachments before awaiting completion", async () => {
78
121
  const resolvers: Array<() => void> = [];
79
122
  addAttachment.mockImplementation(
@@ -5,7 +5,6 @@ import {
5
5
  useCallback,
6
6
  useState,
7
7
  type ReactElement,
8
- cloneElement,
9
8
  isValidElement,
10
9
  } from "react";
11
10
 
@@ -13,6 +12,7 @@ import { composeEventHandlers } from "radix-ui/internal";
13
12
  import { Slot } from "radix-ui";
14
13
  import type React from "react";
15
14
  import { useAui } from "@assistant-ui/store";
15
+ import { renderSlot } from "../../utils/Primitive";
16
16
 
17
17
  export namespace ComposerPrimitiveAttachmentDropzone {
18
18
  export type Element = HTMLDivElement;
@@ -116,15 +116,7 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
116
116
  };
117
117
 
118
118
  if (render && isValidElement(render)) {
119
- const renderChildren =
120
- children !== undefined
121
- ? children
122
- : (render.props as Record<string, unknown>).children;
123
- return (
124
- <Slot.Root {...mergedProps}>
125
- {cloneElement(render, undefined, renderChildren as React.ReactNode)}
126
- </Slot.Root>
127
- );
119
+ return renderSlot(render, children, mergedProps);
128
120
  }
129
121
 
130
122
  const Comp = asChild ? Slot.Root : "div";
@@ -240,6 +240,28 @@ describe("ComposerPrimitiveInput", () => {
240
240
  return textarea;
241
241
  };
242
242
 
243
+ it("composes a render element with the computed input props", async () => {
244
+ await act(async () => {
245
+ root.render(
246
+ <form>
247
+ <ComposerPrimitiveInput
248
+ render={<textarea data-testid="custom" className="child" />}
249
+ className="parent"
250
+ />
251
+ </form>,
252
+ );
253
+ });
254
+
255
+ const textarea = container.querySelector(
256
+ "textarea[data-testid='custom']",
257
+ ) as HTMLTextAreaElement;
258
+ expect(textarea).not.toBeNull();
259
+ expect(textarea.className).toContain("parent");
260
+ expect(textarea.className).toContain("child");
261
+ expect(textarea.name).toBe("input");
262
+ expect(textarea.hasAttribute("render")).toBe(false);
263
+ });
264
+
243
265
  it("syncs setText during active composition so React 19 cannot reset the textarea", async () => {
244
266
  const textarea = await mount();
245
267
 
@@ -12,7 +12,6 @@ import {
12
12
  useCallback,
13
13
  useEffect,
14
14
  useRef,
15
- cloneElement,
16
15
  isValidElement,
17
16
  } from "react";
18
17
  import TextareaAutosize, {
@@ -22,6 +21,7 @@ import TextareaAutosize, {
22
21
  import { useEscapeKeydown } from "radix-ui/internal";
23
22
  import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
24
23
  import { useMediaQuery } from "../../utils/hooks/useMediaQuery";
24
+ import { renderSlot } from "../../utils/Primitive";
25
25
  import { useAui } from "@assistant-ui/store";
26
26
  import { flushTapSync } from "@assistant-ui/tap";
27
27
  import { useComposerInputPluginRegistryOptional } from "./ComposerInputPluginContext";
@@ -434,14 +434,10 @@ export const ComposerPrimitiveInput = forwardRef<
434
434
  };
435
435
 
436
436
  if (render && isValidElement(render)) {
437
- const renderChildren =
438
- (rest as any).children !== undefined
439
- ? ((rest as any).children as ReactNode)
440
- : ((render.props as Record<string, unknown>).children as ReactNode);
441
- return (
442
- <Slot.Root {...inputProps}>
443
- {cloneElement(render, undefined, renderChildren)}
444
- </Slot.Root>
437
+ return renderSlot(
438
+ render,
439
+ (rest as { children?: ReactNode }).children,
440
+ inputProps,
445
441
  );
446
442
  }
447
443
 
@@ -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,4 +1,4 @@
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,
@@ -41,10 +41,12 @@ const useTriggerNavigationResource = ({
41
41
  open: boolean;
42
42
  }): TriggerNavigationResourceOutput => {
43
43
  const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
44
+ const [wasOpen, setWasOpen] = useState(open);
44
45
 
45
- useEffect(() => {
46
+ if (wasOpen !== open) {
47
+ setWasOpen(open);
46
48
  if (!open) setActiveCategoryId(null);
47
- }, [open]);
49
+ }
48
50
 
49
51
  const categories = useMemo<readonly Unstable_TriggerCategory[]>(() => {
50
52
  if (!open || !adapter) return [];
@@ -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";