Add skill sync for remaining local adapters

This commit is contained in:
Dotta
2026-03-14 19:22:23 -05:00
parent b2c0f3f9a5
commit e619e64433
18 changed files with 1284 additions and 262 deletions

View File

@@ -125,6 +125,12 @@ const sourceLabels: Record<string, string> = {
const LIVE_SCROLL_BOTTOM_TOLERANCE_PX = 32;
type ScrollContainer = Window | HTMLElement;
function arraysEqual(a: string[], b: string[]): boolean {
if (a === b) return true;
if (a.length !== b.length) return false;
return a.every((value, index) => value === b[index]);
}
function isWindowContainer(container: ScrollContainer): container is Window {
return container === window;
}
@@ -1144,7 +1150,8 @@ function AgentSkillsTab({
}) {
const queryClient = useQueryClient();
const [skillDraft, setSkillDraft] = useState<string[]>([]);
const [skillDirty, setSkillDirty] = useState(false);
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
const lastSavedSkillsRef = useRef<string[]>([]);
const { data: skillSnapshot, isLoading } = useQuery({
queryKey: queryKeys.agents.skills(agent.id),
@@ -1158,28 +1165,40 @@ function AgentSkillsTab({
enabled: Boolean(companyId),
});
useEffect(() => {
if (!skillSnapshot) return;
setSkillDraft(skillSnapshot.desiredSkills);
setSkillDirty(false);
}, [skillSnapshot]);
const syncSkills = useMutation({
mutationFn: (desiredSkills: string[]) => agentsApi.syncSkills(agent.id, desiredSkills, companyId),
onSuccess: async (snapshot) => {
queryClient.setQueryData(queryKeys.agents.skills(agent.id), snapshot);
lastSavedSkillsRef.current = snapshot.desiredSkills;
setLastSavedSkills(snapshot.desiredSkills);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }),
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }),
queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) }),
companyId
? queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(companyId) })
: Promise.resolve(),
]);
setSkillDraft(snapshot.desiredSkills);
setSkillDirty(false);
},
});
useEffect(() => {
if (!skillSnapshot) return;
setSkillDraft((current) =>
arraysEqual(current, lastSavedSkillsRef.current) ? skillSnapshot.desiredSkills : current,
);
lastSavedSkillsRef.current = skillSnapshot.desiredSkills;
setLastSavedSkills(skillSnapshot.desiredSkills);
}, [skillSnapshot]);
useEffect(() => {
if (!skillSnapshot) return;
if (syncSkills.isPending) return;
if (arraysEqual(skillDraft, lastSavedSkills)) return;
const timeout = window.setTimeout(() => {
syncSkills.mutate(skillDraft);
}, 250);
return () => window.clearTimeout(timeout);
}, [lastSavedSkills, skillDraft, skillSnapshot, syncSkills.isPending, syncSkills.mutate]);
const companySkillBySlug = useMemo(
() => new Map((companySkills ?? []).map((skill) => [skill.slug, skill])),
[companySkills],
@@ -1192,258 +1211,135 @@ function AgentSkillsTab({
() => skillDraft.filter((slug) => !companySkillBySlug.has(slug)),
[companySkillBySlug, skillDraft],
);
const externalEntries = (skillSnapshot?.entries ?? []).filter((entry) => entry.state === "external");
const modeCopy = useMemo(() => {
if (!skillSnapshot) return "Loading skill state...";
if (!skillSnapshot.supported) {
return "This adapter does not implement direct skill sync yet. Paperclip can still store the desired skill set for this agent.";
const skillApplicationLabel = useMemo(() => {
switch (skillSnapshot?.mode) {
case "persistent":
return "Kept in the workspace";
case "ephemeral":
return "Applied when the agent runs";
case "unsupported":
return "Tracked only";
default:
return "Unknown";
}
if (skillSnapshot.mode === "persistent") {
return "Selected skills are synchronized into the adapter's persistent skills home.";
}
if (skillSnapshot.mode === "ephemeral") {
return "Selected skills are mounted for each run instead of being installed globally.";
}
return "This adapter reports skill state but does not define a persistent install model.";
}, [skillSnapshot]);
const primaryActionLabel = !skillSnapshot || skillSnapshot.supported
? "Sync skills"
: "Save desired skills";
}, [skillSnapshot?.mode]);
const hasUnsavedChanges = !arraysEqual(skillDraft, lastSavedSkills);
const saveStatusLabel = syncSkills.isPending
? "Saving changes..."
: hasUnsavedChanges
? "Saving soon..."
: "Changes save automatically";
return (
<div className="max-w-5xl space-y-6">
<section className="overflow-hidden rounded-2xl border border-border bg-card">
<div className="border-b border-border bg-card px-5 py-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-2xl">
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1 text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Skills
</div>
<h3 className="text-2xl font-semibold tracking-tight">Attach reusable skills to {agent.name}.</h3>
<p className="mt-2 text-sm text-muted-foreground">{modeCopy}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link
to="/skills"
className="inline-flex items-center gap-1 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground no-underline transition-colors hover:bg-accent/40"
>
Open company library
<ArrowLeft className="h-3.5 w-3.5 rotate-180" />
</Link>
<Button
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.agents.skills(agent.id) })}
disabled={isLoading}
variant="outline"
>
Refresh state
</Button>
</div>
</div>
<div className="max-w-4xl space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<Link
to="/skills"
className="text-sm font-medium text-foreground underline-offset-4 no-underline transition-colors hover:text-foreground/70 hover:underline"
>
View company library
</Link>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{syncSkills.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
<span>{saveStatusLabel}</span>
</div>
</div>
<div className="space-y-4 px-5 py-5">
{skillSnapshot?.warnings.length ? (
<div className="space-y-1 rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
{skillSnapshot.warnings.map((warning) => (
<div key={warning}>{warning}</div>
))}
</div>
) : null}
{skillSnapshot?.warnings.length ? (
<div className="space-y-1 rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
{skillSnapshot.warnings.map((warning) => (
<div key={warning}>{warning}</div>
))}
</div>
) : null}
{isLoading ? (
<PageSkeleton variant="list" />
) : (
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_20rem]">
<div className="space-y-4">
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-medium">Company skills</h4>
<p className="text-xs text-muted-foreground">
Attach skills from the company library by shortname.
</p>
</div>
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{(companySkills ?? []).length} available
</span>
</div>
{(companySkills ?? []).length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
Import skills into the company library first, then attach them here.
</div>
) : (
<div className="mt-4 space-y-2">
{(companySkills ?? []).map((skill) => {
const checked = skillDraft.includes(skill.slug);
const adapterEntry = adapterEntryByName.get(skill.slug);
return (
<label
key={skill.id}
className="flex items-start gap-3 rounded-xl border border-border/70 px-3 py-3 transition-colors hover:bg-accent/20"
>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
const next = event.target.checked
? Array.from(new Set([...skillDraft, skill.slug]))
: skillDraft.filter((value) => value !== skill.slug);
setSkillDraft(next);
setSkillDirty(true);
}}
className="mt-1"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{skill.name}</span>
<span className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{skill.slug}
</span>
</div>
{skill.description && (
<p className="mt-1 text-xs text-muted-foreground">{skill.description}</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{adapterEntry?.state && (
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{adapterEntry.state}
</span>
)}
<Link
to={`/skills/${skill.id}`}
className="text-xs text-muted-foreground no-underline hover:text-foreground"
>
View skill
</Link>
</div>
</div>
</div>
</label>
);
})}
</div>
)}
</section>
{desiredOnlyMissingSkills.length > 0 && (
<section className="rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-4 dark:border-amber-500/30 dark:bg-amber-950/20">
<h4 className="text-sm font-medium text-amber-900 dark:text-amber-100">
Desired skills not found in the company library
</h4>
<div className="mt-3 space-y-2">
{desiredOnlyMissingSkills.map((skillName) => {
const adapterEntry = adapterEntryByName.get(skillName);
return (
<div key={skillName} className="flex items-center justify-between gap-3 rounded-lg border border-amber-300/50 bg-background/70 px-3 py-2 dark:border-amber-500/20">
<div>
<div className="text-sm font-medium">{skillName}</div>
<div className="text-xs text-muted-foreground">
This skill is still requested for the agent, but it is not tracked in the company library.
</div>
</div>
{adapterEntry?.state && (
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{adapterEntry.state}
</span>
)}
</div>
);
})}
</div>
</section>
)}
{isLoading ? (
<PageSkeleton variant="list" />
) : (
<>
<section className="border-y border-border">
{(companySkills ?? []).length === 0 ? (
<div className="px-3 py-6 text-sm text-muted-foreground">
Import skills into the company library first, then attach them here.
</div>
) : (
(companySkills ?? []).map((skill) => {
const checked = skillDraft.includes(skill.slug);
const adapterEntry = adapterEntryByName.get(skill.slug);
return (
<label
key={skill.id}
className="flex items-start gap-3 border-b border-border px-3 py-3 text-sm last:border-b-0 hover:bg-accent/20"
>
<input
type="checkbox"
checked={checked}
onChange={(event) => {
const next = event.target.checked
? Array.from(new Set([...skillDraft, skill.slug]))
: skillDraft.filter((value) => value !== skill.slug);
setSkillDraft(next);
}}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<span className="truncate font-medium">{skill.name}</span>
<Link
to={`/skills/${skill.id}`}
className="shrink-0 text-xs text-muted-foreground no-underline hover:text-foreground"
>
View
</Link>
</div>
{skill.description && (
<MarkdownBody className="mt-1 text-xs text-muted-foreground prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
{skill.description}
</MarkdownBody>
)}
{adapterEntry?.detail && (
<p className="mt-1 text-xs text-muted-foreground">{adapterEntry.detail}</p>
)}
</div>
</label>
);
})
)}
</section>
<div className="space-y-4">
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
<h4 className="text-sm font-medium">Adapter state</h4>
<div className="mt-3 grid gap-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Adapter</span>
<span className="font-medium">{agent.adapterType}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Sync mode</span>
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{skillSnapshot?.mode ?? "unsupported"}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Desired skills</span>
<span>{skillDraft.length}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">External skills</span>
<span>{externalEntries.length}</span>
</div>
</div>
<div className="mt-4 flex items-center gap-2">
<Button
size="sm"
onClick={() => syncSkills.mutate(skillDraft)}
disabled={syncSkills.isPending || !skillDirty}
>
{syncSkills.isPending ? "Saving..." : primaryActionLabel}
</Button>
{skillDirty && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSkillDraft(skillSnapshot?.desiredSkills ?? []);
setSkillDirty(false);
}}
disabled={syncSkills.isPending}
>
Reset
</Button>
)}
</div>
{syncSkills.isError && (
<p className="mt-3 text-xs text-destructive">
{syncSkills.error instanceof Error ? syncSkills.error.message : "Failed to update skills"}
</p>
)}
</section>
<section className="rounded-xl border border-border/70 bg-background px-4 py-4">
<h4 className="text-sm font-medium">External skills</h4>
{externalEntries.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">
No external skills were discovered by the adapter.
</p>
) : (
<div className="mt-3 space-y-2">
{externalEntries.map((entry) => (
<div key={entry.name} className="rounded-lg border border-border/70 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{entry.name}</span>
<span className="rounded-full border border-border px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{entry.state}
</span>
</div>
{entry.detail && (
<p className="mt-1 text-xs text-muted-foreground">{entry.detail}</p>
)}
</div>
))}
</div>
)}
</section>
{desiredOnlyMissingSkills.length > 0 && (
<div className="rounded-xl border border-amber-300/60 bg-amber-50/60 px-4 py-3 text-sm text-amber-800 dark:border-amber-500/30 dark:bg-amber-950/20 dark:text-amber-200">
<div className="font-medium">Requested skills missing from the company library</div>
<div className="mt-1 text-xs">
{desiredOnlyMissingSkills.join(", ")}
</div>
</div>
)}
</div>
</section>
<section className="border-t border-border pt-4">
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
<span className="text-muted-foreground">Adapter</span>
<span className="font-medium">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
</div>
<div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
<span className="text-muted-foreground">Skills applied</span>
<span>{skillApplicationLabel}</span>
</div>
<div className="flex items-center justify-between gap-3 border-b border-border/60 py-2">
<span className="text-muted-foreground">Selected skills</span>
<span>{skillDraft.length}</span>
</div>
</div>
{syncSkills.isError && (
<p className="mt-3 text-xs text-destructive">
{syncSkills.error instanceof Error ? syncSkills.error.message : "Failed to update skills"}
</p>
)}
</section>
</>
)}
</div>
);
}