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
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
@@ -25,6 +25,7 @@ import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import type { Agent } from '@opencode-ai/sdk/v2';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
interface AgentsSidebarProps {
onItemSelect?: () => void;
@@ -307,6 +308,25 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
const builtInAgents = visibleAgents.filter(isAgentBuiltIn);
const customAgents = visibleAgents.filter((agent) => !isAgentBuiltIn(agent));
// Group custom agents by subfolder
const { groupedCustomAgents, ungroupedCustomAgents } = useMemo(() => {
const groups: Record<string, typeof customAgents> = {};
const ungrouped: typeof customAgents = [];
for (const agent of customAgents) {
const ext = agent as { group?: string };
if (ext.group) {
if (!groups[ext.group]) groups[ext.group] = [];
groups[ext.group].push(agent);
} else {
ungrouped.push(agent);
}
}
const sortedGroups = Object.keys(groups)
.sort((a, b) => a.localeCompare(b))
.map((name) => ({ name, agents: groups[name] }));
return { groupedCustomAgents: sortedGroups, ungroupedCustomAgents: ungrouped };
}, [customAgents]);
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
@@ -363,7 +383,38 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Custom Agents
</div>
{customAgents.map((agent) => (
{/* Grouped agents by subfolder */}
{groupedCustomAgents.map(({ name: groupName, agents: groupAgents }) => (
<SidebarGroup
key={groupName}
label={groupName}
count={groupAgents.length}
storageKey="agents"
>
{groupAgents.map((agent) => (
<AgentListItem
key={agent.name}
agent={agent}
isSelected={selectedAgentName === agent.name}
onSelect={() => {
setSelectedAgent(agent.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(agent)}
onDelete={() => handleDeleteAgent(agent)}
onDuplicate={() => handleDuplicateAgent(agent)}
getAgentModeIcon={getAgentModeIcon}
/>
))}
</SidebarGroup>
))}
{/* Ungrouped agents (flat in root agents dir) */}
{ungroupedCustomAgents.map((agent) => (
<AgentListItem
key={agent.name}
agent={agent}
@@ -0,0 +1,81 @@
import React, { useState, useEffect } from 'react';
import { RiArrowDownSLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
interface SidebarGroupProps {
/** Group display label (e.g. "business", "automation-ai") */
label: string;
/** Number of items in this group */
count: number;
/** Unique storage key prefix for persisting collapse state */
storageKey: string;
/** Whether to start expanded. Defaults to true. */
defaultExpanded?: boolean;
children: React.ReactNode;
}
function getStorageKey(storageKey: string, label: string): string {
return `opencode:sidebar-group:${storageKey}:${label}`;
}
/**
* Collapsible sidebar group with persisted expand/collapse state.
* Used in Agents and Skills sidebars to group items by subfolder.
*/
export const SidebarGroup: React.FC<SidebarGroupProps> = ({
label,
count,
storageKey,
defaultExpanded = true,
children,
}) => {
const key = getStorageKey(storageKey, label);
const [expanded, setExpanded] = useState<boolean>(() => {
try {
const stored = localStorage.getItem(key);
if (stored !== null) return stored === 'true';
} catch {
// ignore storage errors
}
return defaultExpanded;
});
useEffect(() => {
try {
localStorage.setItem(key, String(expanded));
} catch {
// ignore storage errors
}
}, [key, expanded]);
return (
<div>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className={cn(
'flex w-full items-center gap-1 rounded-md px-2 py-1 text-left',
'text-xs font-semibold uppercase tracking-wide text-muted-foreground',
'hover:bg-[var(--interactive-hover)] transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
)}
>
<RiArrowDownSLine
className={cn(
'h-3.5 w-3.5 flex-shrink-0 transition-transform duration-200',
!expanded && '-rotate-90',
)}
/>
<span className="flex-1 truncate">{label}</span>
<span className="ml-1 tabular-nums opacity-60">{count}</span>
</button>
{expanded && (
<div className="mt-0.5 space-y-0.5 ml-2 pl-3 border-l-2 border-[var(--interactive-border)]">
{children}
</div>
)}
</div>
);
};
@@ -54,3 +54,4 @@ export { SettingsSidebarHeader } from './SettingsSidebarHeader';
export { SettingsSidebarItem, type SettingsSidebarItemAction } from './SettingsSidebarItem';
export { SettingsPageLayout } from './SettingsPageLayout';
export { SettingsSection } from './SettingsSection';
export { SidebarGroup } from './SidebarGroup';
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
@@ -24,6 +24,7 @@ import { useDeviceInfo } from '@/lib/device';
import { isVSCodeRuntime } from '@/lib/desktop';
import { cn } from '@/lib/utils';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SidebarGroup } from '@/components/sections/shared/SidebarGroup';
interface SkillsSidebarProps {
onItemSelect?: () => void;
@@ -190,6 +191,27 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
const projectSkills = skills.filter((s) => s.scope === 'project');
const userSkills = skills.filter((s) => s.scope === 'user');
// Helper: group a list of skills by their domain folder
function groupSkillsByFolder(list: DiscoveredSkill[]) {
const groups: Record<string, DiscoveredSkill[]> = {};
const ungrouped: DiscoveredSkill[] = [];
for (const skill of list) {
if (skill.group) {
if (!groups[skill.group]) groups[skill.group] = [];
groups[skill.group].push(skill);
} else {
ungrouped.push(skill);
}
}
const sortedGroups = Object.keys(groups)
.sort((a, b) => a.localeCompare(b))
.map((name) => ({ name, skills: groups[name] }));
return { sortedGroups, ungrouped };
}
const groupedProjectSkills = useMemo(() => groupSkillsByFolder(projectSkills), [projectSkills]);
const groupedUserSkills = useMemo(() => groupSkillsByFolder(userSkills), [userSkills]);
return (
<div className={cn('flex h-full flex-col', bgClass)}>
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
@@ -221,7 +243,33 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Project Skills
</div>
{projectSkills.map((skill) => (
{groupedProjectSkills.sortedGroups.map(({ name: groupName, skills: groupSkills }) => (
<SidebarGroup
key={groupName}
label={groupName}
count={groupSkills.length}
storageKey="project-skills"
>
{groupSkills.map((skill) => (
<SkillListItem
key={skill.name}
skill={skill}
isSelected={selectedSkillName === skill.name}
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
/>
))}
</SidebarGroup>
))}
{groupedProjectSkills.ungrouped.map((skill) => (
<SkillListItem
key={skill.name}
skill={skill}
@@ -246,7 +294,33 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
<div className="px-2 pb-1.5 pt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
User Skills
</div>
{userSkills.map((skill) => (
{groupedUserSkills.sortedGroups.map(({ name: groupName, skills: groupSkills }) => (
<SidebarGroup
key={groupName}
label={groupName}
count={groupSkills.length}
storageKey="user-skills"
>
{groupSkills.map((skill) => (
<SkillListItem
key={skill.name}
skill={skill}
isSelected={selectedSkillName === skill.name}
onSelect={() => {
setSelectedSkill(skill.name);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
}
}}
onRename={() => handleOpenRenameDialog(skill)}
onDelete={() => handleDeleteSkill(skill)}
onDuplicate={() => handleDuplicateSkill(skill)}
/>
))}
</SidebarGroup>
))}
{groupedUserSkills.ungrouped.map((skill) => (
<SkillListItem
key={skill.name}
skill={skill}
+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 });