From 40070a58559938ed649950ccabce0725dd3c966e Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 23 Jun 2026 09:07:50 -0700 Subject: [PATCH 01/30] fix(desktop): show NIP-OA owners in profile pane (#1198) Signed-off-by: Wes Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> --- desktop/src-tauri/src/commands/profile.rs | 1 + desktop/src-tauri/src/models.rs | 3 ++ desktop/src-tauri/src/nostr_convert.rs | 43 +++++++++++----- .../src/nostr_convert/user_search.rs | 12 ++++- .../src/features/channels/ui/ChannelPane.tsx | 1 + .../features/channels/ui/MembersSidebar.tsx | 3 ++ desktop/src/features/profile/hooks.ts | 1 + desktop/src/features/profile/lib/identity.ts | 2 + .../profile/lib/userCandidateSearch.test.mjs | 1 + .../features/profile/ui/UserProfilePanel.tsx | 40 +++++++++++++-- .../profile/ui/UserProfilePanelSections.tsx | 49 ++++++++++++++++++- desktop/src/features/pulse/ui/PulseScreen.tsx | 1 + .../sidebar/ui/NewDirectMessageDialog.tsx | 3 ++ desktop/src/shared/api/tauri.ts | 17 +++---- desktop/src/shared/api/types.ts | 3 ++ desktop/src/testing/e2eBridge.ts | 37 ++++++++++++-- 16 files changed, 183 insertions(+), 34 deletions(-) diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index e218ca36e..64cc6256d 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -296,5 +296,6 @@ fn empty_profile_info(pubkey: &str) -> ProfileInfo { avatar_url: None, about: None, nip05_handle: None, + owner_pubkey: None, } } diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 99007d0c4..dc6f03ca8 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -15,6 +15,7 @@ pub struct ProfileInfo { pub avatar_url: Option, pub about: Option, pub nip05_handle: Option, + pub owner_pubkey: Option, } #[derive(Serialize, Deserialize)] @@ -22,6 +23,7 @@ pub struct UserProfileSummaryInfo { pub display_name: Option, pub avatar_url: Option, pub nip05_handle: Option, + pub owner_pubkey: Option, #[serde(default)] pub is_agent: bool, } @@ -38,6 +40,7 @@ pub struct UserSearchResultInfo { pub display_name: Option, pub avatar_url: Option, pub nip05_handle: Option, + pub owner_pubkey: Option, #[serde(default)] pub is_agent: bool, } diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index aeb7f8c59..0ef035f48 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -55,16 +55,16 @@ fn tags_named<'a>(event: &'a Event, name: &'a str) -> impl Iterator bool { +pub(crate) fn profile_valid_oa_owner_pubkey(event: &Event) -> Option { let target_hex = event.pubkey.to_hex(); let Ok(target_pubkey) = nostr::PublicKey::from_hex(&target_hex) else { - return false; + return None; }; for tag in event.tags.iter() { @@ -75,12 +75,16 @@ pub(crate) fn profile_has_valid_oa_owner(event: &Event) -> bool { let Ok(json) = serde_json::to_string(slice) else { continue; }; - if buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &target_pubkey).is_ok() { - return true; + if let Ok(owner_pubkey) = buzz_sdk_pkg::nip_oa::verify_auth_tag(&json, &target_pubkey) { + return Some(owner_pubkey.to_hex()); } } - false + None +} + +pub(crate) fn profile_has_valid_oa_owner(event: &Event) -> bool { + profile_valid_oa_owner_pubkey(event).is_some() } // ── kind:39000 / 39002 (NIP-29) ───────────────────────────────────────────── @@ -299,6 +303,7 @@ pub fn profile_info_from_event(event: &Event) -> Result { avatar_url, about, nip05_handle, + owner_pubkey: profile_valid_oa_owner_pubkey(event), }) } @@ -326,6 +331,7 @@ pub fn users_batch_from_events( let mut profiles = HashMap::new(); for (pk, ev) in &latest { let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null); + let owner_pubkey = profile_valid_oa_owner_pubkey(ev); let summary = UserProfileSummaryInfo { display_name: v .get("display_name") @@ -334,7 +340,8 @@ pub fn users_batch_from_events( .map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), - is_agent: profile_has_valid_oa_owner(ev), + is_agent: owner_pubkey.is_some(), + owner_pubkey, }; profiles.insert(pk.clone(), summary); } @@ -586,7 +593,7 @@ mod tests { } /// Build a kind:0 profile with a valid NIP-OA auth tag. - fn oa_profile_event(content: &str) -> Event { + fn oa_profile_event(content: &str) -> (Event, String) { let agent_keys = Keys::generate(); let owner_keys = Keys::generate(); let agent_pubkey = agent_keys.public_key(); @@ -595,10 +602,11 @@ mod tests { let tag_values: Vec = serde_json::from_str(&tag_json).expect("parse auth tag json"); let auth_tag = Tag::parse(tag_values).expect("parse auth tag"); - EventBuilder::new(Kind::Metadata, content) + let event = EventBuilder::new(Kind::Metadata, content) .tags(vec![auth_tag]) .sign_with_keys(&agent_keys) - .expect("sign") + .expect("sign"); + (event, owner_keys.public_key().to_hex()) } #[test] @@ -760,6 +768,15 @@ mod tests { assert_eq!(p.about.as_deref(), Some("hi")); assert_eq!(p.nip05_handle.as_deref(), Some("alice@x")); assert_eq!(p.pubkey, e.pubkey.to_hex()); + assert!(p.owner_pubkey.is_none()); + } + + #[test] + fn profile_info_extracts_valid_nip_oa_owner() { + let (event, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); + let p = profile_info_from_event(&event).unwrap(); + + assert_eq!(p.owner_pubkey.as_deref(), Some(owner_pubkey.as_str())); } #[test] @@ -803,12 +820,16 @@ mod tests { #[test] fn users_batch_marks_valid_nip_oa_profiles_as_agents() { - let agent = oa_profile_event(r#"{"display_name":"Mira"}"#); + let (agent, owner_pubkey) = oa_profile_event(r#"{"display_name":"Mira"}"#); let pubkey = agent.pubkey.to_hex(); let resp = users_batch_from_events(std::slice::from_ref(&agent), std::slice::from_ref(&pubkey)); assert!(resp.profiles[&pubkey].is_agent); + assert_eq!( + resp.profiles[&pubkey].owner_pubkey.as_deref(), + Some(owner_pubkey.as_str()) + ); } #[test] diff --git a/desktop/src-tauri/src/nostr_convert/user_search.rs b/desktop/src-tauri/src/nostr_convert/user_search.rs index 85a68df14..c170d895c 100644 --- a/desktop/src-tauri/src/nostr_convert/user_search.rs +++ b/desktop/src-tauri/src/nostr_convert/user_search.rs @@ -5,11 +5,12 @@ use serde_json::Value; use crate::models::{SearchUsersResponse, UserSearchResultInfo}; -use super::profile_has_valid_oa_owner; +use super::profile_valid_oa_owner_pubkey; /// Convert a single kind:0 event to a [`UserSearchResultInfo`]. pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo { let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null); + let owner_pubkey = profile_valid_oa_owner_pubkey(ev); UserSearchResultInfo { pubkey: ev.pubkey.to_hex(), display_name: v @@ -19,7 +20,8 @@ pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo { .map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), - is_agent: profile_has_valid_oa_owner(ev), + is_agent: owner_pubkey.is_some(), + owner_pubkey, } } @@ -224,9 +226,15 @@ mod tests { #[test] fn user_search_result_marks_valid_nip_oa_profile_as_agent() { let event = oa_profile_event(r#"{"display_name":"Mira"}"#); + let owner_pubkey = event + .tags + .iter() + .find_map(|tag| tag.as_slice().get(1).cloned()) + .expect("owner pubkey"); let result = user_search_result_from_event(&event); assert_eq!(result.display_name.as_deref(), Some("Mira")); + assert_eq!(result.owner_pubkey.as_deref(), Some(owner_pubkey.as_str())); assert!(result.is_agent); } diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 79c5e7939..5fd7641fe 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -948,6 +948,7 @@ export const ChannelPane = React.memo(function ChannelPane({ layout={useSplitAuxiliaryPane ? "split" : "standalone"} onClose={onCloseProfilePanel} onOpenDm={onOpenDm} + onOpenProfile={onOpenProfilePanel} onViewChange={onProfilePanelViewChange} pubkey={profilePanelPubkey} splitPaneClamp diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index b29f19e75..37741cb92 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -247,6 +247,7 @@ export function MembersSidebar({ ? currentName : (currentName ?? candidateName), nip05Handle: current.nip05Handle ?? candidate.nip05Handle ?? null, + ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? null, isAgent: current.isAgent || candidate.isAgent, }); }; @@ -265,6 +266,7 @@ export function MembersSidebar({ displayName: agent.name, avatarUrl: null, nip05Handle: null, + ownerPubkey: null, isAgent: true, }); } @@ -275,6 +277,7 @@ export function MembersSidebar({ displayName: agent.name, avatarUrl: null, nip05Handle: null, + ownerPubkey: null, isAgent: true, }); } diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index 4c4f86143..9ba5273bd 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -104,6 +104,7 @@ export function useProfileQuery(enabled = true) { avatarUrl: cached.avatarUrl, about: null, nip05Handle: null, + ownerPubkey: null, } satisfies Profile) : undefined, [cached, pubkey], diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index 69c849651..491883a0a 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -36,6 +36,8 @@ export function mergeCurrentProfileIntoLookup( avatarUrl: currentProfile.avatarUrl, nip05Handle: currentProfile.nip05Handle, isAgent: profiles?.[normalizePubkey(currentProfile.pubkey)]?.isAgent, + ownerPubkey: + profiles?.[normalizePubkey(currentProfile.pubkey)]?.ownerPubkey ?? null, }, }; } diff --git a/desktop/src/features/profile/lib/userCandidateSearch.test.mjs b/desktop/src/features/profile/lib/userCandidateSearch.test.mjs index d8b1a2990..210e92758 100644 --- a/desktop/src/features/profile/lib/userCandidateSearch.test.mjs +++ b/desktop/src/features/profile/lib/userCandidateSearch.test.mjs @@ -13,6 +13,7 @@ function makeUser(overrides = {}) { displayName: null, isAgent: false, nip05Handle: null, + ownerPubkey: null, pubkey: "abcdef1234567890", ...overrides, }; diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 930e73a94..e84dba7ad 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -56,6 +56,7 @@ type UserProfilePanelProps = { layout?: "standalone" | "split"; onClose: () => void; onOpenDm?: (pubkeys: string[]) => void; + onOpenProfile?: (pubkey: string) => void; onResetWidth?: () => void; onResizeStart?: (event: React.PointerEvent) => void; onViewChange: ( @@ -136,6 +137,7 @@ export function UserProfilePanel({ layout = "standalone", onClose, onOpenDm, + onOpenProfile, onResetWidth, onResizeStart, onViewChange, @@ -172,6 +174,8 @@ export function UserProfilePanel({ const { goChannel } = useAppNavigation(); const profile = profileQuery.data; + const ownerPubkey = profile?.ownerPubkey ?? null; + const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined); const pubkeyLower = pubkey.toLowerCase(); const presenceStatus = presenceQuery.data?.[pubkeyLower]; const userStatus = userStatusQuery.data?.[pubkeyLower]; @@ -252,7 +256,16 @@ export function UserProfilePanel({ const displayName = profile?.displayName ?? truncatePubkey(pubkey); const ownerHandle = React.useMemo(() => { - if (currentPubkey === undefined) { + if (ownerPubkey) { + const ownerProfile = ownerProfileQuery.data; + return ( + ownerProfile?.nip05Handle?.trim() || + ownerProfile?.displayName?.trim() || + truncatePubkey(ownerPubkey) + ); + } + + if (currentPubkey === undefined || isOwner !== true) { return null; } @@ -262,8 +275,22 @@ export function UserProfilePanel({ currentProfile?.displayName?.trim() || truncatePubkey(currentPubkey) ); - }, [currentProfileQuery.data, currentPubkey]); - const ownerDisplayName = ownerHandle ? `${ownerHandle} (you)` : null; + }, [ + currentProfileQuery.data, + currentPubkey, + isOwner, + ownerProfileQuery.data, + ownerPubkey, + ]); + const isCurrentUserOwner = + currentPubkey !== undefined && + ownerPubkey !== null && + ownerPubkey.toLowerCase() === currentPubkey.toLowerCase(); + const ownerDisplayName = ownerHandle + ? isCurrentUserOwner || (!ownerPubkey && isOwner === true) + ? `${ownerHandle} (you)` + : ownerHandle + : null; const panelTitle = VIEW_TITLES[view]; const memoryCount = memoryQuery.data ? (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length @@ -334,8 +361,15 @@ export function UserProfilePanel({ memoriesLoading={memoryQuery.isLoading} memoryCount={memoryCount} ownerDisplayName={ownerDisplayName} + ownerAvatarUrl={ownerProfileQuery.data?.avatarUrl ?? null} ownerHandle={ownerHandle} + ownerPubkey={ownerPubkey} onOpenChannels={() => onViewChange("channels")} + onOpenOwner={ + ownerPubkey && onOpenProfile + ? () => onOpenProfile(ownerPubkey) + : undefined + } onOpenMemories={() => onViewChange("memories")} onOpenDm={onOpenDm} presenceLoaded={presenceQuery.isSuccess} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 3baa46ea2..b00a98673 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -42,6 +42,7 @@ import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; import { useNow } from "@/shared/lib/useNow"; import { Badge } from "@/shared/ui/badge"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; const RUNTIME_LABELS: Record = { goose: "Goose", @@ -80,8 +81,11 @@ export type ProfileSummaryViewProps = { memoriesLoading: boolean; memoryCount: number | undefined; ownerDisplayName: string | null; + ownerAvatarUrl: string | null; ownerHandle: string | null; + ownerPubkey: string | null; onOpenChannels: () => void; + onOpenOwner?: () => void; onOpenMemories: () => void; onOpenDm?: (pubkeys: string[]) => void; presenceLoaded: boolean; @@ -112,8 +116,11 @@ export function ProfileSummaryView({ memoriesLoading, memoryCount, ownerDisplayName, + ownerAvatarUrl, ownerHandle, + ownerPubkey, onOpenChannels, + onOpenOwner, onOpenMemories, onOpenDm, presenceLoaded, @@ -134,11 +141,15 @@ export function ProfileSummaryView({ relayAgent, isBot, }), - ...(isOwner === true + ...(ownerDisplayName || isOwner === true ? buildOwnerFields({ + includeOperationalFields: isOwner === true, managedAgent, ownerDisplayName, + ownerAvatarUrl, ownerHandle, + ownerPubkey, + onOpenOwner, presenceLoaded, presenceStatus, relayAgent, @@ -594,16 +605,24 @@ function buildPublicFields({ } function buildOwnerFields({ + includeOperationalFields, managedAgent, ownerDisplayName, + ownerAvatarUrl, ownerHandle, + ownerPubkey, + onOpenOwner, presenceLoaded, presenceStatus, relayAgent, }: { + includeOperationalFields: boolean; managedAgent: ManagedAgent | undefined; ownerDisplayName: string | null; + ownerAvatarUrl: string | null; ownerHandle: string | null; + ownerPubkey: string | null; + onOpenOwner?: () => void; presenceLoaded: boolean; presenceStatus: "online" | "away" | "offline" | undefined; relayAgent: RelayAgent | undefined; @@ -612,14 +631,40 @@ function buildOwnerFields({ if (ownerDisplayName) { fields.push({ - copyValue: ownerHandle ?? undefined, + copyValue: onOpenOwner + ? undefined + : (ownerPubkey ?? ownerHandle ?? undefined), displayValue: ownerDisplayName, + displayNode: onOpenOwner ? ( + + ) : undefined, icon: UserRound, label: "Owned by", testId: "user-profile-owned-by", }); } + if (!includeOperationalFields) { + return fields; + } + if (managedAgent?.agentCommand) { fields.push({ copyValue: managedAgent.agentCommand, diff --git a/desktop/src/features/pulse/ui/PulseScreen.tsx b/desktop/src/features/pulse/ui/PulseScreen.tsx index 6a0563ec8..1baef3b14 100644 --- a/desktop/src/features/pulse/ui/PulseScreen.tsx +++ b/desktop/src/features/pulse/ui/PulseScreen.tsx @@ -59,6 +59,7 @@ export function PulseScreen() { currentPubkey={identityQuery.data?.pubkey} onClose={handleCloseProfilePanel} onOpenDm={handleOpenDm} + onOpenProfile={handleOpenProfilePanel} onResetWidth={threadPanelWidth.onResetWidth} onResizeStart={threadPanelWidth.onResizeStart} onViewChange={handleProfilePanelViewChange} diff --git a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx index 3430ffdc2..64b65fa3f 100644 --- a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx +++ b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx @@ -316,6 +316,7 @@ export function NewDirectMessageDialog({ ? currentName : (currentName ?? candidateName), nip05Handle: current.nip05Handle ?? candidate.nip05Handle ?? null, + ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? null, isAgent: current.isAgent || candidate.isAgent, }); }; @@ -334,6 +335,7 @@ export function NewDirectMessageDialog({ displayName: agent.name, avatarUrl: null, nip05Handle: null, + ownerPubkey: null, isAgent: true, }); } @@ -344,6 +346,7 @@ export function NewDirectMessageDialog({ displayName: agent.name, avatarUrl: null, nip05Handle: null, + ownerPubkey: null, isAgent: true, }); } diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 11e25da4d..6afa9d8f3 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -56,12 +56,10 @@ type RawProfile = { avatar_url: string | null; about: string | null; nip05_handle: string | null; + owner_pubkey: string | null; }; -type RawUserProfileSummary = { - display_name: string | null; - avatar_url: string | null; - nip05_handle: string | null; +type RawUserProfileSummary = Omit & { is_agent?: boolean; }; @@ -70,13 +68,7 @@ type RawUsersBatchResponse = { missing: string[]; }; -type RawUserSearchResult = { - pubkey: string; - display_name: string | null; - avatar_url: string | null; - nip05_handle: string | null; - is_agent?: boolean; -}; +type RawUserSearchResult = RawUserProfileSummary & { pubkey: string }; type RawSearchUsersResponse = { users: RawUserSearchResult[]; @@ -426,6 +418,7 @@ function fromRawProfile(profile: RawProfile): Profile { avatarUrl: profile.avatar_url, about: profile.about, nip05Handle: profile.nip05_handle, + ownerPubkey: profile.owner_pubkey, }; } @@ -436,6 +429,7 @@ function fromRawUserProfileSummary( displayName: profile.display_name, avatarUrl: profile.avatar_url, nip05Handle: profile.nip05_handle, + ownerPubkey: profile.owner_pubkey, isAgent: profile.is_agent ?? false, }; } @@ -446,6 +440,7 @@ function fromRawUserSearchResult(user: RawUserSearchResult): UserSearchResult { displayName: user.display_name, avatarUrl: user.avatar_url, nip05Handle: user.nip05_handle, + ownerPubkey: user.owner_pubkey, isAgent: user.is_agent ?? false, }; } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 653a1be99..1ca74eca3 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -114,12 +114,14 @@ export type Profile = { avatarUrl: string | null; about: string | null; nip05Handle: string | null; + ownerPubkey: string | null; }; export type UserProfileSummary = { displayName: string | null; avatarUrl: string | null; nip05Handle: string | null; + ownerPubkey: string | null; isAgent?: boolean; }; @@ -133,6 +135,7 @@ export type UserSearchResult = { displayName: string | null; avatarUrl: string | null; nip05Handle: string | null; + ownerPubkey: string | null; isAgent: boolean; }; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5fa2c2095..04f8bfe98 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -52,6 +52,7 @@ type MockSearchProfileSeed = { avatarUrl?: string | null; nip05Handle?: string | null; about?: string | null; + ownerPubkey?: string | null; isAgent?: boolean; }; @@ -136,6 +137,7 @@ type RawProfile = { avatar_url: string | null; about: string | null; nip05_handle: string | null; + owner_pubkey: string | null; is_agent?: boolean; }; @@ -143,6 +145,7 @@ type RawUserProfileSummary = { display_name: string | null; avatar_url: string | null; nip05_handle: string | null; + owner_pubkey: string | null; is_agent?: boolean; }; @@ -156,6 +159,7 @@ type RawUserSearchResult = { display_name: string | null; avatar_url: string | null; nip05_handle: string | null; + owner_pubkey: string | null; is_agent?: boolean; }; @@ -1118,6 +1122,7 @@ function seedMockSearchProfiles(config?: E2eConfig) { avatar_url: seed.avatarUrl ?? null, about: seed.about ?? null, nip05_handle: seed.nip05Handle ?? null, + owner_pubkey: seed.ownerPubkey ?? null, is_agent: seed.isAgent ?? false, }; mockProfiles.set(pubkey, profile); @@ -1145,6 +1150,7 @@ function getMockProfileByPubkey(pubkey: string): RawProfile | null { avatar_url: null, about: null, nip05_handle: null, + owner_pubkey: null, is_agent: mockAgentPubkeys.has(normalizedPubkey), }; } @@ -1811,6 +1817,7 @@ const mockProfiles = new Map([ avatar_url: null, about: null, nip05_handle: null, + owner_pubkey: null, is_agent: false, }, ], @@ -1822,6 +1829,7 @@ const mockProfiles = new Map([ avatar_url: null, about: null, nip05_handle: null, + owner_pubkey: MOCK_IDENTITY_PUBKEY, is_agent: true, }, ], @@ -1947,6 +1955,7 @@ function importMockIdentity(nsec: string) { avatar_url: null, about: null, nip05_handle: null, + owner_pubkey: null, }); } @@ -1993,6 +2002,7 @@ function ensureMockProfile(config: E2eConfig | undefined): RawProfile { avatar_url: null, about: null, nip05_handle: null, + owner_pubkey: null, }; mockProfiles.set(pubkey, profile); return profile; @@ -3078,7 +3088,8 @@ async function handleGetProfile(config: E2eConfig | undefined) { display_name: null, about: null, avatar_url: null, - nip05: null, + nip05_handle: null, + owner_pubkey: null, }; } const content = JSON.parse(events[0].content ?? "{}"); @@ -3087,7 +3098,8 @@ async function handleGetProfile(config: E2eConfig | undefined) { display_name: content.display_name ?? content.name ?? null, about: content.about ?? null, avatar_url: content.picture ?? null, - nip05: content.nip05 ?? null, + nip05_handle: content.nip05 ?? null, + owner_pubkey: null, }; } @@ -3170,7 +3182,8 @@ async function handleUpdateProfile( display_name: updated.display_name ?? null, about: updated.about ?? null, avatar_url: updated.picture ?? null, - nip05: updated.nip05 ?? null, + nip05_handle: updated.nip05 ?? null, + owner_pubkey: null, }; } @@ -3201,7 +3214,8 @@ async function handleGetUserProfile( display_name: null, about: null, avatar_url: null, - nip05: null, + nip05_handle: null, + owner_pubkey: null, }; } const content = JSON.parse(events[0].content ?? "{}"); @@ -3210,7 +3224,8 @@ async function handleGetUserProfile( display_name: content.display_name ?? content.name ?? null, about: content.about ?? null, avatar_url: content.picture ?? null, - nip05: content.nip05 ?? null, + nip05_handle: content.nip05 ?? null, + owner_pubkey: null, }; } @@ -3238,6 +3253,7 @@ async function handleGetUsersBatch( display_name: profile.display_name, avatar_url: profile.avatar_url, nip05_handle: profile.nip05_handle, + owner_pubkey: profile.owner_pubkey, is_agent: profile.is_agent ?? false, }; } @@ -3261,6 +3277,10 @@ async function handleGetUsersBatch( display_name: content.display_name ?? content.name ?? null, avatar_url: content.picture ?? null, nip05_handle: content.nip05 ?? null, + owner_pubkey: + ((ev.tags ?? []) as string[][]).find( + (tag) => Array.isArray(tag) && tag[0] === "auth" && tag.length === 4, + )?.[1] ?? null, is_agent: Array.isArray(ev.tags) ? ev.tags.some( (tag) => @@ -3285,6 +3305,7 @@ async function handleGetUsersBatch( display_name: profile.display_name, avatar_url: profile.avatar_url, nip05_handle: profile.nip05_handle, + owner_pubkey: profile.owner_pubkey, is_agent: profile.is_agent ?? false, }; } @@ -3337,6 +3358,7 @@ async function handleSearchUsers( display_name: profile.display_name, avatar_url: profile.avatar_url, nip05_handle: profile.nip05_handle, + owner_pubkey: profile.owner_pubkey, is_agent: profile.is_agent ?? false, })); @@ -3360,6 +3382,10 @@ async function handleSearchUsers( display_name: content.display_name ?? content.name ?? null, avatar_url: content.picture ?? null, nip05_handle: content.nip05 ?? null, + owner_pubkey: + ((ev.tags ?? []) as string[][]).find( + (tag) => Array.isArray(tag) && tag[0] === "auth" && tag.length === 4, + )?.[1] ?? null, is_agent: Array.isArray(ev.tags) ? ev.tags.some( (tag) => @@ -5053,6 +5079,7 @@ async function handleCreateManagedAgent( avatar_url: avatarUrl, about: args.input.systemPrompt?.trim() || null, nip05_handle: null, + owner_pubkey: MOCK_IDENTITY_PUBKEY, is_agent: true, }); syncMockRelayAgentsFromManagedAgents(); From ed556f3deb895e0adfa18274b3ed90f255b5f6ad Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:39:01 -0400 Subject: [PATCH 02/30] Hydrate reactions for rendered messages (#1205) Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- .../src/features/channels/ui/ChannelPane.tsx | 17 +- .../src/features/messages/lib/auxBackfill.ts | 20 +- .../lib/renderScopedReactions.test.mjs | 176 +++++++ .../messages/lib/renderScopedReactions.ts | 140 ++++++ .../lib/useRenderScopedReactionHydration.ts | 49 ++ .../features/messages/ui/MessageTimeline.tsx | 6 + .../messages/ui/TimelineMessageList.tsx | 15 +- .../features/workspaces/useWorkspaceInit.ts | 2 + .../shared/api/relayChannelFilters.test.mjs | 16 + desktop/src/shared/api/relayChannelFilters.ts | 45 +- desktop/src/shared/api/relayClientSession.ts | 15 +- .../tests/e2e/reaction-coldload-repro.spec.ts | 434 ++++++++++++++++++ 12 files changed, 909 insertions(+), 26 deletions(-) create mode 100644 desktop/src/features/messages/lib/renderScopedReactions.test.mjs create mode 100644 desktop/src/features/messages/lib/renderScopedReactions.ts create mode 100644 desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts create mode 100644 desktop/tests/e2e/reaction-coldload-repro.spec.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 5fd7641fe..4c56f91f8 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -50,7 +50,11 @@ import { import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions"; import { Button } from "@/shared/ui/button"; import type { useChannelFind } from "@/features/search/useChannelFind"; -import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; +import { + buildMainTimelineEntries, + type MainTimelineEntry, +} from "@/features/messages/lib/threadPanel"; +import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { isWelcomeChannel } from "@/features/onboarding/welcome"; @@ -563,6 +567,16 @@ export const ChannelPane = React.memo(function ChannelPane({ return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); }, [activeChannel, messages]); + const mainTimelineEntries = React.useMemo( + () => buildMainTimelineEntries(visibleMessages), + [visibleMessages], + ); + useRenderScopedReactionHydration({ + activeChannel, + mainTimelineEntries, + threadHeadMessage, + threadMessages, + }); const videoReviewCommentsByRootId = React.useMemo( () => buildVideoReviewCommentsByRootId(messages), [messages], @@ -677,6 +691,7 @@ export const ChannelPane = React.memo(function ChannelPane({ : "No channel selected" } isLoading={isTimelineLoading} + mainEntries={mainTimelineEntries} messages={visibleMessages} firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} diff --git a/desktop/src/features/messages/lib/auxBackfill.ts b/desktop/src/features/messages/lib/auxBackfill.ts index cac1eec44..df0272d73 100644 --- a/desktop/src/features/messages/lib/auxBackfill.ts +++ b/desktop/src/features/messages/lib/auxBackfill.ts @@ -5,6 +5,7 @@ import { sortMessages, } from "@/features/messages/lib/messageQueryKeys"; import { relayClient } from "@/shared/api/relayClient"; +import { buildChannelStructuralAuxFilter } from "@/shared/api/relayChannelFilters"; import type { RelayEvent } from "@/shared/api/types"; import { CHANNEL_TIMELINE_CONTENT_KINDS, @@ -65,16 +66,16 @@ export async function mergeAuxEventsWithDeletionBackfill(input: { } /** - * After a content-kinds-only history fetch, pull the auxiliary events - * (reactions, edits, deletions) that reference the loaded messages — keyed by - * `#e` over their ids, not by a time window — and merge them into the same - * channel cache. + * After a content-kinds-only history fetch, pull structural auxiliary events + * (edits/deletions) that reference the loaded messages — keyed by `#e` over + * their ids, not by a time window — and merge them into the same channel cache. + * Reactions are hydrated separately for the rows the GUI renders. * * History fetches request content kinds only so the `limit` budget buys - * visible message depth (a reaction-heavy 200-event window was only ~136 - * messages). The cost is that an edit/deletion for a visible message can fall - * outside any fetched time window — so aux must be pulled by reference, or a - * visible message renders stale (un-edited / not-deleted). + * visible message depth. The cost is that an edit/deletion for a visible + * message can fall outside any fetched time window — so structural aux must be + * pulled by reference, or a visible message renders stale (un-edited / + * not-deleted). * * Best-effort: failures are logged but never reject, so a flaky overlay fetch * can't blank the freshly-loaded messages. @@ -92,9 +93,10 @@ export async function backfillAuxForMessages( try { const cacheKey = channelMessagesKey(channelId); const cachedEvents = queryClient.getQueryData(cacheKey) ?? []; - const auxEvents = await relayClient.fetchAuxEventsForMessages( + const auxEvents = await relayClient.fetchAuxEventsByReference( channelId, messageIds, + buildChannelStructuralAuxFilter, ); const mergedAuxEvents = await mergeAuxEventsWithDeletionBackfill({ channelId, diff --git a/desktop/src/features/messages/lib/renderScopedReactions.test.mjs b/desktop/src/features/messages/lib/renderScopedReactions.test.mjs new file mode 100644 index 000000000..6429ecf81 --- /dev/null +++ b/desktop/src/features/messages/lib/renderScopedReactions.test.mjs @@ -0,0 +1,176 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + claimUnhydratedRenderScopedReactionIds, + collectRenderScopedReactionMessageIds, + hydrateRenderScopedReactions, + releaseRenderScopedReactionIds, + resetRenderScopedReactionHydration, +} from "./renderScopedReactions.ts"; +import { formatTimelineMessages } from "./formatTimelineMessages.ts"; +import { channelMessagesKey } from "./messageQueryKeys.ts"; + +const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; + +function hex(char) { + return char.repeat(64); +} + +function event(id, kind, overrides = {}) { + return { + id, + pubkey: hex("a"), + kind, + created_at: 1_700_000_000, + content: "", + tags: [["h", CHANNEL_ID]], + sig: "sig", + ...overrides, + }; +} + +function entry(message) { + return { message, summary: null }; +} + +function makeQueryClientStub(initialEvents = []) { + const store = new Map([ + [JSON.stringify(channelMessagesKey(CHANNEL_ID)), initialEvents], + ]); + return { + getQueryData(key) { + return store.get(JSON.stringify(key)); + }, + setQueryData(key, updater) { + const k = JSON.stringify(key); + const next = + typeof updater === "function" ? updater(store.get(k) ?? []) : updater; + store.set(k, next); + return next; + }, + }; +} + +test.afterEach(() => { + resetRenderScopedReactionHydration(); +}); + +test("collects main timeline and open thread messages without hidden replies", () => { + const main = event(hex("1"), 9); + const hiddenCollapsedReply = event(hex("2"), 9, { + tags: [["e", main.id]], + }); + const threadHead = event(hex("3"), 9); + const visibleThreadReply = event(hex("4"), 9, { + tags: [["e", threadHead.id]], + }); + + assert.deepEqual( + collectRenderScopedReactionMessageIds({ + mainEntries: [entry(main), entry(threadHead)], + threadHeadMessage: threadHead, + threadEntries: [entry(visibleThreadReply)], + }), + [main.id, threadHead.id, visibleThreadReply.id], + ); + + assert.ok( + !collectRenderScopedReactionMessageIds({ + mainEntries: [entry(main), entry(threadHead)], + threadHeadMessage: threadHead, + threadEntries: [entry(visibleThreadReply)], + }).includes(hiddenCollapsedReply.id), + ); +}); + +test("claims each rendered message id once per channel and can retry released ids", () => { + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [ + hex("1"), + hex("2"), + hex("1"), + ]), + [hex("1"), hex("2")], + ); + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [hex("1"), hex("2")]), + [], + ); + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds("other-channel", [hex("1")]), + [hex("1")], + ); + + releaseRenderScopedReactionIds(CHANNEL_ID, [hex("2")]); + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [hex("1"), hex("2")]), + [hex("2")], + ); +}); + +test("hydrates visible reactions into the channel timeline cache", async () => { + const messageId = hex("1"); + const reactionId = hex("2"); + const currentUser = hex("c"); + const message = event(messageId, 9, { + pubkey: hex("a"), + content: "ship it?", + }); + const reaction = event(reactionId, 7, { + pubkey: currentUser, + content: "✅", + tags: [["e", messageId]], + }); + const queryClient = makeQueryClientStub([message]); + const calls = []; + + await hydrateRenderScopedReactions({ + channelId: CHANNEL_ID, + messageIds: [messageId], + queryClient, + deps: { + fetchReactionEventsForMessages: async (channelId, messageIds) => { + calls.push({ channelId, messageIds }); + return [reaction]; + }, + }, + }); + + assert.deepEqual(calls, [{ channelId: CHANNEL_ID, messageIds: [messageId] }]); + const cached = queryClient.getQueryData(channelMessagesKey(CHANNEL_ID)); + assert.ok(cached.some((e) => e.id === reactionId)); + + const timeline = formatTimelineMessages(cached, null, currentUser, null); + assert.deepEqual( + timeline + .find((m) => m.id === messageId) + ?.reactions?.map((r) => ({ + count: r.count, + emoji: r.emoji, + mine: r.reactedByCurrentUser, + })), + [{ count: 1, emoji: "✅", mine: true }], + ); +}); + +test("failed hydration releases ids so the next render can retry", async () => { + const messageId = hex("1"); + const queryClient = makeQueryClientStub([event(messageId, 9)]); + + await hydrateRenderScopedReactions({ + channelId: CHANNEL_ID, + messageIds: [messageId], + queryClient, + deps: { + fetchReactionEventsForMessages: async () => { + throw new Error("relay timeout"); + }, + }, + }); + + assert.deepEqual( + claimUnhydratedRenderScopedReactionIds(CHANNEL_ID, [messageId]), + [messageId], + ); +}); diff --git a/desktop/src/features/messages/lib/renderScopedReactions.ts b/desktop/src/features/messages/lib/renderScopedReactions.ts new file mode 100644 index 000000000..2c35cdf79 --- /dev/null +++ b/desktop/src/features/messages/lib/renderScopedReactions.ts @@ -0,0 +1,140 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { channelMessagesKey, sortMessages } from "./messageQueryKeys"; +import type { MainTimelineEntry } from "./threadPanel"; +import type { TimelineMessage } from "../types"; +import { relayClient } from "@/shared/api/relayClient"; +import { buildChannelReactionAuxFilter } from "@/shared/api/relayChannelFilters"; +import type { RelayEvent } from "@/shared/api/types"; + +export type RenderScopedReactionDeps = { + fetchReactionEventsForMessages: ( + channelId: string, + messageIds: string[], + ) => Promise; +}; + +const defaultDeps: RenderScopedReactionDeps = { + fetchReactionEventsForMessages: (channelId, messageIds) => + relayClient.fetchAuxEventsByReference( + channelId, + messageIds, + buildChannelReactionAuxFilter, + ), +}; + +const hydratedMessageIdsByChannel = new Map>(); + +export function resetRenderScopedReactionHydration() { + hydratedMessageIdsByChannel.clear(); +} + +function hydratedSetForChannel(channelId: string): Set { + let hydrated = hydratedMessageIdsByChannel.get(channelId); + if (!hydrated) { + hydrated = new Set(); + hydratedMessageIdsByChannel.set(channelId, hydrated); + } + return hydrated; +} + +function pushUniqueMessageId(ids: string[], seen: Set, id?: string) { + if (!id || seen.has(id)) { + return; + } + seen.add(id); + ids.push(id); +} + +export function collectRenderScopedReactionMessageIds(input: { + mainEntries: readonly MainTimelineEntry[]; + threadHeadMessage?: TimelineMessage | null; + threadEntries?: readonly MainTimelineEntry[]; +}): string[] { + const ids: string[] = []; + const seen = new Set(); + + for (const entry of input.mainEntries) { + pushUniqueMessageId(ids, seen, entry.message.id); + } + + pushUniqueMessageId(ids, seen, input.threadHeadMessage?.id); + + for (const entry of input.threadEntries ?? []) { + pushUniqueMessageId(ids, seen, entry.message.id); + } + + return ids; +} + +export function claimUnhydratedRenderScopedReactionIds( + channelId: string, + messageIds: readonly string[], +): string[] { + const hydrated = hydratedSetForChannel(channelId); + const claimed: string[] = []; + + for (const id of messageIds) { + if (hydrated.has(id)) { + continue; + } + hydrated.add(id); + claimed.push(id); + } + + return claimed; +} + +export function releaseRenderScopedReactionIds( + channelId: string, + messageIds: readonly string[], +) { + const hydrated = hydratedMessageIdsByChannel.get(channelId); + if (!hydrated) { + return; + } + + for (const id of messageIds) { + hydrated.delete(id); + } + + if (hydrated.size === 0) { + hydratedMessageIdsByChannel.delete(channelId); + } +} + +export async function hydrateRenderScopedReactions(input: { + channelId: string; + messageIds: readonly string[]; + queryClient: QueryClient; + deps?: RenderScopedReactionDeps; +}): Promise { + const messageIds = claimUnhydratedRenderScopedReactionIds( + input.channelId, + input.messageIds, + ); + if (messageIds.length === 0) { + return; + } + + try { + const reactionEvents = await ( + input.deps ?? defaultDeps + ).fetchReactionEventsForMessages(input.channelId, messageIds); + if (reactionEvents.length === 0) { + return; + } + + input.queryClient.setQueryData( + channelMessagesKey(input.channelId), + (current = []) => sortMessages([...current, ...reactionEvents]), + ); + } catch (error) { + releaseRenderScopedReactionIds(input.channelId, messageIds); + console.error( + "Failed to hydrate visible reactions for channel", + input.channelId, + error, + ); + } +} diff --git a/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts new file mode 100644 index 000000000..8bc3f7724 --- /dev/null +++ b/desktop/src/features/messages/lib/useRenderScopedReactionHydration.ts @@ -0,0 +1,49 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import type { MainTimelineEntry } from "./threadPanel"; +import type { TimelineMessage } from "../types"; +import type { Channel } from "@/shared/api/types"; +import { + collectRenderScopedReactionMessageIds, + hydrateRenderScopedReactions, +} from "./renderScopedReactions"; + +export function useRenderScopedReactionHydration(input: { + activeChannel: Channel | null; + mainTimelineEntries: MainTimelineEntry[]; + threadHeadMessage: TimelineMessage | null; + threadMessages: MainTimelineEntry[]; +}) { + const queryClient = useQueryClient(); + + React.useEffect(() => { + const channelId = input.activeChannel?.id; + if (!channelId || input.activeChannel?.channelType === "forum") { + return; + } + + const messageIds = collectRenderScopedReactionMessageIds({ + mainEntries: input.mainTimelineEntries, + threadHeadMessage: input.threadHeadMessage, + threadEntries: input.threadMessages, + }); + if (messageIds.length === 0) return; + + const timeout = window.setTimeout(() => { + void hydrateRenderScopedReactions({ + channelId, + messageIds, + queryClient, + }); + }, 0); + + return () => window.clearTimeout(timeout); + }, [ + input.activeChannel, + input.mainTimelineEntries, + input.threadHeadMessage, + input.threadMessages, + queryClient, + ]); +} diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index 6c63acc7d..62ee43abc 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -9,6 +9,7 @@ import { } from "@/features/messages/lib/timelineSnapshot"; import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; import type { TimelineMessage } from "@/features/messages/types"; +import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -32,6 +33,7 @@ type MessageTimelineProps = { channelName?: string; channelType?: ChannelType | null; messages: TimelineMessage[]; + mainEntries?: MainTimelineEntry[]; directMessageIntro?: { displayName: string; participants: DirectMessageIntroParticipant[]; @@ -137,6 +139,7 @@ const MessageTimelineBase = React.forwardRef< channelIntro = null, directMessageIntro = null, messages, + mainEntries, isLoading = false, emptyTitle = "No messages yet", emptyDescription = "Send the first message to start the thread.", @@ -539,6 +542,9 @@ const MessageTimelineBase = React.forwardRef< isFollowingThreadById={isFollowingThreadById} isMessageUnreadById={isMessageUnreadById} messageFooters={messageFooters} + mainEntries={ + deferredMessages === messages ? mainEntries : undefined + } messages={deferredMessages} onDelete={onDelete} onEdit={onEdit} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index fef2b9984..15ea61a5f 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -38,6 +38,7 @@ type TimelineMessageListProps = { isFollowingThreadById?: (rootId: string) => boolean; isMessageUnreadById?: (messageId: string) => boolean; messageFooters?: Record; + mainEntries?: ReturnType; messages: TimelineMessage[]; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; @@ -103,13 +104,15 @@ type TimelineDayGroup = { }; function buildTimelineRenderRows({ + entries, firstUnreadMessageId, messages, }: { + entries?: ReturnType; firstUnreadMessageId: string | null; messages: TimelineMessage[]; }): TimelineRenderRow[] { - const entries = buildMainTimelineEntries(messages); + entries ??= buildMainTimelineEntries(messages); const rows: TimelineRenderRow[] = []; let previousMessage: TimelineMessage | null = null; @@ -191,6 +194,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ isFollowingThreadById, isMessageUnreadById, messageFooters, + mainEntries, messages, onDelete, onEdit, @@ -208,8 +212,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ unfollowThreadById, }: TimelineMessageListProps) { const rows = React.useMemo( - () => buildTimelineRenderRows({ firstUnreadMessageId, messages }), - [firstUnreadMessageId, messages], + () => + buildTimelineRenderRows({ + entries: mainEntries, + firstUnreadMessageId, + messages, + }), + [firstUnreadMessageId, mainEntries, messages], ); const dayGroups = React.useMemo(() => buildTimelineDayGroups(rows), [rows]); diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index a678c34d4..c7026ecc1 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -9,6 +9,7 @@ import { import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearAllDrafts } from "@/features/messages/lib/useDrafts"; +import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; @@ -29,6 +30,7 @@ function resetWorkspaceState(): void { resetSidebarRelayConnectionCardState(); resetMediaCaches(); resetVideoPlayerState(); + resetRenderScopedReactionHydration(); clearSearchHitEventCache(); clearAllDrafts(); } diff --git a/desktop/src/shared/api/relayChannelFilters.test.mjs b/desktop/src/shared/api/relayChannelFilters.test.mjs index 76380a708..3519c8214 100644 --- a/desktop/src/shared/api/relayChannelFilters.test.mjs +++ b/desktop/src/shared/api/relayChannelFilters.test.mjs @@ -4,6 +4,8 @@ import test from "node:test"; import { buildChannelAuxDeletionFilter, buildChannelAuxFilter, + buildChannelReactionAuxFilter, + buildChannelStructuralAuxFilter, } from "./relayChannelFilters.ts"; const CHANNEL = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; @@ -27,3 +29,17 @@ test("buildChannelAuxDeletionFilter keys on #e only, no #h", () => { assert.deepEqual(filter["#e"], IDS); assert.equal("#h" in filter, false); }); + +test("buildChannelReactionAuxFilter fetches only kind:7 by #e", () => { + const filter = buildChannelReactionAuxFilter(CHANNEL, IDS); + assert.deepEqual(filter.kinds, [7]); + assert.deepEqual(filter["#e"], IDS); + assert.equal("#h" in filter, false); +}); + +test("buildChannelStructuralAuxFilter excludes reactions", () => { + const filter = buildChannelStructuralAuxFilter(CHANNEL, IDS); + assert.deepEqual(filter.kinds, [5, 9005, 40003]); + assert.deepEqual(filter["#e"], IDS); + assert.equal("#h" in filter, false); +}); diff --git a/desktop/src/shared/api/relayChannelFilters.ts b/desktop/src/shared/api/relayChannelFilters.ts index 922c93439..d0c7e7938 100644 --- a/desktop/src/shared/api/relayChannelFilters.ts +++ b/desktop/src/shared/api/relayChannelFilters.ts @@ -5,6 +5,8 @@ import { HOME_MENTION_EVENT_KINDS, KIND_DELETION, KIND_NIP29_DELETE_EVENT, + KIND_REACTION, + KIND_STREAM_MESSAGE_EDIT, } from "@/shared/constants/kinds"; import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; @@ -42,9 +44,10 @@ export function buildChannelFilter( * History filter for cold-load and scrollback: message kinds *only*, so the * `limit` budget buys visible message depth. Auxiliary events (reactions, * edits, deletions) are backfilled separately by `#e` reference via - * {@link buildChannelAuxFilter}, and arrive for future messages through the - * live subscription ({@link buildChannelFilter}, which keeps the broad - * {@link CHANNEL_EVENT_KINDS} set). + * {@link buildChannelStructuralAuxFilter} and + * {@link buildChannelReactionAuxFilter}, and arrive for future messages + * through the live subscription ({@link buildChannelFilter}, which keeps the + * broad {@link CHANNEL_EVENT_KINDS} set). */ export function buildChannelHistoryFilter( channelId: string, @@ -65,10 +68,10 @@ export function buildChannelHistoryFilter( } /** - * Aux-backfill filter for one chunk of loaded message ids: pulls reactions/ - * edits/deletions ({@link CHANNEL_AUX_EVENT_KINDS}) that reference those ids - * by `#e`. Keyed by reference, not time, so a late edit/deletion for an old - * visible message still applies — see {@link buildChannelHistoryFilter}. + * Aux-backfill filter for one chunk of loaded message ids: pulls auxiliary + * events ({@link CHANNEL_AUX_EVENT_KINDS}) that reference those ids by `#e`. + * Keyed by reference, not time, so a late edit/deletion for an old visible + * message still applies — see {@link buildChannelHistoryFilter}. */ export function buildChannelAuxFilter( _channelId: string, @@ -77,6 +80,34 @@ export function buildChannelAuxFilter( return buildChannelAuxKindFilter(messageIds, [...CHANNEL_AUX_EVENT_KINDS]); } +/** + * Structural aux filter for history backfill: edits/deletions only. Reactions + * are hydrated from the rows the GUI actually renders, so the slow kind:5 scan + * never shares a request with first-paint reaction pills. + */ +export function buildChannelStructuralAuxFilter( + _channelId: string, + messageIds: string[], +): RelaySubscriptionFilter { + return buildChannelAuxKindFilter(messageIds, [ + KIND_DELETION, + KIND_NIP29_DELETE_EVENT, + KIND_STREAM_MESSAGE_EDIT, + ]); +} + +/** + * Reactions-only filter for the message rows the GUI is currently rendering. + * Keep this separate from structural aux backfill so the slow kind:5 deletion + * scan cannot delay reaction pills that affect visible pixels right now. + */ +export function buildChannelReactionAuxFilter( + _channelId: string, + messageIds: string[], +): RelaySubscriptionFilter { + return buildChannelAuxKindFilter(messageIds, [KIND_REACTION]); +} + export function buildChannelAuxDeletionFilter( _channelId: string, auxEventIds: string[], diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 8db85397b..1e693ddf2 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -22,7 +22,6 @@ import { import { AUX_BACKFILL_CHUNK_SIZE, buildChannelAuxDeletionFilter, - buildChannelAuxFilter, buildChannelFilter, buildChannelHistoryFilter, buildChannelMentionFilter, @@ -168,14 +167,18 @@ export class RelayClient { ); } - async fetchAuxEventsForMessages( + async fetchAuxEventsByReference( channelId: string, - messageIds: string[], - ): Promise { + referencedEventIds: string[], + buildFilter: ( + channelId: string, + eventIds: string[], + ) => RelaySubscriptionFilter, + ) { return this.fetchChunkedAuxEvents( channelId, - messageIds, - buildChannelAuxFilter, + referencedEventIds, + buildFilter, ); } diff --git a/desktop/tests/e2e/reaction-coldload-repro.spec.ts b/desktop/tests/e2e/reaction-coldload-repro.spec.ts new file mode 100644 index 000000000..2c5925d9b --- /dev/null +++ b/desktop/tests/e2e/reaction-coldload-repro.spec.ts @@ -0,0 +1,434 @@ +import { expect, type Page, test } from "@playwright/test"; +import { decode } from "nostr-tools/nip19"; +import { getPublicKey } from "nostr-tools/pure"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; + +// Ground-truth reproduction for Tyler's "reactions don't show on first load" +// bug. Drives the REAL GUI (relay-mode e2e bridge) against the staging relay +// with Eva's real key, cold-loads #buzz-bugs, and observes whether messages +// known to carry reactions render their reaction pills on first paint vs only +// after a channel switch-away-and-back. +// +// NOT a CI test — points at a live external relay and a real member identity. +// Run explicitly: pnpm exec playwright test reaction-coldload-repro + +const RELAY_WS = "wss://sprout-oss.stage.blox.sqprod.co"; +const RELAY_HTTP = "https://sprout-oss.stage.blox.sqprod.co"; +const BUZZ_BUGS_NAME = "buzz-bugs"; + +function loadTestIdentity() { + const rawPrivateKey = process.env.BUZZ_PRIVATE_KEY ?? ""; + if (!rawPrivateKey) { + throw new Error("BUZZ_PRIVATE_KEY is required for the live staging repro"); + } + + const privateKey = rawPrivateKey.startsWith("nsec") + ? bytesToHex(decode(rawPrivateKey).data) + : bytesToHex(hexToBytes(rawPrivateKey)); + return { + privateKey, + pubkey: getPublicKey(hexToBytes(privateKey)), + username: "Max", + }; +} + +const TEST_IDENTITY = loadTestIdentity(); + +async function collectRenderedReactionState(page: Page) { + return page.locator("[data-message-id]").evaluateAll((rows) => + rows.map((row) => { + const el = row as HTMLElement; + const reactions = Array.from( + el.querySelectorAll( + '[data-testid="message-reactions"] button', + ), + ).map( + (button) => + button.getAttribute("aria-label") ?? button.textContent?.trim() ?? "", + ); + return { + id: el.dataset.messageId ?? "", + hasReactions: reactions.length > 0, + reactions, + text: el.innerText.replace(/\s+/g, " ").slice(0, 120), + }; + }), + ); +} + +test("cold-load buzz-bugs and observe reaction render", async ({ page }) => { + test.setTimeout(180_000); + // Seed workspace + onboarding for Eva's pubkey so the app skips WelcomeSetup + // and boots straight into the workspace pointed at staging. + await page.addInitScript( + ({ relayUrl, pubkey }) => { + const workspaceId = "e2e-repro-workspace"; + window.localStorage.setItem( + "buzz-workspaces", + JSON.stringify([ + { + id: workspaceId, + name: "Staging Repro", + relayUrl, + addedAt: new Date().toISOString(), + }, + ]), + ); + window.localStorage.setItem("buzz-active-workspace-id", workspaceId); + const scope = encodeURIComponent(relayUrl); + window.localStorage.setItem( + `buzz-onboarding-complete.v1:${pubkey}`, + "true", + ); + window.localStorage.setItem( + `buzz-welcome-channel-ensured.v1:${scope}:${pubkey}`, + "true", + ); + }, + { relayUrl: RELAY_WS, pubkey: TEST_IDENTITY.pubkey }, + ); + + await page.addInitScript( + ({ identity, relayHttpUrl, relayWsUrl }) => { + (window as unknown as { __BUZZ_E2E__: unknown }).__BUZZ_E2E__ = { + mode: "relay", + identity, + relayHttpUrl, + relayWsUrl, + }; + }, + { identity: TEST_IDENTITY, relayHttpUrl: RELAY_HTTP, relayWsUrl: RELAY_WS }, + ); + + const consoleLines: string[] = []; + page.on("console", (msg) => { + consoleLines.push(`[${msg.type()}] ${msg.text()}`); + }); + page.on("pageerror", (err) => { + consoleLines.push(`[pageerror] ${err.message}`); + }); + + // The staging relay's HTTP API does not return Access-Control-Allow-Origin, + // so a browser fetch from the test origin (127.0.0.1:4173) is blocked by + // CORS — every get_channels / backfill /query fails with "Failed to fetch". + // That's a harness artifact, NOT Tyler's bug. Proxy the relay's HTTP origin + // through Node fetch (no CORS) and inject the missing CORS header so the + // real GUI data path runs exactly as shipped, just reachable from the test. + const relayQueryBodies: string[] = []; + await page.route(`${RELAY_HTTP}/**`, async (route) => { + const req = route.request(); + const url = req.url(); + const postData = req.postData() ?? undefined; + if (url.endsWith("/query") && postData) { + relayQueryBodies.push(postData.slice(0, 400)); + } + const headers = { ...req.headers() }; + delete headers.origin; + // The staging relay sits behind a WAF that 403s any request whose + // User-Agent contains the `Mozilla/` token (confirmed: `Mozilla/5.0` -> + // 403, `curl`/`buzz-desktop`/empty -> 200). Chromium always sends a + // Mozilla UA, so the browser data path is blocked at infra — a harness + // artifact, NOT Tyler's bug (the real Tauri app queries via the Rust + // reqwest client, which is not browser-UA-shaped). Rewrite the UA so the + // shipped data path can run from the test browser. + headers["user-agent"] = "buzz-desktop-e2e"; + const upstream = await fetch(url, { + method: req.method(), + headers, + body: + req.method() === "GET" || req.method() === "HEAD" + ? undefined + : postData, + }); + const bodyBuf = Buffer.from(await upstream.arrayBuffer()); + const respHeaders: Record = {}; + upstream.headers.forEach((v, k) => { + respHeaders[k] = v; + }); + respHeaders["access-control-allow-origin"] = "*"; + respHeaders["access-control-allow-headers"] = "*"; + respHeaders["access-control-allow-methods"] = "*"; + await route.fulfill({ + status: upstream.status, + headers: respHeaders, + body: bodyBuf, + }); + }); + + // Capture the aux backfill REQ + EVENT traffic over the websocket. + const wsFrames: string[] = []; + // The same `Mozilla/`-UA WAF that 403s the HTTP path also 403s the browser's + // WS upgrade (confirmed by probe), and message history + reaction backfill + // both ride the WebSocket, not the HTTP /query bridge. Playwright's + // `connectToServer()` is ALSO WAF-blocked (its upstream handshake closes + // 1006), and it exposes no way to set the UA. Node's built-in global + // `WebSocket` (undici) connects fine — its UA is not `Mozilla/`-shaped — so + // bridge the page's WS to a Node-side undici WebSocket by hand. The shipped + // WS data path (auth, history, aux reaction backfill) then runs as-is, just + // reachable from the test browser. + // Per-REQ latency tracking: maps a subscription id to the wall-clock time + // its REQ was sent, so we can measure REQ -> EOSE latency for each aux + // backfill chunk. This is what separates "deterministic >8s timeout" (a + // consistent GUI bug, as Tyler insists) from "random latency". + const reqSentAt = new Map(); + const reqFilters = new Map(); + // Aux-backfill subscription tracking + a bridge-throughput counter, to + // distinguish "relay never sent the EOSE" from "bridge stalled and dropped + // it" (harness artifact). auxSubIds holds the aux REQ ids (kinds 5,7,9005, + // 40003 keyed by #e); auxFrameLog records every upstream frame for them. + const auxSubIds = new Set(); + const auxFrameLog: string[] = []; + let upstreamFrameCount = 0; + const t0 = Date.now(); + const rel = () => `${((Date.now() - t0) / 1000).toFixed(2)}s`; + await page.routeWebSocket(/sprout-oss/, (ws) => { + wsFrames.push(`ROUTE HIT url=${ws.url()}`); + const upstream = new WebSocket(ws.url()); + const pageQueue: (string | Buffer)[] = []; + let upstreamOpen = false; + upstream.addEventListener("open", () => { + upstreamOpen = true; + for (const m of pageQueue) upstream.send(m); + pageQueue.length = 0; + }); + upstream.addEventListener("message", (ev: MessageEvent) => { + const m = ev.data as string; + upstreamFrameCount++; + if (typeof m === "string" && m.startsWith("[")) { + try { + const arr = JSON.parse(m) as unknown[]; + const verb = arr[0]; + const sid = arr[1]; + // Track every frame for the aux-backfill sub specifically, so we can + // tell whether the relay delivered its EVENTs/EOSE to the bridge even + // if the page never rendered them (= harness bottleneck, not the bug). + if (typeof sid === "string" && auxSubIds.has(sid)) { + auxFrameLog.push( + `[${rel()}] AUX-FRAME #${upstreamFrameCount} verb=${verb} sid=${sid.slice(0, 20)}`, + ); + } + if (verb === "EOSE" && typeof sid === "string") { + const sent = reqSentAt.get(sid); + const filt = reqFilters.get(sid) ?? ""; + if (sent !== undefined) { + wsFrames.push( + `EOSE sid=${sid} latency=${((Date.now() - sent) / 1000).toFixed(2)}s filter=${filt}`, + ); + reqSentAt.delete(sid); + } + } + } catch { + /* not JSON array */ + } + } + if ( + typeof m === "string" && + (m.includes('"kind":7') || + m.includes("EOSE") || + m.includes("OK") || + m.includes("AUTH")) + ) + wsFrames.push(`[${rel()}] << ${m.slice(0, 200)}`); + ws.send(m); + }); + upstream.addEventListener("close", (ev: CloseEvent) => { + wsFrames.push(`UPSTREAM CLOSE code=${ev.code}`); + void ws + .close({ code: ev.code || 1000, reason: "upstream closed" }) + .catch(() => {}); + }); + upstream.addEventListener("error", () => wsFrames.push(`UPSTREAM ERROR`)); + ws.onMessage((m) => { + const p = typeof m === "string" ? m : ""; + if (typeof m === "string" && m.startsWith("[")) { + try { + const arr = JSON.parse(m) as unknown[]; + if (arr[0] === "REQ" && typeof arr[1] === "string") { + const sid = arr[1]; + reqSentAt.set(sid, Date.now()); + // Record the kinds + which #e tag count so we can attribute the + // latency to aux-backfill (kinds 5,7,9005,40003 + #e) vs other REQs. + const filterObj = arr[2] as + | { kinds?: number[]; "#e"?: string[] } + | undefined; + const kinds = filterObj?.kinds?.join(",") ?? "?"; + const eCount = filterObj?.["#e"]?.length ?? 0; + reqFilters.set(sid, `kinds=[${kinds}] #e=${eCount}`); + // Aux backfill REQs are keyed by #e. Post kind-split (the fix), + // reactions ride a kind:7-only REQ and the structural overlay rides + // a 5/9005/40003 REQ; pre-fix they were one bundled 5+7+... REQ. + // Track all aux REQs so we can measure each one's REQ->EOSE latency + // and prove the reaction REQ now beats the 8s timeout. + const kindSet = new Set(filterObj?.kinds ?? []); + const isReactionReq = + eCount > 0 && kindSet.has(7) && !kindSet.has(5); + const isStructuralReq = + eCount > 0 && kindSet.has(5) && !kindSet.has(7); + const isBundledReq = eCount > 0 && kindSet.has(7) && kindSet.has(5); + if (isReactionReq || isStructuralReq || isBundledReq) { + auxSubIds.add(sid); + } + wsFrames.push( + `[${rel()}] REQ sid=${sid} kinds=[${kinds}] #e=${eCount}${ + isReactionReq + ? " [REACTION-AUX]" + : isStructuralReq + ? " [STRUCTURAL-AUX]" + : isBundledReq + ? " [BUNDLED-AUX]" + : "" + }`, + ); + } + } catch { + /* not JSON array */ + } + } + if (p.includes("REQ") || p.includes("kinds") || p.includes("AUTH")) + wsFrames.push(`[${rel()}] >> ${p.slice(0, 300)}`); + if (upstreamOpen) upstream.send(m); + else pageQueue.push(m); + }); + ws.onClose(() => { + try { + upstream.close(); + } catch { + /* noop */ + } + }); + }); + + await page.goto("/"); + + try { + // Wait for the sidebar + buzz-bugs channel entry to materialize from the + // relay (kind:39002 membership -> kind:39000 metadata). + const channelEntry = page.getByTestId(`channel-${BUZZ_BUGS_NAME}`); + await channelEntry.waitFor({ state: "visible", timeout: 60_000 }); + + // ---- COLD LOAD: first entry into the channel ---- + await channelEntry.click(); + await expect(page.getByTestId("chat-title")).toHaveText(BUZZ_BUGS_NAME, { + timeout: 20_000, + }); + // Let messages render. + await page.getByTestId("message-row").first().waitFor({ timeout: 20_000 }); + + // Give the cold-load aux backfill a generous window to commit. + await page.waitForTimeout(6_000); + + const coldLoadReactionCount = await page + .getByTestId("message-reactions") + .count(); + const coldLoadState = await collectRenderedReactionState(page); + const coldLoadMessageCount = coldLoadState.length; + await page.screenshot({ + path: "test-results/reaction-coldload/01-coldload.png", + fullPage: true, + }); + + // ---- DISAMBIGUATE: switch away then back ---- + // Click any other channel, then return to buzz-bugs. + const otherChannel = page + .locator('[data-testid^="channel-"]') + .filter({ hasNot: channelEntry }) + .first(); + await otherChannel.click(); + await page.waitForTimeout(1_500); + await channelEntry.click(); + await expect(page.getByTestId("chat-title")).toHaveText(BUZZ_BUGS_NAME); + await page.getByTestId("message-row").first().waitFor({ timeout: 20_000 }); + await page.waitForTimeout(6_000); + + const afterSwitchReactionCount = await page + .getByTestId("message-reactions") + .count(); + const afterSwitchState = await collectRenderedReactionState(page); + await page.screenshot({ + path: "test-results/reaction-coldload/02-afterswitch.png", + fullPage: true, + }); + + console.log("=== REPRO RESULT ==="); + console.log("cold-load message rows:", coldLoadMessageCount); + console.log("after-switch message rows:", afterSwitchState.length); + console.log("cold-load reaction containers:", coldLoadReactionCount); + console.log("after-switch reaction containers:", afterSwitchReactionCount); + const coldReactionIds = new Set( + coldLoadState.filter((r) => r.hasReactions).map((r) => r.id), + ); + const afterReactionIds = new Set( + afterSwitchState.filter((r) => r.hasReactions).map((r) => r.id), + ); + const afterSwitchOnlyReactionRows = afterSwitchState.filter( + (r) => r.hasReactions && !coldReactionIds.has(r.id), + ); + const coldLoadOnlyReactionRows = coldLoadState.filter( + (r) => r.hasReactions && !afterReactionIds.has(r.id), + ); + console.log( + "cold-load reaction row ids:", + JSON.stringify([...coldReactionIds]), + ); + console.log( + "after-switch reaction row ids:", + JSON.stringify([...afterReactionIds]), + ); + console.log( + "after-switch-only reaction rows:", + JSON.stringify(afterSwitchOnlyReactionRows), + ); + console.log( + "cold-load-only reaction rows:", + JSON.stringify(coldLoadOnlyReactionRows), + ); + + // The proof: switching away-and-back is what "fixes" the symptom for the + // user today, so after-switch is the ground-truth count of reactions that + // exist for the loaded window. The fix means the FIRST cold-load paint must + // already show them — not zero, and not far short of after-switch. Pre-fix + // this was 0 on a busy workspace (all-or-nothing drop on the bundled REQ + // timeout); post-fix the kind:7 REQ lands well inside the 8s budget. + expect( + coldLoadReactionCount, + "cold-load must render reactions on first paint (pre-fix: 0)", + ).toBeGreaterThan(0); + expect( + coldLoadState.map((row) => row.id), + "cold-load and after-switch must compare the same rendered message rows", + ).toEqual(afterSwitchState.map((row) => row.id)); + + expect( + afterSwitchOnlyReactionRows, + "cold-load must hydrate every reaction-bearing row visible after switch-back", + ).toEqual([]); + } finally { + // Always dump diagnostics, even if a waitFor above timed out — this is the + // whole point of the harness. Without the finally, a timeout fires before + // the log block and we learn nothing. + const channelTestIds = await page + .locator('[data-testid^="channel-"]') + .evaluateAll((els) => + els.map((e) => (e as HTMLElement).dataset.testid ?? ""), + ) + .catch(() => [] as string[]); + console.log("=== CHANNELS RENDERED IN SIDEBAR ==="); + console.log(JSON.stringify(channelTestIds)); + console.log("=== WS FRAMES (reaction/REQ) ==="); + for (const f of wsFrames) console.log(f); + console.log("=== AUX-BACKFILL SUB FRAMES (relay -> bridge) ==="); + console.log("total upstream frames bridged:", upstreamFrameCount); + console.log("aux sub ids tracked:", [...auxSubIds].join(", ")); + for (const f of auxFrameLog) console.log(f); + console.log("=== RELAY /query BODIES ==="); + for (const b of relayQueryBodies.slice(0, 40)) console.log(b); + console.log("=== CONSOLE (last 40) ==="); + for (const l of consoleLines.slice(-40)) console.log(l); + await page + .screenshot({ + path: "test-results/reaction-coldload/99-final.png", + fullPage: true, + }) + .catch(() => {}); + } +}); From 3ef2a8e5c7e655f3347931135dde5f65b919c915 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 23 Jun 2026 15:34:11 -0400 Subject: [PATCH 03/30] ci(release): enable Tauri auto-updater on Windows and Linux builds (#1206) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74e108d7c..261bf2c26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -513,6 +513,8 @@ jobs: - name: Build Linux Tauri app run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --config src-tauri/tauri.release.conf.json env: + BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} + BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json CMAKE_POLICY_VERSION_MINIMUM: "3.5" TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -647,6 +649,8 @@ jobs: shell: bash run: cd desktop && pnpm tauri build --verbose --target "$TARGET" --bundles nsis --config src-tauri/tauri.release.conf.json env: + BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} + BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} CMAKE_POLICY_VERSION_MINIMUM: "3.5" From bee2d64cf7f093088cc28463e96a6c94b64f280e Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 23 Jun 2026 12:46:47 -0700 Subject: [PATCH 04/30] fix(desktop): defer agent page secondary requests (#1217) Signed-off-by: Wes Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> --- desktop/src/features/agents/hooks.ts | 12 +- desktop/src/features/agents/ui/AgentsView.tsx | 426 ++++++++++-------- .../features/agents/ui/CreateAgentDialog.tsx | 10 +- .../features/agents/ui/ManagedAgentRow.tsx | 12 +- .../agents/ui/useManagedAgentActions.ts | 25 +- .../features/agents/ui/usePersonaActions.ts | 10 +- desktop/src/features/channels/hooks.ts | 3 +- 7 files changed, 281 insertions(+), 217 deletions(-) diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 02b9129ba..ed4680978 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -125,16 +125,17 @@ function invalidateManagedAgentQueriesInBackground( ); } -export function useAcpRuntimesQuery() { +export function useAcpRuntimesQuery(options?: { enabled?: boolean }) { return useQuery({ + enabled: options?.enabled ?? true, queryKey: acpRuntimesQueryKey, queryFn: discoverAcpRuntimes, staleTime: 60_000, }); } -export function useAvailableAcpRuntimes() { - const query = useAcpRuntimesQuery(); +export function useAvailableAcpRuntimes(options?: { enabled?: boolean }) { + const query = useAcpRuntimesQuery(options); const available = React.useMemo( () => (query.data ?? []).filter( @@ -155,8 +156,9 @@ export function useInstallAcpRuntimeMutation() { }); } -export function useBackendProvidersQuery() { +export function useBackendProvidersQuery(options?: { enabled?: boolean }) { return useQuery({ + enabled: options?.enabled ?? true, queryKey: backendProvidersQueryKey, queryFn: discoverBackendProviders, staleTime: 30_000, @@ -175,11 +177,13 @@ export function usePersonasQuery() { export function useManagedAgentPrereqsQuery( acpCommand: string, mcpCommand: string, + options?: { enabled?: boolean }, ) { const normalizedAcpCommand = acpCommand.trim(); const normalizedMcpCommand = mcpCommand.trim(); return useQuery({ + enabled: options?.enabled ?? true, queryKey: [ ...managedAgentPrereqsQueryKey, normalizedAcpCommand, diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 639bd3569..cc238ba50 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -180,218 +180,246 @@ export function AgentsView() { - { - agents.setLogAgentPubkey(result.agent.pubkey); - agents.setCreatedAgent(result); - }} - onOpenChange={agents.setIsCreateOpen} - open={agents.isCreateOpen} - /> - { - if (!open) { - agents.setAgentToAddToChannel(null); + {agents.isCreateOpen ? ( + { + agents.setLogAgentPubkey(result.agent.pubkey); + agents.setCreatedAgent(result); + }} + onOpenChange={agents.setIsCreateOpen} + open={agents.isCreateOpen} + /> + ) : null} + {agents.agentToAddToChannel ? ( + { + if (!open) { + agents.setAgentToAddToChannel(null); + } + }} + open={agents.agentToAddToChannel !== null} + /> + ) : null} + {agents.createdAgent ? ( + { + if (!open) { + agents.setCreatedAgent(null); + } + }} + /> + ) : null} + {personas.personaDialogState ? ( + - { - if (!open) { - agents.setCreatedAgent(null); + initialValues={personas.personaDialogState.initialValues} + isImportPending={ + personas.personaImportActions.isApplyingPersonaImportUpdate } - }} - /> - { + if (!open) { + personas.setPersonaDialogState(null); + } + }} + onSubmit={personas.handleSubmit} + open={personas.personaDialogState !== null} + submitLabel={personas.personaDialogState.submitLabel} + title={personas.personaDialogState.title} + /> + ) : null} + {personas.personaToDelete ? ( + { + void personas.handleDelete(persona); + }} + onOpenChange={(open) => { + if (!open) { + personas.setPersonaToDelete(null); + } + }} + open={personas.personaToDelete !== null} + persona={personas.personaToDelete} + /> + ) : null} + {personas.isCatalogDialogOpen ? ( + { - if (!open) { - personas.setPersonaDialogState(null); } - }} - onSubmit={personas.handleSubmit} - open={personas.personaDialogState !== null} - submitLabel={personas.personaDialogState?.submitLabel ?? "Save"} - title={personas.personaDialogState?.title ?? "Persona"} - /> - { - void personas.handleDelete(persona); - }} - onOpenChange={(open) => { - if (!open) { - personas.setPersonaToDelete(null); + feedbackErrorMessage={ + personas.personaFeedbackSurface === "catalog" + ? personas.personaErrorMessage + : null } - }} - open={personas.personaToDelete !== null} - persona={personas.personaToDelete} - /> - { - personas.clearFeedback("catalog"); - }} - onOpenChange={personas.setIsCatalogDialogOpen} - onSelectPersona={(persona, active) => { - void personas.handleSetActive(persona, active, "catalog"); - }} - open={personas.isCatalogDialogOpen} - personas={personas.catalogPersonas} - /> - { - if (!open) { - teamActions.setTeamDialogState(null); } - }} - onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas} - onSubmit={teamActions.handleTeamSubmit} - open={teamActions.teamDialogState !== null} - personas={personas.libraryPersonas} - submitLabel={teamActions.teamDialogState?.submitLabel ?? "Save"} - title={teamActions.teamDialogState?.title ?? "Team"} - /> - { - void teamActions.handleDeleteTeam(team); - }} - onOpenChange={(open) => { - if (!open) { - teamActions.setTeamToDelete(null); + isLoading={personas.personasQuery.isLoading} + isPending={personas.setPersonaActiveMutation.isPending} + onClearFeedback={() => { + personas.clearFeedback("catalog"); + }} + onOpenChange={personas.setIsCatalogDialogOpen} + onSelectPersona={(persona, active) => { + void personas.handleSetActive(persona, active, "catalog"); + }} + open={personas.isCatalogDialogOpen} + personas={personas.catalogPersonas} + /> + ) : null} + {teamActions.teamDialogState ? ( + - { - if (!open) { - teamActions.setTeamToAddToChannel(null); + onImportUpdateFile={teamActions.handleEditDialogImportUpdateFile} + onOpenChange={(open) => { + if (!open) { + teamActions.setTeamDialogState(null); + } + }} + onDeleteRemovedPersonas={teamActions.handleDeleteRemovedPersonas} + onSubmit={teamActions.handleTeamSubmit} + open={teamActions.teamDialogState !== null} + personas={personas.libraryPersonas} + submitLabel={teamActions.teamDialogState.submitLabel} + title={teamActions.teamDialogState.title} + /> + ) : null} + {teamActions.teamToDelete ? ( + { + void teamActions.handleDeleteTeam(team); + }} + onOpenChange={(open) => { + if (!open) { + teamActions.setTeamToDelete(null); + } + }} + open={teamActions.teamToDelete !== null} + team={teamActions.teamToDelete} + /> + ) : null} + {teamActions.teamToAddToChannel ? ( + { + if (!open) { + teamActions.setTeamToAddToChannel(null); + } + }} + open={teamActions.teamToAddToChannel !== null} + personas={personas.libraryPersonas} + team={teamActions.teamToAddToChannel} + /> + ) : null} + {personas.batchImportResult ? ( + { + if (!open) { + personas.setBatchImportResult(null); + } + }} + open={personas.batchImportResult !== null} + result={personas.batchImportResult} + /> + ) : null} + {teamActions.teamImportPreview ? ( + { + if (!open) { + teamActions.setTeamImportPreview(null); + } + }} + open={teamActions.teamImportPreview !== null} + preview={teamActions.teamImportPreview.preview} + /> + ) : null} + {teamActions.teamImportTarget ? ( + - { - if (!open) { - personas.setBatchImportResult(null); + onApply={teamActions.handleTeamImportUpdateApply} + onClear={teamActions.clearImportUpdateAndReturnToEdit} + onOpenChange={(open) => { + if (!open) { + teamActions.closeImportUpdateDialog(); + } + }} + open={teamActions.teamImportTarget !== null} + personas={personas.libraryPersonas} + preview={teamActions.teamImportTargetPreview?.preview ?? null} + team={teamActions.teamImportTarget} + /> + ) : null} + {personas.personaImportActions.personaImportTarget ? ( + - { - if (!open) { - teamActions.setTeamImportPreview(null); + isPending={ + personas.personaImportActions.isApplyingPersonaImportUpdate || + personas.updatePersonaMutation.isPending } - }} - open={teamActions.teamImportPreview !== null} - preview={teamActions.teamImportPreview?.preview ?? null} - /> - { - if (!open) { - teamActions.closeImportUpdateDialog(); + onApply={personas.personaImportActions.handleImportUpdateApply} + onClear={ + personas.personaImportActions.clearImportUpdateAndReturnToEdit } - }} - open={teamActions.teamImportTarget !== null} - personas={personas.libraryPersonas} - preview={teamActions.teamImportTargetPreview?.preview ?? null} - team={teamActions.teamImportTarget} - /> - { - if (!open) { - personas.personaImportActions.closeImportUpdateDialog(); + onOpenChange={(open) => { + if (!open) { + personas.personaImportActions.closeImportUpdateDialog(); + } + }} + open={personas.personaImportActions.personaImportTarget !== null} + persona={personas.personaImportActions.personaImportTarget} + preview={ + personas.personaImportActions.personaImportTargetPreview?.preview ?? + null } - }} - open={personas.personaImportActions.personaImportTarget !== null} - persona={personas.personaImportActions.personaImportTarget} - preview={ - personas.personaImportActions.personaImportTargetPreview?.preview ?? - null - } - /> + /> + ) : null} ); } diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 52e74ad1a..16e67dcf6 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -52,16 +52,18 @@ export function CreateAgentDialog({ onOpenChange: (open: boolean) => void; }) { const createMutation = useCreateManagedAgentMutation(); - const providersQuery = useAvailableAcpRuntimes(); - const allProvidersQuery = useAcpRuntimesQuery(); - const backendProvidersQuery = useBackendProvidersQuery(); + const providersQuery = useAvailableAcpRuntimes({ enabled: open }); + const allProvidersQuery = useAcpRuntimesQuery({ enabled: open }); + const backendProvidersQuery = useBackendProvidersQuery({ enabled: open }); const { lastRuntimeId, setLastRuntime } = useLastRuntime(); const [acpCommand, setAcpCommand] = React.useState("buzz-acp"); const [agentCommand, setAgentCommand] = React.useState("buzz-agent"); const [agentArgs, setAgentArgs] = React.useState("acp"); const [mcpCommand, setMcpCommand] = React.useState(""); const [mcpToolsets, setMcpToolsets] = React.useState(""); - const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand); + const prereqsQuery = useManagedAgentPrereqsQuery(acpCommand, mcpCommand, { + enabled: open, + }); const [name, setName] = React.useState(""); const [relayUrl, setRelayUrl] = React.useState(""); const [spawnAfterCreate, setSpawnAfterCreate] = React.useState(true); diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 556f83a72..33ca2e5bb 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -535,11 +535,13 @@ function AgentActionsMenu({ - + {editOpen ? ( + + ) : null} ); } diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 7d8013791..432544ee2 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -31,7 +31,8 @@ import { export function useManagedAgentActions() { const relayAgentsQuery = useRelayAgentsQuery(); const managedAgentsQuery = useManagedAgentsQuery(); - const channelsQuery = useChannelsQuery(); + const [shouldLoadChannels, setShouldLoadChannels] = React.useState(false); + const channelsQuery = useChannelsQuery({ enabled: shouldLoadChannels }); const startMutation = useStartManagedAgentMutation(); const stopMutation = useStopManagedAgentMutation(); const deleteMutation = useDeleteManagedAgentMutation(); @@ -53,6 +54,13 @@ export function useManagedAgentActions() { const managedAgentLogQuery = useManagedAgentLogQuery(logAgentPubkey); + React.useEffect(() => { + const timeoutId = window.setTimeout(() => { + setShouldLoadChannels(true); + }, 0); + return () => window.clearTimeout(timeoutId); + }, []); + const managedAgents = React.useMemo( () => [...(managedAgentsQuery.data ?? [])].sort((left, right) => { @@ -151,14 +159,24 @@ export function useManagedAgentActions() { } } + async function getChannelsForAction() { + if (channelsQuery.data) { + return channelsQuery.data; + } + + const result = await channelsQuery.refetch(); + return result.data ?? []; + } + async function handleStop(pubkey: string) { clearFeedback(); try { const agent = managedAgents.find((a) => a.pubkey === pubkey); if (!agent) return; + const channels = await getChannelsForAction(); const result = await stopManagedAgentWithRules({ agent, - channels: channelsQuery.data ?? [], + channels, relayAgents: relayAgentsQuery.data ?? [], stopManagedAgent: stopMutation.mutateAsync, }); @@ -193,9 +211,10 @@ export function useManagedAgentActions() { try { const agent = managedAgents.find((a) => a.pubkey === pubkey); if (!agent) return; + const channels = await getChannelsForAction(); const result = await deleteManagedAgentWithRules({ agent, - channels: channelsQuery.data ?? [], + channels, deleteManagedAgent: deleteMutation.mutateAsync, presenceLookup: managedPresenceQuery.data, relayAgents: relayAgentsQuery.data ?? [], diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 478c57ebd..e8f1930e1 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -36,7 +36,11 @@ type PersonaFeedbackSurface = "catalog" | "library"; export function usePersonaActions() { const queryClient = useQueryClient(); const personasQuery = usePersonasQuery(); - const acpRuntimesQuery = useAcpRuntimesQuery(); + const [shouldLoadAcpRuntimes, setShouldLoadAcpRuntimes] = + React.useState(false); + const acpRuntimesQuery = useAcpRuntimesQuery({ + enabled: shouldLoadAcpRuntimes, + }); const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); const deletePersonaMutation = useDeletePersonaMutation(); @@ -141,6 +145,7 @@ export function usePersonaActions() { try { const result = await parsePersonaFiles(fileBytes, fileName); if (isSingleItemFile(fileBytes) && result.personas.length === 1) { + setShouldLoadAcpRuntimes(true); setPersonaDialogState(importPersonaDialogState(result.personas[0])); } else if (result.personas.length > 0) { setBatchImportResult(result); @@ -182,16 +187,19 @@ export function usePersonaActions() { function openCreate() { clearFeedback("library"); + setShouldLoadAcpRuntimes(true); setPersonaDialogState(createPersonaDialogState()); } function openEdit(persona: AgentPersona) { clearFeedback("library"); + setShouldLoadAcpRuntimes(true); setPersonaDialogState(editPersonaDialogState(persona)); } function openDuplicate(persona: AgentPersona) { clearFeedback("library"); + setShouldLoadAcpRuntimes(true); setPersonaDialogState(duplicatePersonaDialogState(persona)); } diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 25994aa02..b6d4d7299 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -101,8 +101,9 @@ function setChannelArchivedState( ); } -export function useChannelsQuery() { +export function useChannelsQuery(options?: { enabled?: boolean }) { return useQuery({ + enabled: options?.enabled ?? true, queryKey: channelsQueryKey, queryFn: async () => sortChannels(await getChannels()), staleTime: 60_000, From 2a522826edc6dfb4df79f34256beea6b8597505b Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 23 Jun 2026 12:47:11 -0700 Subject: [PATCH 05/30] fix(desktop): dedupe welcome intro per channel (#1216) Signed-off-by: Wes Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> --- desktop/src-tauri/src/commands/messages.rs | 48 +++++++++++++++++-- .../src/features/onboarding/welcomeGuide.ts | 1 + .../shared/api/tauriManagedAgentMessages.ts | 2 + 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index fcf73818e..2a04666fc 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -360,19 +360,26 @@ fn event_has_client_marker(event: &Event, marker: &str) -> bool { async fn find_managed_agent_channel_message_by_marker( state: &AppState, - agent_pubkey: &str, + agent_pubkey: Option<&str>, channel_id: &str, marker: &str, ) -> Result, String> { + let author = agent_pubkey + .map(str::trim) + .filter(|pubkey| !pubkey.is_empty()) + .map(str::to_ascii_lowercase); + let mut until: Option = None; for _ in 0..10 { let mut filter = serde_json::json!({ - "authors": [agent_pubkey], "kinds": [buzz_core_pkg::kind::KIND_STREAM_MESSAGE], "#h": [channel_id], "limit": 500, }); + if let Some(author) = author.as_deref() { + filter["authors"] = serde_json::json!([author]); + } if let Some(until) = until { filter["until"] = serde_json::json!(until); } @@ -401,6 +408,16 @@ async fn find_managed_agent_channel_message_by_marker( Ok(None) } +fn marker_author_for_scope<'a>( + marker_scope: Option<&str>, + agent_pubkey: &'a str, +) -> Option<&'a str> { + match marker_scope { + Some("channel") => None, + _ => Some(agent_pubkey), + } +} + fn stored_managed_agent_auth_tag(auth_tag: Option<&str>) -> Option { auth_tag .map(str::trim) @@ -440,6 +457,7 @@ pub async fn send_managed_agent_channel_message( channel_id: String, content: String, marker: Option, + marker_scope: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -480,7 +498,7 @@ pub async fn send_managed_agent_channel_message( if let Some(marker) = marker.as_deref() { if let Some(existing) = find_managed_agent_channel_message_by_marker( &state, - &record.pubkey, + marker_author_for_scope(marker_scope.as_deref(), &record.pubkey), &channel_id, marker, ) @@ -526,6 +544,30 @@ pub async fn send_managed_agent_channel_message( mod tests { use super::*; + #[test] + fn marker_author_scope_defaults_to_agent() { + assert_eq!( + marker_author_for_scope(None, "agent-pubkey"), + Some("agent-pubkey") + ); + assert_eq!( + marker_author_for_scope(Some("agent"), "agent-pubkey"), + Some("agent-pubkey") + ); + assert_eq!( + marker_author_for_scope(Some("unknown"), "agent-pubkey"), + Some("agent-pubkey") + ); + } + + #[test] + fn marker_author_scope_can_dedupe_across_channel() { + assert_eq!( + marker_author_for_scope(Some("channel"), "agent-pubkey"), + None + ); + } + #[test] fn stored_managed_agent_auth_tag_trims_blank_values() { assert_eq!( diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index 1b6afd5d7..4ce2b4969 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -160,6 +160,7 @@ export async function ensureWelcomeGuideIntro( channelId, content: WELCOME_GUIDE_INTRO_MESSAGE, marker: WELCOME_GUIDE_INTRO_MARKER, + markerScope: "channel", }); return agent; } diff --git a/desktop/src/shared/api/tauriManagedAgentMessages.ts b/desktop/src/shared/api/tauriManagedAgentMessages.ts index 37c77ecce..b7a6b2114 100644 --- a/desktop/src/shared/api/tauriManagedAgentMessages.ts +++ b/desktop/src/shared/api/tauriManagedAgentMessages.ts @@ -14,6 +14,7 @@ export async function sendManagedAgentChannelMessage(input: { channelId: string; content: string; marker?: string; + markerScope?: "agent" | "channel"; }): Promise { const response = await invokeTauri( "send_managed_agent_channel_message", @@ -22,6 +23,7 @@ export async function sendManagedAgentChannelMessage(input: { channelId: input.channelId, content: input.content, marker: input.marker ?? null, + markerScope: input.markerScope ?? null, }, ); From b4e75a1e41a614fa3449e814ccbd9f31090dfbfc Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Tue, 23 Jun 2026 16:45:57 -0400 Subject: [PATCH 06/30] Fix collapsed home header chrome overlap (#1215) Signed-off-by: Thomas Petersen --- desktop/playwright.config.ts | 1 + .../src/features/home/ui/InboxListPane.tsx | 11 +++++- ...e-collapsed-top-chrome-screenshots.spec.ts | 36 +++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 desktop/tests/e2e/home-collapsed-top-chrome-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7f7ccce4f..5c431eba7 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -47,6 +47,7 @@ export default defineConfig({ "**/relay-connectivity-screenshots.spec.ts", "**/unread-pill-screenshots.spec.ts", "**/sidebar-more-unread-overlap.spec.ts", + "**/home-collapsed-top-chrome-screenshots.spec.ts", "**/thread-unread-screenshots.spec.ts", "**/animated-avatar-screenshots.spec.ts", "**/reminders-screenshots.spec.ts", diff --git a/desktop/src/features/home/ui/InboxListPane.tsx b/desktop/src/features/home/ui/InboxListPane.tsx index 697045d84..5f2fe3993 100644 --- a/desktop/src/features/home/ui/InboxListPane.tsx +++ b/desktop/src/features/home/ui/InboxListPane.tsx @@ -16,6 +16,7 @@ import { import { RemindersPanel } from "@/features/reminders/ui/RemindersPanel"; import { TopChromeInsetHeader } from "@/shared/layout/TopChromeInsetHeader"; import { cn } from "@/shared/lib/cn"; +import { useOptionalSidebar } from "@/shared/ui/sidebar"; import { ContextMenu, ContextMenuContent, @@ -131,6 +132,9 @@ export function InboxListPane({ reminderPubkey, unreadOnly, }: InboxListPaneProps) { + const sidebar = useOptionalSidebar(); + const clearCollapsedTopChromeControls = + sidebar?.state === "collapsed" && !sidebar.isMobile; const activeFilter = FILTER_OPTIONS.find((option) => option.value === filter); const isReminders = filter === "reminders"; const scrollRef = React.useRef(null); @@ -325,7 +329,12 @@ export function InboxListPane({ )} > -
+
diff --git a/desktop/tests/e2e/home-collapsed-top-chrome-screenshots.spec.ts b/desktop/tests/e2e/home-collapsed-top-chrome-screenshots.spec.ts new file mode 100644 index 000000000..3ccb24fb1 --- /dev/null +++ b/desktop/tests/e2e/home-collapsed-top-chrome-screenshots.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/home-collapsed-top-chrome"; + +test.describe("home inbox header collapsed-sidebar chrome clearance", () => { + test.use({ viewport: { width: 1280, height: 720 } }); + + test("inbox options clear the macOS traffic-light region when sidebar is collapsed", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + await expect(page.getByTestId("home-inbox-list")).toBeVisible(); + + await page.locator('[data-sidebar="trigger"]').click(); + + const inboxOptions = page.getByTestId("inbox-options-trigger"); + await expect(inboxOptions).toBeVisible(); + await expect + .poll(async () => + inboxOptions.evaluate((element) => + Math.round(element.getBoundingClientRect().left), + ), + ) + .toBeGreaterThanOrEqual(168); + + await waitForAnimations(page); + await page.screenshot({ + path: `${SHOTS}/01-collapsed-inbox-header-clears-traffic-lights.png`, + clip: { x: 0, y: 0, width: 420, height: 120 }, + }); + }); +}); From 89aaa26443486244b6f004a7c419f4e0dc86aa44 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 23 Jun 2026 13:59:21 -0700 Subject: [PATCH 07/30] fix(desktop): restore channel unread badges (#1218) Signed-off-by: Wes Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> --- desktop/src/app/AppShell.helpers.test.mjs | 29 +++++ desktop/src/app/AppShell.helpers.ts | 5 + desktop/src/app/AppShell.tsx | 29 +++-- .../features/channels/unreadChannelCounts.ts | 50 +++++++++ .../channels/unreadReadMarker.test.mjs | 65 ++++++++++- .../features/channels/unreadRootIdStore.ts | 30 +++++ .../channels/useLiveChannelUpdates.test.mjs | 22 ---- .../channels/useLiveChannelUpdates.ts | 27 +---- .../features/channels/useUnreadChannels.ts | 103 ++++++++---------- .../features/sidebar/ui/SidebarSection.tsx | 5 +- desktop/tests/e2e/badge.spec.ts | 60 +++++++++- desktop/tests/e2e/channels.spec.ts | 12 +- .../e2e/thread-unread-screenshots.spec.ts | 29 +++-- 13 files changed, 326 insertions(+), 140 deletions(-) create mode 100644 desktop/src/app/AppShell.helpers.test.mjs create mode 100644 desktop/src/features/channels/unreadRootIdStore.ts delete mode 100644 desktop/src/features/channels/useLiveChannelUpdates.test.mjs diff --git a/desktop/src/app/AppShell.helpers.test.mjs b/desktop/src/app/AppShell.helpers.test.mjs new file mode 100644 index 000000000..73505d179 --- /dev/null +++ b/desktop/src/app/AppShell.helpers.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldBounceForChannelNotification } from "./AppShell.helpers.ts"; + +test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => { + assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true); +}); + +test("shouldBounceForChannelNotification_suppressesThreadReplies", () => { + assert.equal( + shouldBounceForChannelNotification([ + ["h", "channel"], + ["e", "root", "", "reply"], + ]), + false, + ); +}); + +test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => { + assert.equal( + shouldBounceForChannelNotification([ + ["h", "channel"], + ["e", "root", "", "reply"], + ["broadcast", "1"], + ]), + true, + ); +}); diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 8ce0b8ca1..7b89240ab 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -1,3 +1,4 @@ +import { isThreadReply } from "@/features/messages/lib/threading"; import type { DesktopNotificationTarget } from "@/features/notifications/lib/desktop"; import type { SearchHit } from "@/shared/api/types"; @@ -25,6 +26,10 @@ export function isWindowDragHandleEvent(event: MouseEvent | PointerEvent) { ); } +export function shouldBounceForChannelNotification(tags: string[][]): boolean { + return !isThreadReply(tags); +} + export function toSearchHit( target: DesktopNotificationTarget, ): SearchHit | null { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 5507c7245..ff0176b63 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -6,6 +6,7 @@ import { Outlet, useLocation } from "@tanstack/react-router"; import { deriveShellRoute, isWindowDragHandleEvent, + shouldBounceForChannelNotification, toSearchHit, } from "@/app/AppShell.helpers"; import { AppShellProvider } from "@/app/AppShellContext"; @@ -179,7 +180,8 @@ export function AppShell() { refetchHomeFeedFromLiveSignal, ); const handleChannelNotification = React.useEffectEvent( - (_channelId: string, _event: RelayEvent) => { + (_channelId: string, event: RelayEvent) => { + if (!shouldBounceForChannelNotification(event.tags)) return; if (!notificationSettings.settings.desktopEnabled) return; void requestDockBounce(); }, @@ -390,11 +392,6 @@ export function AppShell() { channels, ); - // Badge count is computed here (rather than inside useHomeFeedNotifications) - // so it can consume the NIP-RS read-state lifted from the single - // ReadStateManager mounted via useUnreadChannels above. Channel-backed - // feed items contribute to the badge iff strictly newer than that - // channel's read marker; non-channel items keep their seen-set fallback. const { homeBadgeCount, homeBadgeCountExcludingHighPriority } = useHomeFeedNotificationState( homeFeedQuery.data, @@ -412,8 +409,6 @@ export function AppShell() { getThreadReadAt, ); - // Raw add to the in-app nav badge, mirroring the inbox filter badge; gated by - // homeBadgeEnabled to match every other badge contribution. const dueReminderBadge = useDueReminderBadgeCount( identityQuery.data?.pubkey, notificationSettings.settings.homeBadgeEnabled, @@ -592,14 +587,18 @@ export function AppShell() { }, []); React.useEffect(() => { - const numericCount = + const count = unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority; - if (numericCount > 0) { - void setDesktopAppBadge({ kind: "count", count: numericCount }); - } else { - void setDesktopAppBadge({ kind: "none" }); - } - }, [homeBadgeCountExcludingHighPriority, unreadChannelNotificationCount]); + void setDesktopAppBadge( + count + ? { kind: "count", count } + : { kind: unreadChannelIds.size ? "dot" : "none" }, + ); + }, [ + homeBadgeCountExcludingHighPriority, + unreadChannelIds, + unreadChannelNotificationCount, + ]); // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); diff --git a/desktop/src/features/channels/unreadChannelCounts.ts b/desktop/src/features/channels/unreadChannelCounts.ts index 0b96055a1..2a4239cfe 100644 --- a/desktop/src/features/channels/unreadChannelCounts.ts +++ b/desktop/src/features/channels/unreadChannelCounts.ts @@ -3,8 +3,30 @@ export type ObservedUnreadEvent = { createdAt: number; rootId: string | null; highPriority: boolean; + countsTowardBadge: boolean; + countsTowardAppBadge: boolean; }; +export function makeObservedUnreadEvent(input: { + id: string; + createdAt: number; + rootId: string | null; + highPriority: boolean; + channelType: string | undefined; + isThreadedReply: boolean; +}): ObservedUnreadEvent { + const isDm = input.channelType === "dm"; + return { + id: input.id, + createdAt: input.createdAt, + rootId: input.rootId, + highPriority: input.highPriority, + countsTowardBadge: isDm || input.isThreadedReply || input.highPriority, + countsTowardAppBadge: + isDm || (!input.isThreadedReply && input.highPriority), + }; +} + export function mapsEqual( a: ReadonlyMap, b: ReadonlyMap, @@ -54,6 +76,34 @@ export function countUnreadObservedEvents( return count; } +export function countUnreadBadgeObservedEvents( + eventsById: ReadonlyMap | undefined, + getReadAt: (event: ObservedUnreadEvent) => number | null, +): number { + if (!eventsById) return 0; + let count = 0; + for (const event of eventsById.values()) { + if (!event.countsTowardBadge) continue; + const readAt = getReadAt(event); + if (readAt === null || event.createdAt > readAt) count += 1; + } + return count; +} + +export function countUnreadAppBadgeObservedEvents( + eventsById: ReadonlyMap | undefined, + getReadAt: (event: ObservedUnreadEvent) => number | null, +): number { + if (!eventsById) return 0; + let count = 0; + for (const event of eventsById.values()) { + if (!event.countsTowardAppBadge) continue; + const readAt = getReadAt(event); + if (readAt === null || event.createdAt > readAt) count += 1; + } + return count; +} + export function countUnreadHighPriorityObservedEvents( eventsById: ReadonlyMap | undefined, getReadAt: (event: ObservedUnreadEvent) => number | null, diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index 406e3c05c..521414e8a 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -3,6 +3,8 @@ import test from "node:test"; import { computeChannelUnreadMarker } from "../messages/lib/unreadMarker.ts"; import { + countUnreadAppBadgeObservedEvents, + countUnreadBadgeObservedEvents, countUnreadHighPriorityObservedEvents, countUnreadObservedEvents, observedUnreadEventReadAt, @@ -136,8 +138,22 @@ test("observedUnreadEventReadAt_nullChannelMarkerThreadMarkerCanClear", () => { // --- Fix 2b: sidebar badge evaluates all observed events, not a single aggregate frontier --- -function observed(id, createdAt, rootId = null, highPriority = false) { - return { id, createdAt, rootId, highPriority }; +function observed( + id, + createdAt, + rootId = null, + highPriority = false, + countsTowardBadge = true, + countsTowardAppBadge = countsTowardBadge, +) { + return { + id, + createdAt, + rootId, + highPriority, + countsTowardBadge, + countsTowardAppBadge, + }; } function readAtFor(channelMarker, threadMarkers) { @@ -219,6 +235,51 @@ test("countUnreadObservedEvents_topLevelUsesChannelMarker", () => { assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 1); }); +test("countUnreadBadgeObservedEvents_skipsBoldOnlyGeneralChannelItems", () => { + const events = new Map([ + ["plain", observed("plain", 500, null, false, false)], + ["thread", observed("thread", 600, "root-1")], + ]); + + assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 2); + assert.equal( + countUnreadBadgeObservedEvents(events, readAtFor(300, new Map())), + 1, + ); + assert.equal( + countUnreadAppBadgeObservedEvents(events, readAtFor(300, new Map())), + 1, + ); +}); + +test("countUnreadObservedEvents_countsThreadRepliesForChannelUnread", () => { + const events = new Map([ + ["reply", observed("reply", 500, "root-1", false, true, false)], + ]); + + assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 1); + assert.equal( + countUnreadBadgeObservedEvents(events, readAtFor(300, new Map())), + 1, + ); + assert.equal( + countUnreadAppBadgeObservedEvents(events, readAtFor(300, new Map())), + 0, + ); +}); + +test("highPriorityObservedEvents_countsMentionBadgeForGeneralMessage", () => { + const events = new Map([ + ["mention", observed("mention", 500, null, true, true)], + ]); + const getReadAt = readAtFor(300, new Map()); + + assert.equal(countUnreadObservedEvents(events, getReadAt), 1); + assert.equal(countUnreadBadgeObservedEvents(events, getReadAt), 1); + assert.equal(countUnreadAppBadgeObservedEvents(events, getReadAt), 1); + assert.equal(countUnreadHighPriorityObservedEvents(events, getReadAt), 1); +}); + test("recordObservedUnreadEvent_reportsOutOfOrderInsertForInvalidation", () => { const channelId = "chan"; const observedByChannel = new Map(); diff --git a/desktop/src/features/channels/unreadRootIdStore.ts b/desktop/src/features/channels/unreadRootIdStore.ts new file mode 100644 index 000000000..247b8a4b1 --- /dev/null +++ b/desktop/src/features/channels/unreadRootIdStore.ts @@ -0,0 +1,30 @@ +// Per-pubkey JSON array of thread root ids, capped to newest entries and +// tolerant of malformed or unavailable localStorage. +export function makeRootIdStore(prefix: string, maxEntries = 1000) { + const storageKey = (pubkey: string) => `${prefix}:${pubkey}`; + return { + read(pubkey: string): Set { + try { + const raw = window.localStorage.getItem(storageKey(pubkey)); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set( + parsed.filter((id): id is string => typeof id === "string"), + ); + } catch { + return new Set(); + } + }, + write(pubkey: string, rootIds: Set): void { + try { + const arr = [...rootIds]; + const capped = + arr.length > maxEntries ? arr.slice(arr.length - maxEntries) : arr; + window.localStorage.setItem(storageKey(pubkey), JSON.stringify(capped)); + } catch { + // Ignore storage errors (private browsing, quota exceeded). + } + }, + }; +} diff --git a/desktop/src/features/channels/useLiveChannelUpdates.test.mjs b/desktop/src/features/channels/useLiveChannelUpdates.test.mjs deleted file mode 100644 index f6949bcaf..000000000 --- a/desktop/src/features/channels/useLiveChannelUpdates.test.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { shouldRouteChannelUnreadEvent } from "./useLiveChannelUpdates.ts"; - -test("main-channel messages route to channel unread tracking", () => { - assert.equal(shouldRouteChannelUnreadEvent(undefined, false), true); -}); - -test("non-DM thread replies do not route to channel unread tracking", () => { - assert.equal( - shouldRouteChannelUnreadEvent({ channelType: "stream" }, true), - false, - ); -}); - -test("DM thread replies route to channel unread tracking", () => { - assert.equal( - shouldRouteChannelUnreadEvent({ channelType: "dm" }, true), - true, - ); -}); diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index a645c6c1a..f5a03a9a2 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -28,12 +28,11 @@ export type UseLiveChannelUpdatesOptions = { onDmMessage?: (event: RelayEvent, channel: Channel) => void; onLiveMention?: () => void; /** - * Fired for live main-channel "new content" events in a member channel - * authored by someone other than the current user. Non-DM thread replies - * are routed through onThreadReplyNotification instead; DM thread replies - * also fire this callback so the DM unread dot/count stays channel-level. - * Used to drive the in-session "latest message at" map that powers sidebar - * unread badges. See `UNREAD_TRIGGER_KINDS` for the exact kind set. + * Fired for live "new content" events in a member channel authored by + * someone other than the current user. Thread replies also fire + * onThreadReplyNotification so Home inbox activity stays in sync. Used to + * drive the observed unread-event map that powers sidebar unread state. + * See `UNREAD_TRIGGER_KINDS` for the exact kind set. */ onChannelMessage?: (channelId: string, event: RelayEvent) => void; /** @@ -73,13 +72,6 @@ const UNREAD_TRIGGER_KINDS = new Set(CHANNEL_MESSAGE_EVENT_KINDS); export const EMPTY_SET: ReadonlySet = new Set(); -export function shouldRouteChannelUnreadEvent( - channel: Pick | undefined, - isThreadedReply: boolean, -): boolean { - return !isThreadedReply || channel?.channelType === "dm"; -} - function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { return ( currentPubkey.length > 0 && event.pubkey.toLowerCase() !== currentPubkey @@ -237,18 +229,11 @@ export function useLiveChannelUpdates( if (isThreadedReply) { options.onThreadReplyCandidate?.(channelId, event); } - } else if ( - shouldRouteChannelUnreadEvent( - dmChannelMap.get(channelId), - isThreadedReply, - ) - ) { + } else { options.onChannelMessage?.(channelId, event); if (isThreadedReply) { options.onThreadReplyNotification?.(channelId, event); } - } else { - options.onThreadReplyNotification?.(channelId, event); } if (shouldNotify && isThreadedReply) { diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index b9d8eb21e..8cf6808d9 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -1,19 +1,22 @@ import * as React from "react"; import { EMPTY_SET, - shouldRouteChannelUnreadEvent, useLiveChannelUpdates, type UseLiveChannelUpdatesOptions, } from "@/features/channels/useLiveChannelUpdates"; import { + countUnreadAppBadgeObservedEvents, + countUnreadBadgeObservedEvents, countUnreadHighPriorityObservedEvents, countUnreadObservedEvents, + makeObservedUnreadEvent, mapsEqual, observedUnreadEventReadAt, recordObservedUnreadEvent, type ObservedUnreadEvent, } from "@/features/channels/unreadChannelCounts"; import { useReadState } from "@/features/channels/readState/useReadState"; +import { makeRootIdStore } from "@/features/channels/unreadRootIdStore"; import { getThreadReference, isBroadcastReply, @@ -40,41 +43,6 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { // per-channel limit elsewhere in the app. const CATCH_UP_LIMIT = 1000; -// All four thread root-id sets (participation, authored, mentioned, muted) -// share the same localStorage shape: a per-pubkey JSON array of ids, capped to -// the newest N entries on write and tolerant of malformed/absent data on read. -// One factory yields the read/write pair for each so the only difference is the -// key prefix. The closures capture the prefix lexically (no `this`), so a -// caller can alias one store's `write` into a variable and call it bare. -function makeRootIdStore(prefix: string, maxEntries = 1000) { - const storageKey = (pubkey: string) => `${prefix}:${pubkey}`; - return { - read(pubkey: string): Set { - try { - const raw = window.localStorage.getItem(storageKey(pubkey)); - if (!raw) return new Set(); - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return new Set(); - return new Set( - parsed.filter((id): id is string => typeof id === "string"), - ); - } catch { - return new Set(); - } - }, - write(pubkey: string, rootIds: Set): void { - try { - const arr = [...rootIds]; - const capped = - arr.length > maxEntries ? arr.slice(arr.length - maxEntries) : arr; - window.localStorage.setItem(storageKey(pubkey), JSON.stringify(capped)); - } catch { - // Ignore storage errors (private browsing, quota exceeded). - } - }, - }; -} - const participationStore = makeRootIdStore("buzz-thread-participation.v1"); const authoredStore = makeRootIdStore("buzz-thread-authored.v1"); // Thread roots where an external message @-mentioned the current user. The @@ -436,12 +404,20 @@ export function useUnreadChannels( channel?.channelType === "dm" || (normalizedPubkey !== null && isHighPriorityEventForUser(event, normalizedPubkey)); - const didRecordUnreadEvent = recordUnreadEvent(channelId, { - id: event.id, - createdAt: event.created_at, - rootId: resolveObservedUnreadRootId(event.tags), - highPriority: isHighPriority, - }); + const isThreadedReply = + getThreadReference(event.tags).parentId !== null && + !isBroadcastReply(event.tags); + const didRecordUnreadEvent = recordUnreadEvent( + channelId, + makeObservedUnreadEvent({ + id: event.id, + createdAt: event.created_at, + rootId: resolveObservedUnreadRootId(event.tags), + highPriority: isHighPriority, + channelType: channel?.channelType, + isThreadedReply, + }), + ); const current = latestByChannelRef.current.get(channelId) ?? 0; if (event.created_at > current) { latestByChannelRef.current.set(channelId, event.created_at); @@ -694,21 +670,23 @@ export function useUnreadChannels( const evtRef = getThreadReference(event.tags); const isThreadedReply = evtRef.parentId !== null && !isBroadcastReply(event.tags); - if (shouldRouteChannelUnreadEvent(ch, isThreadedReply)) { - if (event.created_at > maxExternal) { - maxExternal = event.created_at; - } - const isHighPriority = - chType === "dm" || - (normalizedPubkey !== null && - isHighPriorityEventForUser(event, normalizedPubkey)); - unreadEvents.push({ + if (event.created_at > maxExternal) { + maxExternal = event.created_at; + } + const isHighPriority = + chType === "dm" || + (normalizedPubkey !== null && + isHighPriorityEventForUser(event, normalizedPubkey)); + unreadEvents.push( + makeObservedUnreadEvent({ id: event.id, createdAt: event.created_at, rootId: resolveObservedUnreadRootId(event.tags), highPriority: isHighPriority, - }); - } + channelType: chType, + isThreadedReply, + }), + ); if (isThreadedReply) { threadReplies.push({ id: event.id, @@ -821,12 +799,14 @@ export function useUnreadChannels( unreadChannelIds: new Set(), highPriorityUnreadChannelIds: new Set(), unreadChannelCounts: new Map(), + unreadChannelNotificationCount: 0, }; } const unread = new Set(); const highPriority = new Set(); const counts = new Map(); + let unreadChannelNotificationCount = 0; for (const channel of channels) { if (channel.id === activeChannelId) continue; @@ -835,6 +815,7 @@ export function useUnreadChannels( // Forced-unread is dot tier only — not high-priority. unread.add(channel.id); counts.set(channel.id, 1); + unreadChannelNotificationCount += 1; continue; } @@ -856,7 +837,15 @@ export function useUnreadChannels( if (unreadCount === 0) continue; unread.add(channel.id); - counts.set(channel.id, unreadCount); + const badgeCount = countUnreadBadgeObservedEvents( + observedEvents, + readAtForObservedEvent, + ); + counts.set(channel.id, badgeCount); + unreadChannelNotificationCount += countUnreadAppBadgeObservedEvents( + observedEvents, + readAtForObservedEvent, + ); // DM channels: any unread DM is high-priority. if (channel.channelType === "dm") { @@ -877,6 +866,7 @@ export function useUnreadChannels( unreadChannelIds: unread, highPriorityUnreadChannelIds: highPriority, unreadChannelCounts: counts, + unreadChannelNotificationCount, }; }, [ activeChannelId, @@ -919,9 +909,8 @@ export function useUnreadChannels( ? prevUnreadCountsRef.current : rawUnread.unreadChannelCounts; prevUnreadCountsRef.current = unreadChannelCounts; - const unreadChannelNotificationCount = [ - ...unreadChannelCounts.values(), - ].reduce((total, count) => total + count, 0); + const unreadChannelNotificationCount = + rawUnread.unreadChannelNotificationCount; const unreadChannelIdsRef = React.useRef(unreadChannelIds); unreadChannelIdsRef.current = unreadChannelIds; diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 8df6f2be2..f8e1915e2 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -233,7 +233,10 @@ export function ChannelMenuButton({ )} /> ) : null} - {hasUnread && !isActive && channel.channelType !== "dm" ? ( + {hasUnread && + unreadCount > 0 && + !isActive && + channel.channelType !== "dm" ? ( 0 ? baseline : { state: "dot", count: 0 }; +} + test.beforeEach(async ({ page }) => { await installMockBridge(page); }); -test("numeric badge increments for regular message in inactive channel", async ({ +test("regular message bolds inactive channel without numeric badge", async ({ page, }) => { await page.goto("/"); @@ -96,8 +100,12 @@ test("numeric badge increments for regular message in inactive channel", async ( { pubkey: TEST_IDENTITIES.alice.pubkey }, ); - await expect(page.getByTestId("channel-unread-random")).toBeVisible(); - await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); + await expect(page.getByTestId("channel-random")).toHaveCSS( + "font-weight", + "600", + ); + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + await waitForBadgeState(page, withDotOnlyBadge(baselineBadge)); }); test("numeric badge increments for @mention in inactive channel", async ({ @@ -148,6 +156,42 @@ test("numeric badge increments for DM message", async ({ page }) => { await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); +test("numeric badge increments for interested thread reply in inactive channel", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "random"); + const baselineBadge = await getSettledBadgeState(page); + + const rootEventId = await page.evaluate(() => { + const root = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: "Conversation I started", + kind: 40002, + pubkey: "deadbeef".repeat(8), + }); + return root?.id; + }); + + await page.evaluate( + ({ parentEventId, pubkey }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: "Thread reply to a followed conversation", + kind: 40002, + parentEventId, + pubkey, + }); + }, + { parentEventId: rootEventId, pubkey: TEST_IDENTITIES.alice.pubkey }, + ); + + await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); +}); + test("numeric badge increments for broadcast reply in inactive channel", async ({ page, }) => { @@ -202,11 +246,19 @@ test("mark-as-read via context menu clears channel unread indicator", async ({ { pubkey: TEST_IDENTITIES.alice.pubkey }, ); - await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await expect(page.getByTestId("channel-random")).toHaveCSS( + "font-weight", + "600", + ); + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); await page.getByTestId("channel-random").click({ button: "right" }); await page.getByText("Mark as read").click(); + await expect(page.getByTestId("channel-random")).not.toHaveCSS( + "font-weight", + "600", + ); await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); await waitForBadgeState(page, baselineBadge); }); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index fb8109e84..2509b6684 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1039,7 +1039,11 @@ test("sidebar shows unread indicator for newly active channels", async ({ { pubkey: TEST_IDENTITIES.alice.pubkey }, ); - await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await expect(page.getByTestId("channel-random")).toHaveCSS( + "font-weight", + "600", + ); + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); @@ -1068,7 +1072,11 @@ test("sidebar shows unread indicator for new forum posts", async ({ page }) => { { pubkey: TEST_IDENTITIES.alice.pubkey }, ); - await expect(page.getByTestId("channel-unread-watercooler")).toBeVisible(); + await expect(page.getByTestId("channel-watercooler")).toHaveCSS( + "font-weight", + "600", + ); + await expect(page.getByTestId("channel-unread-watercooler")).toHaveCount(0); await page.getByTestId("channel-watercooler").click(); await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); diff --git a/desktop/tests/e2e/thread-unread-screenshots.spec.ts b/desktop/tests/e2e/thread-unread-screenshots.spec.ts index 6585e0164..e29311819 100644 --- a/desktop/tests/e2e/thread-unread-screenshots.spec.ts +++ b/desktop/tests/e2e/thread-unread-screenshots.spec.ts @@ -762,10 +762,9 @@ test.describe("thread unread indicator screenshots", () => { }); }); - // Thread-only replies now route through Inbox instead of lighting the - // channel's sidebar dot. Viewing the channel should still leave the channel - // dot clear when the only new item is an unopened thread reply. - test("11-thread-reply-does-not-light-sidebar-dot-after-channel-view", async ({ + // Thread-only replies now also light the channel sidebar badge. Viewing the + // channel should leave unopened thread replies unread until the thread is read. + test("11-thread-reply-lights-sidebar-badge-after-channel-view", async ({ page, }) => { await installMockBridge(page); @@ -799,27 +798,25 @@ test.describe("thread unread indicator screenshots", () => { await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - // The crux: leave general. Its sidebar dot must stay clear because - // thread-only reply activity belongs in Inbox, not the channel nav. + // The crux: leave general. The unopened thread reply should still keep a + // numeric channel sidebar badge until the thread itself is read. await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); - await expect(page.getByTestId("channel-unread-general")).toHaveCount(0); + await expect(page.getByTestId("channel-unread-general")).toBeVisible(); await page.screenshot({ - path: `${SHOTS}/11-thread-reply-no-sidebar-dot.png`, + path: `${SHOTS}/11-thread-reply-sidebar-badge.png`, }); }); // Regression guard for the all-replies window: when the loaded window holds // ONLY thread replies (the top-level root has scrolled past the history - // limit), thread-only activity should still stay out of channel unread dots. + // limit), thread-only activity should still light the channel sidebar badge. // // The `all-replies` fixture carries a far-future `lastMessageAt` (standing in // for the backend's reply-inclusive MAX) with no top-level message in its // window. - test("12-thread-reply-does-not-light-all-replies-sidebar-dot", async ({ - page, - }) => { + test("12-thread-reply-lights-all-replies-sidebar-badge", async ({ page }) => { await installMockBridge(page); await page.goto("/"); @@ -843,14 +840,14 @@ test.describe("thread unread indicator screenshots", () => { await page.getByTestId("channel-all-replies").click(); await expect(page.getByTestId("chat-title")).toHaveText("all-replies"); - // The crux: leave the channel. Its sidebar dot should remain clear because - // thread-only reply activity belongs in Inbox. + // The crux: leave the channel. Its unopened thread reply should still keep + // a numeric channel sidebar badge until the thread itself is read. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - await expect(page.getByTestId("channel-unread-all-replies")).toHaveCount(0); + await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible(); await page.screenshot({ - path: `${SHOTS}/12-thread-reply-no-all-replies-sidebar-dot.png`, + path: `${SHOTS}/12-thread-reply-all-replies-sidebar-badge.png`, }); }); From c0a872e898479bcb2c3dda1b642c0d1373174f68 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 23 Jun 2026 22:15:04 +0100 Subject: [PATCH 08/30] Polish sidebar channel navigation (#1213) --- .../features/channels/ui/MembersSidebar.tsx | 6 ++-- .../src/features/sidebar/ui/AppSidebar.tsx | 7 ++--- .../sidebar/ui/CustomChannelSection.tsx | 29 +++++++++++-------- .../features/sidebar/ui/SidebarSection.tsx | 6 ++-- desktop/src/shared/ui/icons.ts | 10 +++++++ 5 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 desktop/src/shared/ui/icons.ts diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 37741cb92..61efb3104 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -704,11 +704,9 @@ function SearchResultSectionTitle({ children: React.ReactNode; }) { return ( -
+
{children} - {action ? ( - {action} - ) : null} + {action ? {action} : null}
); } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 9b925f07f..f3403672f 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -619,7 +619,7 @@ export function AppSidebar({
{isLoading ? ( @@ -716,9 +716,6 @@ export function AppSidebar({ browseAriaLabel="Browse channels" createAriaLabel="Create a channel" draggable - groupClassName={ - channelSections.length > 0 ? undefined : "pt-1" - } hasUnread={unreadChannelIds.size > 0} isCollapsed={collapsedGroups.channels} isActiveChannel={selectedView === "channel"} @@ -802,7 +799,7 @@ export function AppSidebar({ presenceByChannelId={dmPresenceByChannelId} selectedChannelId={selectedChannelId} testId="dm-list" - title="Direct Messages" + title="Direct messages" unreadChannelCounts={unreadChannelCounts} unreadChannelIds={unreadChannelIds} mutedChannelIds={mutedChannelIds} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index f0d583b7f..f4a0add50 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -11,7 +11,6 @@ import { GripVertical, Pencil, Plus, - Search, Star, StarOff, Trash2, @@ -48,6 +47,7 @@ import { import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { HashSearch } from "@/shared/ui/icons"; // --------------------------------------------------------------------------- // Shared styles @@ -56,7 +56,7 @@ import { cn } from "@/shared/lib/cn"; const SECTION_LABEL_BUTTON_CLASS = "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground"; const SECTION_LABEL_CHEVRON_CLASS = - "relative size-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:text-sidebar-foreground group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-within/sidebar-section:opacity-100 group-focus-within/sidebar-section:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground"; + "relative size-2.5 shrink-0 text-current opacity-0 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/section-label:opacity-100 group-focus-within/sidebar-section:opacity-100 group-focus-visible/section-label:opacity-100"; const SECTION_LABEL_CHEVRON_ICON_CLASS = "absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2"; @@ -261,18 +261,24 @@ function SectionHeaderActions({ {onBrowseClick ? ( ) : null} {onCreateClick ? ( - ))} -
+ )} + + {result.kind !== "message" && trailingLabel ? ( + + {trailingLabel} + + ) : null} + ); + }; + + const renderSearchResultSections = (sections: SearchResultSection[]) => { + let resultIndex = 0; + + return sections.map((section) => ( +
+
{section.title}
+ {section.results.map((result) => + renderSearchResultRow(result, resultIndex++), + )} +
+ )); + }; + + const searchResultContent = isShowingSuggestions ? ( + suggestionResults.length === 0 ? ( +
+

No recent activity yet.

+
+ ) : ( +
+ {(() => { + let resultIndex = 0; + + return ( + <> + {suggestedResults.length > 0 ? ( +
+
+ Recent activity +
+ {suggestedResults.map((result) => + renderSearchResultRow(result, resultIndex++), + )} +
+ ) : null} + {suggestionActionResults.length > 0 ? ( +
+
Actions
+ {suggestionActionResults.map((result) => + renderSearchResultRow(result, resultIndex++), + )} +
+ ) : null} + + ); + })()} +
+ ) + ) : isSearchLoading && searchableResults.length === 0 ? ( + + ) : searchQuery.error instanceof Error && searchableResults.length === 0 ? ( +

+ {searchQuery.error.message} +

+ ) : searchableResults.length === 0 ? ( +

+ No matches for {trimmedQuery}. +

+ ) : ( +
+ {renderSearchResultSections(searchResultSections)} +
+ ); return (
- - - - + + { event.preventDefault(); @@ -368,22 +858,28 @@ export function TopbarSearch({ Search everything
- { - setQuery(event.target.value); - setSelectedMenuIndex(0); - }} - onKeyDown={handleDialogInputKeyDown} - placeholder="Search everything" - spellCheck={false} - value={query} - /> +
+ {query.length === 0 ? ( + + + + ) : null} + { + setQuery(event.target.value); + setSelectedMenuIndex(0); + }} + onKeyDown={handleDialogInputKeyDown} + spellCheck={false} + value={query} + /> +
ESC diff --git a/desktop/src/features/search/useSearchResults.ts b/desktop/src/features/search/useSearchResults.ts index 116f72744..6c71b0b72 100644 --- a/desktop/src/features/search/useSearchResults.ts +++ b/desktop/src/features/search/useSearchResults.ts @@ -1,17 +1,46 @@ import * as React from "react"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; +import { + useUserSearchQuery, + useUsersBatchQuery, +} from "@/features/profile/hooks"; +import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch"; import { useSearchMessagesQuery } from "@/features/search/hooks"; import type { SearchResult } from "@/features/search/ui/SearchResultItem"; -import type { Channel } from "@/shared/api/types"; +import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; export const MIN_SEARCH_QUERY_LENGTH = 2; +function formatUserResultName(user: UserSearchResult) { + return user.displayName?.trim() || user.nip05Handle?.trim() || user.pubkey; +} + +function dedupeSearchHits(hits: SearchHit[]) { + const seenEventIds = new Set(); + + return hits.filter((hit) => { + if (seenEventIds.has(hit.eventId)) { + return false; + } + + seenEventIds.add(hit.eventId); + return true; + }); +} + export function useSearchResults({ + channelLabels, channels, enabled, limit = 12, }: { + channelLabels?: Record; channels: Channel[]; enabled: boolean; limit?: number; @@ -19,6 +48,7 @@ export function useSearchResults({ const [query, setQuery] = React.useState(""); const [debouncedQuery, setDebouncedQuery] = React.useState(""); const [selectedIndex, setSelectedIndex] = React.useState(0); + const isArchivedDiscovery = useIsArchivedPredicate(); const channelLookup = React.useMemo( () => new Map(channels.map((channel) => [channel.id, channel])), @@ -30,7 +60,10 @@ export function useSearchResults({ limit, }); - const messageResults = searchQuery.data?.hits ?? []; + const messageResults = React.useMemo( + () => dedupeSearchHits(searchQuery.data?.hits ?? []), + [searchQuery.data?.hits], + ); const channelResults = React.useMemo(() => { if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) { return []; @@ -41,25 +74,179 @@ export function useSearchResults({ return channels .filter( (channel) => - channel.channelType !== "dm" && (channel.archivedAt ? channel.isMember : channel.visibility === "open" || channel.isMember) && - (channel.name.toLowerCase().includes(normalizedQuery) || - channel.description.toLowerCase().includes(normalizedQuery)), + [ + channel.name, + channel.description, + channelLabels?.[channel.id] ?? "", + ].some((value) => value.toLowerCase().includes(normalizedQuery)), ) .sort((a, b) => { - const aNameMatches = a.name.toLowerCase().includes(normalizedQuery); - const bNameMatches = b.name.toLowerCase().includes(normalizedQuery); + const aDisplayName = channelLabels?.[a.id]?.trim() || a.name; + const bDisplayName = channelLabels?.[b.id]?.trim() || b.name; + const aNameMatches = aDisplayName + .toLowerCase() + .includes(normalizedQuery); + const bNameMatches = bDisplayName + .toLowerCase() + .includes(normalizedQuery); if (aNameMatches !== bNameMatches) { return aNameMatches ? -1 : 1; } - return a.name.localeCompare(b.name); + return aDisplayName.localeCompare(bDisplayName); }) .slice(0, 5); - }, [channels, debouncedQuery]); + }, [channelLabels, channels, debouncedQuery]); + + const userSearchQuery = useUserSearchQuery(debouncedQuery, { + enabled: enabled && debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH, + limit, + }); + const managedAgentsQuery = useManagedAgentsQuery({ enabled }); + const relayAgentsQuery = useRelayAgentsQuery({ enabled }); + const managedAgentPubkeys = React.useMemo( + () => + new Set( + (managedAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), + [managedAgentsQuery.data], + ); + const relayAgentPubkeys = React.useMemo( + () => + new Set( + (relayAgentsQuery.data ?? []).map((agent) => + normalizePubkey(agent.pubkey), + ), + ), + [relayAgentsQuery.data], + ); + const eligibleAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set(managedAgentPubkeys); + + for (const agent of relayAgentsQuery.data ?? []) { + if (agent.respondTo === "anyone") { + pubkeys.add(normalizePubkey(agent.pubkey)); + } + } + + return pubkeys; + }, [managedAgentPubkeys, relayAgentsQuery.data]); + const userResults = React.useMemo(() => { + if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) { + return []; + } + + const normalizedQuery = debouncedQuery.toLowerCase(); + const candidatesByPubkey = new Map(); + + const matchesQuery = (candidate: UserSearchResult) => + [ + candidate.displayName ?? "", + candidate.nip05Handle ?? "", + candidate.isAgent ? "agent" : "", + normalizePubkey(candidate.pubkey), + ].some((value) => value.toLowerCase().includes(normalizedQuery)); + + const addCandidate = (candidate: UserSearchResult) => { + const pubkey = normalizePubkey(candidate.pubkey); + + if (isArchivedDiscovery(pubkey)) { + return; + } + + const isKnownAgent = + candidate.isAgent || + managedAgentPubkeys.has(pubkey) || + relayAgentPubkeys.has(pubkey); + + if (isKnownAgent && !eligibleAgentPubkeys.has(pubkey)) { + return; + } + + const existing = candidatesByPubkey.get(pubkey); + if (!existing) { + candidatesByPubkey.set(pubkey, { + ...candidate, + pubkey, + isAgent: isKnownAgent, + }); + return; + } + + candidatesByPubkey.set(pubkey, { + pubkey, + avatarUrl: existing.avatarUrl ?? candidate.avatarUrl ?? null, + displayName: + candidate.isAgent && candidate.displayName?.trim() + ? candidate.displayName + : (existing.displayName ?? candidate.displayName), + nip05Handle: existing.nip05Handle ?? candidate.nip05Handle ?? null, + ownerPubkey: existing.ownerPubkey ?? candidate.ownerPubkey ?? null, + isAgent: existing.isAgent || isKnownAgent, + }); + }; + + for (const user of userSearchQuery.data ?? []) { + addCandidate(user); + } + + for (const agent of relayAgentsQuery.data ?? []) { + if (agent.respondTo !== "anyone") { + continue; + } + + const candidate = { + pubkey: agent.pubkey, + displayName: agent.name, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }; + + if (matchesQuery(candidate)) { + addCandidate(candidate); + } + } + + for (const agent of managedAgentsQuery.data ?? []) { + const candidate = { + pubkey: agent.pubkey, + displayName: agent.name, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }; + + if (matchesQuery(candidate)) { + addCandidate(candidate); + } + } + + return rankUserCandidatesBySearch({ + candidates: [...candidatesByPubkey.values()], + getLabel: formatUserResultName, + limit, + query: debouncedQuery, + }); + }, [ + debouncedQuery, + eligibleAgentPubkeys, + isArchivedDiscovery, + limit, + managedAgentPubkeys, + managedAgentsQuery.data, + relayAgentPubkeys, + relayAgentsQuery.data, + userSearchQuery.data, + ]); const results = React.useMemo( () => [ @@ -67,12 +254,16 @@ export function useSearchResults({ kind: "channel" as const, channel, })), + ...userResults.map((user) => ({ + kind: "user" as const, + user, + })), ...messageResults.map((hit) => ({ kind: "message" as const, hit, })), ], - [channelResults, messageResults], + [channelResults, messageResults, userResults], ); const resultProfilesQuery = useUsersBatchQuery( @@ -129,5 +320,7 @@ export function useSearchResults({ selectedResult: results[selectedIndex], setQuery, setSelectedIndex, + userResults, + userSearchQuery, }; } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index f3403672f..dc9909571 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -48,6 +48,7 @@ import { SECTION_ACTION_VISIBILITY_CLASS, SECTION_ICON_BUTTON_CLASS, } from "@/features/sidebar/ui/sidebarSectionStyles"; +import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard"; import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider"; import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility"; @@ -140,6 +141,7 @@ type AppSidebarProps = { updates: Partial>, ) => void; onRemoveWorkspace: (id: string) => void; + onCreateAgent: () => void; onSelectAgents: () => void; onSelectProjects: () => void; onSelectPulse: () => void; @@ -209,6 +211,7 @@ export function AppSidebar({ onOpenDm, onUpdateWorkspace, onRemoveWorkspace, + onCreateAgent, onSelectAgents, onSelectProjects, onSelectPulse, @@ -310,6 +313,14 @@ export function AppSidebar({ const [createDialogKind, setCreateDialogKind] = React.useState(null); + const { openNextFrame: openModalNextFrame } = useDeferredModalOpen(); + const openCreateDialog = React.useCallback( + (kind: CreateChannelKind) => { + setCreateDialogKind(null); + openModalNextFrame(() => setCreateDialogKind(kind)); + }, + [openModalNextFrame], + ); React.useEffect(() => { if (!canShowSidebarUpdateCard) { @@ -324,9 +335,9 @@ export function AppSidebar({ // dialog's `onOpenChange` below. React.useEffect(() => { if (isCreateChannelOpenProp) { - setCreateDialogKind("stream"); + openCreateDialog("stream"); } - }, [isCreateChannelOpenProp]); + }, [isCreateChannelOpenProp, openCreateDialog]); const [collapsedGroups, setCollapsedGroups] = React.useState< Record >({ @@ -506,6 +517,15 @@ export function AppSidebar({ [createDialogKind, onCreateChannel, onCreateForum], ); + const handleOpenCreateChannel = React.useCallback(() => { + if (onCreateChannelOpenChange) { + onCreateChannelOpenChange(true); + return; + } + + openCreateDialog("stream"); + }, [onCreateChannelOpenChange, openCreateDialog]); + return ( onOpenDm({ pubkeys: [user.pubkey] })} + onCreateAgent={onCreateAgent} + onCreateChannel={handleOpenCreateChannel} + suggestionChannels={channels} /> setCreateDialogKind("stream")} + onCreateClick={() => openCreateDialog("stream")} onMarkAllRead={onMarkAllChannelsRead} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} @@ -753,7 +778,7 @@ export function AppSidebar({ isActiveChannel={selectedView === "channel"} items={forumChannels} listTestId="forum-list" - onCreateClick={() => setCreateDialogKind("forum")} + onCreateClick={() => openCreateDialog("forum")} onMarkAllRead={onMarkAllChannelsRead} onMarkChannelRead={onMarkChannelRead} onMarkChannelUnread={onMarkChannelUnread} diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index 5efaba549..b71cb17d1 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -929,6 +929,21 @@ color: hsl(var(--muted-foreground) / 0.82); } +.message-markdown .mention-chip.search-channel-chip { + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground) / 0.82); + transition: + background-color 150ms ease-out, + color 150ms ease-out; +} + +.search-result-row[aria-selected="true"] + .message-markdown + .mention-chip.search-channel-chip { + background: hsl(var(--muted) / 0.82); + color: hsl(var(--muted-foreground) / 0.95); +} + .message-markdown.inbox-preview-markdown { --inline-chip-padding-block-start: 0.125rem; --inline-chip-padding-block-end: 0.0625rem; diff --git a/desktop/src/shared/ui/alert-dialog.tsx b/desktop/src/shared/ui/alert-dialog.tsx index 75831c456..d4bf93d4b 100644 --- a/desktop/src/shared/ui/alert-dialog.tsx +++ b/desktop/src/shared/ui/alert-dialog.tsx @@ -6,6 +6,10 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; import { cn } from "@/shared/lib/cn"; import { buttonVariants } from "@/shared/ui/button"; import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop"; +import { + MODAL_CONTENT_MOTION_CLASS, + MODAL_OVERLAY_MOTION_CLASS, +} from "@/shared/ui/modalMotion"; const AlertDialog = AlertDialogPrimitive.Root; const AlertDialogTrigger = AlertDialogPrimitive.Trigger; @@ -16,7 +20,8 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( (null); + const timeoutRef = React.useRef(null); + + const cancelDeferredModalOpen = React.useCallback(() => { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }, []); + + const openNextFrame = React.useCallback( + (open: () => void) => { + cancelDeferredModalOpen(); + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + open(); + }); + }, + [cancelDeferredModalOpen], + ); + + const openAfterExit = React.useCallback( + (open: () => void) => { + cancelDeferredModalOpen(); + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + openNextFrame(open); + }, MODAL_EXIT_ANIMATION_MS); + }, + [cancelDeferredModalOpen, openNextFrame], + ); + + React.useEffect(() => cancelDeferredModalOpen, [cancelDeferredModalOpen]); + + return { + cancelDeferredModalOpen, + openAfterExit, + openNextFrame, + }; +} diff --git a/desktop/src/shared/ui/dialog.tsx b/desktop/src/shared/ui/dialog.tsx index e41d5ae05..88152c981 100644 --- a/desktop/src/shared/ui/dialog.tsx +++ b/desktop/src/shared/ui/dialog.tsx @@ -7,6 +7,10 @@ import { X } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { MODAL_BACKDROP_BLUR_CLASS } from "@/shared/ui/modalBackdrop"; +import { + MODAL_CONTENT_MOTION_CLASS, + MODAL_OVERLAY_MOTION_CLASS, +} from "@/shared/ui/modalMotion"; const Dialog = DialogPrimitive.Root; const DialogTrigger = DialogPrimitive.Trigger; @@ -22,7 +26,8 @@ const DialogOverlay = React.forwardRef< return ( - element.scrollHeight > element.clientHeight && - element.clientHeight > 0, - ), - ).toBe(true); + const catalogScrollAreaMetrics = await page + .getByTestId("persona-catalog-dialog-scroll-area") + .evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); + expect(catalogScrollAreaMetrics.clientHeight).toBeGreaterThan(0); + expect(catalogScrollAreaMetrics.scrollHeight).toBeGreaterThanOrEqual( + catalogScrollAreaMetrics.clientHeight, + ); await expect(page.getByTestId("persona-catalog-dialog-footer")).toBeVisible(); await expect(page.getByRole("tooltip")).toHaveCount(0); const initialCatalogOrder = await getCatalogOrder(page); From 6b5cf325c2777f64696923cf5b1c1ffd4fdf82e2 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 23 Jun 2026 22:40:38 +0100 Subject: [PATCH 12/30] Update navigation header height (#1212) Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> --- .../channels/ui/AgentSessionThreadPanel.tsx | 2 +- .../features/channels/ui/ChannelScreenHeader.tsx | 1 - desktop/src/features/chat/ui/ChatHeader.tsx | 11 ++--------- desktop/src/features/home/ui/HomeLoadingState.tsx | 8 ++++---- desktop/src/features/home/ui/InboxDetailPane.tsx | 4 ++-- desktop/src/features/home/ui/InboxListPane.tsx | 13 ++++++++----- .../src/features/messages/ui/MessageThreadPanel.tsx | 4 ++-- .../src/features/profile/ui/UserProfilePanel.tsx | 2 +- desktop/src/shared/layout/AuxiliaryPanelHeader.tsx | 2 +- desktop/src/shared/ui/ViewLoadingFallback.tsx | 2 +- desktop/tests/e2e/messaging.spec.ts | 10 +++++++--- 11 files changed, 29 insertions(+), 30 deletions(-) diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 5b72e5b92..e858535a3 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -202,7 +202,7 @@ export function AgentSessionThreadPanel({ "flex cursor-default select-none items-center", isSinglePanelView ? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[4.75rem] min-h-[4.75rem] shrink-0 gap-2.5 bg-background/80 pb-1 pl-4 pr-2 pt-[2.625rem] backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:pl-6 sm:pr-3 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55` - : "relative z-50 min-h-11 shrink-0 gap-3 bg-background/80 px-3 py-1.5 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55", + : "relative z-50 min-h-14 shrink-0 gap-3 bg-background/80 px-5 py-2 backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55", )} data-tauri-drag-region > diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index cda079449..311d22449 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -92,7 +92,6 @@ export function ChannelScreenHeader({ ; - density?: "default" | "compact"; title: string; description?: string; channelType?: ChannelType; @@ -87,7 +86,6 @@ export function ChatHeader({ actions, belowSystemChrome = false, chromeWrapperRef, - density = "default", title, description, channelType, @@ -117,13 +115,8 @@ export function ChatHeader({ const header = (
-
-
+
+
@@ -51,8 +51,8 @@ export function HomeLoadingState() {
-
-
+
+
diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index dd45d879f..0b74041ca 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -246,8 +246,8 @@ export function InboxDetailPane({ >
-
-
+
+
-
+
-
+ + } + action={ + + } + /> {templatesQuery.isLoading ? (

diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index d78eab3f9..57e5ff8b8 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -18,6 +18,7 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { SettingsOptionGroup } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; function StatusIcon({ availability, @@ -268,32 +269,28 @@ export function DoctorSettingsPanel() { } return ( -

-
-
-

Doctor

-

- Verify the ACP runtime commands available to the desktop app. -

-
- - -
+
+ { + setInstallResults({}); + void runtimesQuery.refetch(); + }} + size="sm" + type="button" + variant="outline" + > + + Re-run + + } + />
diff --git a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx index 96ac8dedb..4684829d3 100644 --- a/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx +++ b/desktop/src/features/settings/ui/ExperimentalFeaturesCard.tsx @@ -1,6 +1,7 @@ import { desktopFeatures, useFeatureToggle } from "@/shared/features"; import type { FeatureDefinition } from "@/shared/features"; import { Switch } from "@/shared/ui/switch"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; function FeatureRow({ feature }: { feature: FeatureDefinition }) { const [enabled, toggle] = useFeatureToggle(feature.id); @@ -31,13 +32,15 @@ export function ExperimentalFeaturesCard() { return (
-
-

Experiments

-

- These features are functional but still being refined. Enable them to - try new capabilities early. -

-
+ + These features are functional but still being refined. Enable them + to try new capabilities early. + + } + />
{previewFeatures.map((f) => ( diff --git a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx index 2e84f32a0..4cf1276c2 100644 --- a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx +++ b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx @@ -4,6 +4,7 @@ import { type KeyboardShortcut, } from "@/shared/lib/keyboard-shortcuts"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) { const keys = getPlatformKeys(shortcut); @@ -32,14 +33,10 @@ export function KeyboardShortcutsCard() { return (
-
-

- Keyboard Shortcuts -

-

- All available keyboard shortcuts. Shortcuts are read-only. -

-
+
{[...categories.entries()].map(([category, shortcuts]) => ( diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index 6ae0850e3..fab4aa158 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -27,6 +27,7 @@ import { DialogTitle, } from "@/shared/ui/dialog"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; type PairingStep = | "generating" @@ -321,14 +322,16 @@ export function MobilePairingCard({ return (
-
-

Mobile

-

- Connect the Buzz mobile app to this relay by scanning a QR code. The - connection is secured with end-to-end encryption and a verification - code. -

-
+ + Connect the Buzz mobile app to this relay by scanning a QR code. The + connection is secured with end-to-end encryption and a verification + code. + + } + /> diff --git a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx index 72bb08326..f2bbff308 100644 --- a/desktop/src/features/settings/ui/NotificationSettingsCard.tsx +++ b/desktop/src/features/settings/ui/NotificationSettingsCard.tsx @@ -18,6 +18,7 @@ import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Switch } from "@/shared/ui/switch"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { SoundPicker } from "./SoundPicker"; export function NotificationSettingsCard({ @@ -60,12 +61,10 @@ export function NotificationSettingsCard({ return (
-
-

Notifications

-

- Desktop alerts are on by default. Fine-tune what gets through below. -

-
+ {notificationPermission === "unsupported" diff --git a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx index c502efd53..52c48f384 100644 --- a/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx +++ b/desktop/src/features/settings/ui/PreventSleepSettingsCard.tsx @@ -1,6 +1,7 @@ import { usePreventSleepContext } from "@/features/agents/usePreventSleep"; import { Switch } from "@/shared/ui/switch"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; export function PreventSleepSettingsCard() { const { enabled, setEnabled, hasRunningAgents, expired, clearExpired } = @@ -8,12 +9,10 @@ export function PreventSleepSettingsCard() { return (
-
-

Agents

-

- Settings that affect how local managed agents run on this machine. -

-
+ diff --git a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx index c7c81476b..147cfd793 100644 --- a/desktop/src/features/settings/ui/ProfileSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ProfileSettingsCard.tsx @@ -21,6 +21,7 @@ import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; import { Textarea } from "@/shared/ui/textarea"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; type ProfileSettingsCardProps = { currentPubkey?: string; @@ -458,12 +459,10 @@ export function ProfileSettingsCard({ return (
-
-

Profile

-

- Update how your name, avatar, and bio appear across Buzz. -

-
+
{profileQuery.error instanceof Error ? ( diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 78db770c3..c3055c30f 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -43,6 +43,7 @@ import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; import { UpdateChecker } from "../UpdateChecker"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; export type SettingsSection = | "profile" @@ -206,12 +207,10 @@ function ThemeSettingsCard() { return (
-
-

Appearance

-

- Choose a theme for Buzz. Light and dark mode is auto-detected. -

-
+
diff --git a/desktop/src/features/settings/ui/SettingsSectionHeader.tsx b/desktop/src/features/settings/ui/SettingsSectionHeader.tsx new file mode 100644 index 000000000..c22a526cd --- /dev/null +++ b/desktop/src/features/settings/ui/SettingsSectionHeader.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; + +export function SettingsSectionHeader({ + action, + description, + title, +}: { + action?: ReactNode; + description: ReactNode; + title: ReactNode; +}) { + const copy = ( + <> +

{title}

+

+ {description} +

+ + ); + + if (action) { + return ( +
+
{copy}
+
{action}
+
+ ); + } + + return
{copy}
; +} diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 2beff2786..4f10b076f 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -294,7 +294,6 @@ async function expectWelcomeComposerBannerCompletesAfterPersonaMention( await expect(banner).toContainText("Nice work."); await expect(banner).not.toContainText("Try mentioning"); await expect(channelIntro).toBeVisible(); - await expect(banner).toHaveCount(0, { timeout: 12_000 }); } async function getMockChannels(page: Page) { diff --git a/docker-compose.yml b/docker-compose.yml index c22ac2ad3..19ff5c387 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ name: buzz services: postgres: - image: postgres:18-alpine + image: postgres:17-alpine container_name: buzz-postgres environment: POSTGRES_USER: buzz @@ -31,7 +31,7 @@ services: restart: unless-stopped redis: - image: redis:8-alpine + image: redis:7-alpine container_name: buzz-redis ports: - "6379:6379" From 549b7d24813320045bdda629d865c6c7418e7450 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 23 Jun 2026 18:35:30 -0400 Subject: [PATCH 18/30] fix(release): publish versioned relay Docker tags via independent release lanes (#1173) Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .../auto-tag-on-release-pr-merge.yml | 83 ++++++-- .github/workflows/docker.yml | 90 ++++++-- AGENTS.md | 2 +- Justfile | 193 +++++++++++++----- RELEASING.md | 145 ++++++++++--- crates/buzz-relay/Cargo.toml | 5 +- crates/sprig/Cargo.toml | 4 +- 7 files changed, 407 insertions(+), 115 deletions(-) diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index 51dc144d4..f072df0c6 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -1,5 +1,26 @@ name: Auto-tag on Release PR Merge +# Three release lanes share this one workflow — adding a lane is one more branch +# prefix, never a forked copy: +# +# version-bump/ → tag v → dispatch release.yml (desktop app) +# relay-release/ → tag relay-v → dispatch docker.yml (relay image) +# mobile-release/ → tag mobile-v → (manual sprout_ref for buzz-releases build — see below) +# +# Both the desktop and relay lanes dispatch their build workflow rather than +# relying on the consumer's `on.push.tags` trigger: auto-tag pushes the tag +# with the default GITHUB_TOKEN, which GitHub's recursion guard blocks from +# firing any `on: push` trigger. So release.yml and docker.yml both have a +# `push.tags` trigger that is dead for auto-pushed tags — the dispatch is the +# real path. Each dispatch passes the bare version and the tag ref so the +# consumer builds the tagged commit (github.ref on a dispatch is `main`). +# +# The mobile lane is push-only by infosec necessity: OSS `block/buzz` CI must +# not trigger CI in the private `buzz-releases` repo, so auto-dispatch across +# that boundary is deliberately disallowed. The mobile-v* tag is consumed +# manually instead — a human feeds it as the `sprout_ref` input to the +# `buzz-releases` Buildkite pipeline, which builds and ships mobile. + on: pull_request: types: [closed] @@ -13,7 +34,9 @@ jobs: auto-tag: if: > github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'version-bump/') && + (startsWith(github.event.pull_request.head.ref, 'version-bump/') || + startsWith(github.event.pull_request.head.ref, 'relay-release/') || + startsWith(github.event.pull_request.head.ref, 'mobile-release/')) && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: @@ -22,42 +45,78 @@ jobs: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 - - name: Extract version from branch name + - name: Resolve lane and version from branch name env: BRANCH: ${{ github.event.pull_request.head.ref }} run: | - VERSION="${BRANCH#version-bump/}" + # Lane is decided by branch prefix: the tag prefix and whether a + # downstream workflow_dispatch is needed both follow from it. + case "$BRANCH" in + version-bump/*) + VERSION="${BRANCH#version-bump/}" + TAG_PREFIX="v" + DISPATCH="release" ;; + relay-release/*) + VERSION="${BRANCH#relay-release/}" + TAG_PREFIX="relay-v" + DISPATCH="docker" ;; + mobile-release/*) + VERSION="${BRANCH#mobile-release/}" + TAG_PREFIX="mobile-v" + DISPATCH="" ;; + *) + echo "::error::Unhandled branch prefix: '$BRANCH'" + exit 1 ;; + esac if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then echo "::error::Invalid version in branch name: '$VERSION'" exit 1 fi echo "version=$VERSION" >> "$GITHUB_ENV" - echo "Tagging v${VERSION}" + echo "tag=${TAG_PREFIX}${VERSION}" >> "$GITHUB_ENV" + echo "dispatch=$DISPATCH" >> "$GITHUB_ENV" + echo "Tagging ${TAG_PREFIX}${VERSION}" - name: Create and push tag env: - VERSION: ${{ env.version }} + TAG: ${{ env.tag }} run: | - EXISTING_SHA="$(git ls-remote --tags origin "refs/tags/v$VERSION" | awk '{print $1}')" + EXISTING_SHA="$(git ls-remote --tags origin "refs/tags/$TAG" | awk '{print $1}')" if [ -n "$EXISTING_SHA" ]; then if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then - echo "Tag v$VERSION already exists at $GITHUB_SHA — skipping tag creation" + echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation" exit 0 else - echo "::error::Tag v$VERSION already exists at $EXISTING_SHA (expected $GITHUB_SHA)" + echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $GITHUB_SHA)" exit 1 fi fi git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git config user.name "github-actions[bot]" - git tag "v$VERSION" - git push origin "v$VERSION" + git tag "$TAG" + git push origin "$TAG" - name: Trigger release build + # The desktop and relay lanes dispatch their build workflow because the + # consumer's on:push:tags trigger is dead for auto-pushed tags (default + # GITHUB_TOKEN, recursion guard — see header comment). Mobile has no + # consumer yet, so dispatch="" skips this step entirely. + if: ${{ env.dispatch != '' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DISPATCH: ${{ env.dispatch }} VERSION: ${{ env.version }} + TAG: ${{ env.tag }} run: | - gh workflow run release.yml \ + # Both workflows take the bare version (for the build) and the tag ref + # (so the dispatch builds the tagged commit, not main). + case "$DISPATCH" in + release) WORKFLOW="release.yml" ;; + docker) WORKFLOW="docker.yml" ;; + *) + echo "::error::Unhandled dispatch target: '$DISPATCH'" + exit 1 ;; + esac + gh workflow run "$WORKFLOW" \ -f version="$VERSION" \ - -f ref="v$VERSION" + -f ref="$TAG" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8f7c461fa..6321629c6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -8,16 +8,42 @@ name: Docker image # This avoids QEMU emulation (~10× slower for Rust) at zero cost on free # GitHub-hosted runners. # +# Versioning: the relay is versioned independently of the desktop app via +# its own `relay-v*` tags (see `just release-relay`). Desktop `v*` tags and +# agent `sprig-v*` tags do NOT publish this image — only `relay-v*` does, so +# the relay image version tracks crates/buzz-relay/Cargo.toml, never desktop. +# # Triggers: -# - push to main → :main + :sha-<7> -# - push tags v*.*.* → :latest + :{version} + :{major}.{minor} + :{major} -# - pull_request → build only (no push), cache stays warm -# - workflow_dispatch → manual canary +# - push to main → :main + :sha-<7> +# - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} +# (+ :latest for stable, NOT for prereleases) +# - pull_request → build only (no push), cache stays warm +# - workflow_dispatch → manual canary (no inputs), or relay-tag rescue +# dispatch with version+ref inputs (see below) +# +# Why workflow_dispatch carries version/ref inputs: +# auto-tag-on-release-pr-merge.yml pushes relay-v* with the default +# GITHUB_TOKEN, which GitHub deliberately does NOT let fire on:push triggers +# (recursion guard). So the push:tags trigger above never runs for releases. +# auto-tag instead dispatches this workflow with the bare version + tag ref, +# the same rescue release.yml already uses for the desktop lane. On dispatch +# github.ref is `main`, so the tag ref is plumbed through explicitly: checkout +# pins to inputs.ref, and the semver tags take inputs.version via `value=`. +# On the rescue path inputs.version is already bare (e.g. 0.3.0), so the +# match=^relay-v(.*)$ regex simply no-ops (it warns, leaving the value +# intact) and the bare version flows straight to the semver parser. On a +# real push event value= is empty and the match strips relay-v from the ref. +# Inputless canary dispatch (version="", ref=main) renders no semver tag. +# +# The :latest tag tracks the latest STABLE relay release: metadata-action's +# `flavor.latest=auto` (its default) emits :latest only for non-prerelease +# semver, so relay-v0.3.0-rc.1 publishes :0.3.0-rc.1 without moving :latest, +# and main pushes (no semver tag) never produce :latest. on: push: branches: [main] - tags: ["v[0-9]*"] + tags: ["relay-v[0-9]*"] pull_request: paths: - "Dockerfile" @@ -33,6 +59,14 @@ on: - "pnpm-workspace.yaml" - "patches/**" workflow_dispatch: + inputs: + version: + description: "Semver version e.g. 0.3.0 (no relay-v prefix) — for relay-tag rescue dispatch" + required: false + ref: + description: "Tag/branch/SHA to build, e.g. relay-v0.3.0" + required: false + default: main # One image build per ref; cancel superseded PR builds, but never cancel # tag/main builds (publishing must not be aborted mid-flight). @@ -77,6 +111,9 @@ jobs: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: + # On workflow_dispatch (relay-tag rescue) build the tagged commit, + # not main. Empty string = default ref for push/PR events. + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} persist-credentials: false - name: Set up Docker Buildx @@ -103,15 +140,26 @@ jobs: uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.IMAGE_NAME }} - # Tag matrix — every main commit gets sha-<7>, releases get full - # semver family. Pull requests get nothing (push: false below). + # Tag matrix — every main commit gets sha-<7>, relay releases get the + # full semver family. The semver entries carry match=^relay-v(.*)$ + # because metadata-action does NOT strip a `relay-v` prefix on its + # own — it only strips refs/tags/, then runs the raw ref through + # semver.valid(), which rejects "relay-v0.3.0". The match capture + # group feeds the bare version to the semver parser. value= supplies + # the version on the auto-tag rescue dispatch (github.ref is `main` + # there, not the tag): it is already bare, so match no-ops (warns, + # value intact) and the bare version validates as-is. On push value= + # is empty, so the ref drives it and match strips relay-v — push + # behavior is unchanged. Pull requests get nothing (push: false + # below). :latest is intentionally absent — flavor.latest defaults to + # `auto`, which adds :latest for stable semver tags only (not + # prereleases, not main pushes). tags: | - type=ref,event=branch - type=sha,prefix=sha-,format=short - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} labels: | org.opencontainers.image.title=Buzz org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform @@ -186,13 +234,17 @@ jobs: uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 with: images: ${{ env.IMAGE_NAME }} + # Must mirror the build job's tag matrix exactly — the merge job + # re-derives tags to stamp them onto the multi-arch manifest. See + # the build job's `meta` step for why match=^relay-v(.*)$, why + # value=${{ inputs.version }} carries the rescue-dispatch version, + # and why :latest is left to flavor.latest=auto. tags: | - type=ref,event=branch - type=sha,prefix=sha-,format=short - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} + type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} + type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} - name: Create and push manifest list id: manifest diff --git a/AGENTS.md b/AGENTS.md index 3ee87ab11..54de09602 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -552,5 +552,5 @@ just mobile-dev - [CONTRIBUTING.md](CONTRIBUTING.md) — setup, code style, PR process, how to add event kinds / CLI subcommands / API endpoints - [TESTING.md](TESTING.md) — multi-agent E2E test guide - [ARCHITECTURE.md](ARCHITECTURE.md) — system design and component relationships -- [RELEASING.md](RELEASING.md) — release process: `just release`, auto-tag, internal builds +- [RELEASING.md](RELEASING.md) — release process: `release-desktop`, `release-relay`, `release-mobile`, auto-tag, internal builds - [README.md](README.md) — project overview and quick start diff --git a/Justfile b/Justfile index aa1608d0e..1fe413f1b 100644 --- a/Justfile +++ b/Justfile @@ -449,6 +449,10 @@ check-compile: get-current-version: @node -p "require('./desktop/package.json').version" +# Read the current relay version from its crate manifest +get-current-relay-version: + @grep -m1 '^version = ' crates/buzz-relay/Cargo.toml | sed -E 's/version = "(.*)"/\1/' + # Compute next minor version (e.g., 0.3.0 → 0.4.0) get-next-minor-version: @python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{int(v[1])+1}.0')" @@ -457,15 +461,22 @@ get-next-minor-version: get-next-patch-version: @python3 -c "v='$(just get-current-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')" -# Update version in all package manifests and regenerate lockfiles -bump-version version: +# Compute next relay patch version (e.g., 0.3.0 → 0.3.1) +get-next-relay-patch-version: + @python3 -c "v='$(just get-current-relay-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')" + +# Read the current mobile version from pubspec.yaml (strips the +build suffix) +get-current-mobile-version: + @grep -m1 '^version: ' mobile/pubspec.yaml | sed -E 's/version: ([^+]*).*/\1/' + +# Compute next mobile patch version (e.g., 0.3.0 → 0.3.1) +get-next-mobile-patch-version: + @python3 -c "v='$(just get-current-mobile-version)'.split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')" + +# Update version in desktop package manifests and regenerate lockfiles +bump-desktop-version version: #!/usr/bin/env bash set -euo pipefail - # Validate semver format - if ! echo "{{ version }}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then - echo "Error: '{{ version }}' is not valid semver (expected X.Y.Z)" - exit 1 - fi # desktop/package.json cd desktop && npm pkg set "version={{ version }}" && cd .. # desktop/src-tauri/tauri.conf.json @@ -486,50 +497,133 @@ bump-version version: t = t.replace(/^version = \".*\"/m, 'version = \"{{ version }}\"'); fs.writeFileSync(p, t); " - # mobile/pubspec.yaml — bump version but preserve build number - sed -i '' "s/^version: .*/version: {{ version }}+1/" mobile/pubspec.yaml # Regenerate lockfiles pnpm install --lockfile-only cargo update -p buzz-desktop --manifest-path desktop/src-tauri/Cargo.toml + echo "Bumped desktop manifests to {{ version }} and regenerated lockfiles" + +# Bump the relay crate version and regenerate the lockfile +bump-relay-version version: + #!/usr/bin/env bash + set -euo pipefail + # buzz-relay carries its own `version =` (not version.workspace), so the + # replace targets the package version line only. + perl -i -pe 's/^version = ".*"/version = "{{ version }}"/' crates/buzz-relay/Cargo.toml + cargo update -p buzz-relay + echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock" + +# Bump the mobile pubspec version and regenerate the lockfile +bump-mobile-version version: + #!/usr/bin/env bash + set -euo pipefail + # pubspec carries a `version: X.Y.Z+build`; preserve the `+build` convention + # (a literal `+1`, matching the desktop lane's prior behavior). + perl -i -pe 's/^version: .*/version: {{ version }}+1/' mobile/pubspec.yaml (unset GIT_DIR GIT_WORK_TREE; cd mobile && flutter pub get) - echo "Bumped all manifests to {{ version }} and regenerated lockfiles" + echo "Bumped mobile to {{ version }} and regenerated pubspec.lock" -# Create or update a release PR that bumps version and generates changelog -release *ARGS: +# Open or update the desktop release PR (signed desktop app) +release-desktop *ARGS: #!/usr/bin/env bash set -euo pipefail - # Determine target version ARG="{{ ARGS }}" - if [[ -z "$ARG" ]]; then - VERSION=$(just get-next-patch-version) - elif [[ "$ARG" == "patch" ]]; then + if [[ -z "$ARG" || "$ARG" == "patch" ]]; then VERSION=$(just get-next-patch-version) else VERSION="$ARG" fi - echo "Preparing release v${VERSION}..." - # Ensure on main branch + just _release-pr desktop "$VERSION" + +# Open or update the relay release PR (ghcr.io/block/buzz image) +release-relay *ARGS: + #!/usr/bin/env bash + set -euo pipefail + ARG="{{ ARGS }}" + if [[ -z "$ARG" || "$ARG" == "patch" ]]; then + VERSION=$(just get-next-relay-patch-version) + else + VERSION="$ARG" + fi + just _release-pr relay "$VERSION" + +# Open or update the mobile release PR (Buzz mobile app) +release-mobile *ARGS: + #!/usr/bin/env bash + set -euo pipefail + ARG="{{ ARGS }}" + if [[ -z "$ARG" || "$ARG" == "patch" ]]; then + VERSION=$(just get-next-mobile-patch-version) + else + VERSION="$ARG" + fi + just _release-pr mobile "$VERSION" + +# Shared release-PR engine. One body, three lanes — the only lane-specific steps +# are the version-bump command and the file/tag/changelog identifiers selected +# in the `case` below. Everything else (git preflight, branch reset, changelog +# generation, commit, push, PR open/edit) is identical across lanes. +_release-pr lane version: + #!/usr/bin/env bash + set -euo pipefail + VERSION="{{ version }}" + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "Error: '$VERSION' is not valid semver (expected X.Y.Z)" + exit 1 + fi + # Lane-specific identifiers. The bump command runs after the branch switch. + case "{{ lane }}" in + desktop) + BRANCH_PREFIX="version-bump" + TAG_FETCH='v*' + TAG_MATCH='v[0-9]*' + TAG_EXCLUDE='*-*' + TAG_PREFIX="v" + CHANGELOG="CHANGELOG.md" + ADD_FILES=(desktop/package.json desktop/src-tauri/tauri.conf.json desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.lock pnpm-lock.yaml CHANGELOG.md) + ARTIFACT="Buzz Desktop" ;; + relay) + BRANCH_PREFIX="relay-release" + TAG_FETCH='relay-v*' + TAG_MATCH='relay-v[0-9]*' + TAG_EXCLUDE='relay-v*-*' + TAG_PREFIX="relay-v" + CHANGELOG="crates/buzz-relay/CHANGELOG.md" + ADD_FILES=(crates/buzz-relay/Cargo.toml Cargo.lock crates/buzz-relay/CHANGELOG.md) + ARTIFACT="Buzz Relay" ;; + mobile) + BRANCH_PREFIX="mobile-release" + TAG_FETCH='mobile-v*' + TAG_MATCH='mobile-v[0-9]*' + TAG_EXCLUDE='mobile-v*-*' + TAG_PREFIX="mobile-v" + CHANGELOG="mobile/CHANGELOG.md" + ADD_FILES=(mobile/pubspec.yaml mobile/pubspec.lock mobile/CHANGELOG.md) + ARTIFACT="Buzz Mobile" ;; + *) + echo "Error: unknown release lane '{{ lane }}'" + exit 1 ;; + esac + echo "Preparing ${ARTIFACT} release v${VERSION}..." + # Must run on main with a clean, up-to-date tree. CURRENT_BRANCH=$(git symbolic-ref --short HEAD) if [[ "$CURRENT_BRANCH" != "main" ]]; then echo "Error: must be on main branch (currently on '$CURRENT_BRANCH')" exit 1 fi - # Ensure local main and release tags are up-to-date. git fetch origin refs/heads/main:refs/remotes/origin/main --no-tags - # Release tags are remote-owned state; sync only v* tags so stale local - # tags from older histories do not make release preflight fail. - git fetch origin '+refs/tags/v*:refs/tags/v*' + # Release tags are remote-owned state; sync only this lane's tags so stale + # local tags from older histories do not make release preflight fail. + git fetch origin "+refs/tags/${TAG_FETCH}:refs/tags/${TAG_FETCH}" if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then echo "Error: local main is not up-to-date with origin/main. Run 'git pull' first." exit 1 fi - # Ensure clean working tree if ! git diff --quiet || ! git diff --cached --quiet; then echo "Error: working tree is dirty. Commit or stash changes first." exit 1 fi - # Switch to version-bump branch (create if needed, reset to main if it exists) - BRANCH="version-bump/${VERSION}" + # Switch to the release branch (create, or reset to main if it exists). + BRANCH="${BRANCH_PREFIX}/${VERSION}" if git rev-parse --verify "refs/heads/$BRANCH" >/dev/null 2>&1; then echo "Branch '$BRANCH' already exists — resetting to origin/main..." git switch "$BRANCH" @@ -541,10 +635,14 @@ release *ARGS: else git switch -c "$BRANCH" fi - # Bump versions and lockfiles - just bump-version "$VERSION" - # Generate changelog - LAST_TAG=$(git describe --tags --abbrev=0 --match 'v[0-9]*' --exclude '*-*' 2>/dev/null || echo "") + # Lane-specific bump (the one diverging step). + case "{{ lane }}" in + desktop) just bump-desktop-version "$VERSION" ;; + relay) just bump-relay-version "$VERSION" ;; + mobile) just bump-mobile-version "$VERSION" ;; + esac + # Generate the changelog from commits since this lane's last release tag. + LAST_TAG=$(git describe --tags --abbrev=0 --match "$TAG_MATCH" --exclude "$TAG_EXCLUDE" 2>/dev/null || echo "") REPO=$(git remote get-url origin | sed -E 's|.*github\.com[:/]||; s|\.git$||') format_log() { local range="$1" @@ -565,7 +663,7 @@ release *ARGS: { echo "# Changelog" echo "" - echo "## v${VERSION}" + echo "## ${TAG_PREFIX}${VERSION}" echo "" if [[ -n "$LAST_TAG" ]]; then format_log "${LAST_TAG}..HEAD" @@ -573,31 +671,23 @@ release *ARGS: echo "- Initial release" fi echo "" - if [[ -f CHANGELOG.md ]]; then - tail -n +2 CHANGELOG.md + if [[ -f "$CHANGELOG" ]]; then + tail -n +2 "$CHANGELOG" fi } > "$TMPFILE" - mv "$TMPFILE" CHANGELOG.md - # Commit - git add \ - desktop/package.json \ - desktop/src-tauri/tauri.conf.json \ - desktop/src-tauri/Cargo.toml \ - desktop/src-tauri/Cargo.lock \ - mobile/pubspec.yaml \ - mobile/pubspec.lock \ - pnpm-lock.yaml \ - CHANGELOG.md - RELEASE_MSG="chore(release): release version ${VERSION}" + mkdir -p "$(dirname "$CHANGELOG")" + mv "$TMPFILE" "$CHANGELOG" + # Commit. + git add "${ADD_FILES[@]}" + RELEASE_MSG="chore(release): release ${ARTIFACT} version ${VERSION}" if [[ "$(git log -1 --format='%s' 2>/dev/null)" == "$RELEASE_MSG" ]]; then git commit --amend --no-edit else git commit -m "$RELEASE_MSG" fi - # Push and open PR + # Push and open/update the PR. git push --force-with-lease -u origin "$BRANCH" - # Build PR body - PR_BODY="## Release v${VERSION}"$'\n\n' + PR_BODY="## ${ARTIFACT} release v${VERSION}"$'\n\n' if [[ -n "$LAST_TAG" ]]; then PR_BODY+="### Changes since ${LAST_TAG}:"$'\n\n' PR_BODY+="$(format_log "${LAST_TAG}..HEAD~1")"$'\n\n' @@ -605,18 +695,15 @@ release *ARGS: PR_BODY+="Initial release."$'\n\n' fi PR_BODY+="**To release:** merge this PR. The tag and build will happen automatically." + PR_TITLE="chore(release): release ${ARTIFACT} version ${VERSION}" EXISTING_PR=$(gh pr list --head "$BRANCH" --json url --jq '.[0].url' 2>/dev/null || true) if [[ -n "$EXISTING_PR" ]]; then - gh pr edit "$BRANCH" \ - --title "chore(release): release version ${VERSION}" \ - --body "$PR_BODY" + gh pr edit "$BRANCH" --title "$PR_TITLE" --body "$PR_BODY" PR_URL="$EXISTING_PR" echo "" echo "Updated existing release PR: ${PR_URL}" else - PR_URL=$(gh pr create \ - --title "chore(release): release version ${VERSION}" \ - --body "$PR_BODY") + PR_URL=$(gh pr create --title "$PR_TITLE" --body "$PR_BODY") echo "" echo "Release PR opened: ${PR_URL}" fi diff --git a/RELEASING.md b/RELEASING.md index 88f95aa13..ea0bd897b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,60 +1,149 @@ -# Releasing Buzz Desktop +# Releasing Buzz + +Buzz has three independent release lanes, each driven by a release PR — no human +ever pushes a git tag: + +| Lane | Recipe | Artifact | +|------|--------|----------| +| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) | +| Relay | `just release-relay` | `ghcr.io/block/buzz` container image | +| Mobile | `just release-mobile` | Buzz mobile app (tag is the `sprout_ref` for the internal build) | + +The three lanes version independently: the desktop version lives in +`desktop/package.json`, the relay version in `crates/buzz-relay/Cargo.toml`, and +the mobile version in `mobile/pubspec.yaml`. + +The mobile lane publishes a `mobile-v` tag that is consumed +**manually**, cross-repo, as the `sprout_ref` input to the internal +`buzz-releases` Buildkite pipeline (iOS dogfood → Block Comp Portal, App Store → +TestFlight — see [Internal Releases](#internal-releases)). The OSS lane is +tag-only **by design**: OSS `block/buzz` CI cannot trigger CI in the private +`buzz-releases` repo (infosec), so a human cuts the internal build from the tag +rather than auto-dispatching across that boundary. ## Quick Start ```sh -# Regular release (next patch version) -just release +# Desktop release (next patch version) +just release-desktop -# Patch release -just release patch +# Desktop patch / minor / explicit +just release-desktop patch +just release-desktop 0.4.0 +just release-desktop 1.0.0 -# Minor release -just release 0.4.0 +# Relay release (same argument forms) +just release-relay +just release-relay 0.4.0 -# Any explicit version -just release 1.0.0 +# Mobile release (same argument forms) +just release-mobile +just release-mobile 0.4.0 ``` -This creates a `version-bump/` PR that bumps all version manifests, regenerates lockfiles, and appends a changelog entry. Merge the PR to trigger the build automatically. +`just release-desktop` creates a `version-bump/` PR; `just +release-relay` creates a `relay-release/` PR; `just release-mobile` +creates a `mobile-release/` PR. Each bumps its own version manifest, +regenerates lockfiles, and appends a changelog entry. Merge the PR to trigger +the build automatically (the mobile tag is instead the `sprout_ref` a human +feeds the internal build — see above). -Re-running `just release` with the same version is safe — it detects the existing branch and PR, resets to current `main`, regenerates the changelog with any new commits, and updates the PR in place. +Re-running any of these recipes with the same version is safe — it detects the +existing branch and PR, resets to current `main`, regenerates the changelog +with any new commits, and updates the PR in place. --- ## How It Works -1. **`just release`** runs locally on `main` — computes the next version, creates (or reuses) a `version-bump/` branch, bumps versions in all manifests, regenerates lockfiles, generates a changelog entry, commits, pushes, and opens (or updates) a PR. - -2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the `version-bump/*` branch merge and pushes a `v` tag. - -3. **Tag triggers `release.yml`** — the existing release workflow builds, signs, notarizes, and publishes the desktop app for macOS and Linux. +All three lanes share one engine; they differ only in which version manifest +they bump, which branch prefix they use, and what the merge triggers. + +### Desktop + +1. **`just release-desktop`** runs locally on `main` — computes the next + version, creates (or reuses) a `version-bump/` branch, bumps the + desktop manifests, regenerates lockfiles, generates a changelog + entry in `CHANGELOG.md`, commits, pushes, and opens (or updates) a PR. +2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the + `version-bump/*` branch merge and pushes a `v` tag. +3. **Tag triggers `release.yml`** — builds, signs, notarizes, and publishes the + desktop app for macOS and Linux. + +### Relay + +1. **`just release-relay`** runs locally on `main` — computes the next relay + version, creates (or reuses) a `relay-release/` branch, bumps + `crates/buzz-relay/Cargo.toml`, regenerates `Cargo.lock`, generates a + changelog entry in `crates/buzz-relay/CHANGELOG.md`, commits, pushes, and + opens (or updates) a PR. +2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the + `relay-release/*` branch merge and pushes a `relay-v` tag. +3. **Auto-tag dispatches `docker.yml`** — the same workflow then triggers + `docker.yml` with the version and tag ref, which builds the multi-arch relay + image and publishes `ghcr.io/block/buzz:` (plus `:.`, + `:`, and `:latest` for stable releases). Prereleases + (`relay-v-rc.1`) publish only the prerelease tag and do **not** + move `:latest`. (The dispatch — rather than relying on `docker.yml`'s + `push: tags` trigger — is required because GitHub suppresses `on: push` for + tags pushed by the workflow's own `GITHUB_TOKEN`; the desktop lane dispatches + `release.yml` for the same reason.) + +Every push to `main` continues to build and publish `:main` + `:sha-<7>` tags +(the rolling development image). The `:latest` tag tracks the latest **stable** +relay release only — it does not move on main pushes or prereleases. + +### Mobile + +1. **`just release-mobile`** runs locally on `main` — computes the next mobile + version, creates (or reuses) a `mobile-release/` branch, bumps + `mobile/pubspec.yaml` (preserving the `+build` number), regenerates + `mobile/pubspec.lock`, generates a changelog entry in `mobile/CHANGELOG.md`, + commits, pushes, and opens (or updates) a PR. +2. **Merge the PR** — the `auto-tag-on-release-pr-merge` workflow detects the + `mobile-release/*` branch merge and pushes a `mobile-v` tag. +3. **The tag is consumed manually, cross-repo** — nothing in OSS `block/buzz` + builds on the tag (OSS CI must not trigger CI in the private `buzz-releases` + repo — infosec). A human feeds the `mobile-v` tag as the + `sprout_ref` input to the internal `buzz-releases` Buildkite pipeline, which + builds and ships iOS to Block Comp Portal (dogfood) and TestFlight (App + Store, opt-in). See [Internal Releases](#internal-releases). --- ## Release Types +The argument forms below apply to `release-desktop`, `release-relay`, and +`release-mobile`: + | Command | Version | Example | |---------|---------|---------| -| `just release` | Next patch | `0.3.0` → `0.3.1` | -| `just release patch` | Next patch | `0.3.0` → `0.3.1` | -| `just release 0.4.0` | Explicit minor | `0.3.1` → `0.4.0` | -| `just release 1.0.0` | Explicit | `1.0.0` | +| `just release-desktop` | Next patch | `0.3.0` → `0.3.1` | +| `just release-desktop patch` | Next patch | `0.3.0` → `0.3.1` | +| `just release-desktop 0.4.0` | Explicit minor | `0.3.1` → `0.4.0` | +| `just release-desktop 1.0.0` | Explicit | `1.0.0` | --- ## Version Files -`just bump-version ` updates these files: +`just bump-desktop-version ` (desktop lane) updates these files: | File | Field | |------|-------| | `desktop/package.json` | `"version"` | | `desktop/src-tauri/tauri.conf.json` | `"version"` | | `desktop/src-tauri/Cargo.toml` | `version` (under `[package]`) | -| `mobile/pubspec.yaml` | `version:` (preserves build number) | -It also regenerates `pnpm-lock.yaml`, `desktop/src-tauri/Cargo.lock`, and `mobile/pubspec.lock`. +It also regenerates `pnpm-lock.yaml` and `desktop/src-tauri/Cargo.lock`. + +`just bump-relay-version ` (relay lane) updates +`crates/buzz-relay/Cargo.toml` (`version` under `[package]`) and regenerates the +workspace `Cargo.lock`. + +`just bump-mobile-version ` (mobile lane) updates +`mobile/pubspec.yaml` (`version:`, preserving the `+build` number) and +regenerates `mobile/pubspec.lock`. --- @@ -116,14 +205,14 @@ the same `v` release. Intel users download the `_x64.dmg`. ## Troubleshooting -### `just release` fails with "must be on main branch" -Switch to `main` and pull latest before running `just release`. +### `just release-desktop` fails with "must be on main branch" +Switch to `main` and pull latest before running the release recipe. -### `just release` fails with "working tree is dirty" -Commit or stash your changes before running `just release`. +### `just release-desktop` fails with "working tree is dirty" +Commit or stash your changes before running the release recipe. ### New commits merged after creating the release PR -Re-run `just release` from an up-to-date `main`. It resets the branch to current `main`, regenerates the changelog and PR body to include the new commits, and force-pushes the updated branch. +Re-run the release recipe (`just release-desktop`, `just release-relay`, or `just release-mobile`) from an up-to-date `main`. It resets the branch to current `main`, regenerates the changelog and PR body to include the new commits, and force-pushes the updated branch. ### Build fails at "Validate version" The version string must be valid semver: `MAJOR.MINOR.PATCH` with an optional pre-release suffix. Do not include a `v` prefix. diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index e28389080..6545a6f64 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -1,7 +1,10 @@ [package] name = "buzz-relay" default-run = "buzz-relay" -version.workspace = true +# Independent version: buzz-relay ships as a pinnable artifact +# (ghcr.io/block/buzz), released on its own cadence via `just release-relay`. +# It does NOT inherit the workspace version. +version = "0.1.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/sprig/Cargo.toml b/crates/sprig/Cargo.toml index f0bf9a0e4..4e8c4ab41 100644 --- a/crates/sprig/Cargo.toml +++ b/crates/sprig/Cargo.toml @@ -1,6 +1,8 @@ [package] name = "sprig" -version.workspace = true +# Independent version: sprig ships as a pinnable artifact (sprig-v* tags), +# released on its own cadence. It does NOT inherit the workspace version. +version = "0.1.0" edition.workspace = true rust-version.workspace = true license.workspace = true From 3e91ce274e8b78da2960c59884a6d2d273f2833c Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 23 Jun 2026 15:49:04 -0700 Subject: [PATCH 19/30] chore(release): release Buzz Desktop version 0.3.31 (#1223) --- CHANGELOG.md | 25 +++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 5 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2138564..722c5888c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## v0.3.31 + +- fix(release): publish versioned relay Docker tags via independent release lanes ([#1173](https://github.com/block/buzz/pull/1173)) ([`549b7d24`](https://github.com/block/buzz/commit/549b7d24813320045bdda629d865c6c7418e7450)) +- fix(desktop): align settings section headers ([#1165](https://github.com/block/buzz/pull/1165)) ([`6ad68a6b`](https://github.com/block/buzz/commit/6ad68a6b095cd5db328ae07dfac4013eeda5820a)) +- fix(desktop): ground agent workspace, migrate legacy nest, configurable repos_dir ([#1194](https://github.com/block/buzz/pull/1194)) ([`1011cea2`](https://github.com/block/buzz/commit/1011cea2682a5e8cd92c9da255d5ba6c8f7ced78)) +- fix(desktop): enable mesh llm for release builds ([#1221](https://github.com/block/buzz/pull/1221)) ([`fa1262a9`](https://github.com/block/buzz/commit/fa1262a92c85b96c1d643c247d28df4f2f57e81a)) +- fix(desktop): move crypto commands off the main thread ([#1222](https://github.com/block/buzz/pull/1222)) ([`e35e84b0`](https://github.com/block/buzz/commit/e35e84b08bdc44f1f5297e6957bcc52e7c31eb70)) +- fix: tolerate missing private_key_nsec in agent store ([#1220](https://github.com/block/buzz/pull/1220)) ([`c58e9880`](https://github.com/block/buzz/commit/c58e9880bf60324b0fbb64917b6d1c8e197d4ea4)) +- Update navigation header height ([#1212](https://github.com/block/buzz/pull/1212)) ([`6b5cf325`](https://github.com/block/buzz/commit/6b5cf325c2777f64696923cf5b1c1ffd4fdf82e2)) +- Improve global search ([#1195](https://github.com/block/buzz/pull/1195)) ([`5130a6a0`](https://github.com/block/buzz/commit/5130a6a0b60c56a5c31549d3d4e85b956e35a671)) +- Parse Typesense multi_search errors ([#1208](https://github.com/block/buzz/pull/1208)) ([`65ccb126`](https://github.com/block/buzz/commit/65ccb1262fb876b74584bca1165feef39eda67a6)) +- fix(desktop): keep settings shortcut from opening search ([#1204](https://github.com/block/buzz/pull/1204)) ([`89ff9504`](https://github.com/block/buzz/commit/89ff950444d03ea09eb54661a21f0cb96f0bfcb6)) +- Polish sidebar channel navigation ([#1213](https://github.com/block/buzz/pull/1213)) ([`c0a872e8`](https://github.com/block/buzz/commit/c0a872e898479bcb2c3dda1b642c0d1373174f68)) +- fix(desktop): restore channel unread badges ([#1218](https://github.com/block/buzz/pull/1218)) ([`89aaa264`](https://github.com/block/buzz/commit/89aaa26443486244b6f004a7c419f4e0dc86aa44)) +- Fix collapsed home header chrome overlap ([#1215](https://github.com/block/buzz/pull/1215)) ([`b4e75a1e`](https://github.com/block/buzz/commit/b4e75a1e41a614fa3449e814ccbd9f31090dfbfc)) +- fix(desktop): dedupe welcome intro per channel ([#1216](https://github.com/block/buzz/pull/1216)) ([`2a522826`](https://github.com/block/buzz/commit/2a522826edc6dfb4df79f34256beea6b8597505b)) +- fix(desktop): defer agent page secondary requests ([#1217](https://github.com/block/buzz/pull/1217)) ([`bee2d64c`](https://github.com/block/buzz/commit/bee2d64cf7f093088cc28463e96a6c94b64f280e)) +- ci(release): enable Tauri auto-updater on Windows and Linux builds ([#1206](https://github.com/block/buzz/pull/1206)) ([`3ef2a8e5`](https://github.com/block/buzz/commit/3ef2a8e5c7e655f3347931135dde5f65b919c915)) +- Hydrate reactions for rendered messages ([#1205](https://github.com/block/buzz/pull/1205)) ([`ed556f3d`](https://github.com/block/buzz/commit/ed556f3deb895e0adfa18274b3ed90f255b5f6ad)) +- fix(desktop): show NIP-OA owners in profile pane ([#1198](https://github.com/block/buzz/pull/1198)) ([`40070a58`](https://github.com/block/buzz/commit/40070a58559938ed649950ccabce0725dd3c966e)) +- fix(desktop): preserve login-shell PATH for managed agents ([#1193](https://github.com/block/buzz/pull/1193)) ([`29978b6f`](https://github.com/block/buzz/commit/29978b6f93cdd2c5d061093ddce87d567d8d4c17)) +- Fix nav chrome offset in fullscreen ([#1192](https://github.com/block/buzz/pull/1192)) ([`b3b0704e`](https://github.com/block/buzz/commit/b3b0704efb5afc74dca0a093a6e4594973e9edf4)) +- fix(desktop): show due-reminder count in the Inbox nav badge ([#1191](https://github.com/block/buzz/pull/1191)) ([`c0858dac`](https://github.com/block/buzz/commit/c0858dac12a3efd09d301f5424074f18df5cf422)) + + ## v0.3.30 - fix(desktop): collapse mark-read/unread menu into one toggling item ([#1188](https://github.com/block/buzz/pull/1188)) ([`ce994df74`](https://github.com/block/buzz/commit/ce994df74e60cf43b2fb0b97ea9989aacd47650e)) diff --git a/desktop/package.json b/desktop/package.json index d4b51d5d4..a174e6fa2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.3.30", + "version": "0.3.31", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 9714697c9..e3c4a1e9e 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -846,7 +846,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.3.30" +version = "0.3.31" dependencies = [ "anyhow", "atomic-write-file", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index a1f921b7c..7b0d42213 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.3.30" +version = "0.3.31" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index d9babe959..1043c8240 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.3.30", + "version": "0.3.31", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 38a95334396dae0e271a478b46baa62d29deebff Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 23 Jun 2026 17:18:55 -0700 Subject: [PATCH 20/30] [codex] Make relay frame limit configurable (#1225) --- crates/buzz-relay/src/config.rs | 25 +++++++++ crates/buzz-relay/src/connection.rs | 23 ++++++--- crates/buzz-relay/src/main.rs | 1 + crates/buzz-relay/src/nip11.rs | 40 ++++++++++----- crates/buzz-relay/src/router.rs | 12 ++++- crates/buzz-test-client/tests/e2e_relay.rs | 60 ++++++++++++++++++++-- 6 files changed, 135 insertions(+), 26 deletions(-) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 59daa0647..cdd863920 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -5,6 +5,12 @@ use std::net::SocketAddr; use thiserror::Error; use tracing::warn; +/// Default maximum inbound WebSocket frame size in bytes. +/// +/// Must comfortably exceed accepted event content sizes after Nostr JSON and +/// NIP-44 encryption overhead. +pub const DEFAULT_MAX_FRAME_BYTES: usize = 512 * 1024; + /// Errors that can occur while loading relay configuration. #[derive(Debug, Error)] pub enum ConfigError { @@ -37,6 +43,8 @@ pub struct Config { pub max_concurrent_handlers: usize, /// Per-connection outbound message buffer size (number of messages). pub send_buffer_size: usize, + /// Maximum inbound WebSocket frame size in bytes. + pub max_frame_bytes: usize, /// Authentication provider configuration. pub auth: buzz_auth::AuthConfig, /// Whether REST API requests must present a valid token. Independent of @@ -159,6 +167,12 @@ impl Config { .and_then(|v| v.parse().ok()) .unwrap_or(1_000); + let max_frame_bytes = std::env::var("BUZZ_MAX_FRAME_BYTES") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0) + .unwrap_or(DEFAULT_MAX_FRAME_BYTES); + let require_auth_token = std::env::var("BUZZ_REQUIRE_AUTH_TOKEN") .map(|v| v == "true" || v == "1") .unwrap_or(false); @@ -360,6 +374,7 @@ impl Config { max_connections, max_concurrent_handlers, send_buffer_size, + max_frame_bytes, auth, require_auth_token, cors_origins, @@ -401,6 +416,7 @@ mod tests { assert!(!config.redis_url.is_empty()); assert!(config.max_connections > 0); assert!(config.send_buffer_size > 0); + assert_eq!(config.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES); assert!( !config.pubkey_allowlist_enabled, "pubkey_allowlist_enabled should default to false" @@ -428,6 +444,15 @@ mod tests { assert!(matches!(result, Err(ConfigError::InvalidBindAddr(_)))); } + #[test] + fn max_frame_bytes_can_be_configured() { + let _guard = ENV_MUTEX.lock().unwrap(); + std::env::set_var("BUZZ_MAX_FRAME_BYTES", "262144"); + let config = Config::from_env().expect("config"); + std::env::remove_var("BUZZ_MAX_FRAME_BYTES"); + assert_eq!(config.max_frame_bytes, 262_144); + } + #[test] fn server_domain_auto_derived_from_relay_url() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 3d3e2eac7..affd28f81 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -283,9 +283,6 @@ async fn heartbeat_loop( } } -/// NIP-11 advertised max_message_length. Frames exceeding this are rejected. -pub const MAX_FRAME_BYTES: usize = 65536; - async fn recv_loop( mut ws_recv: futures_util::stream::SplitStream, conn: Arc, @@ -298,16 +295,28 @@ async fn recv_loop( msg = ws_recv.next() => { match msg { Some(Ok(WsMessage::Text(text))) => { - if text.len() > MAX_FRAME_BYTES { - warn!(conn_id = %conn.conn_id, bytes = text.len(), "frame too large — disconnecting"); + let max_frame_bytes = state.config.max_frame_bytes; + if text.len() > max_frame_bytes { + warn!( + conn_id = %conn.conn_id, + bytes = text.len(), + max_frame_bytes, + "frame too large — disconnecting" + ); break; } trace!(len = text.len(), "frame received"); handle_text_message(text.to_string(), Arc::clone(&conn), Arc::clone(&state)).await; } Some(Ok(WsMessage::Binary(bytes))) => { - if bytes.len() > MAX_FRAME_BYTES { - warn!(conn_id = %conn.conn_id, bytes = bytes.len(), "binary frame too large — disconnecting"); + let max_frame_bytes = state.config.max_frame_bytes; + if bytes.len() > max_frame_bytes { + warn!( + conn_id = %conn.conn_id, + bytes = bytes.len(), + max_frame_bytes, + "binary frame too large — disconnecting" + ); break; } // Binary frames: attempt UTF-8 decode and treat as text. Some clients diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index d2bafd300..f3fd11b97 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -44,6 +44,7 @@ async fn main() -> anyhow::Result<()> { relay_url = %config.relay_url, health_port = config.health_port, metrics_port = config.metrics_port, + max_frame_bytes = config.max_frame_bytes, "Config loaded" ); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index d797dc807..e988c03f3 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize}; -use crate::connection::MAX_FRAME_BYTES; +#[cfg(test)] +use crate::config::DEFAULT_MAX_FRAME_BYTES; /// NIPs unconditionally supported by this relay, advertised in the NIP-11 /// document. Kept as a module-level constant so tests can verify it without @@ -82,14 +83,14 @@ pub struct RelayLimitation { /// unconditionally reject connections that are not in /// `AuthState::Authenticated`. This is independent of the REST API token /// toggle (`config.require_auth_token`). -fn relay_limitation() -> RelayLimitation { +fn relay_limitation(max_message_length: usize) -> RelayLimitation { let max_not_before_delta: u64 = std::env::var("SPROUT_MAX_NOT_BEFORE_DELTA") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(31_536_000); // 1 year default RelayLimitation { - max_message_length: Some(MAX_FRAME_BYTES as u64), + max_message_length: Some(max_message_length as u64), max_subscriptions: Some(1024), max_filters: Some(10), max_limit: Some(10_000), @@ -118,7 +119,11 @@ impl RelayInfo { /// gates on NIP-43 events — i.e. has a stable key AND enforces /// membership. NIP-43 events are verified against `self`, so it is a /// programmer error to advertise NIP-43 without a `relay_self`. - pub fn build(relay_self: Option<&str>, advertise_nip43: bool) -> Self { + pub fn build( + relay_self: Option<&str>, + advertise_nip43: bool, + max_message_length: usize, + ) -> Self { debug_assert!( !advertise_nip43 || relay_self.is_some(), "advertise_nip43=true requires relay_self=Some — NIP-43 events are verified against `self`" @@ -138,7 +143,7 @@ impl RelayInfo { supported_extensions: Some(vec!["nip-er".to_string()]), software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), - limitation: Some(relay_limitation()), + limitation: Some(relay_limitation(max_message_length)), relay_self: relay_self.map(|s| s.to_string()), } } @@ -149,7 +154,11 @@ pub async fn relay_info_handler( axum::extract::State(state): axum::extract::State>, ) -> axum::response::Json { let (relay_self, advertise_nip43) = nip11_facts(&state); - axum::response::Json(RelayInfo::build(relay_self.as_deref(), advertise_nip43)) + axum::response::Json(RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + state.config.max_frame_bytes, + )) } /// Derives the two NIP-11 facts that depend on runtime config: @@ -199,7 +208,7 @@ mod tests { #[test] fn build_advertises_buzz_repository_url() { - let info = RelayInfo::build(None, false); + let info = RelayInfo::build(None, false, DEFAULT_MAX_FRAME_BYTES); assert_eq!(info.software, "https://github.com/block/buzz"); } @@ -208,7 +217,14 @@ mod tests { // REQ, EVENT, and COUNT all unconditionally require // `AuthState::Authenticated` (see `crates/buzz-relay/src/handlers/`), // so the NIP-11 doc must advertise it. - assert!(relay_limitation().auth_required); + assert!(relay_limitation(DEFAULT_MAX_FRAME_BYTES).auth_required); + } + + #[test] + fn max_message_length_uses_configured_frame_limit() { + let info = RelayInfo::build(None, false, 262_144); + let limitation = info.limitation.expect("limitation"); + assert_eq!(limitation.max_message_length, Some(262_144)); } #[test] @@ -237,7 +253,7 @@ mod tests { /// Open relay, ephemeral key — both `self` and NIP-43 are absent. #[test] fn build_open_relay_ephemeral_key_omits_self_and_nip43() { - let info = RelayInfo::build(None, false); + let info = RelayInfo::build(None, false, DEFAULT_MAX_FRAME_BYTES); assert!(info.relay_self.is_none()); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -250,7 +266,7 @@ mod tests { #[test] fn build_open_relay_stable_key_advertises_self_but_not_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), false); + let info = RelayInfo::build(Some(pk), false, DEFAULT_MAX_FRAME_BYTES); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -259,7 +275,7 @@ mod tests { #[test] fn build_membership_relay_advertises_self_and_nip43() { let pk = "0000000000000000000000000000000000000000000000000000000000000001"; - let info = RelayInfo::build(Some(pk), true); + let info = RelayInfo::build(Some(pk), true, DEFAULT_MAX_FRAME_BYTES); assert_eq!(info.relay_self.as_deref(), Some(pk)); assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP)); } @@ -270,6 +286,6 @@ mod tests { #[test] #[should_panic(expected = "advertise_nip43=true requires relay_self=Some")] fn build_nip43_without_self_panics_in_debug() { - let _ = RelayInfo::build(None, true); + let _ = RelayInfo::build(None, true, DEFAULT_MAX_FRAME_BYTES); } } diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 226592a07..d9a56887d 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -156,7 +156,11 @@ async fn nip11_or_ws_handler( let (relay_self, advertise_nip43) = nip11_facts(&state); if accept.contains("application/nostr+json") { - let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43); + let info = RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + state.config.max_frame_bytes, + ); return Json(info).into_response(); } @@ -175,7 +179,11 @@ async fn nip11_or_ws_handler( } } // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback. - let info = RelayInfo::build(relay_self.as_deref(), advertise_nip43); + let info = RelayInfo::build( + relay_self.as_deref(), + advertise_nip43, + state.config.max_frame_bytes, + ); Json(info).into_response() } } diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 6aaaf9ae4..fe9e308f3 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -39,7 +39,7 @@ fn relay_http_url() -> String { .to_string() } -/// Create a real channel via a signed kind:9007 event submitted to POST /api/events. +/// Create a real channel via a signed kind:9007 event submitted to POST /events. async fn create_test_channel(keys: &Keys) -> String { let client = reqwest::Client::new(); let pubkey_hex = keys.public_key().to_hex(); @@ -57,7 +57,7 @@ async fn create_test_channel(keys: &Keys) -> String { .unwrap(); let resp = client - .post(format!("{}/api/events", relay_http_url())) + .post(format!("{}/events", relay_http_url())) .header("X-Pubkey", &pubkey_hex) .header("Content-Type", "application/json") .body(serde_json::to_string(&event).unwrap()) @@ -151,6 +151,56 @@ async fn test_send_event_and_receive_via_subscription() { client_b.disconnect().await.expect("disconnect B"); } +#[tokio::test] +#[ignore] +async fn test_large_event_frame_below_configured_limit_is_accepted() { + let url = relay_url(); + let kind: u16 = 9; + + let keys = Keys::generate(); + let channel = create_test_channel(&keys).await; + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + + let h_tag = Tag::parse(["h", channel.as_str()]).expect("h tag"); + let content = "x".repeat(70_000); + let event = EventBuilder::new(Kind::Custom(kind), content) + .tags([h_tag]) + .sign_with_keys(&keys) + .expect("sign large event"); + + let frame = serde_json::to_string(&serde_json::json!(["EVENT", &event])).expect("frame JSON"); + assert!( + frame.len() > 65_536, + "test frame must exceed the old 64 KiB cap; got {} bytes", + frame.len() + ); + assert!( + frame.len() < 512 * 1024, + "test frame should fit under the new default cap; got {} bytes", + frame.len() + ); + + let ok = client.send_event(event).await.expect("send large event"); + assert!(ok.accepted, "large event rejected: {}", ok.message); + + let ok_after = client + .send_text_message( + &keys, + &channel, + "socket still usable after large frame", + kind, + ) + .await + .expect("send follow-up event"); + assert!( + ok_after.accepted, + "follow-up event rejected: {}", + ok_after.message + ); + + client.disconnect().await.expect("disconnect"); +} + #[tokio::test] #[ignore] async fn test_subscription_filters_by_kind() { @@ -1347,7 +1397,7 @@ async fn test_membership_notification_emitted_on_add() { .sign_with_keys(&owner_keys) .unwrap(); let resp = http_client - .post(format!("{}/api/events", relay_http_url())) + .post(format!("{}/events", relay_http_url())) .header("X-Pubkey", &owner_keys.public_key().to_hex()) .header("Content-Type", "application/json") .body(serde_json::to_string(&add_event).unwrap()) @@ -1619,7 +1669,7 @@ async fn test_membership_notification_emitted_on_remove() { .sign_with_keys(&owner_keys) .unwrap(); let resp = http_client - .post(format!("{}/api/events", relay_http_url())) + .post(format!("{}/events", relay_http_url())) .header("X-Pubkey", &owner_pubkey_hex) .header("Content-Type", "application/json") .body(serde_json::to_string(&add_event).unwrap()) @@ -1658,7 +1708,7 @@ async fn test_membership_notification_emitted_on_remove() { .sign_with_keys(&owner_keys) .unwrap(); let resp = http_client - .post(format!("{}/api/events", relay_http_url())) + .post(format!("{}/events", relay_http_url())) .header("X-Pubkey", &owner_pubkey_hex) .header("Content-Type", "application/json") .body(serde_json::to_string(&remove_event).unwrap()) From 142a5c909542f1e1d2119ca4562129d492aca94c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 23 Jun 2026 20:26:54 -0400 Subject: [PATCH 21/30] fix(relay): raise grace limit, add replay backpressure, and NOTICE on oversized frames (#1226) --- crates/buzz-relay/src/config.rs | 9 ++++++ crates/buzz-relay/src/connection.rs | 28 +++++++++++++------ crates/buzz-relay/src/handlers/event.rs | 1 + .../buzz-relay/src/handlers/mesh_signaling.rs | 1 + crates/buzz-relay/src/handlers/req.rs | 5 +++- crates/buzz-relay/src/state.rs | 28 +++++++++++-------- 6 files changed, 51 insertions(+), 21 deletions(-) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index cdd863920..714ced400 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -45,6 +45,8 @@ pub struct Config { pub send_buffer_size: usize, /// Maximum inbound WebSocket frame size in bytes. pub max_frame_bytes: usize, + /// Number of consecutive buffer-full events tolerated before cancelling a slow client. + pub slow_client_grace_limit: u8, /// Authentication provider configuration. pub auth: buzz_auth::AuthConfig, /// Whether REST API requests must present a valid token. Independent of @@ -173,6 +175,11 @@ impl Config { .filter(|&v| v > 0) .unwrap_or(DEFAULT_MAX_FRAME_BYTES); + let slow_client_grace_limit = std::env::var("BUZZ_SLOW_CLIENT_GRACE_LIMIT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(15); + let require_auth_token = std::env::var("BUZZ_REQUIRE_AUTH_TOKEN") .map(|v| v == "true" || v == "1") .unwrap_or(false); @@ -375,6 +382,7 @@ impl Config { max_concurrent_handlers, send_buffer_size, max_frame_bytes, + slow_client_grace_limit, auth, require_auth_token, cors_origins, @@ -417,6 +425,7 @@ mod tests { assert!(config.max_connections > 0); assert!(config.send_buffer_size > 0); assert_eq!(config.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES); + assert!(config.slow_client_grace_limit > 0); assert!( !config.pubkey_allowlist_enabled, "pubkey_allowlist_enabled should default to false" diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index affd28f81..d6d6ccf77 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -20,10 +20,6 @@ use crate::handlers; use crate::protocol::{ClientMessage, RelayMessage}; use crate::state::AppState; -/// Number of buffer-full events tolerated before cancelling a slow client. -/// Prevents transient read stalls from hard-disconnecting agents mid-inference. -pub(crate) const SLOW_CLIENT_GRACE_LIMIT: u8 = 3; - /// Shared mutable subscription map for a single WebSocket connection. pub(crate) type ConnectionSubscriptions = Arc>>>; @@ -62,18 +58,20 @@ pub struct ConnectionState { pub ctrl_tx: mpsc::Sender, /// Token used to signal graceful shutdown of this connection's tasks. pub cancel: CancellationToken, - /// Consecutive buffer-full events. Cancel only after [`SLOW_CLIENT_GRACE_LIMIT`]. + /// Consecutive buffer-full events. Cancel only after `grace_limit`. /// Shared with `ConnectionManager::ConnEntry` so both direct sends and /// fan-out broadcasts track the same counter. pub backpressure_count: Arc, + /// Configurable slow-client grace limit (from `Config::slow_client_grace_limit`). + pub grace_limit: u8, } impl ConnectionState { /// Sends a data message to this connection's outbound channel. /// /// On a full buffer, increments the backpressure counter. The first - /// [`SLOW_CLIENT_GRACE_LIMIT`] occurrences log a warning; sustained - /// backpressure cancels the connection to prevent unbounded memory growth. + /// `grace_limit` occurrences log a warning; sustained backpressure + /// cancels the connection to prevent unbounded memory growth. pub fn send(&self, msg: String) -> bool { match self.send_tx.try_send(WsMessage::Text(msg.into())) { Ok(_) => { @@ -83,12 +81,12 @@ impl ConnectionState { } Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { let count = self.backpressure_count.fetch_add(1, Ordering::Relaxed) + 1; - if count >= SLOW_CLIENT_GRACE_LIMIT { + if count >= self.grace_limit { warn!(conn_id = %self.conn_id, count, "sustained backpressure — closing slow client"); metrics::counter!("buzz_ws_backpressure_disconnects_total").increment(1); self.cancel.cancel(); } else { - warn!(conn_id = %self.conn_id, count, grace = SLOW_CLIENT_GRACE_LIMIT, "send buffer full — grace {count}/{SLOW_CLIENT_GRACE_LIMIT}"); + warn!(conn_id = %self.conn_id, count, grace = self.grace_limit, "send buffer full — grace {count}/{}", self.grace_limit); } false } @@ -136,6 +134,7 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So ctrl_tx: ctrl_tx.clone(), cancel: cancel.clone(), backpressure_count: Arc::clone(&backpressure_count), + grace_limit: state.config.slow_client_grace_limit, }); info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established"); @@ -162,6 +161,7 @@ pub async fn handle_connection(socket: WebSocket, state: Arc, addr: So cancel.clone(), Arc::clone(&backpressure_count), subscriptions, + state.config.slow_client_grace_limit, ); let (ws_send, ws_recv) = socket.split(); @@ -303,6 +303,11 @@ async fn recv_loop( max_frame_bytes, "frame too large — disconnecting" ); + conn.send(format!( + r#"["NOTICE","error: frame too large ({} bytes, limit {})"]"#, + text.len(), + max_frame_bytes + )); break; } trace!(len = text.len(), "frame received"); @@ -317,6 +322,11 @@ async fn recv_loop( max_frame_bytes, "binary frame too large — disconnecting" ); + conn.send(format!( + r#"["NOTICE","error: binary frame too large ({} bytes, limit {})"]"#, + bytes.len(), + max_frame_bytes + )); break; } // Binary frames: attempt UTF-8 decode and treat as text. Some clients diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 3c9a1cf93..1c3f17d66 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1071,6 +1071,7 @@ mod tests { CancellationToken::new(), Arc::new(AtomicU8::new(0)), Arc::new(Mutex::new(HashMap::new())), + 3, ); if let Some(pk) = pubkey { state.conn_manager.set_authenticated_pubkey(conn_id, pk); diff --git a/crates/buzz-relay/src/handlers/mesh_signaling.rs b/crates/buzz-relay/src/handlers/mesh_signaling.rs index 4de6d15c1..0f3815169 100644 --- a/crates/buzz-relay/src/handlers/mesh_signaling.rs +++ b/crates/buzz-relay/src/handlers/mesh_signaling.rs @@ -550,6 +550,7 @@ mod tests { tokio_util::sync::CancellationToken::new(), std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)), std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), + 3, ); state.sub_registry.register( conn_id, diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 544106500..1893c3c2b 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -20,7 +20,7 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; -const MAX_HISTORICAL_LIMIT: i64 = 10_000; +const MAX_HISTORICAL_LIMIT: i64 = 2_000; const MAX_SUBSCRIPTIONS: usize = 1024; const P_GATED_KINDS: [u32; 5] = [ KIND_AGENT_OBSERVER_FRAME, @@ -253,6 +253,9 @@ pub async fn handle_req( return; } total_sent += 1; + if total_sent.is_multiple_of(100) { + tokio::task::yield_now().await; + } } } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 3617975a0..76384775e 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -24,7 +24,7 @@ use deadpool_redis; use crate::audio::AudioRoomManager; use crate::config::Config; -use crate::connection::{ConnectionSubscriptions, SLOW_CLIENT_GRACE_LIMIT}; +use crate::connection::ConnectionSubscriptions; use crate::subscription::SubscriptionRegistry; /// Per-connection entry in the connection manager. @@ -36,6 +36,7 @@ struct ConnEntry { backpressure_count: Arc, subscriptions: ConnectionSubscriptions, authenticated_pubkey: Arc>>>, + grace_limit: u8, } /// Tracks active WebSocket connections and provides message routing by connection ID. @@ -52,7 +53,7 @@ impl ConnectionManager { } /// Registers a connection with its outbound sender, cancellation token, - /// shared backpressure counter, and mutable subscription map. + /// shared backpressure counter, mutable subscription map, and grace limit. pub fn register( &self, conn_id: Uuid, @@ -60,6 +61,7 @@ impl ConnectionManager { cancel: CancellationToken, backpressure_count: Arc, subscriptions: ConnectionSubscriptions, + grace_limit: u8, ) { self.connections.insert( conn_id, @@ -69,6 +71,7 @@ impl ConnectionManager { backpressure_count, subscriptions, authenticated_pubkey: Arc::new(std::sync::RwLock::new(None)), + grace_limit, }, ); } @@ -131,8 +134,8 @@ impl ConnectionManager { /// Sends a text message to the given connection. /// /// Returns `false` if the connection is gone or the buffer is full. - /// On sustained backpressure (>[`SLOW_CLIENT_GRACE_LIMIT`] consecutive full - /// buffers), cancels the connection. Transient stalls get a warning only. + /// On sustained backpressure (>grace_limit consecutive full buffers), + /// cancels the connection. Transient stalls get a warning only. pub fn send_to(&self, conn_id: Uuid, msg: String) -> bool { if let Some(entry) = self.connections.get(&conn_id) { let conn = entry.value(); @@ -143,12 +146,12 @@ impl ConnectionManager { } Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => { let count = conn.backpressure_count.fetch_add(1, Ordering::Relaxed) + 1; - if count >= SLOW_CLIENT_GRACE_LIMIT { + if count >= conn.grace_limit { tracing::warn!(conn_id = %conn_id, count, "fan-out: sustained backpressure — cancelling slow client"); metrics::counter!("buzz_ws_backpressure_disconnects_total").increment(1); conn.cancel.cancel(); } else { - tracing::warn!(conn_id = %conn_id, count, grace = SLOW_CLIENT_GRACE_LIMIT, "fan-out: send buffer full — grace {count}/{SLOW_CLIENT_GRACE_LIMIT}"); + tracing::warn!(conn_id = %conn_id, count, grace = conn.grace_limit, "fan-out: send buffer full — grace {count}/{}", conn.grace_limit); } false } @@ -591,6 +594,7 @@ mod tests { cancel.clone(), Arc::clone(&bp), Arc::new(Mutex::new(HashMap::new())), + 3, ); (mgr, conn_id, rx, cancel, bp) } @@ -633,13 +637,13 @@ mod tests { fn send_to_cancels_after_grace_limit() { let (mgr, id, _rx, cancel, _bp) = setup_conn(1); assert!(mgr.send_to(id, "fill".into())); - // Exhaust grace: 3 consecutive Full events. - for _ in 0..SLOW_CLIENT_GRACE_LIMIT { + // Exhaust grace: 3 consecutive Full events (matches grace_limit=3 from setup_conn). + for _ in 0..3u8 { mgr.send_to(id, "overflow".into()); } assert!( cancel.is_cancelled(), - "should cancel after SLOW_CLIENT_GRACE_LIMIT overflows" + "should cancel after grace_limit overflows" ); } @@ -662,6 +666,7 @@ mod tests { ctrl_tx, cancel: cancel.clone(), backpressure_count: Arc::clone(&bp), + grace_limit: 3, }; let mgr = ConnectionManager::new(); @@ -671,6 +676,7 @@ mod tests { cancel.clone(), Arc::clone(&bp), Arc::clone(&conn.subscriptions), + 3, ); // Fill the buffer via direct send. @@ -705,7 +711,7 @@ mod tests { let cancel = CancellationToken::new(); let bp = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); - mgr.register(conn_id, tx, cancel, bp, Arc::clone(&subscriptions)); + mgr.register(conn_id, tx, cancel, bp, Arc::clone(&subscriptions), 3); let pubkey = vec![7u8; 32]; mgr.set_authenticated_pubkey(conn_id, pubkey.clone()); @@ -722,7 +728,7 @@ mod tests { let cancel = CancellationToken::new(); let bp = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); - mgr.register(conn_id, tx, cancel, bp, subscriptions); + mgr.register(conn_id, tx, cancel, bp, subscriptions, 3); assert_eq!(mgr.pubkey_for_conn(conn_id), None); let pubkey = vec![9u8; 32]; From 36d3d2ed1e57e399d5221edfd109082fd8a4d73e Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 23 Jun 2026 22:32:46 -0400 Subject: [PATCH 22/30] Fix presence fan-out across relay pods (#1227) Signed-off-by: Tyler Longwell Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Tyler Longwell --- crates/buzz-relay/src/handlers/event.rs | 269 +++++++++++++++++++++--- crates/buzz-relay/src/main.rs | 56 +---- 2 files changed, 246 insertions(+), 79 deletions(-) diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 1c3f17d66..0190b36ee 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -112,6 +112,57 @@ pub async fn filter_fanout_by_access( allowed } +/// Fan out one event received from Redis pub/sub to this relay's local subscribers. +pub async fn fan_out_pubsub_event(state: &Arc, channel_event: buzz_pubsub::ChannelEvent) { + // Nil UUID is the sentinel for channel-less global events (see + // `handle_ephemeral_event`'s global branch). Convert back to None so + // `fan_out()` uses the global subscriber index. + let channel_id = if channel_event.channel_id.is_nil() { + None + } else { + Some(channel_event.channel_id) + }; + let stored = StoredEvent::new(channel_event.event, channel_id); + + // Skip events that were already fanned out in-process (local echo). The + // cache has TTL-based eviction (60s) so entries are bounded regardless of + // subscriber health. + let event_id_bytes = stored.event.id.to_bytes(); + if state.local_event_ids.get(&event_id_bytes).is_some() { + state.local_event_ids.invalidate(&event_id_bytes); + return; + } + + let matches = state.sub_registry.fan_out(&stored); + let matches = filter_fanout_by_access(state, &stored, matches).await; + metrics::counter!("buzz_multinode_fanout_total").increment(1); + if matches.is_empty() { + return; + } + + let event_json = match serde_json::to_string(&stored.event) { + Ok(json) => json, + Err(e) => { + tracing::error!("Failed to serialize event for multi-node fan-out: {e}"); + return; + } + }; + let mut drop_count = 0u32; + for (conn_id, sub_id) in &matches { + let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json); + if !state.conn_manager.send_to(*conn_id, msg) { + drop_count += 1; + } + } + if drop_count > 0 { + tracing::warn!( + event_id = %stored.event.id.to_hex(), + drop_count, + "multi-node fan-out: {drop_count} connection(s) dropped" + ); + } +} + /// Publish a stored event to subscribers and kick off async side effects. pub(crate) async fn dispatch_persistent_event( state: &Arc, @@ -474,28 +525,9 @@ async fn handle_ephemeral_event( let _ = state.pubsub.set_presence(&auth_pubkey, &status).await; } - let stored_event = StoredEvent::new(event.clone(), None); - let matches = state.sub_registry.fan_out(&stored_event); - metrics::histogram!("buzz_fanout_recipients").record(matches.len() as f64); - let event_json = serde_json::to_string(&event) - .expect("nostr::Event serialization is infallible for well-formed events"); - let mut drop_count = 0u32; - for (target_conn_id, sub_id) in &matches { - let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json); - if !state.conn_manager.send_to(*target_conn_id, msg) { - drop_count += 1; - } - } - if drop_count > 0 { - tracing::warn!( - event_id = %event_id_hex, - drop_count, - "fan-out: {drop_count} connection(s) cancelled due to full/closed buffers" - ); - } - - conn.send(RelayMessage::ok(event_id_hex, true, "")); - return; + // Presence is a channel-less ephemeral event. After updating Redis + // presence state, let it fall through to the shared global ephemeral + // publish/fan-out path below so other relay nodes receive the live delta. } // Mesh status report (kind:24620). An authenticated relay member reports its @@ -1001,6 +1033,188 @@ mod tests { assert!(err.contains("NIP-44")); } + mod pubsub_fanout { + use std::collections::HashMap; + use std::sync::atomic::AtomicU8; + use std::sync::Arc; + + use axum::extract::ws::Message; + use buzz_core::kind::KIND_PRESENCE_UPDATE; + use buzz_pubsub::ChannelEvent; + use nostr::{EventBuilder, Filter, Keys, Kind}; + use tokio::sync::{mpsc, Mutex}; + use tokio_util::sync::CancellationToken; + use uuid::Uuid; + + use crate::handlers::event::fan_out_pubsub_event; + use crate::state::AppState; + + async fn test_state() -> Arc { + super::fanout_access::test_state().await + } + + fn register_presence_sub( + state: &AppState, + sub_id: &str, + ) -> (Uuid, mpsc::Receiver) { + let conn_id = Uuid::new_v4(); + let (tx, rx) = mpsc::channel(10); + state.conn_manager.register( + conn_id, + tx, + CancellationToken::new(), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + ); + state.sub_registry.register( + conn_id, + sub_id.to_string(), + vec![Filter::new().kind(Kind::Custom(KIND_PRESENCE_UPDATE as u16))], + None, + ); + (conn_id, rx) + } + + fn presence_event(status: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status) + .sign_with_keys(&Keys::generate()) + .expect("sign presence") + } + + fn event_from_ws_message(msg: Message) -> nostr::Event { + let Message::Text(text) = msg else { + panic!("expected text ws message"); + }; + let v: serde_json::Value = serde_json::from_str(&text).expect("EVENT frame JSON"); + assert_eq!(v[0], "EVENT"); + serde_json::from_value(v[2].clone()).expect("nostr event") + } + + #[tokio::test] + async fn global_presence_pubsub_event_fans_out_to_local_subscribers() { + let state = test_state().await; + let (_conn_id, mut rx) = register_presence_sub(&state, "presence"); + let event = presence_event("online"); + let event_id = event.id; + + fan_out_pubsub_event( + &state, + ChannelEvent { + channel_id: Uuid::nil(), + event, + }, + ) + .await; + + let delivered = event_from_ws_message(rx.try_recv().expect("presence delivered")); + assert_eq!(delivered.id, event_id); + assert!(rx.try_recv().is_err(), "presence is delivered once"); + } + + #[tokio::test] + async fn local_echo_presence_pubsub_event_is_not_delivered_twice() { + let state = test_state().await; + let (_conn_id, mut rx) = register_presence_sub(&state, "presence"); + let event = presence_event("online"); + + state.mark_local_event(&event.id); + fan_out_pubsub_event( + &state, + ChannelEvent { + channel_id: Uuid::nil(), + event, + }, + ) + .await; + + assert!( + rx.try_recv().is_err(), + "Redis echo of locally fanned-out presence must be suppressed" + ); + } + + async fn redis_url_if_available() -> Option { + let redis_url = + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + let pool = deadpool_redis::Config::from_url(&redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .ok()?; + let mut conn = pool.get().await.ok()?; + redis::cmd("PING") + .query_async::(&mut conn) + .await + .ok()?; + Some(redis_url) + } + + fn spawn_pubsub_fanout_loop(state: Arc) -> tokio::task::JoinHandle<()> { + let mut rx = state.pubsub.subscribe_local(); + tokio::spawn(async move { + while let Ok(channel_event) = rx.recv().await { + fan_out_pubsub_event(&state, channel_event).await; + } + }) + } + + #[tokio::test] + async fn redis_presence_publish_reaches_second_relay_and_suppresses_origin_echo() { + let Some(redis_url) = redis_url_if_available().await else { + eprintln!("skipping Redis round-trip presence fan-out test: Redis unavailable"); + return; + }; + + let origin = super::fanout_access::test_state_with_redis_url(&redis_url).await; + let receiver = super::fanout_access::test_state_with_redis_url(&redis_url).await; + + let origin_subscriber = tokio::spawn(origin.pubsub.clone().run_subscriber()); + let receiver_subscriber = tokio::spawn(receiver.pubsub.clone().run_subscriber()); + let origin_fanout = spawn_pubsub_fanout_loop(origin.clone()); + let receiver_fanout = spawn_pubsub_fanout_loop(receiver.clone()); + + let (_origin_conn, mut origin_rx) = register_presence_sub(&origin, "origin-presence"); + let (_receiver_conn, mut receiver_rx) = + register_presence_sub(&receiver, "receiver-presence"); + + // Match buzz-pubsub's own Redis round-trip test: give PSUBSCRIBE a + // bounded moment to attach before publishing the single test event. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + let event = presence_event("online"); + let event_id = event.id; + origin.mark_local_event(&event.id); + origin + .pubsub + .publish_event(Uuid::nil(), &event) + .await + .expect("publish presence through Redis"); + + let delivered = + tokio::time::timeout(std::time::Duration::from_secs(2), receiver_rx.recv()) + .await + .expect("presence reached second relay") + .expect("receiver connection still open"); + let delivered = event_from_ws_message(delivered); + assert_eq!(delivered.id, event_id); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), receiver_rx.recv()) + .await + .is_err(), + "second relay receives the presence event exactly once" + ); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(250), origin_rx.recv()) + .await + .is_err(), + "origin relay suppresses the Redis echo after local fan-out" + ); + + origin_subscriber.abort(); + receiver_subscriber.abort(); + origin_fanout.abort(); + receiver_fanout.abort(); + } + } + mod fanout_access { use std::collections::HashMap; use std::sync::atomic::AtomicU8; @@ -1015,15 +1229,16 @@ mod tests { use crate::handlers::event::filter_fanout_by_access; use crate::state::AppState; - fn test_config() -> crate::config::Config { + pub(super) fn test_config() -> crate::config::Config { let mut config = crate::config::Config::from_env().expect("default config loads"); config.require_relay_membership = false; config.redis_url = "redis://127.0.0.1:1".to_string(); config } - async fn test_state() -> Arc { - let config = test_config(); + pub(super) async fn test_state_with_redis_url(redis_url: &str) -> Arc { + let mut config = test_config(); + config.redis_url = redis_url.to_string(); let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) @@ -1062,6 +1277,10 @@ mod tests { Arc::new(state) } + pub(super) async fn test_state() -> Arc { + test_state_with_redis_url("redis://127.0.0.1:1").await + } + fn register_conn(state: &AppState, pubkey: Option>) -> Uuid { let conn_id = Uuid::new_v4(); let (tx, _rx) = mpsc::channel(1); diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index f3fd11b97..b555c3d9d 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -494,63 +494,11 @@ async fn main() -> anyhow::Result<()> { loop { match rx.recv().await { Ok(channel_event) => { - // Nil UUID is the sentinel for channel-less global events - // (see event.rs `else` branch). Convert back to None so - // fan_out() uses the global subscriber index instead of - // looking up subscribers under Some(Uuid::nil()), which - // would find nothing and silently drop every cross-node - // global event. - let channel_id = if channel_event.channel_id.is_nil() { - None - } else { - Some(channel_event.channel_id) - }; - let stored = buzz_core::StoredEvent::new(channel_event.event, channel_id); - - // Skip events that were already fanned out in-process (local echo). - // The cache has TTL-based eviction (60s) so entries are bounded - // regardless of subscriber health. - let event_id_bytes = stored.event.id.to_bytes(); - if state_for_sub.local_event_ids.get(&event_id_bytes).is_some() { - state_for_sub.local_event_ids.invalidate(&event_id_bytes); - continue; - } - - let matches = state_for_sub.sub_registry.fan_out(&stored); - let matches = buzz_relay::handlers::event::filter_fanout_by_access( + buzz_relay::handlers::event::fan_out_pubsub_event( &state_for_sub, - &stored, - matches, + channel_event, ) .await; - metrics::counter!("buzz_multinode_fanout_total").increment(1); - if matches.is_empty() { - continue; - } - - let event_json = match serde_json::to_string(&stored.event) { - Ok(json) => json, - Err(e) => { - tracing::error!( - "Failed to serialize event for multi-node fan-out: {e}" - ); - continue; - } - }; - let mut drop_count = 0u32; - for (conn_id, sub_id) in &matches { - let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json); - if !state_for_sub.conn_manager.send_to(*conn_id, msg) { - drop_count += 1; - } - } - if drop_count > 0 { - tracing::warn!( - event_id = %stored.event.id.to_hex(), - drop_count, - "multi-node fan-out: {drop_count} connection(s) dropped" - ); - } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { metrics::counter!("buzz_multinode_fanout_lag_total").increment(n); From dbad79688a94e310e257272c675b6b3b0248c7f2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 23 Jun 2026 22:53:50 -0700 Subject: [PATCH 23/30] fix(desktop): enable all agent profile ingress views - Use the shared profile panel view parser for channel and Pulse URL search state so every focused profile subview survives navigation and reloads. - Replace the old memories/channels-only profileView whitelist in channel and Pulse route validation. - Expand the profile Playwright coverage to exercise agent instruction, model, settings, diagnostics/logs, channels, and memories ingress rows from a mock managed agent profile. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src/app/routes/channels.$channelId.tsx | 12 ++--- desktop/src/app/routes/pulse.tsx | 11 ++-- .../ui/useChannelPanelHistoryState.ts | 11 ++-- desktop/src/features/pulse/ui/PulseScreen.tsx | 6 +-- desktop/tests/e2e/profile.spec.ts | 50 ++++++++++++++++++- 5 files changed, 68 insertions(+), 22 deletions(-) diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 3c64f3ec9..5bffcbd9f 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -1,13 +1,17 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteSearch = { agentSession?: string; messageId?: string; profile?: string; - profileView?: "memories" | "channels"; + profileView?: ProfilePanelView; thread?: string; threadRootId?: string; }; @@ -16,10 +20,6 @@ function nonEmptyString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } -function profileViewValue(value: unknown): "memories" | "channels" | undefined { - return value === "memories" || value === "channels" ? value : undefined; -} - function validateChannelSearch( search: Record, ): ChannelRouteSearch { @@ -27,7 +27,7 @@ function validateChannelSearch( agentSession: nonEmptyString(search.agentSession), messageId: nonEmptyString(search.messageId), profile: nonEmptyString(search.profile), - profileView: profileViewValue(search.profileView), + profileView: parseProfilePanelView(search.profileView) ?? undefined, thread: nonEmptyString(search.thread), threadRootId: nonEmptyString(search.threadRootId), }; diff --git a/desktop/src/app/routes/pulse.tsx b/desktop/src/app/routes/pulse.tsx index e1a5a001e..949b56d0b 100644 --- a/desktop/src/app/routes/pulse.tsx +++ b/desktop/src/app/routes/pulse.tsx @@ -1,6 +1,10 @@ import * as React from "react"; import { createFileRoute } from "@tanstack/react-router"; +import { + parseProfilePanelView, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { usePreviewFeatureWarning } from "@/shared/features"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; @@ -11,7 +15,7 @@ const PulseScreen = React.lazy(async () => { type PulseRouteSearch = { profile?: string; - profileView?: "memories" | "channels"; + profileView?: ProfilePanelView; }; function validatePulseSearch( @@ -22,10 +26,7 @@ function validatePulseSearch( typeof search.profile === "string" && search.profile.length > 0 ? search.profile : undefined, - profileView: - search.profileView === "memories" || search.profileView === "channels" - ? search.profileView - : undefined, + profileView: parseProfilePanelView(search.profileView) ?? undefined, }; } diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 53d236f43..adfb37652 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -1,6 +1,9 @@ import * as React from "react"; -import type { ProfilePanelView } from "@/features/profile/ui/UserProfilePanel"; +import { + profilePanelViewFromSearch, + type ProfilePanelView, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { type HistorySearchSetterOptions, useHistorySearchState, @@ -32,10 +35,6 @@ const CHANNEL_SEARCH_KEYS = [ "threadRootId", ] as const; -function asProfilePanelView(value: string | null): ProfilePanelView { - return value === "memories" || value === "channels" ? value : "summary"; -} - export function useChannelPanelHistoryState() { const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS); @@ -74,7 +73,7 @@ export function useChannelPanelHistoryState() { openAgentSessionPubkey: values.agentSession, openThreadHeadId: values.thread, profilePanelPubkey: values.profile, - profilePanelView: asProfilePanelView(values.profileView), + profilePanelView: profilePanelViewFromSearch(values.profileView), setOpenAgentSessionPubkey, setOpenThreadHeadId, setProfilePanelPubkey, diff --git a/desktop/src/features/pulse/ui/PulseScreen.tsx b/desktop/src/features/pulse/ui/PulseScreen.tsx index 6a0563ec8..56da5c455 100644 --- a/desktop/src/features/pulse/ui/PulseScreen.tsx +++ b/desktop/src/features/pulse/ui/PulseScreen.tsx @@ -6,6 +6,7 @@ import { type ProfilePanelView, UserProfilePanel, } from "@/features/profile/ui/UserProfilePanel"; +import { profilePanelViewFromSearch } from "@/features/profile/ui/UserProfilePanelUtils"; import { PulseView } from "@/features/pulse/ui/PulseView"; import { useIdentityQuery } from "@/shared/api/hooks"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; @@ -18,10 +19,7 @@ export function PulseScreen() { const identityQuery = useIdentityQuery(); const { applyPatch, values } = useHistorySearchState(PULSE_PANEL_SEARCH_KEYS); const profilePanelPubkey = values.profile; - const profilePanelView: ProfilePanelView = - values.profileView === "memories" || values.profileView === "channels" - ? values.profileView - : "summary"; + const profilePanelView = profilePanelViewFromSearch(values.profileView); const handleOpenProfilePanel = React.useCallback( (pubkey: string) => applyPatch({ profile: pubkey, profileView: null }), [applyPatch], diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 64b216cfe..f25ef096e 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -627,7 +627,7 @@ test("updates presence from the profile menu", async ({ page }) => { ).toContainText("Offline"); }); -test("renders agent memories seeded through the Playwright mock bridge", async ({ +test("renders agent profile ingress subviews from the Playwright mock bridge", async ({ page, }) => { await installMockBridge(page, { @@ -671,6 +671,54 @@ test("renders agent memories seeded through the Playwright mock bridge", async ( await messageRow.locator("button").first().click(); await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + const openSubview = async (testId: string, title: string) => { + await page.getByTestId(testId).click(); + await expect( + page.getByRole("heading", { level: 2, name: title }), + ).toBeVisible(); + }; + const backToProfile = async () => { + await page.getByTestId("user-profile-panel-back").click(); + await expect( + page.getByRole("heading", { level: 2, name: "Profile" }), + ).toBeVisible(); + }; + + await openSubview( + "user-profile-agent-instruction-ingress", + "Agent instruction", + ); + await expect( + page.getByTestId("user-profile-agent-instruction"), + ).toContainText("Watch the channel and help when asked."); + await backToProfile(); + + await openSubview("user-profile-model-ingress", "Model"); + await expect( + page.getByRole("heading", { level: 2, name: "Model" }), + ).toBeVisible(); + await backToProfile(); + + await openSubview("user-profile-agent-settings-ingress", "Agent settings"); + await expect( + page.getByTestId(`user-profile-agent-auto-start-${agentPubkey}`), + ).toBeVisible(); + await backToProfile(); + + await openSubview("user-profile-diagnostics-ingress", "Diagnostics"); + await page.getByTestId(`user-profile-agent-logs-${agentPubkey}`).click(); + await expect( + page.getByRole("heading", { level: 2, name: "Harness log" }), + ).toBeVisible(); + await expect(page.getByTestId("managed-agent-log-content")).toBeVisible(); + await backToProfile(); + + await openSubview("user-profile-channels-ingress", "Channels"); + await expect(page.getByTestId("user-profile-channels-list")).toContainText( + "#general", + ); + await backToProfile(); + const memoriesIngress = page.getByTestId("user-profile-memories-ingress"); await expect(memoriesIngress).toContainText("Memories"); await expect(memoriesIngress).toContainText("9"); From 6da152927983724684b7ee3378084a6ed7dbd201 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 23 Jun 2026 23:10:33 -0700 Subject: [PATCH 24/30] fix(desktop): handle model discovery setup states - Reuse managed-agent runtime defaults, provider/model env injection, and Databricks defaults when querying agent models so discovery matches normal startup behavior. - Add a configurationError field to model discovery responses for known missing provider, model, or credential setup issues instead of treating them as generic subprocess failures. - Update the profile model picker to render actionable setup messaging for configuration gaps and reserve the red failure state for unexpected discovery errors. - Show provider-backed agents as remotely managed in the model focused view rather than exposing a local model picker. - Keep E2E Tauri mocks aligned with the expanded AgentModelsResponse shape. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../src-tauri/src/commands/agent_models.rs | 144 +++++++++++++++--- .../src-tauri/src/managed_agents/runtime.rs | 4 +- desktop/src-tauri/src/managed_agents/types.rs | 2 + .../src/features/agents/ui/ModelPicker.tsx | 22 +++ .../profile/ui/UserProfilePanelSections.tsx | 8 +- desktop/src/shared/api/types.ts | 1 + desktop/src/testing/e2eBridge.ts | 1 + 7 files changed, 159 insertions(+), 23 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 8a4e8401b..d527253b3 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -6,12 +6,12 @@ use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut, - known_acp_runtime, load_managed_agents, load_personas, managed_agent_avatar_url, - missing_command_message, normalize_agent_args, resolve_command, - resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes, - try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, - UpdateManagedAgentResponse, + build_databricks_defaults, build_managed_agent_summary, default_agent_workdir, + find_managed_agent_mut, known_acp_runtime, load_managed_agents, load_personas, + managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command, + resolve_effective_prompt_model_provider, runtime_metadata_env_vars, save_managed_agents, + sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, AgentModelsResponse, + UpdateManagedAgentRequest, UpdateManagedAgentResponse, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -27,7 +27,16 @@ pub async fn get_agent_models( app: AppHandle, state: State<'_, AppState>, ) -> Result { - let (resolved_acp, agent_command, agent_args, persisted_model, merged_env) = { + let ( + resolved_acp, + agent_command, + agent_args, + persisted_model, + runtime_default_env, + runtime_metadata_env, + databricks_defaults, + merged_env, + ) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -66,14 +75,51 @@ pub async fn get_agent_models( // Resolve the effective model from the linked persona so the ModelPicker // dropdown shows the current persona model as selected. let personas = load_personas(&app).unwrap_or_default(); - let (_prompt, effective_model, _provider) = resolve_effective_prompt_model_provider( - record.persona_id.as_deref(), - &personas, - record.system_prompt.clone(), - record.model.clone(), - ); - - (resolved, resolved_agent, args, effective_model, env) + let (_prompt, effective_model, effective_provider) = + resolve_effective_prompt_model_provider( + record.persona_id.as_deref(), + &personas, + record.system_prompt.clone(), + record.model.clone(), + ); + let runtime = known_acp_runtime(&record.agent_command); + let runtime_default_env: Vec<(String, String)> = runtime + .map(|meta| { + meta.default_env + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() + }) + .unwrap_or_default(); + let runtime_metadata_env: Vec<(String, String)> = runtime + .map(|meta| { + runtime_metadata_env_vars( + meta.model_env_var, + meta.provider_env_var, + meta.provider_locked, + effective_model.as_deref(), + effective_provider.as_deref(), + ) + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + }) + .unwrap_or_default(); + let databricks_defaults: Vec<(String, String)> = build_databricks_defaults() + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + + ( + resolved, + resolved_agent, + args, + effective_model, + runtime_default_env, + runtime_metadata_env, + databricks_defaults, + env, + ) }; // store lock released — subprocess runs without holding the lock // Clone the env map for redaction below — `merged_env` is moved @@ -96,13 +142,17 @@ pub async fn get_agent_models( .arg("--json") .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); - if let Some(meta) = known_acp_runtime(&agent_command) { - for (key, value) in meta.default_env { - if std::env::var(key).is_err() { - cmd.env(key, value); - } + for (key, value) in &runtime_default_env { + if std::env::var(key).is_err() { + cmd.env(key, value); } } + for (key, value) in &runtime_metadata_env { + cmd.env(key, value); + } + for (key, value) in &databricks_defaults { + cmd.env(key, value); + } // User env layering — written LAST so it overrides any Buzz-set env above. for (k, v) in &merged_env { cmd.env(k, v); @@ -123,6 +173,12 @@ pub async fn get_agent_models( // a failing child process echoed back. let stderr_redacted = crate::managed_agents::redact_env_values_in(stderr.as_ref(), &env_for_redaction); + if let Some(configuration_error) = model_configuration_error(&stderr_redacted) { + return Ok(unavailable_agent_models( + persisted_model, + configuration_error, + )); + } return Err(format!( "buzz-acp models failed (exit {}): {stderr_redacted}", output.status.code().unwrap_or(-1) @@ -380,5 +436,53 @@ fn normalize_agent_models( agent_default_model, selected_model: persisted_model, supports_switching, + configuration_error: None, + } +} + +fn unavailable_agent_models( + persisted_model: Option, + configuration_error: String, +) -> AgentModelsResponse { + AgentModelsResponse { + agent_name: "unknown".to_string(), + agent_version: "unknown".to_string(), + models: Vec::new(), + agent_default_model: None, + selected_model: persisted_model, + supports_switching: false, + configuration_error: Some(configuration_error), } } + +fn model_configuration_error(stderr: &str) -> Option { + let normalized = stderr.to_ascii_lowercase(); + + if normalized.contains("buzz_agent_provider required") { + return Some( + "This agent does not have an LLM provider configured. Set a provider and model on the persona or agent, then retry." + .to_string(), + ); + } + + if normalized.contains("anthropic_model required") + || normalized.contains("openai_compat_model required") + || normalized.contains("databricks_model required") + { + return Some( + "This agent does not have an LLM model configured. Set a model on the persona or agent, then retry." + .to_string(), + ); + } + + if normalized.contains("anthropic_api_key required") + || normalized.contains("openai_compat_api_key required") + { + return Some( + "This agent is missing credentials for its configured LLM provider. Add the provider credentials, then retry." + .to_string(), + ); + } + + None +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b0470bd70..970ea808d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1783,7 +1783,7 @@ fn child_rust_log_filter() -> String { /// Databricks host/model baked in at compile time for internal builds. Empty /// in OSS builds, where the `BUZZ_BUILD_DATABRICKS_*` env is unset. -fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> { +pub(crate) fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> { let mut defaults = Vec::new(); if let Some(host) = option_env!("BUZZ_DESKTOP_BUILD_DATABRICKS_HOST") { if !host.is_empty() { @@ -1915,7 +1915,7 @@ pub fn stop_managed_agent_process( /// switching need the initial bootstrap value. Provider injection is skipped /// when `provider_locked` is true (e.g. Claude runtimes that only work with /// Anthropic). -fn runtime_metadata_env_vars<'a>( +pub(crate) fn runtime_metadata_env_vars<'a>( model_env_var: Option<&'a str>, provider_env_var: Option<&'a str>, provider_locked: bool, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 51761a0f9..96af32a6d 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -477,6 +477,8 @@ pub struct AgentModelsResponse { pub selected_model: Option, /// Whether this agent supports model switching. pub supports_switching: bool, + /// Human-readable setup issue that prevents model discovery. + pub configuration_error: Option, } /// A single model available from an agent. diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 12950201e..fd5f236b8 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -109,6 +109,9 @@ export function ModelPicker({ ) : error ? (

Failed to load models.

+

+ {error} +

+
) : !modelsData.supportsSwitching ? (
{agent.model ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index a52a912b4..5349d25a5 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -835,6 +835,8 @@ export function ModelFocusedView({ modelLabel: string; onModelChanged: () => void; }) { + const canPickModel = managedAgent?.backend.type === "local"; + return (
@@ -849,8 +851,12 @@ export function ModelFocusedView({ {modelLabel} - {managedAgent ? ( + {canPickModel && managedAgent ? ( + ) : managedAgent ? ( + + managed remotely + ) : null}
diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 653a1be99..ff02a87da 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -437,6 +437,7 @@ export type AgentModelsResponse = { agentDefaultModel: string | null; selectedModel: string | null; supportsSwitching: boolean; + configurationError: string | null; }; export type AgentModelInfo = { id: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5fa2c2095..2a1759aec 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -6610,6 +6610,7 @@ export function maybeInstallE2eTauriMocks() { agentDefaultModel: null, selectedModel: null, supportsSwitching: false, + configurationError: null, }; case "update_managed_agent": return handleUpdateManagedAgent( From 3739ea7f449b7738e475af540b08ac537577fe1c Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 23 Jun 2026 23:36:32 -0700 Subject: [PATCH 25/30] feat(profile): surface agent info fields in summary - Replace the Agent info summary ingress with the existing ProfileFieldGroup card for Public key and Owned by. - Preserve the shared muted background and adjacent field styling by reusing the focused profile field container. - Remove the now-unused Agent info summary click handler and Info icon import while leaving the focused info view available for other metadata. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/profile/ui/UserProfilePanel.tsx | 1 - .../profile/ui/UserProfilePanelSections.tsx | 20 +++++++------------ 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 1e6f3dbfe..81a085f53 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -796,7 +796,6 @@ export function UserProfilePanel({ diagnosticsFields={diagnosticsFields} diagnosticsSummary={diagnosticsSummary} modelLabel={modelLabel} - onOpenAgentInfo={() => setView("info")} onOpenAgentSettings={() => setView("settings")} onOpenChannels={() => setView("channels")} onOpenDiagnostics={() => setView("diagnostics")} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 5349d25a5..374ff332b 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -10,7 +10,6 @@ import { Cpu, FileText, Hash, - Info, MessageSquare, Pencil, Play, @@ -90,7 +89,6 @@ export type ProfileSummaryViewProps = { diagnosticsFields: ProfileField[]; diagnosticsSummary: string | null; modelLabel: string; - onOpenAgentInfo: () => void; onOpenAgentSettings: () => void; onOpenChannels: () => void; onOpenDiagnostics: () => void; @@ -140,7 +138,6 @@ export function ProfileSummaryView({ diagnosticsFields, diagnosticsSummary, modelLabel, - onOpenAgentInfo, onOpenAgentSettings, onOpenChannels, onOpenDiagnostics, @@ -171,7 +168,10 @@ export function ProfileSummaryView({ (agentSettingsFields.length > 0 || managedAgent?.backend.type === "local"); const showDiagnosticsIngress = diagnosticsFields.length > 0 || canOpenAgentLogs || canViewActivity; - const showAgentInfoIngress = agentInfoFields.length > 0; + const topLevelAgentInfoFields = agentInfoFields.filter( + (field) => field.label === "Public key" || field.label === "Owned by", + ); + const showTopLevelAgentInfo = topLevelAgentInfoFields.length > 0; const personaActionKey = persona?.id; return ( @@ -238,7 +238,7 @@ export function ProfileSummaryView({ showChannelsIngress || showAgentSettingsIngress || showDiagnosticsIngress || - showAgentInfoIngress ? ( + showTopLevelAgentInfo ? (
{showInstructionIngress ? ( ) : null} - {showAgentInfoIngress ? ( - + {showTopLevelAgentInfo ? ( + ) : null}
) : null} From b94bb0aa348eb1b33215b6b65892267141e0588c Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 23 Jun 2026 23:42:36 -0700 Subject: [PATCH 26/30] feat(profile): combine agent configuration ingress - Replace separate profile sidebar rows for agent instructions, model, and settings with a single Agent configuration ingress. - Add a combined configuration focused view that reuses the existing instruction, model picker, and settings sections under one destination. - Canonicalize legacy profileView values for instructions, model, and settings to configuration so existing deep links continue to resolve. - Update profile panel utility tests to cover the new canonical view and legacy aliases. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/profile/ui/UserProfilePanel.tsx | 31 +--- .../profile/ui/UserProfilePanelSections.tsx | 158 ++++++++++++++---- .../profile/ui/UserProfilePanelUtils.test.mjs | 10 +- .../profile/ui/UserProfilePanelUtils.ts | 27 +-- 4 files changed, 154 insertions(+), 72 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 81a085f53..824bcb167 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -54,13 +54,11 @@ import { useUserProfileQuery, } from "@/features/profile/hooks"; import { + AgentConfigurationFocusedView, AgentInfoFocusedView, - AgentInstructionFocusedView, - AgentSettingsFocusedView, ChannelsFocusedView, DiagnosticsFocusedView, MemoryFocusedView, - ModelFocusedView, ProfileSummaryView, } from "@/features/profile/ui/UserProfilePanelSections"; import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; @@ -796,12 +794,10 @@ export function UserProfilePanel({ diagnosticsFields={diagnosticsFields} diagnosticsSummary={diagnosticsSummary} modelLabel={modelLabel} - onOpenAgentSettings={() => setView("settings")} + onOpenAgentConfiguration={() => setView("configuration")} onOpenChannels={() => setView("channels")} onOpenDiagnostics={() => setView("diagnostics")} - onOpenInstruction={() => setView("instructions")} onOpenMemories={() => setView("memories")} - onOpenModel={() => setView("model")} onOpenDm={onOpenDm} persona={resolvedPersona} presenceStatus={presenceStatus} @@ -817,30 +813,19 @@ export function UserProfilePanel({ ) : null} - {view === "instructions" ? ( - - ) : null} - {view === "info" ? ( ) : null} - {view === "model" ? ( - void managedAgentsQuery.refetch()} - /> - ) : null} - - {view === "settings" ? ( - void managedAgentsQuery.refetch()} onToggleAutoStart={handleToggleAgentAutoStart} /> ) : null} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 374ff332b..4658ff690 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -89,12 +89,10 @@ export type ProfileSummaryViewProps = { diagnosticsFields: ProfileField[]; diagnosticsSummary: string | null; modelLabel: string; - onOpenAgentSettings: () => void; + onOpenAgentConfiguration: () => void; onOpenChannels: () => void; onOpenDiagnostics: () => void; - onOpenInstruction: () => void; onOpenMemories: () => void; - onOpenModel: () => void; onOpenDm?: (pubkeys: string[]) => void; persona?: AgentPersona; presenceStatus: "online" | "away" | "offline" | undefined; @@ -138,12 +136,10 @@ export function ProfileSummaryView({ diagnosticsFields, diagnosticsSummary, modelLabel, - onOpenAgentSettings, + onOpenAgentConfiguration, onOpenChannels, onOpenDiagnostics, - onOpenInstruction, onOpenMemories, - onOpenModel, onOpenDm, persona, presenceStatus, @@ -166,8 +162,15 @@ export function ProfileSummaryView({ const showAgentSettingsIngress = isOwner === true && (agentSettingsFields.length > 0 || managedAgent?.backend.type === "local"); + const showAgentConfigurationIngress = + showInstructionIngress || showModelIngress || showAgentSettingsIngress; const showDiagnosticsIngress = diagnosticsFields.length > 0 || canOpenAgentLogs || canViewActivity; + const agentConfigurationTrailing = showModelIngress + ? modelLabel + : handleEditPersona + ? "Edit" + : "View"; const topLevelAgentInfoFields = agentInfoFields.filter( (field) => field.label === "Public key" || field.label === "Owned by", ); @@ -232,30 +235,19 @@ export function ProfileSummaryView({
) : null} - {showInstructionIngress || - showModelIngress || + {showAgentConfigurationIngress || showMemoriesIngress || showChannelsIngress || - showAgentSettingsIngress || showDiagnosticsIngress || showTopLevelAgentInfo ? (
- {showInstructionIngress ? ( + {showAgentConfigurationIngress ? ( - ) : null} - {showModelIngress ? ( - ) : null} {showMemoriesIngress ? ( @@ -288,15 +280,6 @@ export function ProfileSummaryView({ } /> ) : null} - {showAgentSettingsIngress ? ( - - ) : null} {showDiagnosticsIngress ? ( void; @@ -832,7 +817,7 @@ export function ModelFocusedView({ const canPickModel = managedAgent?.backend.type === "local"; return ( -
+
@@ -858,11 +843,13 @@ export function ModelFocusedView({ } export function AgentSettingsFocusedView({ + className, fields, isActionPending, managedAgent, onToggleAutoStart, }: { + className?: string; fields: ProfileField[]; isActionPending: boolean; managedAgent: ManagedAgent | undefined; @@ -876,7 +863,7 @@ export function AgentSettingsFocusedView({ } return ( -
+
{canToggleAutoStart && managedAgent ? ( void; + onModelChanged: () => void; + onToggleAutoStart: () => void; +}) { + const trimmedInstruction = instruction?.trim() ?? ""; + const showInstructions = + trimmedInstruction.length > 0 || onEditInstruction !== undefined; + const showModel = managedAgent !== undefined || modelLabel.trim().length > 0; + const showSettings = + fields.length > 0 || managedAgent?.backend.type === "local"; + + if (!showInstructions && !showModel && !showSettings) { + return null; + } + + return ( +
+ {showInstructions ? ( + + + + ) : null} + + {showModel ? ( + + + + ) : null} + + {showSettings ? ( + + + + ) : null} +
+ ); +} + +function AgentConfigurationSection({ + children, + description, + title, +}: { + children: React.ReactNode; + description: string; + title: string; +}) { + return ( +
+
+

{title}

+

{description}

+
+ {children} +
+ ); +} + export function DiagnosticsFocusedView({ canOpenAgentLogs, canViewActivity, @@ -945,16 +1029,18 @@ export function DiagnosticsFocusedView({ } export function AgentInstructionFocusedView({ + className, instruction, onEdit, }: { + className?: string; instruction: string | null; onEdit?: () => void; }) { const trimmedInstruction = instruction?.trim() ?? ""; return ( -
+
{trimmedInstruction ? (
diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs index d7839790d..a37b41399 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.test.mjs @@ -157,10 +157,8 @@ test("parseProfilePanelView accepts all profile panel subviews", () => { for (const view of [ "summary", "info", - "settings", + "configuration", "diagnostics", - "model", - "instructions", "memories", "channels", "logs", @@ -169,6 +167,12 @@ test("parseProfilePanelView accepts all profile panel subviews", () => { } }); +test("parseProfilePanelView maps legacy agent config subviews to configuration", () => { + for (const view of ["instructions", "model", "settings"]) { + assert.equal(parseProfilePanelView(view), "configuration"); + } +}); + test("profilePanelViewFromSearch falls back to summary for invalid values", () => { assert.equal(parseProfilePanelView("missing"), null); assert.equal(profilePanelViewFromSearch("missing"), "summary"); diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 026c2dbc4..34c8482ff 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -18,10 +18,8 @@ export type ProfileChannelLink = { export type ProfilePanelView = | "summary" | "info" - | "settings" + | "configuration" | "diagnostics" - | "model" - | "instructions" | "memories" | "channels" | "logs"; @@ -29,10 +27,8 @@ export type ProfilePanelView = export const PROFILE_PANEL_VIEW_TITLES: Record = { summary: "Profile", info: "Agent info", - settings: "Agent settings", + configuration: "Agent configuration", diagnostics: "Diagnostics", - model: "Model", - instructions: "Agent instruction", memories: "Memories", channels: "Channels", logs: "Harness log", @@ -42,11 +38,22 @@ const PROFILE_PANEL_VIEWS = new Set( Object.keys(PROFILE_PANEL_VIEW_TITLES) as ProfilePanelView[], ); +const LEGACY_PROFILE_PANEL_VIEW_ALIASES: Record = { + instructions: "configuration", + model: "configuration", + settings: "configuration", +}; + export function parseProfilePanelView(value: unknown): ProfilePanelView | null { - return typeof value === "string" && - PROFILE_PANEL_VIEWS.has(value as ProfilePanelView) - ? (value as ProfilePanelView) - : null; + if (typeof value !== "string") { + return null; + } + + if (PROFILE_PANEL_VIEWS.has(value as ProfilePanelView)) { + return value as ProfilePanelView; + } + + return LEGACY_PROFILE_PANEL_VIEW_ALIASES[value] ?? null; } export function profilePanelViewFromSearch(value: unknown): ProfilePanelView { From 11b0b728b70f95703d5fb000d179123ddc065853 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 23 Jun 2026 23:59:51 -0700 Subject: [PATCH 27/30] feat(profile): refine agent diagnostics log layout - Move the agent activity log ingress to the top-level profile sidebar actions so Diagnostics focuses on operational status and logs. - Render Diagnostics status with the existing agent status badge instead of plain summary text. - Embed the Harness log directly in Diagnostics and use flex sizing so the terminal fills the available height while the log body scrolls internally. - Collapse Harness log chrome into the terminal frame with a compact dark copy action and footer-mounted log filename. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/features/agents/ui/CopyButton.tsx | 17 ++- .../agents/ui/ManagedAgentLogPanel.tsx | 111 ++++++++++++++---- .../features/profile/ui/UserProfilePanel.tsx | 35 ++++-- .../profile/ui/UserProfilePanelSections.tsx | 77 ++++++------ 4 files changed, 164 insertions(+), 76 deletions(-) diff --git a/desktop/src/features/agents/ui/CopyButton.tsx b/desktop/src/features/agents/ui/CopyButton.tsx index a8d0b621f..4833fcae3 100644 --- a/desktop/src/features/agents/ui/CopyButton.tsx +++ b/desktop/src/features/agents/ui/CopyButton.tsx @@ -1,24 +1,31 @@ import { Copy } from "lucide-react"; import { toast } from "sonner"; -import { Button } from "@/shared/ui/button"; +import { Button, type ButtonProps } from "@/shared/ui/button"; export function CopyButton({ - value, + className, label, + size = "sm", + value, + variant = "outline", }: { - value: string; + className?: string; label?: string; + size?: ButtonProps["size"]; + value: string; + variant?: ButtonProps["variant"]; }) { return (
); + const isDiagnosticsLikeView = view === "diagnostics" || view === "logs"; const profileBody = (
setView("configuration")} onOpenChannels={() => setView("channels")} onOpenDiagnostics={() => setView("diagnostics")} @@ -833,12 +838,15 @@ export function UserProfilePanel({ {view === "diagnostics" ? ( setView("logs")} - pubkey={effectivePubkey} /> ) : null} @@ -854,16 +862,17 @@ export function UserProfilePanel({ ) : null} {view === "logs" ? ( - ) : null}
diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 4658ff690..b78a18523 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -8,7 +8,6 @@ import { ChevronRight, ChevronUp, Cpu, - FileText, Hash, MessageSquare, Pencil, @@ -25,6 +24,7 @@ import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; import { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { ManagedAgentLogPanel } from "@/features/agents/ui/ManagedAgentLogPanel"; import { ModelPicker } from "@/features/agents/ui/ModelPicker"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { getPresenceLabel } from "@/features/presence/lib/presence"; @@ -89,6 +89,7 @@ export type ProfileSummaryViewProps = { diagnosticsFields: ProfileField[]; diagnosticsSummary: string | null; modelLabel: string; + onOpenActivity: () => void; onOpenAgentConfiguration: () => void; onOpenChannels: () => void; onOpenDiagnostics: () => void; @@ -136,6 +137,7 @@ export function ProfileSummaryView({ diagnosticsFields, diagnosticsSummary, modelLabel, + onOpenActivity, onOpenAgentConfiguration, onOpenChannels, onOpenDiagnostics, @@ -165,12 +167,18 @@ export function ProfileSummaryView({ const showAgentConfigurationIngress = showInstructionIngress || showModelIngress || showAgentSettingsIngress; const showDiagnosticsIngress = - diagnosticsFields.length > 0 || canOpenAgentLogs || canViewActivity; + diagnosticsFields.length > 0 || canOpenAgentLogs; + const showActivityIngress = canViewActivity; const agentConfigurationTrailing = showModelIngress ? modelLabel : handleEditPersona ? "Edit" : "View"; + const diagnosticsStatusField = diagnosticsFields.find( + (field) => field.label === "Status", + ); + const diagnosticsTrailing = + diagnosticsStatusField?.displayNode ?? diagnosticsSummary ?? "View"; const topLevelAgentInfoFields = agentInfoFields.filter( (field) => field.label === "Public key" || field.label === "Owned by", ); @@ -239,6 +247,7 @@ export function ProfileSummaryView({ showMemoriesIngress || showChannelsIngress || showDiagnosticsIngress || + showActivityIngress || showTopLevelAgentInfo ? (
{showAgentConfigurationIngress ? ( @@ -286,7 +295,16 @@ export function ProfileSummaryView({ label="Diagnostics" onClick={onOpenDiagnostics} testId="user-profile-diagnostics-ingress" - trailing={diagnosticsSummary ?? "View"} + trailing={diagnosticsTrailing} + /> + ) : null} + {showActivityIngress ? ( + ) : null} {showTopLevelAgentInfo ? ( @@ -667,8 +685,10 @@ function ProfileIngressRow({ label: string; onClick: () => void; testId: string; - trailing?: string; + trailing?: React.ReactNode; }) { + const trailingTitle = typeof trailing === "string" ? trailing : undefined; + return ( + ); +} + function ProfileWorkingBadge({ channelId, name, @@ -834,183 +862,6 @@ export function AgentInfoFocusedView({ ); } -export function ModelFocusedView({ - className, - managedAgent, - modelLabel, - onModelChanged, -}: { - className?: string; - managedAgent: ManagedAgent | undefined; - modelLabel: string; - onModelChanged: () => void; -}) { - const canPickModel = managedAgent?.backend.type === "local"; - - return ( -
-
- - - - - - Model - - - {modelLabel} - - - {canPickModel && managedAgent ? ( - - ) : managedAgent ? ( - - managed remotely - - ) : null} -
-
- ); -} - -export function AgentSettingsFocusedView({ - className, - fields, - isActionPending, - managedAgent, - onToggleAutoStart, -}: { - className?: string; - fields: ProfileField[]; - isActionPending: boolean; - managedAgent: ManagedAgent | undefined; - onToggleAutoStart: () => void; -}) { - const canToggleAutoStart = - managedAgent !== undefined && managedAgent.backend.type === "local"; - - if (fields.length === 0 && !canToggleAutoStart) { - return null; - } - - return ( -
- {canToggleAutoStart && managedAgent ? ( - - ) : null} - {fields.length > 0 ? : null} -
- ); -} - -export function AgentConfigurationFocusedView({ - fields, - instruction, - isActionPending, - managedAgent, - modelLabel, - onEditInstruction, - onModelChanged, - onToggleAutoStart, -}: { - fields: ProfileField[]; - instruction: string | null; - isActionPending: boolean; - managedAgent: ManagedAgent | undefined; - modelLabel: string; - onEditInstruction?: () => void; - onModelChanged: () => void; - onToggleAutoStart: () => void; -}) { - const trimmedInstruction = instruction?.trim() ?? ""; - const showInstructions = - trimmedInstruction.length > 0 || onEditInstruction !== undefined; - const showModel = managedAgent !== undefined || modelLabel.trim().length > 0; - const showSettings = - fields.length > 0 || managedAgent?.backend.type === "local"; - - if (!showInstructions && !showModel && !showSettings) { - return null; - } - - return ( -
- {showInstructions ? ( - - - - ) : null} - - {showModel ? ( - - - - ) : null} - - {showSettings ? ( - - - - ) : null} -
- ); -} - -function AgentConfigurationSection({ - children, - description, - title, -}: { - children: React.ReactNode; - description: string; - title: string; -}) { - return ( -
-
-

{title}

-

{description}

-
- {children} -
- ); -} - export function DiagnosticsFocusedView({ canOpenAgentLogs, fields, @@ -1068,50 +919,3 @@ export function DiagnosticsFocusedView({
); } - -export function AgentInstructionFocusedView({ - className, - instruction, - onEdit, -}: { - className?: string; - instruction: string | null; - onEdit?: () => void; -}) { - const trimmedInstruction = instruction?.trim() ?? ""; - - return ( -
-
- {trimmedInstruction ? ( -
- -
- ) : ( -

- No instruction set. -

- )} -
- {onEdit ? ( - - ) : null} -
- ); -} diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 34c8482ff..b11a5d5c3 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -27,7 +27,7 @@ export type ProfilePanelView = export const PROFILE_PANEL_VIEW_TITLES: Record = { summary: "Profile", info: "Agent info", - configuration: "Agent configuration", + configuration: "Advanced", diagnostics: "Diagnostics", memories: "Memories", channels: "Channels", From f2e0100aad646455360fceb059eec48529a231e8 Mon Sep 17 00:00:00 2001 From: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f Date: Wed, 24 Jun 2026 01:06:24 -0700 Subject: [PATCH 30/30] feat(profile): merge owner and respond-to into one field When a managed agent responds only to its owner, collapse the separate Owned by and Respond to rows into a single Owned by & responds to field. Surface the agent info summary fields regardless of viewer ownership. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../profile/ui/UserProfilePanelFields.tsx | 60 +++++++++++-------- .../profile/ui/UserProfilePanelSections.tsx | 8 ++- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index 3487dfd6d..d173566b1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -50,6 +50,7 @@ export type ProfileField = { const AGENT_INFO_LABELS = new Set([ "Public key", "Owned by", + "Owned by & responds to", "NIP-05", "Agent type", "Capabilities", @@ -251,6 +252,15 @@ export function buildOwnerFields({ relayAgent: RelayAgent | undefined; }): ProfileField[] { const fields: ProfileField[] = []; + const combinesOwnerRespondTo = + managedAgent?.respondTo === "owner-only" && Boolean(ownerDisplayName); + const respondToOwner = + managedAgent?.respondTo === "owner-only" && ownerDisplayName; + const respondToDisplayValue = managedAgent + ? respondToOwner + ? ownerDisplayName + : managedAgent.respondTo.replace(/-/g, " ") + : null; if (ownerDisplayName) { fields.push({ @@ -279,12 +289,20 @@ export function buildOwnerFields({ ) : undefined, icon: UserRound, - label: "Owned by", + label: combinesOwnerRespondTo ? "Owned by & responds to" : "Owned by", testId: "user-profile-owned-by", }); } if (!includeOperationalFields) { + if (managedAgent && !respondToOwner && respondToDisplayValue) { + fields.push({ + displayValue: respondToDisplayValue, + icon: Ear, + label: "Respond to", + testId: "user-profile-respond-to", + }); + } return fields; } @@ -382,36 +400,20 @@ export function buildOwnerFields({ } if (managedAgent) { - const respondToOwner = - managedAgent.respondTo === "owner-only" && ownerDisplayName; - const respondToDisplayValue = respondToOwner - ? ownerDisplayName - : managedAgent.respondTo.replace(/-/g, " "); - fields.push({ displayValue: managedAgent.startOnAppLaunch ? "Yes" : "No", icon: Server, label: "Start on launch", testId: "user-profile-start-on-launch", }); - fields.push({ - displayNode: respondToOwner ? ( - - - {respondToDisplayValue} - - ) : undefined, - displayValue: respondToDisplayValue, - icon: Ear, - label: "Respond to", - testId: "user-profile-respond-to", - }); + if (!respondToOwner && respondToDisplayValue) { + fields.push({ + displayValue: respondToDisplayValue, + icon: Ear, + label: "Respond to", + testId: "user-profile-respond-to", + }); + } } if (managedAgent?.lastError) { @@ -430,14 +432,19 @@ export function buildOwnerFields({ function orderProfileFields(fields: ProfileField[]) { const publicKeyLabel = "Public key"; const ownedByLabel = "Owned by"; + const ownedByRespondsToLabel = "Owned by & responds to"; const statusLabel = "Status"; return [ ...fields.filter((field) => field.label === publicKeyLabel), - ...fields.filter((field) => field.label === ownedByLabel), + ...fields.filter( + (field) => + field.label === ownedByLabel || field.label === ownedByRespondsToLabel, + ), ...fields.filter( (field) => field.label !== publicKeyLabel && field.label !== ownedByLabel && + field.label !== ownedByRespondsToLabel && field.copyValue, ), ...fields.filter((field) => field.label === statusLabel), @@ -445,6 +452,7 @@ function orderProfileFields(fields: ProfileField[]) { if ( field.label === publicKeyLabel || field.label === ownedByLabel || + field.label === ownedByRespondsToLabel || field.label === statusLabel ) { return false; diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 1479baf38..3ac139633 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -176,8 +176,7 @@ export function ProfileSummaryView({ (showInstructionIngress || showModelIngress || runtimeConfigurationFields.length > 0); - const showAgentSettingsRows = - isOwner === true && summaryAgentDetailFields.length > 0; + const showAgentSettingsRows = summaryAgentDetailFields.length > 0; const showAgentConfigurationRows = showAgentSettingsRows; const showDiagnosticsIngress = diagnosticsFields.length > 0 || canOpenAgentLogs; @@ -197,7 +196,10 @@ export function ProfileSummaryView({ (diagnosticsStatusField?.displayNode ?? diagnosticsSummary ?? "View") ); const topLevelAgentInfoFields = agentInfoFields.filter( - (field) => field.label === "Public key" || field.label === "Owned by", + (field) => + field.label === "Public key" || + field.label === "Owned by" || + field.label === "Owned by & responds to", ); const showTopLevelAgentInfo = topLevelAgentInfoFields.length > 0; const personaActionKey = persona?.id;