mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
Apply PAP-10907 Skills Store store + detail polish
Store cards / discovery grid: - Bundled tab no longer appends "(required)"; card badge reads "Bundled" - Card stats reordered to installed-agents · stars · forks, hiding stars and forks when zero - Hover border-highlight (border-primary) on cards - Missing descriptions render a reserved blank line so cards align - Installed badge moved ahead of the other tags, checkmark dropped - Category sidebar scrolls independently of the results pane on desktop - Search field height/typography matched to the sort/action buttons (and 16px font on mobile to stop iOS zoom) Skill detail page: - Source icon moved after the H1 with a "Installed from …" tooltip - Top-level description capped at max-w-2xl - Installs · stars · fork relocated to a GitHub-style top-right cluster; stars and fork are buttons, installs is a stat - Remove moved into the sidebar Sharing section, gated on edit permission - Overview/Files/Versions/Agents tabs given a visible underline - Comments tab removed (and its dead queries/mutations/component) - Agents tab lists only attached agents with a full "Add to agent" button - Sidebar "Add to agent" is now a big button opening the multi-selector, with a preview of up to 3 attached agents (icon + name, "and n more") - Multi-selector sorts checked agents up top and shows agent icon + paused - Related skills section removed Adds a Storybook story (Skills Store/Skill detail) covering the detail page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
3d377ea1f9
commit
d4cd147a33
2 files changed
+426
-347
No files matched your search
+228
-347
@@ -6,7 +6,6 @@ import type {
|
||||
Agent,
|
||||
CatalogSkill,
|
||||
CatalogSkillFileDetail,
|
||||
CompanySkillComment,
|
||||
CompanySkillCompatibility,
|
||||
CompanySkillCreateRequest,
|
||||
CompanySkillDetail,
|
||||
@@ -32,6 +31,7 @@ import { MarkdownEditor } from "../components/MarkdownEditor";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { CopyText } from "../components/CopyText";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { useAdapterCapabilities } from "../adapters/use-adapter-capabilities";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -86,9 +86,9 @@ import {
|
||||
LayoutGrid,
|
||||
Link2,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
ExternalLink,
|
||||
Paperclip,
|
||||
Pause,
|
||||
Pencil,
|
||||
Plus,
|
||||
Copy,
|
||||
@@ -537,7 +537,9 @@ function cardDescriptionText(raw: string | null | undefined): string {
|
||||
const cleaned = (raw ?? "")
|
||||
.replace(/^[\s>#*_\-`>]+/, "")
|
||||
.trim();
|
||||
return cleaned.length >= 3 ? cleaned : "No description yet.";
|
||||
// Empty descriptions render as a blank line on cards so spacing stays
|
||||
// consistent across the grid (PAP-10907).
|
||||
return cleaned.length >= 3 ? cleaned : "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -823,7 +825,7 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
|
||||
type="button"
|
||||
onClick={() => onOpen(card)}
|
||||
className={cn(
|
||||
"group flex h-full min-h-[11.5rem] flex-col rounded-md border border-border p-4 text-left transition-colors hover:border-foreground/30 hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"group flex h-full min-h-[11.5rem] flex-col rounded-md border border-border p-4 text-left transition-colors hover:border-primary hover:bg-accent/30 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
card.required && "bg-muted/30",
|
||||
)}
|
||||
>
|
||||
@@ -844,38 +846,41 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mt-2 line-clamp-2 text-xs text-muted-foreground">
|
||||
{/* Always reserve two lines so cards line up even without a description. */}
|
||||
<p className="mt-2 line-clamp-2 min-h-8 text-xs text-muted-foreground">
|
||||
{cardDescriptionText(card.description)}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto pt-3">
|
||||
{/* Stats: installed agents · stars · forks — stars/forks only when > 0. */}
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{card.required ? (
|
||||
<span>{card.agentCount} agents using</span>
|
||||
) : (
|
||||
<span>{card.agentCount} {card.agentCount === 1 ? "agent" : "agents"}</span>
|
||||
{card.starCount > 0 ? (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<SkillStat icon={Star} value={String(card.starCount)} />
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{card.agentCount} agents</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<SkillStat icon={GitFork} value={`${card.forkCount}`} />
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
{card.forkCount > 0 ? (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<SkillStat icon={GitFork} value={String(card.forkCount)} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1">
|
||||
{card.installed ? (
|
||||
<span className="inline-flex items-center rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-300">
|
||||
Installed
|
||||
</span>
|
||||
) : null}
|
||||
{card.categories.slice(0, 2).map((category) => (
|
||||
<SkillCategoryChip key={category} label={category} />
|
||||
))}
|
||||
<span className="ml-auto" />
|
||||
{card.required ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/60 px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
<span className="ml-auto inline-flex items-center gap-1 rounded-full border border-border bg-muted/60 px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
<Lock className="h-3 w-3" aria-hidden="true" />
|
||||
Required
|
||||
</span>
|
||||
) : card.installed ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[10px] text-emerald-300">
|
||||
<Check className="h-3 w-3" aria-hidden="true" />
|
||||
Installed
|
||||
Bundled
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -976,10 +981,13 @@ export function DiscoveryGrid({
|
||||
scanStatus: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-12rem)]">
|
||||
// On desktop the store is bounded to the viewport so the category sidebar
|
||||
// and the results pane each scroll independently (PAP-10907). Mobile keeps
|
||||
// the natural page flow.
|
||||
<div className="flex min-h-[calc(100vh-12rem)] md:h-[calc(100dvh-6rem)] md:min-h-0 md:overflow-hidden">
|
||||
{/* Secondary category sidebar — the main app nav collapses to a rail while
|
||||
this is present (handled in Layout). */}
|
||||
<aside className="hidden w-60 shrink-0 flex-col border-r border-border md:flex">
|
||||
<aside className="hidden w-60 shrink-0 flex-col overflow-hidden border-r border-border md:flex">
|
||||
<div className="border-b border-border px-4 py-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">Skills Store</h2>
|
||||
<p className="text-xs text-muted-foreground">Discover, install, fork, share</p>
|
||||
@@ -987,7 +995,7 @@ export function DiscoveryGrid({
|
||||
<div className="px-4 pb-1 pt-3 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Categories
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto pb-4">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pb-4">
|
||||
<CategoryNav
|
||||
categories={categories}
|
||||
total={categoryTotal}
|
||||
@@ -997,16 +1005,16 @@ export function DiscoveryGrid({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Search + sort + actions */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3">
|
||||
<div className="flex min-w-[12rem] flex-1 items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
|
||||
<div className="flex h-9 min-w-[12rem] flex-1 items-center gap-2 rounded-md border border-border px-2.5">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => onSearchChange(event.target.value)}
|
||||
placeholder="Search skills, authors, categories…"
|
||||
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
className="h-full w-full bg-transparent text-base outline-none placeholder:text-muted-foreground sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
@@ -1107,14 +1115,13 @@ export function DiscoveryGrid({
|
||||
<TabsTrigger value="bundled" className="px-3">
|
||||
<span>Bundled</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">{tabCounts.bundled}</span>
|
||||
<span className="ml-1 text-[11px] text-muted-foreground">(required)</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Grid body */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{scanStatus ? <p className="mb-3 text-xs text-muted-foreground">{scanStatus}</p> : null}
|
||||
{loading ? (
|
||||
<PageSkeleton variant="list" />
|
||||
@@ -1958,6 +1965,16 @@ function InstallPreviewDialog({
|
||||
);
|
||||
}
|
||||
|
||||
type AttachAgentOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
adapterType: string;
|
||||
supportsSkills: boolean;
|
||||
required: boolean;
|
||||
icon: string | null;
|
||||
paused: boolean;
|
||||
};
|
||||
|
||||
function AttachAgentsPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -1967,15 +1984,17 @@ function AttachAgentsPopover({
|
||||
selectedVersionId,
|
||||
pending,
|
||||
onSubmit,
|
||||
fullWidth = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
agents: Array<{ id: string; name: string; adapterType: string; supportsSkills: boolean; required: boolean }>;
|
||||
agents: AttachAgentOption[];
|
||||
attachedAgentIds: string[];
|
||||
versions: CompanySkillVersion[];
|
||||
selectedVersionId: string | null;
|
||||
pending: boolean;
|
||||
onSubmit: (nextIds: string[], versionId: string | null) => void;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const [filter, setFilter] = useState("");
|
||||
const [draft, setDraft] = useState<Set<string>>(new Set(attachedAgentIds));
|
||||
@@ -1989,21 +2008,26 @@ function AttachAgentsPopover({
|
||||
}
|
||||
}, [open, attachedAgentIds, selectedVersionId]);
|
||||
|
||||
const filtered = agents.filter((agent) => agent.name.toLowerCase().includes(filter.toLowerCase()));
|
||||
// Checked agents float to the top of the list (PAP-10907); within each group
|
||||
// we keep a stable alphabetical order.
|
||||
const filtered = agents
|
||||
.filter((agent) => agent.name.toLowerCase().includes(filter.toLowerCase()))
|
||||
.sort((a, b) => {
|
||||
const aChecked = draft.has(a.id);
|
||||
const bChecked = draft.has(b.id);
|
||||
if (aChecked !== bChecked) return aChecked ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const eligible = agents.filter((agent) => agent.supportsSkills);
|
||||
const sortedVersions = [...versions].sort((a, b) => b.revisionNumber - a.revisionNumber);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
aria-label="Attach to agents"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Edit
|
||||
</button>
|
||||
<Button size="sm" className={cn(fullWidth && "w-full")}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Add to agent
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="end">
|
||||
<div className="border-b border-border px-3 py-2">
|
||||
@@ -2060,8 +2084,17 @@ function AttachAgentsPopover({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<AgentIcon icon={agent.icon} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{agent.name}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate">{agent.name}</span>
|
||||
{agent.paused ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-amber-500">
|
||||
<Pause className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
Paused
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{agent.adapterType}
|
||||
{agent.required ? " · required" : ""}
|
||||
@@ -2304,13 +2337,12 @@ function SkillList({
|
||||
);
|
||||
}
|
||||
|
||||
type SkillDetailTab = "overview" | "files" | "versions" | "comments" | "agents";
|
||||
type SkillDetailTab = "overview" | "files" | "versions" | "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 },
|
||||
];
|
||||
|
||||
@@ -2463,75 +2495,7 @@ function SkillVersionDiffDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function SkillCommentRow({
|
||||
comment,
|
||||
onUpdate,
|
||||
updatePending,
|
||||
onDelete,
|
||||
deletePending,
|
||||
}: {
|
||||
comment: CompanySkillComment;
|
||||
onUpdate: (body: string) => void;
|
||||
updatePending: boolean;
|
||||
onDelete: () => void;
|
||||
deletePending: boolean;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(comment.body);
|
||||
const deleted = Boolean(comment.deletedAt);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(comment.body);
|
||||
}, [comment.body, editing]);
|
||||
|
||||
return (
|
||||
<article className="border-b border-border py-3 last:border-b-0">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{comment.authorAgentId ? "Agent" : comment.authorUserId ? "Board" : "System"} · {relativeTime(comment.createdAt)}
|
||||
</div>
|
||||
{!deleted ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing((value) => !value)} disabled={updatePending || deletePending}>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
{editing ? "Cancel" : "Edit"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onDelete} disabled={deletePending || updatePending}>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{deleted ? (
|
||||
<MarkdownBody className="prose-sm" softBreaks>_Deleted comment_</MarkdownBody>
|
||||
) : editing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea value={draft} onChange={(event) => setDraft(event.target.value)} className="min-h-24" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(false)} disabled={updatePending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onUpdate(draft.trim());
|
||||
setEditing(false);
|
||||
}}
|
||||
disabled={updatePending || draft.trim().length === 0}
|
||||
>
|
||||
{updatePending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownBody className="prose-sm" softBreaks>{comment.body}</MarkdownBody>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillDetailPage({
|
||||
export function SkillDetailPage({
|
||||
detail,
|
||||
loading,
|
||||
activeTab,
|
||||
@@ -2549,17 +2513,6 @@ function SkillDetailPage({
|
||||
savePending,
|
||||
versions,
|
||||
versionsLoading,
|
||||
comments,
|
||||
commentsLoading,
|
||||
commentDraft,
|
||||
setCommentDraft,
|
||||
onCreateComment,
|
||||
createCommentPending,
|
||||
onUpdateComment,
|
||||
updateCommentPending,
|
||||
onDeleteComment,
|
||||
deleteCommentPending,
|
||||
relatedSkills,
|
||||
attachAgents,
|
||||
attachPopoverOpen,
|
||||
setAttachPopoverOpen,
|
||||
@@ -2599,18 +2552,7 @@ function SkillDetailPage({
|
||||
savePending: boolean;
|
||||
versions: CompanySkillVersion[];
|
||||
versionsLoading: boolean;
|
||||
comments: CompanySkillComment[];
|
||||
commentsLoading: boolean;
|
||||
commentDraft: string;
|
||||
setCommentDraft: (value: string) => void;
|
||||
onCreateComment: () => void;
|
||||
createCommentPending: boolean;
|
||||
onUpdateComment: (commentId: string, body: string) => void;
|
||||
updateCommentPending: boolean;
|
||||
onDeleteComment: (commentId: string) => void;
|
||||
deleteCommentPending: boolean;
|
||||
relatedSkills: CompanySkillListItem[];
|
||||
attachAgents: Array<{ id: string; name: string; adapterType: string; supportsSkills: boolean; required: boolean }>;
|
||||
attachAgents: AttachAgentOption[];
|
||||
attachPopoverOpen: boolean;
|
||||
setAttachPopoverOpen: (open: boolean) => void;
|
||||
onSubmitAttach: (ids: string[], versionId: string | null) => void;
|
||||
@@ -2651,6 +2593,8 @@ function SkillDetailPage({
|
||||
const subtitleText = skill.tagline || skill.description
|
||||
? cardDescriptionText(skill.tagline ?? skill.description)
|
||||
: source.label;
|
||||
// Look up the richer agent record (icon, paused) for agents using this skill.
|
||||
const attachAgentMetaById = new Map(attachAgents.map((agent) => [agent.id, agent]));
|
||||
|
||||
function renderFilesBody() {
|
||||
return (
|
||||
@@ -2827,82 +2771,62 @@ function SkillDetailPage({
|
||||
);
|
||||
}
|
||||
|
||||
function renderCommentsBody() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={commentDraft}
|
||||
onChange={(event) => setCommentDraft(event.target.value)}
|
||||
placeholder="Add a comment"
|
||||
className="min-h-24"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={onCreateComment} disabled={createCommentPending || commentDraft.trim().length === 0}>
|
||||
{createCommentPending ? "Posting..." : "Post comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-y border-border">
|
||||
{commentsLoading ? (
|
||||
<PageSkeleton variant="list" />
|
||||
) : comments.length === 0 ? (
|
||||
<div className="py-6 text-sm text-muted-foreground">No comments yet.</div>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<SkillCommentRow
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
updatePending={updateCommentPending}
|
||||
deletePending={deleteCommentPending}
|
||||
onUpdate={(body) => onUpdateComment(comment.id, body)}
|
||||
onDelete={() => onDeleteComment(comment.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderAgentsBody() {
|
||||
const attachedIds = new Set(skill.usedByAgents.map((agent) => agent.id));
|
||||
// Only the agents actually using this skill are listed (PAP-10907); the
|
||||
// multi-selector behind "Add to agent" is where you attach more.
|
||||
const attached = skill.usedByAgents;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{skill.usedByAgents.length} {skill.usedByAgents.length === 1 ? "agent" : "agents"} attached
|
||||
{attached.length} {attached.length === 1 ? "agent" : "agents"} attached
|
||||
{selectedVersion ? ` · ${versionLabel(selectedVersion)}` : " · Latest"}
|
||||
</p>
|
||||
<AttachAgentsPopover
|
||||
open={attachPopoverOpen}
|
||||
onOpenChange={setAttachPopoverOpen}
|
||||
agents={attachAgents}
|
||||
attachedAgentIds={skill.usedByAgents.map((agent) => agent.id)}
|
||||
attachedAgentIds={attached.map((agent) => agent.id)}
|
||||
versions={versions}
|
||||
selectedVersionId={currentVersionSelection(skill)}
|
||||
pending={attachPending}
|
||||
onSubmit={onSubmitAttach}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-y border-border">
|
||||
{attachAgents.map((agent) => (
|
||||
<div key={agent.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-border py-3 text-sm last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<Identity name={agent.name} size="sm" />
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{agent.adapterType}{agent.supportsSkills ? "" : " · skills not supported"}
|
||||
{attached.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border py-8 text-center text-sm text-muted-foreground">
|
||||
No agents are using this skill yet. Use “Add to agent” to attach it.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-y border-border">
|
||||
{attached.map((agent) => {
|
||||
const meta = attachAgentMetaById.get(agent.id);
|
||||
return (
|
||||
<div key={agent.id} className="flex items-center gap-3 border-b border-border py-3 text-sm last:border-b-0">
|
||||
<AgentIcon icon={meta?.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{agent.name}</span>
|
||||
{meta?.paused ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide text-amber-500">
|
||||
<Pause className="h-2.5 w-2.5" aria-hidden="true" />
|
||||
Paused
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">{agent.adapterType}</div>
|
||||
</div>
|
||||
<Link
|
||||
to={`/agents/${agent.urlKey}/skills`}
|
||||
className="shrink-0 text-xs text-muted-foreground no-underline hover:text-foreground"
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn("text-xs", attachedIds.has(agent.id) ? "text-foreground" : "text-muted-foreground")}>
|
||||
{attachedIds.has(agent.id) ? "Attached" : "Not attached"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{attachAgents.length === 0 ? (
|
||||
<div className="py-6 text-sm text-muted-foreground">No agents in this company yet.</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2911,11 +2835,9 @@ function SkillDetailPage({
|
||||
? renderFilesBody()
|
||||
: activeTab === "versions"
|
||||
? renderVersionsBody()
|
||||
: activeTab === "comments"
|
||||
? renderCommentsBody()
|
||||
: activeTab === "agents"
|
||||
? renderAgentsBody()
|
||||
: renderOverviewBody();
|
||||
: activeTab === "agents"
|
||||
? renderAgentsBody()
|
||||
: renderOverviewBody();
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-12rem)]">
|
||||
@@ -2947,11 +2869,25 @@ function SkillDetailPage({
|
||||
size={44}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-semibold">{detail.name}</h1>
|
||||
<p className="mt-1 flex min-w-0 flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<SourceIcon className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{subtitleText}</span>
|
||||
</p>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="truncate text-2xl font-semibold">{detail.name}</h1>
|
||||
{/* Source icon sits right after the title; the tooltip names
|
||||
where the skill was installed from (PAP-10907). */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={`Installed from ${source.label}`}
|
||||
>
|
||||
<SourceIcon className="h-4 w-4" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Installed from {source.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{subtitleText ? (
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">{subtitleText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
@@ -2971,27 +2907,42 @@ function SkillDetailPage({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onToggleStar} disabled={starPending}>
|
||||
<Star className={cn("mr-1.5 h-3.5 w-3.5", detail.starredByCurrentActor && "fill-current text-yellow-400")} />
|
||||
{detail.starredByCurrentActor ? "Starred" : "Star"}
|
||||
<span className="ml-1 text-xs text-muted-foreground">{detail.starCount}</span>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground">
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{detail.installCount}</span>
|
||||
<span className="hidden sm:inline">{detail.installCount === 1 ? "install" : "installs"}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Times this skill has been installed or forked.</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button variant="ghost" size="sm" onClick={onFork}>
|
||||
<GitFork className="mr-1.5 h-3.5 w-3.5" />
|
||||
Fork
|
||||
<span className="ml-1 text-xs text-muted-foreground">{detail.forkCount}</span>
|
||||
</Button>
|
||||
{/* GitHub-style social proof, top-right: installs · stars · fork.
|
||||
Installs is a read-only stat; stars and fork are interactive. */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
<div className="flex items-center overflow-hidden rounded-md border border-border">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-muted-foreground">
|
||||
<Download className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span className="font-medium text-foreground">{detail.installCount}</span>
|
||||
<span className="hidden sm:inline">{detail.installCount === 1 ? "install" : "installs"}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Times this skill has been installed or forked.</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleStar}
|
||||
disabled={starPending}
|
||||
className="inline-flex items-center gap-1.5 border-l border-border px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground disabled:opacity-50"
|
||||
title={detail.starredByCurrentActor ? "Unstar this skill" : "Star this skill"}
|
||||
>
|
||||
<Star className={cn("h-3.5 w-3.5", detail.starredByCurrentActor && "fill-current text-yellow-400")} />
|
||||
<span className="hidden sm:inline">{detail.starredByCurrentActor ? "Starred" : "Star"}</span>
|
||||
<span className="font-medium text-foreground">{detail.starCount}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFork}
|
||||
className="inline-flex items-center gap-1.5 border-l border-border px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
|
||||
title="Fork this skill"
|
||||
>
|
||||
<GitFork className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Fork</span>
|
||||
<span className="font-medium text-foreground">{detail.forkCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
{detail.sourceType === "github" ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onCheckUpdates} disabled={checkUpdatesPending || updateStatusLoading}>
|
||||
@@ -3006,10 +2957,6 @@ function SkillDetailPage({
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<Button variant="ghost" size="sm" onClick={onDelete} disabled={deletePending} title={detail.usedByAgents.length > 0 ? "Detach this skill from all agents before removing it." : undefined}>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{deletePending ? "Removing..." : "Remove"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3017,14 +2964,15 @@ function SkillDetailPage({
|
||||
<div className="grid gap-6 px-4 py-4 xl:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<main className="min-w-0">
|
||||
<Tabs value={activeTab} onValueChange={(value) => onTabChange(value as SkillDetailTab)}>
|
||||
<TabsList variant="line" className="mb-5 w-full max-w-full justify-start overflow-x-auto p-0 [scrollbar-width:none]">
|
||||
{/* Underlined tab strip: the bottom padding keeps the active-tab
|
||||
underline inside the horizontal-scroll clip box (PAP-10907). */}
|
||||
<TabsList variant="line" className="mb-5 w-full max-w-full justify-start overflow-x-auto border-b border-border p-0 pb-1.5 [scrollbar-width:none]">
|
||||
{SKILL_DETAIL_TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<TabsTrigger key={tab.value} value={tab.value} className="px-3">
|
||||
<Icon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{tab.label}
|
||||
{tab.value === "comments" ? <span className="ml-1.5 text-[11px] text-muted-foreground">{comments.length}</span> : null}
|
||||
</TabsTrigger>
|
||||
);
|
||||
})}
|
||||
@@ -3035,12 +2983,9 @@ function SkillDetailPage({
|
||||
|
||||
<aside className="min-w-0 space-y-6 border-t border-border pt-4 xl:border-l xl:border-t-0 xl:pl-5 xl:pt-0">
|
||||
<section>
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Attach to agent</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">
|
||||
{detail.usedByAgents.length === 0 ? "No agents attached" : `${detail.usedByAgents.length} attached`}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{selectedVersion ? versionLabel(selectedVersion) : "Latest"}</div>
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Agents</div>
|
||||
<div className="space-y-3">
|
||||
{/* Big primary action opens the agent multi-selector (PAP-10907). */}
|
||||
<AttachAgentsPopover
|
||||
open={attachPopoverOpen}
|
||||
onOpenChange={setAttachPopoverOpen}
|
||||
@@ -3050,33 +2995,40 @@ function SkillDetailPage({
|
||||
selectedVersionId={currentVersionSelection(detail)}
|
||||
pending={attachPending}
|
||||
onSubmit={onSubmitAttach}
|
||||
fullWidth
|
||||
/>
|
||||
{detail.usedByAgents.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No agents attached yet.</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{/* Preview up to three attached agents, then summarise the rest. */}
|
||||
{detail.usedByAgents.slice(0, 3).map((agent) => {
|
||||
const meta = attachAgentMetaById.get(agent.id);
|
||||
return (
|
||||
<Link
|
||||
key={agent.id}
|
||||
to={`/agents/${agent.urlKey}/skills`}
|
||||
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm no-underline hover:bg-accent/40"
|
||||
>
|
||||
<AgentIcon icon={meta?.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
|
||||
{meta?.paused ? (
|
||||
<Pause className="h-3 w-3 shrink-0 text-amber-500" aria-label="Paused" />
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{detail.usedByAgents.length > 3 ? (
|
||||
<p className="px-1.5 pt-0.5 text-xs text-muted-foreground">
|
||||
and {detail.usedByAgents.length - 3} more
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Related skills</div>
|
||||
{relatedSkills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No related skills yet.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{relatedSkills.map((skill) => (
|
||||
<Link
|
||||
key={skill.id}
|
||||
to={skillRoute(skill.id)}
|
||||
className="block min-w-0 rounded-md px-2 py-1.5 text-sm text-muted-foreground no-underline hover:bg-accent/40 hover:text-foreground"
|
||||
>
|
||||
<span className="block line-clamp-1 break-words font-medium">{skill.name}</span>
|
||||
<span className="block line-clamp-2 break-words text-xs leading-snug">{skill.tagline ?? skill.description ?? skill.sourceLabel}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Sharing scope only surfaces when the viewer can edit it (round 2 #3).
|
||||
Sits below Attach/Related so the primary action lives at the top
|
||||
of the rail (round 2 #2). */}
|
||||
{/* Sharing scope only surfaces when the viewer can edit it (round 2 #3). */}
|
||||
<section>
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Sharing</div>
|
||||
<div className="space-y-2">
|
||||
@@ -3111,6 +3063,20 @@ function SkillDetailPage({
|
||||
<span className="mt-0.5 block text-xs">Coming later.</span>
|
||||
</span>
|
||||
</button>
|
||||
{/* Remove lives in the sidebar and only appears when the viewer can
|
||||
edit (and therefore remove) the skill (PAP-10907). */}
|
||||
{detail.editable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={deletePending}
|
||||
title={detail.usedByAgents.length > 0 ? "Detach this skill from all agents before removing it." : undefined}
|
||||
className="mt-1 flex w-full items-center gap-2 rounded-md border border-destructive/40 px-3 py-2 text-left text-sm text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 shrink-0" />
|
||||
{deletePending ? "Removing…" : "Remove skill"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -3167,7 +3133,7 @@ function SkillPane({
|
||||
deletePending: boolean;
|
||||
onSave: () => void;
|
||||
savePending: boolean;
|
||||
attachAgents: Array<{ id: string; name: string; adapterType: string; supportsSkills: boolean; required: boolean }>;
|
||||
attachAgents: AttachAgentOption[];
|
||||
attachPopoverOpen: boolean;
|
||||
setAttachPopoverOpen: (open: boolean) => void;
|
||||
versions: CompanySkillVersion[];
|
||||
@@ -3454,7 +3420,6 @@ export function CompanySkills() {
|
||||
const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [commentDraft, setCommentDraft] = useState("");
|
||||
const [displayedDetail, setDisplayedDetail] = useState<CompanySkillDetail | null>(null);
|
||||
const [displayedFile, setDisplayedFile] = useState<CompanySkillFileDetail | null>(null);
|
||||
const [scanStatusMessage, setScanStatusMessage] = useState<string | null>(null);
|
||||
@@ -3497,7 +3462,7 @@ export function CompanySkills() {
|
||||
const discoveryTab: DiscoveryTab = DISCOVERY_TABS.includes(tabParam as DiscoveryTab)
|
||||
? (tabParam as DiscoveryTab)
|
||||
: "all";
|
||||
const detailTab: SkillDetailTab = (["overview", "files", "versions", "comments", "agents"] as SkillDetailTab[]).includes(tabParam as SkillDetailTab)
|
||||
const detailTab: SkillDetailTab = (["overview", "files", "versions", "agents"] as SkillDetailTab[]).includes(tabParam as SkillDetailTab)
|
||||
? (tabParam as SkillDetailTab)
|
||||
: selectedPath !== "SKILL.md"
|
||||
? "files"
|
||||
@@ -3604,12 +3569,6 @@ export function CompanySkills() {
|
||||
enabled: Boolean(selectedCompanyId && selectedSkillId),
|
||||
});
|
||||
|
||||
const commentsQuery = useQuery({
|
||||
queryKey: queryKeys.companySkills.comments(selectedCompanyId ?? "", selectedSkillId ?? ""),
|
||||
queryFn: () => companySkillsApi.comments(selectedCompanyId!, selectedSkillId!),
|
||||
enabled: Boolean(selectedCompanyId && selectedSkillId),
|
||||
});
|
||||
|
||||
const updateStatusQuery = useQuery({
|
||||
queryKey: queryKeys.companySkills.updateStatus(selectedCompanyId ?? "", selectedSkillId ?? ""),
|
||||
queryFn: () => companySkillsApi.updateStatus(selectedCompanyId!, selectedSkillId!),
|
||||
@@ -3800,59 +3759,6 @@ export function CompanySkills() {
|
||||
},
|
||||
});
|
||||
|
||||
const createComment = useMutation({
|
||||
mutationFn: () => companySkillsApi.createComment(selectedCompanyId!, selectedSkillId!, { body: commentDraft.trim() }),
|
||||
onSuccess: async () => {
|
||||
setCommentDraft("");
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.companySkills.comments(selectedCompanyId!, selectedSkillId!),
|
||||
});
|
||||
pushToast({ tone: "success", title: "Comment posted" });
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Comment failed",
|
||||
body: error instanceof Error ? error.message : "Failed to post comment.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateComment = useMutation({
|
||||
mutationFn: (input: { commentId: string; body: string }) =>
|
||||
companySkillsApi.updateComment(selectedCompanyId!, selectedSkillId!, input.commentId, { body: input.body }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.companySkills.comments(selectedCompanyId!, selectedSkillId!),
|
||||
});
|
||||
pushToast({ tone: "success", title: "Comment updated" });
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Comment update failed",
|
||||
body: error instanceof Error ? error.message : "Failed to update comment.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteComment = useMutation({
|
||||
mutationFn: (commentId: string) => companySkillsApi.deleteComment(selectedCompanyId!, selectedSkillId!, commentId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.companySkills.comments(selectedCompanyId!, selectedSkillId!),
|
||||
});
|
||||
pushToast({ tone: "success", title: "Comment deleted" });
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
tone: "error",
|
||||
title: "Comment delete failed",
|
||||
body: error instanceof Error ? error.message : "Failed to delete comment.",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const toggleStar = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!activeDetail) throw new Error("Select a skill first.");
|
||||
@@ -4025,22 +3931,6 @@ export function CompanySkills() {
|
||||
}
|
||||
return counts;
|
||||
}, [installedSkills]);
|
||||
const relatedSkills = useMemo(() => {
|
||||
if (!activeDetail) return [];
|
||||
const categorySet = new Set(activeDetail.categories);
|
||||
return installedSkills
|
||||
.filter((skill) => skill.id !== activeDetail.id)
|
||||
.map((skill) => {
|
||||
const sharedCategories = skill.categories.filter((category) => categorySet.has(category)).length;
|
||||
const sameSource = skill.sourceBadge === activeDetail.sourceBadge ? 1 : 0;
|
||||
return { skill, score: sharedCategories * 3 + sameSource };
|
||||
})
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((a, b) => b.score - a.score || a.skill.name.localeCompare(b.skill.name))
|
||||
.slice(0, 5)
|
||||
.map((entry) => entry.skill);
|
||||
}, [activeDetail, installedSkills]);
|
||||
|
||||
const installCatalog = useMutation({
|
||||
mutationFn: (payload: { catalogSkillId: string; slug: string | null; force: boolean }) =>
|
||||
companySkillsApi.installCatalog(selectedCompanyId!, {
|
||||
@@ -4086,6 +3976,8 @@ export function CompanySkills() {
|
||||
adapterType: agent.adapterType,
|
||||
supportsSkills: Boolean(caps.supportsSkills),
|
||||
required: isRequired,
|
||||
icon: agent.icon,
|
||||
paused: agent.status === "paused" || agent.pausedAt != null,
|
||||
attached: usedSet.has(agent.id),
|
||||
requiredKeys,
|
||||
};
|
||||
@@ -4452,17 +4344,6 @@ export function CompanySkills() {
|
||||
savePending={saveFile.isPending}
|
||||
versions={versionsQuery.data ?? []}
|
||||
versionsLoading={versionsQuery.isLoading}
|
||||
comments={commentsQuery.data ?? []}
|
||||
commentsLoading={commentsQuery.isLoading}
|
||||
commentDraft={commentDraft}
|
||||
setCommentDraft={setCommentDraft}
|
||||
onCreateComment={() => createComment.mutate()}
|
||||
createCommentPending={createComment.isPending}
|
||||
onUpdateComment={(commentId, body) => updateComment.mutate({ commentId, body })}
|
||||
updateCommentPending={updateComment.isPending}
|
||||
onDeleteComment={(commentId) => deleteComment.mutate(commentId)}
|
||||
deleteCommentPending={deleteComment.isPending}
|
||||
relatedSkills={relatedSkills}
|
||||
attachAgents={eligibleAgentsForAttach}
|
||||
attachPopoverOpen={attachPopoverOpen}
|
||||
setAttachPopoverOpen={setAttachPopoverOpen}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type {
|
||||
CompanySkillDetail,
|
||||
CompanySkillFileDetail,
|
||||
CompanySkillVersion,
|
||||
} from "@paperclipai/shared";
|
||||
import { SkillDetailPage } from "@/pages/CompanySkills";
|
||||
|
||||
type DetailTab = "overview" | "files" | "versions" | "agents";
|
||||
|
||||
const NOW = new Date("2026-06-01T12:00:00Z");
|
||||
|
||||
const MOCK_DETAIL: CompanySkillDetail = {
|
||||
id: "skill-1",
|
||||
companyId: "company-1",
|
||||
key: "paperclipai/paperclip/deep-research",
|
||||
slug: "deep-research",
|
||||
name: "deep-research",
|
||||
description:
|
||||
"Fan-out web searches, fetch sources, adversarially verify claims, and synthesize a cited report. Best for multi-source, fact-checked research where one search angle will not find everything.",
|
||||
markdown: "# deep-research\n\nResearch harness.",
|
||||
sourceType: "github",
|
||||
sourceLocator: "github.com/paperclipai/skills",
|
||||
sourceRef: "a1b2c3d4e5f6",
|
||||
trustLevel: "scripts_executables",
|
||||
compatibility: "compatible",
|
||||
fileInventory: [
|
||||
{ path: "SKILL.md", kind: "skill" },
|
||||
{ path: "references/methodology.md", kind: "reference" },
|
||||
{ path: "scripts/fetch.ts", kind: "script" },
|
||||
],
|
||||
iconUrl: null,
|
||||
color: "#6366f1",
|
||||
tagline: "Multi-source research with citation-grade synthesis.",
|
||||
authorName: "Astra",
|
||||
homepageUrl: null,
|
||||
categories: ["research", "writing"],
|
||||
sharingScope: "company",
|
||||
publicShareToken: null,
|
||||
forkedFromSkillId: null,
|
||||
forkedFromCompanyId: null,
|
||||
starCount: 211,
|
||||
installCount: 96,
|
||||
forkCount: 17,
|
||||
currentVersionId: "v-2",
|
||||
metadata: null,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
attachedAgentCount: 4,
|
||||
usedByAgents: [
|
||||
{ id: "a-1", name: "Astra", urlKey: "astra", adapterType: "process", desired: true, actualState: null, versionId: null },
|
||||
{ id: "a-2", name: "Scout", urlKey: "scout", adapterType: "http", desired: true, actualState: null, versionId: null },
|
||||
{ id: "a-3", name: "Quill", urlKey: "quill", adapterType: "process", desired: true, actualState: null, versionId: null },
|
||||
{ id: "a-4", name: "Marlow", urlKey: "marlow", adapterType: "process", desired: true, actualState: null, versionId: null },
|
||||
],
|
||||
editable: true,
|
||||
editableReason: null,
|
||||
sourceLabel: "GitHub",
|
||||
sourceBadge: "github",
|
||||
sourcePath: "github.com/paperclipai/skills/tree/main/deep-research",
|
||||
currentVersion: {
|
||||
id: "v-2",
|
||||
companyId: "company-1",
|
||||
companySkillId: "skill-1",
|
||||
revisionNumber: 2,
|
||||
label: "tighten verifier",
|
||||
fileInventory: [],
|
||||
authorAgentId: "a-1",
|
||||
authorUserId: null,
|
||||
createdAt: NOW,
|
||||
},
|
||||
starredByCurrentActor: true,
|
||||
};
|
||||
|
||||
const MOCK_VERSIONS: CompanySkillVersion[] = [
|
||||
{
|
||||
id: "v-2",
|
||||
companyId: "company-1",
|
||||
companySkillId: "skill-1",
|
||||
revisionNumber: 2,
|
||||
label: "tighten verifier",
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# v2" }],
|
||||
authorAgentId: "a-1",
|
||||
authorUserId: null,
|
||||
createdAt: NOW,
|
||||
},
|
||||
{
|
||||
id: "v-1",
|
||||
companyId: "company-1",
|
||||
companySkillId: "skill-1",
|
||||
revisionNumber: 1,
|
||||
label: null,
|
||||
fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# v1" }],
|
||||
authorAgentId: "a-1",
|
||||
authorUserId: null,
|
||||
createdAt: NOW,
|
||||
},
|
||||
];
|
||||
|
||||
const MOCK_FILE: CompanySkillFileDetail = {
|
||||
skillId: "skill-1",
|
||||
path: "SKILL.md",
|
||||
kind: "skill",
|
||||
content: "---\nname: deep-research\n---\n\n# deep-research\n\nFan out, verify, synthesize.",
|
||||
language: "markdown",
|
||||
markdown: true,
|
||||
editable: true,
|
||||
};
|
||||
|
||||
// Agents available to attach, including a paused one to exercise the badge.
|
||||
const MOCK_ATTACH_AGENTS = [
|
||||
{ id: "a-1", name: "Astra", adapterType: "process", supportsSkills: true, required: false, icon: "telescope", paused: false },
|
||||
{ id: "a-2", name: "Scout", adapterType: "http", supportsSkills: true, required: false, icon: "compass", paused: false },
|
||||
{ id: "a-3", name: "Quill", adapterType: "process", supportsSkills: true, required: false, icon: "feather", paused: true },
|
||||
{ id: "a-4", name: "Marlow", adapterType: "process", supportsSkills: true, required: false, icon: "bot", paused: false },
|
||||
{ id: "a-5", name: "Pixel", adapterType: "process", supportsSkills: true, required: false, icon: "palette", paused: false },
|
||||
{ id: "a-6", name: "Forge", adapterType: "process", supportsSkills: false, required: false, icon: "hammer", paused: false },
|
||||
];
|
||||
|
||||
function SkillDetailHarness({ initialTab = "overview" as DetailTab }: { initialTab?: DetailTab }) {
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>(initialTab);
|
||||
const [attachOpen, setAttachOpen] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"preview" | "code">("preview");
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [draft, setDraft] = useState(MOCK_FILE.content);
|
||||
|
||||
return (
|
||||
<SkillDetailPage
|
||||
detail={MOCK_DETAIL}
|
||||
loading={false}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
selectedPath="SKILL.md"
|
||||
file={MOCK_FILE}
|
||||
fileLoading={false}
|
||||
viewMode={viewMode}
|
||||
editMode={editMode}
|
||||
draft={draft}
|
||||
setViewMode={setViewMode}
|
||||
setEditMode={setEditMode}
|
||||
setDraft={setDraft}
|
||||
onSave={() => {}}
|
||||
savePending={false}
|
||||
versions={MOCK_VERSIONS}
|
||||
versionsLoading={false}
|
||||
attachAgents={MOCK_ATTACH_AGENTS}
|
||||
attachPopoverOpen={attachOpen}
|
||||
setAttachPopoverOpen={setAttachOpen}
|
||||
onSubmitAttach={() => setAttachOpen(false)}
|
||||
attachPending={false}
|
||||
expandedDirs={new Set<string>()}
|
||||
onToggleDir={() => {}}
|
||||
onSelectPath={() => {}}
|
||||
updateStatus={{
|
||||
supported: true,
|
||||
reason: null,
|
||||
trackingRef: "main",
|
||||
currentRef: "a1b2c3d4e5f6",
|
||||
latestRef: "a1b2c3d4e5f6",
|
||||
hasUpdate: false,
|
||||
installedHash: null,
|
||||
originHash: null,
|
||||
userModifiedAt: null,
|
||||
updateHoldReason: null,
|
||||
auditVerdict: null,
|
||||
auditCodes: [],
|
||||
}}
|
||||
updateStatusLoading={false}
|
||||
onCheckUpdates={() => {}}
|
||||
checkUpdatesPending={false}
|
||||
onInstallUpdate={() => {}}
|
||||
installUpdatePending={false}
|
||||
onToggleStar={() => {}}
|
||||
starPending={false}
|
||||
onFork={() => {}}
|
||||
onUpdateSharingScope={() => {}}
|
||||
updateSharingPending={false}
|
||||
onDelete={() => {}}
|
||||
deletePending={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof SkillDetailHarness> = {
|
||||
title: "Skills Store/Skill detail",
|
||||
component: SkillDetailHarness,
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof SkillDetailHarness>;
|
||||
|
||||
export const Overview: Story = { args: { initialTab: "overview" } };
|
||||
export const AgentsTab: Story = { args: { initialTab: "agents" } };
|
||||
export const FilesTab: Story = { args: { initialTab: "files" } };
|
||||
export const VersionsTab: Story = { args: { initialTab: "versions" } };
|
||||
Reference in new issue
Block a user