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:
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user