Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion docs/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion src/bundled/agents/AgentsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,13 @@ function AgentCard({
return (
<article className="flex min-w-0 flex-col rounded-2xl border border-primary p-4">
<div className="flex min-h-36 flex-1 items-center justify-center py-5">
<Avatar alt={name} fallback={name} src={picture ?? null} size="large" />
<Avatar
alt={name}
fallback={name}
src={picture ?? null}
size="large"
shape="squircle"
/>
</div>
<h3 className="m-0 truncate text-label" title={name}>
{name}
Expand Down
4 changes: 4 additions & 0 deletions src/bundled/mentions/MentionCompletion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(":");
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -140,6 +143,7 @@ export function MentionCompletion({
channelId,
memberKey,
profiles,
agentPubkeys,
query.query,
publish,
error,
Expand Down
5 changes: 5 additions & 0 deletions src/bundled/mentions/MentionPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Avatar } from "../../shared/Avatar";
import { useKnownAgentPubkeys } from "../../features/agents/use-known";
import { AtSign } from "lucide-react";
import {
useEffect,
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -141,6 +143,9 @@ export function MentionPicker({
"small",
)}
className="size-8 rounded-lg text-caption"
shape={
agentPubkeys.has(recipient.pubkey) ? "squircle" : "circle"
}
/>
<span className={styles.mentionLabel}>
<span>{recipient.name}</span>
Expand Down
8 changes: 6 additions & 2 deletions src/bundled/profiles/ProfilePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
);
Expand All @@ -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);
Expand All @@ -103,6 +106,7 @@ function ProfileDetails({
alt={`${name} avatar`}
fallback={profile?.name ?? "?"}
size={picture ? "fill" : "large"}
shape={agentPubkeys.has(pubkey) ? "squircle" : "circle"}
/>
</div>
<h2 className="text-heading">{name}</h2>
Expand Down
14 changes: 14 additions & 0 deletions src/features/agents/known.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
16 changes: 16 additions & 0 deletions src/features/agents/known.ts
Original file line number Diff line number Diff line change
@@ -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<string, Profile>,
library?: AgentLibrary,
): ReadonlySet<string> {
const keys = new Set<string>();
for (const [pubkey, profile] of profiles) {
if (profile.isAgent) keys.add(pubkey);
}
for (const identity of library?.identities ?? []) keys.add(identity.pubkey);
return keys;
}
21 changes: 21 additions & 0 deletions src/features/agents/use-known.ts
Original file line number Diff line number Diff line change
@@ -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<string, Profile>,
): ReadonlySet<string> {
const library = useSyncExternalStore(
session.agentLibrary.subscribe,
session.agentLibrary.snapshot,
session.agentLibrary.snapshot,
);
return useMemo(
() => knownAgentPubkeys(profiles, library),
[profiles, library],
);
}
4 changes: 4 additions & 0 deletions src/features/messages/ChannelTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -91,6 +92,8 @@ vi.mock("react", async (original) => ({
});
}
},
useSyncExternalStore: (_subscribe: unknown, snapshot: () => unknown) =>
snapshot(),
}));
vi.mock("../relay/react", () => ({
useRowProfiles: () => new Map(),
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/features/messages/ChannelTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -107,6 +108,7 @@ function Timeline({
const restoredAnchor = useRef<string | undefined>(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),
Expand Down Expand Up @@ -481,6 +483,7 @@ function Timeline({
profiles={profiles}
viewer={viewer}
media={queries.media}
agentPubkeys={agentPubkeys}
day={day}
/>
) : (
Expand All @@ -493,6 +496,7 @@ function Timeline({
extensions={extensions}
profile={profiles.get(row.authorId)}
participantProfiles={profiles}
agentPubkeys={agentPubkeys}
media={queries.media}
onOpenLink={onOpenLink}
canOpenLink={canOpenLink}
Expand Down
46 changes: 31 additions & 15 deletions src/features/messages/MembershipRow.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,12 +10,14 @@ export const MembershipRow = memo(function MembershipRow({
profiles,
viewer,
media,
agentPubkeys,
day,
}: {
row: TimelineRow;
profiles: ReadonlyMap<string, Profile>;
viewer?: string | undefined;
media(url: string, size?: "small"): string | undefined;
agentPubkeys?: ReadonlySet<string> | undefined;
day: boolean;
}) {
const { targets, text, title } = membershipDescription(
Expand Down Expand Up @@ -44,25 +47,38 @@ export const MembershipRow = memo(function MembershipRow({
? media(profile.picture, "small")
: undefined;
return (
<span className={styles.membershipAvatar} key={id}>
{name.slice(0, 2).toUpperCase()}
{picture && (
<img
key={picture}
src={picture}
alt=""
loading="lazy"
onError={(event) => {
event.currentTarget.hidden = true;
}}
/>
)}
<span
className={styles.membershipAvatar}
data-avatar-shape={
agentPubkeys?.has(id) ? "squircle" : "circle"
}
key={id}
>
<span className={styles.insetAvatarArtwork}>
{name.slice(0, 2).toUpperCase()}
{picture && (
<img
key={picture}
src={picture}
alt=""
loading="lazy"
onError={(event) => {
event.currentTarget.hidden = true;
}}
/>
)}
</span>
</span>
);
})}
{targets.length > 3 && (
<span className={styles.membershipAvatar}>
+{targets.length - 3}
<span
className={styles.membershipAvatar}
data-avatar-shape="circle"
>
<span className={styles.insetAvatarArtwork}>
+{targets.length - 3}
</span>
</span>
)}
</span>
Expand Down
49 changes: 49 additions & 0 deletions src/features/messages/MessageRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,55 @@ it("does not bypass the session media resolver to paint an inaccessible attachme
expect(html).not.toContain("<img");
});

it("uses the reviewed SVG squircle only for identities classified as agents", () => {
const agent = "a".repeat(64);
const media = vi.fn((url: string) => url);
const html = renderToStaticMarkup(
<MessageRow
row={{ ...row, authorId: agent }}
profile={{ name: "Carl", picture: "https://image.test/agent.png" }}
agentPubkeys={new Set([agent])}
media={media}
onOpenLink={() => 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(
<MessageRow
row={folded}
profile={undefined}
media={() => 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(
Expand Down
Loading
Loading