🐛 fix(page): stabilize agent editor sync (#15730)

This commit is contained in:
Arvin Xu
2026-06-13 01:36:38 +08:00
committed by GitHub
parent 8ab5ec5364
commit a9141c8ade
3 changed files with 196 additions and 4 deletions
@@ -13,9 +13,13 @@ describe('PageAgentExecutor', () => {
beforeEach(() => {
// Create mock runtime with all methods
mockRuntime = {
applyServerSnapshot: vi.fn(),
editTitle: vi.fn(),
getCurrentDocId: vi.fn(() => 'doc-123'),
getDebugSnapshot: vi.fn(() => ({})),
getPageContent: vi.fn(),
initPage: vi.fn(),
isReady: vi.fn(() => true),
modifyNodes: vi.fn(),
replaceText: vi.fn(),
} as unknown as EditorRuntime;
@@ -299,6 +303,42 @@ describe('PageAgentExecutor', () => {
});
});
describe('onAfterCall', () => {
it('should ignore read-only getPageContent state without a document snapshot', async () => {
await executor.onAfterCall({
result: {
state: {
documentId: 'doc-123',
markdown: '# Title\n\nContent',
metadata: { title: 'Title' },
xml: '<h1 id="1">Title</h1>',
},
success: true,
},
} as any);
expect(mockRuntime.applyServerSnapshot).not.toHaveBeenCalled();
});
it('should apply server snapshots when a mutating tool returns document content', async () => {
await executor.onAfterCall({
result: {
state: {
documentContent: '# Updated',
documentId: 'doc-123',
},
success: true,
},
} as any);
expect(mockRuntime.applyServerSnapshot).toHaveBeenCalledWith({
content: '# Updated',
editorData: undefined,
title: undefined,
});
});
});
describe('invoke method', () => {
it('should invoke the correct method based on apiName', async () => {
vi.mocked(mockRuntime.editTitle).mockResolvedValue({
@@ -163,6 +163,10 @@ class PageAgentExecutor extends BaseExecutor<typeof PageAgentApiName> {
state.documentEditorData && typeof state.documentEditorData === 'object'
? (state.documentEditorData as Record<string, unknown>)
: undefined;
const hasDocumentSnapshot =
typeof content === 'string' || typeof title === 'string' || !!editorData;
if (!hasDocumentSnapshot) return;
// Only push into the live editor when this runtime is bound to the same
// document the server just wrote. Otherwise the snapshot would overwrite
+152 -4
View File
@@ -3,8 +3,8 @@
import { DEFAULT_BLOCK_ANCHOR_PADDING, EditorProvider } from '@lobehub/editor/react';
import { Flexbox } from '@lobehub/ui';
import { createStyles, cssVar } from 'antd-style';
import type { CSSProperties, FC, ReactNode } from 'react';
import { memo } from 'react';
import type { CSSProperties, FC, ReactNode, UIEvent } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { CONVERSATION_MIN_WIDTH } from '@/const/layoutTokens';
import DiffAllToolbar from '@/features/EditorCanvas/DiffAllToolbar';
@@ -39,15 +39,37 @@ type PageEditorHeader = ReactNode | null;
const WIDE_SCREEN_CONTAINER_PADDING = 16;
const TABLE_BASE_BLEED = DEFAULT_BLOCK_ANCHOR_PADDING + WIDE_SCREEN_CONTAINER_PADDING;
const getMaxScrollTop = (node: HTMLElement) => Math.max(node.scrollHeight - node.clientHeight, 0);
const shouldRestoreEditorScroll = ({
isUserInteractingWithEditor,
maxScrollTop,
nextScrollTop,
previousScrollTop,
}: {
isUserInteractingWithEditor: boolean;
maxScrollTop: number;
nextScrollTop: number;
previousScrollTop: number;
}) =>
previousScrollTop > 0 &&
nextScrollTop === 0 &&
maxScrollTop >= previousScrollTop &&
!isUserInteractingWithEditor;
const styles = StyleSheet.create({
contentWrapper: {
containerType: 'inline-size',
display: 'flex',
flex: 1,
minHeight: 0,
overflowY: 'auto',
position: 'relative',
},
editorContainer: {
minHeight: 0,
minWidth: 0,
overflow: 'hidden',
position: 'relative',
},
editorContent: {
@@ -111,6 +133,113 @@ const PageEditorCanvas = memo<PageEditorCanvasProps>(({ header, fullWidthHeader
...styles.editorContent,
'--lobe-pageeditor-table-bleed-inline': tableBleedInline,
} as CSSProperties;
const resizeFrameRef = useRef<number | undefined>(undefined);
const restoreScrollFrameRef = useRef<number | undefined>(undefined);
const isRestoringScrollRef = useRef(false);
const isPointerInsideEditorPaneRef = useRef(false);
const lastEditorScrollTopRef = useRef(0);
const editorPaneRef = useRef<HTMLDivElement>(null);
const contentWrapperRef = useRef<HTMLDivElement>(null);
const isUserInteractingWithEditor = useCallback(() => {
if (isPointerInsideEditorPaneRef.current) return true;
const activeElement = document.activeElement;
return !!activeElement && !!editorPaneRef.current?.contains(activeElement);
}, []);
const restoreEditorScrollPosition = useCallback(() => {
const node = contentWrapperRef.current;
if (!node || typeof window === 'undefined') return;
const maxScrollTop = getMaxScrollTop(node);
const targetScrollTop = Math.min(lastEditorScrollTopRef.current, maxScrollTop);
if (targetScrollTop <= 0 || node.scrollTop === targetScrollTop) return;
isRestoringScrollRef.current = true;
node.scrollTop = targetScrollTop;
window.requestAnimationFrame(() => {
isRestoringScrollRef.current = false;
});
}, []);
const scheduleRestoreEditorScrollPosition = useCallback(() => {
if (typeof window === 'undefined') return;
if (restoreScrollFrameRef.current) {
window.cancelAnimationFrame(restoreScrollFrameRef.current);
}
restoreScrollFrameRef.current = window.requestAnimationFrame(() => {
restoreScrollFrameRef.current = undefined;
restoreEditorScrollPosition();
});
}, [restoreEditorScrollPosition]);
const handleEditorScroll = useCallback(
(event: UIEvent<HTMLDivElement>) => {
if (isRestoringScrollRef.current) return;
const node = event.currentTarget;
const nextScrollTop = node.scrollTop;
const previousScrollTop = lastEditorScrollTopRef.current;
if (
shouldRestoreEditorScroll({
isUserInteractingWithEditor: isUserInteractingWithEditor(),
maxScrollTop: getMaxScrollTop(node),
nextScrollTop,
previousScrollTop,
})
) {
scheduleRestoreEditorScrollPosition();
return;
}
lastEditorScrollTopRef.current = nextScrollTop;
},
[isUserInteractingWithEditor, scheduleRestoreEditorScrollPosition],
);
const notifyEditorLayoutChange = useCallback(() => {
if (typeof window === 'undefined') return;
if (resizeFrameRef.current) {
window.cancelAnimationFrame(resizeFrameRef.current);
}
resizeFrameRef.current = window.requestAnimationFrame(() => {
resizeFrameRef.current = undefined;
window.dispatchEvent(new Event('resize'));
scheduleRestoreEditorScrollPosition();
});
}, [scheduleRestoreEditorScrollPosition]);
useEffect(() => {
const node = editorPaneRef.current;
if (!node || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => notifyEditorLayoutChange());
observer.observe(node);
return () => {
observer.disconnect();
};
}, [notifyEditorLayoutChange]);
useEffect(
() => () => {
if (resizeFrameRef.current && typeof window !== 'undefined') {
window.cancelAnimationFrame(resizeFrameRef.current);
}
if (restoreScrollFrameRef.current && typeof window !== 'undefined') {
window.cancelAnimationFrame(restoreScrollFrameRef.current);
}
},
[],
);
// Register Files scope and save document hotkey
useRegisterFilesHotkeys();
@@ -118,11 +247,30 @@ const PageEditorCanvas = memo<PageEditorCanvasProps>(({ header, fullWidthHeader
const headerSlot = header === undefined ? <Header /> : header;
const editorPane = (
<Flexbox flex={1} height={'100%'} style={styles.editorContainer}>
<Flexbox
flex={1}
height={'100%'}
ref={editorPaneRef}
style={styles.editorContainer}
onPointerEnter={() => {
isPointerInsideEditorPaneRef.current = true;
}}
onPointerLeave={() => {
isPointerInsideEditorPaneRef.current = false;
}}
>
{!fullWidthHeader && headerSlot}
<Flexbox horizontal height={'100%'} style={styles.contentWrapper} width={'100%'}>
<Flexbox
horizontal
height={'100%'}
ref={contentWrapperRef}
style={styles.contentWrapper}
width={'100%'}
onScroll={handleEditorScroll}
>
<WideScreenContainer
wrapperStyle={{ cursor: canEdit ? 'text' : 'not-allowed' }}
onChange={notifyEditorLayoutChange}
onClick={() => {
if (!canEdit) return;