From 0d59d77b120dcb90aac2f918e422c11c9fa5353b Mon Sep 17 00:00:00 2001 From: Fizz Date: Mon, 31 Aug 2026 09:10:30 -0400 Subject: [PATCH 1/3] feat: render agent avatars as squircles Signed-off-by: Fizz --- .../agents/ui/AddTeamToChannelDialog.tsx | 1 + .../agents/ui/AgentCreationPreview.tsx | 13 ++--- .../features/agents/ui/AgentIdentityCard.tsx | 1 + .../agents/ui/AgentRuntimeAvatarControl.tsx | 2 + .../CompactMessageSummary.tsx | 4 +- .../agents/ui/IdentityInitialsAvatar.tsx | 2 +- .../src/features/agents/ui/RespondToField.tsx | 1 + desktop/src/features/agents/ui/TeamDialog.tsx | 1 + .../features/agents/ui/TeamIdentityCard.tsx | 55 ++++++++----------- .../UserMessageBubble.tsx | 7 ++- .../channels/lib/dmParticipantDisplay.ts | 2 + .../ui/AddChannelBotPersonasSection.tsx | 1 + .../channels/ui/AddChannelBotTeamsSection.tsx | 1 + .../channels/ui/AddMemberSearchResultRow.tsx | 1 + .../channels/ui/AgentSessionThreadPanel.tsx | 1 + .../features/channels/ui/BotActivityBar.tsx | 2 + .../channels/ui/ChannelMemberAvatarStack.tsx | 1 + .../channels/ui/ChannelMemberInviteCard.tsx | 2 + .../channels/ui/ChannelScreenHeader.tsx | 21 ++++--- .../channels/ui/MembersSidebarMemberCard.tsx | 1 + .../channels/useActiveChannelHeader.ts | 2 + .../community-members/ui/AddMemberDialog.tsx | 1 + .../ui/CommunityMembersSettingsCard.tsx | 1 + .../src/features/forum/ui/ForumPostCard.tsx | 8 ++- .../features/forum/ui/ForumThreadPanel.tsx | 18 +++++- desktop/src/features/home/ui/FeedSection.tsx | 5 ++ .../src/features/home/ui/InboxListPane.tsx | 6 +- .../src/features/home/ui/InboxMessageRow.tsx | 9 ++- .../home/ui/ProjectInboxDetailPane.tsx | 3 + .../features/home/ui/RecentNotesSection.tsx | 1 + .../huddle/components/AddAgentDialog.tsx | 1 + .../huddle/components/ParticipantList.tsx | 8 ++- .../src/features/messages/lib/threadPanel.ts | 10 ++++ .../messages/ui/ComposerAddressControls.tsx | 1 + .../ui/DirectMessageIntroAvatarStack.tsx | 19 ++++--- .../messages/ui/MentionAutocomplete.tsx | 5 ++ .../src/features/messages/ui/MessageRow.tsx | 13 ++--- .../messages/ui/MessageThreadSummaryRow.tsx | 13 ++--- .../messages/ui/NewMessageResultRow.tsx | 1 + .../messages/ui/SystemMessageAvatars.tsx | 50 ++++++++++++----- .../features/messages/ui/SystemMessageRow.tsx | 18 ++++++ .../messages/ui/TypingIndicatorRow.tsx | 4 +- .../src/features/profile/ui/ProfileAvatar.tsx | 3 + .../profile/ui/ProfileAvatarWithStatus.tsx | 6 +- .../profile/ui/SelectedRecipientChip.tsx | 11 +++- .../profile/ui/UserProfilePanelSections.tsx | 2 + .../profile/ui/UserProfilePopover.tsx | 1 + .../projects/ui/IssueAssigneesRow.tsx | 20 ++++++- .../projects/ui/ProjectAuthorIdentity.tsx | 2 + .../src/features/projects/ui/ProjectCards.tsx | 6 +- .../projects/ui/ProjectEntityListRow.tsx | 11 +++- .../ui/ProjectIssueCommentTimeline.tsx | 5 ++ .../projects/ui/ProjectProfileIdentity.tsx | 1 + .../projects/ui/ProjectRepositoryPanel.tsx | 3 + .../ui/ProjectRepositoryUnavailableState.tsx | 1 + .../projects/ui/ProjectsActivityFeed.tsx | 7 ++- .../projects/ui/ProjectsAgentPromptPage.tsx | 2 + .../projects/ui/ProjectsOverviewRail.tsx | 6 +- .../projects/ui/PullRequestReviewersRow.tsx | 1 + .../features/pulse/ui/AgentActivityCard.tsx | 6 +- desktop/src/features/pulse/ui/NoteCard.tsx | 10 +++- desktop/src/features/pulse/ui/PulseView.tsx | 3 + .../features/reminders/ui/RemindersPanel.tsx | 6 ++ .../features/search/ui/SearchResultItem.tsx | 3 + .../src/features/search/ui/TopbarSearch.tsx | 15 +++-- .../src/features/sidebar/ui/AppSidebar.tsx | 1 + .../sidebar/ui/ChannelActivityPopover.tsx | 8 +++ .../features/sidebar/ui/MoreUnreadButton.tsx | 2 + .../features/sidebar/ui/SidebarSection.tsx | 2 + .../features/sidebar/useDmSidebarMetadata.ts | 4 ++ .../workflows/ui/WorkflowAuthorPicker.tsx | 1 + .../workflows/ui/WorkflowFormBuilder.tsx | 1 + .../workflows/ui/WorkflowMessagePicker.tsx | 1 + .../ui/WorkflowRichTriggerDescription.tsx | 3 + .../ui/WorkflowTriggerConditions.tsx | 4 ++ .../ui/useWorkflowAuthorPresentation.ts | 2 + .../ui/useWorkflowListAuthorPresentations.ts | 1 + desktop/src/shared/ui/UserAvatar.tsx | 12 +++- desktop/src/shared/ui/VideoPlayer.tsx | 11 ++-- .../activity/activity_page/inbox_row.dart | 1 + .../features/channels/add_members_sheet.dart | 1 + .../huddle_call_avatar.dart | 20 ++++++- .../huddle_participant_overlay.dart | 12 +++- .../channel_detail_page/message_bubble.dart | 1 + .../channels/channel_details_page.dart | 1 + .../channels/channel_management_provider.dart | 14 +++++ .../channels/channels_page/channel_tile.dart | 1 + .../channels/channels_page/sheets.dart | 2 + .../channels/compose_bar/suggestions.dart | 1 + .../lib/features/channels/members_sheet.dart | 14 ++++- .../lib/features/channels/reaction_row.dart | 9 ++- .../lib/features/channels/small_avatar.dart | 7 ++- .../channels/thread_detail_page/avatar.dart | 1 + .../lib/features/forum/forum_post_card.dart | 1 + .../lib/features/forum/forum_thread_page.dart | 1 + .../features/profile/user_profile_sheet.dart | 39 +++++++++---- .../features/pulse/agent_activity_card.dart | 1 + mobile/lib/features/pulse/note_card.dart | 1 + mobile/lib/features/search/search_page.dart | 2 +- mobile/lib/shared/profile/user_profile.dart | 2 + mobile/lib/shared/widgets/avatar_image.dart | 38 +++++++------ .../shared/widgets/avatar_image_test.dart | 15 ++++- 102 files changed, 523 insertions(+), 163 deletions(-) diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 99d3167d004..dcb8386497d 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -191,6 +191,7 @@ export function AddTeamToChannelDialog({ avatarUrl={persona.avatarUrl} className="h-5 w-5 text-2xs" label={persona.displayName} + shape="squircle" /> {persona.displayName} diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index e7a68211dcd..2856f56e789 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -3,7 +3,6 @@ import Picker from "@emoji-mart/react"; import * as React from "react"; import { Link2, Pencil, Plus, UploadCloud } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; - import { MaskedAvatarBadgeFrame } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { @@ -44,7 +43,6 @@ import { type EmojiMartEmoji, isAvatarFileDrag, } from "./AgentCreationPreview.utils"; - export function AgentCreationPreview({ assetLabel = "avatar", avatarUrl, @@ -127,12 +125,10 @@ export function AgentCreationPreview({ }, processImage, }); - useEmojiMartStyles( emojiPickerContainerRef, isAvatarMenuOpen && activeTab === "emoji", ); - // Emoji Mart mounts its search input inside a shadow root. Wait for it // before focusing so the surrounding Radix popover cannot win the race. React.useEffect(() => { @@ -728,7 +724,7 @@ export function AgentCreationPreview({ ? isCompact ? "rounded-2xl" : "rounded-[2rem]" - : "rounded-full", + : "rounded-[30%]", )} role="img" style={{ backgroundColor: emojiAvatarPreview.color }} @@ -754,6 +750,7 @@ export function AgentCreationPreview({ ) : ( ) : ( ) : ( { event.preventDefault(); @@ -119,6 +119,7 @@ export function CompactMessageSummary({ avatarUrl={avatarUrl} className="size-full text-xs" displayName={displayName} + shape="squircle" size="sm" testId="transcript-agent-sent-avatar" /> @@ -131,6 +132,7 @@ export function CompactMessageSummary({ isCompactPreview ? "text-3xs" : "text-xs", )} displayName={displayName} + shape="squircle" size="sm" testId="transcript-agent-sent-avatar" /> diff --git a/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx index 0381ebc03ba..7b09ca5caf6 100644 --- a/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx +++ b/desktop/src/features/agents/ui/IdentityInitialsAvatar.tsx @@ -39,7 +39,7 @@ export function IdentityInitialsAvatar({ return (
diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx index 695504429dc..1796adaa6b7 100644 --- a/desktop/src/features/agents/ui/TeamDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDialog.tsx @@ -295,6 +295,7 @@ export function TeamDialog({ avatarUrl={persona.avatarUrl} className="h-6 w-6 text-2xs" label={persona.displayName} + shape="squircle" /> {persona.displayName} {persona.isBuiltIn ? ( diff --git a/desktop/src/features/agents/ui/TeamIdentityCard.tsx b/desktop/src/features/agents/ui/TeamIdentityCard.tsx index 8e4b02c9e8d..45931d143aa 100644 --- a/desktop/src/features/agents/ui/TeamIdentityCard.tsx +++ b/desktop/src/features/agents/ui/TeamIdentityCard.tsx @@ -120,7 +120,7 @@ function TeamAvatarRow({ if (visiblePersonas.length === 0 && overflowCount === 0) { return (
-
+
@@ -135,19 +135,14 @@ function TeamAvatarRow({ role="img" > {visiblePersonas.map((persona, index) => ( - + ))} {overflowCount > 0 ? (
0 ? "-ml-5" : ""} style={{ zIndex: stackItemCount }} > - + +{overflowCount}
@@ -159,44 +154,40 @@ function TeamAvatarRow({ function TeamAvatarItem({ index, - isFollowedByAnother, persona, }: { index: number; - isFollowedByAnother: boolean; persona: AgentPersona; }) { const avatarUrl = persona.avatarUrl?.trim() ?? null; return (
0 ? "-ml-5" : ""}`} + className={`relative h-14 w-14 before:absolute before:-inset-0.5 before:rounded-[calc(30%+2px)] before:bg-card before:content-[''] ${index > 0 ? "-ml-5" : ""}`} data-team-member-avatar="avatar" style={{ zIndex: index + 1, - ...(isFollowedByAnother && { - mask: "radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)", - WebkitMask: - "radial-gradient(circle 32px at calc(100% + 8px) 50%, transparent 99%, #fff 100%)", - }), }} > - {avatarUrl ? ( - - ) : ( - - )} +
+ {avatarUrl ? ( + + ) : ( + + )} +
); } diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index 181e4febf5c..470dd1b894e 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -101,7 +101,10 @@ export function UserMessageBubble({ {isCompactPreview ? null : item.authorPubkey && openProfilePanel ? ( @@ -123,6 +127,7 @@ export function UserMessageBubble({ avatarUrl={authorProfile?.avatarUrl ?? null} className="order-last ml-2 mt-1 size-7 shrink-0 text-xs" displayName={authorLabel} + shape={authorProfile?.isAgent ? "squircle" : "circle"} size="sm" /> )} diff --git a/desktop/src/features/channels/lib/dmParticipantDisplay.ts b/desktop/src/features/channels/lib/dmParticipantDisplay.ts index 25f8bd89931..2dd9cfda23b 100644 --- a/desktop/src/features/channels/lib/dmParticipantDisplay.ts +++ b/desktop/src/features/channels/lib/dmParticipantDisplay.ts @@ -14,6 +14,7 @@ export type DmParticipantDisplay = { export type DirectMessageIntroParticipant = { avatarUrl: string | null; displayName: string; + isAgent?: boolean; pubkey: string; }; @@ -95,6 +96,7 @@ export function buildDirectMessageIntro({ profiles, pubkey: participant.pubkey, }), + ...(profile?.isAgent === true ? { isAgent: true } : {}), pubkey: participant.pubkey, }; }); diff --git a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx index e27953d6460..d9ad164bcdd 100644 --- a/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotPersonasSection.tsx @@ -38,6 +38,7 @@ function AgentRow({ className="h-9 w-9 shrink-0 text-xs" iconClassName="h-5 w-5" label={persona.displayName} + shape="squircle" /> {persona.displayName} diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 326866cf63e..fa4c3fe842c 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -161,6 +161,7 @@ export function AddChannelBotTeamsSection({ avatarUrl={persona.avatarUrl} className="h-4 w-4 text-3xs bg-secondary-foreground/20 text-secondary-foreground" label={persona.displayName} + shape="squircle" testId="team-tooltip-persona-avatar" /> diff --git a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx index 4990168b2f6..b059debfc6a 100644 --- a/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx +++ b/desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx @@ -46,6 +46,7 @@ export function AddMemberSearchResultRow({ avatarUrl={user.avatarUrl} className="pointer-events-none relative z-10 h-8 w-8 text-xs shadow-none" displayName={formatAddCandidateName(user)} + shape={user.isAgent ? "squircle" : "circle"} size="sm" />
diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index c1933f14bb7..603d15dfba0 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -422,6 +422,7 @@ export function AgentSessionThreadPanel({ avatarUrl={agentProfile?.avatarUrl ?? null} className="size-9" label={agentLabel} + shape="squircle" testId="agent-session-agent-avatar" />
diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index cfa84f02e3c..d43b621c24b 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -191,6 +191,7 @@ export function BotActivityComposerAction({ isInline ? "!h-4.5 !w-4.5 text-3xs" : "shrink-0", )} displayName={agent.name} + shape="squircle" fallbackDelayMs={isInline ? 0 : undefined} key={agent.pubkey} size="xs" @@ -259,6 +260,7 @@ export function BotActivityComposerAction({ avatarUrl={agentAvatarUrl(agent)} className="shrink-0" displayName={agent.name} + shape="squircle" size="sm" /> {agent.name} diff --git a/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx b/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx index a9662bbf047..34c226a3973 100644 --- a/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberAvatarStack.tsx @@ -56,6 +56,7 @@ export function ChannelMemberAvatarStack({ className="!h-8 !w-8 border-2 border-background text-2xs" displayName={label} fallbackDelayMs={0} + shape={profile?.isAgent ? "squircle" : "circle"} /> ); diff --git a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx index dd370e8c615..90fb5bfaa9b 100644 --- a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx @@ -206,6 +206,7 @@ export function ChannelMemberInviteCard({ @@ -297,6 +298,7 @@ export function ChannelMemberInviteCard({

diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index 44e4d891dc1..3ccabdf7536 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -153,6 +153,7 @@ export function ChannelScreenHeader({ ) : activeDmParticipant ? ( @@ -163,6 +164,7 @@ export function ChannelScreenHeader({ geometry={DM_HEADER_AVATAR_STATUS_GEOMETRY} iconClassName="h-4 w-4" label={activeChannelTitle} + shape={activeDmParticipant.isAgent ? "squircle" : "circle"} size={DM_HEADER_AVATAR_SIZE} status={activeDmPresenceStatus ?? "offline"} statusTestId="chat-presence-badge" @@ -177,6 +179,7 @@ export function ChannelScreenHeader({ geometry={DM_HEADER_AVATAR_STATUS_GEOMETRY} iconClassName="h-4 w-4" label={activeChannelTitle} + shape="circle" size={DM_HEADER_AVATAR_SIZE} status={activeDmPresenceStatus ?? "offline"} statusTestId="chat-presence-badge" @@ -222,23 +225,23 @@ function DmHeaderParticipantStack({ pubkey={participant.pubkey} triggerAriaLabel={`Open profile for ${participant.displayName}`} triggerElement="span" + role={participant.isAgent ? "bot" : undefined} > 0 ? "-ml-2" : ""} data-testid="chat-header-dm-avatar-stack-participant" - style={{ - zIndex: index + 1, - ...(index < stackItemCount - 1 && { - mask: "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)", - WebkitMask: - "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)", - }), - }} + style={{ zIndex: index + 1 }} > diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 76750490cde..c6f32d5fc93 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -168,6 +168,7 @@ export function MembersSidebarMemberCard({ className="h-8 w-8 text-xs shadow-none" iconClassName="h-4 w-4" label={memberAvatarLabel} + shape={memberIsBot ? "squircle" : "circle"} /> {presenceStatus ? ( {name} diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index 06b19d61679..29338ff5672 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -140,6 +140,7 @@ function RelayMemberRow({ avatarUrl={profile?.avatarUrl ?? null} className="h-9 w-9 text-xs shadow-none" label={displayName} + shape={profile?.isAgent === true ? "squircle" : "circle"} />

diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 8cbbdff2b4e..f27a43c724b 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -45,6 +45,7 @@ export function ForumPostCard({ preferResolvedSelfLabel: true, }); const avatarUrl = profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null; + const authorIsAgent = profiles?.[post.pubkey.toLowerCase()]?.isAgent === true; const { mentionNames, mentionPubkeysByName } = resolveMentionProps( post.tags, profiles, @@ -83,14 +84,19 @@ export function ForumPostCard({
{/* biome-ignore lint/a11y/noStaticElementInteractions: presentation wrapper stops click propagation to parent card */}
e.stopPropagation()} role="presentation"> - + diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 0582e539652..30da0f22417 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -642,6 +642,7 @@ function ProfileHero({ } badgeBox={PROFILE_HERO_PRESENCE_BADGE.shell} className="h-20 w-20" + cornerRadius={isBot ? 24 : undefined} curve={STATUS_DOT_MASK_CURVE} cutout={PROFILE_HERO_PRESENCE_BADGE.cutout} size={80} @@ -652,6 +653,7 @@ function ProfileHero({ iconClassName="h-8 w-8" label={displayName} plain + shape={isBot ? "squircle" : "circle"} testId="user-profile-avatar" /> diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index f82bac1c336..d19a5fa19d4 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -381,6 +381,7 @@ function UserProfilePopoverBody({ className="h-10 w-10" iconClassName="h-5 w-5" label={displayName} + shape={isBotProfile ? "squircle" : "circle"} size={40} status={presenceStatus ?? "offline"} statusTestId="user-profile-popover-presence-badge" diff --git a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx index 74130447270..54073bac5dd 100644 --- a/desktop/src/features/projects/ui/IssueAssigneesRow.tsx +++ b/desktop/src/features/projects/ui/IssueAssigneesRow.tsx @@ -68,7 +68,10 @@ export function IssueAssigneeFacepile({ const label = labelForPubkey(pubkey, profiles); return ( @@ -76,6 +79,7 @@ export function IssueAssigneeFacepile({ accent={profile?.isAgent === true} avatarUrl={profile?.avatarUrl ?? null} displayName={label} + shape={profile?.isAgent ? "squircle" : "circle"} size="xs" /> @@ -232,6 +236,7 @@ export function IssueAssigneesRow({ accent={profile?.isAgent === true} avatarUrl={profile?.avatarUrl ?? null} displayName={label} + shape={profile?.isAgent ? "squircle" : "circle"} size="xs" /> ); @@ -241,7 +246,10 @@ export function IssueAssigneesRow({ {canUnassign ? ( @@ -361,6 +374,7 @@ export function IssueAssigneesRow({ accent={candidate.isAgent} avatarUrl={candidate.avatarUrl} displayName={label} + shape={candidate.isAgent ? "squircle" : "circle"} size="xs" /> diff --git a/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx index c145f5f9d7e..ad970db118e 100644 --- a/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx +++ b/desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx @@ -40,6 +40,7 @@ export function ProjectAuthorIdentity({ accent={profile?.isAgent === true} avatarUrl={profile?.avatarUrl ?? null} displayName={label} + shape={profile?.isAgent ? "squircle" : "circle"} fallbackDelayMs={0} size="xs" testId={testId ? `${testId}-avatar` : undefined} @@ -61,6 +62,7 @@ export function ProjectAuthorIdentity({ accent={profile?.isAgent === true} avatarUrl={profile?.avatarUrl ?? null} displayName={label} + shape={profile?.isAgent ? "squircle" : "circle"} fallbackDelayMs={0} size="sm" /> diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index a1a48e89e47..0d859dd9da0 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -85,7 +85,10 @@ export function ProjectPeopleStack({ diff --git a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx index 8acfd90b129..46610e6d6ef 100644 --- a/desktop/src/features/projects/ui/ProjectEntityListRow.tsx +++ b/desktop/src/features/projects/ui/ProjectEntityListRow.tsx @@ -43,8 +43,9 @@ export function ProjectEntityFacepile({ > @@ -57,14 +58,18 @@ export function ProjectEntityFacepile({ triggerElement="span" > diff --git a/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx b/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx index 75566530754..3157721a157 100644 --- a/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx +++ b/desktop/src/features/projects/ui/ProjectIssueCommentTimeline.tsx @@ -120,6 +120,11 @@ export function ProjectIssueCommentTimeline({ } className="relative z-10 bg-background ring-1 ring-border/70" displayName={authorLabel} + shape={ + profiles?.[normalizePubkey(comment.author)]?.isAgent + ? "squircle" + : "circle" + } size="xs" />
diff --git a/desktop/src/features/projects/ui/ProjectProfileIdentity.tsx b/desktop/src/features/projects/ui/ProjectProfileIdentity.tsx index 1bbc4e346e9..9ee60c1736c 100644 --- a/desktop/src/features/projects/ui/ProjectProfileIdentity.tsx +++ b/desktop/src/features/projects/ui/ProjectProfileIdentity.tsx @@ -45,6 +45,7 @@ export function ProfileIdentityButton({ avatarUrl={avatarUrl} className={avatarClassName} displayName={label} + shape={isAgent ? "squircle" : "circle"} size={avatarSize} /> {showLabel ? ( diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx index 89c192777af..de6159afbb0 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx @@ -862,6 +862,9 @@ export function RepositoryFilesPanel({ accent={latestCommitProfile?.isAgent === true} avatarUrl={latestCommitProfile?.avatarUrl ?? null} displayName={latestCommitAuthorLabel} + shape={ + latestCommitProfile?.isAgent ? "squircle" : "circle" + } size="sm" />

diff --git a/desktop/src/features/projects/ui/ProjectRepositoryUnavailableState.tsx b/desktop/src/features/projects/ui/ProjectRepositoryUnavailableState.tsx index 9fde63b21d0..7b2a4284396 100644 --- a/desktop/src/features/projects/ui/ProjectRepositoryUnavailableState.tsx +++ b/desktop/src/features/projects/ui/ProjectRepositoryUnavailableState.tsx @@ -50,6 +50,7 @@ function RepositoryOwnerReference({ accent={ownerIsAgent} avatarUrl={ownerAvatarUrl ?? null} displayName={ownerName} + shape={ownerIsAgent ? "squircle" : "circle"} fallbackDelayMs={0} size="xs" testId="repository-owner-avatar" diff --git a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx index 7bdee36a894..cda0e2354ef 100644 --- a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx +++ b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx @@ -405,13 +405,17 @@ function ActivityCard({ @@ -422,6 +426,7 @@ function ActivityCard({ avatarUrl={profile?.avatarUrl ?? null} className="relative z-10 shrink-0" displayName={actorLabel} + shape={profile?.isAgent ? "squircle" : "circle"} size={compact ? "xs" : "md"} /> )} diff --git a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx index 2b86fa08257..4e1f7c24a68 100644 --- a/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx +++ b/desktop/src/features/projects/ui/ProjectsAgentPromptPage.tsx @@ -671,6 +671,7 @@ export function ProjectsAgentPromptPage({ avatarUrl={avatarUrlFor(selectedAgent.pubkey)} className="shrink-0" displayName={selectedAgent.name} + shape="squircle" size="xs" /> ) : null} @@ -697,6 +698,7 @@ export function ProjectsAgentPromptPage({ avatarUrl={avatarUrlFor(candidate.pubkey)} className="mr-2 shrink-0" displayName={candidate.name} + shape="squircle" size="xs" /> diff --git a/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx b/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx index edc7077c5bb..5ba34da99b8 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx @@ -58,7 +58,10 @@ function OverviewPerson({ @@ -66,6 +69,7 @@ function OverviewPerson({ accent={profile?.isAgent === true} avatarUrl={profile?.avatarUrl ?? null} displayName={label} + shape={profile?.isAgent ? "squircle" : "circle"} size="sm" /> diff --git a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx index a79a705a5b3..fa5f205fabb 100644 --- a/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx +++ b/desktop/src/features/projects/ui/PullRequestReviewersRow.tsx @@ -261,6 +261,7 @@ export function PullRequestReviewersRow({ accent={candidate.isAgent} avatarUrl={candidate.avatarUrl} displayName={label} + shape={candidate.isAgent ? "squircle" : "circle"} size="xs" /> diff --git a/desktop/src/features/pulse/ui/AgentActivityCard.tsx b/desktop/src/features/pulse/ui/AgentActivityCard.tsx index 8d600f09471..dd84b5c31fb 100644 --- a/desktop/src/features/pulse/ui/AgentActivityCard.tsx +++ b/desktop/src/features/pulse/ui/AgentActivityCard.tsx @@ -66,7 +66,11 @@ export function AgentActivityCard({ className="relative flex shrink-0 rounded-xl pt-1 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" type="button" > - + diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index 142d725e97a..96f25a6ede4 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -71,13 +71,18 @@ function ReplyParentContext({ : null; const parentAvatarUrl = cachedProfile?.avatarUrl ?? fetchedProfile?.avatarUrl ?? null; + const parentIsAgent = cachedProfile?.isAgent === true; const parentSnippet = parentNote ? noteSnippet(parentNote.content) : null; return (

{parentNote ? (
- + @@ -170,6 +176,7 @@ export function NoteCard({ avatarUrl={avatarUrl} className="!h-9 !w-9 shrink-0" displayName={displayName} + shape={isAgent ? "squircle" : "circle"} /> {isAgent ? ( @@ -299,6 +306,7 @@ export function NoteCard({ avatarUrl={currentUserAvatarUrl} className="!h-8 !w-8 shrink-0" displayName={currentUserDisplayName} + shape={currentUserProfile?.isAgent ? "squircle" : "circle"} /> {currentUserDisplayName} diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index 4cde503fde5..1b5bcc7ac0e 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -397,6 +397,9 @@ export function PulseView({ currentPubkey }: PulseViewProps) { avatarUrl={currentProfile?.avatarUrl ?? null} className="!h-7 !w-7 shrink-0" displayName={currentDisplayName} + shape={ + currentProfile?.isAgent === true ? "squircle" : "circle" + } /> {currentDisplayName} diff --git a/desktop/src/features/reminders/ui/RemindersPanel.tsx b/desktop/src/features/reminders/ui/RemindersPanel.tsx index 46b6b89e13d..b3720ae043b 100644 --- a/desktop/src/features/reminders/ui/RemindersPanel.tsx +++ b/desktop/src/features/reminders/ui/RemindersPanel.tsx @@ -37,6 +37,7 @@ export type ReminderSource = { avatarUrl: string | null; channel: Channel | null; channelLabel: string; + isAgent?: boolean; }; export function useReminderSources(reminders: readonly Reminder[]) { @@ -80,6 +81,9 @@ export function useReminderSources(reminders: readonly Reminder[]) { channelLabel: channel ? resolveChannelDisplayLabel(channel, currentPubkey, profiles) : UNKNOWN_CHANNEL_LABEL, + ...(profiles?.[normalizePubkey(target.authorPubkey)]?.isAgent === true + ? { isAgent: true } + : {}), }); } return map; @@ -194,6 +198,7 @@ function ReminderRow({ avatarUrl={source.avatarUrl} className="h-4 w-4 shrink-0" displayName={source.authorLabel} + shape={source.isAgent ? "squircle" : "circle"} size="xs" /> @@ -444,6 +449,7 @@ export function ReminderDetailPane({ avatarUrl={source.avatarUrl} className="h-6 w-6" displayName={source.authorLabel} + shape={source.isAgent ? "squircle" : "circle"} size="sm" /> diff --git a/desktop/src/features/search/ui/SearchResultItem.tsx b/desktop/src/features/search/ui/SearchResultItem.tsx index 6eeb33801ea..cc373ca7fb6 100644 --- a/desktop/src/features/search/ui/SearchResultItem.tsx +++ b/desktop/src/features/search/ui/SearchResultItem.tsx @@ -233,6 +233,8 @@ export function MessageResultBody({ }); const avatarUrl = resultProfiles?.[hit.pubkey.toLowerCase()]?.avatarUrl ?? null; + const authorIsAgent = + resultProfiles?.[hit.pubkey.toLowerCase()]?.isAgent === true; return (
@@ -245,6 +247,7 @@ export function MessageResultBody({ {authorLabel} diff --git a/desktop/src/features/search/ui/TopbarSearch.tsx b/desktop/src/features/search/ui/TopbarSearch.tsx index 70365497bcc..68e879a3033 100644 --- a/desktop/src/features/search/ui/TopbarSearch.tsx +++ b/desktop/src/features/search/ui/TopbarSearch.tsx @@ -1,6 +1,5 @@ import { Search } from "lucide-react"; import * as React from "react"; - import { resolveUserLabel } from "@/features/profile/lib/identity"; import { getMinimumSearchQueryLength } from "@/features/search/hooks"; import { parseSearchOperators } from "@/features/search/lib/parseSearchOperators"; @@ -30,7 +29,6 @@ import { } from "@/shared/ui/mentionChip"; import { Skeleton } from "@/shared/ui/skeleton"; import { UserAvatar } from "@/shared/ui/UserAvatar"; - type TopbarSearchProps = { channelLabels?: Record; channels: Channel[]; @@ -48,7 +46,6 @@ type TopbarSearchProps = { scopeFocusRequest?: number; variant?: "bar" | "icon"; }; - const MAX_SEARCH_SUGGESTIONS = 4; const SEARCH_RESULT_LIMIT = 40; const SEARCH_SECTION_TITLE_CLASS = @@ -61,23 +58,18 @@ const SEARCH_RESULT_SECTION_ORDER = [ "messages", "actions", ] as const; - type SearchResultSectionKey = (typeof SEARCH_RESULT_SECTION_ORDER)[number]; - type SearchResultSection = { key: SearchResultSectionKey; results: SearchResult[]; title: string; }; - type SearchHitContextLabel = { channelLabel: string | null; text: string; }; - function formatRelativeTime(unixSeconds: number) { const diff = Math.floor(Date.now() / 1_000) - unixSeconds; - if (diff < 60) { return "just now"; } @@ -736,6 +728,12 @@ export function TopbarSearch({ pubkey: result.hit.pubkey, preferResolvedSelfLabel: true, })} + shape={ + resultProfiles?.[result.hit.pubkey.toLowerCase()]?.isAgent === + true + ? "squircle" + : "circle" + } size="md" /> ) : result.kind === "user" ? ( @@ -743,6 +741,7 @@ export function TopbarSearch({ avatarUrl={result.user.avatarUrl} className="h-7 w-7" displayName={userDisplayName ?? result.user.pubkey} + shape={result.user.isAgent ? "squircle" : "circle"} size="sm" /> ) : ( diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 50c9a80f6b1..9c7f3cf1661 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -418,6 +418,7 @@ export function AppSidebar({ accessibleLabel: participant.label, avatarUrl: participant.avatarUrl, channelId, + isAgent: participant.isAgent, label: dmChannelLabels[channelId] ?? participant.label, }, ]; diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index e6e24ce27b1..34fdba0bdfe 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -78,11 +78,13 @@ function RowActionButton({ } function ThreadPreviewRow({ + isAgent, item, onMarkRead, onOpen, onRemindLater, }: { + isAgent: boolean; item: InboxItem; onMarkRead: () => void; onOpen: () => void; @@ -104,6 +106,7 @@ function ThreadPreviewRow({ avatarUrl={item.avatarUrl} className="h-9 w-9 shrink-0" displayName={item.senderLabel} + shape={isAgent ? "squircle" : "circle"} size="md" />
@@ -168,6 +171,7 @@ function WorkingAgentRow({ avatarUrl={avatarUrl} className="h-9 w-9 shrink-0" displayName={name} + shape="squircle" size="md" />
@@ -424,6 +428,10 @@ export function ChannelActivityPopover({ {activityItems.length > 0 ? activityItems.map((item) => ( handleMarkRead(item)} diff --git a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx index fc1b1ee1941..581f2e59868 100644 --- a/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx +++ b/desktop/src/features/sidebar/ui/MoreUnreadButton.tsx @@ -7,6 +7,7 @@ export type UnreadDmPreview = { avatarUrl: string | null; channelId: string; label: string; + isAgent?: boolean; }; export function canPreviewUnreadDm( @@ -111,6 +112,7 @@ export function MoreUnreadButton({ avatarUrl={preview.avatarUrl} className="ring-2 ring-primary" displayName={preview.label} + shape={preview.isAgent ? "squircle" : "circle"} fallbackDelayMs={0} size="xs" testId={`sidebar-unread-dm-avatar-${preview.channelId}`} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index a5ad12fb336..6fca32350aa 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -153,6 +153,7 @@ function ChannelWorkingBadge({ export type SidebarDmParticipant = { avatarUrl: string | null; label: string; + isAgent?: boolean; pubkey: string; }; @@ -197,6 +198,7 @@ function DmChannelIcon({ geometry={DM_AVATAR_STATUS_GEOMETRY} iconClassName="h-3.5 w-3.5" label={primaryParticipant.label} + shape={primaryParticipant.isAgent ? "squircle" : "circle"} size={DM_AVATAR_SIZE} status={presenceStatus} statusTestId={`channel-presence-${channelName}`} diff --git a/desktop/src/features/sidebar/useDmSidebarMetadata.ts b/desktop/src/features/sidebar/useDmSidebarMetadata.ts index 52f613f7d07..b3bfce56707 100644 --- a/desktop/src/features/sidebar/useDmSidebarMetadata.ts +++ b/desktop/src/features/sidebar/useDmSidebarMetadata.ts @@ -130,6 +130,10 @@ export function useDmSidebarMetadata({ profiles: dmProfiles, pubkey: participant.pubkey, }), + ...(dmProfiles?.[participant.pubkey.toLowerCase()]?.isAgent === + true + ? { isAgent: true } + : {}), pubkey: participant.pubkey, })), ]; diff --git a/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx index bcf0b209ffa..9d6bdddc005 100644 --- a/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx +++ b/desktop/src/features/workflows/ui/WorkflowAuthorPicker.tsx @@ -342,6 +342,7 @@ function AuthorOption({ diff --git a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx index 8ac97402aa0..01705261d2e 100644 --- a/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx +++ b/desktop/src/features/workflows/ui/WorkflowFormBuilder.tsx @@ -632,6 +632,7 @@ export const WorkflowFormBuilder = React.forwardRef< diff --git a/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx b/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx index 6639d85b0ff..55211bc3bce 100644 --- a/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx +++ b/desktop/src/features/workflows/ui/WorkflowMessagePicker.tsx @@ -444,6 +444,7 @@ function MessageOption({ ) : null} diff --git a/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx b/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx index 50eb5ef6726..c6a917048f7 100644 --- a/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx +++ b/desktop/src/features/workflows/ui/WorkflowRichTriggerDescription.tsx @@ -6,11 +6,13 @@ import { splitWorkflowAuthorDescription } from "./workflowTriggerDescription"; export function WorkflowRichTriggerDescription({ avatarUrl, description, + isAgent, label, loading, }: { avatarUrl?: string | null; description: string; + isAgent?: boolean; label?: string | null; loading?: boolean; }) { @@ -41,6 +43,7 @@ export function WorkflowRichTriggerDescription({ className="h-4 w-4" displayName={label} fallbackDelayMs={0} + shape={isAgent ? "squircle" : "circle"} size="xs" testId="workflow-trigger-author-avatar" /> diff --git a/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx b/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx index 78f6e30606d..2d63d7c89f3 100644 --- a/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx +++ b/desktop/src/features/workflows/ui/WorkflowTriggerConditions.tsx @@ -77,10 +77,12 @@ function ExclusionStrike() { function AuthorConditionSummary({ avatarUrl, excluded, + isAgent, label, }: { avatarUrl: string | null; excluded: boolean; + isAgent?: boolean; label: string; }) { return ( @@ -91,6 +93,7 @@ function AuthorConditionSummary({ className="h-6 w-6" displayName={label} fallbackDelayMs={0} + shape={isAgent ? "squircle" : "circle"} size="xs" /> {excluded ? : null} @@ -326,6 +329,7 @@ export function WorkflowTriggerConditions({ ) : messageSummary ? ( diff --git a/desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts b/desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts index 320ba0f835d..b042aa6354d 100644 --- a/desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts +++ b/desktop/src/features/workflows/ui/useWorkflowAuthorPresentation.ts @@ -10,6 +10,7 @@ const FULL_HEX_PUBKEY = /^[0-9a-f]{64}$/i; export type WorkflowAuthorPresentation = { avatarUrl: string | null; description: string; + isAgent: boolean; label: string | null; loading: boolean; pubkey: string | null; @@ -50,6 +51,7 @@ export function useWorkflowAuthorPresentation( authorLabel: label ?? undefined, authorLoading: loading, }), + isAgent: profile?.isAgent === true, label, loading, pubkey, diff --git a/desktop/src/features/workflows/ui/useWorkflowListAuthorPresentations.ts b/desktop/src/features/workflows/ui/useWorkflowListAuthorPresentations.ts index a2ad5f90ddb..a0996ddd554 100644 --- a/desktop/src/features/workflows/ui/useWorkflowListAuthorPresentations.ts +++ b/desktop/src/features/workflows/ui/useWorkflowListAuthorPresentations.ts @@ -44,6 +44,7 @@ export function useWorkflowListAuthorPresentations( workflowId, { avatarUrl: profile?.avatarUrl ?? null, + isAgent: profile?.isAgent === true, label: loading ? null : resolveUserLabel({ diff --git a/desktop/src/shared/ui/UserAvatar.tsx b/desktop/src/shared/ui/UserAvatar.tsx index 40fb9dc310b..8618d83c5a1 100644 --- a/desktop/src/shared/ui/UserAvatar.tsx +++ b/desktop/src/shared/ui/UserAvatar.tsx @@ -37,6 +37,7 @@ type UserAvatarProps = { displayName: string; size?: UserAvatarSize; accent?: boolean; + shape?: "circle" | "squircle"; className?: string; fallbackDelayMs?: number; testId?: string; @@ -47,6 +48,7 @@ export function UserAvatar({ displayName, size = "md", accent = false, + shape, className, fallbackDelayMs = 200, testId, @@ -61,12 +63,20 @@ export function UserAvatar({ : avatarUrl ? rewriteRelayUrl(avatarUrl) : null; + const resolvedShape = shape ?? "circle"; + const radiusClass = + resolvedShape === "squircle" ? "rounded-[30%]" : "rounded-full"; return ( setIsHovered(true) : undefined} onMouseLeave={animated ? () => setIsHovered(false) : undefined} diff --git a/desktop/src/shared/ui/VideoPlayer.tsx b/desktop/src/shared/ui/VideoPlayer.tsx index 71753104014..f94eb5cc5ce 100644 --- a/desktop/src/shared/ui/VideoPlayer.tsx +++ b/desktop/src/shared/ui/VideoPlayer.tsx @@ -43,7 +43,6 @@ import { saveReviewPlaybackPosition, setVideoReviewOpen, } from "./videoPlayerState"; - type VideoReviewReaction = { emoji: string; emojiUrl?: string; @@ -55,11 +54,11 @@ type VideoReviewReaction = { avatarUrl: string | null; }>; }; - export type VideoReviewComment = { id: string; author: string; avatarUrl?: string | null; + isAgent?: boolean; body: string; createdAt: number; time: string; @@ -67,7 +66,6 @@ export type VideoReviewComment = { parentId?: string | null; reactions?: VideoReviewReaction[]; }; - export type VideoReviewContext = { channelId?: string | null; channelName?: string; @@ -91,7 +89,6 @@ export type VideoReviewContext = { rootEventId?: string; title?: string; }; - type VideoPlayerProps = { src: string; poster?: string; @@ -108,14 +105,12 @@ type VideoPlayerProps = { /** imeta `filename`, used as the save-dialog name. */ filename?: string; }; - type TimecodedComment = { comment: VideoReviewComment; seconds: number | null; timecode: string | null; text: string; }; - const QUICK_REACTIONS = ["😂", "😍", "😮", "🙌", "👍", "👎"]; const DEFAULT_PLAYBACK_SPEED = 1; const INLINE_SPEED_CONTROL_MIN_WIDTH = 220; @@ -1813,6 +1808,9 @@ function VideoReviewDialog({ avatarUrl={item.comment.avatarUrl ?? null} className="h-4 w-4 shadow-none" displayName={item.comment.author} + shape={ + item.comment.isAgent ? "squircle" : "circle" + } size="xs" /> @@ -2143,6 +2141,7 @@ function VideoReviewCommentBody({ avatarUrl={item.comment.avatarUrl ?? null} className="h-6 w-6 shadow-none" displayName={item.comment.author} + shape={item.comment.isAgent ? "squircle" : "circle"} size="xs" />

diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index 86edeea0ee7..5c853c02d2f 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -472,6 +472,7 @@ class _RowAvatar extends StatelessWidget { color: context.colors.onPrimaryContainer, ), ), + isAgent: profile?.ownerPubkey != null, ); } } diff --git a/mobile/lib/features/channels/add_members_sheet.dart b/mobile/lib/features/channels/add_members_sheet.dart index a4a6d1e350a..4041f1efbb2 100644 --- a/mobile/lib/features/channels/add_members_sheet.dart +++ b/mobile/lib/features/channels/add_members_sheet.dart @@ -191,6 +191,7 @@ class AddChannelMembersSheet extends HookConsumerWidget { backgroundColor: context.colors.primaryContainer, fallback: Text(user.initial), + isAgent: user.isAgent, ), title: Text( user.label, diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart index 7442cb8155d..58127f90119 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_call_avatar.dart @@ -124,6 +124,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { fallbackLabel: fallbackLabel, isSelf: isSelf, ); + final isAgent = profile?.isAgent == true || fallbackLabel != null; final semanticStates = [ label, @@ -183,7 +184,14 @@ class _HuddleCallAvatar extends HookConsumerWidget { width: speakingRingSize, height: speakingRingSize, decoration: BoxDecoration( - shape: BoxShape.circle, + shape: isAgent + ? BoxShape.rectangle + : BoxShape.circle, + borderRadius: isAgent + ? BorderRadius.circular( + speakingRingSize * 0.3, + ) + : null, color: context.colors.primary.withValues( alpha: 0.07, ), @@ -207,7 +215,14 @@ class _HuddleCallAvatar extends HookConsumerWidget { width: avatarRadius * 2, height: avatarRadius * 2, decoration: BoxDecoration( - shape: BoxShape.circle, + shape: isAgent + ? BoxShape.rectangle + : BoxShape.circle, + borderRadius: isAgent + ? BorderRadius.circular( + avatarRadius * 0.6, + ) + : null, color: context.colors.primaryContainer, ), alignment: Alignment.center, @@ -230,6 +245,7 @@ class _HuddleCallAvatar extends HookConsumerWidget { size: fallbackIconSize, color: context.colors.onPrimaryContainer, ), + isAgent: isAgent, ), ), ], diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart index 215009365f4..48f55bfd879 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_participant_overlay.dart @@ -170,6 +170,7 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { fallbackLabel: fallbackLabel, isSelf: isSelf, ); + final isAgent = profile?.isAgent == true || fallbackLabel != null; return Semantics( label: active ? '$label, speaking' : label, @@ -193,7 +194,12 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { : const Duration(milliseconds: 180), padding: EdgeInsets.all(active ? Grid.xxs : Grid.half), decoration: BoxDecoration( - shape: BoxShape.circle, + shape: isAgent ? BoxShape.rectangle : BoxShape.circle, + borderRadius: isAgent + ? BorderRadius.circular( + (_huddleParticipantSpotlightRadius + Grid.half) * 0.6, + ) + : null, color: context.colors.primary.withValues( alpha: active ? 0.18 : 0.08, ), @@ -207,6 +213,7 @@ class _HuddleParticipantSpotlight extends ConsumerWidget { size: 56, color: context.colors.onPrimaryContainer, ), + isAgent: isAgent, ), ), const SizedBox(height: Grid.twelve), @@ -348,6 +355,9 @@ class _HuddleParticipantRoster extends ConsumerWidget { size: 22, color: context.colors.onPrimaryContainer, ), + isAgent: + profile?.isAgent == true || + fallbackLabels[pubkey] != null, ), const SizedBox(width: Grid.twelve), Expanded( diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 8c953b7211b..3917f702e3d 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -347,6 +347,7 @@ class _UserAvatar extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + isAgent: profile?.ownerPubkey != null, ); } } diff --git a/mobile/lib/features/channels/channel_details_page.dart b/mobile/lib/features/channels/channel_details_page.dart index 138765386f1..d86c01de7a2 100644 --- a/mobile/lib/features/channels/channel_details_page.dart +++ b/mobile/lib/features/channels/channel_details_page.dart @@ -672,6 +672,7 @@ class _ChannelMemberPreviewRow extends StatelessWidget { radius: 20, backgroundColor: context.colors.primaryContainer, fallback: Text(label.isEmpty ? '?' : label[0].toUpperCase()), + isAgent: member.isBot, ), title: Text.rich( TextSpan( diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 119f5dce7b0..6fad4ecc4a4 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/auth/auth.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/crypto/nip_oa.dart'; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../profile/profile_provider.dart'; @@ -155,12 +156,14 @@ class DirectoryUser { final String? displayName; final String? avatarUrl; final String? nip05Handle; + final bool isAgent; const DirectoryUser({ required this.pubkey, this.displayName, this.avatarUrl, this.nip05Handle, + this.isAgent = false, }); String get label { @@ -313,6 +316,7 @@ List directoryUsersFromProfileEvents(List events) { displayName: profile.displayName, avatarUrl: profile.avatarUrl, nip05Handle: profile.nip05, + isAgent: verifiedOaOwnerPubkey(event.tags, event.pubkey) != null, ), ]..sort((a, b) { final labelComparison = a.label.toLowerCase().compareTo( @@ -381,6 +385,16 @@ final relayDirectoryUsersProvider = displayName: profile.displayName, avatarUrl: profile.avatarUrl, nip05Handle: profile.nip05, + isAgent: + verifiedOaOwnerPubkey( + profileEvents + .firstWhere( + (event) => event.pubkey.toLowerCase() == pubkey, + ) + .tags, + pubkey, + ) != + null, ) else DirectoryUser(pubkey: pubkey), diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 04d6b38d739..b94be1951f4 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -214,6 +214,7 @@ class _DmAvatar extends ConsumerWidget { fontWeight: FontWeight.w600, ), ), + isAgent: profile?.isAgent == true, ), Positioned( right: -1, diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index a17bcef4761..14dab6497fe 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -745,6 +745,7 @@ class _NewDirectMessageSheet extends HookConsumerWidget { fontWeight: FontWeight.w600, ), ), + isAgent: user.isAgent, ), title: Text( user.label, @@ -847,6 +848,7 @@ class _SelectedDmRecipientChip extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + isAgent: user.isAgent, ), const SizedBox(width: Grid.xxs), Flexible( diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index 9d05858ab8c..e6bee4515ba 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -53,6 +53,7 @@ class _MentionSuggestions extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + isAgent: candidate.isAgent, ), title: Text(name, style: context.textTheme.titleSmall), subtitle: _MentionSuggestionInfo.build( diff --git a/mobile/lib/features/channels/members_sheet.dart b/mobile/lib/features/channels/members_sheet.dart index 66154a15e1d..568737362f1 100644 --- a/mobile/lib/features/channels/members_sheet.dart +++ b/mobile/lib/features/channels/members_sheet.dart @@ -227,7 +227,11 @@ class _MemberTile extends ConsumerWidget { return ListTile( contentPadding: EdgeInsets.zero, - leading: _MemberAvatar(avatarUrl: profile?.avatarUrl, initial: initial), + leading: _MemberAvatar( + avatarUrl: profile?.avatarUrl, + initial: initial, + isAgent: member.isBot || profile?.isAgent == true, + ), title: Text(label), subtitle: isWorking ? Row( @@ -439,8 +443,13 @@ class _RoleSelector extends StatelessWidget { class _MemberAvatar extends StatelessWidget { final String? avatarUrl; final String initial; + final bool isAgent; - const _MemberAvatar({required this.avatarUrl, required this.initial}); + const _MemberAvatar({ + required this.avatarUrl, + required this.initial, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -448,6 +457,7 @@ class _MemberAvatar extends StatelessWidget { imageUrl: avatarUrl, radius: 20, fallback: Text(initial), + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index 775504abc9a..f5d0066bad7 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -462,6 +462,7 @@ class _ReactorTile extends StatelessWidget { initial: profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'), + isAgent: profile?.isAgent == true, ), title: Text( displayName, @@ -488,8 +489,13 @@ class _ReactorTile extends StatelessWidget { class _ReactorAvatar extends StatelessWidget { final String? avatarUrl; final String initial; + final bool isAgent; - const _ReactorAvatar({required this.avatarUrl, required this.initial}); + const _ReactorAvatar({ + required this.avatarUrl, + required this.initial, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -497,6 +503,7 @@ class _ReactorAvatar extends StatelessWidget { imageUrl: avatarUrl, radius: 20, fallback: Text(initial), + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/channels/small_avatar.dart b/mobile/lib/features/channels/small_avatar.dart index e110969e73c..be7a90955e6 100644 --- a/mobile/lib/features/channels/small_avatar.dart +++ b/mobile/lib/features/channels/small_avatar.dart @@ -4,7 +4,7 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/profile/user_profile.dart'; -/// 20px circle avatar used in thread summary rows and other compact lists. +/// 20px avatar used in thread summary rows and other compact lists. class SmallAvatar extends StatelessWidget { final String pubkey; final Map userCache; @@ -23,12 +23,14 @@ class SmallAvatar extends StatelessWidget { final avatarUrl = profile?.avatarUrl; final initial = profile?.initial ?? (pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'); + final isAgent = profile?.ownerPubkey != null; return Container( width: size, height: size, decoration: BoxDecoration( - shape: BoxShape.circle, + shape: isAgent ? BoxShape.rectangle : BoxShape.circle, + borderRadius: isAgent ? BorderRadius.circular(size * 0.3) : null, border: Border.all(color: context.colors.surface, width: 1.5), ), child: AvatarImage( @@ -43,6 +45,7 @@ class SmallAvatar extends StatelessWidget { color: context.colors.onPrimaryContainer, ), ), + isAgent: isAgent, ), ); } diff --git a/mobile/lib/features/channels/thread_detail_page/avatar.dart b/mobile/lib/features/channels/thread_detail_page/avatar.dart index 502d6cffa8d..996064386ce 100644 --- a/mobile/lib/features/channels/thread_detail_page/avatar.dart +++ b/mobile/lib/features/channels/thread_detail_page/avatar.dart @@ -23,6 +23,7 @@ class _Avatar extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + isAgent: profile?.ownerPubkey != null, ); } } diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index b3ae953ef69..6e7a69e6315 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -328,6 +328,7 @@ class _PostAvatar extends StatelessWidget { fontWeight: FontWeight.w600, ), ), + isAgent: profile?.ownerPubkey != null, ); } } diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 7c1f4c3cea0..5b215e42ccc 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -645,6 +645,7 @@ class _Avatar extends StatelessWidget { color: context.colors.onPrimaryContainer, ), ), + isAgent: profile?.ownerPubkey != null, ); } } diff --git a/mobile/lib/features/profile/user_profile_sheet.dart b/mobile/lib/features/profile/user_profile_sheet.dart index 9779db4a872..63b0eddeef8 100644 --- a/mobile/lib/features/profile/user_profile_sheet.dart +++ b/mobile/lib/features/profile/user_profile_sheet.dart @@ -151,6 +151,7 @@ class UserProfileSheet extends HookConsumerWidget { child: _ProfileAvatar( avatarUrl: avatarUrl, initial: initial, + isAgent: profile?.isAgent == true, ), ), ), @@ -377,8 +378,13 @@ class _ProfilePresenceChip extends StatelessWidget { class _ProfileAvatar extends HookWidget { final String? avatarUrl; final String initial; + final bool isAgent; - const _ProfileAvatar({required this.avatarUrl, required this.initial}); + const _ProfileAvatar({ + required this.avatarUrl, + required this.initial, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -398,17 +404,26 @@ class _ProfileAvatar extends HookWidget { stoppedAnimationUrl.value == animatedAvatar.animationUrl ? null : animatedAvatar.animationUrl, - child: ClipOval( - child: isPlaying - ? ProgressiveAnimatedAvatar( - key: ValueKey(animatedAvatar.animationUrl), - descriptor: animatedAvatar, - fallback: _AvatarFallback(initial: initial), - ) - : AvatarImageContent( - imageUrl: animatedAvatar?.posterUrl ?? avatarUrl, - fallback: _AvatarFallback(initial: initial), - ), + child: LayoutBuilder( + builder: (context, constraints) { + final avatar = isPlaying + ? ProgressiveAnimatedAvatar( + key: ValueKey(animatedAvatar.animationUrl), + descriptor: animatedAvatar, + fallback: _AvatarFallback(initial: initial), + ) + : AvatarImageContent( + imageUrl: animatedAvatar?.posterUrl ?? avatarUrl, + fallback: _AvatarFallback(initial: initial), + ); + if (!isAgent) return ClipOval(child: avatar); + return ClipRRect( + borderRadius: BorderRadius.circular( + constraints.biggest.shortestSide * 0.3, + ), + child: avatar, + ); + }, ), ), ); diff --git a/mobile/lib/features/pulse/agent_activity_card.dart b/mobile/lib/features/pulse/agent_activity_card.dart index 7920cf6ad5e..f844244cc94 100644 --- a/mobile/lib/features/pulse/agent_activity_card.dart +++ b/mobile/lib/features/pulse/agent_activity_card.dart @@ -47,6 +47,7 @@ class AgentActivityCard extends HookConsumerWidget { radius: 18, backgroundColor: context.colors.primaryContainer, fallback: const Icon(LucideIcons.bot, size: 18), + isAgent: true, ), Positioned( right: 0, diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index 8d26d58717a..30294a988f4 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -75,6 +75,7 @@ class NoteCard extends HookConsumerWidget { color: context.colors.onPrimaryContainer, ), ), + isAgent: profile?.ownerPubkey != null, ), ), const SizedBox(width: Grid.xs), diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 0e489ed486b..507e05fb2cc 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; - import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/mentions/mention_tags.dart'; import '../../shared/theme/theme.dart'; @@ -730,6 +729,7 @@ class _PeopleSection extends ConsumerWidget { imageUrl: user.avatarUrl, radius: 20, fallback: Text(user.label.substring(0, 1).toUpperCase()), + isAgent: user.isAgent, ), title: Text( user.label, diff --git a/mobile/lib/shared/profile/user_profile.dart b/mobile/lib/shared/profile/user_profile.dart index de58d955e3e..abc915437bd 100644 --- a/mobile/lib/shared/profile/user_profile.dart +++ b/mobile/lib/shared/profile/user_profile.dart @@ -12,6 +12,8 @@ class UserProfile { /// means this identity is an agent (mirrors desktop's `ownerPubkey`). final String? ownerPubkey; + bool get isAgent => ownerPubkey != null; + const UserProfile({ required this.pubkey, this.displayName, diff --git a/mobile/lib/shared/widgets/avatar_image.dart b/mobile/lib/shared/widgets/avatar_image.dart index b869bf8fbc4..33f4d65470d 100644 --- a/mobile/lib/shared/widgets/avatar_image.dart +++ b/mobile/lib/shared/widgets/avatar_image.dart @@ -13,7 +13,7 @@ import '../emoji/native_emoji_glyph.dart'; import '../push/push_presentation_cache.dart'; import '../relay/relay.dart'; -/// A circular avatar that supports both remote URLs and inline image data. +/// An avatar that supports both remote URLs and inline image data. /// /// Flutter's [NetworkImage] only loads network URLs, while desktop browsers also /// accept `data:image/*` sources directly. Agent emoji avatars are inline SVGs, @@ -23,6 +23,7 @@ class AvatarImage extends StatelessWidget { final double radius; final Color? backgroundColor; final Widget fallback; + final bool isAgent; const AvatarImage({ super.key, @@ -30,28 +31,33 @@ class AvatarImage extends StatelessWidget { required this.radius, required this.fallback, this.backgroundColor, + this.isAgent = false, }); @override Widget build(BuildContext context) { final animatedAvatar = parseAnimatedAvatarUrl(imageUrl); - return CircleAvatar( - radius: radius, - // Animated avatar posters carry their own backdrop disc; preserve their - // transparent surroundings on static/list surfaces, matching desktop. - backgroundColor: animatedAvatar == null - ? backgroundColor - : Colors.transparent, - child: ClipOval( - child: SizedBox.square( - dimension: radius * 2, - child: AvatarImageContent( - imageUrl: animatedAvatar?.posterUrl ?? imageUrl, - fallback: fallback, - ), - ), + final color = animatedAvatar == null ? backgroundColor : Colors.transparent; + final content = SizedBox.square( + dimension: radius * 2, + child: AvatarImageContent( + imageUrl: animatedAvatar?.posterUrl ?? imageUrl, + fallback: fallback, ), ); + if (!isAgent) { + return CircleAvatar( + radius: radius, + backgroundColor: color, + child: ClipOval(child: content), + ); + } + + final borderRadius = BorderRadius.circular(radius * 0.6); + return DecoratedBox( + decoration: BoxDecoration(color: color, borderRadius: borderRadius), + child: ClipRRect(borderRadius: borderRadius, child: content), + ); } } diff --git a/mobile/test/shared/widgets/avatar_image_test.dart b/mobile/test/shared/widgets/avatar_image_test.dart index 821de63a480..34cf86c1443 100644 --- a/mobile/test/shared/widgets/avatar_image_test.dart +++ b/mobile/test/shared/widgets/avatar_image_test.dart @@ -13,13 +13,18 @@ void main() { '' '🦝'; - Widget subject(String? imageUrl, {Color? backgroundColor}) => ProviderScope( + Widget subject( + String? imageUrl, { + Color? backgroundColor, + bool isAgent = false, + }) => ProviderScope( child: MaterialApp( home: AvatarImage( imageUrl: imageUrl, radius: 16, backgroundColor: backgroundColor, fallback: const Text('R'), + isAgent: isAgent, ), ), ); @@ -33,6 +38,14 @@ void main() { expect(isCacheablePushAvatarSource('data:image/png;base64,%%%'), isFalse); }); + testWidgets('clips agents to a 30 percent squircle', (tester) async { + await tester.pumpWidget(subject(null, isAgent: true)); + + expect(find.byType(CircleAvatar), findsNothing); + final clip = tester.widget(find.byType(ClipRRect)); + expect(clip.borderRadius, BorderRadius.circular(9.6)); + }); + testWidgets('renders raccoon percent-encoded SVG data avatar', ( tester, ) async { From 7827c15078b54ee4bf06c1cbc7e18dc07ce3d4ff Mon Sep 17 00:00:00 2001 From: Fizz Date: Mon, 31 Aug 2026 11:18:48 -0400 Subject: [PATCH 2/3] fix: propagate agent avatar classification Signed-off-by: Fizz --- desktop/tests/e2e/agents.spec.ts | 34 ++++- .../activity/activity_page/inbox_row.dart | 13 +- .../channels/channel_detail_page/app_bar.dart | 11 +- .../channel_detail_page/message_bubble.dart | 8 +- .../channel_detail_page/system_rows.dart | 2 + .../channels/thread_detail_page/avatar.dart | 9 +- .../thread_detail_page/thread_message.dart | 4 + .../lib/features/forum/forum_post_card.dart | 18 ++- .../features/activity/activity_page_test.dart | 23 ++++ .../channels/channel_detail_page_test.dart | 119 ++++++++++++++++++ .../features/forum/forum_widgets_test.dart | 36 ++++++ 11 files changed, 266 insertions(+), 11 deletions(-) diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index ac57b30aee7..b72cb393440 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -624,8 +624,38 @@ test("team cards use the thread-style overlapping avatar stack", async ({ ); expect(boxes[1]?.left).toBeLessThan(boxes[0]?.right ?? 0); expect(boxes[2]?.left).toBeLessThan(boxes[1]?.right ?? 0); - await expect(avatars.first()).not.toHaveCSS("mask-image", "none"); - await expect(avatars.last()).toHaveCSS("mask-image", "none"); + const overlapStyles = await avatars.evaluateAll((elements) => + elements.map((element) => { + const styles = getComputedStyle(element); + const outline = getComputedStyle(element, "::before"); + return { + maskImage: styles.maskImage, + outlineBackground: outline.backgroundColor, + outlineBorderRadius: outline.borderRadius, + outlineInset: outline.inset, + }; + }), + ); + expect(overlapStyles).toEqual([ + { + maskImage: "none", + outlineBackground: "rgb(255, 255, 255)", + outlineBorderRadius: "calc(30% + 2px)", + outlineInset: "-2px", + }, + { + maskImage: "none", + outlineBackground: "rgb(255, 255, 255)", + outlineBorderRadius: "calc(30% + 2px)", + outlineInset: "-2px", + }, + { + maskImage: "none", + outlineBackground: "rgb(255, 255, 255)", + outlineBorderRadius: "calc(30% + 2px)", + outlineInset: "-2px", + }, + ]); const avatarSurfaceStyles = await avatars .locator(":scope > *") .evaluateAll((elements) => diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index 5c853c02d2f..1252968baf2 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -89,6 +89,9 @@ class _InboxRow extends HookConsumerWidget { final knownAgentPubkeys = channel == null ? ref.watch(knownAgentPubkeysProvider) : ref.watch(agentMentionPubkeysProvider(channel!.id)); + final isAgent = + knownAgentPubkeys.contains(senderPubkey) || + profile?.ownerPubkey != null; final agentMentionPubkeys = agentPubkeysWithProfileOwners( knownAgentPubkeys: knownAgentPubkeys, profileOwnedAgentPubkeys: [ @@ -223,6 +226,7 @@ class _InboxRow extends HookConsumerWidget { _RowAvatar( pubkey: item.item.pubkey, profile: profile, + isAgent: isAgent, ), const SizedBox(width: messageAvatarContentGap), Expanded( @@ -453,8 +457,13 @@ class _InboxSwipeAction extends StatelessWidget { class _RowAvatar extends StatelessWidget { final String pubkey; final UserProfile? profile; + final bool isAgent; - const _RowAvatar({required this.pubkey, required this.profile}); + const _RowAvatar({ + required this.pubkey, + required this.profile, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -472,7 +481,7 @@ class _RowAvatar extends StatelessWidget { color: context.colors.onPrimaryContainer, ), ), - isAgent: profile?.ownerPubkey != null, + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index 4bfea3f2795..5ea4c041d78 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -231,6 +231,12 @@ class _DmAppBarTitle extends ConsumerWidget { } final avatarUrl = profile?.avatarUrl; + final isAgent = + (otherPubkey != null && + ref + .watch(agentMentionPubkeysProvider(channel.id)) + .contains(otherPubkey)) || + profile?.ownerPubkey != null; final animatedAvatar = parseAnimatedAvatarUrl(avatarUrl); final initial = profile?.initial ?? @@ -249,7 +255,10 @@ class _DmAppBarTitle extends ConsumerWidget { key: const ValueKey('dm-header-avatar'), size: _dmHeaderAvatarSize, geometry: AvatarBadgeMaskGeometry.presenceDot, - avatar: ClipOval( + avatar: ClipRRect( + borderRadius: BorderRadius.circular( + isAgent ? _dmHeaderAvatarSize * 0.3 : _dmHeaderAvatarSize / 2, + ), child: ColoredBox( color: animatedAvatar == null ? context.colors.primaryContainer diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 3917f702e3d..18bbfb67037 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -34,6 +34,9 @@ class _MessageBubble extends HookConsumerWidget { ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); final displayName = profile?.label ?? shortPubkey(message.pubkey); + final isAgent = + ref.watch(agentMentionPubkeysProvider(currentChannelId)).contains(pk) || + profile?.ownerPubkey != null; final canManageMessage = currentPubkey?.toLowerCase() == pk || (profile?.ownerPubkey != null && @@ -148,6 +151,7 @@ class _MessageBubble extends HookConsumerWidget { child: _UserAvatar( profile: profile, pubkey: message.pubkey, + isAgent: isAgent, ), ) else @@ -318,11 +322,13 @@ Widget _messageTimestamp(BuildContext context, int createdAt, {Key? key}) { class _UserAvatar extends StatelessWidget { final UserProfile? profile; final String pubkey; + final bool isAgent; final double size; const _UserAvatar({ required this.profile, required this.pubkey, + required this.isAgent, this.size = messageAvatarSize, }); @@ -347,7 +353,7 @@ class _UserAvatar extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - isAgent: profile?.ownerPubkey != null, + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index 5aaca11f4a6..8084d6c01ba 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -382,6 +382,8 @@ class _MessageStyleSystemMessageContent extends StatelessWidget { child: _UserAvatar( profile: userCache[displayPubkey.toLowerCase()], pubkey: displayPubkey, + isAgent: + userCache[displayPubkey.toLowerCase()]?.ownerPubkey != null, size: messageAvatarSize, ), ), diff --git a/mobile/lib/features/channels/thread_detail_page/avatar.dart b/mobile/lib/features/channels/thread_detail_page/avatar.dart index 996064386ce..c5c14acce79 100644 --- a/mobile/lib/features/channels/thread_detail_page/avatar.dart +++ b/mobile/lib/features/channels/thread_detail_page/avatar.dart @@ -3,8 +3,13 @@ part of '../thread_detail_page.dart'; class _Avatar extends StatelessWidget { final UserProfile? profile; final String pubkey; + final bool isAgent; - const _Avatar({required this.profile, required this.pubkey}); + const _Avatar({ + required this.profile, + required this.pubkey, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -23,7 +28,7 @@ class _Avatar extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - isAgent: profile?.ownerPubkey != null, + isAgent: isAgent, ); } } diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 89bdd40998f..61cab82fadf 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -40,6 +40,9 @@ class _ThreadMessage extends HookConsumerWidget { ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); final displayName = profile?.label ?? shortPubkey(message.pubkey); + final isAgent = + ref.watch(agentMentionPubkeysProvider(channelId)).contains(pk) || + profile?.ownerPubkey != null; final canManageMessage = currentPubkey?.toLowerCase() == pk || (profile?.ownerPubkey != null && @@ -158,6 +161,7 @@ class _ThreadMessage extends HookConsumerWidget { child: _Avatar( profile: profile, pubkey: message.pubkey, + isAgent: isAgent, ), ) else diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index 6e7a69e6315..0a2d2de3e89 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -54,6 +54,9 @@ class ForumPostCard extends HookConsumerWidget { ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); final displayName = profile?.label ?? _shortPubkey(post.pubkey); + final isAgent = + ref.watch(agentMentionPubkeysProvider(post.channelId)).contains(pk) || + profile?.ownerPubkey != null; final profileMentionNames = ref.watch( userCacheProvider.select( (cache) => _buildMentionNames(post.mentionPubkeys, cache), @@ -112,7 +115,11 @@ class ForumPostCard extends HookConsumerWidget { GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => showUserProfileSheet(context, post.pubkey), - child: _PostAvatar(profile: profile, pubkey: post.pubkey), + child: _PostAvatar( + profile: profile, + pubkey: post.pubkey, + isAgent: isAgent, + ), ), const SizedBox(width: Grid.xxs), Expanded( @@ -308,8 +315,13 @@ class ForumPostCard extends HookConsumerWidget { class _PostAvatar extends StatelessWidget { final UserProfile? profile; final String pubkey; + final bool isAgent; - const _PostAvatar({required this.profile, required this.pubkey}); + const _PostAvatar({ + required this.profile, + required this.pubkey, + required this.isAgent, + }); @override Widget build(BuildContext context) { @@ -328,7 +340,7 @@ class _PostAvatar extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - isAgent: profile?.ownerPubkey != null, + isAgent: isAgent, ); } } diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index f770e1ccb11..767e15278f9 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -11,6 +11,7 @@ import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_detail_page.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/channels_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/read_state/read_state_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; @@ -120,6 +121,7 @@ void main() { ValueListenable? tabReselection, List drafts = const [], List reminders = const [], + Set knownAgentPubkeys = const {}, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -135,6 +137,7 @@ void main() { userCacheProvider.overrideWith( () => _FakeUserCacheNotifier(users ?? testUsers), ), + knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), readStateProvider.overrideWith( () => _FakeReadStateNotifier(readContexts), ), @@ -533,6 +536,26 @@ void main() { ); }); + testWidgets('directory-known Activity authors use agent avatars', ( + tester, + ) async { + await tester.pumpWidget( + await buildTestable(knownAgentPubkeys: const {'agent_pk'}), + ); + await tester.pumpAndSettle(); + + final agentRow = find.byKey(const ValueKey('inbox-row-ag1')); + final agentAvatar = tester.widget( + find.descendant(of: agentRow, matching: find.byType(AvatarImage)), + ); + final humanRow = find.byKey(const ValueKey('inbox-row-m1')); + final humanAvatar = tester.widget( + find.descendant(of: humanRow, matching: find.byType(AvatarImage)), + ); + expect(agentAvatar.isAgent, isTrue); + expect(humanAvatar.isAgent, isFalse); + }); + testWidgets('multiple top-level messages in one DM render one row', ( tester, ) async { diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 85ab76a14d7..21fa3d124ec 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -477,6 +477,47 @@ void main() { }); group('ChannelDetailPage', () { + testWidgets( + 'bot-role author avatars stay squircles in channel and thread', + (tester) async { + final message = _textMsg( + id: 'bot-message', + pubkey: 'bot', + content: 'Bot message', + ); + await tester.pumpWidget( + _buildTestable( + messages: [message], + users: const { + 'bot': UserProfile(pubkey: 'bot', displayName: 'Bot'), + }, + loadChannelBotPubkeys: () async => const {'bot'}, + threadReplies: const {'bot-message': []}, + ), + ); + await tester.pumpAndSettle(); + + AvatarImage avatarIn(Finder row) => tester.widget( + find.descendant(of: row, matching: find.byType(AvatarImage)), + ); + expect( + avatarIn( + find.byKey(const ValueKey('message-row-bot-message')), + ).isAgent, + isTrue, + ); + + await tester.tap(find.byKey(const ValueKey('message-row-bot-message'))); + await tester.pumpAndSettle(); + expect( + avatarIn( + find.byKey(const ValueKey('thread-message-row-bot-message')), + ).isAgent, + isTrue, + ); + }, + ); + testWidgets('uses the shared 32px masked presence avatar in DM headers', ( tester, ) async { @@ -510,6 +551,17 @@ void main() { expect(tester.getSize(avatarFinder), const Size.square(32)); expect(avatar.geometry, AvatarBadgeMaskGeometry.presenceDot); expect(avatar.badge, isNotNull); + expect( + tester + .widget( + find.descendant( + of: avatarFinder, + matching: find.byType(ClipRRect), + ), + ) + .borderRadius, + BorderRadius.circular(16), + ); expect( find.descendant(of: avatarFinder, matching: find.byType(ClipPath)), findsOneWidget, @@ -528,6 +580,61 @@ void main() { expect(find.byTooltip('Start Huddle'), findsOneWidget); }); + testWidgets('uses a fallback squircle for bot-role DM participants', ( + tester, + ) async { + final dmChannel = Channel( + id: _channelId, + name: 'Bot DM', + channelType: 'dm', + visibility: 'private', + description: 'Direct message with a bot', + createdBy: 'self', + createdAt: DateTime(2025), + memberCount: 2, + participants: const ['Self', 'Bot'], + participantPubkeys: const ['self', 'bot'], + isMember: true, + ); + + await tester.pumpWidget( + _buildTestable( + messages: const [], + channel: dmChannel, + loadChannelBotPubkeys: () async => const {'bot'}, + ), + ); + await tester.pumpAndSettle(); + + final avatarFinder = find.byKey(const ValueKey('dm-header-avatar')); + expect( + tester + .widget( + find.descendant( + of: avatarFinder, + matching: find.byType(ClipRRect), + ), + ) + .borderRadius, + BorderRadius.circular(9.6), + ); + expect( + tester + .widget( + find.descendant( + of: avatarFinder, + matching: find.byType(AvatarImageContent), + ), + ) + .imageUrl, + isNull, + ); + expect( + find.descendant(of: avatarFinder, matching: find.byType(ClipPath)), + findsOneWidget, + ); + }); + testWidgets('hides the Huddle action in a one-to-one agent DM', ( tester, ) async { @@ -560,6 +667,18 @@ void main() { ); await tester.pumpAndSettle(); + final avatarFinder = find.byKey(const ValueKey('dm-header-avatar')); + expect( + tester + .widget( + find.descendant( + of: avatarFinder, + matching: find.byType(ClipRRect), + ), + ) + .borderRadius, + BorderRadius.circular(9.6), + ); expect(find.byKey(const ValueKey('channel-huddle-button')), findsNothing); expect(find.byTooltip('Start Huddle'), findsNothing); }); diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index 939a25e1397..a13a18fff7f 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -8,10 +8,12 @@ import 'package:buzz/features/forum/forum_posts_view.dart'; import 'package:buzz/features/forum/forum_provider.dart'; import 'package:buzz/features/forum/forum_thread_page.dart'; import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/profile/user_profile.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:shared_preferences/shared_preferences.dart'; const _channelId = 'forum-channel'; @@ -62,10 +64,12 @@ Widget _buildPostCard({ VoidCallback? onTap, void Function(String)? onDelete, TextScaler textScaler = TextScaler.noScaling, + Set knownAgentPubkeys = const {}, }) { return ProviderScope( overrides: [ userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), + knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), ], child: MaterialApp( theme: AppTheme.light(), @@ -207,6 +211,38 @@ void main() { expect(find.text('abcdef12\u2026'), findsOneWidget); }); + testWidgets('uses directory classification for uncached author avatar', ( + tester, + ) async { + await tester.pumpWidget( + _buildPostCard( + post: _makePost(pubkey: 'directory-agent'), + knownAgentPubkeys: const {'directory-agent'}, + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(AvatarImage)).isAgent, + isTrue, + ); + }); + + testWidgets('keeps human author avatar circular', (tester) async { + await tester.pumpWidget( + _buildPostCard( + post: _makePost(), + users: const {'alice': _aliceProfile}, + ), + ); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byType(AvatarImage)).isAgent, + isFalse, + ); + }); + testWidgets( 'constrains an older timestamp at large accessible text sizes', (tester) async { From ff4ff57561d7516ba166335ec47b359f391c8adb Mon Sep 17 00:00:00 2001 From: Fizz Date: Mon, 31 Aug 2026 11:43:43 -0400 Subject: [PATCH 3/3] fix: classify forum thread agent avatars Signed-off-by: Fizz --- .../lib/features/forum/forum_thread_page.dart | 9 ++- .../features/forum/forum_widgets_test.dart | 81 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index 5b215e42ccc..f8c1d904bda 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -359,9 +359,11 @@ class _OriginalPost extends ConsumerWidget { GestureDetector( onTap: () => showUserProfileSheet(context, post.pubkey), child: _Avatar( + key: ValueKey('forum-original-avatar-${post.eventId}'), profile: profile, pubkey: post.pubkey, radius: 16, + isAgent: agentMentionPubkeys.contains(pk), ), ), const SizedBox(width: Grid.xxs), @@ -462,9 +464,11 @@ class _ReplyRow extends ConsumerWidget { GestureDetector( onTap: () => showUserProfileSheet(context, reply.pubkey), child: _Avatar( + key: ValueKey('forum-reply-avatar-${reply.eventId}'), profile: profile, pubkey: reply.pubkey, radius: 12, + isAgent: agentMentionPubkeys.contains(pk), ), ), const SizedBox(width: Grid.xxs), @@ -620,11 +624,14 @@ class _Avatar extends StatelessWidget { final UserProfile? profile; final String pubkey; final double radius; + final bool isAgent; const _Avatar({ + super.key, required this.profile, required this.pubkey, required this.radius, + required this.isAgent, }); @override @@ -645,7 +652,7 @@ class _Avatar extends StatelessWidget { color: context.colors.onPrimaryContainer, ), ), - isAgent: profile?.ownerPubkey != null, + isAgent: isAgent, ); } } diff --git a/mobile/test/features/forum/forum_widgets_test.dart b/mobile/test/features/forum/forum_widgets_test.dart index a13a18fff7f..338dbf36ac3 100644 --- a/mobile/test/features/forum/forum_widgets_test.dart +++ b/mobile/test/features/forum/forum_widgets_test.dart @@ -125,11 +125,17 @@ Widget _buildThreadPage({ bool isMember = true, bool isArchived = false, Map users = const {}, + Set knownAgentPubkeys = const {}, + Set channelBotPubkeys = const {}, TextScaler textScaler = TextScaler.noScaling, }) { return ProviderScope( overrides: [ userCacheProvider.overrideWith(() => _FakeUserCacheNotifier(users)), + knownAgentPubkeysProvider.overrideWithValue(knownAgentPubkeys), + channelBotPubkeysProvider( + _channelId, + ).overrideWith((ref) async => channelBotPubkeys), profileProvider.overrideWith(() => _FakeProfileNotifier()), forumThreadProvider(( channelId: _channelId, @@ -570,6 +576,81 @@ void main() { }); group('ForumThreadPage', () { + AvatarImage avatarIn(WidgetTester tester, Key key) => + tester.widget( + find.descendant( + of: find.byKey(key), + matching: find.byType(AvatarImage), + ), + ); + + testWidgets( + 'uses directory classification for an uncached original author', + (tester) async { + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(pubkey: 'directory-agent'), + replies: const [], + totalReplies: 0, + ), + knownAgentPubkeys: const {'directory-agent'}, + ), + ); + await tester.pumpAndSettle(); + + expect( + avatarIn( + tester, + const ValueKey('forum-original-avatar-post1'), + ).isAgent, + isTrue, + ); + }, + ); + + testWidgets('uses bot-role classification for an uncached reply author', ( + tester, + ) async { + await tester.pumpWidget( + _buildThreadPage( + threadResponse: ForumThreadResponse( + post: _makePost(), + replies: const [ + ThreadReply( + eventId: 'bot-reply', + pubkey: 'channel-bot', + content: 'Automated reply', + kind: 45003, + createdAt: 2000, + channelId: _channelId, + tags: [ + ['h', _channelId], + ], + depth: 1, + ), + ], + totalReplies: 1, + ), + users: const {'alice': _aliceProfile}, + channelBotPubkeys: const {'channel-bot'}, + ), + ); + await tester.pumpAndSettle(); + + expect( + avatarIn( + tester, + const ValueKey('forum-reply-avatar-bot-reply'), + ).isAgent, + isTrue, + ); + expect( + avatarIn(tester, const ValueKey('forum-original-avatar-post1')).isAgent, + isFalse, + ); + }); + testWidgets('shows original post and replies header', (tester) async { await tester.pumpWidget( _buildThreadPage(