<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-bar">
<GnIconButton
icon="ph ph-magnifying-glass"
label="Search"
class="search-toggle-btn"
:class="{ active: searchOpen || sessionsStore.searchActive }"
@click="toggleSearch"
/>
<div v-if="searchOpen" class="search-input-wrap">
<input
ref="searchInputEl"
v-model="searchInput"
type="text"
placeholder="Search conversations..."
class="search-input"
@keydown.esc="closeSearch"
/>
<GnIconButton
v-if="searchInput"
icon="ph ph-x"
label="Clear"
class="search-clear-btn"
@click="clearSearchInput"
/>
</div>
</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>
<GnIconButton
icon="ph ph-arrow-clockwise"
label="Refresh"
class="sessions-refresh-btn"
:class="{ 'is-spinning': sessionsStore.loading }"
@click="sessionsStore.fetchSessions(profilesStore.selectedProfileId)"
/>
</div>
<SessionList @select="handleSelect" />
</div>
<div class="sidebar-footer">
<template v-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">
<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 searchInputEl = ref(null)
let searchDebounceTimer = null
// 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) {
// Restore previous query if any
if (sessionsStore.searchQuery) {
searchInput.value = sessionsStore.searchQuery
}
setTimeout(() => searchInputEl.value?.focus(), 50)
}
}
function closeSearch() {
searchOpen.value = false
}
function clearSearchInput() {
searchInput.value = ''
sessionsStore.clearSearch()
sessionsStore.fetchSessions(profilesStore.selectedProfileId)
searchInputEl.value?.focus()
}
// 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,
() => {
searchOpen.value = false
}
)
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])
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 {
padding: 0 $space-md;
border-bottom: 1px solid var(--color-border);
display: none;
}
.sidebar-search.is-open {
display: block;
padding-top: $space-sm;
padding-bottom: $space-sm;
}
.sidebar-search-bar {
display: flex;
align-items: center;
gap: $space-xs;
}
.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;
}
.search-input-wrap {
flex: 1;
display: flex;
align-items: center;
gap: $space-xs;
position: relative;
}
.search-input {
flex: 1;
min-width: 0;
background: $surface-panel-muted;
border: $border-width-base $border-style-base $border-color-muted;
border-bottom-width: $border-width-accent;
border-bottom-color: $color-text-light;
color: $color-text-light;
font-size: 13px;
padding: $space-xs $space-sm;
outline: none;
font-family: inherit;
transition: border-color $motion-base $motion-ease;
}
.search-input:focus {
border-color: $color-secondary;
}
.search-input::placeholder {
color: $color-text-dark;
}
.search-clear-btn {
font-size: 12px;
color: $color-text-dark;
}
.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>