From b29c8cdaa456307ecdd63e565de4beb14402128e Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 4 Aug 2026 01:16:09 +0100 Subject: [PATCH] feat(desktop): redesign the Huddle experience (#4281) ## Summary - open Huddles in a focused companion window with a clean handoff back to the in-app drawer and backing channel - redesign the participant film strip, sidebar control, transcript surface, and themed shell treatment - preserve microphone and device control across windows, start agent voice on the first reply, and show agent speaking activity in the film strip - give each agent a distinct session voice, beginning with the configured default, plus compact per-agent text-to-speech and voice controls - enroll only agents explicitly mentioned or deliberately added through an agent panel into the live Huddle roster - keep temporary Huddle channels out of the sidebar unless the user explicitly brings one into the main app - remove Huddle-only avatar policy badges and filter short silence or noise segments before speech-to-text posts ## Why The previous flow exposed the temporary channel as product UI, obscured who was present or speaking, and split transcript and audio state between the main and companion windows. This keeps backing channels as implementation details unless a user explicitly brings a Huddle into the app, while sharing the live conversation and audio lifecycle across both surfaces. Agent participants now join only after an explicit invitation, distinct voices make multi-agent Huddles easier to follow, and short microphone noise no longer becomes stray transcript messages. ## Validation - `pnpm check` - `pnpm build:e2e` - `pnpm exec playwright test tests/e2e/huddle-transcription.spec.ts --project=smoke` (13 passed) - Huddle sidebar visibility unit coverage (4 passed) - focused managed-agent and persona-mention E2E coverage (2 passed) - `pnpm test` (3,910 passed) - `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` - `cargo test --manifest-path desktop/src-tauri/Cargo.toml` (2,093 passed, 14 ignored; 3 diagnostics passed) --------- Signed-off-by: kenny lopez Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- desktop/src-tauri/capabilities/default.json | 4 +- desktop/src-tauri/src/huddle/agent_voice.rs | 310 +++++ desktop/src-tauri/src/huddle/agents.rs | 177 ++- desktop/src-tauri/src/huddle/mod.rs | 77 +- desktop/src-tauri/src/huddle/pipeline.rs | 88 +- desktop/src-tauri/src/huddle/playout.rs | 121 +- desktop/src-tauri/src/huddle/state.rs | 16 +- desktop/src-tauri/src/huddle/stt.rs | 121 +- desktop/src-tauri/src/huddle/tts.rs | 123 +- desktop/src-tauri/src/huddle/tts_activity.rs | 45 + desktop/src-tauri/src/huddle/tts_settings.rs | 3 +- desktop/src-tauri/src/huddle/tts_tests.rs | 31 +- .../src/huddle/tts_voice_selection_tests.rs | 20 +- .../src/huddle/tts_voice_transition.rs | 58 +- desktop/src-tauri/src/huddle/window.rs | 67 + desktop/src-tauri/src/initial_window.rs | 67 + desktop/src-tauri/src/lib.rs | 111 +- desktop/src/app/App.tsx | 14 +- desktop/src/app/AppHuddleBar.tsx | 6 +- desktop/src/app/AppHuddleShell.tsx | 76 ++ desktop/src/app/AppShell.tsx | 590 ++++----- desktop/src/app/AppShellChannelSurface.tsx | 43 + desktop/src/app/BuzzThemeSurfaces.tsx | 17 +- desktop/src/app/LazySettingsScreen.tsx | 6 + .../app/huddleBackingChannelStorage.test.mjs | 33 + .../src/app/huddleBackingChannelStorage.ts | 36 + .../src/app/huddleChannelVisibility.test.mjs | 65 + desktop/src/app/huddleChannelVisibility.ts | 19 + .../src/app/navigation/useAppNavigation.ts | 3 + desktop/src/app/routes/ChannelRouteScreen.tsx | 6 + .../src/app/routes/channels.$channelId.tsx | 11 +- .../app/useAppShellDesktopNotifications.ts | 8 +- .../src/app/useAppShellLifecycleEffects.ts | 7 + desktop/src/app/useHuddlePresentation.ts | 444 +++++++ desktop/src/app/useSettingsShortcuts.ts | 4 +- desktop/src/features/agents/hooks.ts | 7 + desktop/src/features/channels/hooks.ts | 16 + .../channels/ui/ChannelMembersBar.tsx | 4 +- .../channels/ui/ChannelPane.helpers.ts | 14 + .../src/features/channels/ui/ChannelPane.tsx | 80 +- .../features/channels/ui/ChannelPane.types.ts | 2 + .../features/channels/ui/ChannelScreen.tsx | 82 +- .../ui/ChannelScreenLoadingFallback.tsx | 14 + .../channels/ui/useChannelPaneMessages.ts | 50 + .../channels/ui/useHuddleChannelMessages.ts | 72 ++ .../channels/ui/useHuddleReadMarker.ts | 89 ++ .../channels/ui/useHuddleThreadIsolation.ts | 24 + desktop/src/features/huddle/HuddleContext.tsx | 585 ++++++--- .../features/huddle/HuddleContext.types.ts | 40 + .../huddle/components/AgentVoiceMenu.tsx | 152 +++ .../huddle/components/HuddleAttachment.tsx | 26 +- .../features/huddle/components/HuddleBar.tsx | 346 +++--- .../components/HuddleProfileControl.tsx | 176 +++ .../huddle/components/HuddleRoomHeader.tsx | 126 ++ .../huddle/components/HuddleStartingView.tsx | 18 + .../components/HuddleTranscriptIntro.tsx | 22 + .../huddle/components/MicControls.tsx | 35 +- .../huddle/components/ParticipantList.tsx | 494 ++++++-- desktop/src/features/huddle/index.ts | 4 + .../src/features/huddle/lib/huddleWindow.ts | 23 + .../features/huddle/lib/ttsLiveMessages.ts | 16 +- .../features/huddle/lib/useAudioDevices.ts | 18 +- .../features/huddle/lib/useHuddlePttState.ts | 73 ++ .../huddle/lib/useHuddleSpeakerActivity.ts | 99 ++ .../features/huddle/lib/useTtsSubscription.ts | 138 ++- .../messages/lib/virtualizedTimelineItems.ts | 3 +- .../src/features/messages/ui/MessageRow.tsx | 11 +- .../messages/ui/MessageThreadPanel.tsx | 137 ++- .../features/messages/ui/MessageTimeline.tsx | 43 +- .../features/messages/ui/MessageTimestamp.tsx | 1 + .../messages/ui/TimelineMessageList.tsx | 46 +- .../messages/ui/useMentionSendFlow.ts | 18 + .../src/features/messages/useThreadReplies.ts | 94 +- desktop/src/features/notifications/hooks.ts | 2 + .../use-feed-desktop-notifications.ts | 4 +- .../onboarding/communityOnboarding.tsx | 15 +- .../src/features/sidebar/ui/AppSidebar.tsx | 24 +- .../features/sidebar/ui/AppSidebar.types.ts | 7 + .../src/features/sidebar/ui/CommunityRail.tsx | 2 +- desktop/src/main.tsx | 3 +- .../shared/api/relayChannelFilters.test.mjs | 7 +- desktop/src/shared/api/relayChannelFilters.ts | 13 +- .../src/shared/styles/globals/components.css | 65 +- desktop/src/shared/styles/globals/theme.css | 46 +- desktop/src/shared/useMessageDeepLinks.ts | 6 +- desktop/src/testing/e2eBridge.ts | 351 +++++- desktop/tests/e2e/community-rail.spec.ts | 9 + .../tests/e2e/huddle-transcription.spec.ts | 1081 +++++++++++++++-- desktop/tests/helpers/bridge.ts | 9 + 89 files changed, 6301 insertions(+), 1368 deletions(-) create mode 100644 desktop/src-tauri/src/huddle/agent_voice.rs create mode 100644 desktop/src-tauri/src/huddle/tts_activity.rs create mode 100644 desktop/src-tauri/src/huddle/window.rs create mode 100644 desktop/src-tauri/src/initial_window.rs create mode 100644 desktop/src/app/AppHuddleShell.tsx create mode 100644 desktop/src/app/AppShellChannelSurface.tsx create mode 100644 desktop/src/app/LazySettingsScreen.tsx create mode 100644 desktop/src/app/huddleBackingChannelStorage.test.mjs create mode 100644 desktop/src/app/huddleBackingChannelStorage.ts create mode 100644 desktop/src/app/huddleChannelVisibility.test.mjs create mode 100644 desktop/src/app/huddleChannelVisibility.ts create mode 100644 desktop/src/app/useHuddlePresentation.ts create mode 100644 desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx create mode 100644 desktop/src/features/channels/ui/useChannelPaneMessages.ts create mode 100644 desktop/src/features/channels/ui/useHuddleChannelMessages.ts create mode 100644 desktop/src/features/channels/ui/useHuddleReadMarker.ts create mode 100644 desktop/src/features/channels/ui/useHuddleThreadIsolation.ts create mode 100644 desktop/src/features/huddle/HuddleContext.types.ts create mode 100644 desktop/src/features/huddle/components/AgentVoiceMenu.tsx create mode 100644 desktop/src/features/huddle/components/HuddleProfileControl.tsx create mode 100644 desktop/src/features/huddle/components/HuddleRoomHeader.tsx create mode 100644 desktop/src/features/huddle/components/HuddleStartingView.tsx create mode 100644 desktop/src/features/huddle/components/HuddleTranscriptIntro.tsx create mode 100644 desktop/src/features/huddle/lib/huddleWindow.ts create mode 100644 desktop/src/features/huddle/lib/useHuddlePttState.ts create mode 100644 desktop/src/features/huddle/lib/useHuddleSpeakerActivity.ts create mode 100644 desktop/src/features/sidebar/ui/AppSidebar.types.ts diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 8835b29de..a2e09bcb3 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], + "description": "Capability for the main window and trusted huddle companions", + "windows": ["main", "huddle-*"], "permissions": [ "core:default", "core:webview:allow-set-webview-zoom", diff --git a/desktop/src-tauri/src/huddle/agent_voice.rs b/desktop/src-tauri/src/huddle/agent_voice.rs new file mode 100644 index 000000000..5232287d7 --- /dev/null +++ b/desktop/src-tauri/src/huddle/agent_voice.rs @@ -0,0 +1,310 @@ +//! Per-agent text-to-speech choices for one local huddle session. + +use std::collections::{BTreeMap, HashSet}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +use crate::app_state::AppState; + +use super::{ + tts_settings::{ + pocket_voice_reference, resolve_voice_for_backend_in_registry, voice_registry, + VoiceRegistryEntry, POCKET_BACKEND_ID, + }, + HuddlePhase, HuddleState, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentVoiceSettings { + pub enabled: bool, + pub voice_key: String, +} + +struct AgentVoiceCatalog { + default_voice_key: String, + voices: Vec, +} + +fn catalog(app: &AppHandle, state: &AppState) -> Result { + let registry = voice_registry(app); + let settings = state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? + .clone(); + let voices: Vec<_> = registry + .iter() + .filter(|voice| { + voice.backend == POCKET_BACKEND_ID + && matches!(voice.availability.as_str(), "bundled" | "installed") + }) + .cloned() + .collect(); + let default_voice_key = resolve_voice_for_backend_in_registry( + &settings.voice_preferences, + POCKET_BACKEND_ID, + &voices, + )? + .key; + Ok(AgentVoiceCatalog { + default_voice_key, + voices, + }) +} + +fn stable_voice_index(agent_pubkey: &str, huddle_generation: u64, len: usize) -> usize { + let hash = agent_pubkey.bytes().fold( + 0xcbf2_9ce4_8422_2325_u64 ^ huddle_generation, + |hash, byte| hash.wrapping_mul(0x0000_0100_0000_01b3) ^ u64::from(byte), + ); + (hash as usize) % len +} + +pub(crate) fn sync_agent_voice_assignments( + huddle: &mut HuddleState, + agent_pubkeys: &[String], + default_voice_key: &str, + voices: &[VoiceRegistryEntry], +) -> bool { + let previous = huddle.agent_voice_settings.clone(); + let available_keys: Vec<_> = voices.iter().map(|voice| voice.key.clone()).collect(); + let available: HashSet<_> = available_keys.iter().cloned().collect(); + let agents: HashSet<_> = agent_pubkeys.iter().cloned().collect(); + huddle.agent_voice_settings.retain(|pubkey, settings| { + agents.contains(pubkey) && available.contains(&settings.voice_key) + }); + + let mut used: HashSet<_> = huddle + .agent_voice_settings + .values() + .map(|settings| settings.voice_key.clone()) + .collect(); + for (index, pubkey) in agent_pubkeys.iter().enumerate() { + if huddle.agent_voice_settings.contains_key(pubkey) { + continue; + } + let preferred = if index == 0 && !used.contains(default_voice_key) { + Some(default_voice_key.to_owned()) + } else { + let unused_alternates: Vec<_> = available_keys + .iter() + .filter(|key| key.as_str() != default_voice_key && !used.contains(*key)) + .cloned() + .collect(); + let unused: Vec<_> = available_keys + .iter() + .filter(|key| !used.contains(*key)) + .cloned() + .collect(); + let candidates = if unused_alternates.is_empty() { + if unused.is_empty() { + &available_keys + } else { + &unused + } + } else { + &unused_alternates + }; + (!candidates.is_empty()).then(|| { + candidates[stable_voice_index(pubkey, huddle.huddle_generation, candidates.len())] + .clone() + }) + }; + if let Some(voice_key) = preferred { + used.insert(voice_key.clone()); + huddle.agent_voice_settings.insert( + pubkey.clone(), + AgentVoiceSettings { + enabled: true, + voice_key, + }, + ); + } + } + huddle.agent_voice_settings != previous +} + +fn ensure_with_catalog( + huddle: &mut HuddleState, + catalog: &AgentVoiceCatalog, + extra_agent: Option<&str>, +) -> bool { + let mut agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + if let Some(pubkey) = extra_agent { + if !agents.iter().any(|agent| agent == pubkey) { + agents.push(pubkey.to_owned()); + } + } + sync_agent_voice_assignments(huddle, &agents, &catalog.default_voice_key, &catalog.voices) +} + +fn require_active_huddle(huddle: &HuddleState) -> Result<(), String> { + matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) + .then_some(()) + .ok_or_else(|| "No active huddle".to_owned()) +} + +#[tauri::command] +pub fn ensure_huddle_agent_voice_settings( + app: AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + let catalog = catalog(&app, &state)?; + let (changed, settings) = { + let mut huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Ok(BTreeMap::new()); + } + let changed = ensure_with_catalog(&mut huddle, &catalog, None); + (changed, huddle.agent_voice_settings.clone()) + }; + if changed { + state.emit_huddle_state_changed(); + } + Ok(settings) +} + +#[tauri::command] +pub fn set_huddle_agent_tts_enabled( + agent_pubkey: String, + enabled: bool, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let catalog = catalog(&app, &state)?; + let settings = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + ensure_with_catalog(&mut huddle, &catalog, Some(&agent_pubkey)); + let settings = huddle + .agent_voice_settings + .get_mut(&agent_pubkey) + .ok_or("Agent is not in the active huddle")?; + settings.enabled = enabled; + settings.clone() + }; + state.emit_huddle_state_changed(); + Ok(settings) +} + +#[tauri::command] +pub fn set_huddle_agent_voice( + agent_pubkey: String, + voice_key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let catalog = catalog(&app, &state)?; + if !catalog.voices.iter().any(|voice| voice.key == voice_key) { + return Err("The selected Pocket voice is not available on this device".to_owned()); + } + let settings = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + ensure_with_catalog(&mut huddle, &catalog, Some(&agent_pubkey)); + let settings = huddle + .agent_voice_settings + .get_mut(&agent_pubkey) + .ok_or("Agent is not in the active huddle")?; + settings.voice_key = voice_key; + settings.clone() + }; + state.emit_huddle_state_changed(); + Ok(settings) +} + +pub(crate) fn voice_reference_for_agent( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, +) -> Result, String> { + let catalog = catalog(app, state)?; + let (changed, settings) = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + let changed = ensure_with_catalog(&mut huddle, &catalog, Some(agent_pubkey)); + let settings = huddle.agent_voice_settings.get(agent_pubkey).cloned(); + (changed, settings) + }; + if changed { + state.emit_huddle_state_changed(); + } + let Some(settings) = settings else { + return Err("Agent is not in the active huddle".to_owned()); + }; + if !settings.enabled { + return Ok(None); + } + pocket_voice_reference(app, &[settings.voice_key]).map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::huddle::tts_settings::bundled_voice_registry; + + #[test] + fn first_agent_uses_default_and_additional_agents_are_distinct() { + let agents = vec!["first".to_owned(), "second".to_owned(), "third".to_owned()]; + let mut huddle = HuddleState { + huddle_generation: 9, + ..HuddleState::default() + }; + + assert!(sync_agent_voice_assignments( + &mut huddle, + &agents, + "pocket:vera", + &bundled_voice_registry(), + )); + + assert_eq!( + huddle.agent_voice_settings["first"].voice_key, + "pocket:vera" + ); + let distinct: HashSet<_> = huddle + .agent_voice_settings + .values() + .map(|settings| settings.voice_key.as_str()) + .collect(); + assert_eq!(distinct.len(), 3); + } + + #[test] + fn explicit_session_choices_survive_roster_resync() { + let agents = vec!["first".to_owned(), "second".to_owned()]; + let voices = bundled_voice_registry(); + let mut huddle = HuddleState::default(); + sync_agent_voice_assignments(&mut huddle, &agents, "pocket:mary", &voices); + huddle + .agent_voice_settings + .get_mut("second") + .unwrap() + .enabled = false; + huddle + .agent_voice_settings + .get_mut("second") + .unwrap() + .voice_key = "pocket:jane".into(); + + assert!(!sync_agent_voice_assignments( + &mut huddle, + &agents, + "pocket:mary", + &voices, + )); + assert_eq!( + huddle.agent_voice_settings["second"], + AgentVoiceSettings { + enabled: false, + voice_key: "pocket:jane".into(), + } + ); + } +} diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index 2de22f99d..41a348d88 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -9,14 +9,24 @@ //! when it receives the kind:9000 membership notification. Huddle-specific //! env vars (interrupt mode, custom system prompt) are a post-MVP enhancement. +use std::collections::HashSet; + use serde::Serialize; +use tauri::State; use uuid::Uuid; use crate::{ - app_state::AppState, events, huddle::relay_api::fetch_channel_members_with_roles, + app_state::AppState, + events, + huddle::relay_api::{ + fetch_channel_members, fetch_channel_members_with_roles, validate_pubkey_hex, + MAX_HUDDLE_AGENTS, + }, relay::submit_event, }; +use super::{pipeline::start_auto_enabled_transcription, HuddlePhase}; + // ── Constants ───────────────────────────────────────────────────────────────── /// Voice-mode guidelines posted as kind:48106 (huddle guidelines) to the @@ -78,6 +88,21 @@ pub struct AgentAddResult { pub parent_error: Option, } +/// Result of reconciling channel agent additions into the active Huddle. +#[derive(Debug, Serialize)] +pub struct AgentHuddleSyncResult { + /// Whether `channel_id` belonged to the active Huddle. + pub matched_active_huddle: bool, + /// Agents newly enrolled in the Huddle's ephemeral channel. + pub added: Vec, +} + +// Multiple frontend mutation paths can observe the same membership addition +// (for example, the member hook and the mention send flow). Serialize native +// reconciliation so they share the first result instead of racing duplicate +// membership events through a relay read that has not caught up yet. +static AGENT_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// Add an agent to both the ephemeral and parent huddle channels. /// /// Returns `Err` only if the ephemeral-channel add fails (policy rejection or @@ -134,6 +159,156 @@ pub async fn add_agent_to_huddle( }) } +/// Reconcile explicitly added channel agents into the active Huddle. +/// +/// The source channel may be either the Huddle's parent or its ephemeral chat. +/// Existing ephemeral membership is hydrated first so a mention sent from the +/// Huddle chat does not publish a duplicate membership event. Missing agents +/// are added through the same parent + ephemeral path as the Add agent picker. +pub(crate) async fn sync_agents_for_active_huddle( + channel_id: &str, + agent_pubkeys: Vec, + state: &AppState, +) -> Result { + let mut seen = HashSet::new(); + let mut requested = Vec::new(); + for pubkey in agent_pubkeys { + let normalized = pubkey.to_ascii_lowercase(); + validate_pubkey_hex(&normalized)?; + if seen.insert(normalized.clone()) { + requested.push(normalized); + } + } + if requested.is_empty() { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let _sync_guard = AGENT_SYNC_LOCK.lock().await; + + let (ephemeral_channel_id, parent_channel_id, huddle_generation, state_agents) = { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let ephemeral_channel_id = huddle + .ephemeral_channel_id + .clone() + .ok_or("no ephemeral channel")?; + let parent_channel_id = huddle + .parent_channel_id + .clone() + .ok_or("no parent channel")?; + if channel_id != ephemeral_channel_id && channel_id != parent_channel_id { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let state_agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + ( + ephemeral_channel_id, + parent_channel_id, + huddle.huddle_generation, + state_agents, + ) + }; + + // Membership reads can lag a just-accepted write, so merge the relay view + // with local state instead of allowing a stale snapshot to remove agents. + let fresh_agents = fetch_channel_members(&ephemeral_channel_id, Some("bot"), state) + .await + .unwrap_or_default(); + let mut known_agents = HashSet::new(); + let mut merged_agents = Vec::new(); + for pubkey in state_agents.into_iter().chain(fresh_agents) { + let normalized = pubkey.to_ascii_lowercase(); + if known_agents.insert(normalized.clone()) { + merged_agents.push(normalized); + } + } + let missing: Vec = requested + .into_iter() + .filter(|pubkey| !known_agents.contains(pubkey)) + .collect(); + if known_agents.len() + missing.len() > MAX_HUDDLE_AGENTS { + return Err(format!( + "agent limit reached: {} requested with {} already present (max {})", + missing.len(), + known_agents.len(), + MAX_HUDDLE_AGENTS + )); + } + + let ephemeral_uuid = Uuid::parse_str(&ephemeral_channel_id).map_err(|e| e.to_string())?; + let parent_uuid = Uuid::parse_str(&parent_channel_id).map_err(|e| e.to_string())?; + let mut added = Vec::new(); + for pubkey in missing { + add_agent_to_huddle(ephemeral_uuid, parent_uuid, &pubkey, state).await?; + merged_agents.push(pubkey.clone()); + added.push(pubkey); + } + + let (roster_changed, transcription_auto_enabled) = { + let mut huddle = state.huddle()?; + if !huddle.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: true, + added, + }); + } + let mut roster_changed = false; + { + let mut current_agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()); + if *current_agents != merged_agents { + *current_agents = merged_agents.clone(); + roster_changed = true; + } + } + for pubkey in &merged_agents { + if !huddle.participants.contains(pubkey) { + huddle.participants.push(pubkey.clone()); + roster_changed = true; + } + } + ( + roster_changed, + huddle.maybe_auto_enable_transcription_for_agents(), + ) + }; + + if transcription_auto_enabled { + start_auto_enabled_transcription(state, &ephemeral_channel_id).await; + } else if roster_changed { + state.emit_huddle_state_changed(); + } + + Ok(AgentHuddleSyncResult { + matched_active_huddle: true, + added, + }) +} + +#[tauri::command] +pub async fn sync_agents_to_active_huddle( + channel_id: String, + agent_pubkeys: Vec, + state: State<'_, AppState>, +) -> Result { + sync_agents_for_active_huddle(&channel_id, agent_pubkeys, &state).await +} + fn contains_member(members: &[(String, Option)], pubkey: &str) -> bool { members .iter() diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 03264f80f..99337400c 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -24,6 +24,7 @@ //! and drops them outside the lock (thread joins can block ~200ms). mod agent_tts_routing; +pub mod agent_voice; pub mod agents; pub mod audio_output; pub mod jitter; @@ -41,6 +42,7 @@ pub mod tts; pub mod tts_settings; mod tts_voice_import; mod tts_voice_registry; +mod window; pub mod wire; // ── Shared utilities ────────────────────────────────────────────────────────── @@ -68,6 +70,7 @@ pub(super) fn drain_until_shutdown( pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode}; pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; pub use tts_settings::set_tts_enabled; +pub use window::{close_huddle_companion, open_huddle_window}; // ── Imports ─────────────────────────────────────────────────────────────────── @@ -90,6 +93,7 @@ use relay_api::{ count_human_members, fetch_channel_members, parse_channel_uuid, validate_pubkey_hex, MAX_HUDDLE_AGENTS, }; +use window::close_huddle_window; fn normalize_huddle_channel_name(candidate: Option, fallback: &str) -> String { let normalized = candidate @@ -175,6 +179,7 @@ pub async fn start_huddle( parent_channel_id: String, member_pubkeys: Vec, channel_name: Option, + app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { // Validate inputs at the Tauri boundary. @@ -198,6 +203,15 @@ pub async fn start_huddle( deduped }; + // Allocate the backing channel ID before the relay work starts. Publishing + // it with the Creating state lets the main webview open an immediate + // companion window while the channel and audio session are being prepared. + let ephemeral_uuid = Uuid::new_v4(); + let ephemeral_channel_id = ephemeral_uuid.to_string(); + let short_id = &ephemeral_channel_id[..8]; + let fallback_channel_name = format!("huddle-{short_id}"); + let channel_name = normalize_huddle_channel_name(channel_name, &fallback_channel_name); + // Transition to Creating. let huddle_generation = { let mut hs = state.huddle()?; @@ -210,20 +224,16 @@ pub async fn start_huddle( let generation = hs.begin_huddle_lifetime(); hs.phase = HuddlePhase::Creating; hs.parent_channel_id = Some(parent_channel_id.clone()); + hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); generation }; - - let ephemeral_uuid = Uuid::new_v4(); - let ephemeral_channel_id = ephemeral_uuid.to_string(); - let short_id = &ephemeral_channel_id[..8]; - let fallback_channel_name = format!("huddle-{short_id}"); - let channel_name = normalize_huddle_channel_name(channel_name, &fallback_channel_name); + state.emit_huddle_state_changed(); // All steps wrapped so we can roll back on ANY failure, including step 1. // channel_was_created tracks whether we need to archive on rollback. let mut channel_was_created = false; - let result: Result, String> = async { + let result: Result<(Vec, String), String> = async { // 1. Create ephemeral channel. let create_builder = events::build_create_channel( ephemeral_uuid, @@ -265,14 +275,14 @@ pub async fn start_huddle( // 4. Emit HUDDLE_STARTED to parent channel. let started_builder = events::build_huddle_started(&parent_channel_id, &ephemeral_channel_id)?; - submit_event(started_builder, &state).await?; + let started_event = submit_event(started_builder, &state).await?; - Ok(successful_agents) + Ok((successful_agents, started_event.event_id)) } .await; match result { - Ok(successful_agents) => { + Ok((successful_agents, huddle_thread_event_id)) => { // 5. Store active state. let committed = { let mut hs = state.huddle()?; @@ -282,6 +292,7 @@ pub async fn start_huddle( hs.phase = HuddlePhase::Connected; hs.is_creator = true; hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); + hs.huddle_thread_event_id = Some(huddle_thread_event_id); *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = successful_agents.clone(); hs.maybe_auto_enable_transcription_for_agents(); @@ -300,6 +311,7 @@ pub async fn start_huddle( }; if !committed { emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; + close_huddle_window(&app, &ephemeral_channel_id); return Err("huddle start was superseded".to_owned()); } @@ -311,6 +323,7 @@ pub async fn start_huddle( match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await { Ok(PostConnectOutcome::Ready) => {} Ok(PostConnectOutcome::Stale) => { + close_huddle_window(&app, &ephemeral_channel_id); return Err("huddle start was superseded".to_owned()); } Err(e) => { @@ -330,6 +343,7 @@ pub async fn start_huddle( } state.emit_huddle_state_changed(); } + close_huddle_window(&app, &ephemeral_channel_id); return Err(e); } } @@ -350,10 +364,19 @@ pub async fn start_huddle( } } // Reset only if this failed attempt still owns the Creating state. - if let Ok(mut hs) = state.huddle_state.lock() { + let reset = if let Ok(mut hs) = state.huddle_state.lock() { if hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) { hs.reset_preserving_generation(); + true + } else { + false } + } else { + false + }; + if reset { + state.emit_huddle_state_changed(); + close_huddle_window(&app, &ephemeral_channel_id); } Err(e) } @@ -372,6 +395,7 @@ pub async fn start_huddle( pub async fn join_huddle( parent_channel_id: String, ephemeral_channel_id: String, + huddle_thread_event_id: Option, state: State<'_, AppState>, ) -> Result { // Transition to Connecting. @@ -387,6 +411,7 @@ pub async fn join_huddle( hs.phase = HuddlePhase::Connecting; hs.parent_channel_id = Some(parent_channel_id.clone()); hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); + hs.huddle_thread_event_id = huddle_thread_event_id; generation }; @@ -557,7 +582,7 @@ async fn remove_huddle_agents(ephemeral_channel_id: &str, state: &AppState) { /// /// The relay emits kind:48102 (participant left) when the audio WS disconnects. #[tauri::command] -pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { +pub async fn leave_huddle(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> { let (parent_channel_id, ephemeral_channel_id) = { let mut hs = state.huddle()?; if hs.phase == HuddlePhase::Idle { @@ -606,6 +631,7 @@ pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { } teardown_huddle(&state)?; + close_huddle_window(&app, &ephemeral_channel_id); Ok(()) } @@ -618,7 +644,11 @@ pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { /// 3. Shut down the STT pipeline (Fix 5). /// 4. Clear local huddle state. #[tauri::command] -pub async fn end_huddle(force: Option, state: State<'_, AppState>) -> Result<(), String> { +pub async fn end_huddle( + force: Option, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { let (parent_channel_id, ephemeral_channel_id) = { let mut hs = state.huddle()?; if hs.phase == HuddlePhase::Idle { @@ -641,6 +671,7 @@ pub async fn end_huddle(force: Option, state: State<'_, AppState>) -> Resu emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; teardown_huddle(&state)?; + close_huddle_window(&app, &ephemeral_channel_id); Ok(()) } @@ -767,6 +798,8 @@ pub fn get_model_status(_state: State<'_, AppState>) -> Result, ) -> Result<(), String> { eprintln!("buzz-desktop: tts stage=invoke status=started route_id={route_id}"); @@ -774,6 +807,22 @@ pub async fn speak_agent_message( // Use char count (not byte length) to avoid panicking on multi-byte UTF-8. let text = normalize_agent_tts_text(text); + if !state.huddle()?.tts_enabled { + eprintln!( + "buzz-desktop: tts stage=invoke status=no_op reason=disabled route_id={route_id}" + ); + return Ok(()); + } + + let Some(voice_reference) = + agent_voice::voice_reference_for_agent(&app, &state, &speaker_pubkey)? + else { + eprintln!( + "buzz-desktop: tts stage=invoke status=no_op reason=agent_disabled route_id={route_id}" + ); + return Ok(()); + }; + let needs_pipeline = { let mut hs = state.huddle()?; if hs @@ -831,7 +880,7 @@ pub async fn speak_agent_message( }; enqueue_agent_tts_text(route_id, text, move |route_id, text| { sender - .send(route_id, text) + .send(route_id, speaker_pubkey, voice_reference, text) .map_err(|error| format!("TTS queue closed while waiting to enqueue: {error}")) }) .await diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index fba5464a6..9572ac25b 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -82,7 +82,7 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S .map(|m| m.take_tts_ready()) .unwrap_or(false); - // Start TTS first (so STT can capture tts_cancel). + // Start TTS first so STT can observe its active-playback gate. if !has_tts && (tts_ready || models::is_tts_ready()) { if let Err(e) = maybe_start_tts_pipeline(&state).await { eprintln!("buzz-desktop: TTS hotstart failed: {e}"); @@ -130,25 +130,45 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S .await .ok(); let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok(); - let transcription_auto_enabled = if fresh_agents.is_some() || fresh_members.is_some() { - let mut hs = state.huddle()?; - if !hs.is_current_huddle(eph_id, huddle_generation) { - return Ok(()); - } - if let Some(agents) = fresh_agents { - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; - } - if let Some(members) = fresh_members { - hs.participants = members; - } - hs.last_agent_refresh = Some(std::time::Instant::now()); - hs.maybe_auto_enable_transcription_for_agents() - } else { - false - }; + let (roster_changed, transcription_auto_enabled) = + if fresh_agents.is_some() || fresh_members.is_some() { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(eph_id, huddle_generation) { + return Ok(()); + } + let mut roster_changed = false; + if let Some(agents) = fresh_agents { + let mut current_agents = + hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()); + if *current_agents != agents { + *current_agents = agents; + roster_changed = true; + } + } + if let Some(members) = fresh_members { + if hs.participants != members { + hs.participants = members; + roster_changed = true; + } + } + hs.last_agent_refresh = Some(std::time::Instant::now()); + ( + roster_changed, + hs.maybe_auto_enable_transcription_for_agents(), + ) + } else { + (false, false) + }; if transcription_auto_enabled { start_auto_enabled_transcription(&state, eph_id).await; } + // Audio authentication auto-adds a joining human to the ephemeral + // channel. Emit whenever that authoritative roster changes so the + // desktop participant strip updates immediately instead of waiting + // for its slow fallback IPC read. + if roster_changed || transcription_auto_enabled { + state.emit_huddle_state_changed(); + } } } @@ -173,23 +193,32 @@ pub(crate) async fn post_connect_setup( fetch_channel_members(ephemeral_channel_id, Some("bot"), state), fetch_channel_members(ephemeral_channel_id, None, state), ); - let transcription_auto_enabled = { + let (roster_changed, transcription_auto_enabled) = { let mut hs = state.huddle()?; if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { return Ok(PostConnectOutcome::Stale); } + let mut roster_changed = false; if let Ok(agents) = agents_result { - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; - } - if let Ok(all_members) = all_members_result { - if !all_members.is_empty() { - hs.participants = all_members; + let mut current_agents = hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()); + if *current_agents != agents { + *current_agents = agents; + roster_changed = true; } } - hs.maybe_auto_enable_transcription_for_agents() + if let Ok(all_members) = all_members_result { + if !all_members.is_empty() && hs.participants != all_members { + hs.participants = all_members; + roster_changed = true; + } + } + ( + roster_changed, + hs.maybe_auto_enable_transcription_for_agents(), + ) }; - if transcription_auto_enabled { + if roster_changed || transcription_auto_enabled { state.emit_huddle_state_changed(); } @@ -281,7 +310,6 @@ pub(crate) async fn maybe_start_stt_pipeline( // the worker thread (~200ms) and must not block under the mutex. let ( tts_active, - tts_cancel, agent_pubkeys_arc, session_gen, expected_generation, @@ -312,7 +340,6 @@ pub(crate) async fn maybe_start_stt_pipeline( }; ( Arc::clone(&hs.tts_active), - Some(Arc::clone(&hs.tts_cancel)), Arc::clone(&hs.agent_pubkeys), Arc::clone(&hs.session_generation), hs.session_generation.load(Ordering::Acquire), @@ -325,7 +352,7 @@ pub(crate) async fn maybe_start_stt_pipeline( drop(old_stt); let constructed = tokio::task::spawn_blocking(move || { - stt::SttPipeline::new(model_dir, tts_active, tts_cancel, ptt_active_for_stt) + stt::SttPipeline::new(model_dir, tts_active, ptt_active_for_stt) }) .await; let (pipeline, text_rx) = match constructed { @@ -421,8 +448,8 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result super::tts_settings::pocket_voice_reference(&app, &voice_preferences)?, + let initial_voice = match app.as_ref() { + Some(app) => super::tts_settings::pocket_voice_reference(app, &voice_preferences)?, None => super::tts_settings::bundled_pocket_voice_reference(&voice_preferences), }; @@ -458,6 +485,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result f32 { + ((f32::from(level_dbov) + 60.0) / 48.0).clamp(0.0, 1.0) +} + +fn should_recover_playout(depth: usize, currently_recovering: bool) -> bool { + if currently_recovering { + depth > PLAYOUT_QUEUE_RECOVERY_END + } else { + depth >= PLAYOUT_QUEUE_RECOVERY_START + } +} /// One remote peer's slot: jitter buffer + dedicated rodio Player. /// @@ -87,6 +105,7 @@ struct PeerSlot { /// by the playout tick to decide whether to keep draining NetEq into the /// Player. Updated on every successful `insert_packet`. last_packet_at: tokio::time::Instant, + recovering_playout: bool, } impl PeerSlot { @@ -96,6 +115,7 @@ impl PeerSlot { jitter, player: rodio::Player::connect_new(sink_mixer), last_packet_at: tokio::time::Instant::now(), + recovering_playout: false, }), Err(e) => { eprintln!("buzz-desktop: jitter buffer init peer {peer_idx}: {e}"); @@ -121,6 +141,19 @@ impl PeerSlot { fn is_active(&self) -> bool { self.last_packet_at.elapsed() < IDLE_PEER_GRACE || !self.jitter.is_empty() } + + fn update_playout_recovery(&mut self) { + let should_recover = should_recover_playout(self.player.len(), self.recovering_playout); + if should_recover == self.recovering_playout { + return; + } + self.recovering_playout = should_recover; + self.player.set_speed(if should_recover { + PLAYOUT_RECOVERY_SPEED + } else { + 1.0 + }); + } } /// Drive the receive loop until cancelled or the WS closes. @@ -149,12 +182,16 @@ pub(crate) async fn run_playout_recv_loop( let mut index_to_pubkey: std::collections::HashMap = initial_peers.into_iter().collect(); let mut active_indices: std::collections::HashSet = std::collections::HashSet::new(); + let mut speaker_levels: std::collections::HashMap = std::collections::HashMap::new(); let mut frame_counts: std::collections::HashMap = std::collections::HashMap::new(); let mut last_frame_reset = tokio::time::Instant::now(); let mut tts_was_active = false; let mut speaker_tick = tokio::time::interval(std::time::Duration::from_millis(SPEAKER_TICK_MS)); speaker_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut speaker_level_tick = + tokio::time::interval(std::time::Duration::from_millis(SPEAKER_LEVEL_TICK_MS)); + speaker_level_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut playout_tick = tokio::time::interval(std::time::Duration::from_millis(PLAYOUT_TICK_MS)); // `Delay` (not `Skip`) so a brief stall in another select arm — e.g. the // ws_tx_for_pongs mutex contending with the encode-side task on a Ping — @@ -187,15 +224,14 @@ pub(crate) async fn run_playout_recv_loop( } match slot.jitter.get_audio() { Ok((samples, _vad)) => { - // Bound producer-vs-device-clock drift. If our - // tokio tick has gotten ahead of the audio - // callback's actual consumption rate, drop the - // oldest queued frame rather than letting the - // queue grow without bound. - if slot.player.len() >= PLAYOUT_QUEUE_HIGH_WATER { + // Smooth out producer-vs-device clock drift. A + // shallow hard drop used to remove entire 10 ms + // chunks and create audible discontinuities. + slot.update_playout_recovery(); + if slot.player.len() >= PLAYOUT_QUEUE_EMERGENCY_HIGH_WATER { eprintln!( - "buzz-desktop: playout queue high-water for peer {peer_idx} \ - (depth={}) — dropping oldest frame", + "buzz-desktop: playout queue emergency high-water for peer \ + {peer_idx} (depth={}) — dropping oldest frame", slot.player.len(), ); slot.player.skip_one(); @@ -221,6 +257,22 @@ pub(crate) async fn run_playout_recv_loop( } active_indices.clear(); } + _ = speaker_level_tick.tick() => { + if let Some(ref app) = app_handle { + use tauri::Emitter; + let levels: std::collections::HashMap = speaker_levels + .iter() + .filter_map(|(idx, level)| { + index_to_pubkey.get(idx).cloned().map(|pubkey| (pubkey, *level)) + }) + .collect(); + let _ = app.emit("huddle-speaker-levels", &levels); + } + for level in speaker_levels.values_mut() { + *level *= 0.55; + } + speaker_levels.retain(|_, level| *level > 0.015); + } msg = ws_rx.next() => { match msg { Some(Ok(WsMsg::Binary(data))) => { @@ -253,6 +305,11 @@ pub(crate) async fn run_playout_recv_loop( // make their tile flash for the 500 ms speaker tick. if !is_dtx { active_indices.insert(peer_idx); + let level = normalized_speaker_level(header.level_dbov); + speaker_levels + .entry(peer_idx) + .and_modify(|current| *current = current.max(level)) + .or_insert(level); } // TTS interrupt frame counter — reset on TTS rising edge. @@ -328,6 +385,7 @@ pub(crate) async fn run_playout_recv_loop( peers.remove(&key); frame_counts.remove(&key); active_indices.remove(&key); + speaker_levels.remove(&key); } index_to_pubkey.insert(key, pk.to_string()); } @@ -351,6 +409,7 @@ pub(crate) async fn run_playout_recv_loop( peers.retain(|idx, _| identity_unchanged(idx)); frame_counts.retain(|idx, _| identity_unchanged(idx)); active_indices.retain(identity_unchanged); + speaker_levels.retain(|idx, _| identity_unchanged(idx)); index_to_pubkey = replacement; } } @@ -359,6 +418,8 @@ pub(crate) async fn run_playout_recv_loop( let key = idx as u8; index_to_pubkey.remove(&key); frame_counts.remove(&key); + active_indices.remove(&key); + speaker_levels.remove(&key); // Dropping Player detaches its queue from the // device mixer, freeing the per-peer slot. peers.remove(&key); @@ -379,4 +440,34 @@ pub(crate) async fn run_playout_recv_loop( } } } + + if let Some(ref app) = app_handle { + use tauri::Emitter; + let _ = app.emit( + "huddle-speaker-levels", + &std::collections::HashMap::::new(), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speaker_level_maps_conversational_range() { + assert_eq!(normalized_speaker_level(-127), 0.0); + assert_eq!(normalized_speaker_level(-60), 0.0); + assert!((normalized_speaker_level(-36) - 0.5).abs() < f32::EPSILON); + assert_eq!(normalized_speaker_level(-12), 1.0); + assert_eq!(normalized_speaker_level(0), 1.0); + } + + #[test] + fn playout_recovery_uses_hysteresis() { + assert!(!should_recover_playout(9, false)); + assert!(should_recover_playout(10, false)); + assert!(should_recover_playout(5, true)); + assert!(!should_recover_playout(4, true)); + } } diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index 37eb3533f..0fe3a46f5 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -4,11 +4,13 @@ //! phase enum, voice input mode, and response types. use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, Arc, Mutex, }; +use super::agent_voice::AgentVoiceSettings; use super::{stt, tts}; /// Voice input mode: push-to-talk (PTT) or voice-activity detection (VAD). @@ -18,8 +20,9 @@ use super::{stt, tts}; /// (after a 200 ms delay) stops mic capture and flushes the utterance. /// /// VAD (default): the earshot VAD runs continuously and speech is accumulated -/// whenever the probability exceeds the threshold. Barge-in is enabled in this -/// mode. +/// whenever the probability exceeds the threshold. While local TTS is playing, +/// mic frames are discarded because VAD has no echo reference with which to +/// distinguish the app's own playback from a human interruption. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum VoiceInputMode { @@ -44,6 +47,9 @@ pub struct HuddleState { pub phase: HuddlePhase, pub parent_channel_id: Option, pub ephemeral_channel_id: Option, + /// Root event for the huddle's visible parent-channel thread. Transcript + /// messages reply here while audio coordination stays ephemeral. + pub huddle_thread_event_id: Option, /// Cancellation token for the audio relay WS task. #[serde(skip)] pub audio_ws_cancel: Option, @@ -67,6 +73,8 @@ pub struct HuddleState { deserialize_with = "deserialize_agent_pubkeys" )] pub agent_pubkeys: Arc>>, + /// Local, huddle-scoped playback choices for each participating agent. + pub agent_voice_settings: BTreeMap, /// Active STT pipeline — not serialized, not cloned. #[serde(skip)] pub stt_pipeline: Option>, @@ -161,10 +169,12 @@ impl Clone for HuddleState { phase: self.phase.clone(), parent_channel_id: self.parent_channel_id.clone(), ephemeral_channel_id: self.ephemeral_channel_id.clone(), + huddle_thread_event_id: self.huddle_thread_event_id.clone(), audio_ws_cancel: None, // Never clone handles. audio_relay_pcm_tx: None, // Never clone handles. participants: self.participants.clone(), agent_pubkeys: Arc::new(Mutex::new(agent_pubkeys_snapshot)), + agent_voice_settings: self.agent_voice_settings.clone(), stt_pipeline: None, // Never clone the pipeline handle. tts_pipeline: None, // Never clone the pipeline handle. is_creator: self.is_creator, @@ -190,10 +200,12 @@ impl Default for HuddleState { phase: HuddlePhase::Idle, parent_channel_id: None, ephemeral_channel_id: None, + huddle_thread_event_id: None, audio_ws_cancel: None, audio_relay_pcm_tx: None, participants: Vec::new(), agent_pubkeys: Arc::new(Mutex::new(Vec::new())), + agent_voice_settings: BTreeMap::new(), stt_pipeline: None, tts_pipeline: None, is_creator: false, diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 6f502ca72..30a47f449 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -63,13 +63,13 @@ impl SttPipeline { /// /// `tts_active` is a shared flag set by the TTS pipeline while audio is /// playing. The STT worker uses it to: - /// - discard accumulated speech (echo prevention / barge-in gating) - /// - apply a 200 ms cooldown after TTS stops before re-enabling STT - /// - detect barge-in: speech onset during TTS → set `tts_cancel` + /// - discard accumulated speech so local playback cannot feed back into STT + /// - apply a cooldown after TTS stops before re-enabling STT /// - /// `tts_cancel` (optional) is the TTS pipeline's cancel flag. When the STT - /// worker detects speech onset while TTS is active, it sets this flag to - /// stop playback immediately (barge-in). Pass `None` if TTS is unavailable. + /// Open-mic VAD cannot distinguish a nearby human from the app's own native + /// TTS playback because it has no acoustic echo reference. Local mic frames + /// therefore never cancel TTS. Push-to-talk and remote participant speech + /// remain explicit, reliable barge-in paths. /// /// `ptt_active` (optional) is the push-to-talk flag. When `Some`, the STT /// pipeline only accumulates speech while the flag is true (key held). @@ -86,7 +86,6 @@ impl SttPipeline { pub fn new( model_dir: PathBuf, tts_active: Arc, - tts_cancel: Option>, ptt_active: Option>, ) -> Result<(Self, tokio_mpsc::Receiver), String> { let (audio_tx, audio_rx) = mpsc::sync_channel::>(AUDIO_QUEUE_DEPTH); @@ -94,7 +93,6 @@ impl SttPipeline { let shutdown = Arc::new(AtomicBool::new(false)); let shutdown_worker = Arc::clone(&shutdown); - let tts_cancel_worker = tts_cancel.as_ref().map(Arc::clone); let ptt_active_worker = ptt_active.as_ref().map(Arc::clone); let handle = thread::Builder::new() .name("stt-worker".into()) @@ -105,7 +103,6 @@ impl SttPipeline { text_tx, shutdown_worker, tts_active, - tts_cancel_worker, ptt_active_worker, ) }) @@ -167,28 +164,26 @@ impl Drop for SttPipeline { /// Previous value (28 frames / 450 ms) felt sluggish in conversation. const SILENCE_FLUSH_FRAMES: usize = 19; -/// Consecutive VAD speech frames required before triggering barge-in during TTS. -/// 20 frames × 256 samples / 16 kHz ≈ 320 ms — must be long enough to filter -/// speaker-to-mic feedback (TTS audio bleeding through the mic) while still -/// catching real human interruptions. 80 ms (previous: 5 frames) was too -/// aggressive — laptop speakers without headphones triggered false barge-in -/// within the first word of TTS playback. -const BARGE_IN_DEBOUNCE_FRAMES: usize = 20; - /// earshot requires exactly 256 samples per frame at 16 kHz. const VAD_FRAME_SAMPLES: usize = 256; /// VAD probability threshold — above this is considered speech. const VAD_THRESHOLD: f32 = 0.5; +/// Minimum voiced audio needed before an utterance may be decoded. +/// One earshot false-positive frame is only 16 ms; requiring 192 ms prevents +/// silence/room-noise blips from reaching Parakeet and becoming hallucinated +/// transcript text while still preserving short replies such as "yes". +const MIN_VOICED_FRAMES: usize = 12; + /// How long the worker waits on the audio channel before checking the shutdown flag. const RECV_TIMEOUT: Duration = Duration::from_millis(50); -/// 50 ms cooldown after TTS stops before STT re-enables. +/// 150 ms cooldown after TTS stops before STT re-enables. /// Prevents the tail of TTS audio from being transcribed as speech. -/// Previous value (200 ms) was eating the first word when the user spoke -/// immediately after the agent finished. -const TTS_COOLDOWN: Duration = Duration::from_millis(50); +/// This remains shorter than the previous 200 ms gate that ate the first word, +/// but is long enough for speaker/AEC tail audio to leave the microphone path. +const TTS_COOLDOWN: Duration = Duration::from_millis(150); /// Number of ONNX Runtime intra-op threads used by the offline recognizer. /// @@ -207,7 +202,6 @@ fn stt_worker( text_tx: tokio_mpsc::Sender, shutdown: Arc, tts_active: Arc, - tts_cancel: Option>, ptt_active: Option>, ) { // ── 1. Initialise rubato resampler (48 kHz → 16 kHz, mono) ─────────────── @@ -274,9 +268,9 @@ fn stt_worker( let mut silence_frames: usize = 0; // Whether we're currently in a speech segment. let mut in_speech = false; - // Consecutive speech frames seen during TTS — used for barge-in debounce. - let mut barge_in_frames: usize = 0; - // Timestamp when TTS last stopped — used for the 200 ms cooldown. + // Number of frames earshot classified as voiced in the current segment. + let mut voiced_frames = 0; + // Timestamp when TTS last stopped — used for the playback-tail cooldown. let mut tts_stopped_at: Option = None; // ── 5. Main loop ────────────────────────────────────────────────────────── @@ -305,10 +299,11 @@ fn stt_worker( if let Some(ref ptt) = ptt_active { let ptt_now = ptt.load(Ordering::Acquire); if ptt_was_active && !ptt_now && in_speech && !speech_buf.is_empty() { - flush_to_stt(&speech_buf, &recognizer, &text_tx); + flush_to_stt(&speech_buf, voiced_frames, &recognizer, &text_tx); speech_buf.clear(); silence_frames = 0; in_speech = false; + voiced_frames = 0; } ptt_was_active = ptt_now; } @@ -342,11 +337,10 @@ fn stt_worker( &mut speech_buf, &mut silence_frames, &mut in_speech, - &mut barge_in_frames, + &mut voiced_frames, &recognizer, &text_tx, &tts_active, - tts_cancel.as_deref(), &mut tts_stopped_at, ptt_active.as_ref(), ); @@ -387,8 +381,8 @@ fn resample_chunk(resampler: &mut rubato::Fft, chunk_48k: &[f32]) -> Vec, silence_frames: &mut usize, in_speech: &mut bool, - barge_in_frames: &mut usize, + voiced_frames: &mut usize, recognizer: &sherpa_onnx::OfflineRecognizer, text_tx: &tokio_mpsc::Sender, tts_active: &Arc, - tts_cancel: Option<&AtomicBool>, tts_stopped_at: &mut Option, ptt_active: Option<&Arc>, ) { @@ -433,38 +426,16 @@ fn process_16k_samples( let tts_playing = tts_active.load(Ordering::Acquire); - // While TTS is playing: skip accumulation (echo prevention). + // While TTS is playing, discard local mic input. The native TTS output + // is not available as an echo-cancellation reference to this worker, so + // VAD cannot reliably tell speaker feedback from a human interruption. + // Push-to-talk and remote participant audio provide the intentional + // cancellation paths instead. if tts_playing { - if ptt_active.is_some() { - // PTT mode — PTT press handles TTS cancellation directly - // (via the global shortcut handler). Just skip accumulation. - *in_speech = false; - *barge_in_frames = 0; - speech_buf.clear(); - *silence_frames = 0; - continue; - } - - // VAD mode — barge-in detection. - // Without acoustic echo cancellation, this requires a longer - // debounce (BARGE_IN_DEBOUNCE_FRAMES ≈ 320 ms) to filter - // speaker-to-mic feedback. - if is_speech { - *barge_in_frames += 1; - if *barge_in_frames >= BARGE_IN_DEBOUNCE_FRAMES { - // Real speech detected during TTS — trigger barge-in. - if let Some(cancel) = tts_cancel { - cancel.store(true, Ordering::Release); - } - *barge_in_frames = 0; - } - } else { - *barge_in_frames = 0; - } - // Don't accumulate speech during TTS (echo prevention). *in_speech = false; speech_buf.clear(); *silence_frames = 0; + *voiced_frames = 0; continue; } @@ -477,28 +448,30 @@ fn process_16k_samples( } speech_buf.clear(); *silence_frames = 0; - *barge_in_frames = 0; + *voiced_frames = 0; continue; } else { // Cooldown expired — clear the timer and reset all segment state. *tts_stopped_at = None; *in_speech = false; *silence_frames = 0; - *barge_in_frames = 0; + *voiced_frames = 0; } } if is_speech { *silence_frames = 0; *in_speech = true; + *voiced_frames += 1; speech_buf.extend_from_slice(&frame); // OOM guard: flush and reset if the buffer exceeds 30 s of audio. if speech_buf.len() >= MAX_SPEECH_SAMPLES { - flush_to_stt(speech_buf, recognizer, text_tx); + flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); speech_buf.clear(); *silence_frames = 0; *in_speech = false; + *voiced_frames = 0; } } else if *in_speech { // Still accumulate during brief silence gaps. @@ -511,10 +484,11 @@ fn process_16k_samples( // threshold so each natural pause becomes a separate message. if ptt_active.is_none() && *silence_frames >= SILENCE_FLUSH_FRAMES { // End of utterance — transcribe. - flush_to_stt(speech_buf, recognizer, text_tx); + flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); speech_buf.clear(); *silence_frames = 0; *in_speech = false; + *voiced_frames = 0; } } // If not in speech and not accumulating, just discard the frame. @@ -527,10 +501,11 @@ fn process_16k_samples( /// The tokio channel's `blocking_send` is safe to call from sync contexts. fn flush_to_stt( speech_buf: &[f32], + voiced_frames: usize, recognizer: &sherpa_onnx::OfflineRecognizer, text_tx: &tokio_mpsc::Sender, ) { - if speech_buf.is_empty() { + if speech_buf.is_empty() || !has_enough_voiced_audio(voiced_frames) { return; } @@ -550,6 +525,10 @@ fn flush_to_stt( } } +fn has_enough_voiced_audio(voiced_frames: usize) -> bool { + voiced_frames >= MIN_VOICED_FRAMES +} + /// Convert raw bytes (f32 LE) to f32 samples. /// Caller should ensure `bytes.len() % 4 == 0`; extra bytes are silently truncated. /// @@ -565,3 +544,15 @@ fn bytes_to_f32(bytes: &[u8]) -> Vec { // drain_until_shutdown lives in super (huddle/mod.rs) — shared with tts.rs. use super::drain_until_shutdown; + +#[cfg(test)] +mod tests { + use super::{has_enough_voiced_audio, MIN_VOICED_FRAMES}; + + #[test] + fn short_vad_blips_do_not_reach_the_recognizer() { + assert!(!has_enough_voiced_audio(1)); + assert!(!has_enough_voiced_audio(MIN_VOICED_FRAMES - 1)); + assert!(has_enough_voiced_audio(MIN_VOICED_FRAMES)); + } +} diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index c03589f9f..1901bb3d2 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -35,7 +35,7 @@ //! can gate microphone input while the agent is speaking. use std::{ - collections::VecDeque, + collections::{HashMap, VecDeque}, num::NonZero, path::PathBuf, sync::{ @@ -44,7 +44,7 @@ use std::{ Arc, Mutex, MutexGuard, PoisonError, }, thread, - time::Duration, + time::{Duration, Instant}, }; use super::pocket::{ @@ -61,6 +61,9 @@ use startup::await_worker_startup; #[path = "tts_audio.rs"] mod audio; use audio::*; +#[path = "tts_activity.rs"] +mod activity; +use activity::*; // ── Constants ───────────────────────────────────────────────────────────────── @@ -77,6 +80,7 @@ const RECV_TIMEOUT: Duration = Duration::from_millis(100); /// ~5 ms — so playing audio dies ~15 ms after the flag is set, even while /// the worker is blocked inside `synth_chunk`. const MONITOR_TICK: Duration = Duration::from_millis(10); +const SPEAKER_ACTIVITY_TICK: Duration = Duration::from_millis(50); const AUDIO_PRIME_TIMEOUT: Duration = Duration::from_secs(2); /// Pocket TTS is a one-step consistency model, not diffusion. Kept for API compat. @@ -167,10 +171,12 @@ impl TtsPipeline { cancel: Arc, voice: &str, output_device: Option, + activity_app: Option, ) -> Result { let (text_tx, text_rx) = mpsc::sync_channel::(TEXT_QUEUE_DEPTH); let shutdown = Arc::new(AtomicBool::new(false)); - // cancel is passed in from HuddleState.tts_cancel — shared with STT for barge-in. + // cancel is passed in from HuddleState.tts_cancel — shared with remote + // participant interruption and the push-to-talk shortcut. let shutdown_worker = Arc::clone(&shutdown); let cancel_worker = Arc::clone(&cancel); @@ -203,6 +209,7 @@ impl TtsPipeline { (cancel_worker, worker_voice_cancel), ), output_device, + activity_app, startup_tx, ) }) @@ -231,6 +238,8 @@ impl TtsPipeline { .try_send(QueuedText { generation: self.voice_generation.load(Ordering::Acquire), route_id: 0, + speaker_pubkey: None, + voice_reference: None, text, }) .map_err(|e| { @@ -309,6 +318,7 @@ fn tts_worker( text_rx: mpsc::Receiver, control_state: WorkerControlState, output_device: Option, + activity_app: Option, startup_tx: mpsc::SyncSender>, ) { let (selected_voice, voice_generation, voice_change_ack) = voice_state; @@ -351,6 +361,7 @@ fn tts_worker( )); return; } + let mut style_cache = HashMap::from([(voice_name.clone(), style.clone())]); // ── 2b. Warmup inference ───────────────────────────────────────────────── // The first ONNX inference on any session is significantly slower than @@ -454,6 +465,7 @@ fn tts_worker( // `cancel == false` and no-ops. The lock is uncontended except during an // actual barge-in, so the hot path is unaffected. let player_ops = Arc::new(Mutex::new(())); + let activity_frames = Arc::new(Mutex::new(VecDeque::::new())); let monitor_stop = Arc::new(AtomicBool::new(false)); let monitor = { let player = Arc::clone(&player); @@ -462,9 +474,12 @@ fn tts_worker( let tts_active = Arc::clone(&tts_active); let stop = Arc::clone(&monitor_stop); let player_ops = Arc::clone(&player_ops); + let activity_frames = Arc::clone(&activity_frames); thread::Builder::new() .name("tts-barge-in-monitor".into()) .spawn(move || { + let mut last_activity_pubkey: Option = None; + let mut next_activity_tick = Instant::now(); while !stop.load(Ordering::Acquire) { if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { let _ops = lock_player_ops(&player_ops); @@ -481,6 +496,46 @@ fn tts_worker( tts_active.store(false, Ordering::Release); } } + if let Some(ref app) = activity_app { + if tts_active.load(Ordering::Acquire) { + let now = Instant::now(); + if now >= next_activity_tick { + let frame = activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .pop_front(); + if let Some(frame) = frame { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: Some(frame.pubkey.clone()), + level: frame.level, + }, + ); + last_activity_pubkey = Some(frame.pubkey); + } + next_activity_tick = now + SPEAKER_ACTIVITY_TICK; + } + } else { + let had_activity = last_activity_pubkey.take().is_some(); + activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + if had_activity { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: None, + level: 0.0, + }, + ); + } + next_activity_tick = Instant::now(); + } + } thread::sleep(MONITOR_TICK); } }) @@ -507,7 +562,9 @@ fn tts_worker( let mut first_append = true; let mut last_route_id = 0; let mut deferred_text = VecDeque::new(); - let append_audio = |prepared: PreparedModelAudio, route_id: u64| { + let append_audio = |prepared: PreparedModelAudio, + route_id: u64, + speaker_pubkey: Option<&str>| { let _ops = lock_player_ops(&player_ops); if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) @@ -525,6 +582,16 @@ fn tts_worker( ); return false; } + if let Some(pubkey) = speaker_pubkey { + activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .extend(build_tts_speaker_activity_frames( + &prepared.buffer, + pubkey, + SAMPLE_RATE as usize, + )); + } player.append(SamplesBuffer::new(channels, rate, prepared.buffer)); eprintln!( "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={} sample_count={}", @@ -555,14 +622,19 @@ fn tts_worker( continue; } - // Voice changes cancel the old utterance/queue and are observed here, - // before receiving subsequent text. A bad bundled asset falls back to - // Mary without discarding the already-warmed Pocket engine. - let voice_ready = - reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style); - acknowledge_voice_change(&voice_change_ack, &voice_cancel); - if !voice_ready { - continue; + // A global Settings voice change cancels the old utterance and is + // acknowledged before receiving subsequent text. Per-agent voice + // changes are carried by each queue item and never drain other agents. + if has_pending_voice_change(&voice_change_ack) { + let voice_ready = + reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style); + if voice_ready { + style_cache.insert(voice_name.clone(), style.clone()); + } + acknowledge_voice_change(&voice_change_ack, &voice_cancel); + if !voice_ready { + continue; + } } let mut queued_text = Some(match deferred_text.pop_front() { @@ -614,15 +686,28 @@ fn tts_worker( ); continue; } + let requested_voice = queued_text.voice_reference.unwrap_or_else(|| { + selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + }); let raw_text = queued_text.text; + let speaker_pubkey = queued_text.speaker_pubkey; let route_id = queued_text.route_id; eprintln!("buzz-desktop: tts stage=synthesis status=started route_id={route_id}"); - // The selected voice can change while this worker is blocked in - // recv_timeout. Reconcile again after receipt so the first message - // queued after an unpublished pipeline is installed cannot use the - // voice captured when construction began. - if !reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style) { + // The selected per-agent voice travels with the queue item, preserving + // message order while allowing one warmed Pocket engine to alternate + // between cached reference styles. + if !reconcile_queued_voice( + &model_dir, + &requested_voice, + &selected_voice, + &mut voice_name, + &mut style, + &mut style_cache, + ) { eprintln!( "buzz-desktop: tts stage=synthesis status=failed reason=voice_unavailable route_id={route_id}" ); @@ -761,7 +846,7 @@ fn tts_worker( silence_buf_len, player.empty(), ) { - if !append_audio(prepared, route_id) { + if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; @@ -787,7 +872,7 @@ fn tts_worker( if let Some(prepared) = playback_audio.finish(&mut first_append, silence_buf_len, player.empty()) { - if !append_audio(prepared, route_id) { + if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; diff --git a/desktop/src-tauri/src/huddle/tts_activity.rs b/desktop/src-tauri/src/huddle/tts_activity.rs new file mode 100644 index 000000000..8e6960918 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_activity.rs @@ -0,0 +1,45 @@ +//! Agent TTS activity envelope shared with the participant film strip. + +#[derive(Clone, serde::Serialize)] +pub(super) struct TtsSpeakerActivityPayload { + pub(super) pubkey: Option, + pub(super) level: f32, +} + +pub(super) struct TtsSpeakerActivityFrame { + pub(super) pubkey: String, + pub(super) level: f32, +} + +/// Build a 50 ms RMS envelope from the exact audio queued for playback. +/// The UI consumes these frames at the same cadence as remote speaker levels, +/// so an agent uses the normal participant ring rather than a generic pulse. +pub(super) fn build_tts_speaker_activity_frames( + samples: &[f32], + pubkey: &str, + sample_rate: usize, +) -> Vec { + let samples_per_frame = (sample_rate / 20).max(1); + samples + .chunks(samples_per_frame) + .map(|frame| { + let mean_square = frame + .iter() + .map(|sample| f64::from(*sample) * f64::from(*sample)) + .sum::() + / frame.len().max(1) as f64; + let rms = mean_square.sqrt() as f32; + let level = if rms <= 0.000_5 { + 0.0 + } else { + // Map roughly -60 dB..-12 dB into the same normalized range + // used by remote Opus speaker levels. + ((20.0 * rms.log10() + 60.0) / 48.0).clamp(0.12, 1.0) + }; + TtsSpeakerActivityFrame { + pubkey: pubkey.to_string(), + level, + } + }) + .collect() +} diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index 1b378af82..64fd6d8a9 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -175,7 +175,7 @@ pub fn resolve_voice_for_backend( resolve_voice_for_backend_in_registry(preferences, backend, &bundled_voice_registry()) } -fn resolve_voice_for_backend_in_registry( +pub(crate) fn resolve_voice_for_backend_in_registry( preferences: &[String], backend: &str, registry: &[VoiceRegistryEntry], @@ -624,6 +624,7 @@ pub async fn preview_pocket_voice( cancel, &voice_name, output_device, + None, )?; pipeline.speak("Hello! This is how I’ll read agent responses.".to_string())?; let started = std::time::Instant::now(); diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 1908b096b..1dee4de90 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -32,6 +32,19 @@ mod token_split; // - Counters reset on the 500ms window (Instant-based in production, // on_tick() in tests — logically equivalent). // - Uses Acquire for tts_active reads, Release for tts_cancel writes. + +#[test] +fn tts_speaker_activity_uses_the_playback_waveform() { + let mut samples = vec![0.0; 1_200]; + samples.extend(vec![0.25; 1_200]); + + let frames = build_tts_speaker_activity_frames(&samples, "agent-pubkey", 24_000); + + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].pubkey, "agent-pubkey"); + assert_eq!(frames[0].level, 0.0); + assert!(frames[1].level > 0.5); +} // use crate::huddle::relay_api::REMOTE_SPEECH_THRESHOLD; @@ -287,24 +300,6 @@ fn cancel_already_true_is_harmless() { ); } -// ── Regression: local-only interrupt still works ────────────────────────── - -/// The existing local barge-in path (STT detects speech → sets tts_cancel) -/// must continue to work independently of remote frame counting. -#[test] -fn local_barge_in_still_works_without_remote_frames() { - let _tts_active = AtomicBool::new(true); - let tts_cancel = AtomicBool::new(false); - - // Simulate local STT barge-in (stt.rs after BARGE_IN_DEBOUNCE_FRAMES). - tts_cancel.store(true, Ordering::Release); - - assert!( - tts_cancel.load(Ordering::Acquire), - "local barge-in should set tts_cancel", - ); -} - // ── Cancel consumption tests (TTS worker side) ──────────────────────────── /// TTS worker correctly resets both tts_cancel and tts_active after cancel. diff --git a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs index 45662c992..044b1acf1 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs @@ -167,6 +167,8 @@ fn an_in_hand_post_change_message_survives_cancellation() { .send(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 1, + speaker_pubkey: None, + voice_reference: None, text: "new message".to_string(), }) .expect("new message"); @@ -178,11 +180,15 @@ fn an_in_hand_post_change_message_survives_cancellation() { QueuedText { generation: 1, route_id: 2, + speaker_pubkey: None, + voice_reference: None, text: "old message".to_string(), }, QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 3, + speaker_pubkey: None, + voice_reference: None, text: "later new message".to_string(), }, ]); @@ -239,6 +245,8 @@ fn superseding_voice_change_removes_earlier_deferred_messages() { deferred_text.push_back(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 4, + speaker_pubkey: None, + voice_reference: None, text: "message for Eve".to_string(), }); assert!(handle_cancel_or_shutdown( @@ -285,6 +293,8 @@ fn barge_in_clears_deferred_voice_change_messages() { let mut deferred_text = VecDeque::from([QueuedText { generation: 2, route_id: 5, + speaker_pubkey: None, + voice_reference: None, text: "deferred message".to_string(), }]); let mut current_text = None; @@ -326,6 +336,8 @@ fn barge_in_during_a_voice_change_clears_post_change_messages() { deferred_text.push_back(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 6, + speaker_pubkey: None, + voice_reference: None, text: "post-change message".to_string(), }); barge_in.store(true, Ordering::Release); @@ -377,9 +389,15 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() None, )); old_sender - .send(7, "late old message".to_string()) + .send( + 7, + "agent".to_string(), + "reference_sample".to_string(), + "late old message".to_string(), + ) .expect("late send"); let late = text_rx.recv().expect("late queued text"); assert!(late.generation < voice_generation.load(Ordering::Acquire)); + assert_eq!(late.voice_reference.as_deref(), Some("reference_sample")); } diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs index 81b33672d..3a6555375 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -1,5 +1,5 @@ use std::{ - collections::VecDeque, + collections::{HashMap, VecDeque}, path::Path, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, @@ -30,6 +30,8 @@ pub(super) type CancelSignals<'a> = (&'a AtomicBool, &'a AtomicBool); pub(super) struct QueuedText { pub(super) generation: u64, pub(super) route_id: u64, + pub(super) speaker_pubkey: Option, + pub(super) voice_reference: Option, pub(super) text: String, } @@ -40,17 +42,32 @@ pub(crate) struct TtsTextSender { } impl TtsTextSender { - pub(crate) fn send(&self, route_id: u64, text: String) -> Result<(), String> { + pub(crate) fn send( + &self, + route_id: u64, + speaker_pubkey: String, + voice_reference: String, + text: String, + ) -> Result<(), String> { self.text_tx .send(QueuedText { generation: self.generation, route_id, + speaker_pubkey: Some(speaker_pubkey), + voice_reference: Some(voice_reference), text, }) .map_err(|error| error.to_string()) } } +pub(super) fn has_pending_voice_change(voice_change_ack: &VoiceChangeAck) -> bool { + voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()) + .is_some() +} + pub(super) fn begin_voice_change( selected_voice: &Mutex, voice_generation: &AtomicU64, @@ -151,6 +168,43 @@ pub(super) fn reconcile_selected_voice( } } +pub(super) fn reconcile_queued_voice( + model_dir: &Path, + requested_voice: &str, + selected_voice: &Mutex, + voice_name: &mut String, + style: &mut VoiceStyle, + style_cache: &mut HashMap, +) -> bool { + if requested_voice == voice_name.as_str() { + return true; + } + if let Some(cached) = style_cache.get(requested_voice) { + *style = cached.clone(); + *voice_name = requested_voice.to_owned(); + return true; + } + + match load_voice_style(&voice_path(model_dir, requested_voice)) { + Ok(requested_style) => { + style_cache.insert(requested_voice.to_owned(), requested_style.clone()); + *style = requested_style; + *voice_name = requested_voice.to_owned(); + true + } + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=agent_voice_switch status=fallback reason=voice_style" + ); + let ready = reconcile_selected_voice(model_dir, selected_voice, voice_name, style); + if ready { + style_cache.insert(voice_name.clone(), style.clone()); + } + ready + } + } +} + pub(super) fn voice_path(model_dir: &Path, voice: &str) -> std::path::PathBuf { let path = Path::new(voice); if path.is_absolute() { diff --git a/desktop/src-tauri/src/huddle/window.rs b/desktop/src-tauri/src/huddle/window.rs new file mode 100644 index 000000000..cb3cfc8bf --- /dev/null +++ b/desktop/src-tauri/src/huddle/window.rs @@ -0,0 +1,67 @@ +//! Native companion-window lifecycle for an active Huddle. + +use tauri::{Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; + +use crate::app_state::AppState; + +/// Close the companion belonging to an ended huddle. The native lifecycle is +/// authoritative here because a webview can be suspended while it is closing. +pub(super) fn close_huddle_window(app: &tauri::AppHandle, ephemeral_channel_id: &str) { + if ephemeral_channel_id.is_empty() { + return; + } + let label = format!("huddle-{ephemeral_channel_id}"); + if let Some(window) = app.get_webview_window(&label) { + if let Err(error) = window.close() { + eprintln!("buzz-desktop: failed to close huddle companion: {error}"); + } + } +} + +/// Close the active companion without leaving the huddle. The main window uses +/// this to restore its drawer presentation while retaining the audio session. +#[tauri::command] +pub fn close_huddle_companion( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let ephemeral_channel_id = state + .huddle()? + .ephemeral_channel_id + .clone() + .ok_or("no active huddle")?; + close_huddle_window(&app, &ephemeral_channel_id); + app.emit("huddle-companion-returned", ()) + .map_err(|error| error.to_string())?; + Ok(()) +} + +/// Open the active huddle's ephemeral channel in a focused companion window. +/// The main window remains the owner of microphone capture; closing this room +/// must never leave the shared huddle session. +#[tauri::command] +pub async fn open_huddle_window( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let ephemeral_channel_id = state + .huddle()? + .ephemeral_channel_id + .clone() + .ok_or("no active huddle")?; + let label = format!("huddle-{ephemeral_channel_id}"); + + if let Some(window) = app.get_webview_window(&label) { + window.show().map_err(|error| error.to_string())?; + window.set_focus().map_err(|error| error.to_string())?; + return Ok(()); + } + + WebviewWindowBuilder::new(&app, label, WebviewUrl::App("index.html".into())) + .title("Huddle") + .inner_size(960.0, 720.0) + .min_inner_size(720.0, 520.0) + .build() + .map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/desktop/src-tauri/src/initial_window.rs b/desktop/src-tauri/src/initial_window.rs new file mode 100644 index 000000000..b12455151 --- /dev/null +++ b/desktop/src-tauri/src/initial_window.rs @@ -0,0 +1,67 @@ +//! First-frame window reveal helpers. + +#[cfg(target_os = "macos")] +pub(crate) const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; + +pub(crate) fn reveal_initial_window(window: &tauri::Window) { + if let Err(error) = window.show() { + eprintln!("buzz-desktop: failed to reveal main window: {error}"); + return; + } + if let Err(error) = window.set_focus() { + eprintln!("buzz-desktop: failed to focus main window: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn set_initial_window_backing(window: &tauri::Window) { + // The window remains transparent at runtime for vibrancy. Use an opaque + // native backing only across the first visible frames so the previous app + // cannot show through before WebKit has submitted its first surface. + if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { + eprintln!("buzz-desktop: failed to set initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) async fn clear_initial_window_backing(window: &tauri::Window) { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if let Err(error) = window.set_background_color(None) { + eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) async fn wait_for_stable_initial_window_geometry( + window: &tauri::Window, +) { + const MAX_POLLS: usize = 120; + const REQUIRED_STABLE_POLLS: usize = 4; + + let mut previous_bounds = None; + let mut stable_polls = 0; + + for _ in 0..MAX_POLLS { + // Accept whatever geometry the window-state plugin restores — maximized + // or a normal saved size. macOS applies the restore asynchronously, so + // consecutive identical outer bounds are enough to know it settled. + let bounds = match (window.outer_position(), window.outer_size()) { + (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)), + _ => None, + }; + + if bounds.is_some() && bounds == previous_bounds { + stable_polls += 1; + if stable_polls >= REQUIRED_STABLE_POLLS { + return; + } + } else { + stable_polls = 0; + } + previous_bounds = bounds; + + tokio::time::sleep(std::time::Duration::from_millis(16)).await; + } + + eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout"); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e..7c5530db7 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod event_sync; mod events; mod huddle; mod identity_storage; +mod initial_window; mod key_backup; mod linux_media; mod managed_agents; @@ -49,11 +50,13 @@ use huddle::audio_output::{ }; use huddle::reconnect::reconnect_huddle_audio; use huddle::{ - add_agent_to_huddle, check_pipeline_hotstart, confirm_huddle_active, download_voice_models, - end_huddle, get_huddle_agent_pubkeys, get_huddle_state, get_model_status, get_voice_input_mode, - join_huddle, leave_huddle, push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, - set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, + add_agent_to_huddle, check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active, + download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state, + get_model_status, get_voice_input_mode, join_huddle, leave_huddle, open_huddle_window, + push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, + speak_agent_message, start_huddle, start_stt_pipeline, HuddlePhase, }; +use initial_window::*; use managed_agents::{ backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes, @@ -66,80 +69,13 @@ use mesh_llm_stubs::*; use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown}; use shutdown::{is_restart_request, shut_down_app}; use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; -use tauri::{Emitter, Manager, RunEvent}; #[cfg(target_os = "macos")] -use tauri::{Listener, WindowEvent}; +use tauri::Listener; +use tauri::{Emitter, Manager, RunEvent, WindowEvent}; use tauri_plugin_window_state::StateFlags; #[cfg(target_os = "macos")] use tray_menu::show_main_window; -#[cfg(target_os = "macos")] -const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; - -fn reveal_initial_window(window: &tauri::Window) { - if let Err(error) = window.show() { - eprintln!("buzz-desktop: failed to reveal main window: {error}"); - return; - } - if let Err(error) = window.set_focus() { - eprintln!("buzz-desktop: failed to focus main window: {error}"); - } -} - -#[cfg(target_os = "macos")] -fn set_initial_window_backing(window: &tauri::Window) { - // The window remains transparent at runtime for vibrancy. Use an opaque - // native backing only across the first visible frames so the previous app - // cannot show through before WebKit has submitted its first surface. - if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { - eprintln!("buzz-desktop: failed to set initial window backing: {error}"); - } -} - -#[cfg(target_os = "macos")] -async fn clear_initial_window_backing(window: &tauri::Window) { - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - if let Err(error) = window.set_background_color(None) { - eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); - } -} - -#[cfg(target_os = "macos")] -async fn wait_for_stable_initial_window_geometry(window: &tauri::Window) { - const MAX_POLLS: usize = 120; - const REQUIRED_STABLE_POLLS: usize = 4; - - let mut previous_bounds = None; - let mut stable_polls = 0; - - for _ in 0..MAX_POLLS { - // Accept whatever geometry the window-state plugin restores — maximized - // or a normal saved size. macOS applies the restore asynchronously, so - // we only need consecutive identical outer bounds to know it settled. - // Gating on `is_maximized()` here would leave `bounds` permanently - // `None` for restored non-maximized windows and stall the reveal until - // the poll timeout. - let bounds = match (window.outer_position(), window.outer_size()) { - (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)), - _ => None, - }; - - if bounds.is_some() && bounds == previous_bounds { - stable_polls += 1; - if stable_polls >= REQUIRED_STABLE_POLLS { - return; - } - } else { - stable_polls = 0; - } - previous_bounds = bounds; - - tokio::time::sleep(std::time::Duration::from_millis(16)).await; - } - - eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout"); -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // mesh-llm's async chains (model download, node start/join) overflow @@ -885,6 +821,8 @@ pub fn run() { leave_huddle, end_huddle, get_huddle_state, + close_huddle_companion, + open_huddle_window, push_audio_pcm, reconnect_huddle_audio, start_stt_pipeline, @@ -898,8 +836,12 @@ pub fn run() { huddle::tts_settings::preview_pocket_voice, huddle::tts_settings::import_pocket_voice, huddle::tts_settings::delete_pocket_voice, + huddle::agent_voice::ensure_huddle_agent_voice_settings, + huddle::agent_voice::set_huddle_agent_tts_enabled, + huddle::agent_voice::set_huddle_agent_voice, speak_agent_message, add_agent_to_huddle, + huddle::agents::sync_agents_to_active_huddle, check_pipeline_hotstart, confirm_huddle_active, perform_sidebar_default_haptic, @@ -971,6 +913,29 @@ pub fn run() { } } } + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { .. }, + .. + } if label.starts_with("huddle-") => { + let is_active_huddle_window = + app_handle + .state::() + .huddle() + .ok() + .is_some_and(|huddle| { + !matches!(huddle.phase, HuddlePhase::Idle | HuddlePhase::Leaving) + && huddle + .ephemeral_channel_id + .as_deref() + .is_some_and(|channel_id| label == format!("huddle-{channel_id}")) + }); + if is_active_huddle_window { + if let Err(error) = app_handle.emit("huddle-companion-returned", ()) { + eprintln!("buzz-desktop: failed to restore huddle drawer: {error}"); + } + } + } RunEvent::ExitRequested { code, .. } => { if is_restart_request(code) { restart_requested.store(true, Ordering::SeqCst); diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 44618f2c7..104edcbaa 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -20,6 +20,7 @@ import { deriveShellRoute } from "@/app/AppShell.helpers"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { useReloadShortcut } from "@/app/useReloadShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; +import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding"; import { @@ -652,12 +653,13 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { [activeCommunity, communityOnboarding.start], ); - // Deep links are captured here — above the machine-onboarding gate — not in - // CommunityApp. The Rust side queues them; draining into the persisted - // community-onboarding transaction immediately means an invite opened on a - // fresh install is acknowledged on screen while the identity steps are - // still pending, and survives a relaunch in between. + // Community links are app-global work. A Huddle companion loads the same + // React tree, but must never race the main window for the native pending-link + // queue or replace its dedicated transcript surface with onboarding. + const acceptsCommunityDeepLinks = huddleWindowChannelId() === null; useEffect(() => { + if (!acceptsCommunityDeepLinks) return; + const unlisten = listenForDeepLinks({ startCommunityOnboarding: communityOnboarding.start, openAddCommunity, @@ -666,7 +668,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { return () => { void unlisten.then((fn) => fn()); }; - }, [communityOnboarding.start, openAddCommunity]); + }, [acceptsCommunityDeepLinks, communityOnboarding.start, openAddCommunity]); if (machine.stage === "reset-failed") return ; if (machine.stage === "keyring-locked") return ; diff --git a/desktop/src/app/AppHuddleBar.tsx b/desktop/src/app/AppHuddleBar.tsx index 9fa12d513..5dfc31d41 100644 --- a/desktop/src/app/AppHuddleBar.tsx +++ b/desktop/src/app/AppHuddleBar.tsx @@ -6,10 +6,12 @@ import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; type AppHuddleBarProps = Pick< React.ComponentProps, - "onOpenThread" | "onVisibilityChange" + "mode" | "onOpenHuddleWindow" | "onOpenThread" | "onVisibilityChange" >; export function AppHuddleBar({ + mode, + onOpenHuddleWindow, onOpenThread, onVisibilityChange, }: AppHuddleBarProps) { @@ -17,6 +19,8 @@ export function AppHuddleBar({ diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx new file mode 100644 index 000000000..8370e8efd --- /dev/null +++ b/desktop/src/app/AppHuddleShell.tsx @@ -0,0 +1,76 @@ +import type * as React from "react"; +import { AppHuddleBar } from "@/app/AppHuddleBar"; +import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; +import { HuddleProvider } from "@/features/huddle"; +import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; +import { cn } from "@/shared/lib/cn"; + +type AppHuddleShellProps = { + children: React.ReactNode; + currentPubkey?: string; + isCompanionOpen: boolean; + isDrawerOpen: boolean; + isRoom: boolean; + onCompanionOpen: () => void; + onHuddleStartPendingChange: (pending: boolean) => void; + onHuddleStarted: (ephemeralChannelId: string) => void | Promise; + onShowHuddleInMainApp: (ephemeralChannelId: string) => void; + onViewHuddleChannel: (ephemeralChannelId: string) => void; + onVisibilityChange: (visible: boolean) => void; +}; + +export function AppHuddleShell({ + children, + currentPubkey, + isCompanionOpen, + isDrawerOpen, + isRoom, + onCompanionOpen, + onHuddleStartPendingChange, + onHuddleStarted, + onShowHuddleInMainApp, + onViewHuddleChannel, + onVisibilityChange, +}: AppHuddleShellProps) { + return ( + + +
+
+ + {children} +
+ {isRoom || !isCompanionOpen ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 46e06b2f9..e5f9f6d86 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -3,8 +3,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers"; import { AppShellProvider } from "@/app/AppShellContext"; -import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; import { AppShellOverlays } from "@/app/AppShellOverlays"; +import { AppShellChannelSurface } from "@/app/AppShellChannelSurface"; +import { AppHuddleShell } from "@/app/AppHuddleShell"; import { AppTopChrome } from "@/app/AppTopChrome"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; @@ -18,6 +19,8 @@ import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects"; import { useChannelActivityProjection } from "@/app/useChannelActivityProjection"; import { useTauriWindowDrag } from "@/app/useTauriWindowDrag"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; +import { useHuddlePresentation } from "@/app/useHuddlePresentation"; +import { shouldShowSidebarChannel } from "@/app/huddleChannelVisibility"; import { channelsQueryKey, useChannelsQuery, @@ -62,10 +65,7 @@ import { type SettingsSection, isSettingsSection, } from "@/features/settings/ui/SettingsPanels"; -import { HuddleProvider } from "@/features/huddle"; -import { AppHuddleBar } from "@/app/AppHuddleBar"; import { useDueReminderBadgeCount } from "@/features/reminders/hooks"; -import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { requestFocusedThreadClose } from "@/features/channels/focusedThreadCloseRequest"; @@ -86,28 +86,38 @@ import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock"; import { joinChannel } from "@/shared/api/tauri"; -import type { ChannelVisibility, SearchHit } from "@/shared/api/types"; +import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; -import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; -import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; -import { cn } from "@/shared/lib/cn"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; -import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; +import { SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu"; import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; -const LazySettingsScreen = React.lazy(async () => { - const module = await import("@/features/settings/ui/SettingsScreen"); - return { default: module.SettingsScreen }; -}); - +import { LazySettingsScreen } from "@/app/LazySettingsScreen"; +const EMPTY_CHANNELS: Channel[] = []; export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); const communitiesHook = useCommunities(); + const { + handleHuddleCompanionOpen, + handleHuddleEnded, + handleHuddleStartPendingChange, + handleHuddleStarted, + handleHuddleVisibilityChange, + handleSidebarChannelSelect, + huddleBackingChannelIds, + revealedHuddleChannelIds, + isHuddleCompanionOpen, + isHuddleDrawerOpen, + isHuddleRoom, + isHuddleRoomStarting, + showHuddleInMainApp, + viewHuddleChannel, + } = useHuddlePresentation(); const hasCommunityRail = communitiesHook.communities.length > 1; const addCommunityDialog = useAddCommunityDialogState(); const [isChannelManagementOpen, setIsChannelManagementOpen] = @@ -118,7 +128,6 @@ export function AppShell() { const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); const [isSendFeedbackOpen, setIsSendFeedbackOpen] = React.useState(false); - const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); const mainInsetRef = React.useRef(null); const location = useLocation(); const queryClient = useQueryClient(); @@ -238,8 +247,17 @@ export function AppShell() { [channels], ); const sidebarChannels = React.useMemo( - () => memberChannels.filter((channel) => channel.archivedAt === null), - [memberChannels], + () => + memberChannels.filter( + (channel) => + channel.archivedAt === null && + shouldShowSidebarChannel( + channel, + huddleBackingChannelIds, + revealedHuddleChannelIds, + ), + ), + [huddleBackingChannelIds, memberChannels, revealedHuddleChannelIds], ); const hasRestoredCommunityDestinationRef = React.useRef(false); React.useEffect(() => { @@ -308,6 +326,7 @@ export function AppShell() { handleThreadReplyDesktopNotification, } = useAppShellDesktopNotifications({ channels, + enabled: !isHuddleRoom, goChannel, goHome, notificationSettings: notificationSettings.settings, @@ -344,19 +363,23 @@ export function AppShell() { mutedRootIds, muteThread, unmuteThread, - } = useUnreadChannels(sidebarChannels, activeChannel, { - pubkey: identityQuery.data?.pubkey, - relayClient, - relayUrl: communitiesHook.activeCommunity?.relayUrl, - currentPubkey: identityQuery.data?.pubkey, - mutedChannelIds, - notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing, - onChannelMessage: handleChannelNotification, - onDmMessage: handleDmNotification, - onLiveMention: refetchHomeFeedFromLiveSignal, - onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification, - followedRootIds, - }); + } = useUnreadChannels( + isHuddleRoom ? EMPTY_CHANNELS : sidebarChannels, + isHuddleRoom ? null : activeChannel, + { + pubkey: identityQuery.data?.pubkey, + relayClient, + relayUrl: communitiesHook.activeCommunity?.relayUrl, + currentPubkey: identityQuery.data?.pubkey, + mutedChannelIds, + notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing, + onChannelMessage: handleChannelNotification, + onDmMessage: handleDmNotification, + onLiveMention: refetchHomeFeedFromLiveSignal, + onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification, + followedRootIds, + }, + ); const { getThreadReadAt, @@ -397,6 +420,7 @@ export function AppShell() { markChannelRead, unreadThreadFeedItems, ]); + // Badge count consumes the shared NIP-RS read-state from useUnreadChannels. const { homeBadgeCount, homeBadgeCountExcludingHighPriority } = useHomeFeedNotificationState( @@ -404,6 +428,7 @@ export function AppShell() { identityQuery.data?.pubkey, notificationSettings.settings, notificationSettings.setDesktopEnabled, + !isHuddleRoom, selectedView === "home" && !settingsOpen, getChannelReadAt, readStateVersion, @@ -603,18 +628,20 @@ export function AppShell() { [openSearchHit], ); useAppShellLifecycleEffects({ + desktopBadgeEnabled: !isHuddleRoom, homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, }); - // Dispatch `buzz://message` deep links into the router. - useMessageDeepLinks(); + // Dispatch `buzz://message` deep links only from the main window. The + // companion is dedicated to its active Huddle route. + useMessageDeepLinks(!isHuddleRoom); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], ); React.useLayoutEffect(() => { - if (settingsOpen) { + if (settingsOpen || isHuddleRoom) { return; } @@ -674,12 +701,13 @@ export function AppShell() { handleOpenSearch, goNewMessage, goHome, + isHuddleRoom, settingsOpen, ]); useSettingsShortcuts({ onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, - open: settingsOpen, + open: isHuddleRoom ? undefined : settingsOpen, }); useMarkAsReadShortcuts({ activeChannelId: activeChannel?.id ?? null, @@ -690,11 +718,13 @@ export function AppShell() { }); return ( - + {!isHuddleRoom ? ( + + ) : null} - - -
-
- - {hasCommunityRail ? ( - void handleRemoveCommunity(id)} - onReorderCommunities={communitiesHook.reorderCommunities} - onSwitchCommunity={handleSwitchCommunity} - onUpdateCommunity={communitiesHook.updateCommunity} - communities={communitiesHook.communities} - /> - ) : null} - - - {!settingsOpen ? ( - - ) : null} - {settingsOpen ? ( -
- - - -
- ) : ( -
- { - const id = communitiesHook.addCommunity({ - ...community, - pubkey: - community.pubkey ?? - identityQuery.data?.pubkey, - }); - handleSwitchCommunity(id); - }} - onAddCommunityOpenChange={ - addCommunityDialog.onOpenChange - } - onNewMessage={goNewMessage} - onBackgroundClick={requestFocusedThreadClose} - onCreateChannelOpenChange={setIsCreateChannelOpen} - onOpenAddCommunity={addCommunityDialog.openDialog} - onSendFeedback={() => setIsSendFeedbackOpen(true)} - onUpdateCommunity={communitiesHook.updateCommunity} - onRemoveCommunity={(id) => - void handleRemoveCommunity(id) - } - onSwitchCommunity={handleSwitchCommunity} - onCreateAgent={() => requestOpenCreateAgent()} - selfPresenceStatus={presenceSession.currentStatus} - communities={communitiesHook.communities} - onCreateChannel={handleCreateChannel} - onCreateForum={handleCreateForum} - onHideDm={handleHideDm} - onMarkAllChannelsRead={markAllChannelsRead} - onMarkChannelRead={markChannelRead} - onMarkChannelUnread={markChannelUnread} - onBrowseChannels={handleOpenBrowseChannels} - onOpenDm={async ({ pubkeys }) => { - const directMessage = - await openDmMutation.mutateAsync({ - pubkeys, - }); - await goChannel(directMessage.id); - }} - onSelectAgents={() => void goAgents()} - onSelectChannel={(channelId) => - void goChannel(channelId) - } - onOpenSearchResult={handleOpenSearchResult} - searchChannels={channels} - searchFocusRequest={searchFocusRequest} - onSelectHome={() => void goHome()} - onSelectProjects={() => void goProjects()} - onSelectPulse={() => void goPulse()} - onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => void goWorkflows()} - onSetPresenceStatus={(status) => - presenceSession.setStatus(status) - } - onSetUserStatus={(text, emoji) => - setUserStatusMutation.mutate({ text, emoji }) - } - onClearUserStatus={() => - setUserStatusMutation.mutate({ - text: "", - emoji: "", - }) - } - profile={profileQuery.data} - selfUserStatus={ - deferredPubkey - ? (selfStatusQuery.data?.[ - deferredPubkey.toLowerCase() - ] ?? undefined) - : undefined - } - selectedChannelId={selectedChannelId} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - unreadChannelCounts={unreadChannelCounts} - mutedChannelIds={mutedChannelIds} - onMuteChannel={muteChannel} - onUnmuteChannel={unmuteChannel} - starredChannelIds={starredChannelIds} - onStarChannel={starChannel} - onUnstarChannel={unstarChannel} - /> - - - - - - - - -
- )} - - - { - setIsChannelManagementOpen(open); - if (!open) { - setManagedChannelId(null); - } - }} - onDeleteActiveChannel={() => { - setIsChannelManagementOpen(false); - setManagedChannelId(null); - void goHome({ replace: true }); - }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - /> - -
-
-
- -
- { - void goChannel(channelId, { - messageId, - threadRootId: messageId, - }); - }} - onVisibilityChange={setIsHuddleDrawerOpen} + + {hasCommunityRail && !isHuddleRoom ? ( + void handleRemoveCommunity(id)} + onReorderCommunities={communitiesHook.reorderCommunities} + onSwitchCommunity={handleSwitchCommunity} + onUpdateCommunity={communitiesHook.updateCommunity} + communities={communitiesHook.communities} + /> + ) : null} + + + {!settingsOpen && !isHuddleRoom ? ( + -
-
-
-
+ ) : null} + {settingsOpen ? ( +
+ + + +
+ ) : ( +
+ {!isHuddleRoom ? ( + { + const id = communitiesHook.addCommunity({ + ...community, + pubkey: + community.pubkey ?? identityQuery.data?.pubkey, + }); + handleSwitchCommunity(id); + }} + onAddCommunityOpenChange={ + addCommunityDialog.onOpenChange + } + onNewMessage={goNewMessage} + onBackgroundClick={requestFocusedThreadClose} + onCreateChannelOpenChange={setIsCreateChannelOpen} + onOpenAddCommunity={addCommunityDialog.openDialog} + onSendFeedback={() => setIsSendFeedbackOpen(true)} + onUpdateCommunity={communitiesHook.updateCommunity} + onRemoveCommunity={(id) => + void handleRemoveCommunity(id) + } + onSwitchCommunity={handleSwitchCommunity} + onCreateAgent={() => requestOpenCreateAgent()} + selfPresenceStatus={presenceSession.currentStatus} + communities={communitiesHook.communities} + onCreateChannel={handleCreateChannel} + onCreateForum={handleCreateForum} + onHideDm={handleHideDm} + onHuddleEnded={handleHuddleEnded} + onMarkAllChannelsRead={markAllChannelsRead} + onMarkChannelRead={markChannelRead} + onMarkChannelUnread={markChannelUnread} + onBrowseChannels={handleOpenBrowseChannels} + onOpenDm={async ({ pubkeys }) => { + const directMessage = + await openDmMutation.mutateAsync({ + pubkeys, + }); + await goChannel(directMessage.id); + }} + onSelectAgents={() => void goAgents()} + onSelectChannel={handleSidebarChannelSelect} + onOpenSearchResult={handleOpenSearchResult} + searchChannels={channels} + searchFocusRequest={searchFocusRequest} + onSelectHome={() => void goHome()} + onSelectProjects={() => void goProjects()} + onSelectPulse={() => void goPulse()} + onSelectSettings={handleOpenSettings} + onSelectWorkflows={() => void goWorkflows()} + onSetPresenceStatus={(status) => + presenceSession.setStatus(status) + } + onSetUserStatus={(text, emoji) => + setUserStatusMutation.mutate({ text, emoji }) + } + onClearUserStatus={() => + setUserStatusMutation.mutate({ + text: "", + emoji: "", + }) + } + profile={profileQuery.data} + selfUserStatus={ + deferredPubkey + ? (selfStatusQuery.data?.[ + deferredPubkey.toLowerCase() + ] ?? undefined) + : undefined + } + selectedChannelId={selectedChannelId} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + unreadChannelCounts={unreadChannelCounts} + mutedChannelIds={mutedChannelIds} + onMuteChannel={muteChannel} + onUnmuteChannel={unmuteChannel} + starredChannelIds={starredChannelIds} + onStarChannel={starChannel} + onUnstarChannel={unstarChannel} + /> + ) : null} + + + + {!isHuddleRoom ? ( + + ) : null} +
+ )} + + + { + setIsChannelManagementOpen(open); + if (!open) { + setManagedChannelId(null); + } + }} + onDeleteActiveChannel={() => { + setIsChannelManagementOpen(false); + setManagedChannelId(null); + void goHome({ replace: true }); + }} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + /> + +
+ + diff --git a/desktop/src/app/AppShellChannelSurface.tsx b/desktop/src/app/AppShellChannelSurface.tsx new file mode 100644 index 000000000..4ab2ea1df --- /dev/null +++ b/desktop/src/app/AppShellChannelSurface.tsx @@ -0,0 +1,43 @@ +import type * as React from "react"; +import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; +import { HuddleRoomHeader, HuddleStartingView } from "@/features/huddle"; +import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; +import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; +import { cn } from "@/shared/lib/cn"; +import { SidebarInset } from "@/shared/ui/sidebar"; + +type AppShellChannelSurfaceProps = { + children: React.ReactNode; + isHuddleRoom: boolean; + isHuddleRoomStarting: boolean; + mainInsetRef: React.RefObject; +}; + +export function AppShellChannelSurface({ + children, + isHuddleRoom, + isHuddleRoomStarting, + mainInsetRef, +}: AppShellChannelSurfaceProps) { + return ( + + + {isHuddleRoom && !isHuddleRoomStarting ? : null} + + {isHuddleRoomStarting ? : children} + + + + ); +} diff --git a/desktop/src/app/BuzzThemeSurfaces.tsx b/desktop/src/app/BuzzThemeSurfaces.tsx index 80461f018..4976fc2ed 100644 --- a/desktop/src/app/BuzzThemeSurfaces.tsx +++ b/desktop/src/app/BuzzThemeSurfaces.tsx @@ -7,6 +7,7 @@ export function GradientLayer() { className="buzz-theme-gradient-layer pointer-events-none absolute inset-0 -z-10" data-buzz-gradient-layer > +
{children}
diff --git a/desktop/src/app/LazySettingsScreen.tsx b/desktop/src/app/LazySettingsScreen.tsx new file mode 100644 index 000000000..8308ec723 --- /dev/null +++ b/desktop/src/app/LazySettingsScreen.tsx @@ -0,0 +1,6 @@ +import * as React from "react"; + +export const LazySettingsScreen = React.lazy(async () => { + const module = await import("@/features/settings/ui/SettingsScreen"); + return { default: module.SettingsScreen }; +}); diff --git a/desktop/src/app/huddleBackingChannelStorage.test.mjs b/desktop/src/app/huddleBackingChannelStorage.test.mjs new file mode 100644 index 000000000..f88c0e1fe --- /dev/null +++ b/desktop/src/app/huddleBackingChannelStorage.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +class MemoryStorage { + values = new Map(); + getItem(key) { + return this.values.get(key) ?? null; + } + setItem(key, value) { + this.values.set(key, value); + } +} + +globalThis.window = { localStorage: new MemoryStorage() }; +const { loadHuddleBackingChannelIds, rememberHuddleBackingChannelId } = + await import("./huddleBackingChannelStorage.ts"); + +test("restores remembered Huddle backing channels", () => { + rememberHuddleBackingChannelId("first"); + rememberHuddleBackingChannelId("second"); + rememberHuddleBackingChannelId("first"); + assert.deepEqual([...loadHuddleBackingChannelIds()], ["second", "first"]); +}); + +test("bounds persisted backing channels", () => { + for (let index = 0; index < 105; index += 1) { + rememberHuddleBackingChannelId(`channel-${index}`); + } + const ids = [...loadHuddleBackingChannelIds()]; + assert.equal(ids.length, 100); + assert.equal(ids.at(0), "channel-5"); + assert.equal(ids.at(-1), "channel-104"); +}); diff --git a/desktop/src/app/huddleBackingChannelStorage.ts b/desktop/src/app/huddleBackingChannelStorage.ts new file mode 100644 index 000000000..44b21a25d --- /dev/null +++ b/desktop/src/app/huddleBackingChannelStorage.ts @@ -0,0 +1,36 @@ +const STORAGE_KEY = "buzz:huddle-backing-channel-ids:v1"; +const MAX_TRACKED_CHANNELS = 100; + +function readStoredIds(): string[] { + try { + const value = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "[]"); + return Array.isArray(value) + ? value.filter((id): id is string => typeof id === "string") + : []; + } catch { + return []; + } +} + +/** Restores Huddle implementation channels after an abnormal app restart. */ +export function loadHuddleBackingChannelIds(): ReadonlySet { + return new Set(readStoredIds()); +} + +/** + * Remembers a backing channel beyond the native Huddle process lifetime. + * Relay archive/removal can lag, so IDs remain hidden if the app crashes or is + * force-quit. The bounded list prevents abandoned local state growing forever. + */ +export function rememberHuddleBackingChannelId(channelId: string): void { + const ids = readStoredIds().filter((id) => id !== channelId); + ids.push(channelId); + try { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify(ids.slice(-MAX_TRACKED_CHANNELS)), + ); + } catch { + // Storage can be unavailable in browser previews or locked-down webviews. + } +} diff --git a/desktop/src/app/huddleChannelVisibility.test.mjs b/desktop/src/app/huddleChannelVisibility.test.mjs new file mode 100644 index 000000000..b08306e9e --- /dev/null +++ b/desktop/src/app/huddleChannelVisibility.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isHuddleBackingChannel, + shouldShowSidebarChannel, +} from "./huddleChannelVisibility.ts"; + +function channel(overrides = {}) { + return { + id: "channel-id", + name: "general", + ttlSeconds: null, + ...overrides, + }; +} + +test("ordinary channels stay visible without an explicit reveal", () => { + assert.equal(shouldShowSidebarChannel(channel(), new Set(), new Set()), true); +}); + +test("tracked huddle backing channels stay hidden by default", () => { + const huddle = channel({ + id: "stale-huddle", + name: "general huddle", + ttlSeconds: 3_600, + }); + const huddleBackingChannelIds = new Set([huddle.id]); + + assert.equal(isHuddleBackingChannel(huddle, huddleBackingChannelIds), true); + assert.equal( + shouldShowSidebarChannel(huddle, huddleBackingChannelIds, new Set()), + false, + ); +}); + +test("an explicitly revealed huddle channel appears in the sidebar", () => { + const huddle = channel({ + id: "active-huddle", + name: "huddle", + ttlSeconds: 3_600, + }); + + assert.equal( + shouldShowSidebarChannel( + huddle, + new Set([huddle.id]), + new Set([huddle.id]), + ), + true, + ); +}); + +test("one-hour channels with huddle-shaped names remain ordinary", () => { + const ordinaryChannel = channel({ + name: "design huddle", + ttlSeconds: 3_600, + }); + + assert.equal(isHuddleBackingChannel(ordinaryChannel, new Set()), false); + assert.equal( + shouldShowSidebarChannel(ordinaryChannel, new Set(), new Set()), + true, + ); +}); diff --git a/desktop/src/app/huddleChannelVisibility.ts b/desktop/src/app/huddleChannelVisibility.ts new file mode 100644 index 000000000..cee0717be --- /dev/null +++ b/desktop/src/app/huddleChannelVisibility.ts @@ -0,0 +1,19 @@ +import type { Channel } from "@/shared/api/types"; + +export function isHuddleBackingChannel( + channel: Channel, + huddleBackingChannelIds: ReadonlySet, +): boolean { + return huddleBackingChannelIds.has(channel.id); +} + +export function shouldShowSidebarChannel( + channel: Channel, + huddleBackingChannelIds: ReadonlySet, + revealedHuddleChannelIds: ReadonlySet, +): boolean { + return ( + !isHuddleBackingChannel(channel, huddleBackingChannelIds) || + revealedHuddleChannelIds.has(channel.id) + ); +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index d19ac0312..5afd0e7e4 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -171,6 +171,8 @@ export function useAppNavigation() { autoSend?: string; messageId?: string; replace?: boolean; + /** Open this thread panel directly without waiting for a timeline row. */ + thread?: string; threadRootId?: string | null; }, ) => @@ -190,6 +192,7 @@ export function useAppNavigation() { ...(options?.agentSession ? { agentSession: options.agentSession } : {}), + ...(options?.thread ? { thread: options.thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), }, }, diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 9e689ab50..d626179eb 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -4,6 +4,8 @@ import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; import { ChannelScreen } from "@/features/channels/ui/ChannelScreen"; +import { HuddleStartingView } from "@/features/huddle/components/HuddleStartingView"; +import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { getThreadReference, isBroadcastReply, @@ -102,6 +104,7 @@ export function ChannelRouteScreen({ targetReplyId, targetThreadRootId, }: ChannelRouteScreenProps) { + const isHuddleTranscript = huddleWindowChannelId() !== null; const { closeForumPost, goForumPost } = useAppNavigation(); const channelsQuery = useChannelsQuery(); const identityQuery = useIdentityQuery(); @@ -186,6 +189,9 @@ export function ChannelRouteScreen({ }, [selectedPostId, targetMessageId, targetThreadRootId]); if (channelsQuery.isPending && !activeChannel) { + if (isHuddleTranscript) { + return ; + } return ( { function ChannelRouteComponent() { const { channelId } = Route.useParams(); const search = Route.useSearch(); + const isHuddleTranscript = huddleWindowChannelId() !== null; return ( } + fallback={ + isHuddleTranscript ? ( + + ) : ( + + ) + } > Promise; goHome: () => Promise; notificationSettings: NotificationSettings; @@ -42,6 +44,7 @@ export function useAppShellDesktopNotifications({ }) { const handleChannelNotification = React.useEffectEvent( (_channelId: string, event: RelayEvent) => { + if (!enabled) return; if (!shouldBounceForChannelNotification(event.tags)) return; if (!notificationSettings.desktopEnabled) return; void requestDockBounce(); @@ -50,6 +53,7 @@ export function useAppShellDesktopNotifications({ const handleDmNotification = React.useEffectEvent( (event: RelayEvent, channel: Channel) => { + if (!enabled) return; if ( !notificationSettings.desktopEnabled || !notificationSettings.slotAlertsEnabled.dm @@ -84,6 +88,7 @@ export function useAppShellDesktopNotifications({ const handleThreadReplyDesktopNotification = React.useEffectEvent( (channelId: string, event: RelayEvent) => { + if (!enabled) return; if ( !notificationSettings.desktopEnabled || !notificationSettings.slotAlertsEnabled.thread_reply @@ -151,6 +156,7 @@ export function useAppShellDesktopNotifications({ ); React.useEffect(() => { + if (!enabled) return; let isCancelled = false; let cleanup = () => {}; @@ -173,7 +179,7 @@ export function useAppShellDesktopNotifications({ isCancelled = true; cleanup(); }; - }, []); + }, [enabled]); return { handleChannelNotification, diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index cc0447cc3..02c97bac5 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -4,12 +4,14 @@ import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { relayClient } from "@/shared/api/relayClient"; type AppShellLifecycleEffectsOptions = { + desktopBadgeEnabled: boolean; homeBadgeCountExcludingHighPriority: number; unreadChannelIds: ReadonlySet; unreadChannelNotificationCount: number; }; export function useAppShellLifecycleEffects({ + desktopBadgeEnabled, homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, @@ -64,6 +66,10 @@ export function useAppShellLifecycleEffects({ }, []); React.useEffect(() => { + if (!desktopBadgeEnabled) { + return; + } + const count = unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority; void setDesktopAppBadge( @@ -72,6 +78,7 @@ export function useAppShellLifecycleEffects({ : { kind: unreadChannelIds.size ? "dot" : "none" }, ); }, [ + desktopBadgeEnabled, homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, diff --git a/desktop/src/app/useHuddlePresentation.ts b/desktop/src/app/useHuddlePresentation.ts new file mode 100644 index 000000000..ddc867471 --- /dev/null +++ b/desktop/src/app/useHuddlePresentation.ts @@ -0,0 +1,444 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useLocation } from "@tanstack/react-router"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import * as React from "react"; +import { + loadHuddleBackingChannelIds, + rememberHuddleBackingChannelId, +} from "@/app/huddleBackingChannelStorage"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { channelsQueryKey } from "@/features/channels/hooks"; +import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; +import { + channelMessagesKey, + channelWindowKey, +} from "@/features/messages/lib/messageQueryKeys"; + +type HuddleTranscriptRouteState = { + phase: + | "idle" + | "creating" + | "connecting" + | "connected" + | "active" + | "leaving"; + parent_channel_id: string | null; + ephemeral_channel_id: string | null; + huddle_thread_event_id: string | null; +}; + +export function useHuddlePresentation() { + const huddleRoomChannelId = huddleWindowChannelId(); + const isHuddleRoom = huddleRoomChannelId !== null; + const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); + const [isHuddleCompanionOpen, setIsHuddleCompanionOpen] = + React.useState(false); + const [isHuddleStartPending, setIsHuddleStartPending] = React.useState(false); + const [revealedHuddleChannelIds, setRevealedHuddleChannelIds] = + React.useState>(() => new Set()); + const [huddleBackingChannelIds, setHuddleBackingChannelIds] = React.useState< + ReadonlySet + >(loadHuddleBackingChannelIds); + const activeHuddleChannelIdRef = React.useRef(null); + const huddleCompanionChannelIdRef = React.useRef(null); + const huddleCompanionDismissedChannelIdRef = React.useRef( + null, + ); + const huddleCompanionOpenPromiseRef = React.useRef | null>( + null, + ); + const activeHuddleParentChannelIdRef = React.useRef(null); + const [huddleTranscriptRoute, setHuddleTranscriptRoute] = + React.useState(null); + const location = useLocation(); + const queryClient = useQueryClient(); + const { goChannel } = useAppNavigation(); + + React.useEffect(() => { + if (!isHuddleRoom) return; + + let cancelled = false; + let unlisten: (() => void) | null = null; + const syncRoute = (state: HuddleTranscriptRouteState) => { + if (!cancelled) setHuddleTranscriptRoute(state); + }; + + void invoke("get_huddle_state") + .then(syncRoute) + .catch((error) => { + console.error("Failed to resolve huddle transcript route:", error); + if (!cancelled) { + setHuddleTranscriptRoute({ + ephemeral_channel_id: huddleRoomChannelId, + huddle_thread_event_id: null, + parent_channel_id: null, + phase: "active", + }); + } + }); + void listen("huddle-state-changed", (event) => + syncRoute(event.payload), + ).then((cleanup) => { + if (cancelled) cleanup(); + else unlisten = cleanup; + }); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [huddleRoomChannelId, isHuddleRoom]); + + const huddleRouteResolved = huddleTranscriptRoute !== null; + const huddleRouteEphemeralChannelId = + huddleTranscriptRoute?.ephemeral_channel_id ?? null; + const huddleRouteIsActive = huddleTranscriptRoute?.phase === "active"; + const huddleRouteDestinationChannelId = + huddleRouteEphemeralChannelId ?? huddleRoomChannelId; + const huddleRouteMatchesLocation = Boolean( + huddleRouteDestinationChannelId && + location.pathname === `/channels/${huddleRouteDestinationChannelId}`, + ); + const isHuddleRoomStarting = + isHuddleRoom && + (!huddleRouteResolved || + !huddleRouteIsActive || + !huddleRouteMatchesLocation); + + React.useEffect(() => { + if (!huddleRoomChannelId || !huddleRouteResolved || !huddleRouteIsActive) { + return; + } + + let cancelled = false; + const channelId = huddleRouteEphemeralChannelId ?? huddleRoomChannelId; + void Promise.all([ + queryClient.invalidateQueries({ queryKey: channelsQueryKey }), + queryClient.invalidateQueries({ + queryKey: channelMessagesKey(channelId), + }), + queryClient.invalidateQueries({ queryKey: channelWindowKey(channelId) }), + ]).then(() => { + if (!cancelled) void goChannel(channelId, { replace: true }); + }); + + return () => { + cancelled = true; + }; + }, [ + goChannel, + huddleRoomChannelId, + huddleRouteEphemeralChannelId, + huddleRouteIsActive, + huddleRouteResolved, + queryClient, + ]); + + const handleHuddleStartPendingChange = React.useCallback( + (pending: boolean) => { + setIsHuddleStartPending(pending); + if (pending) setIsHuddleDrawerOpen(false); + }, + [], + ); + const handleHuddleVisibilityChange = React.useCallback( + (visible: boolean) => { + setIsHuddleDrawerOpen( + visible && !isHuddleStartPending && !isHuddleCompanionOpen, + ); + }, + [isHuddleCompanionOpen, isHuddleStartPending], + ); + const hideHuddleChannel = React.useCallback( + (ephemeralChannelId: string | null | undefined) => { + if (!ephemeralChannelId) return; + setRevealedHuddleChannelIds((current) => { + if (!current.has(ephemeralChannelId)) return current; + const next = new Set(current); + next.delete(ephemeralChannelId); + return next; + }); + }, + [], + ); + const trackHuddleBackingChannel = React.useCallback( + (ephemeralChannelId: string) => { + rememberHuddleBackingChannelId(ephemeralChannelId); + setHuddleBackingChannelIds((current) => { + if (current.has(ephemeralChannelId)) return current; + const next = new Set(current); + next.add(ephemeralChannelId); + return next; + }); + }, + [], + ); + const revealHuddleChannel = React.useCallback( + (ephemeralChannelId: string) => { + setRevealedHuddleChannelIds((current) => { + if (current.has(ephemeralChannelId)) return current; + const next = new Set(current); + next.add(ephemeralChannelId); + return next; + }); + }, + [], + ); + const returnMainWindowToHuddleParent = React.useCallback( + (state: HuddleTranscriptRouteState) => { + const ephemeralChannelId = state.ephemeral_channel_id; + const parentChannelId = state.parent_channel_id; + if (parentChannelId) { + activeHuddleParentChannelIdRef.current = parentChannelId; + } + if ( + ephemeralChannelId && + parentChannelId && + location.pathname === `/channels/${ephemeralChannelId}` + ) { + void goChannel(parentChannelId, { replace: true }); + } + }, + [goChannel, location.pathname], + ); + const handleHuddleCompanionOpen = React.useCallback(() => { + const ephemeralChannelId = activeHuddleChannelIdRef.current; + huddleCompanionDismissedChannelIdRef.current = null; + hideHuddleChannel(ephemeralChannelId); + setIsHuddleDrawerOpen(false); + setIsHuddleCompanionOpen(true); + + const parentChannelId = activeHuddleParentChannelIdRef.current; + if ( + ephemeralChannelId && + parentChannelId && + location.pathname === `/channels/${ephemeralChannelId}` + ) { + void goChannel(parentChannelId, { replace: true }); + return; + } + + void invoke("get_huddle_state") + .then(returnMainWindowToHuddleParent) + .catch((error) => { + console.error("Failed to restore the huddle parent channel:", error); + }); + }, [ + goChannel, + hideHuddleChannel, + location.pathname, + returnMainWindowToHuddleParent, + ]); + const openHuddleCompanion = React.useCallback( + (ephemeralChannelId: string) => { + activeHuddleChannelIdRef.current = ephemeralChannelId; + trackHuddleBackingChannel(ephemeralChannelId); + + if (huddleCompanionDismissedChannelIdRef.current === ephemeralChannelId) { + return Promise.resolve(); + } + + huddleCompanionDismissedChannelIdRef.current = null; + hideHuddleChannel(ephemeralChannelId); + setIsHuddleDrawerOpen(false); + setIsHuddleCompanionOpen(true); + + if ( + huddleCompanionChannelIdRef.current === ephemeralChannelId && + huddleCompanionOpenPromiseRef.current + ) { + return huddleCompanionOpenPromiseRef.current; + } + + huddleCompanionChannelIdRef.current = ephemeralChannelId; + const openPromise = invoke("open_huddle_window").catch((error) => { + if (huddleCompanionChannelIdRef.current === ephemeralChannelId) { + huddleCompanionChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleCompanionOpen(false); + } + throw error; + }); + huddleCompanionOpenPromiseRef.current = openPromise; + return openPromise; + }, + [hideHuddleChannel, trackHuddleBackingChannel], + ); + const handleHuddleStarted = React.useCallback( + async (ephemeralChannelId: string) => { + try { + await openHuddleCompanion(ephemeralChannelId); + } catch (error) { + revealHuddleChannel(ephemeralChannelId); + throw error; + } + }, + [openHuddleCompanion, revealHuddleChannel], + ); + const viewHuddleChannel = React.useCallback( + (ephemeralChannelId: string) => { + revealHuddleChannel(ephemeralChannelId); + void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + void queryClient.invalidateQueries({ + queryKey: channelMessagesKey(ephemeralChannelId), + }); + void queryClient.invalidateQueries({ + queryKey: channelWindowKey(ephemeralChannelId), + }); + void goChannel(ephemeralChannelId); + }, + [goChannel, queryClient, revealHuddleChannel], + ); + const showHuddleInMainApp = React.useCallback( + (ephemeralChannelId: string) => { + activeHuddleChannelIdRef.current = ephemeralChannelId; + trackHuddleBackingChannel(ephemeralChannelId); + viewHuddleChannel(ephemeralChannelId); + }, + [trackHuddleBackingChannel, viewHuddleChannel], + ); + const handleSidebarChannelSelect = React.useCallback( + (channelId: string) => { + if ( + isHuddleDrawerOpen && + channelId === activeHuddleChannelIdRef.current + ) { + showHuddleInMainApp(channelId); + return; + } + void goChannel(channelId); + }, + [goChannel, isHuddleDrawerOpen, showHuddleInMainApp], + ); + const handleHuddleEnded = React.useCallback( + (ephemeralChannelId: string | null) => { + const endedChannelId = + ephemeralChannelId ?? activeHuddleChannelIdRef.current; + hideHuddleChannel(endedChannelId); + activeHuddleChannelIdRef.current = null; + huddleCompanionChannelIdRef.current = null; + huddleCompanionDismissedChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleCompanionOpen(false); + void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + }, + [hideHuddleChannel, queryClient], + ); + + React.useEffect(() => { + if (isHuddleRoom) return; + + let cancelled = false; + let unlisten: (() => void) | null = null; + void listen("huddle-companion-returned", () => { + if (cancelled) return; + huddleCompanionDismissedChannelIdRef.current = + activeHuddleChannelIdRef.current; + huddleCompanionChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleCompanionOpen(false); + setIsHuddleDrawerOpen(true); + void invoke("get_huddle_state") + .then((state) => { + if (!state.ephemeral_channel_id) return; + if (state.parent_channel_id) { + activeHuddleParentChannelIdRef.current = state.parent_channel_id; + } + showHuddleInMainApp(state.ephemeral_channel_id); + }) + .catch((error) => { + console.error("Failed to open huddle in the main app:", error); + }); + }).then((cleanup) => { + if (cancelled) cleanup(); + else unlisten = cleanup; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [isHuddleRoom, showHuddleInMainApp]); + + React.useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | null = null; + void invoke("get_huddle_state") + .then((state) => { + if (cancelled || !state.ephemeral_channel_id) return; + activeHuddleChannelIdRef.current = state.ephemeral_channel_id; + trackHuddleBackingChannel(state.ephemeral_channel_id); + if (state.parent_channel_id) { + activeHuddleParentChannelIdRef.current = state.parent_channel_id; + } + }) + .catch(() => { + /* lifecycle events remain authoritative */ + }); + listen("huddle-state-changed", (event) => { + if (cancelled) return; + if (event.payload.ephemeral_channel_id) { + activeHuddleChannelIdRef.current = event.payload.ephemeral_channel_id; + trackHuddleBackingChannel(event.payload.ephemeral_channel_id); + } + if (event.payload.parent_channel_id) { + activeHuddleParentChannelIdRef.current = + event.payload.parent_channel_id; + } + if ( + !isHuddleRoom && + event.payload.phase === "creating" && + event.payload.ephemeral_channel_id + ) { + void openHuddleCompanion(event.payload.ephemeral_channel_id).catch( + (error) => { + console.error("Failed to open starting huddle window:", error); + }, + ); + } + if (event.payload.phase === "idle") { + hideHuddleChannel(activeHuddleChannelIdRef.current); + activeHuddleChannelIdRef.current = null; + activeHuddleParentChannelIdRef.current = null; + huddleCompanionChannelIdRef.current = null; + huddleCompanionDismissedChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleDrawerOpen(false); + setIsHuddleCompanionOpen(false); + setIsHuddleStartPending(false); + void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + } + }).then((cleanup) => { + if (cancelled) cleanup(); + else unlisten = cleanup; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [ + hideHuddleChannel, + isHuddleRoom, + openHuddleCompanion, + queryClient, + trackHuddleBackingChannel, + ]); + + return { + handleHuddleCompanionOpen, + handleHuddleEnded, + handleHuddleStartPendingChange, + handleHuddleStarted, + handleHuddleVisibilityChange, + handleSidebarChannelSelect, + huddleBackingChannelIds, + revealedHuddleChannelIds, + isHuddleCompanionOpen, + isHuddleDrawerOpen, + isHuddleRoom, + isHuddleRoomStarting, + isHuddleStartPending, + showHuddleInMainApp, + viewHuddleChannel, + }; +} diff --git a/desktop/src/app/useSettingsShortcuts.ts b/desktop/src/app/useSettingsShortcuts.ts index 0d0813a35..757ddc65e 100644 --- a/desktop/src/app/useSettingsShortcuts.ts +++ b/desktop/src/app/useSettingsShortcuts.ts @@ -5,7 +5,7 @@ import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; type UseSettingsShortcutsOptions = { onClose: () => void; onOpenSettings: () => void; - open: boolean; + open?: boolean; }; export function useSettingsShortcuts({ @@ -14,6 +14,8 @@ export function useSettingsShortcuts({ open, }: UseSettingsShortcutsOptions) { React.useLayoutEffect(() => { + if (open === undefined) return; + function handleKeyDown(event: KeyboardEvent) { const isSettingsShortcut = hasPrimaryShortcutModifier(event) && diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e5..b39a7e8ec 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -33,6 +33,7 @@ import { getManagedAgentLog, getRuntimeFileConfig, installAcpRuntime, + invokeTauri, listManagedAgents, listRelayAgents, saveCustomHarness, @@ -647,6 +648,12 @@ export function useAttachManagedAgentToChannelMutation( pubkey: result.agent.pubkey, }), ); + void invokeTauri("sync_agents_to_active_huddle", { + channelId: effectiveChannelId, + agentPubkeys: [result.agent.pubkey], + }).catch((error) => { + console.warn("Could not sync attached agent into Huddle:", error); + }); }, onSettled: (_data, _err, variables) => { // Invalidate the effective channel (the one the server actually mutated) diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 9003d0f5a..8829edab3 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -14,6 +14,7 @@ import { joinChannel, leaveChannel, openDm, + invokeTauri, removeChannelMember, setCanvas, setChannelPurpose, @@ -517,6 +518,21 @@ export function useAddChannelMembersMutation(channelId: string | null) { return addChannelMembers({ ...rest, channelId: effectiveChannelId }); }, + onSuccess: (result, variables) => { + const effectiveChannelId = variables.channelId ?? channelId; + if ( + effectiveChannelId && + variables.role === "bot" && + result.added.length > 0 + ) { + void invokeTauri("sync_agents_to_active_huddle", { + channelId: effectiveChannelId, + agentPubkeys: result.added, + }).catch((error) => { + console.warn("Could not sync added agents into Huddle:", error); + }); + } + }, onSettled: async (_data, _err, variables) => { // Invalidate the effective channel (the one actually mutated) not the // live hook-closure channel, which may have changed mid-send. diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index 7b9bf2b79..2debd9e7a 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -165,8 +165,8 @@ export function ChannelMembersBar({ members, }), ); - // Refetch channels so the new ephemeral channel appears in the sidebar immediately - // (default poll interval is 60s — too slow for huddle UX). + // Keep the channel cache current so the ephemeral transcript is + // available immediately if the huddle returns to the in-app drawer. void queryClient.invalidateQueries({ queryKey: ["channels"] }); } catch (e) { console.error("Failed to start huddle:", e); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index a30ee2411..cb0600a28 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -43,6 +43,20 @@ export function isWelcomeSetupSystemMessage(message: TimelineMessage) { } } +export function isChannelCreatedSystemMessage(message: TimelineMessage) { + if (message.kind !== KIND_SYSTEM_MESSAGE) { + return false; + } + + try { + return ( + (JSON.parse(message.body) as { type?: string }).type === "channel_created" + ); + } catch { + return false; + } +} + export function mentionsKnownAgent( mentionPubkeys: string[], knownAgentPubkeys: ReadonlySet, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 770c45e44..54c0dcd5d 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -48,16 +48,14 @@ import { WELCOME_PERSONA_ROTATION_MS, type WelcomeComposerBannerState, } from "@/features/channels/ui/WelcomeComposerBanner"; -import { - isWelcomeSetupSystemMessage, - mentionsKnownAgent, -} from "@/features/channels/ui/ChannelPane.helpers"; +import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; +import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel"; +import { useChannelPaneMessages } from "@/features/channels/ui/useChannelPaneMessages"; import { Button } from "@/shared/ui/button"; -import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome"; @@ -65,6 +63,12 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; + +const HUDDLE_TRANSCRIPT_ROOT_STYLE = { + "--buzz-channel-content-top-padding": "0rem", + "--channel-top-chrome-height": "0.25rem", +} as React.CSSProperties; + export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -83,6 +87,7 @@ export const ChannelPane = React.memo(function ChannelPane({ hasOlderMessages, historyExhausted, isFetchingOlder, + isHuddleTranscript = false, followThreadById, isFollowingThread, isFollowingThreadById, @@ -191,13 +196,8 @@ export const ChannelPane = React.memo(function ChannelPane({ channelPaneMountedRef.current = false; }; }, []); - // Clear the ?autoSend search param once the auto-submit fires so - // back-navigation cannot re-trigger the send. - // When `onAutoSendComplete` is provided it does a surgical single-key clear - // that preserves `?thread` and all other panel search state (required for - // the thread-draft send path so the thread panel does not unmount before the - // deferred setTimeout(0) submit fires). The goChannel fallback is kept for - // callers that do not supply the prop (e.g. isolated tests / older wrappers). + // Clear only the auto-send key so thread state survives deferred submission; + // older wrappers fall back to goChannel to prevent back-navigation replay. const handleAutoSubmitComplete = React.useCallback(() => { if (onAutoSendComplete) { onAutoSendComplete(); @@ -409,8 +409,6 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel?.id ?? null, ); const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0; - // Background card mints surface in the same rail ("Minting card…" chip), - // so they must also reserve the activity row. const hasCardMintActivity = useCardMintJobs().length > 0; const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity || hasCardMintActivity; @@ -444,7 +442,7 @@ export const ChannelPane = React.memo(function ChannelPane({ messageTimelineRef.current?.scrollToBottomOnNextUpdate(), }); }, [onAddAgent]); - const channelIntro = useChannelIntro({ + const standardChannelIntro = useChannelIntro({ activeChannel, onAddAgent, onBrowseChannels, @@ -452,23 +450,14 @@ export const ChannelPane = React.memo(function ChannelPane({ onOpenMembers, onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined, }); - const visibleMessages = React.useMemo(() => { - if (!isWelcomeExperience(activeChannel)) { - return messages; - } - - return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); - }, [activeChannel, messages]); - const mainTimelineEntries = React.useMemo( - () => - buildMainTimelineEntries( - visibleMessages, - new Set(), - threadSummaries, - profiles, - ), - [profiles, threadSummaries, visibleMessages], - ); + const channelIntro = isHuddleTranscript ? null : standardChannelIntro; + const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({ + activeChannel, + isHuddleTranscript, + messages, + profiles, + threadSummaries, + }); useRenderScopedReactionHydration({ activeChannel, mainTimelineEntries, @@ -585,9 +574,14 @@ export const ChannelPane = React.memo(function ChannelPane({ isSinglePanelView, useSplitAuxiliaryPane, }); + const timelineReplyHandler = + activeChannel?.archivedAt || isHuddleTranscript ? undefined : onOpenThread; return ( -
- {!isSinglePanelView ? ( +
+ {!isSinglePanelView && !isHuddleTranscript ? (