From 60959fc3ba53f033c3bb468b750012fcc686832d Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 9 Sep 2026 03:11:42 +0800 Subject: [PATCH 01/10] fix(ui): separate transcript reading intent from layout changes --- .../src/renderer/styles/chat-message.css | 8 + apps/desktop/stories/app-shell.stories.tsx | 78 ++++- .../__tests__/return-to-latest-pin.test.tsx | 22 +- .../transcript-scroll-authority.test.ts | 244 ++++++++------ .../ui/src/__tests__/use-chat-scroll.test.tsx | 19 +- .../ui/src/transcript-scroll-authority.tsx | 309 ++++++++++-------- packages/ui/src/use-chat-scroll.ts | 33 +- .../transcript-scroll-intent.stories.tsx | 143 ++++++++ .../transcript-scroll-rounding.stories.tsx | 233 ------------- 9 files changed, 558 insertions(+), 531 deletions(-) create mode 100644 packages/ui/stories/transcript-scroll-intent.stories.tsx delete mode 100644 packages/ui/stories/transcript-scroll-rounding.stories.tsx diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 1380bcac24..cfac047f58 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -61,6 +61,14 @@ contain-intrinsic-block-size: auto 280px; } +/* Tail following needs the live Turn's layout on admission, before reading + scrollHeight. Skipping its layout first exposes estimates which can then + shrink and pull the prompt backwards. Keep its individual timeline blocks + skippable: an active Turn can itself contain many screens of older work. */ +.maka-transcript-turn:has(> [data-live-streaming='true']) { + content-visibility: visible; +} + .maka-chat-message-loading { display: grid; place-items: center; diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 4764dbc1d3..4e12d8c8bc 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1913,6 +1913,14 @@ function dockOffered(): boolean { * real one does. What it cannot do is scroll, so cases that need the reader to * move set `scrollTop` themselves. */ +/** Storybook input is synthetic, so supply its native scroll result explicitly. */ +function scrollAsReader(root: HTMLElement, top: number): void { + const deltaY = top - root.scrollTop; + if (deltaY === 0) return; + root.dispatchEvent(new WheelEvent('wheel', { deltaY, bubbles: true })); + root.scrollTo({ top, behavior: 'instant' }); +} + function wheelUp(target: Element): void { target.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true })); } @@ -2168,7 +2176,7 @@ export const StreamingTailFollow: Story = { const input = canvasElement.querySelector('.maka-composer-editor [contenteditable="true"]'); if (!input) throw new Error('The composer input is missing'); await userEvent.type(input, 'Second question after reading history.'); - tailScroller().scrollTop = 0; + scrollAsReader(tailScroller(), 0); await painted(6); expect(tailMetrics().distance).toBeGreaterThan(500); expect(dockOffered()).toBe(true); @@ -2203,6 +2211,47 @@ export const StreamingTailFollow: Story = { }, }; +async function verifySubmittedPrompt(canvasElement: HTMLElement, multiline = false): Promise { + await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); + await painted(40); + const input = canvasElement.querySelector('.maka-composer-editor [contenteditable="true"]'); + if (!input) throw new Error('The composer input is missing'); + await userEvent.type(input, '请简短说明当前提交过程发生了什么。'); + if (multiline) { + for (let line = 1; line < 8; line += 1) { + await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); + await userEvent.type(input, '这是多行提示词,提交后输入框收起仍应平稳跟随新回答。'); + } + } + await painted(40); + // Observe admission itself: the correct final bottom can hide a reverse + // jump when an offscreen block first uses an estimate, then its real size. + const tops: number[] = []; + const arrival = (async () => { + for (let frame = 0; frame < 40; frame += 1) { + await painted(1); + const turn = canvasElement.querySelector('[data-transcript-turn-id="turn-tail"]'); + if (turn) tops.push(turn.getBoundingClientRect().top); + } + })(); + await userEvent.keyboard('{Enter}'); + await arrival; + await expect(tops.length).toBeGreaterThan(1); + const reversal = Math.max(0, ...tops.slice(1).map((top, index) => top - tops[index]!)); + await expect(reversal, JSON.stringify(tops)).toBeLessThanOrEqual(4); + await expect(tailMetrics().distance).toBeLessThanOrEqual(4); +} + +export const SubmittedPromptDoesNotReverse: Story = { + render: () => , + play: async ({ canvasElement }) => verifySubmittedPrompt(canvasElement), +}; + +export const MultilineSubmittedPromptDoesNotReverse: Story = { + render: () => , + play: async ({ canvasElement }) => verifySubmittedPrompt(canvasElement, true), +}; + /** Lets a play function drive props React owns. One story renders per page. */ let appendTurn: (() => void) | undefined; @@ -2314,7 +2363,7 @@ export const ReaderScrolledUpIsNotPulledBack: Story = { expect(boundaryStyle.willChange).toContain('opacity'); await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); - root.scrollTop -= 500; + scrollAsReader(root, root.scrollTop - 500); await painted(6); const before = tailMetrics().distance; expect(before, JSON.stringify(tailMetrics())).toBeGreaterThan(100); @@ -2350,7 +2399,7 @@ export const ReaderScrolledUpIsNotPulledBack: Story = { // pins the causal contract; these samples separately ensure the fix never // turns into a real footer movement. const iconTops: number[] = []; - root.scrollTop = root.scrollHeight; + scrollAsReader(root, root.scrollHeight); root.dispatchEvent(new Event('scroll')); for (let frame = 0; frame < 16; frame += 1) { await painted(1); @@ -2365,7 +2414,7 @@ export const DockAffordanceReturnsToTail: Story = { play: async () => { await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); - tailScroller().scrollTop = 0; + scrollAsReader(tailScroller(), 0); await painted(6); // Offered at all is the assertion: with Astryx's scroll layer off, its // `isScrolledUp` never updates again, so the stock button would stay @@ -2538,7 +2587,7 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { // `behavior: 'instant'` overrides the shell's smooth scrolling: the shell // animates over many frames, and a step measured before the animation // lands reads a still anchor as a 240px jump. - root.scrollTo({ top: root.scrollTop - intended, behavior: 'instant' }); + scrollAsReader(root, root.scrollTop - intended); root.dispatchEvent(new Event('scroll')); await painted(4); const moved = anchor.getBoundingClientRect().top - topBefore; @@ -2567,6 +2616,11 @@ export const OversizedTurnHoldsAReadingAnchorOnColdScroll: Story = { }, }; +export const OversizedLiveTurnHoldsAReadingAnchorOnColdScroll: Story = { + ...OversizedTurnHoldsAReadingAnchorOnColdScroll, + render: () => , +}; + export const AWheelTheScrollerCannotActOnAsksForHistory: Story = { render: () => , play: async () => { @@ -2593,7 +2647,7 @@ export const EarlierHistoryLandsAboveTheReader: Story = { // Just short of the band that asks for more, so the active range has // painted turns around the reader before the load starts. Landing straight // on zero leaves no visible turn above the load boundary to anchor on. - root.scrollTop = loadBand() + 400; + scrollAsReader(root, loadBand() + 400); await painted(6); const before = firstResidentTurnId(); const heightBefore = root.scrollHeight; @@ -2601,7 +2655,7 @@ export const EarlierHistoryLandsAboveTheReader: Story = { // The move that asks for earlier history and the reading of where the // reader is, in one task. - root.scrollTop = Math.min(300, root.scrollHeight - root.clientHeight); + scrollAsReader(root, Math.min(300, root.scrollHeight - root.clientHeight)); const rootTop = root.getBoundingClientRect().top; const turn = [...root.querySelectorAll('[data-turn-id]')].find( (candidate) => candidate.getBoundingClientRect().bottom > rootTop, @@ -2690,7 +2744,7 @@ export const UpwardTraversalHoldsTurnGeometry: Story = { while (root.scrollTop > 0 && steps < 40) { const anchor = anchorInView(); const scrollBefore = root.scrollTop; - root.scrollTop = Math.max(0, scrollBefore - TRAVERSAL_STEP); + scrollAsReader(root, Math.max(0, scrollBefore - TRAVERSAL_STEP)); await painted(4); // The reader moved by what the scroller actually moved, so the Turn under @@ -2745,7 +2799,7 @@ export const HistoryAtTheTopStillLandsAboveTheReader: Story = { // The one position where the browser declines to anchor, and the one the // wheel-to-load path puts the reader in. - root.scrollTop = 0; + scrollAsReader(root, 0); wheelUp(root); await waitFor(() => expect(firstResidentTurnId()).not.toBe(before)); @@ -2855,7 +2909,7 @@ export const PromptRailStaysInsideTheScrollport: Story = { expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight); for (const position of ['top', 'bottom'] as const) { - scroller.scrollTop = position === 'top' ? 0 : scroller.scrollHeight; + scrollAsReader(scroller, position === 'top' ? 0 : scroller.scrollHeight); scroller.dispatchEvent(new Event('scroll')); await painted(4); @@ -2925,14 +2979,14 @@ export const PromptRailHasNoGapsBetweenTicks: Story = { /** Away from the tail, but still inside the band that would ask for history. */ async function scrollAwayFromTail(): Promise { const root = tailScroller(); - root.scrollTop = Math.min(root.scrollHeight - root.clientHeight - 100, loadBand() + 200); + scrollAsReader(root, Math.min(root.scrollHeight - root.clientHeight - 100, loadBand() + 200)); root.dispatchEvent(new Event('scroll')); await painted(4); } async function scrollTranscriptTo(position: 'top' | 'bottom'): Promise { const root = tailScroller(); - root.scrollTop = position === 'top' ? 0 : root.scrollHeight; + scrollAsReader(root, position === 'top' ? 0 : root.scrollHeight); root.dispatchEvent(new Event('scroll')); await painted(4); } diff --git a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx index fc3de1accd..c07abfe45d 100644 --- a/packages/ui/src/__tests__/return-to-latest-pin.test.tsx +++ b/packages/ui/src/__tests__/return-to-latest-pin.test.tsx @@ -130,6 +130,7 @@ interface ReturnToLatestHarness { readonly scrollButton: HTMLElement; readonly clickEvent: Event; readerScroll(): void; + geometryScroll(): void; } /** @@ -226,16 +227,10 @@ function harness(options: { readonly onClick: () => Promise | void }): Ret assert.ok(scrollRoot, 'the layout mounts a scroll container'); const scrollButton = mount.querySelector('button[aria-label="回到最新"]'); assert.ok(scrollButton, 'the return-to-latest affordance is rendered'); - // The authority attached itself to the scroller on mount and wrote the tail - // into a zero-sized box, so its classification state says scrollTop=0 over a - // zero-height scroller. Give the box real geometry, then deliver one scroll - // event at that same offset so the echo branch refreshes the recorded - // geometry without being taken for the reader. Park the reader at 600 — the - // way paging back leaves them — and let `readerScroll()` announce it. + // LinkeDOM has no layout. Start at the tail; readerScroll supplies input and + // its resulting offset, whereas geometryScroll supplies no reading intent. Object.assign(scrollRoot, { scrollHeight: 2_400, clientHeight: 600 }); - scrollRoot.scrollTop = 0; - scrollRoot.dispatchEvent(new window.Event('scroll')); - scrollRoot.scrollTop = 600; + scrollRoot.scrollTop = 1_800; // linkedom ships Event but not MouseEvent; a bubbling Event still reaches // React's root listener, which reads only the type for onClick. const clickEvent = new window.Event('click', { bubbles: true }); @@ -249,6 +244,13 @@ function harness(options: { readonly onClick: () => Promise | void }): Ret scrollButton, clickEvent, readerScroll() { + const event = new window.Event('wheel', { bubbles: true }); + Object.defineProperty(event, 'deltaY', { value: -120 }); + scrollRoot.dispatchEvent(event); + scrollRoot.scrollTop = 600; + scrollRoot.dispatchEvent(new window.Event('scroll')); + }, + geometryScroll() { scrollRoot.dispatchEvent(new window.Event('scroll')); }, }; @@ -289,7 +291,7 @@ test('the anchor stays cleared while the range is still loading', async () => { // The arriving range is what used to re-report the anchor; whatever moves // the scroller while the load is pending must not hand the shell a Turn. - view.readerScroll(); + view.geometryScroll(); assert.deepEqual(view.anchors, [undefined, 'turn-0', undefined]); click.release(); diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 18258df582..496734cb45 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -17,30 +17,25 @@ * under the License. */ -/** - * The state machine only. Whether the reader ends up looking at the right - * pixels needs a real layout engine and currently has no test at all — a - * harness that fakes layout can only report the ordering the harness itself - * chose, so do not add that claim here. - * - * 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. - */ +/** State/command tests. Real layout and native input are checked in Chromium. */ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createTranscriptScrollAuthority } from '../transcript-scroll-authority.js'; interface FakeRoot { + ownerDocument: EventTarget; style: { overflowAnchor: string }; 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; + addEventListener(type: string, listener: (event: unknown) => void): void; + removeEventListener(type: string, listener: (event: unknown) => void): void; + input(deltaY: number): void; + grabScrollbar(): void; + end(): void; /** Dispatch the scroll event the browser would, one frame later. */ emitScroll(): void; grow(by: number): void; @@ -49,22 +44,32 @@ interface FakeRoot { } function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): FakeRoot { - const listeners = new Set<() => void>(); + const listeners = new Map void>>(); + const emit = (type: string, event?: unknown): void => { + for (const listener of listeners.get(type) ?? []) listener(event); + }; const root: FakeRoot = { + ownerDocument: new EventTarget(), style: { overflowAnchor: '' }, scrollTop: 0, scrollHeight: options?.scrollHeight ?? 3_000, clientHeight: options?.clientHeight ?? 600, children: [{}], addEventListener(type, listener) { - if (type === 'scroll') listeners.add(listener); + if (!listeners.has(type)) listeners.set(type, new Set()); + listeners.get(type)!.add(listener); }, - removeEventListener(_type, listener) { - listeners.delete(listener); + removeEventListener(type, listener) { + listeners.get(type)?.delete(listener); }, emitScroll() { - for (const listener of [...listeners]) listener(); + emit('scroll'); + }, + input(deltaY) { emit('wheel', { deltaY, composedPath: () => [proxy] }); }, + grabScrollbar() { + emit('pointerdown', { button: 0, pointerType: 'mouse', pointerId: 1, target: proxy }); }, + end() { emit('scrollend'); }, grow(by) { root.scrollHeight += by; }, @@ -74,7 +79,7 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F }; // 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, { + const proxy = new Proxy(root, { set(target, property, value) { if (property === 'scrollTop') { target.scrollTop = Math.min(value as number, target.scrollHeight - target.clientHeight); @@ -83,6 +88,7 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F return Reflect.set(target, property, value); }, }); + return proxy; } /** @@ -91,15 +97,16 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F * 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. + * End-of-operation frame callbacks are advanced explicitly. */ -function withObservers(run: (resize: () => void) => T): T { +function withObservers(run: (resize: () => void, frame: () => void) => T): T { const observers = new Set<() => void>(); - const globals = globalThis as { ResizeObserver?: unknown; MutationObserver?: unknown }; + const frames: FrameRequestCallback[] = []; + const globals = globalThis as { ResizeObserver?: unknown; MutationObserver?: unknown; requestAnimationFrame?: unknown }; const originalResize = globals.ResizeObserver; const originalMutation = globals.MutationObserver; + const originalFrame = globals.requestAnimationFrame; + globals.requestAnimationFrame = (callback: FrameRequestCallback) => frames.push(callback); globals.ResizeObserver = class { constructor(private readonly callback: () => void) {} // Registered on `observe` rather than on construction: the authority @@ -121,10 +128,11 @@ function withObservers(run: (resize: () => void) => T): T { try { return run(() => { for (const observer of [...observers]) observer(); - }); + }, () => { for (const callback of frames.splice(0)) callback(0); }); } finally { globals.ResizeObserver = originalResize; globals.MutationObserver = originalMutation; + globals.requestAnimationFrame = originalFrame; } } @@ -139,19 +147,116 @@ test('content that grows under a pinned transcript keeps the tail on screen', () 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. + // The write's scroll event carries no reader input. + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, true); + }); +}); + +test('identical shrink/grow geometry follows only when no reader input intervened', () => { + for (const readerInput of [false, true]) { + withObservers((resize) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + let readerMoves = 0; + authority.subscribeToReaderScroll((_direction, phase) => { + if (phase === 'scroll') readerMoves += 1; + }); + if (readerInput) root.input(-100); + root.grow(-190); + root.scrollTop = 2_210; // Browser clamps at the intermediate bottom. + root.grow(22); + root.emitScroll(); + assert.equal(authority.getSnapshot().pinned, !readerInput); + assert.equal(readerMoves, readerInput ? 1 : 0); + resize(); + assert.equal(root.scrollTop, readerInput ? 2_210 : 2_232); + }); + } +}); + +test('scrollend cannot retire a continuing operation or a newer input', () => { + withObservers((resize, frame) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + root.input(-100); + root.scrollTop = 1_000; + root.emitScroll(); + root.end(); + root.input(100); + frame(); + frame(); + root.scrollTop = 1_500; + root.emitScroll(); + root.end(); // An old animation ends while the new one is still moving. + frame(); + root.scrollTop = 2_400; root.emitScroll(); + frame(); + root.end(); + frame(); + frame(); assert.equal(authority.getSnapshot().pinned, true); + root.grow(50); + resize(); + assert.equal(root.scrollTop, 2_450); }); }); -test('a scroll this authority did not write is the reader, and releases the tail', () => { +test('scrollbar defaults can land after pointerup, while an unmoved click retires', () => { + withObservers((resize, frame) => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + root.grabScrollbar(); + root.ownerDocument.dispatchEvent(new Event('pointerup')); + root.scrollTop = 1_700; + root.emitScroll(); + frame(); + root.end(); + frame(); + frame(); + root.grow(100); + resize(); + assert.equal(root.scrollTop, 1_700); + + authority.pinToTail(); + root.grabScrollbar(); + root.ownerDocument.dispatchEvent(new Event('pointerup')); + frame(); + root.grow(100); + resize(); + assert.equal(root.scrollTop, 2_600); + }); +}); + +test('explicit navigation cancels input provenance before positioning its target', () => { + withObservers(() => { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + authority.attach(root as unknown as HTMLElement); + root.input(-100); + root.scrollTop = 1_700; + root.emitScroll(); + authority.releasePin(); + let reports = 0; + authority.subscribeToReaderScroll(() => { reports += 1; }); + root.scrollTop = 200; + root.emitScroll(); + assert.equal(reports, 0); + assert.equal(authority.getSnapshot().pinned, false); + }); +}); + +test('user input releases the tail before content can overwrite the scroll', () => { withObservers((resize) => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); + root.input(-100); root.scrollTop = 1_000; root.emitScroll(); assert.equal(authority.getSnapshot().pinned, false); @@ -171,6 +276,7 @@ test('returning to the tail re-pins, and following resumes', () => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); + root.input(-100); root.scrollTop = 0; root.emitScroll(); assert.equal(authority.getSnapshot().pinned, false); @@ -213,49 +319,6 @@ test('a viewport that loses height takes the pinned reader back to the tail', () }); }); -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('a reader who scrolls up while the answer grows is still the reader', () => { withObservers((resize) => { const root = fakeRoot(); @@ -263,11 +326,9 @@ test('a reader who scrolls up while the answer grows is still the reader', () => authority.attach(root as unknown as HTMLElement); assert.equal(root.scrollTop, 2_400); - // The same shape as the case above — a scroll event carrying a grown - // `scrollHeight` — and the opposite intent. Growth cannot move the offset - // backwards, so an offset that went up the transcript is the reader's, and - // during a streaming answer this is the only kind of event they produce. + // Input must suspend following before a concurrent resize can write. root.grow(37); + root.input(-500); root.scrollTop = 1_900; root.emitScroll(); assert.equal(authority.getSnapshot().pinned, false); @@ -287,12 +348,15 @@ test('reports both directions even when the reader returns to the last written o const authority = createTranscriptScrollAuthority(); authority.attach(root as unknown as HTMLElement); const directions: string[] = []; - authority.subscribeToReaderScroll((direction) => directions.push(direction)); + authority.subscribeToReaderScroll((direction, phase) => { if (phase === 'scroll') directions.push(direction); }); root.emitScroll(); + root.input(-100); root.scrollTop = 900; root.emitScroll(); + root.input(100); root.scrollTop = 2_400; root.emitScroll(); + root.end(); assert.deepEqual(directions, ['up', 'down']); }); }); @@ -312,6 +376,7 @@ test('a slow reader is a reader, however small each step is', () => { // noise and the reader never moves at all; they only mean anything added // up. Nothing grows here, so there is nothing else they could be. for (let step = 0; step < 90; step += 1) { + root.input(-2); root.scrollTop -= 2; root.emitScroll(); } @@ -349,30 +414,6 @@ test('content leaving from above the reader is not the reader either', () => { }); }); -test('a viewport that grew does not move the reader, it only clamps them', () => { - withObservers(() => { - const root = fakeRoot(); - const authority = createTranscriptScrollAuthority(); - authority.attach(root as unknown as HTMLElement); - authority.releasePin(); - root.scrollTop = 2_350; - root.emitScroll(); - let readerMoves = 0; - authority.subscribeToReaderScroll(() => { - readerMoves += 1; - }); - - // The composer loses a line, so the scrollport gets taller and the end of - // the transcript moves up past where the reader was sitting. The browser - // clamps them to it; they did not ask to go. - root.shrinkViewport(-200); - root.scrollTop = 2_200; - root.emitScroll(); - assert.equal(readerMoves, 0); - assert.equal(authority.getSnapshot().pinned, false); - }); -}); - test('content landing above a released reader does not re-pin them', () => { withObservers((resize) => { const root = fakeRoot(); @@ -402,8 +443,8 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => { const root = fakeRoot(); const authority = createTranscriptScrollAuthority(); let heard = 0; - const stop = authority.subscribeToReaderScroll(() => { - heard += 1; + const stop = authority.subscribeToReaderScroll((_direction, phase) => { + if (phase === 'scroll') heard += 1; }); authority.attach(root as unknown as HTMLElement); @@ -418,6 +459,7 @@ test('only the reader\'s own movement reaches a reader-scroll listener', () => { assert.equal(heard, 0); // The reader, at last. + root.input(-100); root.scrollTop = 900; root.emitScroll(); assert.equal(heard, 1); diff --git a/packages/ui/src/__tests__/use-chat-scroll.test.tsx b/packages/ui/src/__tests__/use-chat-scroll.test.tsx index fd8ebd003d..70759d1579 100644 --- a/packages/ui/src/__tests__/use-chat-scroll.test.tsx +++ b/packages/ui/src/__tests__/use-chat-scroll.test.tsx @@ -41,6 +41,7 @@ const originalGlobals = { Node: globalThis.Node, ResizeObserver: globalThis.ResizeObserver, window: globalThis.window, + requestAnimationFrame: globalThis.requestAnimationFrame, }; const originalActEnvironment = (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; @@ -48,6 +49,12 @@ const originalActEnvironment = (globalThis as typeof globalThis & { let mountedRoot: ReturnType | undefined; +function wheel(target: HTMLElement, deltaY: number): void { + const event = new window.Event('wheel', { bubbles: true }); + Object.defineProperty(event, 'deltaY', { value: deltaY }); + target.dispatchEvent(event); +} + afterEach(async () => { if (mountedRoot) await act(() => mountedRoot?.unmount()); mountedRoot = undefined; @@ -108,6 +115,7 @@ const installScrollTestEnvironment = ( Node: window.Node, ResizeObserver: TestResizeObserver, window, + requestAnimationFrame: window.requestAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true, }); return { frames, resizeCallbacks }; @@ -160,23 +168,21 @@ test('pages only toward reader input, including wheels at a bounded edge', async )); await render(true); assert.deepEqual(calls, [], 'mounting at a partial tail is not a request'); + wheel(scroller, -100); scroller.scrollTop = 900; scroller.dispatchEvent(new window.Event('scroll')); + wheel(scroller, 100); scroller.scrollTop = 1000; scroller.dispatchEvent(new window.Event('scroll')); assert.deepEqual(calls, [ { direction: 'up', anchor: 'turn-1' }, { direction: 'down', anchor: 'turn-2' }, + { direction: 'down', anchor: 'turn-2' }, // Input and its resulting scroll. ], 'overlapping edge bands must not reverse the requested direction'); scroller.scrollTop = 1800; scroller.dispatchEvent(new window.Event('scroll')); assert.equal(authority.getSnapshot().pinned, false, 'a partial tail must not follow a page fill'); - const wheel = (target: HTMLElement, deltaY: number) => { - const event = new window.Event('wheel', { bubbles: true }); - Object.defineProperty(event, 'deltaY', { value: deltaY }); - target.dispatchEvent(event); - }; calls.length = 0; wheel(scroller, 100); assert.deepEqual(calls, [{ direction: 'down', anchor: 'turn-3' }]); @@ -344,6 +350,7 @@ test('a session switch restores a Turn anchor after async fill and preserves tai await renderSession('session-a'); assert.equal(scroller.scrollTop, 2_400); + wheel(scroller, -100); scroller.scrollTop = 900; scroller.dispatchEvent(new window.Event('scroll')); assert.equal(anchors.get('session-a'), 'turn-a-2'); @@ -453,9 +460,11 @@ test('a session switch restores a Turn anchor after async fill and preserves tai assert.equal(scroller.scrollTop, 2_400); assert.equal(anchors.has('session-a'), false); + wheel(scroller, -100); scroller.scrollTop = 1_000; scroller.dispatchEvent(new window.Event('scroll')); assert.equal(anchors.get('session-a'), 'turn-a-latest'); + scroller.dispatchEvent(new window.Event('scrollend')); installTranscript(800, [{ id: 'geometry-resident', start: 0, height: 800 }]); scroller.scrollTop = scroller.scrollTop; await renderSession('session-a'); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 1332908750..3f6ce14f8b 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -32,9 +32,8 @@ * behind this authority's own write. Once released, restore native anchoring * to keep the reader on the same content without application writes. * - * The last written offset identifies our asynchronous scroll echoes. When - * released, geometry also accounts for native anchoring and browser clamping - * before an unexplained movement is reported as reader input. + * Input establishes reading intent; scroll and resize only report geometry. + * Layout can shrink, clamp the offset, then grow before scroll is delivered. */ import { @@ -49,26 +48,6 @@ 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; -/** - * How far an offset may miss what the content accounts for and still be the - * content. - * - * The band below holds an exact `scrollTop` against a range built from two - * rounded integers, and native anchoring rounds the anchor's own positions - * separately again, so a step that is entirely the content still lands a pixel - * or two outside its own band. A story in `packages/ui/stories` measures that - * against a real layout engine and goes red if a browser starts missing by - * more. - * - * It is spent only where that arithmetic happened. An event that finds the - * content unchanged has nothing rounded in it: the band is a point, the offset - * either moved or did not, and a reader inching down a settled transcript is - * heard exactly. Spending it on those events instead is what would make a slow - * reader unhearable, and no accumulator can buy that back — the error is - * bounded per event but one-directional across a stream, so a running total - * turns a pixel of arithmetic into a drift that crosses any threshold. - */ -const GEOMETRY_ROUNDING_PX = 2; export interface TranscriptScrollSnapshot { /** Following the tail: growth writes `scrollTop`. */ @@ -89,73 +68,56 @@ export interface TranscriptScrollAuthority { */ 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. + * Input can request history at an edge before any movement. Scroll reports + * the resulting reading position. Neither phase is emitted for layout alone; + * consumers do not interpret raw wheel or scroll events themselves. */ - subscribeToReaderScroll(listener: (direction: 'up' | 'down') => void): () => void; + subscribeToReaderScroll(listener: (direction: 'up' | 'down', phase: 'input' | 'scroll') => void): () => void; subscribe(listener: () => void): () => void; getSnapshot(): TranscriptScrollSnapshot; } +/** Whether the browser can route vertical input through the nested scroll chain. */ +function reachesTranscript(event: Event, root: HTMLElement, direction: 'up' | 'down'): boolean { + for (const node of event.composedPath()) { + if (node === root) return true; + if (!(node instanceof HTMLElement)) continue; + const style = getComputedStyle(node); + if (!['auto', 'scroll', 'overlay'].includes(style.overflowY)) continue; + const remaining = direction === 'up' + ? node.scrollTop : node.scrollHeight - node.clientHeight - node.scrollTop; + if (remaining > 0 || ['contain', 'none'].includes(style.overscrollBehaviorY)) return false; + } + return false; +} + 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; - /** The offset the last event saw, to measure the next one's move against. */ - let lastScrollTop = 0; + // Geometry belongs to a known input operation, never the other way around. + // scrollend also covers smooth keyboard scrolling and touchpad inertia. + let gesture: { top: number; direction?: 'up' | 'down' } | undefined; let snapshot: TranscriptScrollSnapshot = { pinned, awayFromTail }; const listeners = new Set<() => void>(); - const readerListeners = new Set<(direction: 'up' | 'down') => void>(); - + const readerListeners = new Set<(direction: 'up' | 'down', phase: 'input' | 'scroll') => void>(); + const distanceToTail = (): number => + root ? root.scrollHeight - root.scrollTop - root.clientHeight : 0; const publish = (): void => { - // Net height cannot explain anchoring when content shrinks above the - // viewport while growing below it. Give each mode just one scroll writer. if (root) root.style.overflowAnchor = pinned ? 'none' : 'auto'; 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; - lastScrollTop = root.scrollTop; awayFromTail = false; publish(); }; + const reportReader = (direction: 'up' | 'down', phase: 'input' | 'scroll'): void => { + for (const listener of [...readerListeners]) listener(direction, phase); + }; return { attach(next) { @@ -164,92 +126,140 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { if (!target) return () => undefined; const previousOverflowAnchor = target.style.overflowAnchor; publish(); - 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; - lastScrollTop = target.scrollTop; + const begin = (event: Event, direction: 'up' | 'down'): void => { + if (event.defaultPrevented || !reachesTranscript(event, target, direction)) return; + const remaining = direction === 'up' ? target.scrollTop : distanceToTail(); + if (remaining <= 0) { + // An edge gesture can ask for an adjacent history page even though + // it produces no scroll (and therefore no scrollend). + reportReader(direction, 'input'); return; } - // Once the viewport leaves our write, returning to that same pixel is - // a new movement, not an echo (bounded windows often have equal heights). - lastWrittenTop = undefined; - // Content moves the offset too, and only ever by how much the end of - // the transcript moved. Native anchoring answers content landing above - // the reader by pushing the offset down by exactly what was inserted, - // content leaving from above by pulling it up by exactly what went, - // and a transcript that ends before the offset by clamping it to the - // new end — every one of them somewhere between nothing and that whole - // amount. Inside that band their offset changed and their intent did - // not, so the pin 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. - // - // Outside it, the move is the reader's, and this may not be decided - // from the geometry merely having changed. During growth it always - // has, so a reader who scrolled while an answer streamed arrived - // carrying a changed `scrollHeight` and was discarded along with it — - // the pin stayed, and the next growth wrote the view back to the tail. - // Scrolling away from a streaming answer is the one moment a reader - // most needs to be believed. - const maxScroll = target.scrollHeight - target.clientHeight; - const contentDelta = maxScroll - (lastScrollHeight - lastClientHeight); - const explainedLow = Math.min(0, contentDelta); - const explainedHigh = Math.max(0, contentDelta); - const topDelta = target.scrollTop - lastScrollTop; - const unexplained = topDelta - Math.min(explainedHigh, Math.max(explainedLow, topDelta)); - const slack = contentDelta === 0 ? 0 : GEOMETRY_ROUNDING_PX; - const readerMoved = Math.abs(unexplained) > slack; - lastScrollHeight = target.scrollHeight; - lastClientHeight = target.clientHeight; - lastScrollTop = target.scrollTop; - const distance = distanceToTail(); - awayFromTail = distance > BUTTON_THRESHOLD_PX; - if (!readerMoved) { - publish(); - return; + gesture = { top: gesture?.top ?? target.scrollTop, direction }; + pinned = false; + publish(); + reportReader(direction, 'input'); + }; + const onWheel = (event: WheelEvent): void => { + if (event.ctrlKey || event.deltaY === 0) return; + begin(event, event.deltaY < 0 ? 'up' : 'down'); + }; + const onKeyDown = (event: KeyboardEvent): void => { + const element = event.target; + if (!(element instanceof HTMLElement) || element.isContentEditable + || element.closest('input, textarea, select') || event.altKey || event.metaKey) return; + if (event.key === ' ' && element.closest('button, summary, [role="button"]')) return; + if (event.ctrlKey && !['Home', 'End'].includes(event.key)) return; + const direction = ['ArrowUp', 'PageUp', 'Home'].includes(event.key) + || (event.key === ' ' && event.shiftKey) ? 'up' + : ['ArrowDown', 'PageDown', 'End', ' '].includes(event.key) ? 'down' : undefined; + if (direction) begin(event, direction); + }; + let pointer: number | undefined; + const onPointerDown = (event: PointerEvent): void => { + if (event.defaultPrevented || event.button !== 0 || event.pointerType === 'touch' + || event.target !== target) return; + pointer = event.pointerId; + gesture = { top: target.scrollTop }; + }; + const onPointerMove = (event: PointerEvent): void => { + if (pointer === event.pointerId) gesture ??= { top: target.scrollTop }; + }; + const onPointerUp = (): void => { + pointer = undefined; + const pending = gesture; + if (!pending || pending.direction !== undefined) return; + // Native track clicks can start their smooth scroll after pointerup. + // Scroll steps precede rAF; retire a click that still has not moved + // there, rather than leaving a non-scrolling click armed indefinitely. + requestAnimationFrame(() => { + if (gesture !== pending || pending.direction !== undefined) return; + gesture = undefined; + if (pinned) writeToTail(); + }); + }; + let touchY: number | undefined; + const onTouchStart = (event: TouchEvent): void => { + touchY = event.touches.length === 1 ? event.touches[0]!.clientY : undefined; + }; + const onTouchMove = (event: TouchEvent): void => { + const nextY = event.touches.length === 1 ? event.touches[0]!.clientY : undefined; + if (touchY !== undefined && nextY !== undefined && touchY !== nextY) { + begin(event, nextY < touchY ? 'down' : 'up'); + } + touchY = nextY; + }; + const onTouchEnd = (): void => { touchY = undefined; }; + const onScroll = (): void => { + awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; + if (gesture) { + const delta = target.scrollTop - gesture.top; + gesture.top = target.scrollTop; + if (delta !== 0) { + const direction = pointer !== undefined + ? (delta < 0 ? 'up' : 'down') + : gesture.direction ?? (delta < 0 ? 'up' : 'down'); + // A reversed input may arrive while the previous smooth scroll + // still moves in the opposite direction. Its end is not the end + // of the new input's default action. + if ((delta < 0 ? 'up' : 'down') !== direction) { + publish(); + return; + } + gesture.direction = direction; + pinned = false; + publish(); + reportReader(direction, 'scroll'); + return; + } } - pinned = distance <= PIN_THRESHOLD_PX; publish(); - for (const listener of [...readerListeners]) listener(unexplained < 0 ? 'up' : 'down'); }; - lastScrollHeight = target.scrollHeight; - lastClientHeight = target.clientHeight; - lastScrollTop = target.scrollTop; + const onScrollEnd = (): void => { + const ended = gesture; + if (!ended) return; + const top = ended.top; + // Chromium can end a scrollbar animation while a subsequent keyboard + // animation is still moving the same scroller. Let the next rendering + // step report any continuation before retiring its input provenance. + // This schedules no scroll and uses no time-based ignore window. + requestAnimationFrame(() => requestAnimationFrame(() => { + if (gesture !== ended || ended.top !== top) return; + pinned = ended.direction === 'down' && distanceToTail() <= PIN_THRESHOLD_PX; + gesture = undefined; + publish(); + if (pinned) writeToTail(); + })); + }; + target.addEventListener('wheel', onWheel, { passive: true }); + // React's delegated widget handlers run above the scroller. Observe + // keyboard input after they can prevent its native scrolling default. + target.ownerDocument.addEventListener('keydown', onKeyDown); + target.addEventListener('pointerdown', onPointerDown); + target.addEventListener('pointermove', onPointerMove, { passive: true }); + target.addEventListener('touchstart', onTouchStart, { passive: true }); + target.addEventListener('touchmove', onTouchMove, { passive: true }); + target.addEventListener('touchend', onTouchEnd); + target.addEventListener('touchcancel', onTouchEnd); + target.ownerDocument.addEventListener('pointerup', onPointerUp); + target.ownerDocument.addEventListener('pointercancel', onPointerUp); 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. + target.addEventListener('scrollend', onScrollEnd); + + // Observe the viewport and its direct content boxes, including content + // outside Turns. Resize changes position only; it never changes intent. const box = new ResizeObserver(() => { - if (pinned) { - writeToTail(); - return; + if (pinned && !gesture) writeToTail(); + else { + awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; + publish(); } - 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(); @@ -257,31 +267,42 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { return () => { childList.disconnect(); box.disconnect(); + target.removeEventListener('wheel', onWheel); + target.ownerDocument.removeEventListener('keydown', onKeyDown); + target.removeEventListener('pointerdown', onPointerDown); + target.removeEventListener('pointermove', onPointerMove); + target.removeEventListener('touchstart', onTouchStart); + target.removeEventListener('touchmove', onTouchMove); + target.removeEventListener('touchend', onTouchEnd); + target.removeEventListener('touchcancel', onTouchEnd); + target.ownerDocument.removeEventListener('pointerup', onPointerUp); + target.ownerDocument.removeEventListener('pointercancel', onPointerUp); target.removeEventListener('scroll', onScroll); + target.removeEventListener('scrollend', onScrollEnd); target.style.overflowAnchor = previousOverflowAnchor; - lastWrittenTop = undefined; + gesture = undefined; if (root === target) root = null; }; }, pinToTail() { + gesture = undefined; pinned = true; writeToTail(); publish(); }, releasePin() { + gesture = undefined; pinned = false; awayFromTail = distanceToTail() > BUTTON_THRESHOLD_PX; publish(); }, subscribeToReaderScroll(listener) { readerListeners.add(listener); - return () => { - readerListeners.delete(listener); - }; + return () => { readerListeners.delete(listener); }; }, subscribe(listener) { listeners.add(listener); - return () => listeners.delete(listener); + return () => { listeners.delete(listener); }; }, getSnapshot() { return snapshot; diff --git a/packages/ui/src/use-chat-scroll.ts b/packages/ui/src/use-chat-scroll.ts index 93bcd9dd73..8cc9116b14 100644 --- a/packages/ui/src/use-chat-scroll.ts +++ b/packages/ui/src/use-chat-scroll.ts @@ -161,7 +161,9 @@ export function useChatScroll(input: { previousPin = pinned; report(); }); - const stopWatchingReader = authority.subscribeToReaderScroll(report); + const stopWatchingReader = authority.subscribeToReaderScroll((_direction, phase) => { + if (phase === 'scroll') report(); + }); return () => { if (reportReadingAnchor.current === report) reportReadingAnchor.current = undefined; stopWatchingPolicy(); @@ -181,7 +183,9 @@ export function useChatScroll(input: { const requestHistory = (direction: 'up' | 'down'): void => { activation.current = { sessionId: input.sessionId }; commandTarget.current = null; - authority.releasePin(); + // Moving input already released following. Only an immovable edge can + // still be pinned; do not cancel the input operation that requested data. + if (authority.getSnapshot().pinned) authority.releasePin(); // A wheel at either edge moves nothing, so no scroll event refreshes the // anchor and the restore effect would load around an evicted Turn. reportReadingAnchor.current?.(); @@ -212,30 +216,7 @@ export function useChatScroll(input: { const stopWatchingReader = authority.subscribeToReaderScroll((direction) => { if (canLoad(direction) && nearEdge(direction)) requestHistory(direction); }); - // At either bounded edge a wheel cannot move the scroller, so no scroll - // event follows. The gesture still asks for the adjacent page. Do not steal - // a wheel from a nested tool output that can consume it itself. - const onWheel = (event: WheelEvent): void => { - if (event.deltaY === 0) return; - const direction = event.deltaY < 0 ? 'up' : 'down'; - if (!canLoad(direction) || !nearEdge(direction)) return; - for (const target of event.composedPath()) { - if (target === root) break; - if (!(target instanceof HTMLElement)) continue; - const overflowY = getComputedStyle(target).overflowY; - if (!['auto', 'scroll', 'overlay'].includes(overflowY)) continue; - const remaining = direction === 'up' - ? target.scrollTop - : target.scrollHeight - target.clientHeight - target.scrollTop; - if (target.scrollHeight > target.clientHeight && remaining > 0) return; - } - requestHistory(direction); - }; - root.addEventListener('wheel', onWheel, { passive: true }); - return () => { - stopWatchingReader(); - root.removeEventListener('wheel', onWheel); - }; + return stopWatchingReader; }, [authority, input.hasOlderHistory, input.hasNewerHistory, canLoadEarlier, canLoadLater, input.scrollRef, input.sessionId]); diff --git a/packages/ui/stories/transcript-scroll-intent.stories.tsx b/packages/ui/stories/transcript-scroll-intent.stories.tsx new file mode 100644 index 0000000000..d763e63aec --- /dev/null +++ b/packages/ui/stories/transcript-scroll-intent.stories.tsx @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** Browser layout corrections do not create a reader operation. */ + +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect } from 'storybook/test'; +import { createTranscriptScrollAuthority } from '../src/transcript-scroll-authority.js'; + +const SCROLLER_ID = 'intent-probe-scroller'; + +function Scroller() { + return ( +
+
+
+ anchor +
+
+
+ ); +} + +const meta = { + title: 'Product/Transcript Scroll Intent', + component: Scroller, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function scroller(): HTMLElement { + const root = document.getElementById(SCROLLER_ID); + if (!root) throw new Error('the probe scroller is missing'); + return root; +} + +function settled(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); +} + +// A browser probe for native anchoring/clamping; fake DOM geometry cannot +// establish that those operations leave reader intent alone. +export const LayoutDoesNotCreateReaderIntent: Story = { + play: async () => { + const root = scroller(); + const above = root.querySelector('[data-probe="above"]')!; + const below = root.querySelector('[data-probe="below"]')!; + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root); + try { + authority.releasePin(); + root.scrollTop = root.scrollHeight - root.clientHeight - 30; + await settled(); + let readerMoves = 0; + authority.subscribeToReaderScroll((_direction, phase) => { + if (phase === 'scroll') readerMoves += 1; + }); + above.style.height = '407.8px'; + await settled(); + below.style.height = '800.2px'; + await settled(); + await expect(readerMoves).toBe(0); + await expect(authority.getSnapshot().pinned).toBe(false); + + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -2, bubbles: true })); + root.scrollTop -= 2; + await settled(); + await expect(readerMoves).toBe(1); + } finally { + detach(); + } + }, +}; + +// Growth can replace intrinsic-size estimates above the viewport while adding +// content below it. A queued tail-write event must not turn that layout into +// reader intent just because its net height change has the opposite sign. +export const OpposingResizesKeepFollowingTheTail: Story = { + play: async () => { + const root = scroller(); + const above = root.querySelector('[data-probe="above"]'); + const below = root.querySelector('[data-probe="below"]'); + if (!above || !below) throw new Error('the probe spacers are missing'); + const anchor = root.querySelector('[data-probe="anchor"]'); + if (!anchor) throw new Error('the probe anchor is missing'); + // Keep the anchor visible above the tail spacer so native anchoring has + // a candidate whose position changes when the upper box shrinks. + root.style.height = '860px'; + above.style.height = '2000px'; + anchor.style.height = '1000px'; + below.style.height = '600px'; + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root); + try { + await settled(); + // Leave the rAF callback: mutations made inside it are observed by RO + // in that same rendering step, before a pending scroll can be delivered. + await new Promise((resolve) => setTimeout(resolve, 0)); + let readerMoves = 0; + authority.subscribeToReaderScroll(() => { readerMoves += 1; }); + + const previousHeight = root.scrollHeight; + // Queue a real scroll event from a tail write. Before it arrives, layout + // shrinks above the reader and grows below them in the same task. + below.style.height = '601px'; + authority.pinToTail(); + above.style.height = '1909px'; + below.style.height = '1200px'; + // Commit layout before the queued scroll event is delivered. This is + // also what a consumer reading scrollHeight during streaming does. + expect(root.scrollHeight).toBeGreaterThan(previousHeight); + await settled(); + + expect(readerMoves).toBe(0); + expect(authority.getSnapshot().pinned).toBe(true); + expect(root.scrollHeight - root.clientHeight - root.scrollTop).toBeLessThanOrEqual(4); + } finally { + detach(); + } + }, +}; diff --git a/packages/ui/stories/transcript-scroll-rounding.stories.tsx b/packages/ui/stories/transcript-scroll-rounding.stories.tsx deleted file mode 100644 index 239e977d61..0000000000 --- a/packages/ui/stories/transcript-scroll-rounding.stories.tsx +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** - * The one claim in `TranscriptScrollAuthority` a fake DOM cannot make. - * - * It decides the reader moved by measuring the offset against what the content - * accounts for, and it reads those from two different number systems: CSSOM - * gives `scrollTop` as a double and `scrollHeight` / `clientHeight` as longs. - * So the comparison holds an exact number against rounded ones, and the - * `packages/ui` suite — a fake DOM of integers — cannot put a fraction into it. - * - * It is measured here instead, in a real layout engine on fractional heights, - * through both paths that move the offset without the reader: native anchoring - * compensating content above them, and the browser clamping the offset when - * the transcript ends before it. Clamping lands exact. Anchoring misses, which - * is where `GEOMETRY_ROUNDING_PX` comes from — it is that measurement, and - * this is the thing that holds it. - * - * And it holds the other half of the rule, which is that the slack is spent - * only on events the content actually moved. A settled transcript rounds - * nothing, so a reader inching down one is heard exactly; that is the last - * phase, and it is what a slack applied unconditionally would swallow. - * - * It asks the authority directly rather than reading a scroll position. A - * misclassification while the reader is at the tail re-derives the same pin and - * moves nothing, so position is exactly the observable that cannot see this; - * `subscribeToReaderScroll` is the module's own answer to "was that the - * reader", which is the question. - */ - -import type { Meta, StoryObj } from '@storybook/react-vite'; -import { expect } from 'storybook/test'; -import { createTranscriptScrollAuthority } from '../src/transcript-scroll-authority.js'; - -const SCROLLER_ID = 'rounding-probe-scroller'; - -function Scroller() { - return ( -
-
-
- anchor -
-
-
- ); -} - -const meta = { - title: 'Product/Transcript Scroll Rounding', - component: Scroller, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -function scroller(): HTMLElement { - const root = document.getElementById(SCROLLER_ID); - if (!root) throw new Error('the probe scroller is missing'); - return root; -} - -function settled(): Promise { - return new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); - }); -} - -// Real path: none — this is a probe, and it says so. The surface it guards is -// every streaming transcript; what it needs from a browser is fractional box -// heights and CSSOM's two number systems, which no product state adds to it. -export const ContentThatOnlyRoundsIsNotTheReader: Story = { - play: async () => { - const root = scroller(); - const above = root.querySelector('[data-probe="above"]'); - const below = root.querySelector('[data-probe="below"]'); - if (!above || !below) throw new Error('the probe spacers are missing'); - - const authority = createTranscriptScrollAuthority(); - const detach = authority.attach(root); - try { - // Released, so the authority writes nothing and every offset change - // below is the browser's alone. Far enough off the tail that a misread - // cannot be laundered by re-deriving the same pinned answer, but close - // enough that content leaving from underneath reaches them in a few - // steps. - authority.releasePin(); - root.scrollTop = root.scrollHeight - root.clientHeight - 30; - await settled(); - - let readerMoves = 0; - authority.subscribeToReaderScroll(() => { - readerMoves += 1; - }); - - // Fractional content change, crossing a rounding boundary on every step, - // through both paths that move the offset without the reader. Growth - // above them is answered by native anchoring, which pushes the offset - // down by what it inserted. Content taken from under them eventually - // ends the transcript in front of the offset, and the browser pulls the - // offset back to that end — the end it holds itself, while the authority - // reads it as `scrollHeight - clientHeight`, two integers each rounded - // on its own. - const escapes: number[] = []; - const record = async (change: () => void): Promise => { - const beforeTop = root.scrollTop; - const beforeRange = root.scrollHeight - root.clientHeight; - change(); - await settled(); - const contentDelta = root.scrollHeight - root.clientHeight - beforeRange; - const topDelta = root.scrollTop - beforeTop; - const low = Math.min(0, contentDelta); - const high = Math.max(0, contentDelta); - escapes.push(topDelta - Math.min(high, Math.max(low, topDelta))); - }; - - let aboveHeight = 400.5; - for (let step = 0; step < 40; step += 1) { - await record(() => { - aboveHeight += 7.3; - above.style.height = `${aboveHeight}px`; - }); - } - let belowHeight = 900.5; - for (let step = 0; step < 40; step += 1) { - await record(() => { - belowHeight -= 7.3; - below.style.height = `${belowHeight}px`; - }); - } - - // What the arithmetic missed by, which is what the constant has to - // cover. Recorded rather than merely tolerated: if a browser starts - // missing by more, the number in the module is stale and this says so - // here, instead of the transcript quietly deciding a streaming answer's - // reader had reached for the scrollbar. - const worst = Math.max(...escapes.map(Math.abs)); - await expect( - worst, - `content moved the offset further outside its own band than the module allows; escapes ${escapes - .map((value) => value.toExponential(2)) - .join(' ')}`, - ).toBeLessThanOrEqual(2); - - // And none of it was read as the reader, who touched nothing at all. - await expect( - readerMoves, - `content the browser moved under the reader was read as a gesture; escapes ${escapes - .map((value) => value.toExponential(2)) - .join(' ')}`, - ).toBe(0); - - // And the run is only worth anything if this classifier still says yes - // to a reader. Two pixels, on content that just settled: no arithmetic - // happened, so nothing here is owed any slack, and a version that spent - // it anyway would lose this reader — the same reader, moving the same - // way, that a streaming transcript has to keep. - root.scrollTop -= 2; - await settled(); - await expect(readerMoves, 'a reader on settled content went unheard').toBe(1); - } finally { - detach(); - } - }, -}; - -// Growth can replace intrinsic-size estimates above the viewport while adding -// content below it. A queued tail-write event must not turn that layout into -// reader intent just because its net height change has the opposite sign. -export const OpposingResizesKeepFollowingTheTail: Story = { - play: async () => { - const root = scroller(); - const above = root.querySelector('[data-probe="above"]'); - const below = root.querySelector('[data-probe="below"]'); - if (!above || !below) throw new Error('the probe spacers are missing'); - const anchor = root.querySelector('[data-probe="anchor"]'); - if (!anchor) throw new Error('the probe anchor is missing'); - // Keep the anchor visible above the tail spacer so native anchoring has - // a candidate whose position changes when the upper box shrinks. - root.style.height = '860px'; - above.style.height = '2000px'; - anchor.style.height = '1000px'; - below.style.height = '600px'; - const authority = createTranscriptScrollAuthority(); - const detach = authority.attach(root); - try { - await settled(); - // Leave the rAF callback: mutations made inside it are observed by RO - // in that same rendering step, before a pending scroll can be delivered. - await new Promise((resolve) => setTimeout(resolve, 0)); - let readerMoves = 0; - authority.subscribeToReaderScroll(() => { readerMoves += 1; }); - - const previousHeight = root.scrollHeight; - // Queue a real scroll event from a tail write. Before it arrives, layout - // shrinks above the reader and grows below them in the same task. - below.style.height = '601px'; - authority.pinToTail(); - above.style.height = '1909px'; - below.style.height = '1200px'; - // Commit layout before the queued scroll event is delivered. This is - // also what a consumer reading scrollHeight during streaming does. - expect(root.scrollHeight).toBeGreaterThan(previousHeight); - await settled(); - - expect(readerMoves).toBe(0); - expect(authority.getSnapshot().pinned).toBe(true); - expect(root.scrollHeight - root.clientHeight - root.scrollTop).toBeLessThanOrEqual(4); - } finally { - detach(); - } - }, -}; From a2d9b32f9ea50d070b5f89d5c2b77442a298a85f Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 9 Sep 2026 03:24:56 +0800 Subject: [PATCH 02/10] fix(ui): lay out the streaming frontier before tail following --- .../src/renderer/styles/chat-message.css | 15 +++++++-------- apps/desktop/stories/app-shell.stories.tsx | 19 +++++++++++-------- packages/ui/src/chat-turn.tsx | 1 + 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index cfac047f58..aa84d5cccd 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -61,14 +61,6 @@ contain-intrinsic-block-size: auto 280px; } -/* Tail following needs the live Turn's layout on admission, before reading - scrollHeight. Skipping its layout first exposes estimates which can then - shrink and pull the prompt backwards. Keep its individual timeline blocks - skippable: an active Turn can itself contain many screens of older work. */ -.maka-transcript-turn:has(> [data-live-streaming='true']) { - content-visibility: visible; -} - .maka-chat-message-loading { display: grid; place-items: center; @@ -156,6 +148,13 @@ contain-intrinsic-block-size: auto 96px; } +/* A long prompt can put even the first answer beyond the relevance margin. + The streaming frontier must contribute its real height before following; + completed blocks in the same active Turn still retain lazy layout. */ +.maka-chat-message-list [data-maka-transcript-boundary][data-live-streaming='true'] { + content-visibility: visible; +} + /* Container blocks — a Processing sequence, a linked-agent list — hold many entries, so their first-paint estimate stays multi-line. It remains an estimate rather than a clamp: the block grows to its measured size after diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 4e12d8c8bc..7bf3b19c6d 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2211,17 +2211,15 @@ export const StreamingTailFollow: Story = { }, }; -async function verifySubmittedPrompt(canvasElement: HTMLElement, multiline = false): Promise { +async function verifySubmittedPrompt(canvasElement: HTMLElement, lines = 1): Promise { await waitFor(() => expect(tailMetrics().distance).toBeLessThanOrEqual(4)); await painted(40); const input = canvasElement.querySelector('.maka-composer-editor [contenteditable="true"]'); if (!input) throw new Error('The composer input is missing'); - await userEvent.type(input, '请简短说明当前提交过程发生了什么。'); - if (multiline) { - for (let line = 1; line < 8; line += 1) { - await userEvent.keyboard('{Shift>}{Enter}{/Shift}'); - await userEvent.type(input, '这是多行提示词,提交后输入框收起仍应平稳跟随新回答。'); - } + await userEvent.type(input, '请简短说明当前提交过程发生了什么。', { delay: null }); + for (let line = 1; line < lines; line += 1) { + await userEvent.keyboard('{Shift>}{Enter}{/Shift}', { delay: null }); + await userEvent.type(input, '这是多行提示词,提交后输入框收起仍应平稳跟随新回答。', { delay: null }); } await painted(40); // Observe admission itself: the correct final bottom can hide a reverse @@ -2249,7 +2247,12 @@ export const SubmittedPromptDoesNotReverse: Story = { export const MultilineSubmittedPromptDoesNotReverse: Story = { render: () => , - play: async ({ canvasElement }) => verifySubmittedPrompt(canvasElement, true), + play: async ({ canvasElement }) => verifySubmittedPrompt(canvasElement, 8), +}; + +export const TallSubmittedPromptDoesNotReverse: Story = { + render: () => , + play: async ({ canvasElement }) => verifySubmittedPrompt(canvasElement, 80), }; /** Lets a play function drive props React owns. One story renders per page. */ diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 753e199409..1951867e21 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -1174,6 +1174,7 @@ const AssistantAnswerBubble = memo(function AssistantAnswerBubble(props: Assista Date: Wed, 9 Sep 2026 08:26:46 +0800 Subject: [PATCH 03/10] test(desktop): express reader input in send viewport fixture --- .../main/__tests__/transcript-send-viewport.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts index b4829000eb..78353d503f 100644 --- a/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts +++ b/apps/desktop/src/main/__tests__/transcript-send-viewport.test.ts @@ -194,6 +194,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { CSS: globalThis.CSS, document: globalThis.document, window: globalThis.window, Element: globalThis.Element, HTMLElement: globalThis.HTMLElement, Node: globalThis.Node, MutationObserver: globalThis.MutationObserver, ResizeObserver: globalThis.ResizeObserver, + requestAnimationFrame: globalThis.requestAnimationFrame, IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT, }; const { document, window } = parseHTML('
'); @@ -232,6 +233,7 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { CSS: { escape: (value: string) => value }, document, window, Element: window.Element, HTMLElement: window.HTMLElement, Node: window.Node, MutationObserver: TestMutationObserver, ResizeObserver: TestResizeObserver, + requestAnimationFrame: window.requestAnimationFrame, IS_REACT_ACT_ENVIRONMENT: true, }); const messages: StoredMessage[] = []; @@ -307,7 +309,14 @@ function viewportFixture(options: { returnButton?: boolean } = {}) { await act(async () => { authority!.releasePin(); await commands.current!.loadHistory(direction); }); }, async readAt(offset: number) { - await act(() => { scroller.scrollTop = offset; scroller.dispatchEvent(new window.Event('scroll')); }); + await act(() => { + const input = new window.Event('wheel'); + Object.assign(input, { deltaY: offset - scroller.scrollTop }); + scroller.dispatchEvent(input); + scroller.scrollTop = offset; + scroller.dispatchEvent(new window.Event('scroll')); + scroller.dispatchEvent(new window.Event('scrollend')); + }); await render(); }, async append(id: string, size: number) { From db9367d40832e01e4d4313be6e5a1441cbbae09a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 9 Sep 2026 08:27:59 +0800 Subject: [PATCH 04/10] test(perf): establish reading intent before transcript scrolling --- scripts/perf/storybook.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/perf/storybook.mjs b/scripts/perf/storybook.mjs index 661d0990eb..59a2dbfbcf 100644 --- a/scripts/perf/storybook.mjs +++ b/scripts/perf/storybook.mjs @@ -41,8 +41,11 @@ async function scrollSteps(page) { const before = anchor.getBoundingClientRect().top; const intended = Math.min(240, root.scrollTop); if (!intended) throw new Error('Empty scrolling workload'); + root.dispatchEvent(new WheelEvent('wheel', { deltaY: -intended, bubbles: true })); root.scrollBy({ top: -intended, behavior: 'instant' }); root.dispatchEvent(new Event('scroll')); + if (root.style.overflowAnchor !== 'auto') + throw new Error('Reader input did not release tail following'); await new Promise((resolve) => setTimeout(resolve, 100)); result.push({ intended, moved: anchor.getBoundingClientRect().top - before }); } @@ -123,7 +126,7 @@ try { motion: 'reduce', repetitions: 10, conditions: - 'Ten fresh story navigations for cold scrolling, then ten expand/scroll/close cycles in the final mounted story. Synthetic ComposedShell, no Host. Relative programmatic scrolling, 100ms geometry settling.', + 'Ten fresh story navigations for cold scrolling, then ten expand/scroll/close cycles in the final mounted story. Synthetic ComposedShell, no Host. Synthetic wheel intent followed by relative programmatic scrolling, 100ms geometry settling.', limits: 'DOM completion and anchor geometry are not screen-present timestamps or native wheel acceptance. Heap includes uncollected objects and previous document garbage; short repeated cycles alone do not prove a leak.', }, From 48d0503e9482d05ab9257205feef891238d064fb Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 9 Sep 2026 08:34:38 +0800 Subject: [PATCH 05/10] fix(ui): preserve tail following during Meta wheel zoom --- .../transcript-scroll-authority.test.ts | 23 +++++++++++++++++-- .../ui/src/transcript-scroll-authority.tsx | 2 +- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts index 496734cb45..a0034c82b4 100644 --- a/packages/ui/src/__tests__/transcript-scroll-authority.test.ts +++ b/packages/ui/src/__tests__/transcript-scroll-authority.test.ts @@ -33,7 +33,7 @@ interface FakeRoot { children: readonly unknown[]; addEventListener(type: string, listener: (event: unknown) => void): void; removeEventListener(type: string, listener: (event: unknown) => void): void; - input(deltaY: number): void; + input(deltaY: number, modifiers?: { ctrlKey?: boolean; metaKey?: boolean }): void; grabScrollbar(): void; end(): void; /** Dispatch the scroll event the browser would, one frame later. */ @@ -65,7 +65,7 @@ function fakeRoot(options?: { scrollHeight?: number; clientHeight?: number }): F emitScroll() { emit('scroll'); }, - input(deltaY) { emit('wheel', { deltaY, composedPath: () => [proxy] }); }, + input(deltaY, modifiers) { emit('wheel', { deltaY, ...modifiers, composedPath: () => [proxy] }); }, grabScrollbar() { emit('pointerdown', { button: 0, pointerType: 'mouse', pointerId: 1, target: proxy }); }, @@ -136,6 +136,25 @@ function withObservers(run: (resize: () => void, frame: () => void) => T): T } } +test('Ctrl and Meta wheel zoom preserve following without requesting history', () => { + withObservers((resize) => { + for (const modifiers of [{ ctrlKey: true }, { metaKey: true }]) { + const root = fakeRoot(); + const authority = createTranscriptScrollAuthority(); + const detach = authority.attach(root as unknown as HTMLElement); + let readerReports = 0; + authority.subscribeToReaderScroll(() => { readerReports += 1; }); + root.input(-100, modifiers); + root.grow(200); + resize(); + assert.equal(authority.getSnapshot().pinned, true); + assert.equal(root.scrollTop, root.scrollHeight - root.clientHeight); + assert.equal(readerReports, 0); + detach(); + } + }); +}); + test('content that grows under a pinned transcript keeps the tail on screen', () => { withObservers((resize) => { const root = fakeRoot(); diff --git a/packages/ui/src/transcript-scroll-authority.tsx b/packages/ui/src/transcript-scroll-authority.tsx index 3f6ce14f8b..2bd1fa88b0 100644 --- a/packages/ui/src/transcript-scroll-authority.tsx +++ b/packages/ui/src/transcript-scroll-authority.tsx @@ -141,7 +141,7 @@ export function createTranscriptScrollAuthority(): TranscriptScrollAuthority { reportReader(direction, 'input'); }; const onWheel = (event: WheelEvent): void => { - if (event.ctrlKey || event.deltaY === 0) return; + if (event.ctrlKey || event.metaKey || event.deltaY === 0) return; begin(event, event.deltaY < 0 ? 'up' : 'down'); }; const onKeyDown = (event: KeyboardEvent): void => { From 8c85700d8df0bd43a69cf3c7f928a295f4361377 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 9 Sep 2026 10:14:27 +0800 Subject: [PATCH 06/10] fix(ui): synchronize controlled composer content before paint --- patches/@astryxdesign+core+0.5.2.patch | 20 ++++++++++++++++++++ patches/README.md | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/patches/@astryxdesign+core+0.5.2.patch b/patches/@astryxdesign+core+0.5.2.patch index e3654880e3..731627f023 100644 --- a/patches/@astryxdesign+core+0.5.2.patch +++ b/patches/@astryxdesign+core+0.5.2.patch @@ -1,3 +1,23 @@ +diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js +--- a/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js ++++ b/node_modules/@astryxdesign/core/dist/Chat/ChatComposerInput.js +@@ -25,1 +25,1 @@ +-import { useRef, useState, useCallback, useEffect, useImperativeHandle } from 'react'; ++import { useRef, useState, useCallback, useEffect, useLayoutEffect, useImperativeHandle } from 'react'; +@@ -210,2 +210,2 @@ +- useEffect(() => { ++ useLayoutEffect(() => { + if (controlledValue === undefined || !editableRef.current) { +diff --git a/node_modules/@astryxdesign/core/src/Chat/ChatComposerInput.tsx b/node_modules/@astryxdesign/core/src/Chat/ChatComposerInput.tsx +--- a/node_modules/@astryxdesign/core/src/Chat/ChatComposerInput.tsx ++++ b/node_modules/@astryxdesign/core/src/Chat/ChatComposerInput.tsx +@@ -30,1 +30,2 @@ + useEffect, ++ useLayoutEffect, +@@ -405,2 +406,2 @@ +- useEffect(() => { ++ useLayoutEffect(() => { + if (controlledValue === undefined || !editableRef.current) { diff --git a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts b/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts index ff34874..0f5ae14 100644 --- a/node_modules/@astryxdesign/core/dist/Chat/ChatLayout.d.ts diff --git a/patches/README.md b/patches/README.md index 8b7e06c3fe..ae5b23f152 100644 --- a/patches/README.md +++ b/patches/README.md @@ -59,6 +59,12 @@ Delete when that guard passes against an unpatched package. ## `@astryxdesign/core@0.5.2` +`ChatComposerInput` synchronizes external controlled values into its editable +DOM in a layout effect. A passive effect can leave the old multiline draft +visible for a frame after the sent message is rendered; clearing it later +shrinks the dock and moves the already-positioned transcript. The existing +echo and selection guards stay unchanged. + Five published component seams drop host-owned state or semantics: - `ChatLayout` needs a conversation identity that resets scroll/unread state From ef74216b5f5b0eaa833c251333b3afb906101e5e Mon Sep 17 00:00:00 2001 From: AstroHan Date: Wed, 9 Sep 2026 10:23:40 +0800 Subject: [PATCH 07/10] fix(ui): preserve turn layout across message settlement --- .../main/__tests__/streaming-handoff.test.ts | 36 +++++- apps/desktop/stories/app-shell.stories.tsx | 46 ++++++-- packages/ui/src/chat-turn.tsx | 107 ++++++++---------- packages/ui/src/chat-view.tsx | 26 ++--- packages/ui/src/styles.css | 13 ++- 5 files changed, 135 insertions(+), 93 deletions(-) diff --git a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts index f0fa7ea648..aa961387c6 100644 --- a/apps/desktop/src/main/__tests__/streaming-handoff.test.ts +++ b/apps/desktop/src/main/__tests__/streaming-handoff.test.ts @@ -21,6 +21,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { createElement, type ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import { parseHTML } from 'linkedom'; import type { SessionEvent } from '@maka/core/events'; import { armLiveTurn, @@ -87,6 +88,31 @@ function renderLiveTurn(liveTurn: LiveTurnProjection): string { } describe('single live-turn handoff', () => { + it('keeps activity in the answer footer before the session or Turn arrives', () => { + const session: NonNullable[0]['activeSession']> = { + id: 'session-1', name: 'pending', status: 'running' as const, backend: 'ai-sdk', + labels: [], isFlagged: false, isArchived: false, hasUnread: false, + llmConnectionSlug: 'conn', connectionLocked: false, model: 'model', permissionMode: 'ask' as const, + }; + for (const activeSession of [undefined, session]) { + const markup = renderWithLocale(createElement(ChatView, { + activeSession, + messages: [], + transientMessages: [{ + id: 'message-pending', ts: 1, text: 'send now', + transientPlacement: 'current_turn', + }], + runningStatus: true, + scrollBehavior: 'smooth', + onNew() {}, + } satisfies Parameters[0])); + const { document } = parseHTML(markup); + const status = document.querySelector('.maka-assistant-answer [role="status"]'); + assert.ok(status?.closest('.maka-turn-footer'), 'activity must occupy the shared footer'); + assert.equal(document.querySelector('.maka-assistant-answer [role="toolbar"]'), null); + } + }); + it('renders a transient user message without manufacturing a Turn', () => { const markup = renderWithLocale(createElement(ChatView, { activeSession: { @@ -164,7 +190,9 @@ describe('single live-turn handoff', () => { } satisfies Parameters[0])); assert.doesNotMatch(markup, /maka-chat-message-loading/); - assert.ok(markup.indexOf('send now') < markup.indexOf('data-turn-id="turn-1"')); + const answerIndex = markup.indexOf('maka-assistant-answer'); + assert.ok(answerIndex >= 0); + assert.ok(markup.indexOf('send now') < answerIndex); assert.equal((markup.match(/data-transient-message-id="turn-1"/g) ?? []).length, 1); assert.equal((markup.match(/data-transcript-turn-id="turn-1"/g) ?? []).length, 1); }); @@ -200,8 +228,10 @@ describe('single live-turn handoff', () => { onNew() {}, } satisfies Parameters[0])); - assert.ok(markup.indexOf('send now') < markup.indexOf('data-turn-id="host-turn"')); - assert.ok(markup.indexOf('do this next') > markup.indexOf('data-turn-id="host-turn"')); + const answerIndex = markup.indexOf('maka-assistant-answer'); + assert.ok(answerIndex >= 0); + assert.ok(markup.indexOf('send now') < answerIndex); + assert.ok(markup.indexOf('do this next') > answerIndex); assert.equal((markup.match(/data-transient-message-id=/g) ?? []).length, 2); }); diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index 7bf3b19c6d..73a449a776 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -2085,16 +2085,19 @@ export const PartialHistoryNotice: Story = { /** Stops the harness below, so the tail can be read against a settled transcript. */ let stopTailStream: (() => void) | undefined; let startTailStream: (() => void) | undefined; +let settleTailTurn: (() => void) | undefined; /** Streams one line per frame into a live Turn. */ -function StreamingTailHarness() { +function StreamingTailHarness({ pendingUser = false }: { pendingUser?: boolean } = {}) { const [question, setQuestion] = useState(); + const [settled, setSettled] = useState(false); const [streaming, setStreaming] = useState(false); const [viewportNavigation] = useState(createTranscriptViewportNavigation); const [lines, setLines] = useState(1); useEffect(() => { startTailStream = () => setStreaming(true); - return () => { startTailStream = undefined; }; + settleTailTurn = () => setSettled(true); + return () => { startTailStream = undefined; settleTailTurn = undefined; }; }, []); useEffect(() => { if (!streaming) return; @@ -2120,7 +2123,7 @@ function StreamingTailHarness() { }, [streaming]); return ( { // Production publishes this once before admitting the sent Message. @@ -2131,23 +2134,29 @@ function StreamingTailHarness() { }, }} chat={{ - runningStatus: Boolean(question), + runningStatus: Boolean(question) && !settled, + transientMessages: pendingUser && question && !settled ? [{ + id: 'msg-tail-1', text: question, ts: NOW - 30_000, + transientPlacement: 'current_turn', hostTurnId: 'turn-tail', + deliveryStatus: '已接收', + }] : [], viewportNavigation, messages: [ user('history-question', 'history-turn', 6, '已有问题。'), assistant('history-answer', 'history-turn', 5, TAIL_LINES.slice(0, 40).join('\n\n')), ...(question ? [ - user('msg-tail-1', 'turn-tail', 3, question), + ...(!pendingUser || settled ? [user('msg-tail-1', 'turn-tail', 3, question)] : []), + ...(settled ? [assistant('msg-assistant-tail', 'turn-tail', 2, TAIL_LINES.slice(0, lines).join('\n\n'))] : []), { type: 'turn_state' as const, id: 'state-tail', turnId: 'turn-tail', ts: NOW - 30_000, - status: 'running' as const, + status: settled ? 'completed' as const : 'running' as const, }, ] : []), ], - liveTurn: question ? { + liveTurn: question && !settled ? { turnId: 'turn-tail', phase: 'streamed', steps: [{ @@ -2255,6 +2264,25 @@ export const TallSubmittedPromptDoesNotReverse: Story = { play: async ({ canvasElement }) => verifySubmittedPrompt(canvasElement, 80), }; +export const SubmittedPromptSettlesWithoutReversing: Story = { + render: () => , + play: async ({ canvasElement }) => { + await verifySubmittedPrompt(canvasElement); + const turn = canvasElement.querySelector('[data-transcript-turn-id="turn-tail"]')!; + const before = turn.getBoundingClientRect().top; + const offsets: number[] = []; + settleTailTurn?.(); + for (let frame = 0; frame < 40; frame += 1) { + await painted(1); + offsets.push(turn.getBoundingClientRect().top - before); + } + expect(Math.max(...offsets), JSON.stringify(offsets)).toBeLessThanOrEqual(4); + expect(canvasElement.querySelector('.maka-message-delivery')).toBeNull(); + expect(canvasElement.querySelector('.maka-turn-processing')).toBeNull(); + expect(tailMetrics().distance).toBeLessThanOrEqual(4); + }, +}; + /** Lets a play function drive props React owns. One story renders per page. */ let appendTurn: (() => void) | undefined; @@ -2408,7 +2436,9 @@ export const ReaderScrolledUpIsNotPulledBack: Story = { await painted(1); iconTops.push(contextGauge.querySelector('svg')!.getBoundingClientRect().top); } - expect(Math.max(...iconTops) - Math.min(...iconTops)).toBeLessThanOrEqual(0.25); + // Chromium rounds the native scroll limit to a CSS pixel; a fractional + // content height can move the sticky dock by up to half a pixel. + expect(Math.max(...iconTops) - Math.min(...iconTops)).toBeLessThanOrEqual(0.5); }, }; diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 1951867e21..97b9fc1499 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -161,6 +161,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { onEditUserMessage?: () => void; editDisabled?: boolean; editDisabledReason?: string; + delivery?: TransientUserMessageProjection; }) { const locale = useUiLocale(); const copyText = getConversationCopy(locale).messages; @@ -173,7 +174,11 @@ const UserMessageBody = memo(function UserMessageBody(props: { + {props.delivery.deliveryStatus} + + ) : props.ts !== undefined ? ( /* `value` takes ms directly: Timestamp's own parseValue reads anything past 1e12 as milliseconds (2001-09-09 onward), and a chat message never predates that. */ @@ -182,6 +187,9 @@ const UserMessageBody = memo(function UserMessageBody(props: { } footer={ <> + {props.delivery?.deliveryActions?.map((action) => ( + + ))} - {message.deliveryStatus && ( -
- {message.deliveryStatus} - {message.deliveryActions?.map((action) => ( - - ))} -
- )}
); @@ -374,6 +375,7 @@ function MessageCopyButton(props: { */ export const TurnView = memo(function TurnView(props: { turn: TurnViewModel; + transientMessages?: readonly TransientUserMessageProjection[]; userLabel?: string; /** * PR109d-b: footer actions derived from `TurnStatus` + lineage map @@ -434,10 +436,8 @@ export const TurnView = memo(function TurnView(props: { * present, the assistant `ChatMessage` renders the live 深度思考 + answer bubble as * the trailing entries of its timeline — the SAME node the committed turn * will settle into, so live→settled is a data-source swap (no unmount/mount). - * While live the footer is a reserved-height placeholder, not the real - * `TurnFooterActions`: the tail turn's derived status is `completed` (a live - * turn has no `turn_state`), so rendering the real footer would offer a - * clickable regenerate/branch on a still-streaming answer. + * While live the footer shows activity in the same slot as completed + * actions, without exposing actions against a still-streaming answer. */ liveStreaming?: { onStreamingSettled?: (messageId?: string) => void; @@ -554,6 +554,9 @@ export const TurnView = memo(function TurnView(props: { {copy.agentGraphTriggered} )} + {props.transientMessages?.map((message) => ( + + ))} {turn.user && ( )} - {ownsTurnChrome && props.liveStreaming && ( - <> - {props.liveStreaming.providerRetry ? ( - - ) : ( - props.liveStreaming.runningStatus && ( - - ) - )} - - )}
{ownsTurnChrome && reverseBadges.length > 0 && ( @@ -765,34 +753,32 @@ export const TurnView = memo(function TurnView(props: { ))} )} - {ownsTurnChrome && - (props.liveStreaming ? ( - /* #642: reserved-height footer placeholder while streaming — same - `mt-0.5 h-8` box the real footer occupies, so the live→settled - swap is height-neutral (the footer slot never grows/shrinks). No - actionable footer here: the live tail's derived status is - `completed`, so a real `TurnFooterActions` would render a - clickable regenerate/branch on a still-streaming answer. */ -