feat(desktop): show agent owners in messages (#2080)

Signed-off-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n <03271c724b51c287eb37fc1633105ae90417e975e63c3a6b4c339c81b155ba3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Bradley Axen
2026-07-18 18:36:54 +00:00
committed by GitHub
co-authored by npub1qvn3cujt28pg06ehlstrxyz6ayzp06t4uc7r566vxwwgrv24hglq9zju0n
parent b18a79476b
commit 463db6de3e
19 changed files with 264 additions and 4 deletions
@@ -132,6 +132,7 @@ export const ChannelPane = React.memo(function ChannelPane({
unfollowThreadById,
personaLookup,
profiles,
ownerProfiles,
openThreadHeadId,
shouldShowThreadSkeleton,
openAgentSessionChannelId,
@@ -605,6 +606,7 @@ export const ChannelPane = React.memo(function ChannelPane({
isMessageUnreadById={isMessageUnreadById}
personaLookup={personaLookup}
profiles={profiles}
ownerProfiles={ownerProfiles}
unfollowThreadById={unfollowThreadById}
emptyDescription={
activeChannel?.channelType === "forum"
@@ -134,6 +134,7 @@ export type ChannelPaneProps = {
) => void;
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
ownerProfiles?: UserProfileLookup;
openThreadHeadId: string | null;
shouldShowThreadSkeleton: boolean;
openAgentSessionChannelId: string | null;
@@ -5,6 +5,7 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
import { useMessageEventProfilePubkeys } from "@/features/channels/useMessageEventProfilePubkeys";
import { useMessageOwnerProfiles } from "@/features/channels/useMessageOwnerProfiles";
import { useThreadTargetSync } from "@/features/channels/useThreadTargetSync";
import {
useChannelMembersQuery,
@@ -350,6 +351,7 @@ export function ChannelScreen({
profiles: messageProfilesQuery.data?.profiles,
relayAgents,
});
const messageOwnerProfiles = useMessageOwnerProfiles(messageProfiles);
// Agent set for ChannelPane's own consumers (DM huddle member resolution,
// the agents list): the community-scoped baseline shared by every surface,
// widened with channel-member roles and this screen's profile lookup.
@@ -393,6 +395,7 @@ export function ChannelScreen({
personaLookup,
respondToLookup,
relaySelfPubkey,
messageOwnerProfiles,
),
[
activeChannel,
@@ -400,6 +403,7 @@ export function ChannelScreen({
currentProfile?.avatarUrl,
currentPubkey,
messageProfiles,
messageOwnerProfiles,
personaLookup,
relaySelfPubkey,
respondToLookup,
@@ -437,6 +441,7 @@ export function ChannelScreen({
currentPubkey,
currentAvatarUrl: currentProfile?.avatarUrl ?? null,
profiles: messageProfiles,
ownerProfiles: messageOwnerProfiles,
members: channelMembers,
personaLookup,
respondToLookup,
@@ -934,6 +939,7 @@ export function ChannelScreen({
profilePanelView={profilePanelView}
personaLookup={personaLookup}
profiles={messageProfiles}
ownerProfiles={messageOwnerProfiles}
firstUnreadMessageId={firstUnreadMessageId}
unreadCount={unreadCount}
targetMessageId={mainTimelineTargetMessageId}
@@ -0,0 +1,22 @@
import * as React from "react";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
/** Fetches verified agent-owner profiles in one batch for message surfaces. */
export function useMessageOwnerProfiles(profiles: UserProfileLookup) {
const ownerPubkeys = React.useMemo(
() => [
...new Set(
Object.values(profiles)
.map((profile) => profile.ownerPubkey)
.filter((pubkey): pubkey is string => Boolean(pubkey)),
),
],
[profiles],
);
const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, {
enabled: ownerPubkeys.length > 0,
});
return ownerProfilesQuery.data?.profiles;
}
+3
View File
@@ -60,6 +60,9 @@ export type InboxTypeLabel = {
export type InboxReply = {
authorLabel: string;
authorPubkey: string;
isAgent?: boolean;
ownerLabel?: string | null;
ownerPubkey?: string | null;
avatarUrl: string | null;
content: string;
createdAt: number;
@@ -139,6 +139,9 @@ export function toInboxContextMessage(
id: message.id,
authorLabel: message.author,
authorPubkey,
isAgent: message.isAgent,
ownerLabel: message.ownerLabel,
ownerPubkey: message.ownerPubkey,
avatarUrl: message.avatarUrl ?? null,
content: message.body,
createdAt: message.createdAt,
@@ -169,6 +172,9 @@ export function toTimelineMessage(
return {
id: message.id,
author: message.authorLabel,
isAgent: message.isAgent,
ownerLabel: message.ownerLabel,
ownerPubkey: message.ownerPubkey,
avatarUrl: message.avatarUrl,
body: message.content,
createdAt: message.createdAt,
+16
View File
@@ -333,6 +333,20 @@ export function HomeView({
enabled: feedProfilePubkeys.length > 0,
});
const feedProfiles = feedProfilesQuery.data?.profiles;
const feedOwnerPubkeys = React.useMemo(
() => [
...new Set(
Object.values(feedProfiles ?? {})
.map((profile) => profile.ownerPubkey)
.filter((pubkey): pubkey is string => Boolean(pubkey)),
),
],
[feedProfiles],
);
const feedOwnerProfilesQuery = useUsersBatchQuery(feedOwnerPubkeys, {
enabled: feedOwnerPubkeys.length > 0,
});
const feedOwnerProfiles = feedOwnerProfilesQuery.data?.profiles;
// Agent set for the inbox list/detail bot badges: the community-scoped
// baseline widened with this surface's profile lookup.
const communityAgentPubkeys = useKnownAgentPubkeys();
@@ -477,6 +491,7 @@ export function HomeView({
undefined,
undefined,
relaySelfPubkey,
feedOwnerProfiles,
);
return timelineMessages.map((message) =>
@@ -491,6 +506,7 @@ export function HomeView({
channelMessages,
currentPubkey,
feedProfiles,
feedOwnerProfiles,
relaySelfPubkey,
selectedChannel,
selectedEventId,
@@ -7,6 +7,7 @@ import { formatTimeWithoutDayPeriod } from "@/features/messages/lib/dateFormatte
import type { TimelineMessage } from "@/features/messages/types";
import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey";
import { MessageActionBar } from "@/features/messages/ui/MessageActionBar";
import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
@@ -172,6 +173,12 @@ export function InboxMessageRow({
{message.authorLabel}
</span>
</UserProfilePopover>
{message.isAgent ? (
<MessageAgentOwner
ownerLabel={message.ownerLabel}
ownerPubkey={message.ownerPubkey}
/>
) : null}
<p className="shrink-0 text-xs font-normal tabular-nums text-muted-foreground/55">
{message.fullTimestampLabel}
</p>
@@ -14,6 +14,7 @@ import {
isBroadcastReply,
} from "@/features/messages/lib/threading";
import {
formatOwnerLabel,
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
@@ -193,6 +194,8 @@ export function formatTimelineMessages(
respondToLookup?: Map<string, RespondToMode>,
/** Active relay identity from NIP-11 `self`; absent or malformed fails closed to the signer. */
relaySelfPubkey?: string | null,
/** Profiles for verified agent owners, fetched in one batch by the surface. */
ownerProfiles?: UserProfileLookup,
): TimelineMessage[] {
const currentPubkeyLower = currentPubkey?.toLowerCase();
const roleByPubkey = new Map<string, string>();
@@ -423,6 +426,9 @@ export function formatTimelineMessages(
const thread = getThreadReference(event.tags);
const edit = editsByTargetId.get(event.id);
const role = roleByPubkey.get(authorPubkey.toLowerCase());
const authorProfile = profiles?.[authorPubkey.toLowerCase()];
const isAgent = role === "bot" || authorProfile?.isAgent === true;
const ownerPubkey = isAgent ? (authorProfile?.ownerPubkey ?? null) : null;
return {
id: event.id,
renderKey: event.localKey ?? event.id,
@@ -430,6 +436,11 @@ export function formatTimelineMessages(
pubkey: authorPubkey,
signerPubkey: normalizePubkey(event.pubkey),
author,
isAgent,
ownerPubkey,
ownerLabel: isAgent
? formatOwnerLabel(ownerPubkey, currentPubkey, ownerProfiles)
: null,
avatarUrl: getAuthorAvatarUrl({
authorPubkey,
currentPubkey,
+6
View File
@@ -25,6 +25,12 @@ export type TimelineMessage = {
*/
signerPubkey?: string;
author: string;
/** True when the displayed author is known to be an agent. */
isAgent?: boolean;
/** Verified owner pubkey for an agent author, when available. */
ownerPubkey?: string | null;
/** Viewer-relative owner label (for example, "you" or "baxen"). */
ownerLabel?: string | null;
avatarUrl?: string | null;
role?: string;
/** For bot messages, the display name of the persona this bot was created from. */
@@ -0,0 +1,50 @@
import { Bot } from "lucide-react";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
export function MessageAgentOwner({
ownerLabel,
ownerPubkey,
}: {
ownerLabel?: string | null;
ownerPubkey?: string | null;
}) {
return (
<span
className="inline-flex min-w-0 max-w-56 items-baseline gap-1 text-xs leading-4 text-muted-foreground/65"
data-testid="message-agent-owner"
>
<span className="sr-only">
{ownerLabel ? "Agent owned by" : "Agent; owner unavailable"}
</span>
{ownerPubkey && ownerLabel ? (
<>
<span
aria-hidden="true"
className="inline-flex shrink-0 items-baseline gap-1 leading-4"
>
<Bot className="relative -top-px h-3.5 w-3.5 self-center" />
<span>owned by</span>
</span>
<UserProfilePopover
pubkey={ownerPubkey}
triggerAriaLabel={ownerLabel}
triggerElement="span"
>
<span className="min-w-0 truncate rounded font-semibold text-foreground/85 hover:text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
{ownerLabel}
</span>
</UserProfilePopover>
</>
) : (
<span
aria-hidden="true"
className="inline-flex min-w-0 items-center gap-1"
>
<Bot className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">owner unavailable</span>
</span>
)}
</span>
);
}
@@ -40,6 +40,7 @@ import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { MessageActionBar } from "./MessageActionBar";
import { MessageAgentOwner } from "./MessageAgentOwner";
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
import { MessageTimestamp } from "./MessageTimestamp";
import { WaveMessageAttachment } from "./WaveMessageAttachment";
@@ -459,6 +460,12 @@ export const MessageRow = React.memo(
) : (
<MessageAuthorText as="h3">{message.author}</MessageAuthorText>
);
const agentOwnerNode = message.isAgent ? (
<MessageAgentOwner
ownerLabel={message.ownerLabel}
ownerPubkey={message.ownerPubkey}
/>
) : null;
const actionBarNode = (
<div
@@ -545,6 +552,7 @@ export const MessageRow = React.memo(
) : (
authorNode
)}
{agentOwnerNode}
{inlineMetadataNode}
{message.personaDisplayName &&
message.personaDisplayName !== message.author ? (
@@ -811,6 +819,9 @@ export const MessageRow = React.memo(
prev.message.pubkey === next.message.pubkey &&
prev.message.body === next.message.body &&
prev.message.author === next.message.author &&
prev.message.isAgent === next.message.isAgent &&
prev.message.ownerPubkey === next.message.ownerPubkey &&
prev.message.ownerLabel === next.message.ownerLabel &&
prev.message.avatarUrl === next.message.avatarUrl &&
prev.message.accent === next.message.accent &&
prev.message.time === next.message.time &&
@@ -74,6 +74,7 @@ type MessageTimelineProps = {
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
ownerProfiles?: UserProfileLookup;
followThreadById?: (rootId: string) => void;
isFollowingThreadById?: (rootId: string) => boolean;
isMessageUnreadById?: (messageId: string) => boolean;
@@ -170,6 +171,7 @@ const MessageTimelineBase = React.forwardRef<
messageFooters,
personaLookup,
profiles,
ownerProfiles,
onDelete,
onEdit,
onMarkUnread,
@@ -636,6 +638,7 @@ const MessageTimelineBase = React.forwardRef<
onAtBottomStateChange={handleVirtualizerAtBottomStateChange}
personaLookup={personaLookup}
profiles={profiles}
ownerProfiles={ownerProfiles}
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
@@ -9,7 +9,10 @@ import type {
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import {
formatOwnerLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { resolveUserLabel } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
@@ -25,6 +28,7 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { MessageAgentOwner } from "./MessageAgentOwner";
import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader";
import { MessageTimestamp } from "./MessageTimestamp";
@@ -631,6 +635,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
currentPubkey,
agentPubkeys,
profiles,
ownerProfiles,
personaLookup,
onToggleReaction,
}: {
@@ -639,6 +644,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
currentPubkey?: string;
agentPubkeys?: ReadonlySet<string>;
profiles?: UserProfileLookup;
ownerProfiles?: UserProfileLookup;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
onToggleReaction?: (
@@ -720,6 +726,33 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
payload.type === "member_joined" ||
payload.type === "members_added" ||
payload.type === "members_joined";
const displayedIdentityPubkey = isMembershipArrival
? payload.target
: payload.actor;
const displayedIdentityProfile = displayedIdentityPubkey
? profiles?.[normalizePubkey(displayedIdentityPubkey)]
: undefined;
const displayedTimelineIdentity = displayedIdentityPubkey
? sourceMessages.find(
(source) =>
source.pubkey &&
normalizePubkey(source.pubkey) ===
normalizePubkey(displayedIdentityPubkey),
)
: undefined;
const displayedIdentityIsAgent = Boolean(
displayedIdentityProfile?.isAgent ||
displayedTimelineIdentity?.isAgent ||
(displayedIdentityPubkey &&
agentPubkeys?.has(normalizePubkey(displayedIdentityPubkey))),
);
const displayedOwnerPubkey =
displayedIdentityProfile?.ownerPubkey ??
displayedTimelineIdentity?.ownerPubkey ??
null;
const displayedOwnerLabel =
displayedTimelineIdentity?.ownerLabel ??
formatOwnerLabel(displayedOwnerPubkey, currentPubkey, ownerProfiles);
const wouldAddReaction = (emoji: string) =>
!reactions.some(
@@ -750,6 +783,12 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
<MessageAuthorText as="div" className="text-foreground">
{description.title}
</MessageAuthorText>
{displayedIdentityIsAgent ? (
<MessageAgentOwner
ownerLabel={displayedOwnerLabel}
ownerPubkey={displayedOwnerPubkey}
/>
) : null}
<MessageTimestamp
createdAt={message.createdAt}
time={message.time}
@@ -96,6 +96,7 @@ type TimelineMessageListProps = {
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
ownerProfiles?: UserProfileLookup;
/** The message ID of the currently active find-in-channel match. */
searchActiveMessageId?: string | null;
/** Set of message IDs that match the current find-in-channel query. */
@@ -147,6 +148,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onSendVideoReviewComment,
onToggleReaction,
profiles,
ownerProfiles,
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
@@ -237,6 +239,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
footer={messageFooters?.[item.entry.message.id] ?? null}
onToggleReaction={onToggleReaction}
profiles={profiles}
ownerProfiles={ownerProfiles}
/>
);
case "system-group":
@@ -249,6 +252,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
)}
onToggleReaction={onToggleReaction}
profiles={profiles}
ownerProfiles={ownerProfiles}
/>
);
case "message":
@@ -306,6 +310,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
onReply,
onToggleReaction,
profiles,
ownerProfiles,
searchActiveMessageId,
searchMatchingMessageIds,
searchQuery,
@@ -782,6 +787,7 @@ function SystemRow({
footer,
onToggleReaction,
profiles,
ownerProfiles,
}: {
currentPubkey?: string;
entries?: MainTimelineEntry[];
@@ -789,6 +795,7 @@ function SystemRow({
footer: React.ReactNode;
onToggleReaction?: TimelineMessageListProps["onToggleReaction"];
profiles?: UserProfileLookup;
ownerProfiles?: UserProfileLookup;
}) {
const systemEntries = entries ?? (entry ? [entry] : []);
const firstEntry = systemEntries[0];
@@ -806,6 +813,7 @@ function SystemRow({
currentPubkey={currentPubkey}
onToggleReaction={onToggleReaction}
profiles={profiles}
ownerProfiles={ownerProfiles}
/>
{footer}
</div>
@@ -19,6 +19,7 @@ export function useIndependentThreadPanel(args: {
currentPubkey: string | undefined;
currentAvatarUrl: string | null;
profiles: UserProfileLookup | undefined;
ownerProfiles: UserProfileLookup | undefined;
members: ChannelMember[] | undefined;
personaLookup: Map<string, string>;
respondToLookup: Map<string, RespondToMode>;
@@ -48,6 +49,7 @@ export function useIndependentThreadPanel(args: {
args.personaLookup,
args.respondToLookup,
args.relaySelfPubkey,
args.ownerProfiles,
),
[
args.channelEvents,
@@ -59,6 +61,7 @@ export function useIndependentThreadPanel(args: {
args.currentPubkey,
args.currentAvatarUrl,
args.profiles,
args.ownerProfiles,
args.members,
args.personaLookup,
args.respondToLookup,
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { profileLookupsEqual } from "./identity.ts";
import { formatOwnerLabel, profileLookupsEqual } from "./identity.ts";
const OWNER_PUBKEY =
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const summary = (over = {}) => ({
displayName: "Ada",
@@ -12,6 +15,23 @@ const summary = (over = {}) => ({
...over,
});
test("formatOwnerLabel resolves a known owner's display name", () => {
assert.equal(
formatOwnerLabel(OWNER_PUBKEY, null, {
[OWNER_PUBKEY]: summary({ displayName: "baxen" }),
}),
"baxen",
);
});
test("formatOwnerLabel calls the viewer-owned agent's owner you", () => {
assert.equal(formatOwnerLabel(OWNER_PUBKEY, OWNER_PUBKEY, {}), "you");
});
test("formatOwnerLabel returns null when verified ownership is absent", () => {
assert.equal(formatOwnerLabel(null, OWNER_PUBKEY, {}), null);
});
test("profileLookupsEqual: same reference is equal", () => {
const a = { p1: summary() };
assert.equal(profileLookupsEqual(a, a), true);
@@ -55,6 +55,8 @@ type UserProfilePopoverProps = {
children: React.ReactNode;
pubkey: string;
triggerElement?: "div" | "span";
/** Accessible name for interactive trigger content that is visually hidden. */
triggerAriaLabel?: string;
/** Set false when the trigger is inside another interactive control. */
enableProfilePanel?: boolean;
/** When set to "bot", a BotIdenticon badge renders next to the display name. */
@@ -169,6 +171,7 @@ export function UserProfilePopover({
children,
pubkey,
triggerElement = "div",
triggerAriaLabel,
enableProfilePanel = true,
role,
botIdenticonValue,
@@ -550,6 +553,7 @@ export function UserProfilePopover({
<Popover onOpenChange={setOpen} open={open}>
<PopoverAnchor asChild>
<TriggerElement
aria-label={triggerAriaLabel}
role={canOpenProfilePanel ? "button" : undefined}
tabIndex={canOpenProfilePanel ? 0 : undefined}
onClick={handleTriggerClick}
+44 -2
View File
@@ -92,8 +92,50 @@ async function measureThreadSummaryGeometry(summaryRow: Locator) {
});
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
test.beforeEach(async ({ page }, testInfo) => {
const mock = testInfo.title.includes("agent owner label")
? {
searchProfiles: [
{
pubkey: TEST_IDENTITIES.alice.pubkey,
displayName: "alice",
ownerPubkey: TEST_IDENTITIES.bob.pubkey,
isAgent: true,
},
{
pubkey: TEST_IDENTITIES.bob.pubkey,
displayName: "bob",
},
],
}
: undefined;
await installMockBridge(page, mock);
});
test("agent owner label identifies the agent and owner", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
const aliceMessage = page
.getByTestId("message-row")
.filter({ hasText: "Hey team — checking in." });
const ownerTreatment = aliceMessage.getByTestId("message-agent-owner");
await expect(ownerTreatment.locator("svg")).toBeVisible();
await expect(
ownerTreatment.getByText("owned by", { exact: true }),
).toBeVisible();
await expect(ownerTreatment.locator(".font-semibold")).toHaveText("bob");
await expect(ownerTreatment.getByRole("button")).toHaveAccessibleName("bob");
await expect(ownerTreatment.locator(".sr-only")).toHaveText("Agent owned by");
const joinedRow = page
.getByTestId("system-message-row")
.filter({ hasText: "alice" })
.filter({ hasText: "joined the channel" });
await expect(joinedRow.getByTestId("message-agent-owner")).toContainText(
"owned bybob",
);
});
test("send a message and see it in timeline", async ({ page }) => {