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) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-26 01:51:06 -04:00
1 parent e8d642941f
commit 49b75257fb
6 files changed
+671 -6

No files matched your search

@@ -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>
);
}