@assistant-ui/react 0.15.4 → 0.15.5

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 (58) hide show
  1. package/dist/client/ExternalThread.d.ts.map +1 -1
  2. package/dist/client/ExternalThread.js +383 -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/ComposerInput.js +1 -0
  26. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  27. package/dist/primitives/queueItem/QueueItemText.d.ts.map +1 -1
  28. package/dist/primitives/queueItem/QueueItemText.js +10 -4
  29. package/dist/primitives/queueItem/QueueItemText.js.map +1 -1
  30. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
  31. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -4
  32. package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
  33. package/dist/utils/createActionButton.js +1 -1
  34. package/dist/utils/createActionButton.js.map +1 -1
  35. package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
  36. package/package.json +6 -6
  37. package/src/client/ExternalThread.ts +28 -16
  38. package/src/context/providers/MessageProvider.tsx +8 -6
  39. package/src/index.ts +5 -1
  40. package/src/legacy-runtime/AssistantRuntimeProvider.tsx +13 -3
  41. package/src/legacy-runtime/runtime-cores/assistant-transport/runManager.ts +14 -1
  42. package/src/legacy-runtime/runtime-cores/assistant-transport/transport-scheduling.test.ts +38 -0
  43. package/src/legacy-runtime/runtime-cores/assistant-transport/types.ts +5 -1
  44. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransport.spec.md +7 -0
  45. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.test.tsx +290 -0
  46. package/src/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.ts +62 -1
  47. package/src/primitives/actionBar/ActionBarExportMarkdown.tsx +1 -1
  48. package/src/primitives/attachment/AttachmentThumb.test.tsx +70 -0
  49. package/src/primitives/attachment/AttachmentThumb.tsx +8 -4
  50. package/src/primitives/composer/ComposerInput.test.tsx +53 -2
  51. package/src/primitives/composer/ComposerInput.tsx +3 -0
  52. package/src/primitives/queueItem/QueueItemText.tsx +8 -2
  53. package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +74 -0
  54. package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +3 -3
  55. package/src/tests/external-thread-parity.test.tsx +9 -4
  56. package/src/tests/local-runtime-queue.test.tsx +199 -6
  57. package/src/utils/createActionButton.test.tsx +18 -0
  58. package/src/utils/createActionButton.tsx +1 -1
@@ -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,22 @@ 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();
275
280
  });
276
281
 
277
282
  it("still refuses to send an empty composer synchronously after a send", async () => {
@@ -34,7 +34,14 @@ const userTexts = (aui: ReturnType<typeof useAui>) =>
34
34
  m.content.map((p) => (p.type === "text" ? p.text : "")).join(""),
35
35
  );
36
36
 
37
- const renderWithRuntime = (adapter: ChatModelAdapter, enableQueue: boolean) => {
37
+ const renderWithRuntime = (
38
+ adapter: ChatModelAdapter,
39
+ enableQueue: boolean,
40
+ options?: {
41
+ unstable_queueClearOnRewind?: boolean;
42
+ unstable_queueClearOnCancel?: boolean;
43
+ },
44
+ ) => {
38
45
  const captured: { aui?: ReturnType<typeof useAui> } = {};
39
46
  const Capture: FC = () => {
40
47
  captured.aui = useAui();
@@ -43,6 +50,7 @@ const renderWithRuntime = (adapter: ChatModelAdapter, enableQueue: boolean) => {
43
50
  const App: FC = () => {
44
51
  const runtime = useLocalRuntime(adapter, {
45
52
  unstable_enableMessageQueue: enableQueue,
53
+ ...options,
46
54
  });
47
55
  return (
48
56
  <AssistantRuntimeProvider runtime={runtime}>
@@ -114,6 +122,43 @@ describe("local runtime message queue", () => {
114
122
  ).toEqual(["b"]);
115
123
  });
116
124
 
125
+ it("defaults a mid-run send to steer, ahead of an explicitly queued item", async () => {
126
+ const { adapter, releases, getRunCount } = createCountingAdapter();
127
+ const aui = renderWithRuntime(adapter, true);
128
+
129
+ await send(aui, "first");
130
+ expect(getRunCount()).toBe(1);
131
+
132
+ await act(async () => {
133
+ aui.thread.composer().setText("behind");
134
+ aui.thread.composer().send({ steer: false });
135
+ await flush();
136
+ });
137
+ await send(aui, "next");
138
+ expect(
139
+ aui.thread
140
+ .composer()
141
+ .getState()
142
+ .queue.map((q) => q.prompt),
143
+ ).toEqual(["next", "behind"]);
144
+
145
+ await act(async () => {
146
+ releases[0]!();
147
+ await flush();
148
+ await flush();
149
+ });
150
+ expect(getRunCount()).toBe(2);
151
+ expect(userTexts(aui)).toEqual(["first", "next"]);
152
+
153
+ await act(async () => {
154
+ releases[1]!();
155
+ await flush();
156
+ await flush();
157
+ });
158
+ expect(getRunCount()).toBe(3);
159
+ expect(userTexts(aui)).toEqual(["first", "next", "behind"]);
160
+ });
161
+
117
162
  it("queueItem(index).remove() drops a queued message", async () => {
118
163
  const { adapter } = createCountingAdapter();
119
164
  const aui = renderWithRuntime(adapter, true);
@@ -132,7 +177,7 @@ describe("local runtime message queue", () => {
132
177
  expect(queue[0]!.prompt).toBe("b");
133
178
  });
134
179
 
135
- it("clears the queue when the run is cancelled, without flushing", async () => {
180
+ it("clears queued items when the user cancels the run", async () => {
136
181
  const { adapter, getRunCount } = createCountingAdapter();
137
182
  const aui = renderWithRuntime(adapter, true);
138
183
 
@@ -147,13 +192,50 @@ describe("local runtime message queue", () => {
147
192
  await flush();
148
193
  });
149
194
 
195
+ // Stop means stop: nothing pending, nothing dispatched
196
+ expect(getRunCount()).toBe(1);
150
197
  expect(aui.thread.composer().getState().queue).toEqual([]);
151
- // cancelling must not start the next queued message
198
+ });
199
+
200
+ it("keeps queued items on cancel when unstable_queueClearOnCancel is false", async () => {
201
+ const { adapter, getRunCount } = createCountingAdapter();
202
+ const aui = renderWithRuntime(adapter, true, {
203
+ unstable_queueClearOnCancel: false,
204
+ });
205
+
206
+ await send(aui, "first");
207
+ await send(aui, "a");
208
+ await send(aui, "b");
209
+ expect(aui.thread.composer().getState().queue).toHaveLength(2);
210
+
211
+ await act(async () => {
212
+ aui.thread.cancelRun();
213
+ await flush();
214
+ await flush();
215
+ });
216
+
217
+ // cancel pauses the queue: items survive, nothing auto-dispatches
152
218
  expect(getRunCount()).toBe(1);
219
+ expect(
220
+ aui.thread
221
+ .composer()
222
+ .getState()
223
+ .queue.map((q) => q.prompt),
224
+ ).toEqual(["a", "b"]);
225
+
226
+ // the next explicit send drains the head
227
+ await send(aui, "c");
228
+ expect(getRunCount()).toBe(2);
229
+ expect(
230
+ aui.thread
231
+ .composer()
232
+ .getState()
233
+ .queue.map((q) => q.prompt),
234
+ ).toEqual(["b", "c"]);
153
235
  });
154
236
 
155
- it("applies an edit instead of queuing it, dropping pending items", async () => {
156
- const { adapter, releases } = createCountingAdapter();
237
+ it("applies an edit instead of queuing it and clears the pending items", async () => {
238
+ const { adapter, releases, getRunCount } = createCountingAdapter();
157
239
  const aui = renderWithRuntime(adapter, true);
158
240
 
159
241
  await send(aui, "first");
@@ -174,10 +256,52 @@ describe("local runtime message queue", () => {
174
256
  message.composer().setText("edited");
175
257
  message.composer().send();
176
258
  await flush();
259
+ await flush();
260
+ });
261
+
262
+ // the edit is applied (branches the thread); the rewind clears the queue,
263
+ // so nothing pending dispatches against the new branch
264
+ expect(aui.thread.composer().getState().queue).toEqual([]);
265
+ expect(getRunCount()).toBe(3); // first, second, edit — not "queued"
266
+ });
267
+
268
+ it("keeps the queue across an edit when unstable_queueClearOnRewind is false", async () => {
269
+ const { adapter, releases, getRunCount } = createCountingAdapter();
270
+ const aui = renderWithRuntime(adapter, true, {
271
+ unstable_queueClearOnRewind: false,
272
+ });
273
+
274
+ await send(aui, "first");
275
+ await act(async () => {
276
+ releases[0]!();
277
+ await flush();
278
+ });
279
+
280
+ await send(aui, "second");
281
+ await send(aui, "queued");
282
+ expect(aui.thread.composer().getState().queue).toHaveLength(1);
283
+
284
+ await act(async () => {
285
+ const message = aui.thread.message({ index: 0 });
286
+ message.composer().beginEdit();
287
+ message.composer().setText("edited");
288
+ message.composer().send();
289
+ await flush();
290
+ });
291
+
292
+ // the edit's rerun survives; the queued item waits for it to settle
293
+ expect(getRunCount()).toBe(3);
294
+ expect(aui.thread.composer().getState().queue).toHaveLength(1);
295
+
296
+ await act(async () => {
297
+ releases[2]!();
298
+ await flush();
177
299
  });
178
300
 
179
- // the edit is applied (branches the thread) and the stale queue is cleared
301
+ // the surviving queue drains after the edit's run completes
302
+ expect(getRunCount()).toBe(4);
180
303
  expect(aui.thread.composer().getState().queue).toEqual([]);
304
+ expect(userTexts(aui)).toEqual(["edited", "queued"]);
181
305
  });
182
306
 
183
307
  it("buffers a send during a regenerate instead of interrupting it", async () => {
@@ -214,6 +338,75 @@ describe("local runtime message queue", () => {
214
338
  ).toEqual(["Y"]);
215
339
  });
216
340
 
341
+ it("drains the pending head after a regenerate started inside the cancellation window", async () => {
342
+ const { adapter, releases, getRunCount } = createCountingAdapter();
343
+ const aui = renderWithRuntime(adapter, true, {
344
+ unstable_queueClearOnCancel: false,
345
+ });
346
+
347
+ await send(aui, "first");
348
+ await act(async () => {
349
+ releases[0]!();
350
+ await flush();
351
+ });
352
+
353
+ // regenerate, buffer a send, then cancel and regenerate again before
354
+ // the cancelled settle lands
355
+ await act(async () => {
356
+ aui.thread.message({ index: 1 }).reload();
357
+ await flush();
358
+ });
359
+ await send(aui, "pending");
360
+ await act(async () => {
361
+ aui.thread.cancelRun();
362
+ aui.thread.message({ index: 1 }).reload();
363
+ await flush();
364
+ });
365
+ expect(getRunCount()).toBe(3);
366
+ expect(aui.thread.composer().getState().queue).toHaveLength(1);
367
+
368
+ // the cancelled settle must not eat the replacement's: once the
369
+ // replacement settles, the pending head drains exactly once
370
+ await act(async () => {
371
+ releases[2]!();
372
+ await flush();
373
+ });
374
+ expect(getRunCount()).toBe(4);
375
+ expect(aui.thread.composer().getState().queue).toEqual([]);
376
+ });
377
+
378
+ it("keeps a second regenerate alive when the first one's settle arrives", async () => {
379
+ const { adapter, releases, getRunCount } = createCountingAdapter();
380
+ const aui = renderWithRuntime(adapter, true);
381
+
382
+ await send(aui, "first");
383
+ await act(async () => {
384
+ releases[0]!();
385
+ await flush();
386
+ });
387
+
388
+ await act(async () => {
389
+ aui.thread.message({ index: 1 }).reload();
390
+ await flush();
391
+ });
392
+ await send(aui, "Y");
393
+
394
+ // the second regenerate aborts the first; its settle must not dispatch
395
+ await act(async () => {
396
+ aui.thread.message({ index: 1 }).reload();
397
+ await flush();
398
+ });
399
+ expect(getRunCount()).toBe(3);
400
+ expect(aui.thread.composer().getState().queue).toHaveLength(1);
401
+
402
+ await act(async () => {
403
+ releases[2]!();
404
+ await flush();
405
+ });
406
+ expect(getRunCount()).toBe(4);
407
+ expect(aui.thread.composer().getState().queue).toEqual([]);
408
+ });
409
+
217
410
  it("advances exactly once after a failed run, without deadlocking", async () => {
218
411
  const releases: Array<() => void> = [];
219
412
  let runCount = 0;
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { createActionButton } from "./createActionButton";
4
+
5
+ const TestButton = createActionButton("TestButton", () => () => {});
6
+
7
+ describe("createActionButton", () => {
8
+ it("defaults to type=button", () => {
9
+ const html = renderToStaticMarkup(<TestButton />);
10
+ expect(html).toContain('type="button"');
11
+ });
12
+
13
+ it("allows the caller to override type", () => {
14
+ const html = renderToStaticMarkup(<TestButton type="submit" />);
15
+ expect(html).toContain('type="submit"');
16
+ expect(html).not.toContain('type="button"');
17
+ });
18
+ });
@@ -41,8 +41,8 @@ export const createActionButton = <TProps,>(
41
41
  const callback = useActionButton(forwardedProps as TProps) ?? undefined;
42
42
  return (
43
43
  <Primitive.button
44
- {...primitiveProps}
45
44
  type="button"
45
+ {...primitiveProps}
46
46
  ref={forwardedRef}
47
47
  disabled={primitiveProps.disabled || !callback}
48
48
  onClick={composeEventHandlers(primitiveProps.onClick, callback)}