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:
@@ -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.
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "تحرير",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "編集",
|
||||||
|
|||||||
@@ -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": "편집",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "编辑",
|
||||||
|
|||||||
@@ -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": "編輯",
|
||||||
|
|||||||
Reference in New Issue
Block a user