Skip to content

Commit 14ded8f

Browse files
committed
fix(serveui): make forced auto-scroll immediate
Force-scrolling the chat view now clears any pending delayed scroll and updates the position right away instead of waiting for the throttle window. This keeps manual "scroll to bottom" actions responsive while preserving the normal auto-scroll behavior for regular updates. Add a regression test covering the forced path and its interaction with an already scheduled trailing scroll.
1 parent 4aa0ce5 commit 14ded8f

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

internal/serveui/static/app-core.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -740,9 +740,11 @@ const performScrollToBottom = (force = false) => {
740740
const scrollToBottom = (force = false) => {
741741
if (!elements.chatScroll) return;
742742
if (force) {
743-
state.autoScroll = true;
743+
clearPendingScrollToBottom();
744+
performScrollToBottom(true);
745+
return;
744746
}
745-
if (!(force || state.autoScroll)) {
747+
if (!state.autoScroll) {
746748
clearPendingScrollToBottom();
747749
return;
748750
}

internal/serveui/static/app_core_test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,58 @@ const app = loadAppCore();
551551
pass(name);
552552
})();
553553

554+
(function testForceScrollBypassesThrottle() {
555+
const name = 'force scroll bypasses throttle delay';
556+
let nowMs = 1000;
557+
let clearedTimer = 0;
558+
const timers = [];
559+
const chatScroll = Object.assign(makeNode(), {
560+
scrollTop: 0,
561+
scrollHeight: 1000,
562+
clientHeight: 100,
563+
});
564+
const testApp = loadAppCoreWith({
565+
nodeOverrides: { chatScroll },
566+
now: () => nowMs,
567+
timerOverrides: {
568+
setTimeout(fn, delay) {
569+
timers.push({ fn, delay });
570+
return timers.length;
571+
},
572+
clearTimeout(id) {
573+
clearedTimer = id;
574+
},
575+
},
576+
});
577+
578+
testApp.state.autoScroll = true;
579+
testApp.scrollToBottom();
580+
nowMs = 1100;
581+
chatScroll.scrollHeight = 1100;
582+
testApp.scrollToBottom();
583+
if (chatScroll.scrollTop !== 1000 || timers.length !== 1) {
584+
fail(name, 'expected non-forced scroll to be throttled before forcing', JSON.stringify({ scrollTop: chatScroll.scrollTop, timers: timers.length }));
585+
return;
586+
}
587+
588+
chatScroll.scrollHeight = 1200;
589+
testApp.scrollToBottom(true);
590+
if (clearedTimer !== 1) {
591+
fail(name, `expected forced scroll to clear pending trailing timer, got ${clearedTimer}`);
592+
return;
593+
}
594+
if (chatScroll.scrollTop !== 1200) {
595+
fail(name, `expected forced scroll to bottom immediately, got ${chatScroll.scrollTop}`);
596+
return;
597+
}
598+
if (testApp.state.autoScroll !== true) {
599+
fail(name, 'forced scroll should restore autoScroll');
600+
return;
601+
}
602+
603+
pass(name);
604+
})();
605+
554606
(function testPendingScrollDoesNotFightUserScrollIntent() {
555607
const name = 'pending scroll does not fight user scroll intent';
556608
let nowMs = 1000;

0 commit comments

Comments
 (0)