From 31539bc66306d0a10f98467b776f7232e819147c Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 01:07:53 +0800 Subject: [PATCH 01/10] perf(ui): stop prefetching earlier history on session arrival Switching sessions scheduled a requestIdleCallback that fired roughly 150ms later and called requestEarlier() unconditionally, inserting earlier-history turns above the reader's scroll position. Those turns then inflated in waves as their content resolved, moving the transcript under the reader three times after it had already settled. Nothing asked for that history. The prefetch was speculative: it ran on arrival rather than on any reader intent, and the upward-scroll and wheel handlers already load earlier history when the reader actually approaches the top. Measured with alternating within-instance A/B (3 repetitions per configuration, sigma about 0.02): mean CLS across four sessions drops from 0.481 to 0.085. Removing the only reader of requestEarlierRef also removes the cross-effect mutable-callback coupling between the history loader and the arrival gate, so the arrival effect no longer depends on hasOlderHistory or canLoadEarlier. Capability given up: earlier history is no longer warmed during the switch, so the reader's first upward scroll pays one load. Generated-by: Claude Code --- packages/ui/src/use-chat-scroll.ts | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index dd2f200026..d9ed479538 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -44,7 +44,6 @@ export function useChatScroll(input: { historyLoadPendingRef.current = input.historyLoadPending; const canLoadEarlier = input.onLoadEarlierHistory !== undefined; const earlierLoadRequest = useRef(null); - const requestEarlierRef = useRef<() => void>(() => {}); useEffect(() => { earlierLoadRequest.current = null; @@ -87,11 +86,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); }; @@ -134,8 +131,6 @@ export function useChatScroll(input: { 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; @@ -149,16 +144,6 @@ export function useChatScroll(input: { 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); - } }); }); }; @@ -168,19 +153,10 @@ export function useChatScroll(input: { 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, - ]); + }, [input.sessionId, input.hasTurns, input.scrollRef, input.latestNavigationNonce]); useEffect(() => { const target = input.target; From 26896135ef18402abddd74636bc208729ae5ffa6 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 01:09:41 +0800 Subject: [PATCH 02/10] refactor(ui): let the browser anchor the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chat-scroll-anchor captured a turn id plus its offset before content landed above the reader and restored that position a frame later. This is what browser scroll anchoring already does, and does better: the browser compensates during layout, not a frame after it. Measured directly: inserting 1500px above the viewport mid-scroll moves the visible content 0px. Measured in place: with the arrival prefetch gone, removing the hand-rolled anchoring leaves mean CLS unchanged (0.085 to 0.090, byte-identical on three of four sessions). The anchoring was carrying no load once nothing inserted content the reader had not asked for, which is why it could only go after the prefetch. Removing it also removes the double compensation it sat behind — a scrollTop adjustment by the scrollHeight delta, applied whenever the restore reported failure — and the pendingAnchor round trip through the virtualizer's window installs and resize observer. Added in its place: overflow-anchor stated on the transcript column so the dependency is legible rather than inherited from the default, and a one-pixel nudge when the earlier-history request starts at the very top, where anchoring is suppressed and would otherwise let the incoming turns jump the reader. Generated-by: Claude Code --- .../src/renderer/styles/chat-message.css | 7 + .../src/__tests__/chat-scroll-anchor.test.ts | 93 ------------- packages/ui/src/chat-scroll-anchor.ts | 129 ------------------ packages/ui/src/use-chat-scroll.ts | 22 +-- packages/ui/src/use-turn-virtualizer.ts | 17 +-- 5 files changed, 14 insertions(+), 254 deletions(-) delete mode 100644 packages/ui/src/__tests__/chat-scroll-anchor.test.ts delete mode 100644 packages/ui/src/chat-scroll-anchor.ts diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 12f14e623a..739d273b58 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -38,8 +38,15 @@ opacity: 1; } +/* 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 rather than inheriting the `auto` default: Maka reads no + geometry and restores no position of its own. It does not compensate at + `scrollTop === 0`, which is why the earlier-history request keeps the + viewport off the very top. */ .maka-chat-message-list { width: 100%; + overflow-anchor: auto; } .maka-turn-virtual-item { 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/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/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index d9ed479538..1e10ae743f 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -20,7 +20,6 @@ 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; @@ -38,8 +37,6 @@ export function useChatScroll(input: { const arrivalPin = useRef(null); 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; @@ -55,25 +52,14 @@ export function useChatScroll(input: { let previousScrollTop = root.scrollTop; const requestEarlier = (): void => { if (historyLoadPendingRef.current || earlierLoadRequest.current) return; - const scrollHeight = root.scrollHeight; - const anchor = captureChatScrollAnchor(root); - const sessionId = sessionIdRef.current; + // Scroll anchoring is suppressed at the very top, so give it something + // to anchor against before the turns land above the reader. + if (root.scrollTop === 0) root.scrollTop = 1; const request = {}; earlierLoadRequest.current = request; arrivalPin.current?.release(); void Promise.resolve(loadEarlierRef.current?.()).catch(() => undefined).finally(() => { - window.requestAnimationFrame(() => { - if ( - earlierLoadRequest.current === request && - sessionIdRef.current === sessionId && - input.scrollRef.current === root && - root.isConnected && - !restoreChatScrollAnchor(root, anchor) - ) { - root.scrollTop += root.scrollHeight - scrollHeight; - } - if (earlierLoadRequest.current === request) earlierLoadRequest.current = null; - }); + if (earlierLoadRequest.current === request) earlierLoadRequest.current = null; }); }; const nearStart = (): boolean => 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 => { From 43aa1dbe4ebd3a7b7110ebfb613220ab4f7f7513 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 01:14:58 +0800 Subject: [PATCH 03/10] refactor(ui): give following the tail back to Astryx chat-surface-layout states that Astryx owns scrolling and new-message following. arrival-bottom-pin was a second implementation of exactly that, added in #2239 because ChatLayout exposed only scrollContainerRef and contentRef, so its controller could not be reached. #2923 opened that seam for unlockAutoFollow and the pin was never revisited. Reading Astryx's controller, it already covers what six review rounds put into the pin: resize-synthetic scroll events are excluded by comparing scrollHeight and offsetHeight, a horizontal wheel is excluded by requiring deltaY < 0, and gestures are scoped by binding to the scroller itself rather than by testing where the pointer was. Its initial fill positions in one frame instead of springing from the top, which is what the pin's clamp existed to produce. The one gap was reachability again: on a conversation change the patch called lock(), which re-enters through the spring because the hook's initial-fill flag was consumed at mount. Asking for the instant jump directly closes it, in the patch that was already there. Removing the pin leaves two moves Astryx cannot see, both now going through the context: navigating to a turn and loading earlier history release auto-follow, and "return to latest" resumes it. The second needed the other half of #2923's seam, so the patch also exposes scrollToBottom. Both are additive context fields to upstream. data-turn-window went with the pin it gated: its ready state existed to release the pin, and the fonts.ready wait plus fifty markdown polls plus double rAF existed to time that release. The two E2E tests that waited on it wait for a mounted turn instead, which is what they were after. latestNavigationNonce was left write-only and goes too. arrival-bottom-pin.test.ts is replaced by a test of what Maka still owns, the two release moments, rather than a test of Astryx's internals. Capability given up: a wheel or touch over the dock while the transcript is animating now releases following, where the pin discriminated by gesture origin; and returning to the bottom re-locks following, where the pin's release was permanent for that arrival. Generated-by: Claude Code --- apps/desktop/e2e/prompt-rail.spec.ts | 11 +- .../src/__tests__/arrival-bottom-pin.test.ts | 293 ------------------ .../ui/src/__tests__/use-chat-scroll.test.tsx | 165 ++++++++++ packages/ui/src/arrival-bottom-pin.ts | 200 ------------ packages/ui/src/chat-view.tsx | 5 +- packages/ui/src/use-chat-scroll.ts | 74 +---- patches/@astryxdesign+core+0.5.0.patch | 32 +- 7 files changed, 204 insertions(+), 576 deletions(-) delete mode 100644 packages/ui/src/__tests__/arrival-bottom-pin.test.ts create mode 100644 packages/ui/src/__tests__/use-chat-scroll.test.tsx delete mode 100644 packages/ui/src/arrival-bottom-pin.ts 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/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__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx new file mode 100644 index 0000000000..dfe1e92b3a --- /dev/null +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -0,0 +1,165 @@ +/* + * 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; + 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; + Object.defineProperties(scroller, { + clientHeight: { value: 600 }, + scrollHeight: { value: 9_000 }, + 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', + hasTurns: true, + messages: [], + behavior: 'auto', + target: options.target, + hasOlderHistory: options.hasOlderHistory, + onLoadEarlierHistory: options.onLoadEarlierHistory, + unlockAutoFollow: options.unlockAutoFollow, + }); + return null; + } + + return { scroller, document, Harness }; +} + +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 } = 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 at the very top, so the incoming turns would + // push the reader down by their own height. + assert.equal(scroller.scrollTop, 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-view.tsx b/packages/ui/src/chat-view.tsx index 0cd838973c..69e57d0f37 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -506,7 +506,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 { @@ -560,7 +559,7 @@ export function ChatView(props: { hasOlderHistory: props.hasOlderHistory, historyLoadPending: props.historyLoadPending, onLoadEarlierHistory: props.onLoadEarlierHistory, - latestNavigationNonce, + unlockAutoFollow: chatLayout.unlockAutoFollow, }); const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( scrollRef, @@ -675,7 +674,7 @@ export function ChatView(props: { isPending={props.returnToLatest.isPending} onReturnToLatest={() => Promise.resolve(props.returnToLatest?.onClick()).then(() => { - setLatestNavigationNonce((nonce) => nonce + 1); + chatLayout.scrollToBottom?.({ behavior: 'instant' }); })} /> ) : null} diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 1e10ae743f..3c0e777bc1 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -19,7 +19,6 @@ import { useEffect, useRef, useState, type RefObject } from 'react'; import type { StoredMessage } from '@maka/core/session'; -import { createArrivalBottomPin, type ArrivalBottomPin } from './arrival-bottom-pin.js'; export function useChatScroll(input: { scrollRef: RefObject; @@ -31,10 +30,12 @@ export function useChatScroll(input: { 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 historyLoadPendingRef = useRef(input.historyLoadPending); @@ -57,7 +58,7 @@ export function useChatScroll(input: { if (root.scrollTop === 0) root.scrollTop = 1; const request = {}; earlierLoadRequest.current = request; - arrivalPin.current?.release(); + unlockAutoFollowRef.current?.(); void Promise.resolve(loadEarlierRef.current?.()).catch(() => undefined).finally(() => { if (earlierLoadRequest.current === request) earlierLoadRequest.current = null; }); @@ -86,71 +87,12 @@ 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 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 fontsReady: Promise = - typeof document !== 'undefined' && document.fonts ? document.fonts.ready : Promise.resolve(); - void fontsReady.then(finishArrival); - return () => { - disposed = true; - window.clearTimeout(pollTimer); - if (frame !== 0) window.cancelAnimationFrame(frame); - delete root.dataset.turnWindow; - }; - }, [input.sessionId, input.hasTurns, input.scrollRef, input.latestNavigationNonce]); - 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. + unlockAutoFollowRef.current?.(); const frame = window.requestAnimationFrame(() => { const root = input.scrollRef.current; if (!root) return; diff --git a/patches/@astryxdesign+core+0.5.0.patch b/patches/@astryxdesign+core+0.5.0.patch index 25e5a1ead1..e2a66dcce0 100644 --- a/patches/@astryxdesign+core+0.5.0.patch +++ b/patches/@astryxdesign+core+0.5.0.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts b/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts -index d1bfeeb..b4a0e62 100644 +index d1bfeeb..757a2e2 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts -@@ -61,6 +61,13 @@ export interface ChatLayoutContextValue { +@@ -61,6 +61,19 @@ export interface ChatLayoutContextValue { scrollContainerRef: React.RefObject; /** Callback ref for the message list content element — layout observes it for size changes. */ contentRef: (el: HTMLElement | null) => void; @@ -13,6 +13,12 @@ index d1bfeeb..b4a0e62 100644 + * changed scrollHeight and is read as a resize artefact. + */ + unlockAutoFollow?: () => void; ++ /** ++ * Resume auto-follow and go to the newest message. The other half of ++ * `unlockAutoFollow`: a host that offers "return to latest" has to put the ++ * reader back on the tail, and no scroll it performs itself will re-lock. ++ */ ++ scrollToBottom?: (options?: import("./useChatStreamScroll.js").ChatScrollToBottomOptions) => void; } export declare const ChatLayoutContext: import("react").Context; export declare function useChatLayoutContext(): ChatLayoutContextValue | null; @@ -33,7 +39,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..7aeb506 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 +59,7 @@ index ff9b9fa..346e01c 100644 ref, ...rest }) { -@@ -211,6 +212,20 @@ export function ChatLayout({ +@@ -211,6 +212,25 @@ export function ChatLayout({ isLocked: scroll.isLocked, onResize: scroll.scrollIfLocked }); @@ -68,13 +74,18 @@ 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. ++ scroll.scrollToBottom({ ++ behavior: 'instant' ++ }); + newMsgs.reset(); -+ }, [conversationKey, scroll.lock, newMsgs.reset]); ++ }, [conversationKey, scroll.scrollToBottom, 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 +243,17 @@ export function ChatLayout({ // --- Layout context --- const layoutContext = useMemo(() => ({ scrollContainerRef, @@ -86,8 +97,11 @@ index ff9b9fa..346e01c 100644 + // the move was intentional: the scroll-up unlock is skipped whenever the + // event arrives with a changed scrollHeight, which is exactly what a host + // that mounts content before scrolling produces. -+ unlockAutoFollow: scroll.unlock -+ }), [scrollContainerRef, newMsgs.contentRef, scroll.unlock]); ++ unlockAutoFollow: scroll.unlock, ++ // maka: and the way back. Nothing a host does to scrollTop re-locks ++ // auto-follow, so "return to latest" needs the controller itself. ++ scrollToBottom: scroll.scrollToBottom ++ }), [scrollContainerRef, newMsgs.contentRef, scroll.unlock, scroll.scrollToBottom]); // --- Derived styles --- const showEmpty = !hasVisibleContent(children); From cdd796f4da2ef63da861da40df6d485ea8c6a115 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 01:56:53 +0800 Subject: [PATCH 04/10] fix(ui): keep the first fill armed across a conversation swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The instant jump on conversation change assumed the incoming transcript was already mounted. It is not: setActiveId clears messages and marks the load pending in the same update that changes the key, so the scroller the swap sees holds only the loading placeholder and has nothing to scroll. scrollToBottom consumes the hook's pending first fill whether or not the jump could do anything, so the transcript arriving a few frames later took the spring path and flew down from the top — the exact motion the pin used to prevent, reintroduced for cold switches only. Arming the fill again after the jump covers both shapes: a transcript already on screen is positioned by the jump, and one that arrives later is positioned in a single frame by the first scrollIfLocked that sees scrollable content. This matches what the measurements showed and I misread at the time: cold first visits settled 677px from the bottom while warm switches settled at 5px, which I attributed to load cost rather than to this path. Reported by M4n5ter's review agent on #4105. Generated-by: Claude Code --- .../chat-conversation-swap-scroll.test.tsx | 143 ++++++++++++++++++ patches/@astryxdesign+core+0.5.0.patch | 59 +++++++- 2 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/__tests__/chat-conversation-swap-scroll.test.tsx 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/patches/@astryxdesign+core+0.5.0.patch b/patches/@astryxdesign+core+0.5.0.patch index e2a66dcce0..1da6448ada 100644 --- a/patches/@astryxdesign+core+0.5.0.patch +++ b/patches/@astryxdesign+core+0.5.0.patch @@ -39,7 +39,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..7aeb506 100644 +index ff9b9fa..9869942 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js @@ -28,7 +28,7 @@ @@ -59,7 +59,7 @@ index ff9b9fa..7aeb506 100644 ref, ...rest }) { -@@ -211,6 +212,25 @@ export function ChatLayout({ +@@ -211,6 +212,29 @@ export function ChatLayout({ isLocked: scroll.isLocked, onResize: scroll.scrollIfLocked }); @@ -77,15 +77,19 @@ index ff9b9fa..7aeb506 100644 + // 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.scrollToBottom, 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 +243,17 @@ export function ChatLayout({ +@@ -223,8 +247,17 @@ export function ChatLayout({ // --- Layout context --- const layoutContext = useMemo(() => ({ scrollContainerRef, @@ -146,6 +150,53 @@ 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..c63df62 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; +@@ -303,6 +310,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 From ac120ecf00da1f289b2385a91badd86bb8f2c355 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 02:23:13 +0800 Subject: [PATCH 05/10] fix(ui): place each scroll command where its precondition holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review found three more defects, all one mistake. The mechanisms this branch deleted were continuous: the anchor restored a captured position whenever the content landed, and the pin clamped the bottom frame by frame for the whole arrival. Their replacements are one-shot — Astryx's controller acts on the frame it is called, and the browser anchors at the instant content is inserted. The call sites moved across unchanged, and each one now fires before the DOM reaches the state it assumes. Loading earlier history nudged the scroller off zero when the request was made, but anchoring is suppressed or not at the moment the turns land, a whole IPC round trip later — and the reader coasting upward is back at zero by then. The compensation moves to the frame after the turns are on screen, which is also the first moment their height is known. Return to latest jumped in a promise callback that resolves before React commits the newer range, so it jumped against the old geometry and consumed the pending first fill, leaving the range that arrived after it to spring down from the top. The jump is removed rather than repaired: nothing ever required this button to arrive instantly, and the transcript scrolling to the newest turn shows the reader what happened. With no host calling it, scrollToBottom comes back out of the layout context. Navigating to a turn released auto-follow on every transcript update, not once per chosen target: the effect re-runs on messages so a target that arrives before its turn still lands, and the release it used to make was an idempotent no-op. Astryx's unlock is persistent, and the search target is never cleared, so following stayed off for the rest of the session. Also here, because this change is what surfaced them: the gesture releases were gated on a spring being in flight, which never happens under prefers-reduced-motion, leaving those readers unable to leave the tail by wheel or touch — the predicate is following, not animating. And overflow-anchor moves to the scroller that actually runs it; on the content column it was inert. hasTurns had no reader left. Generated-by: Claude Code --- .../src/renderer/styles/chat-message.css | 15 +++-- .../ui/src/__tests__/use-chat-scroll.test.tsx | 56 ++++++++++++++++--- packages/ui/src/chat-view.tsx | 6 +- packages/ui/src/use-chat-scroll.ts | 29 +++++++--- patches/@astryxdesign+core+0.5.0.patch | 47 ++++++++++------ 5 files changed, 111 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 739d273b58..f578b5c265 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -38,14 +38,17 @@ opacity: 1; } -/* 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 rather than inheriting the `auto` default: Maka reads no - geometry and restores no position of its own. It does not compensate at - `scrollTop === 0`, which is why the earlier-history request keeps the - viewport off the very top. */ .maka-chat-message-list { 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; } diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index dfe1e92b3a..17d4cfa9cc 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -59,6 +59,7 @@ afterEach(async () => { function mountTranscript(options: { scrollTop: number; + scrollHeight?: number; hasOlderHistory?: boolean; target?: { turnId: string; nonce: number }; onLoadEarlierHistory?(): void; @@ -70,9 +71,10 @@ function mountTranscript(options: { 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: { value: 9_000 }, + scrollHeight: { get: () => scrollHeight }, scrollTop: { get: () => scrollTop, set: (value: number) => { @@ -98,7 +100,6 @@ function mountTranscript(options: { useChatScroll({ scrollRef, sessionId: 'session-1', - hasTurns: true, messages: [], behavior: 'auto', target: options.target, @@ -109,7 +110,14 @@ function mountTranscript(options: { return null; } - return { scroller, document, Harness }; + return { + scroller, + document, + Harness, + grow(by: number) { + scrollHeight += by; + }, + }; } test('jumping to a turn the reader picked releases auto-follow', async () => { @@ -134,7 +142,7 @@ test('jumping to a turn the reader picked releases auto-follow', async () => { 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 } = mountTranscript({ + const { scroller, document, Harness, grow } = mountTranscript({ scrollTop: 0, hasOlderHistory: true, onLoadEarlierHistory: () => { @@ -159,7 +167,41 @@ test('loading earlier history releases auto-follow and keeps the anchor off the assert.equal(loaded, 1); assert.equal(unlocked, 1); - // Scroll anchoring does nothing at the very top, so the incoming turns would - // push the reader down by their own height. - assert.equal(scroller.scrollTop, 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/chat-view.tsx b/packages/ui/src/chat-view.tsx index 69e57d0f37..e879bd88b8 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -552,7 +552,6 @@ 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, @@ -672,10 +671,7 @@ export function ChatView(props: { description={props.returnToLatest.description} actionLabel={props.returnToLatest.label} isPending={props.returnToLatest.isPending} - onReturnToLatest={() => - Promise.resolve(props.returnToLatest?.onClick()).then(() => { - chatLayout.scrollToBottom?.({ behavior: 'instant' }); - })} + onReturnToLatest={() => props.returnToLatest?.onClick()} /> ) : null} ; sessionId?: string; - hasTurns: boolean; messages: readonly StoredMessage[]; target?: { turnId: string; nonce: number }; behavior: ScrollBehavior; @@ -42,6 +41,7 @@ export function useChatScroll(input: { historyLoadPendingRef.current = input.historyLoadPending; const canLoadEarlier = input.onLoadEarlierHistory !== undefined; const earlierLoadRequest = useRef(null); + const releasedForTarget = useRef(null); useEffect(() => { earlierLoadRequest.current = null; @@ -53,14 +53,22 @@ export function useChatScroll(input: { let previousScrollTop = root.scrollTop; const requestEarlier = (): void => { if (historyLoadPendingRef.current || earlierLoadRequest.current) return; - // Scroll anchoring is suppressed at the very top, so give it something - // to anchor against before the turns land above the reader. - if (root.scrollTop === 0) root.scrollTop = 1; + const scrollHeight = root.scrollHeight; const request = {}; earlierLoadRequest.current = request; unlockAutoFollowRef.current?.(); void Promise.resolve(loadEarlierRef.current?.()).catch(() => undefined).finally(() => { - if (earlierLoadRequest.current === request) earlierLoadRequest.current = null; + // 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) return; + if (root.isConnected && root.scrollTop === 0) { + root.scrollTop += root.scrollHeight - scrollHeight; + } + earlierLoadRequest.current = null; + }); }); }; const nearStart = (): boolean => @@ -91,8 +99,15 @@ export function useChatScroll(input: { const target = input.target; if (!target?.turnId) return; // Navigating to a turn is the reader choosing a position, so it outranks - // following the tail. - unlockAutoFollowRef.current?.(); + // 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/patches/@astryxdesign+core+0.5.0.patch b/patches/@astryxdesign+core+0.5.0.patch index 1da6448ada..e266dedbcb 100644 --- a/patches/@astryxdesign+core+0.5.0.patch +++ b/patches/@astryxdesign+core+0.5.0.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts b/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts -index d1bfeeb..757a2e2 100644 +index d1bfeeb..b4a0e62 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts -@@ -61,6 +61,19 @@ export interface ChatLayoutContextValue { +@@ -61,6 +61,13 @@ export interface ChatLayoutContextValue { scrollContainerRef: React.RefObject; /** Callback ref for the message list content element — layout observes it for size changes. */ contentRef: (el: HTMLElement | null) => void; @@ -13,12 +13,6 @@ index d1bfeeb..757a2e2 100644 + * changed scrollHeight and is read as a resize artefact. + */ + unlockAutoFollow?: () => void; -+ /** -+ * Resume auto-follow and go to the newest message. The other half of -+ * `unlockAutoFollow`: a host that offers "return to latest" has to put the -+ * reader back on the tail, and no scroll it performs itself will re-lock. -+ */ -+ scrollToBottom?: (options?: import("./useChatStreamScroll.js").ChatScrollToBottomOptions) => void; } export declare const ChatLayoutContext: import("react").Context; export declare function useChatLayoutContext(): ChatLayoutContextValue | null; @@ -39,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..9869942 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 @@ @@ -89,7 +83,7 @@ index ff9b9fa..9869942 100644 const defaultScrollButton = /*#__PURE__*/_jsx(ChatLayoutScrollButton, { isVisible: scroll.isScrolledUp || newMsgs.hasNewMessages, label: newMsgs.hasNewMessages ? t('@astryx.chatLayout.newMessages') : undefined, -@@ -223,8 +247,17 @@ export function ChatLayout({ +@@ -223,8 +247,14 @@ export function ChatLayout({ // --- Layout context --- const layoutContext = useMemo(() => ({ scrollContainerRef, @@ -101,11 +95,8 @@ index ff9b9fa..9869942 100644 + // the move was intentional: the scroll-up unlock is skipped whenever the + // event arrives with a changed scrollHeight, which is exactly what a host + // that mounts content before scrolling produces. -+ unlockAutoFollow: scroll.unlock, -+ // maka: and the way back. Nothing a host does to scrollTop re-locks -+ // auto-follow, so "return to latest" needs the controller itself. -+ scrollToBottom: scroll.scrollToBottom -+ }), [scrollContainerRef, newMsgs.contentRef, scroll.unlock, scroll.scrollToBottom]); ++ unlockAutoFollow: scroll.unlock ++ }), [scrollContainerRef, newMsgs.contentRef, scroll.unlock]); // --- Derived styles --- const showEmpty = !hasVisibleContent(children); @@ -170,7 +161,7 @@ index d279277..c13ade0 100644 //# 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..c63df62 100644 +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({ @@ -187,7 +178,29 @@ index d9f2bed..c63df62 100644 const scrollIfLocked = useCallback(() => { if (!enabled) { return; -@@ -303,6 +310,7 @@ export function useChatStreamScroll({ +@@ -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, From c0de634fae55cf293ef23adf958928b0af997629 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 10:59:02 +0800 Subject: [PATCH 06/10] fix(ui): keep the gesture release gated on a running spring Widening the wheel and touchmove release from "a spring is running" to "we are following" reaches every gesture that merely bubbles through the scroller. The transcript is self-scrolling, so the scroller is the whole ChatLayout root: a tool output body, the pty terminal, the composer, and the graph panel all sit inside it and all scroll on their own. A wheel they consume never moves the outer scroller, so no scroll and no scrollend follow, and the scrollend re-lock can never run. Following is off, the distance to the bottom is still zero so the scroll-to-bottom button stays hidden, and the reader has no way back. That widening was aimed at readers who prefer reduced motion, for whom the spring never runs and both releases are therefore dead code. It is a real gap, but scroll-direction detection still serves them correctly, so what they lose is a shortcut rather than the behaviour. Trading that for a way to silently stop following is the wrong exchange, and it is not what this branch set out to change. Reported upstream instead. Generated-by: Claude Code --- patches/@astryxdesign+core+0.5.0.patch | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/patches/@astryxdesign+core+0.5.0.patch b/patches/@astryxdesign+core+0.5.0.patch index e266dedbcb..fe67382dff 100644 --- a/patches/@astryxdesign+core+0.5.0.patch +++ b/patches/@astryxdesign+core+0.5.0.patch @@ -178,29 +178,7 @@ index d9f2bed..d7b6817 100644 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({ +@@ -303,6 +310,7 @@ export function useChatStreamScroll({ lock, unlock, scrollIfLocked, From 5f026ad92c30fe159312fd1fe17b57d858b2fb90 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 11:40:47 +0800 Subject: [PATCH 07/10] refactor(ui): make the transcript the only writer of its scroll position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three writers moved `scrollTop` in the chat transcript — Astryx's auto-follow lock and spring, Maka's height-delta compensation and `scrollIntoView`, and the browser's own anchoring — and none of them held the answer to "where should the viewport be". They avoided each other through flags, effect ordering and heuristics reconstructed from DOM signals, and each heuristic had more than one cause. Collapse the policy to one boolean. `pinned` means growth writes `scrollTop = scrollHeight`; not pinned means nothing here writes it, ever, and `overflow-anchor: auto` — already the initial value — keeps the reader where they were reading for free. Around it sit three one-shot commands: return to the tail, jump to a turn, and compensate earlier history at `scrollTop === 0`, the one place native anchoring declines to help. Every command releases the pin first, so a command can never race the policy. Being the only writer is what makes the state exact. A write flags itself, so an unflagged `scroll` event is the reader by construction — and because `scroll` does not bubble, a gesture a nested scroller consumed never reaches the authority at all. Astryx's scroll layer is turned off per call site through a new `scrollOwner` prop rather than globally: the workhub surfaces render no `ChatView` and still want stock auto-follow. The growth signal reuses the turn virtualizer's existing `ResizeObserver`; no new observer is added. The patch's scroll-related surface shrinks from six files and ten hunks to three files and five hunks — `conversationKey`, `unlockAutoFollow` and `resetInitialFill` all disappear, replaced by forwarding one `autoScroll` flag into the hook's existing `enabled` switch. Generated-by: Claude Opus 5 via Claude Code --- apps/desktop/src/renderer/app-shell.tsx | 8 +- .../tools/side-chat/quote-companion-panel.tsx | 2 +- apps/desktop/src/renderer/workhub-surface.tsx | 2 - apps/desktop/stories/app-shell.stories.tsx | 1 + apps/desktop/stories/onboarding.stories.tsx | 2 +- docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + .../chat-conversation-swap-scroll.test.tsx | 143 ---------- .../transcript-scroll-authority.test.ts | 184 +++++++++++++ .../ui/src/__tests__/use-chat-scroll.test.tsx | 207 -------------- packages/ui/src/chat-scroll-anchor.ts | 64 +++++ packages/ui/src/chat-surface-layout.tsx | 45 +++- packages/ui/src/chat-view.tsx | 15 +- packages/ui/src/prompt-anchor-rail.tsx | 43 ++- .../ui/src/transcript-scroll-authority.tsx | 253 ++++++++++++++++++ packages/ui/src/use-chat-scroll.ts | 82 +++--- packages/ui/src/use-turn-virtualizer.ts | 13 + patches/@astryxdesign+core+0.5.0.patch | 149 ++--------- 18 files changed, 663 insertions(+), 554 deletions(-) delete mode 100644 packages/ui/src/__tests__/chat-conversation-swap-scroll.test.tsx create mode 100644 packages/ui/src/__tests__/transcript-scroll-authority.test.ts delete mode 100644 packages/ui/src/__tests__/use-chat-scroll.test.tsx create mode 100644 packages/ui/src/chat-scroll-anchor.ts create mode 100644 packages/ui/src/transcript-scroll-authority.tsx diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index d532b01bdb..fd0059e636 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -2821,9 +2821,11 @@ function AppShellContent({ ) ) : ( diff --git a/apps/desktop/src/renderer/workhub-surface.tsx b/apps/desktop/src/renderer/workhub-surface.tsx index 49ca281aea..fbaea31fa6 100644 --- a/apps/desktop/src/renderer/workhub-surface.tsx +++ b/apps/desktop/src/renderer/workhub-surface.tsx @@ -363,7 +363,6 @@ export function WorkHubSurface(props: { return (
- + undefined} emptyOverride={emptyOverride} />
diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index d13143bab6..a47e1fc4ec 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 219 files — blocker 0, polish 1, aligned 218. +**Totals:** 220 files — blocker 0, polish 1, aligned 219. ## Exclusions (explicit) @@ -242,6 +242,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/tool-activity/diff-code-preview.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/tool-activity/tool-code-block.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/tool-activity/tool-result-preview.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned | +| `packages/ui/src/transcript-scroll-authority.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/ui.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/user-question-prompt.tsx` | ui-composition | Button, TextInput | aligned — uses Astryx (Button, TextInput) | aligned | | `packages/ui/src/workspace-picker.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index 67daab1532..8b0a5a6970 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -214,6 +214,7 @@ packages/ui/src/tool-activity/agent-preview.tsx packages/ui/src/tool-activity/diff-code-preview.tsx packages/ui/src/tool-activity/tool-code-block.tsx packages/ui/src/tool-activity/tool-result-preview.tsx +packages/ui/src/transcript-scroll-authority.tsx packages/ui/src/ui.tsx packages/ui/src/user-question-prompt.tsx packages/ui/src/workspace-picker.tsx diff --git a/packages/ui/src/__tests__/chat-conversation-swap-scroll.test.tsx b/packages/ui/src/__tests__/chat-conversation-swap-scroll.test.tsx deleted file mode 100644 index c7bb6fc5eb..0000000000 --- a/packages/ui/src/__tests__/chat-conversation-swap-scroll.test.tsx +++ /dev/null @@ -1,143 +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. - */ - -/** - * 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__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts new file mode 100644 index 0000000000..ea3616fbe1 --- /dev/null +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -0,0 +1,184 @@ +/* + * 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. + */ + +/** + * The state machine only. Whether the reader ends up looking at the right + * pixels is `apps/desktop/e2e/transcript-scroll.spec.ts`, in a real Chromium + * with a real scroller — a harness that fakes layout can only report the + * ordering the harness itself chose. + * + * What is worth asserting here is the one property the whole design rests on: + * a scroll event that this authority did not cause is the reader, exactly, with + * no signal in between to be wrong about. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createTranscriptScrollAuthority } from '../transcript-scroll-authority.js'; + +interface FakeRoot { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + addEventListener(type: string, listener: () => void): void; + removeEventListener(type: string, listener: () => void): void; + /** Dispatch the scroll event the browser would, one frame later. */ + emitScroll(): void; + grow(by: number): void; +} + +function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): FakeRoot { + const listeners = new Set<() => void>(); + const root: FakeRoot = { + scrollTop: 0, + scrollHeight: options?.scrollHeight ?? 3_000, + clientHeight: options?.clientHeight ?? 600, + addEventListener(type, listener) { + if (type === 'scroll') listeners.add(listener); + }, + removeEventListener(_type, listener) { + listeners.delete(listener); + }, + emitScroll() { + for (const listener of [...listeners]) listener(); + }, + grow(by) { + root.scrollHeight += by; + }, + }; + // The browser clamps a write past the end; without that the "we wrote it" + // and "the reader is at the tail" cases would not agree on any number. + return new Proxy(root, { + set(target, property, value) { + if (property === 'scrollTop') { + target.scrollTop = Math.min(value as number, target.scrollHeight - target.clientHeight); + return true; + } + return Reflect.set(target, property, value); + }, + }); +} + +/** + * Frames are explicit: the flag that says "this scroll was ours" is cleared on + * the next frame, and every case below turns on whether the event arrives + * before or after that. + */ +function withFrames(run: (flush: () => void) => T): T { + const pending: FrameRequestCallback[] = []; + const originalWindow = (globalThis as { window?: unknown }).window; + const handles = new Map(); + let nextHandle = 1; + (globalThis as { window?: unknown }).window = { + requestAnimationFrame(callback: FrameRequestCallback) { + const handle = nextHandle++; + handles.set(handle, callback); + pending.push(callback); + return handle; + }, + cancelAnimationFrame(handle: number) { + const callback = handles.get(handle); + handles.delete(handle); + const index = callback ? pending.indexOf(callback) : -1; + if (index >= 0) pending.splice(index, 1); + }, + }; + try { + return run(() => { + const frame = pending.splice(0, pending.length); + for (const callback of frame) callback(0); + }); + } finally { + (globalThis as { window?: unknown }).window = originalWindow; + } +} + +test('content that grows under a pinned transcript keeps the tail on screen', () => { + withFrames((flush) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(root.scrollTop, 2_400); + + root.grow(500); + authority.notifyContentResize(); + assert.equal(root.scrollTop, 2_900); + + // The write's own scroll event lands before the frame that clears the flag, + // which is the whole reason the flag exists. + root.emitScroll(); + flush(); + assert.equal(authority.getSnapshot().pinned, true); + }); +}); + +test('a scroll this authority did not write is the reader, and releases the tail', () => { + withFrames((flush) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + flush(); + + root.scrollTop = 1_000; + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, false); + assert.equal(authority.getSnapshot().awayFromTail, true); + + // Nothing arriving afterwards may move the reader: with the pin released + // this authority writes nothing at all, and native anchoring holds the + // position the reader chose. + root.grow(4_000); + authority.notifyContentResize(); + assert.equal(root.scrollTop, 1_000); + }); +}); + +test('returning to the tail re-pins, and following resumes', () => { + withFrames((flush) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + flush(); + root.scrollTop = 0; + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, false); + + authority.pinToTail(); + assert.equal(root.scrollTop, 2_400); + assert.equal(authority.getSnapshot().awayFromTail, false); + flush(); + + root.grow(600); + authority.notifyContentResize(); + assert.equal(root.scrollTop, 3_000); + }); +}); + +test('a detached authority writes nothing and reports the tail', () => { + withFrames(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + detach(); + root.scrollTop = 0; + root.grow(1_000); + authority.notifyContentResize(); + assert.equal(root.scrollTop, 0); + }); +}); diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx deleted file mode 100644 index 17d4cfa9cc..0000000000 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ /dev/null @@ -1,207 +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. - */ - -/** - * 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/chat-scroll-anchor.ts b/packages/ui/src/chat-scroll-anchor.ts new file mode 100644 index 0000000000..1e1dd2eefd --- /dev/null +++ b/packages/ui/src/chat-scroll-anchor.ts @@ -0,0 +1,64 @@ +/* + * 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. + */ + +/** + * The one hole `overflow-anchor: auto` leaves: the browser declines to anchor + * while the scroller sits at zero, which is exactly where earlier history is + * asked for. This is the compensation for that single call point and nothing + * else — everywhere else, native anchoring already does this continuously and + * for free. + * + * The anchor is an element, not a height. `scrollHeight` deltas count growth + * below the reader too, and count a load that returned nothing as a push. + * Measuring one turn's box before and after answers only the question asked: + * how far did the content the reader is looking at move. + */ + +export interface ChatScrollAnchor { + readonly turnId: string; + readonly top: number; +} + +export function captureChatScrollAnchor(root: HTMLElement): ChatScrollAnchor | undefined { + // The first turn the reader can actually see, not the first one mounted. The + // virtual window mounts turns above the viewport too, and those are exactly + // the ones it is free to drop while the load lands — an anchor it unmounted + // can no longer be measured, and the compensation silently does nothing. + const rootTop = root.getBoundingClientRect().top; + for (const turn of root.querySelectorAll('[data-turn-id]')) { + if (turn.getBoundingClientRect().bottom <= rootTop) continue; + const turnId = turn.dataset.turnId; + if (!turnId) continue; + return { turnId, top: turn.getBoundingClientRect().top }; + } + return undefined; +} + +export function restoreChatScrollAnchor( + root: HTMLElement, + anchor: ChatScrollAnchor | undefined, +): boolean { + if (!anchor) return false; + const turn = root.querySelector( + `[data-turn-id="${CSS.escape(anchor.turnId)}"]`, + ); + if (!turn) return false; + root.scrollTop += turn.getBoundingClientRect().top - anchor.top; + return true; +} diff --git a/packages/ui/src/chat-surface-layout.tsx b/packages/ui/src/chat-surface-layout.tsx index 520d8e05e8..12769cb0b3 100644 --- a/packages/ui/src/chat-surface-layout.tsx +++ b/packages/ui/src/chat-surface-layout.tsx @@ -20,27 +20,41 @@ import { useMemo, type ComponentProps } from 'react'; import { ChatLayout } from '@astryxdesign/core/Chat'; import { AstryxLocaleProvider } from './astryx-i18n.js'; +import { + TranscriptScrollAuthorityProvider, + TranscriptScrollButton, +} from './transcript-scroll-authority.js'; import { cn } from './utils.js'; /** - * Stock ChatLayoutProps plus the patch-package conversationKey seam - * (`patches/@astryxdesign+core+0.3.0.patch`): resets scroll / unread state when - * the host switches conversations in place without remounting the composer. + * Stock ChatLayoutProps plus the patch-package `autoScroll` seam + * (`patches/@astryxdesign+core+0.5.0.patch`), which forwards Astryx's own + * published `enabled` option down to `useChatStreamScroll`. * * Intersection is explicit because some TS resolutions only see the published - * Astryx destructure list (which omits conversationKey) via ComponentProps. + * Astryx destructure list (which omits autoScroll) via ComponentProps. */ export type ChatSurfaceLayoutProps = ComponentProps & { - conversationKey?: string | number; + autoScroll?: boolean; + /** + * Who positions this transcript. + * + * `astryx` keeps the library's auto-follow, for the surfaces that render + * their own content rather than a `ChatView`. `host` turns Astryx's scroll + * layer off entirely — no listeners, no spring — and hands `scrollTop` to + * Maka's single authority, which is what a `ChatView` transcript needs: it + * knows turn identity, the virtual window and the navigation the reader + * asked for, none of which a generic scroll container can see. + */ + scrollOwner?: 'astryx' | 'host'; scrollToBottomLabel?: string; }; /** * Maka's product seam for the Astryx chat page shell. * - * Astryx owns scrolling, new-message following, the bottom dock, and the - * scroll-to-bottom affordance. Maka supplies only transcript and composer - * content through the published ChatLayout slots. + * Astryx owns the bottom dock and the message area. Whether it also owns + * scrolling is `scrollOwner`'s answer, and there is never more than one owner. * * The density default drops a `compact` override and lets Astryx's own default * (`balanced`) stand. Compact spends spacing-2 on the dock's gutters — 8px @@ -56,10 +70,11 @@ export type ChatSurfaceLayoutProps = ComponentProps & { export function ChatSurfaceLayout({ className, density = 'balanced', - conversationKey, + scrollOwner = 'astryx', scrollToBottomLabel, ...props }: ChatSurfaceLayoutProps) { + const hostOwned = scrollOwner === 'host'; const astryxOverrides = useMemo( () => scrollToBottomLabel @@ -72,15 +87,23 @@ export function ChatSurfaceLayout({ const layout = ( : props.scrollButton} density={density} className={cn('maka-chat-layout', className)} data-chat-scroll-container="true" /> ); - return astryxOverrides ? ( + const localized = astryxOverrides ? ( {layout} ) : ( layout ); + return hostOwned ? ( + {localized} + ) : ( + localized + ); } diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index e879bd88b8..f7d30f041b 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -58,6 +58,7 @@ import { type TurnPresentationDeriver, } from './chat-turn.js'; import { useChatScroll } from './use-chat-scroll.js'; +import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; import { useTurnVirtualizer } from './use-turn-virtualizer.js'; import { placeChatConversationItems } from './chat-conversation-items.js'; import { useUiLocale } from './locale-context.js'; @@ -506,6 +507,7 @@ export function ChatView(props: { throw new Error('ChatView must be rendered inside ChatSurfaceLayout'); } const scrollRef = chatLayout.scrollContainerRef; + const scrollAuthority = useTranscriptScrollAuthority(); const orderedTurnIds = useMemo(() => turns.map((turn) => turn.turnId), [turns]); const sessionId = props.activeSession?.id; const { @@ -520,6 +522,7 @@ export function ChatView(props: { scrollRef, targetTurnId: props.scrollTargetTurn?.turnId, targetKey: props.scrollTargetTurn?.nonce, + onContentResize: scrollAuthority?.notifyContentResize, }); const navigatePromptRailFallback = useCallback((turn: PromptAnchorRailTurn) => { if (turnIdsRef.current.has(turn.turnId)) revealTurn(turn.turnId); @@ -558,7 +561,6 @@ export function ChatView(props: { hasOlderHistory: props.hasOlderHistory, historyLoadPending: props.historyLoadPending, onLoadEarlierHistory: props.onLoadEarlierHistory, - unlockAutoFollow: chatLayout.unlockAutoFollow, }); const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( scrollRef, @@ -671,7 +673,14 @@ export function ChatView(props: { description={props.returnToLatest.description} actionLabel={props.returnToLatest.label} isPending={props.returnToLatest.isPending} - onReturnToLatest={() => props.returnToLatest?.onClick()} + onReturnToLatest={async () => { + // Loading the latest range is the shell's job; putting the viewport + // on it is this view's, and setting the pin is the whole of it — + // the range that arrives afterwards is growth, and growth is + // already followed. + await props.returnToLatest?.onClick(); + scrollAuthority?.pinToTail(); + }} /> ) : null} string | null; - /** Astryx's auto-follow release, re-asserted for the life of the hold. */ + /** The tail release, re-asserted for the life of the hold. */ releaseAutoFollow?: (() => void) | undefined; onSettled: () => void; scheduler?: PromptRailFrameScheduler; @@ -162,7 +156,7 @@ export function holdJumpDestination(input: { // Quiet means nothing moved at all — not the content, not the position. // Height alone was not enough: with the transcript already mounted there // is nothing to re-aim through, and the hold released three frames in, - // handing the highlight and the auto-follow release back while the jump's + // handing the highlight and the tail release back while the jump’s // own scroll was still in flight. if ( !grew && @@ -245,14 +239,11 @@ export interface PromptAnchorRailProps { /** When the bounded virtual window has not placed the turn in the DOM. */ onNavigateFallback?: (turn: PromptAnchorRailTurn) => void; /** - * Release Astryx's auto-follow before a jump scrolls. + * Stop following the tail, before a jump scrolls. * - * ChatLayout keeps the transcript pinned to the bottom while a turn streams - * and unlocks when the reader scrolls up, which it detects by comparing - * scrollTop between scroll events — but it discards any scroll event that - * arrives with a changed scrollHeight, since Chrome fires those on content - * resize and they are not the reader moving. A jump to an unmounted turn - * changes the virtual window's height, so auto-follow must be released first. + * A tick is the reader choosing where to look, which outranks the tail. It + * has to be said before the scroll, not after: released afterwards, the + * release lands on a viewport the pin has already written back to the bottom. */ onNavigateStart?: (() => void) | undefined; } @@ -491,9 +482,9 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe const turnId = turn.turnId; const root = scrollRef.current; const el = root?.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`); - // Before the scroll, not after: auto-follow has to be released while the + // Before the scroll, not after: the tail has to be released while the // transcript is still where the reader left it, or the release lands after - // it has already pulled the view back to the bottom. + // the next growth has already written the view back to the bottom. onNavigateStart?.(); // Claimed before the scroll starts: a same-frame `scroll` event would // otherwise reach the observer while the highlight is still unowned. @@ -503,8 +494,8 @@ export const PromptAnchorRail = memo(function PromptAnchorRail({ turns, scrollRe // teleport the reader asked for, not a journey — and an animated one // does not survive this surface: traced against a 30-prompt session, the // smooth scroll was cancelled by the mount's own scroll compensation and - // by auto-follow's spring, and stalled two pixels from where it started. - // Landing reliably beats animating unreliably. + // stalled two pixels from where it started. Landing reliably beats + // animating unreliably. (el as HTMLElement).scrollIntoView({ behavior: 'auto', block: 'start' }); } else if (!el) { onNavigateFallback?.(turn); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx new file mode 100644 index 0000000000..c60499f84d --- /dev/null +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -0,0 +1,253 @@ +/* + * 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. + */ + +/** + * The one thing that answers "where should the transcript be looking". + * + * Three writers used to move `scrollTop` — Astryx's lock/spring, Maka's + * compensation and `scrollIntoView`, and the browser's own anchoring — and none + * of them held the answer, so they avoided each other through flags and effect + * ordering. This file is the answer, and it is one boolean: + * + * pinned → content that grows writes `scrollTop = scrollHeight` + * !pinned → nothing here writes `scrollTop`, ever + * + * "Keep the reader where they were reading" is the definition of + * `overflow-anchor: auto`, which is already the initial value and costs nothing, + * and "the reader is dragging" is also just don't touch it — so both of those + * are the same instruction to this code: stay out of the way. + * + * Being the only writer is what makes the state exact rather than guessed. A + * write flags itself, so an unflagged scroll event is the reader by + * construction. Astryx had to infer that from scroll direction, height deltas + * and wheel events, and every one of those signals has more than one cause. + */ + +import { + createContext, + useContext, + useRef, + useSyncExternalStore, + type ReactNode, +} from 'react'; +import { ChatLayoutScrollButton } from '@astryxdesign/core/Chat'; +import { restoreChatScrollAnchor, type ChatScrollAnchor } from './chat-scroll-anchor.js'; + +/** Astryx's own thresholds, so the affordance keeps the feel readers learnt. */ +const PIN_THRESHOLD_PX = 10; +const BUTTON_THRESHOLD_PX = 100; + +export interface TranscriptScrollSnapshot { + /** Following the tail: growth writes `scrollTop`. */ + readonly pinned: boolean; + /** Far enough up that the return-to-tail affordance earns its place. */ + readonly awayFromTail: boolean; +} + +export interface TranscriptScrollAuthority { + /** Take the scroller. Returns the detach for the effect that called it. */ + attach(root: HTMLElement | null): () => void; + /** + * The transcript's box changed. The only moment `pinned` writes `scrollTop`, + * and the only growth signal — there is no second observer. + */ + notifyContentResize(): void; + /** One-shot: put the tail back under the reader and follow it again. */ + pinToTail(): void; + /** + * Keep `anchor` at the viewport offset it had, through whatever arrives + * next. `overflow-anchor: auto` already does this continuously and for free, + * with one exception — the browser declines to anchor while the scroller sits + * at zero, which is precisely where loading earlier history puts the reader. + * + * The hold lasts until the reader scrolls, which is the moment it stops being + * true that they want to stay put. It runs on the same growth signal the pin + * does, so the correction lands in the frame the content arrived rather than + * after the virtual window has already been recomputed around the old + * position. + */ + holdAnchor(anchor: ChatScrollAnchor | undefined): void; + /** + * The reader chose a position, so stop following. A command that moves the + * viewport itself calls this first; afterwards nothing here writes, which is + * why a command cannot race the policy. + */ + releasePin(): void; + subscribe(listener: () => void): () => void; + getSnapshot(): TranscriptScrollSnapshot; +} + +export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { + let root: HTMLElement | null = null; + let pinned = true; + let awayFromTail = false; + let writing = false; + let writingFrame = 0; + let anchor: ChatScrollAnchor | undefined; + let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail }; + const listeners = new Set<() => void>(); + + const publish = (): void => { + if (snapshot.pinned === pinned && snapshot.awayFromTail === awayFromTail) return; + snapshot = { pinned, awayFromTail }; + for (const listener of listeners) listener(); + }; + + const distanceToTail = (): number => + root ? root.scrollHeight - root.scrollTop - root.clientHeight : 0; + + const markWriting = (): void => { + writing = true; + if (writingFrame !== 0) window.cancelAnimationFrame(writingFrame); + // A scroll event is dispatched asynchronously, so clearing this on the + // current turn would let our own write read as a gesture; clearing it any + // later than the next frame would swallow the reader's next one. + writingFrame = window.requestAnimationFrame(() => { + writingFrame = 0; + writing = false; + }); + }; + + const writeToTail = (): void => { + if (!root) return; + markWriting(); + root.scrollTop = root.scrollHeight; + awayFromTail = false; + publish(); + }; + + return { + attach(next) { + root = next; + const target = root; + if (!target) return () => undefined; + const onScroll = (): void => { + // Everything this authority writes flags itself, so an unflagged event + // is the reader — exactly, not by inference. Nested scrollers (a tool + // output box, a terminal) never reach here at all: `scroll` does not + // bubble, and there is no `wheel` listener to catch instead. + if (writing) return; + // The reader moved, so they are no longer asking to stay put. + anchor = undefined; + const distance = distanceToTail(); + pinned = distance <= PIN_THRESHOLD_PX; + awayFromTail = distance > BUTTON_THRESHOLD_PX; + publish(); + }; + target.addEventListener('scroll', onScroll, { passive: true }); + if (pinned) writeToTail(); + return () => { + target.removeEventListener('scroll', onScroll); + anchor = undefined; + if (writingFrame !== 0) { + window.cancelAnimationFrame(writingFrame); + writingFrame = 0; + writing = false; + } + if (root === target) root = null; + }; + }, + notifyContentResize() { + if (!root) return; + if (pinned) { + writeToTail(); + return; + } + if (anchor) { + markWriting(); + restoreChatScrollAnchor(root, anchor); + } + awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; + publish(); + }, + pinToTail() { + pinned = true; + anchor = undefined; + writeToTail(); + publish(); + }, + holdAnchor(next) { + anchor = next; + }, + releasePin() { + pinned = false; + awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; + publish(); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getSnapshot() { + return snapshot; + }, + }; +} + +const TranscriptScrollContext = createContext(null); + +/** + * Deliberately holds no React state: the pin crosses its thresholds on + * scroll, and a provider that re-rendered on each crossing would re-render the + * whole transcript under it. The button subscribes instead. + */ +export function TranscriptScrollAuthorityProvider({ children }: { children: ReactNode }) { + const authority = useRef(undefined); + authority.current ??= createTranscriptScrollAuthority(); + return ( + {children} + ); +} + +export function useTranscriptScrollAuthority(): TranscriptScrollAuthority | null { + return useContext(TranscriptScrollContext); +} + +const DETACHED_SNAPSHOT: TranscriptScrollSnapshot = { pinned: true, awayFromTail: false }; + +/** + * The dock's scroll-to-bottom affordance, driven by Maka's pin rather than + * Astryx's — with auto-scroll off, `isScrolledUp` never updates again, so the + * stock button would be permanently invisible. + * + * The label stays unset on purpose: `ChatSurfaceLayout` overrides Astryx's + * `scrollToBottom` string through the locale provider that wraps this. + */ +export function TranscriptScrollButton() { + const authority = useTranscriptScrollAuthority(); + const snapshot = useSyncExternalStore( + authority?.subscribe ?? noopSubscribe, + authority?.getSnapshot ?? detachedSnapshot, + detachedSnapshot, + ); + return ( + authority?.pinToTail()} + /> + ); +} + +function noopSubscribe(): () => void { + return () => undefined; +} + +function detachedSnapshot(): TranscriptScrollSnapshot { + return DETACHED_SNAPSHOT; +} diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index bfb4bc8d93..dfa82343b9 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -17,8 +17,20 @@ * under the License. */ +/** + * The transcript's scroll commands, and the seam that hands the scroller to the + * authority that owns it (`transcript-scroll-authority.ts`). + * + * A command is one-shot: jump to a turn the reader picked, and compensate the + * earlier history that lands above them. Each releases the pin first, and the + * authority writes nothing while the pin is released — so a command cannot be + * fighting a policy, which is the shape every previous round of this code had. + */ + import { useEffect, useRef, useState, type RefObject } from 'react'; import type { StoredMessage } from '@maka/core/session'; +import { captureChatScrollAnchor } from './chat-scroll-anchor.js'; +import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; export function useChatScroll(input: { scrollRef: RefObject; @@ -29,19 +41,34 @@ export function useChatScroll(input: { hasOlderHistory?: boolean; historyLoadPending?: boolean; onLoadEarlierHistory?(): Promise | void; - /** Astryx's auto-follow release, for the moves the reader asks for. */ - unlockAutoFollow?(): void; }) { const [highlightedTurnId, setHighlightedTurnId] = useState(null); - const unlockAutoFollowRef = useRef(input.unlockAutoFollow); - unlockAutoFollowRef.current = input.unlockAutoFollow; + const authority = useTranscriptScrollAuthority(); + const authorityRef = useRef(authority); + authorityRef.current = authority; const loadEarlierRef = useRef(input.onLoadEarlierHistory); loadEarlierRef.current = input.onLoadEarlierHistory; const historyLoadPendingRef = useRef(input.historyLoadPending); historyLoadPendingRef.current = input.historyLoadPending; const canLoadEarlier = input.onLoadEarlierHistory !== undefined; const earlierLoadRequest = useRef(null); - const releasedForTarget = useRef(null); + const handledTarget = useRef(null); + + // A passive effect, not a layout one: the scroller is Astryx's layout root, + // an ancestor, and React attaches a parent's ref after its children's layout + // effects have already run. The growth signal is a ResizeObserver delivery, + // which lands after passive effects, so this is still installed in time. + useEffect(() => { + if (!authority) return; + return authority.attach(input.scrollRef.current); + }, [authority, input.scrollRef]); + + // A new conversation arrives at its tail. Nothing special positions it: the + // pin is set here and the first fill is growth like any other, so it takes + // the one path instead of a first-fill path of its own. + useEffect(() => { + authorityRef.current?.pinToTail(); + }, [input.sessionId]); useEffect(() => { earlierLoadRequest.current = null; @@ -50,34 +77,29 @@ export function useChatScroll(input: { useEffect(() => { const root = input.scrollRef.current; if (!root || !input.hasOlderHistory || !canLoadEarlier) return; - let previousScrollTop = root.scrollTop; const requestEarlier = (): void => { if (historyLoadPendingRef.current || earlierLoadRequest.current) return; - const scrollHeight = root.scrollHeight; const request = {}; earlierLoadRequest.current = request; - unlockAutoFollowRef.current?.(); + const authority = authorityRef.current; + authority?.releasePin(); + // Where the reader is, not how tall the transcript was. A height delta + // counts growth below them too, and counts a load that returned nothing + // as a push; an element moves by exactly what the reader would see. + authority?.holdAnchor(captureChatScrollAnchor(root)); 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) return; - if (root.isConnected && root.scrollTop === 0) { - root.scrollTop += root.scrollHeight - scrollHeight; - } - earlierLoadRequest.current = null; - }); + if (earlierLoadRequest.current === request) earlierLoadRequest.current = null; }); }; + // Position, not direction: a shrinking transcript also lowers `scrollTop`, + // and asking for history the reader already has is idempotent anyway. const nearStart = (): boolean => root.scrollTop <= Math.max(640, root.clientHeight * 2); const onScroll = (): void => { - const nextScrollTop = root.scrollTop; - if (nextScrollTop < previousScrollTop && nearStart()) requestEarlier(); - previousScrollTop = nextScrollTop; + if (nearStart()) requestEarlier(); }; + // At `scrollTop === 0` there is no scroll event left to fire, so the wheel + // is the only way the reader can ask for more. const onWheel = (event: WheelEvent): void => { if (event.deltaY < 0 && nearStart()) requestEarlier(); }; @@ -98,21 +120,19 @@ export function useChatScroll(input: { useEffect(() => { const target = input.target; if (!target?.turnId) return; - // 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. + // This effect re-runs on every transcript update so a target that arrives + // before its turn still lands. It stops for good once the turn is on + // screen — repeating the release afterwards would take the tail away from + // a reader who had already scrolled back to it. const chosen = `${input.sessionId ?? ''}:${target.turnId}:${target.nonce}`; - if (releasedForTarget.current !== chosen) { - releasedForTarget.current = chosen; - unlockAutoFollowRef.current?.(); - } + if (handledTarget.current === chosen) return; + authorityRef.current?.releasePin(); const frame = window.requestAnimationFrame(() => { const root = input.scrollRef.current; if (!root) return; const element = root.querySelector(`[data-turn-id="${CSS.escape(target.turnId)}"]`); if (!element || !('scrollIntoView' in element)) return; + handledTarget.current = chosen; const targetElement = element as HTMLElement; targetElement.setAttribute('tabindex', '-1'); targetElement.scrollIntoView({ diff --git a/packages/ui/src/use-turn-virtualizer.ts b/packages/ui/src/use-turn-virtualizer.ts index 2f43abadf1..aea0e4c8eb 100644 --- a/packages/ui/src/use-turn-virtualizer.ts +++ b/packages/ui/src/use-turn-virtualizer.ts @@ -57,6 +57,13 @@ export function useTurnVirtualizer(input: { targetTurnId?: string; targetKey?: string | number; scrollRef: RefObject; + /** + * The transcript's box changed. This hook already observes the scroller and + * every mounted turn to keep its height index, so the scroll authority reads + * its growth signal from here rather than installing a second observer over + * the same elements. + */ + onContentResize?(): void; }) { const [geometryRevision, setGeometryRevision] = useState(0); const root = input.scrollRef.current; @@ -131,6 +138,8 @@ export function useTurnVirtualizer(input: { const layoutRef = useRef(layout); const stateRef = useRef(current); + const contentResizeRef = useRef(input.onContentResize); + contentResizeRef.current = input.onContentResize; const pendingReveal = useRef(undefined); useLayoutEffect(() => { @@ -243,6 +252,10 @@ export function useTurnVirtualizer(input: { } } if (changed) setGeometryRevision((revision) => revision + 1); + // Before the window work, and synchronously: this is the observer + // callback, so the pin still writes `scrollTop` in the same frame the + // content grew and the reader never sees the tail slip. + contentResizeRef.current?.(); 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 fe67382dff..565aa381c1 100644 --- a/patches/@astryxdesign+core+0.5.0.patch +++ b/patches/@astryxdesign+core+0.5.0.patch @@ -1,39 +1,25 @@ -diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts b/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts -index d1bfeeb..b4a0e62 100644 ---- a/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts -+++ b/node_modules/@astryxdesign/core/dist/Chat/ChatContext.d.ts -@@ -61,6 +61,13 @@ export interface ChatLayoutContextValue { - scrollContainerRef: React.RefObject; - /** Callback ref for the message list content element — layout observes it for size changes. */ - contentRef: (el: HTMLElement | null) => void; -+ /** -+ * Release auto-follow because the host is navigating the transcript -+ * itself. The scroll-direction unlock cannot see such a move when the -+ * host mounts content before scrolling: that scroll event carries a -+ * changed scrollHeight and is read as a resize artefact. -+ */ -+ unlockAutoFollow?: () => void; - } - export declare const ChatLayoutContext: import("react").Context; - export declare function useChatLayoutContext(): ChatLayoutContextValue | null; diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts -index ff34874..9ec8d7a 100644 +index ff34874..0f5ae14 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts -@@ -69,6 +69,11 @@ export interface ChatLayoutProps extends BaseProps { +@@ -69,6 +69,15 @@ export interface ChatLayoutProps extends BaseProps { * @default 'balanced' */ density?: Density; + /** -+ * Per-conversation identity for hosts that switch conversations in place. -+ * Resets scroll and unread state without remounting composer content. ++ * Whether the layout's own auto-follow runs. Forwards `useChatStreamScroll`'s ++ * published `enabled` option, for hosts that position the transcript ++ * themselves; off, the layout installs no scroll listeners and never writes ++ * `scrollTop`, so the host is the only writer. ++ * ++ * @default true + */ -+ conversationKey?: string | number; ++ autoScroll?: boolean; } 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..6d427d0 100644 +index ff9b9fa..6d4df27 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js +++ b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.js @@ -28,7 +28,7 @@ @@ -49,11 +35,22 @@ index ff9b9fa..6d427d0 100644 className, style, 'data-testid': testId, -+ conversationKey, ++ autoScroll = true, ref, ...rest }) { -@@ -211,6 +212,29 @@ export function ChatLayout({ +@@ -205,12 +206,21 @@ export function ChatLayout({ + + // --- Default scroll behavior --- + const scroll = useChatStreamScroll({ +- scrollRef: scrollContainerRef ++ scrollRef: scrollContainerRef, ++ // maka: forward the hook's own published switch. Off, it installs no ++ // listeners and moves nothing, which is what a host needs when the host is ++ // the one positioning the transcript. ++ enabled: autoScroll + }); + const newMsgs = useChatNewMessages({ isLocked: scroll.isLocked, onResize: scroll.scrollIfLocked }); @@ -62,44 +59,9 @@ index ff9b9fa..6d427d0 100644 + newMsgs.dismiss(); + } + }, [scroll.isLocked, newMsgs.dismiss]); -+ const conversationKeyRef = useRef(conversationKey); -+ useEffect(() => { -+ if (conversationKey === conversationKeyRef.current) { -+ return; -+ } -+ conversationKeyRef.current = conversationKey; -+ // 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.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 +247,14 @@ export function ChatLayout({ - // --- Layout context --- - const layoutContext = useMemo(() => ({ - scrollContainerRef, -- contentRef: newMsgs.contentRef -- }), [scrollContainerRef, newMsgs.contentRef]); -+ contentRef: newMsgs.contentRef, -+ // maka: programmatic navigation seam. A host that scrolls the transcript -+ // itself (jumping to an earlier turn) has no way to tell auto-follow that -+ // the move was intentional: the scroll-up unlock is skipped whenever the -+ // event arrives with a changed scrollHeight, which is exactly what a host -+ // that mounts content before scrolling produces. -+ unlockAutoFollow: scroll.unlock -+ }), [scrollContainerRef, newMsgs.contentRef, scroll.unlock]); - - // --- Derived styles --- - const showEmpty = !hasVisibleContent(children); diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js b/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js index 889970f..459e6e8 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatToolCalls.js @@ -113,7 +75,7 @@ index 889970f..459e6e8 100644 "aria-controls": hasDetail && isDetailOpen ? detailId : undefined, onClick: toggleDetail, diff --git a/node_modules/@astryxdesign/core/dist/Chat/useChatNewMessages.js b/node_modules/@astryxdesign/core/dist/Chat/useChatNewMessages.js -index 8c509bc..4b09993 100644 +index 8c509bc..5f46785 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/useChatNewMessages.js +++ b/node_modules/@astryxdesign/core/dist/Chat/useChatNewMessages.js @@ -49,6 +49,8 @@ export function useChatNewMessages({ @@ -125,69 +87,6 @@ index 8c509bc..4b09993 100644 observeResize(el, () => { onResizeRef.current?.(); const messages = el.getElementsByClassName('astryx-chat-message'); -@@ -90,9 +92,14 @@ export function useChatNewMessages({ - const dismiss = useCallback(() => { - setHasNewMessages(false); - }, []); -+ const reset = useCallback(() => { -+ lastMessageRef.current = null; -+ setHasNewMessages(false); -+ }, []); - return { - hasNewMessages, - dismiss, -+ reset, - contentRef - }; - } -\ 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; -@@ -303,6 +310,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 From 782b5b772db39dd4f1b298bf895208c854a6d4ea Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 11:40:53 +0800 Subject: [PATCH 08/10] test(desktop): assert what the reader sees while the transcript scrolls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five Playwright assertions over the real Electron window, one per property the single scroll authority is supposed to have: a streaming answer keeps the tail on screen, content arriving after the reader scrolls up does not pull them back, a gesture a nested scroller consumed does not release the tail, the dock affordance returns the reader to the tail, and earlier history lands above the turn the reader is on. They assert element positions rather than pixel deltas. Where lag has to be measured at all it is self-calibrating — the worst frame's lag is compared against that frame's own growth, because following by `ResizeObserver` is one frame behind by construction and that frame is never painted. A fixed pixel budget would encode the machine it was written on. Generated-by: Claude Opus 5 via Claude Code --- apps/desktop/e2e/transcript-scroll.spec.ts | 355 +++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 apps/desktop/e2e/transcript-scroll.spec.ts diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts new file mode 100644 index 0000000000..db7601480e --- /dev/null +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -0,0 +1,355 @@ +/* + * 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 { expect, test, COMPOSER_INPUT } from './fixtures'; +import type { Page } from '@playwright/test'; + +/** + * Where the transcript is looking, in a real Chromium with a real scroller. + * + * Two rounds of this work shipped green and wrong, both times because the + * instrument could not see the property being claimed: a CLS measurement is + * blind to scroll position, and a linkedom harness decides the effect ordering + * its own assertions then confirm. Nothing below reads a ref or a flag — each + * test states where an element or the viewport ended up, and the app has to put + * it there. + * + * Positions are asserted against an element or against the scroller's own end, + * never as a pixel delta: a delta is satisfiable by two wrongs (the content + * grew by as much as the view moved), which is the bug class that produced the + * `scrollHeight`-difference compensation this replaces. + */ + +const SCROLLER = '[data-chat-scroll-container="true"]'; +const REGENERATE = /^重新生成回答/; +/** Astryx's dock affordance, relabelled by `ChatSurfaceLayout`. */ +const SCROLL_TO_BOTTOM = /^滚动主对话到底部$/; + +/** Sixty lines: more than one viewport once the fake backend echoes it back. */ +const LONG_PROMPT = Array.from( + { length: 60 }, + (_, index) => `第 ${index} 行:这一段用来把转录推过滚动视口的高度。`, +).join('\n'); + +function distanceToTail(page: Page): Promise { + return page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + return Math.round(root.scrollHeight - root.scrollTop - root.clientHeight); + }, SCROLLER); +} + +/** + * Whether the dock affordance is actually offered. It is always in the DOM — + * Astryx toggles opacity and pointer-events — so presence proves nothing and + * `toBeVisible` passes on the transparent one. + */ +function scrollButtonOffered(page: Page): Promise { + return page.evaluate((name) => { + const button = [...document.querySelectorAll('button')].find( + (candidate) => candidate.getAttribute('aria-label') === name + || candidate.textContent?.trim() === name, + ); + if (!button) throw new Error(`the "${name}" affordance is missing`); + const style = getComputedStyle(button); + return style.pointerEvents !== 'none' && Number(style.opacity) > 0.5; + }, '滚动主对话到底部'); +} + +function turnTop(page: Page, turnId: string): Promise { + return page.evaluate((id) => { + const turn = document.querySelector(`[data-turn-id="${CSS.escape(id)}"]`); + if (!turn) throw new Error(`turn ${id} is not mounted`); + return Math.round(turn.getBoundingClientRect().top); + }, turnId); +} + +/** + * Sample the tail through the frames a growing transcript produces. + * + * Read at the start of each frame, which is one frame behind the pin: the + * content commits, the next frame's layout delivers the resize, and the write + * lands before that frame paints. So the view can only ever be behind by what + * arrived since the last delivery — never more, and never cumulatively. That is + * what `worstLag` against `worstFrameGrowth` states, and it is a property no + * fixed pixel budget can express: a transcript that stopped following instead + * falls behind by the whole of `grewBy`. + */ +function measureTailLag(page: Page, frames: number): Promise<{ + worstLag: number; + worstFrameGrowth: number; + grewBy: number; + viewportHeight: number; +}> { + return page.evaluate(([selector, frameCount]) => new Promise<{ + worstLag: number; + worstFrameGrowth: number; + grewBy: number; + viewportHeight: number; + }>((resolve) => { + const root = document.querySelector(selector as string); + if (!root) throw new Error('the chat scroll container is missing'); + const startedAt = root.scrollHeight; + let previousScrollHeight = startedAt; + let worstLag = 0; + let worstFrameGrowth = 0; + let left = frameCount as number; + const tick = (): void => { + const settledTail = previousScrollHeight - root.clientHeight; + worstLag = Math.max(worstLag, Math.abs(root.scrollTop - settledTail)); + worstFrameGrowth = Math.max(worstFrameGrowth, root.scrollHeight - previousScrollHeight); + previousScrollHeight = root.scrollHeight; + // Stops on the content, not on a frame count: when the answer starts + // arriving is the backend's business, and a fixed window can expire + // before it does. + const enough = root.scrollHeight - startedAt > root.clientHeight; + if (enough || --left <= 0) { + resolve({ + worstLag: Math.round(worstLag), + worstFrameGrowth: Math.round(worstFrameGrowth), + grewBy: Math.round(root.scrollHeight - startedAt), + viewportHeight: root.clientHeight, + }); + } else requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }), [SCROLLER, frames] as const); +} + +async function sendPrompt(page: Page, text: string): Promise { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill(text); + await composer.press('Enter'); +} + +/** Answered turns, so a second send can be waited for without a stale match. */ +function answeredTurns(page: Page) { + return page.getByRole('button', { name: REGENERATE }); +} + +async function scrollTranscriptTo(page: Page, top: number): Promise { + await page.evaluate(([selector, position]) => { + const root = document.querySelector(selector as string); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = position as number; + }, [SCROLLER, top] as const); + await waitForPaintedFrames(page); +} + +async function waitForPaintedFrames(page: Page, count = 3): Promise { + await page.evaluate((frames) => new Promise((resolve) => { + const tick = (left: number) => { + if (left <= 0) { + resolve(); + return; + } + requestAnimationFrame(() => tick(left - 1)); + }; + tick(frames); + }), count); +} + +test('a streaming answer keeps the viewport at the tail', async ({ window: page }) => { + // A full fake-backend turn, streamed nine characters at a time. + test.slow(); + await page.setViewportSize({ width: 900, height: 700 }); + await sendPrompt(page, LONG_PROMPT); + + // Measured through the stream, not only at the end: the failure this guards + // against is the tail slipping away *while* content arrives, which a single + // reading afterwards cannot tell apart from a view dragged back at the last + // delta. + const lag = await measureTailLag(page, 1_200); + await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 }); + + // The samples have to have covered more than a viewport of real growth, or + // every reading above is a stationary transcript and proves nothing. + expect(lag.grewBy).toBeGreaterThan(lag.viewportHeight); + expect(lag.worstLag).toBeLessThanOrEqual(lag.worstFrameGrowth + 8); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + expect(await scrollButtonOffered(page)).toBe(false); +}); + +test('content that arrives after the reader scrolls up does not pull them back', async ({ + window: page, +}) => { + test.slow(); + await page.setViewportSize({ width: 900, height: 700 }); + await sendPrompt(page, LONG_PROMPT); + await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 }); + + const transcript = page.locator('.maka-chat-message-list'); + await transcript.hover(); + await page.mouse.wheel(0, -500); + await waitForPaintedFrames(page); + const before = await distanceToTail(page); + expect(before).toBeGreaterThan(100); + expect(await scrollButtonOffered(page)).toBe(true); + + const anchorTurnId = await page.evaluate(() => { + const turn = document.querySelector('[data-turn-id]'); + const turnId = turn?.dataset.turnId; + if (!turnId) throw new Error('the transcript has no mounted turn'); + return turnId; + }); + const anchorTop = await turnTop(page, anchorTurnId); + + await sendPrompt(page, LONG_PROMPT); + await expect(answeredTurns(page)).toHaveCount(2, { timeout: 30_000 }); + await waitForPaintedFrames(page); + + // The turn the reader was on is still where it was. Everything that arrived, + // arrived below them. + expect(Math.abs((await turnTop(page, anchorTurnId)) - anchorTop)).toBeLessThanOrEqual(4); + expect(await distanceToTail(page)).toBeGreaterThan(before); +}); + +test('a gesture a nested scroller consumed does not release the tail', async ({ + window: page, +}) => { + test.slow(); + await page.setViewportSize({ width: 900, height: 700 }); + await sendPrompt(page, LONG_PROMPT); + await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 }); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + + // A real scroller inside the transcript, standing in for a tool-output box + // (`.maka-tool-output-body`, `max-height: 256px; overflow-y: auto`) or a pty + // terminal. Built here rather than fixtured because what is under test is + // Chromium's scroll chain, which does not care where the element came from, + // and no fixture reliably produces an output tall enough to overflow. + const nested = await page.evaluate(() => { + const turns = document.querySelectorAll('[data-turn-id]'); + const turn = turns[turns.length - 1]; + if (!turn) throw new Error('the transcript has no mounted turn'); + const box = document.createElement('div'); + box.dataset.nestedScroller = 'true'; + box.style.cssText = 'max-height:120px;overflow-y:auto'; + const filler = document.createElement('div'); + filler.style.height = '2000px'; + box.append(filler); + turn.append(box); + // Away from both ends, so scrolling up inside it never reaches a boundary + // and never chains to the transcript. + box.scrollTop = 600; + return box.scrollTop; + }); + + // Appending is growth like any other, so the pin brings the new box into + // view — which also keeps Playwright's hover from scrolling to reach it. + await waitForPaintedFrames(page); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + + // The real input pipeline, over the nested element: the gesture crosses the + // transcript on its way up the tree, the nested element consumes it, and the + // transcript never moves — so no `scroll` follows. A tail-follow that watches + // gestures reads this as the reader leaving; one that watches position cannot + // see it at all. Astryx's stock predicate is the former, and its + // `animatingRef` was measured sitting at `true` on a resting transcript, so + // an upward wheel here released the tail with nothing having scrolled. + await page.locator('[data-nested-scroller="true"]').hover(); + await page.mouse.wheel(0, -400); + await waitForPaintedFrames(page); + const nestedAfter = await page.evaluate( + () => document.querySelector('[data-nested-scroller="true"]')?.scrollTop ?? -1, + ); + // The nested box moved, which is what makes this a gesture the transcript + // never saw. Without this the test would pass on a wheel that did nothing. + expect(nestedAfter).toBeLessThan(nested); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + + // The touch equivalent, which no synthetic-free path can produce here. + await page.evaluate(() => { + const target = document.querySelector('[data-turn-id]'); + if (!target) throw new Error('the transcript has no mounted turn'); + target.dispatchEvent(new Event('touchmove', { bubbles: true })); + }); + await waitForPaintedFrames(page); + + // Following is unharmed: a whole further answer lands and the tail is still + // under the reader. A release would have left them a screen and a half up, + // with no gesture of their own to explain it. + await sendPrompt(page, LONG_PROMPT); + await expect(answeredTurns(page)).toHaveCount(2, { timeout: 30_000 }); + await waitForPaintedFrames(page); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + expect(await scrollButtonOffered(page)).toBe(false); +}); + +test('the dock affordance returns the reader to the tail', async ({ window: page }) => { + test.slow(); + await page.setViewportSize({ width: 900, height: 700 }); + await sendPrompt(page, LONG_PROMPT); + await expect(answeredTurns(page)).toHaveCount(1, { timeout: 30_000 }); + + await scrollTranscriptTo(page, 0); + // Offered at all is the assertion: with Astryx's scroll layer off, its + // `isScrolledUp` never updates again, so the stock button would stay + // transparent forever. This one reads Maka's pin. + expect(await scrollButtonOffered(page)).toBe(true); + + await page.getByRole('button', { name: SCROLL_TO_BOTTOM }).click(); + await waitForPaintedFrames(page); + expect(await distanceToTail(page)).toBeLessThanOrEqual(4); + expect(await scrollButtonOffered(page)).toBe(false); +}); + +test('earlier history lands above the turn the reader is on', async ({ + promptRailWindow: page, +}) => { + const loadedTurns = () => + page.locator('.maka-chat-message-list').getAttribute('data-turn-source-count').then((value) => Number(value)); + const loadedBefore = await loadedTurns(); + + // Just short of the band that asks for more, so the virtual window has + // mounted turns around the reader before the load starts. Landing straight on + // zero puts the viewport inside the leading spacer, where there is no turn to + // be reading and nothing to hold still. + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = Math.max(640, root.clientHeight * 2) + 400; + }, SCROLLER); + await waitForPaintedFrames(page, 6); + + // The move that asks for earlier history, and the reading of where the + // reader is, in one task: the scroll event that starts the load is dispatched + // afterwards, so the app anchors on the same position this reads. + const anchor = await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = Math.max(640, root.clientHeight * 2) - 100; + const rootTop = root.getBoundingClientRect().top; + const turn = [...root.querySelectorAll('[data-turn-id]')].find( + (candidate) => candidate.getBoundingClientRect().bottom > rootTop, + ); + const turnId = turn?.dataset.turnId; + if (!turn || !turnId) throw new Error('no turn is on screen'); + return { turnId, top: Math.round(turn.getBoundingClientRect().top) }; + }, SCROLLER); + + await expect.poll(loadedTurns, { timeout: 20_000 }).toBeGreaterThan(loadedBefore); + await waitForPaintedFrames(page); + + // The turns that arrived went above the reader, and the reader did not go + // with them. Asserting the element rather than a `scrollTop` delta is the + // point: a compensation computed from `scrollHeight` satisfies the delta + // while putting the reader somewhere else entirely. + expect(Math.abs((await turnTop(page, anchor.turnId)) - anchor.top)).toBeLessThanOrEqual(4); +}); From 2892fed9ced08ce300f4adeeecc9ca76a7c11911 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 13:26:52 +0800 Subject: [PATCH 09/10] refactor(ui): let one pixel replace the scroll compensation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transcript carried its own compensation for content landing above the reader: capture a turn's box before an earlier-history load, restore that box after it. It existed for one reason — the browser declines to anchor while a scroller sits at `scrollTop === 0`, which is where the wheel path asks for history. Measured in Chromium, that hole is one pixel deep. Inserting 501px above the reader moves `scrollTop` by 501 at an offset of 1, by 503 at 2, by 551 at 50, and by nothing at all at 0. So raise the offset to 1 before asking, and native anchoring covers the case its absence was being compensated for. Everywhere else the compensation was already redundant: the load fires at `scrollTop <= max(640, clientHeight * 2)`, and at every one of those offsets the browser was anchoring anyway, with the restore computing a delta of zero on top of it. A second authority for a fact the platform already owned, live on one boundary out of 641. Deletes `chat-scroll-anchor`, the `holdAnchor` command and the `anchor` field, leaving the authority with one boolean and two commands. The E2E covers the offset that made this possible: it fails, holding the scroller at zero, without the raise. Generated-by: Claude Code --- apps/desktop/e2e/transcript-scroll.spec.ts | 32 ++++++++++ packages/ui/src/chat-scroll-anchor.ts | 64 ------------------- .../ui/src/transcript-scroll-authority.tsx | 26 -------- packages/ui/src/use-chat-scroll.ts | 24 +++---- 4 files changed, 45 insertions(+), 101 deletions(-) delete mode 100644 packages/ui/src/chat-scroll-anchor.ts diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts index db7601480e..425a940cba 100644 --- a/apps/desktop/e2e/transcript-scroll.spec.ts +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -353,3 +353,35 @@ test('earlier history lands above the turn the reader is on', async ({ // while putting the reader somewhere else entirely. expect(Math.abs((await turnTop(page, anchor.turnId)) - anchor.top)).toBeLessThanOrEqual(4); }); + +test('history asked for at the very top of the scroller still lands above the reader', async ({ + promptRailWindow: page, +}) => { + const loadedTurns = () => + page + .locator('.maka-chat-message-list') + .getAttribute('data-turn-source-count') + .then((value) => Number(value)); + const loadedBefore = await loadedTurns(); + + // The one position where the browser declines to anchor, and the one the + // wheel-to-load path puts the reader in. + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.scrollTop = 0; + }, SCROLLER); + + await expect.poll(loadedTurns, { timeout: 20_000 }).toBeGreaterThan(loadedBefore); + await waitForPaintedFrames(page); + + // Anchoring resumes at an offset of one pixel, so the offset itself is the + // evidence: left at zero the browser holds the scroller at the top and every + // turn that arrives pushes the reader's content down the viewport instead. + const offset = await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + return root.scrollTop; + }, SCROLLER); + expect(offset).toBeGreaterThanOrEqual(1); +}); diff --git a/packages/ui/src/chat-scroll-anchor.ts b/packages/ui/src/chat-scroll-anchor.ts deleted file mode 100644 index 1e1dd2eefd..0000000000 --- a/packages/ui/src/chat-scroll-anchor.ts +++ /dev/null @@ -1,64 +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. - */ - -/** - * The one hole `overflow-anchor: auto` leaves: the browser declines to anchor - * while the scroller sits at zero, which is exactly where earlier history is - * asked for. This is the compensation for that single call point and nothing - * else — everywhere else, native anchoring already does this continuously and - * for free. - * - * The anchor is an element, not a height. `scrollHeight` deltas count growth - * below the reader too, and count a load that returned nothing as a push. - * Measuring one turn's box before and after answers only the question asked: - * how far did the content the reader is looking at move. - */ - -export interface ChatScrollAnchor { - readonly turnId: string; - readonly top: number; -} - -export function captureChatScrollAnchor(root: HTMLElement): ChatScrollAnchor | undefined { - // The first turn the reader can actually see, not the first one mounted. The - // virtual window mounts turns above the viewport too, and those are exactly - // the ones it is free to drop while the load lands — an anchor it unmounted - // can no longer be measured, and the compensation silently does nothing. - const rootTop = root.getBoundingClientRect().top; - for (const turn of root.querySelectorAll('[data-turn-id]')) { - if (turn.getBoundingClientRect().bottom <= rootTop) continue; - const turnId = turn.dataset.turnId; - if (!turnId) continue; - return { turnId, top: turn.getBoundingClientRect().top }; - } - return undefined; -} - -export function restoreChatScrollAnchor( - root: HTMLElement, - anchor: ChatScrollAnchor | undefined, -): boolean { - if (!anchor) return false; - const turn = root.querySelector( - `[data-turn-id="${CSS.escape(anchor.turnId)}"]`, - ); - if (!turn) return false; - root.scrollTop += turn.getBoundingClientRect().top - anchor.top; - return true; -} diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index c60499f84d..9f81ead527 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -47,7 +47,6 @@ import { type ReactNode, } from 'react'; import { ChatLayoutScrollButton } from '@astryxdesign/core/Chat'; -import { restoreChatScrollAnchor, type ChatScrollAnchor } from './chat-scroll-anchor.js'; /** Astryx's own thresholds, so the affordance keeps the feel readers learnt. */ const PIN_THRESHOLD_PX = 10; @@ -70,19 +69,6 @@ export interface TranscriptScrollAuthority { notifyContentResize(): void; /** One-shot: put the tail back under the reader and follow it again. */ pinToTail(): void; - /** - * Keep `anchor` at the viewport offset it had, through whatever arrives - * next. `overflow-anchor: auto` already does this continuously and for free, - * with one exception — the browser declines to anchor while the scroller sits - * at zero, which is precisely where loading earlier history puts the reader. - * - * The hold lasts until the reader scrolls, which is the moment it stops being - * true that they want to stay put. It runs on the same growth signal the pin - * does, so the correction lands in the frame the content arrived rather than - * after the virtual window has already been recomputed around the old - * position. - */ - holdAnchor(anchor: ChatScrollAnchor | undefined): void; /** * The reader chose a position, so stop following. A command that moves the * viewport itself calls this first; afterwards nothing here writes, which is @@ -99,7 +85,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { let awayFromTail = false; let writing = false; let writingFrame = 0; - let anchor: ChatScrollAnchor | undefined; let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail }; const listeners = new Set<() => void>(); @@ -143,8 +128,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { // output box, a terminal) never reach here at all: `scroll` does not // bubble, and there is no `wheel` listener to catch instead. if (writing) return; - // The reader moved, so they are no longer asking to stay put. - anchor = undefined; const distance = distanceToTail(); pinned = distance <= PIN_THRESHOLD_PX; awayFromTail = distance > BUTTON_THRESHOLD_PX; @@ -154,7 +137,6 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { if (pinned) writeToTail(); return () => { target.removeEventListener('scroll', onScroll); - anchor = undefined; if (writingFrame !== 0) { window.cancelAnimationFrame(writingFrame); writingFrame = 0; @@ -169,22 +151,14 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { writeToTail(); return; } - if (anchor) { - markWriting(); - restoreChatScrollAnchor(root, anchor); - } awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; publish(); }, pinToTail() { pinned = true; - anchor = undefined; writeToTail(); publish(); }, - holdAnchor(next) { - anchor = next; - }, releasePin() { pinned = false; awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index dfa82343b9..1a76923cb2 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -21,15 +21,16 @@ * The transcript's scroll commands, and the seam that hands the scroller to the * authority that owns it (`transcript-scroll-authority.ts`). * - * A command is one-shot: jump to a turn the reader picked, and compensate the - * earlier history that lands above them. Each releases the pin first, and the - * authority writes nothing while the pin is released — so a command cannot be - * fighting a policy, which is the shape every previous round of this code had. + * A command is one-shot: jump to a turn the reader picked, or ask for earlier + * history. Each releases the pin first, and the authority writes nothing while + * the pin is released — so a command cannot be fighting a policy, which is the + * shape every previous round of this code had. Nothing here compensates for + * content that lands above the reader; `overflow-anchor: auto` does that + * continuously, and for free. */ import { useEffect, useRef, useState, type RefObject } from 'react'; import type { StoredMessage } from '@maka/core/session'; -import { captureChatScrollAnchor } from './chat-scroll-anchor.js'; import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; export function useChatScroll(input: { @@ -81,12 +82,13 @@ export function useChatScroll(input: { if (historyLoadPendingRef.current || earlierLoadRequest.current) return; const request = {}; earlierLoadRequest.current = request; - const authority = authorityRef.current; - authority?.releasePin(); - // Where the reader is, not how tall the transcript was. A height delta - // counts growth below them too, and counts a load that returned nothing - // as a push; an element moves by exactly what the reader would see. - authority?.holdAnchor(captureChatScrollAnchor(root)); + authorityRef.current?.releasePin(); + // The browser anchors the reader against everything that lands above + // them, with one exception: it declines while the scroller sits at zero, + // which is exactly where a wheel asks for history. One pixel is the whole + // fix — measured in Chromium, an insert of 501px above the reader moves + // `scrollTop` by 501 at an offset of 1 and by 0 at an offset of 0. + if (root.scrollTop < 1) root.scrollTop = 1; void Promise.resolve(loadEarlierRef.current?.()).catch(() => undefined).finally(() => { if (earlierLoadRequest.current === request) earlierLoadRequest.current = null; }); From 20e62f78973f9f9857b4164631a46904e6376367 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 29 Aug 2026 13:33:58 +0800 Subject: [PATCH 10/10] refactor(ui): give the transcript one authority that is always there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three leftovers from when the authority was optional, all of them second behaviours for states nothing can reach: The provider was installed only for `scrollOwner="host"`, so every consumer carried a null branch — a detached snapshot for the dock button, optional calls in `ChatView`, a ref mirror and a guard in `useChatScroll`. Install it unconditionally instead. An authority nobody hands a scroller to writes nothing and costs one object, and `useTranscriptScrollAuthority` can now throw on a missing provider the way `ChatView` already does for a missing layout, because a missing one means the tree is assembled wrong. `autoScroll` sat on the public props while `scrollOwner` decided it, and the internal value was spread last — a caller-supplied one was silently dropped. Omit it from the type; `scrollOwner` remains the only answer. Asking for earlier history was deduplicated twice: `app-shell` refuses a request while one is in flight, and the hook kept its own in-flight ref because the shell's guard read React state that had not updated yet within the same task. Move that guard to a ref at the shell, where the request actually originates, and the hook stops tracking a load it does not own — along with the `historyLoadPending` prop it only needed for the guard. Generated-by: Claude Code --- apps/desktop/src/renderer/app-shell.tsx | 9 +++- .../src/renderer/chat-message-surface.tsx | 1 - packages/ui/src/chat-surface-layout.tsx | 25 +++++------ packages/ui/src/chat-view.tsx | 8 ++-- .../ui/src/transcript-scroll-authority.tsx | 31 +++++++------- packages/ui/src/use-chat-scroll.ts | 42 +++++-------------- 6 files changed, 47 insertions(+), 69 deletions(-) diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index fd0059e636..e0311a0fd7 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -419,6 +419,11 @@ function AppShellContent({ const [newTaskPermissionChoice, setNewTaskPermissionChoice, clearNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); + // The state above is what the transcript renders; this is what the guard + // reads. A scroller can ask twice in one task — two scroll events before + // React has re-rendered anything — and a state read is still the old value + // for both of them. + const historyLoadPendingRef = useRef(false); const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ sessionId: string; throughSequence: number | null; @@ -2568,7 +2573,8 @@ function AppShellContent({ async function loadTranscriptHistory(target: 'earlier' | 'latest') { const controller = transcriptRangeRef.current; const sessionId = activeId; - if (!controller || !sessionId || historyLoadPendingSessionId) return; + if (!controller || !sessionId || historyLoadPendingRef.current) return; + historyLoadPendingRef.current = true; setHistoryLoadPendingSessionId(sessionId); try { if (target === 'earlier') { @@ -2593,6 +2599,7 @@ function AppShellContent({ ), ); } finally { + historyLoadPendingRef.current = false; setHistoryLoadPendingSessionId((current) => current === sessionId ? undefined : current); } } diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index eb0dc1a917..89b4869cc7 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -245,7 +245,6 @@ export function ChatMessageSurface({ deepResearchRun={deepResearchRun} emptyOverride={emptyOverride} hasOlderHistory={hasOlderHistory} - historyLoadPending={historyLoadPending} onLoadEarlierHistory={onLoadEarlierHistory} returnToLatest={hasNewerHistory ? { title: transcriptCopy.partialHistoryTitle, diff --git a/packages/ui/src/chat-surface-layout.tsx b/packages/ui/src/chat-surface-layout.tsx index 12769cb0b3..ab3be1b6b5 100644 --- a/packages/ui/src/chat-surface-layout.tsx +++ b/packages/ui/src/chat-surface-layout.tsx @@ -27,15 +27,12 @@ import { import { cn } from './utils.js'; /** - * Stock ChatLayoutProps plus the patch-package `autoScroll` seam - * (`patches/@astryxdesign+core+0.5.0.patch`), which forwards Astryx's own - * published `enabled` option down to `useChatStreamScroll`. - * - * Intersection is explicit because some TS resolutions only see the published - * Astryx destructure list (which omits autoScroll) via ComponentProps. + * Stock ChatLayoutProps, minus `autoScroll`. That prop is the patch-package + * seam (`patches/@astryxdesign+core+0.5.0.patch`) forwarding Astryx's own + * published `enabled` option to `useChatStreamScroll`, and `scrollOwner` + * decides it — a caller-supplied value would be silently overwritten. */ -export type ChatSurfaceLayoutProps = ComponentProps & { - autoScroll?: boolean; +export type ChatSurfaceLayoutProps = Omit, 'autoScroll'> & { /** * Who positions this transcript. * @@ -65,7 +62,7 @@ export type ChatSurfaceLayoutProps = ComponentProps & { * message-area and dock-inner styles resolve to literally the same StyleX atoms * in both tiers, so this moves the dock and nothing else. It stays written out * rather than dropped entirely so an upstream default change cannot silently - * retune the composer's gutters; `chat-surface-layout.test.tsx` holds the value. + * retune the composer's gutters. */ export function ChatSurfaceLayout({ className, @@ -101,9 +98,9 @@ export function ChatSurfaceLayout({ ) : ( layout ); - return hostOwned ? ( - {localized} - ) : ( - localized - ); + // Unconditional: an authority nobody attaches a scroller to writes nothing + // and costs one object, and providing it always is what lets everything + // below treat it as present instead of carrying a second, unreachable + // behaviour for its absence. + return {localized}; } diff --git a/packages/ui/src/chat-view.tsx b/packages/ui/src/chat-view.tsx index f7d30f041b..90b88f1acf 100644 --- a/packages/ui/src/chat-view.tsx +++ b/packages/ui/src/chat-view.tsx @@ -262,7 +262,6 @@ export function ChatView(props: { scrollTargetTurn?: { turnId: string; nonce: number }; scrollBehavior: ScrollBehavior; hasOlderHistory?: boolean; - historyLoadPending?: boolean; onLoadEarlierHistory?(): Promise | void; returnToLatest?: { title: string; @@ -522,7 +521,7 @@ export function ChatView(props: { scrollRef, targetTurnId: props.scrollTargetTurn?.turnId, targetKey: props.scrollTargetTurn?.nonce, - onContentResize: scrollAuthority?.notifyContentResize, + onContentResize: scrollAuthority.notifyContentResize, }); const navigatePromptRailFallback = useCallback((turn: PromptAnchorRailTurn) => { if (turnIdsRef.current.has(turn.turnId)) revealTurn(turn.turnId); @@ -559,7 +558,6 @@ export function ChatView(props: { target: props.scrollTargetTurn, behavior: props.scrollBehavior, hasOlderHistory: props.hasOlderHistory, - historyLoadPending: props.historyLoadPending, onLoadEarlierHistory: props.onLoadEarlierHistory, }); const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( @@ -679,7 +677,7 @@ export function ChatView(props: { // the range that arrives afterwards is growth, and growth is // already followed. await props.returnToLatest?.onClick(); - scrollAuthority?.pinToTail(); + scrollAuthority.pinToTail(); }} /> ) : null} @@ -710,7 +708,7 @@ export function ChatView(props: { turns={promptRailTurns} scrollRef={scrollRef} onNavigateFallback={navigatePromptRailFallback} - onNavigateStart={scrollAuthority?.releasePin} + onNavigateStart={scrollAuthority.releasePin} /> authority?.pinToTail()} + onClick={() => authority.pinToTail()} /> ); } - -function noopSubscribe(): () => void { - return () => undefined; -} - -function detachedSnapshot(): TranscriptScrollSnapshot { - return DETACHED_SNAPSHOT; -} diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 1a76923cb2..cab10bec94 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -40,61 +40,45 @@ export function useChatScroll(input: { target?: { turnId: string; nonce: number }; behavior: ScrollBehavior; hasOlderHistory?: boolean; - historyLoadPending?: boolean; onLoadEarlierHistory?(): Promise | void; }) { const [highlightedTurnId, setHighlightedTurnId] = useState(null); const authority = useTranscriptScrollAuthority(); - const authorityRef = useRef(authority); - authorityRef.current = authority; const loadEarlierRef = useRef(input.onLoadEarlierHistory); loadEarlierRef.current = input.onLoadEarlierHistory; - const historyLoadPendingRef = useRef(input.historyLoadPending); - historyLoadPendingRef.current = input.historyLoadPending; const canLoadEarlier = input.onLoadEarlierHistory !== undefined; - const earlierLoadRequest = useRef(null); const handledTarget = useRef(null); // A passive effect, not a layout one: the scroller is Astryx's layout root, // an ancestor, and React attaches a parent's ref after its children's layout // effects have already run. The growth signal is a ResizeObserver delivery, // which lands after passive effects, so this is still installed in time. - useEffect(() => { - if (!authority) return; - return authority.attach(input.scrollRef.current); - }, [authority, input.scrollRef]); + useEffect(() => authority.attach(input.scrollRef.current), [authority, input.scrollRef]); // A new conversation arrives at its tail. Nothing special positions it: the // pin is set here and the first fill is growth like any other, so it takes // the one path instead of a first-fill path of its own. useEffect(() => { - authorityRef.current?.pinToTail(); - }, [input.sessionId]); - - useEffect(() => { - earlierLoadRequest.current = null; + authority.pinToTail(); }, [input.sessionId]); useEffect(() => { const root = input.scrollRef.current; if (!root || !input.hasOlderHistory || !canLoadEarlier) return; + // Asking twice is the loader's problem, not this one's: it refuses a + // request while one is in flight, and asking for history the reader + // already has is idempotent anyway. const requestEarlier = (): void => { - if (historyLoadPendingRef.current || earlierLoadRequest.current) return; - const request = {}; - earlierLoadRequest.current = request; - authorityRef.current?.releasePin(); + authority.releasePin(); // The browser anchors the reader against everything that lands above // them, with one exception: it declines while the scroller sits at zero, // which is exactly where a wheel asks for history. One pixel is the whole // fix — measured in Chromium, an insert of 501px above the reader moves // `scrollTop` by 501 at an offset of 1 and by 0 at an offset of 0. if (root.scrollTop < 1) root.scrollTop = 1; - void Promise.resolve(loadEarlierRef.current?.()).catch(() => undefined).finally(() => { - if (earlierLoadRequest.current === request) earlierLoadRequest.current = null; - }); + void Promise.resolve(loadEarlierRef.current?.()).catch(() => undefined); }; - // Position, not direction: a shrinking transcript also lowers `scrollTop`, - // and asking for history the reader already has is idempotent anyway. + // Position, not direction: a shrinking transcript also lowers `scrollTop`. const nearStart = (): boolean => root.scrollTop <= Math.max(640, root.clientHeight * 2); const onScroll = (): void => { @@ -111,13 +95,7 @@ export function useChatScroll(input: { root.removeEventListener('scroll', onScroll); root.removeEventListener('wheel', onWheel); }; - }, [ - input.hasOlderHistory, - input.historyLoadPending, - canLoadEarlier, - input.scrollRef, - input.sessionId, - ]); + }, [authority, input.hasOlderHistory, canLoadEarlier, input.scrollRef, input.sessionId]); useEffect(() => { const target = input.target; @@ -128,7 +106,7 @@ export function useChatScroll(input: { // a reader who had already scrolled back to it. const chosen = `${input.sessionId ?? ''}:${target.turnId}:${target.nonce}`; if (handledTarget.current === chosen) return; - authorityRef.current?.releasePin(); + authority.releasePin(); const frame = window.requestAnimationFrame(() => { const root = input.scrollRef.current; if (!root) return;