From 81d96ae0146bf5b45e30ba07bf48381a147486ed Mon Sep 17 00:00:00 2001 From: Duy /zuey/ Date: Thu, 21 May 2026 16:37:50 +0700 Subject: [PATCH] feat(skills): clarify access modes and repair file paths (#31) * feat(skills): add bulk management actions * feat(skills): improve operations UX * feat(skills): clarify access modes and repair file paths --- docs/07-bootstrap-skills-memory.md | 16 +-- internal/gateway/methods/skills.go | 10 +- internal/gateway/methods/skills_test.go | 66 ++++++++- internal/http/skills.go | 13 ++ internal/http/skills_export.go | 2 +- internal/http/skills_update_test.go | 103 +++++++++++++++ internal/http/skills_upload_test.go | 6 + internal/http/skills_versions.go | 75 +++++++---- internal/http/skills_versions_test.go | 34 +++++ internal/skills/visibility.go | 15 ++- internal/skills/visibility_test.go | 4 +- internal/store/pg/scan_rows_test.go | 11 ++ internal/store/pg/skills_content.go | 4 +- internal/store/pg/skills_embedding.go | 5 +- internal/store/pg/skills_grants.go | 3 +- internal/store/skill_paths.go | 42 ++++++ internal/store/skill_paths_test.go | 31 +++++ internal/store/sqlitestore/skills.go | 4 +- internal/store/sqlitestore/skills_grants.go | 5 +- internal/store/sqlitestore/skills_test.go | 125 ++++++++++++++++++ internal/store/visibility_filter.go | 4 + internal/store/visibility_filter_test.go | 1 + ui/web/src/components/layout/sidebar.tsx | 6 +- ui/web/src/i18n/locales/en/skills.json | 66 ++++++++- ui/web/src/i18n/locales/vi/skills.json | 66 ++++++++- ui/web/src/i18n/locales/zh/skills.json | 66 ++++++++- ui/web/src/index.css | 7 + ui/web/src/lib/clean-version.test.ts | 20 +++ ui/web/src/lib/clean-version.ts | 15 ++- .../skills/lib/skill-access-mode.test.ts | 29 ++++ .../src/pages/skills/lib/skill-access-mode.ts | 31 +++++ .../skills/lib/skill-upload-summary.test.ts | 25 ++++ .../pages/skills/lib/skill-upload-summary.ts | 62 +++++++++ .../pages/skills/lib/skills-filtering.test.ts | 54 ++++++++ .../src/pages/skills/lib/skills-filtering.ts | 115 ++++++++++++++++ .../skills/lib/skills-page-state.test.ts | 37 ++++++ .../src/pages/skills/lib/skills-page-state.ts | 58 ++++++++ ui/web/src/pages/skills/skill-agent-chips.tsx | 42 ++++++ .../skills/skill-bulk-actions-toolbar.tsx | 28 +++- .../src/pages/skills/skill-detail-dialog.tsx | 101 ++++++++++++-- ui/web/src/pages/skills/skill-edit-dialog.tsx | 17 +-- .../src/pages/skills/skill-health-summary.tsx | 50 +++++++ .../src/pages/skills/skill-status-badges.tsx | 33 +++++ ui/web/src/pages/skills/skill-table-row.tsx | 74 +++-------- .../src/pages/skills/skill-upload-dialog.tsx | 25 ++-- ui/web/src/pages/skills/skills-filter-bar.tsx | 68 ++++++++++ ui/web/src/pages/skills/skills-page.tsx | 86 ++++++++---- 47 files changed, 1559 insertions(+), 201 deletions(-) create mode 100644 internal/http/skills_update_test.go create mode 100644 internal/http/skills_versions_test.go create mode 100644 internal/store/skill_paths.go create mode 100644 internal/store/skill_paths_test.go create mode 100644 ui/web/src/lib/clean-version.test.ts create mode 100644 ui/web/src/pages/skills/lib/skill-access-mode.test.ts create mode 100644 ui/web/src/pages/skills/lib/skill-access-mode.ts create mode 100644 ui/web/src/pages/skills/lib/skill-upload-summary.test.ts create mode 100644 ui/web/src/pages/skills/lib/skill-upload-summary.ts create mode 100644 ui/web/src/pages/skills/lib/skills-filtering.test.ts create mode 100644 ui/web/src/pages/skills/lib/skills-filtering.ts create mode 100644 ui/web/src/pages/skills/lib/skills-page-state.test.ts create mode 100644 ui/web/src/pages/skills/lib/skills-page-state.ts create mode 100644 ui/web/src/pages/skills/skill-agent-chips.tsx create mode 100644 ui/web/src/pages/skills/skill-health-summary.tsx create mode 100644 ui/web/src/pages/skills/skill-status-badges.tsx create mode 100644 ui/web/src/pages/skills/skills-filter-bar.tsx diff --git a/docs/07-bootstrap-skills-memory.md b/docs/07-bootstrap-skills-memory.md index aaef2171..cc2af0fb 100644 --- a/docs/07-bootstrap-skills-memory.md +++ b/docs/07-bootstrap-skills-memory.md @@ -320,9 +320,9 @@ flowchart TD --- -## 12. Skills Grants & Visibility +## 12. Skills Grants & Access Mode -Skill access is controlled through a 3-tier visibility model with explicit agent and user grants. +Skill access is controlled through a 3-tier `visibility` field with explicit agent and user grants. The web UI labels this as **Access mode** because `public` means tenant-wide access, not internet publishing. ```mermaid flowchart TD @@ -335,13 +335,13 @@ flowchart TD GRANT -->|No grant| DENIED["Not accessible"] ``` -### Visibility Levels +### Access Modes -| Visibility | Access Rule | -|------------|------------| -| `public` | All agents and users can discover and use the skill | -| `private` | Only the owner (`skills.owner_id = userID`) can access | -| `internal` | Requires an explicit agent grant or user grant | +| DB value | UI label | Access Rule | +|----------|----------|------------| +| `private` | Owner only | Only the owner (`skills.owner_id = userID`) can access | +| `internal` | Granted agents | Requires an explicit agent grant or user grant | +| `public` | All tenant agents | All agents and users in scope can discover and use the skill | ### Grant Tables diff --git a/internal/gateway/methods/skills.go b/internal/gateway/methods/skills.go index 37898aae..9bf2275d 100644 --- a/internal/gateway/methods/skills.go +++ b/internal/gateway/methods/skills.go @@ -255,14 +255,16 @@ func (m *SkillsMethods) handleUpdate(ctx context.Context, client *gateway.Client // Validate visibility enum if present — fail closed before mutating the DB. if v, ok := params.Updates["visibility"]; ok { - vs, _ := v.(string) + vs, ok := v.(string) + if !ok { + client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidVisibility, ""))) + return + } if err := skills.ValidateVisibility(vs); err != nil { client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidVisibility, vs))) return } - if vs != "" { - params.Updates["visibility"] = skills.NormalizeVisibility(vs) - } + params.Updates["visibility"] = skills.NormalizeVisibility(vs) } if err := updater.UpdateSkill(ctx, skillID, params.Updates); err != nil { diff --git a/internal/gateway/methods/skills_test.go b/internal/gateway/methods/skills_test.go index e7742e8f..332940c0 100644 --- a/internal/gateway/methods/skills_test.go +++ b/internal/gateway/methods/skills_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "testing" + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -41,14 +43,14 @@ func (s *stubSkillStore) LoadSkill(_ context.Context, name string) (string, bool return c, ok } -func (s *stubSkillStore) LoadForContext(_ context.Context, _ []string) string { return "" } -func (s *stubSkillStore) BuildSummary(_ context.Context, _ []string) string { return "" } +func (s *stubSkillStore) LoadForContext(_ context.Context, _ []string) string { return "" } +func (s *stubSkillStore) BuildSummary(_ context.Context, _ []string) string { return "" } func (s *stubSkillStore) FilterSkills(_ context.Context, _ []string) []store.SkillInfo { return nil } -func (s *stubSkillStore) Version() int64 { return s.version } -func (s *stubSkillStore) BumpVersion() { s.version++ } -func (s *stubSkillStore) Dirs() []string { return nil } +func (s *stubSkillStore) Version() int64 { return s.version } +func (s *stubSkillStore) BumpVersion() { s.version++ } +func (s *stubSkillStore) Dirs() []string { return nil } // ---- helpers ---- @@ -161,3 +163,57 @@ func TestSkillsUpdate_EmptyUpdates_ReturnsInvalidRequest(t *testing.T) { m.handleUpdate(context.Background(), client, req) // No panic = invalid-request (empty updates) path hit } + +func TestSkillsUpdate_NormalizesEmptyVisibilityToPrivate(t *testing.T) { + skillID := "0193a5b0-7000-7000-8000-000000000321" + store := newUpdatingSkillStore([]store.SkillInfo{{Name: "sk", Slug: "sk", Path: skillID}}) + m := NewSkillsMethods(store, nil) + client := nullClient() + req := skillReqFrame(t, protocol.MethodSkillsUpdate, map[string]any{ + "id": skillID, + "updates": map[string]any{"visibility": ""}, + }) + + m.handleUpdate(context.Background(), client, req) + + if got := store.lastUpdates["visibility"]; got != "private" { + t.Fatalf("visibility update = %#v, want private", got) + } +} + +func TestSkillsUpdate_RejectsNonStringVisibilityBeforeUpdate(t *testing.T) { + skillID := "0193a5b0-7000-7000-8000-000000000322" + store := newUpdatingSkillStore([]store.SkillInfo{{Name: "sk", Slug: "sk", Path: skillID}}) + m := NewSkillsMethods(store, nil) + client := nullClient() + req := skillReqFrame(t, protocol.MethodSkillsUpdate, map[string]any{ + "id": skillID, + "updates": map[string]any{"visibility": true}, + }) + + m.handleUpdate(context.Background(), client, req) + + if store.updateCalled { + t.Fatalf("non-string visibility reached UpdateSkill: %+v", store.lastUpdates) + } +} + +type updatingSkillStore struct { + *stubSkillStore + lastUpdates map[string]any + updateCalled bool +} + +func newUpdatingSkillStore(skills []store.SkillInfo) *updatingSkillStore { + return &updatingSkillStore{stubSkillStore: newStubSkillStore(skills)} +} + +func (s *updatingSkillStore) UpdateSkill(_ context.Context, _ uuid.UUID, updates map[string]any) error { + s.updateCalled = true + s.lastUpdates = updates + return nil +} + +func (s *updatingSkillStore) GetSkillOwnerID(context.Context, uuid.UUID) (string, bool) { + return "", true +} diff --git a/internal/http/skills.go b/internal/http/skills.go index 34a00145..7457cbe8 100644 --- a/internal/http/skills.go +++ b/internal/http/skills.go @@ -216,6 +216,19 @@ func (h *SkillsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { delete(updates, "is_system") delete(updates, "enabled") + if v, ok := updates["visibility"]; ok { + vs, ok := v.(string) + if !ok { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVisibility, "")}) + return + } + if err := skills.ValidateVisibility(vs); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidVisibility, vs)}) + return + } + updates["visibility"] = skills.NormalizeVisibility(vs) + } + if err := h.skills.UpdateSkill(r.Context(), id, updates); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return diff --git a/internal/http/skills_export.go b/internal/http/skills_export.go index d4f688a6..8f8a1e23 100644 --- a/internal/http/skills_export.go +++ b/internal/http/skills_export.go @@ -146,7 +146,7 @@ func (h *SkillsHandler) writeSkillsExportArchive(ctx context.Context, w io.Write // SKILL.md — read from filesystem if sk.FilePath != "" { - fullPath := config.ExpandHome(sk.FilePath) + fullPath := config.ExpandHome(store.SkillMarkdownPath(sk.FilePath)) if data, err := os.ReadFile(fullPath); err == nil { if err := addToTar(tw, prefix+"SKILL.md", data); err != nil { slog.Warn("skills.export: write SKILL.md", "slug", sk.Slug, "error", err) diff --git a/internal/http/skills_update_test.go b/internal/http/skills_update_test.go new file mode 100644 index 00000000..9c967ae2 --- /dev/null +++ b/internal/http/skills_update_test.go @@ -0,0 +1,103 @@ +package http + +import ( + "bytes" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestSkillsHandlerUpdateAcceptsInternalVisibility(t *testing.T) { + baseDir := t.TempDir() + skillStore := newSkillManageStoreStub(baseDir) + id := skillStore.seedCustomSkill("access-mode-skill", filepath.Join(baseDir, "access-mode-skill", "1"), "active", nil) + handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil) + + req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":"INTERNAL"}`)) + req.SetPathValue("id", id.String()) + req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID)) + rec := httptest.NewRecorder() + + handler.handleUpdate(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := skillStore.lastUpdates[id]["visibility"]; got != "internal" { + t.Fatalf("updated visibility = %#v, want internal", got) + } + info, ok := skillStore.GetSkillByID(req.Context(), id) + if !ok { + t.Fatal("GetSkillByID returned !ok") + } + if info.Visibility != "internal" { + t.Fatalf("stored visibility = %q, want internal", info.Visibility) + } +} + +func TestSkillsHandlerUpdateNormalizesEmptyVisibilityToPrivate(t *testing.T) { + baseDir := t.TempDir() + skillStore := newSkillManageStoreStub(baseDir) + id := skillStore.seedCustomSkill("empty-access-mode-skill", filepath.Join(baseDir, "empty-access-mode-skill", "1"), "active", nil) + handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil) + + req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":""}`)) + req.SetPathValue("id", id.String()) + req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID)) + rec := httptest.NewRecorder() + + handler.handleUpdate(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := skillStore.lastUpdates[id]["visibility"]; got != "private" { + t.Fatalf("updated visibility = %#v, want private", got) + } +} + +func TestSkillsHandlerUpdateRejectsInvalidVisibility(t *testing.T) { + baseDir := t.TempDir() + skillStore := newSkillManageStoreStub(baseDir) + id := skillStore.seedCustomSkill("bad-access-mode-skill", filepath.Join(baseDir, "bad-access-mode-skill", "1"), "active", nil) + handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil) + + req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":"team"}`)) + req.SetPathValue("id", id.String()) + req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID)) + rec := httptest.NewRecorder() + + handler.handleUpdate(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if _, ok := skillStore.lastUpdates[id]; ok { + t.Fatalf("invalid visibility reached UpdateSkill: %+v", skillStore.lastUpdates[id]) + } +} + +func TestSkillsHandlerUpdateRejectsNonStringVisibility(t *testing.T) { + baseDir := t.TempDir() + skillStore := newSkillManageStoreStub(baseDir) + id := skillStore.seedCustomSkill("typed-access-mode-skill", filepath.Join(baseDir, "typed-access-mode-skill", "1"), "active", nil) + handler := NewSkillsHandler(skillStore, baseDir, baseDir, "", bus.New(), nil, nil) + + req := httptest.NewRequest(http.MethodPut, "/v1/skills/"+id.String(), bytes.NewBufferString(`{"visibility":true}`)) + req.SetPathValue("id", id.String()) + req = req.WithContext(store.WithTenantID(req.Context(), store.MasterTenantID)) + rec := httptest.NewRecorder() + + handler.handleUpdate(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if _, ok := skillStore.lastUpdates[id]; ok { + t.Fatalf("non-string visibility reached UpdateSkill: %+v", skillStore.lastUpdates[id]) + } +} diff --git a/internal/http/skills_upload_test.go b/internal/http/skills_upload_test.go index 0b979216..c2bb6a0a 100644 --- a/internal/http/skills_upload_test.go +++ b/internal/http/skills_upload_test.go @@ -767,6 +767,7 @@ type skillManageStoreStub struct { hashBySlug map[string]string // slug -> SKILL.md content hash (most recent) grantCalls []skillGrantCall grantErrors map[uuid.UUID]error + lastUpdates map[uuid.UUID]map[string]any } type skillGrantCall struct { @@ -785,6 +786,7 @@ func newSkillManageStoreStub(baseDir string) *skillManageStoreStub { systemDirs: map[string]string{}, hashBySlug: map[string]string{}, grantErrors: map[uuid.UUID]error{}, + lastUpdates: map[uuid.UUID]map[string]any{}, } } @@ -915,6 +917,10 @@ func (s *skillManageStoreStub) UpdateSkill(ctx context.Context, id uuid.UUID, up if status, ok := updates["status"].(string); ok { skill.Status = status } + if visibility, ok := updates["visibility"].(string); ok { + skill.Visibility = visibility + } + s.lastUpdates[id] = maps.Clone(updates) s.skills[id] = skill return nil } diff --git a/internal/http/skills_versions.go b/internal/http/skills_versions.go index f26fd2c4..b26b3c15 100644 --- a/internal/http/skills_versions.go +++ b/internal/http/skills_versions.go @@ -61,16 +61,6 @@ func walkSkillFiles(root string) []fileEntry { return files } -// skillSlugDir derives the slug parent directory from a DB file_path. -// file_path has the form .../slug/version — returns .../slug. -// Returns empty string if filePath is empty or malformed. -func skillSlugDir(filePath string) string { - if filePath == "" { - return "" - } - return filepath.Dir(filePath) -} - // handleListVersions returns all available version numbers for a skill. func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Request) { locale := store.LocaleFromContext(r.Context()) @@ -86,7 +76,7 @@ func (h *SkillsHandler) handleListVersions(w http.ResponseWriter, r *http.Reques return } - slugDir := skillSlugDir(filePath) + slugDir := store.SkillSlugDir(filePath) if slugDir == "" { writeJSON(w, http.StatusOK, map[string]any{ "versions": []int{currentVersion}, @@ -151,7 +141,7 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) version = parsed } - slugDir := skillSlugDir(filePath) + slugDir := store.SkillSlugDir(filePath) if slugDir == "" { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)}) return @@ -159,7 +149,20 @@ func (h *SkillsHandler) handleListFiles(w http.ResponseWriter, r *http.Request) versionDir := filepath.Join(slugDir, strconv.Itoa(version)) if _, err := os.Stat(versionDir); err != nil { - writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)}) + if !isSystem || h.bundledDir == "" { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)}) + return + } + bundledDir := filepath.Join(h.bundledDir, slug) + if _, err := os.Stat(bundledDir); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgVersionNotFound)}) + return + } + files := walkSkillFiles(bundledDir) + if files == nil { + files = []fileEntry{} + } + writeJSON(w, http.StatusOK, map[string]any{"files": files}) return } @@ -216,29 +219,33 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) { version = parsed } - slugDir := skillSlugDir(filePath) + slugDir := store.SkillSlugDir(filePath) if slugDir == "" { writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)}) return } versionDir := filepath.Join(slugDir, strconv.Itoa(version)) - absPath := filepath.Join(versionDir, filepath.Clean(relPath)) - - // Verify resolved path is within the version directory - if !strings.HasPrefix(absPath, versionDir+string(filepath.Separator)) { - slog.Warn("security.skill_files_escape", "resolved", absPath, "root", versionDir) - writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) + roots := readableSkillRoots(versionDir, slug, isSystem, h.bundledDir) + if len(roots) == 0 { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgFileNotFound)}) return } - // Try reading from managed dir; fall back to bundled dir for system skills. - data, info, readErr := readSkillFile(absPath) - if readErr != nil && isSystem && h.bundledDir != "" { - bundledPath := filepath.Join(h.bundledDir, slug, filepath.Clean(relPath)) - bundledRoot := filepath.Join(h.bundledDir, slug) - if strings.HasPrefix(bundledPath, bundledRoot+string(filepath.Separator)) { - data, info, readErr = readSkillFile(bundledPath) + cleanRelPath := filepath.Clean(relPath) + var data []byte + var info os.FileInfo + var readErr error = os.ErrNotExist + for _, root := range roots { + absPath := filepath.Join(root, cleanRelPath) + if !strings.HasPrefix(absPath, root+string(filepath.Separator)) { + slog.Warn("security.skill_files_escape", "resolved", absPath, "root", root) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidPath)}) + return + } + data, info, readErr = readSkillFile(absPath) + if readErr == nil { + break } } if readErr != nil { @@ -253,6 +260,20 @@ func (h *SkillsHandler) handleReadFile(w http.ResponseWriter, r *http.Request) { }) } +func readableSkillRoots(versionDir, slug string, isSystem bool, bundledDir string) []string { + var roots []string + if info, err := os.Stat(versionDir); err == nil && info.IsDir() { + roots = append(roots, versionDir) + } + if isSystem && bundledDir != "" { + bundledRoot := filepath.Join(bundledDir, slug) + if info, err := os.Stat(bundledRoot); err == nil && info.IsDir() { + roots = append(roots, bundledRoot) + } + } + return roots +} + // readSkillFile reads a file with security checks (symlink rejection, artifact filtering). // Returns file data, file info, or error. func readSkillFile(absPath string) ([]byte, os.FileInfo, error) { diff --git a/internal/http/skills_versions_test.go b/internal/http/skills_versions_test.go new file mode 100644 index 00000000..f311e32b --- /dev/null +++ b/internal/http/skills_versions_test.go @@ -0,0 +1,34 @@ +package http + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadableSkillRootsFallsBackToBundledSystemSkill(t *testing.T) { + tmp := t.TempDir() + missingManaged := filepath.Join(tmp, "managed", "demo", "1") + bundled := filepath.Join(tmp, "bundled", "demo") + if err := os.MkdirAll(bundled, 0755); err != nil { + t.Fatal(err) + } + + roots := readableSkillRoots(missingManaged, "demo", true, filepath.Join(tmp, "bundled")) + if len(roots) != 1 || roots[0] != bundled { + t.Fatalf("roots = %#v, want bundled fallback", roots) + } +} + +func TestReadableSkillRootsDoesNotFallbackForCustomSkill(t *testing.T) { + tmp := t.TempDir() + bundled := filepath.Join(tmp, "bundled", "demo") + if err := os.MkdirAll(bundled, 0755); err != nil { + t.Fatal(err) + } + + roots := readableSkillRoots(filepath.Join(tmp, "missing"), "demo", false, filepath.Join(tmp, "bundled")) + if len(roots) != 0 { + t.Fatalf("roots = %#v, want no custom fallback", roots) + } +} diff --git a/internal/skills/visibility.go b/internal/skills/visibility.go index c923ed71..9112f99d 100644 --- a/internal/skills/visibility.go +++ b/internal/skills/visibility.go @@ -7,8 +7,9 @@ import ( // Skill visibility values. const ( - VisibilityPrivate = "private" - VisibilityPublic = "public" + VisibilityPrivate = "private" + VisibilityInternal = "internal" + VisibilityPublic = "public" ) // DefaultVisibility is assigned when a caller does not specify one. @@ -16,10 +17,12 @@ const ( const DefaultVisibility = VisibilityPrivate // validVisibilities enumerates the accepted enum values. System skills use -// "public"; user-published skills default to "private". +// "public"; grant-scoped skills use "internal"; user-published skills default +// to "private". var validVisibilities = map[string]struct{}{ - VisibilityPrivate: {}, - VisibilityPublic: {}, + VisibilityPrivate: {}, + VisibilityInternal: {}, + VisibilityPublic: {}, } // NormalizeVisibility lowercases + trims the input and returns the default @@ -39,7 +42,7 @@ func ValidateVisibility(v string) error { return nil } if _, ok := validVisibilities[strings.ToLower(strings.TrimSpace(v))]; !ok { - return fmt.Errorf("invalid visibility %q: must be one of private, public", v) + return fmt.Errorf("invalid visibility %q: must be one of private, internal, public", v) } return nil } diff --git a/internal/skills/visibility_test.go b/internal/skills/visibility_test.go index 06d7ff43..d97086bc 100644 --- a/internal/skills/visibility_test.go +++ b/internal/skills/visibility_test.go @@ -10,9 +10,10 @@ func TestValidateVisibility(t *testing.T) { }{ {"empty ok (caller defaults)", "", false}, {"private", "private", false}, + {"internal", "internal", false}, {"public", "public", false}, {"uppercase normalized", "PRIVATE", false}, - {"whitespace normalized", " public ", false}, + {"whitespace normalized", " internal ", false}, {"team rejected (v1 scope)", "team", true}, {"garbage rejected", "nope", true}, } @@ -30,6 +31,7 @@ func TestNormalizeVisibility(t *testing.T) { cases := map[string]string{ "": DefaultVisibility, "private": "private", + "INTERNAL": "internal", "PUBLIC": "public", " public ": "public", } diff --git a/internal/store/pg/scan_rows_test.go b/internal/store/pg/scan_rows_test.go index 0e75acd8..cc47ee56 100644 --- a/internal/store/pg/scan_rows_test.go +++ b/internal/store/pg/scan_rows_test.go @@ -619,6 +619,17 @@ func TestBuildSkillInfo_FilePathOverridesBaseDir(t *testing.T) { } } +func TestBuildSkillInfo_FilePathMayPointToSkillMarkdown(t *testing.T) { + fp := "/custom/path/to/skill/SKILL.md" + info := buildSkillInfo("id", "n", "s", nil, 1, "/ignored", &fp) + if info.BaseDir != "/custom/path/to/skill" { + t.Errorf("BaseDir should normalize file path: %q", info.BaseDir) + } + if info.Path != "/custom/path/to/skill/SKILL.md" { + t.Errorf("Path = %q", info.Path) + } +} + func TestBuildSkillInfo_EmptyFilePathFallsBack(t *testing.T) { empty := "" info := buildSkillInfo("id", "n", "slug", nil, 2, "/base", &empty) diff --git a/internal/store/pg/skills_content.go b/internal/store/pg/skills_content.go index 1e51775c..eac99043 100644 --- a/internal/store/pg/skills_content.go +++ b/internal/store/pg/skills_content.go @@ -255,13 +255,13 @@ func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir st } skillDir := fmt.Sprintf("%s/%s/%d", baseDir, slug, version) if filePath != nil && *filePath != "" { - skillDir = *filePath + skillDir = store.SkillBaseDir(*filePath) } return store.SkillInfo{ ID: id, Name: name, Slug: slug, - Path: skillDir + "/SKILL.md", + Path: store.SkillMarkdownPath(skillDir), BaseDir: skillDir, Source: "managed", Description: d, diff --git a/internal/store/pg/skills_embedding.go b/internal/store/pg/skills_embedding.go index a65caacb..b1015ec1 100644 --- a/internal/store/pg/skills_embedding.go +++ b/internal/store/pg/skills_embedding.go @@ -55,16 +55,15 @@ func (s *PGSkillStore) SearchByEmbedding(ctx context.Context, embedding []float3 } // Use DB file_path when available; fall back to baseDir construction. if row.FilePath != nil && *row.FilePath != "" { - r.Path = *row.FilePath + "/SKILL.md" + r.Path = store.SkillMarkdownPath(*row.FilePath) } else { - r.Path = fmt.Sprintf("%s/%s/%d/SKILL.md", s.baseDir, row.Slug, row.Version) + r.Path = store.SkillMarkdownPath(fmt.Sprintf("%s/%s/%d", s.baseDir, row.Slug, row.Version)) } results = append(results, r) } return results, nil } - func buildSkillEmbeddingTenantCond(scope string) string { if scope == "" { return "" diff --git a/internal/store/pg/skills_grants.go b/internal/store/pg/skills_grants.go index 9f53b3a3..04a710a4 100644 --- a/internal/store/pg/skills_grants.go +++ b/internal/store/pg/skills_grants.go @@ -91,7 +91,8 @@ func (s *PGSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID uui _, err = s.db.ExecContext(ctx, `UPDATE skills SET visibility = 'private', updated_at = NOW() WHERE id = $1 AND visibility = 'internal' AND (is_system = true OR tenant_id = $2) - AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = $1)`, + AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = $1) + AND NOT EXISTS (SELECT 1 FROM skill_user_grants WHERE skill_id = $1)`, skillID, tid) if err != nil { slog.Warn("skill_grants: failed to auto-demote visibility", "skill_id", skillID, "error", err) diff --git a/internal/store/skill_paths.go b/internal/store/skill_paths.go new file mode 100644 index 00000000..fcb51d98 --- /dev/null +++ b/internal/store/skill_paths.go @@ -0,0 +1,42 @@ +package store + +import ( + "path/filepath" + "strings" +) + +const SkillMarkdownFilename = "SKILL.md" + +// SkillBaseDir normalizes a persisted skill file_path into the skill directory. +// Older import paths may point directly to SKILL.md; managed uploads usually +// point to the version directory. +func SkillBaseDir(filePath string) string { + if filePath == "" { + return "" + } + cleaned := filepath.Clean(filePath) + if cleaned == "." { + return "" + } + if strings.EqualFold(filepath.Base(cleaned), SkillMarkdownFilename) { + return filepath.Dir(cleaned) + } + return cleaned +} + +func SkillMarkdownPath(filePath string) string { + baseDir := SkillBaseDir(filePath) + if baseDir == "" { + return "" + } + return filepath.Join(baseDir, SkillMarkdownFilename) +} + +// SkillSlugDir returns the directory that contains version subdirectories. +func SkillSlugDir(filePath string) string { + baseDir := SkillBaseDir(filePath) + if baseDir == "" { + return "" + } + return filepath.Dir(baseDir) +} diff --git a/internal/store/skill_paths_test.go b/internal/store/skill_paths_test.go new file mode 100644 index 00000000..7b354114 --- /dev/null +++ b/internal/store/skill_paths_test.go @@ -0,0 +1,31 @@ +package store + +import "testing" + +func TestSkillBaseDirAcceptsDirectoryPath(t *testing.T) { + got := SkillBaseDir("/var/lib/goclaw/data/skills-store/demo/3") + if got != "/var/lib/goclaw/data/skills-store/demo/3" { + t.Fatalf("SkillBaseDir() = %q", got) + } +} + +func TestSkillBaseDirAcceptsSkillMarkdownPath(t *testing.T) { + got := SkillBaseDir("/var/lib/goclaw/data/skills-store/demo/3/SKILL.md") + if got != "/var/lib/goclaw/data/skills-store/demo/3" { + t.Fatalf("SkillBaseDir() = %q", got) + } +} + +func TestSkillMarkdownPath(t *testing.T) { + got := SkillMarkdownPath("/var/lib/goclaw/data/skills-store/demo/3/SKILL.md") + if got != "/var/lib/goclaw/data/skills-store/demo/3/SKILL.md" { + t.Fatalf("SkillMarkdownPath() = %q", got) + } +} + +func TestSkillSlugDir(t *testing.T) { + got := SkillSlugDir("/var/lib/goclaw/data/skills-store/demo/3/SKILL.md") + if got != "/var/lib/goclaw/data/skills-store/demo" { + t.Fatalf("SkillSlugDir() = %q", got) + } +} diff --git a/internal/store/sqlitestore/skills.go b/internal/store/sqlitestore/skills.go index d7181c14..ff6d8182 100644 --- a/internal/store/sqlitestore/skills.go +++ b/internal/store/sqlitestore/skills.go @@ -218,13 +218,13 @@ func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir st } skillDir := fmt.Sprintf("%s/%s/%d", baseDir, slug, version) if filePath != nil && *filePath != "" { - skillDir = *filePath + skillDir = store.SkillBaseDir(*filePath) } return store.SkillInfo{ ID: id, Name: name, Slug: slug, - Path: skillDir + "/SKILL.md", + Path: store.SkillMarkdownPath(skillDir), BaseDir: skillDir, Source: "managed", Description: d, diff --git a/internal/store/sqlitestore/skills_grants.go b/internal/store/sqlitestore/skills_grants.go index e0e2ab72..823b5ff7 100644 --- a/internal/store/sqlitestore/skills_grants.go +++ b/internal/store/sqlitestore/skills_grants.go @@ -94,8 +94,9 @@ func (s *SQLiteSkillStore) RevokeFromAgent(ctx context.Context, skillID, agentID _, err = s.db.ExecContext(ctx, `UPDATE skills SET visibility = 'private', updated_at = ? WHERE id = ? AND visibility = 'internal' AND (is_system = 1 OR tenant_id = ?) - AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = ?)`, - time.Now().UTC(), skillID, tid, skillID) + AND NOT EXISTS (SELECT 1 FROM skill_agent_grants WHERE skill_id = ?) + AND NOT EXISTS (SELECT 1 FROM skill_user_grants WHERE skill_id = ?)`, + time.Now().UTC(), skillID, tid, skillID, skillID) if err != nil { slog.Warn("skill_grants: failed to auto-demote visibility", "skill_id", skillID, "error", err) } diff --git a/internal/store/sqlitestore/skills_test.go b/internal/store/sqlitestore/skills_test.go index f7b3b69b..76fcf03c 100644 --- a/internal/store/sqlitestore/skills_test.go +++ b/internal/store/sqlitestore/skills_test.go @@ -216,6 +216,40 @@ func TestSQLiteSkillStore_RevokeFromAgentDoesNotDemoteCrossTenantSkill(t *testin } } +func TestSQLiteSkillStore_RevokeFromAgentKeepsInternalWhenUserGrantRemains(t *testing.T) { + _, skillStore, db := newTestSQLiteSkillStoreWithDB(t) + tenantID, agentID := seedSQLiteTenantAgent(t, db) + ctx := store.WithTenantID(context.Background(), tenantID) + + skillID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{ + Name: "Shared Internal Skill", + Slug: "shared-internal-skill-" + tenantID.String()[:8], + OwnerID: "owner-user", + Visibility: "internal", + FilePath: filepath.Join(t.TempDir(), "shared-internal-skill", "1"), + }) + if err != nil { + t.Fatalf("CreateSkillManaged error: %v", err) + } + if err := skillStore.GrantToAgent(ctx, skillID, agentID, 1, "owner-user", true); err != nil { + t.Fatalf("GrantToAgent error: %v", err) + } + if err := skillStore.GrantToUser(ctx, skillID, "granted-user", "owner-user"); err != nil { + t.Fatalf("GrantToUser error: %v", err) + } + if err := skillStore.RevokeFromAgent(ctx, skillID, agentID); err != nil { + t.Fatalf("RevokeFromAgent error: %v", err) + } + + got, ok := skillStore.GetSkillByID(ctx, skillID) + if !ok { + t.Fatal("GetSkillByID returned !ok") + } + if got.Visibility != "internal" { + t.Fatalf("visibility after revoking last agent grant = %q, want internal because a user grant remains", got.Visibility) + } +} + func TestSQLiteSkillStore_ListWithGrantStatusIgnoresForeignTenantGrant(t *testing.T) { _, skillStore, db := newTestSQLiteSkillStoreWithDB(t) tenantA, _ := seedSQLiteTenantAgent(t, db) @@ -253,6 +287,97 @@ func TestSQLiteSkillStore_ListWithGrantStatusIgnoresForeignTenantGrant(t *testin t.Fatalf("system skill %s not returned for tenant A", skillID) } +func TestSQLiteSkillStore_ListAccessibleHonorsAccessModes(t *testing.T) { + _, skillStore, db := newTestSQLiteSkillStoreWithDB(t) + tenantID, agentA := seedSQLiteTenantAgent(t, db) + agentB := uuid.New() + if _, err := db.Exec( + `INSERT INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id) + VALUES (?, ?, ?, 'predefined', 'active', 'test', 'test-model', 'user-2')`, + agentB.String(), tenantID.String(), "agent-"+agentB.String()[:8], + ); err != nil { + t.Fatalf("insert second agent: %v", err) + } + ctx := store.WithTenantID(context.Background(), tenantID) + + privateID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{ + Name: "Owner Only Skill", + Slug: "owner-only-skill-" + tenantID.String()[:8], + OwnerID: "owner-user", + Visibility: "private", + FilePath: filepath.Join(t.TempDir(), "owner-only", "1"), + }) + if err != nil { + t.Fatalf("CreateSkillManaged private error: %v", err) + } + internalID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{ + Name: "Granted Agents Skill", + Slug: "granted-agents-skill-" + tenantID.String()[:8], + OwnerID: "owner-user", + Visibility: "internal", + FilePath: filepath.Join(t.TempDir(), "granted-agents", "1"), + }) + if err != nil { + t.Fatalf("CreateSkillManaged internal error: %v", err) + } + publicID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{ + Name: "Tenant Wide Skill", + Slug: "tenant-wide-skill-" + tenantID.String()[:8], + OwnerID: "owner-user", + Visibility: "public", + FilePath: filepath.Join(t.TempDir(), "tenant-wide", "1"), + }) + if err != nil { + t.Fatalf("CreateSkillManaged public error: %v", err) + } + if err := skillStore.GrantToAgent(ctx, internalID, agentA, 1, "owner-user", true); err != nil { + t.Fatalf("GrantToAgent internal error: %v", err) + } + + viewerA := listAccessibleSlugs(t, skillStore, ctx, agentA, "viewer-user") + if !viewerA["granted-agents-skill-"+tenantID.String()[:8]] { + t.Fatalf("granted agent did not receive internal skill; got %v", viewerA) + } + if !viewerA["tenant-wide-skill-"+tenantID.String()[:8]] { + t.Fatalf("agent did not receive public skill; got %v", viewerA) + } + if viewerA["owner-only-skill-"+tenantID.String()[:8]] { + t.Fatalf("non-owner received private skill; got %v", viewerA) + } + + viewerB := listAccessibleSlugs(t, skillStore, ctx, agentB, "viewer-user") + if viewerB["granted-agents-skill-"+tenantID.String()[:8]] { + t.Fatalf("ungranted agent received internal skill; got %v", viewerB) + } + if !viewerB["tenant-wide-skill-"+tenantID.String()[:8]] { + t.Fatalf("ungranted agent did not receive public skill; got %v", viewerB) + } + + owner := listAccessibleSlugs(t, skillStore, ctx, agentB, "owner-user") + if !owner["owner-only-skill-"+tenantID.String()[:8]] { + t.Fatalf("owner did not receive private skill %s; got %v", privateID, owner) + } + if !owner["tenant-wide-skill-"+tenantID.String()[:8]] { + t.Fatalf("owner did not receive public skill %s; got %v", publicID, owner) + } + if owner["granted-agents-skill-"+tenantID.String()[:8]] { + t.Fatalf("owner received internal skill without grant; got %v", owner) + } +} + +func listAccessibleSlugs(t *testing.T, skillStore *SQLiteSkillStore, ctx context.Context, agentID uuid.UUID, userID string) map[string]bool { + t.Helper() + skills, err := skillStore.ListAccessible(ctx, agentID, userID) + if err != nil { + t.Fatalf("ListAccessible error: %v", err) + } + slugs := make(map[string]bool, len(skills)) + for _, skill := range skills { + slugs[skill.Slug] = true + } + return slugs +} + func newTestSQLiteSkillStore(t *testing.T) (context.Context, *SQLiteSkillStore) { ctx, skillStore, _ := newTestSQLiteSkillStoreWithDB(t) return ctx, skillStore diff --git a/internal/store/visibility_filter.go b/internal/store/visibility_filter.go index b08ec11e..c5736a34 100644 --- a/internal/store/visibility_filter.go +++ b/internal/store/visibility_filter.go @@ -13,6 +13,8 @@ import ( // - "private" skills are only visible to the owner. Three identity strings // are considered (actor, user, sender) to match the same identities // isOwnerOfSkill checks for backward compatibility (#915). +// - "internal" skills require explicit grants, which are evaluated in +// ListAccessible; this baseline helper hides them. // // Admin/master-scope bypass is the caller's responsibility — this helper // reflects the non-privileged baseline. @@ -34,6 +36,8 @@ func IsSkillVisibleTo(ctx context.Context, ownerID, visibility string, isSystem userID := UserIDFromContext(ctx) senderID := SenderIDFromContext(ctx) return ownerID == actorID || ownerID == userID || ownerID == senderID + case "internal": + return false default: // Unknown enum value: fail closed (hide). return false diff --git a/internal/store/visibility_filter_test.go b/internal/store/visibility_filter_test.go index ddd6419f..63720cec 100644 --- a/internal/store/visibility_filter_test.go +++ b/internal/store/visibility_filter_test.go @@ -23,6 +23,7 @@ func TestIsSkillVisibleTo(t *testing.T) { {"private visible to owner", alice, "private", false, true}, {"private hidden from non-owner", bob, "private", false, false}, {"private with no owner treated as public", "", "private", false, true}, + {"internal requires explicit grant outside this helper", bob, "internal", false, false}, {"unknown enum fails closed", bob, "team", false, false}, {"uppercase private matched for owner", alice, "PRIVATE", false, true}, {"whitespace public treated as public", bob, " public ", false, true}, diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index 0ef43c30..4a61f330 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -57,7 +57,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { return ( diff --git a/ui/web/src/i18n/locales/en/skills.json b/ui/web/src/i18n/locales/en/skills.json index 8a5a0b08..69801a19 100644 --- a/ui/web/src/i18n/locales/en/skills.json +++ b/ui/web/src/i18n/locales/en/skills.json @@ -13,11 +13,40 @@ "author": "Author", "agents": "Agents", "owner": "Owner", - "visibility": "Visibility", + "visibility": "Access mode", + "accessMode": "Access mode", "status": "Status", "actions": "Actions" }, "noDescription": "No description", + "status": { + "unmanaged": "Unmanaged", + "missingDepsCount": "{{count}} missing" + }, + "filters": { + "label": "Filter", + "all": "All skills", + "attention": "Needs attention", + "missing-deps": "Missing deps", + "disabled": "Disabled", + "archived": "Archived", + "unmanaged": "Unmanaged", + "clear": "Clear filters" + }, + "sort": { + "label": "Sort", + "name": "Name", + "deps": "Missing deps", + "version": "Version" + }, + "health": { + "total": "Total", + "attention": "Needs attention", + "missingDeps": "Missing deps", + "disabled": "Disabled", + "archived": "Archived", + "unmanaged": "Unmanaged" + }, "owner": "Owner", "unknownOwner": "Unknown", "grants": { @@ -54,10 +83,21 @@ "deleted": "Deleted {{count}} skill(s)", "failed": "Bulk action failed", "deleteTitle": "Delete selected skills", - "deleteDescription": "This will delete {{count}} selected custom skill(s). Type DELETE to confirm." + "deleteDescription": "This will delete {{count}} selected custom skill(s). Type DELETE to confirm.", + "customAvailable": "{{count}} custom action(s) available", + "skippedSystem": "{{count}} system skill(s) skipped", + "customOnlyReason": "This action only applies to custom skills", + "noAgentsReason": "No current agents available" }, "visibility": { - "clickToCycle": "Click to change visibility" + "clickToCycle": "Click to change access mode" + }, + "accessMode": { + "private": "Owner only", + "internal": "Granted agents", + "public": "All tenant agents", + "unknown": "Unknown ({{value}})", + "clickToCycle": "Click to change access mode" }, "delete": { "title": "Delete Skill", @@ -96,14 +136,21 @@ "agentManagers": "Grant manager access to agents", "managerAgentsHelp": "Selected current agents can update this skill later.", "selectAllAgents": "Select all agents", + "selectAllCurrentAgents": "Select all current agents", "clearAgents": "Clear agents", - "noAgents": "No agents available" + "noAgents": "No agents available", + "selectedAgentsCount": "{{selected}} of {{total}} current agents selected", + "summaryUploaded": "{{count}} uploaded", + "summaryWarnings": "{{count}} warning(s)", + "summaryUnchanged": "{{count}} unchanged", + "summaryFailed": "{{count}} failed" }, "edit": { "title": "Edit Skill", "name": "Name", "description": "Description", - "visibility": "Visibility", + "visibility": "Access mode", + "accessMode": "Access mode", "tags": "Tags", "addTag": "Add tag...", "add": "Add", @@ -149,7 +196,7 @@ "pythonLabel": "Python", "nodeLabel": "Node.js", "statusActive": "Active", - "statusArchived": "Missing deps", + "statusArchived": "Archived", "runtimeMissing": "Runtime Prerequisites Missing", "runtimeMissingDesc": "Core skills require Python and/or Node.js runtimes inside the GoClaw runtime container. Host-installed runtimes are not used here. If you are using the published latest image, missing runtimes can be expected there.", "runtimeMissingAction": "Open Packages to verify or install runtimes in this container.", @@ -165,7 +212,12 @@ "files": "Files", "version": "Version:", "current": "(current)", - "noContent": "No content available." + "noContent": "No content available.", + "fileSearch": "Search files...", + "filesShown": "{{shown}} of {{total}} files", + "copyLink": "Copy link", + "copySuccess": "Deeplink copied", + "copyFailed": "Failed to copy deeplink" }, "toast": { "updated": "Skill updated", diff --git a/ui/web/src/i18n/locales/vi/skills.json b/ui/web/src/i18n/locales/vi/skills.json index 09581723..2ea7605b 100644 --- a/ui/web/src/i18n/locales/vi/skills.json +++ b/ui/web/src/i18n/locales/vi/skills.json @@ -12,14 +12,50 @@ "source": "Nguồn", "author": "Tác giả", "agents": "Agents", - "visibility": "Hiển thị", + "visibility": "Chế độ truy cập", + "accessMode": "Chế độ truy cập", "status": "Trạng thái", "actions": "Thao tác", "owner": "Chủ sở hữu" }, "noDescription": "Không có mô tả", + "status": { + "unmanaged": "Chưa có agent quản lý", + "missingDepsCount": "Thiếu {{count}}" + }, + "filters": { + "label": "Bộ lọc", + "all": "Tất cả skill", + "attention": "Cần xử lý", + "missing-deps": "Thiếu deps", + "disabled": "Đã tắt", + "archived": "Đã lưu trữ", + "unmanaged": "Chưa có quản lý", + "clear": "Xóa bộ lọc" + }, + "sort": { + "label": "Sắp xếp", + "name": "Tên", + "deps": "Thiếu deps", + "version": "Phiên bản" + }, + "health": { + "total": "Tổng", + "attention": "Cần xử lý", + "missingDeps": "Thiếu deps", + "disabled": "Đã tắt", + "archived": "Đã lưu trữ", + "unmanaged": "Chưa có quản lý" + }, "visibility": { - "clickToCycle": "Nhấp để thay đổi hiển thị" + "clickToCycle": "Nhấp để đổi chế độ truy cập" + }, + "accessMode": { + "private": "Chỉ chủ sở hữu", + "internal": "Agent được cấp quyền", + "public": "Tất cả agent trong tenant", + "unknown": "Không rõ ({{value}})", + "clickToCycle": "Nhấp để đổi chế độ truy cập" }, "delete": { "title": "Xóa skill", @@ -58,14 +94,21 @@ "agentManagers": "Cấp quyền quản lý cho agents", "managerAgentsHelp": "Các agent hiện tại được chọn có thể cập nhật skill này sau.", "selectAllAgents": "Chọn tất cả agents", + "selectAllCurrentAgents": "Chọn tất cả agent hiện tại", "clearAgents": "Bỏ chọn agents", - "noAgents": "Chưa có agent" + "noAgents": "Chưa có agent", + "selectedAgentsCount": "Đã chọn {{selected}} / {{total}} agent hiện tại", + "summaryUploaded": "{{count}} đã tải lên", + "summaryWarnings": "{{count}} cảnh báo", + "summaryUnchanged": "{{count}} không đổi", + "summaryFailed": "{{count}} thất bại" }, "edit": { "title": "Chỉnh sửa skill", "name": "Tên", "description": "Mô tả", - "visibility": "Hiển thị", + "visibility": "Chế độ truy cập", + "accessMode": "Chế độ truy cập", "tags": "Thẻ", "addTag": "Thêm thẻ...", "add": "Thêm", @@ -111,7 +154,7 @@ "pythonLabel": "Python", "nodeLabel": "Node.js", "statusActive": "Active", - "statusArchived": "Missing deps", + "statusArchived": "Đã lưu trữ", "runtimeMissing": "Runtime Prerequisites Missing", "runtimeMissingDesc": "Các core skill cần runtime Python và/hoặc Node.js bên trong container runtime GoClaw. Runtime cài trên máy host sẽ không được dùng ở đây. Nếu bạn đang dùng image latest đã phát hành thì việc thiếu runtime có thể là bình thường.", "runtimeMissingAction": "Mở trang Packages để kiểm tra hoặc cài runtime trong container này.", @@ -127,7 +170,12 @@ "files": "Tệp", "version": "Phiên bản:", "current": "(hiện tại)", - "noContent": "Không có nội dung." + "noContent": "Không có nội dung.", + "fileSearch": "Tìm trong tệp...", + "filesShown": "{{shown}} / {{total}} tệp", + "copyLink": "Copy link", + "copySuccess": "Đã copy deeplink", + "copyFailed": "Không thể copy deeplink" }, "toast": { "updated": "Đã cập nhật skill", @@ -174,6 +222,10 @@ "deleted": "Đã xóa {{count}} skill", "failed": "Bulk action thất bại", "deleteTitle": "Xóa các skill đã chọn", - "deleteDescription": "Thao tác này sẽ xóa {{count}} custom skill đã chọn. Gõ DELETE để xác nhận." + "deleteDescription": "Thao tác này sẽ xóa {{count}} custom skill đã chọn. Gõ DELETE để xác nhận.", + "customAvailable": "{{count}} thao tác custom khả dụng", + "skippedSystem": "Bỏ qua {{count}} skill hệ thống", + "customOnlyReason": "Thao tác này chỉ áp dụng cho custom skill", + "noAgentsReason": "Chưa có agent hiện tại" } } diff --git a/ui/web/src/i18n/locales/zh/skills.json b/ui/web/src/i18n/locales/zh/skills.json index 57f0bfb5..a423d2ab 100644 --- a/ui/web/src/i18n/locales/zh/skills.json +++ b/ui/web/src/i18n/locales/zh/skills.json @@ -12,14 +12,50 @@ "source": "来源", "author": "作者", "agents": "Agent", - "visibility": "可见性", + "visibility": "访问模式", + "accessMode": "访问模式", "status": "状态", "actions": "操作", "owner": "所有者" }, "noDescription": "暂无描述", + "status": { + "unmanaged": "未管理", + "missingDepsCount": "缺少 {{count}} 项" + }, + "filters": { + "label": "筛选", + "all": "全部 Skill", + "attention": "需要处理", + "missing-deps": "缺少依赖", + "disabled": "已禁用", + "archived": "已归档", + "unmanaged": "未管理", + "clear": "清除筛选" + }, + "sort": { + "label": "排序", + "name": "名称", + "deps": "缺少依赖", + "version": "版本" + }, + "health": { + "total": "总数", + "attention": "需要处理", + "missingDeps": "缺少依赖", + "disabled": "已禁用", + "archived": "已归档", + "unmanaged": "未管理" + }, "visibility": { - "clickToCycle": "点击更改可见性" + "clickToCycle": "点击更改访问模式" + }, + "accessMode": { + "private": "仅所有者", + "internal": "已授权 Agent", + "public": "租户内所有 Agent", + "unknown": "未知({{value}})", + "clickToCycle": "点击更改访问模式" }, "delete": { "title": "删除Skill", @@ -58,14 +94,21 @@ "agentManagers": "授予 Agent 管理权限", "managerAgentsHelp": "所选当前 Agent 以后可以更新此 Skill。", "selectAllAgents": "选择所有 Agent", + "selectAllCurrentAgents": "选择所有当前 Agent", "clearAgents": "清除 Agent", - "noAgents": "暂无 Agent" + "noAgents": "暂无 Agent", + "selectedAgentsCount": "已选择 {{selected}} / {{total}} 个当前 Agent", + "summaryUploaded": "已上传 {{count}} 个", + "summaryWarnings": "{{count}} 个警告", + "summaryUnchanged": "{{count}} 个未更改", + "summaryFailed": "{{count}} 个失败" }, "edit": { "title": "编辑Skill", "name": "名称", "description": "描述", - "visibility": "可见性", + "visibility": "访问模式", + "accessMode": "访问模式", "tags": "标签", "addTag": "添加标签...", "add": "添加", @@ -111,7 +154,7 @@ "pythonLabel": "Python", "nodeLabel": "Node.js", "statusActive": "Active", - "statusArchived": "Missing deps", + "statusArchived": "已归档", "runtimeMissing": "Runtime Prerequisites Missing", "runtimeMissingDesc": "核心技能需要在 GoClaw 运行时容器内提供 Python 和/或 Node.js 运行时。宿主机上的运行时不会在这里被使用。如果你使用的是已发布的 latest 镜像,那么缺少这些运行时可能是正常现象。", "runtimeMissingAction": "打开 Packages 页面,在此容器中检查或安装运行时。", @@ -127,7 +170,12 @@ "files": "文件", "version": "版本:", "current": "(当前)", - "noContent": "无可用内容。" + "noContent": "无可用内容。", + "fileSearch": "搜索文件...", + "filesShown": "{{shown}} / {{total}} 个文件", + "copyLink": "复制链接", + "copySuccess": "已复制 Deeplink", + "copyFailed": "复制 Deeplink 失败" }, "toast": { "updated": "技能已更新", @@ -174,6 +222,10 @@ "deleted": "已删除 {{count}} 个 Skill", "failed": "批量操作失败", "deleteTitle": "删除所选 Skill", - "deleteDescription": "这将删除 {{count}} 个所选自定义 Skill。请输入 DELETE 确认。" + "deleteDescription": "这将删除 {{count}} 个所选自定义 Skill。请输入 DELETE 确认。", + "customAvailable": "{{count}} 个自定义操作可用", + "skippedSystem": "已跳过 {{count}} 个系统 Skill", + "customOnlyReason": "此操作仅适用于自定义 Skill", + "noAgentsReason": "暂无当前 Agent" } } diff --git a/ui/web/src/index.css b/ui/web/src/index.css index 99868b87..95302192 100644 --- a/ui/web/src/index.css +++ b/ui/web/src/index.css @@ -137,6 +137,13 @@ scrollbar-width: thin; scrollbar-color: var(--border) transparent; } + html, + body, + #root { + height: 100%; + overflow: hidden; + overscroll-behavior: none; + } body { @apply bg-background text-foreground; font-family: 'Inter', system-ui, -apple-system, sans-serif; diff --git a/ui/web/src/lib/clean-version.test.ts b/ui/web/src/lib/clean-version.test.ts new file mode 100644 index 00000000..4e11b1f4 --- /dev/null +++ b/ui/web/src/lib/clean-version.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { cleanVersion } from "./clean-version"; + +describe("cleanVersion", () => { + it("keeps beta version identity and drops build slug", () => { + expect(cleanVersion("v3.12.0-beta.13-skills-ux-99f30ecc")).toBe("v3.12.0.beta.13"); + }); + + it("keeps rc version identity", () => { + expect(cleanVersion("v3.12.0-rc.2-linux-amd64")).toBe("v3.12.0.rc.2"); + }); + + it("strips git distance metadata from stable versions", () => { + expect(cleanVersion("v2.5.1-3-g4fd653c1")).toBe("v2.5.1"); + }); + + it("leaves non-semver versions unchanged", () => { + expect(cleanVersion("dev")).toBe("dev"); + }); +}); diff --git a/ui/web/src/lib/clean-version.ts b/ui/web/src/lib/clean-version.ts index 64c0bd3e..48cf1335 100644 --- a/ui/web/src/lib/clean-version.ts +++ b/ui/web/src/lib/clean-version.ts @@ -1,6 +1,13 @@ -// Strip git build metadata from version string. -// "v2.5.1-3-g4fd653c1" → "v2.5.1", "dev" → "dev" +// Keep release/beta identity visible while hiding build slugs and git metadata. +// "v3.12.0-beta.13-skills-ux-99f30ecc" -> "v3.12.0.beta.13" +// "v2.5.1-3-g4fd653c1" -> "v2.5.1", "dev" -> "dev" export function cleanVersion(v: string): string { - const match = v.match(/^(v?\d+\.\d+\.\d+)/); - return match?.[1] ?? v; + const match = v.match(/^(v?\d+\.\d+\.\d+)(?:-((?:beta|alpha|rc)\.?\d*))?/i); + if (!match) return v; + + const base = match[1]!; + const prerelease = match[2]; + if (!prerelease) return base; + const normalizedPrerelease = prerelease.replace(/^(beta|alpha|rc)(\d+)$/i, "$1.$2"); + return `${base}.${normalizedPrerelease}`; } diff --git a/ui/web/src/pages/skills/lib/skill-access-mode.test.ts b/ui/web/src/pages/skills/lib/skill-access-mode.test.ts new file mode 100644 index 00000000..994df412 --- /dev/null +++ b/ui/web/src/pages/skills/lib/skill-access-mode.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { + getNextSkillAccessMode, + getSkillAccessModeBadgeVariant, + getSkillAccessModeKey, +} from "./skill-access-mode"; + +describe("skill access mode helpers", () => { + it("maps raw visibility values to stable access-mode keys", () => { + expect(getSkillAccessModeKey("private")).toBe("private"); + expect(getSkillAccessModeKey("internal")).toBe("internal"); + expect(getSkillAccessModeKey("public")).toBe("public"); + expect(getSkillAccessModeKey("team")).toBe("unknown"); + }); + + it("cycles known modes and normalizes unknown values to private", () => { + expect(getNextSkillAccessMode("private")).toBe("internal"); + expect(getNextSkillAccessMode("internal")).toBe("public"); + expect(getNextSkillAccessMode("public")).toBe("private"); + expect(getNextSkillAccessMode("team")).toBe("private"); + }); + + it("uses conservative badge variants for unknown values", () => { + expect(getSkillAccessModeBadgeVariant("public")).toBe("default"); + expect(getSkillAccessModeBadgeVariant("internal")).toBe("secondary"); + expect(getSkillAccessModeBadgeVariant("private")).toBe("outline"); + expect(getSkillAccessModeBadgeVariant("team")).toBe("outline"); + }); +}); diff --git a/ui/web/src/pages/skills/lib/skill-access-mode.ts b/ui/web/src/pages/skills/lib/skill-access-mode.ts new file mode 100644 index 00000000..e53aefae --- /dev/null +++ b/ui/web/src/pages/skills/lib/skill-access-mode.ts @@ -0,0 +1,31 @@ +export type SkillAccessMode = "private" | "internal" | "public"; + +export const SKILL_ACCESS_MODE_ORDER: SkillAccessMode[] = ["private", "internal", "public"]; + +export type SkillAccessModeKey = SkillAccessMode | "unknown"; + +export function getSkillAccessModeKey(visibility?: string): SkillAccessModeKey { + return isSkillAccessMode(visibility) ? visibility : "unknown"; +} + +export function getNextSkillAccessMode(visibility?: string): SkillAccessMode { + if (!isSkillAccessMode(visibility)) return "private"; + const idx = SKILL_ACCESS_MODE_ORDER.indexOf(visibility); + return SKILL_ACCESS_MODE_ORDER[(idx + 1) % SKILL_ACCESS_MODE_ORDER.length] ?? "private"; +} + +export function getSkillAccessModeBadgeVariant(visibility?: string): "default" | "secondary" | "outline" { + switch (getSkillAccessModeKey(visibility)) { + case "public": + return "default"; + case "internal": + return "secondary"; + case "private": + case "unknown": + return "outline"; + } +} + +function isSkillAccessMode(value?: string): value is SkillAccessMode { + return value === "private" || value === "internal" || value === "public"; +} diff --git a/ui/web/src/pages/skills/lib/skill-upload-summary.test.ts b/ui/web/src/pages/skills/lib/skill-upload-summary.test.ts new file mode 100644 index 00000000..75f91bdc --- /dev/null +++ b/ui/web/src/pages/skills/lib/skill-upload-summary.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { summarizeSkillStatuses } from "./skill-upload-summary"; + +describe("skill upload summary", () => { + it("separates upload result states", () => { + expect(summarizeSkillStatuses([ + { status: "valid" }, + { status: "success" }, + { status: "warning" }, + { status: "unchanged" }, + { status: "invalid" }, + { status: "error" }, + { status: "uploading" }, + ])).toEqual({ + total: 7, + valid: 1, + uploaded: 1, + warnings: 1, + unchanged: 1, + failed: 1, + invalid: 1, + inProgress: 1, + }); + }); +}); diff --git a/ui/web/src/pages/skills/lib/skill-upload-summary.ts b/ui/web/src/pages/skills/lib/skill-upload-summary.ts new file mode 100644 index 00000000..dc51c585 --- /dev/null +++ b/ui/web/src/pages/skills/lib/skill-upload-summary.ts @@ -0,0 +1,62 @@ +import type { FileEntry, SkillEntry, SkillStatus } from "./skill-upload-types"; + +export interface SkillUploadSummary { + total: number; + valid: number; + uploaded: number; + warnings: number; + unchanged: number; + failed: number; + invalid: number; + inProgress: number; +} + +const EMPTY_SUMMARY: SkillUploadSummary = { + total: 0, + valid: 0, + uploaded: 0, + warnings: 0, + unchanged: 0, + failed: 0, + invalid: 0, + inProgress: 0, +}; + +export function summarizeUploadEntries(entries: FileEntry[]): SkillUploadSummary { + return summarizeSkillStatuses(entries.flatMap((entry) => entry.skills)); +} + +export function summarizeSkillStatuses(skills: Pick[]): SkillUploadSummary { + return skills.reduce((summary, skill) => { + summary.total += 1; + applyStatus(summary, skill.status); + return summary; + }, { ...EMPTY_SUMMARY }); +} + +function applyStatus(summary: SkillUploadSummary, status: SkillStatus) { + switch (status) { + case "valid": + summary.valid += 1; + break; + case "success": + summary.uploaded += 1; + break; + case "warning": + summary.warnings += 1; + break; + case "unchanged": + summary.unchanged += 1; + break; + case "invalid": + summary.invalid += 1; + break; + case "error": + summary.failed += 1; + break; + case "validating": + case "uploading": + summary.inProgress += 1; + break; + } +} diff --git a/ui/web/src/pages/skills/lib/skills-filtering.test.ts b/ui/web/src/pages/skills/lib/skills-filtering.test.ts new file mode 100644 index 00000000..bac08fd4 --- /dev/null +++ b/ui/web/src/pages/skills/lib/skills-filtering.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import type { SkillInfo } from "../hooks/use-skills"; +import { deriveSkillStats, displayDependencyName, filterSkills, sortSkills } from "./skills-filtering"; + +function skill(overrides: Partial): SkillInfo { + return { + id: overrides.name ?? "id", + name: overrides.name ?? "Skill", + description: overrides.description ?? "", + source: "custom", + ...overrides, + }; +} + +describe("skills filtering", () => { + const skills = [ + skill({ name: "Core", is_system: true, manager_agents: [] }), + skill({ name: "Missing", missing_deps: ["pip:google", "npm:zod"], manager_agents: [{ id: "a1" }] }), + skill({ name: "Disabled", enabled: false, manager_agents: [{ id: "a2" }] }), + skill({ name: "Archived", status: "archived", manager_agents: [{ id: "a3" }] }), + skill({ name: "Unmanaged", manager_agents: [] }), + ]; + + it("derives attention stats without counting system skills as unmanaged", () => { + expect(deriveSkillStats(skills)).toEqual({ + total: 5, + missingDeps: 1, + disabled: 1, + archived: 1, + unmanaged: 1, + attention: 4, + }); + }); + + it("filters attention and unmanaged skills", () => { + expect(filterSkills(skills, { q: "", filter: "attention", agent: null }).map((s) => s.name)) + .toEqual(["Missing", "Disabled", "Archived", "Unmanaged"]); + expect(filterSkills(skills, { q: "", filter: "unmanaged", agent: null }).map((s) => s.name)) + .toEqual(["Unmanaged"]); + }); + + it("searches skill and agent metadata", () => { + expect(filterSkills(skills, { q: "a2", filter: "all", agent: null }).map((s) => s.name)).toEqual(["Disabled"]); + }); + + it("sorts dependency-heavy skills first", () => { + expect(sortSkills(skills, "deps")[0]?.name).toBe("Missing"); + }); + + it("strips dependency runtime prefixes only for display", () => { + expect(displayDependencyName("pip:google")).toBe("google"); + expect(displayDependencyName("core")).toBe("core"); + }); +}); diff --git a/ui/web/src/pages/skills/lib/skills-filtering.ts b/ui/web/src/pages/skills/lib/skills-filtering.ts new file mode 100644 index 00000000..d0305411 --- /dev/null +++ b/ui/web/src/pages/skills/lib/skills-filtering.ts @@ -0,0 +1,115 @@ +import type { SkillInfo } from "../hooks/use-skills"; +import type { SkillsFilter, SkillsPageState } from "./skills-page-state"; + +export interface SkillHealthStats { + total: number; + missingDeps: number; + disabled: number; + archived: number; + unmanaged: number; + attention: number; +} + +export function displayDependencyName(dep: string): string { + return dep.replace(/^(pip|npm):/, ""); +} + +export function deriveSkillStats(skills: SkillInfo[]): SkillHealthStats { + return skills.reduce((stats, skill) => { + stats.total += 1; + if (hasMissingDeps(skill)) stats.missingDeps += 1; + if (isDisabled(skill)) stats.disabled += 1; + if (isArchived(skill)) stats.archived += 1; + if (isUnmanaged(skill)) stats.unmanaged += 1; + if (needsAttention(skill)) stats.attention += 1; + return stats; + }, { total: 0, missingDeps: 0, disabled: 0, archived: 0, unmanaged: 0, attention: 0 }); +} + +export function filterSkills(skills: SkillInfo[], state: Pick): SkillInfo[] { + const q = state.q.trim().toLowerCase(); + return skills.filter((skill) => { + if (q && !matchesQuery(skill, q)) return false; + if (state.agent && !matchesAgent(skill, state.agent)) return false; + return matchesFilter(skill, state.filter); + }); +} + +export function sortSkills(skills: SkillInfo[], sort: SkillsPageState["sort"]): SkillInfo[] { + const next = [...skills]; + next.sort((a, b) => { + if (sort === "deps") { + const diff = (b.missing_deps?.length ?? 0) - (a.missing_deps?.length ?? 0); + if (diff !== 0) return diff; + } + if (sort === "version") { + const diff = (b.version ?? 0) - (a.version ?? 0); + if (diff !== 0) return diff; + } + return a.name.localeCompare(b.name); + }); + return next; +} + +export function needsAttention(skill: SkillInfo): boolean { + return hasMissingDeps(skill) || isDisabled(skill) || isArchived(skill) || isUnmanaged(skill); +} + +export function isUnmanaged(skill: SkillInfo): boolean { + return !skill.is_system && (skill.manager_agents?.length ?? 0) === 0; +} + +export function isArchived(skill: SkillInfo): boolean { + return skill.status === "archived"; +} + +export function isDisabled(skill: SkillInfo): boolean { + return skill.enabled === false; +} + +export function hasMissingDeps(skill: SkillInfo): boolean { + return (skill.missing_deps?.length ?? 0) > 0; +} + +function matchesFilter(skill: SkillInfo, filter: SkillsFilter): boolean { + switch (filter) { + case "attention": + return needsAttention(skill); + case "missing-deps": + return hasMissingDeps(skill); + case "disabled": + return isDisabled(skill); + case "archived": + return isArchived(skill); + case "unmanaged": + return isUnmanaged(skill); + case "all": + return true; + } +} + +function matchesQuery(skill: SkillInfo, q: string): boolean { + const agentLabels = [ + skill.creator_agent?.display_name, + skill.creator_agent?.agent_key, + skill.creator_agent?.id, + ...(skill.manager_agents ?? []).flatMap((agent) => [agent.display_name, agent.agent_key, agent.id]), + ]; + return [ + skill.name, + skill.slug, + skill.description, + skill.author, + ...agentLabels, + ].some((value) => value?.toLowerCase().includes(q)); +} + +function matchesAgent(skill: SkillInfo, agentRef: string): boolean { + const candidates = [ + skill.creator_agent, + ...(skill.manager_agents ?? []), + ]; + return candidates.some((agent) => + agent?.id === agentRef || agent?.agent_key === agentRef || agent?.display_name === agentRef, + ); +} diff --git a/ui/web/src/pages/skills/lib/skills-page-state.test.ts b/ui/web/src/pages/skills/lib/skills-page-state.test.ts new file mode 100644 index 00000000..01d96697 --- /dev/null +++ b/ui/web/src/pages/skills/lib/skills-page-state.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { parseSkillsPageState, serializeSkillsPageState } from "./skills-page-state"; + +describe("skills page state", () => { + it("uses quiet defaults for empty params", () => { + expect(parseSkillsPageState(new URLSearchParams())).toEqual({ + tab: "core", + q: "", + filter: "all", + sort: "name", + agent: null, + }); + }); + + it("rejects invalid params", () => { + const state = parseSkillsPageState(new URLSearchParams("tab=bad&filter=weird&sort=old&q=%20pdf%20")); + expect(state).toMatchObject({ tab: "core", filter: "all", sort: "name", q: "pdf" }); + }); + + it("serializes non-default filters while preserving modal params", () => { + const params = new URLSearchParams("skill=abc&detailTab=files&file=SKILL.md"); + const next = serializeSkillsPageState(params, { tab: "custom", q: "pdf", filter: "missing-deps", sort: "deps" }); + expect(next.get("tab")).toBe("custom"); + expect(next.get("q")).toBe("pdf"); + expect(next.get("filter")).toBe("missing-deps"); + expect(next.get("sort")).toBe("deps"); + expect(next.get("skill")).toBe("abc"); + expect(next.get("detailTab")).toBe("files"); + expect(next.get("file")).toBe("SKILL.md"); + }); + + it("removes default values instead of making noisy URLs", () => { + const params = new URLSearchParams("tab=custom&q=pdf&filter=attention&sort=deps&agent=a1"); + const next = serializeSkillsPageState(params, { tab: "core", q: "", filter: "all", sort: "name", agent: null }); + expect(next.toString()).toBe(""); + }); +}); diff --git a/ui/web/src/pages/skills/lib/skills-page-state.ts b/ui/web/src/pages/skills/lib/skills-page-state.ts new file mode 100644 index 00000000..e1545dbe --- /dev/null +++ b/ui/web/src/pages/skills/lib/skills-page-state.ts @@ -0,0 +1,58 @@ +export type SkillsTab = "core" | "custom"; +export type SkillsFilter = "all" | "attention" | "missing-deps" | "disabled" | "archived" | "unmanaged"; +export type SkillsSort = "name" | "deps" | "version"; + +export interface SkillsPageState { + tab: SkillsTab; + q: string; + filter: SkillsFilter; + sort: SkillsSort; + agent: string | null; +} + +const FILTERS = new Set(["all", "attention", "missing-deps", "disabled", "archived", "unmanaged"]); +const SORTS = new Set(["name", "deps", "version"]); + +export const DEFAULT_SKILLS_PAGE_STATE: SkillsPageState = { + tab: "core", + q: "", + filter: "all", + sort: "name", + agent: null, +}; + +export function parseSkillsPageState(params: URLSearchParams): SkillsPageState { + const rawFilter = params.get("filter"); + const rawSort = params.get("sort"); + const q = (params.get("q") ?? "").trim(); + const agent = (params.get("agent") ?? "").trim(); + + return { + tab: params.get("tab") === "custom" ? "custom" : "core", + q, + filter: rawFilter && FILTERS.has(rawFilter as SkillsFilter) ? (rawFilter as SkillsFilter) : "all", + sort: rawSort && SORTS.has(rawSort as SkillsSort) ? (rawSort as SkillsSort) : "name", + agent: agent || null, + }; +} + +export function serializeSkillsPageState( + currentParams: URLSearchParams, + updates: Partial, +): URLSearchParams { + const next = new URLSearchParams(currentParams); + const state = { ...parseSkillsPageState(currentParams), ...updates }; + + setParam(next, "tab", state.tab === "core" ? "" : state.tab); + setParam(next, "q", state.q.trim()); + setParam(next, "filter", state.filter === "all" ? "" : state.filter); + setParam(next, "sort", state.sort === "name" ? "" : state.sort); + setParam(next, "agent", state.agent ?? ""); + + return next; +} + +function setParam(params: URLSearchParams, key: string, value: string) { + if (value) params.set(key, value); + else params.delete(key); +} diff --git a/ui/web/src/pages/skills/skill-agent-chips.tsx b/ui/web/src/pages/skills/skill-agent-chips.tsx new file mode 100644 index 00000000..dde0d3ab --- /dev/null +++ b/ui/web/src/pages/skills/skill-agent-chips.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; +import type { SkillInfo, SkillAgentRef } from "@/types/skill"; + +interface SkillAgentChipsProps { + skill: SkillInfo; +} + +export function SkillAgentChips({ skill }: SkillAgentChipsProps) { + const { t } = useTranslation("skills"); + const managers = skill.manager_agents ?? []; + const visibleManagers = managers.slice(0, 2); + const overflow = managers.length - visibleManagers.length; + const unmanaged = !skill.is_system && managers.length === 0; + + return ( +
+ {skill.creator_agent && ( + + {t("agents.creator")}: {agentLabel(skill.creator_agent)} + + )} + {visibleManagers.map((agent) => ( + + {agentLabel(agent)} + + ))} + {overflow > 0 && +{overflow}} + {unmanaged && {t("status.unmanaged")}} + {!skill.creator_agent && managers.length === 0 && skill.author && ( + {skill.author} + )} + {!skill.creator_agent && managers.length === 0 && !skill.author && !unmanaged && ( + — + )} +
+ ); +} + +function agentLabel(agent: SkillAgentRef): string { + return agent.display_name || agent.agent_key || agent.id || "unknown"; +} diff --git a/ui/web/src/pages/skills/skill-bulk-actions-toolbar.tsx b/ui/web/src/pages/skills/skill-bulk-actions-toolbar.tsx index 24fbdbda..fe6c8fdd 100644 --- a/ui/web/src/pages/skills/skill-bulk-actions-toolbar.tsx +++ b/ui/web/src/pages/skills/skill-bulk-actions-toolbar.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button"; interface SkillBulkActionsToolbarProps { selectedCount: number; customSelectedCount: number; + skippedSystemCount: number; agentCount: number; loading: boolean; onEnable: () => void; @@ -17,6 +18,7 @@ interface SkillBulkActionsToolbarProps { export function SkillBulkActionsToolbar({ selectedCount, customSelectedCount, + skippedSystemCount, agentCount, loading, onEnable, @@ -27,15 +29,25 @@ export function SkillBulkActionsToolbar({ }: SkillBulkActionsToolbarProps) { const { t } = useTranslation("skills"); const hasSelection = selectedCount > 0; + const customActionDisabledReason = agentCount === 0 + ? t("bulk.noAgentsReason") + : customSelectedCount === 0 + ? t("bulk.customOnlyReason") + : undefined; + + if (!hasSelection) return null; return ( -
- - {t("bulk.selected", { count: selectedCount })} - +
+
+ + {t("bulk.selected", { count: selectedCount })} + + + {t("bulk.customAvailable", { count: customSelectedCount })} + {skippedSystemCount > 0 ? ` · ${t("bulk.skippedSystem", { count: skippedSystemCount })}` : ""} + +
{t("detail.version")} + +
diff --git a/ui/web/src/pages/skills/skill-health-summary.tsx b/ui/web/src/pages/skills/skill-health-summary.tsx new file mode 100644 index 00000000..ecedc0cc --- /dev/null +++ b/ui/web/src/pages/skills/skill-health-summary.tsx @@ -0,0 +1,50 @@ +import { AlertTriangle, Archive, Box, CircleOff, PackageX, ShieldAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import type { SkillHealthStats } from "./lib/skills-filtering"; +import type { SkillsFilter } from "./lib/skills-page-state"; + +interface SkillHealthSummaryProps { + stats: SkillHealthStats; + activeFilter: SkillsFilter; + onFilterChange: (filter: SkillsFilter) => void; +} + +export function SkillHealthSummary({ stats, activeFilter, onFilterChange }: SkillHealthSummaryProps) { + const { t } = useTranslation("skills"); + const items: Array<{ filter: SkillsFilter; label: string; value: number; icon: typeof Box; tone?: string }> = [ + { filter: "all", label: t("health.total"), value: stats.total, icon: Box }, + { filter: "attention", label: t("health.attention"), value: stats.attention, icon: AlertTriangle, tone: "text-amber-700" }, + { filter: "missing-deps", label: t("health.missingDeps"), value: stats.missingDeps, icon: PackageX, tone: "text-amber-700" }, + { filter: "disabled", label: t("health.disabled"), value: stats.disabled, icon: CircleOff, tone: "text-red-700" }, + { filter: "archived", label: t("health.archived"), value: stats.archived, icon: Archive, tone: "text-muted-foreground" }, + { filter: "unmanaged", label: t("health.unmanaged"), value: stats.unmanaged, icon: ShieldAlert, tone: "text-orange-700" }, + ]; + + return ( +
+ {items.map((item) => { + const Icon = item.icon; + const active = activeFilter === item.filter; + return ( + + ); + })} +
+ ); +} diff --git a/ui/web/src/pages/skills/skill-status-badges.tsx b/ui/web/src/pages/skills/skill-status-badges.tsx new file mode 100644 index 00000000..8184e542 --- /dev/null +++ b/ui/web/src/pages/skills/skill-status-badges.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; +import type { SkillInfo } from "./hooks/use-skills"; +import { displayDependencyName, hasMissingDeps, isArchived, isDisabled } from "./lib/skills-filtering"; + +interface SkillStatusBadgesProps { + skill: SkillInfo; +} + +export function SkillStatusBadges({ skill }: SkillStatusBadgesProps) { + const { t } = useTranslation("skills"); + const archived = isArchived(skill); + const disabled = isDisabled(skill); + const missing = skill.missing_deps ?? []; + + return ( +
+ + {archived ? t("deps.statusArchived") : t("deps.statusActive")} + + {disabled && {t("toggle.disabled")}} + {hasMissingDeps(skill) && ( + + {t("status.missingDepsCount", { count: missing.length })} + + )} +
+ ); +} diff --git a/ui/web/src/pages/skills/skill-table-row.tsx b/ui/web/src/pages/skills/skill-table-row.tsx index e3aa2ae5..a473e2b4 100644 --- a/ui/web/src/pages/skills/skill-table-row.tsx +++ b/ui/web/src/pages/skills/skill-table-row.tsx @@ -5,14 +5,11 @@ import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import { SkillTenantOverride } from "./skill-tenant-override"; +import { SkillAgentChips } from "./skill-agent-chips"; +import { SkillStatusBadges } from "./skill-status-badges"; +import { getSkillAccessModeBadgeVariant, getSkillAccessModeKey } from "./lib/skill-access-mode"; import type { SkillInfo } from "./hooks/use-skills"; -const visibilityColor: Record = { - public: "default", - internal: "secondary", - private: "outline", -}; - interface SkillTableRowProps { skill: SkillInfo; tab: "core" | "custom"; @@ -25,21 +22,25 @@ interface SkillTableRowProps { onManageGrants: (skill: SkillInfo) => void; onDelete: (skill: SkillInfo) => void; onToggle: (skill: SkillInfo, enabled: boolean) => void; - onCycleVisibility: (skill: SkillInfo) => void; + onCycleAccessMode: (skill: SkillInfo) => void; onSetTenantConfig: (id: string, enabled: boolean) => Promise; onDeleteTenantConfig: (id: string) => Promise; } -/** Single row in the skills table with inline status, visibility, and action controls. */ +/** Single row in the skills table with inline status, access mode, and action controls. */ export function SkillTableRow({ skill, tab, hasTenantScope, toggling, selected, onToggleSelect, - onView, onEdit, onManageGrants, onDelete, onToggle, onCycleVisibility, + onView, onEdit, onManageGrants, onDelete, onToggle, onCycleAccessMode, onSetTenantConfig, onDeleteTenantConfig, }: SkillTableRowProps) { const { t } = useTranslation("skills"); const isArchived = skill.status === "archived"; const isDisabled = skill.enabled === false; - const hasMissing = (skill.missing_deps?.length ?? 0) > 0; + const accessModeKey = getSkillAccessModeKey(skill.visibility); + const accessModeLabel = accessModeKey === "unknown" + ? t("accessMode.unknown", { value: skill.visibility || t("unknownOwner") }) + : t(`accessMode.${accessModeKey}`); + const accessModeVariant = getSkillAccessModeBadgeVariant(skill.visibility); return ( @@ -77,63 +78,27 @@ export function SkillTableRow({ {tab === "custom" && ( -
- {skill.author && {skill.author}} - {skill.creator_agent && ( - - {t("agents.creator")}: {skill.creator_agent.display_name || skill.creator_agent.agent_key || skill.creator_agent.id} - - )} - {skill.manager_agents && skill.manager_agents.length > 0 ? ( - - {t("agents.managers")}: {skill.manager_agents.map((agent) => agent.display_name || agent.agent_key || agent.id).join(", ")} - - ) : !skill.author && !skill.creator_agent ? ( - — - ) : null} -
+ )} -
- - {isArchived ? t("deps.statusArchived") : t("deps.statusActive")} - - {hasMissing && (() => { - const deps = skill.missing_deps!.map((d) => d.replace(/^(pip|npm):/, "")); - const shown = deps.slice(0, 3); - const rest = deps.length - shown.length; - return ( - - {shown.join(", ")}{rest > 0 && `, +${rest}`} - - ); - })()} -
+ {tab === "custom" && ( {skill.visibility && ( skill.id ? ( - ) : ( - - {skill.visibility} + + {accessModeLabel} ) )} @@ -161,10 +126,12 @@ export function SkillTableRow({ )} {!skill.is_system && ( )} {!skill.is_system && ( @@ -174,6 +141,7 @@ export function SkillTableRow({ className="gap-1 text-destructive hover:text-destructive" > + {t("delete.confirmLabel")} )} diff --git a/ui/web/src/pages/skills/skill-upload-dialog.tsx b/ui/web/src/pages/skills/skill-upload-dialog.tsx index f94250fa..c971e533 100644 --- a/ui/web/src/pages/skills/skill-upload-dialog.tsx +++ b/ui/web/src/pages/skills/skill-upload-dialog.tsx @@ -12,9 +12,11 @@ import { import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; import { useAgents } from "@/pages/agents/hooks/use-agents"; import { createSkillSubZip } from "./lib/create-skill-sub-zip"; import { resolveUploadSkills } from "./lib/resolve-upload-skills"; +import { summarizeUploadEntries } from "./lib/skill-upload-summary"; import { uniqueId } from "@/lib/utils"; import type { SkillUploadOptions, SkillUploadResponse } from "./hooks/use-skills"; import type { FileEntry, SkillStatus } from "./lib/skill-upload-types"; @@ -238,9 +240,8 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD // Derived counts (skill level, not file level) // --------------------------------------------------------------------------- - const allSkills = entries.flatMap((e) => e.skills); - const actionableCount = allSkills.filter((s) => s.status === "valid").length; - const successCount = allSkills.filter((s) => s.status === "success" || s.status === "warning").length; + const uploadSummary = summarizeUploadEntries(entries); + const actionableCount = uploadSummary.valid; const allCurrentAgentsSelected = agents.length > 0 && managerAgentIds.length === agents.length; const toggleManagerAgent = (id: string) => { @@ -326,9 +327,12 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
+

+ {t("upload.selectedAgentsCount", { selected: managerAgentIds.length, total: agents.length })} +

{agents.length === 0 ? (

{t("upload.noAgents")}

@@ -352,13 +356,18 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD {/* Summary line */} {entries.length > 0 && !done && !uploading && (

- {t("upload.validCount", { valid: actionableCount, total: allSkills.length })} + {t("upload.validCount", { valid: actionableCount, total: uploadSummary.total })}

)} {done && ( -

- {t("upload.successCount", { success: successCount, total: allSkills.filter((s) => s.status !== "unchanged" && s.status !== "invalid").length })} -

+
+ {t("upload.summaryUploaded", { count: uploadSummary.uploaded })} + {t("upload.summaryWarnings", { count: uploadSummary.warnings })} + {t("upload.summaryUnchanged", { count: uploadSummary.unchanged })} + 0 ? "destructive" : "outline"}> + {t("upload.summaryFailed", { count: uploadSummary.failed + uploadSummary.invalid })} + +
)} diff --git a/ui/web/src/pages/skills/skills-filter-bar.tsx b/ui/web/src/pages/skills/skills-filter-bar.tsx new file mode 100644 index 00000000..c1a6327b --- /dev/null +++ b/ui/web/src/pages/skills/skills-filter-bar.tsx @@ -0,0 +1,68 @@ +import { X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SearchInput } from "@/components/shared/search-input"; +import type { SkillsFilter, SkillsPageState, SkillsSort } from "./lib/skills-page-state"; + +interface SkillsFilterBarProps { + state: SkillsPageState; + onChange: (updates: Partial) => void; +} + +export function SkillsFilterBar({ state, onChange }: SkillsFilterBarProps) { + const { t } = useTranslation("skills"); + const hasFilters = !!state.q || state.filter !== "all" || state.sort !== "name" || !!state.agent; + const filterOptions: SkillsFilter[] = ["all", "attention", "missing-deps", "disabled", "archived", "unmanaged"]; + const sortOptions: SkillsSort[] = ["name", "deps", "version"]; + + return ( +
+ onChange({ q })} + placeholder={t("searchPlaceholder")} + className="w-full md:max-w-sm" + /> +
+ + + +
+
+ ); +} diff --git a/ui/web/src/pages/skills/skills-page.tsx b/ui/web/src/pages/skills/skills-page.tsx index f0371441..8c604997 100644 --- a/ui/web/src/pages/skills/skills-page.tsx +++ b/ui/web/src/pages/skills/skills-page.tsx @@ -1,11 +1,10 @@ import { useState, useEffect, lazy, Suspense, useMemo } from "react"; -import { useSearchParams } from "react-router"; +import { useNavigate, useParams, useSearchParams } from "react-router"; import { useTranslation } from "react-i18next"; import { Zap, RefreshCw, Upload, ScanSearch, Download } from "lucide-react"; import { Button } from "@/components/ui/button"; import { PageHeader } from "@/components/shared/page-header"; import { EmptyState } from "@/components/shared/empty-state"; -import { SearchInput } from "@/components/shared/search-input"; import { Pagination } from "@/components/shared/pagination"; import { TableSkeleton } from "@/components/shared/loading-skeleton"; import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog"; @@ -16,6 +15,8 @@ import { SkillDetailDialog } from "./skill-detail-dialog"; import { SkillEditDialog } from "./skill-edit-dialog"; import { SkillAgentGrantsDialog } from "./skill-agent-grants-dialog"; import { SkillBulkActionsToolbar } from "./skill-bulk-actions-toolbar"; +import { SkillHealthSummary } from "./skill-health-summary"; +import { SkillsFilterBar } from "./skills-filter-bar"; const SkillUploadDialog = lazy(() => import("./skill-upload-dialog").then((m) => ({ default: m.SkillUploadDialog })) @@ -28,6 +29,13 @@ import { useDeferredLoading } from "@/hooks/use-deferred-loading"; import { usePagination } from "@/hooks/use-pagination"; import { useTenants } from "@/hooks/use-tenants"; import { useAgents } from "@/pages/agents/hooks/use-agents"; +import { deriveSkillStats, filterSkills, sortSkills } from "./lib/skills-filtering"; +import { + parseSkillsPageState, + serializeSkillsPageState, + type SkillsPageState, +} from "./lib/skills-page-state"; +import { getNextSkillAccessMode } from "./lib/skill-access-mode"; const MASTER_TENANT_ID = "0193a5b0-7000-7000-8000-000000000001"; @@ -43,15 +51,17 @@ export function SkillsPage() { setTenantConfig, deleteTenantConfig, } = useSkills(); const [params, setParams] = useSearchParams(); + const routeParams = useParams<{ id?: string }>(); + const navigate = useNavigate(); const { runtimes } = useRuntimes(); const { currentTenantId } = useTenants(); const { agents } = useAgents(); const hasTenantScope = !!currentTenantId && currentTenantId !== MASTER_TENANT_ID; const spinning = useMinLoading(loading); const showSkeleton = useDeferredLoading(loading && skills.length === 0); - const urlTab = params.get("tab") === "custom" ? "custom" : "core"; - const tab: Tab = urlTab; - const [search, setSearch] = useState(""); + const pageState = useMemo(() => parseSkillsPageState(params), [params]); + const tab: Tab = pageState.tab; + const skillRef = params.get("skill"); const [selectedSkill, setSelectedSkill] = useState<(SkillInfo & { content: string }) | null>(null); const [uploadOpen, setUploadOpen] = useState(false); const [editTarget, setEditTarget] = useState(null); @@ -68,27 +78,33 @@ export function SkillsPage() { const coreSkills = useMemo(() => skills.filter((s: SkillInfo) => s.is_system), [skills]); const customSkills = useMemo(() => skills.filter((s: SkillInfo) => !s.is_system), [skills]); const tabSkills = tab === "core" ? coreSkills : customSkills; + const healthStats = useMemo(() => deriveSkillStats(tabSkills), [tabSkills]); const allMissing = useMemo( () => [...new Set(tabSkills.flatMap((s: SkillInfo) => s.missing_deps ?? []))], [tabSkills], ); const filtered = useMemo( - () => tabSkills.filter( - (s: SkillInfo) => - s.name.toLowerCase().includes(search.toLowerCase()) || - s.description.toLowerCase().includes(search.toLowerCase()), - ), - [search, tabSkills], + () => sortSkills(filterSkills(tabSkills, pageState), pageState.sort), + [pageState, tabSkills], ); const { pageItems, pagination, setPage, setPageSize, resetPage } = usePagination(filtered); const selectedSkills = filtered.filter((skill) => skill.id && selectedIds.has(skill.id)); const selectedCustomSkills = selectedSkills.filter((skill) => !skill.is_system); + const skippedSystemCount = selectedSkills.length - selectedCustomSkills.length; const pageSelectableIds = pageItems.map((skill) => skill.id).filter((id): id is string => !!id); const allPageSelected = pageSelectableIds.length > 0 && pageSelectableIds.every((id) => selectedIds.has(id)); const somePageSelected = pageSelectableIds.some((id) => selectedIds.has(id)) && !allPageSelected; - useEffect(() => { resetPage(); }, [search, tab, resetPage]); - useEffect(() => { setSelectedIds(new Set()); }, [search, tab]); + useEffect(() => { + const routeSkillId = routeParams.id; + if (!routeSkillId) return; + const next = new URLSearchParams(params); + next.set("skill", routeSkillId); + navigate({ pathname: "/skills", search: toSearchString(next) }, { replace: true }); + }, [navigate, params, routeParams.id]); + + useEffect(() => { resetPage(); }, [pageState.q, pageState.filter, pageState.sort, pageState.agent, tab, resetPage]); + useEffect(() => { setSelectedIds(new Set()); }, [pageState.q, pageState.filter, pageState.sort, pageState.agent, tab]); useEffect(() => { setSelectedIds((current) => { const valid = new Set(filtered.map((skill) => skill.id).filter(Boolean)); @@ -97,6 +113,10 @@ export function SkillsPage() { }); }, [filtered]); + const setPageState = (updates: Partial) => { + setParams(serializeSkillsPageState(params, updates), { replace: true }); + }; + const setParamValues = (updates: Record) => { const next = new URLSearchParams(params); for (const [key, value] of Object.entries(updates)) { @@ -108,7 +128,8 @@ export function SkillsPage() { const setTab = (nextTab: Tab) => { const next = new URLSearchParams(params); - next.set("tab", nextTab); + if (nextTab === "core") next.delete("tab"); + else next.set("tab", nextTab); next.delete("skill"); next.delete("detailTab"); next.delete("version"); @@ -122,13 +143,14 @@ export function SkillsPage() { next.delete("detailTab"); next.delete("version"); next.delete("file"); - setParams(next, { replace: true }); + navigate({ pathname: "/skills", search: toSearchString(next) }, { replace: true }); setSelectedSkill(null); }; const handleViewSkill = async (skill: SkillInfo) => { const next = new URLSearchParams(params); - next.set("tab", skill.is_system ? "core" : "custom"); + if (skill.is_system) next.delete("tab"); + else next.set("tab", "custom"); next.set("skill", skill.id || skill.slug || skill.name); next.set("detailTab", "content"); next.delete("version"); @@ -137,7 +159,6 @@ export function SkillsPage() { }; useEffect(() => { - const skillRef = params.get("skill"); if (!skillRef) { setSelectedSkill(null); return; @@ -147,13 +168,11 @@ export function SkillsPage() { if (!cancelled) setSelectedSkill(detail); }); return () => { cancelled = true; }; - }, [params, getSkill]); + }, [skillRef, getSkill]); - const handleCycleVisibility = async (skill: SkillInfo) => { + const handleCycleAccessMode = async (skill: SkillInfo) => { if (!skill.id) return; - const order = ["private", "internal", "public"] as const; - const idx = order.indexOf(skill.visibility as typeof order[number]); - await updateSkill(skill.id, { visibility: order[(idx + 1) % order.length] }); + await updateSkill(skill.id, { visibility: getNextSkillAccessMode(skill.visibility) }); }; const handleDelete = async () => { @@ -305,6 +324,7 @@ export function SkillsPage() { handleBulkToggle(true)} @@ -316,7 +336,14 @@ export function SkillsPage() {
- +
+ setPageState({ filter })} + /> + +
@@ -325,8 +352,8 @@ export function SkillsPage() { ) : filtered.length === 0 ? ( ) : (
@@ -347,7 +374,7 @@ export function SkillsPage() { {t("columns.description")} {tab === "custom" && {t("columns.agents")}} {t("columns.status")} - {tab === "custom" && {t("columns.visibility")}} + {tab === "custom" && {t("columns.accessMode")}} {t("columns.actions")} @@ -366,7 +393,7 @@ export function SkillsPage() { onManageGrants={setGrantsTarget} onDelete={setDeleteTarget} onToggle={handleToggle} - onCycleVisibility={handleCycleVisibility} + onCycleAccessMode={handleCycleAccessMode} onSetTenantConfig={handleSetTenantConfig} onDeleteTenantConfig={handleDeleteTenantConfig} /> @@ -445,3 +472,8 @@ export function SkillsPage() {
); } + +function toSearchString(params: URLSearchParams): string { + const serialized = params.toString(); + return serialized ? `?${serialized}` : ""; +}