mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-29 18:52:45 +08:00
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:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user