diff --git a/client/src/App.tsx b/client/src/App.tsx index af3db2b..628f2a5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,6 +8,7 @@ import { LoginPage } from '@/pages/LoginPage' import { MembersPage } from '@/pages/MembersPage' import { OrgChartPage } from '@/pages/OrgChartPage' import { PerformancePage } from '@/pages/PerformancePage' +import { ProductProfilesPage } from '@/pages/ProductProfilesPage' import { ReviewsPage } from '@/pages/ReviewsPage' import { SeatsPage } from '@/pages/SeatsPage' import { SkillsPage } from '@/pages/SkillsPage' @@ -31,6 +32,7 @@ export default function App() { : } /> : } /> : } /> + : } /> : } /> } /> diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index cdf2c1e..d20ccd9 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -12,6 +12,7 @@ import { LayoutDashboard, LogOut, Network, + Package, ShieldCheck, Users, } from 'lucide-react' @@ -46,6 +47,7 @@ export function AppShell({ children }: { children: ReactNode }) { + diff --git a/client/src/pages/ProductProfilesPage.tsx b/client/src/pages/ProductProfilesPage.tsx new file mode 100644 index 0000000..14e2ba5 --- /dev/null +++ b/client/src/pages/ProductProfilesPage.tsx @@ -0,0 +1,396 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Boxes, Download, Eye, GitFork, Pencil, Plus, Store, Upload } from 'lucide-react' +import { toast } from 'sonner' +import { AppShell } from '@/components/AppShell' +import { MarkdownEditor } from '@/components/MarkdownEditor' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet' +import { api } from '@/lib/api' +import { bumpPatch } from '@/lib/semver' +import { cn } from '@/lib/utils' +import { groupVersions } from '@/lib/versionedLibrary' +import { useAuth } from '@/store/auth' + +interface ProductProfileSummary { + id: string + organizationId: string | null + origin: string + profileKey: string + name: string + version: string + summary: string | null + visibility: string + status: string +} + +interface ProductProfileDetail { + profile: ProductProfileSummary + body: string +} + +interface MarketplaceEntry { + profile: ProductProfileSummary + alreadyInLibrary: boolean +} + +interface Product { + id: string + name: string +} + +const TEMPLATE = `--- +product: My product +version: 1.0.0 +summary: One-line description +--- + +# About this product + +What it is, who it serves, and the conventions every agent on it should follow. +This identity is shared by every agent across the product's teams. +` + +/** Reconstruct an editable PRODUCT.md (frontmatter + body) from a stored profile. */ +function toMarkdown(d: ProductProfileDetail, version?: string): string { + const p = d.profile + const lines = [`product: ${p.name}`, `version: ${version ?? p.version}`] + if (p.summary) lines.push(`summary: ${p.summary}`) + return `---\n${lines.join('\n')}\n---\n\n${d.body}` +} + +/** The org's product-profile library (PRODUCT.md): free builtins + the company's own, versioned. */ +export function ProductProfilesPage() { + const organizationId = useAuth((s) => s.organizationId) + const [tab, setTab] = useState<'library' | 'marketplace'>('library') + const [profiles, setProfiles] = useState([]) + const [marketplace, setMarketplace] = useState([]) + const [products, setProducts] = useState([]) + const [editor, setEditor] = useState<{ title: string; content: string } | null>(null) + const [preview, setPreview] = useState<{ title: string; content: string } | null>(null) + const [busy, setBusy] = useState(false) + + const load = useCallback(async () => { + if (!organizationId) return + try { + const [lib, market, prods] = await Promise.all([ + api.get(`/api/orgboard/product-profiles?organizationId=${organizationId}`), + api.get(`/api/orgboard/product-profiles/marketplace?organizationId=${organizationId}`), + api.get(`/api/orgboard/products?organizationId=${organizationId}`), + ]) + setProfiles(lib) + setMarketplace(market) + setProducts(prods) + } catch (err) { + toast.error((err as Error).message) + } + }, [organizationId]) + + useEffect(() => { + void load() + }, [load]) + + const groups = useMemo(() => groupVersions(profiles, (p) => p.profileKey), [profiles]) + + const run = async (action: () => Promise, ok: string) => { + setBusy(true) + try { + await action() + toast.success(ok) + await load() + } catch (err) { + toast.error((err as Error).message) + } finally { + setBusy(false) + } + } + + const fetchVersion = async (key: string, version: string) => { + const versions = await api.get(`/api/orgboard/product-profiles/${key}?organizationId=${organizationId}`) + return versions.find((x) => x.profile.version === version) ?? versions[0] ?? null + } + + const openEditor = async (key: string, version: string, mode: 'edit' | 'version') => { + try { + const d = await fetchVersion(key, version) + if (!d) return + setEditor({ + title: mode === 'version' ? `New version of ${key}` : `Edit ${key}`, + content: toMarkdown(d, mode === 'version' ? bumpPatch(d.profile.version) : undefined), + }) + } catch (err) { + toast.error((err as Error).message) + } + } + + const openView = async (key: string, version: string) => { + try { + const d = await fetchVersion(key, version) + if (!d) return + setPreview({ title: `${d.profile.name} · ${d.profile.version}`, content: toMarkdown(d) }) + } catch (err) { + toast.error((err as Error).message) + } + } + + const upload = () => + run(async () => { + if (!editor) return + await api.post('/api/orgboard/product-profiles/upload', { organizationId, content: editor.content }) + setEditor(null) + }, 'Profile saved.') + + const fork = (key: string, version: string) => + run(() => api.post(`/api/orgboard/product-profiles/${key}/fork`, { organizationId, version }), `Forked ${key} into your org.`) + const setListed = (key: string, version: string, listed: boolean) => + run( + () => api.post(`/api/orgboard/product-profiles/${key}/${listed ? 'publish' : 'unpublish'}`, { organizationId, version }), + listed ? `Published ${key}@${version}.` : `Unlisted ${key}@${version}.`, + ) + const install = (sourceProfileId: string, name: string) => + run(() => api.post('/api/orgboard/product-profiles/install', { organizationId, sourceProfileId }), `Installed ${name}.`) + const apply = (key: string, version: string, productId: string, productName: string) => + run( + () => api.post(`/api/orgboard/product-profiles/${key}/apply`, { organizationId, productId, version }), + `Applied to ${productName} — every agent on it now shares this identity.`, + ) + + return ( + +
+
+
+

+ Product profiles +

+

+ Reusable product identities as PRODUCT.md. Author, version, apply to a product, and publish your own. +

+
+ +
+ +
+ {(['library', 'marketplace'] as const).map((t) => ( + + ))} +
+ + {tab === 'library' ? ( +
+ {groups.map(([key, versions]) => ( + openView(key, v)} + onEdit={(v) => openEditor(key, v, 'edit')} + onNewVersion={(v) => openEditor(key, v, 'version')} + onFork={(v) => fork(key, v)} + onPublish={(v) => setListed(key, v, true)} + onUnpublish={(v) => setListed(key, v, false)} + onApply={(v, productId, productName) => apply(key, v, productId, productName)} + /> + ))} + {groups.length === 0 &&

No product profiles yet — upload a PRODUCT.md to start.

} +
+ ) : ( +
+

+ Product profiles other organizations have published. Install a private copy to use or customize. +

+ {marketplace.map(({ profile: p, alreadyInLibrary }) => ( + + + + {p.name} {p.version} + {p.profileKey} + + {p.summary} + + + {alreadyInLibrary ? ( + In your library + ) : ( + + )} + + + ))} + {marketplace.length === 0 && ( +

Nothing published yet. Publish one of your own to share it here.

+ )} +
+ )} +
+ + {editor && ( + !o && setEditor(null)}> + + + {editor.title} + + A PRODUCT.md: YAML frontmatter (product, version, summary) + a Markdown brief. Re-uploading the + same product+version updates it; bump the version for a new one. + + +
+ setEditor({ ...editor, content })} + /> +
+ + +
+
+
+
+ )} + + {preview && ( + !o && setPreview(null)}> + + + {preview.title} + The full PRODUCT.md — read-only. Fork or make a new version to edit. + +
+ +
+
+
+ )} +
+ ) +} + +function ProfileGroupCard({ + versions, + products, + busy, + onView, + onEdit, + onNewVersion, + onFork, + onPublish, + onUnpublish, + onApply, +}: { + versions: ProductProfileSummary[] + products: Product[] + busy: boolean + onView: (version: string) => void + onEdit: (version: string) => void + onNewVersion: (version: string) => void + onFork: (version: string) => void + onPublish: (version: string) => void + onUnpublish: (version: string) => void + onApply: (version: string, productId: string, productName: string) => void +}) { + const [selected, setSelected] = useState(versions[0].version) + const current = versions.find((v) => v.version === selected) ?? versions[0] + const isBuiltin = current.origin === 'Builtin' + const isListed = current.visibility === 'Public' + const canPublish = !isBuiltin && current.status === 'Published' + + return ( + + +
+
+ + {current.name} + {current.profileKey} + + {current.summary} +
+
+ {current.status} + {current.origin} +
+
+
+ + {versions.length > 1 ? ( + + ) : ( + {current.version} + )} + {isListed && Listed} + + {products.length > 0 && ( + + )} + +
+ + {isBuiltin ? ( + + ) : ( + <> + + {isListed ? ( + + ) : canPublish ? ( + + ) : null} + + )} + +
+
+
+ ) +}