diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.spec.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.spec.ts index cbc4700776..c3f7e819cd 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.spec.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.spec.ts @@ -47,4 +47,50 @@ describe('AIChatMessages scrolling', () => { expect(scrollTo).toHaveBeenCalledWith({ top: 128 }); }); + + test('pauses auto scroll when user scrolls away from the bottom', () => { + const element = { + canScrollDown: false, + scrollTop: 120, + _autoScrollEnabled: true, + _lastObservedScrollTop: 300, + _getDistanceFromBottom: vi.fn(() => 260), + } as unknown as AIChatMessages; + + (AIChatMessages.prototype as any)._onScroll.call(element); + + expect(element.canScrollDown).toBe(true); + expect((element as any)._autoScrollEnabled).toBe(false); + expect((element as any)._lastObservedScrollTop).toBe(120); + }); + + test('resumes auto scroll when user returns to the bottom', () => { + const element = { + canScrollDown: true, + scrollTop: 420, + _autoScrollEnabled: false, + _lastObservedScrollTop: 120, + _getDistanceFromBottom: vi.fn(() => 8), + } as unknown as AIChatMessages; + + (AIChatMessages.prototype as any)._onScroll.call(element); + + expect(element.canScrollDown).toBe(false); + expect((element as any)._autoScrollEnabled).toBe(true); + }); + + test('restores auto scroll when clicking the down indicator', () => { + const scrollToEnd = vi.fn(); + const element = { + canScrollDown: true, + _autoScrollEnabled: false, + scrollToEnd, + } as unknown as AIChatMessages; + + (AIChatMessages.prototype as any)._onDownIndicatorClick.call(element); + + expect((element as any)._autoScrollEnabled).toBe(true); + expect(element.canScrollDown).toBe(false); + expect(scrollToEnd).toHaveBeenCalled(); + }); }); diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.ts index 28d09d1b1a..17cff0c3b8 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/ai-chat-messages.ts @@ -16,7 +16,7 @@ import { css, html, nothing, type PropertyValues } from 'lit'; import { property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { repeat } from 'lit/directives/repeat.js'; -import { debounce } from 'lodash-es'; +import { debounce, throttle } from 'lodash-es'; import { AffineIcon } from '../../_common/icons'; import { type AIError, AIProvider, UnauthorizedError } from '../../provider'; @@ -24,6 +24,11 @@ import { mergeStreamObjects } from '../../utils/stream-objects'; import type { DocDisplayConfig } from '../ai-chat-chips'; import { type ChatContextValue } from '../ai-chat-content/type'; import type { AIReasoningConfig } from '../ai-chat-input'; +import { + AI_CHAT_AUTO_SCROLL_PAUSE_EVENT, + AI_CHAT_AUTO_SCROLL_RESUME_THRESHOLD, + AI_CHAT_SCROLL_DOWN_INDICATOR_THRESHOLD, +} from './auto-scroll'; import { AIPreloadConfig } from './preload-config'; import { type HistoryMessage, @@ -218,6 +223,10 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) { }) accessor testId = 'chat-panel-messages'; + private _autoScrollEnabled = true; + + private _lastObservedScrollTop = 0; + private get _isReasoningActive() { return !!this.reasoningConfig.enabled.value; } @@ -243,20 +252,49 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) { `; } - private readonly _onScroll = () => { - const { clientHeight, scrollTop, scrollHeight } = this; - this.canScrollDown = scrollHeight - scrollTop - clientHeight > 200; - }; + private _onScroll() { + const { scrollTop } = this; + const distanceFromBottom = this._getDistanceFromBottom(); + + this.canScrollDown = + distanceFromBottom > AI_CHAT_SCROLL_DOWN_INDICATOR_THRESHOLD; + + if (distanceFromBottom <= AI_CHAT_AUTO_SCROLL_RESUME_THRESHOLD) { + this._autoScrollEnabled = true; + } else if (scrollTop < this._lastObservedScrollTop) { + this._autoScrollEnabled = false; + } + + this._lastObservedScrollTop = scrollTop; + } private readonly _debouncedOnScroll = debounce( this._onScroll.bind(this), 100 ); - private readonly _onDownIndicatorClick = () => { + private readonly _throttledScrollToEnd = throttle(() => { + if (!this._autoScrollEnabled) { + return; + } + this.scrollToEnd(); + }, 200); + + private _onDownIndicatorClick() { + this._autoScrollEnabled = true; this.canScrollDown = false; this.scrollToEnd(); - }; + } + + private _pauseAutoScroll() { + this._autoScrollEnabled = false; + requestAnimationFrame(() => this._onScroll()); + } + + private _getDistanceFromBottom() { + const { clientHeight, scrollTop, scrollHeight } = this; + return scrollHeight - scrollTop - clientHeight; + } protected override render() { const { status, error } = this.chatContextValue; @@ -392,19 +430,39 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) { // Add scroll event listener to the host element this.addEventListener('scroll', this._debouncedOnScroll); + this.addEventListener( + AI_CHAT_AUTO_SCROLL_PAUSE_EVENT, + this._pauseAutoScroll as EventListener + ); disposables.add(() => { this.removeEventListener('scroll', this._debouncedOnScroll); + this.removeEventListener( + AI_CHAT_AUTO_SCROLL_PAUSE_EVENT, + this._pauseAutoScroll as EventListener + ); }); } - protected override updated(_changedProperties: PropertyValues) { - if (_changedProperties.has('isHistoryLoading')) { + protected override updated(changedProperties: PropertyValues) { + if (changedProperties.has('isHistoryLoading')) { this.canScrollDown = false; + this._autoScrollEnabled = true; + } + + if (changedProperties.has('messages')) { + this._onScroll(); + + if (this.chatContextValue.status === 'transmitting') { + this._throttledScrollToEnd(); + } else if (this._autoScrollEnabled) { + this.scrollToEnd(); + } } if ( - _changedProperties.has('chatContextValue') && - this.chatContextValue.status === 'transmitting' + changedProperties.has('chatContextValue') && + (this.chatContextValue.status === 'success' || + this.chatContextValue.status === 'error') ) { this._onScroll(); } diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/auto-scroll.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/auto-scroll.ts new file mode 100644 index 0000000000..ed8f1e9cd2 --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-messages/auto-scroll.ts @@ -0,0 +1,6 @@ +export const AI_CHAT_AUTO_SCROLL_PAUSE_EVENT = + 'affine-ai-chat-auto-scroll-pause'; + +export const AI_CHAT_AUTO_SCROLL_RESUME_THRESHOLD = 32; + +export const AI_CHAT_SCROLL_DOWN_INDICATOR_THRESHOLD = 200; diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-edit.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-edit.ts index d222a0095d..f66a96344a 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-edit.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/doc-edit.ts @@ -21,6 +21,7 @@ import { AIProvider } from '../../provider'; import { BlockDiffProvider } from '../../services/block-diff'; import { diffMarkdown } from '../../utils/apply-model/markdown-diff'; import { copyText } from '../../utils/editor-actions'; +import { AI_CHAT_AUTO_SCROLL_PAUSE_EVENT } from '../ai-chat-messages/auto-scroll'; import type { ToolError } from './type'; interface DocEditToolCall { @@ -322,6 +323,12 @@ export class DocEditTool extends WithDisposable(ShadowlessElement) { } private async _toggleCollapse() { + this.dispatchEvent( + new CustomEvent(AI_CHAT_AUTO_SCROLL_PAUSE_EVENT, { + bubbles: true, + composed: true, + }) + ); this.isCollapsed = !this.isCollapsed; } diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-card.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-card.ts index dd35489f06..e8f820ccd2 100644 --- a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-card.ts +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/tool-result-card.ts @@ -8,6 +8,8 @@ import { css, html, nothing, type TemplateResult } from 'lit'; import { property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; +import { AI_CHAT_AUTO_SCROLL_PAUSE_EVENT } from '../ai-chat-messages/auto-scroll'; + export interface ToolResult { title: string | TemplateResult<1>; icon?: string | TemplateResult<1>; @@ -352,6 +354,12 @@ export class ToolResultCard extends SignalWatcher( } private toggleCard() { + this.dispatchEvent( + new CustomEvent(AI_CHAT_AUTO_SCROLL_PAUSE_EVENT, { + bubbles: true, + composed: true, + }) + ); this.isCollapsed = !this.isCollapsed; } }