webui: Improve Chat Messages initial scroll + auto-scroll logic + add lazy loading with transitions to content blocks (#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
This commit is contained in:
Aleksander Grygier
2026-03-27 17:01:36 +01:00
committed by GitHub
parent 48cda24c11
commit e6f6770515
8 changed files with 220 additions and 85 deletions
Binary file not shown.
@@ -0,0 +1,54 @@
/**
* Svelte action that fades in an element when it enters the viewport.
* Uses IntersectionObserver for efficient viewport detection.
*
* If skipIfVisible is set and the element is already visible in the viewport
* when the action attaches (e.g. a markdown block promoted from unstable
* during streaming), the fade is skipped entirely to avoid a flash.
*/
export function fadeInView(
node: HTMLElement,
options: { duration?: number; y?: number; skipIfVisible?: boolean } = {}
) {
const { duration = 300, y = 0, skipIfVisible = false } = options;
if (skipIfVisible) {
const rect = node.getBoundingClientRect();
const isAlreadyVisible =
rect.top < window.innerHeight &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.right > 0;
if (isAlreadyVisible) {
return;
}
}
node.style.opacity = '0';
node.style.transform = `translateY(${y}px)`;
node.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`;
$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();
};
});
}
@@ -3,14 +3,12 @@
ChatMessageAgenticContent, ChatMessageAgenticContent,
ChatMessageActions, ChatMessageActions,
ChatMessageStatistics, ChatMessageStatistics,
MarkdownContent,
ModelBadge, ModelBadge,
ModelsSelector ModelsSelector
} from '$lib/components/app'; } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts'; import { getMessageEditContext } from '$lib/contexts';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte'; import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils'; import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
import { tick } from 'svelte'; import { tick } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -87,13 +85,7 @@
const hasAgenticMarkers = $derived( const hasAgenticMarkers = $derived(
messageContent?.includes(AGENTIC_TAGS.TOOL_CALL_START) ?? false messageContent?.includes(AGENTIC_TAGS.TOOL_CALL_START) ?? false
); );
const hasStreamingToolCall = $derived(
isChatStreaming() && agenticStreamingToolCall(message.convId) !== null
);
const hasReasoningMarkers = $derived(messageContent?.includes(REASONING_TAGS.START) ?? false); const hasReasoningMarkers = $derived(messageContent?.includes(REASONING_TAGS.START) ?? false);
const isStructuredContent = $derived(
hasAgenticMarkers || hasReasoningMarkers || hasStreamingToolCall
);
const processingState = useProcessingState(); const processingState = useProcessingState();
let currentConfig = $derived(config()); let currentConfig = $derived(config());
@@ -256,15 +248,13 @@
{:else if message.role === MessageRole.ASSISTANT} {:else if message.role === MessageRole.ASSISTANT}
{#if showRawOutput} {#if showRawOutput}
<pre class="raw-output">{messageContent || ''}</pre> <pre class="raw-output">{messageContent || ''}</pre>
{:else if isStructuredContent} {:else}
<ChatMessageAgenticContent <ChatMessageAgenticContent
content={messageContent || ''} content={messageContent || ''}
isStreaming={isChatStreaming()} isStreaming={isChatStreaming()}
highlightTurns={highlightAgenticTurns} highlightTurns={highlightAgenticTurns}
{message} {message}
/> />
{:else}
<MarkdownContent content={messageContent || ''} attachments={message.extra} />
{/if} {/if}
{:else} {:else}
<div class="text-sm whitespace-pre-wrap"> <div class="text-sm whitespace-pre-wrap">
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import { ChatMessage } from '$lib/components/app'; import { ChatMessage } from '$lib/components/app';
import { setChatActionsContext } from '$lib/contexts'; import { setChatActionsContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums'; import { MessageRole } from '$lib/enums';
@@ -140,13 +141,18 @@
}); });
</script> </script>
<div class="flex h-full flex-col space-y-10 pt-24 {className}" style="height: auto; "> <div
class="flex h-full flex-col space-y-10 pt-24 {className}"
style="height: auto; min-height: calc(100dvh - 14rem);"
>
{#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)} {#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)}
<ChatMessage <div use:fadeInView>
class="mx-auto w-full max-w-[48rem]" <ChatMessage
{message} class="mx-auto w-full max-w-[48rem]"
{isLastAssistantMessage} {message}
{siblingInfo} {isLastAssistantMessage}
/> {siblingInfo}
/>
</div>
{/each} {/each}
</div> </div>
@@ -12,7 +12,6 @@
} from '$lib/components/app'; } from '$lib/components/app';
import * as Alert from '$lib/components/ui/alert'; import * as Alert from '$lib/components/ui/alert';
import * as AlertDialog from '$lib/components/ui/alert-dialog'; import * as AlertDialog from '$lib/components/ui/alert-dialog';
import { INITIAL_SCROLL_DELAY } from '$lib/constants';
import { KeyboardKey } from '$lib/enums'; import { KeyboardKey } from '$lib/enums';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte'; import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import { import {
@@ -48,7 +47,7 @@
let showFileErrorDialog = $state(false); let showFileErrorDialog = $state(false);
let uploadedFiles = $state<ChatUploadedFile[]>([]); let uploadedFiles = $state<ChatUploadedFile[]>([]);
const autoScroll = createAutoScrollController(); const autoScroll = createAutoScrollController({ isColumnReverse: true });
let fileErrorData = $state<{ let fileErrorData = $state<{
generallyUnsupported: File[]; generallyUnsupported: File[];
@@ -310,13 +309,15 @@
afterNavigate(() => { afterNavigate(() => {
if (!disableAutoScroll) { if (!disableAutoScroll) {
setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY); autoScroll.enable();
} }
}); });
onMount(() => { onMount(() => {
autoScroll.startObserving();
if (!disableAutoScroll) { if (!disableAutoScroll) {
setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY); autoScroll.enable();
} }
const pendingDraft = chatStore.consumePendingDraft(); const pendingDraft = chatStore.consumePendingDraft();
@@ -333,10 +334,6 @@
$effect(() => { $effect(() => {
autoScroll.setDisabled(disableAutoScroll); autoScroll.setDisabled(disableAutoScroll);
}); });
$effect(() => {
autoScroll.updateInterval(isCurrentConversationLoading);
});
</script> </script>
{#if isDragOver} {#if isDragOver}
@@ -351,7 +348,7 @@
<div <div
bind:this={chatScrollContainer} bind:this={chatScrollContainer}
aria-label="Chat interface with file drop zone" aria-label="Chat interface with file drop zone"
class="flex h-full flex-col overflow-y-auto px-4 md:px-6" class="flex h-full flex-col-reverse overflow-y-auto px-4 md:px-6"
ondragenter={handleDragEnter} ondragenter={handleDragEnter}
ondragleave={handleDragLeave} ondragleave={handleDragLeave}
ondragover={handleDragOver} ondragover={handleDragOver}
@@ -359,57 +356,59 @@
onscroll={handleScroll} onscroll={handleScroll}
role="main" role="main"
> >
<ChatMessages <div class="flex flex-col">
class="mb-16 md:mb-24" <ChatMessages
messages={activeMessages()} class="mb-16 md:mb-24"
onUserAction={() => { messages={activeMessages()}
autoScroll.enable(); onUserAction={() => {
autoScroll.scrollToBottom(); autoScroll.enable();
}} autoScroll.scrollToBottom();
/> }}
/>
<div <div
class="pointer-events-none sticky right-0 bottom-4 left-0 mt-auto" class="pointer-events-none sticky right-0 bottom-4 left-0 mt-auto"
in:slide={{ duration: 150, axis: 'y' }} in:slide={{ duration: 150, axis: 'y' }}
> >
<ChatScreenProcessingInfo /> <ChatScreenProcessingInfo />
{#if hasPropsError} {#if hasPropsError}
<div <div
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1" class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
in:fly={{ y: 10, duration: 250 }} in:fly={{ y: 10, duration: 250 }}
> >
<Alert.Root variant="destructive"> <Alert.Root variant="destructive">
<AlertTriangle class="h-4 w-4" /> <AlertTriangle class="h-4 w-4" />
<Alert.Title class="flex items-center justify-between"> <Alert.Title class="flex items-center justify-between">
<span>Server unavailable</span> <span>Server unavailable</span>
<button <button
onclick={() => serverStore.fetch()} onclick={() => serverStore.fetch()}
disabled={isServerLoading} disabled={isServerLoading}
class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50" class="flex items-center gap-1.5 rounded-lg bg-destructive/20 px-2 py-1 text-xs font-medium hover:bg-destructive/30 disabled:opacity-50"
> >
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" /> <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
{isServerLoading ? 'Retrying...' : 'Retry'} {isServerLoading ? 'Retrying...' : 'Retry'}
</button> </button>
</Alert.Title> </Alert.Title>
<Alert.Description>{serverError()}</Alert.Description> <Alert.Description>{serverError()}</Alert.Description>
</Alert.Root> </Alert.Root>
</div>
{/if}
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl">
<ChatScreenForm
disabled={hasPropsError || isEditing()}
{initialMessage}
isLoading={isCurrentConversationLoading}
onFileRemove={handleFileRemove}
onFileUpload={handleFileUpload}
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={handleSystemPromptAdd}
showHelperText={false}
bind:uploadedFiles
/>
</div> </div>
{/if}
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl">
<ChatScreenForm
disabled={hasPropsError || isEditing()}
{initialMessage}
isLoading={isCurrentConversationLoading}
onFileRemove={handleFileRemove}
onFileUpload={handleFileUpload}
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={handleSystemPromptAdd}
showHelperText={false}
bind:uploadedFiles
/>
</div> </div>
</div> </div>
</div> </div>
@@ -36,6 +36,7 @@
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte'; import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database'; import type { DatabaseMessageExtra } from '$lib/types/database';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
interface Props { interface Props {
attachments?: DatabaseMessageExtra[]; attachments?: DatabaseMessageExtra[];
@@ -598,7 +599,7 @@
: ''}" : ''}"
> >
{#each renderedBlocks as block (block.id)} {#each renderedBlocks as block (block.id)}
<div class="markdown-block" data-block-id={block.id}> <div class="markdown-block" data-block-id={block.id} use:fadeInView={{ skipIfVisible: true }}>
<!-- eslint-disable-next-line no-at-html-tags --> <!-- eslint-disable-next-line no-at-html-tags -->
{@html block.html} {@html block.html}
</div> </div>
@@ -651,7 +652,6 @@
/> />
<style> <style>
.markdown-block,
.markdown-block--unstable { .markdown-block--unstable {
display: contents; display: contents;
} }
@@ -1,3 +1,2 @@
export const AUTO_SCROLL_INTERVAL = 100; export const AUTO_SCROLL_INTERVAL = 100;
export const INITIAL_SCROLL_DELAY = 50;
export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10; export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;
@@ -1,8 +1,8 @@
import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants'; import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants';
export interface AutoScrollOptions { export interface AutoScrollOptions {
/** Whether auto-scroll is disabled globally (e.g., from settings) */
disabled?: boolean; disabled?: boolean;
isColumnReverse?: boolean;
} }
/** /**
@@ -12,6 +12,7 @@ export interface AutoScrollOptions {
* - Auto-scrolls to bottom during streaming/loading * - Auto-scrolls to bottom during streaming/loading
* - Stops auto-scroll when user manually scrolls up * - Stops auto-scroll when user manually scrolls up
* - Resumes auto-scroll when user scrolls back to bottom * - Resumes auto-scroll when user scrolls back to bottom
* - Supports both normal and column-reverse scroll containers
*/ */
export class AutoScrollController { export class AutoScrollController {
private _autoScrollEnabled = $state(true); private _autoScrollEnabled = $state(true);
@@ -21,9 +22,14 @@ export class AutoScrollController {
private _scrollTimeout: ReturnType<typeof setTimeout> | undefined; private _scrollTimeout: ReturnType<typeof setTimeout> | undefined;
private _container: HTMLElement | undefined; private _container: HTMLElement | undefined;
private _disabled: boolean; private _disabled: boolean;
private _isColumnReverse: boolean;
private _mutationObserver: MutationObserver | null = null;
private _rafPending = false;
private _observerEnabled = false;
constructor(options: AutoScrollOptions = {}) { constructor(options: AutoScrollOptions = {}) {
this._disabled = options.disabled ?? false; this._disabled = options.disabled ?? false;
this._isColumnReverse = options.isColumnReverse ?? false;
} }
get autoScrollEnabled(): boolean { get autoScrollEnabled(): boolean {
@@ -38,7 +44,12 @@ export class AutoScrollController {
* Binds the controller to a scrollable container element. * Binds the controller to a scrollable container element.
*/ */
setContainer(container: HTMLElement | undefined): void { setContainer(container: HTMLElement | undefined): void {
this._doStopObserving();
this._container = container; this._container = container;
if (this._observerEnabled && container && !this._disabled) {
this._doStartObserving();
}
} }
/** /**
@@ -49,6 +60,9 @@ export class AutoScrollController {
if (disabled) { if (disabled) {
this._autoScrollEnabled = false; this._autoScrollEnabled = false;
this.stopInterval(); this.stopInterval();
this._doStopObserving();
} else if (this._observerEnabled && this._container && !this._mutationObserver) {
this._doStartObserving();
} }
} }
@@ -59,10 +73,23 @@ export class AutoScrollController {
if (this._disabled || !this._container) return; if (this._disabled || !this._container) return;
const { scrollTop, scrollHeight, clientHeight } = this._container; const { scrollTop, scrollHeight, clientHeight } = this._container;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
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;
isScrollingUp = scrollTop < this._lastScrollTop;
}
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD; const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
if (scrollTop < this._lastScrollTop && !isAtBottom) { if (isScrollingUp && !isAtBottom) {
this._userScrolledUp = true; this._userScrolledUp = true;
this._autoScrollEnabled = false; this._autoScrollEnabled = false;
} else if (isAtBottom && this._userScrolledUp) { } else if (isAtBottom && this._userScrolledUp) {
@@ -90,10 +117,12 @@ export class AutoScrollController {
scrollToBottom(behavior: ScrollBehavior = 'smooth'): void { scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {
if (this._disabled || !this._container) return; if (this._disabled || !this._container) return;
this._container.scrollTo({ if (this._isColumnReverse) {
top: this._container.scrollHeight, // column-reverse: scrollTop=0 is the bottom
behavior this._container.scrollTo({ top: 0, behavior });
}); } else {
this._container.scrollTo({ top: this._container.scrollHeight, behavior });
}
} }
/** /**
@@ -150,11 +179,69 @@ export class AutoScrollController {
*/ */
destroy(): void { destroy(): void {
this.stopInterval(); this.stopInterval();
this._doStopObserving();
if (this._scrollTimeout) { if (this._scrollTimeout) {
clearTimeout(this._scrollTimeout); clearTimeout(this._scrollTimeout);
this._scrollTimeout = undefined; this._scrollTimeout = undefined;
} }
} }
/**
* Starts a MutationObserver on the container that auto-scrolls to bottom
* on content changes. More responsive than interval-based polling.
*/
startObserving(): void {
this._observerEnabled = true;
if (this._container && !this._disabled && !this._mutationObserver) {
this._doStartObserving();
}
}
/**
* Stops the MutationObserver.
*/
stopObserving(): void {
this._observerEnabled = false;
this._doStopObserving();
}
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;
}
}
});
});
this._mutationObserver.observe(this._container, {
childList: true,
subtree: true,
characterData: true
});
}
private _doStopObserving(): void {
if (this._mutationObserver) {
this._mutationObserver.disconnect();
this._mutationObserver = null;
}
this._rafPending = false;
}
} }
/** /**