@@ -276,7 +280,7 @@ export function DashboardHome({ onNavigate }: { onNavigate: (tab: string) => voi
Profiles
-
+
{/* Quick Actions */}
diff --git a/web/src/components/ProfileCreateModal.tsx b/web/src/components/ProfileCreateModal.tsx
new file mode 100644
index 000000000..3e20fb816
--- /dev/null
+++ b/web/src/components/ProfileCreateModal.tsx
@@ -0,0 +1,772 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { api, ApiError, TemplateSummary, ProfileValidationMessage, ProviderInfo } from '../api'
+import { ValidationFindings } from './ValidationFindings'
+import { CustomSelect, SelectOption } from './CustomSelect'
+import { AlertTriangle, ChevronDown, ChevronRight, Loader2, Package, X } from 'lucide-react'
+
+/** Debounce for the template live preview, matching the search box contract. */
+export const PREVIEW_DEBOUNCE_MS = 300
+
+/**
+ * Frontmatter fields shown directly in the from-scratch form. Everything else
+ * in the server schema renders inside the "Advanced" expander, in schema
+ * order. The list is a display-priority hint only — the set of fields and
+ * their types always come from `GET /agents/profiles/schema`.
+ */
+const PRIMARY_FIELDS = ['name', 'description', 'provider', 'model', 'tags', 'capabilities']
+
+/**
+ * Datalist suggestions for the `role` field: the built-in tool-bundle roles
+ * from constants.py ROLE_TOOL_DEFAULTS. Deliberately suggestions, not a
+ * closed select — settings.json custom roles are legal, and a datalist can
+ * never block a valid value if this list goes stale.
+ */
+const ROLE_SUGGESTIONS = ['supervisor', 'developer', 'reviewer']
+
+type JSONSchemaProp = {
+ type?: string
+ enum?: string[]
+ description?: string
+ default?: unknown
+ minimum?: number
+ pattern?: string
+}
+
+/**
+ * Serialize form values into YAML frontmatter. Values are emitted as JSON,
+ * which YAML accepts verbatim (flow style), so no YAML library is needed and
+ * object-valued fields round-trip exactly what the JSON editor validated.
+ */
+export function buildFrontmatter(values: Record): string {
+ const lines = Object.entries(values)
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
+ return `---\n${lines.join('\n')}\n---\n`
+}
+
+/**
+ * Rewrite the frontmatter `name:` in a rendered template document so it
+ * matches the storage name the user chose. The backend rejects a create where
+ * the two disagree (`_validate_profile_for_write`), and templates ship a
+ * fixed example name. Only the first `name:` line inside the leading
+ * frontmatter block is touched; the markdown body is never modified.
+ */
+export function rewriteFrontmatterName(content: string, name: string): string {
+ const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
+ if (!m) return content
+ // Replacement FUNCTIONS, not strings: String.replace interprets $-patterns
+ // ($&, $', $1) in a replacement STRING, which corrupts a typed name
+ // containing them and — worse — mangles any document whose frontmatter
+ // legally contains such text (e.g. description: costs $& fees), because the
+ // whole updated block is itself passed through a replace. A function
+ // replacement is inserted verbatim. \r?\n keeps CRLF documents (e.g. a
+ // Windows-authored profile being cloned) from silently no-opping the
+ // rewrite and then failing the server's name-match check.
+ const updated = m[1].replace(/^name:.*$/m, () => `name: ${JSON.stringify(name)}`)
+ return content.replace(m[0], () => `---\n${updated}\n---`)
+}
+
+/** Extract the frontmatter `name:` value from a rendered document, if any. */
+export function extractFrontmatterName(content: string): string | null {
+ // Match the frontmatter block first and search name: within it -- an
+ // unbounded [\s\S]*? scan continues into the markdown body when the
+ // frontmatter has no name:, matching a body line like 'name: decoy'
+ // (same bounding rewriteFrontmatterName already uses).
+ const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
+ if (!block) return null
+ const m = block[1].match(/^name:\s*["']?([^"'\r\n]+)["']?\s*$/m)
+ return m ? m[1].trim() : null
+}
+
+function FieldLabel({ name, required, description }: { name: string; required?: boolean; description?: string }) {
+ return (
+
+ )
+}
+
+const inputClass =
+ 'w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-emerald-600'
+
+/**
+ * One schema-driven field. Strings with enums render as selects; booleans as
+ * checkboxes; integers as number inputs; arrays of strings as comma-separated
+ * inputs; object-valued fields as validated JSON editors (per #510, object
+ * fields get JSON editors, not bespoke widgets).
+ */
+function SchemaField({
+ name,
+ schema,
+ required,
+ value,
+ jsonErrors,
+ hasError,
+ selectOptions,
+ suggestions,
+ onChange,
+}: {
+ name: string
+ schema: JSONSchemaProp
+ required?: boolean
+ value: unknown
+ jsonErrors: Record
+ /** Server validation reported an error finding rooted at this field. */
+ hasError?: boolean
+ /**
+ * Field-specific widget overrides on top of the schema-driven renderer.
+ * selectOptions turns an open string field into a closed select (used for
+ * provider, whose value space is the live provider registry); suggestions
+ * attaches a datalist to a text input, keeping free entry (used for role).
+ */
+ selectOptions?: SelectOption[]
+ suggestions?: string[]
+ onChange: (name: string, value: unknown) => void
+}) {
+ const id = `field-${name}`
+ // A red boundary marks the control the validation error points at. The
+ // finding path is dotted with the frontmatter key first (e.g.
+ // 'mcpServers.docs.url'), so the whole field is outlined even when the
+ // error is nested inside it.
+ // border-2 + !important red + ring: visibly thick and immune to losing the
+ // border-color specificity contest against inputClass's border-gray-700.
+ const errClass = hasError ? ' border-2 !border-red-500 ring-2 ring-red-500/30' : ''
+ if (selectOptions) {
+ return (
+
+ )
+}
+
+interface ProfileCreateModalProps {
+ open: boolean
+ onClose: () => void
+ /** Called with the created profile's name after a successful POST. */
+ onCreated: (name: string, warnings: ProfileValidationMessage[]) => void
+}
+
+export function ProfileCreateModal({ open, onClose, onCreated }: ProfileCreateModalProps) {
+ const [mode, setMode] = useState<'template' | 'scratch'>('template')
+
+ // --- shared state ---
+ const [profileName, setProfileName] = useState('')
+ const [saving, setSaving] = useState(false)
+ const [saveError, setSaveError] = useState(null)
+ const [findings, setFindings] = useState([])
+
+ // --- template mode state ---
+ const [templates, setTemplates] = useState([])
+ const [template, setTemplate] = useState('')
+ const [templateSchema, setTemplateSchema] = useState | null>(null)
+ const [config, setConfig] = useState>({})
+ const [preview, setPreview] = useState(null)
+ const [previewError, setPreviewError] = useState(null)
+ const [previewLoading, setPreviewLoading] = useState(false)
+ const previewTimer = useRef | null>(null)
+ const previewSeq = useRef(0)
+ // Staleness token for the template-schema fetch: a fast A->B switch with
+ // out-of-order resolution must never leave A's schema under B's selection.
+ const templateSeq = useRef(0)
+ // Tracks whether the user typed a name; if not, follow the template default.
+ const nameTouched = useRef(false)
+
+ // Invalidate any scheduled or in-flight preview render: bumping the token
+ // makes a late response fail its `seq === previewSeq.current` check and be
+ // dropped. This must run at every site that clears the preview -- a token
+ // bumped only when a *new* request is issued left the "reason for the
+ // request disappeared" paths (template switch, deselect, modal close and
+ // reopen) able to silently re-land stale content (#692 review).
+ const invalidatePreview = () => {
+ previewSeq.current++
+ if (previewTimer.current) {
+ clearTimeout(previewTimer.current)
+ previewTimer.current = null
+ }
+ setPreviewLoading(false)
+ }
+
+ // --- scratch mode state ---
+ const [profileSchema, setProfileSchema] = useState | null>(null)
+ const [providers, setProviders] = useState([])
+ const [scratchValues, setScratchValues] = useState>({})
+ const [jsonDrafts, setJsonDrafts] = useState>({})
+ const [systemPrompt, setSystemPrompt] = useState('')
+ const [advancedOpen, setAdvancedOpen] = useState(false)
+
+ // Reset per open so a cancelled create never leaks into the next one.
+ useEffect(() => {
+ if (!open) return
+ invalidatePreview()
+ setMode('template')
+ setProfileName('')
+ setSaving(false)
+ setSaveError(null)
+ setFindings([])
+ setTemplate('')
+ setTemplateSchema(null)
+ setConfig({})
+ setPreview(null)
+ setPreviewError(null)
+ setScratchValues({})
+ setJsonDrafts({})
+ setSystemPrompt('')
+ setAdvancedOpen(false)
+ nameTouched.current = false
+ api.listProfileTemplates().then(setTemplates).catch(() => setTemplates([]))
+ api.getProfileSchema().then(setProfileSchema).catch(() => setProfileSchema(null))
+ api.listProviders().then(setProviders).catch(() => setProviders([]))
+ }, [open])
+
+ // Template selection loads that template's schema and resets its config.
+ useEffect(() => {
+ const seq = ++templateSeq.current
+ if (!template) {
+ invalidatePreview()
+ setTemplateSchema(null)
+ setPreview(null)
+ setPreviewError(null)
+ return
+ }
+ setTemplateSchema(null)
+ api.getTemplateSchema(template)
+ .then(s => {
+ if (seq !== templateSeq.current) return
+ setTemplateSchema(s)
+ // Seed defaults so the preview renders something meaningful.
+ const seeded: Record = {}
+ for (const [k, v] of Object.entries((s.properties ?? {}) as Record)) {
+ if (v.default !== undefined) seeded[k] = v.default
+ }
+ setConfig(seeded)
+ })
+ .catch(e => {
+ if (seq !== templateSeq.current) return
+ setPreviewError(e?.detail || e?.message || 'Failed to load template schema')
+ })
+ }, [template])
+
+ // Debounced live preview: one render request per quiet burst of config edits.
+ useEffect(() => {
+ if (previewTimer.current) clearTimeout(previewTimer.current)
+ if (!template || !templateSchema) {
+ // No render can be issued from this state, so any in-flight one is
+ // stale by definition (template switched or deselected mid-request).
+ // Without the bump, its late response still satisfied the seq check
+ // and silently re-armed Create with the previous template's body.
+ previewSeq.current++
+ setPreviewLoading(false)
+ return
+ }
+ setPreviewLoading(true)
+ previewTimer.current = setTimeout(() => {
+ const seq = ++previewSeq.current
+ api.previewTemplate(template, config)
+ .then(p => {
+ if (seq !== previewSeq.current) return
+ setPreview(p.content)
+ setPreviewError(null)
+ if (!nameTouched.current) {
+ const fmName = extractFrontmatterName(p.content)
+ if (fmName) setProfileName(fmName)
+ }
+ })
+ .catch((e: ApiError) => {
+ if (seq !== previewSeq.current) return
+ // A config failing template validation is a normal mid-edit state.
+ setPreview(null)
+ setPreviewError(e?.detail || e?.message || 'Preview failed')
+ })
+ .finally(() => {
+ if (seq === previewSeq.current) setPreviewLoading(false)
+ })
+ }, PREVIEW_DEBOUNCE_MS)
+ return () => { if (previewTimer.current) clearTimeout(previewTimer.current) }
+ }, [template, templateSchema, config])
+
+ // Frontmatter keys carrying error-severity findings from the last validate
+ // run: first segment of the dotted finding path (e.g. 'mcpServers.docs.url'
+ // -> 'mcpServers'). Drives the red boundary on the matching form control.
+ const errorFields = useMemo(
+ () => new Set(
+ findings
+ .filter(f => f.severity === 'error' && f.path)
+ .map(f => (f.path as string).split('.')[0]),
+ ),
+ [findings],
+ )
+
+ // Template-config fields named by the preview/validate error. The backend
+ // (agent_scaffold.validate_config) emits one line per error in the form
+ // ' - {path}: {message}', where a missing required property reports at
+ // '(root)' with the field name quoted in the message. Both forms map back
+ // to a config field so the offending control gets the red boundary.
+ const templateErrorFields = useMemo(() => {
+ const fields = new Set()
+ if (!previewError) return fields
+ for (const line of previewError.split('\n')) {
+ const m = line.match(/^\s*-\s+([^:]+):\s*(.*)$/)
+ if (!m) continue
+ const loc = m[1].trim()
+ if (loc === '(root)') {
+ const req = m[2].match(/^'([^']+)' is a required property/)
+ if (req) fields.add(req[1])
+ } else {
+ fields.add(loc.split('.')[0])
+ }
+ }
+ return fields
+ }, [previewError])
+
+ // An error finding on a field inside the collapsed Advanced section would
+ // paint a red boundary nobody can see; expand the section so it is visible.
+ useEffect(() => {
+ if (!profileSchema || errorFields.size === 0) return
+ const advanced = Object.keys((profileSchema.properties ?? {}) as Record)
+ .filter(k => k !== 'name' && !PRIMARY_FIELDS.includes(k))
+ if (advanced.some(k => errorFields.has(k))) setAdvancedOpen(true)
+ }, [errorFields, profileSchema])
+
+ // JSON drafts parse-checked on every keystroke; parse errors block create.
+ const jsonErrors = useMemo(() => {
+ const errs: Record = {}
+ for (const [k, draft] of Object.entries(jsonDrafts)) {
+ if (draft.trim() === '') continue
+ try {
+ const parsed = JSON.parse(draft)
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ errs[k] = 'Must be a JSON object'
+ }
+ } catch (e: any) {
+ errs[k] = `Invalid JSON: ${e.message}`
+ }
+ }
+ return errs
+ }, [jsonDrafts])
+
+ if (!open) return null
+
+ const templateProps = (templateSchema?.properties ?? {}) as Record
+ const templateRequired = new Set((templateSchema?.required as string[]) ?? [])
+ const scratchProps = (profileSchema?.properties ?? {}) as Record
+ const scratchRequired = new Set((profileSchema?.required as string[]) ?? [])
+ const primaryKeys = PRIMARY_FIELDS.filter(k => k in scratchProps && k !== 'name')
+ const advancedKeys = Object.keys(scratchProps).filter(k => k !== 'name' && !PRIMARY_FIELDS.includes(k))
+
+ // Provider select options from the live registry. Uninstalled providers
+ // stay selectable — a profile may target a provider absent on this machine.
+ const providerOptions: SelectOption[] = providers.map(p => ({
+ value: p.name,
+ label: p.name,
+ sublabel: p.installed ? undefined : 'Not installed',
+ }))
+ const fieldOverrides = (k: string) => ({
+ selectOptions: k === 'provider' && providerOptions.length > 0 ? providerOptions : undefined,
+ suggestions: k === 'role' ? ROLE_SUGGESTIONS : undefined,
+ })
+
+ const handleScratchChange = (name: string, value: unknown) => {
+ if (scratchProps[name]?.type === 'object') {
+ setJsonDrafts(d => ({ ...d, [name]: (value as string) ?? '' }))
+ } else {
+ setScratchValues(v => ({ ...v, [name]: value }))
+ }
+ }
+
+ const buildScratchContent = (): string => {
+ const values: Record = { name: profileName, ...scratchValues }
+ for (const [k, draft] of Object.entries(jsonDrafts)) {
+ if (draft.trim() !== '' && !jsonErrors[k]) values[k] = JSON.parse(draft)
+ }
+ return buildFrontmatter(values) + '\n' + (systemPrompt.trim() ? systemPrompt.trim() + '\n' : '')
+ }
+
+ const canCreate =
+ profileName.trim() !== '' &&
+ !saving &&
+ // In template mode the POST body is the preview, so a create mid-debounce
+ // would persist a stale render; wait for the in-flight preview to settle.
+ (mode === 'template' ? preview !== null && !previewLoading : Object.keys(jsonErrors).length === 0)
+
+ const handleCreate = async () => {
+ setSaving(true)
+ setSaveError(null)
+ setFindings([])
+ try {
+ const name = profileName.trim()
+ const content = mode === 'template'
+ ? rewriteFrontmatterName(preview as string, name)
+ : buildScratchContent()
+ // Validate before every save (#510 stage 5). Error findings block the
+ // write client-side; warnings render but do not block. The write route
+ // re-runs the same validator server-side, so this is a UX gate, not the
+ // security boundary.
+ try {
+ const check = await api.validateProfile(content)
+ setFindings(check.messages ?? [])
+ if (!check.valid) {
+ setSaveError('Validation failed — fix the errors below before creating.')
+ return
+ }
+ } catch (ve: any) {
+ const status = (ve as ApiError)?.status
+ if (typeof status === 'number' && status < 500) {
+ // 400 = the document is unparseable (not even frontmatter). Block.
+ setSaveError((ve as ApiError)?.detail || ve?.message || 'Validation failed')
+ return
+ }
+ // Transport failure or server error on the pre-save check: this gate
+ // is UX only -- the write route re-validates authoritatively -- so
+ // fall through to the save instead of hard-blocking behind a
+ // phantom 'Validation failed'.
+ }
+ const res = await api.createProfile(name, content)
+ onCreated(res.name, res.warnings ?? [])
+ onClose()
+ } catch (e: any) {
+ const err = e as ApiError
+ if (err.status === 409) {
+ setSaveError(err.detail || `A profile named '${profileName.trim()}' already exists.`)
+ } else {
+ setSaveError(err.detail || err.message || 'Failed to create profile')
+ // Write-rejection errors carry the same {severity, message, path}
+ // shape as validate findings (_profile_write_rejection), so feed them
+ // through the same rendering: red field boundaries + bulleted list.
+ const meta = err.detailMeta as { errors?: ProfileValidationMessage[] } | undefined
+ if (meta?.errors?.length) setFindings(meta.errors)
+ }
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const tabClass = (active: boolean) =>
+ `px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
+ active ? 'bg-emerald-600 text-white' : 'text-gray-400 hover:text-white hover:bg-gray-800/60'
+ }`
+
+ return (
+
+ {/* Backdrop close is gated on !saving: Cancel is already disabled
+ during a save, and an overlay click mid-flight would unmount the
+ modal while the request resolves — success snackbar with no modal,
+ setState on an unmounted tree. */}
+
+
+ {/* Header */}
+
+
+
+
+
Create profile
+
+ setMode('template')}>
+ From template
+
+ setMode('scratch')}>
+ From scratch
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {/* Profile name — shared by both modes; it is the storage name. */}
+
+ {/* Show the exact document that will be persisted: the
+ create POST applies the frontmatter name rewrite, so
+ the preview must reflect the chosen name too — not
+ the template's shipped example name. */}
+ {profileName.trim() ? rewriteFrontmatterName(preview, profileName.trim()) : preview}
+
+ )
+}
diff --git a/web/src/components/ProfileEditorModal.tsx b/web/src/components/ProfileEditorModal.tsx
new file mode 100644
index 000000000..ad1faee69
--- /dev/null
+++ b/web/src/components/ProfileEditorModal.tsx
@@ -0,0 +1,210 @@
+import { useEffect, useState } from 'react'
+import { api, ApiError, ProfileValidationMessage } from '../api'
+import { rewriteFrontmatterName } from './ProfileCreateModal'
+import { ValidationFindings } from './ValidationFindings'
+import { AlertTriangle, FilePen, Loader2, X } from 'lucide-react'
+
+interface ProfileEditorModalProps {
+ open: boolean
+ /**
+ * edit: PUT back to the same local-store profile; the name is fixed.
+ * clone: POST a copy under a new name — the path for built-in, provider,
+ * and custom profiles, which the write routes deliberately 404
+ * (they resolve only inside the local store).
+ */
+ mode: 'edit' | 'clone'
+ /** The profile whose source is loaded. */
+ name: string
+ onClose: () => void
+ onSaved: (name: string, warnings: ProfileValidationMessage[]) => void
+}
+
+const inputClass =
+ 'w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-emerald-600'
+
+/**
+ * Raw document editor over `GET /agents/profiles/{name}/source`. Deliberately
+ * NOT the schema form: an edit must round-trip the exact stored bytes
+ * (placeholders intact, key order preserved, comments untouched), which a
+ * parse-and-regenerate form cannot guarantee. Authoring-by-form is the create
+ * modal's job; editing is the document's.
+ */
+export function ProfileEditorModal({ open, mode, name, onClose, onSaved }: ProfileEditorModalProps) {
+ const [content, setContent] = useState('')
+ const [cloneName, setCloneName] = useState('')
+ const [loading, setLoading] = useState(true)
+ const [loadError, setLoadError] = useState(null)
+ const [saving, setSaving] = useState(false)
+ const [saveError, setSaveError] = useState(null)
+ const [findings, setFindings] = useState([])
+
+ useEffect(() => {
+ if (!open) return
+ setContent('')
+ setCloneName(mode === 'clone' ? `${name}-copy` : '')
+ setLoading(true)
+ setLoadError(null)
+ setSaveError(null)
+ setFindings([])
+ api.getProfileSource(name)
+ .then(res => setContent(res.content))
+ .catch((e: ApiError) => setLoadError(e?.detail || e?.message || 'Failed to load profile source'))
+ .finally(() => setLoading(false))
+ }, [open, mode, name])
+
+ if (!open) return null
+
+ const targetName = mode === 'edit' ? name : cloneName.trim()
+ const canSave = !saving && !loading && !loadError && content.trim() !== '' && targetName !== ''
+
+ const handleSave = async () => {
+ setSaving(true)
+ setSaveError(null)
+ setFindings([])
+ try {
+ const document = mode === 'edit' ? content : rewriteFrontmatterName(content, targetName)
+ // Validate before every save (#510 stage 5): errors block, warnings
+ // render but allow. Validation runs on the exact document being
+ // persisted — for a clone that includes the rewritten frontmatter name.
+ try {
+ const check = await api.validateProfile(document)
+ setFindings(check.messages ?? [])
+ if (!check.valid) {
+ setSaveError('Validation failed — fix the errors below before saving.')
+ return
+ }
+ } catch (ve: any) {
+ const status = (ve as ApiError)?.status
+ if (typeof status === 'number' && status < 500) {
+ setSaveError((ve as ApiError)?.detail || ve?.message || 'Validation failed')
+ return
+ }
+ // Transport failure or server error on the pre-save check: this gate
+ // is UX only -- the write route re-validates authoritatively -- so
+ // fall through to the save instead of hard-blocking behind a
+ // phantom 'Validation failed'.
+ }
+ const res = mode === 'edit'
+ ? await api.replaceProfile(name, document)
+ : await api.createProfile(targetName, document)
+ onSaved(res.name, res.warnings ?? [])
+ onClose()
+ } catch (e: any) {
+ const err = e as ApiError
+ if (err.status === 409) {
+ setSaveError(err.detail || `A profile named '${targetName}' already exists.`)
+ } else if (err.status === 404) {
+ setSaveError(err.detail || `Profile '${name}' is not editable: only local-store profiles can be replaced.`)
+ } else {
+ setSaveError(err.detail || err.message || 'Failed to save profile')
+ // Same {severity, message, path} shape as validate findings — render
+ // through the findings panel rather than a separate flat list.
+ const meta = err.detailMeta as { errors?: ProfileValidationMessage[] } | undefined
+ if (meta?.errors?.length) setFindings(meta.errors)
+ }
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ {/* Backdrop close is gated on !saving: Cancel is already disabled
+ during a save, and an overlay click mid-flight would unmount the
+ modal while the request resolves — success snackbar with no modal,
+ setState on an unmounted tree. */}
+
+