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

@@ -331,7 +331,10 @@ export function MessageInput({
// set through ACP `availableCommands`, so injecting skills there would
// be duplicate/extra UI noise — skip the skills fetch for them entirely.
const skillAgentType = agentType === "codex" ? "codex" : null
const availableSkills = useAgentSkills(skillAgentType)
// Pass the working dir so we see both global skills and folder-scoped
// project skills (e.g. `{folder}/.codex/skills`). Without this, users
// only ever saw global skills in the `$` autocomplete.
const availableSkills = useAgentSkills(skillAgentType, defaultPath ?? null)
// Expert skills are symlinked into the agent's skill directories, so they
// also show up in `acp_list_agent_skills`. Strip them out — experts remain
// reachable via the expert button, and the `$` list is skills-only.

View File

@@ -52,16 +52,20 @@ import {
acpDeleteAgentSkill,
acpListAgents,
acpListAgentSkills,
loadFolderHistory,
openFolderWindow,
acpReadAgentSkill,
acpSaveAgentSkill,
} from "@/lib/api"
import { invalidateAgentSkillsCache } from "@/hooks/use-agent-skills"
import type {
AcpAgentInfo,
AgentSkillItem,
AgentSkillLayout,
AgentSkillLocation,
AgentSkillScope,
AgentType,
FolderHistoryEntry,
} from "@/lib/types"
type SkillsTranslator = (
@@ -224,6 +228,22 @@ export function SkillsSettings() {
const [skillItems, setSkillItems] = useState<AgentSkillItem[]>([])
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(null)
// Scope: "global" = ~/.claude/skills etc; "folder" = {folder}/.claude/skills
// etc. Folder-scope maps to the backend's `project` AgentSkillScope — no new
// scope type, just threading the folder path through as `workspacePath`.
const [skillsScope, setSkillsScope] = useState<"global" | "folder">("global")
// Only folders registered in the DB (opened at least once via codeg).
// loadFolderHistory() is O(folders), while listFolders() aggregates from
// every conversation — slow on large histories.
const [folderList, setFolderList] = useState<FolderHistoryEntry[]>([])
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(
null
)
const workspacePathForRequest =
skillsScope === "folder" ? selectedFolderPath : null
const backendScope: AgentSkillScope =
skillsScope === "folder" ? "project" : "global"
const [skillDraftId, setSkillDraftId] = useState("")
const [skillDraftContent, setSkillDraftContent] = useState("")
const [searchQuery, setSearchQuery] = useState("")
@@ -235,6 +255,12 @@ export function SkillsSettings() {
useState<AgentSkillItem | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isContentEditing, setIsContentEditing] = useState(false)
// True only while the user is authoring a brand-new skill (clicked "New
// Skill"). Opening an existing skill clears this. The right panel renders
// the form iff a skill is selected OR the user is drafting — otherwise it
// shows a placeholder, so users aren't presented with a surprise form on
// first visit.
const [isDrafting, setIsDrafting] = useState(false)
const sortedAgents = useMemo(
() =>
@@ -326,13 +352,16 @@ export function SkillsSettings() {
try {
const detail = await acpReadAgentSkill({
agentType,
scope: "global",
scope: skill.scope,
skillId: skill.id,
workspacePath:
skill.scope === "project" ? workspacePathForRequest : null,
})
setSelectedSkillId(detail.skill.id)
setSkillDraftId(detail.skill.id)
setSkillDraftContent(detail.content)
setIsContentEditing(mode === "edit")
setIsDrafting(false)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.loadFailed"), { description: message })
@@ -340,32 +369,53 @@ export function SkillsSettings() {
setSkillReading(false)
}
},
[t]
[t, workspacePathForRequest]
)
const loadSkills = useCallback(async (agentType: AgentType) => {
setSkillsLoading(true)
setSkillsError(null)
const loadSkills = useCallback(
async (agentType: AgentType) => {
// Folder scope but no folder chosen → skip the fetch; UI prompts the
// user to pick one. We still clear previous results so list doesn't
// show stale items from another folder.
if (skillsScope === "folder" && !workspacePathForRequest) {
setSkillsError(null)
setSkillsSupported(true)
setSkillLocation(null)
setSkillItems([])
return null
}
try {
const result = await acpListAgentSkills({ agentType })
setSkillsSupported(result.supported)
setSkillLocation(
result.locations.find((location) => location.scope === "global") ?? null
)
setSkillItems(result.skills.filter((skill) => skill.scope === "global"))
return result
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setSkillsError(message)
setSkillsSupported(true)
setSkillLocation(null)
setSkillItems([])
return null
} finally {
setSkillsLoading(false)
}
}, [])
setSkillsLoading(true)
setSkillsError(null)
try {
const result = await acpListAgentSkills({
agentType,
workspacePath: workspacePathForRequest,
})
setSkillsSupported(result.supported)
setSkillLocation(
result.locations.find(
(location) => location.scope === backendScope
) ?? null
)
setSkillItems(
result.skills.filter((skill) => skill.scope === backendScope)
)
return result
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setSkillsError(message)
setSkillsSupported(true)
setSkillLocation(null)
setSkillItems([])
return null
} finally {
setSkillsLoading(false)
}
},
[backendScope, skillsScope, workspacePathForRequest]
)
const refreshAgents = useCallback(async () => {
setLoadingAgents(true)
@@ -401,6 +451,7 @@ export function SkillsSettings() {
const handleCreateDraft = useCallback(() => {
if (!selectedAgent) return
setIsDrafting(true)
resetDraft(selectedAgent.agent_type, true)
}, [resetDraft, selectedAgent])
@@ -477,12 +528,16 @@ export function SkillsSettings() {
try {
const saved = await acpSaveAgentSkill({
agentType: selectedAgent.agent_type,
scope: "global",
scope: backendScope,
skillId: trimmedId,
content: skillDraftContent,
workspacePath: workspacePathForRequest,
layout: resolvedLayout,
})
// Drop any stale in-memory skill list so running sessions (message
// input $ autocomplete) pick up the change on next focus/fetch.
invalidateAgentSkillsCache(selectedAgent.agent_type)
await loadSkills(selectedAgent.agent_type)
await openSkill(
selectedAgent.agent_type,
@@ -499,6 +554,7 @@ export function SkillsSettings() {
setSkillSaving(false)
}
}, [
backendScope,
isEditingExisting,
loadSkills,
openSkill,
@@ -509,6 +565,7 @@ export function SkillsSettings() {
skillLocation,
isContentEditing,
t,
workspacePathForRequest,
])
const handleDeleteSkill = useCallback(
@@ -521,20 +578,31 @@ export function SkillsSettings() {
try {
await acpDeleteAgentSkill({
agentType: selectedAgent.agent_type,
scope: "global",
scope: skill.scope,
skillId: skill.id,
workspacePath:
skill.scope === "project" ? workspacePathForRequest : null,
})
invalidateAgentSkillsCache(selectedAgent.agent_type)
const latest = await loadSkills(selectedAgent.agent_type)
toast.success(t("toasts.deleted"))
if (!deletingCurrent) return
const nextSkill = latest?.skills.find((item) => item.scope === "global")
const nextSkill = latest?.skills.find(
(item) => item.scope === backendScope
)
if (nextSkill) {
await openSkill(selectedAgent.agent_type, nextSkill)
} else {
resetDraft(selectedAgent.agent_type, true)
// No remaining skills → fall back to the placeholder view instead
// of shoving users into an empty new-skill form.
setSelectedSkillId(null)
setSkillDraftId("")
setSkillDraftContent("")
setIsContentEditing(false)
setIsDrafting(false)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -545,7 +613,15 @@ export function SkillsSettings() {
setDeleteTargetSkill(null)
}
},
[loadSkills, openSkill, resetDraft, selectedAgent, selectedSkillId, t]
[
backendScope,
loadSkills,
openSkill,
selectedAgent,
selectedSkillId,
t,
workspacePathForRequest,
]
)
const handleConfirmDelete = useCallback(async () => {
@@ -622,24 +698,32 @@ export function SkillsSettings() {
setSkillDraftContent("")
setSearchQuery("")
setIsContentEditing(false)
setIsDrafting(false)
return
}
let cancelled = false
setSearchQuery("")
resetDraft(currentAgentType)
// Clear any prior selection/draft state. We do NOT pre-fill the draft
// template here anymore — the right panel shows a placeholder until the
// user picks a skill from the list or clicks "New Skill".
setSelectedSkillId(null)
setSkillDraftId("")
setSkillDraftContent("")
setIsContentEditing(false)
setIsDrafting(false)
loadSkills(currentAgentType)
.then((result) => {
if (cancelled || !result || !result.supported) return
const firstGlobalSkill = result.skills.find(
(skill) => skill.scope === "global"
const firstSkill = result.skills.find(
(skill) => skill.scope === backendScope
)
if (!firstGlobalSkill) return
if (!firstSkill) return
openSkill(currentAgentType, firstGlobalSkill).catch((err) => {
openSkill(currentAgentType, firstSkill).catch((err) => {
console.error("[SkillsSettings] initial open skill failed:", err)
})
})
@@ -650,7 +734,36 @@ export function SkillsSettings() {
return () => {
cancelled = true
}
}, [loadSkills, openSkill, resetDraft, selectedAgent])
// Re-run when scope or selected folder changes so switching to "Folder"
// (or picking a different folder) reloads the list from the right place.
}, [
loadSkills,
openSkill,
selectedAgent,
backendScope,
workspacePathForRequest,
])
// Lazy-load the folder list when the user first switches to Folder scope.
// Uses loadFolderHistory (direct `folder` table query) instead of
// listFolders, which aggregates from every conversation and can be slow
// on large histories.
useEffect(() => {
if (skillsScope !== "folder") return
if (folderList.length > 0) return
let cancelled = false
loadFolderHistory()
.then((list) => {
if (cancelled) return
setFolderList(list)
})
.catch((err) => {
console.error("[SkillsSettings] loadFolderHistory failed:", err)
})
return () => {
cancelled = true
}
}, [skillsScope, folderList.length])
if (loadingAgents) {
return (
@@ -719,13 +832,91 @@ export function SkillsSettings() {
</SelectContent>
</Select>
<Input
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
}}
placeholder={t("searchPlaceholder")}
/>
{/* Scope selector: global vs per-folder (project-scoped) */}
<div className="inline-flex w-full rounded-md border p-0.5 text-xs bg-muted/20">
{(["global", "folder"] as const).map((scope) => (
<button
key={scope}
type="button"
className={cn(
"flex-1 rounded px-2 py-1 transition-colors",
skillsScope === scope
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => {
if (skillsScope === scope) return
setSkillsScope(scope)
// Drop any in-flight draft/selection since the list
// about to render belongs to a different scope.
setSelectedSkillId(null)
setSkillDraftId("")
setSkillDraftContent("")
setIsContentEditing(false)
setIsDrafting(false)
setSearchQuery("")
}}
>
{t(`scope.${scope}`)}
</button>
))}
</div>
{skillsScope === "folder" && (
<Select
value={selectedFolderPath ?? ""}
onValueChange={(value) => {
setSelectedFolderPath(value || null)
setSelectedSkillId(null)
setSkillDraftId("")
setSkillDraftContent("")
setIsContentEditing(false)
setIsDrafting(false)
}}
>
<SelectTrigger className="w-full justify-between text-left">
<SelectValue
placeholder={t("scope.selectFolderPlaceholder")}
/>
</SelectTrigger>
<SelectContent align="start">
{folderList.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground text-left">
{t("scope.noFolders")}
</div>
) : (
folderList.map((folder) => (
<SelectItem key={folder.path} value={folder.path}>
{/* name + trailing path for disambiguation when
multiple folders share a name. line-clamp-1
on the trigger keeps the selected view on a
single row. */}
<span className="flex items-center gap-2 min-w-0 text-left">
<span className="text-xs font-medium shrink-0">
{folder.name}
</span>
<span className="text-[10px] text-muted-foreground truncate">
{folder.path}
</span>
</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
{/* Search only applies to the global list — folder scope
already narrows the set via the picker above. */}
{skillsScope === "global" && (
<Input
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value)
}}
placeholder={t("searchPlaceholder")}
/>
)}
</div>
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground flex items-center justify-between gap-2">
@@ -757,7 +948,9 @@ export function SkillsSettings() {
skillsSupported &&
filteredSkills.length === 0 && (
<div className="text-xs text-muted-foreground px-1">
{t("emptySkills")}
{skillsScope === "folder" && !selectedFolderPath
? t("scope.pickFolderHint")
: t("emptySkills")}
</div>
)}
@@ -884,7 +1077,10 @@ export function SkillsSettings() {
size="sm"
className="flex-1"
onClick={handleCreateDraft}
disabled={!selectedAgent}
disabled={
!selectedAgent ||
(skillsScope === "folder" && !selectedFolderPath)
}
>
<Plus className="h-3.5 w-3.5" />
{t("actions.newSkill")}
@@ -898,194 +1094,204 @@ export function SkillsSettings() {
<ResizablePanel defaultSize={64} minSize={rightMinSize}>
<div className="h-full flex-1 min-h-0 min-w-0 rounded-lg border bg-card overflow-hidden lg:rounded-l-none lg:border-l-0">
{selectedAgent ? (
<div className="h-full flex flex-col">
<div className="border-b px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold truncate">
{skillDraftId.trim() || t("newSkillTitle")}
</h3>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="xs"
variant="outline"
onClick={handleResetDraft}
disabled={skillSaving || skillReading}
>
<RotateCcw className="h-3 w-3" />
{t("actions.reset")}
</Button>
<Button
size="xs"
onClick={() => {
handleSaveSkill().catch((err) => {
console.error(
"[SkillsSettings] save skill failed:",
err
)
})
}}
disabled={skillSaving || skillReading}
>
{skillSaving ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
{t("actions.saving")}
</>
) : (
<>
<Save className="h-3 w-3" />
{t("actions.save")}
</>
)}
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="rounded-md border p-3 space-y-2.5">
<div className="text-[11px] text-muted-foreground flex items-center gap-1">
<BookOpenText className="h-3.5 w-3.5" />
{t("skillInfo")}
selectedSkillId || isDrafting ? (
<div className="h-full flex flex-col">
<div className="border-b px-4 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold truncate">
{skillDraftId.trim() || t("newSkillTitle")}
</h3>
</div>
<Input
value={skillDraftId}
onChange={(event) => {
setSkillDraftId(event.target.value)
}}
placeholder={t("skillIdPlaceholder")}
/>
{draftPathPreview ? (
<div className="text-[11px] text-muted-foreground break-all">
{t("skillsDirectoryWithPath", {
path: draftPathPreview,
})}
</div>
) : (
<div className="text-[11px] text-muted-foreground break-all">
{t("skillsDirectoryNeedId")}
</div>
)}
</div>
<div className="rounded-md border p-3 space-y-2">
<div className="text-[11px] text-muted-foreground flex items-center justify-between gap-2">
<span>{t("markdownContent")}</span>
<div className="flex items-center gap-1.5">
<span>
{isContentEditing
? t("editingStatus")
: t("previewStatus")}
</span>
<Button
size="xs"
variant={
isContentEditing ? "secondary" : "outline"
}
onClick={() => {
setIsContentEditing((prev) => !prev)
}}
disabled={skillReading}
>
{isContentEditing ? (
<>
<Eye className="h-3 w-3" />
{t("actions.preview")}
</>
) : (
<>
<Pencil className="h-3 w-3" />
{t("actions.edit")}
</>
)}
</Button>
</div>
</div>
{isContentEditing ? (
<Textarea
value={skillDraftContent}
onChange={(event) => {
setSkillDraftContent(event.target.value)
<div className="flex items-center gap-1.5 shrink-0">
<Button
size="xs"
variant="outline"
onClick={handleResetDraft}
disabled={skillSaving || skillReading}
>
<RotateCcw className="h-3 w-3" />
{t("actions.reset")}
</Button>
<Button
size="xs"
onClick={() => {
handleSaveSkill().catch((err) => {
console.error(
"[SkillsSettings] save skill failed:",
err
)
})
}}
placeholder={t("contentPlaceholder")}
className="min-h-80 font-mono text-xs"
/>
) : (
<div className="space-y-2">
{parsedPreviewContent.frontMatterRaw && (
<div className="rounded-md border bg-muted/10 p-3">
<div className="text-[11px] text-muted-foreground mb-2">
{t("metadataTitle")}
</div>
{parsedPreviewContent.fields.length > 0 ? (
<div className="grid gap-1.5">
{parsedPreviewContent.fields.map(
(field) => (
<div
key={field.key}
className="text-xs grid grid-cols-[100px_1fr] gap-2 items-start"
>
<span className="text-muted-foreground font-mono truncate">
{field.key}
</span>
<span className="font-mono break-all">
{field.value}
</span>
</div>
)
)}
</div>
) : (
<pre className="text-xs font-mono whitespace-pre-wrap break-words text-muted-foreground">
{parsedPreviewContent.frontMatterRaw}
</pre>
)}
</div>
disabled={skillSaving || skillReading}
>
{skillSaving ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
{t("actions.saving")}
</>
) : (
<>
<Save className="h-3 w-3" />
{t("actions.save")}
</>
)}
</Button>
</div>
</div>
<div className="min-h-80 rounded-md border bg-muted/10 p-3 overflow-auto">
{parsedPreviewContent.body.trim() ? (
<div
className={cn(
"text-sm leading-6",
"[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:mb-3",
"[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:mt-5 [&_h2]:mb-2",
"[&_h3]:text-base [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2",
"[&_p]:mb-3 [&_li]:mb-1",
"[&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5",
"[&_code]:font-mono [&_code]:text-xs [&_code]:bg-muted [&_code]:rounded [&_code]:px-1",
"[&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:p-3 [&_pre]:overflow-x-auto"
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{parsedPreviewContent.body}
</ReactMarkdown>
</div>
) : parsedPreviewContent.frontMatterRaw ? (
<div className="text-xs text-muted-foreground py-3">
{t("onlyYamlMetadata")}
</div>
) : (
<div className="text-xs text-muted-foreground py-3">
{t("emptyContentHint")}
</div>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="rounded-md border p-3 space-y-2.5">
<div className="text-[11px] text-muted-foreground flex items-center gap-1">
<BookOpenText className="h-3.5 w-3.5" />
{t("skillInfo")}
</div>
<Input
value={skillDraftId}
onChange={(event) => {
setSkillDraftId(event.target.value)
}}
placeholder={t("skillIdPlaceholder")}
/>
{skillsScope === "folder" && !selectedFolderPath ? (
<div className="text-[11px] text-muted-foreground break-all">
{t("scope.pickFolderHint")}
</div>
) : draftPathPreview ? (
<div className="text-[11px] text-muted-foreground break-all">
{t("skillsDirectoryWithPath", {
path: draftPathPreview,
})}
</div>
) : (
<div className="text-[11px] text-muted-foreground break-all">
{t("skillsDirectoryNeedId")}
</div>
)}
</div>
<div className="rounded-md border p-3 space-y-2">
<div className="text-[11px] text-muted-foreground flex items-center justify-between gap-2">
<span>{t("markdownContent")}</span>
<div className="flex items-center gap-1.5">
<span>
{isContentEditing
? t("editingStatus")
: t("previewStatus")}
</span>
<Button
size="xs"
variant={
isContentEditing ? "secondary" : "outline"
}
onClick={() => {
setIsContentEditing((prev) => !prev)
}}
disabled={skillReading}
>
{isContentEditing ? (
<>
<Eye className="h-3 w-3" />
{t("actions.preview")}
</>
) : (
<>
<Pencil className="h-3 w-3" />
{t("actions.edit")}
</>
)}
</Button>
</div>
</div>
)}
{skillReading && (
<div className="text-[11px] text-muted-foreground">
{t("loadingSkill")}
</div>
)}
{isContentEditing ? (
<Textarea
value={skillDraftContent}
onChange={(event) => {
setSkillDraftContent(event.target.value)
}}
placeholder={t("contentPlaceholder")}
className="min-h-80 font-mono text-xs"
/>
) : (
<div className="space-y-2">
{parsedPreviewContent.frontMatterRaw && (
<div className="rounded-md border bg-muted/10 p-3">
<div className="text-[11px] text-muted-foreground mb-2">
{t("metadataTitle")}
</div>
{parsedPreviewContent.fields.length > 0 ? (
<div className="grid gap-1.5">
{parsedPreviewContent.fields.map(
(field) => (
<div
key={field.key}
className="text-xs grid grid-cols-[100px_1fr] gap-2 items-start"
>
<span className="text-muted-foreground font-mono truncate">
{field.key}
</span>
<span className="font-mono break-all">
{field.value}
</span>
</div>
)
)}
</div>
) : (
<pre className="text-xs font-mono whitespace-pre-wrap break-words text-muted-foreground">
{parsedPreviewContent.frontMatterRaw}
</pre>
)}
</div>
)}
<div className="min-h-80 rounded-md border bg-muted/10 p-3 overflow-auto">
{parsedPreviewContent.body.trim() ? (
<div
className={cn(
"text-sm leading-6",
"[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:mb-3",
"[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:mt-5 [&_h2]:mb-2",
"[&_h3]:text-base [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2",
"[&_p]:mb-3 [&_li]:mb-1",
"[&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5",
"[&_code]:font-mono [&_code]:text-xs [&_code]:bg-muted [&_code]:rounded [&_code]:px-1",
"[&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:p-3 [&_pre]:overflow-x-auto"
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{parsedPreviewContent.body}
</ReactMarkdown>
</div>
) : parsedPreviewContent.frontMatterRaw ? (
<div className="text-xs text-muted-foreground py-3">
{t("onlyYamlMetadata")}
</div>
) : (
<div className="text-xs text-muted-foreground py-3">
{t("emptyContentHint")}
</div>
)}
</div>
</div>
)}
{skillReading && (
<div className="text-[11px] text-muted-foreground">
{t("loadingSkill")}
</div>
)}
</div>
</div>
</div>
</div>
) : (
<div className="h-full flex items-center justify-center px-6 text-center text-xs text-muted-foreground">
{t("noSelectionHint")}
</div>
)
) : (
<div className="h-full flex items-center justify-center text-xs text-muted-foreground">
{t("emptyNoAgents")}

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()
}
}

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "لا يوجد محتوى بعد. انقر \"تحرير\" للبدء.",
"loadingSkill": "جارٍ تحميل Skill...",
"emptyNoAgents": "لا يوجد وكيل متاح.",
"noSelectionHint": "اختر Skill من اليسار، أو انقر على \"Skill جديد\" لإنشاء واحد.",
"scope": {
"global": "عام",
"folder": "مجلد",
"selectFolderPlaceholder": "اختر مجلدًا",
"noFolders": "لم يتم العثور على مجلدات",
"pickFolderHint": "اختر مجلدًا لعرض مهاراته."
},
"actions": {
"preview": "معاينة",
"edit": "تحرير",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "Noch kein Inhalt. Klicke auf „Bearbeiten“, um zu starten.",
"loadingSkill": "Skill wird geladen...",
"emptyNoAgents": "Kein verfügbarer Agent.",
"noSelectionHint": "Wählen Sie links einen Skill oder klicken Sie auf „Neuer Skill“, um einen zu erstellen.",
"scope": {
"global": "Global",
"folder": "Ordner",
"selectFolderPlaceholder": "Ordner auswählen",
"noFolders": "Keine Ordner gefunden",
"pickFolderHint": "Wählen Sie einen Ordner, um dessen Skills anzuzeigen."
},
"actions": {
"preview": "Vorschau",
"edit": "Bearbeiten",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "No content yet. Click \"Edit\" to start.",
"loadingSkill": "Loading skill...",
"emptyNoAgents": "No available agent.",
"noSelectionHint": "Select a skill on the left, or click \"New Skill\" to create one.",
"scope": {
"global": "Global",
"folder": "Folder",
"selectFolderPlaceholder": "Select a folder",
"noFolders": "No folders found",
"pickFolderHint": "Select a folder to view its skills."
},
"actions": {
"preview": "Preview",
"edit": "Edit",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "Aún no hay contenido. Haz clic en \"Editar\" para empezar.",
"loadingSkill": "Cargando Skill...",
"emptyNoAgents": "No hay agentes disponibles.",
"noSelectionHint": "Selecciona un Skill a la izquierda o haz clic en \"Nuevo Skill\" para crear uno.",
"scope": {
"global": "Global",
"folder": "Carpeta",
"selectFolderPlaceholder": "Seleccionar una carpeta",
"noFolders": "No se encontraron carpetas",
"pickFolderHint": "Selecciona una carpeta para ver sus Skills."
},
"actions": {
"preview": "Vista previa",
"edit": "Editar",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "Aucun contenu pour le moment. Cliquez sur « Éditer » pour commencer.",
"loadingSkill": "Chargement de la Skill...",
"emptyNoAgents": "Aucun agent disponible.",
"noSelectionHint": "Sélectionnez un Skill à gauche ou cliquez sur « Nouveau Skill » pour en créer un.",
"scope": {
"global": "Global",
"folder": "Dossier",
"selectFolderPlaceholder": "Sélectionner un dossier",
"noFolders": "Aucun dossier trouvé",
"pickFolderHint": "Sélectionnez un dossier pour afficher ses Skills."
},
"actions": {
"preview": "Aperçu",
"edit": "Éditer",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "まだ内容がありません。「編集」をクリックして開始してください。",
"loadingSkill": "Skillを読み込み中...",
"emptyNoAgents": "利用可能なエージェントがありません。",
"noSelectionHint": "左側から Skill を選択するか、「新規 Skill」をクリックして作成してください。",
"scope": {
"global": "グローバル",
"folder": "フォルダ",
"selectFolderPlaceholder": "フォルダを選択",
"noFolders": "フォルダが見つかりません",
"pickFolderHint": "Skills を表示するフォルダを選択してください。"
},
"actions": {
"preview": "プレビュー",
"edit": "編集",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "아직 내용이 없습니다. \"편집\"을 눌러 시작하세요.",
"loadingSkill": "Skill 불러오는 중...",
"emptyNoAgents": "사용 가능한 에이전트가 없습니다.",
"noSelectionHint": "왼쪽에서 Skill을 선택하거나 \"새 Skill\"을 클릭하여 만드세요.",
"scope": {
"global": "전역",
"folder": "폴더",
"selectFolderPlaceholder": "폴더 선택",
"noFolders": "폴더를 찾을 수 없습니다",
"pickFolderHint": "Skills을 보려면 폴더를 선택하세요."
},
"actions": {
"preview": "미리보기",
"edit": "편집",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "Ainda não há conteúdo. Clique em \"Editar\" para começar.",
"loadingSkill": "Carregando Skill...",
"emptyNoAgents": "Nenhum agente disponível.",
"noSelectionHint": "Selecione um Skill à esquerda ou clique em \"Novo Skill\" para criar um.",
"scope": {
"global": "Global",
"folder": "Pasta",
"selectFolderPlaceholder": "Selecionar uma pasta",
"noFolders": "Nenhuma pasta encontrada",
"pickFolderHint": "Selecione uma pasta para ver suas Skills."
},
"actions": {
"preview": "Prévia",
"edit": "Editar",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "暂无内容。点击“编辑”开始输入。",
"loadingSkill": "正在加载 Skill...",
"emptyNoAgents": "暂无可用 Agent。",
"noSelectionHint": "从左侧选择一个 Skill或点击“新建 Skill”创建。",
"scope": {
"global": "全局",
"folder": "文件夹",
"selectFolderPlaceholder": "选择文件夹",
"noFolders": "未找到任何文件夹",
"pickFolderHint": "选择一个文件夹以查看其 Skills。"
},
"actions": {
"preview": "预览",
"edit": "编辑",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "暫無內容。點擊「編輯」開始輸入。",
"loadingSkill": "正在載入 Skill...",
"emptyNoAgents": "暫無可用 Agent。",
"noSelectionHint": "從左側選擇一個 Skill或點擊「新建 Skill」建立。",
"scope": {
"global": "全域",
"folder": "資料夾",
"selectFolderPlaceholder": "選擇資料夾",
"noFolders": "找不到任何資料夾",
"pickFolderHint": "選擇一個資料夾以檢視其 Skills。"
},
"actions": {
"preview": "預覽",
"edit": "編輯",