fix(vscode,ui): keep user scroll position while session is busy (#9236)

* fix(vscode,ui): keep user scroll position while session is busy

Virtua's measurement-driven resize events race ahead of the debounced
user-scroll detection in createAutoScroll, snapping the viewport back
to the bottom while the user is mid-gesture. The QuestionDock's focus
call on mount also triggers the browser's focus-into-view behavior,
which yanks the view down whenever the user has scrolled up.

Fixes #9198

* fix(ui): guard recentlyInteracted against initial lastInteraction=0
This commit is contained in:
Marius
2026-04-20 14:58:12 +02:00
committed by GitHub
parent 4d85c23268
commit 6f17bb753b
3 changed files with 33 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix chat scroll jumping back to the bottom while the session is busy — you can now scroll up to read earlier context while a response is streaming or while a question is waiting for your answer.
@@ -3,6 +3,11 @@ import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
const DEBOUNCE_MS = 100
// Grace window after a real user interaction (wheel/pointer/key/touch) during
// which a ResizeObserver or non-user scroll event must not snap the view back
// to the bottom. Long enough to cover a single scroll gesture plus the
// DEBOUNCE_MS window used by handleScroll to flip userScrolled.
const USER_INTERACTION_GRACE_MS = 300
export interface AutoScrollOptions {
working: () => boolean
@@ -18,6 +23,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
let cleanup: (() => void) | undefined
let userInitiated = false
let lastScrollTop: number | undefined
let lastInteraction = 0
const threshold = () => options.bottomThreshold ?? 10
@@ -43,8 +49,12 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (scroll && nested && nested !== scroll) return
}
userInitiated = true
lastInteraction = performance.now()
}
const recentlyInteracted = () =>
lastInteraction > 0 && performance.now() - lastInteraction < USER_INTERACTION_GRACE_MS
const scrollToBottomNow = (behavior: ScrollBehavior) => {
const el = scroll
if (!el) return
@@ -119,7 +129,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
if (!store.userScrolled && !byUser) {
if (el.scrollTop < (lastScrollTop ?? el.scrollTop)) {
// virtua fires programmatic scroll events as it measures virtualized
// items. Don't let those snap the view back to the bottom while the
// user is mid-gesture — the wheel event fires before the scroll event,
// so `recentlyInteracted()` is reliable here.
if (el.scrollTop < (lastScrollTop ?? el.scrollTop) || recentlyInteracted()) {
stop()
} else {
scrollToBottomNow("auto")
@@ -163,6 +177,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (store.userScrolled) {
return
}
// Virtualized lists (virtua) re-measure items during user scroll, firing
// resize events that race ahead of handleScroll's DEBOUNCE_MS window.
// If the user just interacted with the scroller and is no longer near
// the bottom, treat the resize as a layout reflow on top of their
// scroll — pause auto-follow instead of snapping back to the bottom.
if (el && recentlyInteracted() && distanceFromBottom(el) > threshold()) {
stop()
return
}
// ResizeObserver fires after layout, before paint.
// Keep the bottom locked in the same frame to avoid visible
// "jump up then catch up" artifacts while streaming content.
@@ -247,13 +247,16 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
// Keep keyboard navigation when the webview already has focus, but do not
// steal focus from the editor, terminal, or other VS Code surfaces.
// preventScroll avoids the browser's focus-into-view behavior fighting
// createAutoScroll (and yanking the viewport back to the dock while the
// user has scrolled up to read earlier context).
createEffect(() => {
void store.tab
if (store.collapsed || store.editing || confirm()) return
requestAnimationFrame(() => {
if (!document.hasFocus()) return
const btn = root?.querySelector<HTMLButtonElement>("button[data-slot='question-option']:not(:disabled)")
btn?.focus()
btn?.focus({ preventScroll: true })
})
})