diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5fe41304..3a1a822a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -14,6 +14,7 @@ import { MagnifyingGlass, Megaphone, Plus, + PuzzlePiece, UsersThree, ShieldCheck, Trash, @@ -75,6 +76,7 @@ import { SocialAccountsPanel } from "./components/social"; import { AutomationPanel } from "./components/automation"; import { CommunicationHub } from "./components/communication"; import { HiringPipeline } from "./components/hiring"; +import { SkillsSection } from "./components/skills"; import { DocumentCenter, KnowledgeBaseManager, MeetingsManager, SprintManager } from "./components/company-operations"; import { ActionButton, ErrorBoundary, ListFrame, ListRow, Modal, Panel, SectionButton, Select, StatusPill, useFocusTrap, useReducedMotion } from "./components/ui"; import ToastContainer from "./components/Toast"; @@ -94,6 +96,7 @@ const sidebarNavDefs: NavItemDef[] = [ { id: "agents", labelKey: "nav.agents", icon: UsersThree }, { id: "tasks", labelKey: "nav.work", icon: ListChecks }, { id: "communication", labelKey: "nav.communication", icon: ChatCircle }, + { id: "skills", labelKey: "nav.skills", icon: PuzzlePiece }, { id: "documents", labelKey: "nav.documents", icon: FileText }, { id: "approvals", labelKey: "nav.approvals", icon: ShieldCheck }, { id: "social", labelKey: "nav.social", icon: Megaphone }, @@ -181,6 +184,10 @@ export function App() { store.setSocialAccounts([]); store.setBrowserActions([]); store.setBadges(null); + store.setSkills([]); + store.setSelectedSkillId(null); + store.setSelectedSkillFilePath("SKILL.md"); + store.setSkillImportModalOpen(false); }, []); const refresh = useCallback(async () => { @@ -1193,6 +1200,10 @@ export function App() { /> )} + {section === "skills" && currentCompany ? ( + + ) : null} + {section === "automation" && currentCompany ? ( child.name === segment); + if (!next) { + next = { + name: segment, + path: isLeaf ? entry.path : currentPath, + kind: isLeaf ? "file" : "dir", + fileKind: isLeaf ? entry.kind : undefined, + children: [], + }; + current.children.push(next); + } + current = next; + } + } + + function sortNode(node: SkillTreeNode) { + node.children.sort((left, right) => { + if (left.kind !== right.kind) return left.kind === "dir" ? -1 : 1; + if (left.name === "SKILL.md") return -1; + if (right.name === "SKILL.md") return 1; + return left.name.localeCompare(right.name); + }); + node.children.forEach(sortNode); + } + + sortNode(root); + return root.children; +} + +function fileIcon(kind: FileInventoryEntry["kind"] | undefined) { + if (kind === "script" || kind === "reference") return FileCode; + return FileText; +} + +export function SkillFileTree({ + nodes, + selectedPath, + expandedDirs, + onToggleDir, + onSelectPath, + depth = 0, +}: { + nodes: SkillTreeNode[]; + selectedPath: string; + expandedDirs: Set; + onToggleDir: (path: string) => void; + onSelectPath: (path: string) => void; + depth?: number; +}) { + return ( +
+ {nodes.map((node) => { + const expanded = node.kind === "dir" && node.path ? expandedDirs.has(node.path) : false; + + if (node.kind === "dir") { + const FolderIcon = expanded ? FolderOpen : FolderSimple; + const ChevronIcon = expanded ? CaretDown : CaretRight; + return ( +
+ + {expanded && ( + + )} +
+ ); + } + + const FileIcon = fileIcon(node.fileKind); + const isSelected = node.path === selectedPath; + return ( + + ); + })} +
+ ); +} diff --git a/src/renderer/components/skills/SkillImportModal.tsx b/src/renderer/components/skills/SkillImportModal.tsx new file mode 100644 index 00000000..16f2823c --- /dev/null +++ b/src/renderer/components/skills/SkillImportModal.tsx @@ -0,0 +1,184 @@ +import { useState, useCallback } from "react"; +import { + FolderSimple, + GithubLogo, + Globe, + Link, +} from "@phosphor-icons/react"; +import { Modal, ActionButton } from "../ui"; +import { useAppStore } from "../../state/store"; + +type TabId = "github" | "local" | "url" | "skills_sh"; + +const tabs: { id: TabId; label: string; icon: typeof GithubLogo }[] = [ + { id: "github", label: "GitHub", icon: GithubLogo }, + { id: "local", label: "Local Path", icon: FolderSimple }, + { id: "url", label: "HTTP URL", icon: Link }, + { id: "skills_sh", label: "skills.sh", icon: Globe }, +]; + +function isValidGithubSource(value: string): boolean { + return /^(https:\/\/github\.com\/[\w.-]+\/[\w.-]+|[\w.-]+\/[\w.-]+)/.test(value.trim()); +} + +function isValidUrl(value: string): boolean { + try { + const url = new URL(value.trim()); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } +} + +export function SkillImportModal({ + open, + onClose, + companyId, + onImported, +}: { + open: boolean; + onClose: () => void; + companyId: string; + onImported: () => void; +}) { + const addToast = useAppStore((s) => s.addToast); + const [activeTab, setActiveTab] = useState("github"); + const [source, setSource] = useState(""); + const [isImporting, setIsImporting] = useState(false); + + const resetForm = useCallback(() => { + setSource(""); + setActiveTab("github"); + setIsImporting(false); + }, []); + + const handleClose = useCallback(() => { + resetForm(); + onClose(); + }, [onClose, resetForm]); + + const handleBrowse = useCallback(async () => { + const result = await window.agentCompany.pickDirectory(); + if (result.ok && result.data) { + setSource(result.data); + } + }, []); + + const canImport = (() => { + const trimmed = source.trim(); + if (!trimmed) return false; + switch (activeTab) { + case "github": + return isValidGithubSource(trimmed); + case "url": + return isValidUrl(trimmed); + case "local": + return trimmed.length > 0; + case "skills_sh": + return trimmed.length > 0; + default: + return false; + } + })(); + + const handleImport = useCallback(async () => { + const trimmed = source.trim(); + if (!trimmed) return; + + setIsImporting(true); + try { + const result = await window.agentCompany.importSkill(companyId, trimmed); + if (result.ok) { + const count = result.data.length; + addToast(`Imported ${count} skill${count !== 1 ? "s" : ""} successfully`); + onImported(); + handleClose(); + } else { + addToast(result.error.message, "danger"); + } + } catch (error) { + addToast(error instanceof Error ? error.message : "Import failed", "danger"); + } finally { + setIsImporting(false); + } + }, [source, companyId, addToast, onImported, handleClose]); + + const placeholder: Record = { + github: "https://github.com/owner/repo or owner/repo", + local: "Enter or browse local path", + url: "https://example.com/path/to/skill", + skills_sh: "skills.sh install command or URL", + }; + + return ( + +
+ {/* Tab bar */} +
+ {tabs.map((tab) => { + const Icon = tab.icon; + const isActive = activeTab === tab.id; + return ( + + ); + })} +
+ + {/* Tab content */} +
+ {activeTab === "local" ? ( +
+ setSource(e.target.value)} + placeholder={placeholder.local} + /> + void handleBrowse()} /> +
+ ) : ( + setSource(e.target.value)} + placeholder={placeholder[activeTab]} + /> + )} + + {activeTab === "skills_sh" && ( +

+ Enter a skills.sh install command (e.g., npx skills.sh install skill-name) or a direct skills.sh URL. +

+ )} +
+ + {/* Actions */} +
+ + void handleImport()} + loading={isImporting} + disabled={!canImport} + /> +
+
+
+ ); +} diff --git a/src/renderer/components/skills/SkillList.tsx b/src/renderer/components/skills/SkillList.tsx new file mode 100644 index 00000000..2819a63a --- /dev/null +++ b/src/renderer/components/skills/SkillList.tsx @@ -0,0 +1,135 @@ +import { useState, useCallback } from "react"; +import { + CaretDown, + CaretRight, + FolderSimple, + GithubLogo, + Globe, + Link, + PuzzlePiece, +} from "@phosphor-icons/react"; +import type { SkillRecord } from "@shared/types"; +import { SkillFileTree, buildTree } from "./SkillFileTree"; + +const sourceIconMap: Record = { + github: GithubLogo, + local_path: FolderSimple, + url: Link, + skills_sh: Globe, + managed: PuzzlePiece, +}; + +export function SkillList({ + skills, + selectedSkillId, + skillFilter, + onSelectSkill, +}: { + skills: SkillRecord[]; + selectedSkillId: string | null; + skillFilter: string; + onSelectSkill: (id: string) => void; +}) { + const [expandedSkillId, setExpandedSkillId] = useState(null); + const [expandedDirs, setExpandedDirs] = useState>>({}); + const [selectedPaths, setSelectedPaths] = useState>({}); + + const filteredSkills = skills.filter((skill) => { + const haystack = `${skill.name} ${skill.key} ${skill.slug}`.toLowerCase(); + return haystack.includes(skillFilter.toLowerCase()); + }); + + const handleToggleSkill = useCallback((skillId: string) => { + setExpandedSkillId((prev) => (prev === skillId ? null : skillId)); + }, []); + + const handleToggleDir = useCallback((skillId: string, path: string) => { + setExpandedDirs((prev) => { + const current = prev[skillId] ?? new Set(); + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return { ...prev, [skillId]: next }; + }); + }, []); + + const handleSelectPath = useCallback((skillId: string, path: string) => { + setSelectedPaths((prev) => ({ ...prev, [skillId]: path })); + }, []); + + if (filteredSkills.length === 0) { + return ( +
+ No skills match this filter. +
+ ); + } + + return ( +
+ {filteredSkills.map((skill) => { + const isExpanded = expandedSkillId === skill.id; + const isSelected = skill.id === selectedSkillId; + const tree = buildTree(skill.fileInventory); + const SourceIcon = sourceIconMap[skill.sourceType] ?? PuzzlePiece; + const dirState = expandedDirs[skill.id] ?? new Set(); + const filePath = selectedPaths[skill.id] ?? "SKILL.md"; + + return ( +
+
+
+
+
+ {tree.length > 0 && ( + handleToggleDir(skill.id, path)} + onSelectPath={(path) => handleSelectPath(skill.id, path)} + /> + )} +
+
+
+ ); + })} +
+ ); +} diff --git a/src/renderer/components/skills/SkillsSection.tsx b/src/renderer/components/skills/SkillsSection.tsx new file mode 100644 index 00000000..5a996c06 --- /dev/null +++ b/src/renderer/components/skills/SkillsSection.tsx @@ -0,0 +1,161 @@ +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"; + +export function SkillsSection({ + companyId, + agents: _agents, + onRefresh: _onRefresh, +}: { + companyId: string; + agents: AgentRecord[]; + onRefresh: () => void; +}) { + const skills = useAppStore((s) => s.skills); + const setSkills = useAppStore((s) => s.setSkills); + const selectedSkillId = useAppStore((s) => s.selectedSkillId); + const setSelectedSkillId = useAppStore((s) => s.setSelectedSkillId); + const skillImportModalOpen = useAppStore((s) => s.skillImportModalOpen); + const setSkillImportModalOpen = useAppStore((s) => s.setSkillImportModalOpen); + const addToast = useAppStore((s) => s.addToast); + + const [skillFilter, setSkillFilter] = useState(""); + + const loadSkills = useCallback(async () => { + try { + const result = await window.agentCompany.listSkills(companyId); + if (result.ok) { + setSkills(result.data); + } else { + addToast(result.error.message, "danger"); + } + } catch (error) { + addToast(error instanceof Error ? error.message : "Failed to load skills", "danger"); + } + }, [companyId, setSkills, addToast]); + + useEffect(() => { + void loadSkills(); + }, [loadSkills]); + + const handleImported = useCallback(() => { + void loadSkills(); + }, [loadSkills]); + + const handleSelectSkill = useCallback( + (id: string) => { + setSelectedSkillId(id); + }, + [setSelectedSkillId], + ); + + const selectedSkill = skills.find((s) => s.id === selectedSkillId); + + return ( +
+ {/* Left column -- skill list sidebar */} + + + {/* Right column -- skill detail */} +
+ {selectedSkill ? ( +
+
+ +

{selectedSkill.name}

+

Skill detail pane -- Plan 02

+
+
+ ) : ( +
+ +
+ )} +
+ + {/* Import modal */} + setSkillImportModalOpen(false)} + companyId={companyId} + onImported={handleImported} + /> +
+ ); +} diff --git a/src/renderer/components/skills/index.ts b/src/renderer/components/skills/index.ts new file mode 100644 index 00000000..bdb8af01 --- /dev/null +++ b/src/renderer/components/skills/index.ts @@ -0,0 +1 @@ +export { SkillsSection } from "./SkillsSection"; diff --git a/src/renderer/i18n/en.ts b/src/renderer/i18n/en.ts index 5bb1b77a..5de272ca 100644 --- a/src/renderer/i18n/en.ts +++ b/src/renderer/i18n/en.ts @@ -22,6 +22,7 @@ export const en: Record = { "nav.costs": "Costs", "nav.activity": "Activity", "nav.social": "Social Media", + "nav.skills": "Skills", "nav.connectors": "Connectors", "nav.workspaces": "Workspaces", "nav.settings": "Settings", diff --git a/src/renderer/i18n/zh.ts b/src/renderer/i18n/zh.ts index 9a58dca0..64f92143 100644 --- a/src/renderer/i18n/zh.ts +++ b/src/renderer/i18n/zh.ts @@ -22,6 +22,7 @@ export const zh: Record = { "nav.costs": "成本", "nav.activity": "活动", "nav.social": "社交媒体", + "nav.skills": "技能", "nav.connectors": "连接器", "nav.workspaces": "工作区", "nav.settings": "设置", diff --git a/src/renderer/state/slices/navigation.ts b/src/renderer/state/slices/navigation.ts index 9f1addc1..615358fb 100644 --- a/src/renderer/state/slices/navigation.ts +++ b/src/renderer/state/slices/navigation.ts @@ -105,6 +105,7 @@ export const createNavigationSlice: StateCreator< else if (section === "connectors") state.selectedConnectorId = entityId; else if (section === "communication") state.selectedCommunicationEntityId = entityId; + else if (section === "skills") state.selectedSkillId = entityId; } }), }); diff --git a/src/renderer/state/slices/skill.ts b/src/renderer/state/slices/skill.ts new file mode 100644 index 00000000..e41dcee3 --- /dev/null +++ b/src/renderer/state/slices/skill.ts @@ -0,0 +1,42 @@ +import type { StateCreator } from "zustand"; +import type { AppState } from "../store"; +import type { SkillRecord } from "@shared/types"; + +export interface SkillSlice { + skills: SkillRecord[]; + setSkills: (skills: SkillRecord[]) => void; + selectedSkillId: string | null; + setSelectedSkillId: (id: string | null) => void; + selectedSkillFilePath: string; + setSelectedSkillFilePath: (path: string) => void; + skillImportModalOpen: boolean; + setSkillImportModalOpen: (open: boolean) => void; +} + +export const createSkillSlice: StateCreator< + AppState, + [["zustand/immer", never]], + [], + SkillSlice +> = (set) => ({ + skills: [], + setSkills: (skills) => + set((state) => { + state.skills = skills; + }), + selectedSkillId: null, + setSelectedSkillId: (id) => + set((state) => { + state.selectedSkillId = id; + }), + selectedSkillFilePath: "SKILL.md", + setSelectedSkillFilePath: (path) => + set((state) => { + state.selectedSkillFilePath = path; + }), + skillImportModalOpen: false, + setSkillImportModalOpen: (open) => + set((state) => { + state.skillImportModalOpen = open; + }), +}); diff --git a/src/renderer/state/store.ts b/src/renderer/state/store.ts index 36124e8a..be6e8bd1 100644 --- a/src/renderer/state/store.ts +++ b/src/renderer/state/store.ts @@ -16,13 +16,15 @@ import { type CommunicationSlice, } from "./slices/communication"; import { createSocialSlice, type SocialSlice } from "./slices/social"; +import { createSkillSlice, type SkillSlice } from "./slices/skill"; export type AppState = NavigationSlice & CompanySlice & SelectionSlice & UiSlice & CommunicationSlice & - SocialSlice; + SocialSlice & + SkillSlice; export const useAppStore = create()( devtools( @@ -33,6 +35,7 @@ export const useAppStore = create()( ...createUiSlice(...a), ...createCommunicationSlice(...a), ...createSocialSlice(...a), + ...createSkillSlice(...a), })), { name: "AgentCompany", diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index a714a915..d7932b9c 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -642,5 +642,12 @@ export type DesktopApi = { 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; }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 1486a7f1..6c2d9d53 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -16,6 +16,7 @@ export type SectionId = | "activity" | "workspaces" | "social" + | "skills" | "automation" | "communication" | "documents"