Newer
Older
navi-1 / webclient / src / components / chat / ChatHeader.vue
<template>
  <div class="chat-header">
    <GnIconButton icon="ph ph-sidebar" :label="t('common.menu')" class="btn-sidebar-toggle" @click="emit('toggle-sidebar')" />

    <div class="chat-header-info">
      <input
        v-if="editing"
        ref="renameEl"
        v-model="renameDraft"
        class="chat-header-rename"
        type="text"
        maxlength="200"
        spellcheck="false"
        @blur="commitRename"
        @keydown.esc.prevent="editing = false"
        @keydown.enter.prevent="renameEl?.blur()"
      />
      <span
        v-else
        class="chat-header-title"
        :title="t('chat.rename')"
        @click="startRename"
      >{{ title }}</span>
      <ProfileBadge v-if="!editing" :profile-id="chatStore.currentProfileId" :show-avatar="false" />
      <ModelChip v-if="!editing" />
    </div>

    <ContextBar @info="infoOpen = true" />

    <!-- Always visible: the ModelChip above only appears on a backup entry,
         and "which model/servers is this session on?" is a question on a
         healthy day too. -->
    <GnIconButton
      icon="ph ph-info"
      :label="t('chat.sessionInfo')"
      class="btn-info-toggle"
      @click="infoOpen = true"
    />

    <GnIconButton icon="ph ph-files" :label="t('artifacts.tabArtifacts')" class="btn-artifacts-toggle" @click="emit('toggle-artifacts')">
      <span v-if="artifactCount" class="artifact-count">{{ artifactCount }}</span>
    </GnIconButton>
  </div>

  <SessionInfoModal v-model:open="infoOpen" />

  <div v-if="chatStore.recall" class="recall-banner">
    <button
      class="recall-open"
      :title="t('chat.recallDetailsHint')"
      @click="recallOpen = true"
    >
      <i class="ph ph-clock"></i>
      <span class="recall-text">{{ recallText }}</span>
    </button>
    <button class="recall-btn" :title="t('sidebar.cancelRecall')" @click="cancelRecall">
      <i class="ph ph-x-circle"></i>
    </button>
    <button v-if="chatStore.recall.call_type === 'recurring'" class="recall-btn" :title="t('sidebar.skipNext')" @click="skipRecall">
      <i class="ph ph-skip-forward"></i>
    </button>
  </div>

  <RecallInfoModal v-model:open="recallOpen" />
</template>

<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import { useChatStore } from '@/stores/chat.js'
import { useSessionsStore } from '@/stores/sessions.js'
import { locale, recallTypeLabel, t } from '@/i18n/index.js'
import ProfileBadge from '@/components/ui/ProfileBadge.vue'
import ModelChip from '@/components/ui/ModelChip.vue'
import ContextBar from '@/components/ui/ContextBar.vue'
import SessionInfoModal from '@/components/chat/SessionInfoModal.vue'
import RecallInfoModal from '@/components/chat/RecallInfoModal.vue'
import { useToast } from 'gnexus-ui-kit/vue'

const emit = defineEmits(['toggle-sidebar', 'toggle-artifacts'])

const chatStore = useChatStore()
const sessionsStore = useSessionsStore()
const toast = useToast()

const infoOpen = ref(false)
const recallOpen = ref(false)

const title = computed(() => {
  const session = sessionsStore.sessions.find(s => s.session_id === chatStore.currentId)
  return session?.name || (chatStore.currentId?.slice(0, 8) ?? 'Navi')
})

const artifactCount = computed(() => chatStore.artifacts.length)

// The recall banner names the kind of recall inside a sentence, so the word
// itself is a slug rather than a server enum read out loud.
const recallText = computed(() => {
  const recall = chatStore.recall
  if (!recall) return ''
  const type = recallTypeLabel(recall.call_type)
  return t('chat.scheduledRecall', { type, time: formatTime(recall.trigger_at) })
})

// ─── Title editing ──────────────────────────────────────────────────────────
// Click the title → an input replaces it, full header width. Blur commits;
// no cancel button — an untouched field loses focus harmlessly. An emptied
// field asks the server to regenerate the name from session content.
const editing = ref(false)
const renameDraft = ref('')
const renameEl = ref(null)

function startRename() {
  renameDraft.value = title.value
  editing.value = true
  nextTick(() => {
    renameEl.value?.focus()
    renameEl.value?.select()
  })
}

async function commitRename() {
  if (!editing.value) return
  const id = chatStore.currentId
  const draft = renameDraft.value
  editing.value = false
  if (!id) return

  const saved = draft === title.value ? null : draft.trim()
  try {
    const res = saved === null
      ? { name: title.value }  // unchanged — no request at all
      : await api.renameSession(id, saved)  // empty string → server regenerates
    if (res?.name) {
      sessionsStore.updateName(id, res.name)  // also fills the nameById map
    }
  } catch (e) {
    console.error('rename session failed', e)
  }
}

function formatTime(iso) {
  if (!iso) return ''
  const d = new Date(iso)
  return d.toLocaleString(locale.value)
}

watch(() => chatStore.currentId, chatStore.loadRecall, { immediate: true })

async function cancelRecall() {
  if (!(await chatStore.cancelRecall())) {
    toast.error({ title: t('chat.recallCancelFailed') })
  }
}

async function skipRecall() {
  if (!(await chatStore.skipRecall())) {
    toast.error({ title: t('chat.recallSkipFailed') })
  }
}
</script>

<style scoped>
.btn-artifacts-toggle {
  position: relative;
}

.artifact-count {
  position: absolute;
  top: -3px;
  right: -3px;
  min-width: 16px;
  height: 16px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0 4px;
  font-size: 10px;
  line-height: 1;
  background: var(--gn-color-secondary);
  color: var(--gn-color-black);
}

.recall-banner {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 12px;
  background: color-mix(in srgb, var(--gn-color-secondary) 12%, transparent);
  border-bottom: 1px solid var(--gn-border-color-muted);
  font-size: 12px;
  color: var(--gn-color-text-medium);
}

/* The banner is a row of buttons: this one opens the details dialog and takes
   the space the text needs. */
.recall-open {
  flex: 1;
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  padding: 0;
  background: none;
  border: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  border-radius: 4px;
}

.recall-open:hover .recall-text {
  color: var(--gn-color-text-dark);
}

.recall-open:focus-visible {
  outline: 2px solid var(--gn-color-primary);
  outline-offset: 2px;
}

.recall-banner i {
  color: var(--gn-color-secondary);
  font-size: 14px;
}

.recall-text {
  flex: 1;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.recall-btn {
  background: none;
  border: none;
  color: var(--gn-color-text-dark);
  cursor: pointer;
  padding: 4px 6px;
  font-size: 15px;
  line-height: 1;
  border-radius: 4px;
  transition: color 0.15s, background 0.15s;
}

.recall-btn:hover {
  color: var(--gn-color-text-medium);
  background: color-mix(in srgb, var(--gn-color-text-light) 6%, transparent);
}
</style>