@assistant-ui/react 0.12.27 → 0.14.0
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/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +0 -2
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/client/InMemoryThreadList.d.ts.map +1 -1
- package/dist/client/InMemoryThreadList.js +3 -0
- package/dist/client/InMemoryThreadList.js.map +1 -1
- package/dist/client/SingleThreadList.d.ts.map +1 -1
- package/dist/client/SingleThreadList.js +3 -0
- package/dist/client/SingleThreadList.js.map +1 -1
- package/dist/context/providers/ThreadViewportProvider.d.ts.map +1 -1
- package/dist/context/providers/ThreadViewportProvider.js +2 -10
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/context/stores/ThreadViewport.d.ts +46 -4
- package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
- package/dist/context/stores/ThreadViewport.js +51 -7
- package/dist/context/stores/ThreadViewport.js.map +1 -1
- package/dist/index.d.ts +2 -30
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -28
- package/dist/index.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +9 -4
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +6 -21
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +6 -21
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/message/MessageRoot.d.ts +6 -30
- package/dist/primitives/message/MessageRoot.d.ts.map +1 -1
- package/dist/primitives/message/MessageRoot.js +68 -25
- package/dist/primitives/message/MessageRoot.js.map +1 -1
- package/dist/primitives/message.d.ts +1 -0
- package/dist/primitives/message.d.ts.map +1 -1
- package/dist/primitives/message.js +1 -0
- package/dist/primitives/message.js.map +1 -1
- package/dist/primitives/thread/ThreadViewport.d.ts +38 -0
- package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +53 -5
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.d.ts +2 -1
- package/dist/primitives/thread/ThreadViewportFooter.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.js +2 -1
- package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts +22 -0
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js +53 -0
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js.map +1 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts +5 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.js +38 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.js.map +1 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts +22 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js +75 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts +15 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.js +13 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.js.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts +15 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.js +51 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.js.map +1 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts +7 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +18 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -0
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +13 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/primitives/thread.d.ts +0 -1
- package/dist/primitives/thread.d.ts.map +1 -1
- package/dist/primitives/thread.js +0 -1
- package/dist/primitives/thread.js.map +1 -1
- package/dist/primitives/threadList/ThreadListLoadMore.d.ts +13 -0
- package/dist/primitives/threadList/ThreadListLoadMore.d.ts.map +1 -0
- package/dist/primitives/threadList/ThreadListLoadMore.js +11 -0
- package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -0
- package/dist/primitives/threadList.d.ts +1 -0
- package/dist/primitives/threadList.d.ts.map +1 -1
- package/dist/primitives/threadList.js +1 -0
- package/dist/primitives/threadList.js.map +1 -1
- package/dist/tests/remote-thread-list-test-helpers.d.ts +3 -0
- package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -0
- package/dist/tests/remote-thread-list-test-helpers.js +27 -0
- package/dist/tests/remote-thread-list-test-helpers.js.map +1 -0
- package/dist/utils/hooks/useManagedRef.d.ts.map +1 -1
- package/dist/utils/hooks/useManagedRef.js +1 -0
- package/dist/utils/hooks/useManagedRef.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +1 -2
- package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
- package/package.json +10 -10
- package/src/client/ExternalThread.ts +0 -2
- package/src/client/InMemoryThreadList.ts +3 -0
- package/src/client/SingleThreadList.ts +3 -0
- package/src/context/providers/ThreadViewportProvider.tsx +2 -12
- package/src/context/stores/ThreadViewport.ts +111 -11
- package/src/index.ts +2 -35
- package/src/legacy-runtime/runtime-cores/assistant-transport/utils.ts +1 -5
- package/src/primitives/composer/ComposerInput.test.tsx +232 -0
- package/src/primitives/composer/ComposerInput.tsx +9 -4
- package/src/primitives/message/MessagePartsGrouped.tsx +6 -21
- package/src/primitives/message/MessageRoot.tsx +135 -57
- package/src/primitives/message.ts +1 -0
- package/src/primitives/thread/ThreadViewport.tsx +95 -4
- package/src/primitives/thread/ThreadViewportFooter.tsx +2 -1
- package/src/primitives/thread/topAnchor/computeTopAnchorSlack.test.ts +131 -0
- package/src/primitives/thread/topAnchor/computeTopAnchorSlack.ts +94 -0
- package/src/primitives/thread/topAnchor/createReserveObservers.ts +50 -0
- package/src/primitives/thread/topAnchor/mountTopAnchorReserve.test.ts +131 -0
- package/src/primitives/thread/topAnchor/mountTopAnchorReserve.ts +127 -0
- package/src/primitives/thread/topAnchor/topAnchorTurn.test.ts +46 -0
- package/src/primitives/thread/topAnchor/topAnchorTurn.ts +30 -0
- package/src/primitives/thread/topAnchor/topAnchorUtils.ts +58 -0
- package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +19 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +15 -1
- package/src/primitives/thread.ts +0 -1
- package/src/primitives/threadList/ThreadListLoadMore.tsx +24 -0
- package/src/primitives/threadList.ts +1 -0
- package/src/tests/BaseComposerRuntimeCore.test.ts +9 -0
- package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +138 -0
- package/src/tests/RemoteThreadListRuntime.deferredProvider.test.tsx +29 -18
- package/src/tests/remote-thread-list-test-helpers.ts +33 -0
- package/src/utils/hooks/useManagedRef.ts +1 -0
- package/src/utils/hooks/useOnResizeContent.ts +1 -2
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts +0 -3
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts.map +0 -1
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js +0 -3
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js.map +0 -1
- package/dist/primitives/thread/ThreadViewportSlack.d.ts +0 -20
- package/dist/primitives/thread/ThreadViewportSlack.d.ts.map +0 -1
- package/dist/primitives/thread/ThreadViewportSlack.js +0 -80
- package/dist/primitives/thread/ThreadViewportSlack.js.map +0 -1
- package/src/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.ts +0 -6
- package/src/primitives/thread/ThreadViewportSlack.tsx +0 -116
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
computeTopAnchorReserve,
|
|
4
|
+
computeTopAnchorTargetScrollTop,
|
|
5
|
+
} from "./computeTopAnchorSlack";
|
|
6
|
+
|
|
7
|
+
const makeElement = ({
|
|
8
|
+
top = 0,
|
|
9
|
+
height = 0,
|
|
10
|
+
scrollTop = 0,
|
|
11
|
+
scrollHeight = 0,
|
|
12
|
+
clientHeight = 0,
|
|
13
|
+
offsetHeight = height,
|
|
14
|
+
offsetTop = top,
|
|
15
|
+
offsetParent = null,
|
|
16
|
+
}: {
|
|
17
|
+
top?: number;
|
|
18
|
+
height?: number;
|
|
19
|
+
scrollTop?: number;
|
|
20
|
+
scrollHeight?: number;
|
|
21
|
+
clientHeight?: number;
|
|
22
|
+
offsetHeight?: number;
|
|
23
|
+
offsetTop?: number;
|
|
24
|
+
offsetParent?: HTMLElement | null;
|
|
25
|
+
}) =>
|
|
26
|
+
({
|
|
27
|
+
scrollTop,
|
|
28
|
+
scrollHeight,
|
|
29
|
+
clientHeight,
|
|
30
|
+
offsetHeight,
|
|
31
|
+
offsetTop,
|
|
32
|
+
offsetParent,
|
|
33
|
+
getBoundingClientRect: () => ({
|
|
34
|
+
top,
|
|
35
|
+
height,
|
|
36
|
+
}),
|
|
37
|
+
}) as HTMLElement;
|
|
38
|
+
|
|
39
|
+
describe("computeTopAnchorTargetScrollTop", () => {
|
|
40
|
+
it("uses layout offset geometry instead of the anchor's transformed visual position", () => {
|
|
41
|
+
const viewport = makeElement({ offsetTop: 0 });
|
|
42
|
+
const anchor = makeElement({
|
|
43
|
+
top: 160,
|
|
44
|
+
height: 60,
|
|
45
|
+
offsetTop: 156,
|
|
46
|
+
offsetParent: viewport,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(
|
|
50
|
+
computeTopAnchorTargetScrollTop({
|
|
51
|
+
viewport,
|
|
52
|
+
anchor,
|
|
53
|
+
tallerThan: 160,
|
|
54
|
+
visibleHeight: 96,
|
|
55
|
+
}),
|
|
56
|
+
).toBe(156);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("over-scrolls tall anchors so only visibleHeight is visible", () => {
|
|
60
|
+
const viewport = makeElement({ offsetTop: 0 });
|
|
61
|
+
const anchor = makeElement({ height: 240, offsetTop: 200 });
|
|
62
|
+
|
|
63
|
+
// 240 > 160 threshold => keep 96 visible => over-scroll by 240 - 96 = 144
|
|
64
|
+
expect(
|
|
65
|
+
computeTopAnchorTargetScrollTop({
|
|
66
|
+
viewport,
|
|
67
|
+
anchor,
|
|
68
|
+
tallerThan: 160,
|
|
69
|
+
visibleHeight: 96,
|
|
70
|
+
}),
|
|
71
|
+
).toBe(200 + 144);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("computeTopAnchorReserve", () => {
|
|
76
|
+
it("reserves only the extra height needed to make the anchor target reachable", () => {
|
|
77
|
+
const viewport = makeElement({
|
|
78
|
+
offsetTop: 0,
|
|
79
|
+
scrollTop: 0,
|
|
80
|
+
scrollHeight: 560,
|
|
81
|
+
clientHeight: 400,
|
|
82
|
+
});
|
|
83
|
+
const anchor = makeElement({ height: 64, offsetTop: 220 });
|
|
84
|
+
const reserve = makeElement({ offsetHeight: 0 });
|
|
85
|
+
|
|
86
|
+
expect(
|
|
87
|
+
computeTopAnchorReserve({
|
|
88
|
+
viewport,
|
|
89
|
+
anchor,
|
|
90
|
+
reserve,
|
|
91
|
+
tallerThan: 160,
|
|
92
|
+
visibleHeight: 96,
|
|
93
|
+
}),
|
|
94
|
+
).toBe(60);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("shrinks as the response content grows", () => {
|
|
98
|
+
const anchor = makeElement({ height: 64, offsetTop: 220 });
|
|
99
|
+
const reserve = makeElement({ offsetHeight: 60 });
|
|
100
|
+
|
|
101
|
+
expect(
|
|
102
|
+
computeTopAnchorReserve({
|
|
103
|
+
viewport: makeElement({
|
|
104
|
+
offsetTop: 0,
|
|
105
|
+
scrollTop: 0,
|
|
106
|
+
scrollHeight: 620,
|
|
107
|
+
clientHeight: 400,
|
|
108
|
+
}),
|
|
109
|
+
anchor,
|
|
110
|
+
reserve,
|
|
111
|
+
tallerThan: 160,
|
|
112
|
+
visibleHeight: 96,
|
|
113
|
+
}),
|
|
114
|
+
).toBe(60);
|
|
115
|
+
|
|
116
|
+
expect(
|
|
117
|
+
computeTopAnchorReserve({
|
|
118
|
+
viewport: makeElement({
|
|
119
|
+
offsetTop: 0,
|
|
120
|
+
scrollTop: 0,
|
|
121
|
+
scrollHeight: 680,
|
|
122
|
+
clientHeight: 400,
|
|
123
|
+
}),
|
|
124
|
+
anchor,
|
|
125
|
+
reserve,
|
|
126
|
+
tallerThan: 160,
|
|
127
|
+
visibleHeight: 96,
|
|
128
|
+
}),
|
|
129
|
+
).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export type ComputeTopAnchorTargetOptions = {
|
|
4
|
+
viewport: HTMLElement;
|
|
5
|
+
anchor: HTMLElement;
|
|
6
|
+
tallerThan: number;
|
|
7
|
+
visibleHeight: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ComputeTopAnchorReserveOptions = ComputeTopAnchorTargetOptions & {
|
|
11
|
+
reserve: HTMLElement;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type ComputeTopAnchorSlackOptions = ComputeTopAnchorTargetOptions & {
|
|
15
|
+
scrollHeight: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const getDocumentOffsetTop = (element: HTMLElement): number => {
|
|
19
|
+
let top = 0;
|
|
20
|
+
let current: HTMLElement | null = element;
|
|
21
|
+
|
|
22
|
+
while (current) {
|
|
23
|
+
top += current.offsetTop;
|
|
24
|
+
current = current.offsetParent as HTMLElement | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return top;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const getLayoutOffsetTop = (
|
|
31
|
+
element: HTMLElement,
|
|
32
|
+
ancestor: HTMLElement,
|
|
33
|
+
): number => {
|
|
34
|
+
// Use layout geometry, not visual rects, so entrance transforms/animations
|
|
35
|
+
// on the anchor do not shift the scroll target while they settle.
|
|
36
|
+
let top = 0;
|
|
37
|
+
let current: HTMLElement | null = element;
|
|
38
|
+
|
|
39
|
+
while (current && current !== ancestor) {
|
|
40
|
+
top += current.offsetTop;
|
|
41
|
+
current = current.offsetParent as HTMLElement | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (current === ancestor) return top;
|
|
45
|
+
|
|
46
|
+
return getDocumentOffsetTop(element) - getDocumentOffsetTop(ancestor);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compute the scroll position that pins the anchor (last user message) to the
|
|
51
|
+
* top of the viewport. For tall user messages the anchor is intentionally
|
|
52
|
+
* over-scrolled so only `visibleHeight` of it remains visible, leaving room
|
|
53
|
+
* for the assistant message below.
|
|
54
|
+
*
|
|
55
|
+
* Depends only on the anchor's offset within the scroll content; never reads
|
|
56
|
+
* `viewport.scrollHeight` (which is volatile while the assistant message
|
|
57
|
+
* streams in).
|
|
58
|
+
*/
|
|
59
|
+
export const computeTopAnchorTargetScrollTop = ({
|
|
60
|
+
viewport,
|
|
61
|
+
anchor,
|
|
62
|
+
tallerThan,
|
|
63
|
+
visibleHeight,
|
|
64
|
+
}: ComputeTopAnchorTargetOptions): number => {
|
|
65
|
+
const anchorTop = getLayoutOffsetTop(anchor, viewport);
|
|
66
|
+
const anchorHeight = anchor.offsetHeight;
|
|
67
|
+
const visibleAnchorHeight =
|
|
68
|
+
anchorHeight <= tallerThan ? anchorHeight : visibleHeight;
|
|
69
|
+
|
|
70
|
+
return anchorTop + Math.max(0, anchorHeight - visibleAnchorHeight);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const computeTopAnchorSlack = ({
|
|
74
|
+
scrollHeight,
|
|
75
|
+
...targetOptions
|
|
76
|
+
}: ComputeTopAnchorSlackOptions): number => {
|
|
77
|
+
const { viewport } = targetOptions;
|
|
78
|
+
const targetScrollTop = computeTopAnchorTargetScrollTop(targetOptions);
|
|
79
|
+
const targetScrollHeight = targetScrollTop + viewport.clientHeight;
|
|
80
|
+
|
|
81
|
+
return Math.max(0, targetScrollHeight - scrollHeight);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const computeTopAnchorReserve = ({
|
|
85
|
+
viewport,
|
|
86
|
+
reserve,
|
|
87
|
+
...targetOptions
|
|
88
|
+
}: ComputeTopAnchorReserveOptions): number => {
|
|
89
|
+
return computeTopAnchorSlack({
|
|
90
|
+
viewport,
|
|
91
|
+
...targetOptions,
|
|
92
|
+
scrollHeight: viewport.scrollHeight - reserve.offsetHeight,
|
|
93
|
+
});
|
|
94
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export const createReserveObservers = (onChange: () => void) => {
|
|
4
|
+
const resizeObserver = new ResizeObserver(onChange);
|
|
5
|
+
const mutationObserver = new MutationObserver(onChange);
|
|
6
|
+
|
|
7
|
+
let observedViewport: HTMLElement | null = null;
|
|
8
|
+
let observedAnchor: HTMLElement | null = null;
|
|
9
|
+
let observedTarget: HTMLElement | null = null;
|
|
10
|
+
|
|
11
|
+
const disconnect = () => {
|
|
12
|
+
resizeObserver.disconnect();
|
|
13
|
+
mutationObserver.disconnect();
|
|
14
|
+
observedViewport = null;
|
|
15
|
+
observedAnchor = null;
|
|
16
|
+
observedTarget = null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
target: (
|
|
21
|
+
viewport: HTMLElement,
|
|
22
|
+
anchor: HTMLElement,
|
|
23
|
+
target: HTMLElement,
|
|
24
|
+
) => {
|
|
25
|
+
if (
|
|
26
|
+
observedViewport === viewport &&
|
|
27
|
+
observedAnchor === anchor &&
|
|
28
|
+
observedTarget === target
|
|
29
|
+
) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
disconnect();
|
|
34
|
+
|
|
35
|
+
resizeObserver.observe(viewport);
|
|
36
|
+
resizeObserver.observe(anchor);
|
|
37
|
+
resizeObserver.observe(target);
|
|
38
|
+
mutationObserver.observe(target, {
|
|
39
|
+
childList: true,
|
|
40
|
+
subtree: true,
|
|
41
|
+
characterData: true,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
observedViewport = viewport;
|
|
45
|
+
observedAnchor = anchor;
|
|
46
|
+
observedTarget = target;
|
|
47
|
+
},
|
|
48
|
+
disconnect,
|
|
49
|
+
};
|
|
50
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
mountTopAnchorReserve,
|
|
6
|
+
type TopAnchorStore,
|
|
7
|
+
} from "./mountTopAnchorReserve";
|
|
8
|
+
|
|
9
|
+
class ResizeObserverMock {
|
|
10
|
+
observe = vi.fn();
|
|
11
|
+
disconnect = vi.fn();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class MutationObserverMock {
|
|
15
|
+
observe = vi.fn();
|
|
16
|
+
disconnect = vi.fn();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const defineReadonlyNumber = (
|
|
20
|
+
element: HTMLElement,
|
|
21
|
+
key: "clientHeight" | "scrollHeight" | "offsetHeight" | "offsetTop",
|
|
22
|
+
value: number,
|
|
23
|
+
) => {
|
|
24
|
+
Object.defineProperty(element, key, { configurable: true, value });
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const makeStore = (state: ReturnType<TopAnchorStore["getState"]>) => {
|
|
28
|
+
const listeners = new Set<() => void>();
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
store: {
|
|
32
|
+
getState: () => state,
|
|
33
|
+
subscribe: (listener: () => void) => {
|
|
34
|
+
listeners.add(listener);
|
|
35
|
+
return () => listeners.delete(listener);
|
|
36
|
+
},
|
|
37
|
+
} satisfies TopAnchorStore,
|
|
38
|
+
setState: (nextState: ReturnType<TopAnchorStore["getState"]>) => {
|
|
39
|
+
state = nextState;
|
|
40
|
+
for (const listener of listeners) listener();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const numericClamp = { tallerThan: 160, visibleHeight: 96 };
|
|
46
|
+
|
|
47
|
+
describe("mountTopAnchorReserve", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.useFakeTimers();
|
|
50
|
+
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
|
51
|
+
vi.stubGlobal("MutationObserver", MutationObserverMock);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
vi.useRealTimers();
|
|
56
|
+
vi.unstubAllGlobals();
|
|
57
|
+
document.body.replaceChildren();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("adds enough stable reserve after the active assistant turn to make the top anchor reachable", () => {
|
|
61
|
+
const viewport = document.createElement("div");
|
|
62
|
+
const anchor = document.createElement("div");
|
|
63
|
+
const target = document.createElement("div");
|
|
64
|
+
const reserveHost = document.createElement("div");
|
|
65
|
+
reserveHost.append(target);
|
|
66
|
+
document.body.append(reserveHost);
|
|
67
|
+
|
|
68
|
+
defineReadonlyNumber(viewport, "offsetTop", 0);
|
|
69
|
+
defineReadonlyNumber(viewport, "clientHeight", 400);
|
|
70
|
+
defineReadonlyNumber(viewport, "scrollHeight", 560);
|
|
71
|
+
defineReadonlyNumber(anchor, "offsetTop", 220);
|
|
72
|
+
defineReadonlyNumber(anchor, "offsetHeight", 64);
|
|
73
|
+
viewport.scrollTo = vi.fn();
|
|
74
|
+
|
|
75
|
+
const { store } = makeStore({
|
|
76
|
+
turnAnchor: "top",
|
|
77
|
+
element: { viewport, anchor, target },
|
|
78
|
+
targetConfig: numericClamp,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
mountTopAnchorReserve(store);
|
|
82
|
+
vi.runOnlyPendingTimers();
|
|
83
|
+
|
|
84
|
+
const reserve = reserveHost.querySelector(
|
|
85
|
+
"[data-aui-top-anchor-reserve]",
|
|
86
|
+
) as HTMLElement;
|
|
87
|
+
|
|
88
|
+
expect(reserve).not.toBe(null);
|
|
89
|
+
expect(reserve.previousElementSibling).toBe(target);
|
|
90
|
+
expect(reserve.style.height).toBe("60px");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not repeat the smooth top-anchor scroll for the same message", () => {
|
|
94
|
+
const viewport = document.createElement("div");
|
|
95
|
+
const anchor = document.createElement("div");
|
|
96
|
+
const target = document.createElement("div");
|
|
97
|
+
document.body.append(target);
|
|
98
|
+
|
|
99
|
+
defineReadonlyNumber(viewport, "offsetTop", 0);
|
|
100
|
+
defineReadonlyNumber(viewport, "clientHeight", 400);
|
|
101
|
+
defineReadonlyNumber(viewport, "scrollHeight", 560);
|
|
102
|
+
defineReadonlyNumber(anchor, "offsetTop", 220);
|
|
103
|
+
defineReadonlyNumber(anchor, "offsetHeight", 64);
|
|
104
|
+
anchor.dataset.messageId = "msg-1";
|
|
105
|
+
viewport.scrollTo = vi.fn();
|
|
106
|
+
|
|
107
|
+
const { store, setState } = makeStore({
|
|
108
|
+
turnAnchor: "top",
|
|
109
|
+
element: { viewport, anchor, target },
|
|
110
|
+
targetConfig: numericClamp,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
mountTopAnchorReserve(store);
|
|
114
|
+
vi.runOnlyPendingTimers();
|
|
115
|
+
|
|
116
|
+
expect(viewport.scrollTo).not.toHaveBeenCalled();
|
|
117
|
+
|
|
118
|
+
vi.runOnlyPendingTimers();
|
|
119
|
+
|
|
120
|
+
expect(viewport.scrollTo).toHaveBeenCalledTimes(1);
|
|
121
|
+
|
|
122
|
+
setState({
|
|
123
|
+
turnAnchor: "top",
|
|
124
|
+
element: { viewport, anchor, target },
|
|
125
|
+
targetConfig: numericClamp,
|
|
126
|
+
});
|
|
127
|
+
vi.runOnlyPendingTimers();
|
|
128
|
+
|
|
129
|
+
expect(viewport.scrollTo).toHaveBeenCalledTimes(1);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
computeTopAnchorReserve,
|
|
5
|
+
computeTopAnchorTargetScrollTop,
|
|
6
|
+
} from "./computeTopAnchorSlack";
|
|
7
|
+
import { createReserveObservers } from "./createReserveObservers";
|
|
8
|
+
import {
|
|
9
|
+
createReserveElement,
|
|
10
|
+
getAnchorId,
|
|
11
|
+
setReserveHeight,
|
|
12
|
+
snapScrollTop,
|
|
13
|
+
} from "./topAnchorUtils";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal slice of `ThreadViewportStore` that the top-anchor reserve needs.
|
|
17
|
+
* Decoupling from the full store keeps `mountTopAnchorReserve` testable in
|
|
18
|
+
* isolation and re-usable from any consumer that can adapt to this shape.
|
|
19
|
+
*/
|
|
20
|
+
export type TopAnchorStore = {
|
|
21
|
+
getState(): {
|
|
22
|
+
turnAnchor: "top" | "bottom";
|
|
23
|
+
element: {
|
|
24
|
+
viewport: HTMLElement | null;
|
|
25
|
+
anchor: HTMLElement | null;
|
|
26
|
+
target: HTMLElement | null;
|
|
27
|
+
};
|
|
28
|
+
targetConfig: {
|
|
29
|
+
tallerThan: number;
|
|
30
|
+
visibleHeight: number;
|
|
31
|
+
} | null;
|
|
32
|
+
};
|
|
33
|
+
subscribe(fn: () => void): () => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const createFrameScheduler = (fn: () => void) => {
|
|
37
|
+
let frame: number | null = null;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
schedule: () => {
|
|
41
|
+
if (frame !== null) return;
|
|
42
|
+
frame = requestAnimationFrame(() => {
|
|
43
|
+
frame = null;
|
|
44
|
+
fn();
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
cancel: () => {
|
|
48
|
+
if (frame !== null) {
|
|
49
|
+
cancelAnimationFrame(frame);
|
|
50
|
+
frame = null;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const mountTopAnchorReserve = (store: TopAnchorStore) => {
|
|
57
|
+
let reserve: HTMLElement | null = null;
|
|
58
|
+
let lastScrolledAnchorId: string | undefined;
|
|
59
|
+
|
|
60
|
+
function apply() {
|
|
61
|
+
const state = store.getState();
|
|
62
|
+
const { viewport, anchor, target } = state.element;
|
|
63
|
+
const clamp = state.targetConfig;
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
state.turnAnchor !== "top" ||
|
|
67
|
+
!viewport ||
|
|
68
|
+
!anchor ||
|
|
69
|
+
!target ||
|
|
70
|
+
!clamp
|
|
71
|
+
) {
|
|
72
|
+
observers.disconnect();
|
|
73
|
+
if (reserve) {
|
|
74
|
+
setReserveHeight(reserve, 0);
|
|
75
|
+
reserve.remove();
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
reserve ??= createReserveElement();
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
reserve.parentElement !== target.parentElement ||
|
|
84
|
+
reserve.previousElementSibling !== target
|
|
85
|
+
) {
|
|
86
|
+
target.after(reserve);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
observers.target(viewport, anchor, target);
|
|
90
|
+
|
|
91
|
+
const reserveChanged = setReserveHeight(
|
|
92
|
+
reserve,
|
|
93
|
+
computeTopAnchorReserve({ viewport, anchor, reserve, ...clamp }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (reserveChanged) {
|
|
97
|
+
scheduler.schedule();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const anchorId = getAnchorId(anchor);
|
|
102
|
+
if (anchorId !== undefined && lastScrolledAnchorId === anchorId) return;
|
|
103
|
+
|
|
104
|
+
const targetScrollTop = snapScrollTop(
|
|
105
|
+
computeTopAnchorTargetScrollTop({ viewport, anchor, ...clamp }),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (Math.abs(viewport.scrollTop - targetScrollTop) > 1) {
|
|
109
|
+
viewport.scrollTo({ top: targetScrollTop, behavior: "smooth" });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (anchorId !== undefined) lastScrolledAnchorId = anchorId;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const scheduler = createFrameScheduler(apply);
|
|
116
|
+
const observers = createReserveObservers(scheduler.schedule);
|
|
117
|
+
|
|
118
|
+
scheduler.schedule();
|
|
119
|
+
const unsubscribe = store.subscribe(scheduler.schedule);
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
scheduler.cancel();
|
|
123
|
+
unsubscribe();
|
|
124
|
+
observers.disconnect();
|
|
125
|
+
reserve?.remove();
|
|
126
|
+
};
|
|
127
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getActiveTopAnchorAnchorId,
|
|
4
|
+
getActiveTopAnchorTargetId,
|
|
5
|
+
getActiveTopAnchorTurn,
|
|
6
|
+
} from "./topAnchorTurn";
|
|
7
|
+
|
|
8
|
+
describe("topAnchorTurn", () => {
|
|
9
|
+
it("does not activate history-loaded messages", () => {
|
|
10
|
+
const messages = [
|
|
11
|
+
{ id: "user-1", role: "user" },
|
|
12
|
+
{ id: "assistant-1", role: "assistant" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
expect(getActiveTopAnchorTurn({ isRunning: false, messages })).toBe(null);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("activates the live user/assistant pair while running", () => {
|
|
19
|
+
const messages = [
|
|
20
|
+
{ id: "assistant-1", role: "assistant" },
|
|
21
|
+
{ id: "user-2", role: "user" },
|
|
22
|
+
{ id: "assistant-2", role: "assistant" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
expect(getActiveTopAnchorTurn({ isRunning: true, messages })).toEqual({
|
|
26
|
+
anchorId: "user-2",
|
|
27
|
+
targetId: "assistant-2",
|
|
28
|
+
});
|
|
29
|
+
expect(getActiveTopAnchorAnchorId({ isRunning: true, messages })).toBe(
|
|
30
|
+
"user-2",
|
|
31
|
+
);
|
|
32
|
+
expect(getActiveTopAnchorTargetId({ isRunning: true, messages })).toBe(
|
|
33
|
+
"assistant-2",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("ignores running states without a trailing user/assistant pair", () => {
|
|
38
|
+
const messages = [
|
|
39
|
+
{ id: "user-1", role: "user" },
|
|
40
|
+
{ id: "assistant-1", role: "assistant" },
|
|
41
|
+
{ id: "user-2", role: "user" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
expect(getActiveTopAnchorTurn({ isRunning: true, messages })).toBe(null);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
type TopAnchorTurnMessage = {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly role: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const getActiveTopAnchorTurn = ({
|
|
9
|
+
isRunning,
|
|
10
|
+
messages,
|
|
11
|
+
}: {
|
|
12
|
+
readonly isRunning: boolean;
|
|
13
|
+
readonly messages: readonly TopAnchorTurnMessage[];
|
|
14
|
+
}) => {
|
|
15
|
+
if (!isRunning) return null;
|
|
16
|
+
|
|
17
|
+
const target = messages.at(-1);
|
|
18
|
+
const anchor = messages.at(-2);
|
|
19
|
+
if (anchor?.role !== "user" || target?.role !== "assistant") return null;
|
|
20
|
+
|
|
21
|
+
return { anchorId: anchor.id, targetId: target.id };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const getActiveTopAnchorAnchorId = (
|
|
25
|
+
options: Parameters<typeof getActiveTopAnchorTurn>[0],
|
|
26
|
+
) => getActiveTopAnchorTurn(options)?.anchorId;
|
|
27
|
+
|
|
28
|
+
export const getActiveTopAnchorTargetId = (
|
|
29
|
+
options: Parameters<typeof getActiveTopAnchorTurn>[0],
|
|
30
|
+
) => getActiveTopAnchorTurn(options)?.targetId;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convert a supported CSS length string (`px`, `em`, `rem`) into pixels,
|
|
5
|
+
* resolving font-relative units against the supplied element's computed style.
|
|
6
|
+
* Unsupported or malformed values disable the tall-message clamp.
|
|
7
|
+
*
|
|
8
|
+
* Part of the top-anchor package's public input contract: consumers may pass
|
|
9
|
+
* clamp configuration as supported CSS-length strings, and this function is the
|
|
10
|
+
* single place that converts them into the pixel values the package operates on.
|
|
11
|
+
*/
|
|
12
|
+
export const parseCssLength = (value: string, element: HTMLElement): number => {
|
|
13
|
+
const match = value.trim().match(/^(\d+(?:\.\d+)?|\.\d+)(em|px|rem)$/);
|
|
14
|
+
if (!match) return Number.POSITIVE_INFINITY;
|
|
15
|
+
|
|
16
|
+
const num = Number(match[1]);
|
|
17
|
+
const unit = match[2];
|
|
18
|
+
|
|
19
|
+
if (unit === "px") return num;
|
|
20
|
+
if (unit === "em") {
|
|
21
|
+
const fontSize = parseFloat(getComputedStyle(element).fontSize) || 16;
|
|
22
|
+
return num * fontSize;
|
|
23
|
+
}
|
|
24
|
+
if (unit === "rem") {
|
|
25
|
+
const rootFontSize =
|
|
26
|
+
parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
27
|
+
return num * rootFontSize;
|
|
28
|
+
}
|
|
29
|
+
return Number.POSITIVE_INFINITY;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const getAnchorId = (anchor: HTMLElement) => anchor.dataset.messageId;
|
|
33
|
+
|
|
34
|
+
export const createReserveElement = () => {
|
|
35
|
+
const reserve = document.createElement("div");
|
|
36
|
+
reserve.dataset.auiTopAnchorReserve = "";
|
|
37
|
+
reserve.style.height = "0px";
|
|
38
|
+
reserve.style.flexShrink = "0";
|
|
39
|
+
reserve.style.pointerEvents = "none";
|
|
40
|
+
reserve.setAttribute("aria-hidden", "true");
|
|
41
|
+
|
|
42
|
+
return reserve;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const setReserveHeight = (reserve: HTMLElement, height: number) => {
|
|
46
|
+
const nextHeight = `${height}px`;
|
|
47
|
+
if (reserve.style.height !== nextHeight) {
|
|
48
|
+
reserve.style.height = nextHeight;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return false;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const snapScrollTop = (top: number) => {
|
|
56
|
+
const pixelRatio = window.devicePixelRatio || 1;
|
|
57
|
+
return Math.round(top * pixelRatio) / pixelRatio;
|
|
58
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useLayoutEffect } from "react";
|
|
4
|
+
import { useThreadViewportStore } from "../../../context/react/ThreadViewportContext";
|
|
5
|
+
import { mountTopAnchorReserve } from "./mountTopAnchorReserve";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mounts the top-turn-anchor reserve element against the active
|
|
9
|
+
* `ThreadViewport` store. Call this from inside the scrollable viewport so
|
|
10
|
+
* the reserve `<div>` is appended next to the streaming assistant message.
|
|
11
|
+
*/
|
|
12
|
+
export const useTopAnchorReserve = (enabled: boolean) => {
|
|
13
|
+
const threadViewportStore = useThreadViewportStore();
|
|
14
|
+
|
|
15
|
+
useLayoutEffect(() => {
|
|
16
|
+
if (!enabled) return;
|
|
17
|
+
return mountTopAnchorReserve(threadViewportStore);
|
|
18
|
+
}, [enabled, threadViewportStore]);
|
|
19
|
+
};
|