Skip to content

Commit f4ea89c

Browse files
authored
Merge pull request #2898 from kev1n77/fmy/compatibility
fix(skills): group installed skills by agent and keep mention names static
2 parents ce4e88a + 11c3daa commit f4ea89c

12 files changed

Lines changed: 168 additions & 47 deletions

File tree

‎src/web-ui/src/app/scenes/skills/SkillsScene.tsx‎

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ interface CategoryInfo {
6060
labelKey: string;
6161
titleKey: string;
6262
descKey: string;
63+
sourceLabel?: string;
6364
}
6465

6566
const CATEGORIES: CategoryInfo[] = [
@@ -245,9 +246,28 @@ const SkillsScene: React.FC = () => {
245246
return list;
246247
}, [hideDuplicates, installed.filteredSkills]);
247248

248-
const activeInstalledCategory = CATEGORIES.find((category) => category.id === installedFilter)
249+
const installedCategories: CategoryInfo[] = [
250+
...CATEGORIES.filter((category) => category.id !== 'suite'),
251+
...installed.sourceGroups.map((group) => ({
252+
id: group.id,
253+
icon: <Icon name="extension" size="sm" />,
254+
labelKey: 'filters.source',
255+
titleKey: 'installed.titleSource',
256+
descKey: 'categories.source',
257+
sourceLabel: group.label,
258+
})),
259+
...CATEGORIES.filter((category) => category.id === 'suite'),
260+
];
261+
const activeInstalledCategory = installedCategories.find((category) => category.id === installedFilter)
249262
?? CATEGORIES[0];
250263

264+
useEffect(() => {
265+
if (!installed.loading && !installed.error && installedFilter.startsWith('source:')
266+
&& !installed.sourceGroups.some((group) => group.id === installedFilter)) {
267+
setInstalledFilter('all');
268+
}
269+
}, [installed.loading, installed.error, installed.sourceGroups, installedFilter, setInstalledFilter]);
270+
251271
return (
252272
<div className="openbitfun-skills-scene" data-testid="agent-skill-panel" data-openbitfun-scene="skills" data-openbitfun-part="root" data-openbitfun-tab={activeTab}>
253273
<GalleryPageHeader
@@ -303,8 +323,8 @@ const SkillsScene: React.FC = () => {
303323
<h2 className="skills-sidebar__title" data-openbitfun-scene="skills" data-openbitfun-part="sidebarTitle">{t('installed.titleAll')}</h2>
304324
</div>
305325
<nav className="skills-sidebar__nav" aria-label={t('installed.titleAll')} data-openbitfun-scene="skills" data-openbitfun-part="sidebarNav">
306-
{CATEGORIES.map((cat) => {
307-
const count = installed.counts[cat.id];
326+
{installedCategories.map((cat) => {
327+
const count = installed.counts[cat.id] ?? 0;
308328
const isEmpty = count === 0;
309329
return (
310330
<div
@@ -321,23 +341,23 @@ const SkillsScene: React.FC = () => {
321341
<NavigationPanelItem
322342
selected={installedFilter === cat.id}
323343
onClick={() => setInstalledFilter(cat.id)}
324-
title={t(cat.descKey)}
344+
title={t(cat.descKey, { source: cat.sourceLabel })}
325345
leading={<span data-openbitfun-scene="skills" data-openbitfun-part="sidebarItemIcon">{cat.icon}</span>}
326346
metadata={(
327347
<span className="skills-sidebar__item-count" data-openbitfun-scene="skills" data-openbitfun-part="sidebarItemCount">
328348
{formatNumber(count)}
329349
</span>
330350
)}
331351
>
332-
<span data-openbitfun-scene="skills" data-openbitfun-part="sidebarItemLabel">{t(cat.labelKey)}</span>
352+
<span data-openbitfun-scene="skills" data-openbitfun-part="sidebarItemLabel">{t(cat.labelKey, { source: cat.sourceLabel })}</span>
333353
</NavigationPanelItem>
334354
</div>
335355
);
336356
})}
337357
</nav>
338358
<div className="skills-sidebar__footer" data-openbitfun-scene="skills" data-openbitfun-part="sidebarFooter">
339359
<p className="skills-sidebar__hint" data-openbitfun-scene="skills" data-openbitfun-part="sidebarHint">
340-
{t(CATEGORIES.find((c) => c.id === installedFilter)?.descKey ?? 'categories.all')}
360+
{t(activeInstalledCategory.descKey, { source: activeInstalledCategory.sourceLabel })}
341361
</p>
342362
</div>
343363
</ScrollArea>}
@@ -387,7 +407,7 @@ const SkillsScene: React.FC = () => {
387407
>
388408
<div className="skills-main__list-heading">
389409
<span data-openbitfun-scene="skills" data-openbitfun-part="installedListTitle">
390-
{t(activeInstalledCategory.titleKey)}
410+
{t(activeInstalledCategory.titleKey, { source: activeInstalledCategory.sourceLabel })}
391411
</span>
392412
<span
393413
className="skills-main__list-count"
@@ -499,7 +519,7 @@ const SkillsScene: React.FC = () => {
499519
</div>
500520
<div className="skills-card__info" data-openbitfun-scene="skills" data-openbitfun-part="installedCardInfo">
501521
<span className="skills-card__name" data-testid="skill-list-item-title" data-openbitfun-scene="skills" data-openbitfun-part="installedCardName">
502-
<OverflowText behavior="marquee">{skill.name}</OverflowText>
522+
<OverflowText behavior="marquee" title="">{skill.name}</OverflowText>
503523
</span>
504524
{skill.description?.trim() && (
505525
<OverflowText lines={2} className="skills-card__desc" data-testid="skill-list-item-description" data-openbitfun-scene="skills" data-openbitfun-part="installedCardDescription">{skill.description}</OverflowText>
@@ -517,14 +537,9 @@ const SkillsScene: React.FC = () => {
517537
</StatusPill>
518538
)}
519539
{skill.isShadowed && (
520-
<span title={t('list.item.shadowedTooltip', {
521-
source: coverageSourceBySkillKey.get(skill.key)
522-
?? t('list.item.unknownSource'),
523-
})}>
524-
<StatusPill tone="warning" leading={<Icon glyph={ShieldAlert} />}>
525-
{t('list.item.shadowed')}
526-
</StatusPill>
527-
</span>
540+
<StatusPill tone="warning" leading={<Icon glyph={ShieldAlert} />}>
541+
{t('list.item.shadowed')}
542+
</StatusPill>
528543
)}
529544
</div>
530545
</div>
@@ -554,7 +569,7 @@ const SkillsScene: React.FC = () => {
554569
{skill.level === 'user'
555570
? <Icon name="user" size="xs" />
556571
: <Icon glyph={FolderOpen} size="xs" />}
557-
<OverflowText>
572+
<OverflowText title="">
558573
{market.isRemoteWorkspace
559574
? skill.level === 'user'
560575
? t('list.item.localUser')

‎src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.test.tsx‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client';
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
66
import type { SkillInfo } from '@/infrastructure/config/types';
77
import { useInstalledSkills } from './useInstalledSkills';
8+
import type { InstalledFilter } from '../skillsSceneStore';
89

910
const getSkillConfigsMock = vi.hoisted(() => vi.fn());
1011
const getGlobalSkillSettingsMock = vi.hoisted(() => vi.fn());
@@ -44,10 +45,14 @@ vi.mock('@/shared/notification-system', () => ({
4445

4546
let currentInstalled: ReturnType<typeof useInstalledSkills> | null = null;
4647

47-
function Harness({ enabled }: { enabled: boolean }) {
48+
function Harness({ enabled, activeFilter = 'all', searchQuery = '' }: {
49+
enabled: boolean;
50+
activeFilter?: InstalledFilter;
51+
searchQuery?: string;
52+
}) {
4853
const installed = useInstalledSkills({
49-
searchQuery: '',
50-
activeFilter: 'all',
54+
searchQuery,
55+
activeFilter,
5156
enabled,
5257
});
5358
currentInstalled = installed;
@@ -102,6 +107,44 @@ describe('useInstalledSkills', () => {
102107
expect(getGlobalSkillSettingsMock).toHaveBeenCalledTimes(1);
103108
});
104109

110+
it('groups external agents across scopes and keeps counts independent of search', async () => {
111+
const skill = (key: string, overrides: Partial<SkillInfo> = {}): SkillInfo => ({
112+
key, name: 'shared-name', description: '', path: `/skills/${key}`,
113+
level: 'user', sourceSlot: 'openbitfun', sourceId: 'openbitfun',
114+
dirName: 'shared-name', isBuiltin: false, ...overrides,
115+
});
116+
const skills = [
117+
skill('owned-user'),
118+
skill('owned-project', { level: 'project' }),
119+
skill('builtin', { isBuiltin: true }),
120+
skill('codex-user', { sourceId: 'codex', sourceSlot: 'home.codex' }),
121+
skill('codex-project', { sourceId: '', sourceSlot: 'codex', level: 'project', description: 'remote workspace' }),
122+
skill('claude', { sourceId: 'claude-code', sourceSlot: 'home.claude', isShadowed: true }),
123+
skill('agents', { sourceId: 'agent-skills', sourceSlot: 'home.agents' }),
124+
];
125+
getSkillConfigsMock.mockResolvedValue(skills);
126+
await act(async () => root.render(<Harness enabled activeFilter="source:codex" />));
127+
expect(currentInstalled?.filteredSkills.map((item) => item.key)).toEqual(['codex-user', 'codex-project']);
128+
expect(currentInstalled?.sourceGroups).toEqual([
129+
{ id: 'source:agent-skills', label: 'Agent Skills' },
130+
{ id: 'source:claude-code', label: 'Claude Code' },
131+
{ id: 'source:codex', label: 'Codex' },
132+
]);
133+
expect(currentInstalled?.counts).toEqual({
134+
all: 7, builtin: 1, suite: 1, user: 1, project: 1,
135+
'source:codex': 2, 'source:claude-code': 1, 'source:agent-skills': 1,
136+
});
137+
await act(async () => root.render(<Harness enabled activeFilter="source:codex" searchQuery="remote" />));
138+
expect(currentInstalled?.filteredSkills.map((item) => item.key)).toEqual(['codex-project']);
139+
expect(currentInstalled?.counts['source:codex']).toBe(2);
140+
await act(async () => root.render(<Harness enabled activeFilter="user" />));
141+
expect(currentInstalled?.filteredSkills.map((item) => item.key)).toEqual(['owned-user']);
142+
await act(async () => root.render(<Harness enabled activeFilter="project" />));
143+
expect(currentInstalled?.filteredSkills.map((item) => item.key)).toEqual(['owned-project']);
144+
await act(async () => root.render(<Harness enabled activeFilter="all" />));
145+
expect(currentInstalled?.filteredSkills).toEqual(skills);
146+
});
147+
105148
it('ignores a desktop skill load that finishes after switching away', async () => {
106149
let resolveLoad: ((skills: SkillInfo[]) => void) | undefined;
107150
getSkillConfigsMock.mockReturnValueOnce(new Promise<SkillInfo[]>((resolve) => {

‎src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts‎

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@ import { open } from '@tauri-apps/plugin-dialog';
33
import { useTranslation } from 'react-i18next';
44
import { configAPI } from '@/infrastructure/api';
55
import type { SkillInfo, SkillLevel, SkillValidationResult } from '@/infrastructure/config/types';
6-
import { canDeleteSkill } from '@/infrastructure/config/skillSourcePresentation';
6+
import { canDeleteSkill, getSkillSourceId, getSkillSourceLabel } from '@/infrastructure/config/skillSourcePresentation';
77
import { useWorkspaceManagerSync } from '@/infrastructure/hooks/useWorkspaceManagerSync';
88
import { useNotification } from '@/shared/notification-system';
99
import { createLogger } from '@/shared/utils/logger';
1010
import type { InstalledFilter } from '../skillsSceneStore';
1111

1212
const log = createLogger('SkillsScene:useInstalledSkills');
1313

14+
function installedSkillGroup(skill: SkillInfo): InstalledFilter {
15+
if (skill.isBuiltin) return 'builtin';
16+
const sourceId = getSkillSourceId(skill);
17+
return sourceId === 'openbitfun' ? skill.level : `source:${sourceId}`;
18+
}
19+
1420
interface UseInstalledSkillsOptions {
1521
searchQuery: string;
1622
activeFilter: InstalledFilter;
@@ -314,14 +320,10 @@ export function useInstalledSkills({
314320
const filteredSkills = useMemo(() => {
315321
return skills.filter((skill) => {
316322
let matchesFilter = true;
317-
if (activeFilter === 'user') {
318-
matchesFilter = skill.level === 'user' && !skill.isBuiltin;
319-
} else if (activeFilter === 'project') {
320-
matchesFilter = skill.level === 'project' && !skill.isBuiltin;
321-
} else if (activeFilter === 'builtin') {
322-
matchesFilter = skill.isBuiltin;
323-
} else if (activeFilter === 'suite') {
323+
if (activeFilter === 'suite') {
324324
matchesFilter = skill.isBuiltin;
325+
} else if (activeFilter !== 'all') {
326+
matchesFilter = installedSkillGroup(skill) === activeFilter;
325327
}
326328

327329
const matchesQuery = !normalizedQuery || [
@@ -333,20 +335,33 @@ export function useInstalledSkills({
333335
});
334336
}, [activeFilter, normalizedQuery, skills]);
335337

336-
const counts = useMemo(() => ({
337-
all: skills.length,
338-
builtin: skills.filter((skill) => skill.isBuiltin).length,
339-
user: skills.filter((skill) => skill.level === 'user' && !skill.isBuiltin).length,
340-
project: skills.filter((skill) => skill.level === 'project' && !skill.isBuiltin).length,
341-
suite: skills.filter((skill) => skill.isBuiltin).length,
342-
}), [skills]);
338+
const { counts, sourceGroups } = useMemo(() => {
339+
const counts: Record<InstalledFilter, number> = {
340+
all: skills.length, builtin: 0, user: 0, project: 0, suite: 0,
341+
};
342+
const sources = new Map<`source:${string}`, string>();
343+
for (const skill of skills) {
344+
const group = installedSkillGroup(skill);
345+
counts[group] = (counts[group] ?? 0) + 1;
346+
if (group.startsWith('source:')) {
347+
sources.set(group as `source:${string}`, getSkillSourceLabel(skill, t('list.item.unknownSource')));
348+
}
349+
}
350+
counts.suite = counts.builtin;
351+
return {
352+
counts,
353+
sourceGroups: [...sources].sort(([left], [right]) => left.localeCompare(right))
354+
.map(([id, label]) => ({ id, label })),
355+
};
356+
}, [skills, t]);
343357

344358
return {
345359
skills,
346360
globallyDisabledSkillKeys,
347361
savingGlobalSkillKey,
348362
filteredSkills,
349363
counts,
364+
sourceGroups,
350365
loading,
351366
error,
352367
loadSkills,

‎src/web-ui/src/app/scenes/skills/skillsSceneStore.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { create } from 'zustand';
22

3-
export type InstalledFilter = 'all' | 'builtin' | 'user' | 'project' | 'suite';
3+
export type InstalledFilter = 'all' | 'builtin' | 'user' | 'project' | 'suite' | `source:${string}`;
44
export type SuiteModeId = 'agentic' | 'Cowork' | 'Claw';
55

66
interface SkillsSceneState {

‎src/web-ui/src/flow_chat/components/ChatContextPicker.scss‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,24 @@
117117
padding: var(--openbitfun-overlay-menu-surface-padding);
118118
}
119119

120+
&--skills {
121+
width: max-content;
122+
min-width: min(300px, calc(100vw - 16px));
123+
max-width: calc(100vw - 16px);
124+
}
125+
120126
&__skill-option > [data-openbitfun-part='content'] {
121-
flex: 0 1 auto;
127+
flex: 0 0 auto;
128+
}
129+
130+
&__skill-name {
131+
display: block;
132+
white-space: nowrap;
122133
}
123134

124135
&__skill-option > [data-openbitfun-part='metadata'] {
125136
flex: 1 1 auto;
137+
inline-size: 8rem;
126138
min-inline-size: 0;
127139
max-inline-size: 60%;
128140
}

‎src/web-ui/src/flow_chat/components/ChatContextPicker.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@ export const ChatContextPicker: React.FC<ChatContextPickerProps> = ({
735735
].filter(Boolean).join(' ') || undefined}
736736
data-openbitfun-placement={isOverlay ? overlayLayout?.placement ?? 'top' : undefined}
737737
ref={containerRef}
738-
className={`chat-context-picker${isOverlay ? ' chat-context-picker--overlay' : ''}`}
738+
className={`chat-context-picker${isOverlay ? ' chat-context-picker--overlay' : ''}${displayItems.some(item => item.kind === 'skill') ? ' chat-context-picker--skills' : ''}`}
739739
style={style}
740740
onMouseDown={event => event.preventDefault()}
741741
>
@@ -875,7 +875,7 @@ export const ChatContextPicker: React.FC<ChatContextPickerProps> = ({
875875
onMouseEnter={() => setSelectedIndex(index)}
876876
value={key}
877877
>
878-
{label}
878+
{skill ? <span className="chat-context-picker__skill-name">{label}</span> : label}
879879
</ListboxOption>
880880
);
881881
})}

‎src/web-ui/src/flow_chat/components/ChatContextPickerOverlay.test.tsx‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ describe('ChatContextPicker overlay', () => {
251251
it('enters the Skill source and returns the selected Skill', async () => {
252252
const skill = {
253253
key: 'pdf-skill',
254-
name: 'pdf',
254+
name: 'pdf-document-extraction-and-accessibility-review',
255255
description: 'Work with PDFs',
256256
argumentHint: '<file>',
257257
};
@@ -283,7 +283,11 @@ describe('ChatContextPicker overlay', () => {
283283
'[data-openbitfun-context-kind="skill"]',
284284
);
285285
expect(skillOptions[0]?.querySelector('[data-openbitfun-part="label"]')?.textContent)
286-
.toBe('pdf');
286+
.toBe(skill.name);
287+
expect(skillOptions[0]?.querySelector('[data-openbitfun-part="label"]')
288+
?.getAttribute('data-overflow-behavior')).toBe('fade');
289+
expect(skillOptions[0]?.querySelector('[data-openbitfun-part="label"] [data-overflow-content]'))
290+
.toBeNull();
287291
expect(skillOptions[0]?.querySelector('[data-openbitfun-part="metadata"]')?.textContent)
288292
.toBe('Work with PDFs');
289293
expect(skillOptions[0]?.querySelector('[data-overflow-behavior="marquee"][data-marquee-active="true"]')

‎src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
formatSkillOrigin,
88
getModeSkillRuntimeStatus,
99
getSkillSourceLabel,
10+
getSkillSourceId,
1011
getSkillSourceLabelFromIdentity,
1112
} from './skillSourcePresentation';
1213

@@ -39,6 +40,16 @@ function modeSkill(overrides: Partial<ModeSkillInfo> = {}): ModeSkillInfo {
3940
}
4041

4142
describe('skill source presentation', () => {
43+
it('normalizes legacy discovery slots without using paths or display labels as group identity', () => {
44+
expect(getSkillSourceId(skill({ sourceId: '', sourceSlot: 'home.codex' }))).toBe('codex');
45+
expect(getSkillSourceId(skill({ sourceId: 'claude' }))).toBe('claude-code');
46+
expect(getSkillSourceId(skill({ sourceId: '', sourceSlot: 'home.agents' }))).toBe('agent-skills');
47+
expect(getSkillSourceId(skill({ sourceId: '', sourceSlot: 'config.opencode.custom-root' }))).toBe('opencode');
48+
expect(getSkillSourceId(skill({ sourceId: '', sourceSlot: 'openbitfun-system' }))).toBe('openbitfun');
49+
expect(getSkillSourceId(skill({ sourceId: '', sourceSlot: '' }))).toBe('openbitfun');
50+
expect(getSkillSourceId(skill({ sourceId: 'future-agent', sourceLabel: 'Codex' }))).toBe('future-agent');
51+
});
52+
4253
it('uses the stable source label and falls back to source identity facts', () => {
4354
expect(getSkillSourceLabel(skill())).toBe('OpenBitFun');
4455
expect(getSkillSourceLabel(skill({ sourceLabel: '', sourceId: 'codex' }))).toBe('Codex');

‎src/web-ui/src/infrastructure/config/skillSourcePresentation.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ export function getSkillSourceLabel(
4646
);
4747
}
4848

49+
/** Stable ecosystem identity shared by user and project discovery slots. */
50+
export function getSkillSourceId(skill: SkillInfo): string {
51+
const identity = (skill.sourceId?.trim() || skill.sourceSlot?.trim() || 'openbitfun')
52+
.toLowerCase()
53+
.replace(/^(home|config)\./, '');
54+
if (identity === 'claude') return 'claude-code';
55+
if (identity === 'agents') return 'agent-skills';
56+
if (identity === 'openbitfun-system' || identity === 'openbitfun-user') return 'openbitfun';
57+
if (identity.startsWith('opencode.')) return 'opencode';
58+
return identity;
59+
}
60+
4961
export function canDeleteSkill(skill: SkillInfo): boolean {
5062
if (skill.isBuiltin) return false;
5163

0 commit comments

Comments
 (0)