From 61272bd14aff0fe775987facb09bb4e01ba6acf5 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 13 Sep 2026 06:18:53 -1000 Subject: [PATCH 1/7] feat(avatars): distinguish agents with svg squircles Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/agents/AgentsPage.tsx | 8 +++++++- src/bundled/mentions/MentionCompletion.tsx | 3 +++ src/bundled/mentions/MentionPicker.tsx | 5 +++++ src/bundled/profiles/ProfilePanel.tsx | 8 ++++++-- src/features/agents/known.test.ts | 14 +++++++++++++ src/features/agents/known.ts | 15 ++++++++++++++ src/features/agents/use-known.ts | 20 +++++++++++++++++++ src/features/messages/ChannelTimeline.tsx | 4 ++++ src/features/messages/MembershipRow.tsx | 10 +++++++++- src/features/messages/MessageRow.test.tsx | 17 ++++++++++++++++ src/features/messages/MessageRow.tsx | 8 ++++++++ src/features/messages/Messages.module.css | 6 ++++++ src/features/messages/ThreadPanel.tsx | 4 ++++ src/features/relay/contracts.ts | 2 ++ src/features/relay/profile-details.test.ts | 14 ++++++++++++- src/features/relay/profiles.ts | 5 +++++ src/shared/Avatar.tsx | 3 +++ .../design-system/styles/components.css | 6 ++++++ src/shared/design-system/ui/Avatar.tsx | 3 +++ tests/browser/fixture.mjs | 1 + tests/browser/membership.spec.mjs | 4 ++++ 21 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 src/features/agents/known.test.ts create mode 100644 src/features/agents/known.ts create mode 100644 src/features/agents/use-known.ts diff --git a/src/bundled/agents/AgentsPage.tsx b/src/bundled/agents/AgentsPage.tsx index e79f342f..aef56a6d 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 30d53b44..62fbc7d6 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(":"); @@ -82,6 +84,7 @@ export function MentionCompletion({ name={recipient.name} src={session.media(profiles.get(recipient.pubkey)?.picture ?? "")} className="size-7 rounded-lg text-xs" + shape={agentPubkeys.has(recipient.pubkey) ? "squircle" : "circle"} /> ), edit: { mention: recipient }, diff --git a/src/bundled/mentions/MentionPicker.tsx b/src/bundled/mentions/MentionPicker.tsx index 90439f16..b520c143 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(() => { @@ -140,6 +142,9 @@ export function MentionPicker({ profiles.get(recipient.pubkey)?.picture ?? "", )} className="size-8 rounded-lg text-xs" + shape={ + agentPubkeys.has(recipient.pubkey) ? "squircle" : "circle" + } /> {recipient.name} diff --git a/src/bundled/profiles/ProfilePanel.tsx b/src/bundled/profiles/ProfilePanel.tsx index 4259775a..cc9033c2 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); @@ -99,6 +102,7 @@ function ProfileDetails({ alt={`${name} avatar`} fallback={profile?.name ?? "?"} size="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..cd81a719 --- /dev/null +++ b/src/features/agents/known.ts @@ -0,0 +1,15 @@ +import type { AgentLibrary } from "./library"; +import type { Profile } from "../relay/contracts"; + +/** Exact identity keys from authenticated profile metadata plus the local Buzz library. */ +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..42d4c4bc --- /dev/null +++ b/src/features/agents/use-known.ts @@ -0,0 +1,20 @@ +import { useMemo, useSyncExternalStore } from "react"; +import type { Profile } from "../relay/contracts"; +import type { RelaySession } from "../relay/session"; +import { knownAgentPubkeys } from "./known"; + +/** One subscribed projection per owning surface; exact keys, never display names. */ +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.tsx b/src/features/messages/ChannelTimeline.tsx index 1312d2f9..927ff3ce 100644 --- a/src/features/messages/ChannelTimeline.tsx +++ b/src/features/messages/ChannelTimeline.tsx @@ -13,6 +13,7 @@ import { readView, writeView } from "../../shared/view-state"; import styles from "./Messages.module.css"; import { useReading } from "./use-reading"; import { messageViewKey } from "./view-key"; +import { useKnownAgentPubkeys } from "../agents/use-known"; const EDGE_HEIGHT = 56; type ReadingPosition = { @@ -103,6 +104,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), @@ -429,6 +431,7 @@ function Timeline({ profiles={profiles} viewer={viewer} media={queries.media} + agentPubkeys={agentPubkeys} day={day} /> ) : ( @@ -439,6 +442,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 e806f2f9..cb23fac3 100644 --- a/src/features/messages/MembershipRow.tsx +++ b/src/features/messages/MembershipRow.tsx @@ -9,12 +9,14 @@ export const MembershipRow = memo(function MembershipRow({ profiles, viewer, media, + agentPubkeys, day, }: { row: TimelineRow; profiles: ReadonlyMap; viewer?: string | undefined; media(url: string): string | undefined; + agentPubkeys?: ReadonlySet | undefined; day: boolean; }) { const { targets, text, title } = membershipDescription( @@ -44,7 +46,13 @@ export const MembershipRow = memo(function MembershipRow({ ? media(profile.picture) : undefined; return ( - + {name.slice(0, 2).toUpperCase()} {picture && ( { + const agent = "a".repeat(64); + const html = renderToStaticMarkup( + undefined} + onOpenLink={() => false} + day={false} + retry={undefined} + />, + ); + expect(html).toContain('data-avatar-shape="squircle"'); + expect(render({}, 0).html).toContain('data-avatar-shape="circle"'); +}); diff --git a/src/features/messages/MessageRow.tsx b/src/features/messages/MessageRow.tsx index 9cc74e38..d67d65f5 100644 --- a/src/features/messages/MessageRow.tsx +++ b/src/features/messages/MessageRow.tsx @@ -17,6 +17,7 @@ export type MessageRowProps = { extensions?: ConversationExtensions | undefined; profile: Profile | undefined; participantProfiles?: ReadonlyMap | undefined; + agentPubkeys?: ReadonlySet | undefined; canOpenLink?: ((target: string) => boolean) | undefined; media(url: string): string | undefined; onOpenLink(url: string): boolean; @@ -37,6 +38,7 @@ export const MessageRow = memo(function MessageRow({ retry, onOpenThread, participantProfiles, + agentPubkeys, }: MessageRowProps) { const threadUnread = useThreadUnread( row.replyCount > 0 && onOpenThread ? unread : undefined, @@ -73,6 +75,9 @@ export const MessageRow = memo(function MessageRow({
{name.slice(0, 2).toUpperCase()} diff --git a/src/features/messages/Messages.module.css b/src/features/messages/Messages.module.css index 87a1faeb..8dbea6f5 100644 --- a/src/features/messages/Messages.module.css +++ b/src/features/messages/Messages.module.css @@ -710,3 +710,9 @@ button.avatar:focus-visible, height: 100%; object-fit: cover; } + +.avatar[data-avatar-shape="squircle"], +.threadAvatar[data-avatar-shape="squircle"], +.membershipAvatar[data-avatar-shape="squircle"] { + clip-path: path("M .5 0 C .93 0 1 .07 1 .5 C 1 .93 .93 1 .5 1 C .07 1 0 .93 0 .5 C 0 .07 .07 0 .5 0 Z"); +} diff --git a/src/features/messages/ThreadPanel.tsx b/src/features/messages/ThreadPanel.tsx index 80a01d32..3cced766 100644 --- a/src/features/messages/ThreadPanel.tsx +++ b/src/features/messages/ThreadPanel.tsx @@ -17,6 +17,7 @@ import { MessageComposer } from "./MessageComposer"; import styles from "./Messages.module.css"; import { useReading } from "./use-reading"; import { messageViewKey } from "./view-key"; +import { useKnownAgentPubkeys } from "../agents/use-known"; export type ThreadPanelProps = { extensions?: ConversationExtensions | undefined; @@ -167,6 +168,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); @@ -235,6 +237,7 @@ function ThreadMessages({ row={snapshot.root} profile={profiles.get(snapshot.root.authorId)} participantProfiles={profiles} + agentPubkeys={agentPubkeys} media={session.media} onOpenLink={onOpenLink} canOpenLink={canOpenLink} @@ -258,6 +261,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/relay/contracts.ts b/src/features/relay/contracts.ts index d7900c75..9d902777 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-authored profile metadata; additive to the local agent library. */ + isAgent?: true; }>; export type Attachment = Readonly<{ url: string; diff --git a/src/features/relay/profile-details.test.ts b/src/features/relay/profile-details.test.ts index d1919044..075ae6dc 100644 --- a/src/features/relay/profile-details.test.ts +++ b/src/features/relay/profile-details.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest"; 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 +29,15 @@ 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); +}); 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 62776d39..8cc780f9 100644 --- a/src/shared/Avatar.tsx +++ b/src/shared/Avatar.tsx @@ -5,10 +5,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 +23,7 @@ export function Avatar({ .toUpperCase() || "?"; return (