@assistant-ui/react 0.15.25 → 0.15.27
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/hooks/useToolCallElapsed.d.ts.map +1 -1
- package/dist/hooks/useToolCallElapsed.js +3 -2
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +31 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +3 -2
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +58 -48
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.d.ts.map +1 -1
- package/dist/mcp-apps/bridge.js +28 -3
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/csp.d.ts +4 -0
- package/dist/mcp-apps/csp.d.ts.map +1 -0
- package/dist/mcp-apps/csp.js +93 -0
- package/dist/mcp-apps/csp.js.map +1 -0
- package/dist/mcp-apps/index.d.ts +1 -1
- package/dist/mcp-apps/index.d.ts.map +1 -1
- package/dist/mcp-apps/types.d.ts +19 -1
- package/dist/mcp-apps/types.d.ts.map +1 -1
- package/dist/mcp-apps/types.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/attachment.d.ts +3 -1
- package/dist/primitives/attachment.d.ts.map +1 -1
- package/dist/primitives/attachment.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +14 -12
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
- package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerCancel.js +14 -1
- package/dist/primitives/composer/ComposerCancel.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +14 -0
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +4 -3
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js +24 -19
- package/dist/primitives/composer/ComposerRoot.js.map +1 -1
- package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
- package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
- package/dist/primitives/composer/composerInputSelector.js +7 -0
- package/dist/primitives/composer/composerInputSelector.js.map +1 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
- package/dist/primitives/composer.d.ts +35 -14
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +58 -278
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +4 -3
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +90 -21
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +3 -2
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useComposerInput.d.ts +9 -4
- package/dist/unstable/useComposerInput.d.ts.map +1 -1
- package/dist/unstable/useComposerInput.js +6 -4
- package/dist/unstable/useComposerInput.js.map +1 -1
- package/dist/unstable/useComposerInputHistory.d.ts +3 -2
- package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
- package/dist/unstable/useComposerInputHistory.js +2 -2
- package/dist/unstable/useComposerInputHistory.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
- package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +8 -7
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.d.ts +12 -3
- package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
- package/dist/unstable/useMentionAdapter.js +2 -2
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts +4 -2
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +2 -2
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
- package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.js +5 -4
- package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
- package/dist/utils/getSelectionMessageId.js +31 -8
- package/dist/utils/getSelectionMessageId.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +2 -22
- package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
- package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
- package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
- package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
- package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
- package/package.json +10 -10
- package/src/hooks/useToolCallElapsed.test.tsx +4 -2
- package/src/hooks/useToolCallElapsed.ts +3 -2
- package/src/index.ts +20 -2
- package/src/mcp-apps/McpAppRenderer.test.tsx +7 -2
- package/src/mcp-apps/McpAppRenderer.tsx +3 -9
- package/src/mcp-apps/app-frame.test.tsx +102 -6
- package/src/mcp-apps/app-frame.tsx +7 -10
- package/src/mcp-apps/bridge.test.ts +154 -0
- package/src/mcp-apps/bridge.ts +51 -9
- package/src/mcp-apps/csp.test.ts +112 -0
- package/src/mcp-apps/csp.ts +124 -0
- package/src/mcp-apps/index.ts +1 -0
- package/src/mcp-apps/types.ts +16 -1
- package/src/model-context/frame/AssistantFrame.test.ts +45 -0
- package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
- package/src/primitives/attachment.ts +4 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +32 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +14 -12
- package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
- package/src/primitives/composer/ComposerCancel.ts +8 -1
- package/src/primitives/composer/ComposerDisabledThread.test.tsx +121 -0
- package/src/primitives/composer/ComposerInput.tsx +19 -1
- package/src/primitives/composer/ComposerRoot.tsx +6 -5
- package/src/primitives/composer/composerInputSelector.ts +4 -0
- package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
- package/src/primitives/composer.ts +56 -21
- package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
- package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
- package/src/primitives/thread/ThreadViewport.tsx +3 -3
- package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +666 -63
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +201 -73
- package/src/sandbox-host/SandboxHost.tsx +3 -8
- package/src/unstable/useComposerInput.ts +9 -4
- package/src/unstable/useComposerInputHistory.ts +3 -2
- package/src/unstable/useLiveCompletionAdapter.ts +9 -14
- package/src/unstable/useMentionAdapter.ts +12 -3
- package/src/unstable/useMessageStallDetection.ts +4 -2
- package/src/unstable/useSlashCommandAdapter.ts +8 -4
- package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
- package/src/utils/getSelectionMessageId.test.ts +231 -0
- package/src/utils/getSelectionMessageId.ts +45 -7
- package/src/utils/hooks/useOnResizeContent.ts +2 -29
- package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
- package/src/utils/useIsomorphicLayoutEffect.ts +9 -0
|
@@ -20,7 +20,10 @@ import {
|
|
|
20
20
|
import { useEffect, useState, type FC, type PropsWithChildren } from "react";
|
|
21
21
|
import { useAuiState } from "@assistant-ui/store";
|
|
22
22
|
import { AssistantRuntimeProvider } from "../../context";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
useThreadViewport,
|
|
25
|
+
useThreadViewportStore,
|
|
26
|
+
} from "../../context/react/ThreadViewportContext";
|
|
24
27
|
import * as MessagePrimitive from "../message";
|
|
25
28
|
import { ThreadPrimitiveMessages } from "./ThreadMessages";
|
|
26
29
|
import { ThreadPrimitiveRoot } from "./ThreadRoot";
|
|
@@ -53,7 +56,7 @@ const getMaxScrollTop = (element: Element) =>
|
|
|
53
56
|
let forceShortViewportMeasurement = false;
|
|
54
57
|
let viewportMeasurementOffset = 0;
|
|
55
58
|
const messageHeights = new Map<string, number>();
|
|
56
|
-
const
|
|
59
|
+
const resizeObservers = new Set<TestResizeObserver>();
|
|
57
60
|
|
|
58
61
|
const messageRows = () => [
|
|
59
62
|
...document.querySelectorAll<HTMLElement>('[data-testid="thread-message"]'),
|
|
@@ -66,22 +69,37 @@ const rowsHeight = (rows: readonly Element[]) =>
|
|
|
66
69
|
rows.reduce((height, row) => height + rowHeight(row), 0);
|
|
67
70
|
|
|
68
71
|
class TestResizeObserver {
|
|
69
|
-
|
|
72
|
+
readonly targets = new Set<Element>();
|
|
73
|
+
readonly callback: ResizeObserverCallback;
|
|
70
74
|
|
|
71
75
|
constructor(callback: ResizeObserverCallback) {
|
|
72
76
|
this.callback = callback;
|
|
73
|
-
|
|
77
|
+
resizeObservers.add(this);
|
|
74
78
|
}
|
|
75
79
|
|
|
76
|
-
observe() {
|
|
80
|
+
observe(target: Element) {
|
|
81
|
+
this.targets.add(target);
|
|
82
|
+
}
|
|
83
|
+
unobserve(target: Element) {
|
|
84
|
+
this.targets.delete(target);
|
|
85
|
+
}
|
|
77
86
|
disconnect() {
|
|
78
|
-
|
|
87
|
+
resizeObservers.delete(this);
|
|
79
88
|
}
|
|
80
89
|
}
|
|
81
90
|
|
|
82
91
|
const notifyResizeObservers = () => {
|
|
83
|
-
for (const
|
|
84
|
-
callback([], {} as ResizeObserver);
|
|
92
|
+
for (const observer of resizeObservers) {
|
|
93
|
+
observer.callback([], {} as ResizeObserver);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Resizes only `target`, as a child growing without a DOM mutation does. */
|
|
98
|
+
const notifyResizeOf = (target: Element) => {
|
|
99
|
+
for (const observer of resizeObservers) {
|
|
100
|
+
if (observer.targets.has(target)) {
|
|
101
|
+
observer.callback([], {} as ResizeObserver);
|
|
102
|
+
}
|
|
85
103
|
}
|
|
86
104
|
};
|
|
87
105
|
|
|
@@ -176,7 +194,7 @@ afterEach(() => {
|
|
|
176
194
|
forceShortViewportMeasurement = false;
|
|
177
195
|
viewportMeasurementOffset = 0;
|
|
178
196
|
messageHeights.clear();
|
|
179
|
-
|
|
197
|
+
resizeObservers.clear();
|
|
180
198
|
cleanup();
|
|
181
199
|
});
|
|
182
200
|
|
|
@@ -201,6 +219,16 @@ const AtBottom: FC = () => {
|
|
|
201
219
|
return <output data-testid="is-at-bottom">{String(isAtBottom)}</output>;
|
|
202
220
|
};
|
|
203
221
|
|
|
222
|
+
const RequestSmoothScrollToBottom: FC = () => {
|
|
223
|
+
const threadViewportStore = useThreadViewportStore();
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
threadViewportStore.getState().scrollToBottom({ behavior: "smooth" });
|
|
227
|
+
}, [threadViewportStore]);
|
|
228
|
+
|
|
229
|
+
return null;
|
|
230
|
+
};
|
|
231
|
+
|
|
204
232
|
const Thread = ({
|
|
205
233
|
autoScroll,
|
|
206
234
|
scrollToBottomOnInitialize,
|
|
@@ -298,6 +326,50 @@ const DelayedThread = ({
|
|
|
298
326
|
};
|
|
299
327
|
|
|
300
328
|
describe("useThreadViewportAutoScroll", () => {
|
|
329
|
+
it("keeps viewport listeners and observers across rerenders", () => {
|
|
330
|
+
const view = render(
|
|
331
|
+
<SyncRuntimeProvider>
|
|
332
|
+
<Thread autoScroll={false} scrollToBottomOnInitialize={false} />
|
|
333
|
+
</SyncRuntimeProvider>,
|
|
334
|
+
);
|
|
335
|
+
const observe = vi.spyOn(TestResizeObserver.prototype, "observe");
|
|
336
|
+
const disconnect = vi.spyOn(TestResizeObserver.prototype, "disconnect");
|
|
337
|
+
const observeMutations = vi.spyOn(MutationObserver.prototype, "observe");
|
|
338
|
+
const disconnectMutations = vi.spyOn(
|
|
339
|
+
MutationObserver.prototype,
|
|
340
|
+
"disconnect",
|
|
341
|
+
);
|
|
342
|
+
const viewport = getViewport();
|
|
343
|
+
const addListenerSpy = vi.spyOn(viewport, "addEventListener");
|
|
344
|
+
const removeListenerSpy = vi.spyOn(viewport, "removeEventListener");
|
|
345
|
+
try {
|
|
346
|
+
view.rerender(
|
|
347
|
+
<SyncRuntimeProvider>
|
|
348
|
+
<Thread autoScroll={false} scrollToBottomOnInitialize={false} />
|
|
349
|
+
</SyncRuntimeProvider>,
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
expect(observe).not.toHaveBeenCalled();
|
|
353
|
+
expect(disconnect).not.toHaveBeenCalled();
|
|
354
|
+
expect(observeMutations).not.toHaveBeenCalled();
|
|
355
|
+
expect(disconnectMutations).not.toHaveBeenCalled();
|
|
356
|
+
expect(addListenerSpy).not.toHaveBeenCalled();
|
|
357
|
+
expect(removeListenerSpy).not.toHaveBeenCalled();
|
|
358
|
+
|
|
359
|
+
view.unmount();
|
|
360
|
+
expect(disconnect).toHaveBeenCalled();
|
|
361
|
+
expect(disconnectMutations).toHaveBeenCalled();
|
|
362
|
+
expect(removeListenerSpy).toHaveBeenCalled();
|
|
363
|
+
} finally {
|
|
364
|
+
observe.mockRestore();
|
|
365
|
+
disconnect.mockRestore();
|
|
366
|
+
observeMutations.mockRestore();
|
|
367
|
+
disconnectMutations.mockRestore();
|
|
368
|
+
addListenerSpy.mockRestore();
|
|
369
|
+
removeListenerSpy.mockRestore();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
301
373
|
it("preserves smooth scrolling from a control outside the viewport", async () => {
|
|
302
374
|
render(
|
|
303
375
|
<SyncRuntimeProvider>
|
|
@@ -327,6 +399,144 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
327
399
|
}
|
|
328
400
|
});
|
|
329
401
|
|
|
402
|
+
it("updates isAtBottom when a wheel interrupts a smooth scroll short of the bottom", async () => {
|
|
403
|
+
const view = render(
|
|
404
|
+
<SyncRuntimeProvider>
|
|
405
|
+
<Thread autoScroll={false} scrollToBottomOnInitialize={false} />
|
|
406
|
+
<ThreadPrimitiveScrollToBottom behavior="smooth">
|
|
407
|
+
Scroll to bottom
|
|
408
|
+
</ThreadPrimitiveScrollToBottom>
|
|
409
|
+
</SyncRuntimeProvider>,
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const viewport = getViewport();
|
|
413
|
+
await waitFor(() => {
|
|
414
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(
|
|
415
|
+
messages.length,
|
|
416
|
+
);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
act(() => {
|
|
420
|
+
viewport.scrollTop = getMaxScrollTop(viewport);
|
|
421
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
422
|
+
});
|
|
423
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
|
|
424
|
+
viewportMeasurementOffset += 200;
|
|
425
|
+
|
|
426
|
+
const scrollToSpy = vi
|
|
427
|
+
.spyOn(viewport, "scrollTo")
|
|
428
|
+
.mockImplementation(() => {});
|
|
429
|
+
try {
|
|
430
|
+
view.rerender(
|
|
431
|
+
<SyncRuntimeProvider>
|
|
432
|
+
<Thread autoScroll={false} scrollToBottomOnInitialize={false} />
|
|
433
|
+
<ThreadPrimitiveScrollToBottom behavior="smooth">
|
|
434
|
+
Scroll to bottom
|
|
435
|
+
</ThreadPrimitiveScrollToBottom>
|
|
436
|
+
<RequestSmoothScrollToBottom />
|
|
437
|
+
</SyncRuntimeProvider>,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
await waitFor(() => {
|
|
441
|
+
expect(scrollToSpy).toHaveBeenCalledWith({
|
|
442
|
+
top: viewport.scrollHeight,
|
|
443
|
+
behavior: "smooth",
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
act(() => {
|
|
448
|
+
viewport.scrollTop += 100;
|
|
449
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
450
|
+
});
|
|
451
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
|
|
452
|
+
expect(
|
|
453
|
+
(
|
|
454
|
+
screen.getByRole("button", {
|
|
455
|
+
name: "Scroll to bottom",
|
|
456
|
+
}) as HTMLButtonElement
|
|
457
|
+
).disabled,
|
|
458
|
+
).toBe(true);
|
|
459
|
+
|
|
460
|
+
act(() => {
|
|
461
|
+
viewport.dispatchEvent(new WheelEvent("wheel"));
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
|
|
465
|
+
expect(
|
|
466
|
+
(
|
|
467
|
+
screen.getByRole("button", {
|
|
468
|
+
name: "Scroll to bottom",
|
|
469
|
+
}) as HTMLButtonElement
|
|
470
|
+
).disabled,
|
|
471
|
+
).toBe(false);
|
|
472
|
+
} finally {
|
|
473
|
+
scrollToSpy.mockRestore();
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("keeps a coalesced user scroll-up after a programmatic bottom scroll", async () => {
|
|
478
|
+
render(
|
|
479
|
+
<SyncRuntimeProvider>
|
|
480
|
+
<Thread autoScroll={false} scrollToBottomOnInitialize={false} />
|
|
481
|
+
<ThreadPrimitiveScrollToBottom behavior="instant">
|
|
482
|
+
Scroll to bottom
|
|
483
|
+
</ThreadPrimitiveScrollToBottom>
|
|
484
|
+
</SyncRuntimeProvider>,
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
const viewport = getViewport();
|
|
488
|
+
await waitFor(() => {
|
|
489
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(
|
|
490
|
+
messages.length,
|
|
491
|
+
);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
act(() => {
|
|
495
|
+
viewport.scrollTop = 0;
|
|
496
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
497
|
+
});
|
|
498
|
+
await waitFor(() => {
|
|
499
|
+
expect(
|
|
500
|
+
screen
|
|
501
|
+
.getByRole("button", { name: "Scroll to bottom" })
|
|
502
|
+
.hasAttribute("disabled"),
|
|
503
|
+
).toBe(false);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const scrollToDescriptor = Object.getOwnPropertyDescriptor(
|
|
507
|
+
viewport,
|
|
508
|
+
"scrollTo",
|
|
509
|
+
);
|
|
510
|
+
Object.defineProperty(viewport, "scrollTo", {
|
|
511
|
+
configurable: true,
|
|
512
|
+
value: ({ top = 0 }: ScrollToOptions) => {
|
|
513
|
+
viewport.scrollTop = Math.min(Number(top), getMaxScrollTop(viewport));
|
|
514
|
+
},
|
|
515
|
+
});
|
|
516
|
+
try {
|
|
517
|
+
fireEvent.click(screen.getByRole("button", { name: "Scroll to bottom" }));
|
|
518
|
+
|
|
519
|
+
act(() => {
|
|
520
|
+
viewport.scrollTop -= 80;
|
|
521
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
const scrollTopAfterLeave = viewport.scrollTop;
|
|
525
|
+
viewportMeasurementOffset += 200;
|
|
526
|
+
act(notifyResizeObservers);
|
|
527
|
+
|
|
528
|
+
expect(viewport.scrollTop).toBe(scrollTopAfterLeave);
|
|
529
|
+
expect(viewport.scrollTop).toBeLessThan(getMaxScrollTop(viewport));
|
|
530
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
|
|
531
|
+
} finally {
|
|
532
|
+
if (scrollToDescriptor) {
|
|
533
|
+
Object.defineProperty(viewport, "scrollTo", scrollToDescriptor);
|
|
534
|
+
} else {
|
|
535
|
+
Reflect.deleteProperty(viewport, "scrollTo");
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
330
540
|
it("scrolls sync initialMessages to the bottom when the viewport mounts after initialization", async () => {
|
|
331
541
|
render(
|
|
332
542
|
<SyncRuntimeProvider>
|
|
@@ -436,6 +646,47 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
436
646
|
expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
|
|
437
647
|
});
|
|
438
648
|
|
|
649
|
+
it("follows a message that grows without a DOM mutation", async () => {
|
|
650
|
+
render(
|
|
651
|
+
<SyncRuntimeProvider>
|
|
652
|
+
<BottomAnchorThread />
|
|
653
|
+
</SyncRuntimeProvider>,
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
const viewport = getViewport();
|
|
657
|
+
await waitFor(() => {
|
|
658
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
const lastRow = messageRows().at(-1)!;
|
|
662
|
+
messageHeights.set(lastRow.getAttribute("data-message-id")!, 280);
|
|
663
|
+
act(() => notifyResizeOf(lastRow));
|
|
664
|
+
|
|
665
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
666
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it("reports leaving the bottom when a message grows without a DOM mutation", async () => {
|
|
670
|
+
render(
|
|
671
|
+
<SyncRuntimeProvider>
|
|
672
|
+
<Thread />
|
|
673
|
+
</SyncRuntimeProvider>,
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const viewport = getViewport();
|
|
677
|
+
await waitFor(() => {
|
|
678
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
679
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const lastRow = messageRows().at(-1)!;
|
|
683
|
+
messageHeights.set(lastRow.getAttribute("data-message-id")!, 280);
|
|
684
|
+
act(() => notifyResizeOf(lastRow));
|
|
685
|
+
|
|
686
|
+
expect(viewport.scrollTop).toBeLessThan(getMaxScrollTop(viewport));
|
|
687
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
|
|
688
|
+
});
|
|
689
|
+
|
|
439
690
|
it("keeps following after a pointerdown that does not scroll the viewport", async () => {
|
|
440
691
|
render(
|
|
441
692
|
<SyncRuntimeProvider>
|
|
@@ -458,8 +709,355 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
458
709
|
expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
|
|
459
710
|
});
|
|
460
711
|
|
|
712
|
+
describe("disclosures in a message", () => {
|
|
713
|
+
const renderPinned = async () => {
|
|
714
|
+
render(
|
|
715
|
+
<SyncRuntimeProvider>
|
|
716
|
+
<BottomAnchorThread />
|
|
717
|
+
</SyncRuntimeProvider>,
|
|
718
|
+
);
|
|
719
|
+
const viewport = getViewport();
|
|
720
|
+
await waitFor(() => {
|
|
721
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
722
|
+
});
|
|
723
|
+
return viewport;
|
|
724
|
+
};
|
|
725
|
+
const growContent = () => {
|
|
726
|
+
viewportMeasurementOffset += 200;
|
|
727
|
+
act(notifyResizeObservers);
|
|
728
|
+
};
|
|
729
|
+
const lastMessage = () => screen.getAllByTestId("thread-message").at(-1)!;
|
|
730
|
+
const addTrigger = (
|
|
731
|
+
attributes: Record<string, string>,
|
|
732
|
+
parent: Element = lastMessage(),
|
|
733
|
+
) => {
|
|
734
|
+
const trigger = document.createElement("button");
|
|
735
|
+
for (const [name, value] of Object.entries(attributes))
|
|
736
|
+
trigger.setAttribute(name, value);
|
|
737
|
+
parent.append(trigger);
|
|
738
|
+
return trigger;
|
|
739
|
+
};
|
|
740
|
+
const click = (target: Element) => {
|
|
741
|
+
act(() => {
|
|
742
|
+
target.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
743
|
+
});
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
it("stops following after a click expands a collapsed trigger", async () => {
|
|
747
|
+
const viewport = await renderPinned();
|
|
748
|
+
const scrollTop = viewport.scrollTop;
|
|
749
|
+
|
|
750
|
+
click(addTrigger({ "aria-expanded": "false" }));
|
|
751
|
+
growContent();
|
|
752
|
+
|
|
753
|
+
expect(viewport.scrollTop).toBe(scrollTop);
|
|
754
|
+
expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("stops following after opening a closed details summary", async () => {
|
|
758
|
+
const viewport = await renderPinned();
|
|
759
|
+
const scrollTop = viewport.scrollTop;
|
|
760
|
+
const details = document.createElement("details");
|
|
761
|
+
const summary = document.createElement("summary");
|
|
762
|
+
details.append(summary);
|
|
763
|
+
lastMessage().append(details);
|
|
764
|
+
|
|
765
|
+
click(summary);
|
|
766
|
+
growContent();
|
|
767
|
+
|
|
768
|
+
expect(viewport.scrollTop).toBe(scrollTop);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it("keeps the pause through a resize while the thread still fits", async () => {
|
|
772
|
+
forceShortViewportMeasurement = true;
|
|
773
|
+
const viewport = await renderPinned();
|
|
774
|
+
|
|
775
|
+
click(addTrigger({ "aria-expanded": "false" }));
|
|
776
|
+
act(notifyResizeObservers);
|
|
777
|
+
forceShortViewportMeasurement = false;
|
|
778
|
+
growContent();
|
|
779
|
+
|
|
780
|
+
expect(viewport.scrollTop).toBe(0);
|
|
781
|
+
expect(getMaxScrollTop(viewport)).toBeGreaterThan(0);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("follows again after the reader scrolls back to the bottom", async () => {
|
|
785
|
+
const viewport = await renderPinned();
|
|
786
|
+
|
|
787
|
+
click(addTrigger({ "aria-expanded": "false" }));
|
|
788
|
+
growContent();
|
|
789
|
+
act(() => {
|
|
790
|
+
viewport.dispatchEvent(new WheelEvent("wheel"));
|
|
791
|
+
viewport.scrollTop = getMaxScrollTop(viewport);
|
|
792
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
793
|
+
});
|
|
794
|
+
growContent();
|
|
795
|
+
|
|
796
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it.each([
|
|
800
|
+
{ label: "an expanded trigger", attributes: { "aria-expanded": "true" } },
|
|
801
|
+
{
|
|
802
|
+
label: "a dialog trigger",
|
|
803
|
+
attributes: { "aria-expanded": "false", "aria-haspopup": "dialog" },
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
label: "a menu trigger",
|
|
807
|
+
attributes: { "aria-expanded": "false", "aria-haspopup": "menu" },
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
label: "a combobox",
|
|
811
|
+
attributes: { "aria-expanded": "false", role: "combobox" },
|
|
812
|
+
},
|
|
813
|
+
{ label: "a plain control", attributes: {} },
|
|
814
|
+
])("keeps following after clicking $label", async ({ attributes }) => {
|
|
815
|
+
const viewport = await renderPinned();
|
|
816
|
+
|
|
817
|
+
click(addTrigger(attributes));
|
|
818
|
+
growContent();
|
|
819
|
+
|
|
820
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
it("keeps following after a press on a collapsed trigger that never clicks", async () => {
|
|
824
|
+
const viewport = await renderPinned();
|
|
825
|
+
|
|
826
|
+
act(() => {
|
|
827
|
+
addTrigger({ "aria-expanded": "false" }).dispatchEvent(
|
|
828
|
+
new Event("pointerdown", { bubbles: true }),
|
|
829
|
+
);
|
|
830
|
+
});
|
|
831
|
+
growContent();
|
|
832
|
+
|
|
833
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("keeps following after expanding a trigger outside the messages", async () => {
|
|
837
|
+
const viewport = await renderPinned();
|
|
838
|
+
|
|
839
|
+
click(addTrigger({ "aria-expanded": "false" }, viewport));
|
|
840
|
+
growContent();
|
|
841
|
+
|
|
842
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
843
|
+
});
|
|
844
|
+
it.each([
|
|
845
|
+
{
|
|
846
|
+
label: "flips its state natively",
|
|
847
|
+
listen: (trigger: HTMLElement) =>
|
|
848
|
+
trigger.addEventListener("click", () =>
|
|
849
|
+
trigger.setAttribute("aria-expanded", "true"),
|
|
850
|
+
),
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
label: "stops the event",
|
|
854
|
+
listen: (trigger: HTMLElement) =>
|
|
855
|
+
trigger.addEventListener("click", (event) => event.stopPropagation()),
|
|
856
|
+
},
|
|
857
|
+
])("stops following when the trigger $label", async ({ listen }) => {
|
|
858
|
+
const viewport = await renderPinned();
|
|
859
|
+
const scrollTop = viewport.scrollTop;
|
|
860
|
+
const trigger = addTrigger({ "aria-expanded": "false" });
|
|
861
|
+
listen(trigger);
|
|
862
|
+
|
|
863
|
+
click(trigger);
|
|
864
|
+
growContent();
|
|
865
|
+
|
|
866
|
+
expect(viewport.scrollTop).toBe(scrollTop);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it("keeps the pause when a scroll gesture stops short of the bottom and the thread then fits", async () => {
|
|
870
|
+
const viewport = await renderPinned();
|
|
871
|
+
|
|
872
|
+
click(addTrigger({ "aria-expanded": "false" }));
|
|
873
|
+
growContent();
|
|
874
|
+
act(() => {
|
|
875
|
+
viewport.dispatchEvent(new WheelEvent("wheel"));
|
|
876
|
+
viewport.scrollTop = getMaxScrollTop(viewport) - 50;
|
|
877
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
878
|
+
});
|
|
879
|
+
forceShortViewportMeasurement = true;
|
|
880
|
+
act(notifyResizeObservers);
|
|
881
|
+
forceShortViewportMeasurement = false;
|
|
882
|
+
const scrollTop = viewport.scrollTop;
|
|
883
|
+
growContent();
|
|
884
|
+
|
|
885
|
+
expect(viewport.scrollTop).toBe(scrollTop);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it("treats aria-haspopup=false as a disclosure", async () => {
|
|
889
|
+
const viewport = await renderPinned();
|
|
890
|
+
const scrollTop = viewport.scrollTop;
|
|
891
|
+
|
|
892
|
+
click(addTrigger({ "aria-expanded": "false", "aria-haspopup": "false" }));
|
|
893
|
+
growContent();
|
|
894
|
+
|
|
895
|
+
expect(viewport.scrollTop).toBe(scrollTop);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
it("follows again after the reader pages back to the bottom with Space", async () => {
|
|
899
|
+
const viewport = await renderPinned();
|
|
900
|
+
|
|
901
|
+
click(addTrigger({ "aria-expanded": "false" }));
|
|
902
|
+
growContent();
|
|
903
|
+
act(() => {
|
|
904
|
+
viewport.dispatchEvent(
|
|
905
|
+
new KeyboardEvent("keydown", { key: " ", bubbles: true }),
|
|
906
|
+
);
|
|
907
|
+
viewport.scrollTop = getMaxScrollTop(viewport);
|
|
908
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
909
|
+
});
|
|
910
|
+
growContent();
|
|
911
|
+
|
|
912
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it("keeps following after a non-activating key on a collapsed trigger", async () => {
|
|
916
|
+
const viewport = await renderPinned();
|
|
917
|
+
|
|
918
|
+
act(() => {
|
|
919
|
+
addTrigger({ "aria-expanded": "false" }).dispatchEvent(
|
|
920
|
+
new KeyboardEvent("keydown", { key: "Tab", bubbles: true }),
|
|
921
|
+
);
|
|
922
|
+
});
|
|
923
|
+
growContent();
|
|
924
|
+
|
|
925
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
const renderWithoutRunStartScroll = async () => {
|
|
929
|
+
let runtime: ReturnType<typeof useLocalRuntime> | null = null;
|
|
930
|
+
const Harness: FC = () => {
|
|
931
|
+
runtime = useLocalRuntime(adapter, { initialMessages: messages });
|
|
932
|
+
return (
|
|
933
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
934
|
+
<ThreadPrimitiveRoot>
|
|
935
|
+
<ThreadPrimitiveViewport
|
|
936
|
+
data-testid="viewport"
|
|
937
|
+
scrollToBottomOnRunStart={false}
|
|
938
|
+
>
|
|
939
|
+
<ThreadPrimitiveMessages components={{ Message }} />
|
|
940
|
+
<AtBottom />
|
|
941
|
+
</ThreadPrimitiveViewport>
|
|
942
|
+
</ThreadPrimitiveRoot>
|
|
943
|
+
</AssistantRuntimeProvider>
|
|
944
|
+
);
|
|
945
|
+
};
|
|
946
|
+
render(<Harness />);
|
|
947
|
+
const viewport = getViewport();
|
|
948
|
+
await waitFor(() => {
|
|
949
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
950
|
+
});
|
|
951
|
+
const startRun = () =>
|
|
952
|
+
act(async () => {
|
|
953
|
+
runtime!.thread.append({
|
|
954
|
+
role: "user",
|
|
955
|
+
content: [{ type: "text", text: "next" }],
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
return { viewport, startRun };
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
it("clears the pause when a run starts, even when run start does not scroll", async () => {
|
|
962
|
+
const { viewport, startRun } = await renderWithoutRunStartScroll();
|
|
963
|
+
|
|
964
|
+
click(addTrigger({ "aria-expanded": "false" }));
|
|
965
|
+
await startRun();
|
|
966
|
+
growContent();
|
|
967
|
+
|
|
968
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
it("follows again after a run starts even when the expansion moved the reader off the bottom", async () => {
|
|
972
|
+
const { viewport, startRun } = await renderWithoutRunStartScroll();
|
|
973
|
+
|
|
974
|
+
click(addTrigger({ "aria-expanded": "false" }));
|
|
975
|
+
growContent();
|
|
976
|
+
expect(viewport.scrollTop).toBeLessThan(getMaxScrollTop(viewport));
|
|
977
|
+
await startRun();
|
|
978
|
+
growContent();
|
|
979
|
+
|
|
980
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it("keeps the reader's place after a run starts when they scrolled up during the pause", async () => {
|
|
984
|
+
const { viewport, startRun } = await renderWithoutRunStartScroll();
|
|
985
|
+
|
|
986
|
+
click(addTrigger({ "aria-expanded": "false" }));
|
|
987
|
+
act(() => {
|
|
988
|
+
viewport.dispatchEvent(new WheelEvent("wheel"));
|
|
989
|
+
viewport.scrollTop = getMaxScrollTop(viewport) - 50;
|
|
990
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
991
|
+
});
|
|
992
|
+
const scrollTop = viewport.scrollTop;
|
|
993
|
+
await startRun();
|
|
994
|
+
growContent();
|
|
995
|
+
|
|
996
|
+
expect(viewport.scrollTop).toBe(scrollTop);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it("clears the pause on a thread switch, even when the switch does not scroll", async () => {
|
|
1000
|
+
const SwitchingThread = () => {
|
|
1001
|
+
const [thread, setThread] = useState({ id: "a", messages });
|
|
1002
|
+
const runtime = useExternalStoreRuntime<ThreadMessageLike>({
|
|
1003
|
+
messages: thread.messages,
|
|
1004
|
+
convertMessage: (message) => message,
|
|
1005
|
+
onNew: async () => {},
|
|
1006
|
+
adapters: {
|
|
1007
|
+
threadList: {
|
|
1008
|
+
threadId: thread.id,
|
|
1009
|
+
threads: [
|
|
1010
|
+
{ id: "a", status: "regular" },
|
|
1011
|
+
{ id: "b", status: "regular" },
|
|
1012
|
+
],
|
|
1013
|
+
},
|
|
1014
|
+
},
|
|
1015
|
+
});
|
|
1016
|
+
return (
|
|
1017
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
1018
|
+
<Thread autoScroll scrollToBottomOnThreadSwitch={false} />
|
|
1019
|
+
<button
|
|
1020
|
+
type="button"
|
|
1021
|
+
data-testid="switch"
|
|
1022
|
+
onClick={() =>
|
|
1023
|
+
setThread({
|
|
1024
|
+
id: "b",
|
|
1025
|
+
messages: messages.map((message, index) => ({
|
|
1026
|
+
...message,
|
|
1027
|
+
content: [{ type: "text", text: `Reply ${index + 1}` }],
|
|
1028
|
+
})),
|
|
1029
|
+
})
|
|
1030
|
+
}
|
|
1031
|
+
/>
|
|
1032
|
+
</AssistantRuntimeProvider>
|
|
1033
|
+
);
|
|
1034
|
+
};
|
|
1035
|
+
render(<SwitchingThread />);
|
|
1036
|
+
const viewport = getViewport();
|
|
1037
|
+
await waitFor(() =>
|
|
1038
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(
|
|
1039
|
+
messages.length,
|
|
1040
|
+
),
|
|
1041
|
+
);
|
|
1042
|
+
act(() => {
|
|
1043
|
+
viewport.scrollTop = getMaxScrollTop(viewport);
|
|
1044
|
+
fireEvent.scroll(viewport);
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
click(addTrigger({ "aria-expanded": "false" }));
|
|
1048
|
+
await act(async () => {
|
|
1049
|
+
fireEvent.click(screen.getByTestId("switch"));
|
|
1050
|
+
});
|
|
1051
|
+
growContent();
|
|
1052
|
+
|
|
1053
|
+
expect(viewport.scrollTop).toBe(getMaxScrollTop(viewport));
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
|
|
461
1057
|
it.each([
|
|
462
1058
|
{ label: "pointerdown", make: () => new Event("pointerdown") },
|
|
1059
|
+
{ label: "wheel", make: () => new WheelEvent("wheel") },
|
|
1060
|
+
{ label: "touchstart", make: () => new Event("touchstart") },
|
|
463
1061
|
{
|
|
464
1062
|
label: "Enter keydown",
|
|
465
1063
|
make: () => new KeyboardEvent("keydown", { key: "Enter" }),
|
|
@@ -468,6 +1066,12 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
468
1066
|
label: "Space keydown",
|
|
469
1067
|
make: () => new KeyboardEvent("keydown", { key: " " }),
|
|
470
1068
|
},
|
|
1069
|
+
...["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].map(
|
|
1070
|
+
(key) => ({
|
|
1071
|
+
label: `${key} keydown`,
|
|
1072
|
+
make: () => new KeyboardEvent("keydown", { key }),
|
|
1073
|
+
}),
|
|
1074
|
+
),
|
|
471
1075
|
])(
|
|
472
1076
|
"drops pending bottom-scroll intent after a $label in a thread that cannot scroll",
|
|
473
1077
|
async ({ make }) => {
|
|
@@ -503,6 +1107,8 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
503
1107
|
|
|
504
1108
|
it.each([
|
|
505
1109
|
{ label: "pointerdown", make: () => new Event("pointerdown") },
|
|
1110
|
+
{ label: "wheel", make: () => new WheelEvent("wheel") },
|
|
1111
|
+
{ label: "touchstart", make: () => new Event("touchstart") },
|
|
506
1112
|
{
|
|
507
1113
|
label: "Enter keydown",
|
|
508
1114
|
make: () => new KeyboardEvent("keydown", { key: "Enter" }),
|
|
@@ -511,6 +1117,10 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
511
1117
|
label: "Space keydown",
|
|
512
1118
|
make: () => new KeyboardEvent("keydown", { key: " " }),
|
|
513
1119
|
},
|
|
1120
|
+
{
|
|
1121
|
+
label: "ArrowDown keydown",
|
|
1122
|
+
make: () => new KeyboardEvent("keydown", { key: "ArrowDown" }),
|
|
1123
|
+
},
|
|
514
1124
|
])(
|
|
515
1125
|
"cancels the frame a pending bottom scroll queued when a $label arrives first",
|
|
516
1126
|
async ({ make }) => {
|
|
@@ -566,73 +1176,66 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
566
1176
|
},
|
|
567
1177
|
);
|
|
568
1178
|
|
|
569
|
-
it.each([
|
|
570
|
-
"
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
"Tab",
|
|
574
|
-
"ArrowDown",
|
|
575
|
-
"PageDown",
|
|
576
|
-
"Escape",
|
|
577
|
-
"a",
|
|
578
|
-
])("keeps pending bottom-scroll intent through a %s press", async (key) => {
|
|
579
|
-
forceShortViewportMeasurement = true;
|
|
580
|
-
|
|
581
|
-
render(
|
|
582
|
-
<AsyncRuntimeProvider>
|
|
583
|
-
<Thread />
|
|
584
|
-
</AsyncRuntimeProvider>,
|
|
585
|
-
);
|
|
1179
|
+
it.each(["Shift", "Control", "Meta", "Tab", "Escape", "a"])(
|
|
1180
|
+
"keeps pending bottom-scroll intent through a %s press",
|
|
1181
|
+
async (key) => {
|
|
1182
|
+
forceShortViewportMeasurement = true;
|
|
586
1183
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1184
|
+
render(
|
|
1185
|
+
<AsyncRuntimeProvider>
|
|
1186
|
+
<Thread />
|
|
1187
|
+
</AsyncRuntimeProvider>,
|
|
590
1188
|
);
|
|
591
|
-
});
|
|
592
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
593
1189
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1190
|
+
await waitFor(() => {
|
|
1191
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(
|
|
1192
|
+
messages.length,
|
|
1193
|
+
);
|
|
1194
|
+
});
|
|
1195
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
598
1196
|
|
|
599
|
-
|
|
600
|
-
|
|
1197
|
+
// neither an activation key nor consumed by a text field
|
|
1198
|
+
act(() => {
|
|
1199
|
+
getViewport().dispatchEvent(new KeyboardEvent("keydown", { key }));
|
|
1200
|
+
});
|
|
601
1201
|
|
|
602
|
-
|
|
603
|
-
|
|
1202
|
+
forceShortViewportMeasurement = false;
|
|
1203
|
+
act(notifyResizeObservers);
|
|
604
1204
|
|
|
605
|
-
|
|
606
|
-
|
|
1205
|
+
expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
|
|
1206
|
+
},
|
|
1207
|
+
);
|
|
607
1208
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
);
|
|
1209
|
+
it.each([" ", "ArrowDown"])(
|
|
1210
|
+
"keeps pending bottom-scroll intent through a %s press in the composer",
|
|
1211
|
+
async (key) => {
|
|
1212
|
+
forceShortViewportMeasurement = true;
|
|
613
1213
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
1214
|
+
render(
|
|
1215
|
+
<AsyncRuntimeProvider>
|
|
1216
|
+
<Thread />
|
|
1217
|
+
</AsyncRuntimeProvider>,
|
|
617
1218
|
);
|
|
618
|
-
});
|
|
619
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
620
1219
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
// Space is an activation key, so this reaches the text-entry check
|
|
625
|
-
// instead of short-circuiting on the allowlist
|
|
626
|
-
.dispatchEvent(
|
|
627
|
-
new KeyboardEvent("keydown", { key: " ", bubbles: true }),
|
|
1220
|
+
await waitFor(() => {
|
|
1221
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(
|
|
1222
|
+
messages.length,
|
|
628
1223
|
);
|
|
629
|
-
|
|
1224
|
+
});
|
|
1225
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
630
1226
|
|
|
631
|
-
|
|
632
|
-
|
|
1227
|
+
act(() => {
|
|
1228
|
+
screen
|
|
1229
|
+
.getByTestId("composer")
|
|
1230
|
+
.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
|
|
1231
|
+
});
|
|
633
1232
|
|
|
634
|
-
|
|
635
|
-
|
|
1233
|
+
forceShortViewportMeasurement = false;
|
|
1234
|
+
act(notifyResizeObservers);
|
|
1235
|
+
|
|
1236
|
+
expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
|
|
1237
|
+
},
|
|
1238
|
+
);
|
|
636
1239
|
|
|
637
1240
|
it.each([
|
|
638
1241
|
{ label: "a checkbox", testid: "checkbox" },
|