mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-12 23:56:36 +08:00
feat(core): auto scroll for chat panel (#14748)
#### PR Dependency Tree * **PR #14748** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced auto-scroll behavior for AI chat messages—the chat now intelligently pauses auto-scrolling when you manually scroll away and resumes when you scroll back near the bottom. * Auto-scroll now pauses when expanding or collapsing AI tool results and document edits. * **Tests** * Added unit tests for AI chat message scroll behavior and interactions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
+46
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+69
-11
@@ -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) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user