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
+90 -19
View File
@@ -45,12 +45,80 @@ function getProjectAgentPath(workingDirectory, agentName) {
}
/**
* Get user-level agent path
* Create a per-request lookup cache for user-level agent path resolution.
*/
function getUserAgentPath(agentName) {
function createAgentLookupCache() {
return {
userAgentIndexByName: new Map(),
userAgentLookupByName: new Map(),
userAgentIndexReady: false,
};
}
function buildUserAgentIndex(cache) {
if (cache.userAgentIndexReady) return;
cache.userAgentIndexReady = true;
if (!fs.existsSync(AGENT_DIR)) return;
const dirsToVisit = [AGENT_DIR];
while (dirsToVisit.length > 0) {
const dir = dirsToVisit.pop();
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const agentName = entry.name.slice(0, -3);
if (!cache.userAgentIndexByName.has(agentName)) {
cache.userAgentIndexByName.set(agentName, path.join(dir, entry.name));
}
}
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (entry.isDirectory()) {
dirsToVisit.push(path.join(dir, entry.name));
}
}
}
}
function getIndexedUserAgentPath(agentName, cache) {
if (cache.userAgentLookupByName.has(agentName)) {
return cache.userAgentLookupByName.get(agentName);
}
buildUserAgentIndex(cache);
const found = cache.userAgentIndexByName.get(agentName) || null;
cache.userAgentLookupByName.set(agentName, found);
return found;
}
/**
* Get user-level agent path — walks subfolders to support grouped layouts.
* e.g. ~/.config/opencode/agents/business/ceo-diginno.md
*/
function getUserAgentPath(agentName, lookupCache = null) {
// 1. Check flat path first (legacy / newly created agents)
const pluralPath = path.join(AGENT_DIR, `${agentName}.md`);
if (fs.existsSync(pluralPath)) return pluralPath;
const legacyPath = path.join(AGENT_DIR, '..', 'agent', `${agentName}.md`);
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
if (fs.existsSync(legacyPath)) return legacyPath;
// 2. Lookup subfolders for grouped layout
const cache = lookupCache || createAgentLookupCache();
const found = getIndexedUserAgentPath(agentName, cache);
if (found) return found;
// 3. Return expected flat path as default (for new agent creation)
return pluralPath;
}
@@ -58,7 +126,7 @@ function getUserAgentPath(agentName) {
* Determine agent scope based on where the .md file exists
* Priority: project level > user level > null (built-in only)
*/
function getAgentScope(agentName, workingDirectory) {
function getAgentScope(agentName, workingDirectory, lookupCache = null) {
if (workingDirectory) {
const projectPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectPath)) {
@@ -66,7 +134,7 @@ function getAgentScope(agentName, workingDirectory) {
}
}
const userPath = getUserAgentPath(agentName);
const userPath = getUserAgentPath(agentName, lookupCache);
if (fs.existsSync(userPath)) {
return { scope: AGENT_SCOPE.USER, path: userPath };
}
@@ -77,9 +145,9 @@ function getAgentScope(agentName, workingDirectory) {
/**
* Get the path where an agent should be written based on scope
*/
function getAgentWritePath(agentName, workingDirectory, requestedScope) {
function getAgentWritePath(agentName, workingDirectory, requestedScope, lookupCache = null) {
// For updates: check existing location first (project takes precedence)
const existing = getAgentScope(agentName, workingDirectory);
const existing = getAgentScope(agentName, workingDirectory, lookupCache);
if (existing.path) {
return existing;
}
@@ -95,7 +163,7 @@ function getAgentWritePath(agentName, workingDirectory, requestedScope) {
return {
scope: AGENT_SCOPE.USER,
path: getUserAgentPath(agentName)
path: getUserAgentPath(agentName, lookupCache)
};
}
@@ -104,7 +172,7 @@ function getAgentWritePath(agentName, workingDirectory, requestedScope) {
* Priority: project .md > user .md > project JSON > user JSON
* Returns: { source: 'md'|'json'|null, scope: 'project'|'user'|null, path: string|null }
*/
function getAgentPermissionSource(agentName, workingDirectory) {
function getAgentPermissionSource(agentName, workingDirectory, lookupCache = null) {
// Check project-level .md first
if (workingDirectory) {
const projectMdPath = getProjectAgentPath(workingDirectory, agentName);
@@ -117,7 +185,7 @@ function getAgentPermissionSource(agentName, workingDirectory) {
}
// Check user-level .md
const userMdPath = getUserAgentPath(agentName);
const userMdPath = getUserAgentPath(agentName, lookupCache);
if (fs.existsSync(userMdPath)) {
const { frontmatter } = parseMdFile(userMdPath);
if (frontmatter.permission !== undefined) {
@@ -215,11 +283,11 @@ function mergePermissionWithNonWildcards(newPermission, permissionSource, agentN
return merged;
}
function getAgentSources(agentName, workingDirectory) {
function getAgentSources(agentName, workingDirectory, lookupCache = createAgentLookupCache()) {
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const projectExists = projectPath && fs.existsSync(projectPath);
const userPath = getUserAgentPath(agentName);
const userPath = getUserAgentPath(agentName, lookupCache);
const userExists = fs.existsSync(userPath);
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
@@ -270,11 +338,11 @@ function getAgentSources(agentName, workingDirectory) {
return sources;
}
function getAgentConfig(agentName, workingDirectory) {
function getAgentConfig(agentName, workingDirectory, lookupCache = createAgentLookupCache()) {
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const projectExists = projectPath && fs.existsSync(projectPath);
const userPath = getUserAgentPath(agentName);
const userPath = getUserAgentPath(agentName, lookupCache);
const userExists = fs.existsSync(userPath);
if (projectExists || userExists) {
@@ -312,9 +380,10 @@ function getAgentConfig(agentName, workingDirectory) {
function createAgent(agentName, config, workingDirectory, scope) {
ensureDirs();
const lookupCache = createAgentLookupCache();
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const userPath = getUserAgentPath(agentName);
const userPath = getUserAgentPath(agentName, lookupCache);
if (projectPath && fs.existsSync(projectPath)) {
throw new Error(`Agent ${agentName} already exists as project-level .md file`);
@@ -350,8 +419,9 @@ function createAgent(agentName, config, workingDirectory, scope) {
function updateAgent(agentName, updates, workingDirectory) {
ensureDirs();
const lookupCache = createAgentLookupCache();
const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory);
const { scope, path: mdPath } = getAgentWritePath(agentName, workingDirectory, undefined, lookupCache);
const mdExists = mdPath && fs.existsSync(mdPath);
const layers = readConfigLayers(workingDirectory);
@@ -369,7 +439,7 @@ function updateAgent(agentName, updates, workingDirectory) {
let targetScope = scope;
if (!mdExists && isBuiltinOverride) {
targetPath = getUserAgentPath(agentName);
targetPath = getUserAgentPath(agentName, lookupCache);
targetScope = AGENT_SCOPE.USER;
}
@@ -412,7 +482,7 @@ function updateAgent(agentName, updates, workingDirectory) {
}
if (field === 'permission') {
const permissionSource = getAgentPermissionSource(agentName, workingDirectory);
const permissionSource = getAgentPermissionSource(agentName, workingDirectory, lookupCache);
const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName);
if (permissionSource.source === 'md') {
@@ -510,6 +580,7 @@ function updateAgent(agentName, updates, workingDirectory) {
}
function deleteAgent(agentName, workingDirectory) {
const lookupCache = createAgentLookupCache();
let deleted = false;
if (workingDirectory) {
@@ -521,7 +592,7 @@ function deleteAgent(agentName, workingDirectory) {
}
}
const userPath = getUserAgentPath(agentName);
const userPath = getUserAgentPath(agentName, lookupCache);
if (fs.existsSync(userPath)) {
fs.unlinkSync(userPath);
console.log(`Deleted user-level agent .md file: ${userPath}`);