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;