diff --git a/.changeset/scroll-up-while-busy.md b/.changeset/scroll-up-while-busy.md new file mode 100644 index 00000000000..67073cf95d3 --- /dev/null +++ b/.changeset/scroll-up-while-busy.md @@ -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. diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx index 81fa4874be0..fcef54cb27c 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -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 @@ -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 @@ -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 @@ -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") @@ -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. diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 36c21303b1f..c095328ea69 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -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("button[data-slot='question-option']:not(:disabled)") - btn?.focus() + btn?.focus({ preventScroll: true }) }) })