-
Notifications
You must be signed in to change notification settings - Fork 3.1k
fix(vscode): eliminate streaming transcript flicker #13408
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4736250
c516947
9bc84d2
694e21a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| "@kilocode/kilo-ui": patch | ||
| "kilo-code": patch | ||
| --- | ||
|
|
||
| Keep the session transcript glued to its bottom while a response streams, so text, tool cards, reasoning, and message actions no longer twitch as they update. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ export function createAutoScroll(options: AutoScrollOptions) { | |
| let settling = false | ||
| let settleTimer: ReturnType<typeof setTimeout> | undefined | ||
| let cleanup: (() => void) | undefined | ||
| let watcher: MutationObserver | undefined | ||
|
|
||
| const [store, setStore] = createStore({ | ||
| contentRef: undefined as HTMLElement | undefined, | ||
|
|
@@ -111,7 +112,15 @@ export function createAutoScroll(options: AutoScrollOptions) { | |
|
|
||
| // Virtualizer and layout corrections can move the viewport without | ||
| // changing content height. Only an input event should pause auto-follow. | ||
| if (!store.userScrolled && !input && !userActivity.isRecent()) return | ||
| if (!store.userScrolled && !input && !userActivity.isRecent()) { | ||
| // A tool card that swaps views shrinks the transcript and recovers inside | ||
| // the same frame. The shrink makes the browser clamp the pin away, and | ||
| // because the final content size is unchanged no resize entry follows, so | ||
| // the correction has to happen here or the transcript stays parked below | ||
| // its bottom until the next content update. | ||
| if (active()) bottom() | ||
| return | ||
| } | ||
|
|
||
| stop() | ||
| } | ||
|
|
@@ -135,6 +144,18 @@ export function createAutoScroll(options: AutoScrollOptions) { | |
| follow() | ||
| } | ||
|
|
||
| // Content mutations are pinned while they are still queued, before the frame | ||
| // lays out and paints. A ResizeObserver entry arrives after that layout, so | ||
| // waiting for it lets the browser paint one frame with the new content hanging | ||
| // below the viewport, which reads as the transcript twitching as it streams. | ||
| const onContentMutate = () => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. low: The new MutationObserver invokes follow() for every childList/characterData mutation of the whole transcript while the session is active, and each invocation forces a synchronous layout through the distanceFromBottom() read (plus a second one through bottom()'s scrollHeight read when the pin fires). During streaming this runs once per streamed chunk on top of the virtualizer's own re-measurement work, so a large transcript can jank at the very moment the change is meant to make it feel smooth. Fix: coalesce the pins per frame, e.g. schedule follow() once via requestAnimationFrame or a microtask flag rather than per mutation. |
||
| if (!scroll) return | ||
| if (store.userScrolled || userActivity.isRecent()) return | ||
| if (!canScroll(scroll)) return | ||
|
|
||
| follow() | ||
| } | ||
|
|
||
| const onViewportResize = () => { | ||
| if (!scroll) return | ||
| if (!canScroll(scroll)) return | ||
|
|
@@ -193,6 +214,18 @@ export function createAutoScroll(options: AutoScrollOptions) { | |
| el.style.overflowAnchor = store.userScrolled ? "auto" : "none" | ||
| } | ||
|
|
||
| const setContent = (el: HTMLElement | undefined) => { | ||
| watcher?.disconnect() | ||
| watcher = undefined | ||
|
|
||
| setStore("contentRef", el) | ||
|
|
||
| if (!el || typeof MutationObserver !== "function") return | ||
|
|
||
| watcher = new MutationObserver(onContentMutate) | ||
| watcher.observe(el, { childList: true, subtree: true, characterData: true }) | ||
| } | ||
|
|
||
| const setScroll = (el: HTMLElement | undefined) => { | ||
| if (cleanup) { | ||
| cleanup() | ||
|
|
@@ -210,6 +243,8 @@ export function createAutoScroll(options: AutoScrollOptions) { | |
|
|
||
| onCleanup(() => { | ||
| if (settleTimer) clearTimeout(settleTimer) | ||
| watcher?.disconnect() | ||
| watcher = undefined | ||
| if (cleanup) cleanup() | ||
| }) | ||
|
|
||
|
|
@@ -219,7 +254,7 @@ export function createAutoScroll(options: AutoScrollOptions) { | |
|
|
||
| return { | ||
| scrollRef: setScroll, | ||
| contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el), | ||
| contentRef: setContent, | ||
| handleScroll, | ||
| pause, | ||
| resume, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
medium: The new re-pin calls bottom() on every non-input scroll event while the session is active (working or settling), which includes user scrolls that are never marked as input — a native scrollbar drag dispatches no pointer events, so mark() in scroll-user-activity.ts never fires, and the search-highlight scrollIntoView at webview-ui/src/components/chat/MessageList.tsx:879 never calls pause(). On classic-scrollbar platforms the view is therefore yanked back to the bottom on every scroll event, so a user cannot scroll up to read earlier output during a stream or within the 300ms settle window, and in-stream search match centering is defeated (the old code left these non-input scrolls in place and re-pinned only on content resize). Fix: re-pin only when the scroll follows content growth (compare scrollHeight to the last pinned value) and treat an unmarked scroll away from the bottom as a pause.