<template>
<div class="skills-panel">
<div class="skills-header">
<h2 class="skills-title">{{ t('settings.skillsTitle') }}</h2>
<div class="skills-header-actions">
<GnButton variant="primary" size="sm" icon="ph ph-plus" @click="startCreate('skill')">
{{ t('settings.skillsAddSkill') }}
</GnButton>
<GnButton variant="secondary" size="sm" icon="ph ph-list-checks" @click="startCreate('protocol')">
{{ t('settings.skillsAddProtocol') }}
</GnButton>
</div>
</div>
<p class="skills-description">{{ t('settings.skillsDescription') }}</p>
<div class="skills-toolbar">
<div class="skills-search">
<GnSearchField
v-model="query"
:placeholder="t('settings.skillsSearchPlaceholder')"
:aria-label="t('settings.skillsSearchPlaceholder')"
/>
</div>
<div class="skills-filter">
<GnSelect
v-model="kindFilter"
:aria-label="t('settings.skillsFilterKind')"
:options="kindOptions"
/>
</div>
<div class="skills-filter">
<GnSelect
v-model="areaFilter"
:aria-label="t('settings.skillsFilterArea')"
:options="areaOptions"
/>
</div>
</div>
<div v-if="loadError" class="skills-error">
<GnAlert variant="error">{{ loadError }}</GnAlert>
</div>
<div class="skills-layout">
<aside class="skills-list" :aria-label="t('settings.skillsCatalog')">
<div v-if="loading" class="skills-loading">
<GnLoader circle :label="t('settings.skillsLoading')" />
</div>
<div v-else-if="!items.length" class="skills-empty">
<i class="ph ph-notebook empty-icon" aria-hidden="true"></i>
<p>{{ t('settings.skillsEmpty') }}</p>
</div>
<button
v-for="item in items"
:key="item.id"
type="button"
class="skills-row"
:class="{ 'is-active': item.id === selectedId }"
@click="select(item.id)"
>
<span class="skills-row-top">
<span class="skills-row-name">{{ item.name }}</span>
<span class="skills-badge" :data-kind="item.kind">
{{ item.kind === 'protocol' ? t('settings.skillsKindProtocol') : t('settings.skillsKindSkill') }}
</span>
</span>
<span v-if="item.description" class="skills-row-description">{{ item.description }}</span>
<span class="skills-row-meta">
{{ areaLabel(item.area) }}
· {{ item.visibility === 'shared' ? t('settings.skillsShared') : t('settings.skillsPersonal') }}
· v{{ item.version }}
</span>
</button>
</aside>
<section class="skills-editor">
<template v-if="editing">
<div class="skills-editor-head">
<div class="skills-editor-title">
{{ entry ? entry.name : t(isCreatingProtocol ? 'settings.skillsNewProtocol' : 'settings.skillsNewSkill') }}
<span v-if="entry" class="skills-editor-version">v{{ entry.version }}</span>
</div>
<span v-if="entry && !entry.can_edit" class="skills-readonly">{{ t('settings.skillsReadOnly') }}</span>
</div>
<GnAlert v-if="saveError" variant="error" class="skills-save-error">{{ saveError }}</GnAlert>
<GnTextarea
v-model="draft"
class="skills-textarea"
:rows="22"
:disabled="!canEdit || saving"
:placeholder="t('settings.skillsEditorPlaceholder')"
:aria-label="t('settings.skillsEditorLabel')"
/>
<div class="skills-editor-actions">
<div v-if="authStore.isAdmin && canEdit" class="skills-visibility">
<GnSelect
v-model="visibility"
:aria-label="t('settings.skillsVisibility')"
:options="visibilityOptions"
/>
</div>
<div class="skills-editor-buttons">
<GnButton
variant="primary"
:disabled="!canEdit || saving || !draft.trim()"
@click="save"
>
{{ t('common.save') }}
</GnButton>
<GnButton variant="ghost" :disabled="saving" @click="reset">
{{ t('common.cancel') }}
</GnButton>
<GnButton
v-if="entry && entry.can_edit"
variant="ghost"
icon="ph ph-trash"
@click="remove"
>
{{ t('common.delete') }}
</GnButton>
</div>
</div>
<div v-if="entry" class="skills-history">
<button type="button" class="skills-history-toggle" @click="toggleHistory">
<i class="ph" :class="showHistory ? 'ph-caret-down' : 'ph-caret-right'" aria-hidden="true"></i>
{{ t('settings.skillsHistory') }}
</button>
<div v-if="showHistory" class="skills-history-list">
<div v-if="!versions.length" class="skills-history-empty">
{{ t('settings.skillsHistoryEmpty') }}
</div>
<div v-for="v in versions" :key="v.version" class="skills-history-item">
<div class="skills-history-meta">
v{{ v.version }} · {{ v.edited_by || t('settings.skillsUnknownEditor') }}
· {{ formatWhen(v.created_at) }}
<template v-if="v.reason"> · {{ v.reason }}</template>
</div>
<pre class="skills-history-text">{{ v.content }}</pre>
</div>
</div>
</div>
</template>
<div v-else class="skills-placeholder">
<i class="ph ph-notebook" aria-hidden="true"></i>
<p>{{ t('settings.skillsPlaceholder') }}</p>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
GnAlert,
GnButton,
GnLoader,
GnSearchField,
GnSelect,
GnTextarea,
useToast,
} from 'gnexus-ui-kit/vue'
import { useAuthStore } from '@/stores/auth.js'
import { useConfirm } from '@/composables/useConfirm.js'
import { locale, t } from '@/i18n/index.js'
import * as api from '@/api/index.js'
const authStore = useAuthStore()
const confirm = useConfirm()
const toast = useToast()
const AREA_SLUGS = {
home: 'settings.skillsAreaHome',
infrastructure: 'settings.skillsAreaInfrastructure',
workflows: 'settings.skillsAreaWorkflows',
general: 'settings.skillsAreaGeneral',
}
const TEMPLATES = {
skill: `---
kind: skill
name:
description:
area: general
tags: []
triggers: []
---
`,
protocol: `---
kind: protocol
name:
description:
area: general
tags: []
triggers: []
preconditions: []
steps:
-
post_checks:
-
on_deviation:
on_blocked:
on_already_done:
---
`,
}
const items = ref([])
const loading = ref(false)
const loadError = ref('')
const query = ref('')
const kindFilter = ref('')
const areaFilter = ref('')
const creating = ref(false)
const newKind = ref('skill')
const entry = ref(null)
const selectedId = ref(null)
const draft = ref('')
const visibility = ref('personal')
const saving = ref(false)
const saveError = ref('')
const versions = ref([])
const showHistory = ref(false)
const editing = computed(() => creating.value || entry.value !== null)
const isCreatingProtocol = computed(() => creating.value && newKind.value === 'protocol')
const canEdit = computed(() => (creating.value ? true : Boolean(entry.value?.can_edit)))
// Computed, not module constants: labels go through t() and must follow a
// language switch while the panel is open.
const kindOptions = computed(() => [
{ value: '', label: t('settings.skillsFilterKindAll') },
{ value: 'skill', label: t('settings.skillsKindSkill') },
{ value: 'protocol', label: t('settings.skillsKindProtocol') },
])
const areaOptions = computed(() => [
{ value: '', label: t('settings.skillsFilterAreaAll') },
...Object.entries(AREA_SLUGS).map(([value, slug]) => ({ value, label: t(slug) })),
])
const visibilityOptions = computed(() => [
{ value: 'personal', label: t('settings.skillsPersonal') },
{ value: 'shared', label: t('settings.skillsShared') },
])
let debounce = null
watch([query, kindFilter, areaFilter], () => {
clearTimeout(debounce)
debounce = setTimeout(load, 250)
})
onMounted(load)
onBeforeUnmount(() => clearTimeout(debounce))
async function load() {
loading.value = true
loadError.value = ''
try {
const data = await api.getSkills({
q: query.value.trim() || null,
kind: kindFilter.value || null,
area: areaFilter.value || null,
})
items.value = data.items || []
} catch (err) {
loadError.value = errorText(err)
} finally {
loading.value = false
}
}
async function select(id) {
if (saving.value) return
selectedId.value = id
creating.value = false
showHistory.value = false
saveError.value = ''
try {
const data = await api.getSkill(id)
entry.value = data
draft.value = data.content
visibility.value = data.visibility
} catch (err) {
loadError.value = errorText(err)
}
}
function startCreate(kind) {
entry.value = null
selectedId.value = null
creating.value = true
newKind.value = kind
draft.value = TEMPLATES[kind]
visibility.value = 'personal'
versions.value = []
showHistory.value = false
saveError.value = ''
}
function reset() {
entry.value = null
selectedId.value = null
creating.value = false
draft.value = ''
saveError.value = ''
versions.value = []
showHistory.value = false
}
async function save() {
saving.value = true
saveError.value = ''
try {
if (creating.value) {
const created = await api.createSkill({
content: draft.value,
visibility: authStore.isAdmin ? visibility.value : 'personal',
})
entry.value = created
selectedId.value = created.id
creating.value = false
draft.value = created.content
visibility.value = created.visibility
toast.success({ title: t('settings.skillsCreated') })
} else {
const payload = { content: draft.value }
if (authStore.isAdmin && visibility.value !== entry.value.visibility) {
payload.visibility = visibility.value
}
const updated = await api.updateSkill(entry.value.id, payload)
entry.value = updated
draft.value = updated.content
visibility.value = updated.visibility
toast.success({ title: t('settings.skillsSaved') })
}
await load()
} catch (err) {
// The server names what is missing in a protocol; that message is the
// whole point of validating on save, so show it verbatim.
saveError.value = errorText(err)
} finally {
saving.value = false
}
}
async function remove() {
if (!entry.value) return
const ok = await confirm(t('settings.skillsDeleteConfirm', { name: entry.value.name }))
if (!ok) return
try {
await api.deleteSkill(entry.value.id)
toast.success({ title: t('settings.skillsDeleted') })
reset()
await load()
} catch (err) {
toast.error({ title: t('common.error'), text: errorText(err) })
}
}
async function toggleHistory() {
showHistory.value = !showHistory.value
if (!showHistory.value || versions.value.length || !entry.value) return
try {
const data = await api.getSkillVersions(entry.value.id)
versions.value = data.items || []
} catch (err) {
saveError.value = errorText(err)
}
}
function areaLabel(area) {
return t(AREA_SLUGS[area] || AREA_SLUGS.general)
}
function formatWhen(iso) {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleString(locale.value, {
year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
})
}
// api/index.js throws `METHOD path → status: body`; the body is FastAPI's JSON,
// whose `detail` is the sentence worth showing.
function errorText(err) {
const raw = String(err?.message || err)
const match = /→\s*\d+:\s*([\s\S]*)$/.exec(raw)
const body = match ? match[1] : raw
try {
const parsed = JSON.parse(body)
return parsed.detail || body
} catch {
return body
}
}
</script>
<style scoped lang="scss">
@use 'kit-deps' as *;
.skills-panel {
margin-bottom: 24px;
}
.skills-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-sm;
flex-wrap: wrap;
margin-bottom: 8px;
}
.skills-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
color: var(--gn-color-text-medium);
}
.skills-header-actions {
display: flex;
gap: $space-xs;
}
.skills-description {
margin: 0 0 16px;
font-size: 0.875rem;
color: var(--gn-color-text-medium);
max-width: 70ch;
}
.skills-toolbar {
display: flex;
gap: $space-sm;
margin-bottom: $space-md;
flex-wrap: wrap;
// The kit's .form-group wrapper carries its own bottom margin; the row's
// gap already separates the fields.
:deep(.form-group) {
margin-bottom: 0;
}
}
.skills-search {
flex: 1 1 240px;
min-width: 0;
}
.skills-filter {
flex: 0 0 180px;
}
.skills-error {
margin-bottom: $space-sm;
}
.skills-layout {
display: grid;
grid-template-columns: minmax(220px, 320px) minmax(0, 1fr);
gap: $space-md;
align-items: start;
@media (max-width: 767px) {
grid-template-columns: 1fr;
}
}
.skills-list {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
max-height: 70vh;
overflow-y: auto;
}
.skills-loading {
padding: 24px;
text-align: center;
}
.skills-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 28px 16px;
border: 1px dashed var(--gn-color-border);
border-radius: var(--gn-radius-md);
text-align: center;
.empty-icon {
font-size: 1.75rem;
color: var(--gn-color-text-dimmed);
}
p {
margin: 0;
font-size: 0.875rem;
color: var(--gn-color-text-medium);
}
}
.skills-row {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--gn-color-border);
border-radius: var(--gn-radius-md);
background: var(--gn-color-surface);
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
&:hover {
border-color: var(--gn-color-border-strong);
}
&.is-active {
border-color: var(--gn-color-primary);
}
}
.skills-row-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-xs;
}
.skills-row-name {
font-size: 0.9rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skills-badge {
flex: none;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--gn-color-border-strong);
color: var(--gn-color-text-medium);
&[data-kind='protocol'] {
color: var(--gn-color-primary);
border-color: var(--gn-color-primary);
}
}
.skills-row-description {
font-size: 0.8rem;
color: var(--gn-color-text-medium);
overflow-wrap: anywhere;
}
.skills-row-meta {
font-size: 0.75rem;
color: var(--gn-color-text-dimmed);
}
.skills-editor {
min-width: 0;
display: flex;
flex-direction: column;
gap: $space-sm;
}
.skills-editor-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $space-xs;
}
.skills-editor-title {
font-size: 1rem;
font-weight: 600;
overflow-wrap: anywhere;
}
.skills-editor-version {
margin-left: 6px;
font-size: 0.8rem;
font-weight: 400;
color: var(--gn-color-text-dimmed);
}
.skills-readonly {
font-size: 0.8rem;
color: var(--gn-color-text-dimmed);
}
// The kit pins a textarea to two control heights (`.form-group .label
// textarea.input` and `.form-group .label .input` both match it) and forbids
// resizing; a document editor needs the room of a page, not a form field. The
// element part of the selector is what outranks those kit rules. `:deep`
// because the class lands on GnTextarea's inner <textarea>, which carries the
// child's scope id, not this component's.
.skills-editor :deep(textarea.skills-textarea) {
width: 100%;
height: auto;
min-height: 360px;
resize: vertical;
font-family: var(--gn-font-mono, monospace);
font-size: 0.82rem;
line-height: 1.5;
}
.skills-editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-sm;
flex-wrap: wrap;
:deep(.form-group) {
margin-bottom: 0;
}
}
.skills-visibility {
flex: 0 0 180px;
}
.skills-editor-buttons {
display: flex;
gap: $space-xs;
flex-wrap: wrap;
}
.skills-history {
border-top: 1px solid var(--gn-color-border);
padding-top: $space-sm;
}
.skills-history-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
border: none;
background: none;
color: var(--gn-color-text-medium);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
padding: 0;
}
.skills-history-list {
display: flex;
flex-direction: column;
gap: $space-sm;
margin-top: $space-sm;
}
.skills-history-empty {
font-size: 0.85rem;
color: var(--gn-color-text-dimmed);
}
.skills-history-item {
border: 1px solid var(--gn-color-border);
border-radius: var(--gn-radius-md);
padding: 8px 10px;
}
.skills-history-meta {
font-size: 0.78rem;
color: var(--gn-color-text-dimmed);
margin-bottom: 6px;
}
.skills-history-text {
margin: 0;
max-height: 220px;
overflow: auto;
font-family: var(--gn-font-mono, monospace);
font-size: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.skills-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 240px;
border: 1px dashed var(--gn-color-border);
border-radius: var(--gn-radius-md);
text-align: center;
color: var(--gn-color-text-dimmed);
i {
font-size: 2rem;
}
p {
margin: 0;
max-width: 44ch;
font-size: 0.875rem;
}
}
</style>