<template>
<div class="catalog-panel">
<header class="catalog-header">
<h2 class="catalog-title">{{ title }}</h2>
<p class="catalog-hint">{{ hint }}</p>
</header>
<GnAlert v-if="loadError" variant="error" class="catalog-alert">{{ loadError }}</GnAlert>
<div class="catalog-layout" :class="{ 'is-detail-open': detailOpen }">
<aside class="catalog-pane catalog-list-pane" :aria-label="t('settings.skillsCatalog')">
<!-- The controls live with the list they act on, so what they filter
is never in doubt. -->
<div class="catalog-list-head">
<GnSearchField
v-model="query"
:placeholder="t('settings.skillsSearchHint')"
:aria-label="t('settings.skillsSearchLabel')"
/>
<div class="catalog-filters">
<GnSelect
v-model="areaFilter"
class="catalog-area"
:aria-label="t('settings.skillsFilterArea')"
:options="areaOptions"
/>
<GnButton
variant="primary"
size="sm"
icon="ph ph-plus"
class="catalog-add"
@click="startCreate"
>
{{ addLabel }}
</GnButton>
</div>
<p v-if="!loading" class="catalog-count">{{ countLabel }}</p>
</div>
<div class="catalog-list">
<div v-if="loading" class="catalog-loading">
<GnLoader circle :label="t('settings.skillsLoading')" />
</div>
<div v-else-if="!items.length" class="catalog-empty">
<i class="ph ph-notebook" aria-hidden="true"></i>
<p>{{ emptyLabel }}</p>
</div>
<button
v-for="item in items"
:key="item.id"
type="button"
class="catalog-row"
:class="{ 'is-active': item.id === selectedId }"
@click="select(item.id)"
>
<span class="catalog-row-main">
<span class="catalog-row-head">
<span class="catalog-row-name">{{ item.name }}</span>
<span class="catalog-row-version">v{{ item.version }}</span>
</span>
<span class="catalog-row-meta">
{{ areaLabel(item.area) }}
<template v-if="item.visibility === 'shared'">
· {{ t('settings.skillsShared') }}
</template>
</span>
<span v-if="item.description" class="catalog-row-description">
{{ item.description }}
</span>
</span>
<i class="ph ph-caret-right catalog-row-open" aria-hidden="true"></i>
</button>
</div>
</aside>
<section class="catalog-pane catalog-detail">
<button type="button" class="catalog-back" @click="reset">
<i class="ph ph-arrow-left" aria-hidden="true"></i>
<span>{{ t('settings.skillsBack') }}</span>
</button>
<!-- Editing: the document is YAML front matter plus markdown, so the
only honest editor is the source itself. -->
<template v-if="editing">
<header class="catalog-detail-head">
<div class="catalog-detail-title">
<h3>{{ entry ? entry.name : newTitle }}</h3>
<span v-if="entry" class="catalog-detail-version">v{{ entry.version }}</span>
</div>
</header>
<div class="catalog-detail-body">
<GnAlert v-if="saveError" variant="error" class="catalog-save-error">
{{ saveError }}
</GnAlert>
<GnTextarea
v-model="draft"
class="catalog-textarea"
:rows="18"
:disabled="!canEdit || saving"
:placeholder="t('settings.skillsEditorPlaceholder')"
:aria-label="t('settings.skillsEditorLabel')"
/>
<div class="catalog-editor-actions">
<div v-if="authStore.isAdmin && canEdit" class="catalog-visibility">
<GnSelect
v-model="visibility"
:aria-label="t('settings.skillsVisibility')"
:options="visibilityOptions"
/>
</div>
<div class="catalog-editor-buttons">
<GnButton
variant="primary"
:disabled="!canEdit || saving || !draft.trim()"
@click="save"
>
{{ t('common.save') }}
</GnButton>
<GnButton variant="ghost" :disabled="saving" @click="cancel">
{{ t('common.cancel') }}
</GnButton>
<GnButton
v-if="entry && entry.can_edit"
variant="ghost"
icon="ph ph-trash"
:disabled="saving"
@click="remove"
>
{{ t('common.delete') }}
</GnButton>
</div>
</div>
</div>
</template>
<!-- Reading: the parsed document, the way the agent loads it. -->
<template v-else-if="entry">
<header class="catalog-detail-head">
<div class="catalog-detail-title">
<h3>{{ entry.name }}</h3>
<span class="catalog-detail-version">v{{ entry.version }}</span>
</div>
<div class="catalog-detail-actions">
<GnButton
v-if="entry.can_edit"
variant="secondary"
size="sm"
icon="ph ph-pencil-simple"
class="catalog-edit-btn"
@click="beginEdit"
>
{{ t('settings.skillsEdit') }}
</GnButton>
<GnIconButton
v-if="entry.can_edit"
icon="ph ph-trash"
:label="t('common.delete')"
size="sm"
class="catalog-delete-btn"
@click="remove"
/>
</div>
</header>
<div class="catalog-detail-body">
<p v-if="entry.description" class="catalog-description">{{ entry.description }}</p>
<div class="catalog-meta">
<span>{{ areaLabel(entry.area) }}</span>
<span>
{{ entry.visibility === 'shared' ? t('settings.skillsShared') : t('settings.skillsPersonal') }}
</span>
<span v-if="!entry.can_edit" class="catalog-readonly">
{{ t('settings.skillsReadOnly') }}
</span>
<span v-if="entry.updated_at">
{{ t('settings.skillsUpdated', { when: formatWhen(entry.updated_at) }) }}
</span>
<span v-if="entry.tags?.length" class="catalog-tags">
<i class="ph ph-tag" aria-hidden="true"></i>
{{ entry.tags.join(', ') }}
</span>
</div>
<SkillDocument v-if="entry.document" :doc="entry.document" class="catalog-document" />
<template v-else>
<GnAlert variant="warning" class="catalog-parse-error">
{{ t('settings.skillsParseFailed') }}
</GnAlert>
<pre class="catalog-raw">{{ entry.content }}</pre>
</template>
<div class="catalog-history">
<button type="button" class="catalog-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="catalog-history-list">
<div v-if="!versions.length" class="catalog-history-empty">
{{ t('settings.skillsHistoryEmpty') }}
</div>
<div v-for="v in versions" :key="v.version" class="catalog-history-item">
<div class="catalog-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="catalog-history-text">{{ v.content }}</pre>
</div>
</div>
</div>
</div>
</template>
<div v-else-if="detailLoading" class="catalog-detail-body catalog-detail-loading">
<GnLoader circle :label="t('settings.skillsLoading')" />
</div>
<div v-else class="catalog-detail-body">
<div class="catalog-placeholder">
<i class="ph ph-notebook" aria-hidden="true"></i>
<p>{{ t('settings.skillsPlaceholder') }}</p>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
GnAlert,
GnButton,
GnIconButton,
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 SkillDocument from '@/components/skills/SkillDocument.vue'
import * as api from '@/api/index.js'
// One catalog, two sections: Settings shows skills and protocols as separate
// tabs and mounts this panel with the kind it owns.
const props = defineProps({
kind: {
type: String,
default: 'skill',
validator: value => value === 'skill' || value === 'protocol',
},
})
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 areaFilter = ref('')
const entry = ref(null)
const selectedId = ref(null)
const detailLoading = ref(false)
const editing = ref(false)
const draft = ref('')
const visibility = ref('personal')
const saving = ref(false)
const saveError = ref('')
const versions = ref([])
const showHistory = ref(false)
const isProtocol = computed(() => props.kind === 'protocol')
const title = computed(() =>
t(isProtocol.value ? 'settings.tabProtocols' : 'settings.tabSkills')
)
const hint = computed(() =>
t(isProtocol.value ? 'settings.skillsHintProtocols' : 'settings.skillsHintSkills')
)
const addLabel = computed(() =>
t(isProtocol.value ? 'settings.skillsAddProtocol' : 'settings.skillsAddSkill')
)
const emptyLabel = computed(() =>
t(isProtocol.value ? 'settings.skillsEmptyProtocols' : 'settings.skillsEmptySkills')
)
const countLabel = computed(() =>
t(
isProtocol.value ? 'settings.skillsCountProtocols' : 'settings.skillsCountSkills',
{ n: items.value.length },
)
)
const newTitle = computed(() =>
t(isProtocol.value ? 'settings.skillsNewProtocol' : 'settings.skillsNewSkill')
)
// A selected entry or a new one takes the detail column; on a phone that also
// replaces the list, so the reader gets the whole width.
const detailOpen = computed(
() => editing.value || entry.value !== null || detailLoading.value
)
const canEdit = computed(() => (editing.value && !entry.value ? true : Boolean(entry.value?.can_edit)))
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, areaFilter], () => {
clearTimeout(debounce)
debounce = setTimeout(load, 250)
})
watch(() => props.kind, () => {
reset()
load()
})
onMounted(load)
onBeforeUnmount(() => clearTimeout(debounce))
async function load() {
loading.value = true
loadError.value = ''
try {
const data = await api.getSkills({
q: query.value.trim() || null,
area: areaFilter.value || null,
kind: props.kind,
})
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
editing.value = false
showHistory.value = false
saveError.value = ''
entry.value = null
versions.value = []
// `detailLoading` keeps the pane open on a phone while the entry travels.
detailLoading.value = true
try {
const data = await api.getSkill(id)
// The reader may have gone back or picked another row meanwhile.
if (selectedId.value !== id) return
entry.value = data
draft.value = data.content
visibility.value = data.visibility
} catch (err) {
if (selectedId.value === id) loadError.value = errorText(err)
} finally {
if (selectedId.value === id) detailLoading.value = false
}
}
function startCreate() {
entry.value = null
selectedId.value = null
detailLoading.value = false
editing.value = true
draft.value = TEMPLATES[props.kind]
visibility.value = 'personal'
versions.value = []
showHistory.value = false
saveError.value = ''
}
function beginEdit() {
if (!entry.value) return
draft.value = entry.value.content
visibility.value = entry.value.visibility
saveError.value = ''
editing.value = true
}
function cancel() {
if (entry.value) {
editorBackToRead()
} else {
reset()
}
}
function editorBackToRead() {
draft.value = entry.value?.content || ''
visibility.value = entry.value?.visibility || 'personal'
saveError.value = ''
editing.value = false
}
function reset() {
entry.value = null
selectedId.value = null
detailLoading.value = false
editing.value = false
draft.value = ''
saveError.value = ''
versions.value = []
showHistory.value = false
}
async function save() {
saving.value = true
saveError.value = ''
try {
if (entry.value) {
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') })
} else {
const created = await api.createSkill({
content: draft.value,
visibility: authStore.isAdmin ? visibility.value : 'personal',
})
entry.value = created
selectedId.value = created.id
draft.value = created.content
visibility.value = created.visibility
toast.success({ title: t('settings.skillsCreated') })
}
editing.value = false
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 *;
.catalog-panel {
display: flex;
flex-direction: column;
gap: $space-md;
min-height: 0;
}
/* ---------- page head ---------- */
.catalog-header {
display: flex;
flex-direction: column;
gap: 4px;
}
.catalog-title {
margin: 0;
font-size: 1.05rem;
font-weight: 600;
color: var(--gn-color-text-light);
}
.catalog-hint {
margin: 0;
max-width: 70ch;
font-size: 0.85rem;
color: var(--gn-color-text-medium);
}
.catalog-alert {
margin: 0;
}
/* ---------- two panes ---------- */
// Two surfaces side by side: the list you pick from, the document you read.
// On a phone they take turns, and `is-detail-open` decides whose turn it is.
.catalog-layout {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: $space-md;
align-items: start;
@media (max-width: 767px) {
grid-template-columns: 1fr;
.catalog-detail {
display: none;
}
&.is-detail-open {
.catalog-list-pane {
display: none;
}
.catalog-detail {
display: flex;
}
}
}
}
.catalog-pane {
min-width: 0;
border: 1px solid var(--gn-border-color-muted);
border-radius: $border-radius-md;
background: var(--gn-surface-panel);
}
.catalog-list-pane {
display: flex;
flex-direction: column;
min-height: 0;
}
.catalog-list-head {
display: flex;
flex-direction: column;
gap: $space-sm;
padding: $space-sm;
border-bottom: 1px solid var(--gn-border-color-muted);
// The kit's .form-group wrapper carries its own bottom margin; the column's
// gap already separates the fields.
:deep(.form-group) {
margin-bottom: 0;
}
}
.catalog-filters {
display: flex;
align-items: center;
gap: $space-sm;
}
.catalog-area {
flex: 1 1 auto;
min-width: 0;
}
.catalog-add {
flex: none;
}
.catalog-count {
margin: 0;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--gn-color-text-dark);
}
.catalog-list {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: $space-xs;
max-height: 60vh;
overflow-y: auto;
}
.catalog-loading {
padding: 24px;
text-align: center;
}
.catalog-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 28px 16px;
border: 1px dashed var(--gn-border-color-muted);
border-radius: $border-radius-md;
text-align: center;
i {
font-size: 1.75rem;
color: var(--gn-color-text-dark);
}
p {
margin: 0;
font-size: 0.85rem;
color: var(--gn-color-text-medium);
}
}
/* ---------- list rows ---------- */
.catalog-row {
display: flex;
align-items: center;
gap: $space-xs;
width: 100%;
min-width: 0;
padding: 7px 8px;
border: 1px solid transparent;
border-radius: $border-radius-sm;
background: none;
color: inherit;
text-align: left;
cursor: pointer;
font: inherit;
&:hover {
background: var(--gn-surface-panel-muted);
border-color: var(--gn-border-color-muted);
}
&:focus-visible {
outline: 2px solid var(--gn-focus-outline-color);
outline-offset: -2px;
}
&.is-active {
background: var(--gn-surface-panel-muted);
border-color: var(--gn-border-color-base);
}
}
.catalog-row-main {
display: flex;
flex-direction: column;
gap: 1px;
flex: 1 1 auto;
min-width: 0;
}
/* The row opens the reading pane; the caret is what says so. */
.catalog-row-open {
flex: none;
font-size: 0.8rem;
color: var(--gn-color-text-dark);
}
.catalog-row.is-active .catalog-row-open {
color: var(--gn-color-primary);
}
.catalog-row-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $space-xs;
}
.catalog-row-name {
font-size: 0.875rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.catalog-row-version {
flex: none;
font-size: 0.7rem;
color: var(--gn-color-text-dark);
}
.catalog-row-meta {
font-size: 0.72rem;
color: var(--gn-color-text-dark);
}
.catalog-row-description {
font-size: 0.78rem;
color: var(--gn-color-text-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ---------- detail ---------- */
.catalog-detail {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 320px;
}
.catalog-back {
display: none;
align-items: center;
gap: 6px;
padding: $space-sm $space-sm 0;
border: none;
background: none;
color: var(--gn-color-text-medium);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
@media (max-width: 767px) {
display: inline-flex;
}
}
.catalog-detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $space-sm;
flex-wrap: wrap;
padding: $space-sm $space-md;
border-bottom: 1px solid var(--gn-border-color-muted);
}
.catalog-detail-body {
display: flex;
flex-direction: column;
gap: $space-sm;
padding: $space-md;
// The kit wraps controls in a .form-group capped at 600px; a document
// editor claims the whole column instead.
:deep(.form-group) {
width: 100%;
max-width: none;
}
}
.catalog-detail-loading {
align-items: center;
justify-content: center;
}
.catalog-detail-title {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
overflow-wrap: anywhere;
}
}
.catalog-detail-version {
font-size: 0.75rem;
color: var(--gn-color-text-dark);
}
.catalog-detail-actions {
display: flex;
align-items: center;
gap: $space-xs;
flex-wrap: wrap;
}
.catalog-description {
margin: 0;
max-width: 72ch;
font-size: 0.875rem;
color: var(--gn-color-text-medium);
}
.catalog-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 0.75rem;
color: var(--gn-color-text-dark);
}
.catalog-readonly {
color: var(--gn-color-text-dark);
font-style: italic;
}
.catalog-tags {
display: inline-flex;
align-items: center;
gap: 4px;
}
// A document reads best at a column, not across a wide pane.
.catalog-document {
max-width: 72ch;
padding-top: $space-xs;
}
.catalog-parse-error {
margin: 0;
}
.catalog-raw,
.catalog-history-text {
margin: 0;
max-height: 320px;
overflow: auto;
padding: 10px 12px;
border: 1px solid var(--gn-border-color-muted);
border-radius: $border-radius-md;
background: var(--gn-surface-panel-muted);
font-family: var(--gn-font-mono, monospace);
font-size: 0.75rem;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* ---------- editor ---------- */
// The kit pins a textarea to a control height and forbids resizing; a document
// editor needs the room of a page. The element part of the selector outranks
// the kit rules; `:deep` because the class lands on GnTextarea's <textarea>.
.catalog-detail :deep(textarea.catalog-textarea) {
width: 100%;
height: auto;
min-height: 320px;
resize: vertical;
font-family: var(--gn-font-mono, monospace);
font-size: 0.8rem;
line-height: 1.5;
}
.catalog-editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: $space-sm;
flex-wrap: wrap;
:deep(.form-group) {
margin-bottom: 0;
}
}
.catalog-visibility {
flex: 0 0 180px;
}
.catalog-editor-buttons {
display: flex;
gap: $space-xs;
flex-wrap: wrap;
}
/* ---------- history ---------- */
.catalog-history {
border-top: 1px solid var(--gn-border-color-muted);
padding-top: $space-sm;
}
.catalog-history-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: none;
background: none;
color: var(--gn-color-text-medium);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.catalog-history-list {
display: flex;
flex-direction: column;
gap: $space-sm;
margin-top: $space-sm;
}
.catalog-history-empty {
font-size: 0.85rem;
color: var(--gn-color-text-dark);
}
.catalog-history-item {
border: 1px solid var(--gn-border-color-muted);
border-radius: $border-radius-md;
padding: 8px 10px;
}
.catalog-history-meta {
margin-bottom: 6px;
font-size: 0.78rem;
color: var(--gn-color-text-dark);
}
.catalog-history-text {
max-height: 220px;
}
/* ---------- placeholder ---------- */
// The detail pane is already a framed surface; a second dashed frame inside
// it would only add noise. The hint sits in the middle of the pane instead.
.catalog-placeholder {
display: flex;
flex: 1 1 auto;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 260px;
padding: $space-md;
text-align: center;
color: var(--gn-color-text-dark);
i {
font-size: 2rem;
}
p {
margin: 0;
max-width: 44ch;
font-size: 0.85rem;
}
}
</style>