Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-session-scroll-flicker.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
75 changes: 43 additions & 32 deletions packages/kilo-ui/src/hooks/create-auto-scroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface AutoScrollOptions {
working: () => boolean
onUserInteracted?: () => void
bottomThreshold?: number
overflowAnchor?: "none" | "auto" | "dynamic"
}

export function createAutoScroll(options: AutoScrollOptions) {
Expand Down Expand Up @@ -111,51 +112,39 @@ export function createAutoScroll(options: AutoScrollOptions) {
}

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
}
if (stopTimer) clearTimeout(stopTimer)
stopTimer = setTimeout(() => {
stopTimer = undefined
if (!scroll) return
if (distanceFromBottom(scroll) < threshold()) return
stop()
}, DEBOUNCE_MS)
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()
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
}

const onContentResize = () => {
if (scroll && !canScroll(scroll)) return
if (!scroll || !canScroll(scroll)) return
if (store.userScrolled) return

if (userActivity.isRecent() && distanceFromBottom(scroll) > threshold()) {
stop()
return
}

if (!active()) {
if (!store.userScrolled && scroll && distanceFromBottom(scroll) > threshold()) {
if (!userActivity.isRecent() && 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()) {
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.

follow()
}

Expand All @@ -173,6 +162,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
Expand All @@ -195,6 +193,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()
Expand All @@ -206,7 +217,7 @@ export function createAutoScroll(options: AutoScrollOptions) {

if (!el) return

el.style.overflowAnchor = "auto"
updateOverflowAnchor(el)
cleanup = userActivity.listen(el)
}

Expand Down
7 changes: 5 additions & 2 deletions packages/kilo-ui/src/hooks/scroll-user-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Loading