From 4ff079644c7c52bbc5659db9181dfa06e71b9cf3 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 20 Apr 2026 14:31:38 +0200 Subject: [PATCH 1/2] fix(vscode,ui): keep user scroll position while session is busy Virtua's measurement-driven resize events race ahead of the debounced user-scroll detection in createAutoScroll, snapping the viewport back to the bottom while the user is mid-gesture. The QuestionDock's focus call on mount also triggers the browser's focus-into-view behavior, which yanks the view down whenever the user has scrolled up. Fixes #9198 --- .changeset/scroll-up-while-busy.md | 5 ++++ .../kilo-ui/src/hooks/create-auto-scroll.tsx | 24 ++++++++++++++++++- .../src/components/chat/QuestionDock.tsx | 5 +++- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 .changeset/scroll-up-while-busy.md 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..4ff21cf20c4 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,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 + const scrollToBottomNow = (behavior: ScrollBehavior) => { const el = scroll if (!el) return @@ -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") @@ -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. 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 }) }) }) From f0015dddce67eca45725f3d1ab51e33e1dc475b4 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 20 Apr 2026 14:40:00 +0200 Subject: [PATCH 2/2] fix(ui): guard recentlyInteracted against initial lastInteraction=0 --- packages/kilo-ui/src/hooks/create-auto-scroll.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx index 4ff21cf20c4..fcef54cb27c 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -52,7 +52,8 @@ export function createAutoScroll(options: AutoScrollOptions) { lastInteraction = performance.now() } - const recentlyInteracted = () => performance.now() - lastInteraction < USER_INTERACTION_GRACE_MS + const recentlyInteracted = () => + lastInteraction > 0 && performance.now() - lastInteraction < USER_INTERACTION_GRACE_MS const scrollToBottomNow = (behavior: ScrollBehavior) => { const el = scroll