victor HF Staff Claude commited on
Commit
2cab13e
·
unverified ·
1 Parent(s): e8f5784

Fix auto scroll behavior in message chat (#2000)

Browse files

* fix: improve auto-scroll behavior for chat messages

- Replace scroll position comparison with wheel/touch event detection
to properly detect user scroll intent (fixes false detachment when
content above viewport changes size)
- Add IntersectionObserver on sentinel element for reliable bottom detection
- Increase threshold from 10px to 50px to prevent edge-case issues
- Include tool update count in scroll signal so tool calls and thinking
blocks properly trigger scroll updates
- Add passive event listeners for better scroll performance

* fix: add scrollbar drag detection and race condition handling

- Detect scrollbar drags by comparing scroll positions when not from
wheel/touch events
- Add 100ms grace period after programmatic scrolls to prevent false
detachment from content resize race conditions
- Update prevScrollTop on all scroll events to maintain accurate tracking

* Improve snapScrollToBottom scroll handling and re-attachment

Enhances the scroll logic to better handle programmatic vs. user-initiated scrolls, ensuring the scroll position is updated when new content arrives while attached. Adds checks for requestAnimationFrame availability, improves re-attachment logic to avoid false positives during content resize, and ensures immediate scroll-to-bottom when re-attaching.

---------

Co-authored-by: Claude <noreply@anthropic.com>

src/lib/actions/snapScrollToBottom.ts CHANGED
@@ -1,78 +1,217 @@
1
  import { navigating } from "$app/state";
2
  import { tick } from "svelte";
3
 
4
- const detachedOffset = 10;
5
-
6
- const waitForAnimationFrame = () =>
7
- typeof requestAnimationFrame === "function"
8
- ? new Promise<void>((resolve) => {
9
- requestAnimationFrame(() => resolve());
10
- })
11
- : Promise.resolve();
12
 
13
  /**
 
 
 
 
 
 
 
 
14
  * @param node element to snap scroll to bottom
15
- * @param dependency pass in a dependency to update scroll on changes.
16
  */
17
  export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
18
- let prevScrollValue = node.scrollTop;
19
  let isDetached = false;
 
 
 
 
 
 
 
 
20
  let resizeObserver: ResizeObserver | undefined;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  const scrollToBottom = () => {
 
 
23
  node.scrollTo({ top: node.scrollHeight });
 
 
 
 
 
 
 
 
24
  };
25
 
26
  const distanceFromBottom = () => node.scrollHeight - node.scrollTop - node.clientHeight;
27
 
 
 
28
  async function updateScroll(_options: { force?: boolean } = {}) {
29
  const options = { force: false, ..._options };
30
  const { force } = options;
31
 
 
32
  if (!force && isDetached && !navigating.to) return;
33
 
34
- // wait for the next tick to ensure that the DOM is updated
 
 
 
35
  await tick();
36
  scrollToBottom();
37
 
38
- // ensure we settle after late layout shifts (e.g. markdown/image renders)
39
  if (typeof requestAnimationFrame === "function") {
40
- await waitForAnimationFrame();
41
- scrollToBottom();
42
- await waitForAnimationFrame();
43
- scrollToBottom();
44
  }
45
  }
46
 
47
- const handleScroll = () => {
48
- // if user scrolled up, we detach
49
- if (node.scrollTop < prevScrollValue) {
 
50
  isDetached = true;
51
  }
52
 
53
- const atBottom = distanceFromBottom() <= detachedOffset;
54
- if (atBottom) {
55
- const wasDetached = isDetached;
56
- isDetached = false;
57
- if (wasDetached) {
58
- void updateScroll({ force: true });
 
 
 
 
 
 
59
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  }
61
 
62
- prevScrollValue = node.scrollTop;
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  };
64
 
65
- node.addEventListener("scroll", handleScroll);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
 
67
  if (typeof ResizeObserver !== "undefined") {
68
  const target = node.firstElementChild ?? node;
69
  resizeObserver = new ResizeObserver(() => {
 
70
  if (isDetached && !navigating.to) return;
 
 
71
  scrollToBottom();
72
  });
73
  resizeObserver.observe(target);
74
  }
75
 
 
76
  if (dependency) {
77
  void updateScroll({ force: true });
78
  }
@@ -80,8 +219,14 @@ export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
80
  return {
81
  update: updateScroll,
82
  destroy: () => {
 
 
 
 
83
  node.removeEventListener("scroll", handleScroll);
84
  resizeObserver?.disconnect();
 
 
85
  },
86
  };
87
  };
 
1
  import { navigating } from "$app/state";
2
  import { tick } from "svelte";
3
 
4
+ // Threshold to determine if user is "at bottom" - larger value prevents false detachment
5
+ const BOTTOM_THRESHOLD = 50;
 
 
 
 
 
 
6
 
7
  /**
8
+ * Auto-scroll action that snaps to bottom while respecting user scroll intent.
9
+ *
10
+ * Key improvements over naive implementations:
11
+ * 1. Uses wheel/touch events to detect actual user intent (not scroll position changes
12
+ * which can be caused by content resizing above the viewport)
13
+ * 2. Uses IntersectionObserver on a sentinel element to reliably detect "at bottom" state
14
+ * 3. Larger threshold to prevent edge-case false detachments
15
+ *
16
  * @param node element to snap scroll to bottom
17
+ * @param dependency pass in a dependency to update scroll on changes
18
  */
19
  export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
20
+ // Track whether user has intentionally scrolled away from bottom
21
  let isDetached = false;
22
+ // Track if user is actively scrolling (via wheel/touch)
23
+ let userScrolling = false;
24
+ let userScrollTimeout: ReturnType<typeof setTimeout> | undefined;
25
+ // Track programmatic scrolls to avoid treating them as user scrolls
26
+ let isProgrammaticScroll = false;
27
+ // Timestamp of last programmatic scroll to handle race conditions
28
+ let lastProgrammaticScrollTime = 0;
29
+
30
  let resizeObserver: ResizeObserver | undefined;
31
+ let intersectionObserver: IntersectionObserver | undefined;
32
+ let sentinel: HTMLDivElement | undefined;
33
+
34
+ // Create a sentinel element at the bottom to observe
35
+ const createSentinel = () => {
36
+ sentinel = document.createElement("div");
37
+ sentinel.style.height = "1px";
38
+ sentinel.style.width = "100%";
39
+ sentinel.setAttribute("aria-hidden", "true");
40
+ sentinel.setAttribute("data-scroll-sentinel", "");
41
+ // Find the content container (first child) and append sentinel there
42
+ const container = node.firstElementChild;
43
+ if (container) {
44
+ container.appendChild(sentinel);
45
+ } else {
46
+ node.appendChild(sentinel);
47
+ }
48
+ };
49
 
50
  const scrollToBottom = () => {
51
+ isProgrammaticScroll = true;
52
+ lastProgrammaticScrollTime = Date.now();
53
  node.scrollTo({ top: node.scrollHeight });
54
+ // Reset flag after scroll completes
55
+ if (typeof requestAnimationFrame === "function") {
56
+ requestAnimationFrame(() => {
57
+ isProgrammaticScroll = false;
58
+ });
59
+ } else {
60
+ isProgrammaticScroll = false;
61
+ }
62
  };
63
 
64
  const distanceFromBottom = () => node.scrollHeight - node.scrollTop - node.clientHeight;
65
 
66
+ const isAtBottom = () => distanceFromBottom() <= BOTTOM_THRESHOLD;
67
+
68
  async function updateScroll(_options: { force?: boolean } = {}) {
69
  const options = { force: false, ..._options };
70
  const { force } = options;
71
 
72
+ // Don't scroll if user has detached and we're not navigating
73
  if (!force && isDetached && !navigating.to) return;
74
 
75
+ // Don't scroll if user is actively scrolling
76
+ if (userScrolling) return;
77
+
78
+ // Wait for DOM to update
79
  await tick();
80
  scrollToBottom();
81
 
82
+ // Settle after layout shifts (markdown/image renders)
83
  if (typeof requestAnimationFrame === "function") {
84
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
85
+ if (!userScrolling && !isDetached) scrollToBottom();
86
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
87
+ if (!userScrolling && !isDetached) scrollToBottom();
88
  }
89
  }
90
 
91
+ // Detect user scroll intent via wheel events (mouse/trackpad)
92
+ const handleWheel = (e: WheelEvent) => {
93
+ // User is scrolling up - detach
94
+ if (e.deltaY < 0) {
95
  isDetached = true;
96
  }
97
 
98
+ // Mark user as actively scrolling
99
+ userScrolling = true;
100
+ if (userScrollTimeout) clearTimeout(userScrollTimeout);
101
+ userScrollTimeout = setTimeout(() => {
102
+ userScrolling = false;
103
+ // If user scrolled back to bottom, re-attach
104
+ if (isAtBottom()) {
105
+ isDetached = false;
106
+ }
107
+ // Re-trigger scroll if still attached, to catch content that arrived during scrolling
108
+ if (!isDetached) {
109
+ scrollToBottom();
110
  }
111
+ }, 150);
112
+ };
113
+
114
+ // Detect user scroll intent via touch events (mobile)
115
+ let touchStartY = 0;
116
+ const handleTouchStart = (e: TouchEvent) => {
117
+ touchStartY = e.touches[0]?.clientY ?? 0;
118
+ };
119
+
120
+ const handleTouchMove = (e: TouchEvent) => {
121
+ const touchY = e.touches[0]?.clientY ?? 0;
122
+ const deltaY = touchStartY - touchY;
123
+
124
+ // User is scrolling up (finger moving down)
125
+ if (deltaY < -10) {
126
+ isDetached = true;
127
  }
128
 
129
+ userScrolling = true;
130
+ if (userScrollTimeout) clearTimeout(userScrollTimeout);
131
+ userScrollTimeout = setTimeout(() => {
132
+ userScrolling = false;
133
+ if (isAtBottom()) {
134
+ isDetached = false;
135
+ }
136
+ // Re-trigger scroll if still attached, to catch content that arrived during scrolling
137
+ if (!isDetached) {
138
+ scrollToBottom();
139
+ }
140
+ }, 150);
141
+
142
+ touchStartY = touchY;
143
  };
144
 
145
+ // Track previous scroll position to detect scrollbar drags
146
+ let prevScrollTop = node.scrollTop;
147
+
148
+ // Handle scroll events to detect scrollbar usage and re-attach when at bottom
149
+ const handleScroll = () => {
150
+ const timeSinceLastProgrammaticScroll = Date.now() - lastProgrammaticScrollTime;
151
+ const inGracePeriod = isProgrammaticScroll || timeSinceLastProgrammaticScroll < 100;
152
+
153
+ // If not from wheel/touch, this is likely a scrollbar drag
154
+ if (!userScrolling) {
155
+ const scrollingUp = node.scrollTop < prevScrollTop;
156
+ // Always allow detach (scrolling up) - don't ignore user intent
157
+ if (scrollingUp) {
158
+ isDetached = true;
159
+ }
160
+ // Only re-attach when at bottom if NOT in grace period
161
+ // (avoids false re-attach from content resize pushing scroll position)
162
+ if (!inGracePeriod && isAtBottom()) {
163
+ isDetached = false;
164
+ // Immediately scroll to catch up with any content that arrived while detached
165
+ scrollToBottom();
166
+ }
167
+ }
168
+
169
+ prevScrollTop = node.scrollTop;
170
+ };
171
+
172
+ // Set up event listeners
173
+ node.addEventListener("wheel", handleWheel, { passive: true });
174
+ node.addEventListener("touchstart", handleTouchStart, { passive: true });
175
+ node.addEventListener("touchmove", handleTouchMove, { passive: true });
176
+ node.addEventListener("scroll", handleScroll, { passive: true });
177
+
178
+ // Create sentinel and set up IntersectionObserver for reliable bottom detection
179
+ createSentinel();
180
+
181
+ if (typeof IntersectionObserver !== "undefined" && sentinel) {
182
+ intersectionObserver = new IntersectionObserver(
183
+ (entries) => {
184
+ const entry = entries[0];
185
+ // If sentinel is visible and user isn't actively scrolling, we're at bottom
186
+ if (entry?.isIntersecting && !userScrolling) {
187
+ isDetached = false;
188
+ // Immediately scroll to catch up with any content that arrived while detached
189
+ scrollToBottom();
190
+ }
191
+ },
192
+ {
193
+ root: node,
194
+ threshold: 0,
195
+ rootMargin: `0px 0px ${BOTTOM_THRESHOLD}px 0px`,
196
+ }
197
+ );
198
+ intersectionObserver.observe(sentinel);
199
+ }
200
 
201
+ // ResizeObserver for content changes (new messages, expanding blocks)
202
  if (typeof ResizeObserver !== "undefined") {
203
  const target = node.firstElementChild ?? node;
204
  resizeObserver = new ResizeObserver(() => {
205
+ // Don't auto-scroll if user has detached
206
  if (isDetached && !navigating.to) return;
207
+ // Don't interrupt active user scrolling
208
+ if (userScrolling) return;
209
  scrollToBottom();
210
  });
211
  resizeObserver.observe(target);
212
  }
213
 
214
+ // Initial scroll if we have content
215
  if (dependency) {
216
  void updateScroll({ force: true });
217
  }
 
219
  return {
220
  update: updateScroll,
221
  destroy: () => {
222
+ if (userScrollTimeout) clearTimeout(userScrollTimeout);
223
+ node.removeEventListener("wheel", handleWheel);
224
+ node.removeEventListener("touchstart", handleTouchStart);
225
+ node.removeEventListener("touchmove", handleTouchMove);
226
  node.removeEventListener("scroll", handleScroll);
227
  resizeObserver?.disconnect();
228
+ intersectionObserver?.disconnect();
229
+ sentinel?.remove();
230
  },
231
  };
232
  };
src/lib/components/chat/ChatWindow.svelte CHANGED
@@ -145,9 +145,16 @@
145
  };
146
 
147
  let lastMessage = $derived(browser && (messages.at(-1) as Message));
 
148
  let scrollSignal = $derived.by(() => {
149
  const last = messages.at(-1) as Message | undefined;
150
- return last ? `${last.id}:${last.content.length}:${messages.length}` : `${messages.length}:0`;
 
 
 
 
 
 
151
  });
152
  let streamingAssistantMessage = $derived(
153
  (() => {
 
145
  };
146
 
147
  let lastMessage = $derived(browser && (messages.at(-1) as Message));
148
+ // Scroll signal includes tool updates and thinking blocks to trigger scroll on all content changes
149
  let scrollSignal = $derived.by(() => {
150
  const last = messages.at(-1) as Message | undefined;
151
+ if (!last) return `${messages.length}:0`;
152
+
153
+ // Count tool updates to trigger scroll when new tools are called or complete
154
+ const toolUpdateCount = last.updates?.length ?? 0;
155
+
156
+ // Include content length, tool count, and message count in signal
157
+ return `${last.id}:${last.content.length}:${messages.length}:${toolUpdateCount}`;
158
  });
159
  let streamingAssistantMessage = $derived(
160
  (() => {