Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
Expand All @@ -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(),
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -545,7 +547,9 @@ describe('VirtualMessageList natural scroll contract', () => {
mocks.activeSession.dialogTurns = [{ id: 'turn-2', userMessage: { id: 'message-2' } }];
act(() => root.render(<VirtualMessageList />));
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<HTMLElement>('.virtual-message-list__extent')?.style.minHeight).toBe('1192px');
Expand All @@ -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(<VirtualMessageList />));
const scroller = container.querySelector<HTMLElement>('[data-flowchat-scroller]')!;
const extent = container.querySelector<HTMLElement>('.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.
Expand Down
22 changes: 17 additions & 5 deletions src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -455,6 +455,14 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
const headerElementRef = useRef<HTMLDivElement | null>(null);
const extentElementRef = useRef<HTMLDivElement | null>(null);
const [submissionKey, setSubmissionKey] = useState<string | null>(null);
const submissionKeyRef = useRef<string | null>(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<HTMLElement | null>(null);
const [viewportHeightPx, setViewportHeightPx] = useState(0);
const [viewportWidthPx, setViewportWidthPx] = useState(0);
Expand Down Expand Up @@ -696,20 +704,24 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
if (!getRenderedUserMessageElement(intent.turnId)) {
// Materialize just this stable row before measuring it. Do not traverse
// history or mount a whole Turn to get a user-message anchor.
setSubmissionKey(getVirtualItemStableKey(item));
updateSubmissionKey(getVirtualItemStableKey(item));
return null;
}
virtualizer.cancelAim();
virtualizer.measureRenderedItems();
// Measurement can synchronously deliver cancellation or a replacement.
// Revalidate before the old transaction changes geometry or scroll position.
if (!intent.scope.isCurrent() || !isSubmittedMessageScrollIntentPending(intent)
|| activeSessionIdRef.current !== intent.sessionId || !isViewportActiveRef.current) return null;
const top = resolveTurnTopScrollTop(intent.turnId);
if (top === null) return null;
extentElementRef.current.style.minHeight = `${top + scroller.clientHeight}px`;
if (!viewportOwner.write({ owner: 'follow-output', topPx: top })) return null;
virtualizer.syncViewportOffset(scroller.scrollTop);
captureLeadingExtent(scroller.scrollTop, true);
setSubmissionKey(null);
updateSubmissionKey(null);
return scroller.scrollTop;
}, [captureLeadingExtent, getRenderedUserMessageElement, resolveTurnTopScrollTop, viewportOwner, virtualItems, virtualizer]);
}, [captureLeadingExtent, getRenderedUserMessageElement, resolveTurnTopScrollTop, updateSubmissionKey, viewportOwner, virtualItems, virtualizer]);
const outputTurns = useMemo(() => new Set(virtualItems.filter(item => item.type !== 'user-message').map(item => item.turnId)), [virtualItems]);

const {
Expand All @@ -735,7 +747,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
readLayoutTarget,
placeSubmittedMessage,
hasRenderedOutput: turnId => outputTurns.has(turnId),
cancelPendingPlacement: () => setSubmissionKey(null),
cancelPendingPlacement: () => updateSubmissionKey(null),
cancelNavigation: virtualizer.cancelAim,
isOpeningViewport,
onViewportOffset: publishFollowOffset,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof useFlowChatFollowOutput>;
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<string | null>(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(<Harness />));
// 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(<Harness />); await Promise.resolve(); });
expect(follow!.isFollowingOutputNow()).toBe(false);
const settledRenders = renders;
for (let i = 0; i < 5; i++) act(() => root.render(<Harness />));
expect(renders - settledRenders).toBe(5);
expect(placements).toBe(1);
} finally {
act(() => root.unmount()); host.remove(); scroller.remove();
}
});
Loading
Loading