diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 30a8e83774e..790b8813515 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -7,6 +7,8 @@ export const queryKeys = { companySkills: { list: (companyId: string) => ["company-skills", companyId] as const, detail: (companyId: string, skillId: string) => ["company-skills", companyId, skillId] as const, + versions: (companyId: string, skillId: string) => ["company-skills", companyId, skillId, "versions"] as const, + comments: (companyId: string, skillId: string) => ["company-skills", companyId, skillId, "comments"] as const, updateStatus: (companyId: string, skillId: string) => ["company-skills", companyId, skillId, "update-status"] as const, file: (companyId: string, skillId: string, relativePath: string) => diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index da8a690ae4b..40d0a2a9faa 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -2,9 +2,11 @@ import { useEffect, useMemo, useState, type SVGProps } from "react"; import { Link, useNavigate, useParams, useSearchParams } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { + AgentDesiredSkillEntry, Agent, CatalogSkill, CatalogSkillFileDetail, + CompanySkillComment, CompanySkillCompatibility, CompanySkillCreateRequest, CompanySkillDetail, @@ -15,6 +17,7 @@ import type { CompanySkillSourceBadge, CompanySkillTrustLevel, CompanySkillUpdateStatus, + CompanySkillVersion, } from "@paperclipai/shared"; import { companySkillsApi } from "../api/companySkills"; import { agentsApi } from "../api/agents"; @@ -55,7 +58,8 @@ import { } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { cn } from "../lib/utils"; +import { buildLineDiff, type DiffRow } from "../lib/line-diff"; +import { cn, relativeTime } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -81,6 +85,7 @@ import { LayoutGrid, Link2, Lock, + MessageSquare, ExternalLink, Paperclip, Pencil, @@ -93,6 +98,7 @@ import { Star, Trash2, Users, + History, XOctagon, } from "lucide-react"; @@ -1648,6 +1654,8 @@ function AttachAgentsPopover({ onOpenChange, agents, attachedAgentIds, + versions, + selectedVersionId, pending, onSubmit, }: { @@ -1655,21 +1663,26 @@ function AttachAgentsPopover({ onOpenChange: (open: boolean) => void; agents: Array<{ id: string; name: string; adapterType: string; supportsSkills: boolean; required: boolean }>; attachedAgentIds: string[]; + versions: CompanySkillVersion[]; + selectedVersionId: string | null; pending: boolean; - onSubmit: (nextIds: string[]) => void; + onSubmit: (nextIds: string[], versionId: string | null) => void; }) { const [filter, setFilter] = useState(""); const [draft, setDraft] = useState>(new Set(attachedAgentIds)); + const [draftVersionId, setDraftVersionId] = useState(selectedVersionId); useEffect(() => { if (open) { setDraft(new Set(attachedAgentIds)); + setDraftVersionId(selectedVersionId); setFilter(""); } - }, [open, attachedAgentIds]); + }, [open, attachedAgentIds, selectedVersionId]); const filtered = agents.filter((agent) => agent.name.toLowerCase().includes(filter.toLowerCase())); const eligible = agents.filter((agent) => agent.supportsSkills); + const sortedVersions = [...versions].sort((a, b) => b.revisionNumber - a.revisionNumber); return ( @@ -1691,6 +1704,23 @@ function AttachAgentsPopover({ placeholder="Filter agents" className="h-8" /> + {sortedVersions.length > 0 ? ( +
+ Version + +
+ ) : null} {eligible.length === 0 ? (
@@ -1741,7 +1771,7 @@ function AttachAgentsPopover({ -
@@ -1965,6 +1995,666 @@ function SkillList({ ); } +type SkillDetailTab = "overview" | "files" | "versions" | "comments" | "agents"; + +const SKILL_DETAIL_TABS: Array<{ value: SkillDetailTab; label: string; icon: typeof FileText }> = [ + { value: "overview", label: "Overview", icon: FileText }, + { value: "files", label: "Files", icon: FolderOpen }, + { value: "versions", label: "Versions", icon: History }, + { value: "comments", label: "Comments", icon: MessageSquare }, + { value: "agents", label: "Agents", icon: Users }, +]; + +function currentVersionSelection(detail: CompanySkillDetail | null | undefined) { + const selected = detail?.usedByAgents.find((agent) => agent.versionId)?.versionId; + return selected ?? null; +} + +function versionLabel(version: CompanySkillVersion | null | undefined) { + if (!version) return "Latest"; + return `v${version.revisionNumber}${version.label ? ` · ${version.label}` : ""}`; +} + +function SkillVersionDiffDialog({ + open, + onOpenChange, + versions, + leftVersionId, + rightVersionId, + onLeftVersionChange, + onRightVersionChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + versions: CompanySkillVersion[]; + leftVersionId: string | null; + rightVersionId: string | null; + onLeftVersionChange: (id: string | null) => void; + onRightVersionChange: (id: string | null) => void; +}) { + const sorted = [...versions].sort((a, b) => b.revisionNumber - a.revisionNumber); + const left = sorted.find((version) => version.id === leftVersionId) ?? sorted[1] ?? null; + const right = sorted.find((version) => version.id === rightVersionId) ?? sorted[0] ?? null; + const allPaths = useMemo(() => { + const paths = new Set(); + for (const file of left?.fileInventory ?? []) paths.add(file.path); + for (const file of right?.fileInventory ?? []) paths.add(file.path); + return Array.from(paths).sort((a, b) => { + if (a === "SKILL.md") return -1; + if (b === "SKILL.md") return 1; + return a.localeCompare(b); + }); + }, [left, right]); + const [selectedPath, setSelectedPath] = useState("SKILL.md"); + const effectivePath = allPaths.includes(selectedPath) ? selectedPath : allPaths[0] ?? "SKILL.md"; + const leftFile = left?.fileInventory.find((file) => file.path === effectivePath); + const rightFile = right?.fileInventory.find((file) => file.path === effectivePath); + const diffRows = useMemo( + () => buildLineDiff(leftFile?.content ?? "", rightFile?.content ?? ""), + [leftFile?.content, rightFile?.content], + ); + const lineClassesByKind: Record = { + context: "bg-transparent", + removed: "bg-red-500/10 text-red-100", + added: "bg-green-500/10 text-green-100", + }; + const markerByKind: Record = { + context: " ", + removed: "-", + added: "+", + }; + + useEffect(() => { + if (open && allPaths.length > 0 && !allPaths.includes(selectedPath)) { + setSelectedPath(allPaths[0]!); + } + }, [allPaths, open, selectedPath]); + + return ( + + +
+ + Diff · skill files + +
+ + +
+
+
+ +
+ {!left || !right ? ( +
Select two versions to compare.
+ ) : left.id === right.id ? ( +
Both sides are the same version.
+ ) : ( +
+
+ Old + New + + {effectivePath} +
+ {diffRows.map((row, index) => ( +
+ {row.oldLineNumber ?? ""} + {row.newLineNumber ?? ""} + {markerByKind[row.kind]} +
{row.text.length > 0 ? row.text : " "}
+
+ ))} +
+ )} +
+
+
+
+ ); +} + +function SkillDetailPage({ + detail, + loading, + activeTab, + onTabChange, + selectedPath, + file, + fileLoading, + viewMode, + editMode, + draft, + setViewMode, + setEditMode, + setDraft, + onSave, + savePending, + versions, + versionsLoading, + comments, + commentsLoading, + commentDraft, + setCommentDraft, + onCreateComment, + createCommentPending, + relatedSkills, + attachAgents, + attachPopoverOpen, + setAttachPopoverOpen, + onSubmitAttach, + attachPending, + expandedDirs, + onToggleDir, + onSelectPath, + updateStatus, + updateStatusLoading, + onCheckUpdates, + checkUpdatesPending, + onInstallUpdate, + installUpdatePending, + onDelete, + deletePending, +}: { + detail: CompanySkillDetail | null | undefined; + loading: boolean; + activeTab: SkillDetailTab; + onTabChange: (tab: SkillDetailTab) => void; + selectedPath: string; + file: CompanySkillFileDetail | null | undefined; + fileLoading: boolean; + viewMode: "preview" | "code"; + editMode: boolean; + draft: string; + setViewMode: (mode: "preview" | "code") => void; + setEditMode: (value: boolean) => void; + setDraft: (value: string) => void; + onSave: () => void; + savePending: boolean; + versions: CompanySkillVersion[]; + versionsLoading: boolean; + comments: CompanySkillComment[]; + commentsLoading: boolean; + commentDraft: string; + setCommentDraft: (value: string) => void; + onCreateComment: () => void; + createCommentPending: boolean; + relatedSkills: CompanySkillListItem[]; + attachAgents: Array<{ id: string; name: string; adapterType: string; supportsSkills: boolean; required: boolean }>; + attachPopoverOpen: boolean; + setAttachPopoverOpen: (open: boolean) => void; + onSubmitAttach: (ids: string[], versionId: string | null) => void; + attachPending: boolean; + expandedDirs: Set; + onToggleDir: (path: string) => void; + onSelectPath: (path: string) => void; + updateStatus: CompanySkillUpdateStatus | null | undefined; + updateStatusLoading: boolean; + onCheckUpdates: () => void; + checkUpdatesPending: boolean; + onInstallUpdate: () => void; + installUpdatePending: boolean; + onDelete: () => void; + deletePending: boolean; +}) { + const [diffOpen, setDiffOpen] = useState(false); + const sortedVersions = [...versions].sort((a, b) => b.revisionNumber - a.revisionNumber); + const [leftVersionId, setLeftVersionId] = useState(null); + const [rightVersionId, setRightVersionId] = useState(null); + + if (!detail) { + return loading ? : ; + } + + const skill = detail; + const source = sourceMeta(skill.sourceBadge, skill.sourceLabel); + const SourceIcon = source.icon; + const body = file?.markdown ? stripFrontmatter(file.content) : file?.content ?? ""; + const currentPin = shortRef(skill.sourceRef); + const latestPin = shortRef(updateStatus?.latestRef); + const selectedVersion = versions.find((version) => version.id === currentVersionSelection(skill)) ?? null; + + function renderFilesBody() { + return ( +
+ +
+
+
{file?.path ?? selectedPath}
+
+ {file?.markdown && !editMode ? ( +
+ + +
+ ) : null} + {skill.editable && file?.editable ? ( + editMode ? ( + <> + + + + ) : ( + + ) + ) : null} +
+
+ {fileLoading ? ( + + ) : !file ? ( +
Select a file to inspect.
+ ) : editMode && file.editable ? ( + file.markdown ? ( + + ) : ( +