<template>
<GnModal
:open="open"
:title="t('chat.sessionInfo')"
@update:open="emit('update:open', $event)"
>
<div class="session-info">
<div v-if="loading && !info" class="si-muted si-loading">
<i class="ph ph-circle-notch si-spin"></i>
{{ t('chat.infoLoading') }}
</div>
<p v-else-if="error" class="si-error">
<i class="ph ph-warning-circle"></i>
{{ error }}
</p>
<template v-else-if="info">
<div class="si-grid">
<section class="si-section">
<h4 class="si-heading">{{ t('chat.infoModelSection') }}</h4>
<div v-if="active" class="si-active" :class="{ 'is-backup': !active.primary }">
<i :class="active.primary ? 'ph ph-cloud-check' : 'ph ph-plug-charging'"></i>
<div class="si-active-text">
<span class="si-model">{{ active.model }}</span>
<span class="si-server">
{{ active.target }}<template v-if="active.host"> · {{ active.host }}</template>
</span>
</div>
<GnBadge v-if="!active.primary" variant="warning">{{ t('chat.infoBackup') }}</GnBadge>
<GnBadge v-else variant="success" outline>{{ t('chat.infoPrimary') }}</GnBadge>
</div>
<p v-else class="si-muted">{{ t('chat.infoNoRoute') }}</p>
<GnDefinitionList :items="modelRows" class="si-rows" />
<div v-if="chain.length" class="si-chain">
<div class="si-subheading">{{ t('chat.infoRouteSection') }}</div>
<ul class="si-chain-list">
<li
v-for="entry in chain"
:key="`${entry.target}:${entry.model}`"
class="si-chain-item"
:class="{ 'is-active': isServing(entry) }"
>
<span class="si-chain-dot" :class="`is-${entry.state}`"></span>
<span class="si-chain-model">{{ entry.model }}</span>
<span class="si-chain-target" :title="entry.host">{{ entry.target }}</span>
<span class="si-chain-state" :title="entry.reason || ''">
{{ stateLabel(entry.state) }}
</span>
</li>
</ul>
</div>
</section>
<section class="si-section">
<h4 class="si-heading">{{ t('chat.infoSessionSection') }}</h4>
<GnDefinitionList :items="sessionRows" class="si-rows">
<template #id="{ item }">
<span class="si-id-row">
<span class="si-id">{{ item.value }}</span>
<GnCopyButton :text="item.value" size="sm" :label="t('common.copy')" />
</span>
</template>
</GnDefinitionList>
</section>
</div>
<section class="si-section si-live">
<h4 class="si-heading">{{ t('chat.infoLiveSection') }}</h4>
<div class="si-live-grid">
<div v-for="row in liveRows" :key="row.key" class="si-live-item">
<span class="si-live-term">{{ row.term }}</span>
<span class="si-live-value" :class="`is-${row.tone}`">{{ row.value }}</span>
</div>
</div>
</section>
</template>
</div>
<template #actions>
<GnButton
variant="secondary"
icon="ph-arrows-clockwise"
:loading="loading"
@click="load"
>
{{ t('chat.infoRefresh') }}
</GnButton>
<GnButton variant="primary" @click="emit('update:open', false)">
{{ t('common.close') }}
</GnButton>
</template>
</GnModal>
</template>
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { GnBadge, GnButton, GnCopyButton, GnDefinitionList, GnModal } from 'gnexus-ui-kit/vue'
import { getSessionInfo } from '@/api/index.js'
import { useChatStore } from '@/stores/chat.js'
import { locale, recallTypeLabel, t } from '@/i18n/index.js'
import { intervalText } from '@/utils/recall.js'
// While the dialog is open its live block must not go stale: a turn started
// from another tab, a reconnect, a background task finishing. Three seconds is
// cheap — the endpoint reads in-process state only.
const POLL_MS = 3000
const props = defineProps({
open: Boolean,
})
const emit = defineEmits(['update:open'])
const chat = useChatStore()
const info = ref(null)
const loading = ref(false)
const error = ref('')
let timer = null
async function load() {
const id = chat.currentId
if (!id) return
loading.value = true
try {
const data = await getSessionInfo(id)
if (chat.currentId !== id) return // switched sessions mid-flight
info.value = data
error.value = ''
} catch (e) {
if (chat.currentId !== id) return
error.value = t('chat.infoError')
console.error('[session-info] load failed', e)
} finally {
loading.value = false
}
}
function stopPolling() {
if (timer !== null) {
clearInterval(timer)
timer = null
}
}
watch(
() => props.open,
open => {
stopPolling()
if (!open) return
info.value = null
error.value = ''
load()
timer = setInterval(load, POLL_MS)
},
{ immediate: true }
)
// A different session under an open dialog is a different answer.
watch(
() => chat.currentId,
() => {
if (!props.open) return
info.value = null
load()
}
)
onUnmounted(stopPolling)
// ─── Derived view ───────────────────────────────────────────────────────────
const active = computed(() => info.value?.llm?.active ?? null)
const chain = computed(() => info.value?.llm?.chain ?? [])
const modelRows = computed(() => {
const llm = info.value?.llm
if (!llm) return []
const rows = []
if (llm.route) {
rows.push({ key: 'route', term: t('chat.infoRoute'), value: llm.route })
}
if (llm.active) {
rows.push({
key: 'window',
term: t('chat.infoWindow'),
value: `${num(llm.active.context_window)} ${t('chat.infoTokens')}`,
})
} else if (llm.models?.length) {
rows.push({
key: 'models',
term: t('chat.infoConfiguredModels'),
value: llm.models.join(', '),
})
}
return rows
})
const contextText = computed(() => {
const s = info.value
if (!s) return '—'
const pct = s.max_context_tokens
? Math.min(100, Math.round((s.context_token_count / s.max_context_tokens) * 100))
: 0
return `${num(s.context_token_count)} / ${num(s.max_context_tokens)} (${pct}%)`
})
const archiveText = computed(() => {
const threshold = info.value?.archive_threshold ?? 0
return threshold > 0 ? t('chat.infoArchiveValue', { seq: num(threshold) }) : t('chat.infoNone')
})
const compressionText = computed(() => {
const c = info.value?.compression
if (!c) return '—'
if (!c.enabled) return t('chat.infoCompressionOff')
return t('chat.infoCompressionValue', {
pct: Math.round(c.threshold * 100),
keep: c.keep_recent,
max: num(c.summary_max_tokens),
})
})
// The dialog polls, so a recall scheduled or cancelled elsewhere shows up here
// on the next tick — the row exists only while one is pending.
const recallText = computed(() => {
const r = info.value?.recall
if (!r) return ''
const parts = [recallTypeLabel(r.call_type)]
if (r.call_type === 'recurring' && r.interval_seconds) {
parts.push(t('chat.infoRecallEvery', { interval: intervalText(r.interval_seconds) }))
}
parts.push(formatTime(r.trigger_at))
return parts.join(' · ')
})
const sessionRows = computed(() => {
const s = info.value
if (!s) return []
const rows = [
{ key: 'id', term: t('chat.infoSessionId'), value: s.session_id },
{ key: 'profile', term: t('chat.infoProfile'), value: s.profile_name || s.profile_id || '—' },
{ key: 'created', term: t('chat.infoCreated'), value: formatTime(s.created_at) },
{ key: 'active', term: t('chat.infoLastActive'), value: formatTime(s.last_active) },
{ key: 'messages', term: t('chat.infoMessages'), value: num(s.message_count) },
{ key: 'archive', term: t('chat.infoArchive'), value: archiveText.value },
{ key: 'context', term: t('chat.infoContext'), value: contextText.value },
{ key: 'compression', term: t('chat.infoCompression'), value: compressionText.value },
]
if (s.recall) {
rows.push({ key: 'recall', term: t('chat.infoRecall'), value: recallText.value })
}
return rows
})
const liveRows = computed(() => {
const s = info.value
if (!s) return []
const running = s.live?.running || chat.streaming
const ws = chat.wsConnected
? t('chat.infoWsConnected')
: chat.wsReconnecting ? t('chat.infoWsReconnecting') : t('chat.infoWsOffline')
const wsTone = chat.wsConnected ? 'ok' : chat.wsReconnecting ? 'warn' : 'error'
const tasks = Object.values(chat.backgroundTasks || {})
.filter(task => task?.status === 'running' || task?.status === 'queued').length
return [
{
key: 'running',
term: t('chat.infoRunning'),
value: running ? t('chat.infoRunningYes') : t('chat.infoRunningNo'),
tone: running ? 'live' : 'muted',
},
{
key: 'clients',
term: t('chat.infoClients'),
value: num(s.live?.clients ?? 0),
tone: 'neutral',
},
{ key: 'ws', term: t('chat.infoWs'), value: ws, tone: wsTone },
{
key: 'tasks',
term: t('chat.infoTasks'),
value: tasks ? num(tasks) : t('chat.infoNone'),
tone: tasks ? 'live' : 'muted',
},
]
})
// ─── Helpers ────────────────────────────────────────────────────────────────
function isServing(entry) {
return !!active.value &&
active.value.model === entry.model &&
active.value.target === entry.target
}
const STATE_SLUGS = {
ok: 'chat.infoStateOk',
degraded: 'chat.infoStateDegraded',
missing: 'chat.infoStateMissing',
}
function stateLabel(state) {
return STATE_SLUGS[state] ? t(STATE_SLUGS[state]) : state
}
function num(value) {
if (value == null) return '—'
return new Intl.NumberFormat(locale.value).format(value)
}
function formatTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString(locale.value)
}
</script>
<style scoped>
.session-info {
min-width: min(460px, calc(100vw - 48px));
max-height: min(76vh, 700px);
overflow-y: auto;
}
/* The kit caps definition lists at 620px; inside a 960px modal that leaves a
dead strip on the right and squeezes the values. */
.session-info .si-rows {
max-width: none;
}
/* Model and session side by side on desktop: stacked they push the live block
past the fold, and both columns are short enough to share a row. */
.si-grid {
display: grid;
gap: 18px;
}
@media (min-width: 780px) {
.si-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
/* The two columns are siblings, not a stack: no rule between them. */
.si-section + .si-section {
margin-top: 0;
padding-top: 0;
border-top: none;
}
}
/* Live block sits below both columns and keeps its own separator. */
.si-live {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--gn-border-color-muted);
}
/* Four short facts read better as tiles than as another tall list: the dialog
then fits without scrolling. */
.si-live-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 8px;
}
.si-live-item {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--gn-border-color-muted);
border-radius: var(--gn-radius-md, 8px);
background: var(--gn-surface-panel-muted, transparent);
}
.si-live-term {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--gn-color-text-light);
}
.si-live-value {
font-size: 12px;
color: var(--gn-color-text-dark);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.si-live-value.is-live {
color: var(--gn-color-primary);
}
.si-live-value.is-ok {
color: var(--gn-color-success);
}
.si-live-value.is-warn {
color: var(--gn-color-warning);
}
.si-live-value.is-error {
color: var(--gn-color-error);
}
.si-live-value.is-muted {
color: var(--gn-color-text-medium);
}
.si-heading {
margin: 0 0 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--gn-color-text-medium);
}
.si-subheading {
margin: 14px 0 6px;
font-size: 11px;
font-weight: 600;
color: var(--gn-color-text-light);
}
.si-active {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
margin-bottom: 10px;
border: 1px solid var(--gn-border-color-muted);
border-radius: var(--gn-radius-md, 8px);
background: var(--gn-surface-panel-muted, transparent);
}
.si-active.is-backup {
border-color: color-mix(in srgb, var(--gn-color-warning) 45%, transparent);
background: color-mix(in srgb, var(--gn-color-warning) 8%, transparent);
}
.si-active > i {
font-size: 18px;
color: var(--gn-color-success);
}
.si-active.is-backup > i {
color: var(--gn-color-warning);
}
.si-active-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.si-model {
font-family: var(--gn-font-mono, monospace);
font-size: 13px;
color: var(--gn-color-text-dark);
overflow: hidden;
text-overflow: ellipsis;
}
.si-server {
font-size: 11px;
color: var(--gn-color-text-medium);
overflow: hidden;
text-overflow: ellipsis;
}
.si-chain-list {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.si-chain-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 6px;
font-size: 12px;
color: var(--gn-color-text-medium);
}
.si-chain-item.is-active {
background: color-mix(in srgb, var(--gn-color-primary) 10%, transparent);
color: var(--gn-color-text-dark);
}
.si-chain-dot {
width: 7px;
height: 7px;
flex: none;
border-radius: 50%;
background: var(--gn-color-success);
}
.si-chain-dot.is-degraded {
background: var(--gn-color-warning);
}
.si-chain-dot.is-missing {
background: var(--gn-color-error);
}
.si-chain-model {
font-family: var(--gn-font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.si-chain-target {
margin-left: auto;
color: var(--gn-color-text-light);
white-space: nowrap;
}
.si-chain-state {
min-width: 48px;
text-align: right;
font-size: 11px;
color: var(--gn-color-text-light);
white-space: nowrap;
}
.si-id {
font-family: var(--gn-font-mono, monospace);
font-size: 11px;
word-break: break-all;
min-width: 0;
}
.si-id-row {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 100%;
}
.si-muted {
color: var(--gn-color-text-medium);
font-size: 12px;
}
.si-error {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
color: var(--gn-color-error);
font-size: 13px;
}
.si-loading {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 0;
}
.si-spin {
animation: si-spin 1s linear infinite;
}
@keyframes si-spin {
to { transform: rotate(360deg); }
}
</style>