From f869c8038feeeee524ea37445df35fe934d796dc Mon Sep 17 00:00:00 2001 From: Duncan Date: Thu, 6 Aug 2026 17:09:52 -0400 Subject: [PATCH] feat(team-catalog): add community catalog UI, team share dialog, and e2e tests Parse layer (teamCatalogRelay.ts, catalogRelay.ts) decodes kind 30178 projection events and feeds the catalog hooks. CommunityCatalogDialog unifies the Agents and Teams sections in a single browsable surface with selection-state helpers (communityCatalogSelection.ts) covering preferred-loading, vanished-item repair, and cross-section auto-init. TeamShareDialog publishes and unshares catalog entries. Playwright e2e suite covers the full catalog flow and screenshot regression set. PersonaCatalogDialog is removed; persona browsing now lives inside the unified dialog. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/playwright.config.ts | 2 + .../src/features/agents/lib/catalogRelay.ts | 241 ++++++ .../agents/lib/personaCatalogRelay.test.mjs | 8 +- .../agents/lib/personaCatalogRelay.ts | 187 +---- .../agents/lib/teamCatalogRelay.test.mjs | 791 ++++++++++++++++++ .../features/agents/lib/teamCatalogRelay.ts | 430 ++++++++++ .../agents/lib/useAgentsDataRefresh.ts | 18 + .../agents/lib/useTeamCatalogRelay.ts | 118 +++ .../agents/ui/AgentDefinitionMetadata.tsx | 12 +- desktop/src/features/agents/ui/AgentsView.tsx | 72 +- .../agents/ui/CommunityCatalogDialog.tsx | 678 +++++++++++++++ .../agents/ui/PersonaCatalogDialog.tsx | 385 --------- .../features/agents/ui/TeamShareDialog.tsx | 39 + .../src/features/agents/ui/TeamsSection.tsx | 13 + .../features/agents/ui/catalogOwnerLabel.ts | 15 + .../ui/communityCatalogSelection.test.mjs | 316 +++++++ .../agents/ui/communityCatalogSelection.ts | 61 ++ .../ui/personaCatalogOwnerLabel.test.mjs | 2 +- .../src/features/agents/ui/teamLibraryCopy.ts | 55 ++ .../features/agents/ui/usePersonaActions.ts | 10 - .../src/features/agents/ui/useTeamActions.ts | 110 +++ .../settings/ui/HarnessCatalogDialog.tsx | 4 +- desktop/src/shared/api/tauriTeams.ts | 106 ++- desktop/src/shared/api/teamTypes.ts | 61 ++ desktop/src/shared/api/types.ts | 38 +- desktop/src/shared/constants/kinds.ts | 5 + desktop/src/shared/styles/globals/theme.css | 2 +- desktop/src/testing/e2eBridge.ts | 261 +++++- desktop/tests/e2e/agents.spec.ts | 92 +- .../e2e/team-catalog-screenshots.spec.ts | 293 +++++++ desktop/tests/e2e/team-catalog.spec.ts | 586 +++++++++++++ desktop/tests/helpers/bridge.ts | 4 + 32 files changed, 4322 insertions(+), 693 deletions(-) create mode 100644 desktop/src/features/agents/lib/catalogRelay.ts create mode 100644 desktop/src/features/agents/lib/teamCatalogRelay.test.mjs create mode 100644 desktop/src/features/agents/lib/teamCatalogRelay.ts create mode 100644 desktop/src/features/agents/lib/useTeamCatalogRelay.ts create mode 100644 desktop/src/features/agents/ui/CommunityCatalogDialog.tsx delete mode 100644 desktop/src/features/agents/ui/PersonaCatalogDialog.tsx create mode 100644 desktop/src/features/agents/ui/catalogOwnerLabel.ts create mode 100644 desktop/src/features/agents/ui/communityCatalogSelection.test.mjs create mode 100644 desktop/src/features/agents/ui/communityCatalogSelection.ts create mode 100644 desktop/src/features/agents/ui/teamLibraryCopy.ts create mode 100644 desktop/src/shared/api/teamTypes.ts create mode 100644 desktop/tests/e2e/team-catalog-screenshots.spec.ts create mode 100644 desktop/tests/e2e/team-catalog.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c1ea0e061b..437fe0748c 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -141,6 +141,7 @@ export default defineConfig({ "**/huddle-transcription.spec.ts", "**/agent-numeric-tuning.spec.ts", "**/needs-restart-screenshots.spec.ts", + "**/team-catalog-screenshots.spec.ts", ], use: { ...devices["Desktop Chrome"], @@ -162,6 +163,7 @@ export default defineConfig({ "**/persona-env-vars.spec.ts", "**/persona-sync.spec.ts", "**/team-snapshot.spec.ts", + "**/team-catalog.spec.ts", "**/agents-everywhere.live.spec.ts", "**/relay-restart.live.spec.ts", "**/parity-ancestor-island.spec.ts", diff --git a/desktop/src/features/agents/lib/catalogRelay.ts b/desktop/src/features/agents/lib/catalogRelay.ts new file mode 100644 index 0000000000..5b6ad99948 --- /dev/null +++ b/desktop/src/features/agents/lib/catalogRelay.ts @@ -0,0 +1,241 @@ +import { relayClient } from "@/shared/api/relayClient"; +import type { RelayEvent } from "@/shared/api/types"; + +/** + * Kind-generic reads of the community catalog. + * + * Personas (kind:30175) and teams (kind:30178) are two projections of one + * publishing model: a NIP-33 replaceable head per coordinate, discoverable + * only while it carries an exact `["shared","true"]` tag. The relay gates both + * through one `SHARED_GATED_KINDS` set, so the client-side read half is shared + * here rather than written twice — the only thing that differs between them is + * how the content body parses. + */ + +/** + * Whether the current identity may share this item to the catalog, and at + * what level. `"none"` means shared with no memories attached; the persona + * dialog and the team dialog both render it through `SnapshotOptionMenu`. + */ +export type CatalogShareLevel = "not-shared" | "none"; + +/** + * A tag's value, but only when the event carries exactly one of that tag. + * + * Ambiguity is treated as absence: the relay's ingest rule admits exactly one + * bounded `d` tag, so a multi-`d` event is malformed, and picking the first + * would resolve a different coordinate than the publisher addressed. + */ +function singleTagValue(event: RelayEvent, name: string): string | null { + const matches = event.tags.filter( + (tag) => tag.length >= 2 && tag[0] === name && typeof tag[1] === "string", + ); + return matches.length === 1 ? (matches[0]?.[1] ?? null) : null; +} + +/** + * Whether a catalog head is discoverable — exactly one `["shared","true"]`. + * + * Mirrors the relay's `event_is_shared` gate byte for byte, including the + * two-element length check: a `["shared","true","extra"]` tag is not the tag + * the gate admits, so treating it as shared here would show the community an + * entry the relay will not serve. + */ +function eventIsShared(event: RelayEvent): boolean { + const sharedTags = event.tags.filter((tag) => tag[0] === "shared"); + return ( + sharedTags.length === 1 && + sharedTags[0]?.length === 2 && + sharedTags[0]?.[1] === "true" + ); +} + +function isSafeHttpUrl(value: unknown): value is string { + // Length cap in UTF-8 bytes — same unit as the Rust is_safe_catalog_avatar_url + // cap. This deliberately differs from `value.length` (UTF-16 code units) so + // both sides agree for non-ASCII input (e.g. URLs with encoded emoji). + // + // byteLength is defined locally here (and identically in teamCatalogRelay.ts) + // so catalogRelay.ts stays self-contained. + function byteLength(s: string): number { + return new TextEncoder().encode(s).length; + } + if ( + typeof value !== "string" || + value.length === 0 || + byteLength(value) > 2_048 || + /[\s()]/u.test(value) + ) { + return false; + } + try { + const parsed = new URL(value); + return parsed.protocol === "https:" || parsed.protocol === "http:"; + } catch { + return false; + } +} + +/** + * Emoji avatars are the one `data:` avatar a catalog entry keeps. + * + * They persist as inline, percent-encoded SVG (`emojiAvatarDataUrl` in + * `ProfileAvatarEditor.utils.ts`), so they are self-contained and render on + * any member's machine — unlike a bundled runtime-default avatar, whose local + * asset path means nothing to another install. The accepted shape is exactly + * that prefix: the trailing comma is what rejects `;base64` payloads, and + * every other `data:` MIME stays rejected. Catalog avatars render through + * `` (`ProfileAvatar` → `AvatarImage`), where SVG script never + * executes, so bounding the length is the remaining concern — 8 KiB is an + * order of magnitude above the ~700 characters an emoji avatar encodes to. + */ +const INLINE_SVG_AVATAR_PREFIX = "data:image/svg+xml,"; +const MAX_INLINE_SVG_AVATAR_LENGTH = 8_192; + +function isInlineSvgAvatar(value: unknown): value is string { + return ( + typeof value === "string" && + value.startsWith(INLINE_SVG_AVATAR_PREFIX) && + value.length <= MAX_INLINE_SVG_AVATAR_LENGTH + ); +} + +/** + * Shared persona heads can carry an uploaded avatar as an inline raster. Keep + * those self-contained images renderable without accepting arbitrary `data:` + * URLs: only the raster MIME types browsers decode in ``, strict base64 + * shape, and a bound no larger than the relay's event-content ceiling. + */ +const MAX_INLINE_RASTER_AVATAR_LENGTH = 256 * 1_024; +const INLINE_RASTER_AVATAR_RE = + /^data:image\/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$/u; + +function isInlineRasterAvatar(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length > MAX_INLINE_RASTER_AVATAR_LENGTH + ) { + return false; + } + const match = INLINE_RASTER_AVATAR_RE.exec(value); + return match !== null && (match[1]?.length ?? 0) % 4 === 0; +} + +/** + * An avatar URL that is safe to hand to ``, or `null`. + * + * The publisher controls this string end to end, so the allowlist is the only + * thing standing between a hostile catalog entry and a `javascript:` or + * arbitrary-`data:` URL in the DOM. Shared by both catalog readers so a team's + * embedded members are held to exactly the persona rule — a team projection + * embeds N member avatars, which is N times the surface, not less. + */ +export function safeCatalogAvatarUrl(value: unknown): string | null { + return isSafeHttpUrl(value) || + isInlineSvgAvatar(value) || + isInlineRasterAvatar(value) + ? value + : null; +} + +/** One shared head, with the coordinate it was claimed under. */ +export type CatalogHead = { + event: RelayEvent; + ownerPubkey: string; + dTag: string; +}; + +/** + * Collapse relay results to the canonical NIP-33 head per coordinate, then + * keep only the shared ones. + * + * The relay normally returns one replaceable head. The client-side collapse is + * defense in depth for older relays and fixtures, and deliberately claims the + * coordinate before testing `shared` so an unshared newest head cannot + * resurrect an older shared definition. A caller that later fails to parse a + * claimed head must likewise drop the coordinate rather than fall back. + */ +export function sharedCatalogHeads( + events: readonly RelayEvent[], + kind: number, +): CatalogHead[] { + const sorted = [...events].sort( + (left, right) => + right.created_at - left.created_at || left.id.localeCompare(right.id), + ); + const seenCoordinates = new Set(); + const heads: CatalogHead[] = []; + + for (const event of sorted) { + if (event.kind !== kind) continue; + const dTag = singleTagValue(event, "d"); + if (!dTag) continue; + const ownerPubkey = event.pubkey.toLowerCase(); + const coordinate = `${ownerPubkey}:${dTag}`; + if (seenCoordinates.has(coordinate)) continue; + seenCoordinates.add(coordinate); + + if (!eventIsShared(event)) continue; + heads.push({ event, ownerPubkey, dTag }); + } + + return heads; +} + +/** + * Events per catalog page. + * + * Kept well under the relay's 1,000-row `query_events` clamp so a page that + * comes back full is a reliable "there may be more" signal rather than a + * silently truncated result. + */ +const CATALOG_PAGE_SIZE = 500; + +/** + * Hard bound on pages walked, so a relay that keeps returning full pages can + * never spin this forever. + */ +const MAX_CATALOG_PAGES = 40; + +/** + * Read every event of one catalog kind, page by page. + * + * A single `limit`-capped fetch silently truncates once a community publishes + * more than the relay's clamp, and the entries that fall off are simply + * undiscoverable. Paging walks backwards through `created_at` using the only + * cursor a WS `REQ` filter carries — `until` — which the relay treats as + * *inclusive*, so consecutive pages overlap on tied timestamps. Two things + * follow, and both are load-bearing: + * + * - dedupe by event id, because the boundary events repeat; and + * - stop when a page contributes nothing new, because a page whose events all + * share one `created_at` would otherwise be requested forever. + */ +export async function fetchCatalogEvents(kind: number): Promise { + const byId = new Map(); + let until: number | undefined; + + for (let page = 0; page < MAX_CATALOG_PAGES; page += 1) { + const events = await relayClient.fetchEvents({ + kinds: [kind], + limit: CATALOG_PAGE_SIZE, + ...(until === undefined ? {} : { until }), + }); + + const sizeBefore = byId.size; + let oldestCreatedAt = Number.POSITIVE_INFINITY; + for (const event of events) { + byId.set(event.id, event); + oldestCreatedAt = Math.min(oldestCreatedAt, event.created_at); + } + + // A short page is the end of the catalog; a page of only-repeats means the + // cursor cannot advance past a run of tied timestamps. + if (events.length < CATALOG_PAGE_SIZE || byId.size === sizeBefore) { + break; + } + until = oldestCreatedAt; + } + + return [...byId.values()]; +} diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index fbaf1f5274..037f619c4d 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -7,7 +7,6 @@ import { catalogPersonasFromPublications, catalogPublicationsFromEvents, fetchPersonaCatalogPublications, - personaEventIsShared, } from "./personaCatalogRelay.ts"; const ALICE = "a".repeat(64); @@ -119,10 +118,6 @@ test("an invalid canonical head does not resurrect an older shared persona", () }); test("only an exact shared true tag opts a persona into discovery", () => { - assert.equal( - personaEventIsShared(personaEvent({ createdAt: 1, id: "exact-shared" })), - true, - ); for (const [index, sharedTag] of [ ["shared"], ["shared", "false"], @@ -134,7 +129,6 @@ test("only an exact shared true tag opts a persona into discovery", () => { shared: false, sharedTag, }); - assert.equal(personaEventIsShared(event), false); assert.deepEqual(catalogPublicationsFromEvents([event]), []); } const duplicate = personaEvent({ @@ -142,7 +136,7 @@ test("only an exact shared true tag opts a persona into discovery", () => { id: "duplicate", }); duplicate.tags.push(["shared", "true"]); - assert.equal(personaEventIsShared(duplicate), false); + assert.deepEqual(catalogPublicationsFromEvents([duplicate]), []); }); test("catalog avatars keep bounded http URLs and drop unsafe schemes", () => { diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index 02c3f8e202..a26e1cfd34 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -1,4 +1,9 @@ -import { relayClient } from "@/shared/api/relayClient"; +import { + fetchCatalogEvents, + safeCatalogAvatarUrl, + sharedCatalogHeads, + type CatalogShareLevel, +} from "@/features/agents/lib/catalogRelay"; import type { AgentPersona, CatalogSourceCoordinate, @@ -7,7 +12,7 @@ import type { } from "@/shared/api/types"; import { KIND_PERSONA } from "@/shared/constants/kinds"; -export type CatalogPersonaShareLevel = "not-shared" | "none"; +export type CatalogPersonaShareLevel = CatalogShareLevel; type CatalogAgentProjection = { displayName: string; @@ -44,84 +49,6 @@ function isObject(value: unknown): value is JsonObject { return typeof value === "object" && value !== null && !Array.isArray(value); } -function extractTag(event: RelayEvent, name: string): string | null { - const matches = event.tags.filter( - (tag) => tag.length >= 2 && tag[0] === name && typeof tag[1] === "string", - ); - return matches.length === 1 ? (matches[0]?.[1] ?? null) : null; -} - -export function personaEventIsShared(event: RelayEvent): boolean { - const sharedTags = event.tags.filter((tag) => tag[0] === "shared"); - return ( - sharedTags.length === 1 && - sharedTags[0]?.length === 2 && - sharedTags[0]?.[1] === "true" - ); -} - -function isSafeHttpUrl(value: unknown): value is string { - if ( - typeof value !== "string" || - value.length === 0 || - value.length > 2_048 || - /[\s()]/u.test(value) - ) { - return false; - } - try { - const parsed = new URL(value); - return parsed.protocol === "https:" || parsed.protocol === "http:"; - } catch { - return false; - } -} - -/** - * Emoji avatars are the one `data:` avatar a catalog entry keeps. - * - * They persist as inline, percent-encoded SVG (`emojiAvatarDataUrl` in - * `ProfileAvatarEditor.utils.ts`), so they are self-contained and render on - * any member's machine — unlike a bundled runtime-default avatar, whose local - * asset path means nothing to another install. The accepted shape is exactly - * that prefix: the trailing comma is what rejects `;base64` payloads, and - * every other `data:` MIME stays rejected. Catalog avatars render through - * `` (`ProfileAvatar` → `AvatarImage`), where SVG script never - * executes, so bounding the length is the remaining concern — 8 KiB is an - * order of magnitude above the ~700 characters an emoji avatar encodes to. - */ -const INLINE_SVG_AVATAR_PREFIX = "data:image/svg+xml,"; -const MAX_INLINE_SVG_AVATAR_LENGTH = 8_192; - -/** - * Shared persona heads can carry an uploaded avatar as an inline raster. Keep - * those self-contained images renderable without accepting arbitrary `data:` - * URLs: only the raster MIME types browsers decode in ``, strict base64 - * shape, and a bound no larger than the relay's event-content ceiling. - */ -const MAX_INLINE_RASTER_AVATAR_LENGTH = 256 * 1_024; -const INLINE_RASTER_AVATAR_RE = - /^data:image\/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$/u; - -function isInlineSvgAvatar(value: unknown): value is string { - return ( - typeof value === "string" && - value.startsWith(INLINE_SVG_AVATAR_PREFIX) && - value.length <= MAX_INLINE_SVG_AVATAR_LENGTH - ); -} - -function isInlineRasterAvatar(value: unknown): value is string { - if ( - typeof value !== "string" || - value.length > MAX_INLINE_RASTER_AVATAR_LENGTH - ) { - return false; - } - const match = INLINE_RASTER_AVATAR_RE.exec(value); - return match !== null && (match[1]?.length ?? 0) % 4 === 0; -} - function optionalString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } @@ -141,12 +68,7 @@ function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { return null; } - const avatarUrl = - isSafeHttpUrl(parsed.avatar_url) || - isInlineSvgAvatar(parsed.avatar_url) || - isInlineRasterAvatar(parsed.avatar_url) - ? parsed.avatar_url - : null; + const avatarUrl = safeCatalogAvatarUrl(parsed.avatar_url); const namePool = Array.isArray(parsed.name_pool) ? parsed.name_pool.filter( (candidate): candidate is string => typeof candidate === "string", @@ -181,41 +103,25 @@ function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { } /** - * Collapse relay results to the canonical NIP-33 head for each persona - * coordinate, then keep only exact `["shared", "true"]` heads. + * Project the shared kind:30175 heads onto persona publications. * - * The relay normally returns one replaceable head. The client-side collapse is - * defense in depth for older relays and fixtures, and deliberately claims the - * coordinate before parsing so an invalid or unshared newest head cannot - * resurrect an older shared definition. + * A head whose content does not parse is dropped, not retried against an older + * event: [`sharedCatalogHeads`] already claimed the coordinate, so falling + * back would resurrect a superseded definition. */ export function catalogPublicationsFromEvents( events: readonly RelayEvent[], ): PersonaCatalogPublication[] { - const sorted = [...events].sort( - (left, right) => - right.created_at - left.created_at || left.id.localeCompare(right.id), - ); - const seenCoordinates = new Set(); const publications: PersonaCatalogPublication[] = []; - for (const event of sorted) { - if (event.kind !== KIND_PERSONA) continue; - const sourcePersonaId = extractTag(event, "d"); - if (!sourcePersonaId) continue; - const ownerPubkey = event.pubkey.toLowerCase(); - const coordinate = `${ownerPubkey}:${sourcePersonaId}`; - if (seenCoordinates.has(coordinate)) continue; - seenCoordinates.add(coordinate); - - if (!personaEventIsShared(event)) continue; - const agent = parsePersonaContent(event); + for (const head of sharedCatalogHeads(events, KIND_PERSONA)) { + const agent = parsePersonaContent(head.event); if (!agent) continue; publications.push({ - eventId: event.id, - ownerPubkey, - sourcePersonaId, - createdAt: event.created_at, + eventId: head.event.id, + ownerPubkey: head.ownerPubkey, + sourcePersonaId: head.dTag, + createdAt: head.event.created_at, agent, }); } @@ -223,64 +129,11 @@ export function catalogPublicationsFromEvents( return publications; } -/** - * Events per catalog page. - * - * Kept well under the relay's 1,000-row `query_events` clamp so a page that - * comes back full is a reliable "there may be more" signal rather than a - * silently truncated result. - */ -const CATALOG_PAGE_SIZE = 500; - -/** - * Hard bound on pages walked, so a relay that keeps returning full pages can - * never spin this forever. - */ -const MAX_CATALOG_PAGES = 40; - -/** - * Read every shared persona event, page by page. - * - * A single `limit`-capped fetch silently truncates once a community publishes - * more agents than the relay's clamp, and the entries that fall off are simply - * undiscoverable. Paging walks backwards through `created_at` using the only - * cursor a WS `REQ` filter carries — `until` — which the relay treats as - * *inclusive*, so consecutive pages overlap on tied timestamps. Two things - * follow, and both are load-bearing: - * - * - dedupe by event id, because the boundary events repeat; and - * - stop when a page contributes nothing new, because a page whose events all - * share one `created_at` would otherwise be requested forever. - */ +/** Read every shared persona event, page by page. */ export async function fetchPersonaCatalogPublications(): Promise< PersonaCatalogPublication[] > { - const byId = new Map(); - let until: number | undefined; - - for (let page = 0; page < MAX_CATALOG_PAGES; page += 1) { - const events = await relayClient.fetchEvents({ - kinds: [KIND_PERSONA], - limit: CATALOG_PAGE_SIZE, - ...(until === undefined ? {} : { until }), - }); - - const sizeBefore = byId.size; - let oldestCreatedAt = Number.POSITIVE_INFINITY; - for (const event of events) { - byId.set(event.id, event); - oldestCreatedAt = Math.min(oldestCreatedAt, event.created_at); - } - - // A short page is the end of the catalog; a page of only-repeats means the - // cursor cannot advance past a run of tied timestamps. - if (events.length < CATALOG_PAGE_SIZE || byId.size === sizeBefore) { - break; - } - until = oldestCreatedAt; - } - - return catalogPublicationsFromEvents([...byId.values()]); + return catalogPublicationsFromEvents(await fetchCatalogEvents(KIND_PERSONA)); } function publicationToPersona( diff --git a/desktop/src/features/agents/lib/teamCatalogRelay.test.mjs b/desktop/src/features/agents/lib/teamCatalogRelay.test.mjs new file mode 100644 index 0000000000..a1105a7072 --- /dev/null +++ b/desktop/src/features/agents/lib/teamCatalogRelay.test.mjs @@ -0,0 +1,791 @@ +import assert from "node:assert/strict"; +import test, { mock } from "node:test"; + +import { relayClient } from "@/shared/api/relayClient"; +import { emojiAvatarDataUrl } from "@/features/profile/ui/ProfileAvatarEditor.utils.ts"; +import { + catalogTeamsFromPublications, + fetchTeamCatalogPublications, + parseTeamCatalogContent, + teamCatalogPublicationsFromEvents, +} from "./teamCatalogRelay.ts"; +import { + teamAutoRetractedNotice, + teamCatalogCopy, +} from "../ui/teamLibraryCopy.ts"; + +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); + +function member(overrides = {}) { + return { + member_key: "reviewer", + display_name: "Relay Reviewer", + system_prompt: "Review changes.", + avatar_url: null, + runtime: "goose", + model: "claude", + ...overrides, + }; +} + +function teamEvent({ + createdAt = 1, + id = "alice-team", + owner = ALICE, + teamDTag = "squad", + kind = 30178, + shared = true, + sharedTag, + members = [member()], + content, + version = 1, + name = "Review Squad", +}) { + return { + id, + pubkey: owner, + created_at: createdAt, + kind, + tags: [ + ["d", teamDTag], + ...(shared + ? [sharedTag ?? ["shared", "true"]] + : sharedTag + ? [sharedTag] + : []), + ], + content: + content ?? + JSON.stringify({ + v: version, + name, + description: "Reviews everything.", + instructions: "Be thorough.", + members, + }), + sig: "sig", + }; +} + +function localTeam(overrides = {}) { + return { + id: "local-1", + name: "Review Squad", + description: null, + instructions: null, + personaIds: [], + isBuiltin: false, + shared: false, + catalogSource: null, + sourceDir: null, + isSymlink: false, + symlinkTarget: null, + version: null, + createdAt: "2026-07-30T00:00:00.000Z", + updatedAt: "2026-07-30T00:00:00.000Z", + ...overrides, + }; +} + +test("test_shared_team_projection_is_discoverable_with_its_members", () => { + const publications = teamCatalogPublicationsFromEvents([teamEvent({})]); + + assert.equal(publications.length, 1); + assert.equal(publications[0].name, "Review Squad"); + assert.equal(publications[0].ownerPubkey, ALICE); + assert.equal(publications[0].teamDTag, "squad"); + assert.equal(publications[0].eventId, "alice-team"); + assert.equal(publications[0].members.length, 1); + assert.equal(publications[0].members[0].memberKey, "reviewer"); + assert.equal(publications[0].members[0].displayName, "Relay Reviewer"); +}); + +// A team's own wire body (30176) shares the coordinate namespace with its +// catalog projection (30178) but is not a projection — reading one as the +// other would show the community a body it never opted into publishing. +test("test_team_wire_kind_is_not_read_as_a_catalog_projection", () => { + assert.deepEqual( + teamCatalogPublicationsFromEvents([teamEvent({ kind: 30176 })]), + [], + ); +}); + +test("test_unshared_newer_head_hides_the_older_shared_team", () => { + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ createdAt: 1, id: "shared" }), + teamEvent({ createdAt: 2, id: "retracted", shared: false }), + ]); + + assert.deepEqual(publications, []); +}); + +test("test_only_an_exact_shared_true_tag_opts_a_team_into_discovery", () => { + for (const [index, sharedTag] of [ + ["shared"], + ["shared", "false"], + ["shared", "true", "extra"], + ].entries()) { + const event = teamEvent({ + createdAt: index + 2, + id: `malformed-${index}`, + shared: false, + sharedTag, + }); + assert.deepEqual(teamCatalogPublicationsFromEvents([event]), []); + } + + const duplicate = teamEvent({ createdAt: 5, id: "duplicate" }); + duplicate.tags.push(["shared", "true"]); + assert.deepEqual(teamCatalogPublicationsFromEvents([duplicate]), []); +}); + +// Two `d` tags name two coordinates; the relay's ingest rule rejects that +// shape, so honouring the first here would resolve a coordinate the publisher +// never claimed. +test("test_two_d_tags_make_a_head_unaddressable", () => { + const ambiguous = teamEvent({ id: "ambiguous" }); + ambiguous.tags.push(["d", "other-squad"]); + + assert.deepEqual(teamCatalogPublicationsFromEvents([ambiguous]), []); +}); + +test("test_unparsable_head_does_not_resurrect_an_older_shared_team", () => { + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ createdAt: 1, id: "older-valid" }), + teamEvent({ createdAt: 2, id: "b".repeat(64), content: "{}" }), + ]); + + assert.deepEqual(publications, []); +}); + +// A future body may legally reshape any field, so rendering whatever happens +// to parse as v1 would present a corrupted team as a valid one. +test("test_unknown_schema_version_is_rejected_rather_than_best_effort_parsed", () => { + assert.deepEqual( + teamCatalogPublicationsFromEvents([teamEvent({ version: 2 })]), + [], + ); + assert.deepEqual( + teamCatalogPublicationsFromEvents([ + teamEvent({ + content: JSON.stringify({ name: "Review Squad", members: [] }), + }), + ]), + [], + "a body with no version at all is not implicitly v1", + ); +}); + +// Invalid members are counted, not dropped: a team with invalid members is +// shown with a diagnostic and the Add button disabled, so the user can see +// what is wrong without losing visibility of the team. +test("test_one_invalid_member_key_counts_as_invalid_not_drop", () => { + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ + members: [member(), member({ member_key: "", display_name: "Nameless" })], + }), + ]); + + assert.equal(publications.length, 1, "team is still shown"); + assert.equal( + publications[0].invalidMemberCount, + 1, + "one invalid member counted", + ); + assert.equal(publications[0].members.length, 1, "only valid member rendered"); +}); + +test("test_member_missing_a_display_name_counts_as_invalid_not_drop", () => { + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ members: [member({ display_name: " " })] }), + ]); + assert.equal(publications.length, 1, "team is still shown"); + assert.equal(publications[0].invalidMemberCount, 1); + assert.equal(publications[0].members.length, 0); +}); + +test("test_a_team_with_no_members_is_still_a_valid_projection", () => { + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ members: [] }), + ]); + + assert.equal(publications.length, 1); + assert.deepEqual(publications[0].members, []); +}); + +/** The avatar a member projects for `avatarUrl`, or null if dropped/invalid. */ +function memberAvatarUrl(avatarUrl) { + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ members: [member({ avatar_url: avatarUrl })] }), + ]); + // When the avatar URL fails memberPassesV1, the member is invalid and not + // rendered. Return a sentinel so callers can distinguish "valid member with + // null avatar" from "invalid member" in assertions. + if (publications[0].members.length === 0) { + return { invalid: true }; + } + return publications[0].members[0].avatarUrl; +} + +// A team embeds N member avatars, so it must be held to exactly the persona +// allowlist rather than the permissive string read it started with. +test("test_member_avatars_keep_bounded_http_urls_and_reject_unsafe_schemes", () => { + assert.equal( + memberAvatarUrl("https://relay.example/avatar.png"), + "https://relay.example/avatar.png", + ); + // Unsafe avatar URLs now mark the member as INVALID (not just drop the URL), + // so Add is disabled at the source rather than showing a blank avatar. + assert.deepEqual( + memberAvatarUrl("javascript:alert(1)"), + { invalid: true }, + "javascript: avatar must mark the member invalid", + ); + assert.deepEqual( + memberAvatarUrl(`data:image/svg+xml;base64,${btoa("")}`), + { invalid: true }, + "svg+xml;base64 is not in the safe allowlist — must mark the member invalid", + ); + assert.deepEqual( + memberAvatarUrl("data:image/png,%89PNG"), + { invalid: true }, + "non-base64 data URL must mark the member invalid", + ); +}); + +test("test_percent_encoded_emoji_member_avatar_survives_the_catalog", () => { + const emojiAvatar = emojiAvatarDataUrl("🐝", "#FFCC00"); + + assert.equal(memberAvatarUrl(emojiAvatar), emojiAvatar); +}); + +test("test_oversized_inline_svg_member_avatar_is_rejected", () => { + const withinCap = `data:image/svg+xml,${"a".repeat(8_192 - "data:image/svg+xml,".length)}`; + assert.equal(withinCap.length, 8_192); + // An inline SVG avatar within the cap must render (member is valid). + assert.equal(memberAvatarUrl(withinCap), withinCap); + // One byte over the cap makes the avatar unsafe → member is invalid. + assert.deepEqual( + memberAvatarUrl(`${withinCap}a`), + { invalid: true }, + "oversized SVG avatar must mark the member invalid", + ); +}); + +test("test_team_coordinates_remain_independent_across_publishers", () => { + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ id: "alice", owner: ALICE }), + teamEvent({ id: "bob", owner: BOB }), + ]); + + assert.equal(publications.length, 2); +}); + +test("test_own_publication_resolves_to_the_local_team_by_id", () => { + const publications = teamCatalogPublicationsFromEvents([teamEvent({})]); + const own = localTeam({ id: "squad", shared: true }); + + const teams = catalogTeamsFromPublications(publications, [own], ALICE); + + assert.equal(teams[0].isOwn, true); + assert.equal(teams[0].localTeam.id, "squad"); +}); + +// The duplicate-add bug: a copy carries a fresh local id, so only the stored +// coordinate links it back to the publication it came from. +test("test_added_foreign_entry_resolves_to_its_local_copy", () => { + const publications = teamCatalogPublicationsFromEvents([teamEvent({})]); + const copy = localTeam({ + id: "a-fresh-uuid", + catalogSource: { ownerPubkey: ALICE, teamDTag: "squad" }, + }); + + const teams = catalogTeamsFromPublications(publications, [copy], BOB); + + assert.equal(teams[0].isOwn, false); + assert.equal(teams[0].localTeam.id, "a-fresh-uuid"); +}); + +test("test_foreign_entry_with_no_local_copy_has_no_local_team", () => { + const publications = teamCatalogPublicationsFromEvents([teamEvent({})]); + // A same-named local team with no provenance is a different team. + const unrelated = localTeam({ id: "unrelated" }); + + const teams = catalogTeamsFromPublications(publications, [unrelated], BOB); + + assert.equal(teams[0].localTeam, null); +}); + +// Provenance is per-owner: the same d-tag under a different publisher is a +// different team, so a copy of Alice's must not mask Bob's entry. +test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ id: "bob-team", owner: BOB }), + ]); + const copyOfAlices = localTeam({ + id: "copy-of-alices", + catalogSource: { ownerPubkey: ALICE, teamDTag: "squad" }, + }); + + const teams = catalogTeamsFromPublications( + publications, + [copyOfAlices], + ALICE, + ); + + assert.equal(teams[0].localTeam, null); +}); + +// An own team's `d`-tag is its local id, so an id match under another +// publisher's coordinate must not read as already-added. +test("test_local_id_match_under_a_foreign_owner_is_not_a_local_copy", () => { + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ owner: BOB, teamDTag: "squad" }), + ]); + const sameId = localTeam({ id: "squad" }); + + const teams = catalogTeamsFromPublications(publications, [sameId], ALICE); + + assert.equal(teams[0].isOwn, false); + assert.equal(teams[0].localTeam, null); +}); + +test("test_identity_pubkey_case_does_not_change_ownership", () => { + const publications = teamCatalogPublicationsFromEvents([teamEvent({})]); + + const teams = catalogTeamsFromPublications( + publications, + [], + ALICE.toUpperCase(), + ); + + assert.equal(teams[0].isOwn, true); +}); + +test("test_catalog_entries_are_sorted_by_name", () => { + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ id: "zed", teamDTag: "zed", name: "Zed Squad" }), + teamEvent({ id: "ace", teamDTag: "ace", name: "Ace Squad" }), + ]); + + const teams = catalogTeamsFromPublications(publications, [], BOB); + + assert.deepEqual( + teams.map((team) => team.name), + ["Ace Squad", "Zed Squad"], + ); +}); + +function pageOfEvents(count, startId, createdAt) { + return Array.from({ length: count }, (_, index) => + teamEvent({ + createdAt: typeof createdAt === "function" ? createdAt(index) : createdAt, + id: `event-${startId + index}`, + teamDTag: `squad-${startId + index}`, + }), + ); +} + +function stubPagedRelay(pages) { + const filters = []; + mock.method(relayClient, "fetchEvents", (filter) => { + filters.push(filter); + return Promise.resolve(pages[filters.length - 1] ?? []); + }); + return filters; +} + +// A single limit-capped fetch drops every team past the relay's clamp, making +// those teams undiscoverable. +test("test_team_catalog_paging_requests_kind_30178_and_follows_full_pages", async (t) => { + t.after(() => mock.restoreAll()); + const filters = stubPagedRelay([ + pageOfEvents(500, 0, (index) => 10_000 - index), + pageOfEvents(3, 500, 9_000), + ]); + + const publications = await fetchTeamCatalogPublications(); + + assert.deepEqual(filters[0].kinds, [30178]); + assert.equal(filters.length, 2, "a full page must be followed by another"); + assert.equal(filters[0].until, undefined, "the first page has no cursor"); + assert.equal( + filters[1].until, + 10_000 - 499, + "the cursor must be the oldest created_at from the previous page", + ); + assert.equal(publications.length, 503); +}); + +test("test_short_first_team_page_does_not_issue_a_second_request", async (t) => { + t.after(() => mock.restoreAll()); + const filters = stubPagedRelay([pageOfEvents(2, 0, 10_000)]); + + const publications = await fetchTeamCatalogPublications(); + + assert.equal(filters.length, 1); + assert.equal(publications.length, 2); +}); + +// ── parseTeamCatalogContent: v1 validation contract ─────────────────────── + +function contentEvent(body) { + return { + id: "evt1", + pubkey: ALICE, + created_at: 1, + kind: 30178, + tags: [ + ["d", "squad"], + ["shared", "true"], + ], + content: JSON.stringify(body), + sig: "sig", + }; +} + +function validBody(memberOverrides = {}) { + return { + v: 1, + name: "Review Squad", + members: [ + { + member_key: "mk1", + display_name: "Agent One", + system_prompt: "Do it.", + ...memberOverrides, + }, + ], + }; +} + +test("test_member_count_cap_exceeded_returns_null", () => { + const manyMembers = Array.from({ length: 65 }, (_, i) => ({ + member_key: `k${i}`, + display_name: `Agent ${i}`, + })); + const result = parseTeamCatalogContent( + contentEvent({ v: 1, name: "Big Team", members: manyMembers }), + ); + assert.equal(result, null); +}); + +test("test_member_with_parallelism_out_of_range_counts_as_invalid", () => { + const result = parseTeamCatalogContent( + contentEvent(validBody({ parallelism: 999 })), + ); + assert.ok(result !== null); + assert.equal(result.invalidMemberCount, 1, "parallelism 999 fails v1"); + assert.equal(result.members.length, 0, "invalid member is not rendered"); +}); + +test("test_parallelism_at_boundary_values_is_valid", () => { + const r1 = parseTeamCatalogContent( + contentEvent(validBody({ parallelism: 1 })), + ); + assert.equal(r1?.invalidMemberCount, 0); + const r32 = parseTeamCatalogContent( + contentEvent(validBody({ parallelism: 32 })), + ); + assert.equal(r32?.invalidMemberCount, 0); +}); + +test("test_member_with_unrecognized_respond_to_counts_as_invalid", () => { + const result = parseTeamCatalogContent( + contentEvent(validBody({ respond_to: "nobody" })), + ); + assert.ok(result !== null); + assert.equal(result.invalidMemberCount, 1, "unknown respond_to fails v1"); +}); + +test("test_member_missing_member_key_counts_as_invalid", () => { + const result = parseTeamCatalogContent( + contentEvent({ v: 1, name: "T", members: [{ display_name: "A" }] }), + ); + assert.ok(result !== null); + assert.equal(result.invalidMemberCount, 1); +}); + +test("test_partial_builtin_hint_counts_as_invalid", () => { + // builtin_slug present but projection_hash absent + const result = parseTeamCatalogContent( + contentEvent(validBody({ builtin_slug: "fizz" })), + ); + assert.ok(result !== null); + assert.equal(result.invalidMemberCount, 1, "half-pair hint must fail"); +}); + +test("test_complete_builtin_hint_with_valid_sha256_is_valid", () => { + const hash = "a".repeat(64); + const result = parseTeamCatalogContent( + contentEvent(validBody({ builtin_slug: "fizz", projection_hash: hash })), + ); + assert.ok(result !== null); + assert.equal( + result.invalidMemberCount, + 0, + "complete hint with 64-char hex hash is valid", + ); +}); + +test("test_multiple_invalid_members_accumulate_count", () => { + const body = { + v: 1, + name: "Mixed Team", + members: [ + { member_key: "k1", display_name: "Good", system_prompt: "OK" }, + { member_key: "k2", display_name: "Bad", parallelism: 0 }, + { member_key: "k3", display_name: "Also Bad", respond_to: "???" }, + ], + }; + const result = parseTeamCatalogContent(contentEvent(body)); + assert.ok(result !== null); + assert.equal(result.invalidMemberCount, 2, "two invalid members"); + assert.equal(result.members.length, 1, "one valid member rendered"); + assert.equal(result.members[0].displayName, "Good"); +}); + +test("test_name_too_long_returns_null", () => { + const longName = "x".repeat(300); // 300 > 256 byte limit + const result = parseTeamCatalogContent( + contentEvent({ v: 1, name: longName, members: [] }), + ); + assert.equal(result, null, "oversize name must return null"); +}); + +test("test_description_too_long_returns_null", () => { + const body = { + v: 1, + name: "T", + description: "x".repeat(5000), // > 4*1024 + members: [], + }; + assert.equal(parseTeamCatalogContent(contentEvent(body)), null); +}); + +// ── parseMember: provider field ─────────────────────────────────────────── + +test("test_parseMember_provider_field_is_forwarded_when_present", () => { + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ members: [member({ provider: "anthropic" })] }), + ]); + assert.equal(publications.length, 1); + assert.equal(publications[0].members.length, 1); + assert.equal(publications[0].members[0].provider, "anthropic"); +}); + +test("test_parseMember_provider_field_is_null_when_absent", () => { + // member() does not set provider; parseMember must return null for it. + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ members: [member()] }), + ]); + assert.equal(publications[0].members[0].provider, null); +}); + +test("test_parseMember_provider_whitespace_only_marks_member_invalid", () => { + // memberPassesV1 rejects a present whitespace-only provider string the same + // way it rejects whitespace-only runtime/model — the member is invalid. + const publications = teamCatalogPublicationsFromEvents([ + teamEvent({ members: [member({ provider: " " })] }), + ]); + assert.equal( + publications[0].members.length, + 0, + "invalid member not rendered", + ); + assert.equal(publications[0].invalidMemberCount, 1, "counted as invalid"); +}); + +// ── F8: share disclosure copy contract ─────────────────────────────────── +// Both team instructions AND member instructions are published as plaintext. +// The copy must name both to satisfy the explicit disclosure requirement. + +test("test_share_disclosure_names_team_and_member_instructions", () => { + const desc = teamCatalogCopy.shareDescription.toLowerCase(); + assert.ok( + desc.includes("team instructions"), + "disclosure must mention team instructions", + ); + assert.ok( + desc.includes("member"), + "disclosure must mention member instructions", + ); + assert.ok( + desc.includes("instructions"), + "disclosure must explicitly say instructions are shared", + ); +}); + +// ── Shared JSON fixture matrix (I8) ────────────────────────────────────────── +// These fixtures are the canonical source of truth shared with the Rust test +// suite. Any divergence surfaces as a failing test in CI on the side that +// disagrees. + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURES = path.join( + __dirname, + "../../../../src-tauri/tests/fixtures/team_catalog_content", +); + +function fixtureEvent(name) { + const content = readFileSync(path.join(FIXTURES, name), "utf8").trim(); + return { + id: "evt-fixture", + pubkey: ALICE, + created_at: 1, + kind: 30178, + tags: [ + ["d", "squad"], + ["shared", "true"], + ], + content, + sig: "sig", + }; +} + +// ── Fixture matrix: accepted fixtures ──────────────────────────────────── +// +// One table-driven test covers all fixtures the TS validator must accept. +// Every fixture's input survives as a table row; per-fixture test functions +// are replaced by this loop (consolidation, not deletion). + +test("test_fixtures_that_must_be_accepted_are_accepted", () => { + for (const name of [ + "valid_minimal.json", + "valid_respond_to_owner_only.json", + "valid_respond_to_allowlist.json", + "valid_respond_to_anyone.json", + // uppercase hex projection_hash — both validators accept via parser normalisation + "valid_uppercase_hash.json", + "valid_avatar_url_https.json", + // HTTPS://example.com — new URL() normalises the scheme; accepted + "valid_avatar_url_uppercase_scheme.json", + // https://a/ + 1019 é = 2 048 UTF-8 bytes (at cap); accepted + "valid_avatar_url_non_ascii_at_utf8_limit.json", + // http:example.com — WHATWG shorthand; new URL() normalises to http:// + "valid_avatar_url_shorthand_scheme.json", + // U+0085 NEL — not matched by /\s/u; new URL() percent-encodes it; accepted + "valid_avatar_url_unicode_nel.json", + ]) { + const result = parseTeamCatalogContent(fixtureEvent(name)); + assert.ok(result !== null, `${name} must be accepted`); + assert.equal( + result.invalidMemberCount, + 0, + `${name} must have no invalid members`, + ); + } +}); + +// ── Fixture matrix: body-level rejections ──────────────────────────────── +// +// Fixtures where the whole body is invalid (parseTeamCatalogContent returns null). + +test("test_fixtures_with_body_level_errors_are_rejected", () => { + for (const name of [ + // Wrong type on a top-level field returns null immediately + "invalid_description_wrong_type.json", + "invalid_instructions_wrong_type.json", + // Blank/whitespace team name fails the top-level name check + "invalid_team_name_blank.json", + ]) { + assert.equal( + parseTeamCatalogContent(fixtureEvent(name)), + null, + `${name} must be rejected (null)`, + ); + } +}); + +// ── Fixture matrix: member-level rejections ─────────────────────────────── +// +// Fixtures where the body parses but one or more members are invalid. +// Rust rejects these at deserialization; TS increments invalidMemberCount. +// Both validators agree the member is invalid; only rejection granularity differs. + +test("test_fixtures_with_member_level_errors_mark_member_invalid", () => { + for (const [name, note] of [ + // Wire protocol uses kebab-case; "OwnerOnly" is the pre-fix TS value + ["invalid_respond_to_pascal_case.json", "PascalCase respond_to"], + // Two members sharing a member_key collapse provenance + ["invalid_duplicate_member_key.json", "duplicate member key"], + // name_pool must be an array when present; bare string fails memberPassesV1 + ["invalid_name_pool_not_array.json", "name_pool non-array"], + // name_pool: null is not absent — null != absent; only undefined is absent + ["invalid_name_pool_null.json", "name_pool: null"], + // builtin_slug: 42 is a present wrong-typed value — must fail, not absent + ["invalid_builtin_slug_wrong_type.json", "builtin_slug wrong type"], + // javascript: passes the byte-length bound but uses an unsafe scheme + ["invalid_avatar_url_javascript.json", "javascript: avatar scheme"], + // Bare https:// with no hostname — URL() constructor throws + ["invalid_avatar_url_bare_https.json", "bare https:// avatar"], + // HTTPS URL with embedded whitespace — rejected by isSafeHttpUrl + ["invalid_avatar_url_whitespace_in_url.json", "whitespace-in-URL avatar"], + // HTTPS URL > 2 048 chars — rejected by isSafeHttpUrl length cap + ["invalid_avatar_url_https_over_2048.json", "over-2048 HTTPS URL avatar"], + // https://a:b — "b" is not a valid port; URL() throws + ["invalid_avatar_url_malformed_port.json", "malformed-port URL"], + // https://a/ + 1020 é = 2 050 UTF-8 bytes > cap (but only 1 030 UTF-16 units) + [ + "invalid_avatar_url_non_ascii_over_utf8_limit.json", + "non-ASCII URL over UTF-8 limit", + ], + // Unicode whitespace parity: ECMAScript /\s/u vs char::is_whitespace + ["invalid_avatar_url_unicode_nbsp.json", "NBSP (U+00A0) in URL"], + ["invalid_avatar_url_unicode_em_space.json", "EM SPACE (U+2003) in URL"], + // U+FEFF BOM — not in Rust char::is_whitespace; added explicitly to match JS + ["invalid_avatar_url_unicode_bom.json", "BOM (U+FEFF) in URL"], + ]) { + const result = parseTeamCatalogContent(fixtureEvent(name)); + assert.ok( + result !== null, + `${name}: body with invalid member must still be parseable`, + ); + assert.ok( + result.invalidMemberCount >= 1, + `${name}: ${note} must mark the member invalid`, + ); + } +}); + +// ── teamAutoRetractedNotice: backend payload format ─────────────────────── +// +// The `team-catalog-auto-retracted` Tauri event carries `{ teamName, reason }`. +// useAgentsDataRefresh builds a toast via teamAutoRetractedNotice, so the +// payload contract — and the "queued" wording that distinguishes an enqueued +// tombstone from a relay-confirmed removal — is tested here as a pure- +// function unit test rather than a React rendering test. + +test("test_team_auto_retracted_notice_contract", () => { + // Names team and reason. + const msg = teamAutoRetractedNotice( + "My Team", + "member instructions too large", + ); + assert.ok(msg.includes("My Team"), "notice must name the affected team"); + assert.ok( + msg.includes("member instructions too large"), + "notice must include the backend reason", + ); + // The relay head may still be live until the flush loop publishes the + // tombstone. The notice must say "queued for removal" — not "was removed". + const pending = teamAutoRetractedNotice( + "Alpha Team", + "team no longer exists", + ); + assert.ok( + !pending.includes("was removed"), + "notice must not claim the team is already gone from the relay", + ); + assert.ok( + pending.includes("queued") || + pending.includes("being removed") || + pending.includes("can no longer be projected"), + `notice must reflect the pending-tombstone status; got: ${pending}`, + ); +}); diff --git a/desktop/src/features/agents/lib/teamCatalogRelay.ts b/desktop/src/features/agents/lib/teamCatalogRelay.ts new file mode 100644 index 0000000000..a3a4913c4b --- /dev/null +++ b/desktop/src/features/agents/lib/teamCatalogRelay.ts @@ -0,0 +1,430 @@ +import { + fetchCatalogEvents, + safeCatalogAvatarUrl, + sharedCatalogHeads, +} from "@/features/agents/lib/catalogRelay"; +import type { AgentTeam, RelayEvent } from "@/shared/api/types"; +import { KIND_TEAM_CATALOG } from "@/shared/constants/kinds"; + +/** + * Read the kind:30178 team catalog. + * + * The projection is self-contained by design: every member's safe definition + * is embedded, so this module renders a published team without resolving + * anything in the publisher's namespace. `member_key` is deliberately treated + * as an opaque label here and never as a kind:30175 coordinate — the publisher + * may never have shared that member individually. + * + * Adding is NOT done from this data. The frontend passes only the coordinate + * to `add_team_from_catalog`, which re-fetches and re-verifies the head + * backend-side; what is parsed here is for display and for deciding whether + * "Add" is offered. + */ + +/** Schema version this client understands. Must match the backend's. */ +const TEAM_CATALOG_SCHEMA_VERSION = 1; + +// ── v1 size bounds — must stay in sync with team_catalog.rs ────────────── +const MAX_MEMBERS = 64; +const MAX_NAME_BYTES = 256; +const MAX_TEXT_BYTES = 4 * 1024; +const MAX_INSTRUCTIONS_BYTES = 16 * 1024; +const MAX_SYSTEM_PROMPT_BYTES = 16 * 1024; +const MAX_AVATAR_URL_BYTES = 32 * 1024; +const MAX_NAME_POOL_ENTRIES = 64; +const MAX_TOTAL_BYTES = 192 * 1024; +const MAX_MEMBER_KEY_BYTES = 128; +const MAX_IDENTIFIER_BYTES = 256; +const MAX_BUILTIN_SLUG_BYTES = 128; + +/** Recognized respond_to wire values. Must stay in sync with RespondTo::parse_wire. */ +const RESPOND_TO_VALUES = new Set(["owner-only", "allowlist", "anyone"]); + +function byteLength(value: string): number { + return new TextEncoder().encode(value).length; +} + +function withinBytes(value: string, max: number): boolean { + return byteLength(value) <= max; +} + +export type CatalogTeamMember = { + memberKey: string; + displayName: string; + systemPrompt: string; + avatarUrl: string | null; + runtime: string | null; + model: string | null; + provider: string | null; +}; + +export type CatalogTeam = { + /** The head event this projection was built from. Passed to the backend so + * it can reject an add whose head moved since the dialog opened. */ + eventId: string; + ownerPubkey: string; + teamDTag: string; + isOwn: boolean; + name: string; + description: string | null; + instructions: string | null; + members: CatalogTeamMember[]; + /** + * Number of members that failed v1 validation. When non-zero the team is + * displayed with a diagnostic and the Add button is disabled — a team with + * invalid members cannot be safely adopted. + */ + invalidMemberCount: number; + /** The local team already copied from this publication, if any. */ + localTeam: AgentTeam | null; +}; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +/** + * Validate one member against the v1 contract. Returns true when the member + * passes all checks, false when any bound is exceeded or a recognized value + * is unrecognized. Mirrors validate_member in team_catalog.rs. + */ +function memberPassesV1(value: unknown): boolean { + if (!isObject(value)) return false; + + // Required: non-empty member_key within bounds + if ( + typeof value.member_key !== "string" || + value.member_key.trim().length === 0 || + !withinBytes(value.member_key, MAX_MEMBER_KEY_BYTES) + ) { + return false; + } + + // Required: non-empty display_name within bounds + if ( + typeof value.display_name !== "string" || + value.display_name.trim().length === 0 || + !withinBytes(value.display_name, MAX_NAME_BYTES) + ) { + return false; + } + + // Optional bounded fields — reject any present wrong-typed value (I8). + // A present non-string must fail validation, not be ignored. + if ( + value.system_prompt !== undefined && + value.system_prompt !== null && + (typeof value.system_prompt !== "string" || + !withinBytes(value.system_prompt, MAX_SYSTEM_PROMPT_BYTES)) + ) { + return false; + } + if ( + value.avatar_url !== undefined && + value.avatar_url !== null && + (typeof value.avatar_url !== "string" || + !withinBytes(value.avatar_url, MAX_AVATAR_URL_BYTES) || + safeCatalogAvatarUrl(value.avatar_url) === null) + ) { + return false; + } + for (const field of ["runtime", "model", "provider"] as const) { + const v = value[field]; + if (v !== undefined && v !== null) { + if ( + typeof v !== "string" || + v.trim().length === 0 || + !withinBytes(v, MAX_IDENTIFIER_BYTES) + ) { + return false; + } + } + } + + // name_pool: must be an array when present; non-array fails (I8). + // null is NOT treated as absent — Rust's Vec rejects explicit null. + if (value.name_pool !== undefined) { + if (value.name_pool === null || !Array.isArray(value.name_pool)) { + return false; + } + } + // name_pool: entry count and per-entry bounds + if (Array.isArray(value.name_pool)) { + if (value.name_pool.length > MAX_NAME_POOL_ENTRIES) return false; + for (const entry of value.name_pool) { + if ( + typeof entry !== "string" || + entry.trim().length === 0 || + !withinBytes(entry, MAX_NAME_BYTES) + ) { + return false; + } + } + } + + // respond_to: recognized wire values only + if ( + value.respond_to !== undefined && + value.respond_to !== null && + (typeof value.respond_to !== "string" || + !RESPOND_TO_VALUES.has(value.respond_to)) + ) { + return false; + } + + // parallelism: 1–32 when present + if (value.parallelism !== undefined && value.parallelism !== null) { + if ( + typeof value.parallelism !== "number" || + !Number.isInteger(value.parallelism) || + value.parallelism < 1 || + value.parallelism > 32 + ) { + return false; + } + } + + // Built-in reuse hint: either both present and valid, or both absent. + // A present-but-wrong-type value must fail (not be treated as absent). + const slugPresent = + value.builtin_slug !== undefined && value.builtin_slug !== null; + const hashPresent = + value.projection_hash !== undefined && value.projection_hash !== null; + const hasSlug = + typeof value.builtin_slug === "string" && value.builtin_slug.length > 0; + const hasHash = + typeof value.projection_hash === "string" && + value.projection_hash.length > 0; + // Reject if present but wrong type (e.g., builtin_slug: 42 or projection_hash: {}) + if (slugPresent && !hasSlug) return false; + if (hashPresent && !hasHash) return false; + if (hasSlug !== hasHash) return false; + if (hasSlug) { + if (!withinBytes(value.builtin_slug as string, MAX_BUILTIN_SLUG_BYTES)) { + return false; + } + // projection_hash must be a 64-char hex digest (case-insensitive, aligning + // with the Rust validator which uses is_ascii_hexdigit() — I8). + if (!/^[0-9a-fA-F]{64}$/.test(value.projection_hash as string)) { + return false; + } + } + + return true; +} + +function parseMember(value: unknown): CatalogTeamMember | null { + if (!isObject(value)) return null; + // Display-layer parsing: only needs the fields the UI renders. + // Full schema validation is done by memberPassesV1 and by the backend + // re-fetch on add. + if ( + typeof value.member_key !== "string" || + value.member_key.length === 0 || + typeof value.display_name !== "string" || + value.display_name.trim().length === 0 + ) { + return null; + } + return { + memberKey: value.member_key, + displayName: value.display_name, + systemPrompt: + typeof value.system_prompt === "string" ? value.system_prompt : "", + avatarUrl: safeCatalogAvatarUrl(value.avatar_url), + runtime: optionalString(value.runtime), + model: optionalString(value.model), + provider: optionalString(value.provider), + }; +} + +/** + * Parse a 30178 content body, rejecting an unrecognized schema version. + * + * Returns `null` when the body cannot be parsed at all (wrong version, + * malformed JSON, missing required top-level fields, member-count cap + * exceeded, or total-size cap exceeded). Otherwise returns the parsed + * name/description/instructions/members and an `invalidMemberCount` for + * members that fail the v1 contract. A non-zero count means the team should + * be shown with a diagnostic and Add should be disabled. + * + * Version dispatch happens before field access, mirroring the backend: a + * future `v: 2` body may legally reshape any field, so rendering whatever + * happens to parse as `v: 1` would present a corrupted team as a valid one. + */ +export function parseTeamCatalogContent( + event: RelayEvent, +): Pick< + CatalogTeam, + "name" | "description" | "instructions" | "members" | "invalidMemberCount" +> | null { + let parsed: unknown; + try { + parsed = JSON.parse(event.content); + } catch { + return null; + } + if ( + !isObject(parsed) || + parsed.v !== TEAM_CATALOG_SCHEMA_VERSION || + typeof parsed.name !== "string" || + parsed.name.trim().length === 0 || + !withinBytes(parsed.name, MAX_NAME_BYTES) || + !Array.isArray(parsed.members) + ) { + return null; + } + + // Team-level text bounds — reject present wrong-typed values (I8). + if ( + parsed.description !== undefined && + parsed.description !== null && + (typeof parsed.description !== "string" || + !withinBytes(parsed.description, MAX_TEXT_BYTES)) + ) { + return null; + } + if ( + parsed.instructions !== undefined && + parsed.instructions !== null && + (typeof parsed.instructions !== "string" || + !withinBytes(parsed.instructions, MAX_INSTRUCTIONS_BYTES)) + ) { + return null; + } + + // Member count cap: a body that exceeds this cannot be adopted anyway + if (parsed.members.length > MAX_MEMBERS) { + return null; + } + + // Total-size cap: same reasoning + if (byteLength(event.content) > MAX_TOTAL_BYTES) { + return null; + } + + // Parse display-layer fields; track members that fail v1 validation + const members: CatalogTeamMember[] = []; + let invalidMemberCount = 0; + const seenMemberKeys = new Set(); + for (const candidate of parsed.members) { + // Check for duplicate member_key before other validation — a duplicate + // is always invalid regardless of the member's other fields (I8). + if ( + typeof (candidate as Record)?.member_key === "string" && + seenMemberKeys.has( + (candidate as Record).member_key as string, + ) + ) { + invalidMemberCount++; + continue; + } + if (!memberPassesV1(candidate)) { + invalidMemberCount++; + continue; // include the invalid count but still render the valid members + } + const member = parseMember(candidate); + if (!member) { + // parseMember is a subset of memberPassesV1; this branch is unreachable + // for a candidate that passed v1, but guard defensively. + invalidMemberCount++; + continue; + } + seenMemberKeys.add(member.memberKey); + members.push(member); + } + + return { + name: parsed.name, + description: optionalString(parsed.description), + instructions: optionalString(parsed.instructions), + members, + invalidMemberCount, + }; +} + +export type TeamCatalogPublication = Omit; + +/** + * Project the shared kind:30178 heads onto team publications. + * + * A head whose content does not parse is dropped, not retried against an older + * event: the coordinate is already claimed, so falling back would resurrect a + * superseded definition. + */ +export function teamCatalogPublicationsFromEvents( + events: readonly RelayEvent[], +): TeamCatalogPublication[] { + const publications: TeamCatalogPublication[] = []; + + for (const head of sharedCatalogHeads(events, KIND_TEAM_CATALOG)) { + const content = parseTeamCatalogContent(head.event); + if (!content) continue; + publications.push({ + eventId: head.event.id, + ownerPubkey: head.ownerPubkey, + teamDTag: head.dTag, + ...content, + }); + } + + return publications; +} + +/** Read every shared team event, page by page. */ +export async function fetchTeamCatalogPublications(): Promise< + TeamCatalogPublication[] +> { + return teamCatalogPublicationsFromEvents( + await fetchCatalogEvents(KIND_TEAM_CATALOG), + ); +} + +/** + * The local team backing a catalog entry, if the user already has it. + * + * An own publication is found by id — its `d`-tag *is* the local team id. A + * copy of another owner's entry carries a fresh local id instead, so the only + * link back is the `catalogSource` coordinate stored on the copy. Matching on + * that coordinate is what stops the catalog from offering "Add" for an entry + * the user already added, which would mint a second copy. + */ +export function findLocalTeamForCatalogEntry( + localTeams: readonly AgentTeam[], + publication: TeamCatalogPublication, + isOwn: boolean, +): AgentTeam | null { + if (isOwn) { + return localTeams.find((team) => team.id === publication.teamDTag) ?? null; + } + return ( + localTeams.find( + (team) => + team.catalogSource?.ownerPubkey === publication.ownerPubkey && + team.catalogSource?.teamDTag === publication.teamDTag, + ) ?? null + ); +} + +export function catalogTeamsFromPublications( + publications: readonly TeamCatalogPublication[], + localTeams: readonly AgentTeam[], + currentPubkey: string | null | undefined, +): CatalogTeam[] { + const normalizedCurrentPubkey = currentPubkey?.toLowerCase() ?? null; + + return publications + .map((publication) => { + const isOwn = publication.ownerPubkey === normalizedCurrentPubkey; + return { + ...publication, + isOwn, + localTeam: findLocalTeamForCatalogEntry(localTeams, publication, isOwn), + }; + }) + .sort((left, right) => left.name.localeCompare(right.name)); +} diff --git a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts index 174fb9c92c..626e739712 100644 --- a/desktop/src/features/agents/lib/useAgentsDataRefresh.ts +++ b/desktop/src/features/agents/lib/useAgentsDataRefresh.ts @@ -1,6 +1,7 @@ import { listen } from "@tauri-apps/api/event"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; +import { toast } from "sonner"; import { managedAgentsQueryKey, @@ -9,6 +10,7 @@ import { teamsQueryKey, } from "@/features/agents/hooks"; import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks"; +import { teamAutoRetractedNotice } from "@/features/agents/ui/teamLibraryCopy"; // Trailing-coalesce window: a backfill burst (up to 500 inbound events fed // one-by-one through reconcile) fires one `agents-data-changed` per event. @@ -47,10 +49,26 @@ export function useAgentsDataRefresh(): void { }, COALESCE_MS); }); + // Typed notice for automatic team catalog retractions (I4): the boot + // reconcile detected a shared team that can no longer be projected and + // tombstoned it. Show a toast so the owner is not left wondering why + // their share toggle changed. + const unlistenRetracted = listen<{ + teamName: string; + reason: string; + }>("team-catalog-auto-retracted", (event) => { + toast.warning( + teamAutoRetractedNotice(event.payload.teamName, event.payload.reason), + ); + // Invalidate team queries so the share toggle reflects the retraction. + void queryClient.invalidateQueries({ queryKey: teamsQueryKey }); + }); + return () => { if (timer !== undefined) clearTimeout(timer); void unlisten.then((fn) => fn()); void unlistenRuntime.then((fn) => fn()); + void unlistenRetracted.then((fn) => fn()); }; }, [queryClient]); } diff --git a/desktop/src/features/agents/lib/useTeamCatalogRelay.ts b/desktop/src/features/agents/lib/useTeamCatalogRelay.ts new file mode 100644 index 0000000000..318e7ad85b --- /dev/null +++ b/desktop/src/features/agents/lib/useTeamCatalogRelay.ts @@ -0,0 +1,118 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + fetchTeamCatalogPublications, + type TeamCatalogPublication, +} from "@/features/agents/lib/teamCatalogRelay"; +import { personasQueryKey, teamsQueryKey } from "@/features/agents/hooks"; +import { relayClient } from "@/shared/api/relayClient"; +import { addTeamFromCatalog, setTeamShared } from "@/shared/api/tauriTeams"; +import type { + AgentTeam, + TeamCatalogSourceCoordinate, +} from "@/shared/api/types"; +import { KIND_TEAM_CATALOG } from "@/shared/constants/kinds"; + +/** + * Team catalog reads and writes, keyed by community. + * + * Structurally the persona equivalent (`usePersonaCatalogRelay`) with the + * kind and command swapped. Adding is the one genuine difference: it writes + * personas as well as teams, so it invalidates both stores. + */ + +export function teamCatalogQueryKey(communityId: string | null) { + return ["team-catalog", communityId] as const; +} + +export function useTeamCatalogQuery(communityId: string | null) { + return useQuery({ + enabled: communityId !== null, + queryKey: teamCatalogQueryKey(communityId), + queryFn: fetchTeamCatalogPublications, + staleTime: 30_000, + refetchInterval: 120_000, + }); +} + +export function useTeamCatalogLiveUpdates(communityId: string | null): void { + const queryClient = useQueryClient(); + + React.useEffect(() => { + if (!communityId) return; + let disposed = false; + let dispose: (() => Promise) | null = null; + + const invalidate = () => { + void queryClient.invalidateQueries({ + queryKey: teamCatalogQueryKey(communityId), + }); + }; + + void relayClient + .subscribeLive({ kinds: [KIND_TEAM_CATALOG], limit: 0 }, invalidate) + .then((unsubscribe) => { + if (disposed) { + void unsubscribe(); + } else { + dispose = unsubscribe; + } + }) + .catch((error) => { + console.error( + "Couldn’t subscribe to the community team catalog", + error, + ); + }); + + const unsubscribeReconnect = relayClient.subscribeToReconnects(invalidate); + + return () => { + disposed = true; + unsubscribeReconnect(); + if (dispose) void dispose(); + }; + }, [communityId, queryClient]); +} + +export function useSetTeamCatalogSharedMutation(communityId: string | null) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, shared }: { id: string; shared: boolean }) => + setTeamShared(id, shared), + onSuccess: (result) => { + queryClient.setQueryData( + teamsQueryKey, + (current) => + current?.map((team) => + team.id === result.team.id ? result.team : team, + ) ?? [result.team], + ); + void queryClient.invalidateQueries({ + queryKey: teamCatalogQueryKey(communityId), + }); + }, + }); +} + +/** + * Add a published team, then refresh both stores. + * + * The command copies every member as a local persona, so leaving the persona + * query stale would show the new team with members the agents list does not + * know about yet. + */ +export function useAddTeamFromCatalogMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (source: TeamCatalogSourceCoordinate & { eventId: string }) => + addTeamFromCatalog(source), + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: teamsQueryKey }), + queryClient.invalidateQueries({ queryKey: personasQueryKey }), + ]); + }, + }); +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx index 50109143cd..509f218737 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx @@ -4,11 +4,13 @@ export function AgentDefinitionMetadata({ className, isBuiltIn, model, + provider, runtime, }: { className?: string; isBuiltIn: boolean; model: string | null; + provider?: string | null; runtime: string | null; }) { const items = [ @@ -24,6 +26,9 @@ export function AgentDefinitionMetadata({ label: "Preferred runtime", value: runtime ?? "Use app default", }, + ...(provider !== undefined + ? [{ label: "Preferred provider", value: provider ?? "Use app default" }] + : []), ]; return ( @@ -31,7 +36,12 @@ export function AgentDefinitionMetadata({ className={cn("rounded-lg border border-border/70 bg-card/70", className)} data-testid="agent-definition-metadata" > -
+
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 ( + + { + event.preventDefault(); + contentRef.current?.focus(); + }} + ref={contentRef} + scrollAreaClassName="flex min-h-0 overflow-hidden px-0" + scrollAreaTestId="community-catalog-dialog-body" + tabIndex={-1} + title="Community Catalog" + > + {bothEmpty && noError ? ( + + ) : ( +
+ {/* Sidebar list */} +
+
+ {personasLoading ? : null} + + {!personasLoading && personas.length > 0 ? ( +
+

+ Agents +

+
+ {personas.map((persona) => { + const key = encodeKey(selectionKeyFor(persona)); + const isCurrent = key === selectedEncodedKey; + return ( + + ); + })} +
+
+ ) : null} + + {teamsLoading ? : null} + + {!teamsLoading && teams.length > 0 ? ( +
+

+ Teams +

+
+ {teams.map((team) => { + const key = encodeKey(teamKeyFor(team)); + const isCurrent = key === selectedEncodedKey; + return ( + + ); + })} +
+
+ ) : null} +
+
+ + {/* Detail pane */} +
+
+ {personasLoading && teamsLoading ? ( + + ) : null} + + {!personasLoading && selectedPersona ? ( + + ) : null} + + {!teamsLoading && selectedTeam ? ( + + ) : null} + + {/* Per-section errors — only blank the section that failed */} + {personasError ? ( +

+ {personasError.message} +

+ ) : null} + {teamsError ? ( +

+ {teamsError.message} +

+ ) : null} + + {selectedTeam && selectedTeamIsInvalid ? ( +

+ {selectedTeam.invalidMemberCount === 1 + ? "1 member in this team could not be verified and cannot be added." + : `${selectedTeam.invalidMemberCount} members in this team could not be verified and cannot be added.`} +

+ ) : null} +
+ + {/* Footer action — context-sensitive per selection type */} +
+ {selectedPersona ? ( + + ) : selectedTeam ? ( + + ) : null} +
+
+
+ )} +
+
+ ); +} + +// ── 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 ( - - { - event.preventDefault(); - contentRef.current?.focus(); - }} - ref={contentRef} - scrollAreaClassName="flex min-h-0 overflow-hidden px-0" - scrollAreaTestId="persona-catalog-dialog-body" - tabIndex={-1} - title={personaCatalogCopy.dialogTitle} - > - - - - ); -} - -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;