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:
soroush.asadi
2026-06-15 15:26:14 +03:30
parent d50cd2790e
commit 4758e4b5de
8 changed files with 1841 additions and 20 deletions
+99
View File
@@ -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>
)
}
+114
View File
@@ -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;
}
+50 -5
View File
@@ -1,5 +1,5 @@
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 { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
@@ -14,7 +14,7 @@ import {
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'
@@ -95,6 +95,7 @@ export function AgentProfilesPage() {
const [profiles, setProfiles] = useState<AgentProfileSummary[]>([])
const [marketplace, setMarketplace] = useState<MarketplaceProfileEntry[]>([])
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 load = useCallback(async () => {
@@ -122,6 +123,16 @@ export function AgentProfilesPage() {
list.push(p)
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]))
}, [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 () => {
if (!editor) return
setBusy(true)
@@ -206,6 +230,7 @@ export function AgentProfilesPage() {
key={key}
versions={versions}
busy={busy}
onView={(v) => openView(key, v)}
onNewVersion={(v) => openEditor(key, v, 'version')}
onEdit={(v) => openEditor(key, v, 'edit')}
onFork={(v) => fork(key, v)}
@@ -259,11 +284,12 @@ export function AgentProfilesPage() {
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 px-4 pb-6">
<Textarea
<MarkdownEditor
rows={22}
className="font-mono text-xs"
mono
frontmatter
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">
<Button variant="ghost" onClick={() => setEditor(null)}>Cancel</Button>
@@ -273,6 +299,20 @@ export function AgentProfilesPage() {
</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-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>
)
}
@@ -280,6 +320,7 @@ export function AgentProfilesPage() {
function ProfileGroupCard({
versions,
busy,
onView,
onNewVersion,
onEdit,
onFork,
@@ -288,6 +329,7 @@ function ProfileGroupCard({
}: {
versions: AgentProfileSummary[]
busy: boolean
onView: (version: string) => void
onNewVersion: (version: string) => void
onEdit: (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>}
<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
+7 -7
View File
@@ -8,6 +8,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { AgentFace } from '@/components/AgentFace'
import { MarkdownEditor } from '@/components/MarkdownEditor'
import { api } from '@/lib/api'
import { diffWords } from '@/lib/diff'
import { useAuth } from '@/store/auth'
@@ -126,10 +128,8 @@ function ReviewCard({ item, onDecided }: { item: ReviewItem; onDecided: (id: str
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-seat-ai font-semibold text-white">
AI
</span>
<div className="flex items-center gap-3">
<AgentFace size="md" monogram={item.agentId} state="review" className="shrink-0" />
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-base">{item.title}</CardTitle>
<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">
<div className="flex flex-col gap-2">
<Label htmlFor={`content-${item.id}`}>Proposed artifact</Label>
<Textarea
<MarkdownEditor
id={`content-${item.id}`}
value={content}
onChange={(e) => setContent(e.target.value)}
onChange={setContent}
rows={6}
className="font-mono text-xs"
mono
/>
</div>
+35 -3
View File
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { KeyRound, Plug, Plus, Bot, Sparkles, Trash2, Wand2 } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { AgentFace, type FaceState } from '@/components/AgentFace'
import { MarkdownEditor } from '@/components/MarkdownEditor'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
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 [newSeat, setNewSeat] = useState('')
const [selectedSeat, setSelectedSeat] = useState<string | null>(null)
const [facePreview, setFacePreview] = useState<FaceState>('idle')
const [profiles, setProfiles] = useState<AgentProfileLite[]>([])
const [agent, setAgent] = useState({
name: '',
@@ -459,6 +462,36 @@ export function SeatsPage() {
</CardHeader>
{selected && (
<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 && (
<Field label="Start from a profile (AGENTS.md)">
<Select value="" onValueChange={applyProfile}>
@@ -552,12 +585,11 @@ export function SeatsPage() {
<div className="flex flex-col gap-2">
<Label>Operating guide (persona)</Label>
<textarea
<MarkdownEditor
value={agent.persona}
onChange={(e) => setAgent({ ...agent, persona: e.target.value })}
onChange={(persona) => setAgent({ ...agent, persona })}
rows={4}
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>
+77 -2
View File
@@ -1,5 +1,5 @@
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 { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
@@ -17,6 +17,7 @@ import {
} 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'
@@ -121,6 +122,31 @@ function bumpPatch(version: string): string {
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)
@@ -128,6 +154,7 @@ export function SkillsPage() {
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 () => {
@@ -156,9 +183,32 @@ export function SkillsPage() {
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}`)
@@ -289,6 +339,7 @@ export function SkillsPage() {
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)}
@@ -446,7 +497,12 @@ export function SkillsPage() {
</Repeater>
<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>
<div className="flex items-center justify-end gap-2">
@@ -459,6 +515,20 @@ export function SkillsPage() {
</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>
)
}
@@ -466,6 +536,7 @@ export function SkillsPage() {
function SkillGroupCard({
versions,
busy,
onView,
onNewVersion,
onEdit,
onFork,
@@ -474,6 +545,7 @@ function SkillGroupCard({
}: {
versions: SkillSummary[]
busy: boolean
onView: (version: string) => void
onNewVersion: (version: string) => void
onEdit: (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>}
<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