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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
3cd6d051cb
commit
b2101acfbf
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user