Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions .changeset/fix-streaming-scroll-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-ui": patch
---

Keep chat pinned to the latest streaming output through layout reflows and downward scrolling.
52 changes: 52 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 @@ -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()
Expand Down Expand Up @@ -262,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()
})
})
13 changes: 12 additions & 1 deletion packages/kilo-ui/src/hooks/create-auto-scroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
let lastTop = 0
let lastHeight = 0

const [store, setStore] = createStore({
contentRef: undefined as HTMLElement | undefined,
Expand Down Expand Up @@ -99,8 +101,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
const handleScroll = () => {
if (!scroll) return

userActivity.consumeScroll()
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

Expand All @@ -109,6 +114,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() && !moved) return

stop()
}

Expand Down Expand Up @@ -200,6 +209,8 @@ export function createAutoScroll(options: AutoScrollOptions) {

if (!el) return

lastTop = el.scrollTop
lastHeight = el.scrollHeight
updateOverflowAnchor(el)
cleanup = userActivity.listen(el)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/kilo-ui/src/hooks/scroll-user-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Loading