]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: Improve Chat Messages initial scroll + auto-scroll logic + add lazy loading...
authorAleksander Grygier <redacted>
Fri, 27 Mar 2026 16:01:36 +0000 (17:01 +0100)
committerGitHub <redacted>
Fri, 27 Mar 2026 16:01:36 +0000 (17:01 +0100)
* 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

tools/server/public/index.html.gz
tools/server/webui/src/lib/actions/fade-in-view.svelte.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte
tools/server/webui/src/lib/constants/auto-scroll.ts
tools/server/webui/src/lib/hooks/use-auto-scroll.svelte.ts

index adc7939d3b48eb69c0e44a2dd91782f40d51a6d9..f68e50ef300b5987484cb5910b133d7c7b44fa26 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
diff --git a/tools/server/webui/src/lib/actions/fade-in-view.svelte.ts b/tools/server/webui/src/lib/actions/fade-in-view.svelte.ts
new file mode 100644 (file)
index 0000000..5c726b7
--- /dev/null
@@ -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();
+               };
+       });
+}
index 553c3fd946920e24d3bb531ba5579de4c421c9cb..8b07b385fb56b2c49af0b3d2b416354ed64e5644 100644 (file)
@@ -3,14 +3,12 @@
                ChatMessageAgenticContent,
                ChatMessageActions,
                ChatMessageStatistics,
-               MarkdownContent,
                ModelBadge,
                ModelsSelector
        } from '$lib/components/app';
        import { getMessageEditContext } from '$lib/contexts';
        import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
        import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
-       import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
        import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
        import { tick } from 'svelte';
        import { fade } from 'svelte/transition';
        const hasAgenticMarkers = $derived(
                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 isStructuredContent = $derived(
-               hasAgenticMarkers || hasReasoningMarkers || hasStreamingToolCall
-       );
        const processingState = useProcessingState();
 
        let currentConfig = $derived(config());
        {:else if message.role === MessageRole.ASSISTANT}
                {#if showRawOutput}
                        <pre class="raw-output">{messageContent || ''}</pre>
-               {:else if isStructuredContent}
+               {:else}
                        <ChatMessageAgenticContent
                                content={messageContent || ''}
                                isStreaming={isChatStreaming()}
                                highlightTurns={highlightAgenticTurns}
                                {message}
                        />
-               {:else}
-                       <MarkdownContent content={messageContent || ''} attachments={message.extra} />
                {/if}
        {:else}
                <div class="text-sm whitespace-pre-wrap">
index 23143c955cae1d83d6515d22c405799b93fe5b4d..439e8adb38117cc7ef266a6d18f6f1841b1b5b0f 100644 (file)
@@ -1,4 +1,5 @@
 <script lang="ts">
+       import { fadeInView } from '$lib/actions/fade-in-view.svelte';
        import { ChatMessage } from '$lib/components/app';
        import { setChatActionsContext } from '$lib/contexts';
        import { MessageRole } from '$lib/enums';
        });
 </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)}
-               <ChatMessage
-                       class="mx-auto w-full max-w-[48rem]"
-                       {message}
-                       {isLastAssistantMessage}
-                       {siblingInfo}
-               />
+               <div use:fadeInView>
+                       <ChatMessage
+                               class="mx-auto w-full max-w-[48rem]"
+                               {message}
+                               {isLastAssistantMessage}
+                               {siblingInfo}
+                       />
+               </div>
        {/each}
 </div>
index 00ddea464941ae2642af0e9bb0b268e17e34abec..290a277a53d30d6c5aee43ef366aa08376b0bff5 100644 (file)
@@ -12,7 +12,6 @@
        } from '$lib/components/app';
        import * as Alert from '$lib/components/ui/alert';
        import * as AlertDialog from '$lib/components/ui/alert-dialog';
-       import { INITIAL_SCROLL_DELAY } from '$lib/constants';
        import { KeyboardKey } from '$lib/enums';
        import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
        import {
@@ -48,7 +47,7 @@
        let showFileErrorDialog = $state(false);
        let uploadedFiles = $state<ChatUploadedFile[]>([]);
 
-       const autoScroll = createAutoScrollController();
+       const autoScroll = createAutoScrollController({ isColumnReverse: true });
 
        let fileErrorData = $state<{
                generallyUnsupported: File[];
 
        afterNavigate(() => {
                if (!disableAutoScroll) {
-                       setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
+                       autoScroll.enable();
                }
        });
 
        onMount(() => {
+               autoScroll.startObserving();
+
                if (!disableAutoScroll) {
-                       setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
+                       autoScroll.enable();
                }
 
                const pendingDraft = chatStore.consumePendingDraft();
        $effect(() => {
                autoScroll.setDisabled(disableAutoScroll);
        });
-
-       $effect(() => {
-               autoScroll.updateInterval(isCurrentConversationLoading);
-       });
 </script>
 
 {#if isDragOver}
        <div
                bind:this={chatScrollContainer}
                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}
                ondragleave={handleDragLeave}
                ondragover={handleDragOver}
                onscroll={handleScroll}
                role="main"
        >
-               <ChatMessages
-                       class="mb-16 md:mb-24"
-                       messages={activeMessages()}
-                       onUserAction={() => {
-                               autoScroll.enable();
-                               autoScroll.scrollToBottom();
-                       }}
-               />
-
-               <div
-                       class="pointer-events-none sticky right-0 bottom-4 left-0 mt-auto"
-                       in:slide={{ duration: 150, axis: 'y' }}
-               >
-                       <ChatScreenProcessingInfo />
+               <div class="flex flex-col">
+                       <ChatMessages
+                               class="mb-16 md:mb-24"
+                               messages={activeMessages()}
+                               onUserAction={() => {
+                                       autoScroll.enable();
+                                       autoScroll.scrollToBottom();
+                               }}
+                       />
+
+                       <div
+                               class="pointer-events-none sticky right-0 bottom-4 left-0 mt-auto"
+                               in:slide={{ duration: 150, axis: 'y' }}
+                       >
+                               <ChatScreenProcessingInfo />
+
+                               {#if hasPropsError}
+                                       <div
+                                               class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
+                                               in:fly={{ y: 10, duration: 250 }}
+                                       >
+                                               <Alert.Root variant="destructive">
+                                                       <AlertTriangle class="h-4 w-4" />
+                                                       <Alert.Title class="flex items-center justify-between">
+                                                               <span>Server unavailable</span>
+                                                               <button
+                                                                       onclick={() => serverStore.fetch()}
+                                                                       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"
+                                                               >
+                                                                       <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
+                                                                       {isServerLoading ? 'Retrying...' : 'Retry'}
+                                                               </button>
+                                                       </Alert.Title>
+                                                       <Alert.Description>{serverError()}</Alert.Description>
+                                               </Alert.Root>
+                                       </div>
+                               {/if}
 
-                       {#if hasPropsError}
-                               <div
-                                       class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
-                                       in:fly={{ y: 10, duration: 250 }}
-                               >
-                                       <Alert.Root variant="destructive">
-                                               <AlertTriangle class="h-4 w-4" />
-                                               <Alert.Title class="flex items-center justify-between">
-                                                       <span>Server unavailable</span>
-                                                       <button
-                                                               onclick={() => serverStore.fetch()}
-                                                               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"
-                                                       >
-                                                               <RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
-                                                               {isServerLoading ? 'Retrying...' : 'Retry'}
-                                                       </button>
-                                               </Alert.Title>
-                                               <Alert.Description>{serverError()}</Alert.Description>
-                                       </Alert.Root>
+                               <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>
-                       {/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>
index 0b10e140080b2d79cc0be9b3694611a22b00f471..9976ffa7cce2bfcf117f8d89d51282b68304f2a7 100644 (file)
@@ -36,6 +36,7 @@
        import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
        import type { DatabaseMessageExtra } from '$lib/types/database';
        import { config } from '$lib/stores/settings.svelte';
+       import { fadeInView } from '$lib/actions/fade-in-view.svelte';
 
        interface Props {
                attachments?: DatabaseMessageExtra[];
                : ''}"
 >
        {#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 -->
                        {@html block.html}
                </div>
 />
 
 <style>
-       .markdown-block,
        .markdown-block--unstable {
                display: contents;
        }
index 098f435d6c63b4dc4e7de7d5ae1da16761d13b09..ca9ba5a9e832697ba1d8172eca8e8dd53dea5e6b 100644 (file)
@@ -1,3 +1,2 @@
 export const AUTO_SCROLL_INTERVAL = 100;
-export const INITIAL_SCROLL_DELAY = 50;
 export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;
index afd0aad8301de15dfa97c56e0d164f1f699b00a3..204d71a90a0274627d15907414b43a2095f5ee00 100644 (file)
@@ -1,8 +1,8 @@
 import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants';
 
 export interface AutoScrollOptions {
-       /** Whether auto-scroll is disabled globally (e.g., from settings) */
        disabled?: boolean;
+       isColumnReverse?: boolean;
 }
 
 /**
@@ -12,6 +12,7 @@ export interface AutoScrollOptions {
  * - Auto-scrolls to bottom during streaming/loading
  * - Stops auto-scroll when user manually scrolls up
  * - Resumes auto-scroll when user scrolls back to bottom
+ * - Supports both normal and column-reverse scroll containers
  */
 export class AutoScrollController {
        private _autoScrollEnabled = $state(true);
@@ -21,9 +22,14 @@ export class AutoScrollController {
        private _scrollTimeout: ReturnType<typeof setTimeout> | undefined;
        private _container: HTMLElement | undefined;
        private _disabled: boolean;
+       private _isColumnReverse: boolean;
+       private _mutationObserver: MutationObserver | null = null;
+       private _rafPending = false;
+       private _observerEnabled = false;
 
        constructor(options: AutoScrollOptions = {}) {
                this._disabled = options.disabled ?? false;
+               this._isColumnReverse = options.isColumnReverse ?? false;
        }
 
        get autoScrollEnabled(): boolean {
@@ -38,7 +44,12 @@ export class AutoScrollController {
         * Binds the controller to a scrollable container element.
         */
        setContainer(container: HTMLElement | undefined): void {
+               this._doStopObserving();
                this._container = container;
+
+               if (this._observerEnabled && container && !this._disabled) {
+                       this._doStartObserving();
+               }
        }
 
        /**
@@ -49,6 +60,9 @@ export class AutoScrollController {
                if (disabled) {
                        this._autoScrollEnabled = false;
                        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;
 
                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;
 
-               if (scrollTop < this._lastScrollTop && !isAtBottom) {
+               if (isScrollingUp && !isAtBottom) {
                        this._userScrolledUp = true;
                        this._autoScrollEnabled = false;
                } else if (isAtBottom && this._userScrolledUp) {
@@ -90,10 +117,12 @@ export class AutoScrollController {
        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 });
+               }
        }
 
        /**
@@ -150,11 +179,69 @@ export class AutoScrollController {
         */
        destroy(): void {
                this.stopInterval();
+               this._doStopObserving();
+
                if (this._scrollTimeout) {
                        clearTimeout(this._scrollTimeout);
                        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;
+       }
 }
 
 /**