diff --git a/docs/profiles.md b/docs/profiles.md index 0965bbc3..eb93d43a 100644 --- a/docs/profiles.md +++ b/docs/profiles.md @@ -3,7 +3,7 @@ The bundled `buzz.profiles` plugin supplies a minimal, read-only panel for any public identity, human or agent. It uses the current session's shared profile directory. Agents retains agent-specific configuration/operations; this slice -adds no ownership/running badge, editor, agent-library lookup or execution API. +adds no ownership/running badge, editor, agent-library read or execution API. When Agent Activity is enabled and the host supplies conversation context, **View activity** opens its raw panel for this exact identity and originating channel. This action is offered for any public identity: it does not infer that the identity @@ -45,11 +45,30 @@ Button use the host-loaded styles directly. The profile content marks its owns layout, not component overrides. No new theme owner, second global reset or shell migration. Designers own later refinement. +Agent hints change avatar shape, not authority: + +- Profiles, mentions, participants and membership avatars use squircles for + self-declared `is_agent`/`isAgent` metadata or exact keys in the loaded session + library; otherwise they use circles. My Agents cards always use squircles. +- Message authors also use these hints. An original kind-40002 envelope is enough + on its own and keeps that treatment through edits. +- Library hints are lazy: opening or refreshing Agents loads them; clearing the + library removes that fallback. Avatars never fetch the library or scan telemetry. + +None of these hints proves ownership or running state. One bundled SVG mask scales +across sizes and clips only artwork, leaving the profile button's focus ring intact. + Use the normal `bin/just desktop` or `bin/just web` workflow in the feature worktree with the existing public live-mode pin; run only one dev target at a time. ## Evidence and remaining checks +`avatar-shapes.spec.mjs` covers painted pixels and focus across sizes, themes and +viewports in Chromium/WebKit, including the artwork inside participant/membership +overlap borders (pictures and initials). Shape attributes alone do not prove that +inset artwork is clipped. Completion tests cover loaded-library changes without +another keystroke; profile-directory tests cover marker-only updates. + `tests/browser/profiles.spec.mjs` runs real React/ChannelsPage, thread reading, profile directory, panel registry and plugin lifecycle against a synthetic transport. It covers avatar/mention keys, keyboard/focus, disable/re-enable, diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index e3cb0555..9c469499 100644 --- a/src/bundled/agents/AgentsPage.tsx +++ b/src/bundled/agents/AgentsPage.tsx @@ -196,7 +196,13 @@ function AgentCard({ return (
- +

{name} diff --git a/src/bundled/mentions/MentionCompletion.tsx b/src/bundled/mentions/MentionCompletion.tsx index 9fbf7869..25dfe5bb 100644 --- a/src/bundled/mentions/MentionCompletion.tsx +++ b/src/bundled/mentions/MentionCompletion.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useSyncExternalStore } from "react"; import type { ComposerCompletionProps } from "../../features/conversation/contracts"; import type { RelaySession } from "../../features/relay/session"; import { Avatar } from "../../shared/Avatar"; +import { useKnownAgentPubkeys } from "../../features/agents/use-known"; import { matchesMentionQuery } from "./mention-query"; // Demand bookkeeping only, not another profile cache. Missing names do not issue @@ -23,6 +24,7 @@ export function MentionCompletion({ session.profiles.snapshot, session.profiles.snapshot, ); + const agentPubkeys = useKnownAgentPubkeys(session, profiles); const channel = list.channels.find((item) => item.id === channelId); const members = channel?.members ?? []; const memberKey = members.join(":"); @@ -85,6 +87,7 @@ export function MentionCompletion({ "small", )} className="size-7 rounded-lg text-caption" + shape={agentPubkeys.has(recipient.pubkey) ? "squircle" : "circle"} /> ), edit: { mention: recipient }, @@ -140,6 +143,7 @@ export function MentionCompletion({ channelId, memberKey, profiles, + agentPubkeys, query.query, publish, error, diff --git a/src/bundled/mentions/MentionPicker.tsx b/src/bundled/mentions/MentionPicker.tsx index b2f17a79..8e013fc9 100644 --- a/src/bundled/mentions/MentionPicker.tsx +++ b/src/bundled/mentions/MentionPicker.tsx @@ -1,4 +1,5 @@ import { Avatar } from "../../shared/Avatar"; +import { useKnownAgentPubkeys } from "../../features/agents/use-known"; import { AtSign } from "lucide-react"; import { useEffect, @@ -39,6 +40,7 @@ export function MentionPicker({ session.profiles.snapshot, session.profiles.snapshot, ); + const agentPubkeys = useKnownAgentPubkeys(session, profiles); const channel = list.channels.find((item) => item.id === channelId); const memberKey = channel?.members?.join(":") ?? ""; useEffect(() => { @@ -141,6 +143,9 @@ export function MentionPicker({ "small", )} className="size-8 rounded-lg text-caption" + shape={ + agentPubkeys.has(recipient.pubkey) ? "squircle" : "circle" + } /> {recipient.name} diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index b0e4e260..debfcb0c 100644 --- a/src/bundled/profiles/ProfilePanel.tsx +++ b/src/bundled/profiles/ProfilePanel.tsx @@ -7,6 +7,7 @@ import { } from "react"; import { IconCopy } from "@tabler/icons-react"; import { Avatar } from "../../shared/design-system/ui/Avatar"; +import { useKnownAgentPubkeys } from "../../features/agents/use-known"; import { Button } from "../../shared/design-system/ui/Button"; import { activityTarget } from "../../features/agents/activity-target"; import type { PanelProps } from "../../features/panels/service"; @@ -49,11 +50,12 @@ function ProfileDetails({ () => selectProfiles(session.profiles, [pubkey]), [session.profiles, pubkey], ); - const profile = useSyncExternalStore( + const profiles = useSyncExternalStore( selection.subscribe, selection.snapshot, selection.snapshot, - ).get(pubkey); + ); + const profile = profiles.get(pubkey); const [status, setStatus] = useState<"loading" | "ready" | "error">( "loading", ); @@ -80,6 +82,7 @@ function ProfileDetails({ active = false; }; }, [session, pubkey, attempt]); + const agentPubkeys = useKnownAgentPubkeys(session, profiles); const npub = profileTarget(pubkey)?.slice(6) ?? pubkey; const name = profile?.name ?? "Unknown profile"; const activity = activityTarget(pubkey, context?.channelId); @@ -103,6 +106,7 @@ function ProfileDetails({ alt={`${name} avatar`} fallback={profile?.name ?? "?"} size={picture ? "fill" : "large"} + shape={agentPubkeys.has(pubkey) ? "squircle" : "circle"} />

{name}

diff --git a/src/features/agents/known.test.ts b/src/features/agents/known.test.ts new file mode 100644 index 00000000..11a6e636 --- /dev/null +++ b/src/features/agents/known.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from "vitest"; +import { knownAgentPubkeys } from "./known"; + +it("combines exact local-library identities with self-authored agent profiles", () => { + const profiles = new Map([ + ["relay-agent", { name: "Relay", isAgent: true as const }], + ["person", { name: "Person" }], + ]); + const keys = knownAgentPubkeys(profiles, { + definitions: [], + identities: [{ pubkey: "local-agent", name: "Local" }], + }); + expect([...keys]).toEqual(["relay-agent", "local-agent"]); +}); diff --git a/src/features/agents/known.ts b/src/features/agents/known.ts new file mode 100644 index 00000000..68e9664f --- /dev/null +++ b/src/features/agents/known.ts @@ -0,0 +1,16 @@ +import type { AgentLibrary } from "./library"; +import type { Profile } from "../relay/contracts"; + +/** Exact keys from self-declared profile hints plus the local Buzz library. + * Display-only evidence, not proof of ownership, membership or authority. */ +export function knownAgentPubkeys( + profiles: ReadonlyMap, + library?: AgentLibrary, +): ReadonlySet { + const keys = new Set(); + for (const [pubkey, profile] of profiles) { + if (profile.isAgent) keys.add(pubkey); + } + for (const identity of library?.identities ?? []) keys.add(identity.pubkey); + return keys; +} diff --git a/src/features/agents/use-known.ts b/src/features/agents/use-known.ts new file mode 100644 index 00000000..e5ec7bc4 --- /dev/null +++ b/src/features/agents/use-known.ts @@ -0,0 +1,21 @@ +import { useMemo, useSyncExternalStore } from "react"; +import type { Profile } from "../relay/contracts"; +import type { RelaySession } from "../relay/session"; +import { knownAgentPubkeys } from "./known"; + +/** One display-only projection per owning surface; exact keys, never display names. + * Subscribes to existing evidence without initiating library reads. */ +export function useKnownAgentPubkeys( + session: RelaySession, + profiles: ReadonlyMap, +): ReadonlySet { + const library = useSyncExternalStore( + session.agentLibrary.subscribe, + session.agentLibrary.snapshot, + session.agentLibrary.snapshot, + ); + return useMemo( + () => knownAgentPubkeys(profiles, library), + [profiles, library], + ); +} diff --git a/src/features/messages/ChannelTimeline.test.tsx b/src/features/messages/ChannelTimeline.test.tsx index db09202f..8616432c 100644 --- a/src/features/messages/ChannelTimeline.test.tsx +++ b/src/features/messages/ChannelTimeline.test.tsx @@ -3,6 +3,7 @@ import { afterEach, expect, it, vi } from "vitest"; import type { ReactElement } from "react"; import { Virtualizer } from "virtua"; import { ChannelTimeline } from "./ChannelTimeline"; +import { createAgentLibrary } from "../agents/library"; import { createRelaySession } from "../relay/session"; import { bounds, @@ -91,6 +92,8 @@ vi.mock("react", async (original) => ({ }); } }, + useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) => + snapshot(), })); vi.mock("../relay/react", () => ({ useRowProfiles: () => new Map(), @@ -230,6 +233,7 @@ function setup({ ({ channels: { loadOlder, window: snapshot }, profiles: {}, + agentLibrary: createAgentLibrary(undefined).queries, // Geometry fixtures are read-only; reading behavior has its own boundary tests. unread: { sync: () => ({ capability: "unsupported" }) }, media: () => undefined, diff --git a/src/features/messages/ChannelTimeline.tsx b/src/features/messages/ChannelTimeline.tsx index 464b441b..8a210217 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -15,6 +15,7 @@ import { useReading } from "./use-reading"; import { useMessageReveal } from "./use-message-reveal"; import type { PageNavigation } from "../navigation/service"; import { messageViewKey } from "./view-key"; +import { useKnownAgentPubkeys } from "../agents/use-known"; const EDGE_HEIGHT = 56; type ReadingPosition = { @@ -107,6 +108,7 @@ function Timeline({ const restoredAnchor = useRef(undefined); const rows = useMemo(() => membershipRows(window.rows), [window.rows]); const profiles = useRowProfiles(queries.profiles, window.rows); + const agentPubkeys = useKnownAgentPubkeys(queries, profiles); const geometry = useMemo(() => geometryFor(queries.channels), [queries]); const signature = useMemo( () => geometrySignature(window.rows, profiles), @@ -481,6 +483,7 @@ function Timeline({ profiles={profiles} viewer={viewer} media={queries.media} + agentPubkeys={agentPubkeys} day={day} /> ) : ( @@ -493,6 +496,7 @@ function Timeline({ extensions={extensions} profile={profiles.get(row.authorId)} participantProfiles={profiles} + agentPubkeys={agentPubkeys} media={queries.media} onOpenLink={onOpenLink} canOpenLink={canOpenLink} diff --git a/src/features/messages/MembershipRow.tsx b/src/features/messages/MembershipRow.tsx index d4b0609d..f378099a 100644 --- a/src/features/messages/MembershipRow.tsx +++ b/src/features/messages/MembershipRow.tsx @@ -1,3 +1,4 @@ +import "../../shared/design-system/styles/avatar-shape.css"; import { memo } from "react"; import type { Profile } from "../relay/contracts"; import { membershipDescription, type TimelineRow } from "./membership-rows"; @@ -9,12 +10,14 @@ export const MembershipRow = memo(function MembershipRow({ profiles, viewer, media, + agentPubkeys, day, }: { row: TimelineRow; profiles: ReadonlyMap; viewer?: string | undefined; media(url: string, size?: "small"): string | undefined; + agentPubkeys?: ReadonlySet | undefined; day: boolean; }) { const { targets, text, title } = membershipDescription( @@ -44,25 +47,38 @@ export const MembershipRow = memo(function MembershipRow({ ? media(profile.picture, "small") : undefined; return ( - - {name.slice(0, 2).toUpperCase()} - {picture && ( - { - event.currentTarget.hidden = true; - }} - /> - )} + + + {name.slice(0, 2).toUpperCase()} + {picture && ( + { + event.currentTarget.hidden = true; + }} + /> + )} + ); })} {targets.length > 3 && ( - - +{targets.length - 3} + + + +{targets.length - 3} + )} diff --git a/src/features/messages/MessageRow.test.tsx b/src/features/messages/MessageRow.test.tsx index 224d9ffd..0adf2988 100644 --- a/src/features/messages/MessageRow.test.tsx +++ b/src/features/messages/MessageRow.test.tsx @@ -376,6 +376,55 @@ it("does not bypass the session media resolver to paint an inaccessible attachme expect(html).not.toContain(" { + const agent = "a".repeat(64); + const media = vi.fn((url: string) => url); + const html = renderToStaticMarkup( + false} + day={false} + retry={undefined} + />, + ); + expect(html).toContain('data-avatar-shape="squircle"'); + expect(media).toHaveBeenCalledWith("https://image.test/agent.png", "small"); + expect(render({}, 0).html).toContain('data-avatar-shape="circle"'); +}); + +it.each([9, 40002])( + "renders kind %s author shape from the existing fold without profile/library evidence", + (kind) => { + const author = keypair(), + relay = keypair(); + const [folded] = foldMessages("channel", relay.pubkey, [ + signed(author, { + kind, + content: + kind === 40002 ? JSON.stringify({ content: "Reply" }) : "Reply", + tags: [["h", "channel"]], + }), + ]); + if (!folded) throw new Error("Missing row"); + const html = renderToStaticMarkup( + undefined} + onOpenLink={() => false} + day={false} + retry={undefined} + />, + ); + expect(html).toContain( + `data-avatar-shape="${kind === 40002 ? "squircle" : "circle"}"`, + ); + }, +); + it("requests a small profile image without downsizing message attachments", () => { const media = vi.fn((url: string) => url); renderToStaticMarkup( diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index f112d9e0..30c02295 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -29,6 +29,7 @@ export type MessageRowProps = { extensions?: ConversationExtensions | undefined; profile: Profile | undefined; participantProfiles?: ReadonlyMap | undefined; + agentPubkeys?: ReadonlySet | undefined; canOpenLink?: ((target: string) => boolean) | undefined; media(url: string, size?: "small"): string | undefined; onOpenLink(url: string): boolean; @@ -53,6 +54,7 @@ export const MessageRow = memo(function MessageRow({ retry, onOpenThread, participantProfiles, + agentPubkeys, }: MessageRowProps) { const directory = useReferenceDirectory(session, row.mentions.length > 0); const threadUnread = useThreadUnread( @@ -104,7 +106,7 @@ export const MessageRow = memo(function MessageRow({ )}
- {picture ? ( - - ) : ( - name.slice(0, 2).toUpperCase() - )} + + {picture ? ( + + ) : ( + name.slice(0, 2).toUpperCase() + )} +
@@ -224,26 +235,36 @@ export const MessageRow = memo(function MessageRow({ - {name.slice(0, 2).toUpperCase()} - {picture && ( - { - event.currentTarget.hidden = true; - }} - /> - )} + + {name.slice(0, 2).toUpperCase()} + {picture && ( + { + event.currentTarget.hidden = true; + }} + /> + )} + ); })} {row.participants.length > 3 && ( - - +{row.participants.length - 3} + + + +{row.participants.length - 3} + )} diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 373070e2..c52d2033 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -1,3 +1,5 @@ +@import "../../shared/design-system/styles/avatar-shape.css"; + /* Reusable conversation components; plugins own placement and surrounding layout. */ .thread { min-width: 0; @@ -66,11 +68,20 @@ gap: var(--space-4); padding: var(--space-2) 0 var(--space-section-gap); } +.avatarButton { + flex-shrink: 0; + align-self: flex-start; + border-radius: var(--radius-sm); +} +html[data-keyboard-navigation] button.avatarButton:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} + .avatar { width: 40px; height: 40px; flex-shrink: 0; - border-radius: 50%; background: var(--surface-accent); color: var(--text-muted); display: grid; @@ -388,8 +399,7 @@ margin-right: calc(-1 * var(--space-half)); overflow: hidden; border: 2px solid var(--surface); - border-radius: 50%; - background: var(--surface-accent); + background: var(--surface); color: var(--text-muted); font-size: var(--text-caption); line-height: var(--text-caption--line-height); @@ -585,12 +595,14 @@ font-style: italic; } -button.avatar { +button.avatarButton { padding: 0; border: 0; + background: transparent; cursor: pointer; } -button.avatar:hover { +button.avatarButton:hover { + background: transparent; box-shadow: 0 0 0 2px var(--border); } .mention { @@ -607,7 +619,6 @@ button.avatar:hover { .mention:hover { text-decoration: underline; } -button.avatar:focus-visible, .mention:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; @@ -642,9 +653,8 @@ button.avatar:focus-visible, height: 28px; margin-right: calc(-1 * var(--space-1h)); border: 2px solid var(--surface); - border-radius: var(--radius-row); overflow: hidden; - background: var(--surface-accent); + background: var(--surface); color: var(--text-muted); font-size: var(--text-caption); line-height: var(--text-caption--line-height); @@ -658,6 +668,19 @@ button.avatar:focus-visible, object-fit: cover; } +/* The overlap border and its inset artwork need independently scaled curves. */ +.insetAvatarArtwork { + position: relative; + display: grid; + place-items: center; + width: 100%; + height: 100%; + overflow: hidden; + background: var(--surface-accent); + border-radius: inherit; + mask: inherit; +} + .typing { color: var(--text-muted); font-size: var(--text-caption); diff --git a/src/features/messages/ThreadPanel.test.tsx b/src/features/messages/ThreadPanel.test.tsx index 0d44b018..3d5294d7 100644 --- a/src/features/messages/ThreadPanel.test.tsx +++ b/src/features/messages/ThreadPanel.test.tsx @@ -10,6 +10,7 @@ import { type ReactNode, } from "react"; import { ThreadPanel } from "./ThreadPanel"; +import { createAgentLibrary } from "../agents/library"; import { MessageRow } from "./MessageRow"; import { MessageMarkdown } from "./MessageMarkdown"; import { MessageComposer } from "./MessageComposer"; @@ -148,6 +149,7 @@ function setup() { const session = { thread, profiles: { ensure }, + agentLibrary: createAgentLibrary(undefined).queries, messages: { retry: vi.fn() }, // Geometry fixtures are read-only; reading behavior has its own boundary tests. unread: { sync: () => ({ capability: "unsupported" }) }, diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index df639a23..38ba6c7b 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -22,6 +22,7 @@ import { useReading } from "./use-reading"; import { useMessageReveal } from "./use-message-reveal"; import type { PageNavigation } from "../navigation/service"; import { messageViewKey } from "./view-key"; +import { useKnownAgentPubkeys } from "../agents/use-known"; export type ThreadPanelProps = { extensions?: ConversationExtensions | undefined; @@ -196,6 +197,7 @@ function ThreadMessages({ .catch(() => {}); }, [session.profiles, authors]); const profiles = useRowProfiles(session.profiles, rows); + const agentPubkeys = useKnownAgentPubkeys(session, profiles); const scroller = useRef(null); const positioned = useRef(false); const follow = useRef(true); @@ -329,6 +331,7 @@ function ThreadMessages({ row={snapshot.root} profile={profiles.get(snapshot.root.authorId)} participantProfiles={profiles} + agentPubkeys={agentPubkeys} media={session.media} onOpenLink={onOpenLink} canOpenLink={canOpenLink} @@ -354,6 +357,7 @@ function ThreadMessages({ row={row} profile={profiles.get(row.authorId)} participantProfiles={profiles} + agentPubkeys={agentPubkeys} media={session.media} onOpenLink={onOpenLink} canOpenLink={canOpenLink} diff --git a/src/features/messages/membership-rows.test.tsx b/src/features/messages/membership-rows.test.tsx index e313bb82..a6fd7a8e 100644 --- a/src/features/messages/membership-rows.test.tsx +++ b/src/features/messages/membership-rows.test.tsx @@ -1,4 +1,4 @@ -import { assert, expect, it } from "vitest"; +import { assert, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import type { ChannelMessage, MembershipChange } from "../relay/contracts"; import { membershipRows, membershipDescription } from "./membership-rows"; @@ -181,6 +181,28 @@ it("caps visible names/avatars with an overflow count, retaining all names in th expect(html).toContain("data-membership-row"); expect(html).toContain("dddddddddd"); }); +it.each([false, true])( + "keeps small membership media requests with agent hint %s", + (agent) => { + const picture = "https://image.test/member.png"; + const media = vi.fn((url: string) => url); + const html = renderToStaticMarkup( + , + ); + expect(media).toHaveBeenCalledWith(picture, "small"); + expect(html).toContain( + `data-avatar-shape="${agent ? "squircle" : "circle"}"`, + ); + expect(html).toContain('aria-hidden="true"'); + expect(html).not.toContain("button"); + }, +); it("actor/subject profile enrichment invalidates cached system-row geometry", () => { const rows = [row("a", joined(wes, pinky))]; const original = geometrySignature(rows, profiles); diff --git a/src/features/relay/contracts.ts b/src/features/relay/contracts.ts index 7477f82c..3ab660c6 100644 --- a/src/features/relay/contracts.ts +++ b/src/features/relay/contracts.ts @@ -19,6 +19,8 @@ export type Profile = Readonly<{ name: string; picture?: string; about?: string; + /** Self-declared display hint, not proof of ownership, membership or authority. */ + isAgent?: true; }>; export type Attachment = Readonly<{ url: string; @@ -42,6 +44,8 @@ export type ChannelMessage = Readonly<{ /** Unix seconds from the signed event. Ordering is (createdAt asc, id desc); no clock inference. */ createdAt: number; content: string; + /** Original kind 40002, regardless of edits; self-declared display evidence, not authority. */ + agentEnvelope?: true; membership?: MembershipChange; /** Current body came from a replacement edit; original recipients do not bind its prose. */ edited?: true; diff --git a/src/features/relay/fold.test.ts b/src/features/relay/fold.test.ts index fe77b4bd..480997f8 100644 --- a/src/features/relay/fold.test.ts +++ b/src/features/relay/fold.test.ts @@ -194,6 +194,46 @@ describe("message fold", () => { }, ); + it.each([ + [9, "Plain message"], + [9, JSON.stringify({ content: "Envelope-looking prose", is_agent: true })], + [40002, JSON.stringify({ content: "Agent message" })], + [40002, "Malformed envelope"], + ])( + "keeps the original kind %s display hint independent of body and edits (%s)", + (kind, content) => { + const original = signed(alice, { + kind, + content, + created_at: 10, + tags: [["h", channel]], + }); + const hint = kind === 40002 ? true : undefined; + expect( + foldMessages(channel, relay.pubkey, [original])[0]?.agentEnvelope, + ).toBe(hint); + for (const replacement of [ + "Plain edit", + JSON.stringify({ content: "Envelope edit" }), + ]) { + const edit = signed(alice, { + kind: 40003, + content: replacement, + created_at: 11, + tags: [["e", original.id]], + }); + const [row] = foldMessages(channel, relay.pubkey, [original, edit]); + expect(row?.edited).toBe(true); + expect(row?.agentEnvelope).toBe(hint); + expect(row?.content).toBe( + kind === 40002 && replacement.startsWith("{") + ? "Envelope edit" + : replacement, + ); + } + }, + ); + it("unwraps agent envelopes and projects valid CommonMark images through one safe URL policy", () => { const agent = signed(bob, { kind: 40002, diff --git a/src/features/relay/fold.ts b/src/features/relay/fold.ts index b404f69d..abc298ef 100644 --- a/src/features/relay/fold.ts +++ b/src/features/relay/fold.ts @@ -165,6 +165,7 @@ export function foldMessages( authorId: event.pubkey, createdAt: event.created_at, content: projected.content, + ...(event.kind === 40002 ? { agentEnvelope: true as const } : {}), ...(edits.length ? { edited: true as const } : {}), ...(projected.content !== content && projected.content !== content.trimEnd() diff --git a/src/features/relay/profile-details.test.ts b/src/features/relay/profile-details.test.ts index d1919044..9bc8b1e6 100644 --- a/src/features/relay/profile-details.test.ts +++ b/src/features/relay/profile-details.test.ts @@ -1,8 +1,9 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; +import { selectProfiles } from "./profile-selection"; import { foldProfiles } from "./profiles"; import { createProfileDirectory } from "./profile-directory"; import { createRelayReader } from "./reader"; -import { keypair, profile, scriptedTransport } from "./testing"; +import { keypair, profile, scriptedTransport, signed } from "./testing"; const user = keypair(); it("projects about safely and publishes an about-only replacement/removal", () => { const wire = scriptedTransport(user.pubkey, keypair().pubkey); @@ -29,3 +30,69 @@ it("projects about safely and publishes an about-only replacement/removal", () = reader.dispose(); } }); + +it("retains self-authored agent metadata without inferring it from display names", () => { + const author = keypair(); + const profiles = foldProfiles([ + signed(author, { + kind: 0, + content: JSON.stringify({ name: "Agent-looking human", is_agent: true }), + tags: [], + }), + ]); + expect(profiles.get(author.pubkey)?.isAgent).toBe(true); +}); + +it.each([ + { field: "is_agent", removal: { is_agent: false } }, + { field: "isAgent", removal: {} }, +])( + "notifies the directory and selected profile on $field-only addition/removal", + ({ field, removal }) => { + const wire = scriptedTransport(user.pubkey, keypair().pubkey); + const reader = createRelayReader(wire.transport); + const directory = createProfileDirectory(reader.reader); + const selection = selectProfiles(directory.queries, [user.pubkey]); + const directoryChanged = vi.fn(); + const selectedChanged = vi.fn(); + const unsubscribeDirectory = directory.queries.subscribe(directoryChanged); + const unsubscribeSelection = selection.subscribe(selectedChanged); + try { + directory.accept([profile(user, { name: "Mic" }, 1)]); + const before = selection.snapshot(); + directoryChanged.mockClear(); + selectedChanged.mockClear(); + + directory.accept([profile(user, { name: "Mic", [field]: true }, 2)]); + const added = selection.snapshot(); + expect(added).not.toBe(before); + expect(added.get(user.pubkey)).toBe( + directory.queries.snapshot().get(user.pubkey), + ); + expect(added.get(user.pubkey)).toEqual({ name: "Mic", isAgent: true }); + expect(directoryChanged).toHaveBeenCalledTimes(1); + expect(selectedChanged).toHaveBeenCalledTimes(1); + + // A newer event with identical display values must still preserve identity. + directory.accept([profile(user, { name: "Mic", [field]: true }, 3)]); + expect(selection.snapshot()).toBe(added); + expect(directoryChanged).toHaveBeenCalledTimes(1); + expect(selectedChanged).toHaveBeenCalledTimes(1); + + directory.accept([profile(user, { name: "Mic", ...removal }, 4)]); + const removed = selection.snapshot(); + expect(removed).not.toBe(added); + expect(removed.get(user.pubkey)).toBe( + directory.queries.snapshot().get(user.pubkey), + ); + expect(removed.get(user.pubkey)).toEqual({ name: "Mic" }); + expect(directoryChanged).toHaveBeenCalledTimes(2); + expect(selectedChanged).toHaveBeenCalledTimes(2); + } finally { + unsubscribeSelection(); + unsubscribeDirectory(); + directory.dispose(); + reader.dispose(); + } + }, +); diff --git a/src/features/relay/profile-directory.ts b/src/features/relay/profile-directory.ts index a3c895ee..731de677 100644 --- a/src/features/relay/profile-directory.ts +++ b/src/features/relay/profile-directory.ts @@ -54,7 +54,8 @@ export function createProfileDirectory( if ( old?.name === value.name && old?.picture === value.picture && - old?.about === value.about + old?.about === value.about && + old?.isAgent === value.isAgent ) next.set(id, old); } diff --git a/src/features/relay/profiles.ts b/src/features/relay/profiles.ts index 85909795..43cfa589 100644 --- a/src/features/relay/profiles.ts +++ b/src/features/relay/profiles.ts @@ -20,6 +20,8 @@ export function foldProfiles( name?: unknown; picture?: unknown; about?: unknown; + is_agent?: unknown; + isAgent?: unknown; }; const name = [body.display_name, body.name].find( (value): value is string => @@ -37,6 +39,9 @@ export function foldProfiles( ...(typeof body.about === "string" && body.about.trim() ? { about: body.about.trim() } : {}), + ...(body.is_agent === true || body.isAgent === true + ? { isAgent: true as const } + : {}), }), ); } catch { diff --git a/src/shared/Avatar.tsx b/src/shared/Avatar.tsx index 291376e7..97d115f4 100644 --- a/src/shared/Avatar.tsx +++ b/src/shared/Avatar.tsx @@ -1,3 +1,4 @@ +import "./design-system/styles/avatar-shape.css"; import { useState } from "react"; /** Caller resolves private relay media through the current session. */ @@ -5,10 +6,12 @@ export function Avatar({ name, src, className = "", + shape = "circle", }: { name: string; src?: string | undefined; className?: string; + shape?: "circle" | "squircle"; }) { const [failed, setFailed] = useState(); const initials = @@ -21,6 +24,7 @@ export function Avatar({ .toUpperCase() || "?"; return (
+ +
+ + + + + + +
+
{/* No `src`, so the fallback initial shows. Same three sizes, because a fallback has to hold the ramp as well as an image does. */} diff --git a/tests/fixtures/mentions.tsx b/tests/fixtures/mentions.tsx index 2b0f6ca5..b700acca 100644 --- a/tests/fixtures/mentions.tsx +++ b/tests/fixtures/mentions.tsx @@ -26,6 +26,8 @@ let time = 1700000000; const publications: RelayEvent[] = []; let incoming = (_events: readonly RelayEvent[]) => {}; let releaseProfiles = () => {}; +let libraryReads = 0; +let libraryIncludesFirst = false; const delayed = new URLSearchParams(location.search).has("delayed-profiles"); const profileGate = delayed ? new Promise((resolve) => { @@ -37,6 +39,16 @@ const owner = createRelaySession( viewer: viewer.pubkey, relayAuthor: relay.pubkey, media: (url) => url, + // Synthetic, lazy capability: only the explicit fixture action loads it. + async readAgentLibrary() { + libraryReads++; + return { + definitions: [], + identities: libraryIncludesFirst + ? [{ pubkey: first.pubkey, name: "Honey" }] + : [], + }; + }, subscribe(callbacks) { incoming = callbacks.receive; callbacks.state({ status: "connected", routes: [] }); @@ -52,7 +64,7 @@ const owner = createRelaySession( metadata(relay, "other", "Other"), profile(viewer, { name: "Viewer" }), profile(first, { name: delayed ? "Mary Jane" : "Honey" }), - profile(second, { name: "Honey" }), + profile(second, { name: "Honey", is_agent: true }), ...publications, ]; return events.filter((event) => @@ -138,6 +150,11 @@ Object.assign(window, { owner.session.channels.refreshList?.(); }, releaseProfiles: () => releaseProfiles(), + libraryReads: () => libraryReads, + setLibraryAgent(included: boolean) { + libraryIncludesFirst = included; + return owner.session.agentLibrary.refresh(); + }, list: () => owner.session.channels.list(), otherMessage() { incoming([message(viewer, "other", "Unrelated preview", ++time)]); diff --git a/tests/fixtures/messages.tsx b/tests/fixtures/messages.tsx index a102fe2e..2d339ba8 100644 --- a/tests/fixtures/messages.tsx +++ b/tests/fixtures/messages.tsx @@ -132,7 +132,10 @@ const owner = createRelaySession({ metadata(relay, "two", "Two"), ]; if (filter.kinds?.includes(0)) - return [profile(viewer, { name: "Fixture Reader" })]; + return [ + profile(viewer, { name: "Fixture Reader" }), + profile(agent, { name: "Agent Fixture" }), + ].filter((event) => filter.authors?.includes(event.pubkey)); if (filter.ids) return events.filter((event) => filter.ids?.includes(event.id)); if (filter.depth_limit) { diff --git a/tests/fixtures/profiles.tsx b/tests/fixtures/profiles.tsx index 3b4cf469..747a21ce 100644 --- a/tests/fixtures/profiles.tsx +++ b/tests/fixtures/profiles.tsx @@ -50,7 +50,12 @@ let failMissing = true; const data = [ profile(viewer, { name: "Viewer", about: "Human profile", picture }), profile(mic, { name: "Mic", about: "Mic biography" }), - profile(pinky, { name: "Pinky", about: "Agent profile" }), + profile(pinky, { + name: "Pinky", + about: "Agent profile", + is_agent: true, + picture, + }), ]; function session() { return createRelaySession({