Newer
Older
navi-1 / webclient / src / components / chat / MessageList.vue
<template>
  <div class="message-list-wrap">
    <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, msgIndex) in chat.messages"
            :key="msg.id"
            :data-msg-index="msgIndex"
            :class="{ 'msg-enter': msg.animate }"
          >
            <component :is="resolveComponent(msg)" :msg="msg" :is-flashing="flashIndex === msgIndex" />
          </div>
        </div>
      </div>
    </div>

    <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 } 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)
const showScrollBtn = ref(false)
const flashIndex = ref(null)
let userScrolledUp = false

function scrollToMessage(rawIndex) {
  const el = containerEl.value
  if (!el) return
  const msgIndex = chat.messages.findIndex(m => m.rawIndices?.includes(rawIndex))
  if (msgIndex === -1) return
  const target = el.querySelector(`[data-msg-index="${msgIndex}"]`)
  if (!target) return
  target.scrollIntoView({ behavior: 'smooth', block: 'center' })
  flashIndex.value = msgIndex
  setTimeout(() => {
    if (flashIndex.value === msgIndex) flashIndex.value = 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) {
  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
  showScrollBtn.value = distFromBottom > 200
}

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

// Auto-scroll during streaming — deep watch because streamingMsg is a shallowRef
watch(
  () => chat.streamingMsg,
  () => {
    if (!userScrolledUp) nextTick(() => scrollToBottom())
  },
  { deep: true }
)

// 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
    showScrollBtn.value = false
    contentVisible.value = false
  }
)

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

// 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>
.message-list-wrap {
  flex: 1;
  min-height: 0;
  position: relative;
  display: flex;
  flex-direction: column;
}

.messages-group {
  display: flex;
  flex-direction: column;
  gap: 32px;
}

.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>