diff --git a/docs/profiles.md b/docs/profiles.md
index eb93d43a4..693f5c400 100644
--- a/docs/profiles.md
+++ b/docs/profiles.md
@@ -89,3 +89,28 @@ channels; `sidebar-unread.spec.mjs` holds unread evidence through real EOSE and
checks its badges without retries. Access-loss/disconnect cancellation is unchanged.
Broad scan and native build/package acceptance remain deferred to an agreed
integration batch. No sending/signing behavior changed.
+
+## Info, channels and linked instances
+
+The profile's Channels tab uses the current viewer's `session.channels`
+list and exact `ChannelSummary.members` from relay-authored rosters. It excludes
+archived, hidden, DM and session conversations. Only positively identified stream
+and forum rows render; when a matching roster lacks a recognized type, that
+membership is unclassified and omitted from the channel rows. Ready empty copy
+qualifies this uncertainty for the viewed identity, including partial lists.
+Verified classified rows remain visible
+alongside a transient list error; loading/error/partial discovery is not an
+empty membership claim; retry uses `refreshList`. Rows navigate using the existing
+scoped conversation destination when a valid community scope and navigation
+capability exist, otherwise remain plain text.
+
+On native hosts, linked instances use `agentControl`'s exact managed `pubkey` and
+normalized `relayUrl` for the active community. The subsection is absent without
+native control or a valid community scope. Agent identity hints gate loading and
+visibility, never ownership; exact native matches alone supply instance rows. The
+section stays hidden for a non-agent without a match. It never derives ownership
+from the old Buzz library, self-declared profile markers, or names. The Agents
+page route opens management, not a per-instance page.
+
+The Info tab keeps the public key and linked instances; the Channels tab is read-only.
+Neither list is a cross-community/global directory.
diff --git a/src/bundled/profiles/ProfileChannels.test.tsx b/src/bundled/profiles/ProfileChannels.test.tsx
new file mode 100644
index 000000000..d938f1098
--- /dev/null
+++ b/src/bundled/profiles/ProfileChannels.test.tsx
@@ -0,0 +1,284 @@
+// @vitest-environment jsdom
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+} from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import type { Navigation } from "../../features/navigation/controller";
+import type {
+ ChannelList,
+ ChannelQueries,
+} from "../../features/relay/contracts";
+import type { RelaySession } from "../../features/relay/session";
+import { ProfileChannels } from "./ProfileChannels";
+
+const person = "a".repeat(64);
+const viewer = "b".repeat(64);
+function fixture() {
+ let list: ChannelList = { status: "loading", channels: [] };
+ const listeners = new Set<() => void>();
+ const refreshList = vi.fn();
+ const ensureList = vi.fn();
+ const channels = {
+ list: () => list,
+ subscribeList(listener: () => void) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ ensureList,
+ refreshList,
+ } as unknown as ChannelQueries;
+ const open = vi.fn(async () => ({ status: "opened" as const }));
+ const navigation = { open } as unknown as Navigation;
+ const session = { channels } as RelaySession;
+ const update = (next: ChannelList) =>
+ act(() => {
+ list = next;
+ for (const listener of listeners) listener();
+ });
+ return { session, navigation, open, update, refreshList, ensureList };
+}
+afterEach(cleanup);
+
+it("shows only exact verified visible memberships, handles partial lists and opens the scoped conversation", () => {
+ const f = fixture();
+ render(
+ ,
+ );
+ expect(screen.getByRole("status").textContent).toContain("Loading channels");
+ expect(f.ensureList).toHaveBeenCalled();
+ f.update({
+ status: "ready",
+ coverage: "partial",
+ channels: [
+ {
+ id: "exact",
+ name: "Visible",
+ channelType: "stream",
+ members: [person, viewer],
+ },
+ { id: "forum", name: "Forum", channelType: "forum", members: [person] },
+ { id: "other", name: "Unrelated", members: [viewer] },
+ { id: "unknown", name: "Unknown" },
+ {
+ id: "hidden",
+ name: "Hidden",
+ channelType: "stream",
+ hidden: true,
+ members: [person],
+ },
+ { id: "untyped-member", name: "Unknown type", members: [person] },
+ { id: "untyped-unrelated", name: "Unrelated type", members: [viewer] },
+ { id: "dm", name: "Direct", channelType: "dm", members: [person] },
+ {
+ id: "session",
+ name: "Child",
+ channelType: "session",
+ members: [person],
+ },
+ { id: "archived", name: "Archived", archived: true, members: [person] },
+ ],
+ });
+ expect(screen.getByRole("button", { name: "#Visible" })).toBeTruthy();
+ expect(screen.getByRole("button", { name: "#Forum" })).toBeTruthy();
+ expect(
+ screen.getByText(/Some memberships for this identity are unclassified/),
+ ).toBeTruthy();
+ expect(
+ screen.queryByText(/Unrelated|Unknown type|Hidden|Archived|Direct|Child/),
+ ).toBeNull();
+ expect(screen.getByText(/More channels may exist/)).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "#Visible" }));
+ expect(f.open).toHaveBeenCalledWith({
+ version: 1,
+ kind: "conversation",
+ channelId: "exact",
+ scope: { viewer, communityOrigin: "https://relay.example.test" },
+ });
+ f.update({ status: "ready", channels: [] });
+ expect(
+ screen.getByText(/No visible channels with verified membership/),
+ ).toBeTruthy();
+});
+
+it("does not invent a route, and retries failed discovery without claiming a complete empty result", () => {
+ const f = fixture();
+ render(
+ ,
+ );
+ f.update({
+ status: "error",
+ channels: [
+ { id: "known", name: "Known", channelType: "stream", members: [person] },
+ ],
+ });
+ expect(screen.getByRole("alert")).toBeTruthy();
+ expect(screen.getByText("Known")).toBeTruthy();
+ expect(screen.queryByRole("button", { name: /Known/ })).toBeNull();
+ expect(screen.getByText(/Channel navigation is unavailable/)).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Retry channels" }));
+ expect(f.refreshList).toHaveBeenCalledOnce();
+ expect(f.open).not.toHaveBeenCalled();
+});
+
+it("renders a verified row without a destination when navigation is unavailable", () => {
+ const f = fixture();
+ render(
+ ,
+ );
+ f.update({
+ status: "ready",
+ channels: [
+ { id: "known", name: "Known", channelType: "stream", members: [person] },
+ ],
+ });
+ expect(screen.getByText("Known")).toBeTruthy();
+ expect(screen.queryByRole("button", { name: /Known/ })).toBeNull();
+ expect(screen.getByText(/Channel navigation is unavailable/)).toBeTruthy();
+});
+
+it("keeps classified roster rows but omits unclassified conversations after metadata failure", () => {
+ const f = fixture();
+ render(
+ ,
+ );
+ f.update({
+ status: "error",
+ channels: [
+ { id: "known", name: "Known", channelType: "forum", members: [person] },
+ { id: "untyped", name: "untyped", members: [person] },
+ {
+ id: "hidden",
+ name: "Hidden",
+ channelType: "stream",
+ hidden: true,
+ members: [person],
+ },
+ { id: "dm", name: "Direct", channelType: "dm", members: [person] },
+ {
+ id: "session",
+ name: "Child",
+ channelType: "session",
+ members: [person],
+ },
+ ],
+ });
+ expect(screen.getByRole("alert")).toBeTruthy();
+ expect(screen.getByRole("button", { name: "#Known" })).toBeTruthy();
+ expect(
+ screen.getByText(/Some memberships for this identity are unclassified/),
+ ).toBeTruthy();
+ expect(screen.queryByText(/untyped|Hidden|Direct|Child/)).toBeNull();
+});
+
+it("qualifies ready empty results when only matching unclassified memberships exist", () => {
+ const f = fixture();
+ render(
+ ,
+ );
+ f.update({
+ status: "ready",
+ channels: [{ id: "unknown", name: "Unknown", members: [person] }],
+ });
+ expect(screen.queryByRole("button", { name: /Unknown/ })).toBeNull();
+ expect(
+ screen.getByText(/No matching channels with a known visible type/),
+ ).toBeTruthy();
+ expect(
+ screen.queryByText(/No visible channels with verified membership/),
+ ).toBeNull();
+ f.update({
+ status: "ready",
+ coverage: "partial",
+ channels: [{ id: "unknown", name: "Unknown", members: [person] }],
+ });
+ expect(
+ screen.getByText(/More channels may exist outside the loaded list/),
+ ).toBeTruthy();
+ expect(
+ screen.queryByText(/No visible channels with verified membership/),
+ ).toBeNull();
+});
+
+it("does not classify hidden, archived or DM memberships in a ready empty list", () => {
+ const f = fixture();
+ render(
+ ,
+ );
+ f.update({
+ status: "ready",
+ channels: [
+ { id: "dm", name: "Direct", channelType: "dm", members: [person] },
+ { id: "hidden", name: "Hidden", hidden: true, members: [person] },
+ { id: "archived", name: "Archived", archived: true, members: [person] },
+ ],
+ });
+ expect(screen.queryByText(/unclassified and omitted/)).toBeNull();
+ expect(
+ screen.getByText(/No visible channels with verified membership/),
+ ).toBeTruthy();
+ expect(
+ screen.queryByText(/No matching channels with a known visible type/),
+ ).toBeNull();
+ expect(f.open).not.toHaveBeenCalled();
+});
+
+it("does not show a metadata caveat for another identity's unclassified roster", () => {
+ const f = fixture();
+ render(
+ ,
+ );
+ f.update({
+ status: "ready",
+ channels: [{ id: "unknown", name: "Unknown", members: [viewer] }],
+ });
+ expect(screen.queryByText(/unclassified and omitted/)).toBeNull();
+ expect(
+ screen.getByText(/No visible channels with verified membership/),
+ ).toBeTruthy();
+});
diff --git a/src/bundled/profiles/ProfileChannels.tsx b/src/bundled/profiles/ProfileChannels.tsx
new file mode 100644
index 000000000..05feab920
--- /dev/null
+++ b/src/bundled/profiles/ProfileChannels.tsx
@@ -0,0 +1,121 @@
+import type { Navigation } from "../../features/navigation/controller";
+import { useChannelList } from "../../features/relay/react";
+import type { RelaySession } from "../../features/relay/session";
+import { Button } from "../../shared/design-system/ui/Button";
+import { ArrowUpRightIcon } from "../../shared/design-system/icons/index";
+import styles from "./Profiles.module.css";
+
+/** Only the viewer's listed channels with an exact roster match are evidence. */
+export function ProfileChannels({
+ session,
+ pubkey,
+ communityOrigin,
+ viewer,
+ navigation,
+}: {
+ session: RelaySession;
+ pubkey: string;
+ communityOrigin: string | undefined;
+ viewer: string | undefined;
+ navigation: Navigation | undefined;
+}) {
+ const list = useChannelList(session.channels);
+ const channels = (
+ list.status === "ready" || list.status === "error" ? list.channels : []
+ ).filter(
+ (channel) =>
+ !channel.archived &&
+ !channel.hidden &&
+ (channel.channelType === "stream" || channel.channelType === "forum") &&
+ channel.members?.includes(pubkey),
+ );
+ const unclassifiedMembership =
+ (list.status === "ready" || list.status === "error") &&
+ list.channels.some(
+ (channel) =>
+ !channel.archived &&
+ !channel.hidden &&
+ !channel.channelType &&
+ channel.members?.includes(pubkey),
+ );
+ return (
+
+
+ {(list.status === "idle" || list.status === "loading") && (
+
+ Loading channels…
+
+ )}
+ {list.status === "unavailable" && (
+
+ Channel membership is unavailable.
+
+ )}
+ {list.status === "error" && (
+
+
Could not finish loading channels.
+
session.channels.refreshList?.()}
+ >
+ Retry channels
+
+
+ )}
+ {unclassifiedMembership && (
+
+ Some memberships for this identity are unclassified and omitted.
+
+ )}
+ {list.status === "ready" && !channels.length && (
+
+ {unclassifiedMembership
+ ? `No matching channels with a known visible type; other memberships could not be classified.${list.coverage === "partial" ? " More channels may exist outside the loaded list." : ""}`
+ : list.coverage === "partial"
+ ? "No matching channels in the loaded list; more channels may exist."
+ : "No visible channels with verified membership for this identity."}
+
+ )}
+ {!!channels.length && (
+
+ {channels.map((channel) => (
+
+ {communityOrigin && navigation && viewer ? (
+
+ void navigation.open({
+ version: 1,
+ kind: "conversation",
+ channelId: channel.id,
+ scope: { viewer, communityOrigin },
+ })
+ }
+ >
+ #{channel.name}
+
+
+ ) : (
+ {channel.name}
+ )}
+
+ ))}
+
+ )}
+
+ {list.coverage === "partial" && !!channels.length && (
+
+ More channels may exist outside the loaded list.
+
+ )}
+ {channels.length > 0 && (!communityOrigin || !navigation) && (
+
+ Channel navigation is unavailable here.
+
+ )}
+
+ );
+}
diff --git a/src/bundled/profiles/ProfileInstances.test.tsx b/src/bundled/profiles/ProfileInstances.test.tsx
new file mode 100644
index 000000000..3d2d8fe3f
--- /dev/null
+++ b/src/bundled/profiles/ProfileInstances.test.tsx
@@ -0,0 +1,180 @@
+// @vitest-environment jsdom
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+} from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+import type {
+ AgentControl,
+ AgentControlState,
+} from "../../features/agents/control";
+import type { Navigation } from "../../features/navigation/controller";
+import { ProfileInstances } from "./ProfileInstances";
+
+const person = "a".repeat(64);
+const viewer = "b".repeat(64);
+const instance = (id: string, pubkey: string, relayUrl: string) => ({
+ id,
+ pubkey,
+ relayUrl,
+ name: id,
+ status: "stopped" as const,
+});
+function fixture() {
+ let state: AgentControlState = {
+ status: "idle",
+ data: null,
+ busy: false,
+ error: null,
+ };
+ const listeners = new Set<() => void>();
+ const refresh = vi.fn(async () => {});
+ const control = {
+ snapshot: () => state,
+ subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ refresh,
+ } as unknown as AgentControl;
+ const open = vi.fn(async () => ({ status: "opened" as const }));
+ const navigation = { open } as unknown as Navigation;
+ const update = (next: AgentControlState) =>
+ act(() => {
+ state = next;
+ for (const listener of listeners) listener();
+ });
+ return { control, navigation, open, update, refresh };
+}
+afterEach(cleanup);
+
+it("uses native exact identity and community, never library display links", () => {
+ const f = fixture();
+ render(
+ ,
+ );
+ expect(f.refresh).toHaveBeenCalledOnce();
+ expect(screen.getByRole("status").textContent).toContain(
+ "Loading managed agents",
+ );
+ f.update({
+ status: "ready",
+ data: {
+ agents: [
+ instance("matched", person, "wss://relay.example.test"),
+ instance("wrong-key", viewer, "wss://relay.example.test"),
+ instance("wrong-relay", person, "wss://other.example.test"),
+ ] as NonNullable["agents"],
+ runtimeAvailable: true,
+ },
+ busy: false,
+ error: null,
+ });
+ expect(screen.getByText("matched")).toBeTruthy();
+ expect(screen.queryByText(/wrong-key|wrong-relay|stopped/)).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: "View in Agents" }));
+ expect(f.open).toHaveBeenCalledWith({
+ version: 1,
+ kind: "page",
+ pluginId: "buzz.agents",
+ pageId: "agents",
+ scope: { viewer, communityOrigin: "https://relay.example.test" },
+ });
+ f.update({ status: "unavailable", data: null, busy: false, error: null });
+ expect(
+ screen.queryByRole("region", { name: "Linked agent instances" }),
+ ).toBeNull();
+});
+
+it("omits instances without a valid community", () => {
+ const f = fixture();
+ f.update({ status: "error", data: null, busy: false, error: "failed" });
+ render(
+ ,
+ );
+ expect(
+ screen.queryByRole("region", { name: "Linked agent instances" }),
+ ).toBeNull();
+ expect(f.refresh).not.toHaveBeenCalled();
+ expect(f.open).not.toHaveBeenCalled();
+});
+
+it("retries failed native discovery in a valid community without presenting stale instances", () => {
+ const f = fixture();
+ f.update({ status: "error", data: null, busy: false, error: "failed" });
+ render(
+ ,
+ );
+ expect(screen.getByRole("alert")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Retry agents" }));
+ expect(f.refresh).toHaveBeenCalledOnce();
+ expect(f.open).not.toHaveBeenCalled();
+});
+
+it("does not refresh or display instances for a human without a native match", () => {
+ const f = fixture();
+ const { rerender } = render(
+ ,
+ );
+ expect(f.refresh).not.toHaveBeenCalled();
+ f.update({
+ status: "ready",
+ data: {
+ agents: [
+ instance("someone-else", viewer, "wss://relay.example.test"),
+ ] as NonNullable["agents"],
+ runtimeAvailable: true,
+ },
+ busy: false,
+ error: null,
+ });
+ rerender(
+ ,
+ );
+ expect(
+ screen.queryByRole("region", { name: "Linked agent instances" }),
+ ).toBeNull();
+});
diff --git a/src/bundled/profiles/ProfileInstances.tsx b/src/bundled/profiles/ProfileInstances.tsx
new file mode 100644
index 000000000..e478e3328
--- /dev/null
+++ b/src/bundled/profiles/ProfileInstances.tsx
@@ -0,0 +1,85 @@
+import { useEffect, useSyncExternalStore } from "react";
+import { sameCommunityAgents } from "../../features/agents/choices";
+import type { AgentControl } from "../../features/agents/control";
+import type { Navigation } from "../../features/navigation/controller";
+import { Button } from "../../shared/design-system/ui/Button";
+
+/** Native-managed identities only. The old library's definition links are not authority. */
+export function ProfileInstances({
+ control,
+ navigation,
+ pubkey,
+ scope,
+ communityOrigin,
+ viewer,
+ knownAgent,
+}: {
+ control: AgentControl;
+ navigation: Navigation | undefined;
+ pubkey: string;
+ scope: string | undefined;
+ communityOrigin: string | undefined;
+ viewer: string | undefined;
+ knownAgent: boolean;
+}) {
+ const state = useSyncExternalStore(
+ control.subscribe,
+ control.snapshot,
+ control.snapshot,
+ );
+ useEffect(() => {
+ if (communityOrigin && knownAgent && state.status === "idle")
+ void control.refresh();
+ }, [control, communityOrigin, knownAgent, state.status]);
+ if (!communityOrigin || state.status === "unavailable") return null;
+ const instances =
+ scope && state.status === "ready"
+ ? sameCommunityAgents(state.data?.agents ?? [], scope).filter(
+ (agent) => agent.pubkey === pubkey,
+ )
+ : [];
+ if (!knownAgent && !instances.length) return null;
+ return (
+
+ Linked agent instances
+ {state.status === "loading" || state.status === "idle" ? (
+ Loading managed agents…
+ ) : state.status === "error" ? (
+
+
Could not refresh managed agents.
+
void control.refresh()}>
+ Retry agents
+
+
+ ) : !instances.length ? (
+ No managed instance for this identity in this community.
+ ) : (
+
+ {instances.map((agent) => (
+ {agent.name}
+ ))}
+
+ )}
+ {!!instances.length && navigation && viewer && (
+
+ void navigation.open({
+ version: 1,
+ kind: "page",
+ pluginId: "buzz.agents",
+ pageId: "agents",
+ scope: { viewer, communityOrigin },
+ })
+ }
+ >
+ View in Agents
+
+ )}
+
+ );
+}
diff --git a/src/bundled/profiles/ProfilePanel.test.tsx b/src/bundled/profiles/ProfilePanel.test.tsx
index 5220c5dab..85070d2a3 100644
--- a/src/bundled/profiles/ProfilePanel.test.tsx
+++ b/src/bundled/profiles/ProfilePanel.test.tsx
@@ -88,7 +88,10 @@ it("recovers provider-owned names in the same live session and releases demand w
viewer: person.pubkey,
relayAuthor: relayKey.pubkey,
scope: "wss://relay.example.test",
- query: async () => [publicProfile],
+ query: async (filters) =>
+ filters.some((filter) => filter.kinds?.includes(0))
+ ? [publicProfile]
+ : [],
media: () => undefined,
readAgentLibrary: read,
subscribe(callbacks) {
@@ -129,6 +132,10 @@ it("recovers provider-owned names in the same live session and releases demand w
live.established();
};
try {
+ owner.session.channels.ensureList();
+ await waitFor(() =>
+ expect(owner.session.channels.list().status).toBe("ready"),
+ );
render(
Unsupported profile.
;
@@ -36,6 +48,10 @@ export function ProfilePanel({
session={connection.session}
pubkey={pubkey}
context={context}
+ navigation={navigation}
+ control={control}
+ scope={connection.scope}
+ viewer={connection.viewer}
/>
);
}
@@ -43,10 +59,18 @@ function ProfileDetails({
session,
pubkey,
context,
+ navigation,
+ control,
+ scope,
+ viewer,
}: {
session: RelaySession;
pubkey: string;
context: PanelProps["context"];
+ navigation: Navigation | undefined;
+ control: AgentControl | undefined;
+ scope: string | undefined;
+ viewer: string | undefined;
}) {
const selection = useMemo(
() => selectProfiles(session.profiles, [pubkey]),
@@ -63,6 +87,7 @@ function ProfileDetails({
);
const [attempt, retry] = useState(0);
const [copyStatus, setCopyStatus] = useState("");
+ const [tab, setTab] = useState<"info" | "channels">("info");
const region = useRef(null);
useEffect(() => {
region.current?.focus();
@@ -85,6 +110,14 @@ function ProfileDetails({
};
}, [session, pubkey, attempt]);
const agentPubkeys = useKnownAgentPubkeys(session, profiles);
+ let communityOrigin: string | undefined;
+ if (scope && viewer && scope.endsWith(`:${viewer}`)) {
+ try {
+ communityOrigin = relayOrigin(scope.slice(0, -(viewer.length + 1)));
+ } catch {
+ // This session has no usable navigation scope.
+ }
+ }
const npub = profileTarget(pubkey)?.slice(6) ?? pubkey;
const identityName = useIdentityNames(session.names);
const name = identityName(pubkey, profile?.name ?? "Unknown profile");
@@ -114,62 +147,111 @@ function ProfileDetails({
{name}
-
- {profile?.about && {profile.about}
}
- {context?.canOpen(activity) && (
-
-
context.open(activity)}>
- View activity
-
-
- Owner-only agent telemetry in this channel, if published.
-
-
- )}
-
-
-
Public key
- {
- setCopyStatus("");
- void Promise.resolve()
- .then(() => navigator.clipboard.writeText(npub))
- .then(
- () => setCopyStatus("Public key copied."),
- () =>
- setCopyStatus(
- "Could not copy. Select the public key above to copy it.",
- ),
- );
- }}
- >
-
- Copy
-
-
-
{npub}
-
- {copyStatus}
-
-
- {!profile &&
- (status === "loading" ? (
- Loading profile…
- ) : (
- <>
-
- {status === "error"
- ? "Could not load this profile."
- : "No profile metadata is available in this community."}
-
- retry((value) => value + 1)}>
- Retry profile
-
- >
- ))}
+ (
+
+ {selected === "info" ? (
+ <>
+
+ {profile?.about && (
+
{profile.about}
+ )}
+ {context?.canOpen(activity) && (
+
+
context.open(activity)}
+ >
+ View activity
+
+
+ Owner-only agent telemetry in this channel, if published.
+
+
+ )}
+ {control && (
+
+ )}
+
+
+
Public key
+ {
+ setCopyStatus("");
+ void Promise.resolve()
+ .then(() => navigator.clipboard.writeText(npub))
+ .then(
+ () => setCopyStatus("Public key copied."),
+ () =>
+ setCopyStatus(
+ "Could not copy. Select the public key above to copy it.",
+ ),
+ );
+ }}
+ >
+
+ Copy
+
+
+
{npub}
+
+ {copyStatus}
+
+
+ {!profile &&
+ (status === "loading" ? (
+
Loading profile…
+ ) : (
+ <>
+
+ {status === "error"
+ ? "Could not load this profile."
+ : "No profile metadata is available in this community."}
+
+
retry((value) => value + 1)}
+ >
+ Retry profile
+
+ >
+ ))}
+ >
+ ) : (
+
+ )}
+
+ )}
+ />
);
}
diff --git a/src/bundled/profiles/Profiles.module.css b/src/bundled/profiles/Profiles.module.css
index ee3bd1155..8b489b775 100644
--- a/src/bundled/profiles/Profiles.module.css
+++ b/src/bundled/profiles/Profiles.module.css
@@ -66,3 +66,45 @@
.feedback:empty {
display: none;
}
+
+.tabContent {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+ padding-top: var(--space-4);
+ min-width: 0;
+}
+
+.channelList {
+ overflow: hidden;
+ border: 1px solid var(--border-standard);
+ border-radius: var(--radius-card);
+}
+.channelNote {
+ padding: var(--space-4);
+}
+.channelRow {
+ border-top: 1px solid var(--border-standard);
+}
+[data-profile-channel-link] {
+ display: flex;
+ width: 100%;
+ min-height: 3.5rem;
+ align-items: center;
+ justify-content: space-between;
+ text-align: left;
+}
+/* biome-ignore lint/correctness/noUnknownPseudoClass: CSS Modules global selector targets the shared button label. */
+[data-profile-channel-link] :global(.buzz-button-label) {
+ display: flex;
+ width: 100%;
+ min-width: 0;
+ align-items: center;
+ justify-content: space-between;
+}
+/* biome-ignore lint/correctness/noUnknownPseudoClass: CSS Modules global selector targets the shared button label. */
+[data-profile-channel-link] :global(.buzz-button-label) span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
diff --git a/src/bundled/profiles/index.tsx b/src/bundled/profiles/index.tsx
index 284746147..fead4e290 100644
--- a/src/bundled/profiles/index.tsx
+++ b/src/bundled/profiles/index.tsx
@@ -2,12 +2,19 @@ import type { PluginModule } from "../../plugins/api";
import { profileKey } from "../../features/profiles/target";
import { ProfilePanel } from "./ProfilePanel";
-export const inject = ["panels", "relay"];
+export const inject = ["panels", "relay", "navigation", "agentControl"];
export const apply: PluginModule["apply"] = (ctx) => {
ctx.panels.register({
id: "profile",
title: "Profile",
matches: (target) => !!profileKey(target),
- component: (props) => ,
+ component: (props) => (
+
+ ),
});
};
diff --git a/tests/browser/profile-channel-focus.spec.mjs b/tests/browser/profile-channel-focus.spec.mjs
new file mode 100644
index 000000000..ac9c37620
--- /dev/null
+++ b/tests/browser/profile-channel-focus.spec.mjs
@@ -0,0 +1,100 @@
+import { test, expect } from "./source-fixture.mjs";
+
+// Read actual painted pixels at the shared 2px outline (2px outward offset).
+// CSS declarations alone cannot tell us whether the list clips the ring.
+async function expectPaintedRing(page, list, row) {
+ await expect(row).toBeFocused();
+ await expect(row).toHaveCSS("outline-width", "2px");
+ expect(
+ await row.evaluate((element) => element.matches(":focus-visible")),
+ ).toBe(true);
+ const bounds = await list.boundingBox();
+ const image = await page.screenshot({
+ clip: {
+ x: Math.floor(bounds.x),
+ y: Math.floor(bounds.y),
+ width: Math.ceil(bounds.x + bounds.width) - Math.floor(bounds.x),
+ height: Math.ceil(bounds.y + bounds.height) - Math.floor(bounds.y),
+ },
+ scale: "css",
+ });
+ const paint = await row.evaluate(
+ async (element, { png, clip }) => {
+ const image = new Image();
+ image.src = `data:image/png;base64,${png}`;
+ await image.decode();
+ const canvas = document.createElement("canvas");
+ canvas.width = image.width;
+ canvas.height = image.height;
+ const context = canvas.getContext("2d");
+ context.drawImage(image, 0, 0);
+ const rect = element.getBoundingClientRect();
+ const color = getComputedStyle(element)
+ .outlineColor.match(/\d+/g)
+ .map(Number);
+ const sample = (x, y) => {
+ const pixel = context.getImageData(
+ Math.floor(x - clip.x),
+ Math.floor(y - clip.y),
+ 1,
+ 1,
+ ).data;
+ return (
+ pixel[3] === 255 &&
+ color.every((value, index) => Math.abs(value - pixel[index]) < 30)
+ );
+ };
+ return {
+ top: sample(rect.left + rect.width / 2, rect.top - 3),
+ bottom: sample(rect.left + rect.width / 2, rect.bottom + 2),
+ left: sample(rect.left - 3, rect.top + rect.height / 2),
+ right: sample(rect.right + 2, rect.top + rect.height / 2),
+ };
+ },
+ {
+ png: image.toString("base64"),
+ clip: { x: Math.floor(bounds.x), y: Math.floor(bounds.y) },
+ },
+ );
+ expect(paint).toEqual({ top: true, bottom: true, left: true, right: true });
+}
+
+test("profile channel focus ring paints on one row and both list boundaries", async ({
+ page,
+}) => {
+ await page.goto("/tests/fixtures/profiles.html");
+ const panel = page.getByRole("complementary", {
+ name: "Profile",
+ exact: true,
+ });
+ const channels = panel.getByRole("region", { name: "Channels" });
+ await page
+ .getByRole("button", { name: "View thread: 1 reply", exact: true })
+ .click();
+ await page.getByRole("button", { name: "View Pinky profile" }).click();
+ await panel.getByRole("tab", { name: "Channels" }).click();
+ const single = channels.locator("ul");
+ await expect(single.locator("li")).toHaveCount(1);
+ // The source fixture has no host keyboard-modality listener; emulate its
+ // state after a real keyboard input while retaining browser :focus-visible.
+ await page.keyboard.press("Tab");
+ await page
+ .locator("html")
+ .evaluate((html) => html.setAttribute("data-keyboard-navigation", ""));
+ await single.getByRole("button", { name: "#One" }).focus();
+ await expectPaintedRing(page, single, single.getByRole("button"));
+
+ await panel.getByRole("button", { name: "Close channel panel" }).click();
+ await page.getByRole("button", { name: "View Viewer profile" }).click();
+ await panel.getByRole("tab", { name: "Channels" }).click();
+ const multiple = channels.locator("ul");
+ await expect(multiple.locator("li")).toHaveCount(2);
+ for (const row of [
+ multiple.getByRole("button").first(),
+ multiple.getByRole("button").last(),
+ ]) {
+ await page.keyboard.press("Tab");
+ await row.focus();
+ await expectPaintedRing(page, multiple, row);
+ }
+});
diff --git a/tests/browser/profiles.spec.mjs b/tests/browser/profiles.spec.mjs
index d3ac0883d..55fdd941a 100644
--- a/tests/browser/profiles.spec.mjs
+++ b/tests/browser/profiles.spec.mjs
@@ -139,6 +139,14 @@ test("profile plumbing: exact avatar/mention targets, thread enrichment, lifecyc
panel.getByRole("img", { name: "Pinky avatar" }),
).toHaveAttribute("data-size", "fill");
await expect(panel.getByText("Agent profile", { exact: true })).toBeVisible();
+ await panel.getByRole("tab", { name: "Channels" }).click();
+ await expect(panel.getByRole("region", { name: "Channels" })).toContainText(
+ "#One",
+ );
+ await panel.getByRole("tab", { name: "Info" }).click();
+ await expect(
+ panel.getByRole("region", { name: "Linked agent instances" }),
+ ).toHaveCount(0);
await panel.getByRole("button", { name: "Close channel panel" }).click();
await expect(
page.getByRole("button", { name: "View thread: 1 reply", exact: true }),
diff --git a/tests/fixtures/profiles.tsx b/tests/fixtures/profiles.tsx
index cfd0cb51c..494b99c56 100644
--- a/tests/fixtures/profiles.tsx
+++ b/tests/fixtures/profiles.tsx
@@ -14,6 +14,9 @@ import { PagesService } from "../../src/features/pages/service";
import { TemplateProvidersService } from "../../src/features/channel-templates/provider";
import { ChannelsPage } from "../../src/bundled/channels/ChannelsPage";
import { createRelaySession } from "../../src/features/relay/session";
+import { createAgentControl } from "../../src/features/agents/control";
+import { createNavigationController } from "../../src/features/navigation/controller";
+import { createMemoryHistory } from "../../src/features/navigation/history";
import type {
RelayData,
RelaySnapshot,
@@ -22,9 +25,9 @@ import {
bounds,
keypair,
message,
- metadata,
profile,
roster,
+ signed,
summary,
} from "../../src/features/relay/testing";
import { profileTarget } from "../../src/features/profiles/target";
@@ -43,7 +46,9 @@ const reply = message(viewer, "one", "Thread @Pinky", 12, [
["p", pinky.pubkey],
]);
const picture = "https://images.test/avatar.png";
+const pinkyPicture = "https://images.test/pinky-animated.gif";
const pictureFixture = "/tests/fixtures/design-system/assets/avatar.png";
+const pinkyPictureFixture = "/tests/fixtures/design-system/assets/avatar.png";
const report = {
profileReads: [] as string[][],
media: [] as [string, "small" | undefined][],
@@ -57,7 +62,7 @@ const data = [
name: "Pinky",
about: "Agent profile",
is_agent: true,
- picture,
+ picture: pinkyPicture,
}),
];
function session() {
@@ -66,7 +71,11 @@ function session() {
relayAuthor: authority.pubkey,
media: (url, size) => {
report.media.push([url, size]);
- return url === picture ? pictureFixture : url;
+ return url === picture
+ ? pictureFixture
+ : url === pinkyPicture
+ ? pinkyPictureFixture
+ : url;
},
async query(filters) {
return filters.flatMap((filter) => {
@@ -77,8 +86,24 @@ function session() {
];
if (filter.kinds?.includes(39000))
return [
- metadata(authority, "one", "One"),
- metadata(authority, "two", "Two"),
+ signed(authority, {
+ kind: 39000,
+ content: JSON.stringify({ name: "One" }),
+ tags: [
+ ["d", "one"],
+ ["name", "One"],
+ ["t", "stream"],
+ ],
+ }),
+ signed(authority, {
+ kind: 39000,
+ content: JSON.stringify({ name: "Two" }),
+ tags: [
+ ["d", "two"],
+ ["name", "Two"],
+ ["t", "stream"],
+ ],
+ }),
];
if (filter.kinds?.includes(0)) {
report.profileReads.push([...(filter.authors ?? [])]);
@@ -124,7 +149,7 @@ let owner = session();
let snapshot: RelaySnapshot = {
status: "ready",
generation: 1,
- scope: "fixture:viewer",
+ scope: `https://relay.example.test:${viewer.pubkey}`,
viewer: viewer.pubkey,
session: owner.session,
};
@@ -143,6 +168,12 @@ const relay: RelayData = {
};
const context = new Context();
context.provide("relay", relay);
+const navigationHost = createNavigationController(createMemoryHistory());
+context.provide("navigation", navigationHost.navigation);
+context.effect(() => () => navigationHost.dispose());
+const agentControl = createAgentControl(null);
+context.provide("agentControl", agentControl);
+context.effect(() => () => agentControl.dispose());
const contexts: PanelContext[] = [];
function ContextProbe({ context }: PanelProps) {
useLayoutEffect(() => {