diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index f82af1468e..dd0e9f5c08 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -781,11 +781,13 @@ export class ChatViewport { /** * Mark the column while the tail is pinned: the reader follows the floor with no unsampled reader - * movement, the test fold batches use for a tail landing, and the content overflows, so following scrolls - * absorb output growth and the status keeps its on-screen position. The first-output slide runs at zero - * duration there. A resize calls this after its own follow scroll, before paint, so a slide that starts in - * that frame finishes before it is drawn; scroll and scrollend call it after delivering the sample, and - * follow-intent commits call it with the latest measurement. The attribute changes only with the pin. + * movement, the test fold batches use for a tail landing, and the content overflows with no fold room + * below it, so following scrolls absorb output growth and the status keeps its on-screen position. Fold + * room, like the empty space below content that fits, takes growth before any scroll, so the status moves + * there. The first-output slide runs at zero duration while pinned. A resize calls this after its own + * follow scroll, before paint, so a slide that starts in that frame finishes before it is drawn; scroll and + * scrollend call it after delivering the sample, and follow-intent commits call it with the latest + * measurement. The attribute changes only with the pin. * @param overflowing - a fresh overflow measurement; omitted, the latest one is reused. */ refreshTailPin(overflowing = this.overflowing): void { @@ -793,7 +795,7 @@ export class ChatViewport { const elements = this.elements if (elements === null) return const reader = this.reader - const pinned = overflowing && reader !== null && reader.follow.active && !reader.pending() + const pinned = overflowing && this.spacerHeight() === 0 && reader !== null && reader.follow.active && !reader.pending() if (pinned === this.tailPinned) return this.tailPinned = pinned if (pinned) elements.column.dataset.chatTailPinned = '' diff --git a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts index f589609e64..ab7e5cfb96 100644 --- a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts @@ -655,3 +655,73 @@ it('pins the tail only while sampled follow intent holds over overflowing conten viewport.detach() expect(pinned()).toBe(false) }) + +it('leaves the tail unpinned while fold room takes growth, then pins once the room is used', () => { + let notifyResize = () => {} + class Observer implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { notifyResize = () => { callback([], this) } } + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + vi.stubGlobal('ResizeObserver', Observer) + const scroller = document.createElement('div') + const column = document.createElement('div') + const spacer = document.createElement('div') + spacer.dataset.chatTurnSpacer = '' + scroller.append(column, spacer) + document.body.append(scroller) + let content = 900 + let top = 0 + const room = () => Number.parseFloat(spacer.style.height) || 0 + const floor = () => Math.max(0, content + room() - 300) + Object.defineProperties(scroller, { + clientHeight: { configurable: true, get: () => 300 }, + scrollHeight: { configurable: true, get: () => Math.max(300, content + room()) }, + scrollTop: { + configurable: true, + get: () => Math.min(top, floor()), + set: (value: number) => { top = Math.max(0, Math.min(value, floor())) }, + }, + }) + const viewport = new ChatViewport() + viewport.attach(scroller, column) + const follow = new ScrollFollow(true, 4) + const reading = new ChatReading(viewport, { read: () => null, save: vi.fn() }, { + initialized: true, followingTail: true, activeTurn: 1, + }, vi.fn(), follow) + const unbind = reading.bindFolds() + viewport.connect({ + scroll: reading.onScroll, scrollEnd: reading.onScrollEnd, interact: () => {}, + resize: () => { reading.onResize() }, + }) + onTestFinished(() => { + unbind() + reading.dispose() + viewport.detach() + scroller.remove() + vi.unstubAllGlobals() + }) + const pinned = () => column.hasAttribute('data-chat-tail-pinned') + + notifyResize() + expect(scroller.scrollTop).toBe(600) + expect(pinned()).toBe(true) + // A fold landing leaves room below the content and commits the tail landing. + spacer.style.height = '200px' + scroller.scrollTop = 800 + reading.followTail() + expect(pinned()).toBe(false) + // Growth fills the room without scrolling, so the status moves on screen and keeps its slide. + content = 940 + notifyResize() + expect(room()).toBe(160) + expect(scroller.scrollTop).toBe(800) + expect(pinned()).toBe(false) + // Once growth has used the room, following scrolls absorb it again. + content = 1_100 + notifyResize() + expect(room()).toBe(0) + expect(scroller.scrollTop).toBe(800) + expect(pinned()).toBe(true) +})