"use client"
import { cn } from "@lib/utils"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@ui/components/alert-dialog"
import { Building2, Loader2, Trash2, UserRound } from "lucide-react"
import { useEffect, useState } from "react"
import ReactMarkdown from "react-markdown"
import { toast } from "sonner"
import {
type BrainSkill,
useDeleteBrainSkill,
useSaveBrainSkill,
} from "@/hooks/use-brain-skills"
import { dmSans125ClassName } from "@/lib/fonts"
import {
canSelectSkillScope,
SKILL_BODY_MAX_LENGTH,
SKILL_DESCRIPTION_MAX_LENGTH,
SKILL_NAME_MAX_LENGTH,
setSkillDraftScope,
skillDraftPayload,
skillDraftForRole,
skillDraftsEqual,
type NewSkillOrigin,
type SkillDraft,
type SkillScope,
} from "./domain"
const inputClass = cn(
dmSans125ClassName(),
"w-full rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-3 text-[13px] text-[#FAFAFA] outline-none transition-colors focus:border-white/[0.16] read-only:cursor-default read-only:text-[#B5BDC9] disabled:opacity-50",
)
const labelClass = cn(
dmSans125ClassName(),
"text-[11px] font-medium uppercase tracking-[0.06em] text-[#687282]",
)
const secondaryButtonClass = cn(
dmSans125ClassName(),
"inline-flex h-9 items-center justify-center gap-2 rounded-full border border-white/10 px-4 text-[13px] font-medium text-[#9AA3B2] transition-colors hover:bg-white/[0.04] hover:text-[#FAFAFA] disabled:cursor-not-allowed disabled:opacity-45",
)
const primaryButtonClass = cn(
dmSans125ClassName(),
"inline-flex h-9 items-center justify-center gap-2 rounded-full bg-[#14161A] px-4 text-[13px] font-semibold text-[#FAFAFA] shadow-inside-out transition-colors hover:bg-[#121820] disabled:cursor-not-allowed disabled:opacity-45",
)
function messageFor(error: unknown): string | null {
return error instanceof Error
? error.message
: error
? "Something went wrong."
: null
}
function ScopeButton({
value,
label,
icon,
selected,
disabled,
onSelect,
}: {
value: SkillScope
label: string
icon: React.ReactNode
selected: boolean
disabled: boolean
onSelect: (scope: SkillScope) => void
}) {
return (
onSelect(value)}
className={cn(
dmSans125ClassName(),
"inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
selected
? "border-[#3B82F6]/40 bg-[#2563EB]/10 text-[#BFDBFE]"
: "border-white/[0.08] text-[#7E8794] hover:border-white/[0.14] hover:text-[#FAFAFA]",
)}
>
{icon}
{label}
)
}
function MarkdownPreview({ body }: { body: string }) {
return (
{body.trim() ? (
{body}
) : (
Nothing to preview yet.
)}
)
}
export function SkillEditor({
skill,
initialDraft,
isAdmin,
viewerId,
draftKind,
createOrigin,
onClose,
onDirtyChange,
}: {
skill: BrainSkill | null
initialDraft: SkillDraft
isAdmin: boolean
viewerId: string
draftKind?: "new" | "upload"
createOrigin?: NewSkillOrigin
onClose: () => void
onDirtyChange?: (dirty: boolean) => void
}) {
const privateSkillUnavailable =
!!skill && skill.scope === "personal" && skill.creatorUserId !== viewerId
const [draft, setDraft] = useState(() =>
privateSkillUnavailable
? { name: "", description: "", body: "", scope: "personal" as const }
: skill
? initialDraft
: skillDraftForRole(initialDraft, isAdmin),
)
const [preview, setPreview] = useState(false)
const [clientError, setClientError] = useState(null)
const [deleteOpen, setDeleteOpen] = useState(false)
const save = useSaveBrainSkill()
const remove = useDeleteBrainSkill()
const isDirty =
!privateSkillUnavailable && !skillDraftsEqual(draft, initialDraft)
useEffect(() => {
onDirtyChange?.(isDirty)
}, [isDirty, onDirtyChange])
if (privateSkillUnavailable) {
return (
Personal skill unavailable
This private skill is only available to its creator.
Close
)
}
const isCreator = !skill || skill.creatorUserId === viewerId
const canManage = skill
? skill.canEdit
: isAdmin || draft.scope === "personal"
const canDelete = skill?.canDelete ?? false
const canSelectScope = (scope: SkillScope) =>
canSelectSkillScope(skill, viewerId, isAdmin, scope)
const showSharedScopeOptions = isAdmin
const busy = save.isPending || remove.isPending
const bodyBytes = new TextEncoder().encode(draft.body).length
const mutationError = messageFor(save.error) ?? messageFor(remove.error)
const visibleError = clientError ?? mutationError
const scopePermissionCopy =
!skill && !isAdmin
? "New member skills are personal. Only admins and owners can create org-wide skills."
: skill?.scope === "personal" && isCreator && !isAdmin
? "This skill stays personal. Only creators who are also admins or owners can make personal skills org-wide."
: skill?.scope !== "personal" && isAdmin && !isCreator
? "Only the original creator can convert this org-wide skill to Personal."
: null
const resetErrors = () => {
setClientError(null)
save.reset()
remove.reset()
}
const set = (key: K, value: SkillDraft[K]) => {
resetErrors()
setDraft((current) => ({ ...current, [key]: value }))
}
const saveDraft = () => {
if (busy || !canManage) return
let payload: SkillDraft
try {
payload = skillDraftPayload(draft)
} catch (error) {
setClientError(messageFor(error))
return
}
save.mutate(
{
id: skill?.id ?? null,
draft: payload,
createOrigin,
expectedVersion: skill?.version,
},
{
onSuccess: () => {
toast.success("Skill saved.")
onClose()
},
},
)
}
return (
{draftKind === "upload"
? "Review uploaded skill"
: skill
? canManage
? "Edit skill"
: "View skill"
: "New skill"}
{draftKind === "upload" ? (
Not saved yet
) : null}
{!canManage ? (
{skill?.scope === "personal"
? "Only this skill’s creator can edit or delete it. Admin access does not override personal ownership. You can still read the full playbook below."
: isAdmin
? "You can view this org-wide skill, but it cannot be edited or deleted in its current state."
: "Only admins and owners can edit or delete org-wide skills. You can still read the full playbook below."}
) : null}
{skill?.status === "disabled" ? (
This skill is disabled.
{skill.rejectionReason ? ` Reason: ${skill.rejectionReason}` : null}
) : null}
Name
{draft.name.length} / {SKILL_NAME_MAX_LENGTH}
set("name", event.target.value)}
readOnly={!canManage}
disabled={busy}
maxLength={SKILL_NAME_MAX_LENGTH}
placeholder="e.g. Incident status updates"
className={cn(inputClass, "h-9")}
/>
{canManage ? (
Who can use it
}
selected={draft.scope === "personal"}
disabled={!canSelectScope("personal") || busy}
onSelect={(scope) => {
resetErrors()
setDraft((current) => setSkillDraftScope(current, scope))
}}
/>
{showSharedScopeOptions ? (
}
selected={draft.scope === "org"}
disabled={!canSelectScope("org") || busy}
onSelect={(scope) => {
resetErrors()
setDraft((current) => setSkillDraftScope(current, scope))
}}
/>
) : null}
{scopePermissionCopy ? (
{scopePermissionCopy}
) : null}
) : null}
Description
{draft.description.length} / {SKILL_DESCRIPTION_MAX_LENGTH}
Instructions (Markdown)
SKILL_BODY_MAX_LENGTH
? "text-red-300"
: "text-[#596270]",
)}
>
{Math.ceil(bodyBytes / 1024)} / 16 KB
{canManage ? (
setPreview(false)}
className={cn(
"rounded-full px-3 py-1 text-[11px] transition-colors",
!preview
? "bg-white/[0.08] text-[#FAFAFA]"
: "text-[#687282]",
)}
>
Write
setPreview(true)}
className={cn(
"rounded-full px-3 py-1 text-[11px] transition-colors",
preview
? "bg-white/[0.08] text-[#FAFAFA]"
: "text-[#687282]",
)}
>
Preview
) : null}
{preview || !canManage ? (
) : (
{visibleError ? (
{visibleError}
) : null}
{canDelete ? (
setDeleteOpen(true)}
className="inline-flex h-9 items-center gap-2 rounded-full px-3 text-[12px] text-[#A7685B] transition-colors hover:bg-red-400/[0.05] hover:text-red-300 disabled:opacity-45"
>
Delete
) : null}
Cancel
{canManage ? (
{save.isPending ? (
) : null}
{save.isPending ? "Saving…" : "Save"}
) : null}
Delete “{skill?.name}”?
{skill?.scope === "org"
? "This removes the playbook for the workspace."
: "This removes your private playbook."}{" "}
This action cannot be undone.
{remove.error ? (
{messageFor(remove.error)}
) : null}
Cancel
{
event.preventDefault()
if (!skill || !canDelete || remove.isPending) return
remove.mutate(
{ id: skill.id, expectedVersion: skill.version },
{
onSuccess: () => {
toast.success("Skill deleted.")
setDeleteOpen(false)
onClose()
},
},
)
}}
className="bg-red-600 text-white hover:bg-red-500"
>
{remove.isPending ? (
) : null}
Delete skill
)
}