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:
DarkSky
2026-03-29 22:17:10 +08:00
committed by GitHub
parent 64f385817d
commit 9ca520a81f
5 changed files with 136 additions and 11 deletions
@@ -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();
});
});
@@ -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;
}
}