diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md index a11dfd5b64..b17c098c1f 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md @@ -1,5 +1,15 @@ # FlowChat History Paging +The anchor renews its settle budget only when a correction reduces its measured +residual (or reaches tolerance), or while a missing Turn is still awaited. +An ineffective correction is remembered for that exact anchor/viewport geometry; +it is retried after geometry changes or a new anchor is captured, not on every +frame. Identical resize notifications do not renew the budget. The comparison +includes the anchor's content coordinate, so displacement at unchanged total +height still opens a settle. Item changes, snapshot restores and host resumes +remain explicit settle triggers. No sign-change count or overall time limit +terminates valid corrections. + Older Turns are fetched when the reader approaches the head of the loaded window, prepended above them, and paid for by moving the viewport down by exactly what arrived. This document covers the whole of that: when the ask goes diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIEWPORT_REGISTER.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIEWPORT_REGISTER.md index 407f1ce619..995d75bef5 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIEWPORT_REGISTER.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIEWPORT_REGISTER.md @@ -1,5 +1,15 @@ # FlowChat Viewport Register +Anchor diagnostics distinguish `anchor.correct` (a requested shift) from +`anchor.correctionResult` (actual scroll travel, residual and progress). Results +include the trigger source, anchor row key, rendered first index/count, frame +timestamp, and cumulative correction/travel/reversal counts since capture. +Reversals are evidence, not a stop condition. `anchor.settleOpened` records +items/resize/snapshot/resume triggers and the previous budget; `anchor.settleEnded` +records the final outcome. Repeating events use the existing gated coalescer; +none contain message or tool content. Read suppressed counts as well as emitted +samples. These diagnostics do not prove that a correction painted on screen. + Every deliberate write to the FlowChat scroller goes through one register, which decides whether the writer may act. This document covers the register, what it replaced, the two writers that stand outside it, and the trail it leaves. diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 1c9b4d4de4..f67cb9ceeb 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -1,5 +1,14 @@ # FlowChat Virtualization +## Measured row spacing + +`VirtualItemRenderer` establishes a `flow-root` on the measured wrapper so +descendant block margins remain inside its border box. A row replaced by virtual +padding must retain its full occupied height, including trailing item spacing. +Do not substitute clipping overflow: user-message decorations and controls can +extend outside the reading column. The rhythm test protects this stylesheet +contract; it does not prove browser layout or end-to-end scroll stability. + ## Interrupted turn continuity Cancelled rounds remain in the ordinary transcript. The display projection removes diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts index 6a474bf2cd..1dea3bae29 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatRhythm.test.ts @@ -12,6 +12,15 @@ function readSource(relativePath: string): string { } describe('FlowChat transcript rhythm', () => { + it('contains descendant spacing within the measured virtual row without clipping controls', () => { + const styles = readSource('./VirtualItemRenderer.scss'); + const wrapper = styles.slice(styles.indexOf('.virtual-item-wrapper {'), styles.indexOf("&[data-item-type='user-message']")); + + // This is a stylesheet contract; browser margin geometry needs real layout. + expect(wrapper).toContain('display: flow-root;'); + expect(wrapper).not.toMatch(/overflow(?:-x|-y)?:\s*(?:hidden|clip|auto|scroll)\s*;/); + }); + function modelRound( turnId: string, roundId: string, diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss index 2d4d6070bc..3f7829863a 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.scss @@ -11,6 +11,11 @@ @include markdownTypography.apply; @include transcriptLayout.reading-column; + // Keep descendant margins inside the border box measured by the virtualizer. + // Escaped margins otherwise disappear when a row is replaced by virtual padding. + // Unlike overflow: hidden, this preserves outlying decorations and controls. + display: flow-root; + /* * Single reading column shared by every item type (model rounds, explore * groups, user messages, notices). Matches ChatInput's 900px so headers, diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 2ffef53501..97ac4d444d 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -926,7 +926,7 @@ const VirtualMessageListSession = forwardRef { - viewportAnchor.openSettleWindow(); + viewportAnchor.openSettleWindow('items'); }, [viewportAnchor, virtualItems]); const updateVisibleTurnInfoFromViewport = useCallback(() => { @@ -1143,7 +1143,7 @@ const VirtualMessageListSession = forwardRef 0) { diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.test.tsx index cf915b19bb..ba044f25e6 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.test.tsx @@ -21,12 +21,12 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true; * to hold; this one fires once per wait and its numbers are the whole point of * it, so it is worth pinning that it fires exactly once and says the truth. */ -const mocks = vi.hoisted(() => ({ traceViewport: vi.fn() })); +const mocks = vi.hoisted(() => ({ traceViewport: vi.fn(), traceViewportRepeating: vi.fn() })); vi.mock('@/infrastructure/diagnostics/flowChatViewportDiagnostics', async () => { const actual = await vi.importActual< typeof import('@/infrastructure/diagnostics/flowChatViewportDiagnostics') >('@/infrastructure/diagnostics/flowChatViewportDiagnostics'); - return { ...actual, traceViewport: mocks.traceViewport }; + return { ...actual, traceViewport: mocks.traceViewport, traceViewportRepeating: mocks.traceViewportRepeating }; }); const VIEWPORT_HEIGHT = 500; @@ -106,6 +106,7 @@ describe('useFlowChatViewportAnchor', () => { beforeEach(() => { mocks.traceViewport.mockReset(); + mocks.traceViewportRepeating.mockReset(); container = document.createElement('div'); scroller = document.createElement('div'); document.body.append(container, scroller); @@ -584,6 +585,76 @@ describe('useFlowChatViewportAnchor', () => { expect(scroller.scrollTop).toBe(0); }); + it('does not renew the frame budget for identical resize notifications', () => { + layoutTurns({ 'turn-3': 100 }); + api.captureAnchor(); + api.openSettleWindow('resize'); + for (let i = 0; i < ANCHOR_SETTLE_FRAMES; i += 1) { + api.openSettleWindow('resize'); + runFrame(); + } + expect(frames).toHaveLength(0); + // Same total height does not mean the anchored content stayed put. + layoutTurns({ 'turn-3': 120 }); + api.openSettleWindow('resize'); + expect(scroller.scrollTop).toBe(20); + expect(frames).toHaveLength(1); + }); + + it('stops retrying a clamped correction until the geometry changes', () => { + let top = 0; + const writes = vi.fn((value: number) => { top = Math.max(0, value); }); + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, get: () => top, set: writes, + }); + layoutTurns({ 'turn-3': 100 }); + api.captureAnchor(); + layoutTurns({ 'turn-3': 80 }); + api.openSettleWindow(); + for (let i = 0; i < ANCHOR_SETTLE_FRAMES; i += 1) runFrame(); + expect(writes).toHaveBeenCalledTimes(1); + expect(frames).toHaveLength(0); + // A real displacement makes a new, now satisfiable correction. + layoutTurns({ 'turn-3': 120 }); + api.openSettleWindow('resize'); + expect(scroller.scrollTop).toBe(20); + expect(writes).toHaveBeenCalledTimes(2); + }); + + it('continues partial corrections when they reduce the residual', () => { + let top = 0; + Object.defineProperty(scroller, 'scrollTop', { + configurable: true, get: () => top, + set: (value: number) => { top += (value - top) / 2; }, + }); + layoutTurns({ 'turn-3': 100 }); + api.captureAnchor(); + layoutTurns({ 'turn-3': 108 }); + api.openSettleWindow(); + for (let i = 0; i < 4; i += 1) runFrame(); + expect(Math.abs(scroller.scrollTop - 8)).toBeLessThan(0.5); + }); + + it('reports reversals without suppressing valid late layout corrections', () => { + layoutTurns({ 'turn-3': 100 }); + api.captureAnchor(); + api.openSettleWindow('items'); + for (let i = 0; i < 30; i += 1) { + layoutTurns({ 'turn-3': i % 2 === 0 ? 108 : 100 }); + runFrame(); + } + expect(scroller.scrollTop).toBe(0); + const results = mocks.traceViewportRepeating.mock.calls + .map(([, probe]) => probe).filter(probe => probe.location === 'anchor.correctionResult'); + expect(results).toHaveLength(30); + expect(results.at(-1)?.data()).toMatchObject({ + source: 'items', requestedPx: -8, appliedPx: -8, residualPx: 0, + improved: true, correctionCount: 30, reversalCount: 29, cumulativeTravelPx: 240, + }); + for (let i = 0; i < ANCHOR_SETTLE_FRAMES; i += 1) runFrame(); + expect(frames).toHaveLength(0); + }); + it('winds the settle window down while another owner holds the viewport', () => { /* * Measured: 27 seconds of `anchor.stoodDown`, one per frame, with the diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts index 1d0a455872..4ac47b5b36 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatViewportAnchor.ts @@ -87,6 +87,7 @@ type AnchorRestoreOutcome = | 'in-place' | 'awaiting-turn' | 'stood-down' + | 'blocked' | 'no-anchor'; export interface FlowChatViewportAnchorApi { @@ -180,7 +181,7 @@ export interface FlowChatViewportAnchorApi { * but where it is, the displacement and its repair are one paint instead of * two. */ - openSettleWindow: () => void; + openSettleWindow: (source?: 'items' | 'resize' | 'snapshot' | 'resume') => void; } export function useFlowChatViewportAnchor({ @@ -231,6 +232,10 @@ export function useFlowChatViewportAnchor({ const lastUserScrollIntentAtRef = useRef(0); /** Frames left in which the anchor is still being re-asserted. */ const settleFramesRef = useRef(0); + const blockedGeometryRef = useRef(null); + const lastResizeGeometryRef = useRef(null); + const correctionStatsRef = useRef({ count: 0, reversals: 0, travelPx: 0, lastSign: 0 }); + const settleSourceRef = useRef('items'); /** * The next settle window takes a reading position instead of restoring one. * @@ -318,6 +323,9 @@ export function useFlowChatViewportAnchor({ const captureAnchorAt = useCallback((source: 'explicit' | 'scroll' | 'navigation') => { const scroller = scrollerRef.current; if (!scroller) return; + blockedGeometryRef.current = null; + lastResizeGeometryRef.current = null; + correctionStatsRef.current = { count: 0, reversals: 0, travelPx: 0, lastSign: 0 }; const previous = anchorRef.current; const next = selectViewportAnchor( readViewportAnchorCandidates(scroller), @@ -570,9 +578,16 @@ export function useFlowChatViewportAnchor({ carryAnchorThroughScroll(scroller, 'viewport-moved'); } const rebased = anchorRef.current ?? anchor; + const currentOffsetPx = readTurnAnchorOffsetPx(scroller, element); + const geometry = JSON.stringify([ + rebased.turnId, rebased.offsetFromScrollerTop, currentOffsetPx, + scroller.scrollTop, scroller.scrollHeight, scroller.clientHeight, scroller.clientWidth, + ]); + if (blockedGeometryRef.current === geometry) return 'blocked'; + blockedGeometryRef.current = null; const correction = viewportAnchorCorrectionPx( rebased, - readTurnAnchorOffsetPx(scroller, element), + currentOffsetPx, ); /* * Already where it belongs, which still counts as answered — and is now the @@ -609,7 +624,7 @@ export function useFlowChatViewportAnchor({ const felt = Math.abs(correction) >= ANCHOR_NOTABLE_CORRECTION_PX; traceViewportRepeating(`anchor|correcting|${felt ? 'felt' : 'rounding'}|${rebased.turnId}`, { location: 'anchor.correct', - message: 'anchor put the reading position back', + message: 'anchor requested a reading position correction', travelPx: correction, data: () => ({ turnId: rebased.turnId, @@ -644,7 +659,44 @@ export function useFlowChatViewportAnchor({ scrollRangePx: roundViewportPx(scroller.scrollHeight), }), }); + const beforePx = scroller.scrollTop; shiftViewportRef.current(correction); + const appliedPx = scroller.scrollTop - beforePx; + const residualPx = viewportAnchorCorrectionPx(rebased, readTurnAnchorOffsetPx(scroller, element)); + const improved = Math.abs(residualPx) < ANCHOR_CORRECTION_EPSILON_PX + || Math.abs(residualPx) < Math.abs(correction); + const stats = correctionStatsRef.current; + const sign = Math.sign(correction); + if (stats.lastSign !== 0 && stats.lastSign !== sign) stats.reversals += 1; + stats.lastSign = sign; + stats.count += 1; + stats.travelPx += Math.abs(appliedPx); + const magnitudeBand = Math.abs(correction) >= ANCHOR_NOTABLE_CORRECTION_PX ? 'felt' : 'rounding'; + traceViewportRepeating(`anchor|result|${rebased.turnId}|${improved}|${sign}|${magnitudeBand}`, { + location: 'anchor.correctionResult', + message: 'measured the result of an anchor correction', + travelPx: appliedPx, + data: () => ({ + turnId: rebased.turnId, source: settleSourceRef.current, + frameStartMs: correctionFrameStartMsRef.current, + requestedPx: roundViewportPx(correction), appliedPx: roundViewportPx(appliedPx), + residualPx: roundViewportPx(residualPx), improved, + correctionCount: stats.count, reversalCount: stats.reversals, + cumulativeTravelPx: roundViewportPx(stats.travelPx), + scrollTopPx: roundViewportPx(scroller.scrollTop), scrollHeightPx: scroller.scrollHeight, + viewportHeightPx: scroller.clientHeight, + anchorItemKey: element.dataset.virtualItemKey ?? null, + renderedFirstIndex: scroller.querySelector('[data-virtual-index]')?.getAttribute('data-virtual-index') ?? null, + renderedRowCount: scroller.querySelectorAll('[data-virtual-index]').length, + }), + }); + if (!improved) { + blockedGeometryRef.current = JSON.stringify([ + rebased.turnId, rebased.offsetFromScrollerTop, + readTurnAnchorOffsetPx(scroller, element), scroller.scrollTop, + scroller.scrollHeight, scroller.clientHeight, scroller.clientWidth, + ]); + } /* * The shift put the Turn back at the stored offset, so advancing only the * position keeps the two halves agreeing. This is the one movement of @@ -652,7 +704,7 @@ export function useFlowChatViewportAnchor({ */ anchorScrollTopRef.current = scroller.scrollTop; isRestoringViewportResumeRef.current = false; - return 'corrected'; + return improved ? 'corrected' : 'blocked'; }, [carryAnchorThroughScroll, reportMissingTurnWait, scrollerRef]); const attemptRestoreRef = useRef(attemptRestore); @@ -674,7 +726,23 @@ export function useFlowChatViewportAnchor({ return outcome === 'corrected' || outcome === 'in-place'; }, []); - const openSettleWindow = useCallback(() => { + const openSettleWindow = useCallback((source: 'items' | 'resize' | 'snapshot' | 'resume' = 'items') => { + const scroller = scrollerRef.current; + if (source === 'resize' && scroller) { + const anchor = anchorRef.current; + const element = anchor ? findRenderedTurnAnchorElement(scroller, anchor.turnId) : null; + const geometry = JSON.stringify([ + scroller.scrollHeight, scroller.clientWidth, scroller.clientHeight, + anchor?.turnId, element ? readTurnAnchorOffsetPx(scroller, element) + scroller.scrollTop : null, + ]); + if (lastResizeGeometryRef.current === geometry) return; + lastResizeGeometryRef.current = geometry; + } + settleSourceRef.current = source; + traceViewportRepeating(`anchor|settle-open|${source}`, { + location: 'anchor.settleOpened', message: 'opened an anchor settle window', + data: () => ({ source, remainingFrames: settleFramesRef.current, alreadyRunning: settleFrameRef.current !== null }), + }); settleFramesRef.current = ANCHOR_SETTLE_FRAMES; if (recaptureOnNextSettleRef.current) { /* @@ -736,10 +804,15 @@ export function useFlowChatViewportAnchor({ correctionFrameStartMsRef.current = null; if (settleFramesRef.current > 0) { settleFrameRef.current = requestAnimationFrame(step); + } else { + traceViewportRepeating(`anchor|settle-ended|${outcome}`, { + location: 'anchor.settleEnded', message: 'anchor settle window ended', + data: () => ({ source: settleSourceRef.current, outcome, ...correctionStatsRef.current }), + }); } }; settleFrameRef.current = requestAnimationFrame(step); - }, [captureAnchorAt]); + }, [captureAnchorAt, scrollerRef]); useEffect(() => () => { if (settleFrameRef.current !== null) {