@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.
- package/README.md +7 -1
- package/dist/context/providers/ThreadViewportProvider.js +2 -2
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +17 -12
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.js +7 -1
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +3 -8
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +127 -137
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/message/MessageParts.js +2 -14
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
- package/dist/primitives/message/MessagePartsGrouped.js +2 -2
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts +3 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js +13 -36
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/reasoning/useScrollLock.js +3 -1
- package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +22 -4
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +24 -2
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.js +2 -2
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts +4 -5
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +16 -15
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/webmcp/convertTools.d.ts.map +1 -1
- package/dist/unstable/webmcp/convertTools.js +54 -3
- package/dist/unstable/webmcp/convertTools.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js +2 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/Primitive.d.ts +14 -6
- package/dist/utils/Primitive.d.ts.map +1 -1
- package/dist/utils/Primitive.js +25 -6
- package/dist/utils/Primitive.js.map +1 -1
- package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
- package/dist/utils/getSelectionMessageId.js +17 -3
- package/dist/utils/getSelectionMessageId.js.map +1 -1
- package/package.json +11 -11
- package/src/context/providers/ThreadViewportProvider.tsx +2 -2
- package/src/hooks/useToolCallElapsed.ts +3 -0
- package/src/index.ts +1 -1
- package/src/mcp-apps/app-frame.test.tsx +189 -0
- package/src/mcp-apps/app-frame.tsx +31 -22
- package/src/mcp-apps/bridge.test.ts +98 -0
- package/src/mcp-apps/bridge.ts +7 -1
- package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
- package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
- package/src/primitives/composer/ComposerInput.test.tsx +22 -0
- package/src/primitives/composer/ComposerInput.tsx +5 -9
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +5 -3
- package/src/primitives/message/MessageParts.tsx +1 -21
- package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
- package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
- package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
- package/src/primitives/messagePart/MessagePartText.tsx +22 -9
- package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
- package/src/primitives/reasoning/useScrollLock.ts +15 -1
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
- package/src/sandbox-host/SandboxHost.test.tsx +40 -0
- package/src/sandbox-host/SandboxHost.tsx +24 -4
- package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
- package/src/tests/toolCallTiming.test.tsx +55 -1
- package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
- package/src/unstable/useLiveCompletionAdapter.ts +38 -8
- package/src/unstable/useMentionAdapter.test.tsx +47 -1
- package/src/unstable/useMentionAdapter.ts +2 -2
- package/src/unstable/useMessageStallDetection.ts +26 -19
- package/src/unstable/webmcp/convertTools.test.ts +181 -21
- package/src/unstable/webmcp/convertTools.ts +87 -7
- package/src/unstable/webmcp/useWebMcpProvider.ts +2 -4
- package/src/utils/Primitive.test.tsx +46 -1
- package/src/utils/Primitive.tsx +37 -7
- package/src/utils/getSelectionMessageId.test.ts +176 -1
- package/src/utils/getSelectionMessageId.ts +21 -3
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { type FC,
|
|
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 {
|
|
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] =
|
|
49
|
-
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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 {
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
}
|
|
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 {
|
|
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
|
-
|
|
46
|
+
if (wasOpen !== open) {
|
|
47
|
+
setWasOpen(open);
|
|
46
48
|
if (!open) setActiveCategoryId(null);
|
|
47
|
-
}
|
|
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 ??
|
|
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
|
|
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 ??
|
|
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
|
-
*
|
|
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
|
|
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
|
-
>(
|
|
55
|
-
|
|
56
|
+
>(
|
|
57
|
+
(
|
|
58
|
+
{ smooth = true, component: Component = Primitive.span, render, ...rest },
|
|
59
|
+
forwardedRef,
|
|
60
|
+
) => {
|
|
61
|
+
const { text, status } = useSmooth(useMessagePartText(), smooth);
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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";
|