diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx
index edefe1775c..c65efa281f 100644
--- a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx
+++ b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx
@@ -60,6 +60,7 @@ interface CategoryInfo {
labelKey: string;
titleKey: string;
descKey: string;
+ sourceLabel?: string;
}
const CATEGORIES: CategoryInfo[] = [
@@ -245,9 +246,28 @@ const SkillsScene: React.FC = () => {
return list;
}, [hideDuplicates, installed.filteredSkills]);
- const activeInstalledCategory = CATEGORIES.find((category) => category.id === installedFilter)
+ const installedCategories: CategoryInfo[] = [
+ ...CATEGORIES.filter((category) => category.id !== 'suite'),
+ ...installed.sourceGroups.map((group) => ({
+ id: group.id,
+ icon: ,
+ labelKey: 'filters.source',
+ titleKey: 'installed.titleSource',
+ descKey: 'categories.source',
+ sourceLabel: group.label,
+ })),
+ ...CATEGORIES.filter((category) => category.id === 'suite'),
+ ];
+ const activeInstalledCategory = installedCategories.find((category) => category.id === installedFilter)
?? CATEGORIES[0];
+ useEffect(() => {
+ if (!installed.loading && !installed.error && installedFilter.startsWith('source:')
+ && !installed.sourceGroups.some((group) => group.id === installedFilter)) {
+ setInstalledFilter('all');
+ }
+ }, [installed.loading, installed.error, installed.sourceGroups, installedFilter, setInstalledFilter]);
+
return (
{
{t('installed.titleAll')}
- {t(CATEGORIES.find((c) => c.id === installedFilter)?.descKey ?? 'categories.all')}
+ {t(activeInstalledCategory.descKey, { source: activeInstalledCategory.sourceLabel })}
}
@@ -387,7 +407,7 @@ const SkillsScene: React.FC = () => {
>
- {t(activeInstalledCategory.titleKey)}
+ {t(activeInstalledCategory.titleKey, { source: activeInstalledCategory.sourceLabel })}
{
- {skill.name}
+ {skill.name}
{skill.description?.trim() && (
{skill.description}
@@ -517,14 +537,9 @@ const SkillsScene: React.FC = () => {
)}
{skill.isShadowed && (
-
- }>
- {t('list.item.shadowed')}
-
-
+ }>
+ {t('list.item.shadowed')}
+
)}
@@ -554,7 +569,7 @@ const SkillsScene: React.FC = () => {
{skill.level === 'user'
?
: }
-
+
{market.isRemoteWorkspace
? skill.level === 'user'
? t('list.item.localUser')
diff --git a/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.test.tsx b/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.test.tsx
index 630f1f1482..9f7b55fe23 100644
--- a/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.test.tsx
+++ b/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.test.tsx
@@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SkillInfo } from '@/infrastructure/config/types';
import { useInstalledSkills } from './useInstalledSkills';
+import type { InstalledFilter } from '../skillsSceneStore';
const getSkillConfigsMock = vi.hoisted(() => vi.fn());
const getGlobalSkillSettingsMock = vi.hoisted(() => vi.fn());
@@ -44,10 +45,14 @@ vi.mock('@/shared/notification-system', () => ({
let currentInstalled: ReturnType | null = null;
-function Harness({ enabled }: { enabled: boolean }) {
+function Harness({ enabled, activeFilter = 'all', searchQuery = '' }: {
+ enabled: boolean;
+ activeFilter?: InstalledFilter;
+ searchQuery?: string;
+}) {
const installed = useInstalledSkills({
- searchQuery: '',
- activeFilter: 'all',
+ searchQuery,
+ activeFilter,
enabled,
});
currentInstalled = installed;
@@ -102,6 +107,44 @@ describe('useInstalledSkills', () => {
expect(getGlobalSkillSettingsMock).toHaveBeenCalledTimes(1);
});
+ it('groups external agents across scopes and keeps counts independent of search', async () => {
+ const skill = (key: string, overrides: Partial = {}): SkillInfo => ({
+ key, name: 'shared-name', description: '', path: `/skills/${key}`,
+ level: 'user', sourceSlot: 'openbitfun', sourceId: 'openbitfun',
+ dirName: 'shared-name', isBuiltin: false, ...overrides,
+ });
+ const skills = [
+ skill('owned-user'),
+ skill('owned-project', { level: 'project' }),
+ skill('builtin', { isBuiltin: true }),
+ skill('codex-user', { sourceId: 'codex', sourceSlot: 'home.codex' }),
+ skill('codex-project', { sourceId: '', sourceSlot: 'codex', level: 'project', description: 'remote workspace' }),
+ skill('claude', { sourceId: 'claude-code', sourceSlot: 'home.claude', isShadowed: true }),
+ skill('agents', { sourceId: 'agent-skills', sourceSlot: 'home.agents' }),
+ ];
+ getSkillConfigsMock.mockResolvedValue(skills);
+ await act(async () => root.render());
+ expect(currentInstalled?.filteredSkills.map((item) => item.key)).toEqual(['codex-user', 'codex-project']);
+ expect(currentInstalled?.sourceGroups).toEqual([
+ { id: 'source:agent-skills', label: 'Agent Skills' },
+ { id: 'source:claude-code', label: 'Claude Code' },
+ { id: 'source:codex', label: 'Codex' },
+ ]);
+ expect(currentInstalled?.counts).toEqual({
+ all: 7, builtin: 1, suite: 1, user: 1, project: 1,
+ 'source:codex': 2, 'source:claude-code': 1, 'source:agent-skills': 1,
+ });
+ await act(async () => root.render());
+ expect(currentInstalled?.filteredSkills.map((item) => item.key)).toEqual(['codex-project']);
+ expect(currentInstalled?.counts['source:codex']).toBe(2);
+ await act(async () => root.render());
+ expect(currentInstalled?.filteredSkills.map((item) => item.key)).toEqual(['owned-user']);
+ await act(async () => root.render());
+ expect(currentInstalled?.filteredSkills.map((item) => item.key)).toEqual(['owned-project']);
+ await act(async () => root.render());
+ expect(currentInstalled?.filteredSkills).toEqual(skills);
+ });
+
it('ignores a desktop skill load that finishes after switching away', async () => {
let resolveLoad: ((skills: SkillInfo[]) => void) | undefined;
getSkillConfigsMock.mockReturnValueOnce(new Promise((resolve) => {
diff --git a/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts b/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts
index b2f5f8fb68..44118a4a19 100644
--- a/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts
+++ b/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts
@@ -3,7 +3,7 @@ import { open } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
import { configAPI } from '@/infrastructure/api';
import type { SkillInfo, SkillLevel, SkillValidationResult } from '@/infrastructure/config/types';
-import { canDeleteSkill } from '@/infrastructure/config/skillSourcePresentation';
+import { canDeleteSkill, getSkillSourceId, getSkillSourceLabel } from '@/infrastructure/config/skillSourcePresentation';
import { useWorkspaceManagerSync } from '@/infrastructure/hooks/useWorkspaceManagerSync';
import { useNotification } from '@/shared/notification-system';
import { createLogger } from '@/shared/utils/logger';
@@ -11,6 +11,12 @@ import type { InstalledFilter } from '../skillsSceneStore';
const log = createLogger('SkillsScene:useInstalledSkills');
+function installedSkillGroup(skill: SkillInfo): InstalledFilter {
+ if (skill.isBuiltin) return 'builtin';
+ const sourceId = getSkillSourceId(skill);
+ return sourceId === 'openbitfun' ? skill.level : `source:${sourceId}`;
+}
+
interface UseInstalledSkillsOptions {
searchQuery: string;
activeFilter: InstalledFilter;
@@ -314,14 +320,10 @@ export function useInstalledSkills({
const filteredSkills = useMemo(() => {
return skills.filter((skill) => {
let matchesFilter = true;
- if (activeFilter === 'user') {
- matchesFilter = skill.level === 'user' && !skill.isBuiltin;
- } else if (activeFilter === 'project') {
- matchesFilter = skill.level === 'project' && !skill.isBuiltin;
- } else if (activeFilter === 'builtin') {
- matchesFilter = skill.isBuiltin;
- } else if (activeFilter === 'suite') {
+ if (activeFilter === 'suite') {
matchesFilter = skill.isBuiltin;
+ } else if (activeFilter !== 'all') {
+ matchesFilter = installedSkillGroup(skill) === activeFilter;
}
const matchesQuery = !normalizedQuery || [
@@ -333,13 +335,25 @@ export function useInstalledSkills({
});
}, [activeFilter, normalizedQuery, skills]);
- const counts = useMemo(() => ({
- all: skills.length,
- builtin: skills.filter((skill) => skill.isBuiltin).length,
- user: skills.filter((skill) => skill.level === 'user' && !skill.isBuiltin).length,
- project: skills.filter((skill) => skill.level === 'project' && !skill.isBuiltin).length,
- suite: skills.filter((skill) => skill.isBuiltin).length,
- }), [skills]);
+ const { counts, sourceGroups } = useMemo(() => {
+ const counts: Record = {
+ all: skills.length, builtin: 0, user: 0, project: 0, suite: 0,
+ };
+ const sources = new Map<`source:${string}`, string>();
+ for (const skill of skills) {
+ const group = installedSkillGroup(skill);
+ counts[group] = (counts[group] ?? 0) + 1;
+ if (group.startsWith('source:')) {
+ sources.set(group as `source:${string}`, getSkillSourceLabel(skill, t('list.item.unknownSource')));
+ }
+ }
+ counts.suite = counts.builtin;
+ return {
+ counts,
+ sourceGroups: [...sources].sort(([left], [right]) => left.localeCompare(right))
+ .map(([id, label]) => ({ id, label })),
+ };
+ }, [skills, t]);
return {
skills,
@@ -347,6 +361,7 @@ export function useInstalledSkills({
savingGlobalSkillKey,
filteredSkills,
counts,
+ sourceGroups,
loading,
error,
loadSkills,
diff --git a/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts b/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts
index 061835de6b..cd656535f2 100644
--- a/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts
+++ b/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts
@@ -1,6 +1,6 @@
import { create } from 'zustand';
-export type InstalledFilter = 'all' | 'builtin' | 'user' | 'project' | 'suite';
+export type InstalledFilter = 'all' | 'builtin' | 'user' | 'project' | 'suite' | `source:${string}`;
export type SuiteModeId = 'agentic' | 'Cowork' | 'Claw';
interface SkillsSceneState {
diff --git a/src/web-ui/src/flow_chat/components/ChatContextPicker.scss b/src/web-ui/src/flow_chat/components/ChatContextPicker.scss
index 7ebe801b5f..12cc65f345 100644
--- a/src/web-ui/src/flow_chat/components/ChatContextPicker.scss
+++ b/src/web-ui/src/flow_chat/components/ChatContextPicker.scss
@@ -117,12 +117,24 @@
padding: var(--openbitfun-overlay-menu-surface-padding);
}
+ &--skills {
+ width: max-content;
+ min-width: min(300px, calc(100vw - 16px));
+ max-width: calc(100vw - 16px);
+ }
+
&__skill-option > [data-openbitfun-part='content'] {
- flex: 0 1 auto;
+ flex: 0 0 auto;
+ }
+
+ &__skill-name {
+ display: block;
+ white-space: nowrap;
}
&__skill-option > [data-openbitfun-part='metadata'] {
flex: 1 1 auto;
+ inline-size: 8rem;
min-inline-size: 0;
max-inline-size: 60%;
}
diff --git a/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx b/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx
index 7bfd781d91..4b615488e3 100644
--- a/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx
+++ b/src/web-ui/src/flow_chat/components/ChatContextPicker.tsx
@@ -735,7 +735,7 @@ export const ChatContextPicker: React.FC = ({
].filter(Boolean).join(' ') || undefined}
data-openbitfun-placement={isOverlay ? overlayLayout?.placement ?? 'top' : undefined}
ref={containerRef}
- className={`chat-context-picker${isOverlay ? ' chat-context-picker--overlay' : ''}`}
+ className={`chat-context-picker${isOverlay ? ' chat-context-picker--overlay' : ''}${displayItems.some(item => item.kind === 'skill') ? ' chat-context-picker--skills' : ''}`}
style={style}
onMouseDown={event => event.preventDefault()}
>
@@ -875,7 +875,7 @@ export const ChatContextPicker: React.FC = ({
onMouseEnter={() => setSelectedIndex(index)}
value={key}
>
- {label}
+ {skill ? {label} : label}
);
})}
diff --git a/src/web-ui/src/flow_chat/components/ChatContextPickerOverlay.test.tsx b/src/web-ui/src/flow_chat/components/ChatContextPickerOverlay.test.tsx
index 70cac68063..142c83c250 100644
--- a/src/web-ui/src/flow_chat/components/ChatContextPickerOverlay.test.tsx
+++ b/src/web-ui/src/flow_chat/components/ChatContextPickerOverlay.test.tsx
@@ -251,7 +251,7 @@ describe('ChatContextPicker overlay', () => {
it('enters the Skill source and returns the selected Skill', async () => {
const skill = {
key: 'pdf-skill',
- name: 'pdf',
+ name: 'pdf-document-extraction-and-accessibility-review',
description: 'Work with PDFs',
argumentHint: '',
};
@@ -283,7 +283,11 @@ describe('ChatContextPicker overlay', () => {
'[data-openbitfun-context-kind="skill"]',
);
expect(skillOptions[0]?.querySelector('[data-openbitfun-part="label"]')?.textContent)
- .toBe('pdf');
+ .toBe(skill.name);
+ expect(skillOptions[0]?.querySelector('[data-openbitfun-part="label"]')
+ ?.getAttribute('data-overflow-behavior')).toBe('fade');
+ expect(skillOptions[0]?.querySelector('[data-openbitfun-part="label"] [data-overflow-content]'))
+ .toBeNull();
expect(skillOptions[0]?.querySelector('[data-openbitfun-part="metadata"]')?.textContent)
.toBe('Work with PDFs');
expect(skillOptions[0]?.querySelector('[data-overflow-behavior="marquee"][data-marquee-active="true"]')
diff --git a/src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts b/src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts
index f1db775c36..e8906de110 100644
--- a/src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts
+++ b/src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts
@@ -7,6 +7,7 @@ import {
formatSkillOrigin,
getModeSkillRuntimeStatus,
getSkillSourceLabel,
+ getSkillSourceId,
getSkillSourceLabelFromIdentity,
} from './skillSourcePresentation';
@@ -39,6 +40,16 @@ function modeSkill(overrides: Partial = {}): ModeSkillInfo {
}
describe('skill source presentation', () => {
+ it('normalizes legacy discovery slots without using paths or display labels as group identity', () => {
+ expect(getSkillSourceId(skill({ sourceId: '', sourceSlot: 'home.codex' }))).toBe('codex');
+ expect(getSkillSourceId(skill({ sourceId: 'claude' }))).toBe('claude-code');
+ expect(getSkillSourceId(skill({ sourceId: '', sourceSlot: 'home.agents' }))).toBe('agent-skills');
+ expect(getSkillSourceId(skill({ sourceId: '', sourceSlot: 'config.opencode.custom-root' }))).toBe('opencode');
+ expect(getSkillSourceId(skill({ sourceId: '', sourceSlot: 'openbitfun-system' }))).toBe('openbitfun');
+ expect(getSkillSourceId(skill({ sourceId: '', sourceSlot: '' }))).toBe('openbitfun');
+ expect(getSkillSourceId(skill({ sourceId: 'future-agent', sourceLabel: 'Codex' }))).toBe('future-agent');
+ });
+
it('uses the stable source label and falls back to source identity facts', () => {
expect(getSkillSourceLabel(skill())).toBe('OpenBitFun');
expect(getSkillSourceLabel(skill({ sourceLabel: '', sourceId: 'codex' }))).toBe('Codex');
diff --git a/src/web-ui/src/infrastructure/config/skillSourcePresentation.ts b/src/web-ui/src/infrastructure/config/skillSourcePresentation.ts
index 4b0605658b..bffff6a8d4 100644
--- a/src/web-ui/src/infrastructure/config/skillSourcePresentation.ts
+++ b/src/web-ui/src/infrastructure/config/skillSourcePresentation.ts
@@ -46,6 +46,18 @@ export function getSkillSourceLabel(
);
}
+/** Stable ecosystem identity shared by user and project discovery slots. */
+export function getSkillSourceId(skill: SkillInfo): string {
+ const identity = (skill.sourceId?.trim() || skill.sourceSlot?.trim() || 'openbitfun')
+ .toLowerCase()
+ .replace(/^(home|config)\./, '');
+ if (identity === 'claude') return 'claude-code';
+ if (identity === 'agents') return 'agent-skills';
+ if (identity === 'openbitfun-system' || identity === 'openbitfun-user') return 'openbitfun';
+ if (identity.startsWith('opencode.')) return 'opencode';
+ return identity;
+}
+
export function canDeleteSkill(skill: SkillInfo): boolean {
if (skill.isBuiltin) return false;
diff --git a/src/web-ui/src/locales/en-US/scenes/skills.json b/src/web-ui/src/locales/en-US/scenes/skills.json
index f74f5c3212..a22a2c4b79 100644
--- a/src/web-ui/src/locales/en-US/scenes/skills.json
+++ b/src/web-ui/src/locales/en-US/scenes/skills.json
@@ -32,6 +32,7 @@
"subtitleAll": "Manage all installed user-level and project-level skills",
"titleBuiltin": "Built-in Skills",
"titleUser": "User Skills",
+ "titleSource": "{{source}} Skills",
"subtitleUser": "Globally installed user-level skills",
"titleProject": "Project Skills",
"subtitleProject": "Project-level skills in the current workspace"
@@ -42,6 +43,7 @@
"hideDuplicates": "Hide duplicates"
},
"filters": {
+ "source": "{{source}}",
"all": "All",
"builtin": "Built-in",
"user": "User",
@@ -51,8 +53,9 @@
"categories": {
"all": "All installed skills, including built-in, user-level, and project-level.",
"builtin": "Core skills shipped with the app. They cannot be deleted.",
- "user": "User-level skills installed globally for your account.",
- "project": "Project-level skills for the current workspace.",
+ "user": "OpenBitFun user-level skills installed globally for your account.",
+ "project": "OpenBitFun project-level skills for the current workspace.",
+ "source": "Skills discovered from {{source}}, including user-level and current-workspace project-level skills.",
"suite": "Built-in skill suites grouped by mode, with one-click visibility controls."
},
"section": {
diff --git a/src/web-ui/src/locales/zh-CN/scenes/skills.json b/src/web-ui/src/locales/zh-CN/scenes/skills.json
index 7db115ac63..b0fb337709 100644
--- a/src/web-ui/src/locales/zh-CN/scenes/skills.json
+++ b/src/web-ui/src/locales/zh-CN/scenes/skills.json
@@ -32,6 +32,7 @@
"subtitleAll": "管理所有已安装的用户级和项目级技能",
"titleBuiltin": "内置技能",
"titleUser": "用户级技能",
+ "titleSource": "{{source}} 技能",
"subtitleUser": "全局安装的用户级技能",
"titleProject": "项目级技能",
"subtitleProject": "当前工作区的项目级技能"
@@ -42,6 +43,7 @@
"hideDuplicates": "隐藏重复项"
},
"filters": {
+ "source": "{{source}}",
"all": "全部",
"builtin": "内置",
"user": "用户级",
@@ -51,8 +53,9 @@
"categories": {
"all": "查看所有已安装的技能,包含内置、用户和项目级。",
"builtin": "系统出厂自带的核心技能,不可删除。",
- "user": "全局安装到当前账户的用户级技能。",
- "project": "当前工作区下的项目级技能。",
+ "user": "全局安装到当前账户的 OpenBitFun 用户级技能。",
+ "project": "当前工作区下的 OpenBitFun 项目级技能。",
+ "source": "从 {{source}} 扫描到的技能,包含用户级及当前工作区的项目级技能。",
"suite": "按模式分组的内置技能套件,可一键控制可见性。"
},
"section": {
diff --git a/src/web-ui/src/locales/zh-TW/scenes/skills.json b/src/web-ui/src/locales/zh-TW/scenes/skills.json
index 7a13ccc7e7..8321dc5775 100644
--- a/src/web-ui/src/locales/zh-TW/scenes/skills.json
+++ b/src/web-ui/src/locales/zh-TW/scenes/skills.json
@@ -32,6 +32,7 @@
"subtitleAll": "管理所有已安裝的用戶級和項目級技能",
"titleBuiltin": "內建技能",
"titleUser": "用戶級技能",
+ "titleSource": "{{source}} 技能",
"subtitleUser": "全局安裝的用戶級技能",
"titleProject": "項目級技能",
"subtitleProject": "目前工作區的項目級技能"
@@ -42,6 +43,7 @@
"hideDuplicates": "隱藏重複項"
},
"filters": {
+ "source": "{{source}}",
"all": "全部",
"builtin": "內建",
"user": "用戶級",
@@ -51,8 +53,9 @@
"categories": {
"all": "查看所有已安裝的技能,包含內建、用戶與項目級。",
"builtin": "系統出廠內建的核心技能,不可刪除。",
- "user": "為目前帳戶全域安裝的用戶級技能。",
- "project": "目前工作區底下的項目級技能。",
+ "user": "為目前帳戶全域安裝的 OpenBitFun 用戶級技能。",
+ "project": "目前工作區底下的 OpenBitFun 項目級技能。",
+ "source": "從 {{source}} 掃描到的技能,包含用戶級及目前工作區的項目級技能。",
"suite": "依模式分組的內建技能套件,可一鍵控制可見性。"
},
"section": {