diff --git a/.changeset/preserve-chat-scroll-pause.md b/.changeset/preserve-chat-scroll-pause.md new file mode 100644 index 00000000000..73106bacf71 --- /dev/null +++ b/.changeset/preserve-chat-scroll-pause.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-ui": patch +--- + +Preserve paused chat scroll positions while transcript layouts temporarily stop overflowing. diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx new file mode 100644 index 00000000000..a6cc29d0b26 --- /dev/null +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx @@ -0,0 +1,186 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" +import { createRoot } from "solid-js" + +const observers: Array<() => void> = [] + +mock.module("@solid-primitives/resize-observer", () => ({ + createResizeObserver: (_source: () => HTMLElement | undefined, callback: () => void) => { + observers.push(callback) + }, +})) + +const originalElement = globalThis.Element +const originalWheelEvent = globalThis.WheelEvent + +type Listener = { + callback: (event: Event) => void + capture: boolean +} + +class FakeElement { + scrollHeight = 100 + clientHeight = 100 + scrollTop = 0 + style = { overflowAnchor: "" } + private listeners = new Map() + + closest() { + return null + } + + scrollTo(options: ScrollToOptions) { + this.scrollTop = options.top ?? this.scrollTop + } + + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ) { + const callback = typeof listener === "function" ? listener : (event: Event) => listener.handleEvent(event) + const capture = typeof options === "boolean" ? options : (options?.capture ?? false) + const listeners = this.listeners.get(type) ?? [] + listeners.push({ callback, capture }) + this.listeners.set(type, listeners) + } + + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ) { + const capture = typeof options === "boolean" ? options : (options?.capture ?? false) + const listeners = this.listeners.get(type) ?? [] + this.listeners.set( + type, + listeners.filter((item) => item.callback !== listener || item.capture !== capture), + ) + } + + fire(type: string, event: Event) { + const listeners = this.listeners.get(type) ?? [] + for (const item of listeners.toSorted((a, b) => Number(b.capture) - Number(a.capture))) { + item.callback(event) + } + } +} + +class FakeWheelEvent { + constructor( + readonly deltaY: number, + readonly target: FakeElement, + ) {} +} + +globalThis.Element = FakeElement as unknown as typeof Element +globalThis.WheelEvent = FakeWheelEvent as unknown as typeof WheelEvent + +const { createAutoScroll } = await import("./create-auto-scroll") + +function setup(options?: { interacted?: () => void }) { + const el = new FakeElement() + const root = createRoot((dispose) => ({ + dispose, + scroll: createAutoScroll({ + working: () => false, + onUserInteracted: options?.interacted, + }), + })) + root.scroll.scrollRef(el as unknown as HTMLElement) + root.scroll.contentRef(new FakeElement() as unknown as HTMLElement) + + const resize = (index?: number) => { + if (index !== undefined) { + observers[index]?.() + return + } + observers.forEach((callback) => callback()) + } + + return { ...root, el, resize } +} + +beforeEach(() => { + observers.length = 0 +}) + +afterAll(() => { + if (originalElement) globalThis.Element = originalElement + else Reflect.deleteProperty(globalThis, "Element") + if (originalWheelEvent) globalThis.WheelEvent = originalWheelEvent + else Reflect.deleteProperty(globalThis, "WheelEvent") +}) + +describe("createAutoScroll non-scrollable layouts", () => { + test("preserves an established pause through temporary non-overflow", () => { + const ctx = setup() + ctx.el.scrollHeight = 300 + ctx.el.scrollTop = 80 + ctx.scroll.pause() + + ctx.el.scrollHeight = 100 + ctx.el.scrollTop = 0 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.resize(0) + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.resize(1) + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollHeight = 300 + ctx.el.scrollTop = 80 + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(80) + ctx.dispose() + }) + + test("allows session restoration to pause before content overflows", () => { + const ctx = setup() + ctx.scroll.pause() + + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollHeight = 300 + ctx.el.scrollTop = 60 + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(60) + + ctx.scroll.resume() + + expect(ctx.scroll.userScrolled()).toBe(false) + expect(ctx.el.scrollTop).toBe(300) + ctx.dispose() + }) + + test("does not pause for an upward wheel on short content", () => { + let interactions = 0 + const ctx = setup({ interacted: () => interactions++ }) + const event = new FakeWheelEvent(-20, ctx.el) + + ctx.el.fire("wheel", event as unknown as Event) + + expect(ctx.scroll.userScrolled()).toBe(false) + expect(interactions).toBe(0) + ctx.dispose() + }) + + test("follows when initially short content starts overflowing", () => { + const ctx = setup() + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(false) + + ctx.el.scrollHeight = 300 + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(false) + expect(ctx.el.scrollTop).toBe(300) + 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 6fa643dd1ac..1edbcc1a604 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -81,13 +81,10 @@ export function createAutoScroll(options: AutoScrollOptions) { scrollToBottomNow("auto") } - const stop = () => { + const stop = (force = false) => { const el = scroll if (!el) return - if (!canScroll(el)) { - if (store.userScrolled) setStore("userScrolled", false) - return - } + if (!force && !canScroll(el)) return if (store.userScrolled) return setStore("userScrolled", true) @@ -114,10 +111,7 @@ export function createAutoScroll(options: AutoScrollOptions) { userInitiated = false const distance = distanceFromBottom(el) - if (!canScroll(el)) { - if (store.userScrolled) setStore("userScrolled", false) - return - } + if (!canScroll(el)) return if (distance < threshold()) { if (store.userScrolled) setStore("userScrolled", false) @@ -156,10 +150,7 @@ export function createAutoScroll(options: AutoScrollOptions) { () => store.contentRef, () => { const el = scroll - if (el && !canScroll(el)) { - if (store.userScrolled) setStore("userScrolled", false) - return - } + if (el && !canScroll(el)) return if (!active()) { if (!store.userScrolled && el && distanceFromBottom(el) > threshold()) { scrollToBottomNow("auto") @@ -191,10 +182,7 @@ export function createAutoScroll(options: AutoScrollOptions) { () => { const el = scroll if (!el) return - if (!canScroll(el)) { - if (store.userScrolled) setStore("userScrolled", false) - return - } + if (!canScroll(el)) return if (store.userScrolled || recentlyInteracted()) return scrollToBottomNow("auto") }, @@ -254,7 +242,7 @@ export function createAutoScroll(options: AutoScrollOptions) { contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el), handleScroll, handleInteraction, - pause: stop, + pause: () => stop(true), resume: () => { if (store.userScrolled) setStore("userScrolled", false) scrollToBottom(true)