From f14cdd7a4cf5affc8ad8822d9142424441dfaf78 Mon Sep 17 00:00:00 2001 From: sreedharsreeram <141047751+sreedharsreeram@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:36:32 +0000 Subject: [PATCH] feat(web): add Company Brain skills settings (#1322) ## 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. --- apps/web/components/configure-view.tsx | 77 ++- .../settings/company-brain-skills.tsx | 446 +++++++++++++++ .../settings/company-brain-skills/domain.ts | 246 +++++++++ .../company-brain-skills/skill-editor.tsx | 511 ++++++++++++++++++ .../company-brain-skills/skill-row.tsx | 71 +++ apps/web/hooks/use-brain-skills.impl.ts | 182 +++++++ apps/web/hooks/use-brain-skills.ts | 3 + apps/web/lib/configure-routes.ts | 1 + 8 files changed, 1535 insertions(+), 2 deletions(-) create mode 100644 apps/web/components/settings/company-brain-skills.tsx create mode 100644 apps/web/components/settings/company-brain-skills/domain.ts create mode 100644 apps/web/components/settings/company-brain-skills/skill-editor.tsx create mode 100644 apps/web/components/settings/company-brain-skills/skill-row.tsx create mode 100644 apps/web/hooks/use-brain-skills.impl.ts create mode 100644 apps/web/hooks/use-brain-skills.ts diff --git a/apps/web/components/configure-view.tsx b/apps/web/components/configure-view.tsx index 98be696b..83f43c3e 100644 --- a/apps/web/components/configure-view.tsx +++ b/apps/web/components/configure-view.tsx @@ -1,12 +1,30 @@ "use client" import { cn } from "@lib/utils" -import { Blocks, CalendarClock, Cpu, ScrollText } from "lucide-react" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@ui/components/alert-dialog" +import { + Blocks, + BookOpenText, + CalendarClock, + Cpu, + ScrollText, +} from "lucide-react" import Link from "next/link" -import { usePathname } from "next/navigation" +import { usePathname, useRouter } from "next/navigation" +import { useState } from "react" import CompanyBrainConnections from "@/components/settings/company-brain-connections" import CompanyBrainModels from "@/components/settings/company-brain-models" import CompanyBrainProactivity from "@/components/settings/company-brain-proactivity" +import CompanyBrainSkills from "@/components/settings/company-brain-skills" import Proactiveness from "@/components/settings/proactiveness" import { ProactivenessIcon } from "@/components/settings/proactiveness-icon" import { WorkspacePrompt } from "@/components/settings/workspace-prompt" @@ -61,16 +79,43 @@ const SECTIONS: { "Read-only scheduled summaries posted to Slack channels or DMs. You manage the ones you create.", icon: CalendarClock, }, + { + id: "skills", + label: "Skills", + description: + "Teach Company Brain your team's repeatable processes, formats, and voice with reusable Markdown playbooks.", + icon: BookOpenText, + }, ] export function ConfigureView() { const { org } = useAuth() const pathname = usePathname() + const router = useRouter() // Reachable via ?view=configure too, where the path carries no section. const activeSection = pathToConfigureSection(pathname) ?? DEFAULT_CONFIGURE_SECTION + const [skillsDirty, setSkillsDirty] = useState(false) + const [pendingSection, setPendingSection] = useState( + null, + ) const active = SECTIONS.find((section) => section.id === activeSection) if (!active) return null + const requestSection = ( + event: React.MouseEvent, + section: ConfigureSection, + ) => { + if (section === activeSection) return + if (activeSection !== "skills" || !skillsDirty) return + event.preventDefault() + setPendingSection(section) + } + const confirmSectionChange = () => { + if (!pendingSection) return + setSkillsDirty(false) + router.push(configureSectionToPath(pendingSection)) + setPendingSection(null) + } return (
requestSection(event, section.id)} className={cn( "flex shrink-0 items-center gap-2.5 rounded-[8px] px-3 py-2 text-left text-[13px] font-medium transition-colors", isActive @@ -144,6 +190,8 @@ export function ConfigureView() { ) : activeSection === "proactivity" ? ( + ) : activeSection === "skills" ? ( + ) : ( )} @@ -151,6 +199,31 @@ export function ConfigureView() {
+ + { + if (!open) setPendingSection(null) + }} + > + + + Discard unsaved changes? + + Leaving Skills will discard your unsaved skill changes. + + + + Keep editing + + Discard and leave + + + + ) } diff --git a/apps/web/components/settings/company-brain-skills.tsx b/apps/web/components/settings/company-brain-skills.tsx new file mode 100644 index 00000000..b546fc40 --- /dev/null +++ b/apps/web/components/settings/company-brain-skills.tsx @@ -0,0 +1,446 @@ +"use client" + +import { useAuth } from "@lib/auth-context" +import { cn } from "@lib/utils" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@ui/components/alert-dialog" +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, +} from "@ui/components/sheet" +import { FileUp, Loader2, Plus, RotateCcw } from "lucide-react" +import { useEffect, useRef, useState } from "react" +import { toast } from "sonner" +import { + type BrainSkill, + useBrainSkills, + useUploadBrainSkill, +} from "@/hooks/use-brain-skills" +import { dmSans125ClassName } from "@/lib/fonts" +import { + emptySkillDraft, + parseSkillMarkdown, + skillDraftForRole, + type SkillDraft, + type SkillScope, +} from "./company-brain-skills/domain" +import { SkillEditor } from "./company-brain-skills/skill-editor" +import { SkillRow } from "./company-brain-skills/skill-row" + +type EditorTarget = + | { mode: "existing"; skillId: string } + | { mode: "new" | "upload"; key: number; draft: SkillDraft } + +type PendingIntent = { type: "close" } | { type: "open"; target: EditorTarget } + +type ScopeFilter = "all" | "org" | "personal" + +const SCOPE_HINTS: Record = { + all: "Org-wide skills are shared with the workspace and managed by admins and owners. Personal skills stay private to you.", + org: "Shared with the workspace and managed by admins and owners.", + personal: "Private to you and managed only by you.", +} + +const EMPTY_MESSAGES: Record = { + all: "No skills yet. Create one or upload a .md playbook to get started.", + org: "No org-wide skills yet.", + personal: "No personal skills yet.", +} + +function draftFromSkill(skill: BrainSkill): SkillDraft { + return { + name: skill.name, + description: skill.description, + body: skill.body, + scope: skill.scope, + } +} + +function uploadedDraft( + local: ReturnType, + server: Partial, +): SkillDraft { + return { + name: typeof server.name === "string" ? server.name : local.name, + description: + typeof server.description === "string" + ? server.description + : local.description, + body: typeof server.body === "string" ? server.body : local.body, + scope: "personal", + } +} + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error ? error.message : fallback +} + +export default function CompanyBrainSkills({ + onUnsavedChangesChange, +}: { + onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void +}) { + const { org, user } = useAuth() + return ( + + ) +} + +function CompanyBrainSkillsContent({ + userId, + onUnsavedChangesChange, +}: { + userId: string + onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void +}) { + const skillsQuery = useBrainSkills() + const upload = useUploadBrainSkill() + const [editing, setEditing] = useState(null) + const [editorDirty, setEditorDirty] = useState(false) + const [pending, setPending] = useState(null) + const [scopeFilter, setScopeFilter] = useState("all") + const [uploadError, setUploadError] = useState(null) + const fileInput = useRef(null) + const draftKey = useRef(0) + // An uploaded draft holds real content the user hasn't saved, so it guards too. + const hasUnsavedChanges = editorDirty || editing?.mode === "upload" + + useEffect(() => { + onUnsavedChangesChange?.(hasUnsavedChanges) + }, [hasUnsavedChanges, onUnsavedChangesChange]) + + useEffect( + () => () => { + onUnsavedChangesChange?.(false) + }, + [onUnsavedChangesChange], + ) + + const data = skillsQuery.data + const viewerId = data?.viewerId ?? userId + const isAdmin = data?.isAdmin ?? false + const openEditor = (target: EditorTarget) => { + setEditing(target) + setEditorDirty(false) + } + const closeEditor = () => { + setEditing(null) + setEditorDirty(false) + setPending(null) + } + const requestOpen = (target: EditorTarget) => { + if (editing && hasUnsavedChanges) { + setPending({ type: "open", target }) + return + } + openEditor(target) + } + const requestClose = () => { + if (editing && hasUnsavedChanges) { + setPending({ type: "close" }) + return + } + closeEditor() + } + const confirmPending = () => { + if (!pending) return + if (pending.type === "close") closeEditor() + else openEditor(pending.target) + setPending(null) + } + const openDraft = (kind: "new" | "upload", draft: SkillDraft) => { + requestOpen({ + mode: kind, + key: draftKey.current++, + draft: skillDraftForRole(draft, isAdmin), + }) + } + + const onUpload = async (file: File | undefined) => { + if (!file || upload.isPending) return + setUploadError(null) + upload.reset() + if (!file.name.toLowerCase().endsWith(".md")) { + setUploadError("Choose a Markdown file ending in .md.") + return + } + try { + const content = await file.text() + // Client parsing gives immediate, deterministic feedback; the harness runs + // the same validation and remains authoritative before the draft opens. + const local = parseSkillMarkdown(content) + const result = await upload.mutateAsync(content) + openDraft("upload", uploadedDraft(local, result.draft)) + toast.success("Skill file is ready to review.") + } catch (error) { + setUploadError(errorMessage(error, "Couldn't read this skill file.")) + } + } + + const orgSkills = (data?.skills ?? []).filter( + (skill) => skill.scope === "org", + ) + const personalSkills = (data?.skills ?? []).filter( + (skill) => skill.scope === "personal" && skill.creatorUserId === viewerId, + ) + const visibleSkills = + scopeFilter === "org" + ? orgSkills + : scopeFilter === "personal" + ? personalSkills + : [...orgSkills, ...personalSkills] + const newSkillScope: SkillScope = + scopeFilter === "org" && isAdmin ? "org" : "personal" + const target = editing + const editingSkill = + target?.mode === "existing" + ? ((data?.skills ?? []).find((skill) => skill.id === target.skillId) ?? + null) + : null + const renderSkill = (skill: BrainSkill) => ( + requestOpen({ mode: "existing", skillId: skill.id })} + /> + ) + + return ( +
+ {skillsQuery.isLoading ? ( +
+ Loading skills… +
+ ) : skillsQuery.isError ? ( +
+

+ {errorMessage(skillsQuery.error, "Couldn't load skills.")} +

+ +
+ ) : ( +
+
+
+ {( + [ + { + id: "all", + label: "All", + count: orgSkills.length + personalSkills.length, + }, + { id: "org", label: "Org-wide", count: orgSkills.length }, + { + id: "personal", + label: "Personal", + count: personalSkills.length, + }, + ] as const + ).map((tab) => ( + + ))} +
+ +
+ { + const file = event.currentTarget.files?.[0] + event.currentTarget.value = "" + void onUpload(file) + }} + /> + + +
+
+ +

+ {SCOPE_HINTS[scopeFilter]} +

+ + {uploadError ? ( +
+ {uploadError} + +
+ ) : null} + + {visibleSkills.length > 0 ? ( +
+
+ {visibleSkills.map(renderSkill)} +
+
+ ) : ( +

+ {EMPTY_MESSAGES[scopeFilter]} +

+ )} +
+ )} + + { + if (!open) requestClose() + }} + > + + Skill editor + + Edit the name, scope, description and Markdown instructions for this + skill. + + {target?.mode === "existing" ? ( + editingSkill ? ( + + ) : ( +

+ This skill is no longer available. +

+ ) + ) : target ? ( + + ) : null} +
+
+ + { + if (!open) setPending(null) + }} + > + + + Discard unsaved changes? + + {pending?.type === "open" + ? "Opening another skill will discard the edits in this skill." + : "Closing the editor will discard the edits in this skill."} + + + + Keep editing + + {pending?.type === "open" ? "Discard and open" : "Discard"} + + + + +
+ ) +} diff --git a/apps/web/components/settings/company-brain-skills/domain.ts b/apps/web/components/settings/company-brain-skills/domain.ts new file mode 100644 index 00000000..df45e085 --- /dev/null +++ b/apps/web/components/settings/company-brain-skills/domain.ts @@ -0,0 +1,246 @@ +export const SKILL_NAME_MAX_LENGTH = 64 +export const SKILL_DESCRIPTION_MAX_LENGTH = 200 +export const SKILL_BODY_MAX_LENGTH = 16 * 1024 + +export type SkillScope = "personal" | "org" + +export type SkillDraft = { + name: string + description: string + body: string + scope: SkillScope +} + +export type SkillMarkdown = Pick +export type NewSkillOrigin = "web" | "upload" + +export type SkillAuthoringTarget = { + canEdit: boolean + creatorUserId: string + scope: SkillScope +} + +export function canSelectSkillScope( + skill: SkillAuthoringTarget | null, + viewerId: string, + isAdmin: boolean, + targetScope: SkillScope, +): boolean { + if (!skill) return targetScope === "personal" || isAdmin + + const isCreator = skill.creatorUserId === viewerId + if (targetScope === skill.scope) return skill.canEdit + return skill.canEdit && isAdmin && isCreator +} + +export function skillDraftForRole( + draft: SkillDraft, + isAdmin: boolean, +): SkillDraft { + return isAdmin || draft.scope === "personal" + ? draft + : { ...draft, scope: "personal" } +} + +export function emptySkillDraft(): SkillDraft { + return { + name: "", + description: "", + body: "", + scope: "personal", + } +} + +export function setSkillDraftScope( + draft: SkillDraft, + scope: SkillScope, +): SkillDraft { + return { + ...draft, + scope, + } +} + +export function normalizeSkillDraft(draft: SkillDraft): SkillDraft { + return { + name: draft.name.trim(), + description: draft.description.trim(), + body: draft.body.trim(), + scope: draft.scope, + } +} + +export function skillDraftsEqual(left: SkillDraft, right: SkillDraft): boolean { + const normalizedLeft = normalizeSkillDraft(left) + const normalizedRight = normalizeSkillDraft(right) + return ( + normalizedLeft.name === normalizedRight.name && + normalizedLeft.description === normalizedRight.description && + normalizedLeft.body === normalizedRight.body && + normalizedLeft.scope === normalizedRight.scope + ) +} + +export function skillDraftPayload(draft: SkillDraft): SkillDraft { + const normalized = normalizeSkillDraft(draft) + const { name, description, body } = normalized + + if (!name) throw new Error("Give the skill a name.") + if (name.length > SKILL_NAME_MAX_LENGTH) { + throw new Error(`Keep the name to ${SKILL_NAME_MAX_LENGTH} characters.`) + } + if (!description) throw new Error("Add a short description.") + if (description.length > SKILL_DESCRIPTION_MAX_LENGTH) { + throw new Error( + `Keep the description to ${SKILL_DESCRIPTION_MAX_LENGTH} characters.`, + ) + } + if (!body) throw new Error("Add the skill instructions.") + if (new TextEncoder().encode(body).length > SKILL_BODY_MAX_LENGTH) { + throw new Error("Keep the skill instructions under 16 KB.") + } + return normalized +} + +export function skillSaveRequestBody( + draft: SkillDraft, + id: string | null, + createOrigin?: NewSkillOrigin, + expectedVersion?: number, +): SkillDraft & { origin?: "upload"; expectedVersion?: number } { + if (id !== null) { + if (!Number.isInteger(expectedVersion) || (expectedVersion ?? 0) < 1) { + throw new Error("Refresh this skill before saving your changes.") + } + return { ...draft, expectedVersion } + } + return createOrigin === "upload" ? { ...draft, origin: "upload" } : draft +} + +function parseQuotedScalar(value: string, key: string): string { + if (value.startsWith('"')) { + let closingQuote = -1 + let escaped = false + for (let index = 1; index < value.length; index += 1) { + const character = value[index] + if (character === '"' && !escaped) { + closingQuote = index + break + } + escaped = character === "\\" ? !escaped : false + } + if (closingQuote < 0) { + throw new Error(`Invalid quoted ${key} in skill frontmatter.`) + } + const suffix = value.slice(closingQuote + 1).trim() + if (suffix && !suffix.startsWith("#")) { + throw new Error(`Invalid quoted ${key} in skill frontmatter.`) + } + try { + const parsed = JSON.parse(value.slice(0, closingQuote + 1)) as unknown + if (typeof parsed !== "string") throw new Error("not a string") + return parsed + } catch { + throw new Error(`Invalid quoted ${key} in skill frontmatter.`) + } + } + if (value.startsWith("'")) { + let parsed = "" + for (let index = 1; index < value.length; index += 1) { + const character = value[index] + if (character !== "'") { + parsed += character + continue + } + if (value[index + 1] === "'") { + parsed += "'" + index += 1 + continue + } + const suffix = value.slice(index + 1).trim() + if (suffix && !suffix.startsWith("#")) { + throw new Error(`Invalid quoted ${key} in skill frontmatter.`) + } + return parsed + } + throw new Error(`Invalid quoted ${key} in skill frontmatter.`) + } + return value.replace(/\s+#.*$/, "").trimEnd() +} + +function parseFrontmatter(source: string): { + attributes: Record + body: string +} { + const normalized = source.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n") + const lines = normalized.split("\n") + if (lines[0]?.trim() !== "---") { + throw new Error("Skill files must start with YAML frontmatter.") + } + + const end = lines.findIndex( + (line, index) => index > 0 && /^---[\t ]*$/.test(line), + ) + if (end < 0) throw new Error("Skill frontmatter is missing its closing ---.") + + const attributes: Record = {} + for (let index = 1; index < end; index += 1) { + const line = lines[index] ?? "" + if (!line.trim() || line.trimStart().startsWith("#")) continue + // Extra SKILL.md metadata may contain nested maps/lists. Only name and + // description are used here, so valid indented metadata can be ignored. + if (/^\s/.test(line)) continue + const match = /^([A-Za-z][\w-]*):(?:\s*(.*))?$/.exec(line) + if (!match) throw new Error("Skill frontmatter is malformed.") + const key = match[1]?.toLowerCase() + let rawValue = match[2] ?? "" + if (!key) throw new Error("Skill frontmatter is malformed.") + if (attributes[key] !== undefined) { + throw new Error(`Skill frontmatter contains duplicate ${key} fields.`) + } + + if ([">", "|", ">-", "|-"].includes(rawValue)) { + const blockLines: string[] = [] + while (index + 1 < end) { + const next = lines[index + 1] ?? "" + if (next && !/^\s/.test(next)) break + index += 1 + blockLines.push(next.replace(/^\s{1,2}/, "")) + } + rawValue = rawValue.startsWith(">") + ? blockLines.join(" ").replace(/\s+/g, " ").trim() + : blockLines.join("\n") + } + + attributes[key] = parseQuotedScalar(rawValue.trim(), key) + } + + return { attributes, body: lines.slice(end + 1).join("\n") } +} + +export function parseSkillMarkdown(source: string): SkillMarkdown { + const { attributes, body } = parseFrontmatter(source) + const name = attributes.name?.trim() ?? "" + const description = attributes.description?.trim() ?? "" + if (!name) throw new Error("Skill frontmatter needs a name.") + if (name.length > SKILL_NAME_MAX_LENGTH) { + throw new Error(`Keep the name to ${SKILL_NAME_MAX_LENGTH} characters.`) + } + if (!description) throw new Error("Skill frontmatter needs a description.") + if (description.length > SKILL_DESCRIPTION_MAX_LENGTH) { + throw new Error( + `Keep the description to ${SKILL_DESCRIPTION_MAX_LENGTH} characters.`, + ) + } + return { name, description, body } +} + +export function serializeSkillMarkdown(skill: SkillMarkdown): string { + return [ + "---", + `name: ${JSON.stringify(skill.name)}`, + `description: ${JSON.stringify(skill.description)}`, + "---", + skill.body, + ].join("\n") +} diff --git a/apps/web/components/settings/company-brain-skills/skill-editor.tsx b/apps/web/components/settings/company-brain-skills/skill-editor.tsx new file mode 100644 index 00000000..72da002f --- /dev/null +++ b/apps/web/components/settings/company-brain-skills/skill-editor.tsx @@ -0,0 +1,511 @@ +"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 ( + + ) +} + +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. +

+
+ +
+ ) + } + + 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} + + + {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} + +