@assistant-ui/react 0.15.29 → 0.15.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context/stores/ThreadViewport.d.ts +8 -0
- package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
- package/dist/context/stores/ThreadViewport.js +24 -8
- package/dist/context/stores/ThreadViewport.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerResume.d.ts +23 -0
- package/dist/primitives/composer/ComposerResume.d.ts.map +1 -0
- package/dist/primitives/composer/ComposerResume.js +34 -0
- package/dist/primitives/composer/ComposerResume.js.map +1 -0
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts +6 -2
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js +4 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/composer.d.ts +1 -0
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js +3 -1
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +32 -292
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +1 -1
- package/package.json +5 -5
- package/src/context/stores/ThreadViewport.ts +36 -8
- package/src/index.ts +2 -0
- package/src/model-context/makeAssistantVisible.test.tsx +25 -37
- package/src/primitives/composer/ComposerInput.tsx +1 -1
- package/src/primitives/composer/ComposerResume.ts +36 -0
- package/src/primitives/composer/trigger/triggerSelectionResource.test.ts +24 -0
- package/src/primitives/composer/trigger/triggerSelectionResource.ts +14 -3
- package/src/primitives/composer/useAuiState.composer.test.tsx +84 -0
- package/src/primitives/composer.ts +1 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +90 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +48 -486
- package/src/model-context/frame/AssistantFrame.test.ts +0 -400
|
@@ -1,126 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useComposedRefs } from "radix-ui/internal";
|
|
4
|
-
import { useCallback, useRef, type RefCallback } from "react";
|
|
5
|
-
import { useAui, useAuiEvent, useAuiState } from "@assistant-ui/store";
|
|
6
3
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
useCallback,
|
|
5
|
+
useInsertionEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
type RefCallback,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { useAui, useAuiEvent, useAuiState } from "@assistant-ui/store";
|
|
12
10
|
import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
|
|
13
11
|
import { useManagedRef } from "../../utils/hooks/useManagedRef";
|
|
14
|
-
import { writableStore } from "../../context/ReadonlyStore";
|
|
15
12
|
import { useThreadViewportStore } from "../../context/react/ThreadViewportContext";
|
|
13
|
+
import { getThreadViewportAutoScroll } from "../../context/stores/ThreadViewport";
|
|
16
14
|
import { useIsomorphicLayoutEffect } from "../../utils/useIsomorphicLayoutEffect";
|
|
17
15
|
|
|
18
|
-
const SCROLL_KEYS = new Set([
|
|
19
|
-
" ",
|
|
20
|
-
"ArrowUp",
|
|
21
|
-
"ArrowDown",
|
|
22
|
-
"PageUp",
|
|
23
|
-
"PageDown",
|
|
24
|
-
"Home",
|
|
25
|
-
"End",
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
|
-
// Enter and Space activate focused controls, while navigation keys can
|
|
29
|
-
// interrupt scrolling without producing a scroll event.
|
|
30
|
-
const INTENT_CANCEL_KEYS = new Set(["Enter", ...SCROLL_KEYS]);
|
|
31
|
-
|
|
32
|
-
// A control that consumes the activation key itself instead of acting on thread
|
|
33
|
-
// content. `contenteditable="false"` marks a non-editable island inside an
|
|
34
|
-
// editable tree, so it is excluded the way ComposerRoot already excludes it;
|
|
35
|
-
// the input types left out are the ones a key activates rather than fills.
|
|
36
|
-
const TEXT_ENTRY_SELECTOR = [
|
|
37
|
-
"textarea",
|
|
38
|
-
"select",
|
|
39
|
-
"[contenteditable]:not([contenteditable='false'])",
|
|
40
|
-
"input:not([type='checkbox']):not([type='radio']):not([type='button'])" +
|
|
41
|
-
":not([type='submit']):not([type='reset']):not([type='image'])" +
|
|
42
|
-
":not([type='range']):not([type='file']):not([type='color'])",
|
|
43
|
-
].join(", ");
|
|
44
|
-
|
|
45
|
-
const MESSAGE_SELECTOR = "[data-message-id]";
|
|
46
|
-
|
|
47
|
-
const DISCLOSURE_SELECTOR = "[aria-expanded], summary";
|
|
48
|
-
|
|
49
|
-
// A popup trigger (menu, dialog, listbox) also carries aria-expanded, but what
|
|
50
|
-
// it opens is portaled away from the thread, so only disclosures count.
|
|
51
|
-
const isOpeningDisclosure = (target: Element): boolean => {
|
|
52
|
-
const control = target.closest(DISCLOSURE_SELECTOR);
|
|
53
|
-
if (!control) return false;
|
|
54
|
-
if (control.tagName === "SUMMARY")
|
|
55
|
-
return control.parentElement?.hasAttribute("open") === false;
|
|
56
|
-
return (
|
|
57
|
-
control.getAttribute("aria-expanded") === "false" &&
|
|
58
|
-
(control.getAttribute("aria-haspopup") ?? "false") === "false" &&
|
|
59
|
-
control.getAttribute("role") !== "combobox"
|
|
60
|
-
);
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
type MessageOffset = { readonly id: string; readonly offset: number };
|
|
64
|
-
|
|
65
|
-
const offsetInViewport = (element: Element, viewport: Element) =>
|
|
66
|
-
element.getBoundingClientRect().top - viewport.getBoundingClientRect().top;
|
|
67
|
-
|
|
68
|
-
const measureFirstMessage = (viewport: HTMLElement): MessageOffset | null => {
|
|
69
|
-
const element = viewport.querySelector<HTMLElement>(MESSAGE_SELECTOR);
|
|
70
|
-
const id = element?.dataset["messageId"];
|
|
71
|
-
return element && id
|
|
72
|
-
? { id, offset: offsetInViewport(element, viewport) }
|
|
73
|
-
: null;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
/** The first message that starts in view, else the one spanning its top edge. */
|
|
77
|
-
const measureReaderMessage = (viewport: HTMLElement): MessageOffset | null => {
|
|
78
|
-
const { top, bottom } = viewport.getBoundingClientRect();
|
|
79
|
-
let spanning: MessageOffset | null = null;
|
|
80
|
-
for (const element of viewport.querySelectorAll<HTMLElement>(
|
|
81
|
-
MESSAGE_SELECTOR,
|
|
82
|
-
)) {
|
|
83
|
-
const rect = element.getBoundingClientRect();
|
|
84
|
-
if (rect.bottom <= top) continue;
|
|
85
|
-
if (rect.top >= bottom) break;
|
|
86
|
-
const id = element.dataset["messageId"];
|
|
87
|
-
if (!id) continue;
|
|
88
|
-
if (rect.top >= top) return { id, offset: rect.top - top };
|
|
89
|
-
spanning ??= { id, offset: rect.top - top };
|
|
90
|
-
}
|
|
91
|
-
return spanning;
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const findMessage = (viewport: HTMLElement, id: string) => {
|
|
95
|
-
for (const element of viewport.querySelectorAll<HTMLElement>(
|
|
96
|
-
MESSAGE_SELECTOR,
|
|
97
|
-
)) {
|
|
98
|
-
if (element.dataset["messageId"] === id) return element;
|
|
99
|
-
}
|
|
100
|
-
return null;
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Scrolls so the message sits at `anchor.offset` again, recording the
|
|
105
|
-
* position it scrolls to in `heldScrollTop` before the scroll event can
|
|
106
|
-
* report it; false when the message is gone.
|
|
107
|
-
*/
|
|
108
|
-
const keepMessageAt = (
|
|
109
|
-
viewport: HTMLElement,
|
|
110
|
-
anchor: MessageOffset,
|
|
111
|
-
heldScrollTop: { current: number },
|
|
112
|
-
) => {
|
|
113
|
-
const element = findMessage(viewport, anchor.id);
|
|
114
|
-
if (!element) return false;
|
|
115
|
-
const delta = offsetInViewport(element, viewport) - anchor.offset;
|
|
116
|
-
heldScrollTop.current = viewport.scrollTop + delta;
|
|
117
|
-
if (Math.abs(delta) >= 1) {
|
|
118
|
-
viewport.scrollTo({ top: heldScrollTop.current, behavior: "instant" });
|
|
119
|
-
}
|
|
120
|
-
heldScrollTop.current = viewport.scrollTop;
|
|
121
|
-
return true;
|
|
122
|
-
};
|
|
123
|
-
|
|
124
16
|
export namespace useThreadViewportAutoScroll {
|
|
125
17
|
export type Options = {
|
|
126
18
|
/**
|
|
@@ -166,335 +58,37 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
166
58
|
const firstMessageId = useAuiState((s) => s.thread.messages[0]?.id);
|
|
167
59
|
const threadId = useAuiState((s) => s.threadListItem.id);
|
|
168
60
|
const isRunning = useAuiState((s) => s.thread.isRunning);
|
|
169
|
-
const initializeScrollRequestedRef = useRef(false);
|
|
170
|
-
const scheduledFrameRef = useRef<number | null>(null);
|
|
171
|
-
|
|
172
61
|
const threadViewportStore = useThreadViewportStore();
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const scrollingToBottomBehaviorRef = useRef<ScrollBehavior | null>(null);
|
|
193
|
-
const followBottomRef = useRef(autoScroll);
|
|
194
|
-
// Set by expanding a disclosure; suspends follow without clearing its intent
|
|
195
|
-
// until a scroll gesture reaches an overflowing bottom (content that fits
|
|
196
|
-
// reads as at the bottom), a run starts, the thread changes, or the reader
|
|
197
|
-
// asks for the bottom again.
|
|
198
|
-
const followPausedRef = useRef(false);
|
|
199
|
-
const scrolledSincePauseRef = useRef(false);
|
|
200
|
-
const previousAutoScrollRef = useRef(autoScroll);
|
|
201
|
-
|
|
202
|
-
useIsomorphicLayoutEffect(() => {
|
|
203
|
-
const previousAutoScroll = previousAutoScrollRef.current;
|
|
204
|
-
previousAutoScrollRef.current = autoScroll;
|
|
205
|
-
if (previousAutoScroll || !autoScroll) return;
|
|
206
|
-
|
|
207
|
-
const div = divRef.current;
|
|
208
|
-
followBottomRef.current = div !== null && isViewportAtBottom(div);
|
|
209
|
-
}, [autoScroll]);
|
|
210
|
-
|
|
211
|
-
const scrollToBottom = useCallback((behavior: ScrollBehavior) => {
|
|
212
|
-
const div = divRef.current;
|
|
213
|
-
if (!div) return;
|
|
214
|
-
|
|
215
|
-
followBottomRef.current = true;
|
|
216
|
-
followPausedRef.current = false;
|
|
217
|
-
scrollingToBottomBehaviorRef.current = behavior;
|
|
218
|
-
prependAnchorRef.current = null;
|
|
219
|
-
div.scrollTo({ top: div.scrollHeight, behavior });
|
|
220
|
-
lastScrollTop.current = div.scrollTop;
|
|
221
|
-
lastScrollHeight.current = div.scrollHeight;
|
|
222
|
-
}, []);
|
|
223
|
-
|
|
224
|
-
const cancelScheduledFrame = useCallback(() => {
|
|
225
|
-
if (scheduledFrameRef.current === null) return;
|
|
226
|
-
cancelAnimationFrame(scheduledFrameRef.current);
|
|
227
|
-
scheduledFrameRef.current = null;
|
|
228
|
-
}, []);
|
|
229
|
-
|
|
230
|
-
const scheduleScrollToBottom = useCallback(
|
|
231
|
-
(behavior: ScrollBehavior) => {
|
|
232
|
-
scrollingToBottomBehaviorRef.current = behavior;
|
|
233
|
-
cancelScheduledFrame();
|
|
234
|
-
scheduledFrameRef.current = requestAnimationFrame(() => {
|
|
235
|
-
scheduledFrameRef.current = null;
|
|
236
|
-
scrollToBottom(behavior);
|
|
237
|
-
});
|
|
238
|
-
},
|
|
239
|
-
[cancelScheduledFrame, scrollToBottom],
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
useIsomorphicLayoutEffect(
|
|
243
|
-
() => () => cancelScheduledFrame(),
|
|
244
|
-
[cancelScheduledFrame],
|
|
245
|
-
);
|
|
246
|
-
|
|
247
|
-
const hasActiveTopAnchor = useCallback(() => {
|
|
248
|
-
const state = threadViewportStore.getState();
|
|
249
|
-
return (
|
|
250
|
-
state.turnAnchor === "top" &&
|
|
251
|
-
state.element.viewport === divRef.current &&
|
|
252
|
-
state.element.anchor !== null
|
|
253
|
-
);
|
|
254
|
-
}, [threadViewportStore]);
|
|
255
|
-
|
|
256
|
-
const onScroll = () => {
|
|
257
|
-
const div = divRef.current;
|
|
258
|
-
if (!div) return;
|
|
259
|
-
|
|
260
|
-
if (
|
|
261
|
-
prependAnchorRef.current &&
|
|
262
|
-
Math.abs(div.scrollTop - heldScrollTopRef.current) >= 1
|
|
263
|
-
) {
|
|
264
|
-
prependAnchorRef.current = null;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
const isAtBottom = threadViewportStore.getState().isAtBottom;
|
|
268
|
-
const newIsAtBottom = isViewportAtBottom(div);
|
|
269
|
-
|
|
270
|
-
const isInFlightDownwardScroll =
|
|
271
|
-
scrollingToBottomBehaviorRef.current !== null &&
|
|
272
|
-
!newIsAtBottom &&
|
|
273
|
-
lastScrollTop.current < div.scrollTop;
|
|
274
|
-
if (isInFlightDownwardScroll) {
|
|
275
|
-
// no-op: a smooth scroll-to-bottom fires many midpoint scroll events
|
|
276
|
-
// before landing, don't flicker isAtBottom or clear intent mid-animation
|
|
277
|
-
} else {
|
|
278
|
-
const userScrolledUp = isUserScrollUp(
|
|
279
|
-
{
|
|
280
|
-
scrollTop: lastScrollTop.current,
|
|
281
|
-
scrollHeight: lastScrollHeight.current,
|
|
282
|
-
},
|
|
283
|
-
div,
|
|
284
|
-
);
|
|
285
|
-
|
|
286
|
-
if (newIsAtBottom) {
|
|
287
|
-
// newIsAtBottom is ambiguous when the viewport doesn't overflow —
|
|
288
|
-
// keep intent alive until content can actually scroll
|
|
289
|
-
if (viewportOverflows(div)) {
|
|
290
|
-
scrollingToBottomBehaviorRef.current = null;
|
|
291
|
-
if (scrolledSincePauseRef.current) followPausedRef.current = false;
|
|
292
|
-
}
|
|
293
|
-
if (autoScroll) followBottomRef.current = true;
|
|
294
|
-
} else if (userScrolledUp) {
|
|
295
|
-
cancelScheduledFrame();
|
|
296
|
-
scrollingToBottomBehaviorRef.current = null;
|
|
297
|
-
followBottomRef.current = false;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const shouldUpdate =
|
|
301
|
-
newIsAtBottom || scrollingToBottomBehaviorRef.current === null;
|
|
302
|
-
|
|
303
|
-
if (shouldUpdate && newIsAtBottom !== isAtBottom) {
|
|
304
|
-
writableStore(threadViewportStore).setState({
|
|
305
|
-
isAtBottom: newIsAtBottom,
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
lastScrollTop.current = div.scrollTop;
|
|
311
|
-
lastScrollHeight.current = div.scrollHeight;
|
|
312
|
-
firstMessageRef.current = measureFirstMessage(div);
|
|
313
|
-
};
|
|
314
|
-
const onScrollRef = useRef(onScroll);
|
|
315
|
-
useIsomorphicLayoutEffect(() => {
|
|
316
|
-
onScrollRef.current = onScroll;
|
|
62
|
+
const binding = getThreadViewportAutoScroll(threadViewportStore);
|
|
63
|
+
const { controller } = binding;
|
|
64
|
+
|
|
65
|
+
useInsertionEffect(() => {
|
|
66
|
+
binding.getOptions = () => {
|
|
67
|
+
const state = threadViewportStore.getState();
|
|
68
|
+
return {
|
|
69
|
+
autoScroll: autoScroll ?? state.turnAnchor !== "top",
|
|
70
|
+
holdFollow:
|
|
71
|
+
isRunning &&
|
|
72
|
+
state.turnAnchor === "top" &&
|
|
73
|
+
state.element.viewport === divRef.current &&
|
|
74
|
+
state.element.anchor !== null,
|
|
75
|
+
scrollToBottomOnInitialize,
|
|
76
|
+
scrollToBottomOnRunStart:
|
|
77
|
+
scrollToBottomOnRunStart && state.turnAnchor !== "top",
|
|
78
|
+
scrollToBottomOnThreadSwitch,
|
|
79
|
+
};
|
|
80
|
+
};
|
|
317
81
|
});
|
|
318
|
-
const handleScroll = useCallback(() => onScrollRef.current(), []);
|
|
319
|
-
|
|
320
|
-
const onResize = () => {
|
|
321
|
-
const div = divRef.current;
|
|
322
|
-
if (!div) return;
|
|
323
82
|
|
|
324
|
-
const { scrollHeight, clientHeight } = div;
|
|
325
|
-
if (
|
|
326
|
-
scrollHeight === lastObservedScrollHeight.current &&
|
|
327
|
-
clientHeight === lastObservedClientHeight.current
|
|
328
|
-
) {
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
lastObservedScrollHeight.current = scrollHeight;
|
|
332
|
-
lastObservedClientHeight.current = clientHeight;
|
|
333
|
-
|
|
334
|
-
// A scheduled frame owns the pending behavior; resize callbacks must not apply it early.
|
|
335
|
-
if (scheduledFrameRef.current === null) {
|
|
336
|
-
const scrollBehavior = scrollingToBottomBehaviorRef.current;
|
|
337
|
-
if (scrollBehavior && hasActiveTopAnchor()) {
|
|
338
|
-
// Let the top-anchor reserve own scrolling while a run starts to avoid a bottom-scroll race.
|
|
339
|
-
scrollingToBottomBehaviorRef.current = null;
|
|
340
|
-
} else if (scrollBehavior) {
|
|
341
|
-
scrollToBottom(scrollBehavior);
|
|
342
|
-
} else if (
|
|
343
|
-
autoScroll &&
|
|
344
|
-
!(isRunning && hasActiveTopAnchor()) &&
|
|
345
|
-
followBottomRef.current &&
|
|
346
|
-
!followPausedRef.current
|
|
347
|
-
) {
|
|
348
|
-
scrollToBottom("instant");
|
|
349
|
-
} else if (
|
|
350
|
-
prependAnchorRef.current &&
|
|
351
|
-
!keepMessageAt(div, prependAnchorRef.current, heldScrollTopRef)
|
|
352
|
-
) {
|
|
353
|
-
prependAnchorRef.current = null;
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
handleScroll();
|
|
358
|
-
};
|
|
359
|
-
const onResizeRef = useRef(onResize);
|
|
360
|
-
useIsomorphicLayoutEffect(() => {
|
|
361
|
-
onResizeRef.current = onResize;
|
|
362
|
-
});
|
|
363
|
-
const resizeRef = useOnResizeContent(() => onResizeRef.current());
|
|
364
|
-
|
|
365
|
-
const scrollRef = useManagedRef<HTMLElement>(
|
|
366
|
-
useCallback(
|
|
367
|
-
(el) => {
|
|
368
|
-
// A user gesture invalidates pending bottom-scroll intent; otherwise an
|
|
369
|
-
// intent kept alive by a non-overflowing thread (see handleScroll) hijacks
|
|
370
|
-
// the next content growth, e.g. expanding a collapsible tool call. Keyboard
|
|
371
|
-
// activation reaches that same content without ever emitting a pointer
|
|
372
|
-
// event, so it has to cancel the intent too.
|
|
373
|
-
const cancelPendingScrollToBottom = () => {
|
|
374
|
-
// With nothing pending, the last scroll event already published isAtBottom.
|
|
375
|
-
if (
|
|
376
|
-
scrollingToBottomBehaviorRef.current === null &&
|
|
377
|
-
scheduledFrameRef.current === null
|
|
378
|
-
)
|
|
379
|
-
return;
|
|
380
|
-
// A scheduled frame re-plants the intent when it runs, so clearing the
|
|
381
|
-
// ref alone leaves the gesture undone.
|
|
382
|
-
cancelScheduledFrame();
|
|
383
|
-
scrollingToBottomBehaviorRef.current = null;
|
|
384
|
-
handleScroll();
|
|
385
|
-
};
|
|
386
|
-
// The composer renders inside the viewport, so its keystrokes bubble here;
|
|
387
|
-
// cancellation keys only represent a gesture on thread content when they
|
|
388
|
-
// originate outside text entry.
|
|
389
|
-
const cancelOnKeyDown = (event: KeyboardEvent) => {
|
|
390
|
-
if (!INTENT_CANCEL_KEYS.has(event.key)) return;
|
|
391
|
-
const target = event.target as Element | null;
|
|
392
|
-
if (target?.closest?.(TEXT_ENTRY_SELECTOR)) return;
|
|
393
|
-
cancelPendingScrollToBottom();
|
|
394
|
-
};
|
|
395
|
-
// Expanding a disclosure inside a message, such as a reasoning block or a
|
|
396
|
-
// tool call card, means the reader is inspecting it, so bottom follow
|
|
397
|
-
// pauses until the reader scrolls or asks for the bottom again. Every
|
|
398
|
-
// other interaction, including copying or selecting text, keeps
|
|
399
|
-
// following. Activation is read from click, which keyboard activation also
|
|
400
|
-
// dispatches, in the capture phase so the state is read before any handler
|
|
401
|
-
// on the disclosure flips it or stops the event.
|
|
402
|
-
const pauseFollowOnExpand = (event: MouseEvent) => {
|
|
403
|
-
const target = event.target as Element | null;
|
|
404
|
-
if (
|
|
405
|
-
!target?.closest?.(MESSAGE_SELECTOR) ||
|
|
406
|
-
target.closest(TEXT_ENTRY_SELECTOR) ||
|
|
407
|
-
!isOpeningDisclosure(target)
|
|
408
|
-
)
|
|
409
|
-
return;
|
|
410
|
-
followPausedRef.current = true;
|
|
411
|
-
scrolledSincePauseRef.current = false;
|
|
412
|
-
cancelPendingScrollToBottom();
|
|
413
|
-
};
|
|
414
|
-
const noteScrollGesture = (event: Event) => {
|
|
415
|
-
if (event instanceof KeyboardEvent) {
|
|
416
|
-
if (!SCROLL_KEYS.has(event.key)) return;
|
|
417
|
-
if (
|
|
418
|
-
(event.target as Element | null)?.closest?.(TEXT_ENTRY_SELECTOR)
|
|
419
|
-
)
|
|
420
|
-
return;
|
|
421
|
-
} else if (event.type === "pointerdown" && event.target !== el) {
|
|
422
|
-
return;
|
|
423
|
-
}
|
|
424
|
-
scrolledSincePauseRef.current = true;
|
|
425
|
-
};
|
|
426
|
-
const releasePrependAnchor = () => {
|
|
427
|
-
prependAnchorRef.current = null;
|
|
428
|
-
};
|
|
429
|
-
const gestures = [
|
|
430
|
-
"pointerdown",
|
|
431
|
-
"wheel",
|
|
432
|
-
"touchstart",
|
|
433
|
-
"keydown",
|
|
434
|
-
] as const;
|
|
435
|
-
const resumeGestures = [
|
|
436
|
-
"pointerdown",
|
|
437
|
-
"wheel",
|
|
438
|
-
"touchmove",
|
|
439
|
-
"keydown",
|
|
440
|
-
] as const;
|
|
441
|
-
el.addEventListener("scroll", handleScroll);
|
|
442
|
-
el.addEventListener("pointerdown", cancelPendingScrollToBottom);
|
|
443
|
-
el.addEventListener("wheel", cancelPendingScrollToBottom, {
|
|
444
|
-
passive: true,
|
|
445
|
-
});
|
|
446
|
-
el.addEventListener("touchstart", cancelPendingScrollToBottom, {
|
|
447
|
-
passive: true,
|
|
448
|
-
});
|
|
449
|
-
el.addEventListener("keydown", cancelOnKeyDown);
|
|
450
|
-
el.addEventListener("click", pauseFollowOnExpand, { capture: true });
|
|
451
|
-
for (const gesture of resumeGestures) {
|
|
452
|
-
el.addEventListener(gesture, noteScrollGesture, { passive: true });
|
|
453
|
-
}
|
|
454
|
-
for (const gesture of gestures) {
|
|
455
|
-
el.addEventListener(gesture, releasePrependAnchor, { passive: true });
|
|
456
|
-
}
|
|
457
|
-
return () => {
|
|
458
|
-
el.removeEventListener("scroll", handleScroll);
|
|
459
|
-
el.removeEventListener("pointerdown", cancelPendingScrollToBottom);
|
|
460
|
-
el.removeEventListener("wheel", cancelPendingScrollToBottom);
|
|
461
|
-
el.removeEventListener("touchstart", cancelPendingScrollToBottom);
|
|
462
|
-
el.removeEventListener("keydown", cancelOnKeyDown);
|
|
463
|
-
el.removeEventListener("click", pauseFollowOnExpand, {
|
|
464
|
-
capture: true,
|
|
465
|
-
});
|
|
466
|
-
for (const gesture of resumeGestures) {
|
|
467
|
-
el.removeEventListener(gesture, noteScrollGesture);
|
|
468
|
-
}
|
|
469
|
-
for (const gesture of gestures) {
|
|
470
|
-
el.removeEventListener(gesture, releasePrependAnchor);
|
|
471
|
-
}
|
|
472
|
-
};
|
|
473
|
-
},
|
|
474
|
-
[cancelScheduledFrame, handleScroll],
|
|
475
|
-
),
|
|
476
|
-
);
|
|
477
|
-
|
|
478
|
-
// Earlier messages prepended above the reader put the old first message back
|
|
479
|
-
// where the reader saw it, so growth below it in the same commit (a
|
|
480
|
-
// streaming reply) is not mistaken for the page; where the browser already
|
|
481
|
-
// anchored the scroll, this is a no-op.
|
|
482
83
|
const previousRef = useRef({ threadId, firstMessageId });
|
|
483
84
|
useIsomorphicLayoutEffect(() => {
|
|
484
85
|
const previous = previousRef.current;
|
|
485
86
|
previousRef.current = { threadId, firstMessageId };
|
|
486
|
-
if (previous.threadId !== threadId)
|
|
487
|
-
prependAnchorRef.current = null;
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
const div = divRef.current;
|
|
87
|
+
if (previous.threadId !== threadId) return;
|
|
491
88
|
const previousFirstMessageId = previous.firstMessageId;
|
|
492
89
|
if (
|
|
493
|
-
!div ||
|
|
494
90
|
previousFirstMessageId === undefined ||
|
|
495
|
-
previousFirstMessageId === firstMessageId
|
|
496
|
-
(autoScroll && followBottomRef.current && !followPausedRef.current) ||
|
|
497
|
-
scrollingToBottomBehaviorRef.current !== null
|
|
91
|
+
previousFirstMessageId === firstMessageId
|
|
498
92
|
)
|
|
499
93
|
return;
|
|
500
94
|
const prepended = aui.thread
|
|
@@ -502,60 +96,28 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
502
96
|
.messages.some(
|
|
503
97
|
(message, index) => index > 0 && message.id === previousFirstMessageId,
|
|
504
98
|
);
|
|
505
|
-
if (
|
|
506
|
-
|
|
507
|
-
const anchor = firstMessageRef.current;
|
|
508
|
-
if (
|
|
509
|
-
anchor?.id === previousFirstMessageId &&
|
|
510
|
-
keepMessageAt(div, anchor, heldScrollTopRef)
|
|
511
|
-
) {
|
|
512
|
-
prependAnchorRef.current = measureReaderMessage(div) ?? anchor;
|
|
513
|
-
} else {
|
|
514
|
-
// Message roots that render no `data-message-id` leave only the total
|
|
515
|
-
// growth to go on, which counts any growth below the reader too.
|
|
516
|
-
const top =
|
|
517
|
-
div.scrollHeight - (lastScrollHeight.current - lastScrollTop.current);
|
|
518
|
-
if (Math.abs(div.scrollTop - top) >= 1) {
|
|
519
|
-
div.scrollTo({ top, behavior: "instant" });
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
lastScrollTop.current = div.scrollTop;
|
|
523
|
-
lastScrollHeight.current = div.scrollHeight;
|
|
524
|
-
}, [aui, autoScroll, firstMessageId, threadId]);
|
|
99
|
+
if (prepended) controller.messagesPrepended(previousFirstMessageId);
|
|
100
|
+
}, [aui, controller, firstMessageId, threadId]);
|
|
525
101
|
|
|
526
102
|
useIsomorphicLayoutEffect(() => {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
initializeScrollRequestedRef.current = false;
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
if (initializeScrollRequestedRef.current) return;
|
|
533
|
-
|
|
534
|
-
initializeScrollRequestedRef.current = true;
|
|
535
|
-
// defer to an in-flight run (e.g. first message on a new thread) that
|
|
536
|
-
// already planted intent — otherwise we'd downgrade its "auto" to "instant"
|
|
537
|
-
if (scrollingToBottomBehaviorRef.current !== null) return;
|
|
538
|
-
scheduleScrollToBottom("instant");
|
|
539
|
-
}, [hasMessages, scheduleScrollToBottom, scrollToBottomOnInitialize]);
|
|
540
|
-
|
|
541
|
-
useOnScrollToBottom(({ behavior }) => {
|
|
542
|
-
scrollToBottom(behavior);
|
|
543
|
-
});
|
|
103
|
+
controller.setHasMessages(hasMessages);
|
|
104
|
+
}, [controller, hasMessages, scrollToBottomOnInitialize]);
|
|
544
105
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
if (!scrollToBottomOnRunStart) return;
|
|
549
|
-
if (threadViewportStore.getState().turnAnchor === "top") return;
|
|
550
|
-
scheduleScrollToBottom("auto");
|
|
551
|
-
});
|
|
106
|
+
useOnScrollToBottom(({ behavior }) => controller.scrollToBottom(behavior));
|
|
107
|
+
useAuiEvent("thread.runStart", () => controller.runStarted());
|
|
108
|
+
useAuiEvent("threads.selectionChanged", () => controller.threadSwitched());
|
|
552
109
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
110
|
+
return useManagedRef(
|
|
111
|
+
useCallback(
|
|
112
|
+
(element: TElement) => {
|
|
113
|
+
divRef.current = element;
|
|
114
|
+
const detach = controller.attach(element);
|
|
115
|
+
return () => {
|
|
116
|
+
detach();
|
|
117
|
+
divRef.current = null;
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
[controller],
|
|
121
|
+
),
|
|
122
|
+
);
|
|
561
123
|
};
|