diff --git a/client/src/App.tsx b/client/src/App.tsx index 4439503..3ff88fd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,6 +9,7 @@ import { OrgChartPage } from '@/pages/OrgChartPage' import { PerformancePage } from '@/pages/PerformancePage' import { ReviewsPage } from '@/pages/ReviewsPage' import { SeatsPage } from '@/pages/SeatsPage' +import { SkillsPage } from '@/pages/SkillsPage' import { StructurePage } from '@/pages/StructurePage' import { useAuth } from '@/store/auth' @@ -27,6 +28,7 @@ export default function App() { : } /> : } /> : } /> + : } /> : } /> } /> diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index cdbddef..429847f 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import { Link, useLocation } from 'react-router' import { + BookMarked, Bot, Boxes, ChartColumn, @@ -42,6 +43,7 @@ export function AppShell({ children }: { children: ReactNode }) { + diff --git a/client/src/pages/SkillsPage.tsx b/client/src/pages/SkillsPage.tsx new file mode 100644 index 0000000..945e39e --- /dev/null +++ b/client/src/pages/SkillsPage.tsx @@ -0,0 +1,523 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { BookMarked, GitFork, Pencil, Plus, Store, Trash2 } from 'lucide-react' +import { toast } from 'sonner' +import { AppShell } from '@/components/AppShell' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet' +import { Textarea } from '@/components/ui/textarea' +import { api } from '@/lib/api' +import { useAuth } from '@/store/auth' + +interface ActionDto { + name: string + risk: string + description?: string | null +} + +interface GoldenTest { + input: string + expected: string +} + +interface SkillSummary { + skillKey: string + name: string + version: string + summary: string | null + roles: string[] + visibility: string + minTier: string + status: string + origin: string + organizationId: string | null + goldenTestCount: number + actions: ActionDto[] +} + +interface SkillDetail { + skill: SkillSummary + inputs: string | null + outputs: string | null + tools: string[] + context: string[] + goldenTests: GoldenTest[] + body: string +} + +type Mode = 'new' | 'version' | 'edit' + +interface FormState { + mode: Mode + skillKey: string + name: string + version: string + summary: string + roles: string + inputs: string + outputs: string + tools: string + context: string + visibility: string + minTier: string + body: string + actions: ActionDto[] + goldenTests: GoldenTest[] +} + +const COMMON_ROLES = 'product-owner, engineer, qa, designer, analyst' +const RISKS = ['read', 'draft', 'publish', 'destructive'] +const VISIBILITIES = ['public', 'private'] +const TIERS = ['free', 'team', 'scale', 'enterprise'] + +const emptyForm = (): FormState => ({ + mode: 'new', + skillKey: '', + name: '', + version: '1.0.0', + summary: '', + roles: '', + inputs: '', + outputs: '', + tools: '', + context: '', + visibility: 'public', + minTier: 'free', + body: '', + actions: [], + goldenTests: [], +}) + +/** Bump the last numeric segment of a semver-ish string (1.0.0 → 1.0.1). */ +function bumpPatch(version: string): string { + const parts = version.split('.') + for (let i = parts.length - 1; i >= 0; i--) { + const n = Number(parts[i]) + if (Number.isInteger(n)) { + parts[i] = String(n + 1) + return parts.join('.') + } + } + return `${version}.1` +} + +const csv = (s: string): string[] => + s.split(',').map((x) => x.trim()).filter(Boolean) + +/** The org's skill library: builtin starter skills + skills the company authors and versions itself. */ +export function SkillsPage() { + const organizationId = useAuth((s) => s.organizationId) + const [tab, setTab] = useState<'library' | 'marketplace'>('library') + const [skills, setSkills] = useState([]) + const [marketplace, setMarketplace] = useState([]) + const [form, setForm] = useState(null) + const [busy, setBusy] = useState(false) + + const load = useCallback(async () => { + if (!organizationId) return + try { + const [lib, market] = await Promise.all([ + api.get(`/api/skills?organizationId=${organizationId}`), + api.get(`/api/skills/marketplace`), + ]) + setSkills(lib) + setMarketplace(market) + } catch (err) { + toast.error((err as Error).message) + } + }, [organizationId]) + + useEffect(() => { + void load() + }, [load]) + + // Group every version under its key; newest (and the org's own) first comes from the API order. + const groups = useMemo(() => { + const byKey = new Map() + for (const s of skills) { + const list = byKey.get(s.skillKey) ?? [] + list.push(s) + byKey.set(s.skillKey, list) + } + return [...byKey.entries()].sort((a, b) => a[0].localeCompare(b[0])) + }, [skills]) + + const openForm = async (key: string, version: string, mode: Mode) => { + try { + const details = await api.get(`/api/skills/${key}?organizationId=${organizationId}`) + const d = details.find((x) => x.skill.version === version) ?? details[0] + if (!d) return + setForm({ + mode, + skillKey: d.skill.skillKey, + name: d.skill.name, + version: mode === 'version' ? bumpPatch(d.skill.version) : d.skill.version, + summary: d.skill.summary ?? '', + roles: d.skill.roles.join(', '), + inputs: d.inputs ?? '', + outputs: d.outputs ?? '', + tools: d.tools.join(', '), + context: d.context.join(', '), + visibility: d.skill.visibility === 'PrivateToOrg' ? 'private' : 'public', + minTier: d.skill.minTier.toLowerCase(), + body: d.body, + actions: d.skill.actions.map((a) => ({ name: a.name, risk: a.risk.toLowerCase(), description: a.description ?? '' })), + goldenTests: d.goldenTests.map((g) => ({ ...g })), + }) + } catch (err) { + toast.error((err as Error).message) + } + } + + const fork = async (key: string, version: string) => { + setBusy(true) + try { + await api.post(`/api/skills/${key}/fork`, { organizationId, version }) + toast.success(`Forked ${key} into your org — edit it to customize.`) + await load() + } catch (err) { + toast.error((err as Error).message) + } finally { + setBusy(false) + } + } + + const save = async () => { + if (!form) return + setBusy(true) + try { + await api.post('/api/skills/authored', { + organizationId, + skillKey: form.skillKey.trim(), + name: form.name.trim(), + version: form.version.trim(), + summary: form.summary.trim() || null, + roles: csv(form.roles), + inputs: form.inputs.trim() || null, + outputs: form.outputs.trim() || null, + tools: csv(form.tools), + context: csv(form.context), + visibility: form.visibility, + minTier: form.minTier, + body: form.body, + actions: form.actions + .filter((a) => a.name.trim()) + .map((a) => ({ name: a.name.trim(), risk: a.risk, description: a.description?.trim() || null })), + goldenTests: form.goldenTests.filter((g) => g.input.trim() && g.expected.trim()), + }) + toast.success(`Saved ${form.skillKey}@${form.version}.`) + setForm(null) + await load() + } catch (err) { + toast.error((err as Error).message) + } finally { + setBusy(false) + } + } + + const willPublish = !!form && csv(form.roles).length > 0 && form.goldenTests.some((g) => g.input.trim() && g.expected.trim()) + + return ( + +
+
+
+

+ Skills +

+

+ Your company's skill library. Builtin starter skills are shared; author and version your own. +

+
+ +
+ +
+ setTab('library')} icon={BookMarked}>Library + setTab('marketplace')} icon={Store}>Marketplace +
+ + {tab === 'library' ? ( +
+ {groups.map(([key, versions]) => ( + openForm(key, v, 'version')} + onEdit={(v) => openForm(key, v, 'edit')} + onFork={(v) => fork(key, v)} + /> + ))} + {groups.length === 0 && ( +

No skills yet. Run a Git sync for builtins, or author one.

+ )} +
+ ) : ( +
+

+ Public skills shared by other organizations. One-click install lands in the next step. +

+ {marketplace.map((s) => ( + + + + {s.name} {s.version} + + {s.summary} + + + {s.roles.map((r) => {r})} + + + + ))} + {marketplace.length === 0 &&

Nothing published yet.

} +
+ )} +
+ + {form && ( + !o && setForm(null)}> + + + + {form.mode === 'new' ? 'New skill' : form.mode === 'version' ? `New version of ${form.skillKey}` : `Edit ${form.skillKey}`} + + + {willPublish + ? 'Has roles + a golden test — saves as Published.' + : 'Add ≥1 role and ≥1 golden test to publish; otherwise saved as Draft.'} + + + +
+
+ + setForm({ ...form, skillKey: e.target.value })} + placeholder="api-endpoint-design" + /> + + + setForm({ ...form, version: e.target.value })} /> + +
+ + setForm({ ...form, name: e.target.value })} placeholder="API Endpoint Design" /> + + + setForm({ ...form, summary: e.target.value })} /> + + + setForm({ ...form, roles: e.target.value })} placeholder="engineer" /> + +
+ + setForm({ ...form, inputs: e.target.value })} /> + + + setForm({ ...form, outputs: e.target.value })} /> + +
+
+ + setForm({ ...form, visibility: v })} /> + + + setForm({ ...form, minTier: v })} /> + +
+
+ + setForm({ ...form, tools: e.target.value })} /> + + + setForm({ ...form, context: e.target.value })} /> + +
+ + setForm({ ...form, actions: [...form.actions, { name: '', risk: 'draft', description: '' }] })} + > + {form.actions.map((a, i) => ( +
+ setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)) })} + /> + setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, risk: v } : x)) })} + /> + +
+ ))} +
+ + setForm({ ...form, goldenTests: [...form.goldenTests, { input: '', expected: '' }] })} + > + {form.goldenTests.map((g, i) => ( +
+
+ setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, input: e.target.value } : x)) })} + /> + +
+