Skip to content

Commit e6f6770

Browse files
authored
webui: Improve Chat Messages initial scroll + auto-scroll logic + add 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
1 parent 48cda24 commit e6f6770

File tree

8 files changed

+221
-86
lines changed

8 files changed

+221
-86
lines changed

tools/server/public/index.html.gz

412 Bytes
Binary file not shown.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Svelte action that fades in an element when it enters the viewport.
3+
* Uses IntersectionObserver for efficient viewport detection.
4+
*
5+
* If skipIfVisible is set and the element is already visible in the viewport
6+
* when the action attaches (e.g. a markdown block promoted from unstable
7+
* during streaming), the fade is skipped entirely to avoid a flash.
8+
*/
9+
export function fadeInView(
10+
node: HTMLElement,
11+
options: { duration?: number; y?: number; skipIfVisible?: boolean } = {}
12+
) {
13+
const { duration = 300, y = 0, skipIfVisible = false } = options;
14+
15+
if (skipIfVisible) {
16+
const rect = node.getBoundingClientRect();
17+
const isAlreadyVisible =
18+
rect.top < window.innerHeight &&
19+
rect.bottom > 0 &&
20+
rect.left < window.innerWidth &&
21+
rect.right > 0;
22+
23+
if (isAlreadyVisible) {
24+
return;
25+
}
26+
}
27+
28+
node.style.opacity = '0';
29+
node.style.transform = `translateY(${y}px)`;
30+
node.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`;
31+
32+
$effect(() => {
33+
const observer = new IntersectionObserver(
34+
(entries) => {
35+
for (const entry of entries) {
36+
if (entry.isIntersecting) {
37+
requestAnimationFrame(() => {
38+
node.style.opacity = '1';
39+
node.style.transform = 'translateY(0)';
40+
});
41+
observer.disconnect();
42+
}
43+
}
44+
},
45+
{ threshold: 0.05 }
46+
);
47+
48+
observer.observe(node);
49+
50+
return () => {
51+
observer.disconnect();
52+
};
53+
});
54+
}

tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@
33
ChatMessageAgenticContent,
44
ChatMessageActions,
55
ChatMessageStatistics,
6-
MarkdownContent,
76
ModelBadge,
87
ModelsSelector
98
} from '$lib/components/app';
109
import { getMessageEditContext } from '$lib/contexts';
1110
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
1211
import { isLoading, isChatStreaming } from '$lib/stores/chat.svelte';
13-
import { agenticStreamingToolCall } from '$lib/stores/agentic.svelte';
1412
import { autoResizeTextarea, copyToClipboard, isIMEComposing } from '$lib/utils';
1513
import { tick } from 'svelte';
1614
import { fade } from 'svelte/transition';
@@ -87,13 +85,7 @@
8785
const hasAgenticMarkers = $derived(
8886
messageContent?.includes(AGENTIC_TAGS.TOOL_CALL_START) ?? false
8987
);
90-
const hasStreamingToolCall = $derived(
91-
isChatStreaming() && agenticStreamingToolCall(message.convId) !== null
92-
);
9388
const hasReasoningMarkers = $derived(messageContent?.includes(REASONING_TAGS.START) ?? false);
94-
const isStructuredContent = $derived(
95-
hasAgenticMarkers || hasReasoningMarkers || hasStreamingToolCall
96-
);
9789
const processingState = useProcessingState();
9890
9991
let currentConfig = $derived(config());
@@ -256,15 +248,13 @@
256248
{:else if message.role === MessageRole.ASSISTANT}
257249
{#if showRawOutput}
258250
<pre class="raw-output">{messageContent || ''}</pre>
259-
{:else if isStructuredContent}
251+
{:else}
260252
<ChatMessageAgenticContent
261253
content={messageContent || ''}
262254
isStreaming={isChatStreaming()}
263255
highlightTurns={highlightAgenticTurns}
264256
{message}
265257
/>
266-
{:else}
267-
<MarkdownContent content={messageContent || ''} attachments={message.extra} />
268258
{/if}
269259
{:else}
270260
<div class="text-sm whitespace-pre-wrap">

tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script lang="ts">
2+
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
23
import { ChatMessage } from '$lib/components/app';
34
import { setChatActionsContext } from '$lib/contexts';
45
import { MessageRole } from '$lib/enums';
@@ -140,13 +141,18 @@
140141
});
141142
</script>
142143

143-
<div class="flex h-full flex-col space-y-10 pt-24 {className}" style="height: auto; ">
144+
<div
145+
class="flex h-full flex-col space-y-10 pt-24 {className}"
146+
style="height: auto; min-height: calc(100dvh - 14rem);"
147+
>
144148
{#each displayMessages as { message, isLastAssistantMessage, siblingInfo } (message.id)}
145-
<ChatMessage
146-
class="mx-auto w-full max-w-[48rem]"
147-
{message}
148-
{isLastAssistantMessage}
149-
{siblingInfo}
150-
/>
149+
<div use:fadeInView>
150+
<ChatMessage
151+
class="mx-auto w-full max-w-[48rem]"
152+
{message}
153+
{isLastAssistantMessage}
154+
{siblingInfo}
155+
/>
156+
</div>
151157
{/each}
152158
</div>

tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte

Lines changed: 57 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
} from '$lib/components/app';
1313
import * as Alert from '$lib/components/ui/alert';
1414
import * as AlertDialog from '$lib/components/ui/alert-dialog';
15-
import { INITIAL_SCROLL_DELAY } from '$lib/constants';
1615
import { KeyboardKey } from '$lib/enums';
1716
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
1817
import {
@@ -48,7 +47,7 @@
4847
let showFileErrorDialog = $state(false);
4948
let uploadedFiles = $state<ChatUploadedFile[]>([]);
5049
51-
const autoScroll = createAutoScrollController();
50+
const autoScroll = createAutoScrollController({ isColumnReverse: true });
5251
5352
let fileErrorData = $state<{
5453
generallyUnsupported: File[];
@@ -310,13 +309,15 @@
310309
311310
afterNavigate(() => {
312311
if (!disableAutoScroll) {
313-
setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
312+
autoScroll.enable();
314313
}
315314
});
316315
317316
onMount(() => {
317+
autoScroll.startObserving();
318+
318319
if (!disableAutoScroll) {
319-
setTimeout(() => autoScroll.scrollToBottom('instant'), INITIAL_SCROLL_DELAY);
320+
autoScroll.enable();
320321
}
321322
322323
const pendingDraft = chatStore.consumePendingDraft();
@@ -333,10 +334,6 @@
333334
$effect(() => {
334335
autoScroll.setDisabled(disableAutoScroll);
335336
});
336-
337-
$effect(() => {
338-
autoScroll.updateInterval(isCurrentConversationLoading);
339-
});
340337
</script>
341338

342339
{#if isDragOver}
@@ -351,65 +348,67 @@
351348
<div
352349
bind:this={chatScrollContainer}
353350
aria-label="Chat interface with file drop zone"
354-
class="flex h-full flex-col overflow-y-auto px-4 md:px-6"
351+
class="flex h-full flex-col-reverse overflow-y-auto px-4 md:px-6"
355352
ondragenter={handleDragEnter}
356353
ondragleave={handleDragLeave}
357354
ondragover={handleDragOver}
358355
ondrop={handleDrop}
359356
onscroll={handleScroll}
360357
role="main"
361358
>
362-
<ChatMessages
363-
class="mb-16 md:mb-24"
364-
messages={activeMessages()}
365-
onUserAction={() => {
366-
autoScroll.enable();
367-
autoScroll.scrollToBottom();
368-
}}
369-
/>
370-
371-
<div
372-
class="pointer-events-none sticky right-0 bottom-4 left-0 mt-auto"
373-
in:slide={{ duration: 150, axis: 'y' }}
374-
>
375-
<ChatScreenProcessingInfo />
359+
<div class="flex flex-col">
360+
<ChatMessages
361+
class="mb-16 md:mb-24"
362+
messages={activeMessages()}
363+
onUserAction={() => {
364+
autoScroll.enable();
365+
autoScroll.scrollToBottom();
366+
}}
367+
/>
368+
369+
<div
370+
class="pointer-events-none sticky right-0 bottom-4 left-0 mt-auto"
371+
in:slide={{ duration: 150, axis: 'y' }}
372+
>
373+
<ChatScreenProcessingInfo />
374+
375+
{#if hasPropsError}
376+
<div
377+
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
378+
in:fly={{ y: 10, duration: 250 }}
379+
>
380+
<Alert.Root variant="destructive">
381+
<AlertTriangle class="h-4 w-4" />
382+
<Alert.Title class="flex items-center justify-between">
383+
<span>Server unavailable</span>
384+
<button
385+
onclick={() => serverStore.fetch()}
386+
disabled={isServerLoading}
387+
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"
388+
>
389+
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
390+
{isServerLoading ? 'Retrying...' : 'Retry'}
391+
</button>
392+
</Alert.Title>
393+
<Alert.Description>{serverError()}</Alert.Description>
394+
</Alert.Root>
395+
</div>
396+
{/if}
376397

377-
{#if hasPropsError}
378-
<div
379-
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
380-
in:fly={{ y: 10, duration: 250 }}
381-
>
382-
<Alert.Root variant="destructive">
383-
<AlertTriangle class="h-4 w-4" />
384-
<Alert.Title class="flex items-center justify-between">
385-
<span>Server unavailable</span>
386-
<button
387-
onclick={() => serverStore.fetch()}
388-
disabled={isServerLoading}
389-
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"
390-
>
391-
<RefreshCw class="h-3 w-3 {isServerLoading ? 'animate-spin' : ''}" />
392-
{isServerLoading ? 'Retrying...' : 'Retry'}
393-
</button>
394-
</Alert.Title>
395-
<Alert.Description>{serverError()}</Alert.Description>
396-
</Alert.Root>
398+
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl">
399+
<ChatScreenForm
400+
disabled={hasPropsError || isEditing()}
401+
{initialMessage}
402+
isLoading={isCurrentConversationLoading}
403+
onFileRemove={handleFileRemove}
404+
onFileUpload={handleFileUpload}
405+
onSend={handleSendMessage}
406+
onStop={() => chatStore.stopGeneration()}
407+
onSystemPromptAdd={handleSystemPromptAdd}
408+
showHelperText={false}
409+
bind:uploadedFiles
410+
/>
397411
</div>
398-
{/if}
399-
400-
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl">
401-
<ChatScreenForm
402-
disabled={hasPropsError || isEditing()}
403-
{initialMessage}
404-
isLoading={isCurrentConversationLoading}
405-
onFileRemove={handleFileRemove}
406-
onFileUpload={handleFileUpload}
407-
onSend={handleSendMessage}
408-
onStop={() => chatStore.stopGeneration()}
409-
onSystemPromptAdd={handleSystemPromptAdd}
410-
showHelperText={false}
411-
bind:uploadedFiles
412-
/>
413412
</div>
414413
</div>
415414
</div>

tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
3737
import type { DatabaseMessageExtra } from '$lib/types/database';
3838
import { config } from '$lib/stores/settings.svelte';
39+
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
3940
4041
interface Props {
4142
attachments?: DatabaseMessageExtra[];
@@ -598,7 +599,7 @@
598599
: ''}"
599600
>
600601
{#each renderedBlocks as block (block.id)}
601-
<div class="markdown-block" data-block-id={block.id}>
602+
<div class="markdown-block" data-block-id={block.id} use:fadeInView={{ skipIfVisible: true }}>
602603
<!-- eslint-disable-next-line no-at-html-tags -->
603604
{@html block.html}
604605
</div>
@@ -651,7 +652,6 @@
651652
/>
652653

653654
<style>
654-
.markdown-block,
655655
.markdown-block--unstable {
656656
display: contents;
657657
}
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
export const AUTO_SCROLL_INTERVAL = 100;
2-
export const INITIAL_SCROLL_DELAY = 50;
32
export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;

0 commit comments

Comments
 (0)