From cf1041ca9691e36df23b21bbdc00f432750b306f Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 19:10:35 +0000 Subject: [PATCH] feat(ui): list agent skills on the Skills page The Skills page asks for the skills of the saved agents with agents=true and shows them with the agent's name. A skill of the agent layer says who proposed it, can be removed (to the Trash) and is not offered to other agents. The list of a plain chat or of the agent form never carries them. Assisted-by: Claude:claude-sonnet-5-5 go node --- core/http/endpoints/studio/skills.go | 12 +++++++++- .../react-ui/public/locales/en/skills.json | 2 ++ core/http/react-ui/src/pages/Skills.jsx | 23 +++++++++++-------- core/http/react-ui/src/utils/api.js | 14 +++++++---- core/http/react-ui/src/utils/chatSkills.js | 10 ++++++++ .../react-ui/tests/unit/chatskills.test.mjs | 8 +++++++ core/services/studio/actorscan_test.go | 2 +- .../studio/agentskills_internal_test.go | 9 ++++++-- core/services/studio/skillsvc.go | 20 ++++++++-------- docs/content/features/chats.md | 2 +- swagger/docs.go | 8 ++++++- swagger/swagger.json | 8 ++++++- swagger/swagger.yaml | 8 ++++++- 13 files changed, 93 insertions(+), 33 deletions(-) diff --git a/core/http/endpoints/studio/skills.go b/core/http/endpoints/studio/skills.go index 448b1f5a12..def01204e0 100644 --- a/core/http/endpoints/studio/skills.go +++ b/core/http/endpoints/studio/skills.go @@ -42,9 +42,10 @@ func editable(c echo.Context, l skills.Layer) bool { // ListSkills lists the skills. // @Summary List your skills. -// @Description Skills from every layer: .skills folders of your files, your personal skills, the shared ones, the ones of the add-ons you may use now (addon, with the add-on in addon and addon_title) and the built-in ones. The nearest wins; a skill that another overrides says so in shadowed. With chat set, the chat's switches are applied. +// @Description Skills from every layer: .skills folders of your files, your personal skills, the shared ones, the ones of the add-ons you may use now (addon, with the add-on in addon and addon_title) and the built-in ones. A chat with a saved agent also lists the skills of that agent (agent). The nearest wins; a skill that another overrides says so in shadowed. With chat set, the chat's switches are applied. // @Tags chats // @Param chat query string false "a chat id, to apply its switches" +// @Param agents query bool false "true: add the skills of your saved agents (layer agent), for the Skills page; ignored with chat" // @Success 200 {object} schema.ChatsSkillList "The skills" // @Router /api/chats/skills [get] func (h *Handlers) ListSkills(c echo.Context) error { @@ -55,6 +56,15 @@ func (h *Handlers) ListSkills(c echo.Context) error { if err != nil { return failFor(c, err) } + // The Skills page asks for the skills of the saved agents too. A chat's + // own list already has the layer of its agent. + if c.QueryParam("agents") == "true" && c.QueryParam("chat") == "" { + more, err := h.svc.AgentSkills(c.Request().Context(), userID(c)) + if err != nil { + return failFor(c, err) + } + list = append(list, more...) + } out := schema.ChatsSkillList{Skills: make([]schema.ChatsSkill, 0, len(list)), Notes: notes} for _, sk := range list { out.Skills = append(out.Skills, schema.ChatsSkill{ diff --git a/core/http/react-ui/public/locales/en/skills.json b/core/http/react-ui/public/locales/en/skills.json index df6824543f..ef6b86d1ac 100644 --- a/core/http/react-ui/public/locales/en/skills.json +++ b/core/http/react-ui/public/locales/en/skills.json @@ -24,6 +24,7 @@ "shadowed": "Hidden by a {{layer}} skill", "shadowedLong": "Hidden by a {{layer}} skill of the same name", "providedBy": "Provided by {{name}}", + "proposedBy": "Proposed by {{name}} and confirmed by you. Only that agent uses it.", "replacedBy": "Replaced by {{name}}", "notActive": "Not active: the built-in skill serves", "notActiveBy": "Not active: {{name}} provides it", @@ -71,6 +72,7 @@ "layers": { "label": "Layer", "personal": "Personal", + "agent": "Agent", "shared": "Shared", "builtin": "Built-in", "folder": "Workspace", diff --git a/core/http/react-ui/src/pages/Skills.jsx b/core/http/react-ui/src/pages/Skills.jsx index ecdbf600a2..35dba8e1ba 100644 --- a/core/http/react-ui/src/pages/Skills.jsx +++ b/core/http/react-ui/src/pages/Skills.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' import { chatSkillsApi } from '../utils/api' +import { skillRef } from '../utils/chatSkills' import { useLibraryFacts } from '../hooks/useLibraryFacts' import { useWideLayout } from '../hooks/useWideLayout' // eslint-disable-next-line no-unused-vars @@ -19,13 +20,14 @@ import './library.css' const PREVIEW_LINES = 8 // The layers, nearest first: a skill of a nearer layer hides one of the same name. -const LAYERS = ['personal', 'folder', 'shared', 'addon', 'builtin'] +const LAYERS = ['personal', 'agent', 'folder', 'shared', 'addon', 'builtin'] // Two add-ons may ship a skill of one name, so an add-on skill keys by its add-on too. -const keyOf = (s) => (s.layer === 'addon' ? `addon/${s.addon}/${s.name}` : `${s.layer}/${s.name}`) +// An agent skill keys by its agent: two agents may keep one name. +const keyOf = (s) => (s.layer === 'addon' ? `addon/${s.addon}/${s.name}` : s.layer === 'agent' ? `agent/${s.agent}/${s.name}` : `${s.layer}/${s.name}`) // The badge of a skill: the add-on's title for an add-on skill, else the layer. -const badgeOf = (t, s) => (s.layer === 'addon' ? (s.addon_title || s.addon) : t(`layers.${s.layer}`)) +const badgeOf = (t, s) => (s.layer === 'addon' ? (s.addon_title || s.addon) : s.layer === 'agent' ? (s.agent_name || s.agent) : t(`layers.${s.layer}`)) // Why a skill does not serve, or where a serving one comes from, in one line. // titles maps an add-on ID to its title, from the add-on skills of the list. @@ -64,7 +66,7 @@ export default function Skills() { const fetchSkills = useCallback(async () => { try { - const data = await chatSkillsApi.list() + const data = await chatSkillsApi.list('', { agents: true }) setSkills(data.skills || []) setNotes(data.notes || []) setOff('') @@ -110,7 +112,7 @@ export default function Skills() { const deleteSkill = async (skill) => { setToDelete(null) try { - await chatSkillsApi.remove(skill.layer, skill.name) + await chatSkillsApi.remove(skill.layer, skill.name, skillRef(skill)) addToast(t('toasts.deleted', { name: skill.name }), 'success') select('') fetchSkills() @@ -315,16 +317,17 @@ function SkillPane({ t, skill, titles, users, status, agents, busy, onBack, onEd const [tab, setTab] = useState('overview') const [detail, setDetail] = useState({ phase: 'loading', data: null }) const unused = status === 'ready' && users.length === 0 - const grantable = !skill.invalid + // An agent skill belongs to its agent: no other agent is given it from here. + const grantable = !skill.invalid && skill.layer !== 'agent' useEffect(() => { if (skill.invalid) { setDetail({ phase: 'failed', data: null }); return undefined } let live = true - chatSkillsApi.get(skill.layer, skill.name, skill.layer === 'addon' ? skill.addon : '') + chatSkillsApi.get(skill.layer, skill.name, skillRef(skill)) .then(data => { if (live) setDetail({ phase: 'ready', data }) }) .catch(() => { if (live) setDetail({ phase: 'failed', data: null }) }) return () => { live = false } - }, [skill.layer, skill.name, skill.addon, skill.invalid]) + }, [skill.layer, skill.name, skill.addon, skill.agent, skill.invalid]) const body = detail.data?.body || '' const tokens = estimateTokens(body) @@ -343,14 +346,14 @@ function SkillPane({ t, skill, titles, users, status, agents, busy, onBack, onEd {skill.editable && ( )} - {skill.editable && ( + {(skill.editable || skill.removable) && ( )}