diff --git a/.changeset/calm-timeline-follow.md b/.changeset/calm-timeline-follow.md new file mode 100644 index 00000000000..77c4d6ca811 --- /dev/null +++ b/.changeset/calm-timeline-follow.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep task timelines in place while reviewing earlier activity across Kilo chat surfaces, and resume following updates from the latest bar. diff --git a/packages/kilo-vscode/tests/unit/timeline-sizes.test.ts b/packages/kilo-vscode/tests/unit/timeline-sizes.test.ts index 4194e7a81ca..5ff2006c0c6 100644 --- a/packages/kilo-vscode/tests/unit/timeline-sizes.test.ts +++ b/packages/kilo-vscode/tests/unit/timeline-sizes.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { sizes, MAX_HEIGHT } from "../../webview-ui/src/utils/timeline/sizes" +import { sizes, pinned, MAX_HEIGHT } from "../../webview-ui/src/utils/timeline/sizes" import type { Part, TextPart, ToolPart, StepFinishPart } from "../../webview-ui/src/types/messages" function mkText(text: string): TextPart { @@ -89,4 +89,10 @@ describe("timeline sizes", () => { expect(Number.isInteger(bar.height)).toBe(true) } }) + + it("detects whether scrolling should follow new bars", () => { + expect(pinned({ scrollLeft: 100, scrollWidth: 200, clientWidth: 100 })).toBe(true) + expect(pinned({ scrollLeft: 88, scrollWidth: 200, clientWidth: 100 })).toBe(true) + expect(pinned({ scrollLeft: 87, scrollWidth: 200, clientWidth: 100 })).toBe(false) + }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx index 1c791cbb6fb..3f4efb1db5b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx @@ -16,7 +16,7 @@ import { Component, Index, Show, createMemo, createEffect, createSignal, on, onC import { Portal } from "solid-js/web" import { useSession } from "../../context/session" import { color, label } from "../../utils/timeline/colors" -import { sizes, MAX_HEIGHT } from "../../utils/timeline/sizes" +import { sizes, pinned, MAX_HEIGHT } from "../../utils/timeline/sizes" import type { Part, Message } from "../../types/messages" export interface TimelineBar { @@ -73,19 +73,34 @@ export const TaskTimeline: Component = () => { const bars = createMemo(() => collect(messages(), allParts())) const busy = () => session.status() === "busy" - // Auto-scroll to the latest bar when new bars appear + // Reading scrollWidth and writing scrollLeft synchronously for every appended bar can + // force repeated layout during streamed part updates. Batch those appends behind one + // animation frame, after Solid has applied the current DOM updates. Only follow while + // pinned so inspecting earlier activity is not interrupted by incoming bars. let prev = 0 + let frame: number | undefined + let follow = true + const onScroll = () => { + if (ref) follow = pinned(ref) + } createEffect( on( () => bars().length, (len) => { - if (len > prev && ref) { - ref.scrollLeft = ref.scrollWidth + if (len > prev && ref && follow && frame === undefined) { + frame = requestAnimationFrame(() => { + frame = undefined + if (!ref || !follow) return + ref.scrollLeft = ref.scrollWidth + }) } prev = len }, ), ) + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + }) const hideTip = () => { tipBar = undefined @@ -161,6 +176,7 @@ export const TaskTimeline: Component = () => { onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onPointerLeave={hideTip} + onScroll={onScroll} > {(bar) => { diff --git a/packages/kilo-vscode/webview-ui/src/utils/timeline/sizes.ts b/packages/kilo-vscode/webview-ui/src/utils/timeline/sizes.ts index 8448b2dc738..78bb05248c2 100644 --- a/packages/kilo-vscode/webview-ui/src/utils/timeline/sizes.ts +++ b/packages/kilo-vscode/webview-ui/src/utils/timeline/sizes.ts @@ -20,6 +20,17 @@ export interface BarSize { content: number } +export interface TimelineScroll { + scrollLeft: number + scrollWidth: number + clientWidth: number +} + +/** Keep following updates when the viewport is within one bar of the right edge. */ +export function pinned(scroll: TimelineScroll, slack = BAR_W): boolean { + return scroll.scrollWidth - scroll.clientWidth - scroll.scrollLeft <= slack +} + // ── Content length ─────────────────────────────────────────────────── function content(part: Part): number {