victor HF Staff commited on
Commit
cb00da9
·
1 Parent(s): 34f6218

Improve snapScrollToBottom action for user intent

Browse files

Refactored snapScrollToBottom to better detect user scroll intent, handle force re-attachment, and optimize performance during streaming. Updated ChatWindow.svelte to use a combined scroll dependency and force re-attach when a user sends a new message, ensuring reliable auto-scroll behavior.

src/lib/actions/snapScrollToBottom.ts CHANGED
@@ -3,55 +3,86 @@ 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;
@@ -61,102 +92,209 @@ export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
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()) {
@@ -169,61 +307,37 @@ export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
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
  }
218
 
 
 
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();
 
3
 
4
  // Threshold to determine if user is "at bottom" - larger value prevents false detachment
5
  const BOTTOM_THRESHOLD = 50;
6
+ const USER_SCROLL_DEBOUNCE_MS = 150;
7
+ const PROGRAMMATIC_SCROLL_GRACE_MS = 100;
8
+ const TOUCH_DETACH_THRESHOLD_PX = 10;
9
+
10
+ interface ScrollDependency {
11
+ signal: unknown;
12
+ forceReattach?: number;
13
+ }
14
+
15
+ type MaybeScrollDependency = ScrollDependency | unknown;
16
+
17
+ const getForceReattach = (value: MaybeScrollDependency): number => {
18
+ if (typeof value === "object" && value !== null && "forceReattach" in value) {
19
+ return (value as ScrollDependency).forceReattach ?? 0;
20
+ }
21
+ return 0;
22
+ };
23
 
24
  /**
25
  * Auto-scroll action that snaps to bottom while respecting user scroll intent.
26
  *
27
+ * Key behaviors:
28
+ * 1. Uses wheel/touch events to detect actual user intent
 
29
  * 2. Uses IntersectionObserver on a sentinel element to reliably detect "at bottom" state
30
  * 3. Larger threshold to prevent edge-case false detachments
31
  *
32
  * @param node element to snap scroll to bottom
33
+ * @param dependency pass in { signal, forceReattach } - signal triggers scroll updates,
34
+ * forceReattach (counter) forces re-attachment when incremented
35
  */
36
+ export const snapScrollToBottom = (node: HTMLElement, dependency: MaybeScrollDependency) => {
37
+ // --- State ----------------------------------------------------------------
38
+
39
  // Track whether user has intentionally scrolled away from bottom
40
  let isDetached = false;
41
+
42
+ // Track the last forceReattach value to detect changes
43
+ let lastForceReattach = getForceReattach(dependency);
44
+
45
  // Track if user is actively scrolling (via wheel/touch)
46
  let userScrolling = false;
47
  let userScrollTimeout: ReturnType<typeof setTimeout> | undefined;
48
+
49
  // Track programmatic scrolls to avoid treating them as user scrolls
50
  let isProgrammaticScroll = false;
 
51
  let lastProgrammaticScrollTime = 0;
52
 
53
+ // Track previous scroll position to detect scrollbar drags
54
+ let prevScrollTop = node.scrollTop;
55
+
56
+ // Touch handling state
57
+ let touchStartY = 0;
58
+
59
+ // Observers and sentinel
60
  let resizeObserver: ResizeObserver | undefined;
61
  let intersectionObserver: IntersectionObserver | undefined;
62
  let sentinel: HTMLDivElement | undefined;
63
 
64
+ // Track content height for early-return optimization during streaming
65
+ let lastScrollHeight = node.scrollHeight;
66
+
67
+ // --- Helpers --------------------------------------------------------------
68
+
69
+ const clearUserScrollTimeout = () => {
70
+ if (userScrollTimeout) {
71
+ clearTimeout(userScrollTimeout);
72
+ userScrollTimeout = undefined;
 
 
 
 
73
  }
74
  };
75
 
76
+ const distanceFromBottom = () => node.scrollHeight - node.scrollTop - node.clientHeight;
77
+
78
+ const isAtBottom = () => distanceFromBottom() <= BOTTOM_THRESHOLD;
79
+
80
  const scrollToBottom = () => {
81
  isProgrammaticScroll = true;
82
  lastProgrammaticScrollTime = Date.now();
83
+
84
  node.scrollTo({ top: node.scrollHeight });
85
+
86
  if (typeof requestAnimationFrame === "function") {
87
  requestAnimationFrame(() => {
88
  isProgrammaticScroll = false;
 
92
  }
93
  };
94
 
95
+ const settleScrollAfterLayout = async () => {
96
+ if (typeof requestAnimationFrame !== "function") return;
 
 
 
 
 
 
 
 
97
 
98
+ const raf = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
 
 
 
 
 
99
 
100
+ await raf();
101
+ if (!userScrolling && !isDetached) {
102
+ scrollToBottom();
 
 
 
103
  }
 
104
 
105
+ await raf();
106
+ if (!userScrolling && !isDetached) {
107
+ scrollToBottom();
 
 
108
  }
109
+ };
110
 
111
+ const scheduleUserScrollEndCheck = () => {
112
  userScrolling = true;
113
+ clearUserScrollTimeout();
114
+
115
  userScrollTimeout = setTimeout(() => {
116
  userScrolling = false;
117
+
118
  // If user scrolled back to bottom, re-attach
119
  if (isAtBottom()) {
120
  isDetached = false;
121
  }
122
+
123
  // Re-trigger scroll if still attached, to catch content that arrived during scrolling
124
  if (!isDetached) {
125
  scrollToBottom();
126
  }
127
+ }, USER_SCROLL_DEBOUNCE_MS);
128
+ };
129
+
130
+ const createSentinel = () => {
131
+ sentinel = document.createElement("div");
132
+ sentinel.style.height = "1px";
133
+ sentinel.style.width = "100%";
134
+ sentinel.setAttribute("aria-hidden", "true");
135
+ sentinel.setAttribute("data-scroll-sentinel", "");
136
+
137
+ // Find the content container (first child) and append sentinel there
138
+ const container = node.firstElementChild;
139
+ if (container) {
140
+ container.appendChild(sentinel);
141
+ } else {
142
+ node.appendChild(sentinel);
143
+ }
144
+ };
145
+
146
+ const setupIntersectionObserver = () => {
147
+ if (typeof IntersectionObserver === "undefined" || !sentinel) return;
148
+
149
+ intersectionObserver = new IntersectionObserver(
150
+ (entries) => {
151
+ const entry = entries[0];
152
+
153
+ // If sentinel is visible and user isn't actively scrolling, we're at bottom
154
+ if (entry?.isIntersecting && !userScrolling) {
155
+ isDetached = false;
156
+ // Immediately scroll to catch up with any content that arrived while detached
157
+ scrollToBottom();
158
+ }
159
+ },
160
+ {
161
+ root: node,
162
+ threshold: 0,
163
+ rootMargin: `0px 0px ${BOTTOM_THRESHOLD}px 0px`,
164
+ }
165
+ );
166
+
167
+ intersectionObserver.observe(sentinel);
168
+ };
169
+
170
+ const setupResizeObserver = () => {
171
+ if (typeof ResizeObserver === "undefined") return;
172
+
173
+ const target = node.firstElementChild ?? node;
174
+ resizeObserver = new ResizeObserver(() => {
175
+ // Don't auto-scroll if user has detached and we're not navigating
176
+ if (isDetached && !navigating.to) return;
177
+ // Don't interrupt active user scrolling
178
+ if (userScrolling) return;
179
+
180
+ scrollToBottom();
181
+ });
182
+
183
+ resizeObserver.observe(target);
184
+ };
185
+
186
+ // --- Action update logic --------------------------------------------------
187
+
188
+ const handleForceReattach = async (newDependency: MaybeScrollDependency) => {
189
+ const forceReattach = getForceReattach(newDependency);
190
+
191
+ if (forceReattach > lastForceReattach) {
192
+ lastForceReattach = forceReattach;
193
+ isDetached = false;
194
+ userScrolling = false;
195
+ clearUserScrollTimeout();
196
+
197
+ await tick();
198
+ scrollToBottom();
199
+ return true;
200
+ }
201
+
202
+ return false;
203
+ };
204
+
205
+ async function updateScroll(newDependency?: MaybeScrollDependency) {
206
+ // 1. Explicit force re-attach
207
+ if (newDependency && (await handleForceReattach(newDependency))) {
208
+ return;
209
+ }
210
+
211
+ // 2. Don't scroll if user has detached and we're not navigating
212
+ if (isDetached && !navigating.to) return;
213
+
214
+ // 3. Don't scroll if user is actively scrolling
215
+ if (userScrolling) return;
216
+
217
+ // 4. Early return if already at bottom and no content change (perf optimization for streaming)
218
+ const currentHeight = node.scrollHeight;
219
+ if (isAtBottom() && currentHeight === lastScrollHeight) {
220
+ return;
221
+ }
222
+ lastScrollHeight = currentHeight;
223
+
224
+ // 5. Wait for DOM to update, then scroll and settle after layout shifts
225
+ await tick();
226
+ scrollToBottom();
227
+ await settleScrollAfterLayout();
228
+ }
229
+
230
+ // --- Event handlers -------------------------------------------------------
231
+
232
+ // Detect user scroll intent via wheel events (mouse/trackpad)
233
+ const handleWheel = (event: WheelEvent) => {
234
+ const { deltaY } = event;
235
+
236
+ // User is scrolling up - detach
237
+ if (deltaY < 0) {
238
+ isDetached = true;
239
+ }
240
+
241
+ // User is scrolling down - check for re-attachment immediately
242
+ // This ensures fast re-attachment when user scrolls to bottom during fast generation
243
+ if (deltaY > 0 && isAtBottom()) {
244
+ isDetached = false;
245
+ userScrolling = false;
246
+ clearUserScrollTimeout();
247
+ scrollToBottom();
248
+ return;
249
+ }
250
+
251
+ scheduleUserScrollEndCheck();
252
  };
253
 
254
  // Detect user scroll intent via touch events (mobile)
255
+ const handleTouchStart = (event: TouchEvent) => {
256
+ touchStartY = event.touches[0]?.clientY ?? 0;
 
257
  };
258
 
259
+ const handleTouchMove = (event: TouchEvent) => {
260
+ const touchY = event.touches[0]?.clientY ?? 0;
261
  const deltaY = touchStartY - touchY;
262
 
263
  // User is scrolling up (finger moving down)
264
+ if (deltaY < -TOUCH_DETACH_THRESHOLD_PX) {
265
  isDetached = true;
266
  }
267
 
268
+ // User is scrolling down (finger moving up) - check for re-attachment immediately
269
+ if (deltaY > TOUCH_DETACH_THRESHOLD_PX && isAtBottom()) {
270
+ isDetached = false;
271
  userScrolling = false;
272
+ clearUserScrollTimeout();
273
+ scrollToBottom();
274
+ touchStartY = touchY;
275
+ return;
276
+ }
 
 
 
277
 
278
+ scheduleUserScrollEndCheck();
279
  touchStartY = touchY;
280
  };
281
 
 
 
 
282
  // Handle scroll events to detect scrollbar usage and re-attach when at bottom
283
  const handleScroll = () => {
284
+ const now = Date.now();
285
+ const timeSinceLastProgrammaticScroll = now - lastProgrammaticScrollTime;
286
+ const inGracePeriod =
287
+ isProgrammaticScroll || timeSinceLastProgrammaticScroll < PROGRAMMATIC_SCROLL_GRACE_MS;
288
 
289
  // If not from wheel/touch, this is likely a scrollbar drag
290
  if (!userScrolling) {
291
  const scrollingUp = node.scrollTop < prevScrollTop;
292
+
293
  // Always allow detach (scrolling up) - don't ignore user intent
294
  if (scrollingUp) {
295
  isDetached = true;
296
  }
297
+
298
  // Only re-attach when at bottom if NOT in grace period
299
  // (avoids false re-attach from content resize pushing scroll position)
300
  if (!inGracePeriod && isAtBottom()) {
 
307
  prevScrollTop = node.scrollTop;
308
  };
309
 
310
+ // --- Setup ----------------------------------------------------------------
311
+
312
  node.addEventListener("wheel", handleWheel, { passive: true });
313
  node.addEventListener("touchstart", handleTouchStart, { passive: true });
314
  node.addEventListener("touchmove", handleTouchMove, { passive: true });
315
  node.addEventListener("scroll", handleScroll, { passive: true });
316
 
 
317
  createSentinel();
318
+ setupIntersectionObserver();
319
+ setupResizeObserver();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
320
 
321
  // Initial scroll if we have content
322
  if (dependency) {
323
+ void (async () => {
324
+ await tick();
325
+ scrollToBottom();
326
+ })();
327
  }
328
 
329
+ // --- Cleanup --------------------------------------------------------------
330
+
331
  return {
332
  update: updateScroll,
333
  destroy: () => {
334
+ clearUserScrollTimeout();
335
+
336
  node.removeEventListener("wheel", handleWheel);
337
  node.removeEventListener("touchstart", handleTouchStart);
338
  node.removeEventListener("touchmove", handleTouchMove);
339
  node.removeEventListener("scroll", handleScroll);
340
+
341
  resizeObserver?.disconnect();
342
  intersectionObserver?.disconnect();
343
  sentinel?.remove();
src/lib/components/chat/ChatWindow.svelte CHANGED
@@ -1,6 +1,6 @@
1
  <script lang="ts">
2
  import type { Message, MessageFile } from "$lib/types/Message";
3
- import { onDestroy, tick } from "svelte";
4
 
5
  import IconOmni from "$lib/components/icons/IconOmni.svelte";
6
  import CarbonCaretDown from "~icons/carbon/caret-down";
@@ -248,19 +248,30 @@
248
 
249
  let chatContainer: HTMLElement | undefined = $state();
250
 
251
- async function scrollToBottom() {
252
- await tick();
253
- if (!chatContainer) return;
254
- chatContainer.scrollTop = chatContainer.scrollHeight;
255
- }
256
-
257
- // If last message is from user, scroll to bottom
258
  $effect(() => {
259
- if (lastMessage && lastMessage.from === "user") {
260
- scrollToBottom();
 
 
 
 
 
 
 
 
 
 
261
  }
 
262
  });
263
 
 
 
 
264
  const settings = useSettingsStore();
265
  let hideRouterExamples = $derived($settings.hidePromptExamples?.[currentModel.id] ?? false);
266
 
@@ -382,7 +393,7 @@
382
  {/if}
383
  <div
384
  class="scrollbar-custom h-full overflow-y-auto"
385
- use:snapScrollToBottom={scrollSignal}
386
  bind:this={chatContainer}
387
  >
388
  <div
 
1
  <script lang="ts">
2
  import type { Message, MessageFile } from "$lib/types/Message";
3
+ import { onDestroy } from "svelte";
4
 
5
  import IconOmni from "$lib/components/icons/IconOmni.svelte";
6
  import CarbonCaretDown from "~icons/carbon/caret-down";
 
248
 
249
  let chatContainer: HTMLElement | undefined = $state();
250
 
251
+ // Force scroll to bottom when user sends a new message
252
+ // Pattern: user message + empty assistant message are added together
253
+ let prevMessageCount = $state(messages.length);
254
+ let forceReattach = $state(0);
 
 
 
255
  $effect(() => {
256
+ if (messages.length > prevMessageCount) {
257
+ const last = messages.at(-1);
258
+ const secondLast = messages.at(-2);
259
+ const userJustSentMessage =
260
+ messages.length === prevMessageCount + 2 &&
261
+ secondLast?.from === "user" &&
262
+ last?.from === "assistant" &&
263
+ last?.content === "";
264
+
265
+ if (userJustSentMessage) {
266
+ forceReattach++;
267
+ }
268
  }
269
+ prevMessageCount = messages.length;
270
  });
271
 
272
+ // Combined scroll dependency for the action
273
+ let scrollDependency = $derived({ signal: scrollSignal, forceReattach });
274
+
275
  const settings = useSettingsStore();
276
  let hideRouterExamples = $derived($settings.hidePromptExamples?.[currentModel.id] ?? false);
277
 
 
393
  {/if}
394
  <div
395
  class="scrollbar-custom h-full overflow-y-auto"
396
+ use:snapScrollToBottom={scrollDependency}
397
  bind:this={chatContainer}
398
  >
399
  <div