Files
krow_talent_app/src/pages/admin/WorkspaceSkills.jsx
2026-08-19 17:36:27 +05:30

746 lines
31 KiB
JavaScript

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 (
<div className="grid gap-3 px-4 py-3.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-body-sm font-semibold text-ink-1">{skill.name}</p>
<Badge variant={enabled ? 'success' : 'neutral'} size="sm">
{enabled ? 'Active' : 'Inactive'}
</Badge>
{type && <Badge variant="soft" size="sm">{type}</Badge>}
{skill.custom && <Badge variant="soft" size="sm">Custom</Badge>}
{both && <Badge variant="neutral" size="sm">Also on the other list</Badge>}
</div>
{skill.description && (
<p className="mt-1 text-caption leading-relaxed text-ink-3">{skill.description}</p>
)}
<p className="mt-1.5 text-[11px] text-ink-4">
{pages.length > 0 && (
<>Pages: <span className="text-ink-3">{pages.join(', ')}</span></>
)}
{pages.length > 0 && detail && ' · '}
{detail && <span className="text-ink-3">{detail}</span>}
</p>
<p className="mt-1 text-[11px] text-ink-4">
{skill.source === 'account' ? 'Added on this account' : 'Shipped with KROW'}
{' · '}
<span className="font-mono">{skill.path}</span>
</p>
</div>
<div className="flex items-center gap-1.5 sm:justify-end">
<Switch
checked={enabled}
onCheckedChange={(v) => onToggle(skill.id, v)}
aria-label={`${skill.name} — ${enabled ? 'active' : 'inactive'}`}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`Actions for ${skill.name}`}
className="grid h-8 w-8 place-items-center rounded-lg text-ink-3 transition-colors hover:bg-surface-sunken hover:text-ink-1
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<MoreHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => onEdit(skill)} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
{skill.custom ? 'Edit skill' : 'Duplicate & edit'}
</DropdownMenuItem>
{/* 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". */}
<DropdownMenuItem onClick={() => onExport(skill)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Export .md
</DropdownMenuItem>
{/* 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. */}
<DropdownMenuItem
onClick={() => onDelete(skill)}
className="cursor-pointer text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{skill.custom ? 'Remove skill' : 'Remove from workspace'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
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 (
<AdminPage
title="Skills"
subtitle="Manage the capabilities that extend KROW pages and Owliver."
actions={
<>
<Button variant="outline" size="sm" shape="rounded" onClick={() => navigate('/admin/workspace')}>
<ChevronLeft aria-hidden="true" /> Workspace
</Button>
{/* 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/`. */}
<input
ref={importRef}
type="file"
accept=".md,text/markdown"
multiple
onChange={importFiles}
className="hidden"
/>
<Button variant="outline" size="sm" shape="rounded" onClick={() => importRef.current?.click()}>
<Upload aria-hidden="true" /> Import .md
</Button>
<Button variant="outline" size="sm" shape="rounded" onClick={exportAll}>
<Download aria-hidden="true" /> Export all
</Button>
<Button size="sm" shape="rounded" onClick={() => navigate(current.route)}>
<Plus aria-hidden="true" /> {current.add}
</Button>
</>
}
>
<Tabs
tabs={tabs}
value={facet}
onChange={setFacet}
variant="underline"
size="sm"
layoutGroupId="skills-facet"
ariaLabel="Skill kinds"
/>
<p className="text-caption leading-relaxed text-ink-3">{current.blurb}</p>
{/* 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 && (
<Alert
tone={diagnostics.some((d) => d.level === 'error') ? 'destructive' : 'warning'}
title={`${diagnostics.length} skill ${diagnostics.length === 1 ? 'problem' : 'problems'} in this workspace`}
>
<div className="flex flex-col gap-3">
<ul className="space-y-2">
{diagnostics.map((d, i) => (
<li key={`${d.kind}:${d.skillId ?? d.path}:${i}`} className="text-caption leading-relaxed flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<span className="font-mono text-[11px] font-semibold">{d.skillId || d.path}</span>
{' — '}
<span>{d.message}</span>
</div>
{d.skillId && (
<Button
size="xs"
variant="outline"
onClick={() => handleFixDiagnostic(d)}
className="shrink-0 self-start sm:self-auto"
>
Remove
</Button>
)}
</li>
))}
</ul>
<div className="pt-2 border-t border-amber-200/50 flex justify-end">
<Button
size="xs"
variant="outline"
onClick={handleFixAllDiagnostics}
>
Resolve all problems
</Button>
</div>
</div>
</Alert>
)}
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="w-full lg:w-72">
<SearchInput value={search} onChange={setSearch} placeholder={`Search ${current.label.toLowerCase()}`} size="sm" />
</div>
<div className="flex flex-wrap items-center gap-2">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-9 w-36"><SelectValue placeholder="Status" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
{pageOptions.length > 1 && (
<Select value={page} onValueChange={setPage}>
<SelectTrigger className="h-9 w-44"><SelectValue placeholder="Page" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">All pages</SelectItem>
{pageOptions.map((key) => (
<SelectItem key={key} value={key}>{surfaceFor(key)?.label || key}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<p className="text-caption text-ink-4 lg:ml-auto">
{listed.length} registered · {activeCount} active
</p>
</div>
{filtered.length ? (
<Surface
variant="solid"
radius="lg"
padding="none"
elevation="xs"
className="divide-y divide-border overflow-hidden"
>
{filtered.map((skill) => (
<SkillRow
key={skill.id}
skill={skill}
facet={facet}
enabled={isEnabled(skill)}
onToggle={toggleSkill}
onEdit={(s) => navigate(editRoute(s))}
onDelete={setPendingDelete}
onExport={exportSkill}
/>
))}
</Surface>
) : (
<EmptyState
title={listed.length ? 'No skills match those filters' : current.emptyTitle}
description={listed.length
? 'Try a shorter term, or clear the filters to see every skill on this list.'
: current.emptyBody}
action={listed.length ? (
<Button
variant="outline"
size="sm"
onClick={() => { setSearch(''); setStatus('all'); setPage('all'); }}
>
Clear filters
</Button>
) : (
<Button size="sm" onClick={() => navigate(current.route)}>
<Plus aria-hidden="true" /> {current.add}
</Button>
)}
/>
)}
{/* 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 && (
<section aria-labelledby="removed-skills" className="space-y-2">
<button
type="button"
onClick={() => setShowRemoved((v) => !v)}
aria-expanded={showRemoved}
className="inline-flex items-center gap-1.5 rounded text-caption font-medium text-ink-3 transition-colors
hover:text-krow-blue focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
>
<span id="removed-skills">
Removed from this workspace ({removed.length})
</span>
<ChevronDown
className={cn('h-3.5 w-3.5 transition-transform', showRemoved && 'rotate-180')}
aria-hidden="true"
/>
</button>
{showRemoved && (
<Surface
variant="solid"
radius="lg"
padding="none"
elevation="xs"
className="divide-y divide-border overflow-hidden"
>
{removed.map((skill) => (
<div key={skill.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<p className="text-body-sm font-medium text-ink-2">{skill.name}</p>
<p className="mt-0.5 text-[11px] text-ink-4">
{skill.source === 'account' ? 'Added on this account' : 'Shipped with KROW'}
{' · '}
<span className="font-mono">{skill.path}</span>
</p>
</div>
<Button variant="outline" size="sm" shape="rounded" onClick={() => restoreSkill(skill)}>
Restore
</Button>
</div>
))}
</Surface>
)}
</section>
)}
<p className="text-caption leading-relaxed text-ink-4">
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.
</p>
<ConfirmModal
open={Boolean(pendingDelete)}
onOpenChange={(open) => !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)}
/>
</AdminPage>
);
}