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
+109 -7
View File
@@ -61,14 +61,104 @@ const getProjectAgentPath = (workingDirectory: string, agentName: string): strin
return pluralPath;
};
const getUserAgentPath = (agentName: string): string => {
type AgentLookupCache = {
userAgentIndexByName: Map<string, string>;
userAgentLookupByName: Map<string, string | null>;
userAgentIndexReady: boolean;
userAgentIndexBuiltAt: number;
};
const AGENT_LOOKUP_CACHE_TTL_MS = 1000;
const createAgentLookupCache = (): AgentLookupCache => ({
userAgentIndexByName: new Map<string, string>(),
userAgentLookupByName: new Map<string, string | null>(),
userAgentIndexReady: false,
userAgentIndexBuiltAt: 0,
});
const globalAgentLookupCache = createAgentLookupCache();
const resetAgentLookupCache = (cache: AgentLookupCache): void => {
cache.userAgentIndexByName.clear();
cache.userAgentLookupByName.clear();
cache.userAgentIndexReady = false;
cache.userAgentIndexBuiltAt = 0;
};
const buildUserAgentIndex = (cache: AgentLookupCache): void => {
if (cache.userAgentIndexReady && Date.now() - cache.userAgentIndexBuiltAt < AGENT_LOOKUP_CACHE_TTL_MS) {
return;
}
cache.userAgentIndexByName.clear();
cache.userAgentLookupByName.clear();
cache.userAgentIndexReady = true;
cache.userAgentIndexBuiltAt = Date.now();
if (!fs.existsSync(AGENT_DIR)) return;
const dirsToVisit: string[] = [AGENT_DIR];
while (dirsToVisit.length > 0) {
const dir = dirsToVisit.pop();
if (!dir) continue;
let entries: fs.Dirent[];
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 discoveredAgentName = entry.name.slice(0, -3);
if (!cache.userAgentIndexByName.has(discoveredAgentName)) {
cache.userAgentIndexByName.set(discoveredAgentName, 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));
}
}
}
};
const getIndexedUserAgentPath = (agentName: string, cache: AgentLookupCache): string | null => {
if (cache.userAgentLookupByName.has(agentName)) {
return cache.userAgentLookupByName.get(agentName) || null;
}
buildUserAgentIndex(cache);
const found = cache.userAgentIndexByName.get(agentName) || null;
cache.userAgentLookupByName.set(agentName, found);
return found;
};
const getUserAgentPath = (agentName: string, lookupCache: AgentLookupCache = globalAgentLookupCache): string => {
const pluralPath = path.join(AGENT_DIR, `${agentName}.md`);
if (fs.existsSync(pluralPath)) return pluralPath;
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'agent', `${agentName}.md`);
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
if (fs.existsSync(legacyPath)) return legacyPath;
const found = getIndexedUserAgentPath(agentName, lookupCache);
if (found) return found;
return pluralPath;
};
export const getAgentScope = (agentName: string, workingDirectory?: string): { scope: AgentScope | null; path: string | null } => {
export const getAgentScope = (
agentName: string,
workingDirectory?: string,
lookupCache: AgentLookupCache = globalAgentLookupCache
): { scope: AgentScope | null; path: string | null } => {
if (workingDirectory) {
const projectPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectPath)) {
@@ -76,7 +166,7 @@ export const getAgentScope = (agentName: string, workingDirectory?: string): { s
}
}
const userPath = getUserAgentPath(agentName);
const userPath = getUserAgentPath(agentName, lookupCache);
if (fs.existsSync(userPath)) {
return { scope: AGENT_SCOPE.USER, path: userPath };
}
@@ -84,8 +174,13 @@ export const getAgentScope = (agentName: string, workingDirectory?: string): { s
return { scope: null, path: null };
};
const getAgentWritePath = (agentName: string, workingDirectory?: string, requestedScope?: AgentScope): { scope: AgentScope; path: string } => {
const existing = getAgentScope(agentName, workingDirectory);
const getAgentWritePath = (
agentName: string,
workingDirectory?: string,
requestedScope?: AgentScope,
lookupCache: AgentLookupCache = globalAgentLookupCache
): { scope: AgentScope; path: string } => {
const existing = getAgentScope(agentName, workingDirectory, lookupCache);
if (existing.path) {
return { scope: existing.scope!, path: existing.path };
}
@@ -100,7 +195,7 @@ const getAgentWritePath = (agentName: string, workingDirectory?: string, request
return {
scope: AGENT_SCOPE.USER,
path: getUserAgentPath(agentName)
path: getUserAgentPath(agentName, lookupCache)
};
};
@@ -703,6 +798,7 @@ export const createAgent = (agentName: string, config: Record<string, unknown>,
const { prompt, scope: _ignored, ...frontmatter } = config as Record<string, unknown> & { prompt?: unknown; scope?: unknown };
void _ignored; // Scope is only used for path determination
writeMdFile(targetPath, frontmatter, typeof prompt === 'string' ? prompt : '');
resetAgentLookupCache(globalAgentLookupCache);
};
export const updateAgent = (agentName: string, updates: Record<string, unknown>, workingDirectory?: string) => {
@@ -807,6 +903,10 @@ export const updateAgent = (agentName: string, updates: Record<string, unknown>,
if (jsonModified) {
writeConfig(config, jsonTarget.path || CONFIG_FILE);
}
if (mdModified || isBuiltinOverride) {
resetAgentLookupCache(globalAgentLookupCache);
}
};
export const deleteAgent = (agentName: string, workingDirectory?: string) => {
@@ -849,6 +949,8 @@ export const deleteAgent = (agentName: string, workingDirectory?: string) => {
targetConfig.agent = agentMap;
writeConfig(targetConfig, jsonTarget.path || CONFIG_FILE);
}
resetAgentLookupCache(globalAgentLookupCache);
};
export const getCommandSources = (commandName: string, workingDirectory?: string): ConfigSources => {