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 // set through ACP `availableCommands`, so injecting skills there would
// be duplicate/extra UI noise — skip the skills fetch for them entirely. // be duplicate/extra UI noise — skip the skills fetch for them entirely.
const skillAgentType = agentType === "codex" ? "codex" : null 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 // 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 // also show up in `acp_list_agent_skills`. Strip them out — experts remain
// reachable via the expert button, and the `$` list is skills-only. // reachable via the expert button, and the `$` list is skills-only.

View File

@@ -52,16 +52,20 @@ import {
acpDeleteAgentSkill, acpDeleteAgentSkill,
acpListAgents, acpListAgents,
acpListAgentSkills, acpListAgentSkills,
loadFolderHistory,
openFolderWindow, openFolderWindow,
acpReadAgentSkill, acpReadAgentSkill,
acpSaveAgentSkill, acpSaveAgentSkill,
} from "@/lib/api" } from "@/lib/api"
import { invalidateAgentSkillsCache } from "@/hooks/use-agent-skills"
import type { import type {
AcpAgentInfo, AcpAgentInfo,
AgentSkillItem, AgentSkillItem,
AgentSkillLayout, AgentSkillLayout,
AgentSkillLocation, AgentSkillLocation,
AgentSkillScope,
AgentType, AgentType,
FolderHistoryEntry,
} from "@/lib/types" } from "@/lib/types"
type SkillsTranslator = ( type SkillsTranslator = (
@@ -224,6 +228,22 @@ export function SkillsSettings() {
const [skillItems, setSkillItems] = useState<AgentSkillItem[]>([]) const [skillItems, setSkillItems] = useState<AgentSkillItem[]>([])
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(null) 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 [skillDraftId, setSkillDraftId] = useState("")
const [skillDraftContent, setSkillDraftContent] = useState("") const [skillDraftContent, setSkillDraftContent] = useState("")
const [searchQuery, setSearchQuery] = useState("") const [searchQuery, setSearchQuery] = useState("")
@@ -235,6 +255,12 @@ export function SkillsSettings() {
useState<AgentSkillItem | null>(null) useState<AgentSkillItem | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [isContentEditing, setIsContentEditing] = 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( const sortedAgents = useMemo(
() => () =>
@@ -326,13 +352,16 @@ export function SkillsSettings() {
try { try {
const detail = await acpReadAgentSkill({ const detail = await acpReadAgentSkill({
agentType, agentType,
scope: "global", scope: skill.scope,
skillId: skill.id, skillId: skill.id,
workspacePath:
skill.scope === "project" ? workspacePathForRequest : null,
}) })
setSelectedSkillId(detail.skill.id) setSelectedSkillId(detail.skill.id)
setSkillDraftId(detail.skill.id) setSkillDraftId(detail.skill.id)
setSkillDraftContent(detail.content) setSkillDraftContent(detail.content)
setIsContentEditing(mode === "edit") setIsContentEditing(mode === "edit")
setIsDrafting(false)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
toast.error(t("toasts.loadFailed"), { description: message }) toast.error(t("toasts.loadFailed"), { description: message })
@@ -340,32 +369,53 @@ export function SkillsSettings() {
setSkillReading(false) setSkillReading(false)
} }
}, },
[t] [t, workspacePathForRequest]
) )
const loadSkills = useCallback(async (agentType: AgentType) => { const loadSkills = useCallback(
setSkillsLoading(true) async (agentType: AgentType) => {
setSkillsError(null) // 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 { setSkillsLoading(true)
const result = await acpListAgentSkills({ agentType }) setSkillsError(null)
setSkillsSupported(result.supported)
setSkillLocation( try {
result.locations.find((location) => location.scope === "global") ?? null const result = await acpListAgentSkills({
) agentType,
setSkillItems(result.skills.filter((skill) => skill.scope === "global")) workspacePath: workspacePathForRequest,
return result })
} catch (err) { setSkillsSupported(result.supported)
const message = err instanceof Error ? err.message : String(err) setSkillLocation(
setSkillsError(message) result.locations.find(
setSkillsSupported(true) (location) => location.scope === backendScope
setSkillLocation(null) ) ?? null
setSkillItems([]) )
return null setSkillItems(
} finally { result.skills.filter((skill) => skill.scope === backendScope)
setSkillsLoading(false) )
} 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 () => { const refreshAgents = useCallback(async () => {
setLoadingAgents(true) setLoadingAgents(true)
@@ -401,6 +451,7 @@ export function SkillsSettings() {
const handleCreateDraft = useCallback(() => { const handleCreateDraft = useCallback(() => {
if (!selectedAgent) return if (!selectedAgent) return
setIsDrafting(true)
resetDraft(selectedAgent.agent_type, true) resetDraft(selectedAgent.agent_type, true)
}, [resetDraft, selectedAgent]) }, [resetDraft, selectedAgent])
@@ -477,12 +528,16 @@ export function SkillsSettings() {
try { try {
const saved = await acpSaveAgentSkill({ const saved = await acpSaveAgentSkill({
agentType: selectedAgent.agent_type, agentType: selectedAgent.agent_type,
scope: "global", scope: backendScope,
skillId: trimmedId, skillId: trimmedId,
content: skillDraftContent, content: skillDraftContent,
workspacePath: workspacePathForRequest,
layout: resolvedLayout, 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 loadSkills(selectedAgent.agent_type)
await openSkill( await openSkill(
selectedAgent.agent_type, selectedAgent.agent_type,
@@ -499,6 +554,7 @@ export function SkillsSettings() {
setSkillSaving(false) setSkillSaving(false)
} }
}, [ }, [
backendScope,
isEditingExisting, isEditingExisting,
loadSkills, loadSkills,
openSkill, openSkill,
@@ -509,6 +565,7 @@ export function SkillsSettings() {
skillLocation, skillLocation,
isContentEditing, isContentEditing,
t, t,
workspacePathForRequest,
]) ])
const handleDeleteSkill = useCallback( const handleDeleteSkill = useCallback(
@@ -521,20 +578,31 @@ export function SkillsSettings() {
try { try {
await acpDeleteAgentSkill({ await acpDeleteAgentSkill({
agentType: selectedAgent.agent_type, agentType: selectedAgent.agent_type,
scope: "global", scope: skill.scope,
skillId: skill.id, skillId: skill.id,
workspacePath:
skill.scope === "project" ? workspacePathForRequest : null,
}) })
invalidateAgentSkillsCache(selectedAgent.agent_type)
const latest = await loadSkills(selectedAgent.agent_type) const latest = await loadSkills(selectedAgent.agent_type)
toast.success(t("toasts.deleted")) toast.success(t("toasts.deleted"))
if (!deletingCurrent) return if (!deletingCurrent) return
const nextSkill = latest?.skills.find((item) => item.scope === "global") const nextSkill = latest?.skills.find(
(item) => item.scope === backendScope
)
if (nextSkill) { if (nextSkill) {
await openSkill(selectedAgent.agent_type, nextSkill) await openSkill(selectedAgent.agent_type, nextSkill)
} else { } 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) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
@@ -545,7 +613,15 @@ export function SkillsSettings() {
setDeleteTargetSkill(null) setDeleteTargetSkill(null)
} }
}, },
[loadSkills, openSkill, resetDraft, selectedAgent, selectedSkillId, t] [
backendScope,
loadSkills,
openSkill,
selectedAgent,
selectedSkillId,
t,
workspacePathForRequest,
]
) )
const handleConfirmDelete = useCallback(async () => { const handleConfirmDelete = useCallback(async () => {
@@ -622,24 +698,32 @@ export function SkillsSettings() {
setSkillDraftContent("") setSkillDraftContent("")
setSearchQuery("") setSearchQuery("")
setIsContentEditing(false) setIsContentEditing(false)
setIsDrafting(false)
return return
} }
let cancelled = false let cancelled = false
setSearchQuery("") 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) loadSkills(currentAgentType)
.then((result) => { .then((result) => {
if (cancelled || !result || !result.supported) return if (cancelled || !result || !result.supported) return
const firstGlobalSkill = result.skills.find( const firstSkill = result.skills.find(
(skill) => skill.scope === "global" (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) console.error("[SkillsSettings] initial open skill failed:", err)
}) })
}) })
@@ -650,7 +734,36 @@ export function SkillsSettings() {
return () => { return () => {
cancelled = true 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) { if (loadingAgents) {
return ( return (
@@ -719,13 +832,91 @@ export function SkillsSettings() {
</SelectContent> </SelectContent>
</Select> </Select>
<Input {/* Scope selector: global vs per-folder (project-scoped) */}
value={searchQuery} <div className="inline-flex w-full rounded-md border p-0.5 text-xs bg-muted/20">
onChange={(event) => { {(["global", "folder"] as const).map((scope) => (
setSearchQuery(event.target.value) <button
}} key={scope}
placeholder={t("searchPlaceholder")} 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>
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground flex items-center justify-between gap-2"> <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 && skillsSupported &&
filteredSkills.length === 0 && ( filteredSkills.length === 0 && (
<div className="text-xs text-muted-foreground px-1"> <div className="text-xs text-muted-foreground px-1">
{t("emptySkills")} {skillsScope === "folder" && !selectedFolderPath
? t("scope.pickFolderHint")
: t("emptySkills")}
</div> </div>
)} )}
@@ -884,7 +1077,10 @@ export function SkillsSettings() {
size="sm" size="sm"
className="flex-1" className="flex-1"
onClick={handleCreateDraft} onClick={handleCreateDraft}
disabled={!selectedAgent} disabled={
!selectedAgent ||
(skillsScope === "folder" && !selectedFolderPath)
}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
{t("actions.newSkill")} {t("actions.newSkill")}
@@ -898,194 +1094,204 @@ export function SkillsSettings() {
<ResizablePanel defaultSize={64} minSize={rightMinSize}> <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"> <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 ? ( {selectedAgent ? (
<div className="h-full flex flex-col"> selectedSkillId || isDrafting ? (
<div className="border-b px-4 py-3 flex items-center justify-between gap-3"> <div className="h-full flex flex-col">
<div className="min-w-0"> <div className="border-b px-4 py-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold truncate"> <div className="min-w-0">
{skillDraftId.trim() || t("newSkillTitle")} <h3 className="text-sm font-semibold truncate">
</h3> {skillDraftId.trim() || t("newSkillTitle")}
</div> </h3>
<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")}
</div> </div>
<Input <div className="flex items-center gap-1.5 shrink-0">
value={skillDraftId} <Button
onChange={(event) => { size="xs"
setSkillDraftId(event.target.value) variant="outline"
}} onClick={handleResetDraft}
placeholder={t("skillIdPlaceholder")} disabled={skillSaving || skillReading}
/> >
<RotateCcw className="h-3 w-3" />
{draftPathPreview ? ( {t("actions.reset")}
<div className="text-[11px] text-muted-foreground break-all"> </Button>
{t("skillsDirectoryWithPath", { <Button
path: draftPathPreview, size="xs"
})} onClick={() => {
</div> handleSaveSkill().catch((err) => {
) : ( console.error(
<div className="text-[11px] text-muted-foreground break-all"> "[SkillsSettings] save skill failed:",
{t("skillsDirectoryNeedId")} err
</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)
}} }}
placeholder={t("contentPlaceholder")} disabled={skillSaving || skillReading}
className="min-h-80 font-mono text-xs" >
/> {skillSaving ? (
) : ( <>
<div className="space-y-2"> <Loader2 className="h-3 w-3 animate-spin" />
{parsedPreviewContent.frontMatterRaw && ( {t("actions.saving")}
<div className="rounded-md border bg-muted/10 p-3"> </>
<div className="text-[11px] text-muted-foreground mb-2"> ) : (
{t("metadataTitle")} <>
</div> <Save className="h-3 w-3" />
{parsedPreviewContent.fields.length > 0 ? ( {t("actions.save")}
<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>
)} )}
</Button>
</div>
</div>
<div className="min-h-80 rounded-md border bg-muted/10 p-3 overflow-auto"> <div className="flex-1 overflow-y-auto p-4 space-y-4">
{parsedPreviewContent.body.trim() ? ( <div className="rounded-md border p-3 space-y-2.5">
<div <div className="text-[11px] text-muted-foreground flex items-center gap-1">
className={cn( <BookOpenText className="h-3.5 w-3.5" />
"text-sm leading-6", {t("skillInfo")}
"[&_h1]:text-xl [&_h1]:font-semibold [&_h1]:mb-3", </div>
"[&_h2]:text-lg [&_h2]:font-semibold [&_h2]:mt-5 [&_h2]:mb-2",
"[&_h3]:text-base [&_h3]:font-semibold [&_h3]:mt-4 [&_h3]:mb-2", <Input
"[&_p]:mb-3 [&_li]:mb-1", value={skillDraftId}
"[&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5", onChange={(event) => {
"[&_code]:font-mono [&_code]:text-xs [&_code]:bg-muted [&_code]:rounded [&_code]:px-1", setSkillDraftId(event.target.value)
"[&_pre]:bg-muted [&_pre]:rounded-md [&_pre]:p-3 [&_pre]:overflow-x-auto" }}
)} placeholder={t("skillIdPlaceholder")}
> />
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{parsedPreviewContent.body} {skillsScope === "folder" && !selectedFolderPath ? (
</ReactMarkdown> <div className="text-[11px] text-muted-foreground break-all">
</div> {t("scope.pickFolderHint")}
) : parsedPreviewContent.frontMatterRaw ? ( </div>
<div className="text-xs text-muted-foreground py-3"> ) : draftPathPreview ? (
{t("onlyYamlMetadata")} <div className="text-[11px] text-muted-foreground break-all">
</div> {t("skillsDirectoryWithPath", {
) : ( path: draftPathPreview,
<div className="text-xs text-muted-foreground py-3"> })}
{t("emptyContentHint")} </div>
</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>
</div> </div>
)}
{skillReading && ( {isContentEditing ? (
<div className="text-[11px] text-muted-foreground"> <Textarea
{t("loadingSkill")} value={skillDraftContent}
</div> 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>
</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"> <div className="h-full flex items-center justify-center text-xs text-muted-foreground">
{t("emptyNoAgents")} {t("emptyNoAgents")}

View File

@@ -5,83 +5,108 @@ import { useCallback, useEffect, useMemo, useState } from "react"
import { acpListAgentSkills } from "@/lib/api" import { acpListAgentSkills } from "@/lib/api"
import type { AgentSkillItem, AgentType } from "@/lib/types" import type { AgentSkillItem, AgentType } from "@/lib/types"
const agentCache = new Map<AgentType, AgentSkillItem[]>() // Cache/inflight keyed by `${agentType}|${workspacePath ?? ""}` so different
const inflightMap = new Map<AgentType, Promise<AgentSkillItem[]>>() // 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[] = [] const EMPTY: AgentSkillItem[] = []
function fetchForAgent(agentType: AgentType): Promise<AgentSkillItem[]> { function makeKey(agentType: AgentType, workspacePath: string | null): string {
let promise = inflightMap.get(agentType) return `${agentType}|${workspacePath ?? ""}`
}
function fetchSkills(
agentType: AgentType,
workspacePath: string | null
): Promise<AgentSkillItem[]> {
const key = makeKey(agentType, workspacePath)
let promise = inflight.get(key)
if (!promise) { if (!promise) {
promise = acpListAgentSkills({ agentType }) promise = acpListAgentSkills({ agentType, workspacePath })
.then((result) => { .then((result) => {
const skills = result.supported ? result.skills : EMPTY const skills = result.supported ? result.skills : EMPTY
agentCache.set(agentType, skills) cache.set(key, skills)
inflightMap.delete(agentType) inflight.delete(key)
return skills return skills
}) })
.catch((err) => { .catch((err) => {
inflightMap.delete(agentType) inflight.delete(key)
console.warn("[useAgentSkills] failed:", err) console.warn("[useAgentSkills] failed:", err)
return EMPTY return EMPTY
}) })
inflightMap.set(agentType, promise) inflight.set(key, promise)
} }
return promise return promise
} }
export function useAgentSkills(agentType: AgentType | null): AgentSkillItem[] { export function useAgentSkills(
const cached = useMemo( agentType: AgentType | null,
() => (agentType ? (agentCache.get(agentType) ?? null) : null), workspacePath?: string | null
[agentType] ): 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 const cached = useMemo(
// from a previous agent is never returned after a switch. () => (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<{ const [fetched, setFetched] = useState<{
agentType: AgentType key: string
skills: AgentSkillItem[] skills: AgentSkillItem[]
} | null>(null) } | null>(null)
const doFetch = useCallback(() => { const doFetch = useCallback(() => {
if (!agentType || agentCache.has(agentType)) return if (!agentType || !cacheKey || cache.has(cacheKey)) return
let cancelled = false let cancelled = false
fetchForAgent(agentType).then((list) => { fetchSkills(agentType, normalizedPath).then((list) => {
if (!cancelled) setFetched({ agentType, skills: list }) if (!cancelled) setFetched({ key: cacheKey, skills: list })
}) })
return () => { return () => {
cancelled = true cancelled = true
} }
}, [agentType]) }, [agentType, cacheKey, normalizedPath])
// Initial fetch // Initial fetch
useEffect(() => doFetch(), [doFetch]) useEffect(() => doFetch(), [doFetch])
// Re-fetch when window regains focus (covers cross-window cache // Re-fetch when window regains focus (covers cross-window cache
// invalidation — e.g. settings window creates/removes skills while the // 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(() => { useEffect(() => {
const onFocus = () => { const onFocus = () => {
if (!agentType) return if (!cacheKey) return
agentCache.delete(agentType) cache.delete(cacheKey)
inflightMap.delete(agentType) inflight.delete(cacheKey)
doFetch() doFetch()
} }
window.addEventListener("focus", onFocus) window.addEventListener("focus", onFocus)
return () => window.removeEventListener("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 (cached) return cached
if (fetched && fetched.agentType === agentType) return fetched.skills if (fetched && fetched.key === cacheKey) return fetched.skills
return EMPTY return EMPTY
} }
export function invalidateAgentSkillsCache(agentType?: AgentType) { export function invalidateAgentSkillsCache(agentType?: AgentType) {
if (agentType) { if (agentType) {
agentCache.delete(agentType) const prefix = `${agentType}|`
inflightMap.delete(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 { } else {
agentCache.clear() cache.clear()
inflightMap.clear() inflight.clear()
} }
} }

View File

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

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "Noch kein Inhalt. Klicke auf „Bearbeiten“, um zu starten.", "emptyContentHint": "Noch kein Inhalt. Klicke auf „Bearbeiten“, um zu starten.",
"loadingSkill": "Skill wird geladen...", "loadingSkill": "Skill wird geladen...",
"emptyNoAgents": "Kein verfügbarer Agent.", "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": { "actions": {
"preview": "Vorschau", "preview": "Vorschau",
"edit": "Bearbeiten", "edit": "Bearbeiten",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "No content yet. Click \"Edit\" to start.", "emptyContentHint": "No content yet. Click \"Edit\" to start.",
"loadingSkill": "Loading skill...", "loadingSkill": "Loading skill...",
"emptyNoAgents": "No available agent.", "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": { "actions": {
"preview": "Preview", "preview": "Preview",
"edit": "Edit", "edit": "Edit",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "Aún no hay contenido. Haz clic en \"Editar\" para empezar.", "emptyContentHint": "Aún no hay contenido. Haz clic en \"Editar\" para empezar.",
"loadingSkill": "Cargando Skill...", "loadingSkill": "Cargando Skill...",
"emptyNoAgents": "No hay agentes disponibles.", "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": { "actions": {
"preview": "Vista previa", "preview": "Vista previa",
"edit": "Editar", "edit": "Editar",

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "Aucun contenu pour le moment. Cliquez sur « Éditer » pour commencer.", "emptyContentHint": "Aucun contenu pour le moment. Cliquez sur « Éditer » pour commencer.",
"loadingSkill": "Chargement de la Skill...", "loadingSkill": "Chargement de la Skill...",
"emptyNoAgents": "Aucun agent disponible.", "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": { "actions": {
"preview": "Aperçu", "preview": "Aperçu",
"edit": "Éditer", "edit": "Éditer",

View File

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

View File

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

View File

@@ -348,6 +348,14 @@
"emptyContentHint": "Ainda não há conteúdo. Clique em \"Editar\" para começar.", "emptyContentHint": "Ainda não há conteúdo. Clique em \"Editar\" para começar.",
"loadingSkill": "Carregando Skill...", "loadingSkill": "Carregando Skill...",
"emptyNoAgents": "Nenhum agente disponível.", "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": { "actions": {
"preview": "Prévia", "preview": "Prévia",
"edit": "Editar", "edit": "Editar",

View File

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

View File

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