Files
pi-agent-integrated/pi-web/components/SkillsConfig.tsx
T

1276 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useI18n } from "@/hooks/useI18n";
import type {
SkillInfo as Skill,
SkillInstallScope,
SkillSearchResult,
SkillsResponse,
SkillUpdateResult,
} from "@/lib/api-types";
function shortenPath(p: string): string {
// Match common home dir patterns: /Users/xxx, /home/xxx
return p.replace(/^\/(?:Users|home)\/[^/]+/, "~");
}
function sourceLabel(skill: Skill): string {
const src = skill.sourceInfo?.source;
const scope = skill.sourceInfo?.scope;
if (scope === "user" || src === "user") return "app";
if (scope === "project" || src === "project") return "project";
return "path";
}
function updateKey(skill: Skill): string | null {
return skill.install
? `${skill.install.scope}\0${skill.install.package}`
: null;
}
function shortVersion(version?: string): string {
return version ? version.slice(0, 8) : "unknown";
}
function Toggle({
enabled,
loading,
onToggle,
}: {
enabled: boolean;
loading: boolean;
onToggle: () => void;
}) {
const { t } = useI18n();
return (
<button
onClick={onToggle}
disabled={loading}
title={
enabled
? t("i18n.visibleInPrompt")
: t("i18n.hiddenFromPrompt")
}
style={{
flexShrink: 0,
width: 40,
height: 22,
borderRadius: 11,
border: "none",
padding: 0,
cursor: loading ? "wait" : "pointer",
background: enabled ? "var(--accent)" : "var(--border)",
position: "relative",
transition: "background 0.18s",
outline: "none",
}}
>
<span
style={{
position: "absolute",
top: 3,
left: enabled ? 21 : 3,
width: 16,
height: 16,
borderRadius: "50%",
background: "var(--bg)",
boxShadow: "0 1px 4px rgba(0,0,0,0.22)",
transition: "left 0.18s cubic-bezier(.4,0,.2,1)",
}}
/>
</button>
);
}
function SkillDetail({
skill,
cwd,
onToggle,
toggling,
saveError,
updateStatus,
checkingUpdate,
updating,
updateError,
onCheckUpdate,
onUpdate,
}: {
skill: Skill;
cwd: string;
onToggle: (skill: Skill) => void;
toggling: boolean;
saveError: string | null;
updateStatus?: SkillUpdateResult;
checkingUpdate: boolean;
updating: boolean;
updateError: string | null;
onCheckUpdate: () => void;
onUpdate: () => void;
}) {
const { t } = useI18n();
const label = sourceLabel(skill);
const enabled = !skill.disableModelInvocation;
function displayPath(p: string): string {
if (label === "project" && p.startsWith(cwd)) {
const rel = p.slice(cwd.length).replace(/^[/\\]/, "");
return `./${rel}`;
}
return shortenPath(p);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* Path + tag + toggle */}
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span
style={{
fontSize: 10,
padding: "1px 5px",
borderRadius: 3,
flexShrink: 0,
background:
label === "project"
? "rgba(99,102,241,0.12)"
: "rgba(120,120,120,0.12)",
color:
label === "project" ? "rgba(99,102,241,0.8)" : "var(--text-dim)",
}}
>
{label}
</span>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--text-dim)",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{displayPath(skill.filePath)}
</span>
<Toggle
enabled={enabled}
loading={toggling}
onToggle={() => onToggle(skill)}
/>
{saveError && (
<span style={{ fontSize: 12, color: "#f87171", flexShrink: 0 }}>
{saveError}
</span>
)}
</div>
{skill.install?.skillsShUrl && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span
style={{ fontSize: 12, color: "var(--text-muted)", fontWeight: 500 }}
>
Source
</span>
<a
href={skill.install.skillsShUrl}
target="_blank"
rel="noreferrer"
title={skill.install.skillsShUrl}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "fit-content",
maxWidth: "100%",
color: "var(--accent)",
textDecoration: "none",
}}
>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{skill.install.skillsShUrl.replace(/^https?:\/\//, "")} ↗
</span>
</a>
</div>
)}
{skill.install && (
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
<span
style={{ fontSize: 12, color: "var(--text-muted)", fontWeight: 500 }}
>
Version
</span>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
flexWrap: "wrap",
}}
>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "var(--text-muted)",
}}
>
{shortVersion(updateStatus?.currentVersion ?? skill.install.versionHash)}
</span>
{skill.install.canCheckForUpdates && (
<button
onClick={onCheckUpdate}
disabled={checkingUpdate || updating}
style={{
padding: "4px 9px",
border: "1px solid var(--border)",
borderRadius: 5,
background: "none",
color: "var(--text-muted)",
cursor: checkingUpdate || updating ? "not-allowed" : "pointer",
opacity: checkingUpdate || updating ? 0.5 : 1,
fontSize: 11,
}}
>
{t("i18n.check")}
</button>
)}
{updateStatus?.state === "update-available" && (
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "#d97706",
}}
>
{shortVersion(updateStatus.latestVersion)}
</span>
)}
{(checkingUpdate ||
(updateStatus && updateStatus.state !== "update-available")) && (
<span
style={{
fontSize: 12,
color: checkingUpdate
? "var(--accent)"
: updateStatus?.state === "up-to-date"
? "#16a34a"
: updateStatus?.state === "error"
? "#ef4444"
: "var(--text-dim)",
}}
>
{checkingUpdate
? t("i18n.checking")
: updateStatus?.state === "up-to-date"
? t("i18n.upToDate")
: updateStatus?.state === "unsupported"
? t("i18n.automaticChecksUnavailable")
: updateStatus?.message || t("i18n.checkFailed")}
</span>
)}
{updateStatus?.state === "update-available" && (
<button
onClick={onUpdate}
disabled={updating || checkingUpdate}
style={{
padding: "4px 10px",
border: "none",
borderRadius: 5,
background: "var(--accent)",
color: "#fff",
cursor: updating || checkingUpdate ? "not-allowed" : "pointer",
opacity: updating || checkingUpdate ? 0.5 : 1,
fontSize: 11,
fontWeight: 600,
}}
>
{updating ? t("i18n.updating") : t("i18n.update")}
</button>
)}
</div>
{updateError && (
<span style={{ fontSize: 12, color: "#ef4444" }}>{updateError}</span>
)}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span
style={{ fontSize: 12, color: "var(--text-muted)", fontWeight: 500 }}
>
Name
</span>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 14,
color: "var(--text)",
}}
>
{skill.name}
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span
style={{ fontSize: 12, color: "var(--text-muted)", fontWeight: 500 }}
>
Description
</span>
<span
style={{ fontSize: 14, color: "var(--text-muted)", lineHeight: 1.6 }}
>
{skill.description}
</span>
</div>
</div>
);
}
function AddSkillPanel({
cwd,
installedPackages,
onInstalled,
}: {
cwd: string;
installedPackages: Record<SkillInstallScope, ReadonlySet<string>>;
onInstalled: () => void;
}) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const [results, setResults] = useState<SkillSearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [installing, setInstalling] = useState<string | null>(null);
const [installError, setInstallError] = useState<string | null>(null);
const [newlyInstalledPkgs, setNewlyInstalledPkgs] = useState<Set<string>>(
new Set(),
);
const scope = "global" as const;
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const search = useCallback(async (q: string) => {
if (!q.trim()) return;
setSearching(true);
setSearchError(null);
setResults([]);
try {
const res = await fetch("/api/skills/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: q.trim() }),
});
const d = (await res.json()) as {
results?: SkillSearchResult[];
error?: string;
};
if (d.error) {
setSearchError(d.error);
return;
}
setResults(d.results ?? []);
if ((d.results ?? []).length === 0) setSearchError("No skills found");
} catch (e) {
setSearchError(String(e));
} finally {
setSearching(false);
}
}, []);
const install = useCallback(
async (pkg: string) => {
setInstalling(pkg);
setInstallError(null);
try {
const res = await fetch("/api/skills/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ package: pkg, scope, cwd }),
});
const d = (await res.json()) as { success?: boolean; error?: string };
if (!res.ok || d.error) {
setInstallError(d.error ?? `HTTP ${res.status}`);
return;
}
setNewlyInstalledPkgs((prev) =>
new Set(prev).add(`${scope}:${pkg}`),
);
onInstalled();
} catch (e) {
setInstallError(String(e));
} finally {
setInstalling(null);
}
},
[onInstalled, cwd],
);
const installPath = "data/skills-home/.agents/skills/";
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* ── Header area ── */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
marginBottom: 20,
}}
>
<div style={{ fontSize: 14, fontWeight: 600, color: "var(--text)" }}>
{t("i18n.addSkill")}
</div>
{/* Search row */}
<div style={{ display: "flex", gap: 8 }}>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") search(query);
}}
placeholder={t("i18n.skillSearchPlaceholder")}
style={{
flex: 1,
padding: "7px 10px",
fontSize: 13,
background: "var(--bg-panel)",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text)",
outline: "none",
}}
/>
<button
onClick={() => search(query)}
disabled={searching || !query.trim()}
style={{
padding: "7px 16px",
fontSize: 13,
borderRadius: 6,
border: "none",
background: "var(--accent)",
color: "#fff",
cursor: searching || !query.trim() ? "not-allowed" : "pointer",
opacity: searching || !query.trim() ? 0.5 : 1,
flexShrink: 0,
}}
>
{searching ? t("i18n.searching") : t("i18n.search")}
</button>
</div>
{/* Scope + install path row */}
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div
style={{
display: "flex",
borderRadius: 5,
border: "1px solid var(--border)",
overflow: "hidden",
fontSize: 12,
flexShrink: 0,
}}
>
<span style={{ padding: "3px 10px", color: "var(--text)", fontWeight: 600 }}>
app
</span>
</div>
<span
style={{
fontSize: 12,
color: "var(--text-dim)",
fontFamily: "var(--font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
→ {installPath}
</span>
</div>
{/* Errors */}
{searchError && (
<div style={{ fontSize: 12, color: "#f87171" }}>{searchError}</div>
)}
{installError && (
<div
style={{ fontSize: 12, color: "#f87171", wordBreak: "break-word" }}
>
{installError}
</div>
)}
</div>
{/* ── Results list ── */}
{results.length > 0 ? (
<div style={{ flex: 1, overflowY: "auto" }}>
{results.map((r) => {
const isInstalled =
installedPackages[scope].has(r.package) ||
newlyInstalledPkgs.has(`${scope}:${r.package}`);
const isInstalling = installing === r.package;
// split "owner/repo@skill" for cleaner display
const atIdx = r.package.indexOf("@");
const repopart = atIdx > -1 ? r.package.slice(0, atIdx) : r.package;
const skillpart = atIdx > -1 ? r.package.slice(atIdx + 1) : null;
return (
<div
key={r.package}
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "12px 0",
borderBottom: "1px solid var(--border)",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
{/* skill name prominent */}
<div
style={{
fontSize: 13,
fontWeight: 600,
color: "var(--text)",
marginBottom: 3,
}}
>
{skillpart ?? repopart}
</div>
{/* repo + installs + link row */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
flexWrap: "wrap",
}}
>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--text-dim)",
}}
>
{repopart}
</span>
<span
style={{
fontSize: 12,
color: "var(--text-muted)",
fontWeight: 500,
}}
>
{r.installs}
</span>
{r.url && (
<a
href={r.url}
target="_blank"
rel="noreferrer"
style={{
fontSize: 12,
color: "var(--accent)",
textDecoration: "none",
}}
>
skills.sh ↗
</a>
)}
</div>
</div>
<button
onClick={() =>
!isInstalled && !isInstalling && install(r.package)
}
disabled={isInstalled || isInstalling || installing !== null}
style={{
flexShrink: 0,
padding: "5px 14px",
fontSize: 12,
fontWeight: 500,
borderRadius: 5,
border: "1px solid var(--border)",
cursor:
isInstalled || isInstalling || installing !== null
? "not-allowed"
: "pointer",
background: isInstalled ? "rgba(34,197,94,0.1)" : "none",
color: isInstalled
? "#16a34a"
: isInstalling
? "var(--accent)"
: "var(--text-muted)",
transition: "color 0.12s",
}}
>
{isInstalled
? `✓ ${t("i18n.installed")}`
: isInstalling
? t("i18n.installing")
: t("i18n.install")}
</button>
</div>
);
})}
</div>
) : (
!searchError &&
!searching && (
<div
style={{ fontSize: 13, color: "var(--text-dim)", lineHeight: 1.8 }}
>
Search{" "}
<a
href="https://skills.sh"
target="_blank"
rel="noreferrer"
style={{ color: "var(--accent)", textDecoration: "none" }}
>
skills.sh
</a>{" "}
to discover and install skills for your agent.
</div>
)
)}
</div>
);
}
export function SkillsConfig({
cwd,
onClose,
}: {
cwd: string;
onClose: () => void;
}) {
const isMobile = useIsMobile();
const { t } = useI18n();
const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [toggling, setToggling] = useState<Set<string>>(new Set());
const [saveError, setSaveError] = useState<string | null>(null);
const [addMode, setAddMode] = useState(false);
const [updateStatuses, setUpdateStatuses] = useState<Record<string, SkillUpdateResult>>({});
const [checkingUpdates, setCheckingUpdates] = useState<Set<string>>(new Set());
const [checkingAll, setCheckingAll] = useState(false);
const [updatingSkill, setUpdatingSkill] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
const loadSkills = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/skills?cwd=${encodeURIComponent(cwd)}`);
const d = (await res.json()) as Partial<SkillsResponse> & { error?: string };
if (!res.ok || d.error) throw new Error(d.error ?? `HTTP ${res.status}`);
const list = d.skills ?? [];
setSkills(list);
if (list.length > 0 && !selected) setSelected(list[0].filePath);
return list;
} catch (e) {
setError(String(e));
return [];
} finally {
setLoading(false);
}
}, [cwd, selected]);
useEffect(() => {
setUpdateStatuses({});
setUpdateError(null);
void loadSkills();
}, [cwd]); // eslint-disable-line react-hooks/exhaustive-deps
const checkForUpdates = useCallback(async (skill?: Skill) => {
const targets = skill
? [skill]
: skills.filter((item) => Boolean(item.install));
const keys = targets
.map(updateKey)
.filter((key): key is string => Boolean(key));
if (keys.length === 0) return;
setUpdateError(null);
setCheckingUpdates((current) => new Set([...current, ...keys]));
if (!skill) setCheckingAll(true);
try {
const res = await fetch("/api/skills/check", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
cwd,
package: skill?.install?.package,
scope: skill?.install?.scope,
}),
});
const data = (await res.json()) as {
updates?: SkillUpdateResult[];
error?: string;
};
if (!res.ok || data.error) throw new Error(data.error ?? `HTTP ${res.status}`);
setUpdateStatuses((current) => {
const next = { ...current };
for (const update of data.updates ?? []) {
next[`${update.scope}\0${update.package}`] = update;
}
return next;
});
} catch (e) {
setUpdateError(e instanceof Error ? e.message : String(e));
} finally {
setCheckingUpdates((current) => {
const next = new Set(current);
for (const key of keys) next.delete(key);
return next;
});
if (!skill) setCheckingAll(false);
}
}, [cwd, skills]);
const updateInstalledSkill = useCallback(async (skill: Skill) => {
if (!skill.install) return;
const key = updateKey(skill)!;
setUpdatingSkill(key);
setUpdateError(null);
try {
const res = await fetch("/api/skills/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
cwd,
package: skill.install.package,
scope: skill.install.scope,
}),
});
const data = (await res.json()) as {
success?: boolean;
skill?: Skill;
error?: string;
};
if (!res.ok || data.error || !data.success) {
throw new Error(data.error ?? `HTTP ${res.status}`);
}
await loadSkills();
const versionHash = data.skill?.install?.versionHash;
setUpdateStatuses((current) => ({
...current,
[key]: {
package: skill.install!.package,
scope: skill.install!.scope,
state: "up-to-date",
currentVersion: versionHash,
latestVersion: versionHash,
},
}));
} catch (e) {
setUpdateError(e instanceof Error ? e.message : String(e));
} finally {
setUpdatingSkill(null);
}
}, [cwd, loadSkills]);
const toggle = useCallback(async (skill: Skill) => {
const next = !skill.disableModelInvocation;
setToggling((s) => new Set(s).add(skill.filePath));
setSaveError(null);
try {
const res = await fetch("/api/skills", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
filePath: skill.filePath,
disableModelInvocation: next,
}),
});
const d = (await res.json()) as { success?: boolean; error?: string };
if (!res.ok || d.error) {
setSaveError(d.error ?? `HTTP ${res.status}`);
return;
}
setSkills((prev) =>
prev.map((s) =>
s.filePath === skill.filePath
? { ...s, disableModelInvocation: next }
: s,
),
);
} catch (e) {
setSaveError(String(e));
} finally {
setToggling((s) => {
const n = new Set(s);
n.delete(skill.filePath);
return n;
});
}
}, []);
const selectedSkill = skills.find((s) => s.filePath === selected) ?? null;
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
background: "rgba(0,0,0,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
width: isMobile ? "calc(100vw - 16px)" : 860,
maxWidth: "calc(100vw - 16px)",
height: isMobile ? "calc(100dvh - 16px)" : "78vh",
maxHeight: "calc(100dvh - 16px)",
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: 10,
display: "flex",
flexDirection: "column",
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 18px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span
style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}
>
{t("common.skills")}
</span>
<code
style={{
fontSize: 11,
color: "var(--text-muted)",
fontFamily: "var(--font-mono)",
maxWidth: 320,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{shortenPath(cwd)}
</code>
</div>
<button
onClick={onClose}
style={{
background: "none",
border: "none",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 20,
lineHeight: 1,
padding: "2px 6px",
}}
>
×
</button>
</div>
{/* Body */}
<div style={{ flex: 1, display: "flex", flexDirection: isMobile ? "column" : "row", overflow: "hidden" }}>
{/* Left: skill list */}
<div
style={{
width: isMobile ? "100%" : 210,
maxHeight: isMobile ? "40vh" : undefined,
borderRight: isMobile ? "none" : "1px solid var(--border)",
borderBottom: isMobile ? "1px solid var(--border)" : "none",
display: "flex",
flexDirection: "column",
flexShrink: 0,
background: "var(--bg-panel)",
}}
>
<div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
{loading ? (
<div
style={{
padding: "10px 8px",
fontSize: 12,
color: "var(--text-muted)",
}}
>
{t("i18n.loading")}
</div>
) : error ? (
<div
style={{
padding: "10px 8px",
fontSize: 11,
color: "#f87171",
}}
>
{error}
</div>
) : skills.length === 0 ? (
<div
style={{
padding: "10px 8px",
fontSize: 11,
color: "var(--text-dim)",
}}
>
{t("i18n.noSkills")}
</div>
) : (
(() => {
const groups: { label: string; skills: typeof skills }[] = [];
const groupDefinitions = [
{
label: "project / skills.sh",
matches: (skill: Skill) =>
sourceLabel(skill) === "project" &&
Boolean(skill.install?.skillsShUrl),
},
{
label: "project",
matches: (skill: Skill) =>
sourceLabel(skill) === "project" &&
!skill.install?.skillsShUrl,
},
{
label: "app / skills.sh",
matches: (skill: Skill) =>
sourceLabel(skill) === "app" &&
Boolean(skill.install?.skillsShUrl),
},
{
label: "app",
matches: (skill: Skill) =>
sourceLabel(skill) === "app" &&
!skill.install?.skillsShUrl,
},
{
label: "path",
matches: (skill: Skill) => sourceLabel(skill) === "path",
},
];
for (const { label, matches } of groupDefinitions) {
const grpSkills = skills.filter(matches);
if (grpSkills.length > 0)
groups.push({ label, skills: grpSkills });
}
return groups.map(
({ label: grpLabel, skills: grpSkills }) => (
<div key={grpLabel} style={{ marginBottom: 6 }}>
<div
style={{
padding: "4px 8px 3px",
fontSize: 10,
fontWeight: 600,
color: "var(--text-dim)",
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
>
{grpLabel}
</div>
{grpSkills.map((skill) => {
const isSelected =
!addMode && selected === skill.filePath;
const disabled = skill.disableModelInvocation;
return (
<div
key={skill.filePath}
onClick={() => {
setSelected(skill.filePath);
setAddMode(false);
}}
style={{
display: "flex",
alignItems: "center",
gap: 7,
padding: "8px 8px",
borderRadius: 5,
cursor: "pointer",
background: isSelected
? "var(--bg-selected)"
: "none",
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.background =
"var(--bg-hover)";
}}
onMouseLeave={(e) => {
if (!isSelected)
e.currentTarget.style.background = "none";
}}
>
<span
style={{
flexShrink: 0,
width: 7,
height: 7,
borderRadius: "50%",
background: disabled
? "var(--border)"
: "var(--accent)",
boxShadow: disabled
? "none"
: "0 0 4px var(--accent)",
transition:
"background 0.15s, box-shadow 0.15s",
}}
/>
<span
style={{
fontSize: 12,
fontWeight: isSelected ? 600 : 400,
color: disabled
? "var(--text-dim)"
: "var(--text)",
fontFamily: "var(--font-mono)",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{skill.name}
</span>
{(() => {
const key = updateKey(skill);
const status = key ? updateStatuses[key] : undefined;
if (status?.state !== "update-available") return null;
return (
<span
title={t("i18n.updateAvailable")}
style={{
color: "#d97706",
fontSize: 13,
lineHeight: 1,
flexShrink: 0,
}}
>
↑
</span>
);
})()}
</div>
);
})}
</div>
),
);
})()
)}
</div>
{/* Add skill button */}
<div
style={{
padding: "8px 6px",
borderTop: "1px solid var(--border)",
flexShrink: 0,
}}
>
<div
onClick={() => setAddMode(true)}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "7px 8px",
borderRadius: 5,
cursor: "pointer",
background: addMode ? "var(--bg-selected)" : "none",
color: addMode ? "var(--accent)" : "var(--text-dim)",
fontSize: 12,
}}
onMouseEnter={(e) => {
if (!addMode)
e.currentTarget.style.background = "var(--bg-hover)";
}}
onMouseLeave={(e) => {
if (!addMode) e.currentTarget.style.background = "none";
}}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t("i18n.addSkill")}
</div>
</div>
</div>
{/* Right: detail or add panel */}
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
{addMode ? (
<AddSkillPanel
cwd={cwd}
installedPackages={{
global: new Set(
skills
.filter((skill) => skill.install?.scope === "global")
.map((skill) => skill.install!.package),
),
project: new Set(
skills
.filter((skill) => skill.install?.scope === "project")
.map((skill) => skill.install!.package),
),
}}
onInstalled={() => {
void loadSkills();
}}
/>
) : loading ? null : selectedSkill ? (
<SkillDetail
key={selectedSkill.filePath}
skill={selectedSkill}
cwd={cwd}
onToggle={toggle}
toggling={toggling.has(selectedSkill.filePath)}
saveError={saveError}
updateStatus={
updateKey(selectedSkill)
? updateStatuses[updateKey(selectedSkill)!]
: undefined
}
checkingUpdate={
updateKey(selectedSkill)
? checkingUpdates.has(updateKey(selectedSkill)!)
: false
}
updating={updatingSkill === updateKey(selectedSkill)}
updateError={updateError}
onCheckUpdate={() => void checkForUpdates(selectedSkill)}
onUpdate={() => void updateInstalledSkill(selectedSkill)}
/>
) : (
<div
style={{
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--text-dim)",
fontSize: 13,
}}
>
{t("i18n.selectSkill")}
</div>
)}
</div>
</div>
{/* Footer */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 18px",
borderTop: "1px solid var(--border)",
flexShrink: 0,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
{skills.some((skill) => Boolean(skill.install)) && (
<button
onClick={() => void checkForUpdates()}
disabled={checkingAll || updatingSkill !== null}
style={{
padding: "6px 12px",
background: "none",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text-muted)",
cursor:
checkingAll || updatingSkill !== null
? "not-allowed"
: "pointer",
opacity: checkingAll || updatingSkill !== null ? 0.5 : 1,
fontSize: 12,
}}
>
{checkingAll ? t("i18n.checking") : t("i18n.checkUpdates")}
</button>
)}
{Object.values(updateStatuses).filter(
(status) => status.state === "update-available",
).length > 0 && (
<span style={{ fontSize: 12, color: "#d97706" }}>
{
Object.values(updateStatuses).filter(
(status) => status.state === "update-available",
).length
}{" "}
{Object.values(updateStatuses).filter(
(status) => status.state === "update-available",
).length === 1
? t("i18n.update")
: t("i18n.updates")}
</span>
)}
</div>
<button
onClick={onClose}
style={{
padding: "6px 14px",
background: "none",
border: "1px solid var(--border)",
borderRadius: 6,
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 13,
}}
>
{t("i18n.close")}
</button>
</div>
</div>
</div>
);
}