diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md index 4aefe19c57..9b3afd06d1 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md @@ -114,6 +114,15 @@ measurement convergence has no such owner. An idle measurement with no owner must only rebase the measured height and must never create Footer space from a negative `scrollHeight` delta alone. +Owners are bounded by an idle reservation audit: while no streaming, collapse +intent, retained anchor, or viewport owner is active and the user has been idle +past `RETAINED_COLLAPSE_RELEASE_QUIET_MS`, `VirtualMessageList` settles the +collapse reservation to its geometric minimum for the current scroll position +and drops sticky pins whose target is no longer the latest turn. This is a +safety net, not a release mechanism: a legitimate owner that still needs its +range keeps it, but a stuck owner can no longer accumulate synthetic tail +whitespace across audit intervals. + Reservation state is ref-owned first and mirrored into React state. A Virtuoso Footer remount must synchronously read the ref-owned value; otherwise one stale React commit can remove exactly the reserved scroll range for a frame. @@ -328,6 +337,29 @@ shrinks. `VirtualMessageList` uses that event to: This pre-compensation is what avoids the flash. +Finalization is bounded. When the settlement strategy is `reconcile-sticky-pin` +or `drain` and the drain target (the sticky pin user message) is not +renderable, `drainCollapseReservationPreservingPinnedItem` returns `null` and +finalization retries every 50 ms. Retries are capped at +`COLLAPSE_INTENT_FINALIZE_MAX_RETRIES`; past the cap the intent force-settles +to the geometric minimum instead of staying active. An intent that stays +active indefinitely would suspend auto-follow via `shouldSuspendAutoFollow` +and let every later collapse coalesce onto the same stuck provisional +reservation, which shows up as bottom whitespace that grows with each +subagent card event. + +While an intent is alive, `measureHeightChange` growth is consumed only down +to the measured protection level (`baseTotalCompensationPx` plus the measured +shrink so far minus the pin reservation), never below the collapse floor. The +part of the provisional estimate above the measured shrink is inflation and +must drain on growth; keeping it intact sediments subagent wrapping/growth +into footer whitespace whenever finalization is delayed. + +After a `preserving-element` settlement, the coordinator anchor is released +immediately when the collapse reservation reaches zero; a retained anchor +with no synthetic range to preserve must not linger until the next user +scroll. + Runtime status is transient session UI state, not a `FlowItem`. The always-mounted `RuntimeStatusSlot` occupies the first 24px of the existing Footer spacer and switches only `visibility`; showing, hiding, and clearing it never change list @@ -359,6 +391,41 @@ This is deliberately separate from the VirtualMessageList collapse-intent TTL and settlement timers: the former controls when a card may compact, while the latter protects the viewport while its height changes. +### Bottom convergence (dead-lock escape for stuck tail whitespace) + +Every settle variant above computes the geometric minimum for the *current* +`scrollTop` via `getRequiredTotalPxForScrollTop`. At the physical bottom this +recomputes exactly the current reservation: the synthetic footer holds +`scrollTop` high, and the required range equals that same synthetic space. The +result is a dead-lock where collapse compensation can only drain through +measured content growth or deliberate downward scrolling. This is why the old +behavior needed repeated up/down scrub cycles to shrink the whitespace, and +why the idle audit could not help either (it settled to the same value). + +`VirtualMessageList.convergeBottomReservationsToContentBottom` breaks the +dead-lock with a bottom-convergence pass: + +- Detection: `|(scrollHeight - clientHeight) - scrollTop| <= + BOTTOM_CONVERGE_EPSILON_PX` (2px) while the coordinator does not own + `preserving-element` / `pinned-item` and no collapse intent is active. + A mid-list reader never matches, so anchor-preserving settles stay intact. +- Action: collapse and pin reservations are cleared in one synchronous Footer + update, then `scrollTop` falls to the new content bottom. The browser would + clamp here anyway; writing it explicitly keeps the scroll/height baselines + consistent. Pending turn-pin / sticky-pin-growth request state is left + alone; those requests expire or settle against the empty reservation on + their own paths. +- Callers: retained quiet settlement (`settleRetainedCollapseRange`), + collapse-intent finalization for the non-pinned strategies, + the idle reservation audit, `handleScroll` (any scroll event at the + physical bottom is bottom intent — one pass replaces per-scrub + consumption), the collapse-intent accumulation guard (a new provisional + estimate must not stack on a settled bottom reservation), and the + input-stack-shrink drain. + +A bottom user converges once and sees the content bottom; a mid-list reader +keeps the geometric minimum for the captured `scrollTop`. + ## Runtime Flow ## A. Known Tool Card Collapse @@ -707,6 +774,11 @@ If a future collapsible component shows the same "header drops" or "flash on col - Pre-collapse intent must capture the anchor before the component shrinks. - Compensation must not be consumed too early during active layout transitions. - Session changes and empty-list resets must clear compensation and anchor state. +- A user at the physical bottom must never retain synthetic tail space: every + settlement path (retained quiet settle, intent finalize, idle audit, scroll + handler, accumulation guards) must converge reservations to the content + bottom instead of settling to the geometric minimum for a `scrollTop` that + the reservation itself holds high. ## Common Ways To Break This @@ -758,6 +830,31 @@ The diagnostic schema groups events by hypothesis: - `D`: Virtuoso scroll compensation and tail-follow ownership - `E`: streaming tool-card collapse intent and anchor preservation +Additional probes help attribute a stuck or growing bottom blank to its owner: + +- `VirtualMessageList.reservationLedger` (hypothesis `C`, sampled every 500 ms + while enabled): full `collapse`/`pin` reservation state, owner kind, intent + activity and remaining TTL, finalize retry count, retained anchor, + coordinator mode, follow/streaming flags, scroller geometry, distance from + bottom, and the rendered footer height. +- `VirtualMessageList.finalizeCollapseIntent` (hypothesis `E`): every deferred + finalization with the retry count and whether the sticky pin target is + rendered; the settlement-completed event closes the intent lifecycle. +- `VirtualMessageList.shouldSuspendAutoFollow` (hypothesis `D`): fires once + when auto-follow stays suspended longer than 2 s. +- `VirtualMessageList.subagentHeightObserver` (hypothesis `C`): task/subagent + card height changes correlated with the reservation and intent state. +- `FlowChatViewportCoordinator.restoreElementAnchor` (hypothesis `C`): anchor + bottom-range additions per source since the last semantic release. +- `VirtualMessageList.convergeBottomReservationsToContentBottom` + (hypothesis `C`): every bottom-convergence pass with the reason, coordinator + mode, reservation before, and the post-convergence scroll geometry. A + healthy session should converge after each card collapse settles; repeated + convergences without an intervening reservation source indicate a producer + that keeps re-adding synthetic space. +- The Footer DOM node exposes `data-reservation-px` (the current synthetic + tail space) for at-a-glance DevTools inspection without log parsing. + Do not add message content, tool arguments, file contents, or other sensitive payloads to this channel. Keep all data producers lazy and guard hot-path probes with `flowChatDiagnostics.isEnabled()` before allocating probe objects. diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts index 32e4589cd8..2834614838 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts @@ -80,6 +80,10 @@ export class FlowChatViewportCoordinator { private pendingElementAnchorRestore: PendingElementAnchorRestore | null = null; private rangeHost: FlowChatViewportRangeHost | null = null; private nextElementAnchorLease = 0; + // Diagnostics: accumulated bottom-range additions per source since the last + // semantic ownership release, so flowchat.log can attribute reservation + // growth to the anchor-restore path that requested it. + private rangeAdditionBySource = new Map(); setRangeHost(host: FlowChatViewportRangeHost | null): void { this.rangeHost = host; @@ -322,8 +326,13 @@ export class FlowChatViewportCoordinator { this.rangeHost && (this.mode === 'pinned-item' || this.mode === 'preserving-element') ) { + const additionalPx = remainingCorrection + ELEMENT_ANCHOR_RANGE_GUARD_PX; + this.rangeAdditionBySource.set( + source, + (this.rangeAdditionBySource.get(source) ?? 0) + additionalPx, + ); const rangeExtended = this.rangeHost.ensureBottomRange({ - additionalPx: remainingCorrection + ELEMENT_ANCHOR_RANGE_GUARD_PX, + additionalPx, mode: this.mode, source, }); @@ -342,11 +351,13 @@ export class FlowChatViewportCoordinator { data: () => ({ mode: this.mode, source, + additionalPx, rangeExtended, remainingCorrection, scrollTop: scroller.scrollTop, scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, + rangeAdditionBySource: Object.fromEntries(this.rangeAdditionBySource), }), }); } @@ -406,9 +417,16 @@ export class FlowChatViewportCoordinator { hypothesis: 'B', location: 'FlowChatViewportCoordinator.release', message: 'Viewport coordinator released semantic ownership', - data: () => ({ previousMode, previousPreservationPhase, hadElementAnchor, reason }), + data: () => ({ + previousMode, + previousPreservationPhase, + hadElementAnchor, + reason, + rangeAdditionBySource: Object.fromEntries(this.rangeAdditionBySource), + }), }); } + this.rangeAdditionBySource.clear(); } private validateElementAnchor(source: string): void { diff --git a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss index 7e2283514d..b63780bdc4 100644 --- a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss +++ b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss @@ -42,6 +42,8 @@ overflow-y: auto; overscroll-behavior: contain; contain: layout paint style; + // Long tokens wrap inside the card instead of widening the FlowChat item. + overflow-wrap: anywhere; .flow-text-block--subagent-compact { margin-bottom: 0.15rem; diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index 8c292f2968..c4377a6cd6 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -80,6 +80,7 @@ vi.mock('@/infrastructure/diagnostics/flowChatDiagnostics', () => ({ flowChatDiagnostics: { isEnabled: () => flowDiagnosticsMocks.enabled, trace: flowDiagnosticsMocks.trace, + subscribe: vi.fn(() => () => {}), }, })); @@ -2028,10 +2029,16 @@ describe('VirtualMessageList session boundary', () => { for (let frame = 0; frame < 4; frame += 1) { flushAnimationFrame(); } - const wasSettledAnchorReleased = () => flowDiagnosticsMocks.trace.mock.calls.some(([event]) => ( - event.location === 'VirtualMessageList.releaseSettledCollapseAnchor' + // The scrollTop now sits exactly at the (new) physical bottom, so the + // retained settlement converges the synthetic range to the content + // bottom instead of keeping it until the quiet-release timer. This is + // the dead-lock escape: at the physical bottom the geometric minimum + // equals the current reservation, so keeping the range would leave + // permanent tail whitespace. + const wasBottomConverged = () => flowDiagnosticsMocks.trace.mock.calls.some(([event]) => ( + event.location === 'VirtualMessageList.convergeBottomReservationsToContentBottom' )); - for (let attempt = 0; attempt < 3 && !wasSettledAnchorReleased(); attempt += 1) { + for (let attempt = 0; attempt < 3 && !wasBottomConverged(); attempt += 1) { act(() => { vi.runOnlyPendingTimers(); }); @@ -2039,7 +2046,11 @@ describe('VirtualMessageList session boundary', () => { flushAnimationFrame(); } } - expect(wasSettledAnchorReleased()).toBe(true); + expect(wasBottomConverged()).toBe(true); + // The synthetic range is gone: the footer returns to the same geometric + // minimum as the earlier settle (no extra whitespace for the bottom user). + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(settledFooterHeight, 1); + expect(scroller.scrollTop).toBeLessThan(1_100); } finally { vi.useRealTimers(); } @@ -3880,4 +3891,903 @@ describe('VirtualMessageList session boundary', () => { expect(flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint).not.toHaveBeenCalled(); expect(container.querySelector('[data-history-boundary-status="not-ready"]')?.textContent).toBe('Older history is not ready yet.'); }); + + it('bounds collapse-intent finalize retries when the sticky pin target is not renderable', () => { + flowDiagnosticsMocks.enabled = true; + const session = createSessionWithTurns('session-a', ['turn-a', 'turn-b'], { + dialogTurns: [ + { + id: 'turn-a', + sessionId: 'session-a', + userMessage: { id: 'user-turn-a', content: 'turn-a', timestamp: 1 }, + modelRounds: [], + status: 'completed', + startTime: 1, + }, + { + id: 'turn-b', + sessionId: 'session-a', + userMessage: { id: 'user-turn-b', content: 'turn-b', timestamp: 2 }, + modelRounds: [{ + id: 'round-turn-b', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 2, + } as typeof session.dialogTurns[number]['modelRounds'][number]], + status: 'processing', + startTime: 2, + }, + ], + }); + stateMocks.activeSession = session; + stateMocks.virtualItems = [ + createItem('turn-a'), + createModelItem('turn-a'), + createItem('turn-b'), + createModelItem('turn-b'), + ]; + const listRef = React.createRef(); + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + expect(scroller).not.toBeNull(); + if (!scroller) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_000, + }); + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 0, + bottom: 1_000, + height: 1_000, + })); + + const pinnedUserMessage = container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-b"]', + ); + expect(pinnedUserMessage).not.toBeNull(); + if (!pinnedUserMessage) { + return; + } + vi.spyOn(pinnedUserMessage, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 57, + bottom: 87, + height: 30, + })); + + // Establish a sticky-latest pin on the latest turn while its target renders, + // and dispatch the subagent card collapse in the same act batch: the active + // intent blocks the pinned-item -> tail handoff until finalization, which + // is the state in which a real subagent card collapse lands mid-stream. + // Fake timers must be active before the dispatch so the collapse TTL and + // the 50ms finalize retry loop run under test control. + vi.useFakeTimers(); + try { + let pinStatus: ReturnType = 'rejected'; + act(() => { + pinStatus = listRef.current?.pinTurnToTopWithStatus('turn-b', { + pinMode: 'sticky-latest', + behavior: 'auto', + }) ?? 'rejected'; + const modelRoundAnchor = container.querySelector( + '[data-item-type="model-round"][data-turn-id="turn-b"]', + ); + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: modelRoundAnchor, + reason: 'auto', + }, + })); + }); + expect(pinStatus).toBe('settled'); + + // The reservation re-render inside the act recreated the wrappers. Evict + // the pinned target now; no further React re-render will restore it while + // the finalize retry loop runs (it only writes refs). + act(() => { + container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-b"]', + )?.remove(); + }); + + // Advance past the collapse TTL and let the 50ms finalize retry loop run. + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + act(() => { + vi.advanceTimersByTime(2_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + + const deferredTraces = flowDiagnosticsMocks.trace.mock.calls.filter(([event]) => ( + event.location === 'VirtualMessageList.finalizeCollapseIntent' && + event.message === 'Collapse intent finalization deferred (drain target not renderable)' + )); + // The dangerous path was exercised: the drain target was not renderable. + expect(deferredTraces.length).toBeGreaterThan(0); + + // Bounded retries: the intent must finalize instead of staying active + // forever (which would suspend auto-follow and strand the reservation). + const completedTraces = flowDiagnosticsMocks.trace.mock.calls.filter(([event]) => ( + event.location === 'VirtualMessageList.finalizeCollapseIntent' && + event.message === 'Collapse intent reservation settlement completed' + )); + expect(completedTraces.length).toBe(1); + } finally { + vi.useRealTimers(); + } + }); + + it('drains an idle input-stack-shrink reservation that no owner protects', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + if (!scroller || !footer) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 0, + }); + + // Collapse the input stack: the shrink is preserved as a bottom + // reservation (owner: input-stack-shrink) because the user is not at + // the physical bottom - exactly the leftover that would otherwise + // persist as permanent tail whitespace. + const footerHeightBeforeShrink = Number.parseFloat(footer.style.height); + inputStateMocks.inputHeight = 0; + act(() => { + root.render(); + }); + flushAnimationFrame(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(footerHeightBeforeShrink, 1); + + // With the user idle at the top, no streaming, no collapse intent and + // no retained anchor, the audit settles the reservation to its + // geometric minimum (zero here) instead of leaving it behind. + act(() => { + vi.advanceTimersByTime(1_500); + }); + flushAnimationFrame(); + + expect(Number.parseFloat(footer.style.height)).toBeLessThan(footerHeightBeforeShrink - 1); + expect(flowDiagnosticsMocks.trace).toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.idleReservationAudit', + message: 'Idle reservation audit settled synthetic tail space', + })); + } finally { + vi.useRealTimers(); + } + }); + + it('converges a collapse reservation to the content bottom when finalizing at the physical bottom', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + if (!scroller || !footer) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_000, // physical bottom + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + // A subagent card collapses while the user sits at the physical bottom: + // provisional compensation equals the card height. + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: null, + reason: 'auto', + }, + })); + }); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 500, 1); + + // Finalize after the collapse TTL. At the physical bottom the geometric + // minimum for the current scrollTop equals the current reservation, so + // the settle would keep the whitespace; bottom convergence clears it. + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + expect(scroller.scrollTop).toBe(4_000); + expect(flowDiagnosticsMocks.trace).toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + } finally { + vi.useRealTimers(); + } + }); + + it('converges bottom reservations when a scroll event arrives at the physical bottom', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + activeSessionStateMocks.isProcessing = true; // streaming: no rAF drain + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + if (!scroller || !footer) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_000, // physical bottom + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + // Input stack shrinks while streaming: the shrink is preserved as a + // reservation (no rAF drain while streaming) and scrollTop is capped by + // the synthetic footer - the exact dead-lock state the user reported. + inputStateMocks.inputHeight = 0; + act(() => { + root.render(); + }); + flushAnimationFrame(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + + // Any scroll event while at the physical bottom is bottom intent: + // converge in one pass instead of per-scrub consumption. + act(() => { + scroller.dispatchEvent(new Event('scroll')); + }); + + expect(Number.parseFloat(footer.style.height)).toBeLessThan(baseFooterHeight - 1); + expect(flowDiagnosticsMocks.trace).toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + } finally { + vi.useRealTimers(); + } + }); + + it('does not stack a second collapse provisional estimate on a settled bottom reservation', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + if (!scroller || !footer) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_000, + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + const dispatchCollapseIntent = (cardHeight: number) => { + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight, + anchorElement: null, + reason: 'auto', + }, + })); + }); + }; + + // First collapse: provisional 500, then finalize converges it away. + dispatchCollapseIntent(500); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 500, 1); + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + + // Second collapse: the guard converges the stale reservation before + // estimating, so the new provisional is just 300, not 300 + 500. + dispatchCollapseIntent(300); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 300, 1); + + // Let the second intent finalize so no timers outlive the test. + act(() => { + vi.advanceTimersByTime(1_000); + }); + } finally { + vi.useRealTimers(); + } + }); + + it('converges a bottom-stuck reservation during the idle audit', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + if (!scroller || !footer) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 0, // reading the top: the shrink reservation stays + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + // Input stack shrinks with the user away from the bottom: the rAF drain + // does not fire (not at the physical bottom), so the reservation stays. + inputStateMocks.inputHeight = 0; + act(() => { + root.render(); + }); + flushAnimationFrame(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + + // User moves to the physical bottom; the idle audit must converge the + // reservation instead of settling to the (equal) geometric minimum. + scroller.scrollTop = 4_000; + act(() => { + vi.advanceTimersByTime(1_500); + }); + flushAnimationFrame(); + + expect(Number.parseFloat(footer.style.height)).toBeLessThan(baseFooterHeight - 1); + expect(flowDiagnosticsMocks.trace).toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + } finally { + vi.useRealTimers(); + } + }); + + it('consumes only the provisional inflation while a collapse intent is alive', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + if (!scroller || !footer) { + return; + } + let naturalContentHeight = 5_000; + Object.defineProperties(scroller, { + clientHeight: { configurable: true, value: 1_000 }, + scrollHeight: { + configurable: true, + get: () => naturalContentHeight + (Number.parseFloat(footer.style.height) || 0), + }, + scrollTop: { configurable: true, writable: true, value: 0 }, + }); + // Sit at the physical bottom before the collapse. + scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight; + const baseFooterHeight = Number.parseFloat(footer.style.height); + + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: null, + reason: 'auto', + }, + })); + }); + // Provisional compensation equals the full card height at the bottom. + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 500, 1); + expect(footer.dataset.reservationPx).toBe('500'); + + // Establish the measurement baseline. The intent render re-mounts the + // scroller ref in this test harness, which resets the measured-height + // baseline; a real Virtuoso does not do that. Note: under fake timers + // requestAnimationFrame is also faked, so measurements advance with + // advanceTimersByTime instead of the rAF stub queue. + const triggerMeasure = () => { + act(() => { + window.dispatchEvent(new Event('tool-card-toggle')); + }); + act(() => { + vi.advanceTimersByTime(50); + }); + }; + triggerMeasure(); + + // Measured shrink enters cumulativeShrinkPx while the intent is alive: + // the protection line rises to 200 and the reservation stays at 500. + naturalContentHeight -= 200; + triggerMeasure(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 500, 1); + + // Content growth of 100 may only consume the inflation above the 200px + // measured protection (collapse 500 -> 400), never the protection line. + naturalContentHeight += 100; + triggerMeasure(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 400, 1); + expect(footer.dataset.reservationPx).toBe('400'); + + // Finalize the intent so no timers outlive the test. + act(() => { + vi.advanceTimersByTime(1_000); + }); + } finally { + vi.useRealTimers(); + } + }); + + it('does not converge reservations for a mid-list reader', () => { + flowDiagnosticsMocks.enabled = true; + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + if (!scroller || !footer) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 3_000, // mid-list: far from the physical bottom + }); + // Sync the scroll baseline (a real browser would have produced scroll + // events while reaching this position); without this the next scroll + // event sees a large synthetic delta and consumes the reservation. + act(() => { + scroller.dispatchEvent(new Event('scroll')); + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + // Input stack shrinks while the user reads mid-list: the rAF drain does + // not fire and the reservation stays as protected tail space. + inputStateMocks.inputHeight = 0; + act(() => { + root.render(); + }); + flushAnimationFrame(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + + // A scroll event at a mid-list position must not converge the reservation + // (the reader's reading position still needs the anchor range). + act(() => { + scroller.dispatchEvent(new Event('scroll')); + }); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight, 1); + expect(flowDiagnosticsMocks.trace).not.toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + }); + + it('does not converge reservations while a pinned turn owns the viewport', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + const session = createSessionWithTurns('session-a', ['turn-a', 'turn-b'], { + dialogTurns: [ + { + id: 'turn-a', + sessionId: 'session-a', + userMessage: { id: 'user-turn-a', content: 'turn-a', timestamp: 1 }, + modelRounds: [], + status: 'completed', + startTime: 1, + }, + { + id: 'turn-b', + sessionId: 'session-a', + userMessage: { id: 'user-turn-b', content: 'turn-b', timestamp: 2 }, + modelRounds: [{ + id: 'round-turn-b', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 2, + } as typeof session.dialogTurns[number]['modelRounds'][number]], + status: 'processing', + startTime: 2, + }, + ], + }); + stateMocks.activeSession = session; + stateMocks.virtualItems = [ + createItem('turn-a'), + createModelItem('turn-a'), + createItem('turn-b'), + createModelItem('turn-b'), + ]; + const listRef = React.createRef(); + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + if (!scroller || !footer) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_000, + }); + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 0, + bottom: 1_000, + height: 1_000, + })); + const pinnedUserMessage = container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-b"]', + ); + expect(pinnedUserMessage).not.toBeNull(); + if (!pinnedUserMessage) { + return; + } + vi.spyOn(pinnedUserMessage, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 57, + bottom: 87, + height: 30, + })); + + act(() => { + listRef.current?.pinTurnToTopWithStatus('turn-b', { + pinMode: 'sticky-latest', + behavior: 'auto', + }); + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + // A collapse lands while the pinned turn owns the viewport; the + // provisional range is real reservation, not convergible space. + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: null, + reason: 'auto', + }, + })); + }); + const footerAfterIntent = Number.parseFloat(footer.style.height); + expect(footerAfterIntent).toBeGreaterThan(baseFooterHeight); + + // Scroll at the physical bottom: convergence must be rejected while the + // coordinator owns the pinned item, leaving the reservation intact. + act(() => { + scroller.dispatchEvent(new Event('scroll')); + }); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(footerAfterIntent, 1); + expect(flowDiagnosticsMocks.trace).not.toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + + // Finalize the intent (reconcile into the pin reservation) so no timers + // outlive the test. + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + } finally { + vi.useRealTimers(); + } + }); + + it('does not converge reservations while an element anchor preserves the viewport', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + stateMocks.activeSession = createSession('session-a', 'turn-a'); + stateMocks.virtualItems = [createItem('turn-a')]; + inputStateMocks.isActive = true; + inputStateMocks.inputHeight = 200; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + const anchor = container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-a"]', + ); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + expect(anchor).not.toBeNull(); + if (!scroller || !footer || !anchor) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 4_200, // near the bottom but not at it: a reading position + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: anchor, + reason: 'auto', + }, + })); + }); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(baseFooterHeight + 500, 1); + + // Finalize: the coordinator stays in preserving-element (retained) with + // a non-zero reservation because the reader is not at the physical + // bottom, so the settle keeps the geometric minimum. + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + const footerAfterSettle = Number.parseFloat(footer.style.height); + expect(footerAfterSettle).toBeGreaterThan(baseFooterHeight); + + // The idle audit must not converge the reservation while the element + // anchor still preserves the reading position. + act(() => { + vi.advanceTimersByTime(1_500); + }); + flushAnimationFrame(); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(footerAfterSettle, 1); + expect(flowDiagnosticsMocks.trace).not.toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + })); + } finally { + vi.useRealTimers(); + } + }); + + it('force-settles a non-bottom collapse to the geometric minimum after bounded retries', () => { + flowDiagnosticsMocks.enabled = true; + vi.useFakeTimers(); + try { + const session = createSessionWithTurns('session-a', ['turn-a', 'turn-b'], { + dialogTurns: [ + { + id: 'turn-a', + sessionId: 'session-a', + userMessage: { id: 'user-turn-a', content: 'turn-a', timestamp: 1 }, + modelRounds: [], + status: 'completed', + startTime: 1, + }, + { + id: 'turn-b', + sessionId: 'session-a', + userMessage: { id: 'user-turn-b', content: 'turn-b', timestamp: 2 }, + modelRounds: [{ + id: 'round-turn-b', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 2, + } as typeof session.dialogTurns[number]['modelRounds'][number]], + status: 'processing', + startTime: 2, + }, + ], + }); + stateMocks.activeSession = session; + stateMocks.virtualItems = [ + createItem('turn-a'), + createModelItem('turn-a'), + createItem('turn-b'), + createModelItem('turn-b'), + ]; + const listRef = React.createRef(); + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + if (!scroller || !footer) { + return; + } + // Mid-list: 200px above the physical bottom, so the geometric minimum + // after settle is 100px, not zero. + setScrollerGeometry(scroller, { + scrollHeight: 5_000, + clientHeight: 1_000, + scrollTop: 3_800, + }); + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 0, + bottom: 1_000, + height: 1_000, + })); + const pinnedUserMessage = container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-b"]', + ); + expect(pinnedUserMessage).not.toBeNull(); + if (!pinnedUserMessage) { + return; + } + vi.spyOn(pinnedUserMessage, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 57, + bottom: 87, + height: 30, + })); + + act(() => { + listRef.current?.pinTurnToTopWithStatus('turn-b', { + pinMode: 'sticky-latest', + behavior: 'auto', + }); + const modelRoundAnchor = container.querySelector( + '[data-item-type="model-round"][data-turn-id="turn-b"]', + ); + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'task-a', + toolName: 'Task', + cardHeight: 500, + anchorElement: modelRoundAnchor, + reason: 'auto', + }, + })); + }); + const baseFooterHeight = Number.parseFloat(footer.style.height); + // Provisional: 500 - 200 (distance from bottom) = 300. + expect(baseFooterHeight).toBeGreaterThan(0); + + // Evict the pinned target so every finalize retry defers. + act(() => { + container.querySelector( + '[data-item-type="user-message"][data-turn-id="turn-b"]', + )?.remove(); + }); + act(() => { + vi.advanceTimersByTime(1_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + act(() => { + vi.advanceTimersByTime(2_000); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + + // Bounded retries exhausted: the intent force-settles to the geometric + // minimum for the current mid-list scrollTop (300 -> 100), instead of + // staying active or keeping the full provisional. + const completedTraces = flowDiagnosticsMocks.trace.mock.calls.filter(([event]) => ( + event.location === 'VirtualMessageList.finalizeCollapseIntent' && + event.message === 'Collapse intent reservation settlement completed' + )); + expect(completedTraces.length).toBe(1); + const settledFooterHeight = Number.parseFloat(footer.style.height); + expect(settledFooterHeight).toBeLessThan(baseFooterHeight); + expect(settledFooterHeight).toBeGreaterThan(0); + // data-reservation-px mirrors the settled geometric minimum (100px + // scroll range plus the 1px anchor guard). + expect(footer.dataset.reservationPx).toBe('101'); + } finally { + vi.useRealTimers(); + } + }); }); 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 39a53c8838..171a80fb3b 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -129,6 +129,19 @@ const STICKY_PIN_GROWTH_SETTLE_MS = 300; const RETAINED_COLLAPSE_QUIET_SETTLE_MS = 120; const RETAINED_COLLAPSE_QUIET_SETTLE_FRAMES = 2; const RETAINED_COLLAPSE_RELEASE_QUIET_MS = COLLAPSE_INTENT_TTL_MS; +// Upper bound for finalize retries when the collapse drain target (a sticky +// pin user message) is not renderable. Without this cap the intent stays +// active forever, suspending auto-follow and stranding its provisional +// reservation, so every later collapse coalesces onto the same stuck intent. +const COLLAPSE_INTENT_FINALIZE_MAX_RETRIES = 20; +// Idle audit cadence for the bottom-reservation safety net. Kept above the +// collapse TTL so a legitimately protected transition finishes first. +const IDLE_RESERVATION_AUDIT_INTERVAL_MS = 1500; +// Tolerance for "user is at the physical bottom" detection used by the bottom +// convergence paths. Slightly looser than the scroll baseline epsilon so a +// footer-sized scrollHeight rounding or a pending browser clamp still counts +// as "at the bottom" and gets converged instead of staying stuck. +const BOTTOM_CONVERGE_EPSILON_PX = 2; const HISTORY_PRESENTATION_COMMIT_SCROLL_QUIET_MS = 320; const IDLE_HISTORY_WINDOW_BOUNDARY_STATE: Record< SessionHistoryWindowDirection, @@ -291,6 +304,8 @@ interface PendingCollapseIntentState { distanceFromBottomBeforeCollapse: number; baseTotalCompensationPx: number; cumulativeShrinkPx: number; + /** Number of times finalization was deferred because the drain target was not renderable. */ + finalizeRetries: number; } interface RetainedCollapseAnchorState { @@ -335,6 +350,7 @@ function createInactiveCollapseIntentState(): PendingCollapseIntentState { distanceFromBottomBeforeCollapse: 0, baseTotalCompensationPx: 0, cumulativeShrinkPx: 0, + finalizeRetries: 0, }; } @@ -644,6 +660,13 @@ const VirtualMessageListSession = forwardRef(null); const retainedCollapseSettleGenerationRef = useRef(0); const retainedCollapseGeometryGenerationRef = useRef(0); + // Diagnostics: continuous auto-follow suspension tracking (hypothesis D). + const autoFollowSuspendStartMsRef = useRef(null); + const autoFollowSuspendLoggedRef = useRef(false); + // Diagnostics: subagent/task card height observation (hypothesis C). + const subagentHeightObserverRef = useRef(null); + const observedSubagentElementsRef = useRef>(new WeakSet()); + const subagentHeightCacheRef = useRef>(new Map()); const pendingStickyPinGrowthRef = useRef<{ targetTurnId: string | null; amountPx: number; @@ -808,6 +831,9 @@ const VirtualMessageListSession = forwardRef { @@ -1261,6 +1287,112 @@ const VirtualMessageListSession = forwardRef { + if (!scroller || !canProcessViewportGeometry(scroller)) { + return false; + } + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + return Math.abs(maxScrollTop - scroller.scrollTop) <= BOTTOM_CONVERGE_EPSILON_PX; + }, [canProcessViewportGeometry]); + + const convergeBottomReservationsToContentBottom = useCallback((reason: string): boolean => { + const scroller = scrollerElementRef.current; + if (!scroller || !isAtPhysicalBottom(scroller)) { + return false; + } + const coordinatorMode = viewportCoordinatorRef.current.getMode(); + if ( + coordinatorMode === 'preserving-element' || + coordinatorMode === 'pinned-item' + ) { + // A semantic anchor or pinned turn owns the viewport; clearing synthetic + // tail space would move content under the user's reading position. + return false; + } + if (pendingCollapseIntentRef.current.active) { + return false; + } + + const currentState = bottomReservationStateRef.current; + const hasCollapse = ( + currentState.collapse.px > COMPENSATION_EPSILON_PX || + currentState.collapse.floorPx > COMPENSATION_EPSILON_PX + ); + const hasPin = ( + currentState.pin.px > COMPENSATION_EPSILON_PX || + currentState.pin.floorPx > COMPENSATION_EPSILON_PX + ); + if (!hasCollapse && !hasPin) { + // Nothing to converge, but a stuck retained anchor / owner may still be + // blocking the idle audit — release them so later paths can run. + if (retainedCollapseAnchorRef.current !== null) { + retainedCollapseAnchorRef.current = null; + clearRetainedCollapseSettlement(); + } + clearCollapseReservationOwner(); + return false; + } + + // Clear both reservations in one synchronous footer update. Pending turn + // pin / sticky-pin-growth request state is left alone: those requests have + // their own expiry paths and settle cleanly against an empty reservation. + const nextState = createInitialBottomReservationState(); + pendingCollapseIntentRef.current = createInactiveCollapseIntentState(); + retainedCollapseAnchorRef.current = null; + clearRetainedCollapseSettlement(); + clearCollapseReservationOwner(); + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + + // Fall to the new content bottom. The browser would clamp here anyway; + // writing it explicitly keeps the scroll/height baselines consistent. + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + scroller.scrollTop = maxScrollTop; + previousScrollTopRef.current = scroller.scrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller, nextState); + recordScrollerGeometry(scroller); + + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'C', + location: 'VirtualMessageList.convergeBottomReservationsToContentBottom', + message: 'Bottom reservations converged to the content bottom', + data: () => ({ + reason, + coordinatorMode, + reservationBefore: currentState, + scrollTopAfter: scroller.scrollTop, + scrollHeightAfter: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }), + }); + } + return true; + }, [ + applyFooterCompensationNow, + clearCollapseReservationOwner, + clearRetainedCollapseSettlement, + isAtPhysicalBottom, + recordScrollerGeometry, + snapshotMeasuredContentHeight, + updateBottomReservationState, + ]); + const preserveCollapseAnchorScrollTop = useCallback(( scroller: HTMLElement, targetScrollTop: number, @@ -1336,6 +1468,15 @@ const VirtualMessageListSession = forwardRef { + if (!collapseProtectionActive) { + return consumeBottomCompensation(heightDelta, { + consumeStickyPinFloor: false, + }); + } + // While a collapse intent is alive, growth must not remove the range + // the ongoing animation still needs, but the provisional estimate can + // exceed the measured shrink so far. Only that inflation is safe to + // consume; otherwise growth during the intent window sediments into + // the footer and shows up as bottom whitespace (especially when the + // intent finalization is delayed by an unrenderable drain target). + const measuredProtectionPx = Math.max( + previousReservationState.collapse.floorPx, + Math.max( + 0, + collapseIntent0.baseTotalCompensationPx + + Math.max( + 0, + collapseIntent0.cumulativeShrinkPx - + collapseIntent0.distanceFromBottomBeforeCollapse, + ) - + getReservationTotalPx(previousReservationState.pin), + ), + ); + const consumablePx = Math.max( + 0, + previousReservationState.collapse.px - measuredProtectionPx, + ); + if (consumablePx <= COMPENSATION_EPSILON_PX) { + return previousReservationState; + } + return consumeBottomCompensation(Math.min(heightDelta, consumablePx), { + consumeStickyPinFloor: false, + }); + })(); const immediatelyConsumedPx = Math.max( 0, getTotalBottomCompensationPx(previousReservationState) - @@ -3287,10 +3448,47 @@ const VirtualMessageListSession = forwardRef= COLLAPSE_INTENT_FINALIZE_MAX_RETRIES; const nextState = (() => { + // Bottom convergence: at the physical bottom, settling to the geometric + // minimum for the current scrollTop recomputes the current reservation + // (dead-lock). The correct settled view for a bottom user is the content + // bottom, so converge instead. `reconcile-sticky-pin` is excluded: it + // owns a pinned turn. (`retain-following-tail` already returned above; + // it waits for the quiet settle pass while Virtuoso measurements may + // still be moving.) + if ( + settlementStrategy !== 'reconcile-sticky-pin' && + scroller && + isAtPhysicalBottom(scroller) + ) { + if (coordinatorMode === 'preserving-element') { + // The user is looking at the bottom; no anchor range needs to keep + // this viewport position after convergence. + viewportCoordinatorRef.current.release(`collapse-finalize:${reason}:bottom-converged`); + } + if (convergeBottomReservationsToContentBottom(`collapse-finalize:${reason}`)) { + return bottomReservationStateRef.current; + } + } switch (settlementStrategy) { case 'reconcile-sticky-pin': case 'drain': + if (forceSettle) { + return scroller + ? settleCollapseReservationForViewport( + bottomReservationStateRef.current, + { + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }, + ) + : protectCurrentCollapseReservation(bottomReservationStateRef.current); + } return drainCollapseReservationPreservingPinnedItem(reason); case 'settle-preserved-element': case 'settle-protected-viewport': @@ -3307,7 +3505,32 @@ const VirtualMessageListSession = forwardRef ({ + reason, + finalizeRetries: retriedIntent.finalizeRetries, + forceSettleNext: retriedIntent.finalizeRetries >= COLLAPSE_INTENT_FINALIZE_MAX_RETRIES, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + pinMode: pinReservation.mode, + pinTargetTurnId: pinReservation.targetTurnId, + stickyPinTargetRendered, + reservation: bottomReservationStateRef.current, + }), + }); + } collapseIntentFinalizeTimerRef.current = window.setTimeout(() => { collapseIntentFinalizeTimerRef.current = null; finalizeCollapseIntentRef.current('collapse-intent-target-retry', { @@ -3359,6 +3582,15 @@ const VirtualMessageListSession = forwardRef clearCollapseIntentScheduling, [clearCollapseIntentScheduling]); + // Diagnostics (hypothesis C): periodic bottom-reservation ledger. Sampling + // only runs while flow_chat_diagnostics is enabled and stops as soon as the + // setting is turned off. It records which reservation owner holds the + // synthetic tail space, so a stuck/growing bottom blank can be attributed to + // the exact field (collapse vs pin) and owner without a debugger. + useEffect(() => { + let ledgerTimer: number | null = null; + const stopLedger = () => { + if (ledgerTimer !== null) { + window.clearInterval(ledgerTimer); + ledgerTimer = null; + } + }; + const startLedger = () => { + if (ledgerTimer !== null) { + return; + } + ledgerTimer = window.setInterval(() => { + if (!flowChatDiagnostics.isEnabled()) { + return; + } + const scroller = scrollerElementRef.current; + const footer = footerElementRef.current; + flowChatDiagnostics.trace({ + hypothesis: 'C', + location: 'VirtualMessageList.reservationLedger', + message: 'Bottom reservation ledger sample', + data: () => { + const state = bottomReservationStateRef.current; + const intent = pendingCollapseIntentRef.current; + return { + reservation: state, + ownerKind: collapseReservationOwnerRef.current?.kind ?? null, + ownerGeneration: collapseReservationOwnerRef.current?.generation ?? null, + intentActive: intent.active, + intentToolId: intent.toolId, + intentToolName: intent.toolName, + intentExpiresInMs: intent.active + ? Math.max(0, Math.round(intent.expiresAtMs - performance.now())) + : null, + intentFinalizeRetries: intent.finalizeRetries, + retainedCollapseAnchor: retainedCollapseAnchorRef.current, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + scrollTop: scroller?.scrollTop ?? null, + scrollHeight: scroller?.scrollHeight ?? null, + clientHeight: scroller?.clientHeight ?? null, + distanceFromBottom: scroller + ? Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop) + : null, + footerRenderedHeight: footer ? Math.round(footer.getBoundingClientRect().height) : null, + inputStackFooterPx: inputStackFooterPxRef.current, + }; + }, + }); + }, 500); + }; + if (flowChatDiagnostics.isEnabled()) { + startLedger(); + } + const unsubscribe = flowChatDiagnostics.subscribe(enabled => { + if (enabled) { + startLedger(); + } else { + stopLedger(); + } + }); + return () => { + unsubscribe(); + stopLedger(); + }; + }, []); + const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => { restoreScrollerMethodsRef.current?.(); restoreScrollerMethodsRef.current = null; @@ -3956,11 +4265,46 @@ const VirtualMessageListSession = forwardRef { const collapseIntent = pendingCollapseIntentRef.current; - return ( + const suspended = ( !isViewportActiveRef.current || viewportGeometrySuspendedRef.current || collapseIntent.active ); + const now = performance.now(); + if (suspended) { + if (autoFollowSuspendStartMsRef.current === null) { + autoFollowSuspendStartMsRef.current = now; + autoFollowSuspendLoggedRef.current = false; + } else if ( + !autoFollowSuspendLoggedRef.current && + now - autoFollowSuspendStartMsRef.current > 2000 && + flowChatDiagnostics.isEnabled() + ) { + autoFollowSuspendLoggedRef.current = true; + const startedAtMs = autoFollowSuspendStartMsRef.current; + flowChatDiagnostics.trace({ + hypothesis: 'D', + location: 'VirtualMessageList.shouldSuspendAutoFollow', + message: 'Auto-follow suspension persisted beyond 2s', + data: () => ({ + suspendedForMs: Math.round(now - startedAtMs), + intentActive: pendingCollapseIntentRef.current.active, + intentToolId: pendingCollapseIntentRef.current.toolId, + intentToolName: pendingCollapseIntentRef.current.toolName, + intentFinalizeRetries: pendingCollapseIntentRef.current.finalizeRetries, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + reservation: bottomReservationStateRef.current, + retainedCollapseAnchor: retainedCollapseAnchorRef.current, + isViewportActive: isViewportActiveRef.current, + viewportGeometrySuspended: viewportGeometrySuspendedRef.current, + }), + }); + } + } else { + autoFollowSuspendStartMsRef.current = null; + autoFollowSuspendLoggedRef.current = false; + } + return suspended; }, []); const scheduleFollowToLatestWithViewportState = useCallback((reason: string) => { @@ -4071,6 +4415,68 @@ const VirtualMessageListSession = forwardRef { + if (element instanceof HTMLElement && element.dataset.toolCardId) { + return element.dataset.toolCardId; + } + const closestCard = element.closest('[data-tool-card-id]'); + return closestCard?.dataset.toolCardId ?? 'subagent-projection'; + }; + const syncSubagentHeightObservers = () => { + if (!flowChatDiagnostics.isEnabled()) { + return; + } + const nodes = Array.from(scrollerElement.querySelectorAll( + '[data-tool-card-id], .subagent-projection-wrapper', + )); + for (const node of nodes) { + if (observedSubagentElementsRef.current.has(node)) { + continue; + } + observedSubagentElementsRef.current.add(node); + const toolId = readSubagentElementToolId(node); + subagentHeightCache.set(toolId, Math.round(node.getBoundingClientRect().height)); + subagentHeightObserverRef.current?.observe(node); + } + }; + subagentHeightObserverRef.current?.disconnect(); + subagentHeightObserverRef.current = new ResizeObserver((entries) => { + if (!flowChatDiagnostics.isEnabled()) { + return; + } + for (const entry of entries) { + const target = entry.target; + const toolId = readSubagentElementToolId(target); + const heightBefore = subagentHeightCache.get(toolId) ?? null; + const heightAfter = Math.round(target.getBoundingClientRect().height); + subagentHeightCache.set(toolId, heightAfter); + if (heightBefore === null || Math.abs(heightAfter - heightBefore) <= 1) { + continue; + } + flowChatDiagnostics.trace({ + hypothesis: 'C', + location: 'VirtualMessageList.subagentHeightObserver', + message: 'Task/subagent card height changed', + data: () => ({ + toolId, + heightBefore, + heightAfter, + heightDelta: heightAfter - heightBefore, + reservation: bottomReservationStateRef.current, + intentActive: pendingCollapseIntentRef.current.active, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + scrollTop: scrollerElement.scrollTop, + scrollHeight: scrollerElement.scrollHeight, + clientHeight: scrollerElement.clientHeight, + }), + }); + } + }); + // Batch observer-triggered work into a single rAF per frame let observerBatchPending = false; const scheduleObserverBatch = () => { @@ -4078,6 +4484,7 @@ const VirtualMessageListSession = forwardRef { observerBatchPending = false; + syncSubagentHeightObservers(); scheduleHeightMeasure(2); scheduleVisibleTurnMeasure(2); schedulePinReservationReconcile(2); @@ -4343,21 +4750,30 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX && !intent.active ) { - const nextScrollTop = scrollerElement.scrollTop; - const scrollDelta = nextScrollTop - previousScrollTopRef.current; - if (scrollDelta > COMPENSATION_EPSILON_PX) { - const nextCompensationState = consumeBottomCompensation(scrollDelta); - applyFooterCompensationNow(nextCompensationState); - if (retainedCollapseAnchorRef.current) { - retainedCollapseAnchorRef.current.anchorScrollTop = Math.max( - retainedCollapseAnchorRef.current.anchorScrollTop, - scrollerElement.scrollTop, + // Bottom convergence: when the user is already at the physical bottom, + // per-scroll consumption never triggers (scrollDelta is 0 because the + // synthetic footer caps scrollTop), so the only way to shrink the + // whitespace was repeated up/down scrubbing. Any scroll event while at + // the physical bottom is bottom intent — converge in one pass. + if (convergeBottomReservationsToContentBottom('scroll-at-bottom')) { + // Converged; reservations are zero and baselines were re-synced. + } else { + const nextScrollTop = scrollerElement.scrollTop; + const scrollDelta = nextScrollTop - previousScrollTopRef.current; + if (scrollDelta > COMPENSATION_EPSILON_PX) { + const nextCompensationState = consumeBottomCompensation(scrollDelta); + applyFooterCompensationNow(nextCompensationState); + if (retainedCollapseAnchorRef.current) { + retainedCollapseAnchorRef.current.anchorScrollTop = Math.max( + retainedCollapseAnchorRef.current.anchorScrollTop, + scrollerElement.scrollTop, + ); + } + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight( + scrollerElement, + nextCompensationState, ); } - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight( - scrollerElement, - nextCompensationState, - ); } } @@ -4588,8 +5004,15 @@ const VirtualMessageListSession = forwardRef).detail; - acquireCollapseReservationOwner('tool-collapse'); const previousIntent = pendingCollapseIntentRef.current; + // Accumulation guard: if the previous intent already settled but left a + // stale reservation behind (the bottom dead-lock), converging first stops + // the new provisional estimate from stacking on top of it. Only safe when + // no active intent/anchor is mid-flight. + if (!previousIntent.active) { + convergeBottomReservationsToContentBottom('collapse-intent-arrived-at-bottom'); + } + acquireCollapseReservationOwner('tool-collapse'); clearRetainedCollapseSettlement(); if (flowChatDiagnostics.isEnabled()) { flowChatDiagnostics.trace({ @@ -4664,6 +5087,7 @@ const VirtualMessageListSession = forwardRef { + const auditIdleReservations = () => { + const scroller = scrollerElementRef.current; + if (!canProcessViewportGeometry(scroller)) { + return; + } + if (isStreamingOutputRef.current) { + return; + } + if (pendingCollapseIntentRef.current.active || retainedCollapseAnchorRef.current !== null) { + return; + } + const coordinatorMode = viewportCoordinatorRef.current.getMode(); + if (coordinatorMode === 'pinned-item' || coordinatorMode === 'following-tail') { + return; + } + if ( + performance.now() - lastUserScrollIntentAtMsRef.current < + RETAINED_COLLAPSE_RELEASE_QUIET_MS + ) { + return; + } + if ( + scrollbarPointerInteractionActiveRef.current || + touchScrollIntentStartYRef.current !== null + ) { + return; + } + + const currentState = bottomReservationStateRef.current; + const hasCollapse = ( + currentState.collapse.px > COMPENSATION_EPSILON_PX || + currentState.collapse.floorPx > COMPENSATION_EPSILON_PX + ); + const hasPin = ( + currentState.pin.px > COMPENSATION_EPSILON_PX || + currentState.pin.floorPx > COMPENSATION_EPSILON_PX + ); + if (!hasCollapse && !hasPin) { + // A bottom user needs no element-anchor correction (tail follow and + // physical-bottom sync own the viewport), so releasing the preserved + // anchor is safe. A mid-list reader may still rely on the retained + // anchor for reading-position corrections, so keep it while the user + // is away from the physical bottom. + if (coordinatorMode === 'preserving-element' && isAtPhysicalBottom(scroller)) { + viewportCoordinatorRef.current.release('idle-audit-empty'); + } + return; + } + + // Bottom convergence: at the physical bottom the geometric minimum for + // the current scrollTop equals the current reservation, so the plain + // settle below would keep the whitespace forever. Converge instead. + if (convergeBottomReservationsToContentBottom('idle-audit-bottom')) { + return; + } + + let nextState = hasCollapse + ? settleCollapseReservationForViewport(currentState, { + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }) + : currentState; + const pin = nextState.pin; + if (pin.mode === 'sticky-latest' && pin.targetTurnId && pin.targetTurnId !== latestTurnId) { + clearPinReservationForUserNavigation('idle-audit-stale-pin', { + preserveCurrentRange: false, + }); + nextState = bottomReservationStateRef.current; + } + if (areBottomReservationStatesEqual(currentState, nextState)) { + return; + } + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + if (nextState.collapse.px <= COMPENSATION_EPSILON_PX) { + clearCollapseReservationOwner(); + // Same rule as the empty-reservation branch: only a bottom user's + // preserved anchor may be released by the idle audit. + if ( + viewportCoordinatorRef.current.getMode() === 'preserving-element' && + isAtPhysicalBottom(scroller) + ) { + viewportCoordinatorRef.current.release('idle-audit-empty'); + } + } + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller, nextState); + recordScrollerGeometry(scroller); + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'C', + location: 'VirtualMessageList.idleReservationAudit', + message: 'Idle reservation audit settled synthetic tail space', + data: () => ({ + reservationBefore: currentState, + reservationAfter: bottomReservationStateRef.current, + coordinatorMode, + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }), + }); + } + }; + + const auditTimer = window.setInterval( + auditIdleReservations, + IDLE_RESERVATION_AUDIT_INTERVAL_MS, + ); + return () => window.clearInterval(auditTimer); + }, [ + applyFooterCompensationNow, + canProcessViewportGeometry, + clearCollapseReservationOwner, + clearPinReservationForUserNavigation, + convergeBottomReservationsToContentBottom, + isAtPhysicalBottom, + latestTurnId, + recordScrollerGeometry, + snapshotMeasuredContentHeight, + updateBottomReservationState, + ]); + const isStreamingOutput = React.useMemo(() => { if (viewportMode === 'history-reading') { return false; diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss index 7b52cb31ac..058ca92eca 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.scss @@ -9,6 +9,10 @@ overflow-y: auto; overscroll-behavior: contain; box-sizing: border-box; + /* Long unbreakable tokens (file paths, model ids, URLs) must wrap inside + the card instead of inflating the min-content width of the virtual item, + which shifts the surrounding FlowChat layout. */ + overflow-wrap: anywhere; } .subagent-projection-container--expanded { @@ -28,6 +32,13 @@ .subagent-projection-content { min-width: 0; + overflow-wrap: anywhere; + + .markdown-renderer, + .text-content, + .flow-text-block { + overflow-wrap: anywhere; + } .flow-thinking-item .thinking-content { padding-left: 0; diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss index ade4241b94..aeb593b4eb 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss @@ -192,6 +192,7 @@ align-items: center; gap: var(--bf-appearance-token-flowchat-inline-gap); width: 100%; + min-width: 0; min-height: 20px; } diff --git a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.test.ts b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.test.ts index 9e13537b85..8820b61200 100644 --- a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.test.ts +++ b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.test.ts @@ -72,6 +72,32 @@ describe('flowChatDiagnostics', () => { }); }); + it('notifies subscribers when the enabled state changes', () => { + const listener = vi.fn(); + const unsubscribe = flowChatDiagnostics.subscribe(listener); + + flowChatDiagnostics.setEnabled(true); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenLastCalledWith(true); + + flowChatDiagnostics.setEnabled(false); + expect(listener).toHaveBeenCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith(false); + + unsubscribe(); + flowChatDiagnostics.setEnabled(true); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('does not notify subscribers when the enabled state is unchanged', () => { + const listener = vi.fn(); + flowChatDiagnostics.subscribe(listener); + + flowChatDiagnostics.setEnabled(false); + flowChatDiagnostics.setEnabled(false); + expect(listener).not.toHaveBeenCalled(); + }); + it('flushes queued diagnostics when disabled', async () => { flowChatDiagnostics.setEnabled(true); flowChatDiagnostics.trace({ diff --git a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts index ff790c3b3b..f337b55b91 100644 --- a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts +++ b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnostics.ts @@ -31,11 +31,24 @@ class FlowChatDiagnosticsRecorder { private flushInFlight: Promise | null = null; private flushRequested = false; private droppedEntrySummary: DroppedEntrySummary | null = null; + private listeners = new Set<(enabled: boolean) => void>(); isEnabled(): boolean { return this.enabled; } + /** + * Registers a listener invoked whenever the enabled state changes. Returns + * an unsubscribe function. Used by viewport diagnostics that must start or + * stop periodic sampling when the logging setting is toggled at runtime. + */ + subscribe(listener: (enabled: boolean) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + setEnabled(enabled: boolean): void { const supportedEnabled = enabled && isTauriRuntime(); if (this.enabled === supportedEnabled) { @@ -50,12 +63,12 @@ class FlowChatDiagnosticsRecorder { location: 'FlowChatDiagnosticsRecorder.setEnabled', message: 'Flow Chat diagnostics enabled', }); - return; + } else { + window.removeEventListener('pagehide', this.handlePageHide); + this.clearFlushTimer(); + void this.flush(); } - - window.removeEventListener('pagehide', this.handlePageHide); - this.clearFlushTimer(); - void this.flush(); + this.listeners.forEach(listener => listener(this.enabled)); } trace(probe: FlowChatDiagnosticProbe): void { @@ -113,6 +126,7 @@ class FlowChatDiagnosticsRecorder { this.droppedEntrySummary = null; this.clearFlushTimer(); this.flushInFlight = null; + this.listeners.clear(); if (typeof window !== 'undefined') { window.removeEventListener('pagehide', this.handlePageHide); }