Skip to content

Commit b29c8cd

Browse files
klopez4212wesbillmanCarl
authored
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 <[email protected]> Signed-off-by: Wes <[email protected]> Co-authored-by: Wes <[email protected]> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
1 parent ede8d22 commit b29c8cd

89 files changed

Lines changed: 6260 additions & 1327 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎desktop/src-tauri/capabilities/default.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
22
"$schema": "../gen/schemas/desktop-schema.json",
33
"identifier": "default",
4-
"description": "Capability for the main window",
5-
"windows": ["main"],
4+
"description": "Capability for the main window and trusted huddle companions",
5+
"windows": ["main", "huddle-*"],
66
"permissions": [
77
"core:default",
88
"core:webview:allow-set-webview-zoom",
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
//! Per-agent text-to-speech choices for one local huddle session.
2+
3+
use std::collections::{BTreeMap, HashSet};
4+
5+
use serde::{Deserialize, Serialize};
6+
use tauri::{AppHandle, State};
7+
8+
use crate::app_state::AppState;
9+
10+
use super::{
11+
tts_settings::{
12+
pocket_voice_reference, resolve_voice_for_backend_in_registry, voice_registry,
13+
VoiceRegistryEntry, POCKET_BACKEND_ID,
14+
},
15+
HuddlePhase, HuddleState,
16+
};
17+
18+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19+
pub struct AgentVoiceSettings {
20+
pub enabled: bool,
21+
pub voice_key: String,
22+
}
23+
24+
struct AgentVoiceCatalog {
25+
default_voice_key: String,
26+
voices: Vec<VoiceRegistryEntry>,
27+
}
28+
29+
fn catalog(app: &AppHandle, state: &AppState) -> Result<AgentVoiceCatalog, String> {
30+
let registry = voice_registry(app);
31+
let settings = state
32+
.huddle_audio
33+
.tts
34+
.lock()
35+
.map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))?
36+
.clone();
37+
let voices: Vec<_> = registry
38+
.iter()
39+
.filter(|voice| {
40+
voice.backend == POCKET_BACKEND_ID
41+
&& matches!(voice.availability.as_str(), "bundled" | "installed")
42+
})
43+
.cloned()
44+
.collect();
45+
let default_voice_key = resolve_voice_for_backend_in_registry(
46+
&settings.voice_preferences,
47+
POCKET_BACKEND_ID,
48+
&voices,
49+
)?
50+
.key;
51+
Ok(AgentVoiceCatalog {
52+
default_voice_key,
53+
voices,
54+
})
55+
}
56+
57+
fn stable_voice_index(agent_pubkey: &str, huddle_generation: u64, len: usize) -> usize {
58+
let hash = agent_pubkey.bytes().fold(
59+
0xcbf2_9ce4_8422_2325_u64 ^ huddle_generation,
60+
|hash, byte| hash.wrapping_mul(0x0000_0100_0000_01b3) ^ u64::from(byte),
61+
);
62+
(hash as usize) % len
63+
}
64+
65+
pub(crate) fn sync_agent_voice_assignments(
66+
huddle: &mut HuddleState,
67+
agent_pubkeys: &[String],
68+
default_voice_key: &str,
69+
voices: &[VoiceRegistryEntry],
70+
) -> bool {
71+
let previous = huddle.agent_voice_settings.clone();
72+
let available_keys: Vec<_> = voices.iter().map(|voice| voice.key.clone()).collect();
73+
let available: HashSet<_> = available_keys.iter().cloned().collect();
74+
let agents: HashSet<_> = agent_pubkeys.iter().cloned().collect();
75+
huddle.agent_voice_settings.retain(|pubkey, settings| {
76+
agents.contains(pubkey) && available.contains(&settings.voice_key)
77+
});
78+
79+
let mut used: HashSet<_> = huddle
80+
.agent_voice_settings
81+
.values()
82+
.map(|settings| settings.voice_key.clone())
83+
.collect();
84+
for (index, pubkey) in agent_pubkeys.iter().enumerate() {
85+
if huddle.agent_voice_settings.contains_key(pubkey) {
86+
continue;
87+
}
88+
let preferred = if index == 0 && !used.contains(default_voice_key) {
89+
Some(default_voice_key.to_owned())
90+
} else {
91+
let unused_alternates: Vec<_> = available_keys
92+
.iter()
93+
.filter(|key| key.as_str() != default_voice_key && !used.contains(*key))
94+
.cloned()
95+
.collect();
96+
let unused: Vec<_> = available_keys
97+
.iter()
98+
.filter(|key| !used.contains(*key))
99+
.cloned()
100+
.collect();
101+
let candidates = if unused_alternates.is_empty() {
102+
if unused.is_empty() {
103+
&available_keys
104+
} else {
105+
&unused
106+
}
107+
} else {
108+
&unused_alternates
109+
};
110+
(!candidates.is_empty()).then(|| {
111+
candidates[stable_voice_index(pubkey, huddle.huddle_generation, candidates.len())]
112+
.clone()
113+
})
114+
};
115+
if let Some(voice_key) = preferred {
116+
used.insert(voice_key.clone());
117+
huddle.agent_voice_settings.insert(
118+
pubkey.clone(),
119+
AgentVoiceSettings {
120+
enabled: true,
121+
voice_key,
122+
},
123+
);
124+
}
125+
}
126+
huddle.agent_voice_settings != previous
127+
}
128+
129+
fn ensure_with_catalog(
130+
huddle: &mut HuddleState,
131+
catalog: &AgentVoiceCatalog,
132+
extra_agent: Option<&str>,
133+
) -> bool {
134+
let mut agents = huddle
135+
.agent_pubkeys
136+
.lock()
137+
.unwrap_or_else(|error| error.into_inner())
138+
.clone();
139+
if let Some(pubkey) = extra_agent {
140+
if !agents.iter().any(|agent| agent == pubkey) {
141+
agents.push(pubkey.to_owned());
142+
}
143+
}
144+
sync_agent_voice_assignments(huddle, &agents, &catalog.default_voice_key, &catalog.voices)
145+
}
146+
147+
fn require_active_huddle(huddle: &HuddleState) -> Result<(), String> {
148+
matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active)
149+
.then_some(())
150+
.ok_or_else(|| "No active huddle".to_owned())
151+
}
152+
153+
#[tauri::command]
154+
pub fn ensure_huddle_agent_voice_settings(
155+
app: AppHandle,
156+
state: State<'_, AppState>,
157+
) -> Result<BTreeMap<String, AgentVoiceSettings>, String> {
158+
let catalog = catalog(&app, &state)?;
159+
let (changed, settings) = {
160+
let mut huddle = state.huddle()?;
161+
if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) {
162+
return Ok(BTreeMap::new());
163+
}
164+
let changed = ensure_with_catalog(&mut huddle, &catalog, None);
165+
(changed, huddle.agent_voice_settings.clone())
166+
};
167+
if changed {
168+
state.emit_huddle_state_changed();
169+
}
170+
Ok(settings)
171+
}
172+
173+
#[tauri::command]
174+
pub fn set_huddle_agent_tts_enabled(
175+
agent_pubkey: String,
176+
enabled: bool,
177+
app: AppHandle,
178+
state: State<'_, AppState>,
179+
) -> Result<AgentVoiceSettings, String> {
180+
let catalog = catalog(&app, &state)?;
181+
let settings = {
182+
let mut huddle = state.huddle()?;
183+
require_active_huddle(&huddle)?;
184+
ensure_with_catalog(&mut huddle, &catalog, Some(&agent_pubkey));
185+
let settings = huddle
186+
.agent_voice_settings
187+
.get_mut(&agent_pubkey)
188+
.ok_or("Agent is not in the active huddle")?;
189+
settings.enabled = enabled;
190+
settings.clone()
191+
};
192+
state.emit_huddle_state_changed();
193+
Ok(settings)
194+
}
195+
196+
#[tauri::command]
197+
pub fn set_huddle_agent_voice(
198+
agent_pubkey: String,
199+
voice_key: String,
200+
app: AppHandle,
201+
state: State<'_, AppState>,
202+
) -> Result<AgentVoiceSettings, String> {
203+
let catalog = catalog(&app, &state)?;
204+
if !catalog.voices.iter().any(|voice| voice.key == voice_key) {
205+
return Err("The selected Pocket voice is not available on this device".to_owned());
206+
}
207+
let settings = {
208+
let mut huddle = state.huddle()?;
209+
require_active_huddle(&huddle)?;
210+
ensure_with_catalog(&mut huddle, &catalog, Some(&agent_pubkey));
211+
let settings = huddle
212+
.agent_voice_settings
213+
.get_mut(&agent_pubkey)
214+
.ok_or("Agent is not in the active huddle")?;
215+
settings.voice_key = voice_key;
216+
settings.clone()
217+
};
218+
state.emit_huddle_state_changed();
219+
Ok(settings)
220+
}
221+
222+
pub(crate) fn voice_reference_for_agent(
223+
app: &AppHandle,
224+
state: &AppState,
225+
agent_pubkey: &str,
226+
) -> Result<Option<String>, String> {
227+
let catalog = catalog(app, state)?;
228+
let (changed, settings) = {
229+
let mut huddle = state.huddle()?;
230+
require_active_huddle(&huddle)?;
231+
let changed = ensure_with_catalog(&mut huddle, &catalog, Some(agent_pubkey));
232+
let settings = huddle.agent_voice_settings.get(agent_pubkey).cloned();
233+
(changed, settings)
234+
};
235+
if changed {
236+
state.emit_huddle_state_changed();
237+
}
238+
let Some(settings) = settings else {
239+
return Err("Agent is not in the active huddle".to_owned());
240+
};
241+
if !settings.enabled {
242+
return Ok(None);
243+
}
244+
pocket_voice_reference(app, &[settings.voice_key]).map(Some)
245+
}
246+
247+
#[cfg(test)]
248+
mod tests {
249+
use super::*;
250+
use crate::huddle::tts_settings::bundled_voice_registry;
251+
252+
#[test]
253+
fn first_agent_uses_default_and_additional_agents_are_distinct() {
254+
let agents = vec!["first".to_owned(), "second".to_owned(), "third".to_owned()];
255+
let mut huddle = HuddleState {
256+
huddle_generation: 9,
257+
..HuddleState::default()
258+
};
259+
260+
assert!(sync_agent_voice_assignments(
261+
&mut huddle,
262+
&agents,
263+
"pocket:vera",
264+
&bundled_voice_registry(),
265+
));
266+
267+
assert_eq!(
268+
huddle.agent_voice_settings["first"].voice_key,
269+
"pocket:vera"
270+
);
271+
let distinct: HashSet<_> = huddle
272+
.agent_voice_settings
273+
.values()
274+
.map(|settings| settings.voice_key.as_str())
275+
.collect();
276+
assert_eq!(distinct.len(), 3);
277+
}
278+
279+
#[test]
280+
fn explicit_session_choices_survive_roster_resync() {
281+
let agents = vec!["first".to_owned(), "second".to_owned()];
282+
let voices = bundled_voice_registry();
283+
let mut huddle = HuddleState::default();
284+
sync_agent_voice_assignments(&mut huddle, &agents, "pocket:mary", &voices);
285+
huddle
286+
.agent_voice_settings
287+
.get_mut("second")
288+
.unwrap()
289+
.enabled = false;
290+
huddle
291+
.agent_voice_settings
292+
.get_mut("second")
293+
.unwrap()
294+
.voice_key = "pocket:jane".into();
295+
296+
assert!(!sync_agent_voice_assignments(
297+
&mut huddle,
298+
&agents,
299+
"pocket:mary",
300+
&voices,
301+
));
302+
assert_eq!(
303+
huddle.agent_voice_settings["second"],
304+
AgentVoiceSettings {
305+
enabled: false,
306+
voice_key: "pocket:jane".into(),
307+
}
308+
);
309+
}
310+
}

0 commit comments

Comments
 (0)