<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>
<div class="recall-filter-bar">
<label class="recall-filter-label">
<input
type="checkbox"
v-model="sessionsStore.hasPendingRecallFilter"
/>
Pending recalls only
</label>
</div>
<DynamicScroller
v-if="sessionsStore.sessions.length"
:key="sessionsStore.searchActive ? 'search' : 'all'"
ref="scrollerRef"
class="session-scroller"
:class="{ 'ptr-pulled': ptrPulling }"
:items="sessionsStore.filteredSessions"
: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"
:search-query="sessionsStore.searchActive ? sessionsStore.searchQuery : ''"
@select="onSelect(item)"
@delete="onDelete(item)"
@pin="onPin(item)"
@cancel-recall="onCancelRecall(item)"
@skip-recall="onSkipRecall(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 * as api from '@/api/index.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)
}
async function onCancelRecall(session) {
try {
await api.cancelSessionRecall(session.session_id)
sessionsStore.updateRecallStatus(session.session_id, false)
} catch (e) {
console.error('cancel recall failed', e)
}
}
async function onSkipRecall(session) {
try {
await api.skipSessionRecall(session.session_id)
} catch (e) {
console.error('skip recall failed', e)
}
}
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;
}
.recall-filter-bar {
padding: 6px 12px;
border-bottom: 1px solid var(--color-border, #3b4261);
font-size: 12px;
color: var(--color-text-dark, #787c99);
}
.recall-filter-label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
}
.recall-filter-label input {
accent-color: var(--color-accent, #7aa2f7);
}
.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>