@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.
Files changed (64) hide show
  1. package/dist/client/ExternalThread.d.ts.map +1 -1
  2. package/dist/client/ExternalThread.js +384 -368
  3. package/dist/client/ExternalThread.js.map +1 -1
  4. package/dist/context/providers/MessageProvider.js +15 -34
  5. package/dist/context/providers/MessageProvider.js.map +1 -1
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +2 -2
  8. package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts +6 -1
  9. package/dist/legacy-runtime/AssistantRuntimeProvider.d.ts.map +1 -1
  10. package/dist/legacy-runtime/AssistantRuntimeProvider.js +10 -8
  11. package/dist/legacy-runtime/AssistantRuntimeProvider.js.map +1 -1
  12. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.d.ts.map +1 -1
  13. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js +8 -1
  14. package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js.map +1 -1
  15. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts +5 -1
  16. package/dist/legacy-runtime/runtime-cores/assistant-transport/types.d.ts.map +1 -1
  17. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  18. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +44 -3
  19. package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  20. package/dist/primitives/actionBar/ActionBarExportMarkdown.js +1 -1
  21. package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
  22. package/dist/primitives/attachment/AttachmentThumb.d.ts.map +1 -1
  23. package/dist/primitives/attachment/AttachmentThumb.js +17 -14
  24. package/dist/primitives/attachment/AttachmentThumb.js.map +1 -1
  25. package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
  26. package/dist/primitives/composer/ComposerAttachmentDropzone.js +64 -50
  27. package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
  28. package/dist/primitives/composer/ComposerInput.js +1 -0
  29. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  30. package/dist/primitives/queueItem/QueueItemText.d.ts.map +1 -1
  31. package/dist/primitives/queueItem/QueueItemText.js +10 -4
  32. package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
  33. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  34. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -4
  35. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  36. package/dist/utils/createActionButton.js +1 -1
  37. package/dist/utils/createActionButton.js.map +1 -1
  38. package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
  39. package/package.json +16 -14
  40. package/src/client/ExternalThread.ts +30 -17
  41. package/src/context/providers/MessageProvider.tsx +8 -6
  42. package/src/index.ts +5 -1
  43. package/src/legacy-runtime/AssistantRuntimeProvider.tsx +13 -3
  44. package/src/legacy-runtime/runtime-cores/assistant-transport/runManager.ts +14 -1
  45. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +38 -0
  46. package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +5 -1
  47. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +7 -0
  48. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +290 -0
  49. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +62 -1
  50. package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +1 -1
  51. package/src/primitives/attachment/AttachmentThumb.test.tsx +70 -0
  52. package/src/primitives/attachment/AttachmentThumb.tsx +8 -4
  53. package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +103 -3
  54. package/src/primitives/composer/ComposerAttachmentDropzone.tsx +18 -3
  55. package/src/primitives/composer/ComposerInput.test.tsx +53 -2
  56. package/src/primitives/composer/ComposerInput.tsx +3 -0
  57. package/src/primitives/queueItem/QueueItemText.tsx +8 -2
  58. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +74 -0
  59. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +3 -3
  60. package/src/tests/external-thread-parity.test.tsx +52 -4
  61. package/src/tests/local-runtime-queue.test.tsx +199 -6
  62. package/src/utils/createActionButton.test.tsx +18 -0
  63. package/src/utils/createActionButton.tsx +1 -1
  64. 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.fn<(file: File) => Promise<void>>();
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 prompt = useAuiState((s) => s.queueItem.prompt);
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 ?? prompt}
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
- onMouseDown?.(e);
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: vi.fn(),
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
- await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1));
274
- expect(enqueue.mock.calls[0]![0].parentId).toBe("u1");
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 () => {