Files
Teamup/client/src/pages/SkillsPage.tsx
T
soroush.asadi 4758e4b5de Markdown Edit/Preview tabs + read-only .md viewer for skills & profiles
Adds MarkdownEditor (react-markdown + remark-gfm, no raw HTML — authored/retrieved
content is data, not markup) with Edit | Preview tabs, wired into the AGENTS.md and
SKILL.md editors, the agent persona, and the review artifact.

Adds a read-only "View" on every skill and agent-profile card — including builtins,
which previously had no way to be inspected at all — rendering the full SKILL.md /
AGENTS.md (frontmatter + body + actions/golden tests). Collapses a same-version
builtin that an org has forked so its own copy shadows it, keeping the version
picker unambiguous and the item clearly editable/versionable.

Also lands the agent-face wiring on the seat configurator (a live xl preview with a
state cycler) and the review inbox header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:26:14 +03:30

666 lines
26 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react'
import { BookMarked, Download, Eye, GitFork, Pencil, Plus, Store, Trash2, Upload } 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 { MarkdownEditor } from '@/components/MarkdownEditor'
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 {
id: string
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
}
interface MarketplaceEntry {
skill: SkillSummary
alreadyInLibrary: boolean
}
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: 'private',
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)
/** Reconstruct a readable SKILL.md (frontmatter + prompt body + actions/golden tests) for the viewer. */
function skillToMarkdown(d: SkillDetail): string {
const s = d.skill
const fm = [
`id: ${s.skillKey}`,
`name: ${s.name}`,
`version: ${s.version}`,
s.summary ? `summary: ${s.summary}` : null,
s.roles.length ? `roles: [${s.roles.join(', ')}]` : null,
d.inputs ? `inputs: ${d.inputs}` : null,
d.outputs ? `outputs: ${d.outputs}` : null,
d.tools.length ? `tools: [${d.tools.join(', ')}]` : null,
d.context.length ? `context: [${d.context.join(', ')}]` : null,
`visibility: ${s.visibility === 'PrivateToOrg' ? 'private' : 'public'}`,
`min_tier: ${s.minTier.toLowerCase()}`,
].filter(Boolean)
const actions = s.actions.length
? `\n\n## Actions\n${s.actions.map((a) => `- **${a.name}** (${a.risk.toLowerCase()})${a.description ? `${a.description}` : ''}`).join('\n')}`
: ''
const golden = d.goldenTests.length
? `\n\n## Golden tests\n${d.goldenTests.map((g, i) => `${i + 1}. input: \`${g.input}\` → expected: ${g.expected}`).join('\n')}`
: ''
return `---\n${fm.join('\n')}\n---\n\n${d.body}${actions}${golden}`
}
/** 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<SkillSummary[]>([])
const [marketplace, setMarketplace] = useState<MarketplaceEntry[]>([])
const [form, setForm] = useState<FormState | null>(null)
const [preview, setPreview] = useState<{ title: string; content: string } | null>(null)
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
if (!organizationId) return
try {
const [lib, market] = await Promise.all([
api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`),
api.get<MarketplaceEntry[]>(`/api/skills/marketplace?organizationId=${organizationId}`),
])
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<string, SkillSummary[]>()
for (const s of skills) {
const list = byKey.get(s.skillKey) ?? []
list.push(s)
byKey.set(s.skillKey, list)
}
// If an org has forked a builtin but kept the same version, the org's own copy shadows the builtin
// (it's the one that runs and the one you can edit), so the picker shows one clear, editable entry.
for (const [key, list] of byKey) {
const perVersion = new Map<string, SkillSummary>()
for (const s of list) {
const existing = perVersion.get(s.version)
if (!existing || (existing.origin === 'Builtin' && s.origin !== 'Builtin')) perVersion.set(s.version, s)
}
byKey.set(key, [...perVersion.values()])
}
return [...byKey.entries()].sort((a, b) => a[0].localeCompare(b[0]))
}, [skills])
// Read-only details: reconstruct the SKILL.md and render it. Works for builtins too — inspect a
// skill (frontmatter, prompt body, actions, golden tests) without forking or versioning it.
const openView = async (key: string, version: string) => {
try {
const details = await api.get<SkillDetail[]>(`/api/skills/${key}?organizationId=${organizationId}`)
const d = details.find((x) => x.skill.version === version) ?? details[0]
if (!d) return
setPreview({ title: `${d.skill.name} · ${d.skill.version}`, content: skillToMarkdown(d) })
} catch (err) {
toast.error((err as Error).message)
}
}
const openForm = async (key: string, version: string, mode: Mode) => {
try {
const details = await api.get<SkillDetail[]>(`/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 setListed = async (key: string, version: string, listed: boolean) => {
setBusy(true)
try {
await api.post(`/api/skills/${key}/${listed ? 'publish' : 'unpublish'}`, { organizationId, version })
toast.success(listed ? `Published ${key}@${version} to the marketplace.` : `Unlisted ${key}@${version}.`)
await load()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
const install = async (sourceSkillId: string, name: string) => {
setBusy(true)
try {
await api.post('/api/skills/install', { organizationId, sourceSkillId })
toast.success(`Installed ${name} into your library.`)
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 (
<AppShell>
<div className="mx-auto max-w-5xl p-6">
<header className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<BookMarked className="size-6" /> Skills
</h1>
<p className="text-sm text-muted-foreground">
Your company's skill library. Builtin starter skills are shared; author and version your own.
</p>
</div>
<Button onClick={() => setForm(emptyForm())}>
<Plus data-icon="inline-start" /> New skill
</Button>
</header>
<div className="mb-4 inline-flex rounded-lg border p-1">
<SegBtn active={tab === 'library'} onClick={() => setTab('library')} icon={BookMarked}>Library</SegBtn>
<SegBtn active={tab === 'marketplace'} onClick={() => setTab('marketplace')} icon={Store}>Marketplace</SegBtn>
</div>
{tab === 'library' ? (
<div className="flex flex-col gap-4">
{groups.map(([key, versions]) => (
<SkillGroupCard
key={key}
versions={versions}
busy={busy}
onView={(v) => openView(key, v)}
onNewVersion={(v) => openForm(key, v, 'version')}
onEdit={(v) => openForm(key, v, 'edit')}
onFork={(v) => fork(key, v)}
onPublish={(v) => setListed(key, v, true)}
onUnpublish={(v) => setListed(key, v, false)}
/>
))}
{groups.length === 0 && (
<p className="text-sm text-muted-foreground">No skills yet. Run a Git sync for builtins, or author one.</p>
)}
</div>
) : (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Published skills shared by other organizations. Install a copy into your library — it lands private,
so you can edit or version it freely.
</p>
{marketplace.map(({ skill: s, alreadyInLibrary }) => (
<Card key={s.id}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
{s.name} <Badge variant="outline">{s.version}</Badge>
<span className="font-mono text-xs text-muted-foreground">{s.skillKey}</span>
</CardTitle>
<CardDescription>{s.summary}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
{s.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
<span className="text-xs text-muted-foreground">
{s.goldenTestCount} golden test{s.goldenTestCount === 1 ? '' : 's'}
</span>
{alreadyInLibrary ? (
<Badge variant="secondary" className="ml-auto">In your library</Badge>
) : (
<Button size="sm" disabled={busy} className="ml-auto" onClick={() => install(s.id, s.name)}>
<Download data-icon="inline-start" /> Install
</Button>
)}
</CardContent>
</Card>
))}
{marketplace.length === 0 && (
<p className="text-sm text-muted-foreground">
Nothing published yet. Publish one of your own skills to share it here.
</p>
)}
</div>
)}
</div>
{form && (
<Sheet open onOpenChange={(o) => !o && setForm(null)}>
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle>
{form.mode === 'new' ? 'New skill' : form.mode === 'version' ? `New version of ${form.skillKey}` : `Edit ${form.skillKey}`}
</SheetTitle>
<SheetDescription>
{willPublish
? 'Has roles + a golden test saves as Published.'
: 'Add 1 role and 1 golden test to publish; otherwise saved as Draft.'}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 px-4 pb-6">
<div className="grid grid-cols-2 gap-3">
<Field label="Skill key (id)">
<Input
value={form.skillKey}
disabled={form.mode !== 'new'}
onChange={(e) => setForm({ ...form, skillKey: e.target.value })}
placeholder="api-endpoint-design"
/>
</Field>
<Field label="Version">
<Input value={form.version} disabled={form.mode === 'edit'} onChange={(e) => setForm({ ...form, version: e.target.value })} />
</Field>
</div>
<Field label="Name">
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="API Endpoint Design" />
</Field>
<Field label="Summary">
<Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} />
</Field>
<Field label={`Roles (comma-separated — e.g. ${COMMON_ROLES})`}>
<Input value={form.roles} onChange={(e) => setForm({ ...form, roles: e.target.value })} placeholder="engineer" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Inputs">
<Input value={form.inputs} onChange={(e) => setForm({ ...form, inputs: e.target.value })} />
</Field>
<Field label="Outputs">
<Input value={form.outputs} onChange={(e) => setForm({ ...form, outputs: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Visibility">
<Pick value={form.visibility} options={VISIBILITIES} onChange={(v) => setForm({ ...form, visibility: v })} />
</Field>
<Field label="Min tier">
<Pick value={form.minTier} options={TIERS} onChange={(v) => setForm({ ...form, minTier: v })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Tools (comma-separated)">
<Input value={form.tools} onChange={(e) => setForm({ ...form, tools: e.target.value })} />
</Field>
<Field label="Context docs (comma-separated)">
<Input value={form.context} onChange={(e) => setForm({ ...form, context: e.target.value })} />
</Field>
</div>
<Repeater
label="Actions (risk-tagged)"
onAdd={() => setForm({ ...form, actions: [...form.actions, { name: '', risk: 'draft', description: '' }] })}
>
{form.actions.map((a, i) => (
<div key={i} className="flex items-center gap-2">
<Input
className="flex-1" placeholder="action name" value={a.name}
onChange={(e) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)) })}
/>
<Pick
value={a.risk} options={RISKS} className="w-32"
onChange={(v) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, risk: v } : x)) })}
/>
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, actions: form.actions.filter((_, j) => j !== i) })}>
<Trash2 className="size-4" />
</Button>
</div>
))}
</Repeater>
<Repeater
label="Golden tests (gate publishing)"
onAdd={() => setForm({ ...form, goldenTests: [...form.goldenTests, { input: '', expected: '' }] })}
>
{form.goldenTests.map((g, i) => (
<div key={i} className="flex flex-col gap-2 rounded-md border p-2">
<div className="flex items-center gap-2">
<Input
className="flex-1" placeholder="input" value={g.input}
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, input: e.target.value } : x)) })}
/>
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, goldenTests: form.goldenTests.filter((_, j) => j !== i) })}>
<Trash2 className="size-4" />
</Button>
</div>
<Textarea
rows={2} placeholder="expected output" value={g.expected}
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, expected: e.target.value } : x)) })}
/>
</div>
))}
</Repeater>
<Field label="Body (the prompt the agent runs)">
<MarkdownEditor
rows={8}
value={form.body}
onChange={(body) => setForm({ ...form, body })}
placeholder="You are the engineer. Turn the input into…"
/>
</Field>
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" onClick={() => setForm(null)}>Cancel</Button>
<Button disabled={busy || !form.skillKey.trim() || !form.name.trim() || !form.body.trim()} onClick={save}>
Save {willPublish ? '& publish' : 'draft'}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
)}
{preview && (
<Sheet open onOpenChange={(o) => !o && setPreview(null)}>
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle>{preview.title}</SheetTitle>
<SheetDescription>The full SKILL.md — read-only. Fork or make a new version to edit.</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 px-4 pb-6">
<MarkdownEditor rows={20} mono frontmatter value={preview.content} />
</div>
</SheetContent>
</Sheet>
)}
</AppShell>
)
}
function SkillGroupCard({
versions,
busy,
onView,
onNewVersion,
onEdit,
onFork,
onPublish,
onUnpublish,
}: {
versions: SkillSummary[]
busy: boolean
onView: (version: string) => void
onNewVersion: (version: string) => void
onEdit: (version: string) => void
onFork: (version: string) => void
onPublish: (version: string) => void
onUnpublish: (version: string) => void
}) {
const [selected, setSelected] = useState(versions[0].version)
const current = versions.find((v) => v.version === selected) ?? versions[0]
const isBuiltin = current.origin === 'Builtin'
const isListed = current.visibility === 'Public'
const canPublish = !isBuiltin && current.status === 'Published'
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-base">
{current.name}
<span className="font-mono text-xs text-muted-foreground">{current.skillKey}</span>
</CardTitle>
<CardDescription className="mt-1">{current.summary}</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant={current.status === 'Published' ? 'default' : 'secondary'}>{current.status}</Badge>
<Badge variant="outline">{current.origin}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
{versions.length > 1 ? (
<Pick value={selected} options={versions.map((v) => v.version)} className="w-28" onChange={setSelected} />
) : (
<Badge variant="outline">{current.version}</Badge>
)}
{current.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
<span className="text-xs text-muted-foreground">{current.goldenTestCount} golden test{current.goldenTestCount === 1 ? '' : 's'}</span>
{isListed && <Badge variant="default" className="gap-1"><Store className="size-3" /> Listed</Badge>}
<div className="ml-auto flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={() => onView(current.version)}>
<Eye data-icon="inline-start" /> View
</Button>
{isBuiltin ? (
<Button size="sm" variant="outline" disabled={busy} onClick={() => onFork(current.version)}>
<GitFork data-icon="inline-start" /> Fork to my org
</Button>
) : (
<>
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
<Pencil data-icon="inline-start" /> Edit
</Button>
{isListed ? (
<Button size="sm" variant="outline" disabled={busy} onClick={() => onUnpublish(current.version)}>
Unlist
</Button>
) : canPublish ? (
<Button size="sm" variant="outline" disabled={busy} onClick={() => onPublish(current.version)}>
<Upload data-icon="inline-start" /> Publish
</Button>
) : null}
</>
)}
<Button size="sm" disabled={busy} onClick={() => onNewVersion(current.version)}>
<Plus data-icon="inline-start" /> New version
</Button>
</div>
</CardContent>
</Card>
)
}
function SegBtn({ active, onClick, icon: Icon, children }: { active: boolean; onClick: () => void; icon: typeof BookMarked; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition ${active ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
<Icon className="size-4" /> {children}
</button>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label className="text-xs">{label}</Label>
{children}
</div>
)
}
function Pick({ value, options, onChange, className }: { value: string; options: string[]; onChange: (v: string) => void; className?: string }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className={className ?? 'w-full'}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
)
}
function Repeater({ label, onAdd, children }: { label: string; onAdd: () => void; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-xs">{label}</Label>
<Button size="sm" variant="ghost" onClick={onAdd}><Plus data-icon="inline-start" /> Add</Button>
</div>
{children}
</div>
)
}