diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index 44d7e64173..9596088c71 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -252,8 +252,9 @@ test('the first click of a session lands on its prompt and holds', async ({ expect((await landing())?.offset).toBeLessThan(24); expect((await landing())?.tickIsCurrent).toBe(true); - // And stays: the fill runs on idle callbacks after the jump, so a jump that - // only wins the first frame reads as landing and then sliding away. + // And stays: turns keep resolving their content and remeasuring after the + // jump, so a jump that only wins the first frame reads as landing and then + // sliding away. await page.waitForTimeout(1_200); const settled = await landing(); expect(settled?.offset).toBeGreaterThan(-24); @@ -265,7 +266,7 @@ test('long transcripts keep a bounded mounted turn window', async ({ promptRailWindow: page, }) => { const count = async () => page.locator('[data-virtual-turn-id]').count(); - await page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]').waitFor(); + await page.locator('[data-virtual-turn-id]').first().waitFor(); await loadPromptRailBeyondVirtualWindow(page); expect(await page.evaluate(() => { const transcript = document.querySelector('.maka-chat-message-list'); @@ -288,8 +289,8 @@ test('long transcripts keep a bounded mounted turn window', async ({ test('evicting a turn-owned sibling interaction hands focus back to the transcript', async ({ promptRailWindow: page, }) => { - const scroller = page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]'); - await scroller.waitFor(); + const scroller = page.locator('[data-chat-scroll-container="true"]'); + await page.locator('[data-virtual-turn-id]').first().waitFor(); await loadPromptRailBeyondVirtualWindow(page); await scrollTranscriptTo(page, 'bottom'); await expect(page.locator('[data-virtual-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 17d2e8f7a9..b126a04081 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -42,6 +42,16 @@ width: 100%; } +/* Inserting earlier turns above the reader must not move what they are + reading. The browser's scroll anchoring does exactly that, so state the + dependency on the scroller that runs it rather than inheriting the `auto` + default: Maka reads no geometry and restores no position of its own. The + one case anchoring declines is a scroller sitting at zero, compensated in + useChatScroll after the turns land. */ +[data-chat-scroll-container='true'] { + overflow-anchor: auto; +} + .maka-turn-virtual-item { display: flex; width: 100%; diff --git a/packages/ui/src/__tests__/arrival-bottom-pin.test.ts b/packages/ui/src/__tests__/arrival-bottom-pin.test.ts deleted file mode 100644 index 84d3b857ba..0000000000 --- a/packages/ui/src/__tests__/arrival-bottom-pin.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { - createArrivalBottomPin, - releasesArrivalPin, - type ArrivalPinSizeObserver, -} from '../arrival-bottom-pin.js'; - -/** - * Clamps `scrollTop` the way a real scroller does, so the assertions below can - * be written as the contract — flush against the bottom — rather than as the - * literal value the pin happens to assign. Writing `scrollHeight` is how the - * pin asks for "as far down as this goes", and a real scroller answers by - * clamping, so overshoot is unobservable here exactly as it is in the product. - * What the clamp buys is the other direction: any arithmetic that stops the - * scroller short shows up as a distance, whatever produced it. - */ -function fakeViewport(initial: { scrollTop: number; scrollHeight: number; clientHeight: number }) { - const listeners = new Map void>>(); - let scrollTop = initial.scrollTop; - return { - scrollHeight: initial.scrollHeight, - clientHeight: initial.clientHeight, - get scrollTop() { - return scrollTop; - }, - set scrollTop(value: number) { - scrollTop = Math.max(0, Math.min(value, this.scrollHeight - this.clientHeight)); - }, - get distanceFromBottom() { - return this.scrollHeight - scrollTop - this.clientHeight; - }, - addEventListener(type: string, listener: (event: Event) => void) { - const set = listeners.get(type) ?? new Set(); - set.add(listener); - listeners.set(type, set); - }, - removeEventListener(type: string, listener: (event: Event) => void) { - listeners.get(type)?.delete(listener); - }, - emit(type: string, event: Partial = {}) { - for (const listener of listeners.get(type) ?? []) listener(event as Event); - }, - listenerCount(type: string) { - return listeners.get(type)?.size ?? 0; - }, - }; -} - -/** - * A transcript element that answers `contains` for the nodes inside it, so the - * gesture handlers can be exercised the way the DOM presents them: the dock's - * wheels and touches bubble through the same scroller as the transcript's. - */ -function fakeTranscript() { - const inside = { name: 'turn' } as unknown as Node; - const dock = { name: 'composer' } as unknown as Node; - const element = { - contains: (node: Node | null) => node === inside, - } as unknown as Element; - return { element, inside, dock }; -} - -function fakeSizeObserver() { - let notify: (() => void) | undefined; - let disconnected = false; - return { - factory: (callback: () => void): ArrivalPinSizeObserver => { - notify = callback; - return { - observe: () => {}, - disconnect: () => { disconnected = true; }, - }; - }, - grow: () => notify?.(), - get disconnected() { return disconnected; }, - }; -} - -describe('releasesArrivalPin', () => { - it('reads an upward scroll with unchanged geometry as the reader taking over', () => { - assert.equal( - releasesArrivalPin({ - scrollTop: 900, - lastScrollTop: 1_400, - scrollHeight: 2_000, - lastScrollHeight: 2_000, - clientHeight: 600, - lastClientHeight: 600, - }), - true, - ); - }); - - it('ignores the synthetic scroll Chromium fires when the document grows', () => { - // The arrival window is nothing but growth: every mounted chunk and every - // warmed placeholder fires a scroll event whose scrollTop can read lower - // than the pin's last write. Only geometry that held still is evidence. - assert.equal( - releasesArrivalPin({ - scrollTop: 900, - lastScrollTop: 1_400, - scrollHeight: 4_000, - lastScrollHeight: 2_000, - clientHeight: 600, - lastClientHeight: 600, - }), - false, - ); - assert.equal( - releasesArrivalPin({ - scrollTop: 900, - lastScrollTop: 1_400, - scrollHeight: 2_000, - lastScrollHeight: 2_000, - clientHeight: 500, - lastClientHeight: 600, - }), - false, - ); - }); - - it('holds the pin through a sub-pixel readback of its own write', () => { - assert.equal( - releasesArrivalPin({ - scrollTop: 1_399.5, - lastScrollTop: 1_400, - scrollHeight: 2_000, - lastScrollHeight: 2_000, - clientHeight: 600, - lastClientHeight: 600, - }), - false, - ); - }); -}); - -describe('createArrivalBottomPin', () => { - it('stops following once the reader scrolls up, and stays released', () => { - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 4_000, clientHeight: 600 }); - const observer = fakeSizeObserver(); - const states: string[] = []; - const pin = createArrivalBottomPin({ - viewport, - content: {} as Element, - onStateChange: (state) => { states.push(state); }, - createSizeObserver: observer.factory, - }); - - viewport.scrollTop = 1_000; - viewport.emit('scroll'); - assert.equal(pin.isPinned(), false); - viewport.scrollHeight = 9_000; - observer.grow(); - assert.equal(viewport.scrollTop, 1_000); - // A later growth step must not re-pin: releasing is permanent for this - // arrival, the way Astryx's own unlock is. - viewport.scrollHeight = 15_000; - observer.grow(); - assert.equal(viewport.scrollTop, 1_000); - assert.deepEqual(states, ['pinned', 'released']); - }); - - it('follows a growth, rides its synthetic scroll, and still yields to the reader after it', () => { - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 800, clientHeight: 600 }); - const observer = fakeSizeObserver(); - const pin = createArrivalBottomPin({ - viewport, - content: {} as Element, - createSizeObserver: observer.factory, - }); - - viewport.scrollHeight = 15_000; - observer.grow(); - assert.equal(viewport.distanceFromBottom, 0); - // Chromium fires a scroll event for the resize itself. It reports a - // position the reader never chose, and the pin must not read it as intent. - viewport.emit('scroll'); - assert.equal(pin.isPinned(), true); - - viewport.scrollTop = 9_000; - viewport.emit('scroll'); - assert.equal(pin.isPinned(), false); - }); - - it('takes its geometry snapshot from growth it did not write itself', () => { - // Not every growth reaches the observed content element: the dock (graph - // status, plan panel) lives inside the scroller but outside the message - // list, so it moves scrollHeight with a scroll event and nothing else. The - // snapshot has to follow that too, or the NEXT genuine upward scroll is - // compared against a stale height, reads as "geometry changed", and the - // reader silently loses control of the transcript. - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 4_000, clientHeight: 600 }); - const pin = createArrivalBottomPin({ viewport, content: null }); - - assert.equal(viewport.distanceFromBottom, 0); - viewport.scrollHeight = 15_000; - viewport.emit('scroll'); - assert.equal(pin.isPinned(), true); - - viewport.scrollTop = 700; - viewport.emit('scroll'); - assert.equal(pin.isPinned(), false); - }); - - it('releases on an upward wheel and on a touch drag over the transcript', () => { - const transcript = fakeTranscript(); - for (const [type, event] of [ - ['wheel', { deltaY: -120, target: transcript.inside }], - ['touchmove', { target: transcript.inside }], - ] as const) { - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 4_000, clientHeight: 600 }); - const observer = fakeSizeObserver(); - const pin = createArrivalBottomPin({ - viewport, - content: transcript.element, - createSizeObserver: observer.factory, - }); - viewport.emit(type, event as unknown as Partial); - assert.equal(pin.isPinned(), false, type); - } - }); - - it('does not read a gesture over the dock as the reader leaving the turn', () => { - // The composer, plan panel and graph status live inside this scroller, so - // their wheels and touches arrive here too — and a wheel over the composer - // that really does scroll the transcript still releases the pin, through - // the scroll event it causes rather than through where the pointer was. - const transcript = fakeTranscript(); - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 4_000, clientHeight: 600 }); - const pin = createArrivalBottomPin({ viewport, content: transcript.element }); - - viewport.emit('wheel', { deltaY: -120, target: transcript.dock } as unknown as Partial); - viewport.emit('touchmove', { target: transcript.dock } as unknown as Partial); - assert.equal(pin.isPinned(), true); - assert.equal(viewport.distanceFromBottom, 0); - - viewport.scrollTop = 1_000; - viewport.emit('scroll'); - assert.equal(pin.isPinned(), false); - }); - - it('keeps following through a wheel that is not the reader going up', () => { - // Down is where the pin is already heading. Zero is a horizontal wheel or - // a trackpad's rounding — no vertical intent at all, and reading it as one - // would drop the pin on a sideways swipe across a wide code block. - for (const deltaY of [120, 0]) { - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 800, clientHeight: 600 }); - const observer = fakeSizeObserver(); - const pin = createArrivalBottomPin({ - viewport, - content: {} as Element, - createSizeObserver: observer.factory, - }); - viewport.emit('wheel', { deltaY } as Partial); - assert.equal(pin.isPinned(), true, `deltaY=${deltaY}`); - } - }); - - it('detaches every observer and listener on dispose', () => { - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 800, clientHeight: 600 }); - const observer = fakeSizeObserver(); - const pin = createArrivalBottomPin({ - viewport, - content: {} as Element, - createSizeObserver: observer.factory, - }); - pin.dispose(); - assert.equal(observer.disconnected, true); - assert.equal(viewport.listenerCount('scroll'), 0); - assert.equal(viewport.listenerCount('wheel'), 0); - assert.equal(viewport.listenerCount('touchmove'), 0); - }); -}); diff --git a/packages/ui/src/__tests__/chat-conversation-swap-scroll.test.tsx b/packages/ui/src/__tests__/chat-conversation-swap-scroll.test.tsx new file mode 100644 index 0000000000..c7bb6fc5eb --- /dev/null +++ b/packages/ui/src/__tests__/chat-conversation-swap-scroll.test.tsx @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Covers the conversation-swap path added to the vendored ChatLayout patch. + * + * Switching sessions clears the transcript in the same update that changes the + * key, so the scroller the swap sees is empty and cannot be jumped anywhere. + * The content lands a few frames later. Astryx positions a first fill in one + * frame and springs everything after it, so the swap has to leave that first + * fill armed — otherwise the arriving transcript flies down from the top. + * + * Driven through useChatStreamScroll rather than ChatLayout because the + * assertion is about which of the hook's two paths the arrival takes, and the + * hook is where the patch put resetInitialFill. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { useChatStreamScroll } from '@astryxdesign/core/Chat'; + +const originalGlobals = { + document: globalThis.document, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + Node: globalThis.Node, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + matchMedia: globalThis.matchMedia, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +let mountedRoot: ReturnType | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +test('a conversation swap against an empty scroller still lands the arriving transcript at the bottom', async () => { + const { document, window } = parseHTML('
'); + const scroller = document.querySelector('#root'); + assert.ok(scroller); + + let scrollTop = 0; + // The loading placeholder is shorter than the viewport, so the swap has + // nothing to scroll; the transcript that replaces it is taller than it. + let scrollHeight = 400; + Object.defineProperties(scroller, { + clientHeight: { value: 1_000 }, + scrollHeight: { get: () => scrollHeight }, + scrollTop: { + get: () => scrollTop, + set: (value: number) => { + scrollTop = Math.max(0, Math.min(value, scrollHeight - 1_000)); + }, + }, + }); + + let frames = 0; + Object.assign(globalThis, { + document, + window, + Element: window.Element, + HTMLElement: window.HTMLElement, + Node: window.Node, + // Counted, never run: a spring would schedule here, and the point of the + // first fill is that the arrival does not need a frame at all. + requestAnimationFrame: () => { + frames += 1; + return frames; + }, + cancelAnimationFrame: () => {}, + matchMedia: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + }), + IS_REACT_ACT_ENVIRONMENT: true, + }); + + let controller: ReturnType | undefined; + function Harness() { + const scrollRef = useRef(scroller); + controller = useChatStreamScroll({ scrollRef }); + return null; + } + + const host = document.createElement('div'); + await act(async () => { + mountedRoot = createRoot(host); + mountedRoot.render(); + }); + assert.ok(controller); + + // The swap: ChatLayout's conversationKey effect, against a scroller holding + // only the loading placeholder. + await act(async () => { + controller?.scrollToBottom({ behavior: 'instant' }); + controller?.resetInitialFill(); + }); + assert.equal(scroller.scrollTop, 0, 'nothing to scroll while the transcript is still loading'); + + // The transcript arrives; ChatLayout's resize observer reports it. + const framesBeforeArrival = frames; + scrollHeight = 5_000; + await act(async () => { + controller?.scrollIfLocked(); + }); + + assert.equal(scroller.scrollTop, 4_000, 'the arriving transcript is at the bottom'); + assert.equal( + frames, + framesBeforeArrival, + 'it got there in the same frame, without entering the spring', + ); +}); diff --git a/packages/ui/src/__tests__/chat-scroll-anchor.test.ts b/packages/ui/src/__tests__/chat-scroll-anchor.test.ts deleted file mode 100644 index c2af386cf9..0000000000 --- a/packages/ui/src/__tests__/chat-scroll-anchor.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { parseHTML } from 'linkedom'; -import { captureChatScrollAnchor, restoreChatScrollAnchor } from '../chat-scroll-anchor.js'; - -test('reuses the visible article while virtual history changes above it', () => { - const { document } = parseHTML('
'); - const root = document.querySelector('#root')!; - Object.defineProperty(root, 'scrollTop', { value: 0, writable: true }); - root.getBoundingClientRect = () => ({ top: 100 }) as DOMRect; - let rectReads = 0; - for (let index = 0; index < 200; index += 1) { - const turn = document.createElement('section'); - turn.dataset.turnId = `turn-${index}`; - const article = document.createElement('article'); - article.dataset.sender = 'assistant'; - article.getBoundingClientRect = () => { - rectReads += 1; - return { top: index < 180 ? 0 : 120, bottom: index < 180 ? 80 : 160 } as DOMRect; - }; - turn.append(article); - root.append(turn); - } - - const first = captureChatScrollAnchor(root); - assert.equal(first?.turnId, 'turn-180'); - rectReads = 0; - const second = captureChatScrollAnchor(root); - assert.equal(second?.turnId, 'turn-180'); - assert.ok(rectReads <= 4); - assert.equal(restoreChatScrollAnchor(root, second), true); -}); - -test('skips message descendants while advancing the cached anchor', () => { - const { document } = parseHTML('
'); - const root = document.querySelector('#root')!; - Object.defineProperty(root, 'scrollTop', { value: 0, writable: true }); - root.getBoundingClientRect = () => ({ top: 100 }) as DOMRect; - - const first = document.createElement('article'); - first.dataset.sender = 'assistant'; - first.getBoundingClientRect = () => ({ top: 120, bottom: 160 }) as DOMRect; - let parent = first; - let descendantReads = 0; - for (let index = 0; index < 1_000; index += 1) { - const child = document.createElement('div'); - parent.append(child); - const current = parent; - const nested = child; - Object.defineProperty(current, 'firstElementChild', { - configurable: true, - get() { - descendantReads += 1; - return nested; - }, - }); - parent = child; - } - const second = document.createElement('article'); - second.dataset.sender = 'assistant'; - second.getBoundingClientRect = () => ({ top: 120, bottom: 160 }) as DOMRect; - const firstTurn = document.createElement('section'); - firstTurn.dataset.turnId = 'turn-1'; - firstTurn.append(first); - const secondTurn = document.createElement('section'); - secondTurn.dataset.turnId = 'turn-2'; - secondTurn.append(second); - root.append(firstTurn, secondTurn); - - assert.equal(captureChatScrollAnchor(root)?.turnId, 'turn-1'); - first.getBoundingClientRect = () => ({ top: 0, bottom: 80 }) as DOMRect; - assert.equal(captureChatScrollAnchor(root)?.turnId, 'turn-2'); - assert.equal(descendantReads, 0); -}); diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx new file mode 100644 index 0000000000..17d4cfa9cc --- /dev/null +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Astryx owns following the tail and decides on its own when a gesture means + * the reader has left it. What stays Maka's is the pair of moves Astryx cannot + * see: a jump to a turn the reader picked, and the earlier history Maka loads + * above the current position. Both have to release auto-follow, or the reader + * is dragged back to the bottom by the next thing that arrives. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { useChatScroll } from '../use-chat-scroll.js'; + +const originalGlobals = { + CSS: globalThis.CSS, + document: globalThis.document, + Element: globalThis.Element, + HTMLElement: globalThis.HTMLElement, + Node: globalThis.Node, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +let mountedRoot: ReturnType | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function mountTranscript(options: { + scrollTop: number; + scrollHeight?: number; + hasOlderHistory?: boolean; + target?: { turnId: string; nonce: number }; + onLoadEarlierHistory?(): void; + unlockAutoFollow(): void; +}) { + const { document, window } = parseHTML( + '
', + ); + const scroller = document.querySelector('#root'); + assert.ok(scroller); + let scrollTop = options.scrollTop; + let scrollHeight = options.scrollHeight ?? 9_000; + Object.defineProperties(scroller, { + clientHeight: { value: 600 }, + scrollHeight: { get: () => scrollHeight }, + scrollTop: { + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + }, + }, + }); + Object.assign(globalThis, { + CSS: { escape: (value: string) => value }, + document, + window, + Element: window.Element, + HTMLElement: window.HTMLElement, + Node: window.Node, + requestAnimationFrame: (callback: FrameRequestCallback) => + setTimeout(() => callback(0), 0) as unknown as number, + cancelAnimationFrame: (handle: number) => clearTimeout(handle), + IS_REACT_ACT_ENVIRONMENT: true, + }); + + function Harness() { + const scrollRef = useRef(scroller); + useChatScroll({ + scrollRef, + sessionId: 'session-1', + messages: [], + behavior: 'auto', + target: options.target, + hasOlderHistory: options.hasOlderHistory, + onLoadEarlierHistory: options.onLoadEarlierHistory, + unlockAutoFollow: options.unlockAutoFollow, + }); + return null; + } + + return { + scroller, + document, + Harness, + grow(by: number) { + scrollHeight += by; + }, + }; +} + +test('jumping to a turn the reader picked releases auto-follow', async () => { + let unlocked = 0; + const { document, Harness } = mountTranscript({ + scrollTop: 8_400, + target: { turnId: 'turn-1', nonce: 1 }, + unlockAutoFollow: () => { + unlocked += 1; + }, + }); + + const host = document.createElement('div'); + await act(async () => { + mountedRoot = createRoot(host); + mountedRoot.render(); + }); + + assert.equal(unlocked, 1); +}); + +test('loading earlier history releases auto-follow and keeps the anchor off the top', async () => { + let unlocked = 0; + let loaded = 0; + const { scroller, document, Harness, grow } = mountTranscript({ + scrollTop: 0, + hasOlderHistory: true, + onLoadEarlierHistory: () => { + loaded += 1; + }, + unlockAutoFollow: () => { + unlocked += 1; + }, + }); + + const host = document.createElement('div'); + await act(async () => { + mountedRoot = createRoot(host); + mountedRoot.render(); + }); + + await act(async () => { + scroller.dispatchEvent( + Object.assign(new window.Event('wheel'), { deltaY: -120 }) as unknown as Event, + ); + }); + + assert.equal(loaded, 1); + assert.equal(unlocked, 1); + // Scroll anchoring does nothing while the scroller sits at the very top, so + // the turns that land above the reader are compensated here instead — after + // they are on screen, which is the only moment their height is known. + grow(3_000); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.equal(scroller.scrollTop, 3_000); +}); + +test('a chosen turn releases auto-follow once, not on every transcript update', async () => { + let unlocked = 0; + const target = { turnId: 'turn-1', nonce: 1 }; + const { document, Harness } = mountTranscript({ + scrollTop: 8_400, + target, + unlockAutoFollow: () => { + unlocked += 1; + }, + }); + + const host = document.createElement('div'); + await act(async () => { + mountedRoot = createRoot(host); + mountedRoot.render(); + }); + + // The effect re-runs on every transcript update so a target that arrives + // before its turn still lands. Releasing is persistent, though: repeating it + // would drop the reader off the tail for the rest of the session. + for (let update = 0; update < 3; update += 1) { + await act(async () => { + mountedRoot?.render(); + }); + } + + assert.equal(unlocked, 1); +}); diff --git a/packages/ui/src/arrival-bottom-pin.ts b/packages/ui/src/arrival-bottom-pin.ts deleted file mode 100644 index a3a79da9d5..0000000000 --- a/packages/ui/src/arrival-bottom-pin.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * Instant bottom pin for a transcript that is still arriving. - * - * Astryx's `useChatStreamScroll` positions the FIRST fill of its scroller - * instantly and springs every later growth. That one-shot lives on the hook - * instance, and `ChatSurfaceLayout` mounts once for the whole app shell, so it - * is spent on the session that happened to be open at boot. Every switch after - * that is "later growth". A switched-to transcript still arrives across the - * virtual tail's first render and measured-height corrections, so a one-shot - * scroll would let the spring chase a moving bottom. - * - * A session change is navigation, not content growth: the transcript is meant - * to be at its latest turn the first time it is painted, exactly as it is on a - * cold start. This pin owns that arrival window only. It writes `scrollTop` - * from a ResizeObserver — after layout, before paint — so the growth the spring - * would have animated is already consumed by the time a frame is painted, and - * the spring settles against a zero delta instead of running. Steady-state - * following (streaming tokens, appended turns) stays Astryx's, which is why the - * caller releases this at the end of the arrival window rather than keeping it. - * - * Any sign the reader took control releases the pin for good, using the same - * signals Astryx unlocks on: an upward wheel or a touch drag over the - * transcript, or a scroll that moved up on its own — one where the geometry did - * NOT change in the same event, since Chromium fires a synthetic scroll for - * every content resize and the arrival window is nothing but resizes. - */ - -export interface ArrivalPinViewport { - scrollTop: number; - readonly scrollHeight: number; - readonly clientHeight: number; - addEventListener(type: string, listener: (event: Event) => void, options?: { passive?: boolean }): void; - removeEventListener(type: string, listener: (event: Event) => void): void; -} - -export interface ArrivalPinSizeObserver { - observe(element: Element): void; - disconnect(): void; -} - -export type ArrivalPinSizeObserverFactory = (callback: () => void) => ArrivalPinSizeObserver; - -export interface ArrivalPinGeometry { - readonly scrollTop: number; - readonly lastScrollTop: number; - readonly scrollHeight: number; - readonly lastScrollHeight: number; - readonly clientHeight: number; - readonly lastClientHeight: number; -} - -/** - * Whether a scroll event is the reader moving up rather than the document - * growing under them. - * - * The 1px tolerance is for Chromium's fractional `scrollTop`: pinning writes - * `scrollHeight`, which clamps to a maximum that can carry a sub-pixel - * fraction, and reading it back a frame later can land just under the value the - * pin recorded. - */ -export function releasesArrivalPin(geometry: ArrivalPinGeometry): boolean { - if ( - geometry.scrollHeight !== geometry.lastScrollHeight || - geometry.clientHeight !== geometry.lastClientHeight - ) { - return false; - } - return geometry.scrollTop < geometry.lastScrollTop - 1; -} - -export interface ArrivalBottomPin { - /** Stop following; the viewport is left wherever it currently sits. */ - release(): void; - /** Release and detach every observer and listener. */ - dispose(): void; - /** False once the reader took control or the caller released the pin. */ - isPinned(): boolean; -} - -export function createArrivalBottomPin(options: { - viewport: ArrivalPinViewport; - /** - * The element whose height the transcript grows with. The scroller's own box - * never changes size while its content does, so observing the viewport would - * report nothing. - */ - content: Element | null; - /** Published by the caller as a DOM marker; see use-chat-scroll. */ - onStateChange?: (state: 'pinned' | 'released') => void; - createSizeObserver?: ArrivalPinSizeObserverFactory; -}): ArrivalBottomPin { - const viewport = options.viewport; - const content = options.content; - let pinned = true; - let lastScrollTop = viewport.scrollTop; - let lastScrollHeight = viewport.scrollHeight; - let lastClientHeight = viewport.clientHeight; - - const pin = (): void => { - if (!pinned) return; - viewport.scrollTop = viewport.scrollHeight; - lastScrollTop = viewport.scrollTop; - lastScrollHeight = viewport.scrollHeight; - lastClientHeight = viewport.clientHeight; - }; - - const release = (): void => { - if (!pinned) return; - pinned = false; - options.onStateChange?.('released'); - }; - - const onScroll = (): void => { - if (!pinned) return; - if ( - releasesArrivalPin({ - scrollTop: viewport.scrollTop, - lastScrollTop, - scrollHeight: viewport.scrollHeight, - lastScrollHeight, - clientHeight: viewport.clientHeight, - lastClientHeight, - }) - ) { - release(); - return; - } - lastScrollTop = viewport.scrollTop; - lastScrollHeight = viewport.scrollHeight; - lastClientHeight = viewport.clientHeight; - }; - - // Wheel and touch are read before the scroll they cause, which is what makes - // them worth listening to on top of `onScroll`: they release the pin in the - // same frame the reader acts, rather than one growth later — a growth landing - // between the gesture and its scroll event would otherwise re-pin under them. - // - // Scoped to gestures over the transcript. The dock — composer, plan panel, - // graph status — sits inside this scroller, so its wheels and touches bubble - // here too, and neither is evidence that the reader left the latest turn. - // Nothing is lost by being strict: a gesture that really moves the scroller - // still reaches `onScroll`, which decides on what the geometry did rather - // than on where the pointer was. - const overTranscript = (event: Event): boolean => { - const target = event.target; - if (!content || typeof content.contains !== 'function' || !target) return true; - return content.contains(target as Node); - }; - const onWheel = (event: Event): void => { - if ((event as WheelEvent).deltaY < 0 && overTranscript(event)) release(); - }; - const onTouchMove = (event: Event): void => { - if (overTranscript(event)) release(); - }; - - viewport.addEventListener('scroll', onScroll, { passive: true }); - viewport.addEventListener('wheel', onWheel, { passive: true }); - viewport.addEventListener('touchmove', onTouchMove, { passive: true }); - - const createSizeObserver = options.createSizeObserver - ?? (typeof ResizeObserver === 'function' - ? (callback: () => void) => new ResizeObserver(callback) - : undefined); - const sizeObserver = content ? createSizeObserver?.(pin) : undefined; - if (content) sizeObserver?.observe(content); - - options.onStateChange?.('pinned'); - pin(); - - return { - release, - isPinned: () => pinned, - dispose: () => { - release(); - sizeObserver?.disconnect(); - viewport.removeEventListener('scroll', onScroll); - viewport.removeEventListener('wheel', onWheel); - viewport.removeEventListener('touchmove', onTouchMove); - }, - }; -} diff --git a/packages/ui/src/chat-scroll-anchor.ts b/packages/ui/src/chat-scroll-anchor.ts deleted file mode 100644 index aec03488eb..0000000000 --- a/packages/ui/src/chat-scroll-anchor.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export interface ChatScrollAnchor { - readonly turnId: string; - readonly sender: string | undefined; - readonly reverseIndex: number; - readonly top: number; - readonly element: HTMLElement; -} - -const lastAnchorByRoot = new WeakMap(); - -export function captureChatScrollAnchor(root: HTMLElement): ChatScrollAnchor | undefined { - const rootTop = root.getBoundingClientRect().top; - const article = firstVisibleArticle(root, rootTop); - const turn = article?.closest('[data-turn-id]'); - const sender = article?.dataset.sender; - const matches = turn - ? Array.from(turn.querySelectorAll('article')) - .filter((candidate) => candidate.dataset.sender === sender) - : []; - const index = article ? matches.indexOf(article) : -1; - if (!article || !turn?.dataset.turnId || index < 0) return undefined; - lastAnchorByRoot.set(root, article); - return { - turnId: turn.dataset.turnId, - sender, - reverseIndex: matches.length - index - 1, - top: article.getBoundingClientRect().top, - element: article, - }; -} - -export function restoreChatScrollAnchor( - root: HTMLElement, - anchor: ChatScrollAnchor | undefined, -): boolean { - if (!anchor) return false; - const retainedTurn = anchor.element.closest('[data-turn-id]'); - let article = - root.contains(anchor.element) && - retainedTurn?.dataset.turnId === anchor.turnId && - anchor.element.dataset.sender === anchor.sender - ? anchor.element - : undefined; - if (!article) { - const turn = root.querySelector( - `[data-turn-id="${CSS.escape(anchor.turnId)}"]`, - ); - const matches = turn - ? Array.from(turn.querySelectorAll('article')) - .filter((candidate) => candidate.dataset.sender === anchor.sender) - : []; - article = matches.at(-anchor.reverseIndex - 1); - } - if (!article) return false; - lastAnchorByRoot.set(root, article); - root.scrollTop += article.getBoundingClientRect().top - anchor.top; - return true; -} - -function firstVisibleArticle(root: HTMLElement, rootTop: number): HTMLElement | undefined { - const cached = lastAnchorByRoot.get(root); - let article = cached && root.contains(cached) ? cached : nextArticle(root, root); - if (!article) return undefined; - if (article.getBoundingClientRect().bottom > rootTop) { - while (true) { - const previous = previousArticle(root, article); - if (!previous) break; - if (previous.getBoundingClientRect().bottom <= rootTop) break; - article = previous; - } - return article; - } - while ((article = nextArticle(root, article))) { - if (article.getBoundingClientRect().bottom > rootTop) return article; - } - return undefined; -} - -function nextArticle(root: HTMLElement, from: HTMLElement): HTMLElement | undefined { - let node: HTMLElement | null = from; - let descend = node.tagName !== 'ARTICLE'; - while (node) { - if (descend && node.firstElementChild) { - node = node.firstElementChild as HTMLElement; - } else { - while (node && node !== root && !node.nextElementSibling) node = node.parentElement; - if (!node || node === root) return undefined; - node = node.nextElementSibling as HTMLElement; - } - if (node.tagName === 'ARTICLE') return node; - descend = true; - } - return undefined; -} - -function previousArticle(root: HTMLElement, from: HTMLElement): HTMLElement | undefined { - let node: HTMLElement | null = from; - while (node && node !== root) { - if (node.previousElementSibling) { - node = node.previousElementSibling as HTMLElement; - while (node.tagName !== 'ARTICLE' && node.lastElementChild) { - node = node.lastElementChild as HTMLElement; - } - } else { - node = node.parentElement; - } - if (node?.tagName === 'ARTICLE') return node; - } - return undefined; -} diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index 2cdfddd34e..105466e01f 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -469,7 +469,6 @@ export function ChatView(props: { throw new Error('ChatView must be rendered inside ChatSurfaceLayout'); } const scrollRef = chatLayout.scrollContainerRef; - const [latestNavigationNonce, setLatestNavigationNonce] = useState(0); const orderedTurnIds = useMemo(() => turns.map((turn) => turn.turnId), [turns]); const sessionId = props.activeSession?.id; const { @@ -516,14 +515,13 @@ export function ChatView(props: { const { highlightedTurnId } = useChatScroll({ scrollRef, sessionId: props.activeSession?.id, - hasTurns: turns.length > 0, messages: props.messages, target: props.scrollTargetTurn, behavior: props.scrollBehavior, hasOlderHistory: props.hasOlderHistory, historyLoadPending: props.historyLoadPending, onLoadEarlierHistory: props.onLoadEarlierHistory, - latestNavigationNonce, + unlockAutoFollow: chatLayout.unlockAutoFollow, }); const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( scrollRef, @@ -638,9 +636,7 @@ export function ChatView(props: { size="sm" isDisabled={props.returnToLatest.isPending} onClick={() => { - void Promise.resolve(props.returnToLatest?.onClick()).then(() => { - setLatestNavigationNonce((nonce) => nonce + 1); - }); + void props.returnToLatest?.onClick(); }} /> diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index dd2f200026..bfb4bc8d93 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -19,32 +19,29 @@ import { useEffect, useRef, useState, type RefObject } from 'react'; import type { StoredMessage } from '@maka/core/session'; -import { createArrivalBottomPin, type ArrivalBottomPin } from './arrival-bottom-pin.js'; -import { captureChatScrollAnchor, restoreChatScrollAnchor } from './chat-scroll-anchor.js'; export function useChatScroll(input: { scrollRef: RefObject; sessionId?: string; - hasTurns: boolean; messages: readonly StoredMessage[]; target?: { turnId: string; nonce: number }; behavior: ScrollBehavior; hasOlderHistory?: boolean; historyLoadPending?: boolean; onLoadEarlierHistory?(): Promise | void; - latestNavigationNonce?: number; + /** Astryx's auto-follow release, for the moves the reader asks for. */ + unlockAutoFollow?(): void; }) { const [highlightedTurnId, setHighlightedTurnId] = useState(null); - const arrivalPin = useRef(null); + const unlockAutoFollowRef = useRef(input.unlockAutoFollow); + unlockAutoFollowRef.current = input.unlockAutoFollow; const loadEarlierRef = useRef(input.onLoadEarlierHistory); loadEarlierRef.current = input.onLoadEarlierHistory; - const sessionIdRef = useRef(input.sessionId); - sessionIdRef.current = input.sessionId; const historyLoadPendingRef = useRef(input.historyLoadPending); historyLoadPendingRef.current = input.historyLoadPending; const canLoadEarlier = input.onLoadEarlierHistory !== undefined; const earlierLoadRequest = useRef(null); - const requestEarlierRef = useRef<() => void>(() => {}); + const releasedForTarget = useRef(null); useEffect(() => { earlierLoadRequest.current = null; @@ -57,23 +54,20 @@ export function useChatScroll(input: { const requestEarlier = (): void => { if (historyLoadPendingRef.current || earlierLoadRequest.current) return; const scrollHeight = root.scrollHeight; - const anchor = captureChatScrollAnchor(root); - const sessionId = sessionIdRef.current; const request = {}; earlierLoadRequest.current = request; - arrivalPin.current?.release(); + unlockAutoFollowRef.current?.(); void Promise.resolve(loadEarlierRef.current?.()).catch(() => undefined).finally(() => { + // Compensate after the turns are on screen, not when they were asked + // for: the reader usually coasts to the top during the load, and the + // browser declines to anchor only while the scroller sits at zero. + // That one hole is this branch; everything else layout already fixed. window.requestAnimationFrame(() => { - if ( - earlierLoadRequest.current === request && - sessionIdRef.current === sessionId && - input.scrollRef.current === root && - root.isConnected && - !restoreChatScrollAnchor(root, anchor) - ) { + if (earlierLoadRequest.current !== request) return; + if (root.isConnected && root.scrollTop === 0) { root.scrollTop += root.scrollHeight - scrollHeight; } - if (earlierLoadRequest.current === request) earlierLoadRequest.current = null; + earlierLoadRequest.current = null; }); }); }; @@ -87,11 +81,9 @@ export function useChatScroll(input: { const onWheel = (event: WheelEvent): void => { if (event.deltaY < 0 && nearStart()) requestEarlier(); }; - requestEarlierRef.current = requestEarlier; root.addEventListener('scroll', onScroll, { passive: true }); root.addEventListener('wheel', onWheel, { passive: true }); return () => { - if (requestEarlierRef.current === requestEarlier) requestEarlierRef.current = () => {}; root.removeEventListener('scroll', onScroll); root.removeEventListener('wheel', onWheel); }; @@ -103,92 +95,19 @@ export function useChatScroll(input: { input.sessionId, ]); - // A session switch is navigation, so its initial virtual tail arrives at the - // bottom instead of animating there as ordinary content growth. - useEffect(() => { - const viewport = input.scrollRef.current; - if (!viewport) return; - // Nothing to arrive: keep the plain positioning this effect has always done - // for a transcript that is empty (or still loading its first turn), and let - // the pin install on the commit those turns land in. - if (!input.hasTurns) { - viewport.scrollTop = viewport.scrollHeight; - return; - } - const pin = createArrivalBottomPin({ - viewport, - content: viewport.querySelector('.maka-chat-message-list'), - onStateChange: (state) => { viewport.dataset.arrivalPin = state; }, - }); - arrivalPin.current = pin; - return () => { - pin.dispose(); - arrivalPin.current = null; - delete viewport.dataset.arrivalPin; - }; - }, [input.sessionId, input.hasTurns, input.scrollRef, input.latestNavigationNonce]); - - useEffect(() => { - const root = input.scrollRef.current; - if (!root || !input.hasTurns) return; - let disposed = false; - let pollTimer: number | undefined; - let frame = 0; - let idle: number | undefined; - let idleTimer: number | undefined; - let polls = 0; - const finishArrival = () => { - if (disposed) return; - if (root.querySelector('.maka-markdown-pending') && polls < 50) { - polls += 1; - pollTimer = window.setTimeout(finishArrival, 100); - return; - } - frame = window.requestAnimationFrame(() => { - frame = window.requestAnimationFrame(() => { - if (disposed) return; - root.dataset.turnWindow = 'ready'; - arrivalPin.current?.release(); - const prefetch = () => { - if (root.scrollTop <= Math.max(640, root.clientHeight * 2)) { - requestEarlierRef.current(); - } - }; - if (typeof window.requestIdleCallback === 'function') { - idle = window.requestIdleCallback(prefetch, { timeout: 250 }); - } else { - idleTimer = window.setTimeout(prefetch, 0); - } - }); - }); - }; - const fontsReady: Promise = - typeof document !== 'undefined' && document.fonts ? document.fonts.ready : Promise.resolve(); - void fontsReady.then(finishArrival); - return () => { - disposed = true; - window.clearTimeout(pollTimer); - window.clearTimeout(idleTimer); - if (idle !== undefined) window.cancelIdleCallback(idle); - if (frame !== 0) window.cancelAnimationFrame(frame); - delete root.dataset.turnWindow; - }; - }, [ - input.sessionId, - input.hasTurns, - input.hasOlderHistory, - input.scrollRef, - input.latestNavigationNonce, - canLoadEarlier, - ]); - useEffect(() => { const target = input.target; if (!target?.turnId) return; - // Navigating to a turn is the reader choosing a position, so it outranks an - // arrival still in flight. (An upward scroll would release the pin on its - // own a frame later; releasing here keeps the first frame honest too.) - arrivalPin.current?.release(); + // Navigating to a turn is the reader choosing a position, so it outranks + // following the tail. Releasing is a persistent state change, and this + // effect also re-runs on every transcript update so a target that arrives + // before its turn still lands — so release once per chosen target, not + // once per run, or the reader loses the tail for the rest of the session. + const chosen = `${input.sessionId ?? ''}:${target.turnId}:${target.nonce}`; + if (releasedForTarget.current !== chosen) { + releasedForTarget.current = chosen; + unlockAutoFollowRef.current?.(); + } const frame = window.requestAnimationFrame(() => { const root = input.scrollRef.current; if (!root) return; diff --git a/packages/ui/src/use-turn-virtualizer.ts b/packages/ui/src/use-turn-virtualizer.ts index 16f8c95758..2f43abadf1 100644 --- a/packages/ui/src/use-turn-virtualizer.ts +++ b/packages/ui/src/use-turn-virtualizer.ts @@ -26,7 +26,6 @@ import { useState, type RefObject, } from 'react'; -import { captureChatScrollAnchor, restoreChatScrollAnchor } from './chat-scroll-anchor.js'; import { createTurnHeightIndex, turnLayoutGap, turnLayoutKey } from './turn-height-index.js'; import { buildTurnVirtualLayout, @@ -132,7 +131,6 @@ export function useTurnVirtualizer(input: { const layoutRef = useRef(layout); const stateRef = useRef(current); - const pendingAnchor = useRef>(undefined); const pendingReveal = useRef(undefined); useLayoutEffect(() => { @@ -147,10 +145,9 @@ export function useTurnVirtualizer(input: { } }, [current, ensureIndex, layout, targetKey]); - const installWindow = useCallback((next: TurnVirtualWindow, anchor = true): boolean => { + const installWindow = useCallback((next: TurnVirtualWindow): boolean => { const root = input.scrollRef.current; if (sameWindow(stateRef.current.window, next)) return false; - if (anchor && root) pendingAnchor.current = captureChatScrollAnchor(root); if (root) handOffExcludedInteraction(root, stateRef.current.turnIds, next); setState((previous) => sameWindow(previous.window, next) ? previous @@ -183,10 +180,6 @@ export function useTurnVirtualizer(input: { useLayoutEffect(() => { const root = input.scrollRef.current; if (!root) return; - if (pendingAnchor.current) { - restoreChatScrollAnchor(root, pendingAnchor.current); - pendingAnchor.current = undefined; - } const reveal = pendingReveal.current; pendingReveal.current = undefined; if (reveal) { @@ -201,7 +194,7 @@ export function useTurnVirtualizer(input: { ensureIndex: targetIndex < 0 ? undefined : targetIndex, preferredTurns: MIN_TURN_WINDOW_SIZE, }, - ), false); + )); } } }, [current.window, input.scrollRef, installWindow]); @@ -236,7 +229,6 @@ export function useTurnVirtualizer(input: { const nextGap = turnLayoutGap(root, DEFAULT_TURN_GAP); const nextLayoutKey = turnLayoutKey(root, nextGap); let changed = nextLayoutKey !== layoutKey; - const anchor = virtualizationRequired ? captureChatScrollAnchor(root) : undefined; for (const entry of entries) { const element = entry.target as HTMLElement; const turnId = element.dataset.virtualTurnId; @@ -250,10 +242,7 @@ export function useTurnVirtualizer(input: { ) || changed; } } - if (changed) { - if (anchor) pendingAnchor.current = anchor; - setGeometryRevision((revision) => revision + 1); - } + if (changed) setGeometryRevision((revision) => revision + 1); scheduleWindow(); }); const observeTree = (node: Node): void => { diff --git a/patches/@astryxdesign+core+0.5.0.patch b/patches/@astryxdesign+core+0.5.0.patch index 25e5a1ead1..e266dedbcb 100644 --- a/patches/@astryxdesign+core+0.5.0.patch +++ b/patches/@astryxdesign+core+0.5.0.patch @@ -33,7 +33,7 @@ index ff34874..9ec8d7a 100644 export declare function ChatLayout({ children, composer, density, emptyState, scrollButton, scrollRef: externalScrollRef, xstyle, className, style, 'data-testid': testId, ref, ...rest }: ChatLayoutProps): import("react").JSX.Element; export declare namespace ChatLayout { diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js -index ff9b9fa..346e01c 100644 +index ff9b9fa..6d427d0 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js @@ -28,7 +28,7 @@ @@ -53,7 +53,7 @@ index ff9b9fa..346e01c 100644 ref, ...rest }) { -@@ -211,6 +212,20 @@ export function ChatLayout({ +@@ -211,6 +212,29 @@ export function ChatLayout({ isLocked: scroll.isLocked, onResize: scroll.scrollIfLocked }); @@ -68,13 +68,22 @@ index ff9b9fa..346e01c 100644 + return; + } + conversationKeyRef.current = conversationKey; -+ scroll.lock(); ++ // maka: a new conversation is a new initial fill, not content growth in ++ // the current one. `lock` alone re-enters through the spring, so the ++ // incoming transcript flies to the bottom instead of arriving there. ++ // The jump handles a transcript that is already mounted; arming the fill ++ // again handles the usual case, where the host clears the transcript in ++ // the same update that changes the key and fills it a few frames later. ++ scroll.scrollToBottom({ ++ behavior: 'instant' ++ }); ++ scroll.resetInitialFill(); + newMsgs.reset(); -+ }, [conversationKey, scroll.lock, newMsgs.reset]); ++ }, [conversationKey, scroll.scrollToBottom, scroll.resetInitialFill, newMsgs.reset]); const defaultScrollButton = /*#__PURE__*/_jsx(ChatLayoutScrollButton, { isVisible: scroll.isScrolledUp || newMsgs.hasNewMessages, label: newMsgs.hasNewMessages ? t('@astryx.chatLayout.newMessages') : undefined, -@@ -223,8 +238,14 @@ export function ChatLayout({ +@@ -223,8 +247,14 @@ export function ChatLayout({ // --- Layout context --- const layoutContext = useMemo(() => ({ scrollContainerRef, @@ -132,6 +141,75 @@ index 8c509bc..4b09993 100644 }; } \ No newline at end of file +diff --git a/node_modules/@astryxdesign/core/dist/Chat/useChatStreamScroll.d.ts b/node_modules/@astryxdesign/core/dist/Chat/useChatStreamScroll.d.ts +index d279277..c13ade0 100644 +--- a/node_modules/@astryxdesign/core/dist/Chat/useChatStreamScroll.d.ts ++++ b/node_modules/@astryxdesign/core/dist/Chat/useChatStreamScroll.d.ts +@@ -65,6 +65,13 @@ export interface UseChatStreamScrollReturn { + scrollIfLocked: () => void; + /** Scroll to the last message in the container. */ + scrollToLastMessage: () => void; ++ /** ++ * Arm the first fill again, for a host that swaps conversations in one ++ * container. The next scrollable content positions in a single frame ++ * instead of springing from the top, which is what the swap needs when it ++ * happens against an empty, still-loading scroller. ++ */ ++ resetInitialFill: () => void; + } + export declare function useChatStreamScroll({ scrollRef, enabled, lockThreshold, buttonThreshold, damping, stiffness, mass, }: UseChatStreamScrollOptions): UseChatStreamScrollReturn; + //# sourceMappingURL=useChatStreamScroll.d.ts.map +\ No newline at end of file +diff --git a/node_modules/@astryxdesign/core/dist/Chat/useChatStreamScroll.js b/node_modules/@astryxdesign/core/dist/Chat/useChatStreamScroll.js +index d9f2bed..d7b6817 100644 +--- a/node_modules/@astryxdesign/core/dist/Chat/useChatStreamScroll.js ++++ b/node_modules/@astryxdesign/core/dist/Chat/useChatStreamScroll.js +@@ -178,6 +178,13 @@ export function useChatStreamScroll({ + animatingRef.current = false; + setIsLocked(false); + }, []); ++ // maka: arm the first fill again. A host that swaps conversations in this ++ // container gets a new initial fill, not growth of the current one — and it ++ // usually swaps to an empty, still-loading scroller, so the jump cannot be ++ // taken now and the arriving content must not enter through the spring. ++ const resetInitialFill = useCallback(() => { ++ initialFillPendingRef.current = true; ++ }, []); + const scrollIfLocked = useCallback(() => { + if (!enabled) { + return; +@@ -253,8 +260,11 @@ export function useChatStreamScroll({ + // Wheel up while animating — interrupt immediately. + // onScroll direction detection covers most cases, but wheel fires + // before the scroll position updates so we can react faster. ++ // maka: the predicate is "are we following", not "is a spring running". ++ // Under prefers-reduced-motion following never animates, so gating on ++ // animatingRef made both gesture releases dead code for those readers. + const onWheel = e => { +- if (e.deltaY < 0 && animatingRef.current) { ++ if (e.deltaY < 0 && lockedRef.current) { + lockedRef.current = false; + animatingRef.current = false; + setIsLocked(false); +@@ -263,7 +273,7 @@ export function useChatStreamScroll({ + + // Touch move — user is dragging, take control + const onTouchMove = () => { +- if (animatingRef.current) { ++ if (lockedRef.current) { + lockedRef.current = false; + animatingRef.current = false; + setIsLocked(false); +@@ -303,6 +313,7 @@ export function useChatStreamScroll({ + lock, + unlock, + scrollIfLocked, +- scrollToLastMessage ++ scrollToLastMessage, ++ resetInitialFill + }; + } +\ No newline at end of file diff --git a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts b/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts index 82ae62c..a97eff5 100644 --- a/node_modules/@astryxdesign/core/dist/DropdownMenu/DropdownMenuItem.d.ts