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>
This commit is contained in:
Generated
+1457
-3
File diff suppressed because it is too large
Load Diff
@@ -24,8 +24,10 @@
|
|||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-hook-form": "^7.78.0",
|
"react-hook-form": "^7.78.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.17.0",
|
"react-router": "^7.17.0",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"shadcn": "^4.11.0",
|
"shadcn": "^4.11.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import './markdown.css'
|
||||||
|
|
||||||
|
interface MarkdownEditorProps {
|
||||||
|
value: string
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
rows?: number
|
||||||
|
/** Monospace editing font — use for raw .md files (AGENTS.md / SKILL.md). */
|
||||||
|
mono?: boolean
|
||||||
|
/** Split a leading YAML frontmatter block and show it above the rendered body in the preview. */
|
||||||
|
frontmatter?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
id?: string
|
||||||
|
/** Which tab to open on first render. Defaults to Preview when read-only (no onChange), else Edit. */
|
||||||
|
defaultTab?: Tab
|
||||||
|
/** Extra classes for the textarea (edit tab). */
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'edit' | 'preview'
|
||||||
|
|
||||||
|
/** Strips a leading `---\n…\n---` frontmatter block so the preview can render the body as prose. */
|
||||||
|
function splitFrontmatter(src: string): { fm: string | null; body: string } {
|
||||||
|
const match = src.match(/^---\n([\s\S]*?)\n---\n?/)
|
||||||
|
return match ? { fm: match[1], body: src.slice(match[0].length) } : { fm: null, body: src }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A markdown field with Edit | Preview tabs. Edit is the familiar textarea; Preview renders
|
||||||
|
* GitHub-flavored markdown (react-markdown + remark-gfm, no raw HTML — retrieved/authored content is
|
||||||
|
* data, not markup). Used wherever the app authors markdown: AGENTS.md, SKILL.md, agent persona, and
|
||||||
|
* the review artifact.
|
||||||
|
*/
|
||||||
|
export function MarkdownEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
rows = 8,
|
||||||
|
mono = false,
|
||||||
|
frontmatter = false,
|
||||||
|
placeholder,
|
||||||
|
id,
|
||||||
|
defaultTab,
|
||||||
|
className,
|
||||||
|
}: MarkdownEditorProps) {
|
||||||
|
const [tab, setTab] = useState<Tab>(defaultTab ?? (onChange ? 'edit' : 'preview'))
|
||||||
|
const { fm, body } = frontmatter ? splitFrontmatter(value) : { fm: null, body: value }
|
||||||
|
const hasContent = (frontmatter ? body : value).trim().length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-1 border-b" role="tablist">
|
||||||
|
{(['edit', 'preview'] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={cn(
|
||||||
|
'-mb-px border-b-2 px-3 py-1.5 text-xs font-medium capitalize transition-colors',
|
||||||
|
tab === t
|
||||||
|
? 'border-primary text-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t === 'edit' && !onChange ? 'source' : t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'edit' ? (
|
||||||
|
<Textarea
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
rows={rows}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={!onChange}
|
||||||
|
className={cn('mt-2 rounded-t-none', mono && 'font-mono text-xs', className)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 rounded-md border bg-background px-3 py-2" style={{ minHeight: rows * 22 }}>
|
||||||
|
{hasContent ? (
|
||||||
|
<div className="md-prose">
|
||||||
|
{frontmatter && fm && <div className="md-frontmatter">{fm}</div>}
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground/70">Nothing to preview yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
* Prose styling for rendered markdown previews. Scoped to .md-prose so it never leaks into the app
|
||||||
|
* chrome. Uses the design tokens so it adapts to light/dark like everything else.
|
||||||
|
*/
|
||||||
|
.md-prose {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: var(--foreground);
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.md-prose > :first-child { margin-top: 0; }
|
||||||
|
.md-prose > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.md-prose h1,
|
||||||
|
.md-prose h2,
|
||||||
|
.md-prose h3,
|
||||||
|
.md-prose h4 {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 1.2em 0 0.5em;
|
||||||
|
}
|
||||||
|
.md-prose h1 { font-size: 1.4em; }
|
||||||
|
.md-prose h2 { font-size: 1.2em; }
|
||||||
|
.md-prose h3 { font-size: 1.05em; }
|
||||||
|
.md-prose h4 { font-size: 1em; }
|
||||||
|
.md-prose h1,
|
||||||
|
.md-prose h2 {
|
||||||
|
padding-bottom: 0.25em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-prose p,
|
||||||
|
.md-prose ul,
|
||||||
|
.md-prose ol,
|
||||||
|
.md-prose blockquote,
|
||||||
|
.md-prose table,
|
||||||
|
.md-prose pre { margin: 0 0 0.75em; }
|
||||||
|
|
||||||
|
.md-prose ul,
|
||||||
|
.md-prose ol { padding-left: 1.4em; }
|
||||||
|
.md-prose li { margin: 0.2em 0; }
|
||||||
|
.md-prose li > ul,
|
||||||
|
.md-prose li > ol { margin: 0.2em 0; }
|
||||||
|
|
||||||
|
.md-prose a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-prose code {
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: var(--muted);
|
||||||
|
padding: 0.12em 0.35em;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.md-prose pre {
|
||||||
|
background: var(--muted);
|
||||||
|
padding: 0.8em 1em;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.md-prose pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-prose blockquote {
|
||||||
|
border-left: 3px solid var(--border);
|
||||||
|
padding-left: 0.9em;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-prose table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.md-prose th,
|
||||||
|
.md-prose td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.md-prose th { background: var(--muted); font-weight: 600; }
|
||||||
|
|
||||||
|
.md-prose hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 1.2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-prose img { max-width: 100%; border-radius: 6px; }
|
||||||
|
|
||||||
|
.md-prose input[type='checkbox'] { margin-right: 0.4em; }
|
||||||
|
|
||||||
|
/* The frontmatter block (YAML) shown above the rendered body for .md-file editors. */
|
||||||
|
.md-frontmatter {
|
||||||
|
margin: 0 0 1em;
|
||||||
|
padding: 0.6em 0.85em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--muted);
|
||||||
|
font-family: var(--font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Bot, Download, GitFork, Pencil, Plus, Store, Upload } from 'lucide-react'
|
import { Bot, Download, Eye, GitFork, Pencil, Plus, Store, Upload } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AppShell } from '@/components/AppShell'
|
import { AppShell } from '@/components/AppShell'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
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 { api } from '@/lib/api'
|
||||||
import { useAuth } from '@/store/auth'
|
import { useAuth } from '@/store/auth'
|
||||||
|
|
||||||
@@ -95,6 +95,7 @@ export function AgentProfilesPage() {
|
|||||||
const [profiles, setProfiles] = useState<AgentProfileSummary[]>([])
|
const [profiles, setProfiles] = useState<AgentProfileSummary[]>([])
|
||||||
const [marketplace, setMarketplace] = useState<MarketplaceProfileEntry[]>([])
|
const [marketplace, setMarketplace] = useState<MarketplaceProfileEntry[]>([])
|
||||||
const [editor, setEditor] = useState<{ title: string; content: string } | null>(null)
|
const [editor, setEditor] = useState<{ title: string; content: string } | null>(null)
|
||||||
|
const [preview, setPreview] = useState<{ title: string; content: string } | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -122,6 +123,16 @@ export function AgentProfilesPage() {
|
|||||||
list.push(p)
|
list.push(p)
|
||||||
byKey.set(p.profileKey, list)
|
byKey.set(p.profileKey, list)
|
||||||
}
|
}
|
||||||
|
// Collapse a builtin that an org has forked at the same version — the org's own copy shadows it
|
||||||
|
// (it's the one that resolves and the one you can edit), so the version picker stays unambiguous.
|
||||||
|
for (const [key, list] of byKey) {
|
||||||
|
const perVersion = new Map<string, AgentProfileSummary>()
|
||||||
|
for (const p of list) {
|
||||||
|
const existing = perVersion.get(p.version)
|
||||||
|
if (!existing || (existing.origin === 'Builtin' && p.origin !== 'Builtin')) perVersion.set(p.version, p)
|
||||||
|
}
|
||||||
|
byKey.set(key, [...perVersion.values()])
|
||||||
|
}
|
||||||
return [...byKey.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
return [...byKey.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
}, [profiles])
|
}, [profiles])
|
||||||
|
|
||||||
@@ -139,6 +150,19 @@ export function AgentProfilesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read-only details: reconstruct the AGENTS.md and render it. Works for builtins too — the only way
|
||||||
|
// to inspect a profile without forking or versioning it.
|
||||||
|
const openView = async (key: string, version: string) => {
|
||||||
|
try {
|
||||||
|
const details = await api.get<AgentProfileDetail[]>(`/api/orgboard/agent-profiles/${key}?organizationId=${organizationId}`)
|
||||||
|
const d = details.find((x) => x.profile.version === version) ?? details[0]
|
||||||
|
if (!d) return
|
||||||
|
setPreview({ title: `${d.profile.name} · ${d.profile.version}`, content: toMarkdown(d) })
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const upload = async () => {
|
const upload = async () => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -206,6 +230,7 @@ export function AgentProfilesPage() {
|
|||||||
key={key}
|
key={key}
|
||||||
versions={versions}
|
versions={versions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
onView={(v) => openView(key, v)}
|
||||||
onNewVersion={(v) => openEditor(key, v, 'version')}
|
onNewVersion={(v) => openEditor(key, v, 'version')}
|
||||||
onEdit={(v) => openEditor(key, v, 'edit')}
|
onEdit={(v) => openEditor(key, v, 'edit')}
|
||||||
onFork={(v) => fork(key, v)}
|
onFork={(v) => fork(key, v)}
|
||||||
@@ -259,11 +284,12 @@ export function AgentProfilesPage() {
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex flex-col gap-4 px-4 pb-6">
|
<div className="flex flex-col gap-4 px-4 pb-6">
|
||||||
<Textarea
|
<MarkdownEditor
|
||||||
rows={22}
|
rows={22}
|
||||||
className="font-mono text-xs"
|
mono
|
||||||
|
frontmatter
|
||||||
value={editor.content}
|
value={editor.content}
|
||||||
onChange={(e) => setEditor({ ...editor, content: e.target.value })}
|
onChange={(content) => setEditor({ ...editor, content })}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button variant="ghost" onClick={() => setEditor(null)}>Cancel</Button>
|
<Button variant="ghost" onClick={() => setEditor(null)}>Cancel</Button>
|
||||||
@@ -273,6 +299,20 @@ export function AgentProfilesPage() {
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<Sheet open onOpenChange={(o) => !o && setPreview(null)}>
|
||||||
|
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-2xl">
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>{preview.title}</SheetTitle>
|
||||||
|
<SheetDescription>The full AGENTS.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={22} mono frontmatter value={preview.content} />
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -280,6 +320,7 @@ export function AgentProfilesPage() {
|
|||||||
function ProfileGroupCard({
|
function ProfileGroupCard({
|
||||||
versions,
|
versions,
|
||||||
busy,
|
busy,
|
||||||
|
onView,
|
||||||
onNewVersion,
|
onNewVersion,
|
||||||
onEdit,
|
onEdit,
|
||||||
onFork,
|
onFork,
|
||||||
@@ -288,6 +329,7 @@ function ProfileGroupCard({
|
|||||||
}: {
|
}: {
|
||||||
versions: AgentProfileSummary[]
|
versions: AgentProfileSummary[]
|
||||||
busy: boolean
|
busy: boolean
|
||||||
|
onView: (version: string) => void
|
||||||
onNewVersion: (version: string) => void
|
onNewVersion: (version: string) => void
|
||||||
onEdit: (version: string) => void
|
onEdit: (version: string) => void
|
||||||
onFork: (version: string) => void
|
onFork: (version: string) => void
|
||||||
@@ -332,6 +374,9 @@ function ProfileGroupCard({
|
|||||||
{isListed && <Badge variant="default" className="gap-1"><Store className="size-3" /> Listed</Badge>}
|
{isListed && <Badge variant="default" className="gap-1"><Store className="size-3" /> Listed</Badge>}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<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 ? (
|
{isBuiltin ? (
|
||||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onFork(current.version)}>
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => onFork(current.version)}>
|
||||||
<GitFork data-icon="inline-start" /> Fork to my org
|
<GitFork data-icon="inline-start" /> Fork to my org
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { AgentFace } from '@/components/AgentFace'
|
||||||
|
import { MarkdownEditor } from '@/components/MarkdownEditor'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { diffWords } from '@/lib/diff'
|
import { diffWords } from '@/lib/diff'
|
||||||
import { useAuth } from '@/store/auth'
|
import { useAuth } from '@/store/auth'
|
||||||
@@ -126,10 +128,8 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-seat-ai font-semibold text-white">
|
<AgentFace size="md" monogram={item.agentId} state="review" className="shrink-0" />
|
||||||
AI
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<CardTitle className="truncate text-base">{item.title}</CardTitle>
|
<CardTitle className="truncate text-base">{item.title}</CardTitle>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
@@ -148,12 +148,12 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
|
|||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor={`content-${item.id}`}>Proposed artifact</Label>
|
<Label htmlFor={`content-${item.id}`}>Proposed artifact</Label>
|
||||||
<Textarea
|
<MarkdownEditor
|
||||||
id={`content-${item.id}`}
|
id={`content-${item.id}`}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={setContent}
|
||||||
rows={6}
|
rows={6}
|
||||||
className="font-mono text-xs"
|
mono
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
|||||||
import { KeyRound, Plug, Plus, Bot, Sparkles, Trash2, Wand2 } from 'lucide-react'
|
import { KeyRound, Plug, Plus, Bot, Sparkles, Trash2, Wand2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AppShell } from '@/components/AppShell'
|
import { AppShell } from '@/components/AppShell'
|
||||||
|
import { AgentFace, type FaceState } from '@/components/AgentFace'
|
||||||
|
import { MarkdownEditor } from '@/components/MarkdownEditor'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
@@ -101,6 +103,7 @@ export function SeatsPage() {
|
|||||||
const [mcp, setMcp] = useState({ name: '', endpoint: '', headerName: 'Authorization', headerValue: '' })
|
const [mcp, setMcp] = useState({ name: '', endpoint: '', headerName: 'Authorization', headerValue: '' })
|
||||||
const [newSeat, setNewSeat] = useState('')
|
const [newSeat, setNewSeat] = useState('')
|
||||||
const [selectedSeat, setSelectedSeat] = useState<string | null>(null)
|
const [selectedSeat, setSelectedSeat] = useState<string | null>(null)
|
||||||
|
const [facePreview, setFacePreview] = useState<FaceState>('idle')
|
||||||
const [profiles, setProfiles] = useState<AgentProfileLite[]>([])
|
const [profiles, setProfiles] = useState<AgentProfileLite[]>([])
|
||||||
const [agent, setAgent] = useState({
|
const [agent, setAgent] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -459,6 +462,36 @@ export function SeatsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
{selected && (
|
{selected && (
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-4 rounded-lg border bg-muted/30 p-4">
|
||||||
|
<AgentFace
|
||||||
|
size="xl"
|
||||||
|
name={agent.name}
|
||||||
|
monogram={agent.monogram || agent.name}
|
||||||
|
state={facePreview}
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium leading-tight">{agent.name || 'Unnamed agent'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Live face — preview each run state</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(['idle', 'thinking', 'working', 'review', 'done', 'failed'] as FaceState[]).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFacePreview(s)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border px-2 py-1 text-xs',
|
||||||
|
facePreview === s ? 'bg-foreground text-background' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{profiles.length > 0 && (
|
{profiles.length > 0 && (
|
||||||
<Field label="Start from a profile (AGENTS.md)">
|
<Field label="Start from a profile (AGENTS.md)">
|
||||||
<Select value="" onValueChange={applyProfile}>
|
<Select value="" onValueChange={applyProfile}>
|
||||||
@@ -552,12 +585,11 @@ export function SeatsPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Operating guide (persona)</Label>
|
<Label>Operating guide (persona)</Label>
|
||||||
<textarea
|
<MarkdownEditor
|
||||||
value={agent.persona}
|
value={agent.persona}
|
||||||
onChange={(e) => setAgent({ ...agent, persona: e.target.value })}
|
onChange={(persona) => setAgent({ ...agent, persona })}
|
||||||
rows={4}
|
rows={4}
|
||||||
placeholder="The agent's persona / operating guide — set by a profile, editable here. Injected into the run."
|
placeholder="The agent's persona / operating guide — set by a profile, editable here. Injected into the run."
|
||||||
className="w-full resize-y rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { BookMarked, Download, GitFork, Pencil, Plus, Store, Trash2, Upload } from 'lucide-react'
|
import { BookMarked, Download, Eye, GitFork, Pencil, Plus, Store, Trash2, Upload } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AppShell } from '@/components/AppShell'
|
import { AppShell } from '@/components/AppShell'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { MarkdownEditor } from '@/components/MarkdownEditor'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { useAuth } from '@/store/auth'
|
import { useAuth } from '@/store/auth'
|
||||||
|
|
||||||
@@ -121,6 +122,31 @@ function bumpPatch(version: string): string {
|
|||||||
const csv = (s: string): string[] =>
|
const csv = (s: string): string[] =>
|
||||||
s.split(',').map((x) => x.trim()).filter(Boolean)
|
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. */
|
/** The org's skill library: builtin starter skills + skills the company authors and versions itself. */
|
||||||
export function SkillsPage() {
|
export function SkillsPage() {
|
||||||
const organizationId = useAuth((s) => s.organizationId)
|
const organizationId = useAuth((s) => s.organizationId)
|
||||||
@@ -128,6 +154,7 @@ export function SkillsPage() {
|
|||||||
const [skills, setSkills] = useState<SkillSummary[]>([])
|
const [skills, setSkills] = useState<SkillSummary[]>([])
|
||||||
const [marketplace, setMarketplace] = useState<MarketplaceEntry[]>([])
|
const [marketplace, setMarketplace] = useState<MarketplaceEntry[]>([])
|
||||||
const [form, setForm] = useState<FormState | null>(null)
|
const [form, setForm] = useState<FormState | null>(null)
|
||||||
|
const [preview, setPreview] = useState<{ title: string; content: string } | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -156,9 +183,32 @@ export function SkillsPage() {
|
|||||||
list.push(s)
|
list.push(s)
|
||||||
byKey.set(s.skillKey, list)
|
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]))
|
return [...byKey.entries()].sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
}, [skills])
|
}, [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) => {
|
const openForm = async (key: string, version: string, mode: Mode) => {
|
||||||
try {
|
try {
|
||||||
const details = await api.get<SkillDetail[]>(`/api/skills/${key}?organizationId=${organizationId}`)
|
const details = await api.get<SkillDetail[]>(`/api/skills/${key}?organizationId=${organizationId}`)
|
||||||
@@ -289,6 +339,7 @@ export function SkillsPage() {
|
|||||||
key={key}
|
key={key}
|
||||||
versions={versions}
|
versions={versions}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
onView={(v) => openView(key, v)}
|
||||||
onNewVersion={(v) => openForm(key, v, 'version')}
|
onNewVersion={(v) => openForm(key, v, 'version')}
|
||||||
onEdit={(v) => openForm(key, v, 'edit')}
|
onEdit={(v) => openForm(key, v, 'edit')}
|
||||||
onFork={(v) => fork(key, v)}
|
onFork={(v) => fork(key, v)}
|
||||||
@@ -446,7 +497,12 @@ export function SkillsPage() {
|
|||||||
</Repeater>
|
</Repeater>
|
||||||
|
|
||||||
<Field label="Body (the prompt the agent runs)">
|
<Field label="Body (the prompt the agent runs)">
|
||||||
<Textarea rows={8} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="You are the engineer. Turn the input into…" />
|
<MarkdownEditor
|
||||||
|
rows={8}
|
||||||
|
value={form.body}
|
||||||
|
onChange={(body) => setForm({ ...form, body })}
|
||||||
|
placeholder="You are the engineer. Turn the input into…"
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
@@ -459,6 +515,20 @@ export function SkillsPage() {
|
|||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</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>
|
</AppShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -466,6 +536,7 @@ export function SkillsPage() {
|
|||||||
function SkillGroupCard({
|
function SkillGroupCard({
|
||||||
versions,
|
versions,
|
||||||
busy,
|
busy,
|
||||||
|
onView,
|
||||||
onNewVersion,
|
onNewVersion,
|
||||||
onEdit,
|
onEdit,
|
||||||
onFork,
|
onFork,
|
||||||
@@ -474,6 +545,7 @@ function SkillGroupCard({
|
|||||||
}: {
|
}: {
|
||||||
versions: SkillSummary[]
|
versions: SkillSummary[]
|
||||||
busy: boolean
|
busy: boolean
|
||||||
|
onView: (version: string) => void
|
||||||
onNewVersion: (version: string) => void
|
onNewVersion: (version: string) => void
|
||||||
onEdit: (version: string) => void
|
onEdit: (version: string) => void
|
||||||
onFork: (version: string) => void
|
onFork: (version: string) => void
|
||||||
@@ -514,6 +586,9 @@ function SkillGroupCard({
|
|||||||
{isListed && <Badge variant="default" className="gap-1"><Store className="size-3" /> Listed</Badge>}
|
{isListed && <Badge variant="default" className="gap-1"><Store className="size-3" /> Listed</Badge>}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<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 ? (
|
{isBuiltin ? (
|
||||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onFork(current.version)}>
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => onFork(current.version)}>
|
||||||
<GitFork data-icon="inline-start" /> Fork to my org
|
<GitFork data-icon="inline-start" /> Fork to my org
|
||||||
|
|||||||
Reference in New Issue
Block a user