From c514c08749dbb72fe4db64c12c75218997b769ff Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 7 Aug 2026 16:53:58 +0200 Subject: [PATCH 1/2] fix(ui): prevent snap-to-bottom and flickering during upward session scroll --- .changeset/fix-session-scroll-flicker.md | 6 ++ .../src/hooks/create-auto-scroll.test.tsx | 46 ++++++++++++ .../kilo-ui/src/hooks/create-auto-scroll.tsx | 75 +++++++++++-------- .../kilo-ui/src/hooks/scroll-user-activity.ts | 7 +- 4 files changed, 100 insertions(+), 34 deletions(-) create mode 100644 .changeset/fix-session-scroll-flicker.md diff --git a/.changeset/fix-session-scroll-flicker.md b/.changeset/fix-session-scroll-flicker.md new file mode 100644 index 00000000000..7fe8232c1e5 --- /dev/null +++ b/.changeset/fix-session-scroll-flicker.md @@ -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. diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx index 08df06b7fce..6f1ec9e3542 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx @@ -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() + }) }) diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx index a84b246f572..8bfca603076 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -14,6 +14,7 @@ export interface AutoScrollOptions { working: () => boolean onUserInteracted?: () => void bottomThreshold?: number + overflowAnchor?: "none" | "auto" | "dynamic" } export function createAutoScroll(options: AutoScrollOptions) { @@ -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() } 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() } @@ -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 @@ -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() @@ -206,7 +217,7 @@ export function createAutoScroll(options: AutoScrollOptions) { if (!el) return - el.style.overflowAnchor = "auto" + updateOverflowAnchor(el) cleanup = userActivity.listen(el) } diff --git a/packages/kilo-ui/src/hooks/scroll-user-activity.ts b/packages/kilo-ui/src/hooks/scroll-user-activity.ts index a0b640c32d0..a8a33fc8a66 100644 --- a/packages/kilo-ui/src/hooks/scroll-user-activity.ts +++ b/packages/kilo-ui/src/hooks/scroll-user-activity.ts @@ -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() } From f1e5ed30e48aec807e675a8a46dc304106aebfc4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 7 Aug 2026 17:17:35 +0200 Subject: [PATCH 2/2] refactor(ui): eliminate stop debounce timer in favor of synchronous scroll state --- .../kilo-ui/src/hooks/create-auto-scroll.tsx | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx index 8bfca603076..09ab785423c 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -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. @@ -25,7 +24,6 @@ export function createAutoScroll(options: AutoScrollOptions) { let scroll: HTMLElement | undefined let settling = false let settleTimer: ReturnType | undefined - let stopTimer: ReturnType | undefined let cleanup: (() => void) | undefined const [store, setStore] = createStore({ @@ -101,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 @@ -111,21 +109,6 @@ export function createAutoScroll(options: AutoScrollOptions) { return } - if (!store.userScrolled && !input) { - if (userActivity.isRecent()) { - stop() - return - } - if (stopTimer) clearTimeout(stopTimer) - stopTimer = setTimeout(() => { - stopTimer = undefined - if (!scroll) return - if (distanceFromBottom(scroll) < threshold()) return - stop() - }, DEBOUNCE_MS) - return - } - stop() } @@ -223,7 +206,6 @@ export function createAutoScroll(options: AutoScrollOptions) { onCleanup(() => { if (settleTimer) clearTimeout(settleTimer) - if (stopTimer) clearTimeout(stopTimer) if (cleanup) cleanup() })