diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index a0bff94619..e43f10e864 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -493,6 +493,8 @@ const ThreadMessageListInner: FC = ({ // 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 = ({ 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 = ({ 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 diff --git a/evals/desktop_bug_campaign/thread-scroll/README.md b/evals/desktop_bug_campaign/thread-scroll/README.md index df76f293ef..bdbbad6dc0 100644 --- a/evals/desktop_bug_campaign/thread-scroll/README.md +++ b/evals/desktop_bug_campaign/thread-scroll/README.md @@ -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. diff --git a/evals/desktop_bug_campaign/thread-scroll/fixture.tsx b/evals/desktop_bug_campaign/thread-scroll/fixture.tsx index 545f5ef976..2f4d6136cf 100644 --- a/evals/desktop_bug_campaign/thread-scroll/fixture.tsx +++ b/evals/desktop_bug_campaign/thread-scroll/fixture.tsx @@ -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
} createRoot(document.getElementById('root')!).render(
{location.search.includes('twins')&&}
) diff --git a/evals/desktop_bug_campaign/thread-scroll/ownership-probe.mjs b/evals/desktop_bug_campaign/thread-scroll/ownership-probe.mjs index c2569cc431..1cea52e0ca 100644 --- a/evals/desktop_bug_campaign/thread-scroll/ownership-probe.mjs +++ b/evals/desktop_bug_campaign/thread-scroll/ownership-probe.mjs @@ -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()}