diff --git a/apps/web/components/settings/company-brain-skills.tsx b/apps/web/components/settings/company-brain-skills.tsx index 4b44e6d0..b546fc40 100644 --- a/apps/web/components/settings/company-brain-skills.tsx +++ b/apps/web/components/settings/company-brain-skills.tsx @@ -12,6 +12,12 @@ import { 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" @@ -26,14 +32,29 @@ import { parseSkillMarkdown, skillDraftForRole, type SkillDraft, + type SkillScope, } from "./company-brain-skills/domain" -import { SkillCard } from "./company-brain-skills/skill-card" import { SkillEditor } from "./company-brain-skills/skill-editor" +import { SkillRow } from "./company-brain-skills/skill-row" -type UnsavedDraft = { - key: number - kind: "new" | "upload" - draft: SkillDraft +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 { @@ -88,14 +109,15 @@ function CompanyBrainSkillsContent({ }) { const skillsQuery = useBrainSkills() const upload = useUploadBrainSkill() - const [openId, setOpenId] = useState(null) - const [openSkillDirty, setOpenSkillDirty] = useState(false) - const [pendingOpenId, setPendingOpenId] = useState(null) - const [drafts, setDrafts] = useState([]) + 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) - const hasUnsavedChanges = openSkillDirty || drafts.length > 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) @@ -111,37 +133,41 @@ function CompanyBrainSkillsContent({ const data = skillsQuery.data const viewerId = data?.viewerId ?? userId const isAdmin = data?.isAdmin ?? false - const addDraft = (kind: UnsavedDraft["kind"], draft: SkillDraft) => { - setDrafts((current) => [ - ...current, - { - key: draftKey.current++, - kind, - draft: skillDraftForRole(draft, isAdmin), - }, - ]) + const openEditor = (target: EditorTarget) => { + setEditing(target) + setEditorDirty(false) } - const removeDraft = (key: number) => { - setDrafts((current) => current.filter((item) => item.key !== key)) + const closeEditor = () => { + setEditing(null) + setEditorDirty(false) + setPending(null) } - const closeOpenSkill = () => { - setOpenId(null) - setOpenSkillDirty(false) - setPendingOpenId(null) - } - const requestOpenSkill = (skillId: string) => { - if (openId && openId !== skillId && openSkillDirty) { - setPendingOpenId(skillId) + const requestOpen = (target: EditorTarget) => { + if (editing && hasUnsavedChanges) { + setPending({ type: "open", target }) return } - setOpenId(skillId) - setOpenSkillDirty(false) + openEditor(target) } - const confirmOpenSkill = () => { - if (!pendingOpenId) return - setOpenId(pendingOpenId) - setOpenSkillDirty(false) - setPendingOpenId(null) + 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) => { @@ -158,7 +184,7 @@ function CompanyBrainSkillsContent({ // the same validation and remains authoritative before the draft opens. const local = parseSkillMarkdown(content) const result = await upload.mutateAsync(content) - addDraft("upload", uploadedDraft(local, result.draft)) + 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.")) @@ -171,91 +197,30 @@ function CompanyBrainSkillsContent({ const personalSkills = (data?.skills ?? []).filter( (skill) => skill.scope === "personal" && skill.creatorUserId === viewerId, ) - const orgDrafts = drafts.filter((draft) => draft.draft.scope === "org") - const personalDrafts = drafts.filter( - (draft) => draft.draft.scope === "personal", - ) - const renderSkill = (skill: BrainSkill) => - openId === skill.id ? ( -
- -
- ) : ( - requestOpenSkill(skill.id)} - /> - ) - const renderDraft = ({ key, kind, draft }: UnsavedDraft) => ( -
- removeDraft(key)} - /> -
+ 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 ( -
-
-
- { - const file = event.currentTarget.files?.[0] - event.currentTarget.value = "" - void onUpload(file) - }} - /> - -
-
- - {uploadError ? ( -
- {uploadError} - -
- ) : null} - +
{skillsQuery.isLoading ? (
Loading skills… @@ -284,106 +249,194 @@ function CompanyBrainSkillsContent({
) : ( -
-
-
-

- Org-wide skills -

-

- Shared with the workspace and managed by admins and owners. -

+
+
+
+ {( + [ + { + 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) => ( + + ))}
- {orgSkills.length > 0 || orgDrafts.length > 0 || isAdmin ? ( -
- {orgSkills.map(renderSkill)} - {orgDrafts.map(renderDraft)} - {isAdmin ? ( - - ) : null} -
- ) : ( -

- No org-wide skills yet. -

- )} -
-
-
-

- Personal skills -

-

- Private to you and managed only by you. -

-
-
- {personalSkills.map(renderSkill)} - {personalDrafts.map(renderDraft)} +
+ { + 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) setPendingOpenId(null) + 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? - Opening another skill will discard the edits in this skill. + {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 - Discard and open + {pending?.type === "open" ? "Discard and open" : "Discard"} diff --git a/apps/web/components/settings/company-brain-skills/skill-card.tsx b/apps/web/components/settings/company-brain-skills/skill-card.tsx deleted file mode 100644 index 5c9397a7..00000000 --- a/apps/web/components/settings/company-brain-skills/skill-card.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client" - -import { cn } from "@lib/utils" -import { dmSans125ClassName } from "@/lib/fonts" -import type { BrainSkill } from "@/hooks/use-brain-skills" - -function StatusChip({ skill }: { skill: BrainSkill }) { - if (skill.status === "disabled") { - return ( - - Disabled - - ) - } - return null -} - -export function SkillCard({ - skill, - onOpen, -}: { - skill: BrainSkill - onOpen: () => void -}) { - return ( -
- -
- ) -} diff --git a/apps/web/components/settings/company-brain-skills/skill-editor.tsx b/apps/web/components/settings/company-brain-skills/skill-editor.tsx index d644e617..0daa148e 100644 --- a/apps/web/components/settings/company-brain-skills/skill-editor.tsx +++ b/apps/web/components/settings/company-brain-skills/skill-editor.tsx @@ -242,66 +242,41 @@ export function SkillEditor({
-
-
-

- {draftKind === "upload" - ? "Review uploaded skill" - : skill - ? canManage - ? "Edit skill" - : "View skill" - : "New skill"} -

- {draftKind === "upload" ? ( -

Not saved yet

- ) : null} -
-
- - -
+
+

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