diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index e5bf7160..11394990 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -1276,6 +1276,12 @@ export default {
toggleFailed: 'Failed to toggle status',
toggleSuccess: 'Status updated',
},
+ tagFilter: {
+ label: 'Tags',
+ clear: 'Clear',
+ search: 'Search tags',
+ noMatch: 'No matching tags',
+ },
binding: {
skillsKicker: 'Trained workflows',
skillsTagline: 'A skill is a workflow — a step-by-step playbook the LLM follows for a coherent multi-step task.',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index bb212e70..346f48c6 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -1168,6 +1168,12 @@ export default {
toggleFailed: '切换状态失败',
toggleSuccess: '状态已更新',
},
+ tagFilter: {
+ label: '标签',
+ clear: '清除',
+ search: '搜索标签',
+ noMatch: '无匹配标签',
+ },
binding: {
skillsKicker: '受过培训的工作流程',
skillsTagline: '技能 = 一段流程,一份工作手册。LLM 按手册执行一系列连贯动作。',
diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue
index 760fc1b3..b12b5008 100644
--- a/mateclaw-ui/src/views/Agents.vue
+++ b/mateclaw-ui/src/views/Agents.vue
@@ -57,6 +57,23 @@
+
+
+ {{ t('agents.tagFilter.label') }}
+
+
+
+ {{ t('agents.tagFilter.noMatch') }}
+
+
+
@@ -86,6 +103,12 @@
{{ agentTagline(agent) || t('agents.messages.noTagline') }}
+
+
+ {{ tag }}
+
+
@@ -644,6 +667,9 @@ const { resolveSkillName } = useSkillName()
const agents = ref([])
const searchText = ref('')
const activeFilter = ref('all')
+// Tag filter (#146) — orthogonal to activeFilter; multi-select with AND
+// (intersection) semantics. Empty array = no tag constraint.
+const activeTags = ref([])
const showModal = ref(false)
const editingAgent = ref(null)
const modalTab = ref<'basic' | 'skills' | 'tools' | 'providers' | 'wiki'>('basic')
@@ -904,6 +930,48 @@ function agentTagline(agent: Agent): string {
return deriveTagline(profile, agent.description)
}
+/** Parse an agent's comma-separated tags into a trimmed, non-empty, de-duped array. */
+function agentTags(agent: Agent): string[] {
+ const parsed = (agent.tags || '').split(',').map(t => t.trim()).filter(Boolean)
+ return [...new Set(parsed)]
+}
+
+// Distinct tags across all agents, ordered by frequency (most-used first) so
+// the filter bar surfaces the common categories. Hidden entirely when empty.
+const availableTags = computed(() => {
+ const freq = new Map()
+ for (const a of agents.value) {
+ for (const tag of agentTags(a)) freq.set(tag, (freq.get(tag) ?? 0) + 1)
+ }
+ return [...freq.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(e => e[0])
+})
+
+// Cap the inline chips so a workspace with dozens of tags doesn't bury the
+// roster under a wall of chips. Beyond the cap, a search box appears.
+const TAG_FILTER_LIMIT = 12
+const tagSearch = ref('')
+const hasMoreTags = computed(() => availableTags.value.length > TAG_FILTER_LIMIT)
+
+// Chips actually rendered. When searching, show every name match (no cap).
+// Otherwise show the top-N frequent tags, but always append any selected tags
+// that fell outside the cap so they stay deselectable.
+const visibleTags = computed(() => {
+ const q = tagSearch.value.trim().toLowerCase()
+ if (q) return availableTags.value.filter(t => t.toLowerCase().includes(q))
+ const top = availableTags.value.slice(0, TAG_FILTER_LIMIT)
+ const extraActive = activeTags.value.filter(t => !top.includes(t) && availableTags.value.includes(t))
+ return [...top, ...extraActive]
+})
+
+function toggleTag(tag: string) {
+ const i = activeTags.value.indexOf(tag)
+ if (i >= 0) activeTags.value.splice(i, 1)
+ else activeTags.value.push(tag)
+}
+function clearTagFilter() {
+ activeTags.value = []
+}
+
const filteredAgents = computed(() => {
let list = agents.value
if (searchText.value) {
@@ -918,6 +986,13 @@ const filteredAgents = computed(() => {
else if (activeFilter.value === 'plan_execute') list = list.filter(a => a.agentType === 'plan_execute')
else if (activeFilter.value === 'enabled') list = list.filter(a => a.enabled)
else if (activeFilter.value === 'disabled') list = list.filter(a => !a.enabled)
+ // Tag filter: intersection — an agent must carry every selected tag.
+ if (activeTags.value.length) {
+ list = list.filter(a => {
+ const tags = agentTags(a)
+ return activeTags.value.every(t => tags.includes(t))
+ })
+ }
return list
})
@@ -1356,6 +1431,16 @@ html.dark .seg-count.warn {
.filter-tab { padding: 8px 14px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 999px; font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; font-weight: 600; }
.filter-tab:hover { background: var(--mc-bg-sunken); }
.filter-tab.active { background: var(--mc-primary-bg); border-color: var(--mc-primary); color: var(--mc-primary); font-weight: 500; }
+.tag-filter-bar { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--mc-border); }
+.tag-filter-bar__label { font-size: 12px; color: var(--mc-text-tertiary); font-weight: 600; margin-right: 2px; }
+.tag-filter-chip { padding: 4px 12px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 999px; font-size: 12px; color: var(--mc-text-secondary); cursor: pointer; transition: all 0.15s; }
+.tag-filter-chip:hover { background: var(--mc-bg-sunken); }
+.tag-filter-chip.active { background: var(--mc-primary-bg); border-color: var(--mc-primary); color: var(--mc-primary); font-weight: 600; }
+.tag-filter-search { padding: 4px 10px; border: 1px solid var(--mc-border); background: var(--mc-bg-muted); border-radius: 999px; font-size: 12px; color: var(--mc-text-primary); width: 120px; outline: none; }
+.tag-filter-search:focus { border-color: var(--mc-primary); }
+.tag-filter-bar__empty { font-size: 12px; color: var(--mc-text-tertiary); }
+.tag-filter-clear { padding: 4px 10px; border: none; background: transparent; font-size: 12px; color: var(--mc-text-tertiary); cursor: pointer; text-decoration: underline; }
+.tag-filter-clear:hover { color: var(--mc-text-secondary); }
/* Agent Card Grid */
.agent-grid {
@@ -1440,6 +1525,11 @@ html.dark .seg-count.warn {
letter-spacing: -0.005em;
}
+.agent-card__tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
+.agent-card__tag { font-size: 11px; padding: 2px 8px; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); border-radius: 999px; white-space: nowrap; cursor: pointer; transition: all 0.15s; }
+.agent-card__tag:hover { color: var(--mc-text-secondary); }
+.agent-card__tag.active { background: var(--mc-primary-bg); color: var(--mc-primary); font-weight: 600; }
+
.agent-card__action-row {
display: flex;
align-items: center;