Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 32 additions & 17 deletions src/web-ui/src/app/scenes/skills/SkillsScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ interface CategoryInfo {
labelKey: string;
titleKey: string;
descKey: string;
sourceLabel?: string;
}

const CATEGORIES: CategoryInfo[] = [
Expand Down Expand Up @@ -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: <Icon name="extension" size="sm" />,
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 (
<div className="openbitfun-skills-scene" data-testid="agent-skill-panel" data-openbitfun-scene="skills" data-openbitfun-part="root" data-openbitfun-tab={activeTab}>
<GalleryPageHeader
Expand Down Expand Up @@ -303,8 +323,8 @@ const SkillsScene: React.FC = () => {
<h2 className="skills-sidebar__title" data-openbitfun-scene="skills" data-openbitfun-part="sidebarTitle">{t('installed.titleAll')}</h2>
</div>
<nav className="skills-sidebar__nav" aria-label={t('installed.titleAll')} data-openbitfun-scene="skills" data-openbitfun-part="sidebarNav">
{CATEGORIES.map((cat) => {
const count = installed.counts[cat.id];
{installedCategories.map((cat) => {
const count = installed.counts[cat.id] ?? 0;
const isEmpty = count === 0;
return (
<div
Expand All @@ -321,23 +341,23 @@ const SkillsScene: React.FC = () => {
<NavigationPanelItem
selected={installedFilter === cat.id}
onClick={() => setInstalledFilter(cat.id)}
title={t(cat.descKey)}
title={t(cat.descKey, { source: cat.sourceLabel })}
leading={<span data-openbitfun-scene="skills" data-openbitfun-part="sidebarItemIcon">{cat.icon}</span>}
metadata={(
<span className="skills-sidebar__item-count" data-openbitfun-scene="skills" data-openbitfun-part="sidebarItemCount">
{formatNumber(count)}
</span>
)}
>
<span data-openbitfun-scene="skills" data-openbitfun-part="sidebarItemLabel">{t(cat.labelKey)}</span>
<span data-openbitfun-scene="skills" data-openbitfun-part="sidebarItemLabel">{t(cat.labelKey, { source: cat.sourceLabel })}</span>
</NavigationPanelItem>
</div>
);
})}
</nav>
<div className="skills-sidebar__footer" data-openbitfun-scene="skills" data-openbitfun-part="sidebarFooter">
<p className="skills-sidebar__hint" data-openbitfun-scene="skills" data-openbitfun-part="sidebarHint">
{t(CATEGORIES.find((c) => c.id === installedFilter)?.descKey ?? 'categories.all')}
{t(activeInstalledCategory.descKey, { source: activeInstalledCategory.sourceLabel })}
</p>
</div>
</ScrollArea>}
Expand Down Expand Up @@ -387,7 +407,7 @@ const SkillsScene: React.FC = () => {
>
<div className="skills-main__list-heading">
<span data-openbitfun-scene="skills" data-openbitfun-part="installedListTitle">
{t(activeInstalledCategory.titleKey)}
{t(activeInstalledCategory.titleKey, { source: activeInstalledCategory.sourceLabel })}
</span>
<span
className="skills-main__list-count"
Expand Down Expand Up @@ -499,7 +519,7 @@ const SkillsScene: React.FC = () => {
</div>
<div className="skills-card__info" data-openbitfun-scene="skills" data-openbitfun-part="installedCardInfo">
<span className="skills-card__name" data-testid="skill-list-item-title" data-openbitfun-scene="skills" data-openbitfun-part="installedCardName">
<OverflowText behavior="marquee">{skill.name}</OverflowText>
<OverflowText behavior="marquee" title="">{skill.name}</OverflowText>
</span>
{skill.description?.trim() && (
<OverflowText lines={2} className="skills-card__desc" data-testid="skill-list-item-description" data-openbitfun-scene="skills" data-openbitfun-part="installedCardDescription">{skill.description}</OverflowText>
Expand All @@ -517,14 +537,9 @@ const SkillsScene: React.FC = () => {
</StatusPill>
)}
{skill.isShadowed && (
<span title={t('list.item.shadowedTooltip', {
source: coverageSourceBySkillKey.get(skill.key)
?? t('list.item.unknownSource'),
})}>
<StatusPill tone="warning" leading={<Icon glyph={ShieldAlert} />}>
{t('list.item.shadowed')}
</StatusPill>
</span>
<StatusPill tone="warning" leading={<Icon glyph={ShieldAlert} />}>
{t('list.item.shadowed')}
</StatusPill>
)}
</div>
</div>
Expand Down Expand Up @@ -554,7 +569,7 @@ const SkillsScene: React.FC = () => {
{skill.level === 'user'
? <Icon name="user" size="xs" />
: <Icon glyph={FolderOpen} size="xs" />}
<OverflowText>
<OverflowText title="">
{market.isRemoteWorkspace
? skill.level === 'user'
? t('list.item.localUser')
Expand Down
49 changes: 46 additions & 3 deletions src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -44,10 +45,14 @@ vi.mock('@/shared/notification-system', () => ({

let currentInstalled: ReturnType<typeof useInstalledSkills> | 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;
Expand Down Expand Up @@ -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> = {}): 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(<Harness enabled activeFilter="source:codex" />));
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(<Harness enabled activeFilter="source:codex" searchQuery="remote" />));
expect(currentInstalled?.filteredSkills.map((item) => item.key)).toEqual(['codex-project']);
expect(currentInstalled?.counts['source:codex']).toBe(2);
await act(async () => root.render(<Harness enabled activeFilter="user" />));
expect(currentInstalled?.filteredSkills.map((item) => item.key)).toEqual(['owned-user']);
await act(async () => root.render(<Harness enabled activeFilter="project" />));
expect(currentInstalled?.filteredSkills.map((item) => item.key)).toEqual(['owned-project']);
await act(async () => root.render(<Harness enabled activeFilter="all" />));
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<SkillInfo[]>((resolve) => {
Expand Down
45 changes: 30 additions & 15 deletions src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@ 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';
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;
Expand Down Expand Up @@ -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 || [
Expand All @@ -333,20 +335,33 @@ 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<InstalledFilter, number> = {
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,
globallyDisabledSkillKeys,
savingGlobalSkillKey,
filteredSkills,
counts,
sourceGroups,
loading,
error,
loadSkills,
Expand Down
2 changes: 1 addition & 1 deletion src/web-ui/src/app/scenes/skills/skillsSceneStore.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
14 changes: 13 additions & 1 deletion src/web-ui/src/flow_chat/components/ChatContextPicker.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
Expand Down
4 changes: 2 additions & 2 deletions src/web-ui/src/flow_chat/components/ChatContextPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,7 @@ export const ChatContextPicker: React.FC<ChatContextPickerProps> = ({
].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()}
>
Expand Down Expand Up @@ -875,7 +875,7 @@ export const ChatContextPicker: React.FC<ChatContextPickerProps> = ({
onMouseEnter={() => setSelectedIndex(index)}
value={key}
>
{label}
{skill ? <span className="chat-context-picker__skill-name">{label}</span> : label}
</ListboxOption>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<file>',
};
Expand Down Expand Up @@ -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"]')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
formatSkillOrigin,
getModeSkillRuntimeStatus,
getSkillSourceLabel,
getSkillSourceId,
getSkillSourceLabelFromIdentity,
} from './skillSourcePresentation';

Expand Down Expand Up @@ -39,6 +40,16 @@ function modeSkill(overrides: Partial<ModeSkillInfo> = {}): 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');
Expand Down
12 changes: 12 additions & 0 deletions src/web-ui/src/infrastructure/config/skillSourcePresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading
Loading