diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index f075ae9..f15763a 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -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. diff --git a/src/components/settings/skills-settings.tsx b/src/components/settings/skills-settings.tsx index 69741d0..5f78dc8 100644 --- a/src/components/settings/skills-settings.tsx +++ b/src/components/settings/skills-settings.tsx @@ -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([]) const [selectedSkillId, setSelectedSkillId] = useState(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([]) + const [selectedFolderPath, setSelectedFolderPath] = useState( + 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(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() { - { - setSearchQuery(event.target.value) - }} - placeholder={t("searchPlaceholder")} - /> + {/* Scope selector: global vs per-folder (project-scoped) */} +
+ {(["global", "folder"] as const).map((scope) => ( + + ))} +
+ + {skillsScope === "folder" && ( + + )} + + {/* Search only applies to the global list — folder scope + already narrows the set via the picker above. */} + {skillsScope === "global" && ( + { + setSearchQuery(event.target.value) + }} + placeholder={t("searchPlaceholder")} + /> + )}
@@ -757,7 +948,9 @@ export function SkillsSettings() { skillsSupported && filteredSkills.length === 0 && (
- {t("emptySkills")} + {skillsScope === "folder" && !selectedFolderPath + ? t("scope.pickFolderHint") + : t("emptySkills")}
)} @@ -884,7 +1077,10 @@ export function SkillsSettings() { size="sm" className="flex-1" onClick={handleCreateDraft} - disabled={!selectedAgent} + disabled={ + !selectedAgent || + (skillsScope === "folder" && !selectedFolderPath) + } > {t("actions.newSkill")} @@ -898,194 +1094,204 @@ export function SkillsSettings() {
{selectedAgent ? ( -
-
-
-

- {skillDraftId.trim() || t("newSkillTitle")} -

-
- -
- - -
-
- -
-
-
- - {t("skillInfo")} + selectedSkillId || isDrafting ? ( +
+
+
+

+ {skillDraftId.trim() || t("newSkillTitle")} +

- { - setSkillDraftId(event.target.value) - }} - placeholder={t("skillIdPlaceholder")} - /> - - {draftPathPreview ? ( -
- {t("skillsDirectoryWithPath", { - path: draftPathPreview, - })} -
- ) : ( -
- {t("skillsDirectoryNeedId")} -
- )} -
- -
-
- {t("markdownContent")} -
- - {isContentEditing - ? t("editingStatus") - : t("previewStatus")} - - -
-
- - {isContentEditing ? ( -