<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 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"
@update:model-value="patch({ completion_notify: $event })"
>
<GnRadio
v-for="opt in COMPLETION_OPTIONS"
:key="opt.value"
:value="opt.value"
:label="opt.label"
/>
</GnRadioGroup>
</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"
@update:model-value="patch({ push_target: $event })"
>
<GnRadio value="app" label="App only" />
<GnRadio
value="app_synapse"
label="App + Synapse"
:disabled="!settings.source_ready"
/>
<GnRadio
value="synapse"
label="Synapse only"
:disabled="!settings.source_ready"
/>
</GnRadioGroup>
</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,
GnRadio,
GnRadioGroup,
GnSwitch,
GnTextarea,
} from 'gnexus-ui-kit/vue'
const COMPLETION_OPTIONS = [
{ value: 'always', label: 'Always' },
{ value: 'important', label: 'Only failures' },
{ value: 'never', label: 'Never' },
]
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>