position ui and owliver update
This commit is contained in:
217
src/pages/admin/WorkspaceSkills.jsx
Normal file
217
src/pages/admin/WorkspaceSkills.jsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Badge, Button, ConfirmModal, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||
DropdownMenuTrigger, EmptyState, SearchInput, Surface, Switch, toast,
|
||||
} from '@/components/ds';
|
||||
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 { 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const KIND_LABEL = {
|
||||
assistant: 'Assistant',
|
||||
workforce: 'Workforce path',
|
||||
};
|
||||
|
||||
/** 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);
|
||||
|
||||
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>
|
||||
<Badge variant="soft" size="sm">{KIND_LABEL[skill.kind] || 'Assistant'}</Badge>
|
||||
{skill.custom && <Badge variant="soft" size="sm">Custom</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">
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkspaceSkills() {
|
||||
const navigate = useNavigate();
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [pendingDelete, setPendingDelete] = useState(null);
|
||||
|
||||
const disabledSkills = preferences.disabledSkills || [];
|
||||
const customSkills = preferences.customSkills || [];
|
||||
const skills = useMemo(() => allSkills(customSkills), [customSkills]);
|
||||
|
||||
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]);
|
||||
|
||||
const toggleSkill = (id, enabled) => {
|
||||
const next = enabled
|
||||
? disabledSkills.filter((s) => s !== id)
|
||||
: [...new Set([...disabledSkills, id])];
|
||||
updatePreferences.mutate({ disabledSkills: next });
|
||||
toast.success(enabled ? 'Skill enabled' : 'Skill disabled');
|
||||
};
|
||||
|
||||
const removeSkill = (skill) => {
|
||||
updatePreferences.mutate({ customSkills: removeCustomSkill(customSkills, skill.id) });
|
||||
setPendingDelete(null);
|
||||
toast.success(`${skill.name} removed`);
|
||||
};
|
||||
|
||||
const activeCount = skills.filter((s) => !disabledSkills.includes(s.id)).length;
|
||||
|
||||
return (
|
||||
<AdminPage
|
||||
title="Skills"
|
||||
subtitle="Manage the capabilities Owliver can use across KROW."
|
||||
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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
<p className="text-caption text-ink-4 lg:ml-auto">
|
||||
{skills.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}
|
||||
enabled={!disabledSkills.includes(skill.id)}
|
||||
onToggle={toggleSkill}
|
||||
onEdit={(s) => navigate(`/admin/workspace/skills/${s.id}`)}
|
||||
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>}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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.
|
||||
</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"
|
||||
tone="destructive"
|
||||
onConfirm={() => pendingDelete && removeSkill(pendingDelete)}
|
||||
/>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user