Merge pull request #13457 from Kilo-Org/fix-chat-scroll-user-intent

fix(vscode): preserve chat scroll intent
This commit is contained in:
Marius
2026-08-26 12:29:47 +02:00
committed by GitHub
5 changed files with 479 additions and 26 deletions
@@ -0,0 +1,6 @@
---
"@kilocode/kilo-ui": patch
"kilo-code": patch
---
Preserve the chat reading position when dragging the scrollbar or using extended scrolling gestures while a response streams.
@@ -1,4 +1,4 @@
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { createRoot } from "solid-js"
const observers: Array<() => void> = []
@@ -22,9 +22,13 @@ type Listener = {
class FakeElement {
scrollHeight = 100
clientHeight = 100
clientWidth = 100
offsetWidth = 100
scrollTop = 0
style = { overflowAnchor: "" }
hovered = false
dir = ""
rect = { left: 0, top: 0, right: 100, bottom: 100 }
ownerDocument!: FakeDocument
private children = new Set<FakeElement>()
private listeners = new Map<string, Listener[]>()
@@ -46,6 +50,10 @@ class FakeElement {
return selector === ":hover" && this.hovered
}
getBoundingClientRect() {
return this.rect
}
scrollTo(options: ScrollToOptions) {
this.scrollTop = options.top ?? this.scrollTop
}
@@ -104,6 +112,27 @@ class FakeWheelEvent {
) {}
}
class FakeMouseEvent {
constructor(
readonly target: FakeElement,
readonly clientX = 0,
readonly clientY = 0,
) {}
}
class FakePointerEvent extends FakeMouseEvent {
constructor(
readonly pointerId: number,
target: FakeElement,
clientX = 0,
clientY = 0,
) {
super(target, clientX, clientY)
}
}
class FakeTouchEvent extends FakeMouseEvent {}
class FakeKeyboardEvent {
readonly defaultPrevented = false
readonly shiftKey = false
@@ -163,6 +192,19 @@ function setup(options?: { doc?: FakeDocument; interacted?: () => void; working?
return { ...root, doc, el, resize, mutate }
}
function overflow(ctx: ReturnType<typeof setup>, height = 1000, top = 800) {
ctx.el.scrollHeight = height
ctx.el.clientHeight = 200
ctx.el.scrollTop = top
}
function gutter(ctx: ReturnType<typeof setup>, dir = "") {
ctx.el.clientWidth = 85
ctx.el.offsetWidth = 100
ctx.el.rect = { left: 0, top: 0, right: 100, bottom: 100 }
ctx.el.dir = dir
}
beforeEach(() => {
observers.length = 0
mutators.length = 0
@@ -537,4 +579,176 @@ describe("createAutoScroll non-scrollable layouts", () => {
expect(ctx.el.scrollTop).toBe(600)
ctx.dispose()
})
test("pauses for a document-targeted mousedown in the scrollbar gutter", () => {
const ctx = setup({ working: true })
overflow(ctx)
gutter(ctx)
ctx.doc.fire("mousedown", new FakeMouseEvent(ctx.doc, 95, 50) as unknown as Event)
ctx.el.scrollTop = 600
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(600)
ctx.dispose()
})
test("tracks gestures on the live document when a detached transcript is adopted", () => {
const detached = new FakeDocument()
const live = new FakeDocument()
const prior = Object.getOwnPropertyDescriptor(globalThis, "document")
Object.defineProperty(globalThis, "document", { value: live, configurable: true })
try {
const ctx = setup({ doc: detached, working: true })
overflow(ctx)
gutter(ctx)
ctx.el.ownerDocument = live
live.fire("mousedown", new FakeMouseEvent(live, 95, 50) as unknown as Event)
ctx.el.scrollTop = 600
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(600)
ctx.dispose()
} finally {
if (prior) Object.defineProperty(globalThis, "document", prior)
if (!prior) Reflect.deleteProperty(globalThis, "document")
}
})
test("tracks document capture presses when the target receives no event", () => {
const ctx = setup({ working: true })
overflow(ctx)
gutter(ctx)
ctx.doc.fire("pointerdown", new FakePointerEvent(1, ctx.doc, 95, 50) as unknown as Event)
ctx.doc.fire("mousedown", new FakeMouseEvent(ctx.doc, 95, 50) as unknown as Event)
ctx.el.scrollTop = 600
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(true)
expect(ctx.el.scrollTop).toBe(600)
ctx.dispose()
})
test("keeps a pointer gesture active beyond the grace period", () => {
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.scroll.handleScroll()
now = 1000
ctx.doc.fire("pointermove", new FakePointerEvent(1, ctx.doc) as unknown as Event)
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 the release grace after a document mouse gesture", () => {
const ctx = setup({ working: true })
overflow(ctx)
gutter(ctx)
let now = 10
const clock = spyOn(performance, "now").mockImplementation(() => now)
try {
ctx.doc.fire("mousedown", new FakeMouseEvent(ctx.doc, 95, 50) as unknown as Event)
ctx.scroll.handleScroll()
now = 100
ctx.doc.fire("mouseup", new FakeMouseEvent(ctx.doc) as unknown as Event)
now = 200
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("does not mark an off-gutter document press", () => {
const ctx = setup({ working: true })
overflow(ctx)
gutter(ctx)
ctx.doc.fire("mousedown", new FakeMouseEvent(ctx.doc, 50, 50) 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("routes a nested document press to the deepest owner", () => {
const doc = new FakeDocument()
const outer = setup({ doc, working: true })
const inner = setup({ doc, working: true })
outer.el.append(inner.el)
overflow(outer)
overflow(inner, 500, 400)
gutter(outer)
inner.el.clientWidth = 65
inner.el.offsetWidth = 80
inner.el.rect = { left: 10, top: 10, right: 90, bottom: 90 }
doc.fire("pointerdown", new FakePointerEvent(1, doc, 80, 50) as unknown as Event)
inner.el.scrollTop = 200
inner.scroll.handleScroll()
expect(inner.scroll.userScrolled()).toBe(true)
expect(outer.scroll.userScrolled()).toBe(false)
outer.dispose()
inner.dispose()
})
test("keeps touch activity alive through a late move", () => {
const ctx = setup({ working: true })
overflow(ctx)
let now = 10
const clock = spyOn(performance, "now").mockImplementation(() => now)
try {
ctx.doc.fire("touchstart", new FakeTouchEvent(ctx.el) as unknown as Event)
ctx.scroll.handleScroll()
now = 1000
ctx.doc.fire("touchmove", new FakeTouchEvent(ctx.doc) as unknown as Event)
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("resume clears an in-progress gesture before correcting the viewport", () => {
const ctx = setup({ working: true })
overflow(ctx)
ctx.el.fire("pointerdown", new FakePointerEvent(1, ctx.el) as unknown as Event)
ctx.scroll.resume()
ctx.el.scrollTop = 600
ctx.scroll.handleScroll()
expect(ctx.scroll.userScrolled()).toBe(false)
expect(ctx.el.scrollTop).toBe(1000)
ctx.dispose()
})
})
@@ -67,6 +67,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
const resume = () => {
userActivity.reset()
if (store.userScrolled) setStore("userScrolled", false)
force()
}
@@ -3,8 +3,12 @@ interface UserActivityOptions {
onWheelUp: () => void
}
type Kind = "pointer" | "mouse" | "touch"
type Gesture = { kind: Kind; id?: number }
const SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "])
const owners = new WeakMap<Document, Set<HTMLElement>>()
const gestures = new WeakMap<Document, HTMLElement>()
const isPotentialScrollInput = (event: Event) => {
if (!(event.target instanceof Element)) return true
@@ -12,21 +16,92 @@ const isPotentialScrollInput = (event: Event) => {
return !event.target.closest("button, input, textarea, select") && !editable?.isContentEditable
}
const deepest = (items: HTMLElement[]) => items.find((el) => !items.some((item) => item !== el && el.contains(item)))
const gutter = (el: HTMLElement, event: Event) => {
const width = el.offsetWidth - el.clientWidth
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(el.offsetWidth) || el.offsetWidth <= 0) return false
const rect = el.getBoundingClientRect()
const x = (event as MouseEvent).clientX
const y = (event as MouseEvent).clientY
const span = rect.right - rect.left
if (!Number.isFinite(x) || !Number.isFinite(y) || span <= 0) return false
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return false
const size = (width * span) / el.offsetWidth
const left = el.dir === "rtl" || el.ownerDocument.defaultView?.getComputedStyle(el).direction === "rtl"
return left ? x <= rect.left + size : x >= rect.right - size
}
const resolve = (event: Event, doc: Document) => {
const items = [...(owners.get(doc) ?? [])]
const target = event.target
if (target !== doc && target instanceof Element && target !== doc.body && target !== doc.documentElement) {
return deepest(items.filter((el) => el.contains(target)))
}
return deepest(items.filter((el) => gutter(el, event)))
}
export const createUserActivity = (options: UserActivityOptions) => {
let marked = false
let time = 0
let scroll: HTMLElement | undefined
let doc: Document | undefined
let gesture: Gesture | undefined
// Mark input that may cause the next scroll so layout-driven scroll events
// do not get mistaken for the user leaving auto-follow mode.
const mark = (event: Event) => {
if (!isPotentialScrollInput(event)) return
if (scroll && scroll.scrollHeight - scroll.clientHeight <= 1) return
const mark = (event?: Event) => {
if (event && !isPotentialScrollInput(event)) return
if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return
marked = true
time = performance.now()
}
const handleWheel = (event: WheelEvent) => {
const start = (event: Event, kind: Kind, local = false) => {
if (!scroll || !doc || !isPotentialScrollInput(event)) return
if (scroll.scrollHeight - scroll.clientHeight <= 1) return
if ("button" in event && event.button !== 0) return
if (!local && resolve(event, doc) !== scroll) return
const owner = gestures.get(doc)
if (owner && owner !== scroll) return
if (gesture) {
if (kind === "touch" && gesture.kind === "pointer") gesture = { kind }
mark()
return
}
gesture = { kind, id: kind === "pointer" ? (event as PointerEvent).pointerId : undefined }
gestures.set(doc, scroll)
mark()
}
const match = (event: Event, kind: Kind) => {
if (!gesture || gesture.kind !== kind) return false
return kind !== "pointer" || gesture.id === (event as PointerEvent).pointerId
}
const move = (event: Event, kind: Kind) => {
if (!doc || !scroll || gestures.get(doc) !== scroll || !match(event, kind)) return
mark()
}
const end = (event: Event, kind: Kind) => {
if (!doc || !scroll || gestures.get(doc) !== scroll || !match(event, kind)) return
mark()
gesture = undefined
gestures.delete(doc)
}
const reset = () => {
if (doc && scroll && gestures.get(doc) === scroll) gestures.delete(doc)
marked = false
time = 0
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
@@ -34,40 +109,68 @@ export const createUserActivity = (options: UserActivityOptions) => {
options.onWheelUp()
}
const handleKey = (event: KeyboardEvent) => {
if (!scroll || event.defaultPrevented || !SCROLL_KEYS.has(event.key)) return
const key = (event: KeyboardEvent) => {
if (!scroll || event.defaultPrevented || !SCROLL_KEYS.has(event.key) || !isPotentialScrollInput(event)) return
const target = event.target
const root = target === scroll.ownerDocument.body || target === scroll.ownerDocument.documentElement
const up = event.key === "ArrowUp" || event.key === "Home" || event.key === "PageUp" || (event.key === " " && event.shiftKey)
const root =
target === scroll.ownerDocument ||
target === scroll.ownerDocument.body ||
target === scroll.ownerDocument.documentElement
const up =
event.key === "ArrowUp" || event.key === "Home" || event.key === "PageUp" || (event.key === " " && event.shiftKey)
const matches = [...(owners.get(scroll.ownerDocument) ?? [])].filter((el) => {
const owns = root ? el.matches(":hover") : target instanceof Node && el.contains(target)
if (!owns) return false
return up ? el.scrollTop > 1 : el.scrollHeight - el.clientHeight - el.scrollTop > 1
})
const owner = matches.find((el) => !matches.some((candidate) => candidate !== el && el.contains(candidate)))
if (owner !== scroll) return
if (deepest(matches) !== scroll) return
mark(event)
}
return {
listen: (el: HTMLElement) => {
scroll = el
const registered = owners.get(el.ownerDocument) ?? new Set<HTMLElement>()
doc = el.isConnected || typeof document === "undefined" ? el.ownerDocument : document
const root = doc
const registered = owners.get(root) ?? new Set<HTMLElement>()
registered.add(el)
owners.set(el.ownerDocument, registered)
el.addEventListener("wheel", handleWheel, { passive: true, capture: true })
el.addEventListener("pointerdown", mark, { passive: true })
el.addEventListener("touchstart", mark, { passive: true })
el.ownerDocument.addEventListener("keydown", handleKey, { passive: true })
owners.set(root, registered)
const down = [
["pointerdown", (event: Event) => start(event, "pointer", true)],
["mousedown", (event: Event) => start(event, "mouse", true)],
["touchstart", (event: Event) => start(event, "touch", true)],
] as const
const handlers = [
["pointerdown", (event: Event) => start(event, "pointer")],
["mousedown", (event: Event) => start(event, "mouse")],
["touchstart", (event: Event) => start(event, "touch")],
["pointermove", (event: Event) => move(event, "pointer")],
["mousemove", (event: Event) => move(event, "mouse")],
["touchmove", (event: Event) => move(event, "touch")],
["pointerup", (event: Event) => end(event, "pointer")],
["mouseup", (event: Event) => end(event, "mouse")],
["touchend", (event: Event) => end(event, "touch")],
["pointercancel", (event: Event) => end(event, "pointer")],
["touchcancel", (event: Event) => end(event, "touch")],
] as const
const opts = { capture: true, passive: true }
for (const [type, handler] of handlers) root.addEventListener(type, handler, opts)
for (const [type, handler] of down) el.addEventListener(type, handler, opts)
el.addEventListener("wheel", wheel, opts)
root.addEventListener("keydown", key, { passive: true })
return () => {
if (scroll === el) scroll = undefined
reset()
registered.delete(el)
if (registered.size === 0) owners.delete(el.ownerDocument)
el.removeEventListener("wheel", handleWheel, { capture: true })
el.removeEventListener("pointerdown", mark)
el.removeEventListener("touchstart", mark)
el.ownerDocument.removeEventListener("keydown", handleKey)
if (registered.size === 0) owners.delete(root)
for (const [type, handler] of handlers) root.removeEventListener(type, handler, opts)
for (const [type, handler] of down) el.removeEventListener(type, handler, opts)
el.removeEventListener("wheel", wheel, opts)
root.removeEventListener("keydown", key)
if (scroll === el) scroll = undefined
if (doc === root) doc = undefined
}
},
consumeScroll: () => {
@@ -75,6 +178,7 @@ export const createUserActivity = (options: UserActivityOptions) => {
marked = false
return value
},
isRecent: () => time > 0 && performance.now() - time < options.grace,
isRecent: () => gesture !== undefined || (time > 0 && performance.now() - time < options.grace),
reset,
}
}
@@ -3,6 +3,13 @@ import { expect, test, type Page } from "@playwright/test"
const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern"
const STORY_ID = "chat--message-list-layout-correction"
test.use({
launchOptions: {
ignoreDefaultArgs: ["--hide-scrollbars"],
args: ["--disable-features=OverlayScrollbar,OverlayScrollbars"],
},
})
async function settle(page: Page, frames = 2) {
await page.evaluate(
(count) =>
@@ -21,6 +28,14 @@ async function distance(page: Page) {
return page.locator(".message-list").evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)
}
async function state(page: Page) {
return page.locator(".message-list").evaluate((el) => ({
top: el.scrollTop,
height: el.scrollHeight,
distance: el.scrollHeight - el.clientHeight - el.scrollTop,
}))
}
test("keeps following after a stable-height layout correction", async ({ page }) => {
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
const list = page.locator(".message-list")
@@ -64,3 +79,116 @@ test("keeps the reading position when the prompt rail scrolls upward", async ({
await expect.poll(() => list.evaluate((el) => el.scrollTop)).toBe(top)
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
})
test("pauses on a native scrollbar drag and resumes at the bottom", async ({ page }) => {
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
const list = page.locator(".message-list")
await expect(list).toBeVisible()
await settle(page, 10)
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
await list.evaluate((el) => {
const count = (key: "pointer" | "mouse" | "scroll") => () => {
el.dataset[key] = String(Number(el.dataset[key] ?? "0") + 1)
}
const block = (event: Event) => {
if (event.target === el) event.stopPropagation()
}
el.dataset.pointer = "0"
el.dataset.mouse = "0"
el.dataset.scroll = "0"
el.addEventListener("pointerdown", count("pointer"), { capture: true })
el.addEventListener("mousedown", count("mouse"), { capture: true })
el.addEventListener("scroll", count("scroll"), { passive: true })
el.ownerDocument.addEventListener("pointerdown", block, { capture: true })
el.ownerDocument.addEventListener("mousedown", block, { capture: true })
})
const coords = await list.evaluate((el) => {
const rect = el.getBoundingClientRect()
const gutter = el.offsetWidth - el.clientWidth
const thumb = Math.max(20, (el.clientHeight * el.clientHeight) / el.scrollHeight)
return {
x: rect.right - gutter / 2,
y: rect.bottom - thumb / 2 - 2,
target: Math.max(rect.top + thumb / 2 + 2, rect.bottom - thumb / 2 - 242),
}
})
await page.mouse.move(coords.x, coords.y)
await page.mouse.down()
await page.mouse.move(coords.x, coords.target, { steps: 12 })
await page.mouse.up()
await expect.poll(() => list.evaluate((el) => Number(el.dataset.scroll ?? "0"))).toBeGreaterThan(0)
await expect.poll(() => distance(page)).toBeGreaterThan(40)
await settle(page, 10)
const before = await state(page)
await settle(page, 4)
const stable = await state(page)
expect(Math.abs(stable.top - before.top)).toBeLessThanOrEqual(1)
expect(Math.abs(stable.height - before.height)).toBeLessThanOrEqual(1)
expect(stable.distance).toBeGreaterThan(40)
expect(await list.getAttribute("data-pointer")).toBe("0")
expect(await list.getAttribute("data-mouse")).toBe("0")
expect(await list.getAttribute("data-scroll")).toMatch(/[1-9]/)
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
await page.getByTestId("append-stream").click()
await expect.poll(() => list.evaluate((el) => el.scrollHeight)).toBeGreaterThan(stable.height)
await settle(page, 10)
const after = await state(page)
expect(after.top).toBeCloseTo(stable.top, 0)
expect(after.distance).toBeGreaterThan(40)
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
await page.getByRole("button", { name: "Scroll to bottom" }).click()
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeHidden()
await page.getByTestId("append-stream").click()
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeHidden()
})
test("keeps a long native scrollbar drag user-controlled", async ({ page }) => {
await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" })
const list = page.locator(".message-list")
await expect(list).toBeVisible()
await settle(page, 10)
await expect.poll(() => distance(page)).toBeLessThanOrEqual(2)
await list.evaluate((el) => {
const count = () => {
el.dataset.scroll = String(Number(el.dataset.scroll ?? "0") + 1)
}
const block = (event: Event) => {
if (event.target === el) event.stopPropagation()
}
el.dataset.scroll = "0"
el.addEventListener("scroll", count, { passive: true })
el.ownerDocument.addEventListener("pointerdown", block, { capture: true })
el.ownerDocument.addEventListener("mousedown", block, { capture: true })
})
const coords = await list.evaluate((el) => {
const rect = el.getBoundingClientRect()
const gutter = el.offsetWidth - el.clientWidth
const thumb = Math.max(20, (el.clientHeight * el.clientHeight) / el.scrollHeight)
return {
x: rect.right - gutter / 2,
y: rect.bottom - thumb / 2 - 2,
target: Math.max(rect.top + thumb / 2 + 2, rect.bottom - thumb / 2 - 242),
}
})
await page.mouse.move(coords.x, coords.y)
await page.mouse.down()
await page.mouse.move(coords.x, coords.target, { steps: 18 })
await page.waitForTimeout(350)
await page.mouse.up()
await expect.poll(() => list.evaluate((el) => Number(el.dataset.scroll ?? "0"))).toBeGreaterThan(0)
await expect.poll(() => distance(page)).toBeGreaterThan(40)
await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible()
})