Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 6 additions & 13 deletions src/components/chat/ChatMessageList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,28 +24,22 @@ const emit = defineEmits<{

const { t } = useI18n();
const messagesContainer = ref<HTMLElement | null>(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 });
Expand All @@ -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"
>
<!-- Loading Overlay for History -->
<div
Expand Down
10 changes: 9 additions & 1 deletion src/stores/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,10 @@ export const useChatStore = defineStore('chat', () => {
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();

Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
Loading