From e71d2e5529c0f5bab77bb6f249f117a1f047e4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 26 Mar 2026 02:01:51 -0400 Subject: [PATCH] feat(03-02): add SkillDetail pane, SkillAgentToggles, and assignment query IPC - Add listSkillAgents and listAgentSkills IPC channels with Zod schemas - Create SkillDetail component with metadata, file viewer, update check, delete - Create SkillAgentToggles with discriminated union for skill/agent-centric modes - Wire SkillDetail into SkillsSection right column replacing placeholder - Remove duplicate skill method declarations from DesktopApi type --- src/main/ipc-domain-handlers.ts | 1 + src/main/ipc-skill-handlers.ts | 21 ++ src/preload/index.ts | 4 + .../components/skills/SkillAgentToggles.tsx | 113 ++++++ .../components/skills/SkillDetail.tsx | 325 ++++++++++++++++++ .../components/skills/SkillsSection.tsx | 22 +- src/renderer/components/skills/index.ts | 1 + src/shared/contracts.ts | 21 +- 8 files changed, 489 insertions(+), 19 deletions(-) create mode 100644 src/renderer/components/skills/SkillAgentToggles.tsx create mode 100644 src/renderer/components/skills/SkillDetail.tsx diff --git a/src/main/ipc-domain-handlers.ts b/src/main/ipc-domain-handlers.ts index fd433d48..7269adcd 100644 --- a/src/main/ipc-domain-handlers.ts +++ b/src/main/ipc-domain-handlers.ts @@ -19,5 +19,6 @@ export function registerDomainHandlers(dependencies: RegisterDomainHandlersDepen ensureCompanyExists: dependencies.ensureCompanyExists, publishDomainChanged: dependencies.publishDomainChanged, skillService: dependencies.skillService, + db: dependencies.db, }); } diff --git a/src/main/ipc-skill-handlers.ts b/src/main/ipc-skill-handlers.ts index 156c40af..5316a8a8 100644 --- a/src/main/ipc-skill-handlers.ts +++ b/src/main/ipc-skill-handlers.ts @@ -7,15 +7,19 @@ import { assignSkillToAgentSchema, removeSkillFromAgentSchema, checkSkillUpdateSchema, + listSkillAgentsSchema, + listAgentSkillsSchema, } from "@shared/contracts"; import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common"; import type { createSkillService } from "./skill-service"; +import type { AppDatabase } from "./database"; interface SkillHandlerDependencies extends Pick< RegisterDomainHandlersDependencies, "registerHandle" | "ensureCompanyExists" | "publishDomainChanged" > { skillService: ReturnType | null; + db: AppDatabase; } export function registerSkillHandlers({ @@ -23,6 +27,7 @@ export function registerSkillHandlers({ ensureCompanyExists, publishDomainChanged, skillService, + db, }: SkillHandlerDependencies) { function requireSkillService() { if (!skillService) { @@ -116,4 +121,20 @@ export function registerSkillHandlers({ if (serviceError) return serviceError; return skillService!.checkUpdateStatus(parsed.companyId, parsed.skillId); }); + + // ---- List agent IDs assigned to a skill ---- + registerHandle(IPC_CHANNELS.listSkillAgents, async (_event, payload) => { + const parsed = listSkillAgentsSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + return ok(db.listSkillAgentAssignments(parsed.skillId)); + }); + + // ---- List skill IDs assigned to an agent ---- + registerHandle(IPC_CHANNELS.listAgentSkills, async (_event, payload) => { + const parsed = listAgentSkillsSchema.parse(payload); + const companyError = ensureCompanyExists(parsed.companyId); + if (companyError) return companyError; + return ok(db.listAgentSkillAssignments(parsed.agentId)); + }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3f6d2610..bd8afb60 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -90,6 +90,10 @@ const api: DesktopApi = { ipcRenderer.invoke(IPC_CHANNELS.removeSkillFromAgent, { companyId, agentId, skillId }), checkSkillUpdate: (companyId, skillId) => ipcRenderer.invoke(IPC_CHANNELS.checkSkillUpdate, { companyId, skillId }), + listSkillAgents: (companyId, skillId) => + ipcRenderer.invoke(IPC_CHANNELS.listSkillAgents, { companyId, skillId }), + listAgentSkills: (companyId, agentId) => + ipcRenderer.invoke(IPC_CHANNELS.listAgentSkills, { companyId, agentId }), recoveryRetry: (taskId: string, agentId: string, companyId: string) => ipcRenderer.invoke("recovery:retry", { taskId, agentId, companyId }), recoveryDismiss: (taskId: string) => diff --git a/src/renderer/components/skills/SkillAgentToggles.tsx b/src/renderer/components/skills/SkillAgentToggles.tsx new file mode 100644 index 00000000..37c11f39 --- /dev/null +++ b/src/renderer/components/skills/SkillAgentToggles.tsx @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useState } from "react"; +import type { AgentRecord, SkillRecord } from "@shared/types"; +import { useAppStore } from "../../state/store"; + +type SkillAgentTogglesProps = + | { mode: "skill"; companyId: string; skillId: string; agents: AgentRecord[] } + | { mode: "agent"; companyId: string; agentId: string; skills: SkillRecord[] }; + +export function SkillAgentToggles(props: SkillAgentTogglesProps) { + const { companyId, mode } = props; + const addToast = useAppStore((s) => s.addToast); + const [assignedIds, setAssignedIds] = useState>(new Set()); + const [togglingId, setTogglingId] = useState(null); + + useEffect(() => { + let cancelled = false; + async function load() { + if (mode === "skill") { + const result = await window.agentCompany.listSkillAgents(companyId, props.skillId); + if (!cancelled && result.ok) setAssignedIds(new Set(result.data)); + } else { + const result = await window.agentCompany.listAgentSkills(companyId, props.agentId); + if (!cancelled && result.ok) setAssignedIds(new Set(result.data)); + } + } + void load(); + return () => { cancelled = true; }; + }, [mode, companyId, mode === "skill" ? props.skillId : props.agentId]); + + const handleToggle = useCallback( + async (targetId: string) => { + setTogglingId(targetId); + const isAssigned = assignedIds.has(targetId); + const agentId = mode === "skill" ? targetId : props.agentId; + const skillId = mode === "skill" ? props.skillId : targetId; + + const result = isAssigned + ? await window.agentCompany.removeSkillFromAgent(companyId, agentId, skillId) + : await window.agentCompany.assignSkillToAgent(companyId, agentId, skillId); + + if (result.ok) { + setAssignedIds((prev) => { + const next = new Set(prev); + if (isAssigned) next.delete(targetId); + else next.add(targetId); + return next; + }); + } else { + addToast(result.error.message, "danger"); + } + setTogglingId(null); + }, + [assignedIds, mode, companyId, addToast, + mode === "skill" ? props.skillId : "", + mode === "agent" ? props.agentId : ""], + ); + + const items: Array<{ id: string; name: string; detail?: string }> = + mode === "skill" + ? props.agents.map((a) => ({ id: a.id, name: a.name, detail: a.role })) + : props.skills.map((s) => ({ id: s.id, name: s.name, detail: s.description ?? undefined })); + + if (items.length === 0) { + return ( +

+ {mode === "skill" ? "No agents in this company" : "No skills imported yet"} +

+ ); + } + + return ( +
+ {items.map((item) => { + const isAssigned = assignedIds.has(item.id); + const isToggling = togglingId === item.id; + return ( + + ); + })} +
+ ); +} diff --git a/src/renderer/components/skills/SkillDetail.tsx b/src/renderer/components/skills/SkillDetail.tsx new file mode 100644 index 00000000..69653558 --- /dev/null +++ b/src/renderer/components/skills/SkillDetail.tsx @@ -0,0 +1,325 @@ +import { useCallback, useEffect, useState } from "react"; +import { + ArrowsClockwise, + FolderSimple, + GithubLogo, + Globe, + Link, + PuzzlePiece, + Trash, +} from "@phosphor-icons/react"; +import type { AgentRecord, SkillRecord, SkillUpdateStatus } from "@shared/types"; +import { useAppStore } from "../../state/store"; +import { ActionButton, StatusPill } from "../ui"; +import { SkillAgentToggles } from "./SkillAgentToggles"; +import { buildTree, SkillFileTree } from "./SkillFileTree"; + +const sourceIconMap: Record = { + github: GithubLogo, + local_path: FolderSimple, + url: Link, + skills_sh: Globe, + managed: PuzzlePiece, +}; + +const sourceLabel: Record = { + github: "GitHub", + local_path: "Local Path", + url: "URL", + skills_sh: "skills.sh", + managed: "Managed", +}; + +const trustToneMap: Record = { + markdown_only: "ready", + assets: "pending", + scripts_executables: "error", +}; + +const trustLabelMap: Record = { + markdown_only: "Markdown only", + assets: "Has assets", + scripts_executables: "Contains scripts", +}; + +function stripFrontmatter(markdown: string): string { + const normalized = markdown.replace(/\r\n/g, "\n"); + if (!normalized.startsWith("---\n")) return normalized.trim(); + const closing = normalized.indexOf("\n---\n", 4); + if (closing < 0) return normalized.trim(); + return normalized.slice(closing + 5).trim(); +} + +function shortRef(ref: string | null | undefined): string { + if (!ref) return "untracked"; + return ref.length > 7 ? ref.slice(0, 7) : ref; +} + +export function SkillDetail({ + companyId, + skillId, + agents, + onDeleted, + onRefresh, +}: { + companyId: string; + skillId: string; + agents: AgentRecord[]; + onDeleted: () => void; + onRefresh: () => void; +}) { + const addToast = useAppStore((s) => s.addToast); + const selectedSkillFilePath = useAppStore((s) => s.selectedSkillFilePath); + const setSelectedSkillFilePath = useAppStore((s) => s.setSelectedSkillFilePath); + + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [updateStatus, setUpdateStatus] = useState(null); + const [checkingUpdate, setCheckingUpdate] = useState(false); + const [confirmingDelete, setConfirmingDelete] = useState(false); + const [expandedDirs, setExpandedDirs] = useState>(new Set()); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setDetail(null); + setUpdateStatus(null); + setSelectedSkillFilePath("SKILL.md"); + setExpandedDirs(new Set()); + + async function load() { + const result = await window.agentCompany.getSkillDetail(companyId, skillId); + if (cancelled) return; + setLoading(false); + if (result.ok) { + setDetail(result.data); + } else { + addToast(result.error.message, "danger"); + } + } + void load(); + return () => { cancelled = true; }; + }, [companyId, skillId, addToast, setSelectedSkillFilePath]); + + const handleCheckUpdate = useCallback(async () => { + if (!detail) return; + setCheckingUpdate(true); + const result = await window.agentCompany.checkSkillUpdate(companyId, skillId); + setCheckingUpdate(false); + if (result.ok) { + setUpdateStatus(result.data); + } else { + addToast(result.error.message, "danger"); + } + }, [companyId, skillId, detail, addToast]); + + const handleDelete = useCallback(async () => { + const result = await window.agentCompany.deleteSkill(companyId, skillId); + if (result.ok) { + addToast("Skill deleted", "success"); + onDeleted(); + } else { + addToast(result.error.message, "danger"); + } + }, [companyId, skillId, addToast, onDeleted]); + + const handleToggleDir = useCallback((path: string) => { + setExpandedDirs((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }, []); + + if (loading) { + return ( +
+
Loading skill...
+
+ ); + } + + if (!detail) { + return ( +
+
Skill not found
+
+ ); + } + + const SourceIcon = sourceIconMap[detail.sourceType] ?? PuzzlePiece; + const treeNodes = buildTree(detail.fileInventory ?? []); + const isGitHubSource = detail.sourceType === "github" || detail.sourceType === "skills_sh"; + + // Determine content for the file viewer + const isSkillMd = selectedSkillFilePath === "SKILL.md"; + const fileContent = isSkillMd + ? stripFrontmatter(detail.markdown ?? "") + : null; + + const selectedFileEntry = !isSkillMd + ? (detail.fileInventory ?? []).find((f) => f.path === selectedSkillFilePath) + : null; + + return ( +
+ {/* Header section */} +
+
+ +
+

+ {detail.name} +

+ {detail.description ? ( +

+ {detail.description} +

+ ) : null} +
+
+
+ + {/* Metadata row */} +
+
+ {/* Source */} +
+ Source + + + {sourceLabel[detail.sourceType] ?? detail.sourceType} + +
+ + {/* Trust level */} +
+ Trust + + {trustLabelMap[detail.trustLevel] ?? detail.trustLevel} +
+ + {/* Compatibility */} +
+ Compat + {detail.compatibility || "all"} +
+ + {/* Ref (for GitHub sources) */} + {isGitHubSource ? ( +
+ Ref + {shortRef(detail.sourceRef)} +
+ ) : null} +
+ + {/* Update check row */} + {isGitHubSource ? ( +
+ void handleCheckUpdate()} + /> + {updateStatus?.hasUpdate ? ( + + Update available: {shortRef(updateStatus.currentRef)} → {shortRef(updateStatus.latestRef)} + + ) : updateStatus && !updateStatus.hasUpdate ? ( + Up to date + ) : null} +
+ ) : null} +
+ + {/* File viewer section */} +
+ {/* File tree sidebar + content */} +
+ {/* File tree sidebar */} +
+ {treeNodes.length > 0 ? ( + + ) : ( +

No files

+ )} +
+ + {/* Content area */} +
+ {/* File path bar */} +
+ {selectedSkillFilePath.split("/").map((segment, i, arr) => ( + + {i > 0 ? / : null} + + {segment} + + + ))} +
+ + {/* Content */} + {isSkillMd ? ( +
+                {fileContent}
+              
+ ) : selectedFileEntry ? ( +
+

{selectedFileEntry.path}

+

Kind: {selectedFileEntry.kind}

+

File content preview is not available for non-SKILL.md files.

+
+ ) : ( +

Select a file to view its details.

+ )} +
+
+
+ + {/* Agent assignments section */} +
+

+ Agent Assignments +

+ +
+ + {/* Delete section */} +
+ {confirmingDelete ? ( +
+

+ Delete “{detail.name}”? This cannot be undone. +

+ void handleDelete()} + /> + setConfirmingDelete(false)} + /> +
+ ) : ( + setConfirmingDelete(true)} + /> + )} +
+
+ ); +} diff --git a/src/renderer/components/skills/SkillsSection.tsx b/src/renderer/components/skills/SkillsSection.tsx index 5a996c06..a211a2ad 100644 --- a/src/renderer/components/skills/SkillsSection.tsx +++ b/src/renderer/components/skills/SkillsSection.tsx @@ -2,17 +2,17 @@ import { useCallback, useEffect, useState } from "react"; import { MagnifyingGlass, Plus, - PuzzlePiece, } from "@phosphor-icons/react"; import type { AgentRecord } from "@shared/types"; import { useAppStore } from "../../state/store"; import { EmptyState } from "../ui"; import { SkillList } from "./SkillList"; import { SkillImportModal } from "./SkillImportModal"; +import { SkillDetail } from "./SkillDetail"; export function SkillsSection({ companyId, - agents: _agents, + agents, onRefresh: _onRefresh, }: { companyId: string; @@ -57,8 +57,6 @@ export function SkillsSection({ [setSelectedSkillId], ); - const selectedSkill = skills.find((s) => s.id === selectedSkillId); - return (
{/* Left column -- skill list sidebar */} @@ -131,14 +129,14 @@ export function SkillsSection({ {/* Right column -- skill detail */}
- {selectedSkill ? ( -
-
- -

{selectedSkill.name}

-

Skill detail pane -- Plan 02

-
-
+ {selectedSkillId ? ( + { setSelectedSkillId(null); void loadSkills(); }} + onRefresh={() => void loadSkills()} + /> ) : (
= | { ok: true; data: T } | { ok: false; error: { code: string; message: string } }; @@ -639,15 +651,10 @@ export type DesktopApi = { assignSkillToAgent: (companyId: string, agentId: string, skillId: string) => Promise>; removeSkillFromAgent: (companyId: string, agentId: string, skillId: string) => Promise>; checkSkillUpdate: (companyId: string, skillId: string) => Promise>; + listSkillAgents: (companyId: string, skillId: string) => Promise>; + listAgentSkills: (companyId: string, agentId: string) => Promise>; recoveryRetry: (taskId: string, agentId: string, companyId: string) => Promise>; recoveryDismiss: (taskId: string) => Promise>; recoveryFail: (taskId: string) => Promise>; - importSkill: (companyId: string, source: string) => Promise>; - listSkills: (companyId: string) => Promise>; - getSkillDetail: (companyId: string, skillId: string) => Promise>; - deleteSkill: (companyId: string, skillId: string) => Promise>; - assignSkillToAgent: (companyId: string, agentId: string, skillId: string) => Promise>; - removeSkillFromAgent: (companyId: string, agentId: string, skillId: string) => Promise>; - checkSkillUpdate: (companyId: string, skillId: string) => Promise>; subscribe: (callback: (event: DesktopEvent) => void) => () => void; };