From e8d642941fd03485d906e2046ecd950dd39e8e21 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 01:45:32 -0400 Subject: [PATCH 1/2] feat(03-01): add skill section type wiring, Zustand slice, i18n, and sidebar nav entry - Add "skills" to SectionId union in shared types - Add SkillRecord, SkillImportResult, SkillUpdateStatus types to shared/types.ts - Create SkillSlice with skills list, selection, file path, and import modal state - Wire SkillSlice into Zustand store composition - Add "nav.skills" i18n keys for English and Chinese - Add Skills sidebar nav entry with PuzzlePiece icon between Communication and Documents - Add section === "skills" rendering route in App.tsx - Clear skill state on company switch - Add skills case to navigateTo in NavigationSlice Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/App.tsx | 11 +++++ .../components/skills/SkillsSection.tsx | 15 +++++++ src/renderer/components/skills/index.ts | 1 + src/renderer/i18n/en.ts | 1 + src/renderer/i18n/zh.ts | 1 + src/renderer/state/slices/navigation.ts | 1 + src/renderer/state/slices/skill.ts | 42 +++++++++++++++++ src/renderer/state/store.ts | 5 ++- src/shared/types.ts | 45 +++++++++++++++++++ 9 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/renderer/components/skills/SkillsSection.tsx create mode 100644 src/renderer/components/skills/index.ts create mode 100644 src/renderer/state/slices/skill.ts 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 ? ( void; +} + +export function SkillsSection({ companyId: _companyId, agents: _agents, onRefresh: _onRefresh }: SkillsSectionProps) { + return ( +
+

Skills section placeholder -- Task 2

+
+ ); +} 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/types.ts b/src/shared/types.ts index c3cefc6a..334f415b 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" @@ -453,6 +454,50 @@ export interface OnboardingBootstrapResult { runId: string | null; } +// ── Skill types ── + +export type SkillSourceType = "github" | "local_path" | "url" | "skills_sh" | "managed"; +export type TrustLevel = "markdown_only" | "assets" | "scripts_executables"; + +export interface FileInventoryEntry { + path: string; + kind: "skill" | "reference" | "script" | "asset" | "markdown" | "other"; +} + +export interface SkillRecord { + id: string; + companyId: string; + key: string; + slug: string; + name: string; + description: string | null; + markdown: string; + sourceType: SkillSourceType; + sourceLocator: string | null; + sourceRef: string | null; + trustLevel: TrustLevel; + compatibility: string; + fileInventory: FileInventoryEntry[]; + metadata: Record | null; + createdAt: string; + updatedAt: string; +} + +export interface SkillImportResult { + skillId: string; + key: string; + name: string; + isUpdate: boolean; +} + +export interface SkillUpdateStatus { + skillId: string; + key: string; + currentRef: string | null; + latestRef: string | null; + hasUpdate: boolean; +} + export type SocialPlatform = "twitter" | "linkedin" | "instagram" | "facebook" | "reddit" | "youtube" | "tiktok" | "bluesky" | "other"; export type SocialAccountStatus = "active" | "logged_in" | "login_required" | "suspended" | "paused"; export type BrowserActionType = "post" | "reply" | "like" | "follow" | "browse_feed" | "screenshot" | "navigate" | "search"; From 49b75257fb6f79846b24035ffe08d94b9ba212af 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 01:51:06 -0400 Subject: [PATCH 2/2] feat(03-01): add SkillsSection layout, SkillList, SkillFileTree, and SkillImportModal - Create SkillsSection with two-column grid layout (19rem + flex) matching Paperclip pattern - Create SkillList with source icons, expandable file trees, CSS grid animation - Create SkillFileTree with recursive rendering, dir/file icons, indent levels - Create SkillImportModal with 4 tabs: GitHub, Local Path, HTTP URL, skills.sh - Wire listSkills IPC for skill loading and importSkill IPC for import flow - Add skill IPC channels, Zod schemas, and DesktopApi methods to contracts.ts - Add skill IPC bridge methods to preload/index.ts - All Phosphor icons, all ui.tsx components, no external dependencies Co-Authored-By: Claude Opus 4.6 (1M context) --- src/preload/index.ts | 14 ++ .../components/skills/SkillFileTree.tsx | 136 +++++++++++++ .../components/skills/SkillImportModal.tsx | 184 ++++++++++++++++++ src/renderer/components/skills/SkillList.tsx | 135 +++++++++++++ .../components/skills/SkillsSection.tsx | 156 ++++++++++++++- src/shared/contracts.ts | 52 ++++- 6 files changed, 671 insertions(+), 6 deletions(-) create mode 100644 src/renderer/components/skills/SkillFileTree.tsx create mode 100644 src/renderer/components/skills/SkillImportModal.tsx create mode 100644 src/renderer/components/skills/SkillList.tsx diff --git a/src/preload/index.ts b/src/preload/index.ts index d55e5e99..3f6d2610 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -76,6 +76,20 @@ const api: DesktopApi = { deleteWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.deleteWorkflow, input), runWorkflow: (input) => ipcRenderer.invoke(IPC_CHANNELS.runWorkflow, input), autoAssignTask: (input) => ipcRenderer.invoke(IPC_CHANNELS.autoAssignTask, input), + importSkill: (companyId, source) => + ipcRenderer.invoke(IPC_CHANNELS.importSkill, { companyId, source }), + listSkills: (companyId) => + ipcRenderer.invoke(IPC_CHANNELS.listSkills, { companyId }), + getSkillDetail: (companyId, skillId) => + ipcRenderer.invoke(IPC_CHANNELS.getSkillDetail, { companyId, skillId }), + deleteSkill: (companyId, skillId) => + ipcRenderer.invoke(IPC_CHANNELS.deleteSkill, { companyId, skillId }), + assignSkillToAgent: (companyId, agentId, skillId) => + ipcRenderer.invoke(IPC_CHANNELS.assignSkillToAgent, { companyId, agentId, skillId }), + removeSkillFromAgent: (companyId, agentId, skillId) => + ipcRenderer.invoke(IPC_CHANNELS.removeSkillFromAgent, { companyId, agentId, skillId }), + checkSkillUpdate: (companyId, skillId) => + ipcRenderer.invoke(IPC_CHANNELS.checkSkillUpdate, { companyId, skillId }), recoveryRetry: (taskId: string, agentId: string, companyId: string) => ipcRenderer.invoke("recovery:retry", { taskId, agentId, companyId }), recoveryDismiss: (taskId: string) => diff --git a/src/renderer/components/skills/SkillFileTree.tsx b/src/renderer/components/skills/SkillFileTree.tsx new file mode 100644 index 00000000..e143b407 --- /dev/null +++ b/src/renderer/components/skills/SkillFileTree.tsx @@ -0,0 +1,136 @@ +import { + CaretDown, + CaretRight, + FileCode, + FileText, + FolderOpen, + FolderSimple, +} from "@phosphor-icons/react"; +import type { FileInventoryEntry } from "@shared/types"; + +export type SkillTreeNode = { + name: string; + path: string | null; + kind: "dir" | "file"; + fileKind?: FileInventoryEntry["kind"]; + children: SkillTreeNode[]; +}; + +const BASE_INDENT = 16; +const STEP_INDENT = 24; + +export function buildTree(entries: FileInventoryEntry[]): SkillTreeNode[] { + const root: SkillTreeNode = { name: "", path: null, kind: "dir", children: [] }; + + for (const entry of entries) { + const segments = entry.path.split("/").filter(Boolean); + let current = root; + let currentPath = ""; + for (const [index, segment] of segments.entries()) { + currentPath = currentPath ? `${currentPath}/${segment}` : segment; + const isLeaf = index === segments.length - 1; + let next = current.children.find((child) => 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 index ec565fc2..5a996c06 100644 --- a/src/renderer/components/skills/SkillsSection.tsx +++ b/src/renderer/components/skills/SkillsSection.tsx @@ -1,15 +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"; -interface SkillsSectionProps { +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); -export function SkillsSection({ companyId: _companyId, agents: _agents, onRefresh: _onRefresh }: SkillsSectionProps) { return ( -
-

Skills section placeholder -- Task 2

+
+ {/* 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/shared/contracts.ts b/src/shared/contracts.ts index 5ff2552a..3439496c 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { AgentMessageRecord, AgentMetrics, BrowserActionRecord, CommentRecord, CompanyMetrics, DesktopEvent, InboxItem, OnboardingBootstrapResult, ProfileSnapshot, RunLogChunk, StandupReport } from "./types"; +import type { AgentMessageRecord, AgentMetrics, BrowserActionRecord, CommentRecord, CompanyMetrics, DesktopEvent, InboxItem, OnboardingBootstrapResult, ProfileSnapshot, RunLogChunk, SkillImportResult, SkillRecord, SkillUpdateStatus, StandupReport } from "./types"; export const IPC_CHANNELS = { loadSnapshot: "agent-company:load-snapshot", @@ -73,6 +73,13 @@ export const IPC_CHANNELS = { runWorkflow: "agent-company:run-workflow", autoAssignTask: "agent-company:auto-assign-task", searchMessages: "agent-company:search-messages", + importSkill: "agent-company:import-skill", + listSkills: "agent-company:list-skills", + getSkillDetail: "agent-company:get-skill-detail", + deleteSkill: "agent-company:delete-skill", + assignSkillToAgent: "agent-company:assign-skill-to-agent", + removeSkillFromAgent: "agent-company:remove-skill-from-agent", + checkSkillUpdate: "agent-company:check-skill-update", } as const; const optionalId = z.preprocess( @@ -514,6 +521,42 @@ export const autoAssignSchema = z.object({ companyId: z.string().uuid(), }); +export const importSkillSchema = z.object({ + companyId: z.string().uuid(), + source: z.string().min(1), +}); + +export const listSkillsSchema = z.object({ + companyId: z.string().uuid(), +}); + +export const getSkillDetailSchema = z.object({ + companyId: z.string().uuid(), + skillId: z.string().uuid(), +}); + +export const deleteSkillSchema = z.object({ + companyId: z.string().uuid(), + skillId: z.string().uuid(), +}); + +export const assignSkillToAgentSchema = z.object({ + companyId: z.string().uuid(), + agentId: z.string().uuid(), + skillId: z.string().uuid(), +}); + +export const removeSkillFromAgentSchema = z.object({ + companyId: z.string().uuid(), + agentId: z.string().uuid(), + skillId: z.string().uuid(), +}); + +export const checkSkillUpdateSchema = z.object({ + companyId: z.string().uuid(), + skillId: z.string().uuid(), +}); + export type DesktopResult = | { ok: true; data: T } | { ok: false; error: { code: string; message: string } }; @@ -592,5 +635,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; };