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