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/scroll-up-while-busy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Fix chat scroll jumping back to the bottom while the session is busy — you can now scroll up to read earlier context while a response is streaming or while a question is waiting for your answer.
25 changes: 24 additions & 1 deletion packages/kilo-ui/src/hooks/create-auto-scroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"

const DEBOUNCE_MS = 100
// Grace window after a real user interaction (wheel/pointer/key/touch) during
// which a ResizeObserver or non-user scroll event must not snap the view back
// to the bottom. Long enough to cover a single scroll gesture plus the
// DEBOUNCE_MS window used by handleScroll to flip userScrolled.
const USER_INTERACTION_GRACE_MS = 300

export interface AutoScrollOptions {
working: () => boolean
Expand All @@ -18,6 +23,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
let cleanup: (() => void) | undefined
let userInitiated = false
let lastScrollTop: number | undefined
let lastInteraction = 0

const threshold = () => options.bottomThreshold ?? 10

Expand All @@ -43,8 +49,12 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (scroll && nested && nested !== scroll) return
}
userInitiated = true
lastInteraction = performance.now()
}

const recentlyInteracted = () =>
lastInteraction > 0 && performance.now() - lastInteraction < USER_INTERACTION_GRACE_MS

const scrollToBottomNow = (behavior: ScrollBehavior) => {
const el = scroll
if (!el) return
Expand Down Expand Up @@ -119,7 +129,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
}

if (!store.userScrolled && !byUser) {
if (el.scrollTop < (lastScrollTop ?? el.scrollTop)) {
// virtua fires programmatic scroll events as it measures virtualized
// items. Don't let those snap the view back to the bottom while the
// user is mid-gesture — the wheel event fires before the scroll event,
// so `recentlyInteracted()` is reliable here.
if (el.scrollTop < (lastScrollTop ?? el.scrollTop) || recentlyInteracted()) {
stop()
} else {
scrollToBottomNow("auto")
Expand Down Expand Up @@ -163,6 +177,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
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 (el && recentlyInteracted() && distanceFromBottom(el) > 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,13 +247,16 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>

// Keep keyboard navigation when the webview already has focus, but do not
// steal focus from the editor, terminal, or other VS Code surfaces.
// preventScroll avoids the browser's focus-into-view behavior fighting
// createAutoScroll (and yanking the viewport back to the dock while the
// user has scrolled up to read earlier context).
createEffect(() => {
void store.tab
if (store.collapsed || store.editing || confirm()) return
requestAnimationFrame(() => {
if (!document.hasFocus()) return
const btn = root?.querySelector<HTMLButtonElement>("button[data-slot='question-option']:not(:disabled)")
btn?.focus()
btn?.focus({ preventScroll: true })
})
})

Expand Down
Loading