@assistant-ui/react 0.15.29 → 0.15.30

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 (36) hide show
  1. package/dist/context/stores/ThreadViewport.d.ts +8 -0
  2. package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
  3. package/dist/context/stores/ThreadViewport.js +24 -8
  4. package/dist/context/stores/ThreadViewport.js.map +1 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  8. package/dist/primitives/composer/ComposerResume.d.ts +23 -0
  9. package/dist/primitives/composer/ComposerResume.d.ts.map +1 -0
  10. package/dist/primitives/composer/ComposerResume.js +34 -0
  11. package/dist/primitives/composer/ComposerResume.js.map +1 -0
  12. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts +6 -2
  13. package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
  14. package/dist/primitives/composer/trigger/triggerSelectionResource.js +4 -1
  15. package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
  16. package/dist/primitives/composer.d.ts +1 -0
  17. package/dist/primitives/composer.d.ts.map +1 -1
  18. package/dist/primitives/composer.js +3 -1
  19. package/dist/primitives/composer.js.map +1 -1
  20. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  21. package/dist/primitives/thread/useThreadViewportAutoScroll.js +32 -292
  22. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  23. package/dist/utils/hooks/useOnResizeContent.js +1 -1
  24. package/package.json +5 -5
  25. package/src/context/stores/ThreadViewport.ts +36 -8
  26. package/src/index.ts +2 -0
  27. package/src/model-context/makeAssistantVisible.test.tsx +25 -37
  28. package/src/primitives/composer/ComposerInput.tsx +1 -1
  29. package/src/primitives/composer/ComposerResume.ts +36 -0
  30. package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +24 -0
  31. package/src/primitives/composer/trigger/triggerSelectionResource.ts +14 -3
  32. package/src/primitives/composer/useAuiState.composer.test.tsx +84 -0
  33. package/src/primitives/composer.ts +1 -0
  34. package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +90 -0
  35. package/src/primitives/thread/useThreadViewportAutoScroll.ts +48 -486
  36. package/src/model-context/frame/AssistantFrame.test.ts +0 -400
@@ -136,6 +136,21 @@ const runEditTool = async (
136
136
  await task;
137
137
  };
138
138
 
139
+ const expectDefaultSettleDelay = async (start: () => Promise<unknown>) => {
140
+ await act(async () => {
141
+ const settled = vi.fn();
142
+ const task = start();
143
+ void task.then(settled);
144
+
145
+ await vi.advanceTimersByTimeAsync(1999);
146
+ expect(settled).not.toHaveBeenCalled();
147
+
148
+ await vi.advanceTimersByTimeAsync(1);
149
+ expect(settled).toHaveBeenCalledOnce();
150
+ await task;
151
+ });
152
+ };
153
+
139
154
  describe("makeAssistantVisible", () => {
140
155
  beforeEach(() => {
141
156
  registerMock.mockClear();
@@ -191,21 +206,14 @@ describe("makeAssistantVisible", () => {
191
206
  });
192
207
  const input = render(<EditableInput />).getByRole("textbox");
193
208
  const editId = (input as HTMLInputElement).dataset.editId!;
194
- const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
195
209
 
196
210
  expect(
197
211
  (input as HTMLInputElement).dataset.actionSettleDelay,
198
212
  ).toBeUndefined();
199
213
 
200
- const task = getRegisteredTools().edit!.execute({
201
- editId,
202
- value: "updated",
203
- });
204
-
205
- expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2000);
206
- await vi.runAllTimersAsync();
207
- await task;
208
- setTimeoutSpy.mockRestore();
214
+ await expectDefaultSettleDelay(() =>
215
+ getRegisteredTools().edit!.execute({ editId, value: "updated" }),
216
+ );
209
217
  });
210
218
 
211
219
  it("uses the default when the rendered settle delay is invalid", async () => {
@@ -218,14 +226,9 @@ describe("makeAssistantVisible", () => {
218
226
  );
219
227
  const clickId = (button as HTMLButtonElement).dataset.clickId!;
220
228
  (button as HTMLButtonElement).dataset.actionSettleDelay = "invalid";
221
- const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
222
-
223
- const task = getRegisteredTools().click!.execute({ clickId });
224
-
225
- expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2000);
226
- await vi.runAllTimersAsync();
227
- await task;
228
- setTimeoutSpy.mockRestore();
229
+ await expectDefaultSettleDelay(() =>
230
+ getRegisteredTools().click!.execute({ clickId }),
231
+ );
229
232
  });
230
233
 
231
234
  it.each(["", " "])(
@@ -239,29 +242,14 @@ describe("makeAssistantVisible", () => {
239
242
  const input = render(<VisibleInput />).getByRole("textbox");
240
243
  const clickId = (input as HTMLInputElement).dataset.clickId!;
241
244
  const editId = (input as HTMLInputElement).dataset.editId!;
242
- const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
243
245
  input.dataset.actionSettleDelay = renderedDelay;
244
246
 
245
- const clickTask = getRegisteredTools().click!.execute({ clickId });
246
- expect(setTimeoutSpy).toHaveBeenLastCalledWith(
247
- expect.any(Function),
248
- 2000,
247
+ await expectDefaultSettleDelay(() =>
248
+ getRegisteredTools().click!.execute({ clickId }),
249
249
  );
250
- await vi.runAllTimersAsync();
251
- await clickTask;
252
-
253
- setTimeoutSpy.mockClear();
254
- const editTask = getRegisteredTools().edit!.execute({
255
- editId,
256
- value: "updated",
257
- });
258
- expect(setTimeoutSpy).toHaveBeenLastCalledWith(
259
- expect.any(Function),
260
- 2000,
250
+ await expectDefaultSettleDelay(() =>
251
+ getRegisteredTools().edit!.execute({ editId, value: "updated" }),
261
252
  );
262
- await vi.runAllTimersAsync();
263
- await editTask;
264
- setTimeoutSpy.mockRestore();
265
253
  },
266
254
  );
267
255
 
@@ -424,7 +424,7 @@ export const ComposerPrimitiveInput = forwardRef<
424
424
  compositionRef.current = false;
425
425
  }
426
426
  const isComposing = nativeIsComposing || compositionRef.current;
427
- // keep controlled value in sync mid-IME so react does not reset the textarea to a stale value
427
+ // Standalone tap roots need a synchronous flush while the input is composing.
428
428
  flushTapSync(() => {
429
429
  aui.composer.setText(e.target.value);
430
430
  });
@@ -0,0 +1,36 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { useComposerResume as useComposerResumeBehavior } from "@assistant-ui/core/react";
5
+ import {
6
+ createActionButton,
7
+ type ActionButtonElement,
8
+ type ActionButtonProps,
9
+ } from "../../utils/createActionButton";
10
+
11
+ const useComposerResume = () => {
12
+ const { disabled, resume } = useComposerResumeBehavior();
13
+ const callback = useCallback(() => resume(), [resume]);
14
+ if (disabled) return null;
15
+ return callback;
16
+ };
17
+
18
+ export namespace ComposerPrimitiveResume {
19
+ export type Element = ActionButtonElement;
20
+ export type Props = ActionButtonProps<typeof useComposerResume>;
21
+ }
22
+
23
+ /**
24
+ * Resumes an interrupted run when its adapter explicitly reports a checkpoint.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * <ComposerPrimitive.Resume>
29
+ * Resume
30
+ * </ComposerPrimitive.Resume>
31
+ * ```
32
+ */
33
+ export const ComposerPrimitiveResume = createActionButton(
34
+ "ComposerPrimitive.Resume",
35
+ useComposerResume,
36
+ );
@@ -82,6 +82,30 @@ describe("TriggerSelectionResource", () => {
82
82
  expect(onSelected).toHaveBeenCalledOnce();
83
83
  });
84
84
 
85
+ it("reports an inserted directive once a select override finishes the insertion", () => {
86
+ const onInserted = vi.fn();
87
+ const { root, setText, onSelected } = render({
88
+ behavior: { kind: "directive", formatter, onInserted },
89
+ text: "@file tail",
90
+ trigger: { offset: 0, query: "file", endOffset: 5 },
91
+ });
92
+ let finishInsertion: (() => void) | undefined;
93
+ root.getValue().registerSelectItemOverride((_, onOverrideInserted) => {
94
+ finishInsertion = onOverrideInserted;
95
+ return true;
96
+ });
97
+
98
+ root.getValue().selectItem(item);
99
+
100
+ expect(onInserted).not.toHaveBeenCalled();
101
+ expect(setText).not.toHaveBeenCalled();
102
+ expect(onSelected).toHaveBeenCalledOnce();
103
+
104
+ finishInsertion?.();
105
+
106
+ expect(onInserted).toHaveBeenCalledExactlyOnceWith(item);
107
+ });
108
+
85
109
  it("resynchronizes the cursor after removing an action", () => {
86
110
  const { root, setText, setCursorPosition } = render({
87
111
  behavior: {
@@ -7,8 +7,15 @@ import type {
7
7
  import type { AssistantClient } from "@assistant-ui/store";
8
8
  import type { DetectedTrigger } from "./triggerDetectionResource";
9
9
 
10
- /** External override for selection (used by Lexical's DirectivePlugin). */
11
- export type SelectItemOverride = (item: Unstable_TriggerItem) => boolean;
10
+ /**
11
+ * External override for selection (used by Lexical's DirectivePlugin).
12
+ * Returns whether it handled the item, and calls `onInserted` once the
13
+ * insertion has reached the composer.
14
+ */
15
+ export type SelectItemOverride = (
16
+ item: Unstable_TriggerItem,
17
+ onInserted: () => void,
18
+ ) => boolean;
12
19
 
13
20
  export type TriggerBehavior =
14
21
  | {
@@ -66,7 +73,11 @@ const useTriggerSelectionResource = ({
66
73
  const selectItem = useEffectEvent((item: Unstable_TriggerItem) => {
67
74
  if (!trigger || !behavior) return;
68
75
 
69
- if (selectItemOverrideRef.current?.(item)) {
76
+ const reportInserted = () => {
77
+ if (behavior.kind === "directive") behavior.onInserted?.(item);
78
+ };
79
+
80
+ if (selectItemOverrideRef.current?.(item, reportInserted)) {
70
81
  onSelected();
71
82
  return;
72
83
  }
@@ -0,0 +1,84 @@
1
+ /** @vitest-environment jsdom */
2
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { useState } from "react";
4
+ import { describe, expect, it } from "vitest";
5
+ import {
6
+ AssistantRuntimeProvider,
7
+ useExternalStoreRuntime,
8
+ } from "@assistant-ui/core/react";
9
+ import type { ThreadMessage } from "@assistant-ui/core";
10
+ import { useAui, useAuiState } from "@assistant-ui/store";
11
+
12
+ function Input({
13
+ value,
14
+ onChange,
15
+ withOwnState,
16
+ }: {
17
+ value: string;
18
+ onChange: (value: string) => void;
19
+ withOwnState: boolean;
20
+ }) {
21
+ const [, setChangeCount] = useState(0);
22
+ return (
23
+ <textarea
24
+ aria-label="composer"
25
+ value={value}
26
+ onChange={(event) => {
27
+ if (withOwnState) setChangeCount((count) => count + 1);
28
+ onChange(event.target.value);
29
+ }}
30
+ />
31
+ );
32
+ }
33
+
34
+ function BoundInput({ withOwnState }: { withOwnState: boolean }) {
35
+ const aui = useAui();
36
+ const text = useAuiState((s) => s.composer.text);
37
+ return (
38
+ <>
39
+ <button onClick={() => aui.composer.setText("hello world")}>seed</button>
40
+ <Input
41
+ value={text}
42
+ onChange={(value) => aui.composer.setText(value)}
43
+ withOwnState={withOwnState}
44
+ />
45
+ </>
46
+ );
47
+ }
48
+
49
+ function App({ withOwnState }: { withOwnState: boolean }) {
50
+ const runtime = useExternalStoreRuntime<ThreadMessage>({
51
+ messages: [],
52
+ convertMessage: (message) => message,
53
+ onNew: async () => {},
54
+ });
55
+ return (
56
+ <AssistantRuntimeProvider runtime={runtime}>
57
+ <BoundInput withOwnState={withOwnState} />
58
+ </AssistantRuntimeProvider>
59
+ );
60
+ }
61
+
62
+ describe("composer state in controlled inputs", () => {
63
+ it.each([
64
+ ["mentions-like", true],
65
+ ["plain textarea", false],
66
+ ])(
67
+ "keeps the caret after typing in the middle: %s",
68
+ async (_, withOwnState) => {
69
+ render(<App withOwnState={withOwnState} />);
70
+ fireEvent.click(screen.getByRole("button", { name: "seed" }));
71
+ const input = screen.getByRole("textbox", {
72
+ name: "composer",
73
+ }) as HTMLTextAreaElement;
74
+ await waitFor(() => expect(input.value).toBe("hello world"));
75
+
76
+ input.setSelectionRange(5, 5);
77
+ fireEvent.change(input, {
78
+ target: { value: "helloX world", selectionStart: 6, selectionEnd: 6 },
79
+ });
80
+ await waitFor(() => expect(input.value).toBe("helloX world"));
81
+ expect(input.selectionStart).toBe(6);
82
+ },
83
+ );
84
+ });
@@ -3,6 +3,7 @@ import type { TriggerMatch, TriggerMatcher } from "./composer/trigger";
3
3
  export { ComposerPrimitiveRoot as Root } from "./composer/ComposerRoot";
4
4
  export { ComposerPrimitiveInput as Input } from "./composer/ComposerInput";
5
5
  export { ComposerPrimitiveSend as Send } from "./composer/ComposerSend";
6
+ export { ComposerPrimitiveResume as Resume } from "./composer/ComposerResume";
6
7
  export { ComposerPrimitiveCancel as Cancel } from "./composer/ComposerCancel";
7
8
  export { ComposerPrimitiveAddAttachment as AddAttachment } from "./composer/ComposerAddAttachment";
8
9
  export { ComposerPrimitiveAttachments as Attachments } from "./composer/ComposerAttachments";
@@ -29,6 +29,7 @@ import { ThreadPrimitiveMessages } from "./ThreadMessages";
29
29
  import { ThreadPrimitiveRoot } from "./ThreadRoot";
30
30
  import { ThreadPrimitiveScrollToBottom } from "./ThreadScrollToBottom";
31
31
  import { ThreadPrimitiveViewport } from "./ThreadViewport";
32
+ import { ThreadPrimitiveViewportFooter } from "./ThreadViewportFooter";
32
33
  import {
33
34
  ExportedMessageRepository,
34
35
  useExternalStoreRuntime,
@@ -325,6 +326,95 @@ const DelayedThread = ({
325
326
  );
326
327
  };
327
328
 
329
+ const measureFooter = (element: HTMLDivElement | null) => {
330
+ if (!element) return;
331
+ Object.defineProperty(element, "offsetHeight", {
332
+ configurable: true,
333
+ get: () => Number.parseFloat(element.style.height),
334
+ });
335
+ };
336
+
337
+ const FooterInsetState = () => {
338
+ const inset = useThreadViewport((s) => s.height.inset);
339
+ return <output data-testid="footer-inset">{inset}</output>;
340
+ };
341
+
342
+ const FooterThread = ({
343
+ footerHeight,
344
+ autoScroll = false,
345
+ }: {
346
+ footerHeight: number | null;
347
+ autoScroll?: boolean;
348
+ }) => (
349
+ <SyncRuntimeProvider>
350
+ <ThreadPrimitiveViewport
351
+ data-testid="viewport"
352
+ autoScroll={autoScroll}
353
+ scrollToBottomOnInitialize={false}
354
+ >
355
+ {messages.map((_, index) => (
356
+ <div key={index} data-testid="thread-message" />
357
+ ))}
358
+ {footerHeight !== null && (
359
+ <ThreadPrimitiveViewportFooter
360
+ ref={measureFooter}
361
+ data-testid="footer"
362
+ style={{ height: footerHeight }}
363
+ />
364
+ )}
365
+ <AtBottom />
366
+ <FooterInsetState />
367
+ </ThreadPrimitiveViewport>
368
+ </SyncRuntimeProvider>
369
+ );
370
+
371
+ describe("viewport footer insets", () => {
372
+ it("counts a footer registered before viewport attachment toward at-bottom", () => {
373
+ render(<FooterThread footerHeight={40} />);
374
+
375
+ act(() => {
376
+ getViewport().scrollTop = 500;
377
+ fireEvent.scroll(getViewport());
378
+ });
379
+
380
+ expect(screen.getByTestId("footer-inset").textContent).toBe("40");
381
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
382
+ });
383
+
384
+ it("follows a pinned viewport when its footer grows without a content resize", () => {
385
+ const view = render(<FooterThread footerHeight={40} autoScroll />);
386
+ act(notifyResizeObservers);
387
+
388
+ act(() => {
389
+ getViewport().scrollTop = 500;
390
+ fireEvent.scroll(getViewport());
391
+ });
392
+
393
+ view.rerender(<FooterThread footerHeight={80} autoScroll />);
394
+ act(() => notifyResizeOf(screen.getByTestId("footer")));
395
+
396
+ expect(getViewport().scrollTop).toBe(540);
397
+ expect(screen.getByTestId("footer-inset").textContent).toBe("80");
398
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
399
+ });
400
+
401
+ it("unregisters an unmounted footer and recomputes at-bottom", () => {
402
+ const view = render(<FooterThread footerHeight={40} />);
403
+
404
+ act(() => {
405
+ getViewport().scrollTop = 500;
406
+ fireEvent.scroll(getViewport());
407
+ });
408
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
409
+
410
+ view.rerender(<FooterThread footerHeight={null} />);
411
+
412
+ expect(screen.getByTestId("footer-inset").textContent).toBe("0");
413
+ expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
414
+ expect(getViewport().scrollTop).toBe(500);
415
+ });
416
+ });
417
+
328
418
  describe("useThreadViewportAutoScroll", () => {
329
419
  it("keeps viewport listeners and observers across rerenders", () => {
330
420
  const view = render(