Merge pull request #13009 from Kilo-Org/fix-agent-manager-scroll-flicker

fix(ui): prevent snap-to-bottom and flickering during upward session scroll
This commit is contained in:
Marius
2026-08-07 18:57:57 +02:00
committed by GitHub
4 changed files with 95 additions and 47 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/kilo-ui": patch
"kilo-code": patch
---
Fix flickering and sticky scrolling when scrolling up in Agent Manager and chat sessions.
@@ -216,4 +216,50 @@ describe("createAutoScroll non-scrollable layouts", () => {
expect(ctx.el.scrollTop).toBe(300)
ctx.dispose()
})
test("does not snap to bottom on content resize after user scrolls up while idle", () => {
const ctx = setup({ working: false })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800 // at bottom
// User wheels up
const event = new FakeWheelEvent(-50, ctx.el)
ctx.el.fire("wheel", event as unknown as Event)
ctx.el.scrollTop = 750
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
// Virtual list re-measures / resizes content
ctx.el.scrollHeight = 1100
ctx.resize()
// Must NOT snap to bottom (1100), must remain at user scroll position (750)
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(750)
ctx.dispose()
})
test("does not snap to bottom when dragging scrollbar up while idle", () => {
const ctx = setup({ working: false })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800 // at bottom
// User presses pointerdown on scrollbar and drags up
ctx.el.fire("pointerdown", new Event("pointerdown"))
ctx.el.scrollTop = 600
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
// Content resize during drag
ctx.el.scrollHeight = 1050
ctx.resize()
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(600)
ctx.dispose()
})
})
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { canScroll, distanceFromBottom } from "./auto-scroll"
import { createUserActivity } from "./scroll-user-activity"
const DEBOUNCE_MS = 100
// Grace window after a real pointer/key/touch interaction during which a
// ResizeObserver or non-user scroll event must not snap the view back to the
// bottom. Upward wheel intent pauses immediately in its capture handler.
@@ -14,6 +13,7 @@ export interface AutoScrollOptions {
working: () => boolean
onUserInteracted?: () => void
bottomThreshold?: number
overflowAnchor?: "none" | "auto" | "dynamic"
}
export function createAutoScroll(options: AutoScrollOptions) {
@@ -24,7 +24,6 @@ export function createAutoScroll(options: AutoScrollOptions) {
let scroll: HTMLElement | undefined
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
let stopTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
const [store, setStore] = createStore({
@@ -100,7 +99,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
const handleScroll = () => {
if (!scroll) return
const input = userActivity.consumeScroll()
userActivity.consumeScroll()
const distance = distanceFromBottom(scroll)
if (!canScroll(scroll)) return
@@ -110,52 +109,25 @@ export function createAutoScroll(options: AutoScrollOptions) {
return
}
if (!store.userScrolled && !input) {
// Only explicit user input can pause following. Treat unclassified
// scroll events from virtualization or layout changes as programmatic.
if (userActivity.isRecent()) {
stop()
} else {
bottom()
}
return
}
// Debounce to avoid layout-induced scroll shifts (e.g. images loading,
// virtual-list reflows) from incorrectly breaking auto-follow.
if (stopTimer) clearTimeout(stopTimer)
stopTimer = setTimeout(() => {
stopTimer = undefined
if (!scroll) return
if (distanceFromBottom(scroll) < threshold()) return
stop()
}, DEBOUNCE_MS)
stop()
}
const onContentResize = () => {
if (scroll && !canScroll(scroll)) return
if (!active()) {
if (!store.userScrolled && scroll && distanceFromBottom(scroll) > threshold()) {
bottom()
return
}
return
}
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 (scroll && userActivity.isRecent() && distanceFromBottom(scroll) > threshold()) {
if (!scroll || !canScroll(scroll)) return
if (store.userScrolled) return
if (userActivity.isRecent() && distanceFromBottom(scroll) > 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.
if (!active()) {
if (!userActivity.isRecent() && distanceFromBottom(scroll) > threshold()) {
bottom()
}
return
}
follow()
}
@@ -173,6 +145,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
createResizeObserver(() => store.contentRef, onContentResize)
createResizeObserver(() => store.scrollRef, onViewportResize)
createEffect(
on(
() => store.userScrolled,
() => {
if (scroll) updateOverflowAnchor(scroll)
},
),
)
createEffect(
on(options.working, (working: boolean) => {
settling = false
@@ -195,6 +176,19 @@ export function createAutoScroll(options: AutoScrollOptions) {
// Lifecycle
// ---------------------------------------------------------------------------
const updateOverflowAnchor = (el: HTMLElement) => {
const mode = options.overflowAnchor ?? "none"
if (mode === "none") {
el.style.overflowAnchor = "none"
return
}
if (mode === "auto") {
el.style.overflowAnchor = "auto"
return
}
el.style.overflowAnchor = store.userScrolled ? "auto" : "none"
}
const setScroll = (el: HTMLElement | undefined) => {
if (cleanup) {
cleanup()
@@ -206,13 +200,12 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (!el) return
el.style.overflowAnchor = "auto"
updateOverflowAnchor(el)
cleanup = userActivity.listen(el)
}
onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer)
if (stopTimer) clearTimeout(stopTimer)
if (cleanup) cleanup()
})
@@ -18,13 +18,16 @@ export const createUserActivity = (options: UserActivityOptions) => {
// do not get mistaken for the user leaving auto-follow mode.
const mark = (event: Event) => {
if (!isPotentialScrollInput(event)) return
if (scroll && scroll.scrollHeight - scroll.clientHeight <= 1) return
marked = true
time = performance.now()
}
const handleWheel = (event: WheelEvent) => {
if (event.deltaY >= 0 || !scroll || scroll.scrollTop <= 0) return
time = performance.now()
if (!isPotentialScrollInput(event)) return
if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return
mark(event)
if (event.deltaY >= 0 || scroll.scrollTop <= 0) return
options.onWheelUp()
}