Newer
Older
navi-1 / webclient / src / components / chat / MessageList.vue
<template>
  <div ref="containerEl" class="message-list" @scroll="onScroll">
    <div class="message-list-inner" :class="{ 'is-hidden': !contentVisible }">
      <div class="messages-group">
        <div
          v-for="msg in chat.messages"
          :key="msg.id"
          :class="{ 'msg-enter': msg.animate }"
        >
          <component :is="resolveComponent(msg)" :msg="msg" />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, watch, nextTick, onMounted } from 'vue'
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 containerEl = ref(null)
const contentVisible = ref(false)
let userScrolledUp = false

function resolveComponent(msg) {
  if (msg.role === 'user') return UserMessage
  if (msg.type === 'summary') return SummaryCard
  if (msg.type === 'compression_notice') return CompressionNotice
  if (msg.type === 'error') return ErrorMessage
  return AssistantMessage
}

function scrollToBottom(smooth = false) {
  const el = containerEl.value
  if (!el) return
  el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'instant' })
}

function onScroll() {
  const el = containerEl.value
  if (!el) return
  const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
  userScrolledUp = distFromBottom > 100
}

// Auto-scroll when messages change — skip during history load
watch(
  () => chat.messages.length,
  () => {
    if (!userScrolledUp && !chat.loading) nextTick(() => scrollToBottom())
  }
)

// Auto-scroll during streaming text
watch(
  () => chat.streamingMsg?.text,
  () => {
    if (!userScrolledUp) nextTick(() => scrollToBottom())
  }
)

// Auto-scroll when tool cards are added or complete
watch(
  () => chat.streamingMsg?.tools?.length,
  () => {
    if (!userScrolledUp) nextTick(() => scrollToBottom())
  }
)

// Double rAF ensures browser has fully computed layout before acting
function afterLayout(fn) {
  nextTick(() => requestAnimationFrame(() => requestAnimationFrame(fn)))
}

// When session changes: hide content immediately, reset scroll state
watch(
  () => chat.currentId,
  () => {
    userScrolledUp = false
    contentVisible.value = false
  }
)

// When session finishes loading: scroll to bottom while invisible, then reveal
watch(
  () => chat.loading,
  (loading) => {
    if (!loading) {
      contentVisible.value = false
      afterLayout(() => {
        scrollToBottom()
        contentVisible.value = true
      })
    }
  }
)

// 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
  }
})
</script>

<style scoped>
.messages-group {
  display: flex;
  flex-direction: column;
  gap: 32px;
}
</style>