@assistant-ui/react 0.15.24 → 0.15.26
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 +3 -1
- package/dist/hooks/useToolCallElapsed.d.ts.map +1 -1
- package/dist/hooks/useToolCallElapsed.js +8 -8
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +39 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- 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 +56 -46
- package/dist/mcp-apps/app-frame.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/types.d.ts +1 -0
- package/dist/mcp-apps/types.d.ts.map +1 -1
- package/dist/mcp-apps/types.js.map +1 -1
- package/dist/model-context/makeAssistantVisible.d.ts +1 -0
- package/dist/model-context/makeAssistantVisible.d.ts.map +1 -1
- package/dist/model-context/makeAssistantVisible.js +31 -12
- package/dist/model-context/makeAssistantVisible.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/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 +15 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +5 -4
- 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/trigger/TriggerPopover.d.ts +11 -11
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js +62 -60
- package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverAction.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +6 -6
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts +3 -3
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts +9 -9
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +7 -7
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts +5 -5
- package/dist/primitives/composer/trigger/TriggerPopoverItems.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +8 -8
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
- package/dist/primitives/composer/trigger/index.d.ts +1 -1
- 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 +55 -15
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js +8 -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/ThreadLoadEarlier.d.ts +19 -0
- package/dist/primitives/thread/ThreadLoadEarlier.d.ts.map +1 -0
- package/dist/primitives/thread/ThreadLoadEarlier.js +36 -0
- package/dist/primitives/thread/ThreadLoadEarlier.js.map +1 -0
- package/dist/primitives/thread/ThreadMessages.d.ts +1 -1
- package/dist/primitives/thread/ThreadMessages.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadMessages.js +2 -2
- 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 +195 -17
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/primitives/thread.d.ts +2 -0
- package/dist/primitives/thread.d.ts.map +1 -1
- package/dist/primitives/thread.js +5 -2
- package/dist/primitives/thread.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +9 -3
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useComposerInput.d.ts +7 -4
- package/dist/unstable/useComposerInput.d.ts.map +1 -1
- package/dist/unstable/useComposerInput.js +4 -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 +12 -8
- 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/smooth/useSmooth.js +6 -6
- package/dist/utils/smooth/useSmooth.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 +12 -11
- package/src/hooks/useToolCallElapsed.test.tsx +37 -0
- package/src/hooks/useToolCallElapsed.ts +10 -7
- package/src/index.ts +47 -5
- package/src/mcp-apps/McpAppRenderer.tsx +3 -9
- package/src/mcp-apps/app-frame.test.tsx +38 -0
- package/src/mcp-apps/app-frame.tsx +5 -8
- package/src/mcp-apps/csp.test.ts +112 -0
- package/src/mcp-apps/csp.ts +124 -0
- package/src/mcp-apps/types.ts +1 -0
- package/src/model-context/frame/AssistantFrame.test.ts +45 -0
- package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
- package/src/model-context/makeAssistantVisible.test.tsx +315 -5
- package/src/model-context/makeAssistantVisible.tsx +43 -7
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
- package/src/primitives/attachment.ts +4 -1
- package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
- package/src/primitives/composer/ComposerCancel.ts +8 -1
- package/src/primitives/composer/ComposerInput.tsx +20 -2
- package/src/primitives/composer/ComposerRoot.tsx +6 -5
- package/src/primitives/composer/composerInputSelector.ts +4 -0
- package/src/primitives/composer/trigger/TriggerPopover.tsx +11 -11
- package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +10 -13
- package/src/primitives/composer/trigger/TriggerPopoverCategories.tsx +2 -2
- package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +11 -14
- package/src/primitives/composer/trigger/TriggerPopoverExports.test.ts +61 -0
- package/src/primitives/composer/trigger/TriggerPopoverItems.tsx +3 -3
- package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +8 -8
- package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
- package/src/primitives/composer.ts +70 -15
- package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
- package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
- package/src/primitives/message/MessageRoot.test.tsx +125 -2
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
- package/src/primitives/thread/ThreadLoadEarlier.tsx +35 -0
- package/src/primitives/thread/ThreadMessages.tsx +1 -0
- package/src/primitives/thread/ThreadViewport.tsx +3 -3
- package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +922 -68
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +330 -56
- package/src/primitives/thread.ts +2 -0
- package/src/sandbox-host/SandboxHost.test.tsx +34 -0
- package/src/sandbox-host/SandboxHost.tsx +11 -12
- package/src/tests/toolCallTiming.test.tsx +19 -2
- package/src/unstable/useComposerInput.ts +7 -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.test.tsx +40 -0
- package/src/unstable/useMessageStallDetection.ts +14 -8
- 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/smooth/useSmooth.test.tsx +16 -5
- package/src/utils/smooth/useSmooth.ts +7 -7
- 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";
|
|
@@ -28,11 +31,13 @@ import { ThreadPrimitiveScrollToBottom } from "./ThreadScrollToBottom";
|
|
|
28
31
|
import { ThreadPrimitiveViewport } from "./ThreadViewport";
|
|
29
32
|
import {
|
|
30
33
|
ExportedMessageRepository,
|
|
34
|
+
useExternalStoreRuntime,
|
|
31
35
|
useLocalRuntime,
|
|
32
36
|
type ChatModelAdapter,
|
|
33
37
|
type ThreadHistoryAdapter,
|
|
34
38
|
type ThreadMessageLike,
|
|
35
39
|
} from "../../index";
|
|
40
|
+
import { ThreadPrimitiveLoadEarlier } from "./ThreadLoadEarlier";
|
|
36
41
|
|
|
37
42
|
const adapter: ChatModelAdapter = {
|
|
38
43
|
async *run() {},
|
|
@@ -50,25 +55,51 @@ const getMaxScrollTop = (element: Element) =>
|
|
|
50
55
|
|
|
51
56
|
let forceShortViewportMeasurement = false;
|
|
52
57
|
let viewportMeasurementOffset = 0;
|
|
53
|
-
const
|
|
58
|
+
const messageHeights = new Map<string, number>();
|
|
59
|
+
const resizeObservers = new Set<TestResizeObserver>();
|
|
60
|
+
|
|
61
|
+
const messageRows = () => [
|
|
62
|
+
...document.querySelectorAll<HTMLElement>('[data-testid="thread-message"]'),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const rowHeight = (row: Element) =>
|
|
66
|
+
messageHeights.get(row.getAttribute("data-message-id") ?? "") ?? 80;
|
|
67
|
+
|
|
68
|
+
const rowsHeight = (rows: readonly Element[]) =>
|
|
69
|
+
rows.reduce((height, row) => height + rowHeight(row), 0);
|
|
54
70
|
|
|
55
71
|
class TestResizeObserver {
|
|
56
|
-
|
|
72
|
+
readonly targets = new Set<Element>();
|
|
73
|
+
readonly callback: ResizeObserverCallback;
|
|
57
74
|
|
|
58
75
|
constructor(callback: ResizeObserverCallback) {
|
|
59
76
|
this.callback = callback;
|
|
60
|
-
|
|
77
|
+
resizeObservers.add(this);
|
|
61
78
|
}
|
|
62
79
|
|
|
63
|
-
observe() {
|
|
80
|
+
observe(target: Element) {
|
|
81
|
+
this.targets.add(target);
|
|
82
|
+
}
|
|
83
|
+
unobserve(target: Element) {
|
|
84
|
+
this.targets.delete(target);
|
|
85
|
+
}
|
|
64
86
|
disconnect() {
|
|
65
|
-
|
|
87
|
+
resizeObservers.delete(this);
|
|
66
88
|
}
|
|
67
89
|
}
|
|
68
90
|
|
|
69
91
|
const notifyResizeObservers = () => {
|
|
70
|
-
for (const
|
|
71
|
-
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
|
+
}
|
|
72
103
|
}
|
|
73
104
|
};
|
|
74
105
|
|
|
@@ -121,11 +152,32 @@ beforeAll(() => {
|
|
|
121
152
|
get() {
|
|
122
153
|
if (this.getAttribute("data-testid") !== "viewport") return 0;
|
|
123
154
|
if (forceShortViewportMeasurement) return this.clientHeight;
|
|
124
|
-
return (
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
155
|
+
return rowsHeight(messageRows()) + viewportMeasurementOffset;
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
Object.defineProperty(HTMLElement.prototype, "getBoundingClientRect", {
|
|
160
|
+
configurable: true,
|
|
161
|
+
value(this: HTMLElement) {
|
|
162
|
+
const rows = messageRows();
|
|
163
|
+
const index = rows.indexOf(this);
|
|
164
|
+
const viewport = this.closest<HTMLElement>('[data-testid="viewport"]');
|
|
165
|
+
const top =
|
|
166
|
+
index === -1
|
|
167
|
+
? 0
|
|
168
|
+
: rowsHeight(rows.slice(0, index)) - (viewport?.scrollTop ?? 0);
|
|
169
|
+
const height = index === -1 ? 0 : rowHeight(this);
|
|
170
|
+
return {
|
|
171
|
+
x: 0,
|
|
172
|
+
y: top,
|
|
173
|
+
top,
|
|
174
|
+
bottom: top + height,
|
|
175
|
+
left: 0,
|
|
176
|
+
right: 0,
|
|
177
|
+
width: 0,
|
|
178
|
+
height,
|
|
179
|
+
toJSON: () => ({}),
|
|
180
|
+
} satisfies DOMRect;
|
|
129
181
|
},
|
|
130
182
|
});
|
|
131
183
|
|
|
@@ -141,7 +193,8 @@ beforeAll(() => {
|
|
|
141
193
|
afterEach(() => {
|
|
142
194
|
forceShortViewportMeasurement = false;
|
|
143
195
|
viewportMeasurementOffset = 0;
|
|
144
|
-
|
|
196
|
+
messageHeights.clear();
|
|
197
|
+
resizeObservers.clear();
|
|
145
198
|
cleanup();
|
|
146
199
|
});
|
|
147
200
|
|
|
@@ -166,12 +219,24 @@ const AtBottom: FC = () => {
|
|
|
166
219
|
return <output data-testid="is-at-bottom">{String(isAtBottom)}</output>;
|
|
167
220
|
};
|
|
168
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
|
+
|
|
169
232
|
const Thread = ({
|
|
170
233
|
autoScroll,
|
|
171
234
|
scrollToBottomOnInitialize,
|
|
235
|
+
scrollToBottomOnThreadSwitch,
|
|
172
236
|
}: {
|
|
173
237
|
autoScroll?: boolean | undefined;
|
|
174
238
|
scrollToBottomOnInitialize?: boolean | undefined;
|
|
239
|
+
scrollToBottomOnThreadSwitch?: boolean | undefined;
|
|
175
240
|
}) => (
|
|
176
241
|
<ThreadPrimitiveRoot>
|
|
177
242
|
<ThreadPrimitiveViewport
|
|
@@ -179,6 +244,7 @@ const Thread = ({
|
|
|
179
244
|
data-testid="viewport"
|
|
180
245
|
turnAnchor="top"
|
|
181
246
|
scrollToBottomOnInitialize={scrollToBottomOnInitialize}
|
|
247
|
+
scrollToBottomOnThreadSwitch={scrollToBottomOnThreadSwitch}
|
|
182
248
|
>
|
|
183
249
|
<ThreadPrimitiveMessages components={{ Message }} />
|
|
184
250
|
<AtBottom />
|
|
@@ -260,6 +326,50 @@ const DelayedThread = ({
|
|
|
260
326
|
};
|
|
261
327
|
|
|
262
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
|
+
|
|
263
373
|
it("preserves smooth scrolling from a control outside the viewport", async () => {
|
|
264
374
|
render(
|
|
265
375
|
<SyncRuntimeProvider>
|
|
@@ -289,6 +399,144 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
289
399
|
}
|
|
290
400
|
});
|
|
291
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
|
+
|
|
292
540
|
it("scrolls sync initialMessages to the bottom when the viewport mounts after initialization", async () => {
|
|
293
541
|
render(
|
|
294
542
|
<SyncRuntimeProvider>
|
|
@@ -398,6 +646,47 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
398
646
|
expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
|
|
399
647
|
});
|
|
400
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
|
+
|
|
401
690
|
it("keeps following after a pointerdown that does not scroll the viewport", async () => {
|
|
402
691
|
render(
|
|
403
692
|
<SyncRuntimeProvider>
|
|
@@ -420,8 +709,355 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
420
709
|
expect(screen.getByTestId("is-at-bottom").textContent).toBe("true");
|
|
421
710
|
});
|
|
422
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
|
+
|
|
423
1057
|
it.each([
|
|
424
1058
|
{ label: "pointerdown", make: () => new Event("pointerdown") },
|
|
1059
|
+
{ label: "wheel", make: () => new WheelEvent("wheel") },
|
|
1060
|
+
{ label: "touchstart", make: () => new Event("touchstart") },
|
|
425
1061
|
{
|
|
426
1062
|
label: "Enter keydown",
|
|
427
1063
|
make: () => new KeyboardEvent("keydown", { key: "Enter" }),
|
|
@@ -430,6 +1066,12 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
430
1066
|
label: "Space keydown",
|
|
431
1067
|
make: () => new KeyboardEvent("keydown", { key: " " }),
|
|
432
1068
|
},
|
|
1069
|
+
...["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].map(
|
|
1070
|
+
(key) => ({
|
|
1071
|
+
label: `${key} keydown`,
|
|
1072
|
+
make: () => new KeyboardEvent("keydown", { key }),
|
|
1073
|
+
}),
|
|
1074
|
+
),
|
|
433
1075
|
])(
|
|
434
1076
|
"drops pending bottom-scroll intent after a $label in a thread that cannot scroll",
|
|
435
1077
|
async ({ make }) => {
|
|
@@ -465,6 +1107,8 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
465
1107
|
|
|
466
1108
|
it.each([
|
|
467
1109
|
{ label: "pointerdown", make: () => new Event("pointerdown") },
|
|
1110
|
+
{ label: "wheel", make: () => new WheelEvent("wheel") },
|
|
1111
|
+
{ label: "touchstart", make: () => new Event("touchstart") },
|
|
468
1112
|
{
|
|
469
1113
|
label: "Enter keydown",
|
|
470
1114
|
make: () => new KeyboardEvent("keydown", { key: "Enter" }),
|
|
@@ -473,6 +1117,10 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
473
1117
|
label: "Space keydown",
|
|
474
1118
|
make: () => new KeyboardEvent("keydown", { key: " " }),
|
|
475
1119
|
},
|
|
1120
|
+
{
|
|
1121
|
+
label: "ArrowDown keydown",
|
|
1122
|
+
make: () => new KeyboardEvent("keydown", { key: "ArrowDown" }),
|
|
1123
|
+
},
|
|
476
1124
|
])(
|
|
477
1125
|
"cancels the frame a pending bottom scroll queued when a $label arrives first",
|
|
478
1126
|
async ({ make }) => {
|
|
@@ -528,73 +1176,66 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
528
1176
|
},
|
|
529
1177
|
);
|
|
530
1178
|
|
|
531
|
-
it.each([
|
|
532
|
-
"
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
"Tab",
|
|
536
|
-
"ArrowDown",
|
|
537
|
-
"PageDown",
|
|
538
|
-
"Escape",
|
|
539
|
-
"a",
|
|
540
|
-
])("keeps pending bottom-scroll intent through a %s press", async (key) => {
|
|
541
|
-
forceShortViewportMeasurement = true;
|
|
542
|
-
|
|
543
|
-
render(
|
|
544
|
-
<AsyncRuntimeProvider>
|
|
545
|
-
<Thread />
|
|
546
|
-
</AsyncRuntimeProvider>,
|
|
547
|
-
);
|
|
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;
|
|
548
1183
|
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
1184
|
+
render(
|
|
1185
|
+
<AsyncRuntimeProvider>
|
|
1186
|
+
<Thread />
|
|
1187
|
+
</AsyncRuntimeProvider>,
|
|
552
1188
|
);
|
|
553
|
-
});
|
|
554
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
555
1189
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
1190
|
+
await waitFor(() => {
|
|
1191
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(
|
|
1192
|
+
messages.length,
|
|
1193
|
+
);
|
|
1194
|
+
});
|
|
1195
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
560
1196
|
|
|
561
|
-
|
|
562
|
-
|
|
1197
|
+
// neither an activation key nor consumed by a text field
|
|
1198
|
+
act(() => {
|
|
1199
|
+
getViewport().dispatchEvent(new KeyboardEvent("keydown", { key }));
|
|
1200
|
+
});
|
|
563
1201
|
|
|
564
|
-
|
|
565
|
-
|
|
1202
|
+
forceShortViewportMeasurement = false;
|
|
1203
|
+
act(notifyResizeObservers);
|
|
566
1204
|
|
|
567
|
-
|
|
568
|
-
|
|
1205
|
+
expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
|
|
1206
|
+
},
|
|
1207
|
+
);
|
|
569
1208
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
);
|
|
1209
|
+
it.each([" ", "ArrowDown"])(
|
|
1210
|
+
"keeps pending bottom-scroll intent through a %s press in the composer",
|
|
1211
|
+
async (key) => {
|
|
1212
|
+
forceShortViewportMeasurement = true;
|
|
575
1213
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1214
|
+
render(
|
|
1215
|
+
<AsyncRuntimeProvider>
|
|
1216
|
+
<Thread />
|
|
1217
|
+
</AsyncRuntimeProvider>,
|
|
579
1218
|
);
|
|
580
|
-
});
|
|
581
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
582
1219
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
// Space is an activation key, so this reaches the text-entry check
|
|
587
|
-
// instead of short-circuiting on the allowlist
|
|
588
|
-
.dispatchEvent(
|
|
589
|
-
new KeyboardEvent("keydown", { key: " ", bubbles: true }),
|
|
1220
|
+
await waitFor(() => {
|
|
1221
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(
|
|
1222
|
+
messages.length,
|
|
590
1223
|
);
|
|
591
|
-
|
|
1224
|
+
});
|
|
1225
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
592
1226
|
|
|
593
|
-
|
|
594
|
-
|
|
1227
|
+
act(() => {
|
|
1228
|
+
screen
|
|
1229
|
+
.getByTestId("composer")
|
|
1230
|
+
.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
|
|
1231
|
+
});
|
|
595
1232
|
|
|
596
|
-
|
|
597
|
-
|
|
1233
|
+
forceShortViewportMeasurement = false;
|
|
1234
|
+
act(notifyResizeObservers);
|
|
1235
|
+
|
|
1236
|
+
expect(getViewport().scrollTop).toBe(getMaxScrollTop(getViewport()));
|
|
1237
|
+
},
|
|
1238
|
+
);
|
|
598
1239
|
|
|
599
1240
|
it.each([
|
|
600
1241
|
{ label: "a checkbox", testid: "checkbox" },
|
|
@@ -908,4 +1549,217 @@ describe("useThreadViewportAutoScroll", () => {
|
|
|
908
1549
|
act(notifyResizeObservers);
|
|
909
1550
|
expect(screen.getByTestId("is-at-bottom").textContent).toBe("false");
|
|
910
1551
|
});
|
|
1552
|
+
|
|
1553
|
+
describe("earlier messages", () => {
|
|
1554
|
+
const pagedMessages: ThreadMessageLike[] = messages.map(
|
|
1555
|
+
(message, index) => ({
|
|
1556
|
+
...message,
|
|
1557
|
+
id: `message-${index}`,
|
|
1558
|
+
}),
|
|
1559
|
+
);
|
|
1560
|
+
|
|
1561
|
+
type PagedThreadProps = {
|
|
1562
|
+
follow?: boolean;
|
|
1563
|
+
running?: boolean;
|
|
1564
|
+
/** Runs in the same update that prepends the page. */
|
|
1565
|
+
onPrepend?: () => void;
|
|
1566
|
+
};
|
|
1567
|
+
|
|
1568
|
+
const PagedThread = ({
|
|
1569
|
+
follow = false,
|
|
1570
|
+
running = false,
|
|
1571
|
+
onPrepend,
|
|
1572
|
+
}: PagedThreadProps) => {
|
|
1573
|
+
const [loaded, setLoaded] = useState(pagedMessages.slice(4));
|
|
1574
|
+
const runtime = useExternalStoreRuntime<ThreadMessageLike>({
|
|
1575
|
+
messages: loaded,
|
|
1576
|
+
convertMessage: (message) => message,
|
|
1577
|
+
onNew: async () => {},
|
|
1578
|
+
isRunning: running,
|
|
1579
|
+
hasEarlier: loaded.length < pagedMessages.length,
|
|
1580
|
+
onLoadEarlier: async () => {
|
|
1581
|
+
onPrepend?.();
|
|
1582
|
+
setLoaded(pagedMessages);
|
|
1583
|
+
},
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
return (
|
|
1587
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
1588
|
+
{follow ? <BottomAnchorThread /> : <Thread />}
|
|
1589
|
+
<ThreadPrimitiveLoadEarlier data-testid="load-earlier" />
|
|
1590
|
+
</AssistantRuntimeProvider>
|
|
1591
|
+
);
|
|
1592
|
+
};
|
|
1593
|
+
|
|
1594
|
+
const renderAt = async (
|
|
1595
|
+
scrollTop: number,
|
|
1596
|
+
{ follow = false, onPrepend }: PagedThreadProps = {},
|
|
1597
|
+
) => {
|
|
1598
|
+
render(<PagedThread follow={follow} {...(onPrepend && { onPrepend })} />);
|
|
1599
|
+
await waitFor(() =>
|
|
1600
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
|
|
1601
|
+
);
|
|
1602
|
+
act(() => {
|
|
1603
|
+
getViewport().scrollTop = scrollTop;
|
|
1604
|
+
fireEvent.scroll(getViewport());
|
|
1605
|
+
});
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
const loadEarlier = async () => {
|
|
1609
|
+
await act(async () => {
|
|
1610
|
+
fireEvent.click(screen.getByTestId("load-earlier"));
|
|
1611
|
+
});
|
|
1612
|
+
await waitFor(() =>
|
|
1613
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(8),
|
|
1614
|
+
);
|
|
1615
|
+
act(notifyResizeObservers);
|
|
1616
|
+
};
|
|
1617
|
+
|
|
1618
|
+
it("keeps the rows a reader scrolled to in place when an earlier page is prepended", async () => {
|
|
1619
|
+
await renderAt(0);
|
|
1620
|
+
|
|
1621
|
+
await loadEarlier();
|
|
1622
|
+
|
|
1623
|
+
expect(getViewport().scrollTop).toBe(4 * 80);
|
|
1624
|
+
expect(
|
|
1625
|
+
(screen.getByTestId("load-earlier") as HTMLButtonElement).disabled,
|
|
1626
|
+
).toBe(true);
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
it.each([false, true])(
|
|
1630
|
+
"keeps a reader at the bottom at the bottom (auto-follow %s)",
|
|
1631
|
+
async (follow) => {
|
|
1632
|
+
await renderAt(4 * 80 - 100, { follow });
|
|
1633
|
+
|
|
1634
|
+
await loadEarlier();
|
|
1635
|
+
|
|
1636
|
+
expect(getViewport().scrollTop).toBe(8 * 80 - 100);
|
|
1637
|
+
},
|
|
1638
|
+
);
|
|
1639
|
+
|
|
1640
|
+
it("does not count growth below the reader in the same update as part of the page", async () => {
|
|
1641
|
+
await renderAt(100, {
|
|
1642
|
+
onPrepend: () => {
|
|
1643
|
+
viewportMeasurementOffset = 40;
|
|
1644
|
+
},
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
await loadEarlier();
|
|
1648
|
+
|
|
1649
|
+
expect(getViewport().scrollTop).toBe(4 * 80 + 100);
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
it("holds the rows in place while the page above settles, until the reader's next gesture", async () => {
|
|
1653
|
+
await renderAt(0);
|
|
1654
|
+
await loadEarlier();
|
|
1655
|
+
expect(getViewport().scrollTop).toBe(4 * 80);
|
|
1656
|
+
|
|
1657
|
+
messageHeights.set("message-3", 120);
|
|
1658
|
+
act(notifyResizeObservers);
|
|
1659
|
+
expect(getViewport().scrollTop).toBe(4 * 80 + 40);
|
|
1660
|
+
|
|
1661
|
+
fireEvent.wheel(getViewport());
|
|
1662
|
+
messageHeights.set("message-2", 120);
|
|
1663
|
+
act(notifyResizeObservers);
|
|
1664
|
+
expect(getViewport().scrollTop).toBe(4 * 80 + 40);
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
it("lets go of the held rows when a run starts, so the new turn can take the viewport", async () => {
|
|
1668
|
+
const { rerender } = render(<PagedThread />);
|
|
1669
|
+
await waitFor(() =>
|
|
1670
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
|
|
1671
|
+
);
|
|
1672
|
+
act(() => {
|
|
1673
|
+
getViewport().scrollTop = 0;
|
|
1674
|
+
fireEvent.scroll(getViewport());
|
|
1675
|
+
});
|
|
1676
|
+
await loadEarlier();
|
|
1677
|
+
|
|
1678
|
+
await act(async () => {
|
|
1679
|
+
rerender(<PagedThread running />);
|
|
1680
|
+
});
|
|
1681
|
+
messageHeights.set("message-3", 120);
|
|
1682
|
+
act(notifyResizeObservers);
|
|
1683
|
+
|
|
1684
|
+
expect(getViewport().scrollTop).toBe(4 * 80);
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
it("lets go of the held rows once something else scrolls the viewport", async () => {
|
|
1688
|
+
await renderAt(0);
|
|
1689
|
+
await loadEarlier();
|
|
1690
|
+
|
|
1691
|
+
act(() => {
|
|
1692
|
+
getViewport().scrollTo({ top: 100 });
|
|
1693
|
+
});
|
|
1694
|
+
messageHeights.set("message-3", 120);
|
|
1695
|
+
act(notifyResizeObservers);
|
|
1696
|
+
|
|
1697
|
+
expect(getViewport().scrollTop).toBe(100);
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
it("holds the row the reader sees, not the old first message, while content between them settles", async () => {
|
|
1701
|
+
await renderAt(100);
|
|
1702
|
+
await loadEarlier();
|
|
1703
|
+
expect(getViewport().scrollTop).toBe(4 * 80 + 100);
|
|
1704
|
+
|
|
1705
|
+
messageHeights.set("message-4", 120);
|
|
1706
|
+
act(notifyResizeObservers);
|
|
1707
|
+
expect(getViewport().scrollTop).toBe(4 * 80 + 100 + 40);
|
|
1708
|
+
});
|
|
1709
|
+
|
|
1710
|
+
it("does not treat a thread switch as a page, even when the new thread repeats the old first message", async () => {
|
|
1711
|
+
const SwitchingThread = () => {
|
|
1712
|
+
const [thread, setThread] = useState({
|
|
1713
|
+
id: "a",
|
|
1714
|
+
messages: pagedMessages.slice(4),
|
|
1715
|
+
});
|
|
1716
|
+
const runtime = useExternalStoreRuntime<ThreadMessageLike>({
|
|
1717
|
+
messages: thread.messages,
|
|
1718
|
+
convertMessage: (message) => message,
|
|
1719
|
+
onNew: async () => {},
|
|
1720
|
+
adapters: {
|
|
1721
|
+
threadList: {
|
|
1722
|
+
threadId: thread.id,
|
|
1723
|
+
threads: [
|
|
1724
|
+
{ id: "a", status: "regular" },
|
|
1725
|
+
{ id: "b", status: "regular" },
|
|
1726
|
+
],
|
|
1727
|
+
},
|
|
1728
|
+
},
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
return (
|
|
1732
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
1733
|
+
<Thread scrollToBottomOnThreadSwitch={false} />
|
|
1734
|
+
<button
|
|
1735
|
+
type="button"
|
|
1736
|
+
data-testid="switch"
|
|
1737
|
+
onClick={() =>
|
|
1738
|
+
setThread({ id: "b", messages: pagedMessages.slice(3) })
|
|
1739
|
+
}
|
|
1740
|
+
/>
|
|
1741
|
+
</AssistantRuntimeProvider>
|
|
1742
|
+
);
|
|
1743
|
+
};
|
|
1744
|
+
|
|
1745
|
+
render(<SwitchingThread />);
|
|
1746
|
+
await waitFor(() =>
|
|
1747
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(4),
|
|
1748
|
+
);
|
|
1749
|
+
act(() => {
|
|
1750
|
+
getViewport().scrollTop = 100;
|
|
1751
|
+
fireEvent.scroll(getViewport());
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
await act(async () => {
|
|
1755
|
+
fireEvent.click(screen.getByTestId("switch"));
|
|
1756
|
+
});
|
|
1757
|
+
await waitFor(() =>
|
|
1758
|
+
expect(screen.getAllByTestId("thread-message")).toHaveLength(5),
|
|
1759
|
+
);
|
|
1760
|
+
act(notifyResizeObservers);
|
|
1761
|
+
|
|
1762
|
+
expect(getViewport().scrollTop).toBe(100);
|
|
1763
|
+
});
|
|
1764
|
+
});
|
|
911
1765
|
});
|