update owliver skill
This commit is contained in:
@@ -1,14 +1,21 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
ChevronDown, ChevronLeft, LayoutTemplate, MoreHorizontal, Pencil, Plus, Sparkles, Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuTrigger, EmptyState, SearchInput, Surface, Switch, toast,
|
||||
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 { allSkills, contextIdsForSkill } from '@/lib/skills/registry';
|
||||
import { removeCustomSkill } from '@/lib/skills/customSkills';
|
||||
import { ASSISTANT_CONTEXTS } from '@/components/ai-assistant/contexts';
|
||||
import { PAGE_KEYS, allSkills, skillsWithFacet } from '@/lib/skills/registry';
|
||||
import { removeCustomSkill, upsertCustomSkill } from '@/lib/skills/customSkills';
|
||||
import { countSections } from '@/lib/skills/uiConfig';
|
||||
import {
|
||||
owliverCapabilityLabel, placementLabel, sectionTypeLabel, surfaceFor,
|
||||
} from '@/lib/skills/surfaces';
|
||||
import { AdminPage } from '@/pages/admin/_shell';
|
||||
|
||||
/**
|
||||
@@ -20,21 +27,85 @@ import { AdminPage } from '@/pages/admin/_shell';
|
||||
* A registry deserves an address, so this is one — reachable from Workspace &
|
||||
* Skills, where Owliver's capabilities belong, and linkable on its own.
|
||||
*
|
||||
* Nothing here is a second skill system. The rows are `allSkills()` — the
|
||||
* Markdown files on disk plus whatever the account has added — and enabling or
|
||||
* disabling one writes the same `disabledSkills` preference it always did.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const KIND_LABEL = {
|
||||
assistant: 'Assistant',
|
||||
workforce: 'Workforce path',
|
||||
};
|
||||
/** 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, enabled, onToggle, onEdit, onDelete }) {
|
||||
const pages = contextIdsForSkill(skill)
|
||||
.map((id) => ASSISTANT_CONTEXTS[id]?.page)
|
||||
.filter(Boolean);
|
||||
function SkillRow({ skill, facet, enabled, onToggle, onEdit, onDelete }) {
|
||||
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">
|
||||
@@ -44,30 +115,24 @@ function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
|
||||
<Badge variant={enabled ? 'success' : 'neutral'} size="sm">
|
||||
{enabled ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
<Badge variant="soft" size="sm">{KIND_LABEL[skill.kind] || 'Assistant'}</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>
|
||||
)}
|
||||
|
||||
{/* A workforce path names the capability it governs and the rungs it
|
||||
defines: on this list those are what change other pages. */}
|
||||
{skill.kind === 'workforce' && (
|
||||
<p className="mt-1.5 text-[11px] text-ink-4">
|
||||
Capability: <span className="text-ink-3">{skill.skillId}</span>
|
||||
{skill.levels.length > 0 && <> · {skill.levels.map((l) => l.label).join(' → ')}</>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pages.length > 0 && (
|
||||
<p className="mt-1.5 text-[11px] text-ink-4">
|
||||
Surfaces: <span className="text-ink-3">{pages.join(', ')}</span>
|
||||
</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>
|
||||
@@ -96,14 +161,16 @@ function SkillRow({ skill, enabled, onToggle, onEdit, onDelete }) {
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{skill.custom ? 'Edit skill' : 'Duplicate & edit'}
|
||||
</DropdownMenuItem>
|
||||
{skill.custom && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(skill)}
|
||||
className="cursor-pointer text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> Remove skill
|
||||
</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>
|
||||
@@ -116,56 +183,240 @@ export default function WorkspaceSkills() {
|
||||
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 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 || [];
|
||||
const skills = useMemo(() => allSkills(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. */
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[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)),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[skills, removedSkills.join('|')]
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return skills;
|
||||
return skills.filter((s) => `${s.name} ${s.description} ${s.pages.join(' ')}`.toLowerCase().includes(q));
|
||||
}, [skills, search]);
|
||||
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 next = enabled
|
||||
? disabledSkills.filter((s) => s !== id)
|
||||
: [...new Set([...disabledSkills, id])];
|
||||
updatePreferences.mutate({ disabledSkills: next });
|
||||
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);
|
||||
toast.success(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) => {
|
||||
updatePreferences.mutate({ customSkills: removeCustomSkill(customSkills, skill.id) });
|
||||
const patch = skill.custom
|
||||
? { customSkills: removeCustomSkill(customSkills, skill.id) }
|
||||
: {
|
||||
removedSkills: [...new Set([...removedSkills, skill.id])],
|
||||
disabledSkills: [...new Set([...disabledSkills, skill.id])],
|
||||
};
|
||||
|
||||
updatePreferences.mutate(patch);
|
||||
setPendingDelete(null);
|
||||
toast.success(`${skill.name} removed`);
|
||||
};
|
||||
|
||||
const activeCount = skills.filter((s) => !disabledSkills.includes(s.id)).length;
|
||||
/**
|
||||
* 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),
|
||||
});
|
||||
toast.success(`${skill.name} restored — switched off`);
|
||||
};
|
||||
|
||||
/** 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 Owliver can use across KROW."
|
||||
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>
|
||||
<Button size="sm" shape="rounded" onClick={() => navigate('/admin/workspace/skills/new')}>
|
||||
<Plus aria-hidden="true" /> Add skill
|
||||
<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>
|
||||
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
|
||||
<div className="w-full lg:w-80">
|
||||
<SearchInput value={search} onChange={setSearch} placeholder="Search skills" size="sm" />
|
||||
<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">
|
||||
{skills.length} registered · {activeCount} active
|
||||
{listed.length} registered · {activeCount} active
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -181,34 +432,109 @@ export default function WorkspaceSkills() {
|
||||
<SkillRow
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
enabled={!disabledSkills.includes(skill.id)}
|
||||
facet={facet}
|
||||
enabled={isEnabled(skill)}
|
||||
onToggle={toggleSkill}
|
||||
onEdit={(s) => navigate(`/admin/workspace/skills/${s.id}`)}
|
||||
onEdit={(s) => navigate(editRoute(s))}
|
||||
onDelete={setPendingDelete}
|
||||
/>
|
||||
))}
|
||||
</Surface>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No skills match that search"
|
||||
description="Try a shorter term, or clear the search to see every registered skill."
|
||||
action={<Button variant="outline" size="sm" onClick={() => setSearch('')}>Clear search</Button>}
|
||||
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.
|
||||
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.
|
||||
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={`Remove ${pendingDelete?.name || 'skill'}?`}
|
||||
description="This removes the definition from this account. Skills shipped with KROW are unaffected."
|
||||
confirmLabel="Remove skill"
|
||||
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)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user