<template>
<GnModal
:open="open"
:title="t('chat.recallInfoTitle')"
@update:open="emit('update:open', $event)"
>
<div v-if="recall" class="recall-info">
<GnDefinitionList :items="rows" class="ri-rows" />
<section v-if="recall.internal_comment" class="ri-block">
<h4 class="ri-heading">{{ t('chat.recallComment') }}</h4>
<p class="ri-comment">{{ recall.internal_comment }}</p>
</section>
<section v-if="recall.additional_context_message" class="ri-block">
<h4 class="ri-heading">{{ t('chat.recallMessage') }}</h4>
<pre class="ri-message">{{ recall.additional_context_message }}</pre>
</section>
</div>
<p v-else class="ri-muted">{{ t('chat.recallGone') }}</p>
<template #actions>
<GnButton
v-if="isRecurring"
variant="secondary"
icon="ph-skip-forward"
:loading="skipping"
@click="skip"
>
{{ t('sidebar.skipNext') }}
</GnButton>
<GnButton
variant="danger"
icon="ph-x-circle"
:loading="cancelling"
@click="cancel"
>
{{ t('chat.recallCancel') }}
</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 { GnButton, GnDefinitionList, GnModal, useToast } from 'gnexus-ui-kit/vue'
import { useChatStore } from '@/stores/chat.js'
import { locale, recallTypeLabel, t } from '@/i18n/index.js'
import { intervalText, recallInText } from '@/utils/recall.js'
const props = defineProps({
open: Boolean,
})
const emit = defineEmits(['update:open'])
const chat = useChatStore()
const toast = useToast()
const cancelling = ref(false)
const skipping = ref(false)
// The "in 25 min" line is the one thing here that decays on its own.
const now = ref(Date.now())
let ticker = null
const recall = computed(() => chat.recall)
const isRecurring = computed(() => recall.value?.call_type === 'recurring')
const rows = computed(() => {
const r = recall.value
if (!r) return []
const items = [
{ key: 'type', term: t('chat.recallType'), value: recallTypeLabel(r.call_type) },
{ key: 'at', term: t('chat.recallAt'), value: formatTime(r.trigger_at) },
]
if (r.created_at) {
items.push({ key: 'added', term: t('chat.recallAdded'), value: formatTime(r.created_at) })
}
items.push({ key: 'when', term: t('chat.recallWhen'), value: recallInText(r.trigger_at, now.value) })
if (isRecurring.value && r.interval_seconds) {
items.push({
key: 'interval',
term: t('chat.recallInterval'),
value: intervalText(r.interval_seconds),
})
}
return items
})
watch(
() => props.open,
open => {
stopTicker()
if (!open) return
// Another tab may have rescheduled or cancelled it since the banner loaded.
chat.loadRecall()
now.value = Date.now()
ticker = setInterval(() => { now.value = Date.now() }, 1000)
},
{ immediate: true }
)
onUnmounted(stopTicker)
function stopTicker() {
if (ticker !== null) {
clearInterval(ticker)
ticker = null
}
}
async function cancel() {
cancelling.value = true
try {
if (await chat.cancelRecall()) {
emit('update:open', false)
} else {
toast.error({ title: t('chat.recallCancelFailed') })
}
} finally {
cancelling.value = false
}
}
async function skip() {
skipping.value = true
try {
if (await chat.skipRecall()) {
now.value = Date.now()
} else {
toast.error({ title: t('chat.recallSkipFailed') })
}
} finally {
skipping.value = false
}
}
function formatTime(iso) {
if (!iso) return '—'
return new Date(iso).toLocaleString(locale.value)
}
</script>
<style scoped>
.recall-info {
min-width: min(420px, calc(100vw - 48px));
max-height: min(70vh, 640px);
overflow-y: auto;
}
/* The kit caps definition lists at 620px; inside the dialog that leaves a
dead strip on the right. */
.recall-info .ri-rows {
max-width: none;
}
.ri-block {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--gn-border-color-muted);
}
.ri-heading {
margin: 0 0 6px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--gn-color-text-light);
}
.ri-comment {
margin: 0;
font-size: 13px;
color: var(--gn-color-text-light);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* The agent wrote this as a todo for itself — keep its line breaks and let a
long one scroll instead of stretching the dialog. */
.ri-message {
margin: 0;
padding: 10px 12px;
max-height: 260px;
overflow: auto;
font-family: var(--gn-font-mono, monospace);
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: var(--gn-color-text-light);
background: var(--gn-surface-panel-muted, transparent);
border: 1px solid var(--gn-border-color-muted);
border-radius: var(--gn-radius-md, 8px);
}
.ri-muted {
margin: 0;
color: var(--gn-color-text-medium);
}
</style>