3 ? "sm:grid-cols-4" : "sm:grid-cols-3",
+ )}
+ >
{items.map((item, index) => (
(null);
+
+ function openCommunityCatalog(target: "agents" | "teams") {
+ personas.clearFeedback("catalog");
+ void personas.catalogQuery.refetch();
+ void teamActions.catalogQuery.refetch();
+ setCatalogLaunchTarget(target);
+ }
+
const isActionPending =
agents.isPending ||
personas.isPending ||
@@ -262,7 +276,7 @@ export function AgentsView() {
isPersonasLoading={personas.personasQuery.isLoading}
isPersonasPending={personas.isPending}
onCreatePersona={openUnifiedCreate}
- onDiscoverPersonas={personas.openCatalog}
+ onDiscoverPersonas={() => openCommunityCatalog("agents")}
onDuplicatePersona={personas.openDuplicate}
onEditPersona={personas.openEdit}
onSharePersona={personas.openShare}
@@ -292,6 +306,7 @@ export function AgentsView() {
onDuplicate={teamActions.openDuplicateDialog}
onEdit={teamActions.openEditDialog}
onAddToChannel={teamActions.setTeamToAddToChannel}
+ onDiscover={() => openCommunityCatalog("teams")}
onShare={teamActions.openShare}
onImport={() => {
teamImportInputRef.current?.click();
@@ -501,13 +516,17 @@ export function AgentsView() {
}}
/>
) : null}
- {personas.isCatalogDialogOpen ? (
-
{
- personas.clearFeedback("catalog");
- }}
- onOpenChange={personas.setIsCatalogDialogOpen}
+ onClearFeedback={() => personas.clearFeedback("catalog")}
onSelectPersona={(persona, active) => {
void personas.handleSetActive(persona, active, "catalog");
}}
- open={personas.isCatalogDialogOpen}
- personas={personas.catalogPersonas}
+ // Team side
+ teams={teamActions.catalogTeams}
+ teamsError={
+ teamActions.catalogQuery.error instanceof Error
+ ? teamActions.catalogQuery.error
+ : null
+ }
+ teamsLoading={teamActions.catalogQuery.isLoading}
+ teamsAdding={teamActions.isAddingFromCatalog}
+ onAddTeam={(team) => {
+ void teamActions.handleAddTeamFromCatalog(team, () =>
+ setCatalogLaunchTarget(null),
+ );
+ }}
+ // Dialog
+ open={catalogLaunchTarget !== null}
+ preferSection={catalogLaunchTarget}
+ onOpenChange={(open) => {
+ if (!open) setCatalogLaunchTarget(null);
+ }}
/>
) : null}
{teamActions.teamDialogState ? (
@@ -588,11 +620,23 @@ export function AgentsView() {
) : null}
{teamActions.teamToShare ? (
{
+ if (teamActions.teamToShare) {
+ void teamActions.setTeamCatalogShareLevel(
+ teamActions.teamToShare,
+ shareLevel,
+ );
+ }
+ }}
onExport={() => {
if (teamActions.teamToShare) {
const team = teamActions.teamToShare;
diff --git a/desktop/src/features/agents/ui/CommunityCatalogDialog.tsx b/desktop/src/features/agents/ui/CommunityCatalogDialog.tsx
new file mode 100644
index 0000000000..30c9965323
--- /dev/null
+++ b/desktop/src/features/agents/ui/CommunityCatalogDialog.tsx
@@ -0,0 +1,678 @@
+import * as React from "react";
+
+import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
+import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay";
+import type { CatalogTeam } from "@/features/agents/lib/teamCatalogRelay";
+import { useUsersBatchQuery } from "@/features/profile/hooks";
+import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import type { AgentPersona } from "@/shared/api/types";
+import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
+import { cn } from "@/shared/lib/cn";
+import { Button } from "@/shared/ui/button";
+import { Dialog } from "@/shared/ui/dialog";
+import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
+import { Markdown } from "@/shared/ui/markdown";
+import { Skeleton } from "@/shared/ui/skeleton";
+import { ChevronDown } from "lucide-react";
+
+import agentOutlineUrl from "../assets/agent-outline.svg";
+import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata";
+import { PersonaAddedBy } from "./PersonaAddedBy";
+import { resolveCatalogOwnerLabel } from "./catalogOwnerLabel";
+import { nextCatalogSelection } from "./communityCatalogSelection";
+
+// Inline instruction markdown class — no external consumers so kept internal.
+const agentInstructionMarkdownClassName = [
+ "mt-3 w-full min-w-0 max-w-full overflow-x-hidden leading-6 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [&_.code-block-lines]:min-w-0 [&_.code-block-lines]:max-w-full [&_.code-block-lines]:whitespace-pre-wrap [&_.code-block-lines]:[overflow-wrap:anywhere] [&_.inline-code-chip]:max-w-full [&_.inline-code-chip]:whitespace-pre-wrap [&_.inline-code-chip]:[overflow-wrap:anywhere] [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground",
+ "[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground",
+ "[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground",
+ "[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground",
+ "[&>h4]:!text-sm [&>h4]:!font-semibold [&>h4]:!leading-6 [&>h4]:!tracking-normal [&>h4]:!text-foreground",
+ "[&>h5]:!text-sm [&>h5]:!font-semibold [&>h5]:!leading-6 [&>h5]:!tracking-normal [&>h5]:!text-foreground",
+ "[&>h6]:!text-sm [&>h6]:!font-semibold [&>h6]:!leading-6 [&>h6]:!tracking-normal [&>h6]:!text-foreground",
+].join(" ");
+
+// ── Type-tagged selection keys ────────────────────────────────────────────────
+
+// IDs are implementation-defined strings; prefixing prevents collision between
+// persona IDs and team coordinates.
+type CatalogSelectionKey =
+ | { kind: "persona"; id: string }
+ | { kind: "team"; key: string };
+
+function selectionKeyFor(persona: AgentPersona): CatalogSelectionKey {
+ return { kind: "persona", id: persona.id };
+}
+
+function teamSelectionKey(team: CatalogTeam): string {
+ return `${team.ownerPubkey}:${team.teamDTag}`;
+}
+
+function teamKeyFor(team: CatalogTeam): CatalogSelectionKey {
+ return { kind: "team", key: teamSelectionKey(team) };
+}
+
+function encodeKey(k: CatalogSelectionKey): string {
+ return k.kind === "persona" ? `p:${k.id}` : `t:${k.key}`;
+}
+
+// ── Props ─────────────────────────────────────────────────────────────────────
+
+type CommunityCatalogDialogProps = {
+ // Persona side
+ personas: AgentPersona[];
+ personasError: Error | null;
+ personasLoading: boolean;
+ personasPending: boolean;
+ feedbackErrorMessage: string | null;
+ feedbackNoticeMessage: string | null;
+ onClearFeedback: () => void;
+ onSelectPersona: (persona: AgentPersona, active: boolean) => void;
+
+ // Team side
+ teams: CatalogTeam[];
+ teamsError: Error | null;
+ teamsLoading: boolean;
+ teamsAdding: boolean;
+ onAddTeam: (team: CatalogTeam) => void;
+
+ // Dialog
+ open: boolean;
+ preferSection: "agents" | "teams";
+ onOpenChange: (open: boolean) => void;
+};
+
+// ── CommunityCatalogDialog ────────────────────────────────────────────────────
+
+export function CommunityCatalogDialog({
+ personas,
+ personasError,
+ personasLoading,
+ personasPending,
+ feedbackErrorMessage,
+ feedbackNoticeMessage,
+ onClearFeedback,
+ onSelectPersona,
+ teams,
+ teamsError,
+ teamsLoading,
+ teamsAdding,
+ onAddTeam,
+ open,
+ preferSection,
+ onOpenChange,
+}: CommunityCatalogDialogProps) {
+ const contentRef = React.useRef(null);
+
+ // Selected item — a type-tagged key guarantees persona IDs and team
+ // coordinates cannot collide.
+ const [selectedEncodedKey, setSelectedEncodedKey] = React.useState<
+ string | null
+ >(null);
+
+ // Tracks whether the user has explicitly clicked an item since the dialog
+ // opened. When true, automatic initialization must not overwrite it.
+ const [userHasSelected, setUserHasSelected] = React.useState(false);
+
+ // Compute the preferred first item for each section.
+ const firstPersonaKey =
+ personas.length > 0 ? encodeKey(selectionKeyFor(personas[0])) : null;
+ const firstTeamKey =
+ teams.length > 0 ? encodeKey(teamKeyFor(teams[0])) : null;
+
+ // Whether the current selection still exists in the live data. Used to
+ // detect when a live relay refresh retracts an item the user had selected.
+ const currentIsValid =
+ selectedEncodedKey !== null &&
+ (personas.some(
+ (p) => encodeKey(selectionKeyFor(p)) === selectedEncodedKey,
+ ) ||
+ teams.some((t) => encodeKey(teamKeyFor(t)) === selectedEncodedKey));
+
+ // When the dialog opens or any dependency changes, run the selection
+ // initialization logic. Respects the launch preference without committing
+ // a cross-section fallback while the preferred section is still loading,
+ // and never overwrites a still-valid explicit user selection.
+ React.useEffect(() => {
+ if (!open) {
+ // Reset user-selection guard whenever the dialog closes so the next
+ // open gets a fresh auto-init.
+ setUserHasSelected(false);
+ return;
+ }
+
+ // If the user's explicit selection was retracted by a live refresh, clear
+ // the intent guard so the replacement is treated as auto-init.
+ if (userHasSelected && !currentIsValid) {
+ setUserHasSelected(false);
+ }
+
+ setSelectedEncodedKey((current) =>
+ nextCatalogSelection(
+ current,
+ userHasSelected,
+ currentIsValid,
+ preferSection,
+ personasLoading,
+ teamsLoading,
+ firstPersonaKey,
+ firstTeamKey,
+ ),
+ );
+ }, [
+ open,
+ preferSection,
+ personasLoading,
+ teamsLoading,
+ firstPersonaKey,
+ firstTeamKey,
+ userHasSelected,
+ currentIsValid,
+ ]);
+
+ useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage);
+
+ // Resolve current selection.
+ const selectedPersona = selectedEncodedKey?.startsWith("p:")
+ ? (personas.find((p) => p.id === selectedEncodedKey.slice(2)) ?? null)
+ : null;
+ const selectedTeamKey = selectedEncodedKey?.startsWith("t:")
+ ? selectedEncodedKey.slice(2)
+ : null;
+ const selectedTeam = selectedTeamKey
+ ? (teams.find((t) => teamSelectionKey(t) === selectedTeamKey) ?? null)
+ : null;
+
+ const selectedPersonaIsActive = selectedPersona
+ ? isCatalogPersonaSelected(selectedPersona)
+ : false;
+ const selectedTeamIsAdded = selectedTeam?.localTeam != null;
+ const selectedTeamIsInvalid = (selectedTeam?.invalidMemberCount ?? 0) > 0;
+
+ const bothEmpty =
+ !personasLoading &&
+ !teamsLoading &&
+ personas.length === 0 &&
+ teams.length === 0;
+ const noError = !personasError && !teamsError;
+
+ function handleUseAgent() {
+ if (!selectedPersona || selectedPersonaIsActive) return;
+ onClearFeedback();
+ onSelectPersona(selectedPersona, true);
+ }
+
+ function handleAddTeam() {
+ if (!selectedTeam || selectedTeamIsAdded || selectedTeamIsInvalid) return;
+ onAddTeam(selectedTeam);
+ }
+
+ return (
+
+ );
+}
+
+// ── Empty state ───────────────────────────────────────────────────────────────
+
+function CatalogEmptyState() {
+ return (
+
+
+

+
Nothing shared yet
+
+ Shared agents and teams will appear here.
+
+
+
+ );
+}
+
+// ── Skeleton loaders ──────────────────────────────────────────────────────────
+
+function CatalogListSkeleton() {
+ return (
+
+ {["first", "second", "third", "fourth", "fifth"].map((key) => (
+
+
+
+
+ ))}
+
+ );
+}
+
+function CatalogDetailSkeleton() {
+ return (
+
+ );
+}
+
+// ── Persona detail ────────────────────────────────────────────────────────────
+
+function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {
+ const isCommunityEntry =
+ isCatalogPersona(persona) && !persona.catalogSource.isOwn;
+ const ownerPubkey = isCommunityEntry
+ ? persona.catalogSource.ownerPubkey
+ : undefined;
+ const ownerBatchQuery = useUsersBatchQuery(ownerPubkey ? [ownerPubkey] : [], {
+ enabled: !!ownerPubkey,
+ });
+
+ let addedByLabel: string;
+ if (!isCommunityEntry) {
+ addedByLabel = "You";
+ } else {
+ const summary = ownerPubkey
+ ? ownerBatchQuery.data?.profiles[ownerPubkey.toLowerCase()]
+ : undefined;
+ addedByLabel = resolveCatalogOwnerLabel(summary);
+ }
+
+ return (
+
+
+
+
+
+ {persona.displayName}
+
+ {persona.isBuiltIn ? null : (
+
+ )}
+
+
+
+
+
+
+
+ Agent instruction
+
+
+
+
+ );
+}
+
+// ── Team detail ───────────────────────────────────────────────────────────────
+
+function TeamCatalogDetail({ team }: { team: CatalogTeam }) {
+ const ownerPubkey = team.isOwn ? undefined : team.ownerPubkey;
+ const ownerBatchQuery = useUsersBatchQuery(ownerPubkey ? [ownerPubkey] : [], {
+ enabled: !!ownerPubkey,
+ });
+
+ let addedByLabel: string;
+ if (team.isOwn) {
+ addedByLabel = "You";
+ } else {
+ const summary = ownerPubkey
+ ? ownerBatchQuery.data?.profiles[ownerPubkey.toLowerCase()]
+ : undefined;
+ addedByLabel = resolveCatalogOwnerLabel(summary);
+ }
+
+ const hasInstructions =
+ team.instructions !== null && team.instructions.trim().length > 0;
+
+ return (
+
+
+
+ {team.name}
+
+
+ {team.description ? (
+
+ {team.description}
+
+ ) : null}
+
+
+ {hasInstructions ? (
+
+
+ Team instructions
+
+
+
+ ) : null}
+
+
+
+ {team.members.length}{" "}
+ {team.members.length === 1 ? "member" : "members"}
+
+
+ {team.members.map((member) => (
+
+ ))}
+
+
+
+ );
+}
+
+type TeamCatalogMemberRowProps = {
+ member: CatalogTeam["members"][number];
+};
+
+function TeamCatalogMemberRow({ member }: TeamCatalogMemberRowProps) {
+ const [expanded, setExpanded] = React.useState(false);
+
+ return (
+
+
+
+ {expanded ? (
+
+
+
+
+ Agent instruction
+
+ {member.systemPrompt.trim().length > 0 ? (
+
+ ) : (
+
+ No instructions
+
+ )}
+
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx
deleted file mode 100644
index e1ec946092..0000000000
--- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx
+++ /dev/null
@@ -1,385 +0,0 @@
-import * as React from "react";
-
-import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
-import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay";
-import { useUsersBatchQuery } from "@/features/profile/hooks";
-import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
-import type { AgentPersona } from "@/shared/api/types";
-import { useFeedbackToasts } from "@/shared/hooks/useToastEffect";
-import { cn } from "@/shared/lib/cn";
-import { Button } from "@/shared/ui/button";
-import { Dialog } from "@/shared/ui/dialog";
-import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
-import { Markdown } from "@/shared/ui/markdown";
-import { Skeleton } from "@/shared/ui/skeleton";
-
-import agentOutlineUrl from "../assets/agent-outline.svg";
-import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata";
-import { PersonaAddedBy } from "./PersonaAddedBy";
-import { personaCatalogCopy } from "./personaLibraryCopy";
-
-type PersonaCatalogDialogProps = {
- error: Error | null;
- feedbackErrorMessage: string | null;
- feedbackNoticeMessage: string | null;
- isLoading: boolean;
- isPending: boolean;
- onClearFeedback: () => void;
- onOpenChange: (open: boolean) => void;
- onSelectPersona: (persona: AgentPersona, active: boolean) => void;
- open: boolean;
- personas: AgentPersona[];
-};
-
-const agentInstructionMarkdownClassName = [
- "mt-3 w-full min-w-0 max-w-full overflow-x-hidden leading-6 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [&_.code-block-lines]:min-w-0 [&_.code-block-lines]:max-w-full [&_.code-block-lines]:whitespace-pre-wrap [&_.code-block-lines]:[overflow-wrap:anywhere] [&_.inline-code-chip]:max-w-full [&_.inline-code-chip]:whitespace-pre-wrap [&_.inline-code-chip]:[overflow-wrap:anywhere] [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground",
- "[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground",
- "[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground",
- "[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground",
- "[&>h4]:!text-sm [&>h4]:!font-semibold [&>h4]:!leading-6 [&>h4]:!tracking-normal [&>h4]:!text-foreground",
- "[&>h5]:!text-sm [&>h5]:!font-semibold [&>h5]:!leading-6 [&>h5]:!tracking-normal [&>h5]:!text-foreground",
- "[&>h6]:!text-sm [&>h6]:!font-semibold [&>h6]:!leading-6 [&>h6]:!tracking-normal [&>h6]:!text-foreground",
-].join(" ");
-
-export function PersonaCatalogDialog({
- error,
- feedbackErrorMessage,
- feedbackNoticeMessage,
- isLoading,
- isPending,
- onClearFeedback,
- onOpenChange,
- onSelectPersona,
- open,
- personas,
-}: PersonaCatalogDialogProps) {
- const contentRef = React.useRef(null);
- const [selectedPersonaId, setSelectedPersonaId] = React.useState<
- string | null
- >(null);
- const selectedPersona = React.useMemo(() => {
- if (personas.length === 0) {
- return null;
- }
-
- return (
- personas.find((persona) => persona.id === selectedPersonaId) ??
- personas[0]
- );
- }, [personas, selectedPersonaId]);
-
- React.useEffect(() => {
- if (!open) {
- return;
- }
-
- if (personas.length === 0) {
- setSelectedPersonaId(null);
- return;
- }
-
- setSelectedPersonaId((current) =>
- current && personas.some((persona) => persona.id === current)
- ? current
- : personas[0].id,
- );
- }, [open, personas]);
-
- useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage);
-
- const selectedPersonaIsActive = selectedPersona
- ? isCatalogPersonaSelected(selectedPersona)
- : false;
-
- const handleUseSelectedPersona = () => {
- if (!selectedPersona || selectedPersonaIsActive) {
- return;
- }
-
- onClearFeedback();
- onSelectPersona(selectedPersona, true);
- };
-
- return (
-
- );
-}
-
-type PersonaCatalogChooserProps = {
- error: Error | null;
- isLoading: boolean;
- isPending: boolean;
- isSelectedPersonaActive: boolean;
- onUsePersona: () => void;
- onSelectPersona: (personaId: string) => void;
- personas: AgentPersona[];
- selectedPersona: AgentPersona | null;
- selectedPersonaId: string | null;
-};
-
-function PersonaCatalogChooser({
- error,
- isLoading,
- isPending,
- isSelectedPersonaActive,
- onUsePersona,
- onSelectPersona,
- personas,
- selectedPersona,
- selectedPersonaId,
-}: PersonaCatalogChooserProps) {
- if (!isLoading && personas.length === 0 && !error) {
- return (
-
-
-

-
- {personaCatalogCopy.emptyCatalogTitle}
-
-
- {personaCatalogCopy.emptyCatalogDescription}
-
-
-
- );
- }
-
- return (
-
-
-
- {isLoading ?
: null}
-
- {!isLoading && personas.length > 0 ? (
-
- {personas.map((persona) => {
- const isCurrent = persona.id === selectedPersonaId;
-
- return (
-
- );
- })}
-
- ) : null}
-
-
-
-
-
- {isLoading ?
: null}
-
- {!isLoading && selectedPersona ? (
-
- ) : null}
-
- {error ? (
-
- {error.message}
-
- ) : null}
-
-
-
-
-
-
-
- );
-}
-
-/**
- * Derives the "Added by" label for a catalog entry from a resolved profile
- * summary. Prefers `displayName`, falls back to `name`, then to the default
- * "Community member" string when both are absent, null, or whitespace-only.
- */
-export function resolveCatalogOwnerLabel(
- summary:
- | { displayName?: string | null; name?: string | null }
- | null
- | undefined,
-): string {
- return (
- summary?.displayName?.trim() || summary?.name?.trim() || "Community member"
- );
-}
-
-function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {
- const isCommunityEntry =
- isCatalogPersona(persona) && !persona.catalogSource.isOwn;
- const ownerPubkey = isCommunityEntry
- ? persona.catalogSource.ownerPubkey
- : undefined;
- const ownerBatchQuery = useUsersBatchQuery(ownerPubkey ? [ownerPubkey] : [], {
- enabled: !!ownerPubkey,
- });
-
- let addedByLabel: string;
- if (!isCommunityEntry) {
- addedByLabel = "You";
- } else {
- const summary = ownerPubkey
- ? ownerBatchQuery.data?.profiles[ownerPubkey.toLowerCase()]
- : undefined;
- addedByLabel = resolveCatalogOwnerLabel(summary);
- }
-
- return (
-
-
-
-
-
- {persona.displayName}
-
- {persona.isBuiltIn ? null : (
-
- )}
-
-
-
-
-
-
-
- Agent instruction
-
-
-
-
- );
-}
-
-function PersonaCatalogListSkeleton() {
- return (
-
- {["first", "second", "third", "fourth", "fifth"].map((key) => (
-
-
-
-
- ))}
-
- );
-}
-
-function PersonaCatalogDetailSkeleton() {
- return (
-
- );
-}
diff --git a/desktop/src/features/agents/ui/TeamShareDialog.tsx b/desktop/src/features/agents/ui/TeamShareDialog.tsx
index 179af32b6a..efce5cb69f 100644
--- a/desktop/src/features/agents/ui/TeamShareDialog.tsx
+++ b/desktop/src/features/agents/ui/TeamShareDialog.tsx
@@ -1,12 +1,18 @@
import * as React from "react";
+import { BookUser } from "lucide-react";
+import type { CatalogShareLevel } from "@/features/agents/lib/catalogRelay";
import { encodeTeamSnapshotForSend } from "@/shared/api/tauriTeams";
import type { AgentTeam } from "@/shared/api/types";
+import { Switch } from "@/shared/ui/switch";
import { SnapshotShareDialog } from "./PersonaShareDialog";
+import { teamCatalogCopy } from "./teamLibraryCopy";
type TeamShareDialogProps = {
+ catalogShareLevel: CatalogShareLevel;
isPending: boolean;
+ onCatalogShareLevelChange: (shareLevel: CatalogShareLevel) => void;
onExport: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
@@ -14,7 +20,9 @@ type TeamShareDialogProps = {
};
export function TeamShareDialog({
+ catalogShareLevel,
isPending,
+ onCatalogShareLevelChange,
onExport,
onOpenChange,
open,
@@ -28,6 +36,37 @@ export function TeamShareDialog({
return (
+
+
+
+ {teamCatalogCopy.shareTitle}
+
+
+ {teamCatalogCopy.shareDescription}
+
+
+
+ onCatalogShareLevelChange(checked ? "none" : "not-shared")
+ }
+ style={{ cursor: "default" }}
+ />
+
+ )
+ }
displayName={team.name}
encodeSnapshot={encodeSnapshot}
hasMemoryOptions
diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx
index c5a7a078b3..4a169d6292 100644
--- a/desktop/src/features/agents/ui/TeamsSection.tsx
+++ b/desktop/src/features/agents/ui/TeamsSection.tsx
@@ -21,6 +21,7 @@ import { SectionHeader } from "@/shared/ui/PageHeader";
import { CreateIdentityCard } from "./CreateIdentityCard";
import { TeamIdentityCard } from "./TeamIdentityCard";
import { IDENTITY_CARD_GRID_CLASS } from "./UnifiedAgentsSection";
+import { teamCatalogCopy } from "./teamLibraryCopy";
const TEAM_CARD_COLUMN_CLASS = "w-full";
@@ -36,6 +37,7 @@ type TeamsSectionProps = {
onDelete: (team: AgentTeam) => void;
onAddToChannel: (team: AgentTeam) => void;
onShare: (team: AgentTeam) => void;
+ onDiscover: () => void;
onImport: () => void;
};
@@ -51,6 +53,7 @@ export function TeamsSection({
onDelete,
onAddToChannel,
onShare,
+ onDiscover,
onImport,
}: TeamsSectionProps) {
return (
@@ -172,6 +175,7 @@ export function TeamsSection({
@@ -191,10 +195,12 @@ export function TeamsSection({
function NewTeamCard({
isPending,
onCreate,
+ onDiscover,
onImport,
}: {
isPending: boolean;
onCreate: () => void;
+ onDiscover: () => void;
onImport: () => void;
}) {
return (
@@ -209,6 +215,13 @@ function NewTeamCard({
Create team
+
+ {teamCatalogCopy.chooseFromCatalog}
+
Import
diff --git a/desktop/src/features/agents/ui/catalogOwnerLabel.ts b/desktop/src/features/agents/ui/catalogOwnerLabel.ts
new file mode 100644
index 0000000000..aad1d2d0c9
--- /dev/null
+++ b/desktop/src/features/agents/ui/catalogOwnerLabel.ts
@@ -0,0 +1,15 @@
+/**
+ * Derives the "Added by" label for a catalog entry from a resolved profile
+ * summary. Prefers `displayName`, falls back to `name`, then to the default
+ * "Community member" string when both are absent, null, or whitespace-only.
+ */
+export function resolveCatalogOwnerLabel(
+ summary:
+ | { displayName?: string | null; name?: string | null }
+ | null
+ | undefined,
+): string {
+ return (
+ summary?.displayName?.trim() || summary?.name?.trim() || "Community member"
+ );
+}
diff --git a/desktop/src/features/agents/ui/communityCatalogSelection.test.mjs b/desktop/src/features/agents/ui/communityCatalogSelection.test.mjs
new file mode 100644
index 0000000000..af421c23ea
--- /dev/null
+++ b/desktop/src/features/agents/ui/communityCatalogSelection.test.mjs
@@ -0,0 +1,316 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { nextCatalogSelection } from "./communityCatalogSelection.ts";
+
+// ── Stubs ─────────────────────────────────────────────────────────────────────
+
+const PERSONA_KEY = "p:persona-abc";
+const TEAM_KEY = "t:alice-pubkey:release";
+
+// ── Rule 1: user-made selection preserved when item still valid ───────────────
+
+test("user selection is preserved while preferred section is loading", () => {
+ // User clicked a team while agents were still loading.
+ assert.equal(
+ nextCatalogSelection(
+ TEAM_KEY,
+ /*userHasSelected*/ true,
+ /*currentIsValid*/ true,
+ "agents",
+ /*personasLoading*/ true,
+ /*teamsLoading*/ false,
+ PERSONA_KEY,
+ TEAM_KEY,
+ ),
+ TEAM_KEY,
+ );
+});
+
+test("user selection is preserved after all sections have settled", () => {
+ assert.equal(
+ nextCatalogSelection(
+ TEAM_KEY,
+ /*userHasSelected*/ true,
+ /*currentIsValid*/ true,
+ "agents",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ false,
+ PERSONA_KEY,
+ TEAM_KEY,
+ ),
+ TEAM_KEY,
+ );
+});
+
+// ── Rule 1 (vanish): a retracted user selection falls through to auto-init ────
+
+test("manually selected agent that vanishes falls back to first available item", () => {
+ // User had selected an agent (agents section was their preference).
+ // A live refresh retracts the agent — currentIsValid is now false.
+ // Both sections are settled; personas has a different first item, teams has items.
+ // Expected: fall through to rule 3 (preferred section nonempty), pick first agent.
+ const OTHER_PERSONA_KEY = "p:persona-xyz";
+ assert.equal(
+ nextCatalogSelection(
+ PERSONA_KEY, // the retracted agent
+ /*userHasSelected*/ true,
+ /*currentIsValid*/ false, // item vanished
+ "agents",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ false,
+ OTHER_PERSONA_KEY, // first item now in the personas list
+ TEAM_KEY,
+ ),
+ OTHER_PERSONA_KEY,
+ );
+});
+
+test("manually selected team that vanishes falls back to first available item", () => {
+ // User had selected a team (teams section was their preference).
+ // A live refresh retracts the team — currentIsValid is now false.
+ const OTHER_TEAM_KEY = "t:bob-pubkey:security";
+ assert.equal(
+ nextCatalogSelection(
+ TEAM_KEY, // the retracted team
+ /*userHasSelected*/ true,
+ /*currentIsValid*/ false, // item vanished
+ "teams",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ false,
+ PERSONA_KEY,
+ OTHER_TEAM_KEY, // first item now in the teams list
+ ),
+ OTHER_TEAM_KEY,
+ );
+});
+
+test("vanish while preferred section is loading does not reintroduce premature cross-section fallback", () => {
+ // User selected a team (agents was the launch preference) while agents were loading.
+ // Live refresh then retracts that team. Agents are still loading.
+ // Rule 1 fails (currentIsValid=false), Rule 2 fires (agents still loading) → null.
+ // The repair must NOT commit a cross-section fallback from the teams list.
+ const OTHER_TEAM_KEY = "t:bob-pubkey:security";
+ assert.equal(
+ nextCatalogSelection(
+ TEAM_KEY, // retracted team
+ /*userHasSelected*/ true,
+ /*currentIsValid*/ false, // item vanished
+ "agents",
+ /*personasLoading*/ true, // preferred section still loading
+ /*teamsLoading*/ false,
+ null, // no agents available yet
+ OTHER_TEAM_KEY, // a team item is available, but must not be auto-committed
+ ),
+ null, // wait for agents to settle
+ );
+});
+
+// ── Rule 2: no auto-commit while preferred section is loading ─────────────────
+
+test("agents launch: no selection while agents are loading, even when teams are ready", () => {
+ // Thufir's race scenario — teams settle first.
+ assert.equal(
+ nextCatalogSelection(
+ null,
+ /*userHasSelected*/ false,
+ /*currentIsValid*/ false,
+ "agents",
+ /*personasLoading*/ true,
+ /*teamsLoading*/ false,
+ null, // firstPersonaKey not yet available
+ TEAM_KEY,
+ ),
+ null,
+ );
+});
+
+test("teams launch: no selection while teams are loading, even when agents are ready", () => {
+ // Symmetric race — agents settle first.
+ assert.equal(
+ nextCatalogSelection(
+ null,
+ /*userHasSelected*/ false,
+ /*currentIsValid*/ false,
+ "teams",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ true,
+ PERSONA_KEY,
+ null, // firstTeamKey not yet available
+ ),
+ null,
+ );
+});
+
+// ── Rule 3: preferred section settled nonempty → select its first item ────────
+
+test("agents launch: selects first agent once agents settle", () => {
+ assert.equal(
+ nextCatalogSelection(
+ null,
+ /*userHasSelected*/ false,
+ /*currentIsValid*/ false,
+ "agents",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ false,
+ PERSONA_KEY,
+ TEAM_KEY,
+ ),
+ PERSONA_KEY,
+ );
+});
+
+test("teams launch: selects first team once teams settle", () => {
+ assert.equal(
+ nextCatalogSelection(
+ null,
+ /*userHasSelected*/ false,
+ /*currentIsValid*/ false,
+ "teams",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ false,
+ PERSONA_KEY,
+ TEAM_KEY,
+ ),
+ TEAM_KEY,
+ );
+});
+
+// ── Rule 4: preferred section settled empty → fall back to other section ──────
+
+test("agents launch: falls back to first team when agents section is empty", () => {
+ assert.equal(
+ nextCatalogSelection(
+ null,
+ /*userHasSelected*/ false,
+ /*currentIsValid*/ false,
+ "agents",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ false,
+ null, // no personas
+ TEAM_KEY,
+ ),
+ TEAM_KEY,
+ );
+});
+
+test("teams launch: falls back to first agent when teams section is empty", () => {
+ assert.equal(
+ nextCatalogSelection(
+ null,
+ /*userHasSelected*/ false,
+ /*currentIsValid*/ false,
+ "teams",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ false,
+ PERSONA_KEY,
+ null, // no teams
+ ),
+ PERSONA_KEY,
+ );
+});
+
+// ── Rule 5: both settled and empty → null ────────────────────────────────────
+
+test("both sections settled and empty yields null", () => {
+ assert.equal(
+ nextCatalogSelection(
+ null,
+ /*userHasSelected*/ false,
+ /*currentIsValid*/ false,
+ "agents",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ false,
+ null,
+ null,
+ ),
+ null,
+ );
+});
+
+// ── Staggered-settlement regressions (Thufir's required scenarios) ────────────
+
+test("agents launch staggered: teams settle first then agents settle — selects agent not team", () => {
+ // Step 1: teams settle while agents still loading → auto-init stays null.
+ const afterTeamsSettle = nextCatalogSelection(
+ null,
+ false,
+ /*currentIsValid*/ false,
+ "agents",
+ /*personasLoading*/ true,
+ /*teamsLoading*/ false,
+ null, // agents not ready
+ TEAM_KEY,
+ );
+ assert.equal(afterTeamsSettle, null);
+
+ // Step 2: agents now settle → selects first agent, not the team.
+ const afterAgentsSettle = nextCatalogSelection(
+ afterTeamsSettle,
+ false,
+ /*currentIsValid*/ false,
+ "agents",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ false,
+ PERSONA_KEY,
+ TEAM_KEY,
+ );
+ assert.equal(afterAgentsSettle, PERSONA_KEY);
+});
+
+test("teams launch staggered: agents settle first then teams settle — selects team not agent", () => {
+ // Step 1: agents settle while teams still loading → auto-init stays null.
+ const afterAgentsSettle = nextCatalogSelection(
+ null,
+ false,
+ /*currentIsValid*/ false,
+ "teams",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ true,
+ PERSONA_KEY,
+ null, // teams not ready
+ );
+ assert.equal(afterAgentsSettle, null);
+
+ // Step 2: teams now settle → selects first team, not the agent.
+ const afterTeamsSettle = nextCatalogSelection(
+ afterAgentsSettle,
+ false,
+ /*currentIsValid*/ false,
+ "teams",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ false,
+ PERSONA_KEY,
+ TEAM_KEY,
+ );
+ assert.equal(afterTeamsSettle, TEAM_KEY);
+});
+
+test("user selects from ready section while preferred section is loading — preserved after preferred settles", () => {
+ // Teams are ready; agents are loading. User clicks a team while waiting.
+ // (userHasSelected = true because the user clicked)
+ const afterUserClick = nextCatalogSelection(
+ TEAM_KEY,
+ /*userHasSelected*/ true,
+ /*currentIsValid*/ true, // team exists in current data
+ "agents",
+ /*personasLoading*/ true,
+ /*teamsLoading*/ false,
+ null, // agents not ready yet
+ TEAM_KEY,
+ );
+ assert.equal(afterUserClick, TEAM_KEY);
+
+ // Agents now settle — user's team selection must survive.
+ const afterAgentsSettle = nextCatalogSelection(
+ TEAM_KEY,
+ /*userHasSelected*/ true, // still true — user clicked
+ /*currentIsValid*/ true, // team still present
+ "agents",
+ /*personasLoading*/ false,
+ /*teamsLoading*/ false,
+ PERSONA_KEY,
+ TEAM_KEY,
+ );
+ assert.equal(afterAgentsSettle, TEAM_KEY);
+});
diff --git a/desktop/src/features/agents/ui/communityCatalogSelection.ts b/desktop/src/features/agents/ui/communityCatalogSelection.ts
new file mode 100644
index 0000000000..96a20c6d57
--- /dev/null
+++ b/desktop/src/features/agents/ui/communityCatalogSelection.ts
@@ -0,0 +1,61 @@
+/**
+ * Pure selection-initialization logic for CommunityCatalogDialog.
+ *
+ * Extracted so it can be tested deterministically without React rendering.
+ * The component calls this inside a useEffect on every dependency change.
+ */
+
+export type CatalogSectionPreference = "agents" | "teams";
+
+/**
+ * Compute the next auto-initialized selection key.
+ *
+ * Rules (in priority order):
+ * 1. If the user has made an explicit selection AND that item still exists in
+ * the current data, preserve it. If it has vanished (live refresh retracted
+ * it), fall through to auto-init so the dialog never shows a blank detail.
+ * 2. While the preferred section is still loading, return null — do not
+ * commit a cross-section fallback that cannot be retracted when the
+ * preferred section settles.
+ * 3. When the preferred section has settled nonempty, select its first item.
+ * 4. When the preferred section has settled empty, fall back to the other
+ * section's first item (if any).
+ * 5. Both sections settled and both empty → null.
+ *
+ * @param current The current encoded selection key (null if none).
+ * @param userHasSelected True if the user clicked an item since the dialog
+ * opened — automatic init must never overwrite this.
+ * @param currentIsValid True if `current` still exists in the live data.
+ * False when the item was retracted by a live refresh.
+ * @param preferSection The section requested at launch time.
+ * @param personasLoading Whether the personas query is still in-flight.
+ * @param teamsLoading Whether the teams query is still in-flight.
+ * @param firstPersonaKey Encoded key for the first persona, or null.
+ * @param firstTeamKey Encoded key for the first team, or null.
+ */
+export function nextCatalogSelection(
+ current: string | null,
+ userHasSelected: boolean,
+ currentIsValid: boolean,
+ preferSection: CatalogSectionPreference,
+ personasLoading: boolean,
+ teamsLoading: boolean,
+ firstPersonaKey: string | null,
+ firstTeamKey: string | null,
+): string | null {
+ // Rule 1: preserve an explicit user selection only while the item is still
+ // present. A vanished selection falls through to auto-init.
+ if (userHasSelected && currentIsValid) return current;
+
+ const preferredLoading =
+ preferSection === "agents" ? personasLoading : teamsLoading;
+
+ // Rule 2: preferred section still resolving — stay null.
+ if (preferredLoading) return null;
+
+ // Preferred section settled. Rules 3-4.
+ if (preferSection === "agents") {
+ return firstPersonaKey ?? firstTeamKey ?? null;
+ }
+ return firstTeamKey ?? firstPersonaKey ?? null;
+}
diff --git a/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs b/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs
index 7ad726352f..b7f3c7968c 100644
--- a/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs
+++ b/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
-import { resolveCatalogOwnerLabel } from "./PersonaCatalogDialog.tsx";
+import { resolveCatalogOwnerLabel } from "./catalogOwnerLabel.ts";
// ── null / undefined summary ──────────────────────────────────────────────────
diff --git a/desktop/src/features/agents/ui/teamLibraryCopy.ts b/desktop/src/features/agents/ui/teamLibraryCopy.ts
new file mode 100644
index 0000000000..b3122d92d6
--- /dev/null
+++ b/desktop/src/features/agents/ui/teamLibraryCopy.ts
@@ -0,0 +1,55 @@
+export const teamCatalogCopy = {
+ chooseFromCatalog: "Choose from catalog",
+ dialogTitle: "Team Catalog",
+ dialogDescription: "Browse teams shared to this relay.",
+ emptyCatalogTitle: "No teams are being shared",
+ emptyCatalogDescription: "Shared teams will appear here.",
+ addAction: "Add team",
+ addedAction: "Added to my teams",
+ addingAction: "Adding…",
+ shareTitle: "Share to catalog",
+ shareDescription:
+ "Anyone in this community can find and add a copy of this team. Both the team instructions and every member’s instructions are shared as plaintext. Memories and secrets aren’t included.",
+ invalidMemberSingular:
+ "1 member in this team could not be verified and cannot be added.",
+ invalidMemberPlural: (count: number) =>
+ `${count} members in this team could not be verified and cannot be added.`,
+} as const;
+
+/**
+ * The warning notice shown when the backend automatically queues a retraction
+ * for a shared team that can no longer be projected.
+ *
+ * "Queued" is accurate — the tombstone has been enqueued for the flush loop
+ * but the relay head may still be discoverable until the flush succeeds.
+ * Using "queued for removal" rather than "was removed" avoids a false claim
+ * that the catalog has already changed.
+ */
+export function teamAutoRetractedNotice(
+ teamName: string,
+ reason: string,
+): string {
+ return `"${teamName}" has been queued for removal from the community catalog because it can no longer be projected: ${reason}`;
+}
+
+/**
+ * The result message for a share toggle.
+ *
+ * `queued` is not a failure: the head is durably enqueued and the flush loop
+ * will publish it, so the copy promises eventual visibility rather than
+ * claiming the catalog already changed.
+ */
+export function teamShareNotice(
+ teamName: string,
+ shared: boolean,
+ publicationStatus: "published" | "queued",
+): string {
+ if (publicationStatus === "queued") {
+ return shared
+ ? `Sharing ${teamName} is queued. It will appear after the relay accepts the update.`
+ : `Removing ${teamName} is queued. It may remain discoverable until the relay accepts the update.`;
+ }
+ return shared
+ ? `Published ${teamName} to the community catalog.`
+ : `${teamName} is no longer discoverable in the community catalog.`;
+}
diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts
index 2c7668969a..c3aa2e5443 100644
--- a/desktop/src/features/agents/ui/usePersonaActions.ts
+++ b/desktop/src/features/agents/ui/usePersonaActions.ts
@@ -115,7 +115,6 @@ export function usePersonaActions() {
React.useState
(null);
const [snapshotImportConfirmError, setSnapshotImportConfirmError] =
React.useState(null);
- const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false);
const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState<
string | null
>(null);
@@ -435,12 +434,6 @@ export function usePersonaActions() {
setPersonaDialogState(duplicatePersonaDialogState(persona));
}
- function openCatalog() {
- clearFeedback("catalog");
- void catalogQuery.refetch();
- setIsCatalogDialogOpen(true);
- }
-
function openDelete(persona: AgentPersona) {
clearFeedback("library");
setPersonaToDelete(persona);
@@ -581,8 +574,6 @@ export function usePersonaActions() {
setPersonaToDelete,
personaToShare,
setPersonaToShare,
- isCatalogDialogOpen,
- setIsCatalogDialogOpen,
personaNoticeMessage,
personaErrorMessage,
personaFeedbackSurface,
@@ -593,7 +584,6 @@ export function usePersonaActions() {
prepareCreate,
openEdit,
openDuplicate,
- openCatalog,
openDelete,
openShare,
personaToExportSnapshot,
diff --git a/desktop/src/features/agents/ui/useTeamActions.ts b/desktop/src/features/agents/ui/useTeamActions.ts
index 3652acf954..7864857b77 100644
--- a/desktop/src/features/agents/ui/useTeamActions.ts
+++ b/desktop/src/features/agents/ui/useTeamActions.ts
@@ -10,7 +10,20 @@ import {
useTeamsQuery,
useUpdateTeamMutation,
} from "@/features/agents/hooks";
+import type { CatalogShareLevel } from "@/features/agents/lib/catalogRelay";
+import {
+ catalogTeamsFromPublications,
+ type CatalogTeam,
+} from "@/features/agents/lib/teamCatalogRelay";
+import {
+ useAddTeamFromCatalogMutation,
+ useSetTeamCatalogSharedMutation,
+ useTeamCatalogLiveUpdates,
+ useTeamCatalogQuery,
+} from "@/features/agents/lib/useTeamCatalogRelay";
+import { useCommunities } from "@/features/communities/useCommunities";
import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents";
+import { useIdentityQuery } from "@/shared/api/hooks";
import { deletePersona } from "@/shared/api/tauriPersonas";
import {
confirmTeamSnapshotImport,
@@ -28,6 +41,7 @@ import type {
UpdateTeamInput,
} from "@/shared/api/types";
import { deriveImportToast } from "./teamSnapshotImport.lib";
+import { teamShareNotice } from "./teamLibraryCopy";
type TeamDialogState = {
description: string;
@@ -51,7 +65,14 @@ export function useTeamActions(
refetch: RefetchCallbacks,
) {
const queryClient = useQueryClient();
+ const { activeCommunity } = useCommunities();
+ const identityQuery = useIdentityQuery();
+ const communityId = activeCommunity?.id ?? null;
const teamsQuery = useTeamsQuery();
+ const catalogQuery = useTeamCatalogQuery(communityId);
+ useTeamCatalogLiveUpdates(communityId);
+ const setCatalogSharedMutation = useSetTeamCatalogSharedMutation(communityId);
+ const addTeamFromCatalogMutation = useAddTeamFromCatalogMutation();
const createTeamMutation = useCreateTeamMutation();
const updateTeamMutation = useUpdateTeamMutation();
const deleteTeamMutation = useDeleteTeamMutation();
@@ -103,6 +124,16 @@ export function useTeamActions(
});
const teams = teamsQuery.data ?? [];
+ const publications = catalogQuery.data ?? [];
+ const catalogTeams = React.useMemo(
+ () =>
+ catalogTeamsFromPublications(
+ publications,
+ teams,
+ identityQuery.data?.pubkey,
+ ),
+ [identityQuery.data?.pubkey, publications, teams],
+ );
async function handleTeamSubmit(input: CreateTeamInput | UpdateTeamInput) {
actions.setActionNoticeMessage(null);
@@ -233,6 +264,78 @@ export function useTeamActions(
setTeamToShare(team);
}
+ function getTeamCatalogShareLevel(team: AgentTeam): CatalogShareLevel {
+ return team.shared ? "none" : "not-shared";
+ }
+
+ async function setTeamCatalogShareLevel(
+ team: AgentTeam,
+ shareLevel: CatalogShareLevel,
+ ): Promise {
+ if (team.isBuiltin) return;
+
+ actions.setActionNoticeMessage(null);
+ actions.setActionErrorMessage(null);
+ const shared = shareLevel !== "not-shared";
+ try {
+ const result = await setCatalogSharedMutation.mutateAsync({
+ id: team.id,
+ shared,
+ });
+ // The open dialog holds its own copy of the team, so re-point it at the
+ // returned record — otherwise the toggle snaps back to its old value.
+ setTeamToShare((current) =>
+ current?.id === result.team.id ? result.team : current,
+ );
+ if (result.relayMessage) {
+ console.warn(
+ `[setTeamShared] relay publication queued: ${result.relayMessage}`,
+ );
+ }
+ actions.setActionNoticeMessage(
+ teamShareNotice(team.name, shared, result.publicationStatus),
+ );
+ } catch (error) {
+ actions.setActionErrorMessage(
+ error instanceof Error
+ ? error.message
+ : `Failed to ${shared ? "share" : "unshare"} team.`,
+ );
+ }
+ }
+
+ /**
+ * Add a published team.
+ *
+ * Only the coordinate is sent; the backend re-verifies the head, so an entry
+ * retracted or republished while the dialog sat open fails loudly here
+ * rather than copying a stale projection.
+ */
+ async function handleAddTeamFromCatalog(
+ team: CatalogTeam,
+ onSuccess?: () => void,
+ ): Promise {
+ actions.setActionNoticeMessage(null);
+ actions.setActionErrorMessage(null);
+ try {
+ const result = await addTeamFromCatalogMutation.mutateAsync({
+ ownerPubkey: team.ownerPubkey,
+ teamDTag: team.teamDTag,
+ eventId: team.eventId,
+ });
+ actions.setActionNoticeMessage(
+ result.alreadyPresent
+ ? `${result.team.name} is already in your teams.`
+ : `Added ${result.team.name} to your teams.`,
+ );
+ onSuccess?.();
+ } catch (error) {
+ actions.setActionErrorMessage(
+ error instanceof Error ? error.message : "Failed to add team.",
+ );
+ }
+ }
+
function handleExportTeamSnapshot(
team: AgentTeam,
memoryLevel: SnapshotMemoryLevel,
@@ -317,6 +420,10 @@ export function useTeamActions(
return {
teams,
teamsQuery,
+ catalogQuery,
+ catalogTeams,
+ isAddingFromCatalog: addTeamFromCatalogMutation.isPending,
+ isCatalogSharePending: setCatalogSharedMutation.isPending,
createTeamMutation,
updateTeamMutation,
deleteTeamMutation,
@@ -344,6 +451,9 @@ export function useTeamActions(
openEditDialog,
openExportSnapshot,
openShare,
+ getTeamCatalogShareLevel,
+ setTeamCatalogShareLevel,
+ handleAddTeamFromCatalog,
handleExportTeamSnapshot,
handleImportTeamSnapshotFile,
handleConfirmTeamSnapshotImport,
diff --git a/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx
index 8511d26d57..79a4e1f8d9 100644
--- a/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx
+++ b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx
@@ -47,8 +47,8 @@ import {
const CUSTOM_ENTRY_ID = "\u0000custom";
/**
- * "Add runtimes" — master-detail catalog dialog, modeled on the Agent
- * Catalog (PersonaCatalogDialog): searchable left chooser, right detail pane
+ * "Add runtimes" — master-detail catalog dialog, modeled on the Community
+ * Catalog (CommunityCatalogDialog): searchable left chooser, right detail pane
* with one neutral vendor-sourced sentence, operational setup state, and
* technical details, plus a primary Install / setup-guide CTA pinned in a
* bottom action bar (same position as the custom-harness Save button).
diff --git a/desktop/src/shared/api/tauriTeams.ts b/desktop/src/shared/api/tauriTeams.ts
index a2c7fdf1d7..8558272587 100644
--- a/desktop/src/shared/api/tauriTeams.ts
+++ b/desktop/src/shared/api/tauriTeams.ts
@@ -2,9 +2,16 @@ import { invokeTauri } from "@/shared/api/tauri";
import type {
AgentTeam,
CreateTeamInput,
+ TeamCatalogSourceCoordinate,
UpdateTeamInput,
} from "@/shared/api/types";
+/** Wire shape of `TeamCatalogSource` — snake_case, like its parent record. */
+type RawTeamCatalogSource = {
+ owner_pubkey: string;
+ team_d_tag: string;
+};
+
type RawTeam = {
id: string;
name: string;
@@ -12,6 +19,8 @@ type RawTeam = {
instructions?: string | null;
persona_ids: string[];
is_builtin?: boolean;
+ shared?: boolean;
+ catalog_source?: RawTeamCatalogSource | null;
source_dir?: string | null;
is_symlink?: boolean;
symlink_target?: string | null;
@@ -20,6 +29,14 @@ type RawTeam = {
updated_at: string;
};
+function fromRawCatalogSource(
+ source: RawTeamCatalogSource | null | undefined,
+): TeamCatalogSourceCoordinate | null {
+ return source
+ ? { ownerPubkey: source.owner_pubkey, teamDTag: source.team_d_tag }
+ : null;
+}
+
function fromRawTeam(team: RawTeam): AgentTeam {
return {
id: team.id,
@@ -28,6 +45,8 @@ function fromRawTeam(team: RawTeam): AgentTeam {
instructions: team.instructions ?? null,
personaIds: team.persona_ids,
isBuiltin: team.is_builtin ?? false,
+ shared: team.shared ?? false,
+ catalogSource: fromRawCatalogSource(team.catalog_source),
sourceDir: team.source_dir ?? null,
isSymlink: team.is_symlink ?? false,
symlinkTarget: team.symlink_target ?? null,
@@ -72,6 +91,74 @@ export async function deleteTeam(id: string): Promise {
await invokeTauri("delete_team", { id });
}
+// ── Team catalog commands ────────────────────────────────────────────────────
+
+export type TeamSharePublicationResult = {
+ team: AgentTeam;
+ /** `queued` means the head is durably enqueued but the relay has not yet
+ * accepted it, so catalog visibility lags the toggle. */
+ publicationStatus: "published" | "queued";
+ relayMessage: string | null;
+};
+
+type RawTeamSharePublicationResult = {
+ team: RawTeam;
+ publicationStatus: "published" | "queued";
+ relayMessage?: string;
+};
+
+/** Publish this team's catalog head, or replace it with an untagged one. */
+export async function setTeamShared(
+ id: string,
+ shared: boolean,
+): Promise {
+ const raw = await invokeTauri(
+ "set_team_shared",
+ { id, shared },
+ );
+ return {
+ team: fromRawTeam(raw.team),
+ publicationStatus: raw.publicationStatus,
+ relayMessage: raw.relayMessage ?? null,
+ };
+}
+
+export type AddTeamFromCatalogResult = {
+ team: AgentTeam;
+ /** True when the team was already added and nothing was written. */
+ alreadyPresent: boolean;
+};
+
+type RawAddTeamFromCatalogResult = {
+ team: RawTeam;
+ alreadyPresent: boolean;
+};
+
+/**
+ * Copy a published team into the local stores.
+ *
+ * Only the coordinate crosses the boundary — never the projection the UI is
+ * displaying. The backend re-fetches the current head at
+ * `30178::` and rejects the add if it is not `eventId` or has
+ * stopped being shared, so a catalog entry that moved or was retracted while
+ * the dialog sat open cannot be copied.
+ */
+export async function addTeamFromCatalog(
+ source: TeamCatalogSourceCoordinate & { eventId: string },
+): Promise {
+ const raw = await invokeTauri(
+ "add_team_from_catalog",
+ {
+ input: {
+ ownerPubkey: source.ownerPubkey,
+ teamDTag: source.teamDTag,
+ eventId: source.eventId,
+ },
+ },
+ );
+ return { team: fromRawTeam(raw.team), alreadyPresent: raw.alreadyPresent };
+}
+
// ── Team snapshot types ─────────────────────────────────────────────────────
export type SnapshotFormat = "json" | "png";
@@ -113,27 +200,10 @@ export type TeamSnapshotImportMemberResult = {
profileSyncError: string | null;
};
-/** Wire shape of the nested `TeamRecord` — Rust has no `rename_all` so fields
- * arrive in snake_case, matching the existing `RawTeam` convention. */
-type RawTeamRecord = {
- id: string;
- name: string;
- description: string | null;
- persona_ids: string[];
- instructions: string | null;
- is_builtin: boolean;
- source_dir: string | null;
- is_symlink: boolean;
- symlink_target: string | null;
- version: string | null;
- created_at: string;
- updated_at: string;
-};
-
/** Raw wire shape of the import result — outer struct is camelCase,
* but the nested `team` field is snake_case (no `rename_all` on TeamRecord). */
type RawTeamSnapshotImportResult = {
- team: RawTeamRecord;
+ team: RawTeam;
personaIds: string[];
members: TeamSnapshotImportMemberResult[];
};
diff --git a/desktop/src/shared/api/teamTypes.ts b/desktop/src/shared/api/teamTypes.ts
new file mode 100644
index 0000000000..bd0e19617a
--- /dev/null
+++ b/desktop/src/shared/api/teamTypes.ts
@@ -0,0 +1,61 @@
+/**
+ * Team library wire types.
+ *
+ * Split out of `types.ts` the same way `searchTypes` and `socialTypes` are:
+ * they are one cohesive group, and `types.ts` is at its size ceiling.
+ */
+
+/**
+ * A publication's coordinate in the kind:30178 team catalog. Mirrors the
+ * backend `TeamCatalogSource`.
+ *
+ * Deliberately not `CatalogSourceCoordinate`: that one addresses a kind:30175
+ * persona, and a team `d`-tag resolved in the persona namespace names a
+ * different — possibly unrelated — event.
+ */
+export type TeamCatalogSourceCoordinate = {
+ ownerPubkey: string;
+ teamDTag: string;
+};
+
+export type AgentTeam = {
+ id: string;
+ name: string;
+ description: string | null;
+ instructions: string | null;
+ personaIds: string[];
+ isBuiltin: boolean;
+ /** Whether this team is discoverable in the active community catalog. */
+ shared: boolean;
+ /**
+ * Set only on a local copy of another owner's shared team. A copy carries a
+ * fresh local `id`, so this coordinate is the only thing that can answer "is
+ * this catalog entry already added" without minting a duplicate.
+ */
+ catalogSource: TeamCatalogSourceCoordinate | null;
+ /** Absolute path to the team's backing directory (if directory-backed). */
+ sourceDir: string | null;
+ /** Whether sourceDir is a symlink to an external directory. */
+ isSymlink: boolean;
+ /** Resolved symlink target path (for display). Only set when isSymlink is true. */
+ symlinkTarget: string | null;
+ /** Version from the team's plugin.json manifest. */
+ version: string | null;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type CreateTeamInput = {
+ name: string;
+ description?: string;
+ instructions?: string;
+ personaIds: string[];
+};
+
+export type UpdateTeamInput = {
+ id: string;
+ name: string;
+ description?: string;
+ instructions?: string;
+ personaIds: string[];
+};
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts
index 24ef625783..3a3b2b9d49 100644
--- a/desktop/src/shared/api/types.ts
+++ b/desktop/src/shared/api/types.ts
@@ -792,39 +792,13 @@ export type UpdatePersonaInput = {
};
// ── Team types ────────────────────────────────────────────────────────────────
-export type AgentTeam = {
- id: string;
- name: string;
- description: string | null;
- instructions: string | null;
- personaIds: string[];
- isBuiltin: boolean;
- /** Absolute path to the team's backing directory (if directory-backed). */
- sourceDir: string | null;
- /** Whether sourceDir is a symlink to an external directory. */
- isSymlink: boolean;
- /** Resolved symlink target path (for display). Only set when isSymlink is true. */
- symlinkTarget: string | null;
- /** Version from the team's plugin.json manifest. */
- version: string | null;
- createdAt: string;
- updatedAt: string;
-};
-
-export type CreateTeamInput = {
- name: string;
- description?: string;
- instructions?: string;
- personaIds: string[];
-};
+export type {
+ AgentTeam,
+ CreateTeamInput,
+ TeamCatalogSourceCoordinate,
+ UpdateTeamInput,
+} from "./teamTypes";
-export type UpdateTeamInput = {
- id: string;
- name: string;
- description?: string;
- instructions?: string;
- personaIds: string[];
-};
// ── Channel Template types ─────────────────────────────────────────────────────
export type TemplateBackend =
diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts
index f995a63596..af2ae3a3d7 100644
--- a/desktop/src/shared/constants/kinds.ts
+++ b/desktop/src/shared/constants/kinds.ts
@@ -53,6 +53,11 @@ export const KIND_COMMUNITY_THEME = 30078;
export const KIND_PERSONA = 30175;
export const KIND_TEAM = 30176;
export const KIND_MANAGED_AGENT = 30177;
+// Team catalog projection: a self-contained snapshot of a team plus every
+// member's safe definition, so a recipient can rebuild it without reading the
+// publisher's personas. Separate from KIND_TEAM (30176, the team's own wire
+// body) so an ordinary team edit cannot disturb catalog share state.
+export const KIND_TEAM_CATALOG = 30178;
export const KIND_USER_STATUS = 30315;
export const KIND_AGENT_OBSERVER_FRAME = 24200;
export const KIND_AGENT_TURN_METRIC = 44200;
diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css
index 1cb3c36cf2..7d4418c369 100644
--- a/desktop/src/shared/styles/globals/theme.css
+++ b/desktop/src/shared/styles/globals/theme.css
@@ -404,7 +404,7 @@
* SCOPED to the app sidebar container, NOT :root - the `bg-sidebar-active` /
* `text-sidebar-active-foreground` tokens are also consumed by non-sidebar
* controls (avatar edit buttons in ProfileSettingsCard / AgentCreationPreview,
- * the selected persona row in PersonaCatalogDialog). A root-level override
+ * the selected row in CommunityCatalogDialog). A root-level override
* turned those white (white-on-white under Buzz Dark); scoping keeps them on
* the normal accent-driven active colors.
*/
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 1987e00ff1..00860a4851 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -50,6 +50,7 @@ import {
KIND_STREAM_MESSAGE_EDIT,
KIND_SYSTEM_MESSAGE,
KIND_TEXT_NOTE,
+ KIND_TEAM_CATALOG,
KIND_USER_STATUS,
} from "@/shared/constants/kinds";
import type {
@@ -284,6 +285,10 @@ type E2eConfig = {
/** Outcomes for successive explicit persona share publications. */
personaSharePublicationStatuses?: Array<"published" | "queued">;
teams?: MockTeamSeed[];
+ /** Community team-catalog (kind:30178) heads returned by relay queries. */
+ teamCatalogEvents?: RelayEvent[];
+ /** Outcomes for successive explicit team share publications. */
+ teamSharePublicationStatuses?: Array<"published" | "queued">;
relayAgents?: MockRelayAgentSeed[];
/** Native-like huddle state seeded from authoritative role-bearing membership. */
huddle?: MockHuddleSeed;
@@ -897,6 +902,8 @@ type RawTeam = {
description: string | null;
persona_ids: string[];
is_builtin: boolean;
+ shared?: boolean;
+ catalog_source?: { owner_pubkey: string; team_d_tag: string } | null;
source_dir: string | null;
is_symlink: boolean;
symlink_target: string | null;
@@ -1129,6 +1136,12 @@ declare global {
members: MockHuddleMemberSeed[];
transcriptionEnabled: boolean;
}) => Promise;
+ /**
+ * Replace the stored kind:30178 head for a coordinate WITHOUT notifying
+ * live subscribers. Reproduces a head that moved on the relay while a
+ * catalog dialog sat open holding the superseded event id.
+ */
+ __BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: (event: RelayEvent) => void;
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: RawFeedItem) => RawFeedItem;
/** Replace an existing feed item by id (or push if not found) and fire the updated event. */
__BUZZ_E2E_REPLACE_MOCK_FEED_ITEM__?: (
@@ -2966,6 +2979,7 @@ const deferredSendMessageLiveEchoes: Array<{
const mockUserStatuses: RelayEvent[] = [];
const mockReminderEvents: RelayEvent[] = [];
const mockPersonaEvents: RelayEvent[] = [];
+const mockTeamCatalogEvents: RelayEvent[] = [];
let mockRelayMembers: RawRelayMember[] = [];
const mockSockets = new Map();
const mockAuthResponses: Array<{ success: boolean; message: string }> = [];
@@ -3010,6 +3024,16 @@ function resetMockPersonaCatalogEvents(config: E2eConfig | undefined) {
}
}
+function resetMockTeamCatalogEvents(config: E2eConfig | undefined) {
+ mockTeamCatalogEvents.length = 0;
+ for (const event of config?.mock?.teamCatalogEvents ?? []) {
+ mockTeamCatalogEvents.push({
+ ...event,
+ tags: event.tags.map((tag) => [...tag]),
+ });
+ }
+}
+
// Mesh-compute mock state — TEST-ONLY.
//
// This entire module (e2eBridge.ts) is loaded only when `window.__BUZZ_E2E__`
@@ -4211,9 +4235,9 @@ function emitOrDeferMockSendMessageLiveEcho(
function emitMockGlobalEvent(event: RelayEvent) {
if (
- event.kind === KIND_PERSONA &&
+ (event.kind === KIND_PERSONA || event.kind === KIND_TEAM_CATALOG) &&
event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() &&
- !personaHasExactSharedTag(event)
+ !hasExactSharedTag(event)
) {
return;
}
@@ -7629,6 +7653,7 @@ const MOCK_PASSPHRASE_WORDS = [
// Per-page explicit catalog publication outcomes.
let personaSharePublicationCallCount = 0;
+let teamSharePublicationCallCount = 0;
// Per-page confirm_team_snapshot_import call counter for sequenced error testing.
let teamSnapshotConfirmCallCount = 0;
@@ -8022,7 +8047,7 @@ async function handleSetPersonaActive(args: {
return { ...persona };
}
-function personaHasExactSharedTag(event: RelayEvent): boolean {
+function hasExactSharedTag(event: RelayEvent): boolean {
const tags = event.tags.filter((tag) => tag[0] === "shared");
return tags.length === 1 && tags[0]?.length === 2 && tags[0]?.[1] === "true";
}
@@ -8143,11 +8168,12 @@ function ensureMockPersonaIdsAreActive(personaIds: string[]) {
}
}
+function cloneMockTeam(team: RawTeam): RawTeam {
+ return { ...team, persona_ids: [...team.persona_ids] };
+}
+
async function handleListTeams(): Promise {
- return mockTeams.map((team) => ({
- ...team,
- persona_ids: [...team.persona_ids],
- }));
+ return mockTeams.map(cloneMockTeam);
}
async function handleCreateTeam(args: {
@@ -8206,6 +8232,180 @@ async function handleDeleteTeam(args: { id: string }): Promise {
mockTeams = mockTeams.filter((candidate) => candidate.id !== args.id);
}
+// ── Team catalog (kind:30178) ───────────────────────────────────────────────
+
+/** The team's catalog projection, as `team_catalog_content` builds it. */
+function mockTeamCatalogContent(team: RawTeam): string {
+ return JSON.stringify({
+ v: 1,
+ name: team.name,
+ description: team.description,
+ instructions: null,
+ members: team.persona_ids.map((personaId) => {
+ const persona = mockPersonas.find(
+ (candidate) => candidate.id === personaId,
+ );
+ return {
+ member_key: personaId,
+ display_name: persona?.display_name ?? personaId,
+ system_prompt: persona?.system_prompt ?? "",
+ avatar_url: persona?.avatar_url ?? null,
+ runtime: persona?.runtime ?? null,
+ model: persona?.model ?? null,
+ };
+ }),
+ });
+}
+
+function upsertMockTeamCatalogEvent(team: RawTeam): void {
+ const event: RelayEvent = {
+ id: mockEventId(),
+ pubkey: MOCK_IDENTITY_PUBKEY,
+ created_at: Math.floor(Date.now() / 1_000),
+ kind: KIND_TEAM_CATALOG,
+ tags: [["d", team.id], ...(team.shared ? [["shared", "true"]] : [])],
+ content: mockTeamCatalogContent(team),
+ sig: "0".repeat(128),
+ };
+ const existingIndex = mockTeamCatalogEvents.findIndex(
+ (candidate) =>
+ candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() &&
+ candidate.tags.some((tag) => tag[0] === "d" && tag[1] === team.id),
+ );
+ if (existingIndex >= 0) {
+ mockTeamCatalogEvents.splice(existingIndex, 1);
+ }
+ mockTeamCatalogEvents.push(event);
+ emitMockGlobalEvent(event);
+}
+
+type MockTeamPublicationResult = {
+ team: RawTeam;
+ publicationStatus: "published" | "queued";
+ relayMessage?: string;
+};
+
+/**
+ * Mirrors `set_team_shared`. A `queued` outcome must NOT make the head visible
+ * to catalog readers — that lag is exactly what the UI copy reports.
+ */
+async function handleSetTeamShared(
+ args: { id: string; shared: boolean },
+ config?: E2eConfig,
+): Promise {
+ const team = mockTeams.find((candidate) => candidate.id === args.id);
+ if (!team) {
+ throw new Error(`Team ${args.id} not found.`);
+ }
+ if (team.is_builtin) {
+ throw new Error("Built-in teams cannot be shared to the catalog.");
+ }
+ team.shared = args.shared;
+ team.updated_at = new Date().toISOString();
+
+ const publicationStatus =
+ config?.mock?.teamSharePublicationStatuses?.[
+ teamSharePublicationCallCount++
+ ] ?? "published";
+ if (publicationStatus === "published") {
+ upsertMockTeamCatalogEvent(team);
+ }
+ return {
+ team: cloneMockTeam(team),
+ publicationStatus,
+ ...(publicationStatus === "queued"
+ ? { relayMessage: "relay unreachable: could not connect to relay" }
+ : {}),
+ };
+}
+
+/**
+ * Mirrors `add_team_from_catalog`, including the canonical-head check: the
+ * coordinate is re-resolved against the current heads and the add is rejected
+ * unless that head is still `eventId` and still shared. A test that stales the
+ * head must see the same failure the real command produces.
+ */
+async function handleAddTeamFromCatalog(args: {
+ input: { ownerPubkey: string; teamDTag: string; eventId: string };
+}): Promise<{ team: RawTeam; alreadyPresent: boolean }> {
+ const { ownerPubkey, teamDTag, eventId } = args.input;
+ const owner = ownerPubkey.toLowerCase();
+ const head = mockTeamCatalogEvents
+ .filter(
+ (event) =>
+ event.pubkey.toLowerCase() === owner &&
+ event.tags.filter((tag) => tag[0] === "d").length === 1 &&
+ event.tags.some((tag) => tag[0] === "d" && tag[1] === teamDTag),
+ )
+ .sort(
+ (left, right) =>
+ right.created_at - left.created_at || left.id.localeCompare(right.id),
+ )[0];
+
+ if (!head || !hasExactSharedTag(head)) {
+ throw new Error("This team is no longer shared to the catalog.");
+ }
+ if (head.id !== eventId) {
+ throw new Error(
+ "This team was updated since you opened the catalog. Reopen it and try again.",
+ );
+ }
+
+ const existing = mockTeams.find(
+ (candidate) =>
+ candidate.catalog_source?.owner_pubkey === owner &&
+ candidate.catalog_source?.team_d_tag === teamDTag,
+ );
+ if (existing) {
+ return { team: cloneMockTeam(existing), alreadyPresent: true };
+ }
+
+ const content = JSON.parse(head.content) as {
+ name: string;
+ description: string | null;
+ members: Array<{
+ member_key: string;
+ display_name: string;
+ system_prompt: string;
+ avatar_url: string | null;
+ }>;
+ };
+ const now = new Date().toISOString();
+ const personaIds = content.members.map((member) => {
+ const id = crypto.randomUUID();
+ mockPersonas.push({
+ id,
+ display_name: member.display_name,
+ avatar_url: member.avatar_url,
+ system_prompt: member.system_prompt,
+ is_builtin: false,
+ is_active: true,
+ shared: false,
+ env_vars: {},
+ created_at: now,
+ updated_at: now,
+ });
+ return id;
+ });
+ const team: RawTeam = {
+ id: crypto.randomUUID(),
+ name: content.name,
+ description: content.description,
+ persona_ids: personaIds,
+ is_builtin: false,
+ shared: false,
+ catalog_source: { owner_pubkey: owner, team_d_tag: teamDTag },
+ source_dir: null,
+ is_symlink: false,
+ symlink_target: null,
+ version: null,
+ created_at: now,
+ updated_at: now,
+ };
+ mockTeams.push(team);
+ return { team: cloneMockTeam(team), alreadyPresent: false };
+}
+
async function handleExportTeamToJson(args: { id: string }): Promise {
const team = mockTeams.find((candidate) => candidate.id === args.id);
if (!team) {
@@ -9656,7 +9856,7 @@ function sendToMockSocket(args: {
if (authors && !authors.includes(event.pubkey.toLowerCase())) continue;
if (
event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() &&
- !personaHasExactSharedTag(event)
+ !hasExactSharedTag(event)
) {
continue;
}
@@ -9668,6 +9868,27 @@ function sendToMockSocket(args: {
return;
}
+ if (filter.kinds?.includes(KIND_TEAM_CATALOG)) {
+ const authors = filter.authors?.map((author) => author.toLowerCase());
+ const teamDTags = filter["#d"];
+ for (const event of mockTeamCatalogEvents) {
+ if (authors && !authors.includes(event.pubkey.toLowerCase())) continue;
+ // Own heads are readable unshared (the owner sees their own state);
+ // anyone else's must carry the exact shared tag, like the relay gate.
+ if (
+ event.pubkey.toLowerCase() !== MOCK_IDENTITY_PUBKEY.toLowerCase() &&
+ !hasExactSharedTag(event)
+ ) {
+ continue;
+ }
+ const teamDTag = event.tags.find((tag) => tag[0] === "d")?.[1];
+ if (teamDTags && (!teamDTag || !teamDTags.includes(teamDTag))) continue;
+ sendWsText(socket.handler, ["EVENT", subId, event]);
+ }
+ sendWsText(socket.handler, ["EOSE", subId]);
+ return;
+ }
+
// Project queries: NIP-34 kinds, or kind:1 comments scoped by repo `a`
// tag (PR/issue discussions, approvals, review requests).
if (
@@ -9795,7 +10016,7 @@ function sendToMockSocket(args: {
const sharedTags = event.tags.filter((tag) => tag[0] === "shared");
if (
sharedTags.length > 1 ||
- (sharedTags.length === 1 && !personaHasExactSharedTag(event))
+ (sharedTags.length === 1 && !hasExactSharedTag(event))
) {
sendWsText(socket.handler, [
"OK",
@@ -9971,6 +10192,7 @@ export function maybeInstallE2eTauriMocks() {
resetMockMesh();
resetMockUserStatuses();
resetMockPersonaCatalogEvents(config);
+ resetMockTeamCatalogEvents(config);
resetMockSaveSubscriptions(config);
resetMockPendingCommunityDeepLinks(config);
initializeMockHuddle(config.mock?.huddle, config);
@@ -10072,6 +10294,18 @@ export function maybeInstallE2eTauriMocks() {
ownerPubkey,
kind,
}) => hasMockOwnerKindSubscription(ownerPubkey, kind);
+ window.__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__ = (event) => {
+ const dTag = event.tags.find((tag) => tag[0] === "d")?.[1];
+ const existingIndex = mockTeamCatalogEvents.findIndex(
+ (candidate) =>
+ candidate.pubkey.toLowerCase() === event.pubkey.toLowerCase() &&
+ candidate.tags.some((tag) => tag[0] === "d" && tag[1] === dTag),
+ );
+ if (existingIndex >= 0) {
+ mockTeamCatalogEvents.splice(existingIndex, 1);
+ }
+ mockTeamCatalogEvents.push(event);
+ };
window.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ = (item) => {
const category = item.category === "mention" ? "mentions" : item.category;
mockFeedOverrides[category].unshift(item);
@@ -11846,6 +12080,15 @@ export function maybeInstallE2eTauriMocks() {
);
case "list_teams":
return handleListTeams();
+ case "set_team_shared":
+ return handleSetTeamShared(
+ payload as Parameters[0],
+ activeConfig,
+ );
+ case "add_team_from_catalog":
+ return handleAddTeamFromCatalog(
+ payload as Parameters[0],
+ );
case "list_channel_templates":
return (activeConfig?.mock?.channelTemplates ?? []).map((template) => ({
id: template.id,
diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts
index befe2b5563..6f8dfb7c75 100644
--- a/desktop/tests/e2e/agents.spec.ts
+++ b/desktop/tests/e2e/agents.spec.ts
@@ -74,7 +74,7 @@ async function openPersonaCatalog(page: import("@playwright/test").Page) {
async function getCatalogOrder(page: import("@playwright/test").Page) {
return page
- .locator('[data-testid^="persona-catalog-list-item-"]')
+ .locator('[data-testid^="community-catalog-agent-"]')
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid") ?? ""),
);
@@ -84,7 +84,7 @@ async function selectCatalogPersona(
page: import("@playwright/test").Page,
personaId: string,
) {
- await page.getByTestId(`persona-catalog-list-item-${personaId}`).click();
+ await page.getByTestId(`community-catalog-agent-${personaId}`).click();
}
async function sharePersonaToCatalog(
@@ -233,26 +233,26 @@ test("catalog hides built-ins and shows the shared-agent empty state", async ({
await openPersonaCatalog(page);
for (const personaName of ["Fizz", "Honey", "Bumble"]) {
- await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText(
- personaName,
- );
+ await expect(
+ page.getByTestId("community-catalog-dialog"),
+ ).not.toContainText(personaName);
}
- await expect(page.getByTestId("persona-catalog-dialog-header")).toBeVisible();
- await expect(page.getByTestId("persona-catalog-dialog-body")).toBeVisible();
- const emptyState = page.getByTestId("persona-catalog-empty-state");
- await expect(emptyState).toContainText("No agents are being shared");
await expect(
- emptyState.getByTestId("persona-catalog-empty-agent-artwork"),
+ page.getByTestId("community-catalog-dialog-header"),
).toBeVisible();
+ await expect(page.getByTestId("community-catalog-dialog-body")).toBeVisible();
+ const emptyState = page.getByTestId("community-catalog-empty-state");
+ await expect(emptyState).toContainText("Nothing shared yet");
await expect(
- page.locator('[data-testid^="persona-catalog-list-item-"]'),
- ).toHaveCount(0);
+ emptyState.getByTestId("community-catalog-empty-artwork"),
+ ).toBeVisible();
await expect(
- page.getByTestId("persona-catalog-use-agent-target"),
+ page.locator('[data-testid^="community-catalog-agent-"]'),
).toHaveCount(0);
+ await expect(page.getByTestId("community-catalog-use-agent")).toHaveCount(0);
await page
- .getByTestId("persona-catalog-dialog")
+ .getByTestId("community-catalog-dialog")
.getByRole("button", { name: "Close" })
.click();
await page.getByLabel("Open actions for Fizz").click();
@@ -267,16 +267,16 @@ test("catalog empty state remains available after reopening", async ({
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await openPersonaCatalog(page);
- await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible();
+ await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
await page
- .getByTestId("persona-catalog-dialog")
+ .getByTestId("community-catalog-dialog")
.getByRole("button", { name: "Close" })
.click();
- await expect(page.getByTestId("persona-catalog-dialog")).not.toBeVisible();
+ await expect(page.getByTestId("community-catalog-dialog")).not.toBeVisible();
await openPersonaCatalog(page);
- await expect(page.getByTestId("persona-catalog-empty-state")).toContainText(
- "No agents are being shared",
+ await expect(page.getByTestId("community-catalog-empty-state")).toContainText(
+ "Nothing shared yet",
);
});
@@ -449,9 +449,9 @@ test("the new agent card offers create, discover, and import", async ({
await page
.getByRole("menuitem", { exact: true, name: "Discover agents" })
.click();
- await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible();
+ await expect(page.getByTestId("community-catalog-dialog")).toBeVisible();
await page
- .getByTestId("persona-catalog-dialog")
+ .getByTestId("community-catalog-dialog")
.getByRole("button", { name: "Close" })
.click();
await newAgentCard.click();
@@ -797,28 +797,28 @@ test("catalog detail pane shows the full persona details", async ({ page }) => {
await selectCatalogPersona(page, personaId);
const useAgentTarget = page.getByTestId(
- `persona-catalog-use-agent-target-${personaId}`,
+ `community-catalog-use-agent-${personaId}`,
);
- await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
+ await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
"Researcher",
);
- await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
+ await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
"Added by You",
);
- await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
+ await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
"Research the question and cite the evidence.",
);
- await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
+ await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
"Custom agent",
);
- await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
+ await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
"Preferred model",
);
- await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
+ await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
"Preferred runtime",
);
- await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
+ await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
"Agent instruction",
);
await expect(useAgentTarget).toHaveAttribute(
@@ -1475,7 +1475,7 @@ This deliberately long fenced-code example must not establish the minimum width
await page.getByTestId("open-agents-view").click();
await openPersonaCatalog(page);
await expect(
- page.getByTestId(`persona-catalog-list-item-${personaId}`),
+ page.getByTestId(`community-catalog-agent-${personaId}`),
).toHaveCount(0);
await page.keyboard.press("Escape");
@@ -1527,11 +1527,11 @@ This deliberately long fenced-code example must not establish the minimum width
await openPersonaCatalog(page);
await expect(
- page.getByTestId(`persona-catalog-list-item-${personaId}`),
+ page.getByTestId(`community-catalog-agent-${personaId}`),
).toContainText("Catalog Analyst");
await selectCatalogPersona(page, personaId);
- const catalogDialog = page.getByTestId("persona-catalog-dialog");
- const catalogDetailPane = page.getByTestId("persona-catalog-detail-pane");
+ const catalogDialog = page.getByTestId("community-catalog-dialog");
+ const catalogDetailPane = page.getByTestId("community-catalog-detail-pane");
await expect(catalogDetailPane).toContainText("Design System And Styling");
await expect(catalogDialog).toBeVisible();
await expect(catalogDetailPane).toBeVisible();
@@ -1594,7 +1594,7 @@ This deliberately long fenced-code example must not establish the minimum width
await openPersonaCatalog(page);
await selectCatalogPersona(page, personaId);
- await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
+ await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
"Review the latest catalog changes.",
);
await page.keyboard.press("Escape");
@@ -1611,7 +1611,7 @@ This deliberately long fenced-code example must not establish the minimum width
await openPersonaCatalog(page);
await expect(
- page.getByTestId(`persona-catalog-list-item-${personaId}`),
+ page.getByTestId(`community-catalog-agent-${personaId}`),
).toHaveCount(0);
});
@@ -1648,7 +1648,7 @@ test("a queued catalog share is not presented as relay-published", async ({
await openPersonaCatalog(page);
await expect(
- page.getByTestId(`persona-catalog-list-item-${personaId}`),
+ page.getByTestId(`community-catalog-agent-${personaId}`),
).toHaveCount(0);
});
@@ -1673,9 +1673,9 @@ test("a foreign reader does not receive an unshared kind 30175 persona", async (
await openPersonaCatalog(page);
await expect(
- page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`),
+ page.getByTestId(`community-catalog-agent-${remoteCatalogId}`),
).toHaveCount(0);
- await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible();
+ await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
});
test("a catalog entry keeps the owner's emoji avatar", async ({ page }) => {
@@ -1703,12 +1703,12 @@ test("a catalog entry keeps the owner's emoji avatar", async ({ page }) => {
// An `
` carrying the avatar — not the initials fallback — in both the
// list row and the detail header is what proves the projection kept it.
const remoteEntry = page.getByTestId(
- `persona-catalog-list-item-${remoteCatalogId}`,
+ `community-catalog-agent-${remoteCatalogId}`,
);
await expect(remoteEntry.locator("img")).toHaveAttribute("src", avatarUrl);
await remoteEntry.click();
await expect(
- page.getByTestId("persona-catalog-detail-pane").locator("img").first(),
+ page.getByTestId("community-catalog-detail-pane").locator("img").first(),
).toHaveAttribute("src", avatarUrl);
});
@@ -1732,13 +1732,13 @@ test("a community member can discover and add another member's catalog agent", a
await openPersonaCatalog(page);
const remoteEntry = page.getByTestId(
- `persona-catalog-list-item-${remoteCatalogId}`,
+ `community-catalog-agent-${remoteCatalogId}`,
);
await expect(remoteEntry).toContainText("Alice’s Reviewer");
await remoteEntry.click();
// The detail pane resolves the publisher's display name; 'Community member'
// is only the fallback for an unresolvable pubkey.
- await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
+ await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
"Added by alice",
);
@@ -1778,10 +1778,10 @@ test("a community member can discover and add another member's catalog agent", a
// The entry now projects onto the local copy, so its list-item testid is the
// local persona id rather than the catalog coordinate.
await expect(
- page.getByTestId(`persona-catalog-list-item-${remoteCatalogId}`),
+ page.getByTestId(`community-catalog-agent-${remoteCatalogId}`),
).toHaveCount(0);
await page
- .locator('[data-testid^="persona-catalog-list-item-"]')
+ .locator('[data-testid^="community-catalog-agent-"]')
.filter({ hasText: "Alice’s Reviewer" })
.click();
const addedTarget = page.getByRole("button", {
@@ -1816,10 +1816,10 @@ test("catalog detail shows Community member when the publisher profile cannot be
await page
.getByTestId(
- `persona-catalog-list-item-catalog:${unknownPubkey}:${personaId}`,
+ `community-catalog-agent-catalog:${unknownPubkey}:${personaId}`,
)
.click();
- await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText(
+ await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
"Added by Community member",
);
});
diff --git a/desktop/tests/e2e/team-catalog-screenshots.spec.ts b/desktop/tests/e2e/team-catalog-screenshots.spec.ts
new file mode 100644
index 0000000000..c5eec56dfd
--- /dev/null
+++ b/desktop/tests/e2e/team-catalog-screenshots.spec.ts
@@ -0,0 +1,293 @@
+import { expect, test } from "@playwright/test";
+
+import type { RelayEvent } from "@/shared/api/types";
+
+import { waitForAnimations } from "../helpers/animations";
+import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
+
+const SHOTS = "test-results/team-catalog";
+
+type CatalogMember = {
+ memberKey: string;
+ displayName: string;
+ systemPrompt: string;
+ model?: string | null;
+ runtime?: string | null;
+ provider?: string | null;
+};
+
+/** A kind:30178 head, shaped exactly as `team_catalog_content` projects it. */
+function createTeamCatalogEvent(input: {
+ ownerPubkey: string;
+ teamDTag: string;
+ name: string;
+ description?: string | null;
+ instructions?: string | null;
+ members: CatalogMember[];
+ eventId: string;
+}): RelayEvent {
+ return {
+ id: input.eventId,
+ pubkey: input.ownerPubkey,
+ created_at: 1_721_750_400,
+ kind: 30178,
+ tags: [
+ ["d", input.teamDTag],
+ ["shared", "true"],
+ ],
+ content: JSON.stringify({
+ v: 1,
+ name: input.name,
+ description: input.description ?? null,
+ instructions: input.instructions ?? null,
+ members: input.members.map((member) => ({
+ member_key: member.memberKey,
+ display_name: member.displayName,
+ system_prompt: member.systemPrompt,
+ avatar_url: null,
+ runtime: member.runtime ?? null,
+ model: member.model ?? null,
+ provider: member.provider ?? null,
+ })),
+ }),
+ sig: "2".repeat(128),
+ };
+}
+
+/** A kind:30175 head, shaped exactly as `persona_catalog_content` projects it. */
+function createPersonaCatalogEvent(input: {
+ ownerPubkey: string;
+ sourcePersonaId: string;
+ displayName: string;
+ systemPrompt: string;
+}): RelayEvent {
+ return {
+ id: "c".repeat(64),
+ pubkey: input.ownerPubkey,
+ created_at: 1_721_750_400,
+ kind: 30175,
+ tags: [
+ ["d", input.sourcePersonaId],
+ ["shared", "true"],
+ ],
+ content: JSON.stringify({
+ display_name: input.displayName,
+ system_prompt: input.systemPrompt,
+ avatar_url: null,
+ runtime: null,
+ model: null,
+ provider: null,
+ name_pool: [],
+ }),
+ sig: "2".repeat(128),
+ };
+}
+
+const PERSONA_CATALOG_EVENTS: RelayEvent[] = [
+ createPersonaCatalogEvent({
+ ownerPubkey: TEST_IDENTITIES.bob.pubkey,
+ sourcePersonaId: "code-reviewer",
+ displayName: "Code Reviewer",
+ systemPrompt: "Review pull requests for correctness and edge cases.",
+ }),
+];
+
+const CATALOG_EVENTS: RelayEvent[] = [
+ createTeamCatalogEvent({
+ eventId: "a".repeat(64),
+ ownerPubkey: TEST_IDENTITIES.alice.pubkey,
+ teamDTag: "release-review",
+ name: "Release Review",
+ description:
+ "Reads the diff, drafts the release note, and files the follow-ups.",
+ instructions:
+ "Coordinate as a unit. The reviewer and scribe share findings before the triager acts.",
+ members: [
+ {
+ memberKey: "reviewer",
+ displayName: "Reviewer",
+ systemPrompt: "Review the diff for correctness and edge cases.",
+ model: "claude-sonnet-4-5",
+ runtime: "claude-code",
+ provider: "anthropic",
+ },
+ {
+ memberKey: "scribe",
+ displayName: "Scribe",
+ systemPrompt: "Write the release note from the merged changes.",
+ },
+ {
+ memberKey: "triager",
+ displayName: "Triager",
+ systemPrompt: "File follow-ups for anything the review deferred.",
+ model: "gpt-5-codex",
+ },
+ ],
+ }),
+ createTeamCatalogEvent({
+ eventId: "b".repeat(64),
+ ownerPubkey: TEST_IDENTITIES.bob.pubkey,
+ teamDTag: "incident-desk",
+ name: "Incident Desk",
+ description: "Two agents that hold the timeline during an incident.",
+ members: [
+ {
+ memberKey: "commander",
+ displayName: "Commander",
+ systemPrompt: "Own the incident timeline and the comms cadence.",
+ },
+ {
+ memberKey: "investigator",
+ displayName: "Investigator",
+ systemPrompt: "Chase the root cause and report findings.",
+ },
+ ],
+ }),
+];
+
+async function gotoAgentsView(page: import("@playwright/test").Page) {
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await expect(page.getByTestId("open-agents-view")).toBeVisible({
+ timeout: 10_000,
+ });
+ await page.getByTestId("open-agents-view").click();
+}
+
+async function openTeamCatalog(page: import("@playwright/test").Page) {
+ await page.getByTestId("new-team-card").click();
+ await page.getByTestId("team-catalog-open").click();
+ await expect(page.getByTestId("community-catalog-dialog")).toBeVisible();
+ await waitForAnimations(page);
+}
+
+test.describe("team catalog screenshots", () => {
+ test.use({ viewport: { width: 1280, height: 900 } });
+
+ test("01 — catalog browse, add, and added states", async ({ page }) => {
+ test.setTimeout(60_000);
+ await installMockBridge(page, { teamCatalogEvents: CATALOG_EVENTS });
+ await gotoAgentsView(page);
+ await openTeamCatalog(page);
+
+ // 1. Browsing another member's publication: list, provenance, per-member
+ // model. Release Review is not the default selection, so click it.
+ const releaseReview = `community-catalog-team-${TEST_IDENTITIES.alice.pubkey}:release-review`;
+ await page.getByTestId(releaseReview).click();
+ await waitForAnimations(page);
+ await page.getByTestId("community-catalog-dialog").screenshot({
+ path: `${SHOTS}/catalog-browse.png`,
+ });
+
+ // 2. Expand the Reviewer member row to show metadata + instruction.
+ await page.getByTestId("community-catalog-member-expand-reviewer").click();
+ await waitForAnimations(page);
+ await page.getByTestId("community-catalog-dialog").screenshot({
+ path: `${SHOTS}/catalog-member-expanded.png`,
+ });
+
+ // 3. Team instructions section is visible (Release Review has instructions).
+ // Collapse the expanded member row first so the team-instructions state
+ // is visually distinct from the expanded-member screenshot above.
+ await page.getByTestId("community-catalog-member-expand-reviewer").click();
+ await waitForAnimations(page);
+ await page.getByTestId("community-catalog-dialog").screenshot({
+ path: `${SHOTS}/catalog-team-instructions.png`,
+ });
+
+ // 4. Adding closes the dialog and names the team in the notice.
+ await page.getByTestId("community-catalog-add-team").click();
+ await expect(
+ page.getByText("Added Release Review to your teams."),
+ ).toBeVisible();
+
+ // 5. Reopened: the action reads "Added to my teams" and is inert, so a
+ // second copy of the same publication is not offered.
+ await openTeamCatalog(page);
+ await page.getByTestId(releaseReview).click();
+ await expect(page.getByTestId("community-catalog-add-team")).toHaveText(
+ "Added to my teams",
+ );
+ await waitForAnimations(page);
+ await page.getByTestId("community-catalog-dialog").screenshot({
+ path: `${SHOTS}/catalog-added.png`,
+ });
+ });
+
+ test("02 — empty catalog", async ({ page }) => {
+ await installMockBridge(page, { teamCatalogEvents: [] });
+ await gotoAgentsView(page);
+ await openTeamCatalog(page);
+
+ await page.getByTestId("community-catalog-dialog").screenshot({
+ path: `${SHOTS}/catalog-empty.png`,
+ });
+ });
+
+ test("03 — share dialog before and after publishing", async ({ page }) => {
+ test.setTimeout(60_000);
+ await installMockBridge(page, {
+ personas: [
+ {
+ id: "custom:release-analyst",
+ displayName: "Release Analyst",
+ systemPrompt: "Summarise the release.",
+ },
+ {
+ id: "custom:release-scribe",
+ displayName: "Release Scribe",
+ systemPrompt: "Write the release note.",
+ },
+ ],
+ teams: [
+ {
+ id: "team-release-010",
+ name: "Release Crew",
+ description: "Ships the release notes.",
+ personaIds: ["custom:release-analyst", "custom:release-scribe"],
+ },
+ ],
+ });
+ await gotoAgentsView(page);
+
+ await page.getByLabel("Release Crew team actions").click();
+ await page.getByRole("menuitem", { name: "Share" }).click();
+ await expect(page.getByTestId("team-share-dialog")).toBeVisible();
+ await waitForAnimations(page);
+
+ // 4. Catalog access sits alongside the existing snapshot-share controls,
+ // defaulting to unchecked (not shared).
+ await page.getByTestId("team-share-dialog").screenshot({
+ path: `${SHOTS}/share-not-shared.png`,
+ });
+
+ // 5. Published: toggle the Switch to share, the toast names the effect.
+ await page.getByTestId("team-share-catalog-access").click();
+ await expect(page.getByTestId("team-share-catalog-access")).toBeChecked();
+ await expect(
+ page.getByText("Published Release Crew to the community catalog."),
+ ).toBeVisible();
+ await waitForAnimations(page);
+ await page.screenshot({ path: `${SHOTS}/share-published.png` });
+ });
+
+ test("04 — both sections populated (agents + teams)", async ({ page }) => {
+ await installMockBridge(page, {
+ personaCatalogEvents: PERSONA_CATALOG_EVENTS,
+ teamCatalogEvents: CATALOG_EVENTS,
+ });
+ await gotoAgentsView(page);
+ await openTeamCatalog(page);
+
+ // The Agents section header is visible alongside Teams in the sidebar.
+ await expect(
+ page.locator('[data-testid^="community-catalog-agent-"]'),
+ ).toHaveCount(1);
+ await expect(
+ page.locator('[data-testid^="community-catalog-team-"]'),
+ ).toHaveCount(2);
+ await waitForAnimations(page);
+ await page.getByTestId("community-catalog-dialog").screenshot({
+ path: `${SHOTS}/catalog-both-sections.png`,
+ });
+ });
+});
diff --git a/desktop/tests/e2e/team-catalog.spec.ts b/desktop/tests/e2e/team-catalog.spec.ts
new file mode 100644
index 0000000000..229bb0ef51
--- /dev/null
+++ b/desktop/tests/e2e/team-catalog.spec.ts
@@ -0,0 +1,586 @@
+import { expect, test } from "@playwright/test";
+
+import type { RelayEvent } from "@/shared/api/types";
+
+import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
+
+type CatalogMember = {
+ memberKey: string;
+ displayName: string;
+ systemPrompt: string;
+ model?: string | null;
+ runtime?: string | null;
+ provider?: string | null;
+};
+
+/** A kind:30178 head, shaped exactly as `team_catalog_content` projects it. */
+function createTeamCatalogEvent(input: {
+ ownerPubkey: string;
+ teamDTag: string;
+ name: string;
+ description?: string | null;
+ instructions?: string | null;
+ members: CatalogMember[];
+ createdAt?: number;
+ eventId?: string;
+ shared?: boolean;
+}): RelayEvent {
+ return {
+ id: input.eventId ?? "1".repeat(64),
+ pubkey: input.ownerPubkey,
+ created_at: input.createdAt ?? 1_721_750_400,
+ kind: 30178,
+ tags: [
+ ["d", input.teamDTag],
+ ...(input.shared === false ? [] : [["shared", "true"]]),
+ ],
+ content: JSON.stringify({
+ v: 1,
+ name: input.name,
+ description: input.description ?? null,
+ instructions: input.instructions ?? null,
+ members: input.members.map((member) => ({
+ member_key: member.memberKey,
+ display_name: member.displayName,
+ system_prompt: member.systemPrompt,
+ avatar_url: null,
+ runtime: member.runtime ?? null,
+ model: member.model ?? null,
+ provider: member.provider ?? null,
+ })),
+ }),
+ sig: "2".repeat(128),
+ };
+}
+
+async function gotoAgentsView(page: import("@playwright/test").Page) {
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await expect(page.getByTestId("open-agents-view")).toBeVisible({
+ timeout: 10_000,
+ });
+ await page.getByTestId("open-agents-view").click();
+}
+
+async function openTeamCatalog(page: import("@playwright/test").Page) {
+ await page.getByTestId("new-team-card").click();
+ await page.getByTestId("team-catalog-open").click();
+ await expect(page.getByTestId("community-catalog-dialog")).toBeVisible();
+}
+
+async function openTeamShareDialog(
+ page: import("@playwright/test").Page,
+ teamName: string,
+) {
+ await page.getByLabel(`${teamName} team actions`).click();
+ await page.getByRole("menuitem", { name: "Share" }).click();
+ await expect(page.getByTestId("team-share-dialog")).toBeVisible();
+}
+
+async function setTeamCatalogAccess(
+ page: import("@playwright/test").Page,
+ shared: boolean,
+) {
+ const toggle = page.getByTestId("team-share-catalog-access");
+ const isChecked = await toggle.isChecked();
+ if (isChecked !== shared) {
+ await toggle.click();
+ }
+}
+
+async function listMockTeams(page: import("@playwright/test").Page) {
+ return page.evaluate(async () => {
+ const invoke = (
+ window as Window & {
+ __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
+ command: string,
+ ) => Promise;
+ }
+ ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
+ if (!invoke) throw new Error("Mock invoke bridge is not installed.");
+ return (await invoke("list_teams")) as Array<{
+ name: string;
+ persona_ids: string[];
+ catalog_source: { owner_pubkey: string; team_d_tag: string } | null;
+ }>;
+ });
+}
+
+const ALICE_TEAM_D_TAG = "alice-review-crew";
+const ALICE_TEAM_MEMBERS: CatalogMember[] = [
+ {
+ memberKey: "reviewer",
+ displayName: "Alice’s Reviewer",
+ systemPrompt: "Review changes for the whole community.",
+ },
+ {
+ memberKey: "scribe",
+ displayName: "Alice’s Scribe",
+ systemPrompt: "Write the summary.",
+ },
+];
+
+test("an unshared kind 30178 head from another member is not offered", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ teamCatalogEvents: [
+ createTeamCatalogEvent({
+ ownerPubkey: TEST_IDENTITIES.alice.pubkey,
+ teamDTag: ALICE_TEAM_D_TAG,
+ name: "Alice’s Private Crew",
+ members: ALICE_TEAM_MEMBERS,
+ shared: false,
+ }),
+ ],
+ });
+ await gotoAgentsView(page);
+ await openTeamCatalog(page);
+
+ await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
+ await expect(
+ page.locator('[data-testid^="community-catalog-team-"]'),
+ ).toHaveCount(0);
+});
+
+test("adding another member's team records its catalog provenance", async ({
+ page,
+}) => {
+ const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
+ await installMockBridge(page, {
+ teamCatalogEvents: [
+ createTeamCatalogEvent({
+ ownerPubkey: TEST_IDENTITIES.alice.pubkey,
+ teamDTag: ALICE_TEAM_D_TAG,
+ name: "Alice’s Review Crew",
+ description: "Two agents that review and summarise.",
+ members: ALICE_TEAM_MEMBERS,
+ }),
+ ],
+ });
+ await gotoAgentsView(page);
+ await openTeamCatalog(page);
+
+ const entry = page.getByTestId(`community-catalog-team-${entryKey}`);
+ await expect(entry).toContainText("Alice’s Review Crew");
+ await entry.click();
+
+ const detail = page.getByTestId("community-catalog-detail-pane");
+ await expect(detail).toContainText("Added by alice");
+ await expect(detail).toContainText("2 members");
+ await expect(
+ page.getByTestId("community-catalog-member-reviewer"),
+ ).toContainText("Alice’s Reviewer");
+ await expect(
+ page.getByTestId("community-catalog-member-scribe"),
+ ).toContainText("Alice’s Scribe");
+
+ await page.getByTestId("community-catalog-add-team").click();
+ await expect(
+ page.getByText("Added Alice’s Review Crew to your teams."),
+ ).toBeVisible();
+
+ // The copy carries a fresh local id, so only the stored coordinate links it
+ // back to Alice's publication — that link is what stops a second copy.
+ const teams = await listMockTeams(page);
+ const added = teams.find((team) => team.name === "Alice’s Review Crew");
+ expect(added).toMatchObject({
+ catalog_source: {
+ owner_pubkey: TEST_IDENTITIES.alice.pubkey,
+ team_d_tag: ALICE_TEAM_D_TAG,
+ },
+ });
+ expect(added?.persona_ids).toHaveLength(2);
+
+ await openTeamCatalog(page);
+ await page.getByTestId(`community-catalog-team-${entryKey}`).click();
+ const addButton = page.getByTestId("community-catalog-add-team");
+ await expect(addButton).toHaveText("Added to my teams");
+ await expect(addButton).toBeDisabled();
+ expect(
+ (await listMockTeams(page)).filter(
+ (team) => team.name === "Alice’s Review Crew",
+ ),
+ ).toHaveLength(1);
+});
+
+test("a head that moved while the dialog was open is rejected", async ({
+ page,
+}) => {
+ const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
+ await installMockBridge(page, {
+ teamCatalogEvents: [
+ createTeamCatalogEvent({
+ ownerPubkey: TEST_IDENTITIES.alice.pubkey,
+ teamDTag: ALICE_TEAM_D_TAG,
+ name: "Alice’s Review Crew",
+ members: ALICE_TEAM_MEMBERS,
+ }),
+ ],
+ });
+ await gotoAgentsView(page);
+ await openTeamCatalog(page);
+ await page.getByTestId(`community-catalog-team-${entryKey}`).click();
+
+ // Republish the coordinate without notifying subscribers: the dialog keeps
+ // rendering — and keeps holding — the superseded event id.
+ await page.evaluate(
+ ({ ownerPubkey, teamDTag }) => {
+ const replace = (
+ window as Window & {
+ __BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: (
+ event: unknown,
+ ) => void;
+ }
+ ).__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__;
+ if (!replace) throw new Error("Team catalog head seam is not installed.");
+ replace({
+ id: "3".repeat(64),
+ pubkey: ownerPubkey,
+ created_at: 1_721_760_400,
+ kind: 30178,
+ tags: [
+ ["d", teamDTag],
+ ["shared", "true"],
+ ],
+ content: JSON.stringify({
+ v: 1,
+ name: "Alice’s Review Crew",
+ description: null,
+ instructions: null,
+ members: [],
+ }),
+ sig: "2".repeat(128),
+ });
+ },
+ { ownerPubkey: TEST_IDENTITIES.alice.pubkey, teamDTag: ALICE_TEAM_D_TAG },
+ );
+
+ await page.getByTestId("community-catalog-add-team").click();
+ await expect(
+ page.getByText(
+ "This team was updated since you opened the catalog. Reopen it and try again.",
+ ),
+ ).toBeVisible();
+ expect(
+ (await listMockTeams(page)).filter(
+ (team) =>
+ team.catalog_source !== null && team.catalog_source !== undefined,
+ ),
+ ).toHaveLength(0);
+});
+
+test("a lower-id head at the same timestamp is correctly selected as canonical and rejected as stale", async ({
+ page,
+}) => {
+ // Two events for the same coordinate at identical created_at. The relay's
+ // tie-break is `id ASC` so the lower-id event is canonical. The mock must
+ // agree: only the lower-id event should be selected as the head; presenting
+ // the higher-id event id when the bridge holds the lower-id one must be
+ // rejected as stale.
+ const SAME_TIMESTAMP = 1_721_760_000;
+ const LOWER_ID = "1".repeat(64); // lexicographically first — canonical
+ const HIGHER_ID = "9".repeat(64); // lexicographically second — not canonical
+
+ const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
+ await installMockBridge(page, {
+ teamCatalogEvents: [
+ createTeamCatalogEvent({
+ ownerPubkey: TEST_IDENTITIES.alice.pubkey,
+ teamDTag: ALICE_TEAM_D_TAG,
+ name: "Alice's Review Crew",
+ members: ALICE_TEAM_MEMBERS,
+ createdAt: SAME_TIMESTAMP,
+ eventId: LOWER_ID,
+ }),
+ createTeamCatalogEvent({
+ ownerPubkey: TEST_IDENTITIES.alice.pubkey,
+ teamDTag: ALICE_TEAM_D_TAG,
+ name: "Alice's Review Crew v2",
+ members: [],
+ createdAt: SAME_TIMESTAMP,
+ eventId: HIGHER_ID,
+ }),
+ ],
+ });
+ await gotoAgentsView(page);
+ await openTeamCatalog(page);
+ // The UI rendered the lower-id head (canonical); click to open the detail.
+ await page.getByTestId(`community-catalog-team-${entryKey}`).click();
+
+ // Silently replace the stored head with the higher-id event (same timestamp).
+ // The dialog still holds LOWER_ID; the bridge now considers HIGHER_ID
+ // canonical. The add must be rejected as stale.
+ await page.evaluate(
+ ({ ownerPubkey, teamDTag }) => {
+ const replace = (
+ window as Window & {
+ __BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__?: (
+ event: unknown,
+ ) => void;
+ }
+ ).__BUZZ_E2E_REPLACE_MOCK_TEAM_CATALOG_HEAD__;
+ if (!replace) throw new Error("Team catalog head seam is not installed.");
+ replace({
+ id: "9".repeat(64),
+ pubkey: ownerPubkey,
+ created_at: 1_721_760_000,
+ kind: 30178,
+ tags: [
+ ["d", teamDTag],
+ ["shared", "true"],
+ ],
+ content: JSON.stringify({
+ v: 1,
+ name: "Alice's Review Crew v2",
+ description: null,
+ instructions: null,
+ members: [],
+ }),
+ sig: "2".repeat(128),
+ });
+ },
+ { ownerPubkey: TEST_IDENTITIES.alice.pubkey, teamDTag: ALICE_TEAM_D_TAG },
+ );
+
+ await page.getByTestId("community-catalog-add-team").click();
+ await expect(
+ page.getByText(
+ "This team was updated since you opened the catalog. Reopen it and try again.",
+ ),
+ ).toBeVisible();
+});
+
+test("sharing a team publishes it to the catalog and unsharing retracts it", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ personas: [
+ {
+ id: "custom:release-analyst",
+ displayName: "Release Analyst",
+ systemPrompt: "Summarise the release.",
+ },
+ ],
+ teams: [
+ {
+ id: "team-release-010",
+ name: "Release Crew",
+ description: "Ships the release notes.",
+ personaIds: ["custom:release-analyst"],
+ },
+ ],
+ });
+ await gotoAgentsView(page);
+
+ await openTeamCatalog(page);
+ await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
+ await page.keyboard.press("Escape");
+
+ await openTeamShareDialog(page, "Release Crew");
+ const catalogAccess = page.getByTestId("team-share-catalog-access");
+ await expect(catalogAccess).not.toBeChecked();
+ await setTeamCatalogAccess(page, true);
+ await expect(catalogAccess).toBeChecked();
+ await expect(
+ page.getByText("Published Release Crew to the community catalog."),
+ ).toBeVisible();
+ await page
+ .getByTestId("team-share-dialog")
+ .getByRole("button", { name: "Close" })
+ .click();
+
+ await openTeamCatalog(page);
+ const ownEntry = page.locator('[data-testid^="community-catalog-team-"]');
+ await expect(ownEntry).toHaveCount(1);
+ await ownEntry.click();
+ await expect(page.getByTestId("community-catalog-detail-pane")).toContainText(
+ "Added by You",
+ );
+ // The publisher already has the team, so the catalog must not offer a copy.
+ await expect(page.getByTestId("community-catalog-add-team")).toBeDisabled();
+ await page.keyboard.press("Escape");
+
+ await openTeamShareDialog(page, "Release Crew");
+ await setTeamCatalogAccess(page, false);
+ await expect(
+ page.getByText(
+ "Release Crew is no longer discoverable in the community catalog.",
+ ),
+ ).toBeVisible();
+ await page
+ .getByTestId("team-share-dialog")
+ .getByRole("button", { name: "Close" })
+ .click();
+
+ await openTeamCatalog(page);
+ await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
+});
+
+test("a queued team share is not presented as relay-published", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ personas: [
+ {
+ id: "custom:queued-analyst",
+ displayName: "Queued Analyst",
+ systemPrompt: "Wait for relay acceptance.",
+ },
+ ],
+ teams: [
+ {
+ id: "team-queued-011",
+ name: "Queued Crew",
+ description: null,
+ personaIds: ["custom:queued-analyst"],
+ },
+ ],
+ teamSharePublicationStatuses: ["queued"],
+ });
+ await gotoAgentsView(page);
+
+ await openTeamShareDialog(page, "Queued Crew");
+ await setTeamCatalogAccess(page, true);
+ await expect(
+ page.getByText(
+ "Sharing Queued Crew is queued. It will appear after the relay accepts the update.",
+ ),
+ ).toBeVisible();
+ await page
+ .getByTestId("team-share-dialog")
+ .getByRole("button", { name: "Close" })
+ .click();
+
+ await openTeamCatalog(page);
+ await expect(page.getByTestId("community-catalog-empty-state")).toBeVisible();
+});
+
+test("expanding a member row reveals its metadata and instruction", async ({
+ page,
+}) => {
+ const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
+ await installMockBridge(page, {
+ teamCatalogEvents: [
+ createTeamCatalogEvent({
+ ownerPubkey: TEST_IDENTITIES.alice.pubkey,
+ teamDTag: ALICE_TEAM_D_TAG,
+ name: "Alice's Review Crew",
+ members: [
+ {
+ memberKey: "reviewer",
+ displayName: "Alice's Reviewer",
+ systemPrompt: "Review changes for the whole community.",
+ model: "claude-sonnet-4-5",
+ runtime: "claude-code",
+ provider: "anthropic",
+ },
+ ],
+ }),
+ ],
+ });
+ await gotoAgentsView(page);
+ await openTeamCatalog(page);
+ await page.getByTestId(`community-catalog-team-${entryKey}`).click();
+
+ const memberRow = page.getByTestId("community-catalog-member-reviewer");
+ await expect(memberRow).toBeVisible();
+
+ // Metadata card and instruction are hidden before expansion.
+ await expect(memberRow.getByTestId("agent-definition-metadata")).toBeHidden();
+
+ // Expand the row.
+ await page.getByTestId("community-catalog-member-expand-reviewer").click();
+
+ // aria-expanded transitions to true.
+ await expect(
+ page.getByTestId("community-catalog-member-expand-reviewer"),
+ ).toHaveAttribute("aria-expanded", "true");
+
+ // Metadata card is now visible and contains model/runtime/provider.
+ const metadata = memberRow.getByTestId("agent-definition-metadata");
+ await expect(metadata).toBeVisible();
+ await expect(metadata).toContainText("claude-sonnet-4-5");
+ await expect(metadata).toContainText("claude-code");
+ await expect(metadata).toContainText("anthropic");
+
+ // Instruction text is visible.
+ await expect(memberRow).toContainText(
+ "Review changes for the whole community.",
+ );
+});
+
+test("expanding a member with no system prompt shows the no-instructions placeholder", async ({
+ page,
+}) => {
+ const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
+ await installMockBridge(page, {
+ teamCatalogEvents: [
+ createTeamCatalogEvent({
+ ownerPubkey: TEST_IDENTITIES.alice.pubkey,
+ teamDTag: ALICE_TEAM_D_TAG,
+ name: "Alice's Review Crew",
+ members: [
+ {
+ memberKey: "reviewer",
+ displayName: "Alice's Reviewer",
+ systemPrompt: "",
+ },
+ ],
+ }),
+ ],
+ });
+ await gotoAgentsView(page);
+ await openTeamCatalog(page);
+ await page.getByTestId(`community-catalog-team-${entryKey}`).click();
+
+ await page.getByTestId("community-catalog-member-expand-reviewer").click();
+ await expect(
+ page.getByTestId("community-catalog-member-reviewer"),
+ ).toContainText("No instructions");
+});
+
+test("team instructions section is visible when the team has instructions set", async ({
+ page,
+}) => {
+ const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
+ await installMockBridge(page, {
+ teamCatalogEvents: [
+ createTeamCatalogEvent({
+ ownerPubkey: TEST_IDENTITIES.alice.pubkey,
+ teamDTag: ALICE_TEAM_D_TAG,
+ name: "Alice's Review Crew",
+ instructions: "Always check for security issues first.",
+ members: ALICE_TEAM_MEMBERS,
+ }),
+ ],
+ });
+ await gotoAgentsView(page);
+ await openTeamCatalog(page);
+ await page.getByTestId(`community-catalog-team-${entryKey}`).click();
+
+ const detail = page.getByTestId("community-catalog-detail-pane");
+ await expect(detail).toContainText("Team instructions");
+ await expect(detail).toContainText("Always check for security issues first.");
+});
+
+test("team instructions section is absent when instructions are not set", async ({
+ page,
+}) => {
+ const entryKey = `${TEST_IDENTITIES.alice.pubkey}:${ALICE_TEAM_D_TAG}`;
+ await installMockBridge(page, {
+ teamCatalogEvents: [
+ createTeamCatalogEvent({
+ ownerPubkey: TEST_IDENTITIES.alice.pubkey,
+ teamDTag: ALICE_TEAM_D_TAG,
+ name: "Alice's Review Crew",
+ members: ALICE_TEAM_MEMBERS,
+ }),
+ ],
+ });
+ await gotoAgentsView(page);
+ await openTeamCatalog(page);
+ await page.getByTestId(`community-catalog-team-${entryKey}`).click();
+
+ const detail = page.getByTestId("community-catalog-detail-pane");
+ await expect(detail).not.toContainText("Team instructions");
+});
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 0234254410..5931bef483 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -249,6 +249,10 @@ type MockBridgeOptions = {
/** Outcomes for successive explicit persona share publications. */
personaSharePublicationStatuses?: Array<"published" | "queued">;
teams?: MockTeamSeed[];
+ /** Community team-catalog (kind:30178) heads returned by relay queries. */
+ teamCatalogEvents?: RelayEvent[];
+ /** Outcomes for successive explicit team share publications. */
+ teamSharePublicationStatuses?: Array<"published" | "queued">;
relayAgents?: MockRelayAgentSeed[];
/** Delay both managed and relay agent directory reads. */
agentListDelayMs?: number;