Newer
Older
navi-1 / webclient / src / components / settings / SynapseReactionsPanel.vue
<template>
  <div class="reactions-panel">
    <h2 class="reactions-title">Synapse reactions</h2>

    <p class="reactions-description">
      When a GNEXUS Synapse event is delivered, Navi can react to it on its own:
      a dispatcher picks a profile by your reaction instructions, the work runs
      in a hidden service session, and you can browse that session later via the
      sidebar "Service sessions" toggle.
    </p>

    <GnAlert
      v-if="ready && !settings?.source_ready"
      variant="info"
      class="reactions-alert"
    >
      <i class="ph ph-info"></i>
      <span>
        Synapse integration is not configured on this navi: options that send
        events to Synapse are disabled. App-only delivery keeps working.
      </span>
    </GnAlert>

    <div v-if="store.loading && !settings" class="reactions-loading">
      <GnLoader circle label="Loading reaction settings" />
    </div>

    <template v-else-if="settings">
      <div class="reactions-row">
        <div class="reactions-row-text">
          <div class="reactions-row-title">Enable reactions</div>
          <div class="reactions-row-status">
            {{ settings.reactions_enabled
              ? 'Delivered events start a background reaction run.'
              : 'Events are accepted and logged, but Navi does nothing.' }}
          </div>
        </div>
        <GnSwitch
          :model-value="settings.reactions_enabled"
          :disabled="store.saving"
          @update:model-value="patch({ reactions_enabled: $event })"
        />
      </div>

      <div class="reactions-row">
        <div class="reactions-row-text">
          <div class="reactions-row-title">Notify me about finished reactions</div>
          <div class="reactions-row-status">
            {{ COMPLETION_LABELS[settings.completion_notify] }}
          </div>
        </div>
        <GnRadioGroup
          :model-value="settings.completion_notify"
          name="completion-notify"
          :options="COMPLETION_OPTIONS"
          @update:model-value="patch({ completion_notify: $event })"
        />
      </div>

      <div class="reactions-row">
        <div class="reactions-row-text">
          <div class="reactions-row-title">Where notifications go</div>
          <div class="reactions-row-status">
            Applies to Navi's notify tool and to reaction-completion pushes.
          </div>
        </div>
        <GnRadioGroup
          :model-value="settings.push_target"
          name="push-target"
          :options="PUSH_TARGET_OPTIONS"
          @update:model-value="patch({ push_target: $event })"
        />
      </div>

      <div class="reactions-instructions">
        <div class="reactions-row-title">Reaction instructions</div>
        <div class="reactions-row-status">
          Guidance the dispatcher uses to pick a profile and to shape the task.
          Both you and Navi can edit it; every change is recorded in the
          history below.
        </div>
        <GnTextarea
          v-model="draft"
          :rows="6"
          :placeholder="INSTRUCTIONS_PLACEHOLDER"
          class="reactions-instructions-textarea"
        />
        <div class="reactions-instructions-actions">
          <GnButton
            variant="primary"
            :disabled="!instructionsDirty || store.saving"
            @click="saveInstructions"
          >
            Save instructions
          </GnButton>
        </div>

        <div
          v-if="store.versions.length"
          class="reactions-history"
        >
          <div class="reactions-history-title">Edit history</div>
          <div
            v-for="v in store.versions"
            :key="v.created_at + ':' + v.content.slice(0, 24)"
            class="reactions-history-item"
          >
            <span class="reactions-history-meta">
              <i
                class="ph"
                :class="v.edited_by === 'navi' ? 'ph-robot' : 'ph-user'"
              ></i>
              {{ v.edited_by === 'navi' ? 'Navi' : 'You' }}
              · {{ formatWhen(v.created_at) }}
              <template v-if="v.reason"> · {{ v.reason }}</template>
            </span>
            <div class="reactions-history-text">{{ v.content }}</div>
          </div>
        </div>
      </div>
    </template>
  </div>
</template>

<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useSynapseReactionsStore } from '@/stores/synapseReactions.js'
import { useToast } from 'gnexus-ui-kit/vue'
import {
  GnAlert,
  GnButton,
  GnLoader,
  GnRadioGroup,
  GnSwitch,
  GnTextarea,
} from 'gnexus-ui-kit/vue'

const COMPLETION_OPTIONS = [
  { value: 'always', label: 'Always' },
  { value: 'important', label: 'Only failures' },
  { value: 'never', label: 'Never' },
]

// Distinct radio-group names: without them native exclusion either fails
// (slot radios get no name) or crosses the two groups (kit default name).
const PUSH_TARGET_OPTIONS = computed(() => [
  { value: 'app', label: 'App only' },
  { value: 'app_synapse', label: 'App + Synapse', disabled: !settings.value?.source_ready },
  { value: 'synapse', label: 'Synapse only', disabled: !settings.value?.source_ready },
])

const COMPLETION_LABELS = {
  always: 'Push on every finished run.',
  important: 'Push only when a run ended with errors.',
  never: 'No completion push.',
}

const INSTRUCTIONS_PLACEHOLDER =
  'e.g. "Todo events from gntodo go to the developer profile; junk mail goes nowhere."'

const store = useSynapseReactionsStore()
const toast = useToast()

const settings = computed(() => store.settings)
const ready = ref(false)

const draft = ref('')
const instructionsDirty = computed(
  () => settings.value !== null && draft.value !== settings.value.instructions
)

watch(
  settings,
  (val) => { if (val && draft.value !== val.instructions) draft.value = val.instructions },
  { immediate: true }
)

onMounted(async () => {
  ready.value = true
  try {
    await store.fetchSettings()
    await store.fetchVersions()
  } catch (err) {
    toast.error({ title: 'Failed to load reaction settings', text: err.message })
  }
})

async function patch(partial) {
  try {
    await store.save(partial)
  } catch (err) {
    toast.error({ title: 'Failed to save', text: err.message })
  }
}

async function saveInstructions() {
  try {
    await store.save({ instructions: draft.value })
    await store.fetchVersions()
    toast.success({ title: 'Instructions saved' })
  } catch (err) {
    toast.error({ title: 'Failed to save instructions', text: err.message })
  }
}

function formatWhen(iso) {
  const d = new Date(iso)
  return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +
    ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}
</script>

<style scoped lang="scss">
.reactions-panel {
  margin-bottom: 24px;
}

.reactions-title {
  font-size: 1.1rem;
  font-weight: 600;
  margin: 0 0 12px;
  color: var(--gn-color-text-medium);
}

.reactions-description {
  margin: 0 0 16px;
  font-size: 0.875rem;
  color: var(--gn-color-text-medium);
}

.reactions-alert {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  margin-bottom: 16px;

  i {
    font-size: 16px;
    flex-shrink: 0;
  }
}

.reactions-loading {
  padding: 24px;
  text-align: center;
}

.reactions-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  padding: 14px 16px;
  margin-bottom: 12px;
  border: 1px solid var(--gn-border-color-muted);
  border-radius: 8px;
}

.reactions-row-title {
  font-size: 0.9rem;
  color: var(--gn-color-text-medium);
  font-weight: 500;
}

.reactions-row-status {
  font-size: 0.8rem;
  color: var(--gn-color-text-dark);
  margin-top: 2px;
  max-width: 420px;
}

.reactions-instructions {
  padding: 14px 16px;
  margin-bottom: 12px;
  border: 1px solid var(--gn-border-color-muted);
  border-radius: 8px;
}

.reactions-instructions-textarea {
  margin-top: 10px;
}

.reactions-instructions-actions {
  margin-top: 10px;
  display: flex;
  justify-content: flex-end;
}

.reactions-history {
  margin-top: 14px;
}

.reactions-history-title {
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--gn-color-text-medium);
  margin-bottom: 8px;
}

.reactions-history-item {
  padding: 8px 10px;
  border-left: 2px solid var(--gn-border-color-muted);

  & + & {
    margin-top: 8px;
  }
}

.reactions-history-meta {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 0.75rem;
  color: var(--gn-color-text-dark);

  i {
    font-size: 12px;
  }
}

.reactions-history-text {
  margin-top: 4px;
  font-size: 0.8rem;
  color: var(--gn-color-text-medium);
  white-space: pre-wrap;
  word-break: break-word;
}
</style>