feat(03-02): add SkillDetail pane, SkillAgentToggles, and assignment query IPC
- Add listSkillAgents and listAgentSkills IPC channels with Zod schemas - Create SkillDetail component with metadata, file viewer, update check, delete - Create SkillAgentToggles with discriminated union for skill/agent-centric modes - Wire SkillDetail into SkillsSection right column replacing placeholder - Remove duplicate skill method declarations from DesktopApi type
This commit is contained in:
1 parent
6397c118ea
commit
e71d2e5529
8 files changed
+489
-19
No files matched your search
@@ -19,5 +19,6 @@ export function registerDomainHandlers(dependencies: RegisterDomainHandlersDepen
|
|||||||
ensureCompanyExists: dependencies.ensureCompanyExists,
|
ensureCompanyExists: dependencies.ensureCompanyExists,
|
||||||
publishDomainChanged: dependencies.publishDomainChanged,
|
publishDomainChanged: dependencies.publishDomainChanged,
|
||||||
skillService: dependencies.skillService,
|
skillService: dependencies.skillService,
|
||||||
|
db: dependencies.db,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -7,15 +7,19 @@ import {
|
|||||||
assignSkillToAgentSchema,
|
assignSkillToAgentSchema,
|
||||||
removeSkillFromAgentSchema,
|
removeSkillFromAgentSchema,
|
||||||
checkSkillUpdateSchema,
|
checkSkillUpdateSchema,
|
||||||
|
listSkillAgentsSchema,
|
||||||
|
listAgentSkillsSchema,
|
||||||
} from "@shared/contracts";
|
} from "@shared/contracts";
|
||||||
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
import { fail, ok, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
|
||||||
import type { createSkillService } from "./skill-service";
|
import type { createSkillService } from "./skill-service";
|
||||||
|
import type { AppDatabase } from "./database";
|
||||||
|
|
||||||
interface SkillHandlerDependencies extends Pick<
|
interface SkillHandlerDependencies extends Pick<
|
||||||
RegisterDomainHandlersDependencies,
|
RegisterDomainHandlersDependencies,
|
||||||
"registerHandle" | "ensureCompanyExists" | "publishDomainChanged"
|
"registerHandle" | "ensureCompanyExists" | "publishDomainChanged"
|
||||||
> {
|
> {
|
||||||
skillService: ReturnType<typeof createSkillService> | null;
|
skillService: ReturnType<typeof createSkillService> | null;
|
||||||
|
db: AppDatabase;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerSkillHandlers({
|
export function registerSkillHandlers({
|
||||||
@@ -23,6 +27,7 @@ export function registerSkillHandlers({
|
|||||||
ensureCompanyExists,
|
ensureCompanyExists,
|
||||||
publishDomainChanged,
|
publishDomainChanged,
|
||||||
skillService,
|
skillService,
|
||||||
|
db,
|
||||||
}: SkillHandlerDependencies) {
|
}: SkillHandlerDependencies) {
|
||||||
function requireSkillService() {
|
function requireSkillService() {
|
||||||
if (!skillService) {
|
if (!skillService) {
|
||||||
@@ -116,4 +121,20 @@ export function registerSkillHandlers({
|
|||||||
if (serviceError) return serviceError;
|
if (serviceError) return serviceError;
|
||||||
return skillService!.checkUpdateStatus(parsed.companyId, parsed.skillId);
|
return skillService!.checkUpdateStatus(parsed.companyId, parsed.skillId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- List agent IDs assigned to a skill ----
|
||||||
|
registerHandle(IPC_CHANNELS.listSkillAgents, async (_event, payload) => {
|
||||||
|
const parsed = listSkillAgentsSchema.parse(payload);
|
||||||
|
const companyError = ensureCompanyExists(parsed.companyId);
|
||||||
|
if (companyError) return companyError;
|
||||||
|
return ok(db.listSkillAgentAssignments(parsed.skillId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- List skill IDs assigned to an agent ----
|
||||||
|
registerHandle(IPC_CHANNELS.listAgentSkills, async (_event, payload) => {
|
||||||
|
const parsed = listAgentSkillsSchema.parse(payload);
|
||||||
|
const companyError = ensureCompanyExists(parsed.companyId);
|
||||||
|
if (companyError) return companyError;
|
||||||
|
return ok(db.listAgentSkillAssignments(parsed.agentId));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,10 @@ const api: DesktopApi = {
|
|||||||
ipcRenderer.invoke(IPC_CHANNELS.removeSkillFromAgent, { companyId, agentId, skillId }),
|
ipcRenderer.invoke(IPC_CHANNELS.removeSkillFromAgent, { companyId, agentId, skillId }),
|
||||||
checkSkillUpdate: (companyId, skillId) =>
|
checkSkillUpdate: (companyId, skillId) =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.checkSkillUpdate, { companyId, skillId }),
|
ipcRenderer.invoke(IPC_CHANNELS.checkSkillUpdate, { companyId, skillId }),
|
||||||
|
listSkillAgents: (companyId, skillId) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.listSkillAgents, { companyId, skillId }),
|
||||||
|
listAgentSkills: (companyId, agentId) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.listAgentSkills, { companyId, agentId }),
|
||||||
recoveryRetry: (taskId: string, agentId: string, companyId: string) =>
|
recoveryRetry: (taskId: string, agentId: string, companyId: string) =>
|
||||||
ipcRenderer.invoke("recovery:retry", { taskId, agentId, companyId }),
|
ipcRenderer.invoke("recovery:retry", { taskId, agentId, companyId }),
|
||||||
recoveryDismiss: (taskId: string) =>
|
recoveryDismiss: (taskId: string) =>
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import type { AgentRecord, SkillRecord } from "@shared/types";
|
||||||
|
import { useAppStore } from "../../state/store";
|
||||||
|
|
||||||
|
type SkillAgentTogglesProps =
|
||||||
|
| { mode: "skill"; companyId: string; skillId: string; agents: AgentRecord[] }
|
||||||
|
| { mode: "agent"; companyId: string; agentId: string; skills: SkillRecord[] };
|
||||||
|
|
||||||
|
export function SkillAgentToggles(props: SkillAgentTogglesProps) {
|
||||||
|
const { companyId, mode } = props;
|
||||||
|
const addToast = useAppStore((s) => s.addToast);
|
||||||
|
const [assignedIds, setAssignedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function load() {
|
||||||
|
if (mode === "skill") {
|
||||||
|
const result = await window.agentCompany.listSkillAgents(companyId, props.skillId);
|
||||||
|
if (!cancelled && result.ok) setAssignedIds(new Set(result.data));
|
||||||
|
} else {
|
||||||
|
const result = await window.agentCompany.listAgentSkills(companyId, props.agentId);
|
||||||
|
if (!cancelled && result.ok) setAssignedIds(new Set(result.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [mode, companyId, mode === "skill" ? props.skillId : props.agentId]);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
async (targetId: string) => {
|
||||||
|
setTogglingId(targetId);
|
||||||
|
const isAssigned = assignedIds.has(targetId);
|
||||||
|
const agentId = mode === "skill" ? targetId : props.agentId;
|
||||||
|
const skillId = mode === "skill" ? props.skillId : targetId;
|
||||||
|
|
||||||
|
const result = isAssigned
|
||||||
|
? await window.agentCompany.removeSkillFromAgent(companyId, agentId, skillId)
|
||||||
|
: await window.agentCompany.assignSkillToAgent(companyId, agentId, skillId);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
setAssignedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (isAssigned) next.delete(targetId);
|
||||||
|
else next.add(targetId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addToast(result.error.message, "danger");
|
||||||
|
}
|
||||||
|
setTogglingId(null);
|
||||||
|
},
|
||||||
|
[assignedIds, mode, companyId, addToast,
|
||||||
|
mode === "skill" ? props.skillId : "",
|
||||||
|
mode === "agent" ? props.agentId : ""],
|
||||||
|
);
|
||||||
|
|
||||||
|
const items: Array<{ id: string; name: string; detail?: string }> =
|
||||||
|
mode === "skill"
|
||||||
|
? props.agents.map((a) => ({ id: a.id, name: a.name, detail: a.role }))
|
||||||
|
: props.skills.map((s) => ({ id: s.id, name: s.name, detail: s.description ?? undefined }));
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="px-3 py-4 text-center text-[13px] text-[color:var(--muted)]">
|
||||||
|
{mode === "skill" ? "No agents in this company" : "No skills imported yet"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{items.map((item) => {
|
||||||
|
const isAssigned = assignedIds.has(item.id);
|
||||||
|
const isToggling = togglingId === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
disabled={isToggling}
|
||||||
|
className="flex w-full items-center gap-3 border-b border-[color:var(--line)] px-3 py-2 text-left transition hover:bg-[color:var(--panel-soft)] disabled:opacity-60"
|
||||||
|
onClick={() => void handleToggle(item.id)}
|
||||||
|
>
|
||||||
|
{/* Toggle indicator */}
|
||||||
|
<span
|
||||||
|
className={`flex h-[18px] w-[32px] shrink-0 items-center rounded-full p-[2px] transition-colors ${
|
||||||
|
isAssigned
|
||||||
|
? "bg-[color:var(--accent)]"
|
||||||
|
: "bg-[color:var(--line)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`block h-[14px] w-[14px] rounded-full bg-white shadow-sm transition-transform ${
|
||||||
|
isAssigned ? "translate-x-[14px]" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] font-medium text-[color:var(--text)]">
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
{item.detail ? (
|
||||||
|
<div className="truncate text-[12px] text-[color:var(--muted)]">
|
||||||
|
{item.detail}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowsClockwise,
|
||||||
|
FolderSimple,
|
||||||
|
GithubLogo,
|
||||||
|
Globe,
|
||||||
|
Link,
|
||||||
|
PuzzlePiece,
|
||||||
|
Trash,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import type { AgentRecord, SkillRecord, SkillUpdateStatus } from "@shared/types";
|
||||||
|
import { useAppStore } from "../../state/store";
|
||||||
|
import { ActionButton, StatusPill } from "../ui";
|
||||||
|
import { SkillAgentToggles } from "./SkillAgentToggles";
|
||||||
|
import { buildTree, SkillFileTree } from "./SkillFileTree";
|
||||||
|
|
||||||
|
const sourceIconMap: Record<string, typeof GithubLogo> = {
|
||||||
|
github: GithubLogo,
|
||||||
|
local_path: FolderSimple,
|
||||||
|
url: Link,
|
||||||
|
skills_sh: Globe,
|
||||||
|
managed: PuzzlePiece,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceLabel: Record<string, string> = {
|
||||||
|
github: "GitHub",
|
||||||
|
local_path: "Local Path",
|
||||||
|
url: "URL",
|
||||||
|
skills_sh: "skills.sh",
|
||||||
|
managed: "Managed",
|
||||||
|
};
|
||||||
|
|
||||||
|
const trustToneMap: Record<string, string> = {
|
||||||
|
markdown_only: "ready",
|
||||||
|
assets: "pending",
|
||||||
|
scripts_executables: "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const trustLabelMap: Record<string, string> = {
|
||||||
|
markdown_only: "Markdown only",
|
||||||
|
assets: "Has assets",
|
||||||
|
scripts_executables: "Contains scripts",
|
||||||
|
};
|
||||||
|
|
||||||
|
function stripFrontmatter(markdown: string): string {
|
||||||
|
const normalized = markdown.replace(/\r\n/g, "\n");
|
||||||
|
if (!normalized.startsWith("---\n")) return normalized.trim();
|
||||||
|
const closing = normalized.indexOf("\n---\n", 4);
|
||||||
|
if (closing < 0) return normalized.trim();
|
||||||
|
return normalized.slice(closing + 5).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortRef(ref: string | null | undefined): string {
|
||||||
|
if (!ref) return "untracked";
|
||||||
|
return ref.length > 7 ? ref.slice(0, 7) : ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillDetail({
|
||||||
|
companyId,
|
||||||
|
skillId,
|
||||||
|
agents,
|
||||||
|
onDeleted,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
companyId: string;
|
||||||
|
skillId: string;
|
||||||
|
agents: AgentRecord[];
|
||||||
|
onDeleted: () => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const addToast = useAppStore((s) => s.addToast);
|
||||||
|
const selectedSkillFilePath = useAppStore((s) => s.selectedSkillFilePath);
|
||||||
|
const setSelectedSkillFilePath = useAppStore((s) => s.setSelectedSkillFilePath);
|
||||||
|
|
||||||
|
const [detail, setDetail] = useState<SkillRecord | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [updateStatus, setUpdateStatus] = useState<SkillUpdateStatus | null>(null);
|
||||||
|
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
|
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setDetail(null);
|
||||||
|
setUpdateStatus(null);
|
||||||
|
setSelectedSkillFilePath("SKILL.md");
|
||||||
|
setExpandedDirs(new Set());
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const result = await window.agentCompany.getSkillDetail(companyId, skillId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
if (result.ok) {
|
||||||
|
setDetail(result.data);
|
||||||
|
} else {
|
||||||
|
addToast(result.error.message, "danger");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [companyId, skillId, addToast, setSelectedSkillFilePath]);
|
||||||
|
|
||||||
|
const handleCheckUpdate = useCallback(async () => {
|
||||||
|
if (!detail) return;
|
||||||
|
setCheckingUpdate(true);
|
||||||
|
const result = await window.agentCompany.checkSkillUpdate(companyId, skillId);
|
||||||
|
setCheckingUpdate(false);
|
||||||
|
if (result.ok) {
|
||||||
|
setUpdateStatus(result.data);
|
||||||
|
} else {
|
||||||
|
addToast(result.error.message, "danger");
|
||||||
|
}
|
||||||
|
}, [companyId, skillId, detail, addToast]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
const result = await window.agentCompany.deleteSkill(companyId, skillId);
|
||||||
|
if (result.ok) {
|
||||||
|
addToast("Skill deleted", "success");
|
||||||
|
onDeleted();
|
||||||
|
} else {
|
||||||
|
addToast(result.error.message, "danger");
|
||||||
|
}
|
||||||
|
}, [companyId, skillId, addToast, onDeleted]);
|
||||||
|
|
||||||
|
const handleToggleDir = useCallback((path: string) => {
|
||||||
|
setExpandedDirs((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(path)) next.delete(path);
|
||||||
|
else next.add(path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-8 text-[color:var(--muted)]">
|
||||||
|
<div className="text-[13px]">Loading skill...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detail) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center p-8 text-[color:var(--muted)]">
|
||||||
|
<div className="text-[13px]">Skill not found</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SourceIcon = sourceIconMap[detail.sourceType] ?? PuzzlePiece;
|
||||||
|
const treeNodes = buildTree(detail.fileInventory ?? []);
|
||||||
|
const isGitHubSource = detail.sourceType === "github" || detail.sourceType === "skills_sh";
|
||||||
|
|
||||||
|
// Determine content for the file viewer
|
||||||
|
const isSkillMd = selectedSkillFilePath === "SKILL.md";
|
||||||
|
const fileContent = isSkillMd
|
||||||
|
? stripFrontmatter(detail.markdown ?? "")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const selectedFileEntry = !isSkillMd
|
||||||
|
? (detail.fileInventory ?? []).find((f) => f.path === selectedSkillFilePath)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
{/* Header section */}
|
||||||
|
<div className="border-b border-[color:var(--line)] px-5 py-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<SourceIcon size={20} className="mt-1 shrink-0 text-[color:var(--muted)]" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="text-[20px] font-semibold leading-tight text-[color:var(--text)]">
|
||||||
|
{detail.name}
|
||||||
|
</h1>
|
||||||
|
{detail.description ? (
|
||||||
|
<p className="mt-1 max-w-3xl text-[13px] text-[color:var(--muted)]">
|
||||||
|
{detail.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata row */}
|
||||||
|
<div className="space-y-3 border-b border-[color:var(--line)] px-5 pt-4 pb-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-[13px]">
|
||||||
|
{/* Source */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Source</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<SourceIcon size={14} className="text-[color:var(--muted)]" />
|
||||||
|
<span className="text-[color:var(--text)]">{sourceLabel[detail.sourceType] ?? detail.sourceType}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trust level */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Trust</span>
|
||||||
|
<StatusPill status={trustToneMap[detail.trustLevel] ?? "default"} />
|
||||||
|
<span className="text-[color:var(--text)]">{trustLabelMap[detail.trustLevel] ?? detail.trustLevel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compatibility */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Compat</span>
|
||||||
|
<span className="text-[color:var(--text)]">{detail.compatibility || "all"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ref (for GitHub sources) */}
|
||||||
|
{isGitHubSource ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">Ref</span>
|
||||||
|
<span className="font-mono text-[12px] text-[color:var(--text)]">{shortRef(detail.sourceRef)}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update check row */}
|
||||||
|
{isGitHubSource ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[13px]">
|
||||||
|
<ActionButton
|
||||||
|
label="Check for updates"
|
||||||
|
icon={ArrowsClockwise}
|
||||||
|
loading={checkingUpdate}
|
||||||
|
onClick={() => void handleCheckUpdate()}
|
||||||
|
/>
|
||||||
|
{updateStatus?.hasUpdate ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-[color:var(--accent-soft)] px-2.5 py-0.5 text-[12px] font-medium text-[color:var(--accent-text)]">
|
||||||
|
Update available: {shortRef(updateStatus.currentRef)} → {shortRef(updateStatus.latestRef)}
|
||||||
|
</span>
|
||||||
|
) : updateStatus && !updateStatus.hasUpdate ? (
|
||||||
|
<span className="text-[12px] text-[color:var(--muted)]">Up to date</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File viewer section */}
|
||||||
|
<div className="min-h-[560px]">
|
||||||
|
{/* File tree sidebar + content */}
|
||||||
|
<div className="grid grid-cols-[12rem_minmax(0,1fr)]">
|
||||||
|
{/* File tree sidebar */}
|
||||||
|
<div className="border-r border-[color:var(--line)] py-2">
|
||||||
|
{treeNodes.length > 0 ? (
|
||||||
|
<SkillFileTree
|
||||||
|
nodes={treeNodes}
|
||||||
|
selectedPath={selectedSkillFilePath}
|
||||||
|
expandedDirs={expandedDirs}
|
||||||
|
onToggleDir={handleToggleDir}
|
||||||
|
onSelectPath={setSelectedSkillFilePath}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="px-3 py-2 text-[12px] text-[color:var(--muted)]">No files</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content area */}
|
||||||
|
<div className="p-4">
|
||||||
|
{/* File path bar */}
|
||||||
|
<div className="mb-3 flex items-center gap-1 text-[12px] text-[color:var(--muted)]">
|
||||||
|
{selectedSkillFilePath.split("/").map((segment, i, arr) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
{i > 0 ? <span className="opacity-40">/</span> : null}
|
||||||
|
<span className={i === arr.length - 1 ? "font-medium text-[color:var(--text)]" : ""}>
|
||||||
|
{segment}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isSkillMd ? (
|
||||||
|
<pre className="rounded-lg bg-[color:var(--panel)] p-4 font-mono text-[13px] text-[color:var(--text)] overflow-auto whitespace-pre-wrap">
|
||||||
|
{fileContent}
|
||||||
|
</pre>
|
||||||
|
) : selectedFileEntry ? (
|
||||||
|
<div className="rounded-lg bg-[color:var(--panel)] p-4 text-[13px] text-[color:var(--muted)]">
|
||||||
|
<p className="font-medium text-[color:var(--text)]">{selectedFileEntry.path}</p>
|
||||||
|
<p className="mt-1">Kind: {selectedFileEntry.kind}</p>
|
||||||
|
<p className="mt-1 text-[12px] italic">File content preview is not available for non-SKILL.md files.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[13px] text-[color:var(--muted)]">Select a file to view its details.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent assignments section */}
|
||||||
|
<div className="border-t border-[color:var(--line)] px-5 py-4">
|
||||||
|
<h3 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[color:var(--accent-text)] mb-3">
|
||||||
|
Agent Assignments
|
||||||
|
</h3>
|
||||||
|
<SkillAgentToggles mode="skill" companyId={companyId} skillId={skillId} agents={agents} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete section */}
|
||||||
|
<div className="border-t border-[color:var(--line)] px-5 py-4">
|
||||||
|
{confirmingDelete ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<p className="text-[13px] text-[color:var(--danger)]">
|
||||||
|
Delete “{detail.name}”? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
<ActionButton
|
||||||
|
label="Confirm Delete"
|
||||||
|
tone="danger"
|
||||||
|
onClick={() => void handleDelete()}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="Cancel"
|
||||||
|
onClick={() => setConfirmingDelete(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ActionButton
|
||||||
|
label="Delete Skill"
|
||||||
|
tone="danger"
|
||||||
|
icon={Trash}
|
||||||
|
onClick={() => setConfirmingDelete(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,17 +2,17 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
MagnifyingGlass,
|
MagnifyingGlass,
|
||||||
Plus,
|
Plus,
|
||||||
PuzzlePiece,
|
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import type { AgentRecord } from "@shared/types";
|
import type { AgentRecord } from "@shared/types";
|
||||||
import { useAppStore } from "../../state/store";
|
import { useAppStore } from "../../state/store";
|
||||||
import { EmptyState } from "../ui";
|
import { EmptyState } from "../ui";
|
||||||
import { SkillList } from "./SkillList";
|
import { SkillList } from "./SkillList";
|
||||||
import { SkillImportModal } from "./SkillImportModal";
|
import { SkillImportModal } from "./SkillImportModal";
|
||||||
|
import { SkillDetail } from "./SkillDetail";
|
||||||
|
|
||||||
export function SkillsSection({
|
export function SkillsSection({
|
||||||
companyId,
|
companyId,
|
||||||
agents: _agents,
|
agents,
|
||||||
onRefresh: _onRefresh,
|
onRefresh: _onRefresh,
|
||||||
}: {
|
}: {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -57,8 +57,6 @@ export function SkillsSection({
|
|||||||
[setSelectedSkillId],
|
[setSelectedSkillId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedSkill = skills.find((s) => s.id === selectedSkillId);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-1 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
<div className="grid h-full grid-cols-1 xl:grid-cols-[19rem_minmax(0,1fr)]">
|
||||||
{/* Left column -- skill list sidebar */}
|
{/* Left column -- skill list sidebar */}
|
||||||
@@ -131,14 +129,14 @@ export function SkillsSection({
|
|||||||
|
|
||||||
{/* Right column -- skill detail */}
|
{/* Right column -- skill detail */}
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||||
{selectedSkill ? (
|
{selectedSkillId ? (
|
||||||
<div className="flex flex-1 items-center justify-center p-8 text-[color:var(--muted)]">
|
<SkillDetail
|
||||||
<div className="text-center">
|
companyId={companyId}
|
||||||
<PuzzlePiece size={32} className="mx-auto mb-3 opacity-40" />
|
skillId={selectedSkillId}
|
||||||
<p className="text-[14px] font-medium text-[color:var(--text)]">{selectedSkill.name}</p>
|
agents={agents}
|
||||||
<p className="mt-1 text-[13px]">Skill detail pane -- Plan 02</p>
|
onDeleted={() => { setSelectedSkillId(null); void loadSkills(); }}
|
||||||
</div>
|
onRefresh={() => void loadSkills()}
|
||||||
</div>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { SkillsSection } from "./SkillsSection";
|
export { SkillsSection } from "./SkillsSection";
|
||||||
|
export { SkillAgentToggles } from "./SkillAgentToggles";
|
||||||
+14
-7
@@ -80,6 +80,8 @@ export const IPC_CHANNELS = {
|
|||||||
assignSkillToAgent: "agent-company:assign-skill-to-agent",
|
assignSkillToAgent: "agent-company:assign-skill-to-agent",
|
||||||
removeSkillFromAgent: "agent-company:remove-skill-from-agent",
|
removeSkillFromAgent: "agent-company:remove-skill-from-agent",
|
||||||
checkSkillUpdate: "agent-company:check-skill-update",
|
checkSkillUpdate: "agent-company:check-skill-update",
|
||||||
|
listSkillAgents: "agent-company:list-skill-agents",
|
||||||
|
listAgentSkills: "agent-company:list-agent-skills",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const optionalId = z.preprocess(
|
const optionalId = z.preprocess(
|
||||||
@@ -557,6 +559,16 @@ export const checkSkillUpdateSchema = z.object({
|
|||||||
skillId: z.string().min(1),
|
skillId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const listSkillAgentsSchema = z.object({
|
||||||
|
companyId: z.string().min(1),
|
||||||
|
skillId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const listAgentSkillsSchema = z.object({
|
||||||
|
companyId: z.string().min(1),
|
||||||
|
agentId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export type DesktopResult<T> =
|
export type DesktopResult<T> =
|
||||||
| { ok: true; data: T }
|
| { ok: true; data: T }
|
||||||
| { ok: false; error: { code: string; message: string } };
|
| { ok: false; error: { code: string; message: string } };
|
||||||
@@ -639,15 +651,10 @@ export type DesktopApi = {
|
|||||||
assignSkillToAgent: (companyId: string, agentId: 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>>;
|
removeSkillFromAgent: (companyId: string, agentId: string, skillId: string) => Promise<DesktopResult<void>>;
|
||||||
checkSkillUpdate: (companyId: string, skillId: string) => Promise<DesktopResult<SkillUpdateStatus>>;
|
checkSkillUpdate: (companyId: string, skillId: string) => Promise<DesktopResult<SkillUpdateStatus>>;
|
||||||
|
listSkillAgents: (companyId: string, skillId: string) => Promise<DesktopResult<string[]>>;
|
||||||
|
listAgentSkills: (companyId: string, agentId: string) => Promise<DesktopResult<string[]>>;
|
||||||
recoveryRetry: (taskId: string, agentId: string, companyId: string) => Promise<DesktopResult<null>>;
|
recoveryRetry: (taskId: string, agentId: string, companyId: string) => Promise<DesktopResult<null>>;
|
||||||
recoveryDismiss: (taskId: string) => Promise<DesktopResult<null>>;
|
recoveryDismiss: (taskId: string) => Promise<DesktopResult<null>>;
|
||||||
recoveryFail: (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;
|
subscribe: (callback: (event: DesktopEvent) => void) => () => void;
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user