Preserve transcript pin through room repaint

This commit is contained in:
jason.woltje committed 2026-11-07 06:57:26 -06:00
1 parent 94c9f04f5b
commit bf6e92232e
2 files changed
+69 -4

No files matched your search

+10 -4
View File
@@ -7,7 +7,7 @@
c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
const txt = value => esc(inert(value));
const states = new Map(), forms = new Map(), knownRequests = new Map();
const pinnedScrolls = new WeakMap();
const pinnedScrolls = new WeakMap(), settlingPins = new WeakMap();
const pageFlow = log => log.closest('.project-chat') && matchMedia('(max-width: 319px), (max-height: 540px)').matches;
// In page flow the tail is the composer pin. Reaching the newest message's end
// also counts, except on an upward scroll: that is the reader leaving the tail.
@@ -443,7 +443,9 @@
const content = current.container.querySelector('[data-project-content]');
const projectFocus = content?.contains(active) ? {id:active.id,index:[...content.querySelectorAll('a,button,select,input,textarea,summary,[tabindex]')].indexOf(active)} : null;
const disclosures = content ? [...content.querySelectorAll('details')].map((e,i)=>e.open?i:-1).filter(i=>i>=0) : [];
return { field, dialog:dialog?.id, dialogValues, dialogFocus, toggles, projectFocus, disclosures, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log ? (pageFlow(log) ? document.scrollingElement.scrollTop : log.scrollTop) : undefined, tail: log ? transcriptAtTail(log) : true };
// A repaint can replace a log while its own pin is waiting for layout.
// Carry that intent only while the offset still matches the pending pin.
return { field, dialog:dialog?.id, dialogValues, dialogFocus, toggles, projectFocus, disclosures, detailsOpen: current.container.querySelector('.chat-details')?.open, top: log ? (pageFlow(log) ? document.scrollingElement.scrollTop : log.scrollTop) : undefined, tail: log ? transcriptAtTail(log) : true, settlingPin: !!log && !pageFlow(log) && (pinnedScrolls.get(log) === log.scrollTop || settlingPins.get(log) === log.scrollTop) };
}
let transcriptObserver = null, transcriptResize = null;
function paint(navigated = false) {
@@ -546,7 +548,7 @@
if (log) {
const s = state(c.key ?? c.id);
if (navigated) s.follow = true;
if (navigated || s.tailRequested || s.follow && (keep?.tail ?? true)) pinTranscript(log);
if (navigated || s.tailRequested || s.follow && ((keep?.tail ?? true) || keep?.settlingPin)) pinTranscript(log);
else if (keep?.top !== undefined) { if (pageFlow(log)) window.scrollTo(0, keep.top); else log.scrollTop = keep.top; }
s.tailRequested = false;
}
@@ -814,7 +816,7 @@
const mark = ++readerMark;
requestAnimationFrame(() => requestAnimationFrame(() => { if (mark === readerMark) pageReaderInput = false; }));
}
else if (event.target === log || log.contains(event.target)) pinnedScrolls.delete(log);
else if (event.key === 'Tab' || event.target === log || log.contains(event.target)) { pinnedScrolls.delete(log); settlingPins.delete(log); }
};
for (const type of ['wheel', 'touchstart', 'pointerdown', 'keydown']) document.addEventListener(type, readerInput, true);
document.addEventListener('scroll', event => {
@@ -842,7 +844,11 @@
if (transcriptAtTail(log)) return;
// 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.
settlingPins.set(log, pinned);
requestAnimationFrame(() => requestAnimationFrame(() => {
if (settlingPins.get(log) !== pinned) return;
settlingPins.delete(log);
if (log.isConnected && scroller.scrollTop === pinned && !transcriptAtTail(log)) s.follow = false;
}));
return;
@@ -0,0 +1,59 @@
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';
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
// before the log's resize callback. Other observers retain normal delivery.
const delayLogResize = `(() => { const Native = window.ResizeObserver; window.ResizeObserver = class extends Native { constructor(callback) { super((entries, observer) => { if (!entries.some(e => e.target.matches?.('.project-chat .chat-log'))) return callback(entries, observer); const deliver = n => n ? requestAnimationFrame(() => deliver(n-1)) : callback(entries, observer); deliver(4); }); } }; })();`;
for (const width of [700, 1440]) test(`S14 room ${width}x700 follows the tail when resize delivery follows repaint`, 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(() => {}));
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: delayLogResize + history });
await b.viewport(width, 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')})()`);
await wait("document.querySelector('[data-project-content] [data-project-room-status]')");
await b.evaluate(`location.hash=${JSON.stringify('#/projects/rooms/'+P)}`);
await wait("document.querySelectorAll('.project-chat .chat-log > li').length >= 45");
await b.evaluate('(async()=>{for(let i=0;i<20;i++)await new Promise(requestAnimationFrame)})()');
const m = await b.evaluate(`(()=>{const l=document.querySelector('.project-chat .chat-log'),last=l.lastElementChild.getBoundingClientRect(),box=l.getBoundingClientRect();return {top:l.scrollTop,scrollHeight:l.scrollHeight,clientHeight:l.clientHeight,lastBottom:last.bottom,logBottom:box.bottom}})()`);
console.log('S14',width,JSON.stringify(m));
if (process.env.S14_CAPTURE) await b.screenshot(`${process.env.S14_CAPTURE}/${process.env.S14_PHASE}-room-${width}x700.png`);
assert.ok(m.lastBottom <= m.logBottom + 0.5, `newest message is clipped: ${JSON.stringify(m)}`);
// The delayed resize must not override a reader who leaves the tail.
const point = await b.evaluate(`(()=>{const r=document.querySelector('.project-chat .chat-log').getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2}})()`);
await b.call('Input.dispatchMouseEvent',{type:'mouseWheel',...point,deltaX:0,deltaY:-300});
await b.evaluate('(async()=>{for(let i=0;i<8;i++)await new Promise(requestAnimationFrame)})()');
const up = await b.evaluate("document.querySelector('.chat-log').scrollTop");
assert.ok(up < m.top - 100, `wheel did not scroll up: ${up}`);
await b.evaluate("document.querySelector('.chat-log li:last-child .chat-body').append(' more '.repeat(100))");
await b.evaluate('(async()=>{for(let i=0;i<12;i++)await new Promise(requestAnimationFrame)})()');
const held = await b.evaluate("document.querySelector('.chat-log').scrollTop");
assert.ok(Math.abs(held-up)<=1, `reader pulled back to tail: ${JSON.stringify({up,held})}`);
// Find-style reveals and focus return must also remain away from the tail.
await b.evaluate("document.querySelectorAll('.chat-log > li')[4].scrollIntoView({block:'center'})");
await b.evaluate('(async()=>{for(let i=0;i<8;i++)await new Promise(requestAnimationFrame)})()');
const revealed = await b.evaluate("document.querySelector('.chat-log').scrollTop");
await b.evaluate(`(()=>{const id='50000000-0000-4000-8000-000000000099';fixtureRoom.push({id,ids:[id],from_role:'human',to:['assistant'],body:'New while reading',at:'2026-11-06T10:27:00Z'});document.querySelector('#refresh').click()})()`);
await wait("document.querySelector('[data-message$=\"000000000099\"]')");
await b.evaluate('(async()=>{for(let i=0;i<12;i++)await new Promise(requestAnimationFrame)})()');
const afterRefresh = await b.evaluate("document.querySelector('.chat-log').scrollTop");
assert.ok(Math.abs(afterRefresh-revealed)<=1, `revealed message moved: ${JSON.stringify({revealed,afterRefresh})}`);
await b.evaluate("document.querySelector('[data-project-dialog=room]').focus()");
await b.key('Enter');
assert.ok(await b.evaluate("!!document.querySelector('dialog[open]')"), 'room details opened');
await b.key('Escape');
await b.evaluate('(async()=>{for(let i=0;i<8;i++)await new Promise(requestAnimationFrame)})()');
const focus = await b.evaluate(`(()=>{const e=document.activeElement,r=e.getBoundingClientRect();return {details:e.matches('[data-project-dialog=room]'),visible:r.top<innerHeight&&r.bottom>0,top:document.querySelector('.chat-log').scrollTop}})()`);
assert.ok(focus.details && focus.visible && Math.abs(focus.top-revealed)<=1, `dialog focus or reader position lost: ${JSON.stringify(focus)}`);
await b.evaluate("document.querySelector('.chat-log').focus({preventScroll:true})");
await b.key('Tab');
await b.evaluate('(async()=>{for(let i=0;i<8;i++)await new Promise(requestAnimationFrame)})()');
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)}`);
});