mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
## Stack Context This is the frontend half of the Company Brain Skills feature. The harness is implemented in supermemoryai/mono#2611. ## What? Add Skills settings with separate Org-wide and Personal sections, Markdown upload autofill, scoped creation and editing, approval controls, and server-driven permissions. ## Why? Members need a focused way to manage their private playbooks while admins create and approve organization-wide guidance. ## Related - Harness: https://github.com/supermemoryai/mono/pull/2611 --- **Session Details** - Session: [View Session](https://supermemory.us1.vorflux.com/agent-sessions/d8c77451-8f55-47ab-a182-5c98da616263) - Requested by: Sreeram Sreedhar (sreeram@supermemory.com) - Address comments on this PR. Add `(aside)` to your comment to have me ignore it.
511 lines
15 KiB
TypeScript
511 lines
15 KiB
TypeScript
"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 (
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
aria-pressed={selected}
|
||
onClick={() => 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}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function MarkdownPreview({ body }: { body: string }) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"prose prose-invert prose-sm min-h-[280px] max-w-none overflow-auto rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-4 py-3 text-[#C6CDD7]",
|
||
"prose-headings:text-[#FAFAFA] prose-a:text-[#93C5FD] prose-code:text-[#D8B4FE] prose-strong:text-[#FAFAFA]",
|
||
)}
|
||
>
|
||
{body.trim() ? (
|
||
<ReactMarkdown>{body}</ReactMarkdown>
|
||
) : (
|
||
<p className="not-prose text-[13px] text-[#596270]">
|
||
Nothing to preview yet.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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<string | null>(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 (
|
||
<div
|
||
role="alert"
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"flex min-h-40 flex-col items-center justify-center gap-3 rounded-[14px] bg-[#14161A] px-5 py-8 text-center",
|
||
"shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]",
|
||
)}
|
||
>
|
||
<div>
|
||
<p className="text-[13px] font-semibold text-[#FAFAFA]">
|
||
Personal skill unavailable
|
||
</p>
|
||
<p className="mt-1 text-[12px] text-[#737B87]">
|
||
This private skill is only available to its creator.
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className={secondaryButtonClass}
|
||
>
|
||
Close
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 = <K extends keyof SkillDraft>(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 (
|
||
<div
|
||
className={cn(
|
||
dmSans125ClassName(),
|
||
"flex min-h-0 flex-1 flex-col bg-[#14161A]",
|
||
)}
|
||
>
|
||
<div className="flex min-w-0 shrink-0 flex-col border-white/[0.05] border-b px-5 py-4 pr-14">
|
||
<p className="text-[13px] font-semibold text-[#FAFAFA]">
|
||
{draftKind === "upload"
|
||
? "Review uploaded skill"
|
||
: skill
|
||
? canManage
|
||
? "Edit skill"
|
||
: "View skill"
|
||
: "New skill"}
|
||
</p>
|
||
{draftKind === "upload" ? (
|
||
<p className="mt-0.5 text-[11px] text-[#687282]">Not saved yet</p>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||
{!canManage ? (
|
||
<p className="rounded-[9px] border border-white/[0.07] bg-white/[0.025] px-3 py-2 text-[12px] text-[#8B929E]">
|
||
{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."}
|
||
</p>
|
||
) : null}
|
||
{skill?.status === "disabled" ? (
|
||
<p className="rounded-[9px] border border-red-400/15 bg-red-400/[0.05] px-3 py-2 text-[12px] text-red-200">
|
||
This skill is disabled.
|
||
{skill.rejectionReason ? ` Reason: ${skill.rejectionReason}` : null}
|
||
</p>
|
||
) : null}
|
||
<label className="flex min-w-0 shrink-0 flex-col gap-1.5">
|
||
<span className="flex items-center justify-between gap-2">
|
||
<span className={labelClass}>Name</span>
|
||
<span className="text-[10px] text-[#596270]">
|
||
{draft.name.length} / {SKILL_NAME_MAX_LENGTH}
|
||
</span>
|
||
</span>
|
||
<input
|
||
value={draft.name}
|
||
onChange={(event) => 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")}
|
||
/>
|
||
</label>
|
||
|
||
{canManage ? (
|
||
<div className="flex shrink-0 flex-col gap-2">
|
||
<span className={labelClass}>Who can use it</span>
|
||
<div className="flex flex-wrap gap-2">
|
||
<ScopeButton
|
||
value="personal"
|
||
label="Personal"
|
||
icon={<UserRound className="size-3.5" />}
|
||
selected={draft.scope === "personal"}
|
||
disabled={!canSelectScope("personal") || busy}
|
||
onSelect={(scope) => {
|
||
resetErrors()
|
||
setDraft((current) => setSkillDraftScope(current, scope))
|
||
}}
|
||
/>
|
||
{showSharedScopeOptions ? (
|
||
<ScopeButton
|
||
value="org"
|
||
label="Org-wide"
|
||
icon={<Building2 className="size-3.5" />}
|
||
selected={draft.scope === "org"}
|
||
disabled={!canSelectScope("org") || busy}
|
||
onSelect={(scope) => {
|
||
resetErrors()
|
||
setDraft((current) => setSkillDraftScope(current, scope))
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
{scopePermissionCopy ? (
|
||
<p className="text-[11px] leading-5 text-[#687282]">
|
||
{scopePermissionCopy}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<label className="flex min-w-0 shrink-0 flex-col gap-1.5">
|
||
<span className="flex items-center justify-between gap-2">
|
||
<span className={labelClass}>Description</span>
|
||
<span className="text-[10px] text-[#596270]">
|
||
{draft.description.length} / {SKILL_DESCRIPTION_MAX_LENGTH}
|
||
</span>
|
||
</span>
|
||
<textarea
|
||
value={draft.description}
|
||
onChange={(event) => set("description", event.target.value)}
|
||
readOnly={!canManage}
|
||
disabled={busy}
|
||
maxLength={SKILL_DESCRIPTION_MAX_LENGTH}
|
||
rows={3}
|
||
placeholder="When should the brain use this playbook?"
|
||
className={cn(inputClass, "resize-y py-2 leading-5")}
|
||
/>
|
||
</label>
|
||
|
||
<div className="flex min-h-0 flex-1 flex-col gap-1.5">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className={labelClass}>Instructions (Markdown)</span>
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className={cn(
|
||
"text-[10px]",
|
||
bodyBytes > SKILL_BODY_MAX_LENGTH
|
||
? "text-red-300"
|
||
: "text-[#596270]",
|
||
)}
|
||
>
|
||
{Math.ceil(bodyBytes / 1024)} / 16 KB
|
||
</span>
|
||
{canManage ? (
|
||
<div className="inline-flex rounded-full border border-white/[0.08] bg-[#0D0F14] p-0.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPreview(false)}
|
||
className={cn(
|
||
"rounded-full px-3 py-1 text-[11px] transition-colors",
|
||
!preview
|
||
? "bg-white/[0.08] text-[#FAFAFA]"
|
||
: "text-[#687282]",
|
||
)}
|
||
>
|
||
Write
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPreview(true)}
|
||
className={cn(
|
||
"rounded-full px-3 py-1 text-[11px] transition-colors",
|
||
preview
|
||
? "bg-white/[0.08] text-[#FAFAFA]"
|
||
: "text-[#687282]",
|
||
)}
|
||
>
|
||
Preview
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{preview || !canManage ? (
|
||
<MarkdownPreview body={draft.body} />
|
||
) : (
|
||
<textarea
|
||
value={draft.body}
|
||
onChange={(event) => set("body", event.target.value)}
|
||
disabled={busy}
|
||
maxLength={SKILL_BODY_MAX_LENGTH}
|
||
spellCheck={false}
|
||
placeholder={
|
||
"# Process\n\nDescribe the steps, format, and voice to follow."
|
||
}
|
||
className="min-h-[240px] w-full flex-1 resize-y rounded-[10px] border border-white/[0.08] bg-[#0D0F14] px-4 py-3 font-mono text-[12px] leading-5 text-[#D3D8E0] outline-none transition-colors focus:border-white/[0.16] disabled:opacity-50"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{visibleError ? (
|
||
<div
|
||
role="alert"
|
||
className="shrink-0 rounded-[9px] border border-red-400/15 bg-red-400/[0.05] px-3 py-2 text-[12px] text-red-300"
|
||
>
|
||
{visibleError}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-white/[0.05] border-t px-5 py-4">
|
||
<div>
|
||
{canDelete ? (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => 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"
|
||
>
|
||
<Trash2 className="size-4" /> Delete
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={onClose}
|
||
className={secondaryButtonClass}
|
||
>
|
||
Cancel
|
||
</button>
|
||
{canManage ? (
|
||
<button
|
||
type="button"
|
||
disabled={busy || (!!skill && !isDirty)}
|
||
onClick={saveDraft}
|
||
className={primaryButtonClass}
|
||
>
|
||
{save.isPending ? (
|
||
<Loader2 className="size-4 animate-spin" />
|
||
) : null}
|
||
{save.isPending ? "Saving…" : "Save"}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||
<AlertDialogContent className="border-white/[0.08] bg-[#191D24] text-[#FAFAFA]">
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Delete “{skill?.name}”?</AlertDialogTitle>
|
||
<AlertDialogDescription className="text-[#8B929E]">
|
||
{skill?.scope === "org"
|
||
? "This removes the playbook for the workspace."
|
||
: "This removes your private playbook."}{" "}
|
||
This action cannot be undone.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
{remove.error ? (
|
||
<p role="alert" className="text-[12px] text-red-300">
|
||
{messageFor(remove.error)}
|
||
</p>
|
||
) : null}
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel disabled={remove.isPending}>
|
||
Cancel
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
disabled={remove.isPending}
|
||
onClick={(event) => {
|
||
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 ? (
|
||
<Loader2 className="size-4 animate-spin" />
|
||
) : null}
|
||
Delete skill
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
</div>
|
||
)
|
||
}
|