@assistant-ui/react 0.15.23 → 0.15.25
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 +44 -23
- package/dist/assistant-transport.js.map +1 -1
- package/dist/cloud-renderer/CloudRendererHost.js.map +1 -1
- package/dist/context/providers/MessageProvider.js.map +1 -1
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/hooks/useToolCallElapsed.d.ts +3 -1
- package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
- package/dist/hooks/useToolCallElapsed.js +7 -8
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -1
- package/dist/model-context/makeAssistantVisible.d.ts +1 -0
- package/dist/model-context/makeAssistantVisible.d.ts.map +1 -1
- package/dist/model-context/makeAssistantVisible.js +31 -12
- package/dist/model-context/makeAssistantVisible.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarCopy.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarEdit.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarReload.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarSpeak.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarStopSpeaking.js.map +1 -1
- package/dist/primitives/actionBar/useActionBarFloatStatus.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreContent.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreItem.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreRoot.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js.map +1 -1
- package/dist/primitives/actionBarMore/scope.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalAnchor.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalContent.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalTrigger.js.map +1 -1
- package/dist/primitives/assistantModal/popoverRenderPrimitives.js.map +1 -1
- package/dist/primitives/assistantModal/scope.js.map +1 -1
- package/dist/primitives/attachment/AttachmentName.js.map +1 -1
- package/dist/primitives/attachment/AttachmentRemove.js.map +1 -1
- package/dist/primitives/attachment/AttachmentRoot.js.map +1 -1
- package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerCount.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerNext.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerNumber.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerPrevious.js.map +1 -1
- package/dist/primitives/branchPicker/BranchPickerRoot.js.map +1 -1
- package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
- package/dist/primitives/chainOfThought/ChainOfThoughtRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
- package/dist/primitives/composer/ComposerCancel.js.map +1 -1
- package/dist/primitives/composer/ComposerDictate.js.map +1 -1
- package/dist/primitives/composer/ComposerDictationTranscript.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +4 -3
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
- package/dist/primitives/composer/ComposerQuote.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerSend.js.map +1 -1
- package/dist/primitives/composer/ComposerStopDictation.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts +11 -11
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js +62 -60
- package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +6 -6
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverBack.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts +3 -3
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts +9 -9
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +7 -7
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts +5 -5
- package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
- package/dist/primitives/composer/trigger/index.d.ts +1 -1
- package/dist/primitives/composer/trigger/index.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/composer/useComposerInputState.js.map +1 -1
- package/dist/primitives/composer.d.ts +20 -1
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js +8 -1
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/dropdownMenuRenderPrimitives.js.map +1 -1
- package/dist/primitives/error/ErrorMessage.js.map +1 -1
- package/dist/primitives/error/ErrorRoot.js.map +1 -1
- package/dist/primitives/message/MessageIf.js.map +1 -1
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +42 -17
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/message/MessageRoot.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartImage.js.map +1 -1
- package/dist/primitives/queueItem/QueueItemRemove.js.map +1 -1
- package/dist/primitives/queueItem/QueueItemSteer.js.map +1 -1
- package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
- package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionDescription.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionTitle.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
- package/dist/primitives/thread/ThreadIf.js.map +1 -1
- package/dist/primitives/thread/ThreadLoadEarlier.d.ts +19 -0
- package/dist/primitives/thread/ThreadLoadEarlier.d.ts.map +1 -0
- package/dist/primitives/thread/ThreadLoadEarlier.js +36 -0
- package/dist/primitives/thread/ThreadLoadEarlier.js.map +1 -0
- package/dist/primitives/thread/ThreadMessages.d.ts +1 -1
- package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadMessages.js +2 -2
- package/dist/primitives/thread/ThreadRoot.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadRoot.js +2 -0
- package/dist/primitives/thread/ThreadRoot.js.map +1 -1
- package/dist/primitives/thread/ThreadScrollToBottom.js.map +1 -1
- package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +111 -2
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/primitives/thread.d.ts +2 -0
- package/dist/primitives/thread.d.ts.map +1 -1
- package/dist/primitives/thread.js +5 -2
- package/dist/primitives/thread.js.map +1 -1
- package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -1
- package/dist/primitives/threadList/ThreadListNew.js.map +1 -1
- package/dist/primitives/threadList/ThreadListRoot.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemArchive.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemDelete.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemRoot.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemTrigger.js.map +1 -1
- package/dist/primitives/threadListItem/ThreadListItemUnarchive.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreRoot.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js.map +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js.map +1 -1
- package/dist/primitives/threadListItemMore/scope.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +6 -1
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +10 -6
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/useModelContextSnapshot.js.map +1 -1
- package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/Primitive.js.map +1 -1
- package/dist/utils/createActionButton.js.map +1 -1
- package/dist/utils/hooks/useManagedRef.js.map +1 -1
- package/dist/utils/hooks/useMediaQuery.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
- package/dist/utils/hooks/useOnScrollToBottom.js.map +1 -1
- package/dist/utils/hooks/useSizeHandle.js.map +1 -1
- package/dist/utils/isCompositionKey.d.ts +2 -0
- package/dist/utils/isCompositionKey.d.ts.map +1 -0
- package/dist/utils/isCompositionKey.js +6 -0
- package/dist/utils/isCompositionKey.js.map +1 -0
- package/dist/utils/smooth/SmoothContext.js.map +1 -1
- package/dist/utils/smooth/useSmooth.js +6 -6
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/package.json +12 -11
- package/src/hooks/useToolCallElapsed.test.tsx +35 -0
- package/src/hooks/useToolCallElapsed.ts +9 -7
- package/src/index.ts +28 -3
- package/src/model-context/makeAssistantVisible.test.tsx +315 -5
- package/src/model-context/makeAssistantVisible.tsx +43 -7
- package/src/primitives/composer/ComposerInput.test.tsx +33 -1
- package/src/primitives/composer/ComposerInput.tsx +4 -3
- package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -11
- package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +10 -13
- package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +2 -2
- package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +11 -14
- package/src/primitives/composer/trigger/TriggerPopoverExports.test.ts +61 -0
- package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +3 -3
- package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +8 -8
- package/src/primitives/composer.ts +21 -1
- package/src/primitives/message/MessagePartsGrouped.test.tsx +271 -1
- package/src/primitives/message/MessagePartsGrouped.tsx +14 -6
- package/src/primitives/message/MessageRoot.test.tsx +125 -2
- package/src/primitives/thread/ThreadLoadEarlier.tsx +35 -0
- package/src/primitives/thread/ThreadMessages.tsx +1 -0
- package/src/primitives/thread/ThreadRoot.test.tsx +33 -0
- package/src/primitives/thread/ThreadRoot.tsx +3 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +256 -5
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +147 -1
- package/src/primitives/thread.ts +2 -0
- package/src/sandbox-host/SandboxHost.test.tsx +34 -0
- package/src/sandbox-host/SandboxHost.tsx +8 -4
- package/src/tests/toolCallTiming.test.tsx +19 -2
- package/src/unstable/useMessageStallDetection.test.tsx +40 -0
- package/src/unstable/useMessageStallDetection.ts +10 -6
- package/src/utils/isCompositionKey.ts +5 -0
- package/src/utils/smooth/useSmooth.test.tsx +16 -5
- package/src/utils/smooth/useSmooth.ts +7 -7
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
4
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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={
|
|
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
|
};
|
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
import { act, render } from "@testing-library/react";
|
|
4
4
|
import { createContext, useContext } from "react";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import { hydrateRoot } from "react-dom/client";
|
|
7
|
+
import { renderToString } from "react-dom/server";
|
|
5
8
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
ChatModelAdapter,
|
|
11
|
+
ThreadMessageLike,
|
|
12
|
+
ThreadMessage,
|
|
13
|
+
} from "@assistant-ui/core";
|
|
7
14
|
import {
|
|
8
15
|
AssistantRuntimeProvider,
|
|
16
|
+
ThreadListItemRuntimeProvider,
|
|
9
17
|
useExternalStoreRuntime,
|
|
18
|
+
useLocalRuntime,
|
|
10
19
|
} from "@assistant-ui/core/react";
|
|
11
20
|
import { useAuiState } from "@assistant-ui/store";
|
|
12
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
ThreadPrimitiveMessageByIndex,
|
|
23
|
+
ThreadPrimitiveMessages,
|
|
24
|
+
} from "../thread/ThreadMessages";
|
|
13
25
|
import { ThreadPrimitiveRoot } from "../thread/ThreadRoot";
|
|
14
26
|
import { ThreadPrimitiveViewport } from "../thread/ThreadViewport";
|
|
15
27
|
import { MessagePrimitiveRoot } from "./MessageRoot";
|
|
@@ -22,6 +34,14 @@ const messages: ThreadMessageLike[] = [
|
|
|
22
34
|
},
|
|
23
35
|
];
|
|
24
36
|
|
|
37
|
+
const initialMessages: readonly ThreadMessageLike[] = [
|
|
38
|
+
{ role: "user", content: "Hydrated message" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const chatModel: ChatModelAdapter = {
|
|
42
|
+
run: async () => ({ content: [] }),
|
|
43
|
+
};
|
|
44
|
+
|
|
25
45
|
class TestResizeObserver {
|
|
26
46
|
observe() {}
|
|
27
47
|
unobserve() {}
|
|
@@ -62,6 +82,64 @@ const Example = ({ visible = true }: { visible?: boolean }) => {
|
|
|
62
82
|
);
|
|
63
83
|
};
|
|
64
84
|
|
|
85
|
+
const HydrationMessage = () => (
|
|
86
|
+
<MessagePrimitiveRoot data-testid="hydration-message" />
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const LocalThread = ({
|
|
90
|
+
onRuntime,
|
|
91
|
+
turnAnchor,
|
|
92
|
+
}: {
|
|
93
|
+
onRuntime: (runtime: ReturnType<typeof useLocalRuntime>) => void;
|
|
94
|
+
turnAnchor: "bottom" | "top";
|
|
95
|
+
}) => {
|
|
96
|
+
const runtime = useLocalRuntime(chatModel, { initialMessages });
|
|
97
|
+
onRuntime(runtime);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
101
|
+
<ThreadPrimitiveRoot>
|
|
102
|
+
<ThreadPrimitiveViewport
|
|
103
|
+
scrollToBottomOnInitialize={false}
|
|
104
|
+
turnAnchor={turnAnchor}
|
|
105
|
+
>
|
|
106
|
+
<ThreadPrimitiveMessages components={{ Message: HydrationMessage }} />
|
|
107
|
+
</ThreadPrimitiveViewport>
|
|
108
|
+
</ThreadPrimitiveRoot>
|
|
109
|
+
</AssistantRuntimeProvider>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const NestedLocalRuntime = ({
|
|
114
|
+
onRuntime,
|
|
115
|
+
turnAnchor,
|
|
116
|
+
}: {
|
|
117
|
+
onRuntime: (runtime: ReturnType<typeof useLocalRuntime>) => void;
|
|
118
|
+
turnAnchor: "bottom" | "top";
|
|
119
|
+
}) => {
|
|
120
|
+
const host = useExternalStoreRuntime<ThreadMessage>({
|
|
121
|
+
messages: [],
|
|
122
|
+
onNew: async () => {},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<AssistantRuntimeProvider runtime={host}>
|
|
127
|
+
<ThreadListItemRuntimeProvider runtime={host.threads.mainItem}>
|
|
128
|
+
<LocalThread onRuntime={onRuntime} turnAnchor={turnAnchor} />
|
|
129
|
+
</ThreadListItemRuntimeProvider>
|
|
130
|
+
</AssistantRuntimeProvider>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const renderOnServer = (node: ReactNode) => {
|
|
135
|
+
vi.stubGlobal("document", undefined);
|
|
136
|
+
try {
|
|
137
|
+
return renderToString(node);
|
|
138
|
+
} finally {
|
|
139
|
+
vi.unstubAllGlobals();
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
65
143
|
beforeEach(() => {
|
|
66
144
|
vi.stubGlobal("ResizeObserver", TestResizeObserver);
|
|
67
145
|
});
|
|
@@ -82,6 +160,51 @@ const mockHoveredElement = () => {
|
|
|
82
160
|
};
|
|
83
161
|
|
|
84
162
|
describe("MessagePrimitiveRoot", () => {
|
|
163
|
+
it.each(["bottom", "top"] as const)(
|
|
164
|
+
"hydrates nested local message ids with %s anchoring",
|
|
165
|
+
async (turnAnchor) => {
|
|
166
|
+
let serverRuntime: ReturnType<typeof useLocalRuntime> | undefined;
|
|
167
|
+
const html = renderOnServer(
|
|
168
|
+
<NestedLocalRuntime
|
|
169
|
+
onRuntime={(runtime) => (serverRuntime = runtime)}
|
|
170
|
+
turnAnchor={turnAnchor}
|
|
171
|
+
/>,
|
|
172
|
+
);
|
|
173
|
+
const serverMessageId = serverRuntime!.thread.getState().messages[0]!.id;
|
|
174
|
+
const container = document.createElement("div");
|
|
175
|
+
container.innerHTML = html;
|
|
176
|
+
vi.stubGlobal("ResizeObserver", TestResizeObserver);
|
|
177
|
+
|
|
178
|
+
let clientRuntime: ReturnType<typeof useLocalRuntime> | undefined;
|
|
179
|
+
const errors = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
180
|
+
let root: ReturnType<typeof hydrateRoot> | undefined;
|
|
181
|
+
await act(async () => {
|
|
182
|
+
root = hydrateRoot(
|
|
183
|
+
container,
|
|
184
|
+
<NestedLocalRuntime
|
|
185
|
+
onRuntime={(runtime) => (clientRuntime = runtime)}
|
|
186
|
+
turnAnchor={turnAnchor}
|
|
187
|
+
/>,
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const clientMessageId = clientRuntime!.thread.getState().messages[0]!.id;
|
|
192
|
+
const domMessageId = container
|
|
193
|
+
.querySelector("[data-testid='hydration-message']")!
|
|
194
|
+
.getAttribute("data-message-id");
|
|
195
|
+
|
|
196
|
+
expect(clientMessageId).toBe(serverMessageId);
|
|
197
|
+
expect(domMessageId).toBe(clientMessageId);
|
|
198
|
+
expect(
|
|
199
|
+
errors.mock.calls.some((args) =>
|
|
200
|
+
args.some((value) => String(value).includes("data-message-id")),
|
|
201
|
+
),
|
|
202
|
+
).toBe(false);
|
|
203
|
+
|
|
204
|
+
await act(async () => root?.unmount());
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
|
|
85
208
|
it("synchronizes hover state while mounted", async () => {
|
|
86
209
|
mockHoveredElement();
|
|
87
210
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import { useAui, useAuiState } from "@assistant-ui/store";
|
|
5
|
+
import {
|
|
6
|
+
type ActionButtonElement,
|
|
7
|
+
type ActionButtonProps,
|
|
8
|
+
createActionButton,
|
|
9
|
+
} from "../../utils/createActionButton";
|
|
10
|
+
|
|
11
|
+
const useThreadLoadEarlier = () => {
|
|
12
|
+
const aui = useAui();
|
|
13
|
+
const hasEarlier = useAuiState((s) => s.thread.hasEarlier);
|
|
14
|
+
const loadEarlier = useCallback(() => {
|
|
15
|
+
void aui.thread.loadEarlier();
|
|
16
|
+
}, [aui]);
|
|
17
|
+
if (!hasEarlier) return null;
|
|
18
|
+
return loadEarlier;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export namespace ThreadPrimitiveLoadEarlier {
|
|
22
|
+
export type Element = ActionButtonElement;
|
|
23
|
+
export type Props = ActionButtonProps<typeof useThreadLoadEarlier>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A button that loads the page before the first loaded message. Disabled
|
|
28
|
+
* while the runtime reports no earlier messages. A press while a page is
|
|
29
|
+
* loading joins that load, so the button stays enabled and keeps keyboard
|
|
30
|
+
* focus until the page lands; read `thread.isLoadingEarlier` to show progress.
|
|
31
|
+
*/
|
|
32
|
+
export const ThreadPrimitiveLoadEarlier = createActionButton(
|
|
33
|
+
"ThreadPrimitive.LoadEarlier",
|
|
34
|
+
useThreadLoadEarlier,
|
|
35
|
+
);
|
|
@@ -357,6 +357,39 @@ describe("ThreadPrimitiveRoot", () => {
|
|
|
357
357
|
expect(event.defaultPrevented).toBe(true);
|
|
358
358
|
});
|
|
359
359
|
|
|
360
|
+
it.each([
|
|
361
|
+
["during the composition", { isComposing: true }],
|
|
362
|
+
["after compositionend (Safari)", { keyCode: 229 }],
|
|
363
|
+
])(
|
|
364
|
+
"keeps speech playing when Escape ends an IME composition %s",
|
|
365
|
+
async (_, init) => {
|
|
366
|
+
const speech = createSpeechAdapter();
|
|
367
|
+
const runtimeRef: RuntimeRef = { current: null };
|
|
368
|
+
render(
|
|
369
|
+
<RuntimeProvider runtimeRef={runtimeRef} speech={speech.adapter}>
|
|
370
|
+
<ThreadPrimitiveRoot>
|
|
371
|
+
<ComposerPrimitiveInput data-testid="composer" />
|
|
372
|
+
</ThreadPrimitiveRoot>
|
|
373
|
+
</RuntimeProvider>,
|
|
374
|
+
);
|
|
375
|
+
startSpeaking(runtimeRef);
|
|
376
|
+
await waitFor(() => {
|
|
377
|
+
expect(runtimeRef.current!.thread.getState().speech).toBeDefined();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const event = new KeyboardEvent("keydown", {
|
|
381
|
+
key: "Escape",
|
|
382
|
+
bubbles: true,
|
|
383
|
+
cancelable: true,
|
|
384
|
+
...init,
|
|
385
|
+
});
|
|
386
|
+
act(() => screen.getByTestId("composer").dispatchEvent(event));
|
|
387
|
+
|
|
388
|
+
expect(speech.cancel).not.toHaveBeenCalled();
|
|
389
|
+
expect(event.defaultPrevented).toBe(false);
|
|
390
|
+
},
|
|
391
|
+
);
|
|
392
|
+
|
|
360
393
|
it("lets an idle composer fall through so Escape still stops speech", async () => {
|
|
361
394
|
const speech = createSpeechAdapter();
|
|
362
395
|
const runtimeRef: RuntimeRef = { current: null };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Primitive } from "../../utils/Primitive";
|
|
4
|
+
import { isCompositionKey } from "../../utils/isCompositionKey";
|
|
4
5
|
import {
|
|
5
6
|
type ComponentRef,
|
|
6
7
|
forwardRef,
|
|
@@ -64,6 +65,8 @@ export const ThreadPrimitiveRoot = forwardRef<
|
|
|
64
65
|
useEffect(() => {
|
|
65
66
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
66
67
|
if (event.key !== "Escape") return;
|
|
68
|
+
// An Escape that ends an IME composition belongs to the input method.
|
|
69
|
+
if (isCompositionKey(event)) return;
|
|
67
70
|
if (event.defaultPrevented || aui.thread.source === null) return;
|
|
68
71
|
const eventThreadRoot =
|
|
69
72
|
escapeEventThreadRoots.get(event) ??
|