diff --git a/apps/desktop/e2e/prompt-rail.spec.ts b/apps/desktop/e2e/prompt-rail.spec.ts index 44d7e64173..9596088c71 100644 --- a/apps/desktop/e2e/prompt-rail.spec.ts +++ b/apps/desktop/e2e/prompt-rail.spec.ts @@ -252,8 +252,9 @@ test('the first click of a session lands on its prompt and holds', async ({ expect((await landing())?.offset).toBeLessThan(24); expect((await landing())?.tickIsCurrent).toBe(true); - // And stays: the fill runs on idle callbacks after the jump, so a jump that - // only wins the first frame reads as landing and then sliding away. + // And stays: turns keep resolving their content and remeasuring after the + // jump, so a jump that only wins the first frame reads as landing and then + // sliding away. await page.waitForTimeout(1_200); const settled = await landing(); expect(settled?.offset).toBeGreaterThan(-24); @@ -265,7 +266,7 @@ test('long transcripts keep a bounded mounted turn window', async ({ promptRailWindow: page, }) => { const count = async () => page.locator('[data-virtual-turn-id]').count(); - await page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]').waitFor(); + await page.locator('[data-virtual-turn-id]').first().waitFor(); await loadPromptRailBeyondVirtualWindow(page); expect(await page.evaluate(() => { const transcript = document.querySelector('.maka-chat-message-list'); @@ -288,8 +289,8 @@ test('long transcripts keep a bounded mounted turn window', async ({ test('evicting a turn-owned sibling interaction hands focus back to the transcript', async ({ promptRailWindow: page, }) => { - const scroller = page.locator('[data-chat-scroll-container="true"][data-turn-window="ready"]'); - await scroller.waitFor(); + const scroller = page.locator('[data-chat-scroll-container="true"]'); + await page.locator('[data-virtual-turn-id]').first().waitFor(); await loadPromptRailBeyondVirtualWindow(page); await scrollTranscriptTo(page, 'bottom'); await expect(page.locator('[data-virtual-turn-id="turn-prompt-rail-120"]')).toHaveCount(1); diff --git a/apps/desktop/e2e/transcript-scroll.spec.ts b/apps/desktop/e2e/transcript-scroll.spec.ts new file mode 100644 index 0000000000..f8ac41f8c6 --- /dev/null +++ b/apps/desktop/e2e/transcript-scroll.spec.ts @@ -0,0 +1,525 @@ +/* + * 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 scrollMetrics(page).then((metrics) => metrics.distance); +} + +/** + * The distance plus the three numbers it came from. A failure that reports only + * the distance cannot say whether the transcript grew past the reader or the + * viewport shrank under them, and those have different causes. + */ +function scrollMetrics(page: Page): Promise<{ + distance: number; + scrollTop: number; + scrollHeight: number; + clientHeight: number; +}> { + return page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + return { + distance: Math.round(root.scrollHeight - root.scrollTop - root.clientHeight), + scrollTop: Math.round(root.scrollTop), + scrollHeight: root.scrollHeight, + clientHeight: 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); + const settled = await scrollMetrics(page); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + expect(await scrollButtonOffered(page)).toBe(false); +}); + +/** + * The turn wrappers are not the transcript. It also renders the optimistic user + * message, the no-tail live fallback and orphaned conversation items outside + * them, so a growth signal that watches turns has a blind spot the size of + * whatever else gets rendered next. + * + * Grown here rather than by sending a Follow Up: whether the optimistic message + * is ever on screen is the host's timing, and it was measured both appearing + * and being overtaken by its own answer within the same fixture. What is under + * test is not that message — it is that `scrollHeight` growing anywhere is + * enough, which is a property of the scroller and needs no help from the + * transcript to state. + */ +test('content that grows outside the turn wrappers is followed too', 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); + + const outsideTurnWrapper = await page.evaluate(() => { + const list = document.querySelector('.maka-chat-message-list'); + if (!list) throw new Error('the transcript content box is missing'); + const grown = document.createElement('div'); + grown.dataset.outsideTurnGrowth = 'true'; + grown.style.height = '600px'; + list.append(grown); + return grown.closest('[data-virtual-turn-id]') === null; + }); + await waitForPaintedFrames(page); + + // Outside a wrapper is what makes this the uncovered path: growth inside one + // is what every other test in this file already exercises. + expect(outsideTurnWrapper, 'the injected box landed inside a turn wrapper').toBe(true); + const settled = await scrollMetrics(page); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); +}); + +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 }); + const settled = await scrollMetrics(page); + expect(settled.distance, JSON.stringify(settled)).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); +}); + +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); +}); + +/** + * A transcript shorter than about three viewports has its tail inside the band + * that asks for earlier history, so "near the start" cannot mean the reader + * wants it. + * + * The other half of that rule — a wheel the scroller cannot act on releases the + * pin, because it is the reader asking for what is above them — has no assertion + * here, and not for want of trying. Its only observable consequence is that a + * later arrival does not take the reader back down, and in this fixture the + * reader who asks is already at the tail: anchoring holds them at the same + * distance from it, the session takes no new turns, and a viewport change moves + * them the same way pinned or not. An assertion that passes either way is worse + * than none. `transcript-scroll-authority.test.ts` covers the state machine it + * turns on. + */ +test('following the tail does not ask for the history above it', async ({ + promptRailWindow: page, +}) => { + const loadedTurns = () => + page + .locator('.maka-chat-message-list') + .getAttribute('data-turn-source-count') + .then((value) => Number(value)); + const loadedBefore = await loadedTurns(); + + // Tall enough that the tail sits inside `max(640, clientHeight * 2)`. The + // resize itself is a growth signal, so the pin writes the tail and that write + // dispatches the scroll event this test is about. + await page.setViewportSize({ width: 900, height: 1500 }); + await waitForPaintedFrames(page, 6); + + const settled = await scrollMetrics(page); + expect(settled.distance, JSON.stringify(settled)).toBeLessThanOrEqual(4); + expect( + settled.scrollTop, + `the tail must be inside the load band for this test to mean anything: ${JSON.stringify(settled)}`, + ).toBeLessThanOrEqual(Math.max(640, settled.clientHeight * 2)); + + // Nothing arrived that the reader did not ask for. + await waitForPaintedFrames(page, 12); + expect(await loadedTurns()).toBe(loadedBefore); +}); + +test('a wheel the scroller cannot act on still asks for history', async ({ + promptRailWindow: page, +}) => { + const loadedTurns = () => + page + .locator('.maka-chat-message-list') + .getAttribute('data-turn-source-count') + .then((value) => Number(value)); + const loadedBefore = await loadedTurns(); + + await page.setViewportSize({ width: 900, height: 1500 }); + await waitForPaintedFrames(page, 6); + const asked = await scrollMetrics(page); + expect(asked.distance, JSON.stringify(asked)).toBeLessThanOrEqual(4); + + // Dispatched rather than driven, and that is the point: the case is a wheel + // the scroller cannot act on — already at zero, or too short to move — where + // no scroll follows and the authority never learns the reader asked. A real + // `mouse.wheel` would scroll, and the scroll alone would carry the request. + await page.evaluate((selector) => { + const root = document.querySelector(selector); + if (!root) throw new Error('the chat scroll container is missing'); + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); + }, SCROLLER); + + await expect.poll(loadedTurns, { timeout: 20_000 }).toBeGreaterThan(loadedBefore); + + // And it landed above the reader: they are where they were, with more above + // them than before. + const after = await scrollMetrics(page); + expect(after.scrollTop, `${JSON.stringify(asked)} then ${JSON.stringify(after)}`) + .toBeGreaterThan(asked.scrollTop); +}); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index cfac7f8a42..7f937136ce 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -426,6 +426,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; @@ -2580,7 +2585,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') { @@ -2605,6 +2611,7 @@ function AppShellContent({ ), ); } finally { + historyLoadPendingRef.current = false; setHistoryLoadPendingSessionId((current) => current === sessionId ? undefined : current); } } @@ -2843,9 +2850,11 @@ function AppShellContent({ ) ) : ( diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 5ecb267492..dfddf83464 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -42,6 +42,16 @@ width: 100%; } +/* Inserting earlier turns above the reader must not move what they are + reading. The browser's scroll anchoring does exactly that, so state the + dependency on the scroller that runs it rather than inheriting the `auto` + default: Maka reads no geometry and restores no position of its own. The + one case anchoring declines is a scroller sitting at zero, compensated in + useChatScroll after the turns land. */ +[data-chat-scroll-container='true'] { + overflow-anchor: auto; +} + .maka-turn-virtual-item { display: flex; width: 100%; diff --git a/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 1588a9edb9..4961847b92 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:** 221 files — blocker 0, polish 1, aligned 220. +**Totals:** 222 files — blocker 0, polish 1, aligned 221. ## Exclusions (explicit) @@ -244,6 +244,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 f7dbe7e930..87af07d30e 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -216,6 +216,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__/arrival-bottom-pin.test.ts b/packages/ui/src/__tests__/arrival-bottom-pin.test.ts deleted file mode 100644 index 84d3b857ba..0000000000 --- a/packages/ui/src/__tests__/arrival-bottom-pin.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { strict as assert } from 'node:assert'; -import { describe, it } from 'node:test'; -import { - createArrivalBottomPin, - releasesArrivalPin, - type ArrivalPinSizeObserver, -} from '../arrival-bottom-pin.js'; - -/** - * Clamps `scrollTop` the way a real scroller does, so the assertions below can - * be written as the contract — flush against the bottom — rather than as the - * literal value the pin happens to assign. Writing `scrollHeight` is how the - * pin asks for "as far down as this goes", and a real scroller answers by - * clamping, so overshoot is unobservable here exactly as it is in the product. - * What the clamp buys is the other direction: any arithmetic that stops the - * scroller short shows up as a distance, whatever produced it. - */ -function fakeViewport(initial: { scrollTop: number; scrollHeight: number; clientHeight: number }) { - const listeners = new Map void>>(); - let scrollTop = initial.scrollTop; - return { - scrollHeight: initial.scrollHeight, - clientHeight: initial.clientHeight, - get scrollTop() { - return scrollTop; - }, - set scrollTop(value: number) { - scrollTop = Math.max(0, Math.min(value, this.scrollHeight - this.clientHeight)); - }, - get distanceFromBottom() { - return this.scrollHeight - scrollTop - this.clientHeight; - }, - addEventListener(type: string, listener: (event: Event) => void) { - const set = listeners.get(type) ?? new Set(); - set.add(listener); - listeners.set(type, set); - }, - removeEventListener(type: string, listener: (event: Event) => void) { - listeners.get(type)?.delete(listener); - }, - emit(type: string, event: Partial = {}) { - for (const listener of listeners.get(type) ?? []) listener(event as Event); - }, - listenerCount(type: string) { - return listeners.get(type)?.size ?? 0; - }, - }; -} - -/** - * A transcript element that answers `contains` for the nodes inside it, so the - * gesture handlers can be exercised the way the DOM presents them: the dock's - * wheels and touches bubble through the same scroller as the transcript's. - */ -function fakeTranscript() { - const inside = { name: 'turn' } as unknown as Node; - const dock = { name: 'composer' } as unknown as Node; - const element = { - contains: (node: Node | null) => node === inside, - } as unknown as Element; - return { element, inside, dock }; -} - -function fakeSizeObserver() { - let notify: (() => void) | undefined; - let disconnected = false; - return { - factory: (callback: () => void): ArrivalPinSizeObserver => { - notify = callback; - return { - observe: () => {}, - disconnect: () => { disconnected = true; }, - }; - }, - grow: () => notify?.(), - get disconnected() { return disconnected; }, - }; -} - -describe('releasesArrivalPin', () => { - it('reads an upward scroll with unchanged geometry as the reader taking over', () => { - assert.equal( - releasesArrivalPin({ - scrollTop: 900, - lastScrollTop: 1_400, - scrollHeight: 2_000, - lastScrollHeight: 2_000, - clientHeight: 600, - lastClientHeight: 600, - }), - true, - ); - }); - - it('ignores the synthetic scroll Chromium fires when the document grows', () => { - // The arrival window is nothing but growth: every mounted chunk and every - // warmed placeholder fires a scroll event whose scrollTop can read lower - // than the pin's last write. Only geometry that held still is evidence. - assert.equal( - releasesArrivalPin({ - scrollTop: 900, - lastScrollTop: 1_400, - scrollHeight: 4_000, - lastScrollHeight: 2_000, - clientHeight: 600, - lastClientHeight: 600, - }), - false, - ); - assert.equal( - releasesArrivalPin({ - scrollTop: 900, - lastScrollTop: 1_400, - scrollHeight: 2_000, - lastScrollHeight: 2_000, - clientHeight: 500, - lastClientHeight: 600, - }), - false, - ); - }); - - it('holds the pin through a sub-pixel readback of its own write', () => { - assert.equal( - releasesArrivalPin({ - scrollTop: 1_399.5, - lastScrollTop: 1_400, - scrollHeight: 2_000, - lastScrollHeight: 2_000, - clientHeight: 600, - lastClientHeight: 600, - }), - false, - ); - }); -}); - -describe('createArrivalBottomPin', () => { - it('stops following once the reader scrolls up, and stays released', () => { - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 4_000, clientHeight: 600 }); - const observer = fakeSizeObserver(); - const states: string[] = []; - const pin = createArrivalBottomPin({ - viewport, - content: {} as Element, - onStateChange: (state) => { states.push(state); }, - createSizeObserver: observer.factory, - }); - - viewport.scrollTop = 1_000; - viewport.emit('scroll'); - assert.equal(pin.isPinned(), false); - viewport.scrollHeight = 9_000; - observer.grow(); - assert.equal(viewport.scrollTop, 1_000); - // A later growth step must not re-pin: releasing is permanent for this - // arrival, the way Astryx's own unlock is. - viewport.scrollHeight = 15_000; - observer.grow(); - assert.equal(viewport.scrollTop, 1_000); - assert.deepEqual(states, ['pinned', 'released']); - }); - - it('follows a growth, rides its synthetic scroll, and still yields to the reader after it', () => { - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 800, clientHeight: 600 }); - const observer = fakeSizeObserver(); - const pin = createArrivalBottomPin({ - viewport, - content: {} as Element, - createSizeObserver: observer.factory, - }); - - viewport.scrollHeight = 15_000; - observer.grow(); - assert.equal(viewport.distanceFromBottom, 0); - // Chromium fires a scroll event for the resize itself. It reports a - // position the reader never chose, and the pin must not read it as intent. - viewport.emit('scroll'); - assert.equal(pin.isPinned(), true); - - viewport.scrollTop = 9_000; - viewport.emit('scroll'); - assert.equal(pin.isPinned(), false); - }); - - it('takes its geometry snapshot from growth it did not write itself', () => { - // Not every growth reaches the observed content element: the dock (graph - // status, plan panel) lives inside the scroller but outside the message - // list, so it moves scrollHeight with a scroll event and nothing else. The - // snapshot has to follow that too, or the NEXT genuine upward scroll is - // compared against a stale height, reads as "geometry changed", and the - // reader silently loses control of the transcript. - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 4_000, clientHeight: 600 }); - const pin = createArrivalBottomPin({ viewport, content: null }); - - assert.equal(viewport.distanceFromBottom, 0); - viewport.scrollHeight = 15_000; - viewport.emit('scroll'); - assert.equal(pin.isPinned(), true); - - viewport.scrollTop = 700; - viewport.emit('scroll'); - assert.equal(pin.isPinned(), false); - }); - - it('releases on an upward wheel and on a touch drag over the transcript', () => { - const transcript = fakeTranscript(); - for (const [type, event] of [ - ['wheel', { deltaY: -120, target: transcript.inside }], - ['touchmove', { target: transcript.inside }], - ] as const) { - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 4_000, clientHeight: 600 }); - const observer = fakeSizeObserver(); - const pin = createArrivalBottomPin({ - viewport, - content: transcript.element, - createSizeObserver: observer.factory, - }); - viewport.emit(type, event as unknown as Partial); - assert.equal(pin.isPinned(), false, type); - } - }); - - it('does not read a gesture over the dock as the reader leaving the turn', () => { - // The composer, plan panel and graph status live inside this scroller, so - // their wheels and touches arrive here too — and a wheel over the composer - // that really does scroll the transcript still releases the pin, through - // the scroll event it causes rather than through where the pointer was. - const transcript = fakeTranscript(); - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 4_000, clientHeight: 600 }); - const pin = createArrivalBottomPin({ viewport, content: transcript.element }); - - viewport.emit('wheel', { deltaY: -120, target: transcript.dock } as unknown as Partial); - viewport.emit('touchmove', { target: transcript.dock } as unknown as Partial); - assert.equal(pin.isPinned(), true); - assert.equal(viewport.distanceFromBottom, 0); - - viewport.scrollTop = 1_000; - viewport.emit('scroll'); - assert.equal(pin.isPinned(), false); - }); - - it('keeps following through a wheel that is not the reader going up', () => { - // Down is where the pin is already heading. Zero is a horizontal wheel or - // a trackpad's rounding — no vertical intent at all, and reading it as one - // would drop the pin on a sideways swipe across a wide code block. - for (const deltaY of [120, 0]) { - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 800, clientHeight: 600 }); - const observer = fakeSizeObserver(); - const pin = createArrivalBottomPin({ - viewport, - content: {} as Element, - createSizeObserver: observer.factory, - }); - viewport.emit('wheel', { deltaY } as Partial); - assert.equal(pin.isPinned(), true, `deltaY=${deltaY}`); - } - }); - - it('detaches every observer and listener on dispose', () => { - const viewport = fakeViewport({ scrollTop: 0, scrollHeight: 800, clientHeight: 600 }); - const observer = fakeSizeObserver(); - const pin = createArrivalBottomPin({ - viewport, - content: {} as Element, - createSizeObserver: observer.factory, - }); - pin.dispose(); - assert.equal(observer.disconnected, true); - assert.equal(viewport.listenerCount('scroll'), 0); - assert.equal(viewport.listenerCount('wheel'), 0); - assert.equal(viewport.listenerCount('touchmove'), 0); - }); -}); diff --git a/packages/ui/src/__tests__/chat-scroll-anchor.test.ts b/packages/ui/src/__tests__/chat-scroll-anchor.test.ts deleted file mode 100644 index c2af386cf9..0000000000 --- a/packages/ui/src/__tests__/chat-scroll-anchor.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import test from 'node:test'; -import { parseHTML } from 'linkedom'; -import { captureChatScrollAnchor, restoreChatScrollAnchor } from '../chat-scroll-anchor.js'; - -test('reuses the visible article while virtual history changes above it', () => { - const { document } = parseHTML('
'); - const root = document.querySelector('#root')!; - Object.defineProperty(root, 'scrollTop', { value: 0, writable: true }); - root.getBoundingClientRect = () => ({ top: 100 }) as DOMRect; - let rectReads = 0; - for (let index = 0; index < 200; index += 1) { - const turn = document.createElement('section'); - turn.dataset.turnId = `turn-${index}`; - const article = document.createElement('article'); - article.dataset.sender = 'assistant'; - article.getBoundingClientRect = () => { - rectReads += 1; - return { top: index < 180 ? 0 : 120, bottom: index < 180 ? 80 : 160 } as DOMRect; - }; - turn.append(article); - root.append(turn); - } - - const first = captureChatScrollAnchor(root); - assert.equal(first?.turnId, 'turn-180'); - rectReads = 0; - const second = captureChatScrollAnchor(root); - assert.equal(second?.turnId, 'turn-180'); - assert.ok(rectReads <= 4); - assert.equal(restoreChatScrollAnchor(root, second), true); -}); - -test('skips message descendants while advancing the cached anchor', () => { - const { document } = parseHTML('
'); - const root = document.querySelector('#root')!; - Object.defineProperty(root, 'scrollTop', { value: 0, writable: true }); - root.getBoundingClientRect = () => ({ top: 100 }) as DOMRect; - - const first = document.createElement('article'); - first.dataset.sender = 'assistant'; - first.getBoundingClientRect = () => ({ top: 120, bottom: 160 }) as DOMRect; - let parent = first; - let descendantReads = 0; - for (let index = 0; index < 1_000; index += 1) { - const child = document.createElement('div'); - parent.append(child); - const current = parent; - const nested = child; - Object.defineProperty(current, 'firstElementChild', { - configurable: true, - get() { - descendantReads += 1; - return nested; - }, - }); - parent = child; - } - const second = document.createElement('article'); - second.dataset.sender = 'assistant'; - second.getBoundingClientRect = () => ({ top: 120, bottom: 160 }) as DOMRect; - const firstTurn = document.createElement('section'); - firstTurn.dataset.turnId = 'turn-1'; - firstTurn.append(first); - const secondTurn = document.createElement('section'); - secondTurn.dataset.turnId = 'turn-2'; - secondTurn.append(second); - root.append(firstTurn, secondTurn); - - assert.equal(captureChatScrollAnchor(root)?.turnId, 'turn-1'); - first.getBoundingClientRect = () => ({ top: 0, bottom: 80 }) as DOMRect; - assert.equal(captureChatScrollAnchor(root)?.turnId, 'turn-2'); - assert.equal(descendantReads, 0); -}); diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts new file mode 100644 index 0000000000..aaf66433a9 --- /dev/null +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -0,0 +1,311 @@ +/* + * 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; + /** The boxes `scrollHeight` is made of, which is what the authority watches. */ + children: readonly unknown[]; + 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; + /** Take height away from the viewport, as a resize or a taller dock does. */ + shrinkViewport(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, + children: [{}], + 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; + }, + shrinkViewport(by) { + root.clientHeight -= 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); + }, + }); +} + +/** + * The authority watches the scroller's box and its children's boxes, and keeps + * that set current with a `MutationObserver`, so the suite owns both. `resize` + * is every box changing at once, which is the only distinction the authority + * draws between them: none. + * + * Frames are not faked — nothing here schedules one. Whether a scroll event is + * this authority's own is answered by where the scroller is, not by when the + * event arrives. + */ +function withObservers(run: (resize: () => void) => T): T { + const observers = new Set<() => void>(); + const globals = globalThis as { ResizeObserver?: unknown; MutationObserver?: unknown }; + const originalResize = globals.ResizeObserver; + const originalMutation = globals.MutationObserver; + globals.ResizeObserver = class { + constructor(private readonly callback: () => void) {} + // Registered on `observe` rather than on construction: the authority + // re-points one observer at a changing set of boxes, so a stub that ignored + // `disconnect` and `observe` would report a detached authority as live. + observe(): void { + observers.add(this.callback); + } + disconnect(): void { + observers.delete(this.callback); + } + }; + // The set of children only changes when the transcript mounts or unmounts + // one, and `resize` already stands for every box in that set changing. + globals.MutationObserver = class { + observe(): void {} + disconnect(): void {} + }; + try { + return run(() => { + for (const observer of [...observers]) observer(); + }); + } finally { + globals.ResizeObserver = originalResize; + globals.MutationObserver = originalMutation; + } +} + +test('content that grows under a pinned transcript keeps the tail on screen', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(root.scrollTop, 2_400); + + root.grow(500); + resize(); + assert.equal(root.scrollTop, 2_900); + + // Its own write echoes back as an ordinary scroll event, and finding the + // scroller still on the offset it wrote is how it knows. + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, true); + }); +}); + +test('a scroll this authority did not write is the reader, and releases the tail', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + + 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); + resize(); + assert.equal(root.scrollTop, 1_000); + }); +}); + +test('returning to the tail re-pins, and following resumes', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + 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); + + root.grow(600); + resize(); + assert.equal(root.scrollTop, 3_000); + }); +}); + +test('a detached authority writes nothing and reports the tail', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + detach(); + root.scrollTop = 0; + root.grow(1_000); + resize(); + assert.equal(root.scrollTop, 0); + }); +}); + +test('a viewport that loses height takes the pinned reader back to the tail', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + + // The transcript did not change at all — the box looking at it did, which + // is a window resize, a composer gaining a line, or a dock growing taller. + root.shrinkViewport(300); + resize(); + assert.equal(root.scrollTop, 2_700); + assert.equal(authority.getSnapshot().pinned, true); + }); +}); + +test('a scroll event that arrives late is still this authority\'s own write', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(root.scrollTop, 2_400); + + // The write's event has not been dispatched yet, and the transcript keeps + // growing underneath it. By the time it lands the scroller is 302px from a + // tail that has moved — which is exactly what a reader who scrolled up + // looks like, and is why timing cannot be the discriminator. + root.grow(302); + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, true); + + resize(); + assert.equal(root.scrollTop, 2_702); + }); +}); + +test('growth that outruns the write does not read as the reader scrolling up', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + assert.equal(root.scrollTop, 2_400); + + // The transcript grew, and the scroll event for it arrives before this + // authority has been told to follow it. The offset is 302px from a tail + // that moved — identical, as a position, to a reader who scrolled up. + root.grow(302); + root.scrollTop = 2_402; + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, true); + + // The affordance still knows how far the tail now is, and the next growth + // signal takes the reader back to it. + assert.equal(authority.getSnapshot().awayFromTail, true); + resize(); + assert.equal(root.scrollTop, 2_702); + }); +}); + +test('content landing above a released reader does not re-pin them', () => { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + + // The reader is at the tail and asks for what is above them: a wheel the + // scroller cannot act on, so only the command says so. + authority.releasePin(); + assert.equal(authority.getSnapshot().pinned, false); + + // History lands above them and native anchoring moves the offset to keep + // them still. Distance to the tail is unchanged — which is exactly the + // reading that used to put the pin back and scroll the new turns away. + root.grow(4_000); + root.scrollTop = 6_400; + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, false); + + resize(); + assert.equal(root.scrollTop, 6_400); + }); +}); + +test('only the reader\'s own movement reaches a reader-scroll listener', () => { + withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + let heard = 0; + const stop = authority.subscribeToReaderScroll(() => { + heard += 1; + }); + authority.attach(root as unknown as HTMLElement); + + // This authority's own write, echoed back late. + root.emitScroll(); + assert.equal(heard, 0); + + // Content arriving, with anchoring moving the offset to hold the reader. + root.grow(500); + root.scrollTop = 2_900; + root.emitScroll(); + assert.equal(heard, 0); + + // The reader, at last. + root.scrollTop = 900; + root.emitScroll(); + assert.equal(heard, 1); + + stop(); + root.scrollTop = 400; + root.emitScroll(); + assert.equal(heard, 1); + }); +}); diff --git a/packages/ui/src/arrival-bottom-pin.ts b/packages/ui/src/arrival-bottom-pin.ts deleted file mode 100644 index a3a79da9d5..0000000000 --- a/packages/ui/src/arrival-bottom-pin.ts +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * Instant bottom pin for a transcript that is still arriving. - * - * Astryx's `useChatStreamScroll` positions the FIRST fill of its scroller - * instantly and springs every later growth. That one-shot lives on the hook - * instance, and `ChatSurfaceLayout` mounts once for the whole app shell, so it - * is spent on the session that happened to be open at boot. Every switch after - * that is "later growth". A switched-to transcript still arrives across the - * virtual tail's first render and measured-height corrections, so a one-shot - * scroll would let the spring chase a moving bottom. - * - * A session change is navigation, not content growth: the transcript is meant - * to be at its latest turn the first time it is painted, exactly as it is on a - * cold start. This pin owns that arrival window only. It writes `scrollTop` - * from a ResizeObserver — after layout, before paint — so the growth the spring - * would have animated is already consumed by the time a frame is painted, and - * the spring settles against a zero delta instead of running. Steady-state - * following (streaming tokens, appended turns) stays Astryx's, which is why the - * caller releases this at the end of the arrival window rather than keeping it. - * - * Any sign the reader took control releases the pin for good, using the same - * signals Astryx unlocks on: an upward wheel or a touch drag over the - * transcript, or a scroll that moved up on its own — one where the geometry did - * NOT change in the same event, since Chromium fires a synthetic scroll for - * every content resize and the arrival window is nothing but resizes. - */ - -export interface ArrivalPinViewport { - scrollTop: number; - readonly scrollHeight: number; - readonly clientHeight: number; - addEventListener(type: string, listener: (event: Event) => void, options?: { passive?: boolean }): void; - removeEventListener(type: string, listener: (event: Event) => void): void; -} - -export interface ArrivalPinSizeObserver { - observe(element: Element): void; - disconnect(): void; -} - -export type ArrivalPinSizeObserverFactory = (callback: () => void) => ArrivalPinSizeObserver; - -export interface ArrivalPinGeometry { - readonly scrollTop: number; - readonly lastScrollTop: number; - readonly scrollHeight: number; - readonly lastScrollHeight: number; - readonly clientHeight: number; - readonly lastClientHeight: number; -} - -/** - * Whether a scroll event is the reader moving up rather than the document - * growing under them. - * - * The 1px tolerance is for Chromium's fractional `scrollTop`: pinning writes - * `scrollHeight`, which clamps to a maximum that can carry a sub-pixel - * fraction, and reading it back a frame later can land just under the value the - * pin recorded. - */ -export function releasesArrivalPin(geometry: ArrivalPinGeometry): boolean { - if ( - geometry.scrollHeight !== geometry.lastScrollHeight || - geometry.clientHeight !== geometry.lastClientHeight - ) { - return false; - } - return geometry.scrollTop < geometry.lastScrollTop - 1; -} - -export interface ArrivalBottomPin { - /** Stop following; the viewport is left wherever it currently sits. */ - release(): void; - /** Release and detach every observer and listener. */ - dispose(): void; - /** False once the reader took control or the caller released the pin. */ - isPinned(): boolean; -} - -export function createArrivalBottomPin(options: { - viewport: ArrivalPinViewport; - /** - * The element whose height the transcript grows with. The scroller's own box - * never changes size while its content does, so observing the viewport would - * report nothing. - */ - content: Element | null; - /** Published by the caller as a DOM marker; see use-chat-scroll. */ - onStateChange?: (state: 'pinned' | 'released') => void; - createSizeObserver?: ArrivalPinSizeObserverFactory; -}): ArrivalBottomPin { - const viewport = options.viewport; - const content = options.content; - let pinned = true; - let lastScrollTop = viewport.scrollTop; - let lastScrollHeight = viewport.scrollHeight; - let lastClientHeight = viewport.clientHeight; - - const pin = (): void => { - if (!pinned) return; - viewport.scrollTop = viewport.scrollHeight; - lastScrollTop = viewport.scrollTop; - lastScrollHeight = viewport.scrollHeight; - lastClientHeight = viewport.clientHeight; - }; - - const release = (): void => { - if (!pinned) return; - pinned = false; - options.onStateChange?.('released'); - }; - - const onScroll = (): void => { - if (!pinned) return; - if ( - releasesArrivalPin({ - scrollTop: viewport.scrollTop, - lastScrollTop, - scrollHeight: viewport.scrollHeight, - lastScrollHeight, - clientHeight: viewport.clientHeight, - lastClientHeight, - }) - ) { - release(); - return; - } - lastScrollTop = viewport.scrollTop; - lastScrollHeight = viewport.scrollHeight; - lastClientHeight = viewport.clientHeight; - }; - - // Wheel and touch are read before the scroll they cause, which is what makes - // them worth listening to on top of `onScroll`: they release the pin in the - // same frame the reader acts, rather than one growth later — a growth landing - // between the gesture and its scroll event would otherwise re-pin under them. - // - // Scoped to gestures over the transcript. The dock — composer, plan panel, - // graph status — sits inside this scroller, so its wheels and touches bubble - // here too, and neither is evidence that the reader left the latest turn. - // Nothing is lost by being strict: a gesture that really moves the scroller - // still reaches `onScroll`, which decides on what the geometry did rather - // than on where the pointer was. - const overTranscript = (event: Event): boolean => { - const target = event.target; - if (!content || typeof content.contains !== 'function' || !target) return true; - return content.contains(target as Node); - }; - const onWheel = (event: Event): void => { - if ((event as WheelEvent).deltaY < 0 && overTranscript(event)) release(); - }; - const onTouchMove = (event: Event): void => { - if (overTranscript(event)) release(); - }; - - viewport.addEventListener('scroll', onScroll, { passive: true }); - viewport.addEventListener('wheel', onWheel, { passive: true }); - viewport.addEventListener('touchmove', onTouchMove, { passive: true }); - - const createSizeObserver = options.createSizeObserver - ?? (typeof ResizeObserver === 'function' - ? (callback: () => void) => new ResizeObserver(callback) - : undefined); - const sizeObserver = content ? createSizeObserver?.(pin) : undefined; - if (content) sizeObserver?.observe(content); - - options.onStateChange?.('pinned'); - pin(); - - return { - release, - isPinned: () => pinned, - dispose: () => { - release(); - sizeObserver?.disconnect(); - viewport.removeEventListener('scroll', onScroll); - viewport.removeEventListener('wheel', onWheel); - viewport.removeEventListener('touchmove', onTouchMove); - }, - }; -} diff --git a/packages/ui/src/chat-scroll-anchor.ts b/packages/ui/src/chat-scroll-anchor.ts deleted file mode 100644 index aec03488eb..0000000000 --- a/packages/ui/src/chat-scroll-anchor.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export interface ChatScrollAnchor { - readonly turnId: string; - readonly sender: string | undefined; - readonly reverseIndex: number; - readonly top: number; - readonly element: HTMLElement; -} - -const lastAnchorByRoot = new WeakMap(); - -export function captureChatScrollAnchor(root: HTMLElement): ChatScrollAnchor | undefined { - const rootTop = root.getBoundingClientRect().top; - const article = firstVisibleArticle(root, rootTop); - const turn = article?.closest('[data-turn-id]'); - const sender = article?.dataset.sender; - const matches = turn - ? Array.from(turn.querySelectorAll('article')) - .filter((candidate) => candidate.dataset.sender === sender) - : []; - const index = article ? matches.indexOf(article) : -1; - if (!article || !turn?.dataset.turnId || index < 0) return undefined; - lastAnchorByRoot.set(root, article); - return { - turnId: turn.dataset.turnId, - sender, - reverseIndex: matches.length - index - 1, - top: article.getBoundingClientRect().top, - element: article, - }; -} - -export function restoreChatScrollAnchor( - root: HTMLElement, - anchor: ChatScrollAnchor | undefined, -): boolean { - if (!anchor) return false; - const retainedTurn = anchor.element.closest('[data-turn-id]'); - let article = - root.contains(anchor.element) && - retainedTurn?.dataset.turnId === anchor.turnId && - anchor.element.dataset.sender === anchor.sender - ? anchor.element - : undefined; - if (!article) { - const turn = root.querySelector( - `[data-turn-id="${CSS.escape(anchor.turnId)}"]`, - ); - const matches = turn - ? Array.from(turn.querySelectorAll('article')) - .filter((candidate) => candidate.dataset.sender === anchor.sender) - : []; - article = matches.at(-anchor.reverseIndex - 1); - } - if (!article) return false; - lastAnchorByRoot.set(root, article); - root.scrollTop += article.getBoundingClientRect().top - anchor.top; - return true; -} - -function firstVisibleArticle(root: HTMLElement, rootTop: number): HTMLElement | undefined { - const cached = lastAnchorByRoot.get(root); - let article = cached && root.contains(cached) ? cached : nextArticle(root, root); - if (!article) return undefined; - if (article.getBoundingClientRect().bottom > rootTop) { - while (true) { - const previous = previousArticle(root, article); - if (!previous) break; - if (previous.getBoundingClientRect().bottom <= rootTop) break; - article = previous; - } - return article; - } - while ((article = nextArticle(root, article))) { - if (article.getBoundingClientRect().bottom > rootTop) return article; - } - return undefined; -} - -function nextArticle(root: HTMLElement, from: HTMLElement): HTMLElement | undefined { - let node: HTMLElement | null = from; - let descend = node.tagName !== 'ARTICLE'; - while (node) { - if (descend && node.firstElementChild) { - node = node.firstElementChild as HTMLElement; - } else { - while (node && node !== root && !node.nextElementSibling) node = node.parentElement; - if (!node || node === root) return undefined; - node = node.nextElementSibling as HTMLElement; - } - if (node.tagName === 'ARTICLE') return node; - descend = true; - } - return undefined; -} - -function previousArticle(root: HTMLElement, from: HTMLElement): HTMLElement | undefined { - let node: HTMLElement | null = from; - while (node && node !== root) { - if (node.previousElementSibling) { - node = node.previousElementSibling as HTMLElement; - while (node.tagName !== 'ARTICLE' && node.lastElementChild) { - node = node.lastElementChild as HTMLElement; - } - } else { - node = node.parentElement; - } - if (node?.tagName === 'ARTICLE') return node; - } - return undefined; -} diff --git a/packages/ui/src/chat-surface-layout.tsx b/packages/ui/src/chat-surface-layout.tsx index 520d8e05e8..ab3be1b6b5 100644 --- a/packages/ui/src/chat-surface-layout.tsx +++ b/packages/ui/src/chat-surface-layout.tsx @@ -20,27 +20,38 @@ 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. - * - * Intersection is explicit because some TS resolutions only see the published - * Astryx destructure list (which omits conversationKey) 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 & { - conversationKey?: string | number; +export type ChatSurfaceLayoutProps = Omit, 'autoScroll'> & { + /** + * 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 @@ -51,15 +62,16 @@ 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, density = 'balanced', - conversationKey, + scrollOwner = 'astryx', scrollToBottomLabel, ...props }: ChatSurfaceLayoutProps) { + const hostOwned = scrollOwner === 'host'; const astryxOverrides = useMemo( () => scrollToBottomLabel @@ -72,15 +84,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 ); + // 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 2efc296d3a..43535b6f75 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'; @@ -260,7 +261,6 @@ export function ChatView(props: { scrollTargetTurn?: { turnId: string; nonce: number }; scrollBehavior: ScrollBehavior; hasOlderHistory?: boolean; - historyLoadPending?: boolean; onLoadEarlierHistory?(): Promise | void; returnToLatest?: { title: string; @@ -504,7 +504,7 @@ export function ChatView(props: { throw new Error('ChatView must be rendered inside ChatSurfaceLayout'); } const scrollRef = chatLayout.scrollContainerRef; - const [latestNavigationNonce, setLatestNavigationNonce] = useState(0); + const scrollAuthority = useTranscriptScrollAuthority(); const orderedTurnIds = useMemo(() => turns.map((turn) => turn.turnId), [turns]); const sessionId = props.activeSession?.id; const { @@ -551,14 +551,11 @@ export function ChatView(props: { const { highlightedTurnId } = useChatScroll({ scrollRef, sessionId: props.activeSession?.id, - hasTurns: turns.length > 0, messages: props.messages, target: props.scrollTargetTurn, behavior: props.scrollBehavior, hasOlderHistory: props.hasOlderHistory, - historyLoadPending: props.historyLoadPending, onLoadEarlierHistory: props.onLoadEarlierHistory, - latestNavigationNonce, }); const { quote: selectionQuote, clear: clearSelectionQuote } = useMessageSelectionQuote( scrollRef, @@ -670,10 +667,14 @@ export function ChatView(props: { title={props.returnToLatest.title} actionLabel={props.returnToLatest.label} isPending={props.returnToLatest.isPending} - onReturnToLatest={() => - Promise.resolve(props.returnToLatest?.onClick()).then(() => { - setLatestNavigationNonce((nonce) => nonce + 1); - })} + 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 && @@ -243,14 +237,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; } @@ -489,9 +480,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. @@ -501,8 +492,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..e2a20e5b0a --- /dev/null +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -0,0 +1,294 @@ +/* + * 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. It + * remembers the offset it wrote, so a scroll event that finds the scroller + * still on that offset is its own echo and any other offset is the reader — by + * construction, and with no dependence on when the event arrives. 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'; + +/** 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; + /** One-shot: put the tail back under the reader and follow it again. */ + pinToTail(): 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; + /** + * Called when the reader moved the scroller, and only then. Growth, native + * anchoring and this authority's own writes all move `scrollTop` without + * saying anything about what the reader wants, and none of them reach here. + * + * It exists so nothing else keeps a second reading of the raw `scroll` event: + * whoever needs "the reader is near the start" asks the position, and this + * says when asking means anything. + */ + subscribeToReaderScroll(listener: () => void): () => void; + subscribe(listener: () => void): () => void; + getSnapshot(): TranscriptScrollSnapshot; +} + +export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { + let root: HTMLElement | null = null; + let pinned = true; + let awayFromTail = false; + /** + * The offset this authority last wrote, as the browser clamped it. + * + * A scroll event arrives asynchronously, and on a loaded machine that can be + * more than a frame after the write that caused it. Timing cannot tell the + * two apart — the position can: our own write is still sitting in `scrollTop` + * when its event lands, and a reader's gesture has already moved it somewhere + * else. + */ + let lastWrittenTop: number | undefined; + /** + * The scroll geometry the last event saw. + * + * Both numbers move `scrollTop` without the reader touching anything: content + * lands and native anchoring compensates, or the viewport changes size and + * the browser clamps the offset to the new end. Comparing them is how a + * gesture is told from everything else that writes. + */ + let lastScrollHeight = 0; + let lastClientHeight = 0; + let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail }; + const listeners = new Set<() => void>(); + const readerListeners = 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 writeToTail = (): void => { + if (!root) return; + root.scrollTop = root.scrollHeight; + // Read them back: the browser clamps the write to the end of the scroller, + // and the clamped offset is what the event will carry. + lastWrittenTop = root.scrollTop; + lastScrollHeight = root.scrollHeight; + lastClientHeight = root.clientHeight; + awayFromTail = false; + publish(); + }; + + return { + attach(next) { + root = next; + const target = root; + if (!target) return () => undefined; + const onScroll = (): void => { + // An event that finds the scroller still on the offset this authority + // put it on is the echo of that write, however late it arrives; any + // other offset is the reader, exactly, and 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 (lastWrittenTop !== undefined && Math.abs(target.scrollTop - lastWrittenTop) < 1) { + lastScrollHeight = target.scrollHeight; + lastClientHeight = target.clientHeight; + return; + } + // An event that arrives with the scroll geometry changed is the content + // or the viewport moving under the reader, not the reader moving: + // anchoring holding them still as turns land above, growth that outran + // this authority's own write, or a resize the browser answered by + // clamping the offset. Their offset changed and their intent did not, + // so the pin — which is that intent — must not be re-derived from where + // they now are, and nobody may be told the reader asked for anything. + // The affordance still follows the new distance, because that is a fact + // about the viewport rather than about them. + const moved = + target.scrollHeight !== lastScrollHeight || target.clientHeight !== lastClientHeight; + lastScrollHeight = target.scrollHeight; + lastClientHeight = target.clientHeight; + const distance = distanceToTail(); + awayFromTail = distance > BUTTON_THRESHOLD_PX; + if (moved) { + publish(); + return; + } + pinned = distance <= PIN_THRESHOLD_PX; + publish(); + for (const listener of [...readerListeners]) listener(); + }; + lastScrollHeight = target.scrollHeight; + lastClientHeight = target.clientHeight; + target.addEventListener('scroll', onScroll, { passive: true }); + // Everything that moves the tail without the reader asking, watched in + // one place: the scroller's own box, because the tail also moves when the + // viewport shrinks (a window resize, a composer that gains a line), and + // its children's boxes, because that is what `scrollHeight` is made of. + // + // Children rather than the scroller: a ResizeObserver on a scroll + // container reports the viewport, never the overflow. And children rather + // than the transcript's own idea of what grew — a turn, a streaming + // message — because the transcript renders content outside turns too, and + // an observer that knows which nodes matter is an observer that can be + // wrong about it. + const box = new ResizeObserver(() => { + if (pinned) { + writeToTail(); + return; + } + awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; + publish(); + }); + const observeBox = (): void => { + box.disconnect(); + box.observe(target); + for (const child of target.children) box.observe(child); + }; + // Only the direct children: anything deeper grows one of them on its way + // to growing `scrollHeight`, or is out of flow and does not grow it. + const childList = new MutationObserver(observeBox); + childList.observe(target, { childList: true }); + observeBox(); + if (pinned) writeToTail(); + return () => { + childList.disconnect(); + box.disconnect(); + target.removeEventListener('scroll', onScroll); + lastWrittenTop = undefined; + if (root === target) root = null; + }; + }, + pinToTail() { + pinned = true; + writeToTail(); + publish(); + }, + releasePin() { + pinned = false; + awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; + publish(); + }, + subscribeToReaderScroll(listener) { + readerListeners.add(listener); + return () => { + readerListeners.delete(listener); + }; + }, + 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} + ); +} + +/** + * Every `ChatSurfaceLayout` provides one, so a missing authority is a tree that + * was assembled wrong rather than a state to degrade into — the same contract + * `ChatView` already states about its layout. + */ +export function useTranscriptScrollAuthority(): TranscriptScrollAuthority { + const authority = useContext(TranscriptScrollContext); + if (!authority) { + throw new Error('useTranscriptScrollAuthority must be used inside ChatSurfaceLayout'); + } + return authority; +} + +/** + * 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, + authority.getSnapshot, + authority.getSnapshot, + ); + return ( + authority.pinToTail()} + /> + ); +} diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index dd2f200026..9def1060a5 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -17,183 +17,114 @@ * 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, ask for the history + * above them — and it releases the pin first, because the authority writes + * nothing while the pin is released and so a command can never be fighting a + * policy. That was the shape every previous round of this code had. + * + * What decides whether the reader wants either thing is never re-derived here. + * "They have left the tail" is the pin, and the pin has one owner. Nothing here + * compensates for content that lands above them either; `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 { createArrivalBottomPin, type ArrivalBottomPin } from './arrival-bottom-pin.js'; -import { captureChatScrollAnchor, restoreChatScrollAnchor } from './chat-scroll-anchor.js'; +import { useTranscriptScrollAuthority } from './transcript-scroll-authority.js'; export function useChatScroll(input: { scrollRef: RefObject; sessionId?: string; - hasTurns: boolean; messages: readonly StoredMessage[]; target?: { turnId: string; nonce: number }; behavior: ScrollBehavior; hasOlderHistory?: boolean; - historyLoadPending?: boolean; onLoadEarlierHistory?(): Promise | void; - latestNavigationNonce?: number; }) { const [highlightedTurnId, setHighlightedTurnId] = useState(null); - const arrivalPin = useRef(null); + const authority = useTranscriptScrollAuthority(); const loadEarlierRef = useRef(input.onLoadEarlierHistory); loadEarlierRef.current = input.onLoadEarlierHistory; - const sessionIdRef = useRef(input.sessionId); - sessionIdRef.current = input.sessionId; - const historyLoadPendingRef = useRef(input.historyLoadPending); - historyLoadPendingRef.current = input.historyLoadPending; const canLoadEarlier = input.onLoadEarlierHistory !== undefined; - const earlierLoadRequest = useRef(null); - const requestEarlierRef = useRef<() => void>(() => {}); + const 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(() => 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(() => { - earlierLoadRequest.current = null; + authority.pinToTail(); }, [input.sessionId]); useEffect(() => { const root = input.scrollRef.current; if (!root || !input.hasOlderHistory || !canLoadEarlier) return; - let previousScrollTop = root.scrollTop; + // 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 scrollHeight = root.scrollHeight; - const anchor = captureChatScrollAnchor(root); - const sessionId = sessionIdRef.current; - 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; - }); - }); + // 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); }; + /** Close enough to the start that the reader is about to reach it. */ 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; - }; + // Nearness alone does not mean the reader wants history — on a transcript + // shorter than about three viewports the tail is inside this band too, so + // following it would ask on every write, and content landing above would + // ask again on every anchoring correction until there was no history left. + // Which movements were the reader's is not re-derived here; the authority + // watches the scroller and says so. + const stopWatchingReader = authority.subscribeToReaderScroll(() => { + if (nearStart()) requestEarlier(); + }); + // A wheel is the reader asking to go up, which at `scrollTop === 0` is the + // only way they can: the scroller cannot move, so no scroll event follows + // and the authority never sees the gesture. Releasing here is what tells it + // — a reader who asked for what is above them is no longer following what + // is below. const onWheel = (event: WheelEvent): void => { - if (event.deltaY < 0 && nearStart()) requestEarlier(); + if (event.deltaY >= 0 || !nearStart()) return; + authority.releasePin(); + 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); + stopWatchingReader(); root.removeEventListener('wheel', onWheel); }; - }, [ - input.hasOlderHistory, - input.historyLoadPending, - canLoadEarlier, - input.scrollRef, - input.sessionId, - ]); - - // A session switch is navigation, so its initial virtual tail arrives at the - // bottom instead of animating there as ordinary content growth. - useEffect(() => { - const viewport = input.scrollRef.current; - if (!viewport) return; - // Nothing to arrive: keep the plain positioning this effect has always done - // for a transcript that is empty (or still loading its first turn), and let - // the pin install on the commit those turns land in. - if (!input.hasTurns) { - viewport.scrollTop = viewport.scrollHeight; - return; - } - const pin = createArrivalBottomPin({ - viewport, - content: viewport.querySelector('.maka-chat-message-list'), - onStateChange: (state) => { viewport.dataset.arrivalPin = state; }, - }); - arrivalPin.current = pin; - return () => { - pin.dispose(); - arrivalPin.current = null; - delete viewport.dataset.arrivalPin; - }; - }, [input.sessionId, input.hasTurns, input.scrollRef, input.latestNavigationNonce]); - - useEffect(() => { - const root = input.scrollRef.current; - if (!root || !input.hasTurns) return; - let disposed = false; - let pollTimer: number | undefined; - let frame = 0; - let idle: number | undefined; - let idleTimer: number | undefined; - let polls = 0; - const finishArrival = () => { - if (disposed) return; - if (root.querySelector('.maka-markdown-pending') && polls < 50) { - polls += 1; - pollTimer = window.setTimeout(finishArrival, 100); - return; - } - frame = window.requestAnimationFrame(() => { - frame = window.requestAnimationFrame(() => { - if (disposed) return; - root.dataset.turnWindow = 'ready'; - arrivalPin.current?.release(); - const prefetch = () => { - if (root.scrollTop <= Math.max(640, root.clientHeight * 2)) { - requestEarlierRef.current(); - } - }; - if (typeof window.requestIdleCallback === 'function') { - idle = window.requestIdleCallback(prefetch, { timeout: 250 }); - } else { - idleTimer = window.setTimeout(prefetch, 0); - } - }); - }); - }; - const fontsReady: Promise = - typeof document !== 'undefined' && document.fonts ? document.fonts.ready : Promise.resolve(); - void fontsReady.then(finishArrival); - return () => { - disposed = true; - window.clearTimeout(pollTimer); - window.clearTimeout(idleTimer); - if (idle !== undefined) window.cancelIdleCallback(idle); - if (frame !== 0) window.cancelAnimationFrame(frame); - delete root.dataset.turnWindow; - }; - }, [ - input.sessionId, - input.hasTurns, - input.hasOlderHistory, - input.scrollRef, - input.latestNavigationNonce, - canLoadEarlier, - ]); + }, [authority, input.hasOlderHistory, canLoadEarlier, input.scrollRef, input.sessionId]); 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(); + // 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 (handledTarget.current === chosen) return; + authority.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 16f8c95758..2f43abadf1 100644 --- a/packages/ui/src/use-turn-virtualizer.ts +++ b/packages/ui/src/use-turn-virtualizer.ts @@ -26,7 +26,6 @@ import { useState, type RefObject, } from 'react'; -import { captureChatScrollAnchor, restoreChatScrollAnchor } from './chat-scroll-anchor.js'; import { createTurnHeightIndex, turnLayoutGap, turnLayoutKey } from './turn-height-index.js'; import { buildTurnVirtualLayout, @@ -132,7 +131,6 @@ export function useTurnVirtualizer(input: { const layoutRef = useRef(layout); const stateRef = useRef(current); - const pendingAnchor = useRef>(undefined); const pendingReveal = useRef(undefined); useLayoutEffect(() => { @@ -147,10 +145,9 @@ export function useTurnVirtualizer(input: { } }, [current, ensureIndex, layout, targetKey]); - const installWindow = useCallback((next: TurnVirtualWindow, anchor = true): boolean => { + const installWindow = useCallback((next: TurnVirtualWindow): boolean => { const root = input.scrollRef.current; if (sameWindow(stateRef.current.window, next)) return false; - if (anchor && root) pendingAnchor.current = captureChatScrollAnchor(root); if (root) handOffExcludedInteraction(root, stateRef.current.turnIds, next); setState((previous) => sameWindow(previous.window, next) ? previous @@ -183,10 +180,6 @@ export function useTurnVirtualizer(input: { useLayoutEffect(() => { const root = input.scrollRef.current; if (!root) return; - if (pendingAnchor.current) { - restoreChatScrollAnchor(root, pendingAnchor.current); - pendingAnchor.current = undefined; - } const reveal = pendingReveal.current; pendingReveal.current = undefined; if (reveal) { @@ -201,7 +194,7 @@ export function useTurnVirtualizer(input: { ensureIndex: targetIndex < 0 ? undefined : targetIndex, preferredTurns: MIN_TURN_WINDOW_SIZE, }, - ), false); + )); } } }, [current.window, input.scrollRef, installWindow]); @@ -236,7 +229,6 @@ export function useTurnVirtualizer(input: { const nextGap = turnLayoutGap(root, DEFAULT_TURN_GAP); const nextLayoutKey = turnLayoutKey(root, nextGap); let changed = nextLayoutKey !== layoutKey; - const anchor = virtualizationRequired ? captureChatScrollAnchor(root) : undefined; for (const entry of entries) { const element = entry.target as HTMLElement; const turnId = element.dataset.virtualTurnId; @@ -250,10 +242,7 @@ export function useTurnVirtualizer(input: { ) || changed; } } - if (changed) { - if (anchor) pendingAnchor.current = anchor; - setGeometryRevision((revision) => revision + 1); - } + if (changed) setGeometryRevision((revision) => revision + 1); scheduleWindow(); }); const observeTree = (node: Node): void => { diff --git a/patches/@astryxdesign+core+0.5.0.patch b/patches/@astryxdesign+core+0.5.0.patch index 25e5a1ead1..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..346e01c 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..346e01c 100644 className, style, 'data-testid': testId, -+ conversationKey, ++ autoScroll = true, ref, ...rest }) { -@@ -211,6 +212,20 @@ 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,35 +59,9 @@ index ff9b9fa..346e01c 100644 + newMsgs.dismiss(); + } + }, [scroll.isLocked, newMsgs.dismiss]); -+ const conversationKeyRef = useRef(conversationKey); -+ useEffect(() => { -+ if (conversationKey === conversationKeyRef.current) { -+ return; -+ } -+ conversationKeyRef.current = conversationKey; -+ scroll.lock(); -+ newMsgs.reset(); -+ }, [conversationKey, scroll.lock, 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({ - // --- 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 @@ -104,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({ @@ -116,22 +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/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