SkillEditor

Create or edit a skill: a slug name checked against existingNames, description, tags, allowed tools and Markdown instructions with Write and Preview tabs. A rejected onSave keeps the form open with the message; leaving with unsaved changes asks for confirmation. Omit skill to create one, pass initialDraft to start from a duplicate.

Getting Started
$pnpm add @sinups/ai-kit @mantine/core @mantine/hooks @tabler/icons-react
Examples
Edit a skill
New skill
Source

The component source as it ships in the package. Useful as a reference, or as a starting point if you want to fork a piece.

skills/SkillEditor.tsx
import React, { memo, useEffect, useMemo, useState } from 'react';
import {
  Alert,
  Button,
  Group,
  MultiSelect,
  Paper,
  Stack,
  Tabs,
  TagsInput,
  Text,
  TextInput,
  Textarea,
} from '@mantine/core';
import { IconAlertCircle } from '@tabler/icons-react';
import { Markdown } from '../Markdown/Markdown';
import { ConfirmDialog } from '../primitives/ConfirmDialog/ConfirmDialog';
import { cx } from '../utils/cx';
import { getErrorMessage } from '../utils/error-message';
import {
  DEFAULT_SKILL_VALIDATION_MESSAGES,
  SKILL_DESCRIPTION_MAX_LENGTH,
  SKILL_NAME_MAX_LENGTH,
  isSkillDraftDirty,
  normalizeSkillDraft,
  skillToDraft,
  validateSkillDraft,
  type SkillDraftErrors,
  type SkillValidationMessages,
} from './skill-utils';
import type { Skill, SkillDraft } from './types';
import classes from './Skills.module.css';
import { FIELD_ORDER_DESCRIPTION_BELOW } from '../utils/field-order';

export interface SkillEditorLabels extends SkillValidationMessages {
  createTitle: string;
  editTitle: string;
  name: string;
  nameDescription: string;
  description: string;
  descriptionHint: string;
  tags: string;
  tagsPlaceholder: string;
  allowedTools: string;
  allowedToolsDescription: string;
  allowedToolsPlaceholder: string;
  instructions: string;
  write: string;
  preview: string;
  contentPlaceholder: string;
  emptyPreview: string;
  save: string;
  create: string;
  cancel: string;
  unsaved: string;
  discardTitle: string;
  discardMessage: string;
  keepEditing: string;
  discard: string;
  saveError: string;
}

export interface SkillEditorProps {
  /** Skill to edit, a new skill is created when omitted */
  skill?: Skill | null;
  /** Initial values of a new skill, for example a duplicate */
  initialDraft?: Partial<SkillDraft>;
  /** Tool names offered in the allowed tools select */
  availableTools?: string[];
  /** Names of other skills, used to check that the name is unique */
  existingNames?: string[];
  /** Saves the draft, the form stays open and shows the rejection message when the promise rejects */
  onSave: (draft: SkillDraft) => Promise<void> | void;
  /** Leaves the editor, asks for confirmation first when there are unsaved changes */
  onCancel?: () => void;
  /** Called when the form gains or loses unsaved changes, lets the host guard its own navigation */
  onDirtyChange?: (dirty: boolean) => void;
  /** Overrides of the default English labels and validation messages */
  labels?: Partial<SkillEditorLabels>;
  /** Class name added to the root element */
  className?: string;
  /** Inline styles added to the root element */
  style?: React.CSSProperties;
}

export const DEFAULT_SKILL_EDITOR_LABELS: SkillEditorLabels = {
  ...DEFAULT_SKILL_VALIDATION_MESSAGES,
  createTitle: 'New skill',
  editTitle: 'Edit skill',
  name: 'Name',
  nameDescription: 'Lowercase letters, digits and hyphens, for example pdf-forms',
  description: 'Description',
  descriptionHint: 'What the skill does and when the agent should use it',
  tags: 'Tags',
  tagsPlaceholder: 'Add a tag and press Enter',
  allowedTools: 'Allowed tools',
  allowedToolsDescription: 'Tools the skill may use without asking',
  allowedToolsPlaceholder: 'Pick tools',
  instructions: 'Instructions',
  write: 'Write',
  preview: 'Preview',
  contentPlaceholder: '# Skill name\n\nStep-by-step instructions in Markdown',
  emptyPreview: 'Nothing to preview yet.',
  save: 'Save',
  create: 'Create skill',
  cancel: 'Cancel',
  unsaved: 'Unsaved changes',
  discardTitle: 'Discard changes?',
  discardMessage: 'Your edits to this skill will be lost.',
  keepEditing: 'Keep editing',
  discard: 'Discard',
  saveError: 'Could not save the skill',
};

export const SkillEditor = memo(function SkillEditor({
  skill,
  initialDraft,
  availableTools = [],
  existingNames = [],
  onSave,
  onCancel,
  onDirtyChange,
  labels,
  className,
  style,
}: SkillEditorProps) {
  const text = { ...DEFAULT_SKILL_EDITOR_LABELS, ...labels };
  const [baseline, setBaseline] = useState<SkillDraft>(() => ({
    ...skillToDraft(skill),
    ...initialDraft,
  }));
  const [draft, setDraft] = useState<SkillDraft>(baseline);
  const [errors, setErrors] = useState<SkillDraftErrors>({});
  const [submitted, setSubmitted] = useState(false);
  const [saving, setSaving] = useState(false);
  const [saveError, setSaveError] = useState<string | null>(null);
  const [confirmOpen, setConfirmOpen] = useState(false);
  const [tab, setTab] = useState<string | null>('write');

  const otherNames = useMemo(
    () => existingNames.filter((name) => name !== skill?.name),
    [existingNames, skill?.name]
  );
  const dirty = isSkillDraftDirty(draft, baseline);

  useEffect(() => {
    onDirtyChange?.(dirty);
  }, [dirty, onDirtyChange]);
  const toolOptions = useMemo(
    () => [...new Set([...availableTools, ...draft.allowedTools])],
    [availableTools, draft.allowedTools]
  );

  const update = <K extends keyof SkillDraft>(key: K, value: SkillDraft[K]) => {
    const next = { ...draft, [key]: value };
    setDraft(next);
    if (submitted) {
      setErrors(validateSkillDraft(next, otherNames, text));
    } else if (key === 'name' && errors.name) {
      setErrors((current) => ({
        ...current,
        name: validateSkillDraft(next, otherNames, text).name,
      }));
    }
  };

  const save = async () => {
    const normalized = normalizeSkillDraft(draft);
    const nextErrors = validateSkillDraft(normalized, otherNames, text);
    setSubmitted(true);
    setErrors(nextErrors);
    if (Object.keys(nextErrors).length > 0) {
      return;
    }
    setSaving(true);
    setSaveError(null);
    try {
      await onSave(normalized);
      setDraft(normalized);
      setBaseline(normalized);
    } catch (reason) {
      setSaveError(getErrorMessage(reason, text.saveError));
    } finally {
      setSaving(false);
    }
  };

  const cancel = () => {
    if (dirty) {
      setConfirmOpen(true);
    } else {
      onCancel?.();
    }
  };

  return (
    <form
      noValidate
      className={cx(classes.content, className)}
      style={style}
      onSubmit={(event) => {
        event.preventDefault();
        save();
      }}
    >
      <Stack gap="md" p="md">
        <Group justify="space-between" gap="xs">
          <Text component="h3" size="sm" fw={500}>
            {skill ? text.editTitle : text.createTitle}
          </Text>
          {dirty && (
            <Text size="xs" c="dimmed">
              {text.unsaved}
            </Text>
          )}
        </Group>

        <TextInput
          label={text.name}
          description={text.nameDescription}
          inputWrapperOrder={FIELD_ORDER_DESCRIPTION_BELOW}
          withAsterisk
          value={draft.name}
          maxLength={SKILL_NAME_MAX_LENGTH}
          error={errors.name}
          disabled={saving}
          onChange={(event) => update('name', event.currentTarget.value)}
          onBlur={() =>
            setErrors((current) => ({
              ...current,
              name: validateSkillDraft(draft, otherNames, text).name,
            }))
          }
        />
        <Textarea
          label={text.description}
          description={text.descriptionHint}
          inputWrapperOrder={FIELD_ORDER_DESCRIPTION_BELOW}
          withAsterisk
          autosize
          minRows={2}
          maxRows={5}
          value={draft.description}
          maxLength={SKILL_DESCRIPTION_MAX_LENGTH}
          error={errors.description}
          disabled={saving}
          onChange={(event) => update('description', event.currentTarget.value)}
        />
        <TagsInput
          label={text.tags}
          placeholder={text.tagsPlaceholder}
          value={draft.tags}
          disabled={saving}
          clearable
          onChange={(value) => update('tags', value)}
        />
        <MultiSelect
          label={text.allowedTools}
          description={text.allowedToolsDescription}
          inputWrapperOrder={FIELD_ORDER_DESCRIPTION_BELOW}
          placeholder={draft.allowedTools.length === 0 ? text.allowedToolsPlaceholder : undefined}
          data={toolOptions}
          value={draft.allowedTools}
          searchable
          clearable
          hidePickedOptions
          disabled={saving}
          onChange={(value) => update('allowedTools', value)}
        />

        <Stack gap={4}>
          <Text size="xs" fw={500}>
            {text.instructions}
          </Text>
          <Tabs value={tab} onChange={setTab} keepMounted={false}>
            <Tabs.List>
              <Tabs.Tab value="write">{text.write}</Tabs.Tab>
              <Tabs.Tab value="preview">{text.preview}</Tabs.Tab>
            </Tabs.List>
            <Tabs.Panel value="write" pt="xs">
              <Textarea
                aria-label={text.instructions}
                autosize
                minRows={8}
                maxRows={24}
                placeholder={text.contentPlaceholder}
                value={draft.content}
                disabled={saving}
                classNames={{ input: classes.editorInput }}
                onChange={(event) => update('content', event.currentTarget.value)}
              />
            </Tabs.Panel>
            <Tabs.Panel value="preview" pt="xs">
              <Paper withBorder radius="md" p="sm" className={classes.preview}>
                {draft.content.trim() ? (
                  <Markdown content={draft.content} />
                ) : (
                  <Text size="sm" c="dimmed">
                    {text.emptyPreview}
                  </Text>
                )}
              </Paper>
            </Tabs.Panel>
          </Tabs>
        </Stack>

        {saveError && (
          <Alert color="red" variant="light" icon={<IconAlertCircle size={18} />}>
            {saveError}
          </Alert>
        )}

        <Group justify="flex-end" gap="xs">
          {onCancel && (
            <Button variant="subtle" color="gray" disabled={saving} onClick={cancel}>
              {text.cancel}
            </Button>
          )}
          <Button type="submit" loading={saving} disabled={!!skill && !dirty}>
            {skill ? text.save : text.create}
          </Button>
        </Group>

        <ConfirmDialog
          opened={confirmOpen}
          title={text.discardTitle}
          message={text.discardMessage}
          labels={{ confirm: text.discard, cancel: text.keepEditing }}
          danger
          onConfirm={() => {
            setDraft(baseline);
            setErrors({});
            onCancel?.();
          }}
          onClose={() => setConfirmOpen(false)}
        />
      </Stack>
    </form>
  );
});

SkillEditor.displayName = 'SkillEditor';
API reference
Prop
Type
Required
skill
Skill | null
No
Skill to edit, a new skill is created when omitted
initialDraft
Partial<SkillDraft>
No
Initial values of a new skill, for example a duplicate
availableTools
string[]
No
Tool names offered in the allowed tools select
existingNames
string[]
No
Names of other skills, used to check that the name is unique
onSave
(draft: SkillDraft) => Promise<void> | void
Yes
Saves the draft, the form stays open and shows the rejection message when the promise rejects
onCancel
() => void
No
Leaves the editor, asks for confirmation first when there are unsaved changes
onDirtyChange
(dirty: boolean) => void
No
Called when the form gains or loses unsaved changes, lets the host guard its own navigation
labels
Partial<SkillEditorLabels>
No
Overrides of the default English labels and validation messages
className
string
No
Class name added to the root element
style
React.CSSProperties
No
Inline styles added to the root element