refactor: improve chat scroll logic (#263)

This commit is contained in:
Quincy
2026-03-18 15:19:32 +08:00
committed by GitHub
parent d5b410d7e3
commit 76a90191b4
+88 -114
View File
@@ -17,48 +17,49 @@
<template v-else> <template v-else>
<!-- Bot info header --> <!-- Bot info header -->
<!-- Messages --> <!-- Messages -->
<div <section class="flex-1 relative w-full">
ref="scrollContainer" <section class="absolute inset-0">
class="flex-1 overflow-y-auto" <ScrollArea
role="log" ref="scrollContainer"
aria-live="polite" class="h-full"
aria-relevant="additions text"
@scroll="handleScroll"
>
<div class="max-w-3xl mx-auto px-4 py-6 space-y-6">
<!-- Load older indicator -->
<div
v-if="loadingOlder"
class="flex justify-center py-2"
> >
<FontAwesomeIcon <div class="max-w-3xl mx-auto px-4 py-6 space-y-6">
:icon="['fas', 'spinner']" <!-- Load older indicator -->
class="size-3.5 animate-spin text-muted-foreground" <div
/> v-if="loadingOlder"
</div> class="flex justify-center py-2"
>
<FontAwesomeIcon
:icon="['fas', 'spinner']"
class="size-3.5 animate-spin text-muted-foreground"
/>
</div>
<!-- Empty state --> <!-- Empty state -->
<div <div
v-if="messages.length === 0 && !loadingChats" v-if="messages.length === 0 && !loadingChats"
class="flex items-center justify-center min-h-[300px]" class="flex items-center justify-center min-h-[300px]"
> >
<p class="text-muted-foreground text-lg"> <p class="text-muted-foreground text-lg">
{{ $t('chat.greeting') }} {{ $t('chat.greeting') }}
</p> </p>
</div> </div>
<!-- Message list -->
<MessageItem
v-for="msg in messages"
:key="msg.id"
:message="msg"
:on-open-media="galleryOpenBySrc"
/>
</div>
</ScrollArea>
</section>
</section>
<!-- Message list -->
<MessageItem
v-for="msg in messages"
:key="msg.id"
:message="msg"
:on-open-media="galleryOpenBySrc"
/>
</div>
</div>
<!-- Media gallery lightbox --> <!-- Media gallery lightbox -->
<MediaGalleryLightbox <MediaGalleryLightbox
@@ -115,7 +116,7 @@
class="justify-end" class="justify-end"
> >
<Button <Button
v-if="!streaming" v-if="!streaming"
type="button" type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -200,8 +201,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, provide } from 'vue' import { ref, computed, nextTick, onMounted, provide, useTemplateRef, watchEffect} from 'vue'
import { Textarea, Button, Avatar, AvatarImage, AvatarFallback, Badge, InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupTextarea, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@memoh/ui' import { ScrollArea, Button, InputGroup, InputGroupAddon, InputGroupTextarea, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@memoh/ui'
import { useChatStore } from '@/store/chat-list' import { useChatStore } from '@/store/chat-list'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import MessageItem from './message-item.vue' import MessageItem from './message-item.vue'
@@ -210,6 +211,7 @@ import FileManager from '@/components/file-manager/index.vue'
import { useMediaGallery } from '../composables/useMediaGallery' import { useMediaGallery } from '../composables/useMediaGallery'
import { openInFileManagerKey } from '../composables/useFileManagerProvider' import { openInFileManagerKey } from '../composables/useFileManagerProvider'
import type { ChatAttachment } from '@/composables/api/useChat' import type { ChatAttachment } from '@/composables/api/useChat'
import { useScroll, useElementBounding } from '@vueuse/core'
const chatStore = useChatStore() const chatStore = useChatStore()
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
@@ -231,8 +233,7 @@ provide(openInFileManagerKey, (path: string, isDir = false) => {
const { const {
messages, messages,
streaming, streaming,
currentBotId, currentBotId,
bots,
activeChatReadOnly, activeChatReadOnly,
loadingOlder, loadingOlder,
loadingChats, loadingChats,
@@ -247,81 +248,60 @@ const {
} = useMediaGallery(messages) } = useMediaGallery(messages)
const inputText = ref('') const inputText = ref('')
const scrollContainer = ref<HTMLElement>()
const currentBot = computed(() =>
bots.value.find((b) => b.id === currentBotId.value) ?? null,
)
onMounted(() => { onMounted(async () => {
void chatStore.initialize() try {
}) await chatStore.initialize()
} finally {
// ---- Auto-scroll ----
let userScrolledUp = false
function scrollToBottom(instant = false) {
requestAnimationFrame(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const el = scrollContainer.value requestAnimationFrame(() => {
if (!el) return isInstant.value = true
el.scrollTo({ top: el.scrollHeight, behavior: instant ? 'instant' : 'smooth' }) })
}) })
}
}) })
}
function handleScroll() { const elNode = useTemplateRef('scrollContainer')
const el = scrollContainer.value const descEl = computed(() => elNode.value?.$el?.children[0]?.children[0])
if (!el) return const scrollEl = computed(() => descEl.value?.parentNode)
const distanceFromBottom = el.scrollHeight - el.clientHeight - el.scrollTop const isAutoScroll = ref(true)
userScrolledUp = distanceFromBottom > 50 const isInstant=ref(false)
const { y, directions, arrivedState } = useScroll(scrollEl, { behavior: computed(() => isAutoScroll.value&&isInstant.value ? 'smooth' : 'instant') })
const { height,bottom } = useElementBounding(descEl)
if (el.scrollTop < 200 && hasMoreOlder.value && !loadingOlder.value) {
const prevHeight = el.scrollHeight watchEffect(() => {
chatStore.loadOlderMessages().then((count) => { if (directions.top) {
if (count > 0) { isAutoScroll.value = false
nextTick(() => {
el.scrollTop = el.scrollHeight - prevHeight
})
}
})
} }
} if (arrivedState.bottom) {
isAutoScroll.value = true
// After full load (initial / chat switch), instantly jump to bottom
watch(loadingChats, (cur, prev) => {
if (prev && !cur) {
userScrolledUp = false
scrollToBottom(true)
} }
}) })
// Stream content auto-scroll watchEffect(() => {
watch( if (isAutoScroll.value) {
() => { y.value = height.value
const last = messages.value[messages.value.length - 1] }
return last?.blocks.reduce((acc, b) => { })
if (b.type === 'text') return acc + b.content.length
if (b.type === 'thinking') return acc + b.content.length
return acc + 1
}, 0) ?? 0
},
() => {
if (!userScrolledUp) scrollToBottom()
},
)
// New realtime message auto-scroll let Throttle = true
watch(
() => messages.value.length, watchEffect(() => {
() => { if (directions.top && arrivedState.top && Throttle && hasMoreOlder.value && !loadingOlder.value) {
if (loadingChats.value) return const prev=bottom.value
userScrolledUp = false Throttle = false
scrollToBottom() chatStore.loadOlderMessages().then((count) => {
}, setTimeout(() => {
) if (count > 0) {
y.value = height.value-prev
Throttle = true
}
})
})
}
})
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
if (e.isComposing) return if (e.isComposing) return
@@ -329,13 +309,6 @@ function handleKeydown(e: KeyboardEvent) {
handleSend() handleSend()
} }
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (input.files) {
pendingFiles.value.push(...Array.from(input.files))
}
input.value = ''
}
function handlePaste(e: ClipboardEvent) { function handlePaste(e: ClipboardEvent) {
const items = e.clipboardData?.items const items = e.clipboardData?.items
@@ -365,6 +338,7 @@ async function fileToAttachment(file: File): Promise<ChatAttachment> {
} }
async function handleSend() { async function handleSend() {
isAutoScroll.value=true
const text = inputText.value.trim() const text = inputText.value.trim()
const files = [...pendingFiles.value] const files = [...pendingFiles.value]
if ((!text && !files.length) || streaming.value || activeChatReadOnly.value) return if ((!text && !files.length) || streaming.value || activeChatReadOnly.value) return