<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" />
<!-- Session info and sharing sit behind one menu: as two separate icons
they eat the header on a phone, where the title and context bar need
the room more. -->
<GnDropdown class="chat-header-menu" :items="menuItems">
<template #trigger="{ open, toggle }">
<GnIconButton
icon="ph ph-dots-three-outline"
:label="t('chat.moreActions')"
class="btn-more-toggle"
:aria-expanded="open ? 'true' : 'false'"
@click="toggle"
/>
</template>
</GnDropdown>
<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" />
<ShareChatModal v-model:open="shareOpen" />
<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 ShareChatModal from '@/components/chat/ShareChatModal.vue'
import RecallInfoModal from '@/components/chat/RecallInfoModal.vue'
import { renameSession } from '@/api/index.js'
import { useConfirm } from '@/composables/useConfirm.js'
import { GnDropdown, useToast } from 'gnexus-ui-kit/vue'
const emit = defineEmits(['toggle-sidebar', 'toggle-artifacts'])
const chatStore = useChatStore()
const sessionsStore = useSessionsStore()
const toast = useToast()
const confirm = useConfirm()
const infoOpen = ref(false)
const shareOpen = ref(false)
const recallOpen = ref(false)
// Secondary header actions, in menu order. Computed rather than a constant so
// the labels follow the interface language (and the share row appears only
// once there is a session to share).
const menuItems = computed(() => {
const items = [
{
label: t('chat.sessionInfo'),
icon: 'ph-info',
onSelect: () => { infoOpen.value = true },
},
]
if (chatStore.currentId) {
items.push({
label: t('share.title'),
icon: 'ph-share-network',
onSelect: () => { shareOpen.value = true },
})
items.push({
label: t('chat.deleteSession'),
icon: 'ph-trash',
danger: true,
onSelect: deleteCurrent,
})
}
return items
})
// Deleting from the header is the phone-friendly path: the sidebar is a
// drawer there, and closing a chat mid-way to go hunting for it in the list
// is the long way around. Confirmation and the resulting welcome screen match
// what deleting from the list does.
async function deleteCurrent() {
const id = chatStore.currentId
if (!id) return
if (!(await confirm(t('confirm.deleteSession')))) return
try {
await sessionsStore.deleteSession(id)
chatStore.clearSession()
} catch (e) {
console.error('delete session failed', e)
toast.error({ title: t('common.error') })
}
}
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 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>
/* The trigger sits near the right edge, so the panel must open leftwards —
the kit anchors it to the left by default and would push it off-screen. */
.chat-header-menu :deep(.dropdown-menu) {
left: auto;
right: 0;
min-width: 200px;
transform-origin: top right;
}
.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>