From 52697a4da77ef9d0d2aaa9a68be74b509c14d752 Mon Sep 17 00:00:00 2001 From: Varenik-vkusny Date: Fri, 27 Mar 2026 12:39:57 +0500 Subject: [PATCH] Fix: gluing down while scrolling and out of time issues --- src/components/chat/ChatMessageList.vue | 19 ++++++------------- src/stores/chat.ts | 10 +++++++++- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/components/chat/ChatMessageList.vue b/src/components/chat/ChatMessageList.vue index d007a40..9c43b7e 100644 --- a/src/components/chat/ChatMessageList.vue +++ b/src/components/chat/ChatMessageList.vue @@ -24,28 +24,22 @@ const emit = defineEmits<{ const { t } = useI18n(); const messagesContainer = ref(null); -const isUserScrolledUp = ref(false); const BOTTOM_THRESHOLD = 80; // px from bottom before we consider the user "at bottom" -const onContainerScroll = () => { - const el = messagesContainer.value; - if (!el) return; - const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - isUserScrolledUp.value = distanceFromBottom > BOTTOM_THRESHOLD; -}; - const scrollToBottom = async (force = false) => { await nextTick(); const el = messagesContainer.value; if (!el) return; - if (force || !isUserScrolledUp.value) { + // Read position at execution time to avoid the race condition where the + // isUserScrolledUp flag hasn't been updated yet when a queued callback fires. + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + if (force || distanceFromBottom <= BOTTOM_THRESHOLD) { el.scrollTop = el.scrollHeight; - isUserScrolledUp.value = false; } }; -// Deep watch: fires on both new messages (length) AND text changes during rAF animation. -// Smart scroll: only follows bottom if the user hasn't scrolled up to read history. +// Deep watch: fires on both new messages AND text changes during rAF animation. +// Smart scroll: reads scroll position directly at check time (no stale flag). watch(() => props.messages, () => { scrollToBottom(false); }, { deep: true }); @@ -63,7 +57,6 @@ defineExpose({ scrollToBottom: () => scrollToBottom(true) }); ref="messagesContainer" id="tour-chat-container" class="flex-1 overflow-y-auto px-6 pt-8 custom-scrollbar relative" - @scroll="onContainerScroll" >
{ if (!text.trim() && (!attachments || attachments.length === 0)) return; isLoading.value = true; - isGenerating.value = true; + // isGenerating stays false until first token arrives so that + // isBotTyping (= isLoading && !isGenerating) is true while waiting — + // this shows the bouncing-dots indicator during the network round-trip. + isGenerating.value = false; error.value = null; abortController.value = new AbortController(); @@ -424,6 +427,8 @@ export const useChatStore = defineStore('chat', () => { // Token chunk — accumulate raw text (used as abort fallback); animation runs on final message if (parsed.type === 'token' && typeof parsed.token === 'string') { + // First token: switch from "waiting" to "generating" — hides bouncing dots + if (!isGenerating.value) isGenerating.value = true; _streamingRawText += parsed.token; if (parsed.session_id) { // Update session ID without touching messages array @@ -440,6 +445,9 @@ export const useChatStore = defineStore('chat', () => { } } } else if (parsed.type === 'message') { + // If no tokens arrived before the final message (e.g. non-streaming + // backend path), mark as generating now so state is cleaned up correctly. + if (!isGenerating.value) isGenerating.value = true; const streamingMsg = messages.value[streamingIndex]; if (streamingMsg) { streamingMsg.id = parsed.id;