<template>
<aside class="app-sidebar" :class="{ 'is-mobile-open': props.mobileOpen }">
<div class="sidebar-header">
<div class="sidebar-logo">
<img src="/images/logo.svg" alt="Navi" />
<span>Navi</span>
<GnIconButton icon="ph ph-x" label="Close" class="sidebar-close-btn" @click="emit('close')" />
</div>
<div class="sidebar-controls-row">
<div class="sidebar-profile-select">
<select
:value="profilesStore.selectedProfileId"
@change="profilesStore.selectedProfileId = $event.target.value"
>
<option
v-for="p in profilesStore.profiles"
:key="p.id"
:value="p.id"
>{{ p.name }}</option>
</select>
</div>
<GnButton variant="primary" icon="ph ph-plus" @click="handleNewSession">
New Chat
</GnButton>
</div>
</div>
<div class="sidebar-search" :class="{ 'is-open': searchOpen }">
<div
class="sidebar-search-inner"
:class="{ 'is-loading': sessionsStore.loading && sessionsStore.searchActive }"
>
<GnSearchField
ref="searchFieldEl"
v-model="searchInput"
placeholder="Search conversations..."
compact
clearable
@clear="onSearchClear"
@keydown.esc="closeSearch"
/>
</div>
</div>
<div class="sidebar-sessions">
<div class="sessions-header">
<div class="sessions-label">
<template v-if="sessionsStore.searchActive">
{{ sessionsStore.sessions.length }} result{{ sessionsStore.sessions.length === 1 ? '' : 's' }}
</template>
<template v-else>Conversations</template>
</div>
<div class="sessions-header-actions">
<GnIconButton
icon="ph ph-clock"
label="Pending recalls only"
class="recall-filter-btn"
:class="{ active: sessionsStore.hasPendingRecallFilter }"
@click="sessionsStore.hasPendingRecallFilter = !sessionsStore.hasPendingRecallFilter"
/>
<GnIconButton
icon="ph ph-magnifying-glass"
label="Search"
class="search-toggle-btn"
:class="{ active: searchOpen || sessionsStore.searchActive }"
@click="toggleSearch"
/>
<GnIconButton
icon="ph ph-arrow-clockwise"
label="Refresh"
class="sessions-refresh-btn"
:class="{ 'is-spinning': sessionsStore.loading }"
@click="sessionsStore.fetchSessions(profilesStore.selectedProfileId)"
/>
</div>
</div>
<SessionList @select="handleSelect" />
</div>
<div class="sidebar-footer">
<template v-if="authStore.isNoAuthMode">
<div class="user-info" title="Authorization is disabled">
<GnAvatar size="sm" src="" alt="local" initials="L" />
<span class="user-name">Local mode</span>
</div>
<div class="sidebar-footer-actions">
<GnIconButton
icon="ph ph-gear"
label="Settings"
without-hover
@click="openSettings"
/>
<a
href="/admin"
target="_blank"
rel="noopener noreferrer"
class="btn-icon without-hover admin-link"
title="Admin"
>
<i class="ph ph-shield-check"></i>
</a>
</div>
</template>
<template v-else-if="authStore.isAuthenticated">
<a
class="user-info"
:href="authStore.user?.profile_url"
target="_blank"
rel="noopener noreferrer"
title="Open profile in gnexus-auth"
>
<GnAvatar
size="sm"
:src="authStore.user?.avatar_url || ''"
:alt="authStore.user?.display_name || 'avatar'"
:initials="(authStore.user?.display_name || authStore.user?.email || '?').charAt(0).toUpperCase()"
/>
<span class="user-name">{{ authStore.user?.display_name || authStore.user?.email }}</span>
</a>
<div class="sidebar-footer-actions">
<GnIconButton
icon="ph ph-gear"
label="Settings"
without-hover
@click="openSettings"
/>
<a
v-if="authStore.isAdmin"
href="/admin"
target="_blank"
rel="noopener noreferrer"
class="btn-icon without-hover admin-link"
title="Admin"
>
<i class="ph ph-shield-check"></i>
</a>
<GnIconButton icon="ph ph-sign-out" label="Logout" without-hover @click="authStore.logout" />
</div>
</template>
<template v-else>
<GnButton variant="primary" icon="ph ph-sign-in" @click="authStore.login">
Login
</GnButton>
</template>
</div>
</aside>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { useProfilesStore } from '@/stores/profiles.js'
import { useSessionsStore } from '@/stores/sessions.js'
import { useChatStore } from '@/stores/chat.js'
import { useAuthStore } from '@/stores/auth.js'
import SessionList from './SessionList.vue'
const props = defineProps({
mobileOpen: Boolean
})
const emit = defineEmits(['close'])
const profilesStore = useProfilesStore()
const sessionsStore = useSessionsStore()
const chatStore = useChatStore()
const authStore = useAuthStore()
const searchOpen = ref(false)
const searchInput = ref('')
const searchFieldEl = ref(null)
let searchDebounceTimer = null
function focusSearchInput() {
const wrap = searchFieldEl.value?.$el ?? searchFieldEl.value
if (!wrap) return
const input = wrap.querySelector('input')
input?.focus()
}
// Keep selector in sync with the active session's profile
watch(
() => chatStore.currentProfileId,
(id) => { if (id) profilesStore.selectedProfileId = id }
)
// Toggle search UI
function toggleSearch() {
searchOpen.value = !searchOpen.value
if (searchOpen.value) {
setTimeout(focusSearchInput, 50)
if (sessionsStore.searchQuery) {
searchInput.value = sessionsStore.searchQuery
sessionsStore.restoreSearch()
}
}
}
function closeSearch() {
if (!searchOpen.value) return
searchOpen.value = false
sessionsStore.exitSearch()
sessionsStore.fetchSessions(profilesStore.selectedProfileId)
}
function onSearchClear() {
searchInput.value = ''
sessionsStore.clearSearch()
sessionsStore.fetchSessions(profilesStore.selectedProfileId)
}
// Debounced search
watch(searchInput, (val) => {
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
if (!val) {
sessionsStore.clearSearch()
sessionsStore.fetchSessions(profilesStore.selectedProfileId)
return
}
searchDebounceTimer = setTimeout(() => {
sessionsStore.setSearch(val)
sessionsStore.fetchSessions()
}, 300)
})
// Close search when profile changes (but keep state)
watch(
() => profilesStore.selectedProfileId,
() => {
if (searchOpen.value) {
searchOpen.value = false
sessionsStore.exitSearch()
sessionsStore.fetchSessions(profilesStore.selectedProfileId)
}
}
)
async function handleNewSession() {
const session = await sessionsStore.createSession(profilesStore.selectedProfileId)
await chatStore.loadSession(session.session_id)
emit('close')
}
async function handleSelect(session) {
// If selected from search, switch to the session's profile
if (sessionsStore.searchActive && session.profile_id) {
profilesStore.selectedProfileId = session.profile_id
}
await chatStore.loadSession(session.session_id, session.match_indices?.[0])
searchOpen.value = false
sessionsStore.exitSearch()
sessionsStore.fetchSessions(profilesStore.selectedProfileId)
emit('close')
}
function openSettings() {
location.hash = '#settings'
emit('close')
}
// Keyboard shortcut: Ctrl/Cmd + K
function onKeyDown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
toggleSearch()
}
}
onMounted(() => window.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeyDown))
</script>
<style scoped lang="scss">
@use 'kit-deps' as *;
.sidebar-close-btn {
display: none;
@media (max-width: 1280px) {
display: flex;
margin-left: auto;
}
}
.sidebar-search {
display: none;
padding: 0 $space-md;
}
.sidebar-search.is-open {
display: block;
padding-top: $space-sm;
padding-bottom: $space-sm;
border-bottom: 1px solid var(--color-border);
}
.sidebar-search-inner {
position: relative;
display: flex;
align-items: center;
gap: $space-xs;
:deep(.search-field) {
flex: 1;
min-width: 0;
}
&.is-loading :deep(.input-group-addon) {
position: relative !important;
> i {
display: none !important;
}
&::after {
content: '';
display: block;
width: 12px;
height: 12px;
border: 2px solid $border-color-muted;
border-top-color: $color-secondary;
border-radius: 50%;
animation: spin 0.6s linear infinite;
margin: auto;
}
}
}
.sessions-header-actions {
display: flex;
align-items: center;
gap: 2px;
}
.search-toggle-btn {
font-size: 14px;
color: $color-text-dark;
transition: color 0.15s;
}
.search-toggle-btn.active,
.search-toggle-btn:hover {
color: $color-text-light;
}
.recall-filter-btn {
font-size: 14px;
color: $color-text-dark;
transition: color 0.15s;
}
.recall-filter-btn.active,
.recall-filter-btn:hover {
color: var(--color-accent, #7aa2f7);
}
.sidebar-footer {
padding: 12px 16px;
border-top: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: inherit;
flex: 1;
min-width: 0;
cursor: pointer;
}
.user-info:hover .user-name {
color: var(--color-text-primary);
}
.user-name {
font-size: 13px;
color: var(--color-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidebar-footer-actions {
display: flex;
align-items: center;
gap: 8px;
}
.admin-link {
text-decoration: none;
}
</style>