@assistant-ui/react 0.15.4 → 0.15.7
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/dist/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +384 -368
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/context/providers/MessageProvider.js +15 -34
- package/dist/context/providers/MessageProvider.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts +6 -1
- package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts.map +1 -1
- package/dist/legacy-runtime/AssistantRuntimeProvider.js +10 -8
- package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js +8 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts +5 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +44 -3
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
- package/dist/primitives/attachment/AttachmentThumb.d.ts.map +1 -1
- package/dist/primitives/attachment/AttachmentThumb.js +17 -14
- package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +64 -50
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +1 -0
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/queueItem/QueueItemText.d.ts.map +1 -1
- package/dist/primitives/queueItem/QueueItemText.js +10 -4
- package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -4
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/utils/createActionButton.js +1 -1
- package/dist/utils/createActionButton.js.map +1 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
- package/package.json +16 -14
- package/src/client/ExternalThread.ts +30 -17
- package/src/context/providers/MessageProvider.tsx +8 -6
- package/src/index.ts +5 -1
- package/src/legacy-runtime/AssistantRuntimeProvider.tsx +13 -3
- package/src/legacy-runtime/runtime-cores/assistant-transport/runManager.ts +14 -1
- package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +38 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +5 -1
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +7 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +290 -0
- package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +62 -1
- package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +1 -1
- package/src/primitives/attachment/AttachmentThumb.test.tsx +70 -0
- package/src/primitives/attachment/AttachmentThumb.tsx +8 -4
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +103 -3
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +18 -3
- package/src/primitives/composer/ComposerInput.test.tsx +53 -2
- package/src/primitives/composer/ComposerInput.tsx +3 -0
- package/src/primitives/queueItem/QueueItemText.tsx +8 -2
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +74 -0
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +3 -3
- package/src/tests/external-thread-parity.test.tsx +52 -4
- package/src/tests/local-runtime-queue.test.tsx +199 -6
- package/src/utils/createActionButton.test.tsx +18 -0
- package/src/utils/createActionButton.tsx +1 -1
- package/LICENSE +0 -21
|
@@ -6,7 +6,10 @@ import { createRoot, type Root } from "react-dom/client";
|
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
7
|
import { ComposerPrimitiveAttachmentDropzone } from "./ComposerAttachmentDropzone";
|
|
8
8
|
|
|
9
|
-
const addAttachment = vi.
|
|
9
|
+
const { addAttachment, threadCapabilities } = vi.hoisted(() => ({
|
|
10
|
+
addAttachment: vi.fn<(file: File) => Promise<void>>(),
|
|
11
|
+
threadCapabilities: { attachments: true },
|
|
12
|
+
}));
|
|
10
13
|
|
|
11
14
|
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
15
|
|
|
@@ -18,14 +21,26 @@ vi.mock("@assistant-ui/store", async (importOriginal) => {
|
|
|
18
21
|
composer: {
|
|
19
22
|
addAttachment,
|
|
20
23
|
},
|
|
24
|
+
thread: {
|
|
25
|
+
getState: () => ({ capabilities: threadCapabilities }),
|
|
26
|
+
},
|
|
21
27
|
}),
|
|
22
28
|
};
|
|
23
29
|
});
|
|
24
30
|
|
|
25
|
-
const createDropEvent = (files: File[]) => {
|
|
31
|
+
const createDropEvent = (files: File[], types: string[] = ["Files"]) => {
|
|
26
32
|
const event = new Event("drop", { bubbles: true, cancelable: true });
|
|
27
33
|
Object.defineProperty(event, "dataTransfer", {
|
|
28
|
-
value: { files },
|
|
34
|
+
value: { files, types },
|
|
35
|
+
configurable: true,
|
|
36
|
+
});
|
|
37
|
+
return event;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const createDragEvent = (type: string, types: string[]) => {
|
|
41
|
+
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
42
|
+
Object.defineProperty(event, "dataTransfer", {
|
|
43
|
+
value: { files: [], types },
|
|
29
44
|
configurable: true,
|
|
30
45
|
});
|
|
31
46
|
return event;
|
|
@@ -37,6 +52,7 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
|
|
|
37
52
|
|
|
38
53
|
beforeEach(async () => {
|
|
39
54
|
addAttachment.mockReset();
|
|
55
|
+
threadCapabilities.attachments = true;
|
|
40
56
|
container = document.createElement("div");
|
|
41
57
|
document.body.appendChild(container);
|
|
42
58
|
root = createRoot(container);
|
|
@@ -123,4 +139,88 @@ describe("ComposerPrimitiveAttachmentDropzone", () => {
|
|
|
123
139
|
expect(errorSpy).not.toHaveBeenCalled();
|
|
124
140
|
errorSpy.mockRestore();
|
|
125
141
|
});
|
|
142
|
+
|
|
143
|
+
it("highlights on file drags", async () => {
|
|
144
|
+
const dropzone = container.querySelector("[data-testid='dropzone']");
|
|
145
|
+
expect(dropzone).not.toBeNull();
|
|
146
|
+
|
|
147
|
+
const event = createDragEvent("dragenter", ["Files"]);
|
|
148
|
+
await act(async () => {
|
|
149
|
+
dropzone!.dispatchEvent(event);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(dropzone!.getAttribute("data-dragging")).toBe("true");
|
|
153
|
+
expect(event.defaultPrevented).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("ignores non-file drags", async () => {
|
|
157
|
+
const dropzone = container.querySelector("[data-testid='dropzone']");
|
|
158
|
+
expect(dropzone).not.toBeNull();
|
|
159
|
+
|
|
160
|
+
const enter = createDragEvent("dragenter", ["text/plain"]);
|
|
161
|
+
const over = createDragEvent("dragover", ["text/plain"]);
|
|
162
|
+
await act(async () => {
|
|
163
|
+
dropzone!.dispatchEvent(enter);
|
|
164
|
+
dropzone!.dispatchEvent(over);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(dropzone!.hasAttribute("data-dragging")).toBe(false);
|
|
168
|
+
expect(enter.defaultPrevented).toBe(false);
|
|
169
|
+
expect(over.defaultPrevented).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("claims file drags without highlighting when the runtime does not support attachments", async () => {
|
|
173
|
+
threadCapabilities.attachments = false;
|
|
174
|
+
const dropzone = container.querySelector("[data-testid='dropzone']");
|
|
175
|
+
expect(dropzone).not.toBeNull();
|
|
176
|
+
|
|
177
|
+
const enter = createDragEvent("dragenter", ["Files"]);
|
|
178
|
+
const drop = createDropEvent([
|
|
179
|
+
new File(["a"], "first.txt", { type: "text/plain" }),
|
|
180
|
+
]);
|
|
181
|
+
await act(async () => {
|
|
182
|
+
dropzone!.dispatchEvent(enter);
|
|
183
|
+
dropzone!.dispatchEvent(drop);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(dropzone!.hasAttribute("data-dragging")).toBe(false);
|
|
187
|
+
expect(enter.defaultPrevented).toBe(true);
|
|
188
|
+
expect((enter as unknown as DragEvent).dataTransfer!.dropEffect).toBe(
|
|
189
|
+
"none",
|
|
190
|
+
);
|
|
191
|
+
expect(drop.defaultPrevented).toBe(true);
|
|
192
|
+
expect(addAttachment).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("clears the highlight and keeps a file drop claimed when it yields no files", async () => {
|
|
196
|
+
const dropzone = container.querySelector("[data-testid='dropzone']");
|
|
197
|
+
expect(dropzone).not.toBeNull();
|
|
198
|
+
|
|
199
|
+
await act(async () => {
|
|
200
|
+
dropzone!.dispatchEvent(createDragEvent("dragenter", ["Files"]));
|
|
201
|
+
});
|
|
202
|
+
expect(dropzone!.getAttribute("data-dragging")).toBe("true");
|
|
203
|
+
|
|
204
|
+
const drop = createDropEvent([], ["Files"]);
|
|
205
|
+
await act(async () => {
|
|
206
|
+
dropzone!.dispatchEvent(drop);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(dropzone!.hasAttribute("data-dragging")).toBe(false);
|
|
210
|
+
expect(drop.defaultPrevented).toBe(true);
|
|
211
|
+
expect(addAttachment).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("lets non-file drops pass through", async () => {
|
|
215
|
+
const dropzone = container.querySelector("[data-testid='dropzone']");
|
|
216
|
+
expect(dropzone).not.toBeNull();
|
|
217
|
+
|
|
218
|
+
const drop = createDropEvent([], []);
|
|
219
|
+
await act(async () => {
|
|
220
|
+
dropzone!.dispatchEvent(drop);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(drop.defaultPrevented).toBe(false);
|
|
224
|
+
expect(addAttachment).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
126
226
|
});
|
|
@@ -30,22 +30,34 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
|
|
|
30
30
|
const [isDragging, setIsDragging] = useState(false);
|
|
31
31
|
const aui = useAui();
|
|
32
32
|
|
|
33
|
+
// An unprevented file drop navigates the tab to the file, so file drags are
|
|
34
|
+
// claimed via preventDefault even when the runtime does not support attachments.
|
|
33
35
|
const handleDragEnterCapture = useCallback(
|
|
34
36
|
(e: React.DragEvent) => {
|
|
35
37
|
if (disabled) return;
|
|
38
|
+
if (!e.dataTransfer.types.includes("Files")) return;
|
|
36
39
|
e.preventDefault();
|
|
40
|
+
if (!aui.thread.getState().capabilities.attachments) {
|
|
41
|
+
e.dataTransfer.dropEffect = "none";
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
37
44
|
setIsDragging(true);
|
|
38
45
|
},
|
|
39
|
-
[disabled],
|
|
46
|
+
[disabled, aui],
|
|
40
47
|
);
|
|
41
48
|
|
|
42
49
|
const handleDragOverCapture = useCallback(
|
|
43
50
|
(e: React.DragEvent) => {
|
|
44
51
|
if (disabled) return;
|
|
52
|
+
if (!e.dataTransfer.types.includes("Files")) return;
|
|
45
53
|
e.preventDefault();
|
|
54
|
+
if (!aui.thread.getState().capabilities.attachments) {
|
|
55
|
+
e.dataTransfer.dropEffect = "none";
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
46
58
|
if (!isDragging) setIsDragging(true);
|
|
47
59
|
},
|
|
48
|
-
[disabled, isDragging],
|
|
60
|
+
[disabled, isDragging, aui],
|
|
49
61
|
);
|
|
50
62
|
|
|
51
63
|
const handleDragLeaveCapture = useCallback(
|
|
@@ -64,9 +76,12 @@ export const ComposerPrimitiveAttachmentDropzone = forwardRef<
|
|
|
64
76
|
const handleDrop = useCallback(
|
|
65
77
|
async (e: React.DragEvent) => {
|
|
66
78
|
if (disabled) return;
|
|
67
|
-
e.preventDefault();
|
|
68
79
|
setIsDragging(false);
|
|
80
|
+
if (!e.dataTransfer.types.includes("Files")) return;
|
|
81
|
+
e.preventDefault();
|
|
69
82
|
const files = Array.from(e.dataTransfer.files);
|
|
83
|
+
if (!aui.thread.getState().capabilities.attachments || files.length === 0)
|
|
84
|
+
return;
|
|
70
85
|
await Promise.all(
|
|
71
86
|
files.map(async (file) => {
|
|
72
87
|
try {
|
|
@@ -10,6 +10,7 @@ const setText = vi.fn<(text: string) => void>();
|
|
|
10
10
|
const setCursorPosition = vi.fn<(pos: number) => void>();
|
|
11
11
|
const sendSpy = vi.fn<(options?: { steer?: boolean }) => void>();
|
|
12
12
|
const addAttachment = vi.fn<(file: File) => Promise<void>>();
|
|
13
|
+
const cancelSpy = vi.fn<() => void>();
|
|
13
14
|
|
|
14
15
|
const composerState = {
|
|
15
16
|
isEditing: true,
|
|
@@ -41,7 +42,7 @@ vi.mock("@assistant-ui/store", () => {
|
|
|
41
42
|
composer: {
|
|
42
43
|
setText: (text: string) => setText(text),
|
|
43
44
|
getState: () => composerState,
|
|
44
|
-
cancel: () =>
|
|
45
|
+
cancel: () => cancelSpy(),
|
|
45
46
|
send: (options?: { steer?: boolean }) => sendSpy(options),
|
|
46
47
|
addAttachment: (file: File) => addAttachment(file),
|
|
47
48
|
},
|
|
@@ -78,8 +79,12 @@ vi.mock("./trigger/TriggerPopoverRootContext", () => ({
|
|
|
78
79
|
useTriggerPopoverActiveAriaOptional: () => activeAria,
|
|
79
80
|
}));
|
|
80
81
|
|
|
82
|
+
let escapeKeydownHandler: ((event: KeyboardEvent) => void) | null = null;
|
|
83
|
+
|
|
81
84
|
vi.mock("@radix-ui/react-use-escape-keydown", () => ({
|
|
82
|
-
useEscapeKeydown: () => {
|
|
85
|
+
useEscapeKeydown: (handler: (event: KeyboardEvent) => void) => {
|
|
86
|
+
escapeKeydownHandler = handler;
|
|
87
|
+
},
|
|
83
88
|
}));
|
|
84
89
|
|
|
85
90
|
vi.mock("../../utils/hooks/useOnScrollToBottom", () => ({
|
|
@@ -181,16 +186,19 @@ describe("ComposerPrimitiveInput", () => {
|
|
|
181
186
|
setCursorPosition.mockReset();
|
|
182
187
|
sendSpy.mockReset();
|
|
183
188
|
addAttachment.mockReset();
|
|
189
|
+
cancelSpy.mockReset();
|
|
184
190
|
composerState.isEditing = true;
|
|
185
191
|
composerState.text = "";
|
|
186
192
|
composerState.isEmpty = true;
|
|
187
193
|
composerState.canSend = true;
|
|
188
194
|
composerState.dictation = undefined;
|
|
195
|
+
composerState.canCancel = false;
|
|
189
196
|
threadState.isDisabled = false;
|
|
190
197
|
threadState.isRunning = false;
|
|
191
198
|
threadState.capabilities = { queue: false, attachments: false };
|
|
192
199
|
pluginRegistry = null;
|
|
193
200
|
activeAria = null;
|
|
201
|
+
escapeKeydownHandler = null;
|
|
194
202
|
setMatchMedia(false);
|
|
195
203
|
|
|
196
204
|
requestSubmitSpy = vi.fn<(submitter?: HTMLElement | null) => void>();
|
|
@@ -514,6 +522,49 @@ describe("ComposerPrimitiveInput", () => {
|
|
|
514
522
|
});
|
|
515
523
|
});
|
|
516
524
|
|
|
525
|
+
describe("escape behavior", () => {
|
|
526
|
+
const fireEscape = (
|
|
527
|
+
textarea: HTMLTextAreaElement,
|
|
528
|
+
opts: { isComposing?: boolean } = {},
|
|
529
|
+
): KeyboardEvent => {
|
|
530
|
+
const event = new KeyboardEvent("keydown", {
|
|
531
|
+
bubbles: true,
|
|
532
|
+
cancelable: true,
|
|
533
|
+
key: "Escape",
|
|
534
|
+
isComposing: opts.isComposing ?? false,
|
|
535
|
+
});
|
|
536
|
+
textarea.dispatchEvent(event);
|
|
537
|
+
escapeKeydownHandler?.(event);
|
|
538
|
+
return event;
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
it("cancels the composer on Escape when cancellable", async () => {
|
|
542
|
+
composerState.canCancel = true;
|
|
543
|
+
const textarea = await mount();
|
|
544
|
+
|
|
545
|
+
let event!: KeyboardEvent;
|
|
546
|
+
await act(async () => {
|
|
547
|
+
event = fireEscape(textarea);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
expect(cancelSpy).toHaveBeenCalledTimes(1);
|
|
551
|
+
expect(event.defaultPrevented).toBe(true);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("ignores Escape while an IME composition is active", async () => {
|
|
555
|
+
composerState.canCancel = true;
|
|
556
|
+
const textarea = await mount();
|
|
557
|
+
|
|
558
|
+
let event!: KeyboardEvent;
|
|
559
|
+
await act(async () => {
|
|
560
|
+
event = fireEscape(textarea, { isComposing: true });
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
expect(cancelSpy).not.toHaveBeenCalled();
|
|
564
|
+
expect(event.defaultPrevented).toBe(false);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
517
568
|
describe("paste attachments", () => {
|
|
518
569
|
it("attempts every pasted file and prevents default when attachments are supported", async () => {
|
|
519
570
|
threadState.capabilities = { queue: false, attachments: true };
|
|
@@ -199,6 +199,9 @@ export const ComposerPrimitiveInput = forwardRef<
|
|
|
199
199
|
// Only handle ESC if it originated from within this input
|
|
200
200
|
if (!textareaRef.current?.contains(e.target as Node)) return;
|
|
201
201
|
|
|
202
|
+
// ignore IME composition events
|
|
203
|
+
if (e.isComposing) return;
|
|
204
|
+
|
|
202
205
|
// Let registered plugins (mention, slash command, etc.) handle Escape first
|
|
203
206
|
if (pluginRegistry) {
|
|
204
207
|
for (const plugin of pluginRegistry.getPlugins()) {
|
|
@@ -25,11 +25,17 @@ export const QueueItemPrimitiveText = forwardRef<
|
|
|
25
25
|
QueueItemPrimitiveText.Element,
|
|
26
26
|
QueueItemPrimitiveText.Props
|
|
27
27
|
>((props, ref) => {
|
|
28
|
-
const
|
|
28
|
+
const text = useAuiState((s) =>
|
|
29
|
+
// hosts on the pre-parts adapter shape may omit the field at runtime
|
|
30
|
+
(s.queueItem.parts ?? [])
|
|
31
|
+
.filter((part) => part.type === "text")
|
|
32
|
+
.map((part) => part.text)
|
|
33
|
+
.join("\n\n"),
|
|
34
|
+
);
|
|
29
35
|
|
|
30
36
|
return (
|
|
31
37
|
<Primitive.span {...props} ref={ref}>
|
|
32
|
-
{props.children ??
|
|
38
|
+
{props.children ?? text}
|
|
33
39
|
</Primitive.span>
|
|
34
40
|
);
|
|
35
41
|
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import type { MouseEvent } from "react";
|
|
3
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type * as GetSelectionMessageIdModule from "../../utils/getSelectionMessageId";
|
|
6
|
+
import { SelectionToolbarPrimitiveRoot } from "./SelectionToolbarRoot";
|
|
7
|
+
|
|
8
|
+
vi.mock("../../utils/getSelectionMessageId", async (importOriginal) => ({
|
|
9
|
+
...(await importOriginal<typeof GetSelectionMessageIdModule>()),
|
|
10
|
+
getSelectionMessageId: () => "m1",
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
const fakeSelection = {
|
|
14
|
+
isCollapsed: false,
|
|
15
|
+
toString: () => "selected text",
|
|
16
|
+
getRangeAt: () => ({
|
|
17
|
+
getBoundingClientRect: () => ({ top: 100, left: 50, width: 20 }) as DOMRect,
|
|
18
|
+
}),
|
|
19
|
+
} as unknown as Selection;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
|
23
|
+
cb(0);
|
|
24
|
+
return 0;
|
|
25
|
+
});
|
|
26
|
+
vi.spyOn(window, "getSelection").mockReturnValue(fakeSelection);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const setupToolbar = (
|
|
34
|
+
onMouseDown?: (e: MouseEvent<HTMLDivElement>) => void,
|
|
35
|
+
) => {
|
|
36
|
+
render(
|
|
37
|
+
<SelectionToolbarPrimitiveRoot
|
|
38
|
+
data-testid="toolbar"
|
|
39
|
+
onMouseDown={onMouseDown}
|
|
40
|
+
/>,
|
|
41
|
+
);
|
|
42
|
+
fireEvent.mouseUp(document);
|
|
43
|
+
const toolbar = document.querySelector('[data-testid="toolbar"]');
|
|
44
|
+
expect(toolbar).not.toBeNull();
|
|
45
|
+
return toolbar as HTMLElement;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
describe("SelectionToolbarPrimitiveRoot onMouseDown composition", () => {
|
|
49
|
+
it("runs the consumer handler on an un-prevented event before preventing default", () => {
|
|
50
|
+
let observedDefaultPrevented: boolean | undefined;
|
|
51
|
+
const onMouseDown = vi.fn((event: MouseEvent<HTMLDivElement>) => {
|
|
52
|
+
observedDefaultPrevented = event.defaultPrevented;
|
|
53
|
+
});
|
|
54
|
+
const toolbar = setupToolbar(onMouseDown);
|
|
55
|
+
|
|
56
|
+
const notPrevented = fireEvent.mouseDown(toolbar);
|
|
57
|
+
|
|
58
|
+
expect(onMouseDown).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(observedDefaultPrevented).toBe(false);
|
|
60
|
+
expect(notPrevented).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("keeps the event prevented when the consumer prevents default", () => {
|
|
64
|
+
const onMouseDown = vi.fn((event: MouseEvent<HTMLDivElement>) => {
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
});
|
|
67
|
+
const toolbar = setupToolbar(onMouseDown);
|
|
68
|
+
|
|
69
|
+
const notPrevented = fireEvent.mouseDown(toolbar);
|
|
70
|
+
|
|
71
|
+
expect(onMouseDown).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(notPrevented).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Primitive } from "../../utils/Primitive";
|
|
4
|
+
import { composeEventHandlers } from "@radix-ui/primitive";
|
|
4
5
|
import {
|
|
5
6
|
type ComponentPropsWithoutRef,
|
|
6
7
|
type ComponentRef,
|
|
@@ -117,11 +118,10 @@ export const SelectionToolbarPrimitiveRoot = forwardRef<
|
|
|
117
118
|
{...props}
|
|
118
119
|
ref={forwardedRef}
|
|
119
120
|
style={positionStyle}
|
|
120
|
-
onMouseDown={(e) => {
|
|
121
|
+
onMouseDown={composeEventHandlers(onMouseDown, (e) => {
|
|
121
122
|
// Prevent mousedown from clearing the text selection
|
|
122
123
|
e.preventDefault();
|
|
123
|
-
|
|
124
|
-
}}
|
|
124
|
+
})}
|
|
125
125
|
/>
|
|
126
126
|
</SelectionToolbarContext.Provider>,
|
|
127
127
|
document.body,
|
|
@@ -246,6 +246,7 @@ describe("ExternalThread composer", () => {
|
|
|
246
246
|
|
|
247
247
|
it("stamps the thread head as parentId on queue-adapter sends", async () => {
|
|
248
248
|
const enqueue = vi.fn();
|
|
249
|
+
const steer = vi.fn();
|
|
249
250
|
const { aui } = renderThread({
|
|
250
251
|
messages: [
|
|
251
252
|
{
|
|
@@ -260,18 +261,65 @@ describe("ExternalThread composer", () => {
|
|
|
260
261
|
isRunning: true,
|
|
261
262
|
queue: {
|
|
262
263
|
items: [],
|
|
264
|
+
steerItems: [],
|
|
263
265
|
enqueue,
|
|
264
|
-
steer
|
|
266
|
+
steer,
|
|
267
|
+
move: vi.fn(),
|
|
268
|
+
edit: vi.fn(),
|
|
265
269
|
remove: vi.fn(),
|
|
266
|
-
clear: vi.fn(),
|
|
267
270
|
},
|
|
268
271
|
});
|
|
269
272
|
|
|
270
273
|
aui().thread.composer().setText("queued");
|
|
271
274
|
aui().thread.composer().send();
|
|
272
275
|
|
|
273
|
-
|
|
274
|
-
expect(
|
|
276
|
+
// mid-run sends default to the steer lane
|
|
277
|
+
await waitFor(() => expect(steer).toHaveBeenCalledTimes(1));
|
|
278
|
+
expect(steer.mock.calls[0]![0].parentId).toBe("u1");
|
|
279
|
+
expect(enqueue).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("routes edit-composer sends to onEdit with sourceId, bypassing the queue", async () => {
|
|
283
|
+
const onEdit = vi.fn();
|
|
284
|
+
const enqueue = vi.fn();
|
|
285
|
+
const steer = vi.fn();
|
|
286
|
+
const { aui } = renderThread({
|
|
287
|
+
messages: [
|
|
288
|
+
{
|
|
289
|
+
id: "u1",
|
|
290
|
+
role: "user",
|
|
291
|
+
content: [{ type: "text", text: "hi" }],
|
|
292
|
+
createdAt: new Date(0),
|
|
293
|
+
attachments: [],
|
|
294
|
+
metadata: { custom: {} },
|
|
295
|
+
} as unknown as ExternalThreadMessage,
|
|
296
|
+
],
|
|
297
|
+
isRunning: false,
|
|
298
|
+
onEdit,
|
|
299
|
+
queue: {
|
|
300
|
+
items: [],
|
|
301
|
+
steerItems: [],
|
|
302
|
+
enqueue,
|
|
303
|
+
steer,
|
|
304
|
+
move: vi.fn(),
|
|
305
|
+
edit: vi.fn(),
|
|
306
|
+
remove: vi.fn(),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const composer = () => aui().thread.message({ id: "u1" }).composer();
|
|
311
|
+
composer().beginEdit();
|
|
312
|
+
await waitFor(() => expect(composer().getState().isEditing).toBe(true));
|
|
313
|
+
composer().setText("edited");
|
|
314
|
+
composer().send();
|
|
315
|
+
|
|
316
|
+
await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(1));
|
|
317
|
+
expect(onEdit.mock.calls[0]![0]).toMatchObject({
|
|
318
|
+
sourceId: "u1",
|
|
319
|
+
content: [{ type: "text", text: "edited" }],
|
|
320
|
+
});
|
|
321
|
+
expect(enqueue).not.toHaveBeenCalled();
|
|
322
|
+
expect(steer).not.toHaveBeenCalled();
|
|
275
323
|
});
|
|
276
324
|
|
|
277
325
|
it("still refuses to send an empty composer synchronously after a send", async () => {
|