--- /dev/null
+/**
+ * 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();
+ };
+ });
+}
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">
<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>
} 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 {
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>
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;
}
export const AUTO_SCROLL_INTERVAL = 100;
-export const INITIAL_SCROLL_DELAY = 50;
export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;
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;
}
/**
* - 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);
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 {
* 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();
+ }
}
/**
if (disabled) {
this._autoScrollEnabled = false;
this.stopInterval();
+ this._doStopObserving();
+ } else if (this._observerEnabled && this._container && !this._mutationObserver) {
+ this._doStartObserving();
}
}
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) {
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 });
+ }
}
/**
*/
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;
+ }
}
/**