diff --git a/crates/buzz-cli/src/commands/reactions.rs b/crates/buzz-cli/src/commands/reactions.rs index 9e23d30131..c8959f3ee6 100644 --- a/crates/buzz-cli/src/commands/reactions.rs +++ b/crates/buzz-cli/src/commands/reactions.rs @@ -53,10 +53,15 @@ pub async fn cmd_remove_reaction( .as_array() .ok_or_else(|| CliError::Other("reactions query response is not an array".into()))?; - // Find the reaction event matching the emoji + // Find the reaction event matching the emoji. Custom-emoji reactions store + // `:shortcode:` while callers often pass the bare shortcode. let reaction_event_id = arr .iter() - .find(|ev| ev.get("content").and_then(|c| c.as_str()) == Some(emoji)) + .find(|ev| { + ev.get("content") + .and_then(|c| c.as_str()) + .is_some_and(|content| buzz_sdk::reaction_content_matches(content, emoji)) + }) .and_then(|ev| ev.get("id").and_then(|id| id.as_str())) .ok_or_else(|| { CliError::Other(format!( diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 8cc9c8650a..3ec5b19637 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -149,6 +149,23 @@ pub fn normalize_custom_emoji_shortcode(shortcode: &str) -> Result bool { + let content = content.trim(); + let emoji = emoji.trim(); + if content == emoji { + return true; + } + match normalize_custom_emoji_shortcode(emoji) { + Ok(shortcode) => content == format!(":{shortcode}:"), + Err(_) => false, + } +} + fn check_custom_emoji_url(url: &str) -> Result<(), SdkError> { if url.is_empty() { return Err(SdkError::InvalidInput( @@ -2323,6 +2340,17 @@ mod tests { assert!(has_tag(&ev, "e", &eid.to_hex())); } + #[test] + fn reaction_content_matches_unicode_and_custom_shortcodes() { + assert!(reaction_content_matches("👍", "👍")); + assert!(reaction_content_matches(" 👍 ", "👍")); + assert!(reaction_content_matches(":party:", "party")); + assert!(reaction_content_matches(":party:", ":Party:")); + assert!(reaction_content_matches(":party_parrot:", "Party_Parrot")); + assert!(!reaction_content_matches(":party:", "other")); + assert!(!reaction_content_matches("👍", "party")); + } + #[test] fn set_canvas_happy_path() { let cid = uuid(); diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b7c37bec3d..153ed38315 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -911,7 +911,7 @@ pub async fn remove_reaction( let reaction_event = reactions .iter() - .find(|ev| ev.content.trim() == trimmed_emoji) + .find(|ev| buzz_sdk_pkg::reaction_content_matches(&ev.content, trimmed_emoji)) .ok_or("could not find your reaction event for this emoji")?; let builder = events::build_remove_reaction(reaction_event.id)?; diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index b3def930f1..cb3d77dd6f 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { activateWelcomeTeamPersonasSequentially, buildWelcomeStarterCreateInput, + filterWelcomeTeamAgentsMissingFromChannel, LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT, pickWelcomeGuideAgent, pickWelcomeGuideAgentForRelay, @@ -379,3 +380,52 @@ test("starter matching prefers running, then deployed instances", () => { deployed, ); }); + +test("filterWelcomeTeamAgentsMissingFromChannel skips same-named agent members", () => { + const localFizz = makeAgent({ name: "Fizz", pubkey: PUB_A }); + const localHoney = makeAgent({ name: "Honey", pubkey: PUB_B }); + const localBumble = makeAgent({ name: "Bumble", pubkey: PUB_C }); + + const members = [ + { + pubkey: "d".repeat(64), + role: "bot", + isAgent: true, + joinedAt: "2026-06-11T00:00:00.000Z", + displayName: "Fizz", + }, + { + pubkey: "e".repeat(64), + role: "bot", + isAgent: true, + joinedAt: "2026-06-11T00:00:00.000Z", + displayName: "honey", + }, + ]; + + const missing = filterWelcomeTeamAgentsMissingFromChannel( + [localFizz, localHoney, localBumble], + members, + ); + assert.deepEqual( + missing.map((agent) => agent.pubkey), + [PUB_C], + ); +}); + +test("filterWelcomeTeamAgentsMissingFromChannel skips pubkeys already present", () => { + const localFizz = makeAgent({ name: "Fizz", pubkey: PUB_A }); + const members = [ + { + pubkey: PUB_A, + role: "bot", + isAgent: true, + joinedAt: "2026-06-11T00:00:00.000Z", + displayName: "Fizz", + }, + ]; + assert.deepEqual( + filterWelcomeTeamAgentsMissingFromChannel([localFizz], members), + [], + ); +}); diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index aa8deb7c19..5ad9542d43 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -15,6 +15,7 @@ import { listPersonas, setPersonaActive } from "@/shared/api/tauriPersonas"; import type { AcpRuntime, AgentPersona, + ChannelMember, CreateManagedAgentInput, ManagedAgent, } from "@/shared/api/types"; @@ -183,11 +184,9 @@ async function ensureWelcomeTeamMembership( agents: WelcomeTeamAgents, ) { const members = await getChannelMembers(channelId).catch(() => []); - const memberPubkeys = new Set( - members.map((member) => normalizePubkey(member.pubkey)), - ); - const missingAgents = agents.filter( - (agent) => !memberPubkeys.has(normalizePubkey(agent.pubkey)), + const missingAgents = filterWelcomeTeamAgentsMissingFromChannel( + agents, + members, ); if (missingAgents.length === 0) { return; @@ -206,6 +205,35 @@ async function ensureWelcomeTeamMembership( } } +/** + * Welcome Team agents that still need channel membership. + * + * Skips pubkeys already in the channel, and skips local starters whose display + * name is already used by another agent member — a second Desktop install must + * not mint parallel Fizz/Honey/Bumble identities into the shared roster (#2648). + */ +export function filterWelcomeTeamAgentsMissingFromChannel( + agents: readonly ManagedAgent[], + members: readonly ChannelMember[], +): ManagedAgent[] { + const memberPubkeys = new Set( + members.map((member) => normalizePubkey(member.pubkey)), + ); + const occupiedAgentNames = new Set( + members + .filter((member) => member.isAgent) + .map((member) => member.displayName?.trim().toLowerCase()) + .filter((name): name is string => Boolean(name)), + ); + return agents.filter((agent) => { + if (memberPubkeys.has(normalizePubkey(agent.pubkey))) { + return false; + } + const name = agent.name.trim().toLowerCase(); + return !occupiedAgentNames.has(name); + }); +} + export async function buildWelcomeStarterCreateInput( starter: WelcomeTeamStarterDefinition, persona: AgentPersona, diff --git a/desktop/src/features/profile/ui/ProfileAvatar.tsx b/desktop/src/features/profile/ui/ProfileAvatar.tsx index 3153fb4be1..897ed64879 100644 --- a/desktop/src/features/profile/ui/ProfileAvatar.tsx +++ b/desktop/src/features/profile/ui/ProfileAvatar.tsx @@ -48,6 +48,14 @@ export function ProfileAvatar({ // the poster and hover animation can recover independently. const liveSrc = baseUrl ? rewriteRelayUrl(baseUrl) : null; const [failedSrc, setFailedSrc] = React.useState(null); + // Adjust failure state when the candidate URL changes so a transient relay + // blip can recover without remounting (e.g. visiting Settings). + const [trackedLiveSrc, setTrackedLiveSrc] = React.useState(liveSrc); + if (liveSrc !== trackedLiveSrc) { + setTrackedLiveSrc(liveSrc); + setFailedSrc(null); + } + const liveFailed = liveSrc !== null && failedSrc === liveSrc; // When the relay is unreachable the proxied avatar URL 404s/times out; fall @@ -55,7 +63,9 @@ export function ProfileAvatar({ const src = liveFailed ? (avatarDataUrl ?? undefined) : (liveSrc ?? avatarDataUrl ?? undefined); - const shouldShowFallback = src === undefined || (!animated && liveFailed); + // Monogram only when there is no image candidate. A live failure that falls + // back to avatarDataUrl must still render that image, not initials (#2665). + const shouldShowFallback = src === undefined; return ( { - if (status === "error") setFailedSrc(liveSrc); + if (status === "error" && liveSrc !== null && src === liveSrc) { + setFailedSrc(liveSrc); + } + // Only clear on a successful live load — clearing after a data-URL + // fallback would immediately retry the broken live URL in a loop. if (status === "loaded" && src === liveSrc) { setFailedSrc(null); }