mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
Apply PAP-10907 round-2 Skills Store frontend changes
- Top-bar "installs" now counts agents that have the skill attached (attachedAgentCount) instead of the stored installCount (D) - Fix "Add to agent": the agents-tab and sidebar popovers shared one controlled open flag, which made both mount open and swallow clicks so the button looked dead; each popover now owns its open state (H) - Files sidebar narrowed from 18rem to 13rem (I) - Sharing moved into a gear-triggered Settings modal (sharing dropdown + danger-zone Remove); replaces the inline sidebar Sharing section (A) - GitHub check-updates / install-update controls moved to the sidebar under Agents, above the Settings gear (F) - Store/catalog cards show a source icon (where the skill is from) and the discovery grid gains a Source filter (github / skills.sh / local / …) (E) - Floating "Unsaved changes" save bar while a file edit is dirty, plus a beforeunload guard so the page isn't left accidentally (J) Storybook: discovery mocks now carry varied sourceBadge so the source filter/icons render; detail story updated for the popover prop change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4cd147a33
commit
115b4ebf47
3 files changed
+214
-106
No files matched your search
+203
-102
@@ -95,6 +95,7 @@ import {
|
||||
RefreshCw,
|
||||
Save,
|
||||
Search,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
Trash2,
|
||||
@@ -581,6 +582,8 @@ export type DiscoveryCard = {
|
||||
required: boolean;
|
||||
forkedFrom: boolean;
|
||||
updatedAt: number;
|
||||
sourceBadge?: CompanySkillSourceBadge | null;
|
||||
sourceLabel?: string | null;
|
||||
};
|
||||
|
||||
// Stable palette used to auto-assign an accent colour to a skill when the
|
||||
@@ -722,6 +725,8 @@ function buildDiscoveryCards(
|
||||
required,
|
||||
forkedFrom: Boolean(skill.forkedFromSkillId),
|
||||
updatedAt: new Date(skill.updatedAt).getTime() || 0,
|
||||
sourceBadge: skill.sourceBadge,
|
||||
sourceLabel: skill.sourceLabel,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -747,6 +752,8 @@ function buildDiscoveryCards(
|
||||
required,
|
||||
forkedFrom: false,
|
||||
updatedAt: 0,
|
||||
sourceBadge: "catalog",
|
||||
sourceLabel: entry.packageName ?? "Catalog",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -837,6 +844,16 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco
|
||||
by {card.author}{card.version ? ` · ${card.version}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
{/* Where the skill came from (PAP-10907 E); native title gives a hover hint. */}
|
||||
{(() => {
|
||||
const meta = sourceMeta(card.sourceBadge ?? "catalog", card.sourceLabel ?? null);
|
||||
const SourceIcon = meta.icon;
|
||||
return (
|
||||
<span className="shrink-0 text-muted-foreground" title={`From ${meta.label}`} aria-label={`From ${meta.label}`}>
|
||||
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{card.forkedFrom ? (
|
||||
@@ -980,6 +997,25 @@ export function DiscoveryGrid({
|
||||
scanPending: boolean;
|
||||
scanStatus: string | null;
|
||||
}) {
|
||||
// Source filter (github / skills.sh / local / …) lives in the grid so it
|
||||
// narrows whatever the parent already filtered by tab/category/search (PAP-10907 E).
|
||||
const [sourceBadgeFilter, setSourceBadgeFilter] = useState<string>("all");
|
||||
const availableSources = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const card of cards) if (card.sourceBadge) set.add(card.sourceBadge);
|
||||
return Array.from(set).sort();
|
||||
}, [cards]);
|
||||
useEffect(() => {
|
||||
if (sourceBadgeFilter !== "all" && !availableSources.includes(sourceBadgeFilter)) {
|
||||
setSourceBadgeFilter("all");
|
||||
}
|
||||
}, [availableSources, sourceBadgeFilter]);
|
||||
const sourceFilteredCards = useMemo(
|
||||
() => (sourceBadgeFilter === "all" ? cards : cards.filter((card) => card.sourceBadge === sourceBadgeFilter)),
|
||||
[cards, sourceBadgeFilter],
|
||||
);
|
||||
const sourceFilterActive = sourceBadgeFilter !== "all";
|
||||
|
||||
return (
|
||||
// On desktop the store is bounded to the viewport so the category sidebar
|
||||
// and the results pane each scroll independently (PAP-10907). Mobile keeps
|
||||
@@ -1035,6 +1071,29 @@ export function DiscoveryGrid({
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{availableSources.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<span className="text-muted-foreground">Source</span>
|
||||
<span className="ml-1.5 capitalize">
|
||||
{sourceBadgeFilter === "all" ? "All" : sourceMeta(sourceBadgeFilter as CompanySkillSourceBadge, null).label}
|
||||
</span>
|
||||
<ChevronDown className="ml-1 h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={sourceBadgeFilter} onValueChange={setSourceBadgeFilter}>
|
||||
<DropdownMenuRadioItem value="all">All sources</DropdownMenuRadioItem>
|
||||
{availableSources.map((badge) => (
|
||||
<DropdownMenuRadioItem key={badge} value={badge}>
|
||||
{sourceMeta(badge as CompanySkillSourceBadge, null).label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
@@ -1127,15 +1186,15 @@ export function DiscoveryGrid({
|
||||
<PageSkeleton variant="list" />
|
||||
) : error ? (
|
||||
<div className="py-6 text-sm text-destructive">{error}</div>
|
||||
) : cards.length === 0 ? (
|
||||
) : sourceFilteredCards.length === 0 ? (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
icon={LayoutGrid}
|
||||
message={
|
||||
totalCount === 0
|
||||
? "No skills yet. Create one or install from the catalog."
|
||||
: search || activeCategory
|
||||
? "No skills match your search."
|
||||
: search || activeCategory || sourceFilterActive
|
||||
? "No skills match your filters."
|
||||
: "No skills in this tab yet."
|
||||
}
|
||||
/>
|
||||
@@ -1148,7 +1207,7 @@ export function DiscoveryGrid({
|
||||
Create a skill
|
||||
</Button>
|
||||
</div>
|
||||
) : (search || activeCategory) ? (
|
||||
) : (search || activeCategory || sourceFilterActive) ? (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1156,6 +1215,7 @@ export function DiscoveryGrid({
|
||||
onClick={() => {
|
||||
onSearchChange("");
|
||||
onCategoryChange(null);
|
||||
setSourceBadgeFilter("all");
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
@@ -1166,11 +1226,11 @@ export function DiscoveryGrid({
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
{cards.length} {cards.length === 1 ? "skill" : "skills"}
|
||||
{sourceFilteredCards.length} {sourceFilteredCards.length === 1 ? "skill" : "skills"}
|
||||
{activeCategory ? <span className="capitalize"> · {activeCategory}</span> : null}
|
||||
</p>
|
||||
<div className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(15rem,1fr))]">
|
||||
{cards.map((card) => (
|
||||
{sourceFilteredCards.map((card) => (
|
||||
<SkillCard key={card.key} card={card} onOpen={onOpenCard} />
|
||||
))}
|
||||
</div>
|
||||
@@ -1976,8 +2036,6 @@ type AttachAgentOption = {
|
||||
};
|
||||
|
||||
function AttachAgentsPopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
agents,
|
||||
attachedAgentIds,
|
||||
versions,
|
||||
@@ -1986,8 +2044,6 @@ function AttachAgentsPopover({
|
||||
onSubmit,
|
||||
fullWidth = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
agents: AttachAgentOption[];
|
||||
attachedAgentIds: string[];
|
||||
versions: CompanySkillVersion[];
|
||||
@@ -1996,6 +2052,10 @@ function AttachAgentsPopover({
|
||||
onSubmit: (nextIds: string[], versionId: string | null) => void;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
// Each popover instance owns its open state. The detail page renders two of
|
||||
// these (agents tab + sidebar); sharing a single controlled flag made both
|
||||
// open at once and swallowed clicks, so "Add to agent" appeared dead (PAP-10907 H).
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [draft, setDraft] = useState<Set<string>>(new Set(attachedAgentIds));
|
||||
const [draftVersionId, setDraftVersionId] = useState<string | null>(selectedVersionId);
|
||||
@@ -2022,7 +2082,7 @@ function AttachAgentsPopover({
|
||||
const sortedVersions = [...versions].sort((a, b) => b.revisionNumber - a.revisionNumber);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" className={cn(fullWidth && "w-full")}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
@@ -2110,10 +2170,17 @@ function AttachAgentsPopover({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border px-3 py-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setOpen(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => onSubmit(Array.from(draft), draftVersionId)} disabled={pending}>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSubmit(Array.from(draft), draftVersionId);
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
{pending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -2514,8 +2581,6 @@ export function SkillDetailPage({
|
||||
versions,
|
||||
versionsLoading,
|
||||
attachAgents,
|
||||
attachPopoverOpen,
|
||||
setAttachPopoverOpen,
|
||||
onSubmitAttach,
|
||||
attachPending,
|
||||
expandedDirs,
|
||||
@@ -2553,8 +2618,6 @@ export function SkillDetailPage({
|
||||
versions: CompanySkillVersion[];
|
||||
versionsLoading: boolean;
|
||||
attachAgents: AttachAgentOption[];
|
||||
attachPopoverOpen: boolean;
|
||||
setAttachPopoverOpen: (open: boolean) => void;
|
||||
onSubmitAttach: (ids: string[], versionId: string | null) => void;
|
||||
attachPending: boolean;
|
||||
expandedDirs: Set<string>;
|
||||
@@ -2575,10 +2638,25 @@ export function SkillDetailPage({
|
||||
deletePending: boolean;
|
||||
}) {
|
||||
const [diffOpen, setDiffOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const sortedVersions = [...versions].sort((a, b) => b.revisionNumber - a.revisionNumber);
|
||||
const [leftVersionId, setLeftVersionId] = useState<string | null>(null);
|
||||
const [rightVersionId, setRightVersionId] = useState<string | null>(null);
|
||||
|
||||
// Track unsaved edits so we can float a save bar and warn before the page is
|
||||
// unloaded with a dirty draft (PAP-10907 J).
|
||||
const savedFileContent = file?.content ?? "";
|
||||
const isDirty = editMode && Boolean(file?.editable) && draft !== savedFileContent;
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
|
||||
if (!detail) {
|
||||
return loading ? <PageSkeleton variant="detail" /> : <EmptyState icon={Boxes} message="Skill not found." />;
|
||||
}
|
||||
@@ -2598,7 +2676,7 @@ export function SkillDetailPage({
|
||||
|
||||
function renderFilesBody() {
|
||||
return (
|
||||
<div className="grid min-h-[560px] gap-0 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<div className="grid min-h-[560px] gap-0 lg:grid-cols-[13rem_minmax(0,1fr)]">
|
||||
<aside className="border-b border-border pb-3 lg:border-b-0 lg:border-r lg:pb-0 lg:pr-3">
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Files</div>
|
||||
<SkillTree
|
||||
@@ -2783,8 +2861,6 @@ export function SkillDetailPage({
|
||||
{selectedVersion ? ` · ${versionLabel(selectedVersion)}` : " · Latest"}
|
||||
</p>
|
||||
<AttachAgentsPopover
|
||||
open={attachPopoverOpen}
|
||||
onOpenChange={setAttachPopoverOpen}
|
||||
agents={attachAgents}
|
||||
attachedAgentIds={attached.map((agent) => agent.id)}
|
||||
versions={versions}
|
||||
@@ -2908,18 +2984,19 @@ export function SkillDetailPage({
|
||||
</div>
|
||||
</div>
|
||||
{/* GitHub-style social proof, top-right: installs · stars · fork.
|
||||
Installs is a read-only stat; stars and fork are interactive. */}
|
||||
"Installs" counts agents that currently have this skill attached
|
||||
(PAP-10907); 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 className="font-medium text-foreground">{detail.attachedAgentCount}</span>
|
||||
<span className="hidden sm:inline">{detail.attachedAgentCount === 1 ? "install" : "installs"}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Times this skill has been installed or forked.</TooltipContent>
|
||||
<TooltipContent>Agents in this company that currently have this skill installed.</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2943,20 +3020,6 @@ export function SkillDetailPage({
|
||||
<span className="font-medium text-foreground">{detail.forkCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
{detail.sourceType === "github" ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onCheckUpdates} disabled={checkUpdatesPending || updateStatusLoading}>
|
||||
<RefreshCw className={cn("mr-1.5 h-3.5 w-3.5", (checkUpdatesPending || updateStatusLoading) && "animate-spin")} />
|
||||
Check updates
|
||||
</Button>
|
||||
{updateStatus?.supported && updateStatus.hasUpdate ? (
|
||||
<Button size="sm" onClick={onInstallUpdate} disabled={installUpdatePending}>
|
||||
<RefreshCw className={cn("mr-1.5 h-3.5 w-3.5", installUpdatePending && "animate-spin")} />
|
||||
Install update{latestPin ? ` ${latestPin}` : ""}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2987,8 +3050,6 @@ export function SkillDetailPage({
|
||||
<div className="space-y-3">
|
||||
{/* Big primary action opens the agent multi-selector (PAP-10907). */}
|
||||
<AttachAgentsPopover
|
||||
open={attachPopoverOpen}
|
||||
onOpenChange={setAttachPopoverOpen}
|
||||
agents={attachAgents}
|
||||
attachedAgentIds={detail.usedByAgents.map((agent) => agent.id)}
|
||||
versions={versions}
|
||||
@@ -3028,59 +3089,112 @@ export function SkillDetailPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sharing scope only surfaces when the viewer can edit it (round 2 #3). */}
|
||||
{/* Revision / update controls sit under Agents, above the config gear
|
||||
(PAP-10907 F). Only GitHub-sourced skills can pull updates. */}
|
||||
{detail.sourceType === "github" ? (
|
||||
<section>
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Updates</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="shrink-0 uppercase tracking-wide">Pin</span>
|
||||
<span className="truncate font-mono text-foreground">{currentPin ?? "untracked"}</span>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={onCheckUpdates} disabled={checkUpdatesPending || updateStatusLoading}>
|
||||
<RefreshCw className={cn("mr-1.5 h-3.5 w-3.5", (checkUpdatesPending || updateStatusLoading) && "animate-spin")} />
|
||||
Check for updates
|
||||
</Button>
|
||||
{updateStatus?.supported && updateStatus.hasUpdate ? (
|
||||
<Button size="sm" className="w-full" onClick={onInstallUpdate} disabled={installUpdatePending}>
|
||||
<ArrowUpCircle className={cn("mr-1.5 h-3.5 w-3.5", installUpdatePending && "animate-spin")} />
|
||||
Install update{latestPin ? ` ${latestPin}` : ""}
|
||||
</Button>
|
||||
) : updateStatus?.supported && !updateStatus.hasUpdate && !updateStatusLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Up to date.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Config lives behind a gear; sharing + danger zone open in a modal
|
||||
(PAP-10907 A). */}
|
||||
<section>
|
||||
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">Sharing</div>
|
||||
<div className="space-y-2">
|
||||
{(["company", "private"] as const).map((scope) => (
|
||||
<button
|
||||
key={scope}
|
||||
type="button"
|
||||
onClick={() => onUpdateSharingScope(scope)}
|
||||
disabled={updateSharingPending || detail.sharingScope === scope}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-md border px-3 py-2 text-left text-sm transition-colors",
|
||||
detail.sharingScope === scope ? "border-foreground/50 bg-accent/40" : "border-border hover:bg-accent/30",
|
||||
)}
|
||||
>
|
||||
{scope === "company" ? <Users className="mt-0.5 h-3.5 w-3.5" /> : <Lock className="mt-0.5 h-3.5 w-3.5" />}
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium">{scope === "company" ? "Company" : "Private"}</span>
|
||||
<span className="mt-0.5 block text-xs text-muted-foreground">
|
||||
{scope === "company" ? "Visible inside this company." : "Only visible in your library."}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="flex w-full items-start gap-2 rounded-md border border-dashed border-border px-3 py-2 text-left text-sm text-muted-foreground"
|
||||
>
|
||||
<Globe className="mt-0.5 h-3.5 w-3.5" />
|
||||
<span>
|
||||
<span className="block font-medium">Public link</span>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-border px-3 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-accent/30 hover:text-foreground"
|
||||
>
|
||||
<Settings className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">Settings</span>
|
||||
</button>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Floating save bar: stays visible while a file edit is dirty so the
|
||||
unsaved state is obvious (PAP-10907 J). */}
|
||||
{isDirty ? (
|
||||
<div className="fixed bottom-6 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 rounded-full border border-border bg-background/95 px-4 py-2 shadow-lg backdrop-blur">
|
||||
<span className="text-sm text-muted-foreground">Unsaved changes</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDraft(savedFileContent);
|
||||
setEditMode(false);
|
||||
}}
|
||||
disabled={savePending}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button size="sm" onClick={onSave} disabled={savePending}>
|
||||
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||
{savePending ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skill settings</DialogTitle>
|
||||
<DialogDescription>Manage how {detail.name} is shared.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Sharing</label>
|
||||
<select
|
||||
value={detail.sharingScope === "public_link" ? "company" : detail.sharingScope}
|
||||
onChange={(event) => onUpdateSharingScope(event.target.value as Exclude<CompanySkillSharingScope, "public_link">)}
|
||||
disabled={updateSharingPending}
|
||||
className="h-9 w-full rounded-md border border-border bg-background px-2 text-sm text-foreground"
|
||||
>
|
||||
<option value="company">Company — visible inside this company</option>
|
||||
<option value="private">Private — only visible in your library</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">Public link sharing is coming later.</p>
|
||||
</div>
|
||||
{detail.editable ? (
|
||||
<div className="rounded-md border border-destructive/40 p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-destructive">Danger zone</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<p className="min-w-0 text-xs text-muted-foreground">Remove this skill from the company library.</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
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>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3107,8 +3221,6 @@ function SkillPane({
|
||||
onSave,
|
||||
savePending,
|
||||
attachAgents,
|
||||
attachPopoverOpen,
|
||||
setAttachPopoverOpen,
|
||||
versions,
|
||||
onSubmitAttach,
|
||||
attachPending,
|
||||
@@ -3134,8 +3246,6 @@ function SkillPane({
|
||||
onSave: () => void;
|
||||
savePending: boolean;
|
||||
attachAgents: AttachAgentOption[];
|
||||
attachPopoverOpen: boolean;
|
||||
setAttachPopoverOpen: (open: boolean) => void;
|
||||
versions: CompanySkillVersion[];
|
||||
onSubmitAttach: (ids: string[], versionId: string | null) => void;
|
||||
attachPending: boolean;
|
||||
@@ -3299,8 +3409,6 @@ function SkillPane({
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Used by</span>
|
||||
<AttachAgentsPopover
|
||||
open={attachPopoverOpen}
|
||||
onOpenChange={setAttachPopoverOpen}
|
||||
agents={attachAgents}
|
||||
attachedAgentIds={usedBy.map((agent) => agent.id)}
|
||||
versions={versions}
|
||||
@@ -3441,7 +3549,6 @@ export function CompanySkills() {
|
||||
defaultAction: "install" | "update" | "replace";
|
||||
error: string | null;
|
||||
}>({ open: false, catalogSkill: null, conflict: null, defaultSlug: null, defaultForce: false, defaultAction: "install", error: null });
|
||||
const [attachPopoverOpen, setAttachPopoverOpen] = useState(false);
|
||||
const [discoverySearch, setDiscoverySearch] = useState("");
|
||||
const [discoverySort, setDiscoverySort] = useState<DiscoverySort>("agents");
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
@@ -4013,7 +4120,6 @@ export function CompanySkills() {
|
||||
);
|
||||
const affected = new Set<string>([...toAdd, ...toRemove, ...toUpdateVersion]);
|
||||
if (affected.size === 0) {
|
||||
setAttachPopoverOpen(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -4027,7 +4133,6 @@ export function CompanySkills() {
|
||||
await attachAgentsMutation.mutateAsync({ agentId, desiredSkills: currentEntries });
|
||||
}
|
||||
pushToast({ tone: "success", title: "Agents updated", body: `${nextAgentIds.length} agent(s) attached.` });
|
||||
setAttachPopoverOpen(false);
|
||||
} catch (error) {
|
||||
pushToast({ tone: "error", title: "Update failed", body: error instanceof Error ? error.message : "Failed to update agent skills." });
|
||||
}
|
||||
@@ -4345,8 +4450,6 @@ export function CompanySkills() {
|
||||
versions={versionsQuery.data ?? []}
|
||||
versionsLoading={versionsQuery.isLoading}
|
||||
attachAgents={eligibleAgentsForAttach}
|
||||
attachPopoverOpen={attachPopoverOpen}
|
||||
setAttachPopoverOpen={setAttachPopoverOpen}
|
||||
onSubmitAttach={handleAttachSubmit}
|
||||
attachPending={attachAgentsMutation.isPending}
|
||||
expandedDirs={expandedDirs[selectedSkillId] ?? new Set<string>()}
|
||||
@@ -4548,8 +4651,6 @@ export function CompanySkills() {
|
||||
onSave={() => saveFile.mutate()}
|
||||
savePending={saveFile.isPending}
|
||||
attachAgents={eligibleAgentsForAttach}
|
||||
attachPopoverOpen={attachPopoverOpen}
|
||||
setAttachPopoverOpen={setAttachPopoverOpen}
|
||||
versions={versionsQuery.data ?? []}
|
||||
onSubmitAttach={handleAttachSubmit}
|
||||
attachPending={attachAgentsMutation.isPending}
|
||||
|
||||
@@ -120,7 +120,6 @@ const MOCK_ATTACH_AGENTS = [
|
||||
|
||||
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);
|
||||
@@ -145,9 +144,7 @@ function SkillDetailHarness({ initialTab = "overview" as DetailTab }: { initialT
|
||||
versions={MOCK_VERSIONS}
|
||||
versionsLoading={false}
|
||||
attachAgents={MOCK_ATTACH_AGENTS}
|
||||
attachPopoverOpen={attachOpen}
|
||||
setAttachPopoverOpen={setAttachOpen}
|
||||
onSubmitAttach={() => setAttachOpen(false)}
|
||||
onSubmitAttach={() => {}}
|
||||
attachPending={false}
|
||||
expandedDirs={new Set<string>()}
|
||||
onToggleDir={() => {}}
|
||||
|
||||
@@ -25,6 +25,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 2 * 86_400_000,
|
||||
sourceBadge: "github",
|
||||
},
|
||||
{
|
||||
key: "here.now/agent-browser",
|
||||
@@ -45,6 +46,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 5 * 86_400_000,
|
||||
sourceBadge: "skills_sh",
|
||||
},
|
||||
{
|
||||
key: "paperclipai/paperclip/verify",
|
||||
@@ -65,6 +67,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 9 * 86_400_000,
|
||||
sourceBadge: "local",
|
||||
},
|
||||
{
|
||||
key: "you/hue-prosumer",
|
||||
@@ -85,6 +88,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
|
||||
required: false,
|
||||
forkedFrom: true,
|
||||
updatedAt: Date.now() - 1 * 86_400_000,
|
||||
sourceBadge: "paperclip",
|
||||
},
|
||||
{
|
||||
key: "anthropic/claude-api",
|
||||
@@ -105,6 +109,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: 0,
|
||||
sourceBadge: "url",
|
||||
},
|
||||
{
|
||||
key: "paperclipai/paperclip/security-review",
|
||||
@@ -125,6 +130,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 3 * 86_400_000,
|
||||
sourceBadge: "github",
|
||||
},
|
||||
{
|
||||
key: "yuki/commit-perfect",
|
||||
@@ -145,6 +151,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: 0,
|
||||
sourceBadge: "skills_sh",
|
||||
},
|
||||
{
|
||||
key: "astra/deep-research",
|
||||
@@ -165,6 +172,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 4 * 86_400_000,
|
||||
sourceBadge: "local",
|
||||
},
|
||||
{
|
||||
key: "paperclipai/paperclip/paperclip",
|
||||
@@ -185,6 +193,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
|
||||
required: true,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 30 * 86_400_000,
|
||||
sourceBadge: "paperclip",
|
||||
},
|
||||
{
|
||||
key: "paperclipai/paperclip/diagnose-why-work-stopped",
|
||||
@@ -205,6 +214,7 @@ const MOCK_CARDS: DiscoveryCard[] = [
|
||||
required: true,
|
||||
forkedFrom: false,
|
||||
updatedAt: Date.now() - 28 * 86_400_000,
|
||||
sourceBadge: "url",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user