<template>
<div class="skill-proposal">
<div v-if="loading" class="skill-proposal-state">
<span class="skill-proposal-spinner" aria-hidden="true" />
{{ t('ui.proposalLoading') }}
</div>
<div v-else-if="loadError" class="skill-proposal-state is-error">{{ loadError }}</div>
<template v-else-if="proposal">
<div class="skill-proposal-head">
<div class="skill-proposal-title">{{ proposal.name }}</div>
<span class="skill-proposal-kind">
{{ proposal.replaces_id ? t('ui.proposalReplaces') : t('ui.proposalNew') }}
</span>
</div>
<div v-if="proposal.description" class="skill-proposal-description">
{{ proposal.description }}
</div>
<div class="skill-proposal-meta">
{{ areaLabel }}
<template v-if="proposal.tags && proposal.tags.length"> ยท {{ proposal.tags.join(', ') }}</template>
</div>
<div v-if="proposal.preconditions && proposal.preconditions.length" class="skill-proposal-block">
<div class="skill-proposal-label">{{ t('ui.proposalPreconditions') }}</div>
<ul class="skill-proposal-list">
<li v-for="(item, i) in proposal.preconditions" :key="`pre-${i}`">{{ item }}</li>
</ul>
</div>
<div v-if="proposal.steps && proposal.steps.length" class="skill-proposal-block">
<div class="skill-proposal-label">{{ t('ui.proposalSteps') }}</div>
<ol class="skill-proposal-steps">
<li v-for="(step, i) in proposal.steps" :key="`step-${i}`">{{ step }}</li>
</ol>
</div>
<div v-if="proposal.post_checks && proposal.post_checks.length" class="skill-proposal-block">
<div class="skill-proposal-label">{{ t('ui.proposalPostChecks') }}</div>
<ul class="skill-proposal-list">
<li v-for="(check, i) in proposal.post_checks" :key="`check-${i}`">{{ check }}</li>
</ul>
</div>
<dl class="skill-proposal-hatches">
<div v-if="proposal.on_deviation" class="skill-proposal-hatch">
<dt>{{ t('ui.proposalOnDeviation') }}</dt>
<dd>{{ proposal.on_deviation }}</dd>
</div>
<div v-if="proposal.on_blocked" class="skill-proposal-hatch">
<dt>{{ t('ui.proposalOnBlocked') }}</dt>
<dd>{{ proposal.on_blocked }}</dd>
</div>
<div v-if="proposal.on_already_done" class="skill-proposal-hatch">
<dt>{{ t('ui.proposalOnAlreadyDone') }}</dt>
<dd>{{ proposal.on_already_done }}</dd>
</div>
</dl>
<div v-if="proposal.body" class="skill-proposal-block">
<div class="skill-proposal-body">{{ proposal.body }}</div>
</div>
<div v-if="status === 'pending'" class="skill-proposal-actions">
<button
type="button"
class="skill-proposal-btn is-accept"
:disabled="sending"
@click="decide('accept')"
>
<i class="ph ph-check" aria-hidden="true"></i>
{{ t('ui.proposalAccept') }}
</button>
<button
type="button"
class="skill-proposal-btn is-reject"
:disabled="sending"
@click="decide('reject')"
>
{{ t('ui.proposalReject') }}
</button>
<span v-if="actionError || decisionError" class="skill-proposal-error">
{{ actionError || decisionError }}
</span>
</div>
<div v-else class="skill-proposal-decision" :class="`is-${status}`">
<i
class="ph"
:class="status === 'accepted' ? 'ph-check-circle' : 'ph-x-circle'"
aria-hidden="true"
></i>
{{ status === 'accepted' ? t('ui.proposalAccepted') : t('ui.proposalRejected') }}
<span v-if="decisionError" class="skill-proposal-error">{{ decisionError }}</span>
</div>
</template>
</div>
</template>
<script setup>
import { computed, inject, onMounted, ref, watch } from 'vue'
import { t } from '@/i18n/index.js'
import * as api from '@/api/index.js'
const props = defineProps({
data: { type: Object, default: () => ({}) }
})
const wsSend = inject('wsSend')
const AREA_SLUGS = {
home: 'settings.skillsAreaHome',
infrastructure: 'settings.skillsAreaInfrastructure',
workflows: 'settings.skillsAreaWorkflows',
general: 'settings.skillsAreaGeneral',
}
const proposal = ref(null)
const loading = ref(true)
const loadError = ref('')
const sending = ref(false)
const actionError = ref('')
const proposalId = computed(() => props.data?.proposal_id || '')
// The payload is what the chat store patches when the decided event arrives;
// the fetched copy is the fallback for a card restored from history.
const status = computed(
() => props.data?.status || proposal.value?.status || 'pending'
)
const decisionError = computed(() => props.data?.error || '')
const areaLabel = computed(() =>
t(AREA_SLUGS[proposal.value?.area] || AREA_SLUGS.general)
)
// A decided card or a refusal both end the wait; the status may stay
// 'pending' when the catalog refused the document, so the error is the other
// signal that the request came back.
watch([status, decisionError], () => {
sending.value = false
})
onMounted(async () => {
if (!proposalId.value) {
loading.value = false
loadError.value = t('ui.proposalNotFound')
return
}
try {
proposal.value = await api.getSkillProposal(proposalId.value)
} catch (err) {
loadError.value = t('ui.proposalNotFound')
console.error('[SkillProposal]', err)
} finally {
loading.value = false
}
})
function decide(action) {
if (sending.value) return
if (typeof wsSend !== 'function') {
actionError.value = t('ui.proposalOffline')
return
}
actionError.value = ''
sending.value = true
wsSend({ type: 'skill_proposal', proposal_id: proposalId.value, action })
}
</script>
<style scoped>
.skill-proposal {
display: flex;
flex-direction: column;
gap: 12px;
}
.skill-proposal-state {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--gn-color-text-dark);
}
.skill-proposal-state.is-error {
color: var(--gn-color-error);
}
.skill-proposal-spinner {
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: skill-proposal-spin 0.8s linear infinite;
}
@keyframes skill-proposal-spin {
to { transform: rotate(360deg); }
}
.skill-proposal-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.skill-proposal-title {
font-size: 1rem;
font-weight: 600;
color: var(--gn-color-text-light);
}
.skill-proposal-kind {
flex: none;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border: 1px solid var(--gn-border-color-muted);
color: var(--gn-color-text-dark);
}
.skill-proposal-description {
font-size: 0.87rem;
line-height: 1.45;
color: var(--gn-color-text-light);
}
.skill-proposal-meta {
font-size: 0.75rem;
color: var(--gn-color-text-dark);
}
.skill-proposal-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.skill-proposal-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--gn-color-text-dark);
}
.skill-proposal-list,
.skill-proposal-steps {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 3px;
font-size: 0.87rem;
line-height: 1.45;
color: var(--gn-color-text-light);
}
.skill-proposal-hatches {
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.skill-proposal-hatch {
display: flex;
flex-direction: column;
gap: 1px;
}
.skill-proposal-hatch dt {
font-size: 0.75rem;
color: var(--gn-color-text-dark);
}
.skill-proposal-hatch dd {
margin: 0;
font-size: 0.85rem;
color: var(--gn-color-text-light);
}
.skill-proposal-body {
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
color: var(--gn-color-text-light);
border-left: 2px solid var(--gn-border-color-muted);
padding-left: 10px;
}
.skill-proposal-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding-top: 4px;
}
.skill-proposal-btn {
padding: 9px 16px;
border: 1px solid var(--gn-border-color-muted);
background: transparent;
color: var(--gn-color-text-light);
font: inherit;
font-size: 0.87rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.skill-proposal-btn.is-accept {
background: var(--gn-color-secondary);
border-color: var(--gn-color-secondary);
color: var(--gn-surface-panel);
}
.skill-proposal-btn:not(:disabled):hover {
opacity: 0.88;
}
.skill-proposal-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.skill-proposal-decision {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
}
.skill-proposal-decision.is-accepted {
color: var(--gn-color-secondary);
}
.skill-proposal-decision.is-rejected {
color: var(--gn-color-text-dark);
}
.skill-proposal-error {
font-size: 0.8rem;
color: var(--gn-color-error);
}
</style>