@assistant-ui/react 0.12.28 → 0.14.2

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.
Files changed (187) hide show
  1. package/README.md +58 -42
  2. package/dist/client/ExternalThread.d.ts +7 -0
  3. package/dist/client/ExternalThread.d.ts.map +1 -1
  4. package/dist/client/ExternalThread.js +24 -18
  5. package/dist/client/ExternalThread.js.map +1 -1
  6. package/dist/client/InMemoryThreadList.d.ts.map +1 -1
  7. package/dist/client/InMemoryThreadList.js +3 -0
  8. package/dist/client/InMemoryThreadList.js.map +1 -1
  9. package/dist/client/SingleThreadList.d.ts.map +1 -1
  10. package/dist/client/SingleThreadList.js +3 -0
  11. package/dist/client/SingleThreadList.js.map +1 -1
  12. package/dist/context/providers/ThreadViewportProvider.d.ts.map +1 -1
  13. package/dist/context/providers/ThreadViewportProvider.js +2 -10
  14. package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
  15. package/dist/context/stores/ThreadViewport.d.ts +46 -4
  16. package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
  17. package/dist/context/stores/ThreadViewport.js +51 -7
  18. package/dist/context/stores/ThreadViewport.js.map +1 -1
  19. package/dist/index.d.ts +5 -30
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3 -28
  22. package/dist/index.js.map +1 -1
  23. package/dist/legacy-runtime/cloud/auiV0.d.ts +10 -1
  24. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  25. package/dist/legacy-runtime/cloud/auiV0.js +21 -3
  26. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  27. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts +1 -1
  28. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts.map +1 -1
  29. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js +1 -1
  30. package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js.map +1 -1
  31. package/dist/mcp-apps/McpAppRenderer.d.ts +28 -0
  32. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -0
  33. package/dist/mcp-apps/McpAppRenderer.js +115 -0
  34. package/dist/mcp-apps/McpAppRenderer.js.map +1 -0
  35. package/dist/mcp-apps/McpAppsRemoteHost.d.ts +3 -0
  36. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -0
  37. package/dist/mcp-apps/McpAppsRemoteHost.js +27 -0
  38. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -0
  39. package/dist/mcp-apps/app-frame.d.ts +3 -0
  40. package/dist/mcp-apps/app-frame.d.ts.map +1 -0
  41. package/dist/mcp-apps/app-frame.js +203 -0
  42. package/dist/mcp-apps/app-frame.js.map +1 -0
  43. package/dist/mcp-apps/bridge.d.ts +18 -0
  44. package/dist/mcp-apps/bridge.d.ts.map +1 -0
  45. package/dist/mcp-apps/bridge.js +290 -0
  46. package/dist/mcp-apps/bridge.js.map +1 -0
  47. package/dist/mcp-apps/index.d.ts +4 -0
  48. package/dist/mcp-apps/index.d.ts.map +1 -0
  49. package/dist/mcp-apps/index.js +3 -0
  50. package/dist/mcp-apps/index.js.map +1 -0
  51. package/dist/mcp-apps/types.d.ts +144 -0
  52. package/dist/mcp-apps/types.d.ts.map +1 -0
  53. package/dist/mcp-apps/types.js +3 -0
  54. package/dist/mcp-apps/types.js.map +1 -0
  55. package/dist/mcp-apps/utils.d.ts +5 -0
  56. package/dist/mcp-apps/utils.d.ts.map +1 -0
  57. package/dist/mcp-apps/utils.js +10 -0
  58. package/dist/mcp-apps/utils.js.map +1 -0
  59. package/dist/primitives/composer/ComposerInput.d.ts +6 -0
  60. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  61. package/dist/primitives/composer/ComposerInput.js +28 -6
  62. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  63. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  64. package/dist/primitives/composer/trigger/TriggerPopover.js +17 -1
  65. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  66. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +33 -0
  67. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts.map +1 -1
  68. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +80 -11
  69. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  70. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  71. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +2 -1
  72. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  73. package/dist/primitives/message/MessageRoot.d.ts +6 -30
  74. package/dist/primitives/message/MessageRoot.d.ts.map +1 -1
  75. package/dist/primitives/message/MessageRoot.js +68 -25
  76. package/dist/primitives/message/MessageRoot.js.map +1 -1
  77. package/dist/primitives/messagePart/useMessagePartSource.d.ts +22 -3
  78. package/dist/primitives/messagePart/useMessagePartSource.d.ts.map +1 -1
  79. package/dist/primitives/thread/ThreadViewport.d.ts +38 -0
  80. package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
  81. package/dist/primitives/thread/ThreadViewport.js +53 -5
  82. package/dist/primitives/thread/ThreadViewport.js.map +1 -1
  83. package/dist/primitives/thread/ThreadViewportFooter.d.ts +2 -1
  84. package/dist/primitives/thread/ThreadViewportFooter.d.ts.map +1 -1
  85. package/dist/primitives/thread/ThreadViewportFooter.js +2 -1
  86. package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
  87. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts +22 -0
  88. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts.map +1 -0
  89. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js +53 -0
  90. package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js.map +1 -0
  91. package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts +5 -0
  92. package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts.map +1 -0
  93. package/dist/primitives/thread/topAnchor/createReserveObservers.js +38 -0
  94. package/dist/primitives/thread/topAnchor/createReserveObservers.js.map +1 -0
  95. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts +22 -0
  96. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts.map +1 -0
  97. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js +75 -0
  98. package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js.map +1 -0
  99. package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts +15 -0
  100. package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts.map +1 -0
  101. package/dist/primitives/thread/topAnchor/topAnchorTurn.js +13 -0
  102. package/dist/primitives/thread/topAnchor/topAnchorTurn.js.map +1 -0
  103. package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts +15 -0
  104. package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts.map +1 -0
  105. package/dist/primitives/thread/topAnchor/topAnchorUtils.js +51 -0
  106. package/dist/primitives/thread/topAnchor/topAnchorUtils.js.map +1 -0
  107. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts +7 -0
  108. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts.map +1 -0
  109. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +18 -0
  110. package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -0
  111. package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
  112. package/dist/primitives/thread/useThreadViewportAutoScroll.js +13 -1
  113. package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
  114. package/dist/primitives/thread.d.ts +0 -1
  115. package/dist/primitives/thread.d.ts.map +1 -1
  116. package/dist/primitives/thread.js +0 -1
  117. package/dist/primitives/thread.js.map +1 -1
  118. package/dist/primitives/threadList/ThreadListLoadMore.d.ts +13 -0
  119. package/dist/primitives/threadList/ThreadListLoadMore.d.ts.map +1 -0
  120. package/dist/primitives/threadList/ThreadListLoadMore.js +11 -0
  121. package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -0
  122. package/dist/primitives/threadList.d.ts +1 -0
  123. package/dist/primitives/threadList.d.ts.map +1 -1
  124. package/dist/primitives/threadList.js +1 -0
  125. package/dist/primitives/threadList.js.map +1 -1
  126. package/dist/utils/hooks/useManagedRef.d.ts.map +1 -1
  127. package/dist/utils/hooks/useManagedRef.js +1 -0
  128. package/dist/utils/hooks/useManagedRef.js.map +1 -1
  129. package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
  130. package/dist/utils/hooks/useOnResizeContent.js +1 -2
  131. package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
  132. package/package.json +13 -13
  133. package/src/client/ExternalThread.ts +32 -19
  134. package/src/client/InMemoryThreadList.ts +3 -0
  135. package/src/client/SingleThreadList.ts +3 -0
  136. package/src/context/providers/ThreadViewportProvider.tsx +2 -12
  137. package/src/context/stores/ThreadViewport.ts +111 -11
  138. package/src/index.ts +20 -34
  139. package/src/legacy-runtime/cloud/auiV0.ts +37 -4
  140. package/src/legacy-runtime/runtime-cores/assistant-transport/utils.ts +1 -5
  141. package/src/mcp-apps/McpAppRenderer.tsx +215 -0
  142. package/src/mcp-apps/McpAppsRemoteHost.ts +52 -0
  143. package/src/mcp-apps/app-frame.tsx +280 -0
  144. package/src/mcp-apps/bridge.test.ts +391 -0
  145. package/src/mcp-apps/bridge.ts +435 -0
  146. package/src/mcp-apps/index.ts +16 -0
  147. package/src/mcp-apps/types.ts +158 -0
  148. package/src/mcp-apps/utils.ts +16 -0
  149. package/src/primitives/composer/ComposerInput.test.tsx +280 -0
  150. package/src/primitives/composer/ComposerInput.tsx +29 -6
  151. package/src/primitives/composer/trigger/TriggerPopover.tsx +21 -1
  152. package/src/primitives/composer/trigger/TriggerPopoverRootContext.test.tsx +152 -0
  153. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +134 -17
  154. package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +236 -0
  155. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +2 -1
  156. package/src/primitives/message/MessageRoot.tsx +135 -57
  157. package/src/primitives/thread/ThreadViewport.tsx +95 -4
  158. package/src/primitives/thread/ThreadViewportFooter.tsx +2 -1
  159. package/src/primitives/thread/topAnchor/computeTopAnchorSlack.test.ts +131 -0
  160. package/src/primitives/thread/topAnchor/computeTopAnchorSlack.ts +94 -0
  161. package/src/primitives/thread/topAnchor/createReserveObservers.ts +50 -0
  162. package/src/primitives/thread/topAnchor/mountTopAnchorReserve.test.ts +131 -0
  163. package/src/primitives/thread/topAnchor/mountTopAnchorReserve.ts +127 -0
  164. package/src/primitives/thread/topAnchor/topAnchorTurn.test.ts +46 -0
  165. package/src/primitives/thread/topAnchor/topAnchorTurn.ts +30 -0
  166. package/src/primitives/thread/topAnchor/topAnchorUtils.ts +58 -0
  167. package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +19 -0
  168. package/src/primitives/thread/useThreadViewportAutoScroll.ts +15 -1
  169. package/src/primitives/thread.ts +0 -1
  170. package/src/primitives/threadList/ThreadListLoadMore.tsx +24 -0
  171. package/src/primitives/threadList.ts +1 -0
  172. package/src/tests/BaseComposerRuntimeCore.test.ts +4 -0
  173. package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +138 -0
  174. package/src/tests/RemoteThreadListRuntime.deferredProvider.test.tsx +28 -17
  175. package/src/tests/auiV0Encode.test.ts +55 -0
  176. package/src/utils/hooks/useManagedRef.ts +1 -0
  177. package/src/utils/hooks/useOnResizeContent.ts +1 -2
  178. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts +0 -3
  179. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts.map +0 -1
  180. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js +0 -3
  181. package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js.map +0 -1
  182. package/dist/primitives/thread/ThreadViewportSlack.d.ts +0 -20
  183. package/dist/primitives/thread/ThreadViewportSlack.d.ts.map +0 -1
  184. package/dist/primitives/thread/ThreadViewportSlack.js +0 -80
  185. package/dist/primitives/thread/ThreadViewportSlack.js.map +0 -1
  186. package/src/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.ts +0 -6
  187. package/src/primitives/thread/ThreadViewportSlack.tsx +0 -116
@@ -7,11 +7,23 @@ import {
7
7
  forwardRef,
8
8
  type ComponentPropsWithoutRef,
9
9
  useCallback,
10
+ useLayoutEffect,
11
+ useMemo,
10
12
  } from "react";
13
+ import { useAuiEvent, useAuiState } from "@assistant-ui/store";
14
+ import { useManagedRef } from "../../utils/hooks/useManagedRef";
11
15
  import { useThreadViewportAutoScroll } from "./useThreadViewportAutoScroll";
12
16
  import { ThreadPrimitiveViewportProvider } from "../../context/providers/ThreadViewportProvider";
13
17
  import { useSizeHandle } from "../../utils/hooks/useSizeHandle";
14
- import { useThreadViewport } from "../../context/react/ThreadViewportContext";
18
+ import {
19
+ useThreadViewport,
20
+ useThreadViewportStore,
21
+ } from "../../context/react/ThreadViewportContext";
22
+ import { useTopAnchorReserve } from "./topAnchor/useTopAnchorReserve";
23
+ import {
24
+ getActiveTopAnchorAnchorId,
25
+ getActiveTopAnchorTargetId,
26
+ } from "./topAnchor/topAnchorTurn";
15
27
 
16
28
  export namespace ThreadPrimitiveViewport {
17
29
  export type Element = ComponentRef<typeof Primitive.div>;
@@ -31,6 +43,26 @@ export namespace ThreadPrimitiveViewport {
31
43
  */
32
44
  turnAnchor?: "top" | "bottom" | undefined;
33
45
 
46
+ /**
47
+ * Clamps tall user messages so the assistant response stays in view.
48
+ *
49
+ * @default { tallerThan: "10em", visibleHeight: "6em" }
50
+ */
51
+ topAnchorMessageClamp?: {
52
+ /**
53
+ * Clamp messages taller than this. Supports `px`, `em`, and `rem`.
54
+ *
55
+ * @default "10em"
56
+ */
57
+ tallerThan?: string;
58
+ /**
59
+ * Visible portion of clamped messages. Supports `px`, `em`, and `rem`.
60
+ *
61
+ * @default "6em"
62
+ */
63
+ visibleHeight?: string;
64
+ };
65
+
34
66
  /**
35
67
  * Whether to scroll to bottom when a new run starts.
36
68
  *
@@ -60,6 +92,52 @@ const useViewportSizeRef = () => {
60
92
  return useSizeHandle(register, getHeight);
61
93
  };
62
94
 
95
+ const useViewportElementRef = () => {
96
+ const registerViewportElement = useThreadViewport(
97
+ (s) => s.registerViewportElement,
98
+ );
99
+
100
+ return useManagedRef(registerViewportElement);
101
+ };
102
+
103
+ const useTopAnchorTurn = (enabled: boolean) => {
104
+ const threadViewportStore = useThreadViewportStore();
105
+ const activeAnchorId = useAuiState((s) => {
106
+ if (!enabled) return undefined;
107
+ return getActiveTopAnchorAnchorId(s.thread);
108
+ });
109
+ const activeTargetId = useAuiState((s) => {
110
+ if (!enabled) return undefined;
111
+ return getActiveTopAnchorTargetId(s.thread);
112
+ });
113
+ const activeTurn = useMemo(() => {
114
+ if (!activeAnchorId || !activeTargetId) return null;
115
+ return { anchorId: activeAnchorId, targetId: activeTargetId };
116
+ }, [activeAnchorId, activeTargetId]);
117
+
118
+ useLayoutEffect(() => {
119
+ if (!activeTurn) return;
120
+
121
+ const state = threadViewportStore.getState();
122
+ const current = state.topAnchorTurn;
123
+ if (
124
+ current?.anchorId === activeTurn.anchorId &&
125
+ current.targetId === activeTurn.targetId
126
+ ) {
127
+ return;
128
+ }
129
+
130
+ state.setTopAnchorTurn(activeTurn);
131
+ }, [activeTurn, threadViewportStore]);
132
+
133
+ const clearTopAnchorTurn = useCallback(() => {
134
+ threadViewportStore.getState().setTopAnchorTurn(null);
135
+ }, [threadViewportStore]);
136
+
137
+ useAuiEvent("thread.initialize", clearTopAnchorTurn);
138
+ useAuiEvent("threadListItem.switchedTo", clearTopAnchorTurn);
139
+ };
140
+
63
141
  const ThreadPrimitiveViewportScrollable = forwardRef<
64
142
  ThreadPrimitiveViewport.Element,
65
143
  ThreadPrimitiveViewport.Props
@@ -82,7 +160,18 @@ const ThreadPrimitiveViewportScrollable = forwardRef<
82
160
  scrollToBottomOnThreadSwitch,
83
161
  });
84
162
  const viewportSizeRef = useViewportSizeRef();
85
- const ref = useComposedRefs(forwardedRef, autoScrollRef, viewportSizeRef);
163
+ const viewportElementRef = useViewportElementRef();
164
+ const threadViewportStore = useThreadViewportStore();
165
+ const turnAnchor = threadViewportStore.getState().turnAnchor;
166
+ const topAnchorEnabled = turnAnchor === "top";
167
+ useTopAnchorTurn(topAnchorEnabled);
168
+ useTopAnchorReserve(topAnchorEnabled);
169
+ const ref = useComposedRefs(
170
+ forwardedRef,
171
+ autoScrollRef,
172
+ viewportSizeRef,
173
+ viewportElementRef,
174
+ );
86
175
 
87
176
  return (
88
177
  <Primitive.div {...rest} ref={ref}>
@@ -114,9 +203,11 @@ ThreadPrimitiveViewportScrollable.displayName =
114
203
  export const ThreadPrimitiveViewport = forwardRef<
115
204
  ThreadPrimitiveViewport.Element,
116
205
  ThreadPrimitiveViewport.Props
117
- >(({ turnAnchor, ...props }, ref) => {
206
+ >(({ turnAnchor, topAnchorMessageClamp, ...props }, ref) => {
118
207
  return (
119
- <ThreadPrimitiveViewportProvider options={{ turnAnchor }}>
208
+ <ThreadPrimitiveViewportProvider
209
+ options={{ turnAnchor, topAnchorMessageClamp }}
210
+ >
120
211
  <ThreadPrimitiveViewportScrollable {...props} ref={ref} />
121
212
  </ThreadPrimitiveViewportProvider>
122
213
  );
@@ -20,7 +20,8 @@ export namespace ThreadPrimitiveViewportFooter {
20
20
  * A footer container that measures its height for scroll calculations.
21
21
  *
22
22
  * This component measures its height and provides it to the viewport context
23
- * for use in scroll calculations (e.g., ViewportSlack min-height).
23
+ * so the auto-scroll system can account for any sticky footer overlapping the
24
+ * message list.
24
25
  *
25
26
  * Multiple ViewportFooter components can be used - their heights are summed.
26
27
  *
@@ -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
+ };