diff --git a/packages/builtin-tool-page-agent/src/client/executor/index.test.ts b/packages/builtin-tool-page-agent/src/client/executor/index.test.ts
index 8c8ee41f5a..d885483bbb 100644
--- a/packages/builtin-tool-page-agent/src/client/executor/index.test.ts
+++ b/packages/builtin-tool-page-agent/src/client/executor/index.test.ts
@@ -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: '
Title
',
+ },
+ 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({
diff --git a/packages/builtin-tool-page-agent/src/client/executor/index.ts b/packages/builtin-tool-page-agent/src/client/executor/index.ts
index b2d6f4f1bd..610ccc83b2 100644
--- a/packages/builtin-tool-page-agent/src/client/executor/index.ts
+++ b/packages/builtin-tool-page-agent/src/client/executor/index.ts
@@ -163,6 +163,10 @@ class PageAgentExecutor extends BaseExecutor {
state.documentEditorData && typeof state.documentEditorData === 'object'
? (state.documentEditorData as Record)
: 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
diff --git a/src/features/PageEditor/PageEditor.tsx b/src/features/PageEditor/PageEditor.tsx
index dd3c5241f0..a3420fa026 100644
--- a/src/features/PageEditor/PageEditor.tsx
+++ b/src/features/PageEditor/PageEditor.tsx
@@ -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(({ header, fullWidthHeader
...styles.editorContent,
'--lobe-pageeditor-table-bleed-inline': tableBleedInline,
} as CSSProperties;
+ const resizeFrameRef = useRef(undefined);
+ const restoreScrollFrameRef = useRef(undefined);
+ const isRestoringScrollRef = useRef(false);
+ const isPointerInsideEditorPaneRef = useRef(false);
+ const lastEditorScrollTopRef = useRef(0);
+ const editorPaneRef = useRef(null);
+ const contentWrapperRef = useRef(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) => {
+ 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(({ header, fullWidthHeader
const headerSlot = header === undefined ? : header;
const editorPane = (
-
+ {
+ isPointerInsideEditorPaneRef.current = true;
+ }}
+ onPointerLeave={() => {
+ isPointerInsideEditorPaneRef.current = false;
+ }}
+ >
{!fullWidthHeader && headerSlot}
-
+
{
if (!canEdit) return;