Merge pull request #13408 from Kilo-Org/fix-session-scroll-flicker

fix(vscode): eliminate streaming transcript flicker
This commit is contained in:
Marius
2026-08-25 14:07:05 +02:00
committed by GitHub
10 changed files with 174 additions and 16 deletions
@@ -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.
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bbe086983757d02e90c26f3798a6a8e3883ac57e009bebdde0852836684c7595
size 32553
oid sha256:208d1aed2d0a3a3b5397d1a11155871f6bd8c01ee8772b81fb45875c36d43a17
size 32340
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d866d08bc0d18ee2abc4c2bd246f2a958107ca1cb1771cb61e37aa2597504ed8
size 28266
oid sha256:e50f52681c7c255f4f65bea146dd4adbbb8a5d3a0027777406e42572c58378f4
size 29904
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c2c8b3e4ded1bde02fe2c72764d16c4e056a91e9fa935c9925b8095bfaf9a748
size 27272
oid sha256:7403709acf8593873394ba15f23a50b56b2008e7d99440df503089a3f21b2fe6
size 30279
@@ -119,7 +119,6 @@
color: var(--text-base);
}
}
}
[data-slot="message-part-title-filename"] {
@@ -155,7 +154,6 @@
direction: rtl;
text-align: left;
}
}
/* Task tool child-session tool list (v1.0.25 style) */
@@ -828,6 +826,30 @@ html[data-theme="kilo-vscode"] [data-component="reasoning-part"] {
}
}
/* The shared collapsible turns overflow visible when expanded, which let the
reasoning markdown spill below its box for a frame while the height changed.
Clip it at the box instead, and give the auto-collapse at the end of a
reasoning block a real animation, so the transcript slides instead of
jumping when a tall block closes. */
[data-component="reasoning-part"] [data-slot="collapsible-content"][data-expanded] {
overflow: clip;
}
[data-component="reasoning-part"] [data-slot="collapsible-content"][data-closed] {
overflow: clip;
animation: kilo-reasoning-close 180ms ease-out;
}
@keyframes kilo-reasoning-close {
from {
height: var(--kb-collapsible-content-height);
}
to {
height: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="reasoning-part"] [data-slot="collapsible-content"][data-expanded],
[data-component="reasoning-part"] [data-slot="collapsible-content"][data-closed] {
@@ -12,6 +12,7 @@ mock.module("@solid-primitives/resize-observer", () => ({
const originalElement = globalThis.Element
const originalNode = globalThis.Node
const originalWheelEvent = globalThis.WheelEvent
const originalMutationObserver = globalThis.MutationObserver
type Listener = {
callback: (event: Event) => void
@@ -113,9 +114,25 @@ class FakeKeyboardEvent {
) {}
}
const mutators: (() => void)[] = []
class FakeMutationObserver {
constructor(readonly callback: () => void) {
mutators.push(callback)
}
observe() {}
disconnect() {
const at = mutators.indexOf(this.callback)
if (at >= 0) mutators.splice(at, 1)
}
}
globalThis.Element = FakeElement as unknown as typeof Element
globalThis.Node = FakeElement as unknown as typeof Node
globalThis.WheelEvent = FakeWheelEvent as unknown as typeof WheelEvent
globalThis.MutationObserver = FakeMutationObserver as unknown as typeof MutationObserver
const { createAutoScroll } = await import("./create-auto-scroll")
@@ -133,6 +150,8 @@ function setup(options?: { doc?: FakeDocument; interacted?: () => void; working?
root.scroll.scrollRef(el as unknown as HTMLElement)
root.scroll.contentRef(new FakeElement() as unknown as HTMLElement)
const mutate = () => mutators.forEach((callback) => callback())
const resize = (index?: number) => {
if (index !== undefined) {
observers[index]?.()
@@ -141,11 +160,12 @@ function setup(options?: { doc?: FakeDocument; interacted?: () => void; working?
observers.forEach((callback) => callback())
}
return { ...root, doc, el, resize }
return { ...root, doc, el, resize, mutate }
}
beforeEach(() => {
observers.length = 0
mutators.length = 0
})
afterAll(() => {
@@ -155,6 +175,8 @@ afterAll(() => {
else Reflect.deleteProperty(globalThis, "Node")
if (originalWheelEvent) globalThis.WheelEvent = originalWheelEvent
else Reflect.deleteProperty(globalThis, "WheelEvent")
if (originalMutationObserver) globalThis.MutationObserver = originalMutationObserver
else Reflect.deleteProperty(globalThis, "MutationObserver")
})
describe("createAutoScroll non-scrollable layouts", () => {
@@ -289,16 +311,59 @@ describe("createAutoScroll non-scrollable layouts", () => {
ctx.el.scrollTop = 800
ctx.scroll.handleScroll()
// A tool card that shrinks and recovers inside one frame makes the browser
// clamp the pin away without changing the final content size, so no resize
// entry follows and the pin has to be restored from the scroll event.
ctx.el.scrollTop = 760
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(false)
expect(ctx.el.scrollTop).toBe(1000)
ctx.dispose()
})
ctx.el.scrollHeight = 1100
ctx.resize(0)
test("pins streamed content when it is added, before any resize entry", () => {
const ctx = setup({ working: true })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800
// The resize entry for this growth only arrives after the frame has laid out
// and painted, so the mutation itself has to pin the view.
ctx.el.scrollHeight = 1080
ctx.mutate()
expect(ctx.scroll.userScrolled()).toBe(false)
expect(ctx.el.scrollTop).toBe(1100)
expect(ctx.el.scrollTop).toBe(1080)
ctx.dispose()
})
test("ignores content mutations while the user reads earlier output", () => {
const ctx = setup({ working: true })
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 400
ctx.scroll.pause()
ctx.el.scrollHeight = 1080
ctx.mutate()
expect(ctx.el.scrollTop).toBe(400)
ctx.dispose()
})
test("leaves an idle transcript where a layout clamp put it", () => {
const ctx = setup()
ctx.el.scrollHeight = 1000
ctx.el.clientHeight = 200
ctx.el.scrollTop = 800
ctx.scroll.handleScroll()
ctx.el.scrollTop = 704
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(false)
expect(ctx.el.scrollTop).toBe(704)
ctx.dispose()
})
@@ -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 = () => {
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,
@@ -149,6 +149,23 @@ describe("transcriptRows", () => {
expect(rows.filter((row) => row.type === "assistant").map((row) => row.copy)).toEqual(["p1", "p1"])
})
it("keeps historical copy rows while hiding the live turn copy row", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const u2 = user("u2")
const a2 = assistant("a2", "u2")
const rows = transcriptRows(
messageTurns([u1, a1, u2, a2]),
lookup({ a1: [part("p1", "a1")], a2: [part("p2", "a2")] }),
{ live: new Set(["u2"]) },
)
expect(rows.filter((row) => row.type === "assistant").map((row) => ({ turn: row.turn, copy: row.copy }))).toEqual([
{ turn: "u1", copy: "p1" },
{ turn: "u2", copy: undefined },
])
})
it("keeps compaction replies ordered under the compacted turn and respects revert turns", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
@@ -108,7 +108,11 @@ function diffs(msg: Message) {
return msg.summary.diffs ?? []
}
function copy(messages: Message[], getParts: (id: string) => Part[]) {
function copy(messages: Message[], getParts: (id: string) => Part[], live: boolean) {
// While the session streams, the last non-empty text part changes at every
// part boundary and the copy/feedback row would hop between parts (mount/
// unmount churn next to the streamed text). Anchor it only once idle.
if (live) return undefined
for (let i = messages.length - 1; i >= 0; i -= 1) {
const parts = getParts(messages[i]!.id)
for (let j = parts.length - 1; j >= 0; j -= 1) {
@@ -138,7 +142,7 @@ export function transcriptRows(
queued: opts.queued?.has(turn.id) === true,
live: opts.live?.has(turn.id) === true,
}
const copied = copy(turn.assistant, parts)
const copied = copy(turn.assistant, parts, meta.live)
if (!turn.partial) {
rows.push({
@@ -132,6 +132,15 @@
margin-inline: calc(var(--chat-scrollbar-width, 10px) / 2) calc(var(--chat-scrollbar-width, 10px) / -2);
}
/* Streaming content reflows under a parked pointer, so without a cursor of its
own the transcript alternated between the text cursor over message text and
the default cursor over containers and gaps. The whole transcript is
selectable text, so it declares the text cursor once and every descendant
inherits it. Controls keep their own pointer cursor. */
.message-list-content {
cursor: text;
}
.message-list-content-empty {
display: flex;
min-height: 100%;