Build the Skills Store (#7990)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents increasingly depend on reusable skills, so the control plane
needs a first-class way to browse, inspect, install, version, and attach
those skills.
> - The old skills surface was mostly operational plumbing; it did not
give operators a store-like discovery flow, canonical detail URLs, rich
source/version context, or creation paths.
> - The backend also needed stronger contracts around company skill
metadata, versions, install counts, runtime materialization, and adapter
skill preferences.
> - This pull request builds the Skills Store foundation across DB,
shared contracts, server routes/services, UI, and Storybook.
> - The benefit is a more inspectable, operator-friendly skill workflow
that still preserves company-scoped control-plane boundaries and agent
runtime behavior.

## Linked Issues or Issue Description

No GitHub issue exists for this Paperclip work item. Paperclip task
refs: PAP-10846 and PAP-10921.

Feature request:
Paperclip operators need a single Skills Store experience where company
skills can be discovered, inspected, created, versioned, installed, and
attached to agents without relying on scattered operational screens or
implicit runtime state.

Related PR search:
- Searched GitHub for `Skills Store`, `company skills`, and `skill
detail`.
- Found several open skills-related PRs such as #7809 and #4409, but no
duplicate PR for this end-to-end Skills Store branch.

## What Changed

- Added the Skills Store backend foundation: company skill schema
fields, migrations, shared types/validators, and expanded server skill
routes/services.
- Added skill discovery, category navigation, canonical skill detail
routes, tabs, source attribution, version snapshots/diffs, install count
backfill, and creation flows.
- Updated agent skill preference handling so version selections survive
runtime mention injection and runtime skill materialization honors
pinned versions.
- Preserved unversioned skill assignments as live/current selections
instead of silently pinning them to the current version at assignment
time.
- Added focused regression coverage for company skill routes/services,
route helpers, UI behavior, skill version diffs, and runtime skill
version pins.
- Added Storybook coverage for Skills Store discovery/detail states and
updated the main layout navigation.
- Addressed Greptile findings around version creation races,
soft-deleted comments, fork metadata scoping, GitHub skill directory
fallback, runtime snapshot materialization, shared runtime
skill-selection helpers, and version-assignment semantics.

## Verification

- `pnpm exec vitest run
server/src/__tests__/heartbeat-project-env.test.ts
server/src/__tests__/heartbeat-runtime-skills.test.ts`
- `pnpm exec vitest run
packages/shared/src/validators/company-skill.test.ts`
- `pnpm exec vitest run server/src/__tests__/company-portability.test.ts
server/src/__tests__/company-skills-service.test.ts`
- `pnpm exec vitest run
cli/src/__tests__/company-import-export-e2e.test.ts`
- `pnpm exec vitest run
server/src/__tests__/agent-skills-routes.test.ts`
- `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts`
- `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts
server/src/__tests__/company-skills-routes.test.ts
server/src/__tests__/heartbeat-runtime-skills.test.ts`
- `pnpm exec vitest run
server/src/__tests__/company-skills-service.test.ts`
- `pnpm exec vitest run ui/src/components/IssueProperties.test.tsx -t
"edits existing custom assignee model options from the properties pane"`
- `pnpm --filter @paperclipai/server typecheck`
- GitHub checks are green on `0823957a2`: Build, Canary Dry Run, General
tests, Typecheck + Release Registry, serialized server suites, e2e,
policy/review, Socket, Snyk, and aggregate `verify`.
- Greptile Review succeeded on `0823957a2` with `40 files reviewed, 0
comments added`; GitHub unresolved review threads: 0.

Not run in this heartbeat:
- Browser screenshot capture for the UI changes. This PR intentionally
omits screenshots per the Paperclip task direction not to add design
screenshots/images.

## Risks

- Broad feature branch touching DB, shared contracts, server, and UI;
reviewers should still scan merge conflicts carefully if `master` moves
again before landing.
- Skill version/runtime behavior is sensitive: pinned skill versions
must stay pinned while default selections should continue following the
current version.
- UI polish should get normal reviewer/browser attention before merge
because this PR includes a large Skills Store surface and screenshots
were intentionally omitted.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5-based coding agent with tool use and local command
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots (intentionally omitted per PAP-10921 direction)
- [ ] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-06-11 14:02:09 -05:00
committed by GitHub
parent 69a368ed51
commit 1413729a06
40 changed files with 6846 additions and 492 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
import type {
Agent,
AgentDesiredSkillEntry,
AgentPermissions,
AgentDetail,
AgentInstructionsBundle,
@@ -174,7 +175,7 @@ export const agentsApi = {
listKeys: (id: string, companyId?: string) => api.get<AgentKey[]>(agentPath(id, companyId, "/keys")),
skills: (id: string, companyId?: string) =>
api.get<AgentSkillSnapshot>(agentPath(id, companyId, "/skills")),
syncSkills: (id: string, desiredSkills: string[], companyId?: string) =>
syncSkills: (id: string, desiredSkills: Array<string | AgentDesiredSkillEntry>, companyId?: string) =>
api.post<AgentSkillSnapshot>(agentPath(id, companyId, "/skills/sync"), { desiredSkills }),
createKey: (id: string, name: string, companyId?: string) =>
api.post<AgentKeyCreated>(agentPath(id, companyId, "/keys"), { name }),
+71 -2
View File
@@ -3,16 +3,26 @@ import type {
CatalogSkillFileDetail,
CatalogSkillKind,
CompanySkill,
CompanySkillCategoryCount,
CompanySkillComment,
CompanySkillCommentCreateRequest,
CompanySkillCommentUpdateRequest,
CompanySkillCreateRequest,
CompanySkillDetail,
CompanySkillFileDetail,
CompanySkillForkRequest,
CompanySkillImportResult,
CompanySkillInstallCatalogRequest,
CompanySkillInstallCatalogResult,
CompanySkillListQuery,
CompanySkillListItem,
CompanySkillProjectScanRequest,
CompanySkillProjectScanResult,
CompanySkillStarResult,
CompanySkillUpdateRequest,
CompanySkillUpdateStatus,
CompanySkillVersion,
CompanySkillVersionCreateRequest,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -23,12 +33,66 @@ export interface CatalogListQuery {
}
export const companySkillsApi = {
list: (companyId: string) =>
api.get<CompanySkillListItem[]>(`/companies/${encodeURIComponent(companyId)}/skills`),
list: (companyId: string, query: CompanySkillListQuery = {}) => {
const params = new URLSearchParams();
if (query.q) params.set("q", query.q);
if (query.sort) params.set("sort", query.sort);
if (query.scope) params.set("scope", query.scope);
for (const category of query.categories ?? []) params.append("categories[]", category);
const search = params.toString();
return api.get<CompanySkillListItem[]>(`/companies/${encodeURIComponent(companyId)}/skills${search ? `?${search}` : ""}`);
},
categories: (companyId: string) =>
api.get<CompanySkillCategoryCount[]>(`/companies/${encodeURIComponent(companyId)}/skills/categories`),
detail: (companyId: string, skillId: string) =>
api.get<CompanySkillDetail>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
),
versions: (companyId: string, skillId: string) =>
api.get<CompanySkillVersion[]>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions`,
),
version: (companyId: string, skillId: string, versionId: string) =>
api.get<CompanySkillVersion>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions/${encodeURIComponent(versionId)}`,
),
createVersion: (companyId: string, skillId: string, payload: CompanySkillVersionCreateRequest = {}) =>
api.post<CompanySkillVersion>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/versions`,
payload,
),
star: (companyId: string, skillId: string) =>
api.post<CompanySkillStarResult>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/star`,
{},
),
unstar: (companyId: string, skillId: string) =>
api.delete<CompanySkillStarResult>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/star`,
),
fork: (companyId: string, skillId: string, payload: CompanySkillForkRequest = {}) =>
api.post<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/fork`,
payload,
),
comments: (companyId: string, skillId: string) =>
api.get<CompanySkillComment[]>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments`,
),
createComment: (companyId: string, skillId: string, payload: CompanySkillCommentCreateRequest) =>
api.post<CompanySkillComment>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments`,
payload,
),
updateComment: (companyId: string, skillId: string, commentId: string, payload: CompanySkillCommentUpdateRequest) =>
api.patch<CompanySkillComment>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments/${encodeURIComponent(commentId)}`,
payload,
),
deleteComment: (companyId: string, skillId: string, commentId: string) =>
api.delete<CompanySkillComment>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/comments/${encodeURIComponent(commentId)}`,
),
updateStatus: (companyId: string, skillId: string) =>
api.get<CompanySkillUpdateStatus>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}/update-status`,
@@ -47,6 +111,11 @@ export const companySkillsApi = {
`/companies/${encodeURIComponent(companyId)}/skills`,
payload,
),
update: (companyId: string, skillId: string, payload: CompanySkillUpdateRequest) =>
api.patch<CompanySkill>(
`/companies/${encodeURIComponent(companyId)}/skills/${encodeURIComponent(skillId)}`,
payload,
),
importFromSource: (companyId: string, source: string) =>
api.post<CompanySkillImportResult>(
`/companies/${encodeURIComponent(companyId)}/skills/import`,
+12 -3
View File
@@ -78,6 +78,9 @@ export function Layout() {
const location = useLocation();
const navigationType = useNavigationType();
const isCompanySettingsRoute = location.pathname.includes("/company/settings");
// The Skills Store renders its own secondary (category) sidebar, so the main
// app nav collapses to its rail throughout the /skills section (PAP-10879).
const isSkillsRoute = /(^|\/)skills(\/|$)/.test(location.pathname);
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const previousPathname = useRef<string | null>(null);
@@ -148,10 +151,11 @@ export function Layout() {
// is active, but does NOT mutate the persisted preference. Clearing the force
// on cleanup restores the user's expanded/collapsed choice when navigating
// off the takeover route (PAP-10694).
const forceRailCollapsed = hasSecondarySidebar || isSkillsRoute;
useLayoutEffect(() => {
setForceCollapsed(hasSecondarySidebar);
setForceCollapsed(forceRailCollapsed);
return () => setForceCollapsed(false);
}, [hasSecondarySidebar, setForceCollapsed]);
}, [forceRailCollapsed, setForceCollapsed]);
useEffect(() => {
if (companiesLoading || onboardingTriggered.current) return;
@@ -536,7 +540,12 @@ export function Layout() {
tabIndex={-1}
className={cn(
"flex-1 p-4 outline-none md:p-6",
isMobile ? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]" : "overflow-auto",
// Reserve the scrollbar gutter on desktop so pages whose height
// changes (e.g. switching skill-detail tabs) don't widen/shift
// when the vertical scrollbar appears or disappears (PAP-10907).
isMobile
? "overflow-visible pb-[calc(5rem+env(safe-area-inset-bottom))]"
: "overflow-auto [scrollbar-gutter:stable]",
)}
>
{hasUnknownCompanyPrefix ? (
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import {
canonicalSkillRouteToken,
parseSkillRoute,
resolveSkillRouteToken,
skillRoute,
type CompanySkillRouteSubject,
} from "./company-skill-routes";
function skill(id: string, slug: string, key = slug): CompanySkillRouteSubject {
return { id, slug, key };
}
const deepResearch = skill("11111111-1111-4111-8111-111111111111", "deep-research", "paperclip/deep-research");
const scopedDeepResearch = skill("22222222-2222-4222-8222-222222222222", "deep-research", "community/deep-research");
const browser = skill("33333333-3333-4333-8333-333333333333", "browser", "paperclip/browser");
describe("company skill routes", () => {
it("builds unique slug detail and file routes", () => {
expect(canonicalSkillRouteToken(browser, [deepResearch, scopedDeepResearch, browser])).toBe("browser");
expect(skillRoute(browser, [deepResearch, scopedDeepResearch, browser])).toBe("/skills/browser");
expect(skillRoute(browser, [browser], "references/setup guide.md")).toBe(
"/skills/browser/files/references/setup%20guide.md",
);
});
it("uses a unique key to disambiguate colliding slugs", () => {
const skills = [deepResearch, scopedDeepResearch, browser];
expect(canonicalSkillRouteToken(deepResearch, skills)).toBe("paperclip/deep-research");
expect(canonicalSkillRouteToken(scopedDeepResearch, skills)).toBe("community/deep-research");
expect(skillRoute(deepResearch, skills)).toBe("/skills/paperclip/deep-research");
});
it("falls back to a slug plus short id when the key is not route-safe", () => {
const unsafe = skill("44444444-4444-4444-8444-444444444444", "deep-research", "paperclip/files/deep-research");
const skills = [deepResearch, unsafe];
expect(canonicalSkillRouteToken(unsafe, skills)).toBe("deep-research-44444444");
expect(skillRoute(unsafe, skills)).toBe("/skills/deep-research-44444444");
});
it("resolves legacy UUID URLs and asks callers to redirect to the canonical token", () => {
const resolution = resolveSkillRouteToken(browser.id, [browser]);
expect(resolution.skill?.id).toBe(browser.id);
expect(resolution.canonicalToken).toBe("browser");
expect(resolution.shouldRedirect).toBe(true);
});
it("resolves canonical key URLs for colliding slugs", () => {
const skills = [deepResearch, scopedDeepResearch];
const resolution = resolveSkillRouteToken("community/deep-research", skills);
expect(resolution.skill?.id).toBe(scopedDeepResearch.id);
expect(resolution.shouldRedirect).toBe(false);
expect(resolution.ambiguous).toBe(false);
});
it("leaves ambiguous bare slug URLs unresolved", () => {
const resolution = resolveSkillRouteToken("deep-research", [deepResearch, scopedDeepResearch]);
expect(resolution.skill).toBeNull();
expect(resolution.ambiguous).toBe(true);
});
it("parses token paths and encoded file paths", () => {
expect(parseSkillRoute("paperclip/deep-research/files/references/setup%20guide.md")).toEqual({
skillToken: "paperclip/deep-research",
filePath: "references/setup guide.md",
});
expect(parseSkillRoute(undefined)).toEqual({ skillToken: null, filePath: "SKILL.md" });
});
});
+192
View File
@@ -0,0 +1,192 @@
import type { CompanySkill, CompanySkillDetail, CompanySkillListItem } from "@paperclipai/shared";
export type CompanySkillRouteSubject = Pick<CompanySkill | CompanySkillDetail | CompanySkillListItem, "id" | "key" | "slug">;
export type ParsedCompanySkillRoute = {
skillToken: string | null;
filePath: string;
};
export type CompanySkillRouteResolution = {
skill: CompanySkillRouteSubject | null;
canonicalToken: string | null;
shouldRedirect: boolean;
ambiguous: boolean;
};
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function decodeRouteSegment(segment: string) {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
function encodeRoutePath(value: string) {
return value.split("/").map((segment) => encodeURIComponent(segment)).join("/");
}
export function encodeSkillFilePath(filePath: string) {
return encodeRoutePath(filePath);
}
export function decodeSkillFilePath(filePath: string | undefined) {
if (!filePath) return "SKILL.md";
return filePath
.split("/")
.filter(Boolean)
.map(decodeRouteSegment)
.join("/");
}
function decodeSkillRouteToken(tokenPath: string | undefined) {
if (!tokenPath) return null;
const token = tokenPath
.split("/")
.filter(Boolean)
.map(decodeRouteSegment)
.join("/");
return token.length > 0 ? token : null;
}
export function parseSkillRoute(routePath: string | undefined): ParsedCompanySkillRoute {
const segments = (routePath ?? "").split("/").filter(Boolean);
if (segments.length === 0) {
return { skillToken: null, filePath: "SKILL.md" };
}
const filesIndex = segments.indexOf("files");
const tokenSegments = filesIndex >= 0 ? segments.slice(0, filesIndex) : segments;
const skillToken = decodeSkillRouteToken(tokenSegments.join("/"));
if (!skillToken) {
return { skillToken: null, filePath: "SKILL.md" };
}
return {
skillToken,
filePath: filesIndex >= 0 ? decodeSkillFilePath(segments.slice(filesIndex + 1).join("/")) : "SKILL.md",
};
}
function shortSkillId(skill: CompanySkillRouteSubject) {
return skill.id.replace(/-/g, "").slice(0, 8);
}
function slugShortIdToken(skill: CompanySkillRouteSubject) {
const slug = skill.slug.trim();
return `${slug || "skill"}-${shortSkillId(skill)}`;
}
function hasExactlyOneMatch(skills: CompanySkillRouteSubject[], predicate: (skill: CompanySkillRouteSubject) => boolean) {
return skills.filter(predicate).length === 1;
}
function isSafeKeyToken(skill: CompanySkillRouteSubject, skills: CompanySkillRouteSubject[]) {
const key = skill.key.trim();
if (!key || key.split("/").includes("files")) return false;
return !skills.some((candidate) =>
candidate.id !== skill.id
&& (
candidate.key === key
|| candidate.slug === key
|| slugShortIdToken(candidate) === key
)
);
}
export function canonicalSkillRouteToken(
skill: CompanySkillRouteSubject,
skills: CompanySkillRouteSubject[] = [],
) {
const slug = skill.slug.trim();
if (slug && hasExactlyOneMatch(skills.length > 0 ? skills : [skill], (candidate) => candidate.slug === slug)) {
return slug;
}
if (isSafeKeyToken(skill, skills)) {
return skill.key.trim();
}
return slugShortIdToken(skill);
}
function uniqueMatch(
skills: CompanySkillRouteSubject[],
predicate: (skill: CompanySkillRouteSubject) => boolean,
) {
const matches = skills.filter(predicate);
if (matches.length !== 1) return { skill: null, ambiguous: matches.length > 1 };
return { skill: matches[0], ambiguous: false };
}
export function resolveSkillRouteToken(
token: string | null,
skills: CompanySkillRouteSubject[],
): CompanySkillRouteResolution {
if (!token) {
return { skill: null, canonicalToken: null, shouldRedirect: false, ambiguous: false };
}
const legacyId = UUID_PATTERN.test(token) ? skills.find((skill) => skill.id === token) ?? null : null;
if (legacyId) {
return {
skill: legacyId,
canonicalToken: canonicalSkillRouteToken(legacyId, skills),
shouldRedirect: true,
ambiguous: false,
};
}
const canonical = uniqueMatch(skills, (skill) => canonicalSkillRouteToken(skill, skills) === token);
if (canonical.skill) {
return { skill: canonical.skill, canonicalToken: token, shouldRedirect: false, ambiguous: false };
}
const bySlug = uniqueMatch(skills, (skill) => skill.slug === token);
if (bySlug.skill) {
const canonicalToken = canonicalSkillRouteToken(bySlug.skill, skills);
return {
skill: bySlug.skill,
canonicalToken,
shouldRedirect: canonicalToken !== token,
ambiguous: false,
};
}
if (bySlug.ambiguous) {
return { skill: null, canonicalToken: null, shouldRedirect: false, ambiguous: true };
}
const byKey = uniqueMatch(skills, (skill) => skill.key === token);
if (byKey.skill) {
const canonicalToken = canonicalSkillRouteToken(byKey.skill, skills);
return {
skill: byKey.skill,
canonicalToken,
shouldRedirect: canonicalToken !== token,
ambiguous: false,
};
}
return { skill: null, canonicalToken: null, shouldRedirect: false, ambiguous: byKey.ambiguous };
}
export function skillRoute(
skill: CompanySkillRouteSubject | string,
skillsOrFilePath: CompanySkillRouteSubject[] | string | null = [],
filePath?: string | null,
) {
const skills = Array.isArray(skillsOrFilePath) ? skillsOrFilePath : [];
const effectiveFilePath = Array.isArray(skillsOrFilePath) ? filePath : skillsOrFilePath;
const token = typeof skill === "string" ? skill : canonicalSkillRouteToken(skill, skills);
const basePath = `/skills/${encodeRoutePath(token)}`;
return effectiveFilePath ? `${basePath}/files/${encodeSkillFilePath(effectiveFilePath)}` : basePath;
}
export function withRouteSkill(
skills: CompanySkillRouteSubject[],
skill: CompanySkillRouteSubject,
) {
return skills.some((candidate) => candidate.id === skill.id) ? skills : [...skills, skill];
}
+2
View File
@@ -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) =>
+276
View File
@@ -0,0 +1,276 @@
// @vitest-environment jsdom
import type { ComponentProps, ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import type { CompanySkillDetail, CompanySkillVersion } from "@paperclipai/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SkillDetailPage, getSkillVersionDiffSelection } from "./CompanySkills";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
<a href={to} {...props}>{children}</a>
),
useNavigate: () => vi.fn(),
useParams: () => ({}),
useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
children,
type = "button",
variant: _variant,
size: _size,
asChild: _asChild,
...props
}: ComponentProps<"button"> & { asChild?: boolean; variant?: string; size?: string }) => (
<button type={type} {...props}>{children}</button>
),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ open, children }: { open?: boolean; children: ReactNode }) => (open ? <div>{children}</div> : null),
DialogContent: ({ children }: { children: ReactNode }) => <div role="dialog">{children}</div>,
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
}));
vi.mock("@/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
DropdownMenuContent: () => null,
DropdownMenuItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
<button type="button" onClick={onSelect}>{children}</button>
),
DropdownMenuLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuRadioGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DropdownMenuRadioItem: ({ children, onSelect }: { children: ReactNode; onSelect?: () => void }) => (
<button type="button" onClick={onSelect}>{children}</button>
),
DropdownMenuSeparator: () => <hr />,
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => <>{children}</>,
PopoverContent: () => null,
PopoverTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
}));
vi.mock("@/components/ui/tabs", () => ({
Tabs: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabsList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabsTrigger: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
}));
vi.mock("@/components/ui/checkbox", () => ({
Checkbox: (props: ComponentProps<"input">) => <input type="checkbox" {...props} />,
}));
vi.mock("../components/MarkdownBody", () => ({
MarkdownBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
vi.mock("../components/MarkdownEditor", () => ({
MarkdownEditor: ({ value }: { value: string }) => <textarea readOnly value={value} />,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | null = null;
let container: HTMLDivElement | null = null;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
afterEach(() => {
root?.unmount();
root = null;
container?.remove();
container = null;
});
function makeVersion(revisionNumber: number, content: string): CompanySkillVersion {
return {
id: `version-${revisionNumber}`,
companyId: "company-1",
companySkillId: "skill-1",
revisionNumber,
label: null,
fileInventory: [
{
path: "SKILL.md",
kind: "skill",
content,
},
],
authorAgentId: null,
authorUserId: null,
createdAt: new Date(`2026-01-0${revisionNumber}T00:00:00Z`),
};
}
function makeDetail(currentVersion: CompanySkillVersion): CompanySkillDetail {
return {
id: "skill-1",
companyId: "company-1",
key: "demo-skill",
slug: "demo-skill",
name: "Demo Skill",
description: "A demo skill.",
markdown: "# Demo Skill",
sourceType: "local_path",
sourceLocator: null,
sourceRef: null,
trustLevel: "markdown_only",
compatibility: "compatible",
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
iconUrl: null,
color: null,
tagline: null,
authorName: null,
homepageUrl: null,
categories: [],
sharingScope: "private",
publicShareToken: null,
forkedFromSkillId: null,
forkedFromCompanyId: null,
starCount: 0,
installCount: 0,
forkCount: 0,
currentVersionId: currentVersion.id,
metadata: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-02T00:00:00Z"),
attachedAgentCount: 0,
usedByAgents: [],
editable: true,
editableReason: null,
sourceLabel: "Local",
sourceBadge: "local",
sourcePath: null,
currentVersion,
starredByCurrentActor: false,
};
}
async function renderSkillDetail(versions: CompanySkillVersion[]) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
<SkillDetailPage
detail={makeDetail(versions[0]!)}
loading={false}
activeTab="versions"
onTabChange={vi.fn()}
selectedPath="SKILL.md"
file={null}
fileLoading={false}
viewMode="preview"
editMode={false}
draft=""
setViewMode={vi.fn()}
setEditMode={vi.fn()}
setDraft={vi.fn()}
onSave={vi.fn()}
savePending={false}
versions={versions}
versionsLoading={false}
attachAgents={[]}
onSubmitAttach={vi.fn()}
attachPending={false}
expandedDirs={new Set()}
onToggleDir={vi.fn()}
onSelectPath={vi.fn()}
updateStatus={null}
updateStatusLoading={false}
onCheckUpdates={vi.fn()}
checkUpdatesPending={false}
onInstallUpdate={vi.fn()}
installUpdatePending={false}
onToggleStar={vi.fn()}
starPending={false}
onFork={vi.fn()}
onUpdateSharingScope={vi.fn()}
updateSharingPending={false}
onDelete={vi.fn()}
deletePending={false}
/>,
);
});
return container;
}
function buttonsNamed(node: ParentNode, name: string) {
return Array.from(node.querySelectorAll("button")).filter((button) => button.textContent?.trim() === name);
}
async function click(button: HTMLButtonElement) {
await act(async () => {
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
describe("getSkillVersionDiffSelection", () => {
it("selects the previous saved revision or the initial baseline for row diffs", () => {
const v1 = makeVersion(1, "first");
const v2 = makeVersion(2, "second");
expect(getSkillVersionDiffSelection([v2, v1], v2.id)).toEqual({
leftVersionId: v1.id,
rightVersionId: v2.id,
});
expect(getSkillVersionDiffSelection([v2, v1], v1.id)).toEqual({
leftVersionId: null,
rightVersionId: v1.id,
});
});
});
describe("SkillDetailPage versions tab", () => {
it("opens per-row version diffs for newest and oldest revisions", async () => {
const v1 = makeVersion(1, "# Demo Skill\n\nFirst line");
const v2 = makeVersion(2, "# Demo Skill\n\nSecond line");
const node = await renderSkillDetail([v2, v1]);
const viewDiffButtons = buttonsNamed(node, "View diff") as HTMLButtonElement[];
expect(viewDiffButtons).toHaveLength(2);
await click(viewDiffButtons[0]!);
let dialog = node.querySelector('[role="dialog"]') as HTMLElement;
let selects = Array.from(dialog.querySelectorAll("select"));
expect(dialog.textContent).toContain("Diff");
expect(selects[0]?.value).toBe(v1.id);
expect(selects[1]?.value).toBe(v2.id);
expect(dialog.textContent).toContain("Second line");
await click(viewDiffButtons[1]!);
dialog = node.querySelector('[role="dialog"]') as HTMLElement;
selects = Array.from(dialog.querySelectorAll("select"));
expect(selects[0]?.value).toBe("");
expect(selects[1]?.value).toBe(v1.id);
expect(dialog.textContent).toContain("Initial");
expect(dialog.textContent).toContain("First line");
expect(dialog.textContent).not.toContain("Both sides are the same version");
});
});
File diff suppressed because it is too large Load Diff