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