feat(skills): support folder-scoped skills in agent sessions

- Thread workspace path through useAgentSkills so Codex $-autocomplete
  surfaces folder-local skills in addition to global ones; cache keyed
  by agent + workspace and invalidated per key on focus.
- Add Global/Folder scope tabs and a folder picker (sourced from the
  folder table via loadFolderHistory) to the Skills settings page.
  CRUD for skills now operates against the selected scope and folder.
- Default the settings right panel to a placeholder hint; the new-skill
  form only appears after clicking "New Skill" or selecting an existing
  skill. Search input is hidden in folder scope.
- Disable "New Skill" when folder scope has no folder chosen; show a
  pick-folder hint in the path preview for that state.
- Add scope/noSelectionHint/pickFolderHint strings across 10 locales.
This commit is contained in:
xintaofei
2026-04-15 14:56:53 +08:00
parent 3148966371
commit a6f80088d0
13 changed files with 568 additions and 254 deletions

View File

@@ -5,83 +5,108 @@ import { useCallback, useEffect, useMemo, useState } from "react"
import { acpListAgentSkills } from "@/lib/api"
import type { AgentSkillItem, AgentType } from "@/lib/types"
const agentCache = new Map<AgentType, AgentSkillItem[]>()
const inflightMap = new Map<AgentType, Promise<AgentSkillItem[]>>()
// Cache/inflight keyed by `${agentType}|${workspacePath ?? ""}` so different
// folders keep their own skill list, and switching folders never serves stale
// entries from a previous workspace.
const cache = new Map<string, AgentSkillItem[]>()
const inflight = new Map<string, Promise<AgentSkillItem[]>>()
const EMPTY: AgentSkillItem[] = []
function fetchForAgent(agentType: AgentType): Promise<AgentSkillItem[]> {
let promise = inflightMap.get(agentType)
function makeKey(agentType: AgentType, workspacePath: string | null): string {
return `${agentType}|${workspacePath ?? ""}`
}
function fetchSkills(
agentType: AgentType,
workspacePath: string | null
): Promise<AgentSkillItem[]> {
const key = makeKey(agentType, workspacePath)
let promise = inflight.get(key)
if (!promise) {
promise = acpListAgentSkills({ agentType })
promise = acpListAgentSkills({ agentType, workspacePath })
.then((result) => {
const skills = result.supported ? result.skills : EMPTY
agentCache.set(agentType, skills)
inflightMap.delete(agentType)
cache.set(key, skills)
inflight.delete(key)
return skills
})
.catch((err) => {
inflightMap.delete(agentType)
inflight.delete(key)
console.warn("[useAgentSkills] failed:", err)
return EMPTY
})
inflightMap.set(agentType, promise)
inflight.set(key, promise)
}
return promise
}
export function useAgentSkills(agentType: AgentType | null): AgentSkillItem[] {
const cached = useMemo(
() => (agentType ? (agentCache.get(agentType) ?? null) : null),
[agentType]
export function useAgentSkills(
agentType: AgentType | null,
workspacePath?: string | null
): AgentSkillItem[] {
const normalizedPath = workspacePath ?? null
const cacheKey = useMemo(
() => (agentType ? makeKey(agentType, normalizedPath) : null),
[agentType, normalizedPath]
)
// Track which agent type the fetched result belongs to so stale data
// from a previous agent is never returned after a switch.
const cached = useMemo(
() => (cacheKey ? (cache.get(cacheKey) ?? null) : null),
[cacheKey]
)
// Track which (agentType, workspacePath) the fetched result belongs to so
// stale data from a previous key is never returned after a switch.
const [fetched, setFetched] = useState<{
agentType: AgentType
key: string
skills: AgentSkillItem[]
} | null>(null)
const doFetch = useCallback(() => {
if (!agentType || agentCache.has(agentType)) return
if (!agentType || !cacheKey || cache.has(cacheKey)) return
let cancelled = false
fetchForAgent(agentType).then((list) => {
if (!cancelled) setFetched({ agentType, skills: list })
fetchSkills(agentType, normalizedPath).then((list) => {
if (!cancelled) setFetched({ key: cacheKey, skills: list })
})
return () => {
cancelled = true
}
}, [agentType])
}, [agentType, cacheKey, normalizedPath])
// Initial fetch
useEffect(() => doFetch(), [doFetch])
// Re-fetch when window regains focus (covers cross-window cache
// invalidation — e.g. settings window creates/removes skills while the
// conversation window stays mounted).
// conversation window stays mounted). Only invalidate the current key to
// avoid clobbering caches for other folders.
useEffect(() => {
const onFocus = () => {
if (!agentType) return
agentCache.delete(agentType)
inflightMap.delete(agentType)
if (!cacheKey) return
cache.delete(cacheKey)
inflight.delete(cacheKey)
doFetch()
}
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
}, [agentType, doFetch])
}, [cacheKey, doFetch])
if (!agentType) return EMPTY
if (!agentType || !cacheKey) return EMPTY
if (cached) return cached
if (fetched && fetched.agentType === agentType) return fetched.skills
if (fetched && fetched.key === cacheKey) return fetched.skills
return EMPTY
}
export function invalidateAgentSkillsCache(agentType?: AgentType) {
if (agentType) {
agentCache.delete(agentType)
inflightMap.delete(agentType)
const prefix = `${agentType}|`
for (const key of Array.from(cache.keys())) {
if (key.startsWith(prefix)) cache.delete(key)
}
for (const key of Array.from(inflight.keys())) {
if (key.startsWith(prefix)) inflight.delete(key)
}
} else {
agentCache.clear()
inflightMap.clear()
cache.clear()
inflight.clear()
}
}