@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.
- package/dist/context/stores/ThreadViewport.d.ts +8 -0
- package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
- package/dist/context/stores/ThreadViewport.js +24 -8
- package/dist/context/stores/ThreadViewport.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerResume.d.ts +23 -0
- package/dist/primitives/composer/ComposerResume.d.ts.map +1 -0
- package/dist/primitives/composer/ComposerResume.js +34 -0
- package/dist/primitives/composer/ComposerResume.js.map +1 -0
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts +6 -2
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js +4 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/composer.d.ts +1 -0
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js +3 -1
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +32 -292
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +1 -1
- package/package.json +5 -5
- package/src/context/stores/ThreadViewport.ts +36 -8
- package/src/index.ts +2 -0
- package/src/model-context/makeAssistantVisible.test.tsx +25 -37
- package/src/primitives/composer/ComposerInput.tsx +1 -1
- package/src/primitives/composer/ComposerResume.ts +36 -0
- package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +24 -0
- package/src/primitives/composer/trigger/triggerSelectionResource.ts +14 -3
- package/src/primitives/composer/useAuiState.composer.test.tsx +84 -0
- package/src/primitives/composer.ts +1 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +90 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +48 -486
- 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
|
-
|
|
201
|
-
editId,
|
|
202
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
expect.any(Function),
|
|
248
|
-
2000,
|
|
247
|
+
await expectDefaultSettleDelay(() =>
|
|
248
|
+
getRegisteredTools().click!.execute({ clickId }),
|
|
249
249
|
);
|
|
250
|
-
await
|
|
251
|
-
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
11
|
-
|
|
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
|
-
|
|
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(
|