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