feat(settings): group agents and skills sidebar by subfolder (#464)

* feat(settings): group agents and skills by subfolder in sidebar

- Server: fix getUserAgentPath() to walk subfolders so grouped agent
  layouts (e.g. agents/business/ceo.md) are correctly resolved
- Store: add 'group' field to AgentWithExtras and DiscoveredSkill,
  parsed from file path at load time
- UI: add collapsible SidebarGroup component with localStorage-persisted
  expand/collapse state
- AgentsSidebar: render custom agents grouped by subfolder name
- SkillsSidebar: render project/user skills grouped by domain folder
- Ungrouped items (flat root) fall through and render normally

* fix(settings): normalize group paths and add agent lookup caching parity

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Nguyễn Ngô Thượng
2026-02-23 12:28:24 +02:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 3cd6d051cb
commit b2101acfbf
8 changed files with 453 additions and 33 deletions
+24 -2
View File
@@ -81,8 +81,26 @@ export type AgentWithExtras = Agent & {
native?: boolean;
hidden?: boolean;
options?: { hidden?: boolean };
scope?: AgentScope;
/** Subfolder name parsed from file path, e.g. "business", "development" */
group?: string;
};
/** Parse the subfolder group name from an agent file path.
* e.g. "~/.config/opencode/agents/business/ceo.md" → "business"
* e.g. "~/.config/opencode/agents/ceo.md" → undefined
*/
function parseAgentGroup(path: string | null | undefined): string | undefined {
if (!path) return undefined;
const normalizedPath = path.replace(/\\/g, '/');
const idx = normalizedPath.lastIndexOf('/agents/');
if (idx === -1) return undefined;
const relative = normalizedPath.substring(idx + '/agents/'.length);
const parts = relative.split('/');
// parts[0] = group, parts[1] = filename; need at least 2 parts
return parts.length > 1 ? parts[0] : undefined;
}
// Helper to check if agent is built-in (handles both SDK 'builtIn' and API 'native')
export const isAgentBuiltIn = (agent: Agent): boolean => {
const extended = agent as AgentWithExtras & { builtIn?: boolean };
@@ -202,12 +220,16 @@ export const useAgentsStore = create<AgentsStore>()(
?? sources.json?.scope;
}
// Parse subfolder group from file path
const mdPath: string | null | undefined = data.sources?.md?.path;
const group = parseAgentGroup(mdPath);
if (scope === 'project' || scope === 'user') {
return { ...agent, scope: scope as AgentScope };
return { ...agent, scope: scope as AgentScope, group };
}
// Explicitly set null scope if not found, to clear stale state
return { ...agent, scope: undefined };
return { ...agent, scope: undefined, group };
}
} catch (err) {
console.warn(`[AgentsStore] Failed to fetch config for agent ${agent.name}:`, err);
+18
View File
@@ -64,6 +64,23 @@ export interface DiscoveredSkill {
scope: SkillScope;
source: SkillSource;
description?: string;
/** Domain folder parsed from file path, e.g. "automation-ai", "lark-ecosystem" */
group?: string;
}
/** Parse the domain group folder from a skill file path.
* e.g. "~/.config/opencode/skills/automation-ai/ai-production/SKILL.md" → "automation-ai"
* e.g. "~/.config/opencode/skills/theme-system/SKILL.md" → undefined (flat)
*/
function parseSkillGroup(path: string): string | undefined {
const normalizedPath = path.replace(/\\/g, '/');
const idx = normalizedPath.lastIndexOf('/skills/');
if (idx === -1) return undefined;
const relative = normalizedPath.substring(idx + '/skills/'.length);
const parts = relative.split('/');
// Grouped layout: <group>/<name>/SKILL.md → parts.length >= 3
// Flat layout: <name>/SKILL.md → parts.length == 2
return parts.length >= 3 ? parts[0] : undefined;
}
// Raw skill response from API before transformation
@@ -185,6 +202,7 @@ export const useSkillsStore = create<SkillsStore>()(
scope: s.scope ?? 'user',
source: s.source ?? 'opencode',
description: s.sources?.md?.description || '',
group: parseSkillGroup(s.path),
}));
set({ skills, isLoading: false });