fix(webui): finish scroll classification when Tab lands mid-settle

A Tab outside the transcript while a stale matching scroll was still being
classified deleted the settling pin, which cancelled the classification.
Tab moves no transcript, so no later scroll event decided it, follow stayed
on and the next growth pulled the reader to the tail.

The classification now has its own mark. Reader input drops only the pin
provenance that a repaint reads; the two-frame check still runs.

Room and Conversation 700x700 cases: bf6e92232 fails 2/2 in 3 of 3 runs
(5017 to 5267, 5135 to 5385); this commit passes 4/4 in 3 of 3.
This commit is contained in:
jason.woltje committed 2026-11-07 07:27:10 -06:00
1 parent bf6e92232e
commit 7c664fac91
2 files changed
+39 -4

No files matched your search

+6 -3
View File
@@ -800,7 +800,7 @@
if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); }
}
});
let pageReaderInput = false, readerMark = 0, pageTop = 0;
let pageReaderInput = false, readerMark = 0, settleMark = 0, pageTop = 0;
// User input cancels a pending page pin, including scrollbar dragging.
// Tab counts from any target: it moves focus and can scroll the document.
const readerInput = event => {
@@ -845,10 +845,13 @@
// Allow the resize observer to settle a shrinking log before treating an
// old matching offset as a reader scroll away from newly grown content.
// Preserve the pin's provenance if paint replaces this log before settling.
// Reader input may drop that provenance, but the classification still runs:
// input that scrolls nothing gives no later scroll event to decide it.
settlingPins.set(log, pinned);
const mark = ++settleMark;
requestAnimationFrame(() => requestAnimationFrame(() => {
if (settlingPins.get(log) !== pinned) return;
settlingPins.delete(log);
if (mark !== settleMark) return;
if (settlingPins.get(log) === pinned) settlingPins.delete(log);
if (log.isConnected && scroller.scrollTop === pinned && !transcriptAtTail(log)) s.follow = false;
}));
return;
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, stub } from './helpers/projects-review-fixture.mjs';
import { P, T, stub } from './helpers/projects-review-fixture.mjs';
const history = stub + `;for(let i=0;i<42;i++){const n=String(i+10).padStart(12,'0'),body='History '+i+' '+('message text '.repeat(8)),at='2026-11-06T10:26:00Z',id='50000000-0000-4000-8000-'+n;fixtureRoom.push({id,ids:[id],from_role:'human',to:['assistant'],body,at})}`;
// Exercise a valid ordering in which repaint and its native scroll event happen
@@ -57,3 +57,35 @@ for (const width of [700, 1440]) test(`S14 room ${width}x700 follows the tail wh
const tab = await b.evaluate(`(()=>{const r=document.activeElement.getBoundingClientRect();return {visible:r.top<innerHeight&&r.bottom>0,top:document.querySelector('.chat-log').scrollTop}})()`);
assert.ok(tab.visible && tab.top < m.top-100, `Tab focus or reader position lost: ${JSON.stringify(tab)}`);
});
// A Tab outside the log while a stale matching scroll is still being classified
// moves no transcript, so no later scroll event decides it. The reader stays put.
for (const name of ['room', 'conversation']) test(`S14 ${name} 700x700 Tab during pending classification keeps the reader's place`, async t => {
const f = await fixture(), b = await browser({ machine: false });
t.after(async () => { await b.close(); await f.close(); });
await b.call('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
b.on('Fetch.requestPaused', p => void b.call(p.request.url.startsWith(f.base+'/') ? 'Fetch.continueRequest' : 'Fetch.failRequest', p.request.url.startsWith(f.base+'/') ? { requestId:p.requestId } : { requestId:p.requestId, errorReason:'BlockedByClient' }).catch(() => {}));
const threadHistory = `;for(let i=0;i<42;i++){const n=String(i+10).padStart(12,'0');fixtureMessages.push({id:'40000000-0000-4000-8000-'+n,from_role:'human',body:'History '+i+' '+('message text '.repeat(8)),at:'2026-11-06T10:26:00Z'})}`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: history + threadHistory });
await b.viewport(700, 700);
await b.navigate(f.base+'/#/projects/'+P);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30))}throw Error('Timeout')})()`);
const frames = n => b.evaluate(`(async()=>{for(let i=0;i<${n};i++)await new Promise(requestAnimationFrame)})()`);
await wait("document.querySelector('[data-project-content] [data-project-room-status]')");
await b.evaluate(`location.hash=${JSON.stringify('#/projects/'+(name==='room'?'rooms/'+P:'threads/'+T))}`);
await wait("document.querySelectorAll('.project-chat .chat-log > li').length >= 43");
await frames(20);
const gap = "(()=>{const l=document.querySelector('.chat-log');return l.scrollHeight-l.clientHeight-l.scrollTop})()";
assert.ok(await b.evaluate(gap) <= 1, 'opens at the tail');
await b.evaluate("document.querySelector('h1').focus({preventScroll:true})");
// The S11 stale-pin collision: a pin, growth the observer has not settled, a coalesced matching scroll.
const before = await b.evaluate(`(async()=>{const l=document.querySelector('.chat-log'),last=l.lastElementChild.querySelector('.chat-body');const scrolled=new Promise(r=>l.addEventListener('scroll',()=>r(),{once:true}));last.append(' pin '.repeat(150));await Promise.resolve();const pin=l.scrollTop;last.style.minHeight=(last.getBoundingClientRect().height+250)+'px';l.scrollTop=pin+100;l.scrollTop=pin;await scrolled;return {pin,gap:l.scrollHeight-l.clientHeight-l.scrollTop}})()`);
await b.key('Tab');
await frames(8);
const tab = await b.evaluate(`(()=>{const e=document.activeElement,r=e.getBoundingClientRect();return {top:document.querySelector('.chat-log').scrollTop,inLog:!!e.closest('.chat-log'),visible:e!==document.body&&r.top<innerHeight&&r.bottom>0}})()`);
assert.ok(before.gap >= 249 && tab.visible && !tab.inLog && Math.abs(tab.top-before.pin) <= 2, `setup: ${JSON.stringify({before,tab})}`);
await b.evaluate("document.querySelector('.chat-log li:last-child .chat-body').append(' next '.repeat(150))");
await frames(12);
const after = await b.evaluate("document.querySelector('.chat-log').scrollTop");
assert.ok(Math.abs(after-before.pin) <= 2, `reader pulled to the tail after Tab: ${JSON.stringify({before,tab,after})}`);
});