From 62338ac27db081ac6bb2ca1a4051a13428568dae Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:28:49 +0800 Subject: [PATCH] fix(chat): keep the status slide while fold room takes growth Fold room takes output growth before any scroll, like the empty space below content that fits, so the tail is pinned only when no room remains. Refs #6637 --- .../src/client/chat/use-chat-viewport.ts | 14 ++-- .../tests/chat-viewport.client.spec.ts | 70 +++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) 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) +})