oxios 1.16.0

Oxios Agent OS — Agent Operating System powered by oxi-sdk
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { api } from '@/lib/api-client'
import type { Skill } from '@/types'

interface SkillEditorDialogProps {
  open: boolean
  onOpenChange: (open: boolean) => void
  /** When set, the dialog edits this skill; otherwise it creates a new one. */
  skill?: Skill | null
  /** Initial content for edit mode (fetched SKILL.md raw). */
  initialContent?: string
}

const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/

/**
 * Create / edit skill modal (design F1 + F3).
 *
 * - Create: POST /api/skills (name/description/body) — frontmatter is
 *   synthesized, which is the intended behavior for brand-new skills.
 * - Edit:   PUT /api/skills/{name}/content — writes the raw SKILL.md verbatim
 *   so rich frontmatter is preserved (create_skill would strip it).
 */
export function SkillEditorDialog({
  open,
  onOpenChange,
  skill,
  initialContent = '',
}: SkillEditorDialogProps) {
  const { t } = useTranslation()
  const qc = useQueryClient()
  const isEdit = !!skill

  const [name, setName] = useState(skill?.name ?? '')
  const [description, setDescription] = useState(skill?.description ?? '')
  const [body, setBody] = useState(initialContent)

  // Re-seed when the dialog opens for a different skill.
  const seedKey = `${open}:${skill?.name ?? 'new'}`
  const [lastSeed, setLastSeed] = useState('')
  if (open && seedKey !== lastSeed) {
    setName(skill?.name ?? '')
    setDescription(skill?.description ?? '')
    setBody(initialContent)
    setLastSeed(seedKey)
  }

  const nameValid = NAME_RE.test(name)
  const descValid = description.trim().length > 0 && description.length <= 1024
  const canSave = nameValid && descValid && (isEdit || body.trim().length > 0)

  const createMut = useMutation({
    mutationFn: (vars: { name: string; description: string; content: string }) =>
      api.post<{ name: string }>('/api/skills', vars),
    onSuccess: () => {
      toast.success(t('skills.saveSuccess'))
      qc.invalidateQueries({ queryKey: ['skills'] })
      onOpenChange(false)
    },
    onError: (e: unknown) => toast.error(e instanceof Error ? e.message : t('skills.saveFailed')),
  })

  const updateMut = useMutation({
    mutationFn: (vars: { content: string }) =>
      api.put<Skill>(`/api/skills/${encodeURIComponent(skill!.name)}/content`, {
        content: vars.content,
      }),
    onSuccess: () => {
      toast.success(t('skills.saveSuccess'))
      qc.invalidateQueries({ queryKey: ['skills'] })
      qc.invalidateQueries({ queryKey: ['skill', skill!.name, 'content'] })
      onOpenChange(false)
    },
    onError: (e: unknown) => toast.error(e instanceof Error ? e.message : t('skills.saveFailed')),
  })

  const saving = createMut.isPending || updateMut.isPending

  const handleSave = () => {
    if (!canSave) return
    if (isEdit) {
      updateMut.mutate({ content: body })
    } else {
      createMut.mutate({ name, description, content: body })
    }
  }

  // Frontmatter preview for create mode.
  const fmPreview = isEdit
    ? null
    : `---\nname: ${name || '<name>'}\ndescription: ${description || '<description>'}\n---`

  return (
    <>
      {open && (
        <div
          className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-6"
          onClick={() => onOpenChange(false)}
        >
          <div
            className="bg-card border rounded-xl w-full max-w-3xl max-h-[92vh] flex flex-col overflow-hidden shadow-lg"
            onClick={(e) => e.stopPropagation()}
          >
            <div className="flex items-center gap-3 px-6 py-4 border-b">
              <span className="font-mono text-[10px] uppercase tracking-wider text-primary bg-primary/10 px-2 py-0.5 rounded-full">
                {isEdit ? 'edit' : 'create'}
              </span>
              <h2 className="font-semibold text-lg">
                {isEdit ? t('skills.editTitle', { name: skill!.name }) : t('skills.createTitle')}
              </h2>
            </div>

            <div className="p-6 overflow-y-auto flex-1 space-y-4">
              {!isEdit && (
                <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
                  <div>
                    <label className="block text-sm font-semibold mb-1.5">
                      {t('skills.nameLabel')} <span className="text-destructive">*</span>
                    </label>
                    <input
                      className="font-mono text-sm w-full bg-background border rounded-md px-3 py-2 outline-none focus:border-ring focus:ring-2 focus:ring-ring/20"
                      placeholder="code-review"
                      value={name}
                      onChange={(e) => setName(e.target.value)}
                    />
                    <p className="text-xs text-muted-foreground mt-1 font-mono">
                      {t('skills.nameHint')}
                    </p>
                  </div>
                  <div>
                    <label className="block text-sm font-semibold mb-1.5">
                      {t('skills.descriptionLabel')} <span className="text-destructive">*</span>
                    </label>
                    <input
                      className="w-full bg-background border rounded-md px-3 py-2 outline-none focus:border-ring focus:ring-2 focus:ring-ring/20"
                      placeholder={t('skills.descriptionHint')}
                      value={description}
                      onChange={(e) => setDescription(e.target.value)}
                    />
                    <p className="text-xs text-muted-foreground mt-1 font-mono">
                      {t('skills.descriptionHint')}
                    </p>
                  </div>
                </div>
              )}

              {isEdit && (
                <div className="rounded-md bg-info/10 border border-info/20 px-3 py-2 text-xs text-info">
                  {t('skills.editMarketplaceWarning')}
                </div>
              )}

              <div>
                <label className="block text-sm font-semibold mb-1.5">
                  {t('skills.bodyLabel')}
                </label>
                <textarea
                  className="font-mono text-xs w-full bg-background border rounded-md px-3 py-3 outline-none focus:border-ring focus:ring-2 focus:ring-ring/20 min-h-[280px] resize-y"
                  spellCheck={false}
                  value={body}
                  onChange={(e) => setBody(e.target.value)}
                />
              </div>

              {fmPreview && (
                <div className="bg-background border border-dashed rounded-md px-4 py-3 font-mono text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed">
                  {fmPreview}
                </div>
              )}
            </div>

            <div className="flex items-center gap-2 px-6 py-4 border-t bg-background">
              <div className="flex-1" />
              <Button variant="ghost" onClick={() => onOpenChange(false)} disabled={saving}>
                {t('common.cancel')}
              </Button>
              <Button onClick={handleSave} disabled={!canSave || saving}>
                <Plus className="h-4 w-4" />
                {isEdit ? t('common.save') : t('skills.saveActivate')}
              </Button>
            </div>
          </div>
        </div>
      )}
    </>
  )
}