fix(desktop): retain restored offsets through deferred layout

Observe post-settle transcript resizes until reader input or a live run takes ownership. Keep parked upward-wheel cancellation and namespace capture intact. Extend the maintained Chromium restoration probe with long reload and streaming controls; retain the two existing component invariants.
This commit is contained in:
Teknium
2026-09-07 05:57:29 -07:00
parent 3329b38bf3
commit f4f691c6cc
4 changed files with 72 additions and 12 deletions
@@ -493,6 +493,8 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
// transcript at its true bottom. While false, scrollTop is a way-point of a
// load in progress, not a reading position anyone chose — never anchor to it.
const loadSettledRef = useRef(false)
const cancelRestoreRef = useRef<(() => void) | null>(null)
const isRunning = useAuiState(s => s.thread.isRunning)
// Session the settle loop last armed for, so a re-arm within the same load
// is distinguishable from a switch to a different transcript.
const settleKeyRef = useRef(sessionKey)
@@ -657,6 +659,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
useEffect(() => () => endEditHold(), [endEditHold])
// New run → snap to the latest turn only when already near the bottom.
useAuiEvent('thread.runStart', () => {
cancelRestoreRef.current?.()
const el = scrollRef.current
if (el && shouldSnapOnRunStart(el.scrollHeight - el.scrollTop - el.clientHeight)) {
@@ -866,27 +869,68 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
let rafId = requestAnimationFrame(settle)
const onWheel = (event: WheelEvent) => {
if (event.deltaY >= 0 || loadSettledRef.current) {
// Quiet frames are not layout completion: deferred Markdown and intrinsic
// row measurements can change height after the initial settle. Retain the
// restored offset through those resizes until input or a live run takes over.
const resizeObserver = new ResizeObserver(() => {
if (target.kind === 'offset' && loadSettledRef.current) {
el.scrollTop = threadScrollTargetTop(target, el)
liveScrollStateRef.current = threadScrollStateFromMetrics(el)
}
})
if (contentRef.current) {
resizeObserver.observe(contentRef.current)
}
const cancelRestore = () => {
resizeObserver.disconnect()
cancelAnimationFrame(rafId)
if (loadSettledRef.current) {
return
}
// User intent wins even at a clamped top, where no scroll event fires.
cancelAnimationFrame(rafId)
// Input wins even at a clamped top, where no scroll event fires.
stopScroll()
loadSettledRef.current = true
liveScrollStateRef.current = threadScrollStateFromMetrics(el)
restoreFromBottomRef.current = el.scrollHeight - el.scrollTop
}
cancelRestoreRef.current = target.kind === 'offset' ? cancelRestore : () => resizeObserver.disconnect()
const onWheel = (event: WheelEvent) => {
resizeObserver.disconnect()
if (event.deltaY < 0) {
cancelRestore()
}
}
const unsubscribeJump = onScrollToBottomRequest(cancelRestore, sessionId)
el.addEventListener('wheel', onWheel, { passive: true })
el.addEventListener('pointerdown', cancelRestore, { passive: true })
el.addEventListener('keydown', cancelRestore)
return () => {
cancelRestoreRef.current = null
resizeObserver.disconnect()
unsubscribeJump()
el.removeEventListener('wheel', onWheel)
el.removeEventListener('pointerdown', cancelRestore)
el.removeEventListener('keydown', cancelRestore)
cancelAnimationFrame(rafId)
record()
}
}, [hasGroups, scrollRef, scrollToBottom, sessionKey, stopScroll])
}, [contentRef, hasGroups, scrollRef, scrollToBottom, sessionId, sessionKey, stopScroll])
// A thread can mount with a run already active, without a runStart event.
useEffect(() => {
if (isRunning) {
cancelRestoreRef.current?.()
}
}, [hasGroups, isRunning, sessionKey])
// Prepend an older page while preserving the on-screen position. The user is
// scrolled up (reading history) so the stick-to-bottom lock is escaped and
@@ -42,10 +42,12 @@ messages to exclude virtualization estimation from that ownership assertion;
the hydration release expands to the original 200-message history. No DOM
geometry is overridden. Wheel input comes from Playwright's native input path.
The probe deliberately retains a strict document-reload assertion: current
receipts show a 17px reload drift, so an overall nonzero exit is NOT a failure
of the separately recorded profile/gateway/remount and hydration controls.
Do not relax that assertion to call the whole issue solved. The earlier
200-message full remount also drifted by one estimated turn (323px).
The probe retains strict document-reload assertions. It also exercises the
200-message full remount/reload and live response growth after restoration.
A continuation now observes late Markdown/intrinsic-row resizes until reader
input or a live run takes over: 12-message reload drift changed from 17px to
0px, and the current 200-message reload control changed from 72px to 0px.
Earlier historical long-remount drift was 323px; the current control retains
its distance through remount, but this is not native-app or issue acceptance.
Remove the two temporary Desktop entry files after stopping the server.
@@ -14,8 +14,10 @@ const histories=Object.fromEntries(['a','b'].map(key=>[key,Array.from({length:ke
function TranscriptProbe({id='first'}) {
const [key,setKey]=useState('a'); const [loaded,setLoaded]=useState(true)
const [epoch,setEpoch]=useState(0); const [partial,setPartial]=useState(false); const [hydrating,setHydrating]=useState(false)
Object.assign(window,{scrollProbe:{scope:(profile: string,remote: string | null)=>{setActiveProfile(profile);rescopeConnectionScopedStores(remote?{mode:'remote',baseUrl:remote,profile}:null);setEpoch(e=>e+1)},remount:()=>setEpoch(e=>e+1),partial:()=>{saveThreadScrollPosition('hydrating',{kind:'offset',fromBottom:9000});setHydrating(true);setPartial(true);setEpoch(e=>e+1)},release:()=>startTransition(()=>setPartial(false))}})
const runtime=useExternalStoreRuntime({messages:loaded?((partial || (!hydrating && location.search.includes('ownership')))?histories[key].slice(-12):histories[key]):[],isRunning:false,onNew:async()=>{},convertMessage:m=>m})
const [running,setRunning]=useState(false); const [growth,setGrowth]=useState(false)
Object.assign(window,{scrollProbe:{run:()=>setRunning(true),grow:()=>setGrowth(true),scope:(profile: string,remote: string | null)=>{setActiveProfile(profile);rescopeConnectionScopedStores(remote?{mode:'remote',baseUrl:remote,profile}:null);setEpoch(e=>e+1)},remount:()=>setEpoch(e=>e+1),partial:()=>{saveThreadScrollPosition('hydrating',{kind:'offset',fromBottom:9000});setHydrating(true);setPartial(true);setEpoch(e=>e+1)},release:()=>startTransition(()=>setPartial(false))}})
const messages=loaded?((partial || (!hydrating && location.search.includes('ownership')))?histories[key].slice(-12):histories[key]):[]
const runtime=useExternalStoreRuntime({messages:growth?messages.map((m,i)=>i===messages.length-1?{...m,content:[{type:'text' as const,text:m.content[0].text+'\n\n'+('Additional streamed paragraph.\n\n'.repeat(30))}]}:m):messages,isRunning:running,onNew:async()=>{},convertMessage:m=>m})
return <section data-probe={id}><button id={`switch-${id}`} onClick={()=>setKey(k=>k==='a'?'b':'a')}>Switch {key}</button><button id={`reload-${id}`} onClick={()=>{setLoaded(false);setTimeout(()=>setLoaded(true),500)}}>Reload</button><button id={`jump-${id}`} onClick={()=>requestScrollToBottom(id)}>Jump</button><div style={{height:700,width:850}}><AssistantRuntimeProvider runtime={runtime}><Thread key={epoch} sessionKey={hydrating?'hydrating':key} sessionId={id}/></AssistantRuntimeProvider></div></section>
}
createRoot(document.getElementById('root')!).render(<I18nProvider><RootTooltipProvider><MemoryRouter><div style={{display:'flex'}}><TranscriptProbe/>{location.search.includes('twins')&&<TranscriptProbe id="second"/>}</div></MemoryRouter></RootTooltipProvider></I18nProvider>)
@@ -26,8 +26,16 @@ try {
await page.evaluate(()=>window.scrollProbe.partial()); await page.waitForTimeout(700);
result.hydration={partial:await metrics()}; await el.hover(); await page.mouse.wheel(0,500); await page.waitForTimeout(100); result.hydration.beforeWheel=await metrics(); await page.mouse.wheel(0,-200); await page.waitForTimeout(100); result.hydration.wheel=await metrics();
await page.evaluate(()=>window.scrollProbe.release()); await page.waitForTimeout(4000); result.hydration.released=await metrics();
result.long={};
await page.evaluate(()=>localStorage.clear());
await page.goto('http://127.0.0.1:18480/scroll-campaign-probe.html?thread'); await el.waitFor(); await page.waitForTimeout(4000);
await el.hover(); await page.mouse.wheel(0,-3100); await page.waitForTimeout(500);
result.long.before=await metrics(); await page.evaluate(()=>window.scrollProbe.remount()); await page.waitForTimeout(4000); result.long.remount=await metrics();
await page.reload(); await el.waitFor(); await page.waitForTimeout(4000); result.long.reload=await metrics();
result.streaming={before:await metrics()}; await page.evaluate(()=>window.scrollProbe.run()); await page.waitForTimeout(100); await page.evaluate(()=>window.scrollProbe.grow()); await page.waitForTimeout(1500); result.streaming.after=await metrics();
result.errors=errors;
result.checks={profile:result.profile.after.gap<15,gateway:result.gateway.after.gap<15,remount:Math.abs(result.remount.before.gap-result.remount.after.gap)<15,reload:Math.abs(result.reload.expected.gap-result.reload.after.gap)<15,hydration:Math.abs(result.hydration.released.gap-result.hydration.wheel.gap)<32 && result.hydration.released.following==='false'};
Object.assign(result.checks,{longRemount:Math.abs(result.long.before.gap-result.long.remount.gap)<15,longReload:Math.abs(result.long.before.gap-result.long.reload.gap)<15,streaming:result.streaming.after.gap>result.streaming.before.gap+100 && Math.abs(result.streaming.after.top-result.streaming.before.top)<15});
fs.writeFileSync(`${out}/${process.env.PROBE_TAG??'ownership'}.json`,JSON.stringify(result,null,2)); console.log(JSON.stringify(result,null,2));
assert.ok(result.profile.after.gap<15,'incoming profile must not inherit outgoing position');
assert.ok(result.gateway.after.gap<15,'incoming gateway must not inherit outgoing position');
@@ -35,5 +43,9 @@ try {
assert.ok(Math.abs(result.reload.expected.gap-result.reload.after.gap)<15,'document reload restores the default profile, not remote state');
assert.ok(Math.abs(result.hydration.released.gap-result.hydration.wheel.gap)<32,'wheel during deferred hydration must replace the parked restore with the reader position');
assert.equal(result.hydration.released.following,'false');
assert.ok(Math.abs(result.long.before.gap-result.long.remount.gap)<15,'long remount preserves reading distance despite intrinsic row measurements');
assert.ok(Math.abs(result.long.before.gap-result.long.reload.gap)<15,'long document reload preserves reading distance');
assert.ok(result.streaming.after.gap > result.streaming.before.gap + 100,'live growth must stop restoring distance from bottom');
assert.ok(Math.abs(result.streaming.after.top-result.streaming.before.top)<15,'live growth below the reader must not move their viewport');
assert.deepEqual(errors,[]);
} finally {await browser.close()}