import React, { useMemo, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { ChevronDown, ChevronLeft, Download, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles, Trash2, Upload, } from 'lucide-react'; import { Alert, Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, EmptyState, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Surface, Switch, Tabs, toast, } from '@/components/ds'; import { cn } from '@/lib/utils'; import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks'; import { PAGE_KEYS, parseSkill, readSkillRegistry, skillsWithFacet, validateSkillSource, } from '@/lib/skills/registry'; import { removeCustomSkill, upsertCustomSkill } from '@/lib/skills/customSkills'; import { reportSave } from '@/lib/skills/saveFeedback'; import { normalizeUpload } from '@/lib/skills/skillFields'; import { countSections } from '@/lib/skills/uiConfig'; import { owliverCapabilityLabel, placementLabel, sectionTypeLabel, surfaceFor, } from '@/lib/skills/surfaces'; import { AdminPage } from '@/pages/admin/_shell'; /** * Skills — the management page. * * Skills used to be a section at the bottom of the account page, which made * "add a skill" mean "append another row to a page about something else". They * are a registry with their own lifecycle: authored, enabled, edited, removed. * A registry deserves an address, so this is one — reachable from Workspace & * Skills, where Owliver's capabilities belong, and linkable on its own. * * Two lists, because there are two jobs. A **UI skill** extends a KROW page: it * declares a section, a placement and a reading, and the page draws it. An * **Owliver skill** extends the assistant: it declares triggers, suggestions * and the shapes an answer can take. Reading one list and finding both mixed * together was the problem this page had — "pages" meant "where it draws" in * one row and "where it listens" in the next, and nothing on screen said which. * * What is *not* split is as important. Both lists are readings of `allSkills()` * — one registry, one parser, one validator, one persistence, one data * resolver. A definition's facets are derived from what it declares, so this * page classifies rather than configures, and nothing stored had to be * migrated for the split to exist. */ /** The two lists, and the words each one uses for itself. */ const FACETS = [ { id: 'ui', label: 'Board Skills', icon: LayoutTemplate, add: 'Add Board Skill', route: '/admin/workspace/skills/new', blurb: 'Definitions that add dynamic sections to KROW pages, read from real page data.', emptyTitle: 'No Board skills yet', emptyBody: 'A Board skill declares a section, where it sits and what it reads. Add one and the page it names renders it.', }, { id: 'owliver', label: 'Owliver Skills', icon: Sparkles, add: 'Add Owliver Skill', route: '/admin/workspace/skills/owliver/new', blurb: 'Definitions that teach Owliver what it can be asked for on a page, and how to answer.', emptyTitle: 'No Owliver skills yet', emptyBody: 'An Owliver skill declares triggers, suggestions and the shapes its answers take. Add one and the panel on that page offers it.', }, ]; const facetFor = (id) => FACETS.find((f) => f.id === id) || FACETS[0]; /** The page labels a definition attaches to, in the product's own words. */ const surfaceLabels = (skill) => skill.pages.map((p) => surfaceFor(p)?.label || p).filter(Boolean); /** * What this definition contributes, in the terms of the list it is on. * * The same skill answers the question differently depending on which job is * being managed: on the UI list it is a shape and a placement, on the Owliver * list it is the capabilities it can be asked for. Derived from the definition * both times — nothing here is a stored label. */ function contribution(skill, facet) { if (facet === 'ui') { const sections = Object.values(skill.ui || {}).flatMap((page) => page.sections || []); return { type: [...new Set(sections.map((s) => sectionTypeLabel(s.type)))].join(' · '), detail: [...new Set(sections.map((s) => placementLabel(s.placement)))].join(', '), }; } const capabilities = skill.owliver?.capabilities || []; return { type: capabilities.map(owliverCapabilityLabel).join(' · '), /* A definition written before the split has no capability list — what it offers Owliver is its triggers, so say that rather than nothing. */ detail: capabilities.length ? `${skill.owliver.suggestions.length || 'No'} suggestion${skill.owliver.suggestions.length === 1 ? '' : 's'}` : `${skill.triggers.length} trigger${skill.triggers.length === 1 ? '' : 's'}`, }; } /** One skill, as a row in the management list. */ function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete, onExport }) { const pages = surfaceLabels(skill); const { type, detail } = contribution(skill, facet); /* A definition doing both jobs appears on both lists. Saying so on the row is what stops it reading as a duplicate. */ const both = skill.facets.length > 1; return (

{skill.name}

{enabled ? 'Active' : 'Inactive'} {type && {type}} {skill.custom && Custom} {both && Also on the other list}
{skill.description && (

{skill.description}

)}

{pages.length > 0 && ( <>Pages: {pages.join(', ')} )} {pages.length > 0 && detail && ' · '} {detail && {detail}}

{skill.source === 'account' ? 'Added on this account' : 'Shipped with KROW'} {' · '} {skill.path}

onToggle(skill.id, v)} aria-label={`${skill.name} — ${enabled ? 'active' : 'inactive'}`} /> onEdit(skill)} className="cursor-pointer"> {skill.custom ? 'Edit skill' : 'Duplicate & edit'} {/* The definition, back out as the file it is. An account skill lives in this browser and nowhere else; without a way to take one out, "stored locally" means "stored until something goes wrong". */} onExport(skill)} className="cursor-pointer"> Export .md {/* Offered for every definition. What it does differs — an account definition is deleted, a shipped one is taken out of this workspace — and the dialog says which. */} onDelete(skill)} className="cursor-pointer text-destructive focus:text-destructive" > {skill.custom ? 'Remove skill' : 'Remove from workspace'}
); } export default function WorkspaceSkills() { const navigate = useNavigate(); const preferences = usePreferences(); const updatePreferences = useUpdatePreferences(); /* Which list is open lives in the URL, so a link can open one and the back button steps between them rather than out of the page. */ const [params, setParams] = useSearchParams(); const facet = FACETS.some((f) => f.id === params.get('tab')) ? params.get('tab') : 'ui'; const setFacet = (next) => setParams(next === 'ui' ? {} : { tab: next }, { replace: true }); const [search, setSearch] = useState(''); const [status, setStatus] = useState('all'); const [page, setPage] = useState('all'); const [pendingDelete, setPendingDelete] = useState(null); const [showRemoved, setShowRemoved] = useState(false); const importRef = React.useRef(null); const disabledSkills = preferences.disabledSkills || []; /** * Definitions this account has removed. * * A shipped definition lives in the repository, so "remove" cannot mean * deleting it — the file would come back on the next deployment and the * button would be lying. It means removed *from this workspace*: hidden from * this list and switched off everywhere. * * The second half is why removal needs no other file to change. A removed id * is also written to `disabledSkills`, which every surface, resolver and * suggestion path already honours — so "removed" is exactly "disabled, and * out of the way", and nothing had to learn a second rule. */ const removedSkills = preferences.removedSkills || []; const customSkills = preferences.customSkills || []; /* The registry and everything that went wrong assembling it, from one read. A definition that failed to parse, lost a capability, or was overridden by another used to disappear without a word — the skill simply stopped answering and this page still looked healthy. */ const { skills, diagnostics } = useMemo( () => readSkillRegistry(customSkills), [customSkills] ); /** * Whether a skill is actually offered. * * Two independent things switch a skill off — the definition declaring * `status: inactive`, and this account disabling it — and the renderer honours * both. This list used to read only the second, so a definition that ships * inactive appeared here as "Active" while every surface correctly refused to * render it. The Skill Editor already reconciled the two; now so does this. */ const isEnabled = (skill) => skill.status !== 'inactive' && !disabledSkills.includes(skill.id); /** * Everything still in this workspace. * * Removal is filtered out *here*, at the source, rather than where the rows * are drawn — because four things count these definitions: the two tab * badges, the page filter's options, and the "N registered · N active" * footer. Filtering at render would leave a tab reading "4" above an empty * list and a footer counting definitions the reader has removed. */ const present = useMemo( () => skills.filter((s) => !removedSkills.includes(s.id)), /* Keyed on the content: `preferences` rebuilds its arrays every read. */ [skills, removedSkills.join('|')] ); /** The definitions on this list, before the management filters. */ const listed = useMemo(() => skillsWithFacet(present, facet), [present, facet]); /** The removed ones, for the drawer that lets them be put back. */ const removed = useMemo( () => skills.filter((s) => removedSkills.includes(s.id)), [skills, removedSkills.join('|')] ); const filtered = useMemo(() => { const q = search.trim().toLowerCase(); return listed.filter((s) => { if (q && !`${s.name} ${s.description} ${s.pages.join(' ')}`.toLowerCase().includes(q)) return false; if (status === 'active' && !isEnabled(s)) return false; if (status === 'disabled' && isEnabled(s)) return false; if (page !== 'all' && !s.pages.some((p) => (surfaceFor(p)?.id || p) === page)) return false; return true; }); /* `isEnabled` closes over the preference arrays, so `disabledSkills` is the real input rather than the function itself. */ }, [listed, search, status, page, disabledSkills]); /** * Flipping the switch has to move whichever of the two is holding the skill * off, or the control does nothing visible. * * Enabling one that ships inactive writes an account definition with * `status: active` — the same override the Skill Editor writes, through the * same helper — rather than a second kind of "enabled" state beside it. * * One definition is switched, never a list: a UI skill and an Owliver skill * are separate definitions with separate ids, so disabling either leaves the * other exactly as it was. */ const toggleSkill = (id, enabled) => { const skill = skills.find((s) => s.id === id); const patch = { disabledSkills: enabled ? disabledSkills.filter((s) => s !== id) : [...new Set([...disabledSkills, id])], }; if (skill && enabled && skill.status === 'inactive') { const source = /^status:\s*\w+$/m.test(skill.markdown) ? skill.markdown.replace(/^status:\s*\w+$/m, 'status: active') : skill.markdown.replace(/^---\n/, '---\nstatus: active\n'); patch.customSkills = upsertCustomSkill(customSkills, source).next; } updatePreferences.mutate(patch, reportSave(enabled ? 'Skill enabled' : 'Skill disabled')); }; /** * Remove, which means two different things honestly rather than one thing * badly. * * An **account** definition is stored here, so removing it deletes it — the * behaviour this button has always had. A **shipped** definition is a file in * the repository this page cannot delete, so removing it takes it out of the * workspace instead: hidden from the list, and switched off everywhere by the * same `disabledSkills` every surface already reads. Reversible either way for * shipped definitions, and the copy on the dialog says which it is doing. */ const removeSkill = (skill) => { const patch = skill.custom ? { customSkills: removeCustomSkill(customSkills, skill.id) } : { removedSkills: [...new Set([...removedSkills, skill.id])], disabledSkills: [...new Set([...disabledSkills, skill.id])], }; updatePreferences.mutate(patch, reportSave(`${skill.name} removed`)); setPendingDelete(null); }; /** * Puts a removed definition back on the list — switched off. * * Deliberately not re-enabled. `disabledSkills` now carries two intentions: * "the reader switched this off" and "this was switched off because it was * removed", and nothing distinguishes them. Clearing it on restore would * silently turn on a skill the reader had disabled *before* removing it, * which is the kind of thing that surfaces as a bug report weeks later. * * Coming back visibly inactive has no silent failure mode: the row is on the * list with its switch off, and one click is the whole cost. */ const restoreSkill = (skill) => { updatePreferences.mutate( { removedSkills: removedSkills.filter((id) => id !== skill.id) }, reportSave(`${skill.name} restored — switched off`) ); }; /** * A definition, downloaded as the file it already is. * * `skill.markdown` is the artefact — the same text a file in `src/skills/` * holds — so this is a copy, not an export format. That is what makes it * useful: the file that comes out can be uploaded into another browser, * committed to the repository, or read by a person. */ const exportSkill = (skill) => { const blob = new Blob([skill.markdown], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${skill.id}.md`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); }; /** Every definition this account authored, one file at a time. */ const exportAll = () => { const mine = skills.filter((s) => s.custom); if (!mine.length) { toast.error('This workspace has no account-authored skills to export.'); return; } mine.forEach(exportSkill); toast.success(`Exported ${mine.length} skill${mine.length === 1 ? '' : 's'}`); }; /** * Definitions read back in from files. * * The same validator and the same writer the editors use, so an imported file * cannot enter the registry by a route that checks less than authoring does. * Each file is reported on its own: one bad file in a selection of six must * not cost the other five. */ const importFiles = (event) => { const files = [...(event.target.files || [])]; event.target.value = ''; if (!files.length) return; Promise.all(files.map((file) => file.text().then( (raw) => ({ name: file.name, raw }), () => ({ name: file.name, raw: null }) ))).then((read) => { let next = customSkills; const added = []; const refused = []; for (const { name, raw: text } of read) { /* Same normalisation the editors apply, so a file imported here and the same file uploaded there become the identical stored definition. */ const raw = text === null ? null : normalizeUpload(text); const problem = raw === null ? 'could not be read' : validateSkillSource(raw); if (problem) { refused.push(`${name}: ${problem}`); continue; } const result = upsertCustomSkill(next, raw); next = result.next; added.push(result.skill.name); } if (added.length) { updatePreferences.mutate( { customSkills: next }, reportSave(`Imported ${added.length} skill${added.length === 1 ? '' : 's'}`) ); } refused.forEach((message) => toast.error(message)); }); }; const handleFixDiagnostic = (d) => { if (!d.skillId) return; const isCustom = customSkills.some((cs) => { try { const parsed = parseSkill(typeof cs === 'string' ? cs : cs?.raw ?? '', { custom: true }); return parsed?.id === d.skillId; } catch { return false; } }); if (isCustom) { const nextCustom = removeCustomSkill(customSkills, d.skillId); updatePreferences.mutate( { customSkills: nextCustom }, reportSave(`Removed custom skill "${d.skillId}"`) ); } else { const patch = { removedSkills: [...new Set([...removedSkills, d.skillId])], disabledSkills: [...new Set([...disabledSkills, d.skillId])], }; updatePreferences.mutate(patch, reportSave(`Removed "${d.skillId}" from workspace`)); } }; const handleFixAllDiagnostics = () => { let nextCustom = [...customSkills]; let nextRemoved = [...removedSkills]; let nextDisabled = [...disabledSkills]; diagnostics.forEach((d) => { if (!d.skillId) return; nextCustom = removeCustomSkill(nextCustom, d.skillId); nextRemoved = [...new Set([...nextRemoved, d.skillId])]; nextDisabled = [...new Set([...nextDisabled, d.skillId])]; }); updatePreferences.mutate( { customSkills: nextCustom, removedSkills: nextRemoved, disabledSkills: nextDisabled }, reportSave('Resolved all skill problems') ); }; /** A row opens the editor its own kind is authored in. */ const editRoute = (skill) => (facet === 'owliver' ? `/admin/workspace/skills/owliver/${skill.id}` : `/admin/workspace/skills/${skill.id}`); const current = facetFor(facet); const tabs = FACETS.map((f) => ({ value: f.id, label: f.label, icon: f.icon, count: skillsWithFacet(present, f.id).length, })); /* Only the pages something is actually attached to, so the filter never offers a page that would empty the list. */ const pageOptions = useMemo(() => { const used = new Set(listed.flatMap((s) => s.pages.map((p) => surfaceFor(p)?.id || p))); return PAGE_KEYS.filter((key) => used.has(key)); }, [listed]); const activeCount = listed.filter(isEnabled).length; return ( {/* Account definitions live in this browser's storage and nowhere else. Import and export are what make that a place rather than a trap: a skill can be moved to another machine, handed to someone, or committed to `src/skills/`. */} } >

{current.blurb}

{/* What the registry could not read, or read only in part. A skill that half-loads used to be indistinguishable from a skill that works: the capabilities that resolved answered, the ones that did not were dropped without a word, and the definition still appeared here as Active. Saying so is the whole fix — the parser's leniency is correct, its silence was not. */} {diagnostics.length > 0 && ( d.level === 'error') ? 'destructive' : 'warning'} title={`${diagnostics.length} skill ${diagnostics.length === 1 ? 'problem' : 'problems'} in this workspace`} >
    {diagnostics.map((d, i) => (
  • {d.skillId || d.path} {' — '} {d.message}
    {d.skillId && ( )}
  • ))}
)}
{pageOptions.length > 1 && ( )}

{listed.length} registered · {activeCount} active

{filtered.length ? ( {filtered.map((skill) => ( navigate(editRoute(s))} onDelete={setPendingDelete} onExport={exportSkill} /> ))} ) : ( { setSearch(''); setStatus('all'); setPage('all'); }} > Clear filters ) : ( )} /> )} {/* Removed definitions, and the way back. A removal that cannot be undone from the same page it was made on is a deletion wearing a softer word — so the list is here, collapsed until there is something in it. */} {removed.length > 0 && (
{showRemoved && ( {removed.map((skill) => (

{skill.name}

{skill.source === 'account' ? 'Added on this account' : 'Shipped with KROW'} {' · '} {skill.path}

))}
)}
)}

Each skill is a Markdown definition that attaches itself to the pages it names. A UI skill draws a section on those pages; an Owliver skill answers questions on them. Both are read by the same registry and the same data resolver, so a figure in a card and a figure in an answer come from one reading. Definitions shipped with KROW live in the repository and can be switched off here; skills added on this account are stored with your preferences. Markdown is stored as data — it is never executed.

!open && setPendingDelete(null)} title={pendingDelete?.custom ? `Remove ${pendingDelete?.name || 'skill'}?` : `Remove ${pendingDelete?.name || 'skill'} from this workspace?`} description={pendingDelete?.custom ? `This deletes one definition from this account${ countSections(pendingDelete) > 0 && pendingDelete.facets?.includes('owliver') ? ' — including both the section it draws and the answers it offers' : '' }. Every other skill, and anything shipped with KROW, is unaffected.` : `${pendingDelete?.name || 'This skill'} ships with KROW, so its definition stays in the repository. It is switched off everywhere and taken off this list — you can put it back from “Removed”.`} confirmLabel={pendingDelete?.custom ? 'Remove skill' : 'Remove from workspace'} tone="destructive" onConfirm={() => pendingDelete && removeSkill(pendingDelete)} />
); }