mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge pull request #13408 from Kilo-Org/fix-session-scroll-flicker
fix(vscode): eliminate streaming transcript flicker
This commit is contained in:
@@ -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.
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bbe086983757d02e90c26f3798a6a8e3883ac57e009bebdde0852836684c7595
|
||||
size 32553
|
||||
oid sha256:208d1aed2d0a3a3b5397d1a11155871f6bd8c01ee8772b81fb45875c36d43a17
|
||||
size 32340
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d866d08bc0d18ee2abc4c2bd246f2a958107ca1cb1771cb61e37aa2597504ed8
|
||||
size 28266
|
||||
oid sha256:e50f52681c7c255f4f65bea146dd4adbbb8a5d3a0027777406e42572c58378f4
|
||||
size 29904
|
||||
|
||||
+2
-2
@@ -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%;
|
||||
|
||||
Reference in New Issue
Block a user