From cba6ebc6fa7ba313bc0aae51550389626621d9c5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 10:46:51 +0200 Subject: [PATCH 1/2] fix(ui): preserve streaming chat scroll position --- .changeset/fix-streaming-scroll-follow.md | 5 +++ .../src/hooks/create-auto-scroll.test.tsx | 33 +++++++++++++++++++ .../kilo-ui/src/hooks/create-auto-scroll.tsx | 6 +++- .../kilo-ui/src/hooks/scroll-user-activity.ts | 2 +- 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-streaming-scroll-follow.md diff --git a/.changeset/fix-streaming-scroll-follow.md b/.changeset/fix-streaming-scroll-follow.md new file mode 100644 index 00000000000..30600ba02ec --- /dev/null +++ b/.changeset/fix-streaming-scroll-follow.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-ui": patch +--- + +Keep chat pinned to the latest streaming output through layout reflows and downward scrolling. 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 6f1ec9e3542..0a0334ade4e 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx @@ -203,6 +203,39 @@ describe("createAutoScroll non-scrollable layouts", () => { ctx.dispose() }) + test("continues following streaming growth after a downward wheel at the bottom", () => { + const ctx = setup({ working: true }) + ctx.el.scrollHeight = 1000 + ctx.el.clientHeight = 200 + ctx.el.scrollTop = 800 + + ctx.el.fire("wheel", new FakeWheelEvent(50, ctx.el) as unknown as Event) + ctx.el.scrollHeight = 1048 + ctx.resize(0) + + expect(ctx.scroll.userScrolled()).toBe(false) + expect(ctx.el.scrollTop).toBe(1048) + ctx.dispose() + }) + + test("continues following when streaming reflow emits scroll before resize", () => { + const ctx = setup({ working: true }) + ctx.el.scrollHeight = 1000 + ctx.el.clientHeight = 200 + ctx.el.scrollTop = 800 + + ctx.el.scrollHeight = 1108 + ctx.scroll.handleScroll() + + expect(ctx.scroll.userScrolled()).toBe(false) + + ctx.resize(0) + + expect(ctx.scroll.userScrolled()).toBe(false) + expect(ctx.el.scrollTop).toBe(1108) + ctx.dispose() + }) + test("follows when initially short content starts overflowing", () => { const ctx = setup() ctx.resize() diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx index 09ab785423c..171dcb263f3 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -99,7 +99,7 @@ export function createAutoScroll(options: AutoScrollOptions) { const handleScroll = () => { if (!scroll) return - userActivity.consumeScroll() + const input = userActivity.consumeScroll() const distance = distanceFromBottom(scroll) if (!canScroll(scroll)) return @@ -109,6 +109,10 @@ export function createAutoScroll(options: AutoScrollOptions) { return } + // Virtualizer and layout remeasurement can emit scroll before the + // ResizeObserver restores bottom-follow. Only user input should pause it. + if (!store.userScrolled && !input && !userActivity.isRecent()) return + stop() } diff --git a/packages/kilo-ui/src/hooks/scroll-user-activity.ts b/packages/kilo-ui/src/hooks/scroll-user-activity.ts index a8a33fc8a66..2cd5b8a76bb 100644 --- a/packages/kilo-ui/src/hooks/scroll-user-activity.ts +++ b/packages/kilo-ui/src/hooks/scroll-user-activity.ts @@ -26,8 +26,8 @@ export const createUserActivity = (options: UserActivityOptions) => { const handleWheel = (event: WheelEvent) => { if (!isPotentialScrollInput(event)) return if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return - mark(event) if (event.deltaY >= 0 || scroll.scrollTop <= 0) return + mark(event) options.onWheelUp() } From 2acdb46eeec7e577bc27cf589c0dfe16c4e83410 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 17 Aug 2026 10:55:01 +0200 Subject: [PATCH 2/2] fix(ui): preserve native scroll pauses --- .../src/hooks/create-auto-scroll.test.tsx | 19 +++++++++++++++++++ .../kilo-ui/src/hooks/create-auto-scroll.tsx | 9 ++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) 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 0a0334ade4e..463c58c08c1 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx @@ -295,4 +295,23 @@ describe("createAutoScroll non-scrollable layouts", () => { expect(ctx.el.scrollTop).toBe(600) ctx.dispose() }) + + test("pauses when a native scrollbar drag changes scroll position without input events", () => { + const ctx = setup({ working: true }) + ctx.el.scrollHeight = 1000 + ctx.el.clientHeight = 200 + ctx.el.scrollTop = 800 + ctx.scroll.handleScroll() + + ctx.el.scrollTop = 600 + ctx.scroll.handleScroll() + + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollHeight = 1050 + ctx.resize() + + 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 171dcb263f3..a41cdbb0f00 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -25,6 +25,8 @@ export function createAutoScroll(options: AutoScrollOptions) { let settling = false let settleTimer: ReturnType | undefined let cleanup: (() => void) | undefined + let lastTop = 0 + let lastHeight = 0 const [store, setStore] = createStore({ contentRef: undefined as HTMLElement | undefined, @@ -101,6 +103,9 @@ export function createAutoScroll(options: AutoScrollOptions) { const input = userActivity.consumeScroll() const distance = distanceFromBottom(scroll) + const moved = Math.abs(scroll.scrollTop - lastTop) > 1 && Math.abs(scroll.scrollHeight - lastHeight) <= 1 + lastTop = scroll.scrollTop + lastHeight = scroll.scrollHeight if (!canScroll(scroll)) return @@ -111,7 +116,7 @@ export function createAutoScroll(options: AutoScrollOptions) { // Virtualizer and layout remeasurement can emit scroll before the // ResizeObserver restores bottom-follow. Only user input should pause it. - if (!store.userScrolled && !input && !userActivity.isRecent()) return + if (!store.userScrolled && !input && !userActivity.isRecent() && !moved) return stop() } @@ -204,6 +209,8 @@ export function createAutoScroll(options: AutoScrollOptions) { if (!el) return + lastTop = el.scrollTop + lastHeight = el.scrollHeight updateOverflowAnchor(el) cleanup = userActivity.listen(el) }