Files
doormilxpress_astryx/src/components/agents/AddSkillsModal.jsx
2026-08-20 18:18:10 +05:30

181 lines
6.9 KiB
JavaScript

import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button, Modal, SearchInput } from '@/components/ds';
import { allSkills, skillsWithFacet } from '@/lib/skills/registry';
import { surfaceFor } from '@/lib/skills/surfaces';
/**
* Attaching skills to an agent.
*
* Reads **the one skill registry** — the same `allSkills` the Skills page and
* Owliver itself read. There is deliberately no separate list for agents: a
* second one would drift, and an agent would end up offering a skill the
* runtime does not have.
*
* Categories are derived rather than written down. A skill's own `category`
* field when it declares one, and the pages it attaches to otherwise, so a
* filter can never offer a grouping that matches nothing — and a skill added
* tomorrow appears under its own category without this file being edited.
*/
const ALL = 'all';
/** The groupings the registry actually contains, in a stable order. */
function categoriesFor(skills) {
const named = new Set();
for (const skill of skills) {
if (skill.category) named.add(skill.category);
}
return [ALL, ...[...named].sort()];
}
/** What this skill contributes, in the terms the reader is choosing between. */
function skillSummary(skill) {
const pages = skill.pages.map((p) => surfaceFor(p)?.label || p);
const capabilities = skill.owliver?.capabilities || [];
return [
pages.length ? pages.join(', ') : 'No pages',
capabilities.length
? `${capabilities.length} capabilit${capabilities.length === 1 ? 'y' : 'ies'}`
: (skill.actions || []).length ? `${skill.actions.length} action${skill.actions.length === 1 ? '' : 's'}` : null,
].filter(Boolean).join(' · ');
}
/** @param {any} props */
export function AddSkillsModal({ open, onOpenChange, attached = [], customSkills = [], onAdd }) {
const [query, setQuery] = React.useState('');
const [category, setCategory] = React.useState(ALL);
const [picked, setPicked] = React.useState([]);
/* A fresh sheet each time, so a previous selection is not still ticked. */
React.useEffect(() => {
if (open) { setQuery(''); setCategory(ALL); setPicked([]); }
}, [open]);
/* Owliver skills only. A workforce training path is something a person
learns, not something an agent can be asked to do, and offering one here
would promise behaviour that does not exist. */
const available = React.useMemo(
() => skillsWithFacet(allSkills(customSkills), 'owliver')
.filter((s) => s.status === 'active' && !attached.includes(s.id)),
[customSkills, attached]
);
const categories = React.useMemo(() => categoriesFor(available), [available]);
const visible = React.useMemo(() => {
const q = query.trim().toLowerCase();
return available
.filter((s) => category === ALL || s.category === category)
.filter((s) => !q
|| s.name.toLowerCase().includes(q)
|| s.description.toLowerCase().includes(q)
|| s.id.includes(q));
}, [available, category, query]);
const toggle = (id) =>
setPicked((current) => (current.includes(id)
? current.filter((x) => x !== id)
: [...current, id]));
return (
<Modal
open={open}
onOpenChange={onOpenChange}
title="Add skills"
description="Select skills to extend what this agent can do. A skill still only answers on the pages it names."
size="lg"
footer={
<>
<p className="mr-auto text-body-sm text-ink-3">
{picked.length
? `${picked.length} skill${picked.length === 1 ? '' : 's'} selected`
: 'Nothing selected yet'}
</p>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button disabled={!picked.length} onClick={() => { onAdd(picked); onOpenChange(false); }}>
Add
</Button>
</>
}
>
<div className="space-y-3">
<SearchInput value={query} onChange={setQuery} placeholder="Search skills..." debounce={0} />
{categories.length > 1 && (
<div className="flex flex-wrap gap-1.5">
{categories.map((id) => (
<button
key={id}
type="button"
onClick={() => setCategory(id)}
className={cn(
`rounded-full border px-2.5 py-1 text-caption font-medium capitalize transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
category === id
? 'border-krow-blue/40 bg-krow-blue-tint text-krow-blue'
: 'border-border bg-surface text-ink-3 hover:border-krow-blue/30 hover:text-ink-2'
)}
>
{id === ALL ? 'All skills' : id}
</button>
))}
</div>
)}
<div className="max-h-[22rem] space-y-1.5 overflow-y-auto pr-0.5">
{visible.map((skill) => {
const chosen = picked.includes(skill.id);
return (
<button
key={skill.id}
type="button"
onClick={() => toggle(skill.id)}
aria-pressed={chosen}
className={cn(
`flex w-full items-start gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
chosen
? 'border-krow-blue/50 bg-krow-blue-tint/50'
: 'border-border bg-surface hover:border-krow-blue/30 hover:bg-surface-subtle'
)}
>
<span
className={cn(
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border',
chosen ? 'border-krow-blue bg-krow-blue text-white' : 'border-border bg-surface'
)}
aria-hidden="true"
>
{chosen && <Check className="h-3 w-3" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-body-sm font-medium text-ink-1">{skill.name}</span>
{skill.description && (
<span className="mt-0.5 block text-caption leading-snug text-ink-3">
{skill.description}
</span>
)}
<span className="mt-1 block truncate text-[10px] text-ink-4">{skillSummary(skill)}</span>
</span>
</button>
);
})}
{!visible.length && (
<p className="px-1 py-6 text-center text-body-sm text-ink-3">
{available.length
? 'No skills match that.'
: 'Every available skill is already attached to this agent.'}
</p>
)}
</div>
</div>
</Modal>
);
}
export default AddSkillsModal;