From 4344ad6b1a45360c28140d5f1f1a678c4ebeb044 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 3 Jun 2026 19:44:59 +0000 Subject: [PATCH] PAP-10256 Team Catalog UI: INSTALLED group, out-of-date badge, Re-install CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consume the installed-state endpoint to surface installed/out-of-date teams from the server signal (PAP-10238 §3.2 + §5): - list: installed teams collapse under `INSTALLED · N`; out-of-date rows show an amber chevron-up badge with a tooltip - detail: `Installed` badge when current, `Update available` chip when stale, and the primary CTA switches to `Re-install latest` - invalidate installed/agents queries after an install completes - design-guide + Storybook coverage via shared installed-state fixtures - tests: INSTALLED group, out-of-date vs current rendering Co-Authored-By: Paperclip --- ui/src/api/teamCatalog.ts | 5 + ui/src/lib/queryKeys.ts | 1 + ui/src/pages/DesignGuide.tsx | 13 ++- ui/src/pages/TeamCatalog.fixtures.ts | 20 ++++ ui/src/pages/TeamCatalog.test.tsx | 51 +++++++++ ui/src/pages/TeamCatalog.tsx | 107 ++++++++++++++++-- ui/storybook/stories/team-catalog.stories.tsx | 28 +++++ 7 files changed, 216 insertions(+), 9 deletions(-) diff --git a/ui/src/api/teamCatalog.ts b/ui/src/api/teamCatalog.ts index 5e4cd8e3f95..42f5fcfbf7a 100644 --- a/ui/src/api/teamCatalog.ts +++ b/ui/src/api/teamCatalog.ts @@ -6,6 +6,7 @@ import type { CatalogTeamInstallOptions, CatalogTeamInstallResult, CatalogTeamKind, + InstalledCatalogTeam, } from "@paperclipai/shared"; import { api } from "./client"; @@ -35,6 +36,10 @@ export const teamCatalogApi = { }, catalogDetail: (catalogRef: string) => api.get(`/teams/catalog/${encodeURIComponent(catalogRef)}`), + installed: (companyId: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/teams/catalog/installed`, + ), catalogFile: (catalogRef: string, relativePath = "TEAM.md") => api.get( `/teams/catalog/${encodeURIComponent(catalogRef)}/files?path=${encodeURIComponent(relativePath)}`, diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index f6361e1d31c..67214b276ce 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -23,6 +23,7 @@ export const queryKeys = { catalogDetail: (catalogRef: string) => ["team-catalog", "catalog", "detail", catalogRef] as const, catalogFile: (catalogRef: string, relativePath: string) => ["team-catalog", "catalog", "file", catalogRef, relativePath] as const, + installed: (companyId: string) => ["team-catalog", "installed", companyId] as const, }, agents: { list: (companyId: string) => ["agents", companyId] as const, diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 1ac9db65146..6a89948475d 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -138,8 +138,10 @@ import { TeamRow, } from "@/pages/TeamCatalog"; import { + currentInstalledState, onboardingTeams, optionalTeam, + outOfDateInstalledState, sampleSkillPreparations, sampleTeam, warnTeam, @@ -1474,8 +1476,17 @@ export function DesignGuide() { Optional · 2 {}} /> - {}} /> +
+ Installed · 2 +
+ {}} installed={outOfDateInstalledState} /> + {}} installed={currentInstalledState} /> +

+ Installed teams collapse under INSTALLED · N; an out-of-date + install (server originHash ≠ catalog contentHash) + shows the amber ↑ badge (PAP-10256). +

diff --git a/ui/src/pages/TeamCatalog.fixtures.ts b/ui/src/pages/TeamCatalog.fixtures.ts index bf4f6f36272..c3f93191f85 100644 --- a/ui/src/pages/TeamCatalog.fixtures.ts +++ b/ui/src/pages/TeamCatalog.fixtures.ts @@ -1,6 +1,7 @@ import type { CatalogTeam, CatalogTeamSkillPreparation, + InstalledCatalogTeam, } from "@paperclipai/shared"; // --------------------------------------------------------------------------- @@ -95,6 +96,25 @@ export const warnTeam: CatalogTeam = { ], }; +// Server-computed installed-team state (PAP-10256). Drives the `INSTALLED · N` +// group, the per-row out-of-date badge, and the detail header chip. Shared by +// the Storybook stories and /design-guide showcase so they stay in sync. +export const outOfDateInstalledState: InstalledCatalogTeam = { + catalogId: sampleTeam.id, + catalogKey: sampleTeam.key, + present: true, + currentContentHash: sampleTeam.contentHash, + installedOriginHashes: ["sha256:0000older0000older0000older"], + agentCount: 3, + outOfDate: true, +}; + +export const currentInstalledState: InstalledCatalogTeam = { + ...outOfDateInstalledState, + installedOriginHashes: [sampleTeam.contentHash], + outOfDate: false, +}; + export const sampleSkillPreparations: CatalogTeamSkillPreparation[] = [ { type: "catalog", ref: "engineering/code-review", agentSlugs: ["cto"], action: "already_in_package", catalogSkillId: "skill-1", catalogSkillKey: "engineering/code-review", sourceLocator: null, sourceRef: null, reason: null }, { type: "github", ref: "acme/growth-playbook@v1.2.0", agentSlugs: ["cmo"], action: "external_import_required", catalogSkillId: null, catalogSkillKey: null, sourceLocator: "github.com/acme/growth-playbook", sourceRef: "v1.2.0", reason: "Resolved from GitHub at install time" }, diff --git a/ui/src/pages/TeamCatalog.test.tsx b/ui/src/pages/TeamCatalog.test.tsx index d1c3ed167ae..5dceafe0fdd 100644 --- a/ui/src/pages/TeamCatalog.test.tsx +++ b/ui/src/pages/TeamCatalog.test.tsx @@ -17,6 +17,7 @@ const mockTeamCatalogApi = vi.hoisted(() => ({ catalogFile: vi.fn(), preview: vi.fn(), install: vi.fn(), + installed: vi.fn(), })); const mockAgentsApi = vi.hoisted(() => ({ @@ -201,6 +202,7 @@ describe("TeamCatalog install preview path", () => { document.body.appendChild(container); currentRoute = "team-no-deps"; mockAgentsApi.list.mockResolvedValue([]); + mockTeamCatalogApi.installed.mockResolvedValue([]); mockTeamCatalogApi.catalogList.mockResolvedValue([makeTeam()]); mockTeamCatalogApi.preview.mockResolvedValue(makePreview()); mockTeamCatalogApi.install.mockResolvedValue({ @@ -387,4 +389,53 @@ describe("TeamCatalog install preview path", () => { // Preview has not been requested yet (still on step 1). expect(mockTeamCatalogApi.preview).not.toHaveBeenCalled(); }); + + it("surfaces the INSTALLED group, out-of-date badge, and Re-install CTA from the server signal", async () => { + mockTeamCatalogApi.installed.mockResolvedValue([ + { + catalogId: "team-no-deps", + catalogKey: "paperclipai/bundled/company-defaults/team-no-deps", + present: true, + currentContentHash: "sha256:deadbeefdeadbeefdeadbeef", + installedOriginHashes: ["sha256:stale"], + agentCount: 2, + outOfDate: true, + }, + ]); + + await renderPage(); + + // List group header reflects the installed team, not Bundled. + expect(document.body.textContent).toContain("Installed · 1"); + expect(document.body.textContent).not.toContain("Bundled · 1"); + + // Detail header chip + Re-install CTA replace the plain Install button. + expect(document.body.textContent).toContain("Update available"); + expect(findButton("Re-install latest")).toBeTruthy(); + expect(findButton("Install team")).toBeFalsy(); + + // The out-of-date badge in the list row exposes an accessible label. + expect(document.querySelector('[aria-label="Update available"]')).toBeTruthy(); + }); + + it("renders an Installed badge (no update chip) when the installed team is current", async () => { + mockTeamCatalogApi.installed.mockResolvedValue([ + { + catalogId: "team-no-deps", + catalogKey: "paperclipai/bundled/company-defaults/team-no-deps", + present: true, + currentContentHash: "sha256:deadbeefdeadbeefdeadbeef", + installedOriginHashes: ["sha256:deadbeefdeadbeefdeadbeef"], + agentCount: 2, + outOfDate: false, + }, + ]); + + await renderPage(); + + expect(document.body.textContent).toContain("Installed · 1"); + expect(document.body.textContent).toContain("Installed"); + expect(document.body.textContent).not.toContain("Update available"); + expect(findButton("Re-install latest")).toBeTruthy(); + }); }); diff --git a/ui/src/pages/TeamCatalog.tsx b/ui/src/pages/TeamCatalog.tsx index 555362883ea..86df68c2a33 100644 --- a/ui/src/pages/TeamCatalog.tsx +++ b/ui/src/pages/TeamCatalog.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams, useSearchParams } from "@/lib/router"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { Agent, CatalogTeam, @@ -14,6 +14,7 @@ import type { CatalogTeamImportOptions, CatalogTeamInstallOptions, CatalogTeamInstallResult, + InstalledCatalogTeam, CompanyPortabilityAdapterOverride, CompanyPortabilityCollisionStrategy, } from "@paperclipai/shared"; @@ -82,6 +83,7 @@ import { ChevronDown, ChevronLeft, ChevronRight, + ChevronUp, Cpu, Crown, Download, @@ -641,6 +643,7 @@ export function TeamDetailPane({ onInstall, canInstall, fileContent, + installed, }: { team: CatalogTeam; selectedPath: string | null; @@ -648,11 +651,14 @@ export function TeamDetailPane({ onInstall: () => void; canInstall: boolean; fileContent: string | null; + installed?: InstalledCatalogTeam | null; }) { const [expandedDirs, setExpandedDirs] = useState>(new Set()); const tree = useMemo(() => buildTree(team.files), [team.files]); const invalid = team.compatibility === "invalid"; const unsafe = team.trustLevel === "scripts_executables"; + const isInstalled = Boolean(installed); + const outOfDate = Boolean(installed?.outOfDate); const toggleDir = (name: string) => setExpandedDirs((current) => { @@ -662,10 +668,22 @@ export function TeamDetailPane({ return next; }); + // Installed teams default to update/re-install semantics; out-of-date teams + // get the primary amber affordance (design §5 / PAP-10256). const installButton = ( - ); @@ -684,6 +702,19 @@ export function TeamDetailPane({ + {isInstalled && !outOfDate && ( + + Installed + + )} + {outOfDate && ( + + Update available + + )} {invalid ? ( @@ -2001,12 +2032,15 @@ export function TeamRow({ team, selected, onSelect, + installed, }: { team: CatalogTeam; selected: boolean; onSelect: () => void; + installed?: InstalledCatalogTeam | null; }) { const risk = teamRisk(team); + const outOfDate = Boolean(installed?.outOfDate); return (