Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/smooth-streaming-transcript.md
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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 24 additions & 2 deletions packages/kilo-ui/src/components/message-part.css
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,6 @@
color: var(--text-base);
}
}

}

[data-slot="message-part-title-filename"] {
Expand Down Expand Up @@ -155,7 +154,6 @@
direction: rtl;
text-align: left;
}

}

/* Task tool child-session tool list (v1.0.25 style) */
Expand Down Expand Up @@ -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] {
Expand Down
73 changes: 69 additions & 4 deletions packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")

Expand All @@ -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]?.()
Expand All @@ -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(() => {
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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(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(1100)
expect(ctx.el.scrollTop).toBe(704)
ctx.dispose()
})

Expand Down
39 changes: 37 additions & 2 deletions packages/kilo-ui/src/hooks/create-auto-scroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Copy link
Copy Markdown
Collaborator

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.

return
}

stop()
}
Expand All @@ -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 = () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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()
Expand All @@ -210,6 +243,8 @@ export function createAutoScroll(options: AutoScrollOptions) {

onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer)
watcher?.disconnect()
watcher = undefined
if (cleanup) cleanup()
})

Expand All @@ -219,7 +254,7 @@ export function createAutoScroll(options: AutoScrollOptions) {

return {
scrollRef: setScroll,
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
contentRef: setContent,
handleScroll,
pause,
resume,
Expand Down
17 changes: 17 additions & 0 deletions packages/kilo-vscode/tests/unit/transcript-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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({
Expand Down
9 changes: 9 additions & 0 deletions packages/kilo-vscode/webview-ui/src/styles/chat-layout.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
Loading