webui: Fix Chat Screen Form box disappearing + autoscroll issues on WebKit (#22977)
* debug: Scroll/Sticky issues * fix: UI improvements * refactor: Remove unneeded logic * fix: Better logic for initial load of messages
This commit is contained in:
committed by
GitHub
parent
7bfe120c21
commit
dded58b450
File diff suppressed because one or more lines are too long
+726
-722
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||||
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
|
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
|
||||||
import { setChatActionsContext } from '$lib/contexts';
|
import { setChatActionsContext } from '$lib/contexts';
|
||||||
import { MessageRole } from '$lib/enums';
|
import { MessageRole } from '$lib/enums';
|
||||||
@@ -27,11 +29,14 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
messages?: DatabaseMessage[];
|
messages?: DatabaseMessage[];
|
||||||
onUserAction?: () => void;
|
onUserAction?: () => void;
|
||||||
|
onMessagesReady?: (messageCount: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { messages = [], onUserAction }: Props = $props();
|
let { messages = [], onUserAction, onMessagesReady }: Props = $props();
|
||||||
|
|
||||||
let allConversationMessages = $state<DatabaseMessage[]>([]);
|
let allConversationMessages = $state<DatabaseMessage[]>([]);
|
||||||
|
let isVisible = $state(false);
|
||||||
|
let previousConversationId = $state<string | null>(null);
|
||||||
|
|
||||||
const currentConfig = config();
|
const currentConfig = config();
|
||||||
|
|
||||||
@@ -117,15 +122,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single effect that tracks both conversation and message changes
|
// Track conversation changes to trigger transition even on same route
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const conversation = activeConversation();
|
const conversation = activeConversation();
|
||||||
|
const currentId = conversation?.id ?? null;
|
||||||
|
|
||||||
if (conversation) {
|
if (currentId !== previousConversationId && previousConversationId !== null) {
|
||||||
refreshAllMessages();
|
// Conversation changed - trigger fade out/in
|
||||||
|
isVisible = false;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
refreshAllMessages();
|
||||||
|
previousConversationId = currentId;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isVisible = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
previousConversationId = currentId;
|
||||||
|
if (conversation) {
|
||||||
|
refreshAllMessages();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void allConversationMessages;
|
||||||
|
|
||||||
|
onMessagesReady?.(displayMessages.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isVisible = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeNavigate(() => {
|
||||||
|
isVisible = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterNavigate(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
isVisible = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
let displayMessages = $derived.by(() => {
|
let displayMessages = $derived.by(() => {
|
||||||
if (!messages.length) {
|
if (!messages.length) {
|
||||||
return [];
|
return [];
|
||||||
@@ -207,42 +248,47 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)}
|
<div
|
||||||
<ChatMessage
|
class="transition-opacity delay-300 duration-500 ease-out
|
||||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
{isVisible ? 'opacity-100' : 'opacity-0'}"
|
||||||
{message}
|
>
|
||||||
{toolMessages}
|
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, siblingInfo } (message.id)}
|
||||||
{isLastAssistantMessage}
|
<ChatMessage
|
||||||
{siblingInfo}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if activeConversation() && agenticPendingSteeringMessageContent(activeConversation()!.id)}
|
|
||||||
{@const convId = activeConversation()!.id}
|
|
||||||
{@const pendingContent = agenticPendingSteeringMessageContent(convId)}
|
|
||||||
|
|
||||||
{#if pendingContent}
|
|
||||||
<ChatMessageUserPending
|
|
||||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||||
content={pendingContent}
|
{message}
|
||||||
extras={agenticPendingSteeringMessageExtras(convId)}
|
{toolMessages}
|
||||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
{isLastAssistantMessage}
|
||||||
onEdit={(newContent, extras) => agenticInjectSteeringMessage(convId, newContent, extras)}
|
{siblingInfo}
|
||||||
onDelete={() => agenticClearSteeringMessage(convId)}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/each}
|
||||||
{:else if activeConversation() && chatPendingMessageContent(activeConversation()!.id)}
|
|
||||||
{@const convId = activeConversation()!.id}
|
|
||||||
{@const pendingContent = chatPendingMessageContent(convId)}
|
|
||||||
|
|
||||||
{#if pendingContent}
|
{#if activeConversation() && agenticPendingSteeringMessageContent(activeConversation()!.id)}
|
||||||
<ChatMessageUserPending
|
{@const convId = activeConversation()!.id}
|
||||||
class="mx-auto mt-12 w-full max-w-[48rem]"
|
{@const pendingContent = agenticPendingSteeringMessageContent(convId)}
|
||||||
content={pendingContent}
|
|
||||||
extras={chatPendingMessageExtras(convId)}
|
{#if pendingContent}
|
||||||
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
<ChatMessageUserPending
|
||||||
onEdit={(newContent, extras) => chatInjectPendingMessage(convId, newContent, extras)}
|
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||||
onDelete={() => chatClearPendingMessage(convId)}
|
content={pendingContent}
|
||||||
/>
|
extras={agenticPendingSteeringMessageExtras(convId)}
|
||||||
|
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||||
|
onEdit={(newContent, extras) => agenticInjectSteeringMessage(convId, newContent, extras)}
|
||||||
|
onDelete={() => agenticClearSteeringMessage(convId)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{:else if activeConversation() && chatPendingMessageContent(activeConversation()!.id)}
|
||||||
|
{@const convId = activeConversation()!.id}
|
||||||
|
{@const pendingContent = chatPendingMessageContent(convId)}
|
||||||
|
|
||||||
|
{#if pendingContent}
|
||||||
|
<ChatMessageUserPending
|
||||||
|
class="mx-auto mt-12 w-full max-w-[48rem]"
|
||||||
|
content={pendingContent}
|
||||||
|
extras={chatPendingMessageExtras(convId)}
|
||||||
|
onSendImmediately={() => chatStore.abortCurrentFlow(convId)}
|
||||||
|
onEdit={(newContent, extras) => chatInjectPendingMessage(convId, newContent, extras)}
|
||||||
|
onDelete={() => chatClearPendingMessage(convId)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
|
|||||||
@@ -42,6 +42,8 @@
|
|||||||
|
|
||||||
let { showCenteredEmpty = false } = $props();
|
let { showCenteredEmpty = false } = $props();
|
||||||
|
|
||||||
|
const autoScroll = createAutoScrollController();
|
||||||
|
|
||||||
let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
|
let disableAutoScroll = $derived(Boolean(config().disableAutoScroll));
|
||||||
let chatScrollContainer: HTMLDivElement | undefined = $state();
|
let chatScrollContainer: HTMLDivElement | undefined = $state();
|
||||||
let dragCounter = $state(0);
|
let dragCounter = $state(0);
|
||||||
@@ -49,8 +51,6 @@
|
|||||||
let showFileErrorDialog = $state(false);
|
let showFileErrorDialog = $state(false);
|
||||||
let uploadedFiles = $state<ChatUploadedFile[]>([]);
|
let uploadedFiles = $state<ChatUploadedFile[]>([]);
|
||||||
|
|
||||||
const autoScroll = createAutoScrollController({ isColumnReverse: true });
|
|
||||||
|
|
||||||
let fileErrorData = $state<{
|
let fileErrorData = $state<{
|
||||||
generallyUnsupported: File[];
|
generallyUnsupported: File[];
|
||||||
modalityUnsupported: File[];
|
modalityUnsupported: File[];
|
||||||
@@ -322,6 +322,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function handleMessagesReady() {
|
||||||
|
if (!disableAutoScroll) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
autoScroll.scrollToBottom('instant');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
autoScroll.startObserving();
|
autoScroll.startObserving();
|
||||||
|
|
||||||
@@ -357,7 +365,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-reverse overflow-y-auto px-4 md:px-6"
|
class="flex h-full flex-col overflow-y-auto px-4 md:px-6"
|
||||||
ondragenter={handleDragEnter}
|
ondragenter={handleDragEnter}
|
||||||
ondragleave={handleDragLeave}
|
ondragleave={handleDragLeave}
|
||||||
ondragover={handleDragOver}
|
ondragover={handleDragOver}
|
||||||
@@ -373,6 +381,7 @@
|
|||||||
autoScroll.enable();
|
autoScroll.enable();
|
||||||
autoScroll.scrollToBottom();
|
autoScroll.scrollToBottom();
|
||||||
}}
|
}}
|
||||||
|
onMessagesReady={handleMessagesReady}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/cons
|
|||||||
|
|
||||||
export interface AutoScrollOptions {
|
export interface AutoScrollOptions {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isColumnReverse?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,7 +11,6 @@ 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);
|
||||||
@@ -22,14 +20,11 @@ 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 _mutationObserver: MutationObserver | null = null;
|
||||||
private _rafPending = false;
|
private _rafPending = false;
|
||||||
private _observerEnabled = 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 {
|
||||||
@@ -73,20 +68,8 @@ 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 - clientHeight - scrollTop;
|
||||||
let distanceFromBottom: number;
|
const isScrollingUp = scrollTop < this._lastScrollTop;
|
||||||
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 (isScrollingUp && !isAtBottom) {
|
if (isScrollingUp && !isAtBottom) {
|
||||||
@@ -116,13 +99,7 @@ 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({ 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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,20 +187,13 @@ export class AutoScrollController {
|
|||||||
private _doStartObserving(): void {
|
private _doStartObserving(): void {
|
||||||
if (!this._container || this._mutationObserver) return;
|
if (!this._container || this._mutationObserver) return;
|
||||||
|
|
||||||
const isReverse = this._isColumnReverse;
|
|
||||||
|
|
||||||
this._mutationObserver = new MutationObserver(() => {
|
this._mutationObserver = new MutationObserver(() => {
|
||||||
if (!this._autoScrollEnabled || this._rafPending) return;
|
if (!this._autoScrollEnabled || this._rafPending) return;
|
||||||
this._rafPending = true;
|
this._rafPending = true;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this._rafPending = false;
|
this._rafPending = false;
|
||||||
if (this._autoScrollEnabled && this._container) {
|
if (this._autoScrollEnabled && this._container) {
|
||||||
if (isReverse) {
|
this._container.scrollTop = this._container.scrollHeight;
|
||||||
// column-reverse: scrollTop=0 is the bottom
|
|
||||||
this._container.scrollTop = 0;
|
|
||||||
} else {
|
|
||||||
this._container.scrollTop = this._container.scrollHeight;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user