import * as React from 'react'; import { BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus, SlidersHorizontal, Trash2, Wrench, X, } from 'lucide-react'; import { Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, } from '@/components/ds'; import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces'; import { getSkillsForPage } from '@/lib/skills/registry'; import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary'; import { AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip, SettingRow, Workspace, } from './AgentCanvas'; /** * The agent configuration workspace. * * Four sections of one document — Identity, Instructions, Capabilities, * Behavior — with a rail beside them that lists the same four and what is * inside each. The rail is the page's table of contents and its scroll * position; it is never a second source of state, which is why every value it * shows is read from `fields` at render rather than tracked. * * Every control writes a *field*. The caller composes frontmatter from those * fields at save time, which is what keeps Markdown the storage format while a * recruiter never meets it. */ const SECTION_IDS = ['identity', 'instructions', 'capabilities', 'behavior']; /** * Which section the reader is in. * * Observed rather than set on click, so scrolling the document moves the rail * too — a table of contents that only updates when you use it is a menu, not a * position. * * Read from the sections' own top edges against a marker just below the sticky * header, rather than from intersection ratios. A ratio-based spy cannot * resolve the case this document actually has: Capabilities is long enough that * it is still crossing the reading band when Behavior arrives, so "the first * one intersecting" stays on Capabilities for the rest of the page. The last * section gets the same exemption for the same reason in reverse — the document * runs out of scroll before its top reaches the marker, so at the end of the * page it is simply what is being read. */ function useActiveSection(ids) { const [active, setActive] = React.useState(ids[0]); React.useEffect(() => { if (typeof window === 'undefined') return undefined; let frame = 0; const measure = () => { frame = 0; const marker = 140; let current = ids[0]; for (const id of ids) { const top = document.getElementById(id)?.getBoundingClientRect().top; if (top !== undefined && top <= marker) current = id; } /* Only when the document genuinely scrolls. With every section closed it fits on one screen, and "the end of the page" is then also its start — which would light the last section while the reader is looking at the first. */ const doc = document.documentElement; const scrolls = doc.scrollHeight > window.innerHeight + 8; const atBottom = window.innerHeight + window.scrollY >= doc.scrollHeight - 4; if (scrolls && atBottom) current = ids[ids.length - 1]; setActive(current); }; const schedule = () => { if (!frame) frame = requestAnimationFrame(measure); }; measure(); window.addEventListener('scroll', schedule, { passive: true }); window.addEventListener('resize', schedule); return () => { if (frame) cancelAnimationFrame(frame); window.removeEventListener('scroll', schedule); window.removeEventListener('resize', schedule); }; }, [ids]); return [active, setActive]; } export function AgentConfigure({ fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false, /* Attaching and detaching a skill goes through the caller's existing write path, NOT through `set`. Every other control here writes a draft that Save persists; a skill persists immediately, on purpose — the whole point of the catalog is that the agent gains the capability there and then. Two write paths for one field would mean the Configure screen and the catalog disagreed about when a skill takes effect. */ onToggleSkill = null, pendingSkill = null, /* The tools this deployment registers, from GET /api/v1/tools. Passed in rather than fetched here so this component stays a form over `fields`. */ toolCatalogue = [], }: any) { /* Sections are open by default: this is a document, and one that greets its author with four closed headers hides the thing they came to write. Closing is for focus, not for the initial reading. */ const [open, setOpen] = React.useState( () => Object.fromEntries(SECTION_IDS.map((id) => [id, true])) ); const [setting, setSetting] = React.useState(/** @type {string | null} */ (null)); const [active, setActive] = useActiveSection(SECTION_IDS); /** * The skills these pages offer, and the ones not yet attached. * * Derived at render from `fields.pages` rather than held in state, for the * same reason the rail is: a second copy of a list that is already on screen * is a second thing to keep correct, and the one that drifts is always the * copy. * * Deduplicated by id because an agent covering several pages will be offered * the same skill by each of them. */ const availableSkills = React.useMemo(() => { const seen = new Map(); for (const page of fields.pages) { for (const skill of getSkillsForPage(page, { customSources: customSkills })) { if (!seen.has(skill.id)) seen.set(skill.id, skill); } } return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name)); }, [fields.pages, customSkills]); const attachableSkills = React.useMemo( () => availableSkills.filter((sk) => !fields.skills.includes(sk.id)), [availableSkills, fields.skills] ); const set = (patch) => onChange({ ...fields, ...patch }); const instructionWords = React.useMemo( () => (fields.instructions.trim() ? fields.instructions.trim().split(/\s+/).length : 0), [fields.instructions] ); const availableSubagents = React.useMemo( () => agents.filter((a) => a.id !== fields.id && a.status === 'published'), [agents, fields.id] ); const reasoning = REASONING_MODES.find((m) => m.id === fields.reasoning); const pageLabel = (p) => surfaceFor(p)?.label || p; const coveredLabels = fields.pages.map(pageLabel); const starterCount = fields.starters.filter((s) => s.label.trim()).length; /** * Going somewhere. * * A closed section is still addressable — the rail lists what is inside it, * and following one of those leaves has to arrive at the field rather than at * a header that happens to be shut. Opening first, scrolling on the next * frame, so the target has a height by the time it is scrolled to. */ const goTo = React.useCallback((sectionId, itemId) => { setOpen((current) => (current[sectionId] ? current : { ...current, [sectionId]: true })); if (itemId && itemId.startsWith('behavior-')) setSetting(itemId); setActive(sectionId); requestAnimationFrame(() => { requestAnimationFrame(() => { const target = document.getElementById(itemId || sectionId) || document.getElementById(sectionId); target?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }); }, [setActive]); const toggleSection = (id) => setOpen((current) => ({ ...current, [id]: !current[id] })); const toggleSetting = (id) => setSetting((current) => (current === id ? null : id)); /* The rail's model. Derived from the same fields the document renders, so a count on the rail cannot disagree with the list under it. */ const sections = [ { id: 'identity', icon: IdCard, label: 'Identity', meta: '', items: [ { id: 'identity-name', label: 'Name' }, { id: 'identity-description', label: 'Description' }, { id: 'identity-icon', label: 'Icon' }, { id: 'identity-trigger', label: 'When to use' }, ], }, { id: 'instructions', icon: FileText, label: 'Instructions', meta: instructionWords ? `${instructionWords}w` : '', items: [{ id: 'instructions-editor', label: 'Editor' }], }, { id: 'capabilities', icon: Boxes, label: 'Capabilities', meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`, items: [ { id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length }, { id: 'capabilities-skills', label: 'Skills', meta: fields.skills.length }, { id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length }, ], }, { id: 'behavior', icon: SlidersHorizontal, label: 'Behavior', meta: reasoning?.label || '', items: [ { id: 'behavior-reasoning', label: 'Reasoning', meta: reasoning?.label }, { id: 'behavior-starters', label: 'Starters', meta: starterCount }, { id: 'behavior-web', label: 'Web search', meta: fields.webSearch ? 'On' : 'Off' }, { id: 'behavior-subagents', label: 'Subagents', meta: fields.subagents.length }, ], }, ]; return ( <> }> {/* ── Identity ───────────────────────────────────────────────────── */} toggleSection('identity')} >
set({ name: e.target.value })} className="w-full" /> set({ description: e.target.value })} className="w-full" />