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": {