From c8df9c4a7dc871fcd616accbf1e917241e48f777 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 27 Aug 2026 10:31:27 +0200 Subject: [PATCH] fix(vscode): preserve streaming scroll intent --- .changeset/fix-streaming-scroll-intent.md | 5 + .../src/hooks/create-auto-scroll.test.tsx | 168 +++++++++++++++++- .../kilo-ui/src/hooks/create-auto-scroll.tsx | 18 +- .../kilo-ui/src/hooks/scroll-user-activity.ts | 17 +- .../tests/chat-auto-scroll.spec.ts | 142 +++++++++++++++ .../webview-ui/src/stories/chat.stories.tsx | 14 ++ 6 files changed, 351 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-streaming-scroll-intent.md diff --git a/.changeset/fix-streaming-scroll-intent.md b/.changeset/fix-streaming-scroll-intent.md new file mode 100644 index 0000000000..82b6d09b94 --- /dev/null +++ b/.changeset/fix-streaming-scroll-intent.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep chat scroll position during streaming, including small upward gestures, direction changes, and scrolling over message controls. 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 fb72b94652..a0a8e694ee 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx @@ -27,17 +27,18 @@ class FakeElement { scrollTop = 0 style = { overflowAnchor: "" } hovered = false + control = false dir = "" rect = { left: 0, top: 0, right: 100, bottom: 100 } ownerDocument!: FakeDocument private children = new Set() private listeners = new Map() - closest() { - return null + closest(selector: string) { + return this.control && selector === "button, input, textarea, select" ? this : null } - contains(node: unknown) { + contains(node: unknown): boolean { return node === this || (node instanceof FakeElement && [...this.children].some((child) => child.contains(node))) } @@ -313,6 +314,140 @@ describe("createAutoScroll non-scrollable layouts", () => { ctx.dispose() }) + test.each([0, 0.5, 1])("preserves upward intent after a %spx scroll near the bottom", (offset) => { + const ctx = setup({ working: true }) + overflow(ctx) + let now = 10 + const clock = spyOn(performance, "now").mockImplementation(() => now) + + try { + ctx.el.fire("wheel", new FakeWheelEvent(-1, ctx.el) as unknown as Event) + ctx.el.scrollTop -= offset + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + now = 500 + ctx.scroll.handleScroll() + ctx.el.scrollHeight = 1100 + ctx.mutate() + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(800 - offset) + } finally { + clock.mockRestore() + ctx.dispose() + } + }) + + test("pauses for upward wheel input over a transcript button", () => { + const ctx = setup({ working: true }) + overflow(ctx) + const button = new FakeElement() + button.control = true + ctx.el.append(button) + + ctx.el.fire("wheel", new FakeWheelEvent(-240, button) as unknown as Event) + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollTop = 560 + ctx.scroll.handleScroll() + ctx.el.scrollHeight = 1100 + ctx.mutate() + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(560) + ctx.dispose() + }) + + test("does not treat button clicks and key presses as scroll input", () => { + const ctx = setup({ working: true }) + overflow(ctx) + const button = new FakeElement() + button.control = true + ctx.el.append(button) + + ctx.el.fire("pointerdown", new FakePointerEvent(1, button) as unknown as Event) + ctx.doc.fire("keydown", new FakeKeyboardEvent("ArrowUp", button) as unknown as Event) + ctx.el.scrollTop = 600 + ctx.scroll.handleScroll() + + expect(ctx.scroll.userScrolled()).toBe(false) + expect(ctx.el.scrollTop).toBe(1000) + ctx.dispose() + }) + + test("keeps a pause when a layout change puts the same position at the bottom", () => { + const ctx = setup({ working: true }) + overflow(ctx, 1000, 400) + ctx.scroll.pause() + + ctx.el.scrollHeight = 600 + ctx.scroll.handleScroll() + ctx.el.scrollHeight = 1000 + ctx.mutate() + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(400) + ctx.dispose() + }) + + test.each(["wheel", "keyboard"])("preserves new %s input before a pending bottom scroll", (input) => { + const ctx = setup({ working: true }) + overflow(ctx) + let now = 10 + const clock = spyOn(performance, "now").mockImplementation(() => now) + + try { + ctx.el.fire("wheel", new FakeWheelEvent(-20, ctx.el) as unknown as Event) + ctx.el.scrollTop = 780 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollTop = 800 + if (input === "wheel") ctx.el.fire("wheel", new FakeWheelEvent(-20, ctx.el) as unknown as Event) + if (input === "keyboard") { + ctx.doc.fire("keydown", new FakeKeyboardEvent("ArrowUp", ctx.el) as unknown as Event) + } + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollTop = 780 + ctx.scroll.handleScroll() + now = 500 + ctx.el.scrollHeight = 1100 + ctx.mutate() + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(780) + } finally { + clock.mockRestore() + ctx.dispose() + } + }) + + test("reattaches when a downward wheel returns to the bottom", () => { + const ctx = setup({ working: true }) + overflow(ctx) + ctx.el.fire("wheel", new FakeWheelEvent(-20, ctx.el) as unknown as Event) + ctx.el.scrollTop = 780 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.fire("wheel", new FakeWheelEvent(20, ctx.el) as unknown as Event) + ctx.el.scrollTop = 800 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(false) + + ctx.el.scrollHeight = 1100 + ctx.mutate() + expect(ctx.el.scrollTop).toBe(1100) + ctx.dispose() + }) + test("continues following streaming growth after a downward wheel at the bottom", () => { const ctx = setup({ working: true }) ctx.el.scrollHeight = 1000 @@ -634,6 +769,33 @@ describe("createAutoScroll non-scrollable layouts", () => { ctx.dispose() }) + test("keeps a scrollbar gesture active after reaching the bottom", () => { + const ctx = setup({ working: true }) + overflow(ctx) + let now = 10 + const clock = spyOn(performance, "now").mockImplementation(() => now) + + try { + ctx.doc.fire("pointerdown", new FakePointerEvent(1, ctx.el) as unknown as Event) + ctx.el.scrollTop = 600 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollTop = 800 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(false) + + now = 1000 + ctx.el.scrollTop = 600 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(600) + } finally { + clock.mockRestore() + ctx.dispose() + } + }) + test("keeps a pointer gesture active beyond the grace period", () => { const ctx = setup({ working: true }) overflow(ctx) diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx index 64e059df8d..e294655355 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -22,6 +22,7 @@ export function createAutoScroll(options: AutoScrollOptions) { // --------------------------------------------------------------------------- let scroll: HTMLElement | undefined + let top = 0 let settling = false let settleTimer: ReturnType | undefined let cleanup: (() => void) | undefined @@ -73,7 +74,9 @@ export function createAutoScroll(options: AutoScrollOptions) { } const pause = () => { - if (!scroll || store.userScrolled) return + if (!scroll) return + top = scroll.scrollTop + if (store.userScrolled) return setStore("userScrolled", true) options.onUserInteracted?.() } @@ -91,7 +94,7 @@ export function createAutoScroll(options: AutoScrollOptions) { grace: USER_INTERACTION_GRACE_MS, // Upward wheel input anywhere in the transcript expresses the user's // intent to review earlier content, even when a nested region consumes it. - onWheelUp: stop, + onUp: stop, }) // --------------------------------------------------------------------------- @@ -101,13 +104,19 @@ export function createAutoScroll(options: AutoScrollOptions) { const handleScroll = () => { if (!scroll) return + const position = scroll.scrollTop + const down = position > top + top = position const input = userActivity.consumeScroll() const distance = distanceFromBottom(scroll) if (!canScroll(scroll)) return if (distance < threshold()) { - if (store.userScrolled && (distance < 2 || !userActivity.isRecent())) setStore("userScrolled", false) + if (store.userScrolled && down && (distance < 2 || !userActivity.isRecent())) { + userActivity.clear() + setStore("userScrolled", false) + } return } @@ -187,7 +196,7 @@ export function createAutoScroll(options: AutoScrollOptions) { settleTimer = undefined if (working) { - force() + follow() return } @@ -234,6 +243,7 @@ export function createAutoScroll(options: AutoScrollOptions) { } scroll = el + top = el?.scrollTop ?? 0 setStore("scrollRef", el) if (!el) return diff --git a/packages/kilo-ui/src/hooks/scroll-user-activity.ts b/packages/kilo-ui/src/hooks/scroll-user-activity.ts index a0bcdf0c17..f82d78e77d 100644 --- a/packages/kilo-ui/src/hooks/scroll-user-activity.ts +++ b/packages/kilo-ui/src/hooks/scroll-user-activity.ts @@ -1,6 +1,6 @@ interface UserActivityOptions { grace: number - onWheelUp: () => void + onUp: () => void } type Kind = "pointer" | "mouse" | "touch" @@ -94,19 +94,22 @@ export const createUserActivity = (options: UserActivityOptions) => { gestures.delete(doc) } - const reset = () => { - if (doc && scroll && gestures.get(doc) === scroll) gestures.delete(doc) + const clear = () => { marked = false time = 0 + } + + const reset = () => { + if (doc && scroll && gestures.get(doc) === scroll) gestures.delete(doc) + clear() gesture = undefined } const wheel = (event: WheelEvent) => { - if (!isPotentialScrollInput(event)) return if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return if (event.deltaY >= 0 || scroll.scrollTop <= 0) return - mark(event) - options.onWheelUp() + mark() + options.onUp() } const key = (event: KeyboardEvent) => { @@ -125,6 +128,7 @@ export const createUserActivity = (options: UserActivityOptions) => { }) if (deepest(matches) !== scroll) return mark(event) + if (up) options.onUp() } return { @@ -179,6 +183,7 @@ export const createUserActivity = (options: UserActivityOptions) => { return value }, isRecent: () => gesture !== undefined || (time > 0 && performance.now() - time < options.grace), + clear, reset, } } diff --git a/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts b/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts index cef78a90d8..8a7f6f9197 100644 --- a/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts +++ b/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts @@ -192,3 +192,145 @@ test("keeps a long native scrollbar drag user-controlled", async ({ page }) => { await expect.poll(() => distance(page)).toBeGreaterThan(40) await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible() }) + +test("pauses on an upward wheel over the Copy response button", async ({ page }) => { + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + const list = page.locator(".message-list") + const copy = page.getByRole("button", { name: "Copy response" }).first() + await expect(list).toBeVisible() + await expect(copy).toBeVisible() + await settle(page, 10) + await expect.poll(() => distance(page)).toBeLessThanOrEqual(2) + + await copy.hover() + await page.mouse.wheel(0, -240) + + await expect.poll(() => distance(page)).toBeGreaterThan(40) + await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible() +}) + +test("keeps a one-pixel upward wheel pause through delayed streaming", async ({ page }) => { + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + const list = page.locator(".message-list") + const copy = page.getByRole("button", { name: "Copy response" }).first() + await expect(list).toBeVisible() + await expect(copy).toBeVisible() + await settle(page, 10) + await expect.poll(() => distance(page)).toBeLessThanOrEqual(2) + + await copy.hover() + await page.mouse.wheel(0, -1) + await expect.poll(() => distance(page)).toBeGreaterThan(0) + const before = await state(page) + + await page.waitForTimeout(350) + await page.getByTestId("append-stream").click() + + await expect.poll(() => list.evaluate((el) => el.scrollHeight)).toBeGreaterThan(before.height) + await settle(page, 10) + const after = await state(page) + expect(after.top).toBeCloseTo(before.top, 0) + expect(after.distance).toBeGreaterThan(40) + await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible() +}) + +test("keeps the pause after a pending bottom scroll event", async ({ page }) => { + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + const list = page.locator(".message-list") + const copy = page.getByRole("button", { name: "Copy response" }).first() + const bottom = page.getByRole("button", { name: "Scroll to bottom" }) + await expect(list).toBeVisible() + await expect(copy).toBeVisible() + await settle(page, 10) + await expect.poll(() => distance(page)).toBeLessThanOrEqual(2) + + await list.evaluate((el) => { + const fire = () => { + el.dataset.pending = "1" + el.dispatchEvent(new Event("scroll")) + } + const wheel = (event: Event) => { + if (event.target !== el && event.target instanceof Element && !el.contains(event.target)) return + queueMicrotask(fire) + el.ownerDocument.removeEventListener("wheel", wheel, true) + } + el.dataset.pending = "0" + el.ownerDocument.addEventListener("wheel", wheel, true) + }) + + await copy.hover() + await page.mouse.wheel(0, -1) + await expect.poll(() => list.getAttribute("data-pending")).toBe("1") + await expect.poll(() => distance(page)).toBeGreaterThan(0) + await expect(bottom).toBeVisible() +}) + +for (const input of ["wheel", "keyboard"] as const) { + test(`keeps new upward ${input} input before a pending return-to-bottom scroll`, async ({ page }) => { + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + const list = page.locator(".message-list") + const bottom = page.getByRole("button", { name: "Scroll to bottom" }) + await expect(list).toBeVisible() + await settle(page, 10) + await expect.poll(() => distance(page)).toBeLessThanOrEqual(2) + + await list.hover() + await page.mouse.wheel(0, -240) + await expect.poll(() => distance(page)).toBeGreaterThan(40) + await expect(bottom).toBeVisible() + await settle(page, 4) + + await list.evaluate((el, input) => { + if (input === "keyboard") { + el.tabIndex = 0 + el.focus({ preventScroll: true }) + } + const type = input === "wheel" ? "wheel" : "keydown" + const prime = () => { + el.scrollTop = el.scrollHeight - el.clientHeight + } + const pending = () => { + el.dispatchEvent(new Event("scroll")) + el.dataset.pending = "1" + } + el.ownerDocument.addEventListener(type, prime, { capture: true, passive: false, once: true }) + const target = input === "wheel" ? el : el.ownerDocument + target.addEventListener(type, pending, { capture: input === "wheel", passive: false, once: true }) + }, input) + + if (input === "wheel") await page.mouse.wheel(0, -20) + if (input === "keyboard") await page.keyboard.press("ArrowUp") + await expect.poll(() => list.getAttribute("data-pending")).toBe("1") + await expect.poll(() => distance(page)).toBeGreaterThan(10) + await expect(bottom).toBeVisible() + await settle(page, 20) + const before = await state(page) + + await page.waitForTimeout(350) + await page.getByTestId("append-stream").click() + await expect.poll(() => list.evaluate((el) => el.scrollHeight)).toBeGreaterThan(before.height) + await settle(page, 10) + expect((await state(page)).top).toBeCloseTo(before.top, 0) + await expect(bottom).toBeVisible() + }) +} + +test("preserves the pause across working status changes", async ({ page }) => { + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + const list = page.locator(".message-list") + const bottom = page.getByRole("button", { name: "Scroll to bottom" }) + await expect(list).toBeVisible() + await settle(page, 10) + await expect.poll(() => distance(page)).toBeLessThanOrEqual(2) + + await list.hover() + await page.mouse.wheel(0, -240) + await expect.poll(() => distance(page)).toBeGreaterThan(40) + await expect(bottom).toBeVisible() + await page.getByTestId("toggle-status").click() + await page.getByTestId("toggle-status").click() + await settle(page, 4) + + await expect.poll(() => distance(page)).toBeGreaterThan(40) + await expect(bottom).toBeVisible() +}) diff --git a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx index 2594b5b0ed..e5002f3c32 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/chat.stories.tsx @@ -747,8 +747,13 @@ export const MessageListLayoutCorrection: Story = { name: "MessageList - follow after layout correction", render: () => { const [output, setOutput] = createSignal("Initial streamed response.") + const [status, setStatus] = createSignal<"idle" | "busy">("busy") const session = { ...mockSessionValue({ id: SESSION_ID, status: "busy" }), + status, + statusInfo: () => ({ type: status() }), + statusText: () => (status() === "busy" ? "Thinking…" : undefined), + busySince: () => (status() === "busy" ? Date.now() - 2000 : undefined), messages: () => correctionMessages, userMessages: () => correctionMessages.filter((msg) => msg.role === "user"), getParts: (id: string) => { @@ -769,6 +774,8 @@ export const MessageListLayoutCorrection: Story = { position: fixed; inset: 8px 8px auto auto; z-index: 10; + display: flex; + gap: 8px; } `}
@@ -779,6 +786,13 @@ export const MessageListLayoutCorrection: Story = { > Append stream +