<template>
<div class="reactions-panel">
<h2 class="reactions-title">{{ t('settings.reactionsTitle') }}</h2>
<p class="reactions-description">{{ t('settings.reactionsDescription') }}</p>
<GnAlert
v-if="ready && !settings?.source_ready"
variant="info"
class="reactions-alert"
>
<i class="ph ph-info"></i>
<span>{{ t('settings.reactionsNotConfigured') }}</span>
</GnAlert>
<div v-if="store.loading && !settings" class="reactions-loading">
<GnLoader circle :label="t('settings.reactionsLoading')" />
</div>
<template v-else-if="settings">
<div class="reactions-row">
<div class="reactions-row-text">
<div class="reactions-row-title">{{ t('settings.reactionsEnable') }}</div>
<div class="reactions-row-status">
{{ t(settings.reactions_enabled ? 'settings.reactionsOn' : 'settings.reactionsOff') }}
</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">{{ t('settings.reactionsNotify') }}</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">{{ t('settings.reactionsTarget') }}</div>
<div class="reactions-row-status">{{ t('settings.reactionsTargetNote') }}</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-row reactions-row--stacked">
<div class="reactions-row-text">
<div class="reactions-row-title">{{ t('settings.reactionsSessionTtl') }}</div>
<div class="reactions-row-status">{{ t('settings.reactionsSessionTtlNote') }}</div>
</div>
<GnInput
:model-value="ttlDraft"
type="number"
min="0"
max="43200"
step="60"
class="reactions-ttl-input"
:help="t('settings.reactionsSessionTtlUnit')"
@update:model-value="ttlDraft = $event"
@change="saveTtl"
/>
</div>
<SynapseInstructionsField
doc="reaction"
:title="t('settings.reactionsInstructions')"
:note="t('settings.reactionsInstructionsNote')"
:placeholder="t('settings.reactionsInstructionsPlaceholder')"
:save-label="t('settings.reactionsInstructionsSave')"
:model-value="draft"
:versions="store.versions"
:dirty="instructionsDirty"
:saving="store.saving"
@update:model-value="draft = $event"
@save="saveInstructions"
/>
<SynapseInstructionsField
doc="dispatcher"
:title="t('settings.dispatcherInstructions')"
:note="t('settings.dispatcherInstructionsNote')"
:placeholder="t('settings.dispatcherInstructionsPlaceholder')"
:save-label="t('settings.dispatcherInstructionsSave')"
:model-value="dispatcherDraft"
:versions="store.dispatcherVersions"
:dirty="dispatcherDirty"
:saving="store.saving"
@update:model-value="dispatcherDraft = $event"
@save="saveDispatcherInstructions"
/>
</template>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { t } from '@/i18n/index.js'
import { useSynapseReactionsStore } from '@/stores/synapseReactions.js'
import { useToast } from 'gnexus-ui-kit/vue'
import {
GnAlert,
GnInput,
GnLoader,
GnRadioGroup,
GnSwitch,
} from 'gnexus-ui-kit/vue'
import SynapseInstructionsField from '@/components/settings/SynapseInstructionsField.vue'
// Computed, the three of them: options and labels built once would keep the
// language the panel was opened in.
const COMPLETION_OPTIONS = computed(() => [
{ value: 'always', label: t('common.always') },
{ value: 'important', label: t('settings.reactionsOnlyFailures') },
{ value: 'never', label: t('common.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: t('settings.reactionsTargetApp') },
{ value: 'app_synapse', label: t('settings.reactionsTargetAppSynapse'), disabled: !settings.value?.source_ready },
{ value: 'synapse', label: t('settings.reactionsTargetSynapse'), disabled: !settings.value?.source_ready },
])
const COMPLETION_LABELS = computed(() => ({
always: t('settings.reactionsPushAlways'),
important: t('settings.reactionsPushFailures'),
never: t('settings.reactionsPushNever'),
}))
const instructionsPlaceholder = computed(() => t('settings.reactionsInstructionsPlaceholder'))
const store = useSynapseReactionsStore()
const toast = useToast()
const settings = computed(() => store.settings)
const ready = ref(false)
// Two drafts, one per instruction document (reaction / dispatcher), each with
// its own history — the API keeps them apart by `doc`.
const draft = ref('')
const dispatcherDraft = ref('')
const ttlDraft = ref(0)
const instructionsDirty = computed(
() => settings.value !== null && draft.value !== settings.value.instructions
)
const dispatcherDirty = computed(
() => settings.value !== null && dispatcherDraft.value !== settings.value.dispatcher_instructions
)
watch(
settings,
(val) => {
if (!val) return
if (draft.value !== val.instructions) draft.value = val.instructions
if (dispatcherDraft.value !== val.dispatcher_instructions) {
dispatcherDraft.value = val.dispatcher_instructions
}
ttlDraft.value = val.reaction_session_ttl_minutes
},
{ immediate: true }
)
onMounted(async () => {
ready.value = true
try {
await store.fetchSettings()
await Promise.all([store.fetchVersions(), store.fetchDispatcherVersions()])
} catch (err) {
toast.error({ title: t('toast.reactionsLoadFailed'), text: err.message })
}
})
async function patch(partial) {
try {
await store.save(partial)
} catch (err) {
toast.error({ title: t('toast.saveFailed'), text: err.message })
}
}
async function saveInstructions() {
try {
await store.save({ instructions: draft.value })
await store.fetchVersions()
toast.success({ title: t('toast.instructionsSaved') })
} catch (err) {
toast.error({ title: t('toast.instructionsFailed'), text: err.message })
}
}
async function saveDispatcherInstructions() {
try {
await store.save({ dispatcher_instructions: dispatcherDraft.value })
await store.fetchDispatcherVersions()
toast.success({ title: t('toast.instructionsSaved') })
} catch (err) {
toast.error({ title: t('toast.instructionsFailed'), text: err.message })
}
}
/** Committed on blur/Enter (`change`), not per keystroke: the input is a
* free-text number and a PUT per digit would spam the version history. */
async function saveTtl() {
const value = Number(ttlDraft.value)
if (!Number.isFinite(value) || value < 0) {
ttlDraft.value = settings.value?.reaction_session_ttl_minutes ?? 0
return
}
const clamped = Math.min(Math.round(value), 43200)
if (clamped === settings.value?.reaction_session_ttl_minutes) return
ttlDraft.value = clamped
await patch({ reaction_session_ttl_minutes: clamped })
}
</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-ttl-input {
width: 90px;
flex: 0 0 auto;
}
</style>