diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 35b0c8568b2..56263e7fc04 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -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}` : ""} + {/* 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 ( + + + ); + })()} {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("all"); + const availableSources = useMemo(() => { + const set = new Set(); + 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({ + {availableSources.length > 1 ? ( + + + + + + + All sources + {availableSources.map((badge) => ( + + {sourceMeta(badge as CompanySkillSourceBadge, null).label} + + ))} + + + + ) : null} - ) : (search || activeCategory) ? ( + ) : (search || activeCategory || sourceFilterActive) ? (
)}
- -
@@ -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; @@ -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(null); const [rightVersionId, setRightVersionId] = useState(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 ? : ; } @@ -2598,7 +2676,7 @@ export function SkillDetailPage({ function renderFilesBody() { return ( -
+
{/* 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. */}
- Times this skill has been installed or forked. + Agents in this company that currently have this skill installed.
- {detail.sourceType === "github" ? ( - <> - - {updateStatus?.supported && updateStatus.hasUpdate ? ( - - ) : null} - - ) : null}
@@ -2987,8 +3050,6 @@ export function SkillDetailPage({
{/* Big primary action opens the agent multi-selector (PAP-10907). */} agent.id)} versions={versions} @@ -3028,59 +3089,112 @@ export function SkillDetailPage({
- {/* 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" ? ( +
+
Updates
+
+
+ Pin + {currentPin ?? "untracked"} +
+ + {updateStatus?.supported && updateStatus.hasUpdate ? ( + + ) : updateStatus?.supported && !updateStatus.hasUpdate && !updateStatusLoading ? ( +

Up to date.

+ ) : null} +
+
+ ) : null} + + {/* Config lives behind a gear; sharing + danger zone open in a modal + (PAP-10907 A). */}
-
Sharing
-
- {(["company", "private"] as const).map((scope) => ( - - ))} - - {/* Remove lives in the sidebar and only appears when the viewer can - edit (and therefore remove) the skill (PAP-10907). */} - {detail.editable ? ( - - ) : null} -
+
+ + {/* Floating save bar: stays visible while a file edit is dirty so the + unsaved state is obvious (PAP-10907 J). */} + {isDirty ? ( +
+ Unsaved changes + + +
+ ) : null} + + + + + Skill settings + Manage how {detail.name} is shared. + +
+
+ + +

Public link sharing is coming later.

+
+ {detail.editable ? ( +
+
Danger zone
+
+

Remove this skill from the company library.

+ +
+
+ ) : null} +
+
+
); } @@ -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({
Used by 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("agents"); const [createDialogOpen, setCreateDialogOpen] = useState(false); @@ -4013,7 +4120,6 @@ export function CompanySkills() { ); const affected = new Set([...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()} @@ -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} diff --git a/ui/storybook/stories/skills-store-detail.stories.tsx b/ui/storybook/stories/skills-store-detail.stories.tsx index 5183edb9ff4..888f0a66db1 100644 --- a/ui/storybook/stories/skills-store-detail.stories.tsx +++ b/ui/storybook/stories/skills-store-detail.stories.tsx @@ -120,7 +120,6 @@ const MOCK_ATTACH_AGENTS = [ function SkillDetailHarness({ initialTab = "overview" as DetailTab }: { initialTab?: DetailTab }) { const [activeTab, setActiveTab] = useState(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()} onToggleDir={() => {}} diff --git a/ui/storybook/stories/skills-store-discovery.stories.tsx b/ui/storybook/stories/skills-store-discovery.stories.tsx index 5abd404d40c..eb13b82b80b 100644 --- a/ui/storybook/stories/skills-store-discovery.stories.tsx +++ b/ui/storybook/stories/skills-store-discovery.stories.tsx @@ -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", }, ];