merge: resolve wave 1 worktree conflicts (keep Phase 02 schema validation)
This commit is contained in:
commit
6397c118ea
13 files changed
+685
-1
No files matched your search
@@ -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 ? (
|
||||
<SkillsSection companyId={currentCompany.id} agents={companyAgents} onRefresh={refresh} />
|
||||
) : null}
|
||||
|
||||
{section === "automation" && currentCompany ? (
|
||||
<AutomationPanel
|
||||
key={currentCompany.id}
|
||||
|
||||
@@ -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<string>;
|
||||
onToggleDir: (path: string) => void;
|
||||
onSelectPath: (path: string) => void;
|
||||
depth?: number;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{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 (
|
||||
<div key={node.path ?? node.name}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-9 w-full items-center gap-2 text-left text-[13px] text-[color:var(--muted)] hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"
|
||||
style={{ paddingLeft: `${BASE_INDENT + depth * STEP_INDENT}px` }}
|
||||
onClick={() => node.path && onToggleDir(node.path)}
|
||||
>
|
||||
<FolderIcon size={14} className="shrink-0" />
|
||||
<span className="min-w-0 truncate">{node.name}</span>
|
||||
<ChevronIcon size={12} className="ml-auto mr-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
{expanded && (
|
||||
<SkillFileTree
|
||||
nodes={node.children}
|
||||
selectedPath={selectedPath}
|
||||
expandedDirs={expandedDirs}
|
||||
onToggleDir={onToggleDir}
|
||||
onSelectPath={onSelectPath}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FileIcon = fileIcon(node.fileKind);
|
||||
const isSelected = node.path === selectedPath;
|
||||
return (
|
||||
<button
|
||||
key={node.path ?? node.name}
|
||||
type="button"
|
||||
className={`flex min-h-9 w-full items-center gap-2 text-left text-[13px] hover:bg-[color:var(--panel-soft)] ${
|
||||
isSelected
|
||||
? "text-[color:var(--text)] font-medium"
|
||||
: "text-[color:var(--muted)]"
|
||||
}`}
|
||||
style={{ paddingLeft: `${BASE_INDENT + depth * STEP_INDENT}px` }}
|
||||
onClick={() => node.path && onSelectPath(node.path)}
|
||||
>
|
||||
<FileIcon size={14} className="shrink-0" />
|
||||
<span className="min-w-0 truncate">{node.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<TabId>("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<TabId, string> = {
|
||||
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 (
|
||||
<Modal open={open} title="Import Skill" onClose={handleClose} width="560px">
|
||||
<div className="space-y-5">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-[color:var(--line)]">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={`flex items-center gap-1.5 px-3 py-2 text-[12px] font-medium transition-colors ${
|
||||
isActive
|
||||
? "border-b-2 border-[color:var(--accent)] text-[color:var(--text)]"
|
||||
: "text-[color:var(--muted)] hover:text-[color:var(--text)]"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.id);
|
||||
setSource("");
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="space-y-3">
|
||||
{activeTab === "local" ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
placeholder={placeholder.local}
|
||||
/>
|
||||
<ActionButton label="Browse" onClick={() => void handleBrowse()} />
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
placeholder={placeholder[activeTab]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "skills_sh" && (
|
||||
<p className="text-[12px] text-[color:var(--muted)]">
|
||||
Enter a skills.sh install command (e.g., <code className="rounded bg-[color:var(--panel-soft)] px-1 py-0.5 font-mono text-[11px]">npx skills.sh install skill-name</code>) or a direct skills.sh URL.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<ActionButton label="Cancel" onClick={handleClose} />
|
||||
<ActionButton
|
||||
label="Import"
|
||||
tone="accent"
|
||||
onClick={() => void handleImport()}
|
||||
loading={isImporting}
|
||||
disabled={!canImport}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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<string, typeof GithubLogo> = {
|
||||
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<string | null>(null);
|
||||
const [expandedDirs, setExpandedDirs] = useState<Record<string, Set<string>>>({});
|
||||
const [selectedPaths, setSelectedPaths] = useState<Record<string, string>>({});
|
||||
|
||||
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<string>();
|
||||
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 (
|
||||
<div className="px-4 py-6 text-[13px] text-[color:var(--muted)]">
|
||||
No skills match this filter.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{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<string>();
|
||||
const filePath = selectedPaths[skill.id] ?? "SKILL.md";
|
||||
|
||||
return (
|
||||
<div key={skill.id} className="border-b border-[color:var(--line)]">
|
||||
<div
|
||||
className={`group flex items-center gap-2 px-3 py-1.5 hover:bg-[color:var(--panel-soft)] ${
|
||||
isSelected ? "text-[color:var(--text)]" : "text-[color:var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
<SourceIcon
|
||||
size={14}
|
||||
className="shrink-0 opacity-75 transition-opacity group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => onSelectSkill(skill.id)}
|
||||
>
|
||||
<span className="text-[13px] font-medium leading-5 [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3]">
|
||||
{skill.name}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[4px] text-[color:var(--muted)] opacity-60 transition-opacity hover:bg-[color:var(--panel-soft)] hover:opacity-100"
|
||||
onClick={() => handleToggleSkill(skill.id)}
|
||||
aria-label={isExpanded ? "Collapse file tree" : "Expand file tree"}
|
||||
>
|
||||
{isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="overflow-hidden transition-[grid-template-rows] duration-200"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateRows: isExpanded ? "1fr" : "0fr",
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
{tree.length > 0 && (
|
||||
<SkillFileTree
|
||||
nodes={tree}
|
||||
selectedPath={filePath}
|
||||
expandedDirs={dirState}
|
||||
onToggleDir={(path) => handleToggleDir(skill.id, path)}
|
||||
onSelectPath={(path) => handleSelectPath(skill.id, path)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="grid h-full grid-cols-1 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||
{/* Left column -- skill list sidebar */}
|
||||
<aside className="flex flex-col overflow-y-auto border-r border-[color:var(--line)]">
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center gap-3 px-4 py-3">
|
||||
<h2 className="text-[20px] font-bold tracking-[-0.025em] text-[color:var(--text)]">
|
||||
Skills
|
||||
</h2>
|
||||
{skills.length > 0 && (
|
||||
<span className="inline-flex h-[20px] min-w-[20px] items-center justify-center rounded-full bg-[color:var(--panel-soft)] px-1.5 text-[11px] font-semibold text-[color:var(--muted-strong)]">
|
||||
{skills.length}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring ml-auto flex h-7 w-7 items-center justify-center rounded-[6px] text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"
|
||||
onClick={() => setSkillImportModalOpen(true)}
|
||||
aria-label="Import skill"
|
||||
>
|
||||
<Plus size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter input */}
|
||||
<div className="shrink-0 px-3 pb-2">
|
||||
<div className="relative">
|
||||
<MagnifyingGlass
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[color:var(--muted)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
className="focus-ring w-full rounded-[8px] border border-[color:var(--line)] bg-[color:var(--panel)] py-2 pl-8 pr-3 text-[13px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]"
|
||||
value={skillFilter}
|
||||
onChange={(e) => setSkillFilter(e.target.value)}
|
||||
placeholder="Filter skills..."
|
||||
aria-label="Filter skills"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{skills.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No skills imported"
|
||||
detail="Import skills from GitHub, a local path, or skills.sh to get started."
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
className="focus-ring inline-flex items-center gap-2 rounded-[8px] border border-transparent bg-[color:var(--accent)] px-4 py-2.5 text-[12px] font-bold text-[color:var(--text-on-accent)] shadow-[0_1px_3px_rgba(74,93,122,0.35)] transition hover:bg-[color:var(--accent-hover)]"
|
||||
onClick={() => setSkillImportModalOpen(true)}
|
||||
>
|
||||
<Plus size={14} weight="bold" />
|
||||
Import Skill
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SkillList
|
||||
skills={skills}
|
||||
selectedSkillId={selectedSkillId}
|
||||
skillFilter={skillFilter}
|
||||
onSelectSkill={handleSelectSkill}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right column -- skill detail */}
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{selectedSkill ? (
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-[color:var(--muted)]">
|
||||
<div className="text-center">
|
||||
<PuzzlePiece size={32} className="mx-auto mb-3 opacity-40" />
|
||||
<p className="text-[14px] font-medium text-[color:var(--text)]">{selectedSkill.name}</p>
|
||||
<p className="mt-1 text-[13px]">Skill detail pane -- Plan 02</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<EmptyState
|
||||
title="Select a skill to view details"
|
||||
detail="Choose a skill from the list to see its content, file tree, and configuration."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Import modal */}
|
||||
<SkillImportModal
|
||||
open={skillImportModalOpen}
|
||||
onClose={() => setSkillImportModalOpen(false)}
|
||||
companyId={companyId}
|
||||
onImported={handleImported}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SkillsSection } from "./SkillsSection";
|
||||
@@ -22,6 +22,7 @@ export const en: Record<string, string> = {
|
||||
"nav.costs": "Costs",
|
||||
"nav.activity": "Activity",
|
||||
"nav.social": "Social Media",
|
||||
"nav.skills": "Skills",
|
||||
"nav.connectors": "Connectors",
|
||||
"nav.workspaces": "Workspaces",
|
||||
"nav.settings": "Settings",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const zh: Record<string, string> = {
|
||||
"nav.costs": "成本",
|
||||
"nav.activity": "活动",
|
||||
"nav.social": "社交媒体",
|
||||
"nav.skills": "技能",
|
||||
"nav.connectors": "连接器",
|
||||
"nav.workspaces": "工作区",
|
||||
"nav.settings": "设置",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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<AppState>()(
|
||||
devtools(
|
||||
@@ -33,6 +35,7 @@ export const useAppStore = create<AppState>()(
|
||||
...createUiSlice(...a),
|
||||
...createCommunicationSlice(...a),
|
||||
...createSocialSlice(...a),
|
||||
...createSkillSlice(...a),
|
||||
})),
|
||||
{
|
||||
name: "AgentCompany",
|
||||
|
||||
@@ -642,5 +642,12 @@ export type DesktopApi = {
|
||||
recoveryRetry: (taskId: string, agentId: string, companyId: string) => Promise<DesktopResult<null>>;
|
||||
recoveryDismiss: (taskId: string) => Promise<DesktopResult<null>>;
|
||||
recoveryFail: (taskId: string) => Promise<DesktopResult<null>>;
|
||||
importSkill: (companyId: string, source: string) => Promise<DesktopResult<SkillImportResult[]>>;
|
||||
listSkills: (companyId: string) => Promise<DesktopResult<SkillRecord[]>>;
|
||||
getSkillDetail: (companyId: string, skillId: string) => Promise<DesktopResult<SkillRecord>>;
|
||||
deleteSkill: (companyId: string, skillId: string) => Promise<DesktopResult<void>>;
|
||||
assignSkillToAgent: (companyId: string, agentId: string, skillId: string) => Promise<DesktopResult<void>>;
|
||||
removeSkillFromAgent: (companyId: string, agentId: string, skillId: string) => Promise<DesktopResult<void>>;
|
||||
checkSkillUpdate: (companyId: string, skillId: string) => Promise<DesktopResult<SkillUpdateStatus>>;
|
||||
subscribe: (callback: (event: DesktopEvent) => void) => () => void;
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export type SectionId =
|
||||
| "activity"
|
||||
| "workspaces"
|
||||
| "social"
|
||||
| "skills"
|
||||
| "automation"
|
||||
| "communication"
|
||||
| "documents"
|
||||
|
||||
Reference in new issue
Block a user