webui: Improve Chat Messages initial scroll + auto-scroll logic + add lazy loading with transitions to content blocks#20999
Conversation
There was a problem hiding this comment.
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
fadeInViewaction and apply it to chat messages and markdown content blocks. - Simplify assistant message rendering to consistently use
ChatMessageAgenticContentand 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.
|
|
||
| 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; |
There was a problem hiding this comment.
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.
| 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; |
| 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 }); | ||
| } |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| 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(); |
| $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(); | ||
| }; | ||
| }); |
There was a problem hiding this comment.
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).
| $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(); | |
| } | |
| }; |
… 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
… 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
… 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
… 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
… 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
… 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
… 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
Supersedes #19557