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
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.
24 changes: 23 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,11 @@ export function createAutoScroll(options: AutoScrollOptions) {
if (scroll && nested && nested !== scroll) return
}
userInitiated = true
lastInteraction = performance.now()
}

const recentlyInteracted = () => performance.now() - lastInteraction < USER_INTERACTION_GRACE_MS
Comment thread
marius-kilocode marked this conversation as resolved.
Outdated

const scrollToBottomNow = (behavior: ScrollBehavior) => {
const el = scroll
if (!el) return
Expand Down Expand Up @@ -119,7 +128,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 +176,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