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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user