diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx index 12c39bbe8f..1c012a6ed1 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.test.tsx @@ -17,16 +17,15 @@ const controlledDragMock = vi.hoisted(() => vi.fn()); const pointerDrag = vi.hoisted(() => ({ prepare: vi.fn(), move: vi.fn(), finish: vi.fn(), cancel: vi.fn() })); vi.mock('@/infrastructure/config/services/AgentCompanionDragService', () => ({ startAgentCompanionDrag: controlledDragMock })); vi.mock('@/infrastructure/config/services/AgentCompanionPointerDragService', () => ({ prepareAgentCompanionPointerDrag: pointerDrag.prepare })); -/** Backs the Tauri IPC bridge, so window resize requests are observable. */ -const hostInvokeMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); vi.mock('@tauri-apps/api/event', () => ({ emit: emitMock, listen: listenMock, })); -vi.mock('@tauri-apps/api/core', () => ({ - invoke: invokeMock, +// Mock the component's API boundary before ApiClient initializes a transport. +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ + api: { invoke: invokeMock }, })); vi.mock('@tauri-apps/api/window', () => ({ @@ -95,13 +94,6 @@ function typeInto(input: HTMLInputElement, value: string): void { } beforeAll(async () => { - // The pet resizes its own window through `invoke`; the dynamic import inside - // the component reaches the real Tauri core module, so stub the IPC bridge. - (window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = { - invoke: hostInvokeMock, - transformCallback: (callback: unknown) => callback, - }; - i18n = createInstance(); await i18n.use(initReactI18next).init({ lng: 'en-US', @@ -152,9 +144,9 @@ describe('AgentCompanionDesktopPet', () => { }; beforeEach(async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); emitMock.mockClear(); invokeMock.mockClear(); - hostInvokeMock.mockClear(); cursorPositionMock.mockReset(); cursorPositionMock.mockResolvedValue({ x: 0, y: 0 }); startDraggingMock.mockReset(); @@ -189,12 +181,13 @@ describe('AgentCompanionDesktopPet', () => { }); }); - afterEach(() => { - act(() => { + afterEach(async () => { + await act(async () => { root.unmount(); }); container.remove(); vi.useRealTimers(); + vi.unstubAllGlobals(); }); it('provides the off-window pointer direction to v2 only while idle', async () => { @@ -400,7 +393,11 @@ describe('AgentCompanionDesktopPet', () => { await act(async () => { await Promise.resolve(); }); - hostInvokeMock.mockClear(); + expect(invokeMock).toHaveBeenCalledWith( + 'resize_agent_companion_desktop_pet', + expect.objectContaining({ width: expect.any(Number), height: expect.any(Number) }), + ); + invokeMock.mockClear(); dispatch(query('.openbitfun-agent-companion-window__bubble-shell')!, 'contextmenu'); dispatch(query('.openbitfun-agent-companion-window__pet-hitbox')!, 'contextmenu'); @@ -410,10 +407,9 @@ describe('AgentCompanionDesktopPet', () => { // Resizing moves every bottom-right anchored element for a frame, which // reads as the pet and bubbles flashing. - expect(hostInvokeMock).not.toHaveBeenCalledWith( + expect(invokeMock).not.toHaveBeenCalledWith( 'resize_agent_companion_desktop_pet', expect.anything(), - expect.anything(), ); }); diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index 304f20b429..b74cd3711d 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -447,6 +447,8 @@ export const AgentCompanionDesktopPet: React.FC = () => { }, [typedOutputBySessionId]); const visibleTaskCountRef = useRef(0); + // Only the inline composer changes bubble layout; floating menus do not. + const composingSessionId = overlay?.kind === 'composer' ? overlay.sessionId : null; useLayoutEffect(() => { // Written here (not during render) so an abandoned/double render cannot @@ -501,7 +503,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { .catch(error => { log.warn('Failed to resize Agent companion window', error); }); - }, [activePetSize.height, activePetSize.width, overlay, bubbleTasks, visibleTasks.length]); + }, [activePetSize.height, activePetSize.width, composingSessionId, bubbleTasks, visibleTasks.length]); useEffect(() => { if (IS_WINDOWS_WEBVIEW && !trackPetLook) { 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 055409a0c4..c7ec5487a7 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 @@ -7,7 +7,7 @@ import { FlowGroup, ThinkingBlock } from '@openbitfun/ui/flow-chat'; import { DeferredContent } from '@openbitfun/flow-chat-presentation/deferred-content'; import { computeFlowChatInputStackFooterPx } from '../../utils/flowChatScrollLayout'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; -import type { SubmittedMessageScrollIntent } from '../../services/submittedMessageScrollIntent'; +import { finishSubmittedMessageScrollIntent, registerSubmittedMessageScrollIntent, type SubmittedMessageScrollIntent } from '../../services/submittedMessageScrollIntent'; import { readingLinePxForViewport, tailSpacerPxForViewport } from './flowChatTailFollow'; import { ONE_SHOT_NAVIGATION_HOLD_MS } from './flowChatViewportOwnership'; import { VirtualMessageList, type VirtualMessageListRef } from './VirtualMessageList'; @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ scrollItemIntoView: vi.fn(), scrollToOffset: vi.fn(), cancelAim: vi.fn(), + measureRenderedItems: vi.fn(), setVisibleTurnInfo: vi.fn(), handleViewportResize: vi.fn(), enterFollowOutput: vi.fn(), @@ -159,7 +160,7 @@ vi.mock('./useFlowChatVirtualizer', async () => { ), // The real one flushes the DOM's heights into the library's cache; // these rows are placed by arithmetic, so there is nothing to flush. - measureRenderedItems: () => {}, + measureRenderedItems: mocks.measureRenderedItems, syncViewportOffset: () => {}, getVisibleItemRange: () => { const scroller = options.scrollerRef.current; @@ -355,6 +356,7 @@ describe('VirtualMessageList natural scroll contract', () => { mocks.scrollItemIntoView.mockReset(); mocks.scrollToOffset.mockReset(); mocks.cancelAim.mockReset(); + mocks.measureRenderedItems.mockReset(); mocks.handleViewportResize.mockReset(); mocks.enterFollowOutput.mockReset(); mocks.exitFollowOutput.mockReset(); @@ -545,7 +547,9 @@ describe('VirtualMessageList natural scroll contract', () => { mocks.activeSession.dialogTurns = [{ id: 'turn-2', userMessage: { id: 'message-2' } }]; act(() => root.render()); act(() => { - expect(mocks.placeSubmittedMessage?.({ scope: getActiveSurfaceScope(), sessionId: 'session-1', turnId: 'turn-2', messageId: 'message-2' })).toBe(592); + const intent = registerSubmittedMessageScrollIntent(getActiveSurfaceScope(), 'session-1', 'turn-2', 'message-2')!; + expect(mocks.placeSubmittedMessage?.(intent)).toBe(592); + finishSubmittedMessageScrollIntent(intent); }); expect(scroller.scrollTop).toBe(592); expect(container.querySelector('.virtual-message-list__extent')?.style.minHeight).toBe('1192px'); @@ -555,6 +559,25 @@ describe('VirtualMessageList natural scroll contract', () => { } }); + it('does not scroll a submission cancelled by a synchronous measurement callback', () => { + const restoreLayout = fakeLayout({ clientHeight: 600, scrollHeight: 1400, turnTopFromScrollerTop: 500 }); + try { + act(() => root.render()); + const scroller = container.querySelector('[data-flowchat-scroller]')!; + const extent = container.querySelector('.virtual-message-list__extent')!; + scroller.scrollTop = 100; + const previousMinHeight = extent.style.minHeight; + const intent = registerSubmittedMessageScrollIntent(getActiveSurfaceScope(), 'session-1', 'turn-2', 'message-2')!; + mocks.measureRenderedItems.mockImplementationOnce(() => finishSubmittedMessageScrollIntent(intent)); + act(() => { expect(mocks.placeSubmittedMessage?.(intent)).toBeNull(); }); + expect(mocks.measureRenderedItems).toHaveBeenCalled(); + expect(scroller.scrollTop).toBe(100); + expect(extent.style.minHeight).toBe(previousMinHeight); + } finally { + restoreLayout(); + } + }); + it('keeps the latest Turn minimum extent through growth, collapse and resize', () => { // Supplied physical geometry tests the production extent policy, not CSS // rendering. In particular no send receipt is needed after opening history. 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 af35ea8d04..a4a8161120 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -112,7 +112,7 @@ import { findExcerptSource, resolveExcerptRange } from '../../selection/flowChat import { highlightExcerptRange } from '../../selection/locateConversationExcerpt'; import './VirtualMessageList.scss'; import { Icon } from '@openbitfun/ui'; -import type { SubmittedMessageScrollIntent } from '../../services/submittedMessageScrollIntent'; +import { isSubmittedMessageScrollIntentPending, type SubmittedMessageScrollIntent } from '../../services/submittedMessageScrollIntent'; const SEARCH_NAVIGATION_MAX_ATTEMPTS = 24; /** Consecutive quiet frames that mark the opening viewport as settled. */ @@ -455,6 +455,14 @@ const VirtualMessageListSession = forwardRef(null); const extentElementRef = useRef(null); const [submissionKey, setSubmissionKey] = useState(null); + const submissionKeyRef = useRef(null); + const updateSubmissionKey = useCallback((key: string | null) => { + // Cancellation can run from layout and measurement in the same commit. + // Avoid dispatching another React update when the requested pin is unchanged. + if (submissionKeyRef.current === key) return; + submissionKeyRef.current = key; + setSubmissionKey(key); + }, []); const [scrollerElement, setScrollerElement] = useState(null); const [viewportHeightPx, setViewportHeightPx] = useState(0); const [viewportWidthPx, setViewportWidthPx] = useState(0); @@ -696,20 +704,24 @@ const VirtualMessageListSession = forwardRef new Set(virtualItems.filter(item => item.type !== 'user-message').map(item => item.turnId)), [virtualItems]); const { @@ -735,7 +747,7 @@ const VirtualMessageListSession = forwardRef outputTurns.has(turnId), - cancelPendingPlacement: () => setSubmissionKey(null), + cancelPendingPlacement: () => updateSubmissionKey(null), cancelNavigation: virtualizer.cancelAim, isOpeningViewport, onViewportOffset: publishFollowOffset, diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.measurement.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.measurement.test.tsx new file mode 100644 index 0000000000..7f36efac6a --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.measurement.test.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import React, { act, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, it, vi } from 'vitest'; +import { activateSurface, getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { claimSubmittedMessageScrollIntent, registerSubmittedMessageScrollIntent } from '../../services/submittedMessageScrollIntent'; +import { useFlowChatFollowOutput } from './useFlowChatFollowOutput'; +import { useFlowChatVirtualizer } from './useFlowChatVirtualizer'; +import { useFlowChatViewportOwner } from './useFlowChatViewportOwner'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); activateSurface('local'); }); + +// Real follow, virtualizer and ownership hooks. Supplied DOM heights reproduce +// synchronous resizeItem notifications, not native WebView rendering. +it('places once through real measurement notifications and remains idle after deactivation', async () => { + activateSurface('local'); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + vi.stubGlobal('requestAnimationFrame', vi.fn().mockReturnValue(1)); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + const host = document.createElement('div'); + const scroller = document.createElement('div'); + const header = document.createElement('div'); + document.body.append(host, scroller); + Object.defineProperties(scroller, { + clientHeight: { value: 500 }, clientWidth: { value: 800 }, + offsetHeight: { value: 500 }, offsetWidth: { value: 800 }, scrollHeight: { value: 1000 }, + }); + const root = createRoot(host); + let active = true, renders = 0, placements = 0, reconciliations = 0; + let materialized = true; + let follow: ReturnType; + const items = ['message', 'answer']; + function Harness() { + renders++; + const scrollerRef = useRef(scroller), headerRef = useRef(header); + const owner = useFlowChatViewportOwner(scrollerRef); + const reconcile = useRef<() => boolean>(() => false); + const [, setPin] = useState(null); + const virtualizer = useFlowChatVirtualizer({ + items, scrollerRef, headerRef, getItemKey: String, + estimateItemHeightPx: () => 100, scrollPaddingStartPx: 0, + writeViewport: owner.write, shiftViewport: owner.shift, + reconcileOpeningMeasurement: () => reconcile.current(), + }); + follow = useFlowChatFollowOutput({ + activeSessionId: 'measurement-session', virtualItemCount: items.length, + isViewportActive: active, isStreaming: true, startAtTailOnMount: false, + scrollerRef, viewportOwner: owner, viewportId: 123, + readLayoutTarget: () => 500, hasRenderedOutput: () => false, + isOpeningViewport: () => true, cancelNavigation: virtualizer.cancelAim, + cancelPendingPlacement: () => setPin(null), + placeSubmittedMessage: intent => { + if (!materialized) { setPin(intent.turnId); return null; } + placements++; + virtualizer.measureRenderedItems(); + owner.write({ owner: 'follow-output', topPx: 100 }); + virtualizer.syncViewportOffset(scroller.scrollTop); + setPin(null); + return scroller.scrollTop; + }, + }); + reconcile.current = () => { + if (!active || !follow.isFollowingOutputNow()) return false; + reconciliations++; + follow.scheduleFollowToLatest(); + return true; + }; + return null; + } + try { + act(() => root.render()); + // Attach after mount so the submission's measurement sees two real deltas. + for (let index = 0; index < items.length; index++) { + const row = document.createElement('div'); + row.dataset.virtualIndex = String(index); + Object.defineProperty(row, 'offsetHeight', { value: 40 }); + scroller.append(row); + } + await act(async () => { + registerSubmittedMessageScrollIntent(getActiveSurfaceScope(), 'measurement-session', 'turn', 'message'); + await Promise.resolve(); + }); + expect(reconciliations).toBeGreaterThan(0); + expect(placements).toBe(1); + expect(scroller.scrollTop).toBe(100); + expect(claimSubmittedMessageScrollIntent('measurement-session', 123)).toBeNull(); + + materialized = false; + act(() => { registerSubmittedMessageScrollIntent(getActiveSurfaceScope(), 'measurement-session', 'next', 'next-message'); }); + active = false; + await act(async () => { root.render(); await Promise.resolve(); }); + expect(follow!.isFollowingOutputNow()).toBe(false); + const settledRenders = renders; + for (let i = 0; i < 5; i++) act(() => root.render()); + expect(renders - settledRenders).toBe(5); + expect(placements).toBe(1); + } finally { + act(() => root.unmount()); host.remove(); scroller.remove(); + } +}); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx index 709271323f..e4e3f9a1c0 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx @@ -21,6 +21,7 @@ describe('desktop follow ownership and lifecycle (supplied geometry, not visual let frames: Map, frameId: number, now: number; let normalTarget: number, floor: number | null, sentTop: number, output: boolean, renderable: boolean; let cancelledPlacements: number; + let duringPlacement: (() => void) | undefined; let props: { session: string; active: boolean; suspended: boolean; opening: boolean; count: number; startAtTail: boolean; streaming: boolean }; let reader: FlowChatReaderState; const placements: string[] = []; @@ -40,6 +41,7 @@ describe('desktop follow ownership and lifecycle (supplied geometry, not visual floor = sentTop; if (!owner.write({ owner: 'follow-output', topPx: sentTop })) return null; placements.push(intent.turnId); + duringPlacement?.(); return sentTop; }, hasRenderedOutput: () => output, @@ -67,6 +69,7 @@ describe('desktop follow ownership and lifecycle (supplied geometry, not visual document.body.append(host, scroller); root = createRoot(host); normalTarget = 1000; floor = null; sentTop = 1400; output = false; renderable = true; cancelledPlacements = 0; + duringPlacement = undefined; props = { session: 'session', active: true, suspended: false, opening: false, count: 2, startAtTail: true, streaming: true }; reader = new FlowChatReaderState(); frames = new Map(); frameId = 0; now = 0; placements.length = 0; readbacks.length = 0; @@ -104,6 +107,61 @@ describe('desktop follow ownership and lifecycle (supplied geometry, not visual expect(placements).toEqual(['turn-2']); expect(controller.isFollowingOutputNow()).toBe(true); }); + it('consumes a submission once when measurement synchronously reenters follow', async () => { + render(); + duringPlacement = () => controller.scheduleFollowToLatest(); + submit(); + await act(async () => { await Promise.resolve(); }); + expect(placements).toEqual(['turn-2']); + expect(claimSubmittedMessageScrollIntent(props.session, 1)).toBeNull(); + expect(controller.getFollowTargetScrollTop()).toBe(1400); + }); + it.each(['reader', 'device', 'cancel'] as const)('does not restore follow after %s invalidation inside placement', async reason => { + render(); + duringPlacement = () => { + duringPlacement = undefined; + if (reason === 'reader') controller.exitFollowOutput('reader-interaction'); + else if (reason === 'device') activateSurface('peer'); + else finishSubmittedMessageScrollIntent(claimSubmittedMessageScrollIntent(props.session, 1)!); + }; + submit(); + await act(async () => { await Promise.resolve(); }); + expect(controller.isFollowingOutputNow()).toBe(false); + expect(controller.getFollowTargetScrollTop()).toBeNull(); + expect(owner.currentOwner()).not.toBe('follow-output'); + }); + it('preserves a replacement submission registered during measurement', async () => { + render(); + duringPlacement = () => { + duringPlacement = undefined; + registerSubmittedMessageScrollIntent(getActiveSurfaceScope(), props.session, 'replacement', 'user-replacement'); + }; + submit(); + await act(async () => { await Promise.resolve(); }); + expect(placements).toEqual(['turn-2', 'replacement']); + expect(claimSubmittedMessageScrollIntent(props.session, 1)).toBeNull(); + expect(controller.getFollowTargetScrollTop()).toBe(1400); + }); + it('releases the placement guard after a measurement failure', () => { + render(); + duringPlacement = () => { throw new Error('Measurement failed'); }; + expect(() => submit()).toThrow('Measurement failed'); + duringPlacement = undefined; + signal(); + expect(placements).toEqual(['turn-2']); + }); + it('cancels a deferred send once while inactive and accepts a fresh send after reactivation', async () => { + render(); renderable = false; submit(); + props.active = false; render(); + for (let i = 0; i < 5; i++) render(); + expect(cancelledPlacements).toBe(1); + expect(controller.isFollowingOutputNow()).toBe(false); + props.active = true; renderable = true; render(); signal(); + expect(placements).toEqual([]); + submit('fresh'); + await act(async () => { await Promise.resolve(); }); + expect(placements).toEqual(['fresh']); + }); it('does not let a tall user message start following before assistant output exists', () => { render(); submit(); normalTarget = 2100; signal(); settle(); expect(scroller.scrollTop).toBe(1400); output = true; signal(); settle(); expect(scroller.scrollTop).toBe(2100); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts index aba4f5b8e5..33f82a94ed 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts @@ -11,7 +11,7 @@ import type { FlowChatViewportOwnerApi } from './useFlowChatViewportOwner'; import { easeFollowOffset, resolveAnimatedJumpBehavior } from './flowChatTailFollow'; export type FollowOutputEnterReason = 'jump-to-latest' | 'session-open' | 'streaming-resumed' | 'turns-rolled-back' | 'reader-at-bottom'; -export type FollowOutputExitReason = 'session-changed' | 'user-scroll' | 'reader-interaction' | 'scroll-to-turn' | 'scroll-to-index'; +export type FollowOutputExitReason = 'session-changed' | 'viewport-inactive' | 'user-scroll' | 'reader-interaction' | 'scroll-to-turn' | 'scroll-to-index'; export interface ViewportResizeInput { viewportHeightDeltaPx: number; wasAtTail: boolean } interface Options { @@ -47,6 +47,7 @@ export function useFlowChatFollowOutput(options: Options) { const following = useRef(false); const phase = useRef('reading'); const pending = useRef(null); + const placingSubmission = useRef(false); const submittedTurn = useRef(null); const target = useRef(0); const frame = useRef(null); @@ -57,6 +58,12 @@ export function useFlowChatFollowOutput(options: Options) { const run = useRef<(now: number) => void>(() => {}); const refresh = useRef<() => void>(() => {}); + const setFollowing = useCallback((value: boolean) => { + if (following.current === value) return; + following.current = value; + setIsFollowingOutput(value); + }, []); + const stop = useCallback(() => { if (frame.current !== null) cancelAnimationFrame(frame.current); frame.current = null; @@ -73,27 +80,27 @@ export function useFlowChatFollowOutput(options: Options) { } }, [available]); const discardPending = useCallback(() => { - if (pending.current) finishSubmittedMessageScrollIntent(pending.current); + const discarded = pending.current; + pending.current = null; + if (discarded) finishSubmittedMessageScrollIntent(discarded); const o = current.current; const unbound = session.current ? claimSubmittedMessageScrollIntent(session.current, o.viewportId ?? 0, true) : null; if (unbound) finishSubmittedMessageScrollIntent(unbound); - pending.current = null; - current.current.cancelPendingPlacement(); + if (discarded) o.cancelPendingPlacement(); }, []); const exitFollowOutput = useCallback((reason: FollowOutputExitReason) => { const o = current.current; - following.current = false; + setFollowing(false); phase.current = 'reading'; downward.current = null; discardPending(); - setIsFollowingOutput(false); stop(); o.viewportOwner.release('follow-output'); traceViewportRepeating(`follow|exit|${reason}`, { location: 'followOutput.exit', message: 'The reader owns the viewport', data: () => ({ reason, viewportId: o.viewportId }), }); - }, [discardPending, stop]); + }, [discardPending, setFollowing, stop]); run.current = now => { frame.current = null; @@ -136,9 +143,8 @@ export function useFlowChatFollowOutput(options: Options) { o.viewportOwner.release('one-shot-navigation'); if (reason !== 'reader-at-bottom') o.viewportOwner.release('user-gesture'); o.viewportOwner.claim('follow-output'); - following.current = true; + setFollowing(true); phase.current = 'following'; - setIsFollowingOutput(true); target.current = o.readLayoutTarget(); const scroller = o.scrollerRef.current!; const animate = reason === 'jump-to-latest' && resolveAnimatedJumpBehavior({ @@ -152,52 +158,76 @@ export function useFlowChatFollowOutput(options: Options) { data: () => ({ reason, viewportId: o.viewportId, targetPx: target.current }), }); wake(); - }, [available, discardPending, wake]); + }, [available, discardPending, setFollowing, wake]); const trySubmission = useCallback(() => { + // Measuring a placement synchronously notifies the virtualizer, which can + // call follow again before this transaction has consumed its intent. + // Treat that notification as handled; the outer placement reads the final + // geometry after measurement and must remain the only consumer. + if (placingSubmission.current) return true; const o = current.current; if (!o.activeSessionId || !available()) return false; - const wasCancelled = Boolean(pending.current && !isSubmittedMessageScrollIntentPending(pending.current)); - if (wasCancelled) { + placingSubmission.current = true; + let placement: SubmittedMessageScrollIntent | null = null; + try { + const wasCancelled = Boolean(pending.current && !isSubmittedMessageScrollIntentPending(pending.current)); + if (wasCancelled) { + pending.current = null; + submittedTurn.current = null; + o.cancelPendingPlacement(); + } + const intent = claimSubmittedMessageScrollIntent(o.activeSessionId, o.viewportId ?? 0); + if (wasCancelled && !intent) { + // A failed or queued send no longer owns a placement. Do not let the + // former pending-send phase resume tail following on its next signal. + setFollowing(false); + phase.current = 'reading'; + stop(); + o.viewportOwner.release('follow-output'); + } + if (intent && intent !== pending.current) { + if (pending.current) finishSubmittedMessageScrollIntent(pending.current); + pending.current = intent; + phase.current = 'pending-send'; + stop(); + o.cancelNavigation(); + // Submission is a new explicit placement, including from a history view. + o.viewportOwner.release('one-shot-navigation'); + o.viewportOwner.release('user-gesture'); + o.viewportOwner.claim('follow-output'); + setFollowing(true); + } + if (!pending.current) return false; + if (!pending.current.scope.isCurrent()) { exitFollowOutput('session-changed'); return false; } + placement = pending.current; + const placed = o.placeSubmittedMessage(placement); + // A measurement callback may cancel, replace, or move this submission to + // another surface/session. Never resurrect it or consume its replacement. + if (pending.current !== placement || !placement.scope.isCurrent() + || !isSubmittedMessageScrollIntentPending(placement) + || current.current.activeSessionId !== placement.sessionId || !available()) return true; + if (placed === null) return true; + submittedTurn.current = placement.turnId; pending.current = null; - submittedTurn.current = null; - o.cancelPendingPlacement(); - } - const intent = claimSubmittedMessageScrollIntent(o.activeSessionId, o.viewportId ?? 0); - if (wasCancelled && !intent) { - // A failed or queued send no longer owns a placement. Do not let the - // former pending-send phase resume tail following on its next signal. - following.current = false; - phase.current = 'reading'; - setIsFollowingOutput(false); - stop(); - o.viewportOwner.release('follow-output'); - } - if (intent && intent !== pending.current) { - if (pending.current) finishSubmittedMessageScrollIntent(pending.current); - pending.current = intent; - phase.current = 'pending-send'; - stop(); - o.cancelNavigation(); - // Submission is a new explicit placement, including from a history view. - o.viewportOwner.release('one-shot-navigation'); - o.viewportOwner.release('user-gesture'); - o.viewportOwner.claim('follow-output'); - following.current = true; - setIsFollowingOutput(true); + finishSubmittedMessageScrollIntent(placement); + phase.current = 'awaiting-output'; + target.current = placed; + initialized.current = true; + return true; + } catch (error) { + if (placement && pending.current === placement) { + pending.current = null; + finishSubmittedMessageScrollIntent(placement); + phase.current = 'reading'; + setFollowing(false); + current.current.viewportOwner.release('follow-output'); + } + throw error; + } finally { + placingSubmission.current = false; } - if (!pending.current) return false; - if (!pending.current.scope.isCurrent()) { exitFollowOutput('session-changed'); return false; } - const placed = o.placeSubmittedMessage(pending.current); - if (placed === null) return true; - submittedTurn.current = pending.current.turnId; - finishSubmittedMessageScrollIntent(pending.current); - pending.current = null; - phase.current = 'awaiting-output'; - target.current = placed; - initialized.current = true; - return true; - }, [available, exitFollowOutput, stop]); + }, [available, exitFollowOutput, setFollowing, stop]); const scheduleFollowToLatest = useCallback(() => { if (!available()) return; @@ -254,7 +284,13 @@ export function useFlowChatFollowOutput(options: Options) { session.current = options.activeSessionId; initialized.current = false; } - if (!options.isViewportActive) { discardPending(); stop(); return; } + if (!options.isViewportActive) { + // Cancelling a deferred send also ends its pending-send phase. Keeping + // that phase would strand follow on reactivation with no intent to place. + if (phase.current === 'pending-send') exitFollowOutput('viewport-inactive'); + else { discardPending(); stop(); } + return; + } if (trySubmission()) return; if (!initialized.current && options.virtualItemCount > 0 && available()) { initialized.current = true;