diff --git a/webclient/src/components/settings/SkillsCatalogPanel.vue b/webclient/src/components/settings/SkillsCatalogPanel.vue
index b1d6e69..744859e 100644
--- a/webclient/src/components/settings/SkillsCatalogPanel.vue
+++ b/webclient/src/components/settings/SkillsCatalogPanel.vue
@@ -234,7 +234,7 @@
diff --git a/webclient/src/components/settings/skills/composables/useCatalogEntry.js b/webclient/src/components/settings/skills/composables/useCatalogEntry.js
new file mode 100644
index 0000000..22822d2
--- /dev/null
+++ b/webclient/src/components/settings/skills/composables/useCatalogEntry.js
@@ -0,0 +1,212 @@
+import { computed, ref } from 'vue'
+import { useToast } from 'gnexus-ui-kit/vue'
+
+import * as api from '@/api/index.js'
+import { useConfirm } from '@/composables/useConfirm.js'
+import { locale, t } from '@/i18n/index.js'
+import { useAuthStore } from '@/stores/auth.js'
+
+import { TEMPLATES } from '../constants.js'
+import { errorText } from '../errors.js'
+
+/**
+ * The detail column: the selected entry, its reading view with history, and
+ * the source editor that creates, saves and deletes documents.
+ *
+ * `loadError` is the list's banner ref — a failed detail fetch shows there,
+ * above both panes, exactly as it did when this state lived in one setup.
+ */
+export function useCatalogEntry({ getKind, reload, loadError }) {
+ const authStore = useAuthStore()
+ const confirm = useConfirm()
+ const toast = useToast()
+
+ 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)
+
+ // 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 visibilityOptions = computed(() => [
+ { value: 'personal', label: t('settings.skillsPersonal') },
+ { value: 'shared', label: t('settings.skillsShared') },
+ ])
+
+ 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[getKind()]
+ 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 reload()
+ } 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 reload()
+ } 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 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',
+ })
+ }
+
+ return {
+ authStore,
+ entry,
+ selectedId,
+ detailLoading,
+ editing,
+ draft,
+ visibility,
+ saving,
+ saveError,
+ versions,
+ showHistory,
+ detailOpen,
+ canEdit,
+ visibilityOptions,
+ select,
+ startCreate,
+ beginEdit,
+ cancel,
+ reset,
+ save,
+ remove,
+ toggleHistory,
+ formatWhen,
+ }
+}
diff --git a/webclient/src/components/settings/skills/composables/useCatalogLabels.js b/webclient/src/components/settings/skills/composables/useCatalogLabels.js
new file mode 100644
index 0000000..7e3acc9
--- /dev/null
+++ b/webclient/src/components/settings/skills/composables/useCatalogLabels.js
@@ -0,0 +1,34 @@
+import { computed } from 'vue'
+
+import { t } from '@/i18n/index.js'
+
+/**
+ * The panel's copy: one catalog rendered as either skills or protocols, so
+ * every label picks the slug that matches the kind.
+ */
+export function useCatalogLabels({ getKind, items }) {
+ const isProtocol = computed(() => getKind() === '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')
+ )
+
+ return { isProtocol, title, hint, addLabel, emptyLabel, countLabel, newTitle }
+}
diff --git a/webclient/src/components/settings/skills/composables/useCatalogList.js b/webclient/src/components/settings/skills/composables/useCatalogList.js
new file mode 100644
index 0000000..b990cc3
--- /dev/null
+++ b/webclient/src/components/settings/skills/composables/useCatalogList.js
@@ -0,0 +1,53 @@
+import { computed, onBeforeUnmount, ref, watch } from 'vue'
+
+import * as api from '@/api/index.js'
+import { t } from '@/i18n/index.js'
+
+import { AREA_SLUGS } from '../constants.js'
+import { errorText } from '../errors.js'
+
+/**
+ * The catalog list: the fetched rows, the search box and area filter that
+ * narrow them, and the debounce that turns typing into one request.
+ */
+export function useCatalogList({ getKind }) {
+ const items = ref([])
+ const loading = ref(false)
+ const loadError = ref('')
+
+ const query = ref('')
+ const areaFilter = ref('')
+
+ const areaOptions = computed(() => [
+ { value: '', label: t('settings.skillsFilterAreaAll') },
+ ...Object.entries(AREA_SLUGS).map(([value, slug]) => ({ value, label: t(slug) })),
+ ])
+
+ let debounce = null
+
+ watch([query, areaFilter], () => {
+ clearTimeout(debounce)
+ debounce = setTimeout(load, 250)
+ })
+
+ 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: getKind(),
+ })
+ items.value = data.items || []
+ } catch (err) {
+ loadError.value = errorText(err)
+ } finally {
+ loading.value = false
+ }
+ }
+
+ return { items, loading, loadError, query, areaFilter, areaOptions, load }
+}
diff --git a/webclient/src/components/settings/skills/constants.js b/webclient/src/components/settings/skills/constants.js
new file mode 100644
index 0000000..ec20492
--- /dev/null
+++ b/webclient/src/components/settings/skills/constants.js
@@ -0,0 +1,46 @@
+import { t } from '@/i18n/index.js'
+
+// Catalog areas → their i18n slugs.
+export const AREA_SLUGS = {
+ home: 'settings.skillsAreaHome',
+ infrastructure: 'settings.skillsAreaInfrastructure',
+ workflows: 'settings.skillsAreaWorkflows',
+ general: 'settings.skillsAreaGeneral',
+}
+
+export function areaLabel(area) {
+ return t(AREA_SLUGS[area] || AREA_SLUGS.general)
+}
+
+// A new document starts as front matter only: the editor shows the source the
+// agent will read, so the template is a valid minimal file of that kind.
+export 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:
+---
+
+`,
+}
diff --git a/webclient/src/components/settings/skills/errors.js b/webclient/src/components/settings/skills/errors.js
new file mode 100644
index 0000000..29bd886
--- /dev/null
+++ b/webclient/src/components/settings/skills/errors.js
@@ -0,0 +1,13 @@
+// api/index.js throws `METHOD path → status: body`; the body is FastAPI's JSON,
+// whose `detail` is the sentence worth showing.
+export 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
+ }
+}
diff --git a/webclient/src/components/settings/skills/styles/_detail.scss b/webclient/src/components/settings/skills/styles/_detail.scss
new file mode 100644
index 0000000..0de8784
--- /dev/null
+++ b/webclient/src/components/settings/skills/styles/_detail.scss
@@ -0,0 +1,134 @@
+@use 'kit-deps' as *;
+
+/* ---------- 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;
+}
diff --git a/webclient/src/components/settings/skills/styles/_editor.scss b/webclient/src/components/settings/skills/styles/_editor.scss
new file mode 100644
index 0000000..b29e45c
--- /dev/null
+++ b/webclient/src/components/settings/skills/styles/_editor.scss
@@ -0,0 +1,38 @@
+@use 'kit-deps' as *;
+
+/* ---------- 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