Newer
Older
navi-1 / webclient / src / components / sidebar / SessionList.vue
<template>
  <div
    class="session-list-wrap"
    @touchstart="onTouchStart"
    @touchmove="onTouchMove"
    @touchend="onTouchEnd"
  >
    <!-- Pull-to-refresh indicator (mobile only) -->
    <div
      class="ptr-indicator"
      :class="{ 'ptr-active': ptrPulling && !sessionsStore.loading }"
      :style="ptrStyle"
    >
      <span
        class="ptr-spinner"
        :class="{ 'ptr-spinning': ptrTriggered }"
      ></span>
      <span class="ptr-label">{{ ptrLabel }}</span>
    </div>

    <div
      v-if="sessionsStore.loading && !sessionsStore.sessions.length"
      class="sessions-loading"
    >
      <span class="sessions-spinner"></span>
      <p>Loading conversations...</p>
    </div>

    <div
      v-else-if="!sessionsStore.sessions.length"
      class="empty-sessions"
    >
      <i class="ph ph-chat-dots"></i>
      <p>No conversations yet</p>
    </div>

    <DynamicScroller
      v-else
      ref="scrollerRef"
      class="session-scroller"
      :class="{ 'ptr-pulled': ptrPulling }"
      :items="sessionsStore.sessions"
      :min-item-size="64"
      key-field="session_id"
      @scroll.passive="onScroll"
    >
      <template #default="{ item, index, active }">
        <DynamicScrollerItem :item="item" :active="active" :data-index="index">
          <SessionItem
            :session="item"
            :active="item.session_id === chatStore.currentId"
            @select="onSelect(item)"
            @delete="onDelete(item)"
            @pin="onPin(item)"
          />
        </DynamicScrollerItem>
      </template>
    </DynamicScroller>

    <div
      v-if="sessionsStore.loadingMore"
      class="sessions-loading sessions-loading-more"
    >
      <span class="sessions-spinner"></span>
    </div>
  </div>
</template>

<script setup>
import { ref, watch, computed } from 'vue'
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { useSessionsStore } from '@/stores/sessions.js'
import { useChatStore } from '@/stores/chat.js'
import { useProfilesStore } from '@/stores/profiles.js'
import SessionItem from './SessionItem.vue'

const emit = defineEmits(['select'])

const sessionsStore = useSessionsStore()
const chatStore = useChatStore()
const profilesStore = useProfilesStore()

watch(
  () => profilesStore.selectedProfileId,
  (profileId) => {
    if (profileId && profileId !== sessionsStore.currentProfileId) {
      sessionsStore.fetchSessions(profileId)
    }
  }
)

async function onSelect(session) {
  await chatStore.loadSession(session.session_id)
  emit('select', session)
}

async function onDelete(session) {
  const wasActive = session.session_id === chatStore.currentId
  await sessionsStore.deleteSession(session.session_id)
  if (wasActive) {
    chatStore.clearSession()
  }
}

async function onPin(session) {
  await sessionsStore.pinSession(session.session_id, !session.pinned)
}

function onScroll(event) {
  const el = event.target
  const distanceToBottom = el.scrollHeight - el.scrollTop - el.clientHeight
  if (distanceToBottom < 160) {
    sessionsStore.fetchMoreSessions()
  }
}

// ── Pull-to-refresh (mobile) ───────────────────────────────────────────
const PTR_THRESHOLD = 80   // px to trigger refresh
const PTR_MAX = 120        // max visual pull distance

const ptrPulling = ref(false)
const ptrOffset = ref(0)
const ptrTriggered = ref(false)
const scrollerRef = ref(null)

const ptrStyle = computed(() => ({
  transform: `translateY(${Math.min(ptrOffset.value, PTR_MAX)}px)`,
  opacity: ptrPulling.value ? Math.min(ptrOffset.value / PTR_THRESHOLD, 1) : 0,
}))

const ptrLabel = computed(() => {
  if (sessionsStore.loading) return 'Refreshing...'
  if (ptrTriggered.value) return 'Release to refresh'
  return 'Pull to refresh'
})

let _ptrStartY = 0
let _ptrStartScroll = 0

function onTouchStart(e) {
  const scroller = scrollerRef.value?.$el
  if (!scroller) return
  _ptrStartScroll = scroller.scrollTop || 0
  if (_ptrStartScroll > 2) return
  _ptrStartY = e.touches[0]?.clientY ?? 0
  ptrPulling.value = false
  ptrOffset.value = 0
  ptrTriggered.value = false
}

function onTouchMove(e) {
  if (_ptrStartScroll > 2 || !_ptrStartY) return
  const y = e.touches[0]?.clientY ?? 0
  const delta = y - _ptrStartY
  if (delta <= 0) {
    ptrPulling.value = false
    return
  }
  // Prevent default only when we are actually pulling (top of list)
  if (delta > 4) {
    e.preventDefault()
  }
  ptrPulling.value = true
  ptrOffset.value = delta
  ptrTriggered.value = delta >= PTR_THRESHOLD
}

function onTouchEnd() {
  if (ptrTriggered.value && !sessionsStore.loading) {
    sessionsStore.fetchSessions(profilesStore.selectedProfileId)
  }
  ptrPulling.value = false
  ptrOffset.value = 0
  ptrTriggered.value = false
  _ptrStartY = 0
  _ptrStartScroll = 0
}
</script>

<style scoped>
.session-list-wrap {
  flex: 1;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.session-scroller {
  flex: 1;
  overflow-y: auto;
}

.empty-sessions {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 32px 16px;
  color: var(--color-text-dark, #787c99);
  font-size: 13px;

  i { font-size: 32px; opacity: 0.4; }
  p { margin: 0; }
}

.sessions-loading {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 28px 16px;
  color: var(--color-text-dark, #787c99);
  font-size: 12px;
}

.sessions-loading-more {
  padding: 10px 16px 14px;
}

.sessions-spinner {
  width: 16px;
  height: 16px;
  border: 2px solid var(--color-border, #3b4261);
  border-top-color: var(--color-accent, #7aa2f7);
  border-radius: 50%;
  animation: sessions-spin 0.8s linear infinite;
}

@keyframes sessions-spin {
  to { transform: rotate(360deg); }
}

/* ── Pull-to-refresh (mobile) ── */
.ptr-indicator {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 8px;
  font-size: 12px;
  color: var(--color-text-dark, #787c99);
  pointer-events: none;
  transition: opacity 0.15s, transform 0.15s;
  will-change: transform, opacity;
}

.ptr-spinner {
  width: 16px;
  height: 16px;
  border: 2px solid var(--color-border, #3b4261);
  border-top-color: var(--color-accent, #7aa2f7);
  border-radius: 50%;
  transition: transform 0.15s;
}

.ptr-spinning {
  animation: sessions-spin 0.8s linear infinite;
}

.ptr-pulled {
  transition: transform 0.15s;
}

@media (min-width: 769px) {
  .ptr-indicator { display: none; }
}
</style>