mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #13500 from Kilo-Org/fix-streaming-scroll-race
fix(vscode): preserve streaming scroll intent
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep chat scroll position during streaming, including small upward gestures, direction changes, and scrolling over message controls.
|
||||
@@ -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<FakeElement>()
|
||||
private listeners = new Map<string, Listener[]>()
|
||||
|
||||
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)
|
||||
|
||||
@@ -22,6 +22,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let scroll: HTMLElement | undefined
|
||||
let top = 0
|
||||
let settling = false
|
||||
let settleTimer: ReturnType<typeof setTimeout> | 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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`}</style>
|
||||
<div class="auto-scroll-correction-controls">
|
||||
@@ -779,6 +786,13 @@ export const MessageListLayoutCorrection: Story = {
|
||||
>
|
||||
Append stream
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="toggle-status"
|
||||
onClick={() => setStatus((value) => (value === "busy" ? "idle" : "busy"))}
|
||||
>
|
||||
Toggle status
|
||||
</button>
|
||||
</div>
|
||||
<ChatView />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user