<template>
<div class="message-list-wrap">
<!-- Session open: the list is still being fetched — show a centered
spinner instead of a blank area -->
<div v-if="chat.loading" class="session-loading">
<i class="ph ph-spinner animate-spin"></i>
</div>
<div v-if="chat.archiveLoading" class="archive-loading">
<i class="ph ph-spinner animate-spin"></i>
<span>Loading older messages…</span>
</div>
<!-- Virtualized: only rows near the viewport mount. Markdown remount costs
are amortized by the renderMarkdown memo (useMarkdown.js). -->
<DynamicScroller
v-if="chat.messages.length"
ref="scrollerRef"
:key="chat.currentId ?? 'empty'"
class="message-scroller"
:class="{ 'is-hidden': !contentVisible }"
:items="chat.messages"
:min-item-size="140"
:buffer="800"
key-field="id"
shift
@wheel.passive="markUserInput"
@pointerdown.passive="markUserInput"
@touchstart.passive="markUserInput"
@scroll.passive="onScroll"
>
<template #default="{ item, index, active }">
<DynamicScrollerItem :item="item" :active="active" :data-index="index">
<div
class="message-row"
:data-msg-index="index"
:class="{ 'msg-enter': item.animate }"
>
<component :is="resolveComponent(item)" :msg="item" :is-flashing="flashIndex === index" />
</div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
<Transition name="scroll-btn">
<button
v-if="showScrollBtn"
class="scroll-to-bottom-btn"
title="Scroll to bottom"
@click="scrollToBottom(true)"
>
<i class="ph ph-arrow-down"></i>
</button>
</Transition>
</div>
</template>
<script setup>
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { useChatStore } from '@/stores/chat.js'
import UserMessage from '@/components/messages/UserMessage.vue'
import AssistantMessage from '@/components/messages/AssistantMessage.vue'
import SummaryCard from '@/components/messages/SummaryCard.vue'
import CompressionNotice from '@/components/messages/CompressionNotice.vue'
import ErrorMessage from '@/components/messages/ErrorMessage.vue'
const chat = useChatStore()
const scrollerRef = ref(null)
const contentVisible = ref(false)
const showScrollBtn = ref(false)
const flashIndex = ref(null)
let userScrolledUp = false
let scrollAnchorIndex = null
let flashTimeout = null
let convergeChain = 0
let lastUserInputAt = 0
function containerEl() {
return scrollerRef.value?.$el ?? null
}
// Real user input (wheel / pointer / touch). Scroll events are NOT usable for
// this while the virtual scroller is settling: its internal size adjustments
// move scrollTop themselves and would look like a user scroll.
function markUserInput() {
lastUserInputAt = performance.now()
}
function scrollToMessage(rawIndex) {
const msgIndex = chat.messages.findIndex(m => m.rawIndices?.includes(rawIndex))
if (msgIndex === -1) return
scrollerRef.value?.scrollToItem(msgIndex, { align: 'center', smooth: true })
// The row mounts as the smooth scroll passes it — the 3s flash window
// covers the arrival.
flashIndex.value = msgIndex
if (flashTimeout) clearTimeout(flashTimeout)
flashTimeout = setTimeout(() => {
flashIndex.value = null
flashTimeout = null
}, 3000)
}
function resolveComponent(msg) {
if (msg.role === 'user') return UserMessage
if (msg.type === 'summary') return SummaryCard
if (msg.type === 'compression_notice' || msg.type === 'compression_pending') return CompressionNotice
if (msg.type === 'error') return ErrorMessage
return AssistantMessage
}
function scrollToBottom(smooth = false) {
if (smooth) {
const el = containerEl()
if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
return
}
convergeToBottom()
}
// Virtual-scroller landing: freshly-landed rows are still unmeasured
// (min-item-size estimates), so a single scrollTop=scrollHeight clamp leaves
// the tail short — clamp again after the ResizeObserver settles, until the
// bottom is really at the bottom.
function convergeToBottom() {
const chain = ++convergeChain
const el = containerEl()
if (!el) return
// user already touched the list during loading — don't steal their position
if (performance.now() - lastUserInputAt < 250) return
el.scrollTop = el.scrollHeight
// Lazy row measurements keep nudging scrollHeight for ~1s after the
// initial clamp (estimates → measured heights, often smaller) — keep
// re-clamping inside a bounded time window until the bottom holds.
const deadline = performance.now() + 2500
const loop = async () => {
while (performance.now() < deadline) {
if (chain !== convergeChain) return
await new Promise(r => requestAnimationFrame(r))
if (chain !== convergeChain) return
// Content shrinks/grows late (lazy images, slow markdown settle) — watch
// the bottom hold through the whole window, but yield to real user input
// the moment it happens.
if (performance.now() - lastUserInputAt < 250) return
const d = el.scrollHeight - el.scrollTop - el.clientHeight
if (d > 1) el.scrollTop = el.scrollHeight
}
}
loop()
}
function jumpToBottom() {
const el = containerEl()
if (!el) return
el.scrollTop = el.scrollHeight
// rows below may still be measuring — one re-clamp after the next frame
requestAnimationFrame(() => {
if (el.scrollHeight - el.scrollTop - el.clientHeight > 1) {
el.scrollTop = el.scrollHeight
}
})
}
function onScroll(event) {
const el = event.target
if (!el) return
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
userScrolledUp = distFromBottom > 100
showScrollBtn.value = distFromBottom > 200
// Trigger archive load when scrolled near top
if (el.scrollTop < 80 && chat.archiveHasMore && !chat.archiveLoading) {
// Remember which message the viewport was anchored to so position can be
// restored after the async prepend.
const mid = Math.floor(el.scrollTop + el.clientHeight / 2)
const rows = el.querySelectorAll('[data-msg-index]')
for (const row of rows) {
const top = row.offsetTop
const bottom = top + row.offsetHeight
if (bottom >= mid) {
scrollAnchorIndex = Number(row.dataset.msgIndex)
break
}
}
chat.loadArchivedMessages()
}
}
// Auto-scroll when messages change — skip during history or archive load
watch(
() => chat.messages.length,
() => {
if (!userScrolledUp && !chat.loading && !chat.archiveLoading) nextTick(() => scrollToBottom())
}
)
// Auto-scroll during streaming — deep watch because streamingMsg is a shallowRef
watch(
() => chat.streamingMsg,
() => {
if (!userScrolledUp) nextTick(() => jumpToBottom())
},
{ deep: true }
)
// Restore scroll position after archived messages are prepended: the anchor
// row keeps its offsetTop — pin it back under the same viewport position.
watch(
() => chat.archiveLoading,
(loading) => {
if (!loading && scrollAnchorIndex != null) {
nextTick(() => {
scrollerRef.value?.scrollToItem(scrollAnchorIndex, { align: 'start', offset: 0 })
scrollAnchorIndex = null
})
}
}
)
// When session changes: reset landing chain and scroll state. DynamicScroller
// is keyed by session id — teardown on switch drops its pool and measured
// size cache entirely (message ids like `h_<index>` repeat across sessions,
// so cached heights from the previous session would corrupt the new one's
// geometry).
watch(
() => chat.currentId,
() => {
convergeChain++ // invalidate any pending landing loop
userScrolledUp = false
showScrollBtn.value = false
contentVisible.value = false
scrollAnchorIndex = null
}
)
// When session finishes loading: scroll to target message or bottom, then reveal
watch(
() => chat.loading,
(loading) => {
if (!loading) {
if (chat.scrollToMessageIndex != null) {
contentVisible.value = true
afterLayout(() => {
scrollToMessage(chat.scrollToMessageIndex)
chat.scrollToMessageIndex = null
})
} else {
contentVisible.value = false
afterLayout(() => {
scrollToBottom()
contentVisible.value = true
})
}
}
}
)
// Double rAF ensures browser has fully computed layout before acting
function afterLayout(fn) {
nextTick(() => requestAnimationFrame(() => requestAnimationFrame(fn)))
}
// Handle case where loading is already false when component mounts
onMounted(() => {
if (!chat.loading && chat.messages.length) {
afterLayout(() => {
scrollToBottom()
contentVisible.value = true
})
} else if (!chat.loading) {
contentVisible.value = true
}
})
onUnmounted(() => {
if (flashTimeout) clearTimeout(flashTimeout)
})
</script>
<style scoped>
.message-list-wrap {
flex: 1;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
}
.message-scroller {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
transition: opacity 0.25s ease;
&.is-hidden {
opacity: 0;
pointer-events: none;
}
}
.message-row {
padding-bottom: 32px; /* replaces the old .messages-group gap of 32px */
width: 100%;
max-width: 920px;
margin: 0 auto;
padding-left: 24px;
padding-right: 24px;
}
@media (max-width: 767px) {
.message-row {
padding-left: 10px;
padding-right: 10px;
}
}
.session-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
color: var(--color-text-medium, #8b95c7);
pointer-events: none;
.animate-spin {
animation: session-spin 1s linear infinite;
}
}
@keyframes session-spin {
to { transform: rotate(360deg); }
}
.archive-loading {
position: absolute;
top: 8px;
left: 0;
right: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px;
font-size: 13px;
color: var(--color-fg-muted, #a9b1d6);
background: var(--color-bg-primary, transparent);
pointer-events: none;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.scroll-to-bottom-btn {
position: absolute;
bottom: 16px;
right: 16px;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-secondary, #7aa2f7);
color: var(--color-black, #1a1b2e);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
opacity: 0.9;
transition: opacity 0.15s ease, transform 0.15s ease;
z-index: 10;
&:hover {
opacity: 1;
transform: translateY(-2px);
}
}
.scroll-btn-enter-active,
.scroll-btn-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.scroll-btn-enter-from,
.scroll-btn-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>