import { interactionReadinessRefetchInterval } from "@/lib/issue-thread-interactions"; import { SkillBinaryFile } from "../components/SkillBinaryFile"; import { SkillSourceProvenance } from "../components/SkillSourceProvenance"; import { AgentIdentity } from "@/components/AgentIdentity"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Clock, Copy, FileCode, FilePlus, FileText, FolderMinus, FolderPlus, FlaskConical, GitFork, History, MoreHorizontal, Pencil, Play, Plus, RotateCcw, Share2, Trash2, } from "lucide-react"; import type { Agent, CompanySkillDetail, CompanySkillListItem, CompanySkillTestInput, CompanySkillTestRun, CompanySkillTestRunDetail, CompanySkillTestRunTemplate, CompanySkillTestRunTemplateCreateRequest, CompanySkillTestRunTemplateUpdateRequest, CompanySkillVersion, IssueDocument, IssueThreadInteraction, AskUserQuestionsInteraction, AskUserQuestionsAnswer, } from "@paperclipai/shared"; import { Link, useLocation, useNavigate, useParams, useSearchParams } from "@/lib/router"; import { SearchableSelect, type SearchableSelectGroup, type SearchableSelectOption, } from "@/components/SearchableSelect"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useCompany } from "../context/CompanyContext"; import { useOptionalToastActions } from "../context/ToastContext"; import { classifySkillDenial } from "@/lib/skill-policy-denial"; import { agentsApi } from "@/api/agents"; import { companySkillsApi } from "@/api/companySkills"; import { issuesApi } from "@/api/issues"; import { queryKeys } from "@/lib/queryKeys"; import { copyTextToClipboard } from "@/lib/clipboard"; import { useCopyToast } from "@/lib/use-copy-action"; import { skillStudioNewRoute, skillStudioRoute } from "@/lib/company-skill-routes"; import { buildBlankSkillDraft, buildForkSkillDraft, defaultSkillMarkdown, normalizeSkillDraftSlug, SKILL_CREATE_ACCENTS, skillAccentColor, skillCreateDraftToPayload, splitCategoryDraft, type SkillCreateDraft, } from "@/lib/skill-create"; import { getRecentStudioSkillIds, trackRecentStudioSkill } from "@/lib/recent-skills"; import { AgentsUsingSkillBadge } from "@/components/skill-studio/AgentsUsingSkillDialog"; import { ForkSkillDialog } from "@/components/skill-studio/ForkSkillDialog"; import { ProjectScanNotice, SkillLineageChip, } from "@/components/skill-studio/SkillProvenance"; import { isProjectScanSkill } from "@/lib/skill-fork"; import { cn, formatCents, relativeTime } from "@/lib/utils"; import { SkillCardIcon, type DiscoveryCard } from "./CompanySkills"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable-panels"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { FileTree, buildFileTree, type FileTreeNode } from "@/components/FileTree"; import { MarkdownEditor } from "@/components/MarkdownEditor"; import { FrontmatterPanel } from "@/components/FrontmatterPanel"; import { joinFrontmatterBlock, splitFrontmatterBlock } from "@paperclipai/shared"; import { MarkdownBody } from "@/components/MarkdownBody"; import { StatusBadge } from "@/components/StatusBadge"; import { EmptyState } from "@/components/EmptyState"; import { EntityRow } from "@/components/EntityRow"; import { FilterBar } from "@/components/FilterBar"; import { Identity } from "@/components/Identity"; import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard"; import { IssueAttachmentsSection } from "@/components/IssueAttachmentsSection"; import { ImageGalleryModal, type GalleryMediaItem } from "@/components/ImageGalleryModal"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { IssueOutputSection } from "@/components/issue-output/IssueOutputSection"; import { buildLineDiff } from "@/lib/line-diff"; import { buildCreateRunRequest, buildReRunRequest, DEFAULT_TEST_RUN_TEMPLATE_ID, EMPTY_SAVED_INPUT_DRAFT_STATE, evaluateRunGate, getRunAdditionalDocuments, getRunMediaGalleryItems, getRunRawAttachments, isAgentSelectable, isInteractionAnswerable, isTerminalRunStatus, orderRecentlyUpdatedSkills, orderRecentlyVisitedSkills, skillEditorAvatar, NO_TEST_RUN_TEMPLATE_STORAGE_VALUE, parseRunTemplateSelection, routeInteraction, runBadgeStatus, runHarnessUnavailableCopy, runOutputMode, runShortId, resolveRunTemplateSelection, serializeRunTemplateSelection, savedInputDraftDirty, selectedSavedInputDraft, shouldPollRun, showRunErrorCard, syncSavedInputDraftState, testTaskLinkState, type RunTemplateSelection, type SavedInputDraftState, } from "@/lib/skill-studio"; const PANE_STORAGE_KEY = "skillStudio.paneSizes"; const RUN_TEMPLATE_STORAGE_KEY_PREFIX = "skillStudio.runTemplate"; const MOBILE_BREAKPOINT = 900; const POLL_MS = 2000; const EMPTY_RUN_TEMPLATES: CompanySkillTestRunTemplate[] = []; /** * Surface a mutation rejection as an error toast. Every Studio mutation routes * failures through here so server rejections (409 agent_not_assignable, 422 * read-only, …) never get silently swallowed (PAP-13001). */ function useMutationErrorToast() { const toast = useOptionalToastActions(); return useCallback( (title: string) => (error: unknown) => { // Under the open default there is no permission chrome. When an action is // actually denied — by an explicit company policy (State B) or a platform // safety invariant (State C) — show the actionable denial title/remediation // instead of a generic "try again" error (§9.10, PAP-13865). Transient // failures keep the plain error toast. const denial = classifySkillDenial(error); if (denial) { toast?.pushToast({ tone: "warn", title: denial.title, body: denial.remediation }); return; } const body = error instanceof Error && error.message ? error.message : "Please try again."; toast?.pushToast({ tone: "error", title, body }); }, [toast], ); } // --------------------------------------------------------------------------- // Pane-size persistence (contract: persist per user `skillStudio.paneSizes`) // --------------------------------------------------------------------------- type PaneLayout = { skill: number; input: number; runs: number }; const DEFAULT_LAYOUT: PaneLayout = { skill: 37.5, input: 25, runs: 37.5 }; function loadPaneLayout(): PaneLayout { try { const raw = localStorage.getItem(PANE_STORAGE_KEY); if (!raw) return DEFAULT_LAYOUT; const parsed = JSON.parse(raw) as Partial; if ( typeof parsed?.skill === "number" && typeof parsed?.input === "number" && typeof parsed?.runs === "number" ) { return { skill: parsed.skill, input: parsed.input, runs: parsed.runs }; } } catch { /* ignore malformed persisted layout */ } return DEFAULT_LAYOUT; } function runTemplateStorageKey(companyId: string) { return `${RUN_TEMPLATE_STORAGE_KEY_PREFIX}.${companyId}`; } function loadRunTemplateSelection(companyId: string): RunTemplateSelection { try { return parseRunTemplateSelection(localStorage.getItem(runTemplateStorageKey(companyId))); } catch { return DEFAULT_TEST_RUN_TEMPLATE_ID; } } function persistRunTemplateSelection(companyId: string, selection: RunTemplateSelection) { try { localStorage.setItem(runTemplateStorageKey(companyId), serializeRunTemplateSelection(selection)); } catch { /* storage may be unavailable (private mode) — non-fatal */ } } function useIsMobile() { const [isMobile, setIsMobile] = useState( () => typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT, ); useEffect(() => { const onResize = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); return isMobile; } // --------------------------------------------------------------------------- // Root // --------------------------------------------------------------------------- export function SkillStudio() { const { skillId = "" } = useParams<{ skillId: string }>(); const location = useLocation(); const [searchParams] = useSearchParams(); const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); const companyId = selectedCompanyId ?? ""; const isCreateMode = location.pathname.replace(/\/+$/, "").endsWith("/skills/studio/new"); const forkFromSkillId = isCreateMode ? searchParams.get("forkFrom")?.trim() || null : null; // New skills created from a folder context (e.g. My Skills) carry their // destination folder through this query param; without it the created skill // silently lands in Unfiled (PAP-14086). const newSkillFolderId = isCreateMode ? searchParams.get("folderId")?.trim() || null : null; const skillsQuery = useQuery({ queryKey: queryKeys.companySkills.list(companyId), queryFn: () => companySkillsApi.list(companyId, { sort: "alphabetical" }), enabled: Boolean(companyId), }); const detailQuery = useQuery({ queryKey: queryKeys.companySkills.detail(companyId, skillId), queryFn: () => companySkillsApi.detail(companyId, skillId), enabled: Boolean(companyId && skillId && !isCreateMode), }); const forkDetailQuery = useQuery({ queryKey: queryKeys.companySkills.detail(companyId, forkFromSkillId ?? ""), queryFn: () => companySkillsApi.detail(companyId, forkFromSkillId!), enabled: Boolean(companyId && forkFromSkillId), }); const skill = detailQuery.data ?? null; useEffect(() => { setBreadcrumbs( isCreateMode ? [ { label: "Skills", href: "/skills" }, { label: "Studio", href: "/skills/studio" }, { label: "New skill" }, ] : skill ? [ { label: "Skills", href: "/skills" }, { label: "Studio", href: "/skills/studio" }, { label: skill.name }, ] : [ { label: "Skills", href: "/skills" }, { label: "Studio" }, ], ); }, [isCreateMode, setBreadcrumbs, skill]); // Record a per-browser visit whenever a skill successfully opens, powering the // landing's "Recently visited" section (PAP-13150). useEffect(() => { if (skill?.id) trackRecentStudioSkill(skill.id); }, [skill?.id]); if (!companyId) { return ; } if (isCreateMode) { return ( navigate(skillStudioRoute(nextSkillId))} /> ); } if (!skillId) { return ( navigate(skillStudioRoute(nextSkillId))} onCreateNew={() => navigate(skillStudioNewRoute())} /> ); } if (detailQuery.isLoading) { return ; } if (detailQuery.isError || !detailQuery.data) { return ; } return ( ); } function StudioCreateMode({ companyId, skills, skillsLoading, forkFromSkillId, folderId, forkSkill, forkLoading, forkError, onSelectSkill, }: { companyId: string; skills: CompanySkillListItem[]; skillsLoading: boolean; forkFromSkillId: string | null; folderId: string | null; forkSkill: CompanySkillDetail | null; forkLoading: boolean; forkError: boolean; onSelectSkill: (skillId: string) => void; }) { return (
); } function StudioNewSkillPanel({ companyId, forkFromSkillId, folderId, forkSkill, forkLoading, forkError, }: { companyId: string; forkFromSkillId: string | null; folderId: string | null; forkSkill: CompanySkillDetail | null; forkLoading: boolean; forkError: boolean; }) { const navigate = useNavigate(); const queryClient = useQueryClient(); const toast = useOptionalToastActions(); const initialDraft = useMemo(() => { const base = forkSkill ? buildForkSkillDraft(forkSkill) : buildBlankSkillDraft(); // An explicit folder context from the URL wins over a fork source's folder // so the new skill is filed where the user launched creation (PAP-14086). return folderId ? { ...base, folderId } : base; }, [forkSkill, folderId]); const [draft, setDraft] = useState(initialDraft); const [slugDirty, setSlugDirty] = useState(initialDraft.slug.trim().length > 0); const [categoryDraft, setCategoryDraft] = useState(initialDraft.categories.join(", ")); const parsedCategories = splitCategoryDraft(categoryDraft); const effectiveSlug = draft.slug.trim() || normalizeSkillDraftSlug(draft.name); const nameValid = draft.name.trim().length > 0; useEffect(() => { setDraft(initialDraft); setSlugDirty(initialDraft.slug.trim().length > 0); setCategoryDraft(initialDraft.categories.join(", ")); }, [initialDraft]); function patchDraft(patch: Partial) { setDraft((current) => ({ ...current, ...patch })); } const createSkill = useMutation({ mutationFn: () => companySkillsApi.create(companyId, skillCreateDraftToPayload({ ...draft, categories: parsedCategories, })), onSuccess: async (skill) => { await queryClient.invalidateQueries({ queryKey: queryKeys.companySkills.list(companyId) }); toast?.pushToast({ tone: "success", title: skill.forkedFromSkillId ? "Skill fork created" : "Skill created", body: `${skill.name} is now editable in the Paperclip workspace.`, }); navigate(skillStudioRoute(skill.id)); }, onError: (error) => { toast?.pushToast({ tone: "error", title: "Skill creation failed", body: error instanceof Error ? error.message : "Failed to create skill.", }); }, }); if (forkFromSkillId && forkLoading) { return ; } const previewCard: DiscoveryCard = { key: effectiveSlug || draft.name || "new-skill", skillId: null, catalogRef: null, name: draft.name || "New Skill", slug: effectiveSlug || "skill", author: "you", version: null, tagline: draft.tagline || null, description: draft.tagline, categories: parsedCategories, iconUrl: null, color: draft.color, starCount: 0, agentCount: 0, forkCount: 0, installed: false, required: false, forkedFrom: Boolean(draft.forkedFromSkillId), updatedAt: Date.now(), }; return (

{draft.forkedFromSkillId ? "Fork skill" : "Create a new skill"}

Create an editable organization skill and open it directly in Studio.

{draft.forkedFromName ? (
Forking {draft.forkedFromName}
) : forkError ? (
Fork source not found. You can still create a blank skill.
) : null}

Basics

Name the skill and set the route-safe slug.

{ const nextName = event.target.value; patchDraft({ name: nextName, slug: slugDirty ? draft.slug : normalizeSkillDraftSlug(nextName), markdown: draft.markdown === defaultSkillMarkdown(draft.name, draft.tagline) ? defaultSkillMarkdown(nextName, draft.tagline) : draft.markdown, }); }} placeholder="Code review" />
{ const nextSlug = normalizeSkillDraftSlug(event.target.value); setSlugDirty(nextSlug.length > 0); patchDraft({ slug: nextSlug }); }} placeholder="code-review" className="font-mono" />