Newer
Older
navi-1 / webclient / src / components / chat / MessageList.vue
<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>

    <!-- Plain DOM list. Off-screen rows skip rendering/paint/layout via
         content-visibility: auto (CSS on .message-row) — the engine-free
         replacement for the abandoned vue-virtual-scroller list, whose
         measurement states duplicated/parked item views and broke card
         toggling. -->
    <div
      v-if="chat.messages.length"
      ref="listRef"
      class="message-scroller"
      :class="{ 'is-hidden': !contentVisible }"
      @wheel.passive="markUserInput"
      @pointerdown.passive="markUserInput"
      @touchstart.passive="markUserInput"
      @scroll.passive="onScroll"
    >
      <div
        v-for="(item, index) in chat.messages"
        :key="item.id ?? index"
        class="message-row"
        :data-msg-index="index"
        :class="{ 'msg-enter': item.animate }"
      >
        <component :is="resolveComponent(item)" :msg="item" :is-flashing="flashIndex === index" />
      </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, onUnmounted } 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 listRef = ref(null)
const contentVisible = ref(false)
const showScrollBtn = ref(false)
const flashIndex = ref(null)
let userScrolledUp = false
let scrollAnchorIndex = null
let scrollAnchorScreenTop = null
let flashTimeout = null
let convergeChain = 0
let lastUserInputAt = 0

function containerEl() {
  return listRef.value ?? null
}

// Real user input (wheel / pointer / touch). Scroll events are NOT usable for
// this while content-visibility rows settle: unmeasured heights move
// scrollHeight 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
  const el = containerEl()
  const row = el?.querySelector(`[data-msg-index="${msgIndex}"]`)
  if (row) {
    row.scrollIntoView({ block: 'center', behavior: 'smooth' })
  } else {
    el?.scrollTo({ top: 0, behavior: 'smooth' })
  }
  // The 3s flash window covers the smooth scroll's 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()
}

// Landing: newly-mounted rows far from the viewport skip layout
// (content-visibility), so scrollHeight is not final at the first clamp —
// re-clamp until the bottom really holds.
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
  const deadline = performance.now() + 2500
  const loop = async () => {
    while (performance.now() < deadline) {
      if (chain !== convergeChain) return
      // Content 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
      await new Promise(r => requestAnimationFrame(r))
      if (chain !== convergeChain) return
    }
  }
  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 the anchor row's on-screen position so it can be pinned back
    // 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)
        scrollAnchorScreenTop = Math.round(row.getBoundingClientRect().top)
        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 }
)

// Stream finished: the pin above fires per delta, but the last things to land
// — the stats/rating footer (rendered only when msg.done) and the copy buttons
// attached to code blocks after render — arrive *after* that final pin, so the
// view is left short of the bottom. Re-clamp through the settle window the way
// a session landing does, instead of guessing one more frame.
watch(
  () => chat.streaming,
  (streaming, wasStreaming) => {
    if (wasStreaming && !streaming && !userScrolledUp && !chat.loading) convergeToBottom()
  }
)

// Restore scroll position after archived messages are prepended: the anchor
// row must stay at the same on-screen position — shift scrollTop by the
// difference its rect moved (it slides down by the prepended block height).
watch(
  () => chat.archiveLoading,
  (loading) => {
    if (!loading && scrollAnchorIndex != null) {
      nextTick(() => {
        const el = containerEl()
        const row = el?.querySelector(`[data-msg-index="${scrollAnchorIndex}"]`)
        if (el && row && scrollAnchorScreenTop != null) {
          const delta = Math.round(row.getBoundingClientRect().top) - scrollAnchorScreenTop
          if (delta) el.scrollTop += delta
        }
        scrollAnchorIndex = null
        scrollAnchorScreenTop = null
      })
    }
  }
)

// When session changes: reset landing chain and scroll state.
watch(
  () => chat.currentId,
  () => {
    convergeChain++ // invalidate any pending landing loop
    userScrolledUp = false
    showScrollBtn.value = false
    contentVisible.value = false
    scrollAnchorIndex = null
    scrollAnchorScreenTop = 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;
  /* The first message mustn't hug the chat header */
  padding-top: 16px;

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

  /* Browser-level virtualization for long sessions: off-viewport rows skip
     render/paint (and mostly layout); contain-intrinsic-size keeps the
     scrollbar stable with the min row height as the estimate. */
  content-visibility: auto;
  contain-intrinsic-size: auto 140px;
}

@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(--gn-color-text-medium);
  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(--gn-color-text-medium);
  background: 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(--gn-color-secondary);
  color: var(--gn-color-black);
  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>