diff --git a/.changeset/smooth-streaming-transcript.md b/.changeset/smooth-streaming-transcript.md new file mode 100644 index 00000000000..68d740d6646 --- /dev/null +++ b/.changeset/smooth-streaming-transcript.md @@ -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. diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png index 203aa7fd0ea..6014d515eea 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bbe086983757d02e90c26f3798a6a8e3883ac57e009bebdde0852836684c7595 -size 32553 +oid sha256:208d1aed2d0a3a3b5397d1a11155871f6bd8c01ee8772b81fb45875c36d43a17 +size 32340 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png index f00d7b3ef94..ca23518fe56 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d866d08bc0d18ee2abc4c2bd246f2a958107ca1cb1771cb61e37aa2597504ed8 -size 28266 +oid sha256:e50f52681c7c255f4f65bea146dd4adbbb8a5d3a0027777406e42572c58378f4 +size 29904 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png index b291c211758..34d77548c9a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2c8b3e4ded1bde02fe2c72764d16c4e056a91e9fa935c9925b8095bfaf9a748 -size 27272 +oid sha256:7403709acf8593873394ba15f23a50b56b2008e7d99440df503089a3f21b2fe6 +size 30279 diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 3d9d9d63128..1edeff46e16 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -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] { diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx index e793f166934..1033c5ad4f1 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx @@ -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(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() }) diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx index 3e8d83dd6cf..0a79023e0b9 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -25,6 +25,7 @@ export function createAutoScroll(options: AutoScrollOptions) { let settling = false let settleTimer: ReturnType | 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, diff --git a/packages/kilo-vscode/tests/unit/transcript-rows.test.ts b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts index 6889aab495a..8225595b60f 100644 --- a/packages/kilo-vscode/tests/unit/transcript-rows.test.ts +++ b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts @@ -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") diff --git a/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts b/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts index 89ac134335a..0d8939042cf 100644 --- a/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts +++ b/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts @@ -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({ diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css index 08cab9f94d4..8d849ec4e85 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css @@ -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%;