fix(ui): preserve auto-scroll pause after wheel up

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Thomas Brugman
2026-07-10 16:50:44 +02:00
parent 2031f946b7
commit 9fa8a13e26
4 changed files with 49 additions and 7 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-ui": patch
---
Keep chat auto-scroll detached after a small upward wheel scroll near the latest message.
@@ -77,12 +77,12 @@ globalThis.WheelEvent = FakeWheelEvent as unknown as typeof WheelEvent
const { createAutoScroll } = await import("./create-auto-scroll")
function setup(options?: { interacted?: () => void }) {
function setup(options?: { interacted?: () => void; working?: boolean }) {
const el = new FakeElement()
const root = createRoot((dispose) => ({
dispose,
scroll: createAutoScroll({
working: () => false,
working: () => options?.working ?? false,
onUserInteracted: options?.interacted,
}),
}))
@@ -158,15 +158,48 @@ describe("createAutoScroll non-scrollable layouts", () => {
ctx.dispose()
})
test("does not pause for an upward wheel on short content", () => {
test("does not pause when content overflows after an upward wheel on short content", () => {
let interactions = 0
const ctx = setup({ interacted: () => interactions++ })
const ctx = setup({ interacted: () => interactions++, working: true })
const event = new FakeWheelEvent(-20, ctx.el)
ctx.el.fire("wheel", event as unknown as Event)
ctx.el.scrollHeight = 300
ctx.resize()
expect(ctx.scroll.userScrolled()).toBe(false)
expect(ctx.el.scrollTop).toBe(300)
expect(interactions).toBe(0)
ctx.dispose()
})
test("does not pause for an upward wheel at the top", () => {
const ctx = setup()
ctx.el.scrollHeight = 300
const event = new FakeWheelEvent(-5, ctx.el)
ctx.el.fire("wheel", event as unknown as Event)
expect(ctx.scroll.userScrolled()).toBe(false)
expect(interactions).toBe(0)
ctx.dispose()
})
test("does not reattach after an upward wheel within the bottom threshold", () => {
const ctx = setup()
ctx.el.scrollHeight = 300
ctx.el.scrollTop = 195
const event = new FakeWheelEvent(-5, ctx.el)
ctx.el.fire("wheel", event as unknown as Event)
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(195)
ctx.el.scrollTop = 200
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(false)
ctx.dispose()
})
@@ -106,7 +106,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (!canScroll(scroll)) return
if (distance < threshold()) {
if (store.userScrolled) setStore("userScrolled", false)
if (store.userScrolled && (distance < 2 || !userActivity.isRecent())) setStore("userScrolled", false)
return
}
@@ -12,6 +12,7 @@ const isPotentialScrollInput = (event: Event) => {
export const createUserActivity = (options: UserActivityOptions) => {
let marked = false
let time = 0
let scroll: HTMLElement | undefined
// Mark input that may cause the next scroll so layout-driven scroll events
// do not get mistaken for the user leaving auto-follow mode.
@@ -22,18 +23,21 @@ export const createUserActivity = (options: UserActivityOptions) => {
}
const handleWheel = (event: WheelEvent) => {
if (event.deltaY >= 0) return
if (event.deltaY >= 0 || !scroll || scroll.scrollTop <= 0) return
time = performance.now()
options.onWheelUp()
}
return {
listen: (el: HTMLElement) => {
scroll = el
el.addEventListener("wheel", handleWheel, { passive: true, capture: true })
el.addEventListener("pointerdown", mark, { passive: true })
el.addEventListener("keydown", mark, { passive: true })
el.addEventListener("touchstart", mark, { passive: true })
return () => {
if (scroll === el) scroll = undefined
el.removeEventListener("wheel", handleWheel, { capture: true })
el.removeEventListener("pointerdown", mark)
el.removeEventListener("keydown", mark)