Skip to content

webui: Improve Chat Messages initial scroll + auto-scroll logic + add lazy loading with transitions to content blocks#20999

Merged
allozaur merged 4 commits into
ggml-org:masterfrom
allozaur:allozaur/markdown-lazy-loading
Mar 27, 2026
Merged

webui: Improve Chat Messages initial scroll + auto-scroll logic + add lazy loading with transitions to content blocks#20999
allozaur merged 4 commits into
ggml-org:masterfrom
allozaur:allozaur/markdown-lazy-loading

Conversation

@allozaur

@allozaur allozaur commented Mar 25, 2026

Copy link
Copy Markdown
Contributor

Supersedes #19557

  • Simplifies rendering Assistant Message by using Agentic Content as default component
  • Improves initial scroll for chat conversation view
  • Improves auto-scroll logic
  • Adds fade in transition action + applies it to Chat Messages
  • Lazy-load messages content as they are intersecting with the viewport

@ggerganov ggerganov requested review from Copilot and removed request for a team March 27, 2026 15:30

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the WebUI chat experience by changing the conversation layout to support column-reverse scrolling, improving auto-scroll behavior, adding a viewport-based fade-in action, and simplifying assistant message rendering to use the agentic content renderer by default.

Changes:

  • Update auto-scroll controller to support column-reverse containers and add MutationObserver-based scrolling.
  • Introduce a fadeInView action and apply it to chat messages and markdown content blocks.
  • Simplify assistant message rendering to consistently use ChatMessageAgenticContent and remove the old initial scroll delay constant.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts Adds column-reverse support and MutationObserver-driven auto-scroll.
tools/server/webui/src/lib/constants/auto-scroll.ts Removes INITIAL_SCROLL_DELAY constant.
tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte Applies fade-in action to rendered markdown blocks and adjusts block wrapper styling.
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte Switches chat scroll container to flex-col-reverse and updates auto-scroll initialization/observing.
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte Wraps each message with fade-in action and tweaks container sizing.
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte Uses ChatMessageAgenticContent by default (removes Markdown-only path).
tools/server/webui/src/lib/actions/fade-in-view.svelte.ts New action to fade elements in when they enter the viewport.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +76 to +86

let distanceFromBottom: number;
let isScrollingUp: boolean;

if (this._isColumnReverse) {
// column-reverse: scrollTop=0 at bottom, negative when scrolled up
distanceFromBottom = Math.abs(scrollTop);
isScrollingUp = scrollTop < this._lastScrollTop;
} else {
// normal: scrollTop=0 at top, increases when scrolled down
distanceFromBottom = scrollHeight - clientHeight - scrollTop;

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

column-reverse scroll handling assumes scrollTop is 0 at bottom and becomes negative when scrolling up. In practice, browsers implement reversed flex scrolling differently (some report positive scrollTop with bottom at maxScrollTop). With the current logic, isAtBottom/isScrollingUp can be wrong, breaking auto-scroll pause/resume on common browsers. Consider normalizing reversed scrolling by computing maxScrollTop = scrollHeight - clientHeight and handling both positive and negative scrollTop models when computing distance-from-bottom and scroll direction.

Suggested change
let distanceFromBottom: number;
let isScrollingUp: boolean;
if (this._isColumnReverse) {
// column-reverse: scrollTop=0 at bottom, negative when scrolled up
distanceFromBottom = Math.abs(scrollTop);
isScrollingUp = scrollTop < this._lastScrollTop;
} else {
// normal: scrollTop=0 at top, increases when scrolled down
distanceFromBottom = scrollHeight - clientHeight - scrollTop;
const maxScrollTop = scrollHeight - clientHeight;
let distanceFromBottom: number;
let isScrollingUp: boolean;
if (this._isColumnReverse) {
// column-reverse containers can behave differently across browsers:
// - Some report scrollTop=0 at the *bottom* and negative values when scrolled up.
// - Others use the standard model where scrollTop is positive and the bottom is at maxScrollTop.
if (scrollTop <= 0) {
// Model 1: bottom at 0, negative when scrolling up
distanceFromBottom = Math.abs(scrollTop);
isScrollingUp = scrollTop < this._lastScrollTop;
} else {
// Model 2: standard positive scrollTop, bottom at maxScrollTop
distanceFromBottom = Math.max(0, maxScrollTop - scrollTop);
isScrollingUp = scrollTop < this._lastScrollTop;
}
} else {
// normal: scrollTop=0 at top, increases when scrolled down
distanceFromBottom = maxScrollTop - scrollTop;

Copilot uses AI. Check for mistakes.
Comment on lines 117 to +125
scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {
if (this._disabled || !this._container) return;

this._container.scrollTo({
top: this._container.scrollHeight,
behavior
});
if (this._isColumnReverse) {
// column-reverse: scrollTop=0 is the bottom
this._container.scrollTo({ top: 0, behavior });
} else {
this._container.scrollTo({ top: this._container.scrollHeight, behavior });
}

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For column-reverse, scrollToBottom() always scrolls to top: 0. This only works in the “negative scrollTop” model; in browsers where the bottom corresponds to scrollTop === (scrollHeight - clientHeight), this will jump to the wrong place. Recommend scrolling to the computed bottom value for reversed containers (based on the detected scrollTop model) rather than hard-coding 0.

Copilot uses AI. Check for mistakes.
Comment on lines +210 to +226
private _doStartObserving(): void {
if (!this._container || this._mutationObserver) return;

const isReverse = this._isColumnReverse;

this._mutationObserver = new MutationObserver(() => {
if (!this._autoScrollEnabled || this._rafPending) return;
this._rafPending = true;
requestAnimationFrame(() => {
this._rafPending = false;
if (this._autoScrollEnabled && this._container) {
if (isReverse) {
// column-reverse: scrollTop=0 is the bottom
this._container.scrollTop = 0;
} else {
this._container.scrollTop = this._container.scrollHeight;
}

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MutationObserver auto-scroll also hard-codes scrollTop = 0 for column-reverse. If the browser uses the positive scrollTop model for reversed flex containers, this will scroll to the wrong end during streaming and effectively disable the intended behavior. Suggest reusing the same “scroll to bottom” normalization logic here instead of branching to a fixed 0.

Suggested change
private _doStartObserving(): void {
if (!this._container || this._mutationObserver) return;
const isReverse = this._isColumnReverse;
this._mutationObserver = new MutationObserver(() => {
if (!this._autoScrollEnabled || this._rafPending) return;
this._rafPending = true;
requestAnimationFrame(() => {
this._rafPending = false;
if (this._autoScrollEnabled && this._container) {
if (isReverse) {
// column-reverse: scrollTop=0 is the bottom
this._container.scrollTop = 0;
} else {
this._container.scrollTop = this._container.scrollHeight;
}
/**
* Scrolls the container to the logical "bottom" in a layout-agnostic way.
*
* Uses the maximum valid scrollTop (scrollHeight - clientHeight), which corresponds
* to the bottom of the content in both normal and column-reverse layouts under
* the positive scrollTop model.
*/
private _scrollToBottom(): void {
if (!this._container) return;
const el = this._container;
const maxScrollTop = el.scrollHeight - el.clientHeight;
el.scrollTop = maxScrollTop > 0 ? maxScrollTop : 0;
}
private _doStartObserving(): void {
if (!this._container || this._mutationObserver) return;
this._mutationObserver = new MutationObserver(() => {
if (!this._autoScrollEnabled || this._rafPending) return;
this._rafPending = true;
requestAnimationFrame(() => {
this._rafPending = false;
if (this._autoScrollEnabled && this._container) {
this._scrollToBottom();

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +53
$effect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
requestAnimationFrame(() => {
node.style.opacity = '1';
node.style.transform = 'translateY(0)';
});
observer.disconnect();
}
}
},
{ threshold: 0.05 }
);

observer.observe(node);

return () => {
observer.disconnect();
};
});

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The action doesn’t return a destroy() handler, and it relies on a $effect for cleanup. If the node is removed before it ever intersects (or if the action is re-used in a list where nodes are frequently mounted/unmounted), the observer can remain active until the parent component is destroyed. Prefer implementing the action lifecycle directly: create the IntersectionObserver immediately, call observe(node), and return { destroy() { observer.disconnect(); } } (and optionally clear the inline styles).

Suggested change
$effect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
requestAnimationFrame(() => {
node.style.opacity = '1';
node.style.transform = 'translateY(0)';
});
observer.disconnect();
}
}
},
{ threshold: 0.05 }
);
observer.observe(node);
return () => {
observer.disconnect();
};
});
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
requestAnimationFrame(() => {
node.style.opacity = '1';
node.style.transform = 'translateY(0)';
});
observer.disconnect();
}
}
},
{ threshold: 0.05 }
);
observer.observe(node);
return {
destroy() {
observer.disconnect();
}
};

Copilot uses AI. Check for mistakes.
@allozaur allozaur merged commit e6f6770 into ggml-org:master Mar 27, 2026
10 checks passed
@allozaur allozaur deleted the allozaur/markdown-lazy-loading branch March 28, 2026 16:08
slartibardfast pushed a commit to slartibardfast/llama.cpp that referenced this pull request Apr 12, 2026
… lazy loading with transitions to content blocks (ggml-org#20999)

* refactor: Always use agentic content renderer for Assistant Message

* feat: Improve initial scroll + auto-scroll logic + implement fade in action for content blocks

* chore: update webui build output
Seunghhon pushed a commit to Seunghhon/llama.cpp that referenced this pull request Apr 26, 2026
… lazy loading with transitions to content blocks (ggml-org#20999)

* refactor: Always use agentic content renderer for Assistant Message

* feat: Improve initial scroll + auto-scroll logic + implement fade in action for content blocks

* chore: update webui build output
rsenthilkumar6 pushed a commit to rsenthilkumar6/llama.cpp that referenced this pull request May 1, 2026
… lazy loading with transitions to content blocks (ggml-org#20999)

* refactor: Always use agentic content renderer for Assistant Message

* feat: Improve initial scroll + auto-scroll logic + implement fade in action for content blocks

* chore: update webui build output
ljubomirj pushed a commit to ljubomirj/llama.cpp that referenced this pull request May 6, 2026
… lazy loading with transitions to content blocks (ggml-org#20999)

* refactor: Always use agentic content renderer for Assistant Message

* feat: Improve initial scroll + auto-scroll logic + implement fade in action for content blocks

* chore: update webui build output
my-other-github-account pushed a commit to my-other-github-account/llama.cpp that referenced this pull request May 15, 2026
… lazy loading with transitions to content blocks (ggml-org#20999)

* refactor: Always use agentic content renderer for Assistant Message

* feat: Improve initial scroll + auto-scroll logic + implement fade in action for content blocks

* chore: update webui build output
my-other-github-account pushed a commit to my-other-github-account/llama.cpp that referenced this pull request May 15, 2026
… lazy loading with transitions to content blocks (ggml-org#20999)

* refactor: Always use agentic content renderer for Assistant Message

* feat: Improve initial scroll + auto-scroll logic + implement fade in action for content blocks

* chore: update webui build output
fewtarius pushed a commit to fewtarius/llama.cpp that referenced this pull request May 30, 2026
… lazy loading with transitions to content blocks (ggml-org#20999)

* refactor: Always use agentic content renderer for Assistant Message

* feat: Improve initial scroll + auto-scroll logic + implement fade in action for content blocks

* chore: update webui build output
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants