feat(desktop): improve huddle bar UX + audio device selection (#340)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-16 19:36:46 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent b8d6a5cf8b
commit 12dca80e1a
18 changed files with 1059 additions and 509 deletions
+4
View File
@@ -20,6 +20,9 @@ pub struct AppState {
///
/// Set once during `setup()` in `lib.rs`; never cleared.
pub app_handle: Mutex<Option<AppHandle>>,
/// Selected audio output device name. `None` = system default.
/// Used by `connect_audio_relay` and TTS pipeline when opening sinks.
pub audio_output_device: Mutex<Option<String>>,
}
pub fn build_app_state() -> AppState {
@@ -65,6 +68,7 @@ pub fn build_app_state() -> AppState {
managed_agent_processes: Mutex::new(HashMap::new()),
huddle_state: Mutex::new(HuddleState::default()),
app_handle: Mutex::new(None),
audio_output_device: Mutex::new(None),
}
}
@@ -0,0 +1,83 @@
//! Audio output device enumeration, selection, and sink creation.
use tauri::State;
use crate::app_state::AppState;
/// List available audio output devices. Returns (name, is_default) pairs.
#[tauri::command]
pub fn list_audio_output_devices() -> Result<Vec<AudioOutputDevice>, String> {
use rodio::cpal::traits::HostTrait;
use rodio::DeviceTrait;
let host = rodio::cpal::default_host();
let default_name = host.default_output_device().and_then(|d| d.name().ok());
let devices = host
.output_devices()
.map_err(|e| format!("enumerate output devices: {e}"))?;
let mut result = Vec::new();
for device in devices {
if let Ok(name) = device.name() {
let is_default = default_name.as_deref() == Some(&name);
result.push(AudioOutputDevice { name, is_default });
}
}
Ok(result)
}
/// Set the preferred audio output device by name. Empty string = system default.
/// Takes effect on the next huddle start/join (does not change a live stream).
#[tauri::command]
pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Result<(), String> {
let mut guard = state
.audio_output_device
.lock()
.map_err(|e| e.to_string())?;
*guard = if name.is_empty() { None } else { Some(name) };
Ok(())
}
/// Get the currently selected audio output device name (empty = system default).
#[tauri::command]
pub fn get_audio_output_device(state: State<'_, AppState>) -> Result<String, String> {
let guard = state
.audio_output_device
.lock()
.map_err(|e| e.to_string())?;
Ok(guard.clone().unwrap_or_default())
}
#[derive(Debug, serde::Serialize)]
pub struct AudioOutputDevice {
pub name: String,
pub is_default: bool,
}
/// Open a rodio sink for a named output device, falling back to default.
pub(crate) fn open_output_sink_by_name(
preferred: Option<&str>,
) -> Result<rodio::MixerDeviceSink, String> {
use rodio::cpal::traits::HostTrait;
use rodio::DeviceTrait;
if let Some(name) = preferred {
let host = rodio::cpal::default_host();
if let Ok(devices) = host.output_devices() {
for device in devices {
if device.name().ok().as_deref() == Some(name) {
if let Ok(sink) = rodio::DeviceSinkBuilder::from_device(device) {
return sink
.open_stream()
.map_err(|e| format!("audio output ({name}): {e}"));
}
}
}
}
eprintln!(
"sprout-desktop: preferred output device {name:?} not found, falling back to default"
);
}
rodio::DeviceSinkBuilder::open_default_sink().map_err(|e| format!("audio output: {e}"))
}
+1
View File
@@ -24,6 +24,7 @@
//! and drops them outside the lock (thread joins can block ~200ms).
pub mod agents;
pub mod audio_output;
pub mod kokoro;
pub mod models;
pub mod pipeline;
+6 -1
View File
@@ -193,7 +193,12 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result<bool, S
// Construct outside the lock — this spawns the TTS worker thread and
// loads ONNX sessions (~200ms). If this fails, clear the sentinel.
let pipeline = match tts::TtsPipeline::new(model_dir, tts_active, tts_cancel) {
let output_device = state
.audio_output_device
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let pipeline = match tts::TtsPipeline::new(model_dir, tts_active, tts_cancel, output_device) {
Ok(p) => Arc::new(p),
Err(e) => {
let hs = state.huddle()?;
+8 -2
View File
@@ -162,6 +162,11 @@ pub(crate) async fn connect_audio_relay(
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let (pcm_tx, pcm_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(50);
let output_device_name = state
.audio_output_device
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
tokio::spawn(async move {
if let Err(e) = audio_relay_pipeline(
@@ -173,6 +178,7 @@ pub(crate) async fn connect_audio_relay(
initial_peers,
tts_cancel,
tts_active,
output_device_name,
)
.await
{
@@ -206,6 +212,7 @@ async fn audio_relay_pipeline(
initial_peers: Vec<(u8, String)>,
tts_cancel: Arc<AtomicBool>,
tts_active: Arc<AtomicBool>,
output_device_name: Option<String>,
) -> Result<(), String> {
let mut encoder = opus::Encoder::new(48000, opus::Channels::Mono, opus::Application::Voip)
.map_err(|e| format!("opus encoder: {e}"))?;
@@ -216,8 +223,7 @@ async fn audio_relay_pipeline(
.set_dtx(true)
.map_err(|e| format!("opus dtx: {e}"))?;
let sink_handle =
rodio::DeviceSinkBuilder::open_default_sink().map_err(|e| format!("audio output: {e}"))?;
let sink_handle = super::audio_output::open_output_sink_by_name(output_device_name.as_deref())?;
let player = rodio::Player::connect_new(&sink_handle.mixer());
let decoders: std::collections::HashMap<u8, opus::Decoder> = std::collections::HashMap::new();
+8 -3
View File
@@ -110,9 +110,10 @@ impl TtsPipeline {
model_dir: PathBuf,
tts_active: Arc<AtomicBool>,
cancel: Arc<AtomicBool>,
output_device: Option<String>,
) -> Result<Self, String> {
use super::kokoro::DEFAULT_VOICE;
Self::new_with_voice(model_dir, tts_active, cancel, DEFAULT_VOICE)
Self::new_with_voice(model_dir, tts_active, cancel, DEFAULT_VOICE, output_device)
}
/// Spawn the TTS pipeline thread with a specific voice name (e.g. `"af_heart"`, `"am_michael"`).
@@ -121,6 +122,7 @@ impl TtsPipeline {
tts_active: Arc<AtomicBool>,
cancel: Arc<AtomicBool>,
voice: &str,
output_device: Option<String>,
) -> Result<Self, String> {
let (text_tx, text_rx) = mpsc::sync_channel::<String>(TEXT_QUEUE_DEPTH);
let shutdown = Arc::new(AtomicBool::new(false));
@@ -142,6 +144,7 @@ impl TtsPipeline {
tts_active_worker,
shutdown_worker,
cancel_worker,
output_device,
)
})
.map_err(|e| format!("failed to spawn tts-worker thread: {e}"))?;
@@ -199,6 +202,7 @@ fn tts_worker(
tts_active: Arc<AtomicBool>,
shutdown: Arc<AtomicBool>,
cancel: Arc<AtomicBool>,
output_device: Option<String>,
) {
// ── 1. Initialise Kokoro engine ───────────────────────────────────────────
let model_dir_str = model_dir.to_string_lossy().to_string();
@@ -229,9 +233,10 @@ fn tts_worker(
};
// ── 3. Initialise rodio output device ─────────────────────────────────────
use rodio::{DeviceSinkBuilder, Player};
use rodio::Player;
let sink_handle = match DeviceSinkBuilder::open_default_sink() {
let sink_handle = match super::audio_output::open_output_sink_by_name(output_device.as_deref())
{
Ok(h) => h,
Err(e) => {
eprintln!("sprout-desktop: TTS audio output failed: {e}. TTS disabled.");
+6
View File
@@ -10,6 +10,9 @@ mod util;
use app_state::{build_app_state, resolve_persisted_identity, AppState};
use commands::*;
use huddle::audio_output::{
get_audio_output_device, list_audio_output_devices, set_audio_output_device,
};
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,
@@ -641,6 +644,9 @@ pub fn run() {
get_huddle_agent_pubkeys,
set_voice_input_mode,
get_voice_input_mode,
list_audio_output_devices,
set_audio_output_device,
get_audio_output_device,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");
+156 -154
View File
@@ -387,172 +387,174 @@ export function AppShell() {
}}
>
<HuddleProvider>
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
<div className="fixed left-[80px] top-[8px] z-50 flex items-center gap-1.5">
<SidebarTrigger className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-back"
disabled={!canGoBack}
onClick={goBack}
size="icon"
variant="ghost"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
aria-label="Go forward"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-forward"
disabled={!canGoForward}
onClick={goForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<AppSidebar
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isPresencePending={presenceSession.isPending}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
const createdChannel = await createChannelMutation.mutateAsync({
name,
<div className="flex h-dvh flex-col overflow-hidden overscroll-none">
<SidebarProvider className="min-h-0 flex-1 overflow-hidden">
<div className="fixed left-[80px] top-[8px] z-50 flex items-center gap-1.5">
<SidebarTrigger className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
<Button
aria-label="Go back"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-back"
disabled={!canGoBack}
onClick={goBack}
size="icon"
variant="ghost"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
aria-label="Go forward"
className="h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
data-testid="global-forward"
disabled={!canGoForward}
onClick={goForward}
size="icon"
variant="ghost"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
<AppSidebar
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isOpeningDm={openDmMutation.isPending}
isPresencePending={presenceSession.isPending}
selfPresenceStatus={presenceSession.currentStatus}
onCreateChannel={async ({
description,
channelType: "stream",
name,
visibility,
ttlSeconds,
});
}) => {
const createdChannel =
await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility,
ttlSeconds,
});
await goChannel(createdChannel.id);
}}
onCreateForum={async ({
description,
name,
visibility,
ttlSeconds,
}) => {
const createdForum = await createForumMutation.mutateAsync({
name,
await goChannel(createdChannel.id);
}}
onCreateForum={async ({
description,
channelType: "forum",
name,
visibility,
ttlSeconds,
});
}) => {
const createdForum = await createForumMutation.mutateAsync({
name,
description,
channelType: "forum",
visibility,
ttlSeconds,
});
await goChannel(createdForum.id);
}}
onHideDm={handleHideDm}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
await goChannel(directMessage.id);
}}
onOpenSearch={handleOpenSearch}
onSelectAgents={() => {
void goAgents();
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
onSelectHome={() => {
void goHome();
}}
onSelectPulse={() => {
void goPulse();
}}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => {
void goWorkflows();
}}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
profile={profileQuery.data}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
/>
await goChannel(createdForum.id);
}}
onHideDm={handleHideDm}
onOpenBrowseChannels={handleOpenBrowseChannels}
onOpenBrowseForums={handleOpenBrowseForums}
onOpenDm={async ({ pubkeys }) => {
const directMessage = await openDmMutation.mutateAsync({
pubkeys,
});
await goChannel(directMessage.id);
}}
onOpenSearch={handleOpenSearch}
onSelectAgents={() => {
void goAgents();
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
onSelectHome={() => {
void goHome();
}}
onSelectPulse={() => {
void goPulse();
}}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => {
void goWorkflows();
}}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
profile={profileQuery.data}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
/>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<Outlet />
</SidebarInset>
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
<Outlet />
</SidebarInset>
<AppShellOverlays
activeChannel={activeChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isSearchOpen={isSearchOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onOpenSearchResult={handleOpenSearchResult}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
<AppShellOverlays
activeChannel={activeChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isSearchOpen={isSearchOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
void goHome({ replace: true });
}}
onOpenSearchResult={handleOpenSearchResult}
onSearchOpenChange={setIsSearchOpen}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
{settingsOpen ? (
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
section={settingsSection}
/>
</React.Suspense>
) : null}
</SidebarProvider>
<HuddleBar />
{settingsOpen ? (
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={notificationSettings.errorMessage}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={setSettingsSection}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetMentionNotificationsEnabled={
notificationSettings.setMentionsEnabled
}
onSetNeedsActionNotificationsEnabled={
notificationSettings.setNeedsActionEnabled
}
section={settingsSection}
/>
</React.Suspense>
) : null}
</SidebarProvider>
</div>
</HuddleProvider>
</AppShellProvider>
</ChannelNavigationProvider>
+94 -105
View File
@@ -2,8 +2,9 @@ import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import { setupAudioWorklet, type AudioWorkletHandle } from "./lib/audioWorklet";
import { useAudioDevices } from "./lib/useAudioDevices";
import { useTtsSubscription } from "./lib/useTtsSubscription";
/**
* Huddle lifecycle (React context):
@@ -41,6 +42,22 @@ interface HuddleContextValue {
setVoiceInputMode: (mode: VoiceInputMode) => Promise<void>;
/** Pubkeys of currently speaking participants (from Rust backend) */
activeSpeakers: string[];
/** Available audio input devices */
audioDevices: MediaDeviceInfo[];
/** Currently selected mic device ID (empty string = system default) */
selectedDeviceId: string;
/** Select a different mic — takes effect on next huddle start/join */
setSelectedDeviceId: (id: string) => void;
/** Mic input gain 01 */
micGain: number;
/** Adjust mic input gain — applied immediately to the active audio graph */
setMicGain: (value: number) => void;
/** Available audio output devices */
outputDevices: { name: string; is_default: boolean }[];
/** Currently selected output device name (empty = system default) */
selectedOutputDevice: string;
/** Select a different speaker — takes effect on next huddle start/join */
setSelectedOutputDevice: (name: string) => void;
/** Start a new huddle — calls Rust start_huddle, then connects mic + AudioWorklet */
startHuddle: (
parentChannelId: string,
@@ -90,6 +107,59 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
const selfPubkeyRef = React.useRef<string | null>(null);
/** Pubkeys of participants currently speaking (from Rust backend via Tauri event) */
const [activeSpeakers, setActiveSpeakers] = React.useState<string[]>([]);
const {
audioDevices,
selectedDeviceId,
setSelectedDeviceId,
micGain,
setMicGain,
} = useAudioDevices(workletRef);
/** Audio output devices from Rust backend */
const [outputDevices, setOutputDevices] = React.useState<
{ name: string; is_default: boolean }[]
>([]);
const [selectedOutputDevice, setSelectedOutputDeviceState] =
React.useState("");
const setSelectedOutputDevice = React.useCallback((name: string) => {
setSelectedOutputDeviceState(name);
invoke("set_audio_output_device", { name }).catch(() => {
/* best-effort */
});
}, []);
// Fetch output devices on mount and when system devices change.
React.useEffect(() => {
function refreshOutputDevices() {
invoke<{ name: string; is_default: boolean }[]>(
"list_audio_output_devices",
)
.then(setOutputDevices)
.catch(() => {
/* best-effort */
});
}
refreshOutputDevices();
invoke<string>("get_audio_output_device")
.then(setSelectedOutputDeviceState)
.catch(() => {
/* best-effort */
});
navigator.mediaDevices.addEventListener(
"devicechange",
refreshOutputDevices,
);
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
refreshOutputDevices,
);
};
}, []);
/** Ref tracking latest micGain read inside connectAndSetupMedia to
* avoid stale closure capture. */
const micGainRef = React.useRef(1);
micGainRef.current = micGain;
// Bootstrap voice input mode from Rust backend on mount.
// Ensures frontend stays in sync after remount/recovery.
@@ -296,12 +366,16 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
// Get mic — Rust backend owns the audio WS connection.
// Request 48 kHz to match the Opus encoder and worklet buffer size (960 samples = 20ms).
const audioConstraints: MediaTrackConstraints = {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 48000,
};
if (selectedDeviceId) {
audioConstraints.deviceId = { exact: selectedDeviceId };
}
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 48000,
},
audio: audioConstraints,
});
const audioTrack = stream.getAudioTracks()[0];
@@ -322,6 +396,8 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
audioTrack,
initialTransmitting,
);
// Apply current gain level to the new audio graph.
worklet.setGain(micGainRef.current);
if (tokenRef.current !== myToken) {
worklet.stop();
@@ -343,7 +419,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
throw err;
}
},
[],
[selectedDeviceId],
);
const startHuddle = React.useCallback(
@@ -354,6 +430,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
tokenRef.current += 1;
const myToken = tokenRef.current;
setHuddleError(null);
setIsStarting(true);
try {
// Step 1: Call Rust to create ephemeral channel
@@ -394,6 +471,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
busyRef.current = true;
tokenRef.current += 1;
const myToken = tokenRef.current;
setHuddleError(null);
setIsStarting(true);
try {
@@ -430,104 +508,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
[cleanupFailedStart, connectAndSetupMedia],
);
// TTS subscription — pipe AGENT messages from ephemeral channel to speak_agent_message.
// Human STT transcripts are also kind:9 in this channel, so we must filter them out
// using an authoritative agent list fetched from the relay membership API.
React.useEffect(() => {
if (!ephemeralChannelId) return;
let disposed = false;
let cleanup: (() => void) | null = null;
// ── Agent identity (authoritative, fail-closed) ───────────────────────
//
// Fetch the ephemeral channel's member list from the relay REST API and
// identify agents by their "bot" role. This is authoritative — it works
// for both creators and joiners, and reflects mid-huddle agent additions.
//
// FAIL-CLOSED: agentsLoaded starts false. Until the fetch succeeds and
// populates agentPubkeys, NO messages are spoken. An empty set after a
// successful fetch means "no agents in the huddle" → still mute.
let agentsLoaded = false;
const agentPubkeys = new Set<string>();
async function loadAgentPubkeys() {
try {
const pubkeys = await invoke<string[]>("get_huddle_agent_pubkeys");
agentPubkeys.clear();
for (const pk of pubkeys) agentPubkeys.add(pk);
agentsLoaded = true;
} catch (e) {
// Fail-closed on ALL failures, including refresh after prior success.
// Clear the set and mark as not loaded — TTS goes mute until the
// next successful refresh. Stale membership must never authorize speech.
agentPubkeys.clear();
agentsLoaded = false;
console.error("[huddle] Failed to load agent pubkeys:", e);
}
}
// Initial load + periodic refresh (catches mid-huddle agent additions).
void loadAgentPubkeys();
const agentRefreshId = window.setInterval(() => {
void loadAgentPubkeys();
}, 10_000);
// ── Live-only subscription ───────────────────────────────────────────
// subscribeToChannelLive uses `since: now` — the relay never sends
// historical backlog. Every event delivered is a live message.
// Event-ID dedup handles reconnect replay (same event arriving twice).
const seenEventIds = new Set<string>();
const seenOrder: string[] = [];
const MAX_SEEN_EVENTS = 5000;
relayClient
.subscribeToChannelLive(ephemeralChannelId, (event) => {
if (disposed) return;
// Defense-in-depth: subscription already filters to kind:9 only.
if (event.kind !== 9) return;
// Dedup by event ID (covers reconnect replay).
if (seenEventIds.has(event.id)) return;
seenEventIds.add(event.id);
seenOrder.push(event.id);
if (seenOrder.length > MAX_SEEN_EVENTS) {
const oldest = seenOrder.shift();
if (oldest !== undefined) seenEventIds.delete(oldest);
}
// Fail-closed: don't speak until agent list is loaded.
if (!agentsLoaded) return;
// Only speak agent messages — skip human STT transcripts.
if (!agentPubkeys.has(event.pubkey)) return;
if (event.pubkey === selfPubkeyRef.current) return;
if (event.content.trim().length <= 1) return;
// Legacy: skip [System]-prefixed messages from before kind:48106.
if (event.content.startsWith("[System]")) return;
invoke("speak_agent_message", { text: event.content }).catch((err) => {
console.warn(
"[huddle] TTS speak failed (backpressure or pipeline unavailable):",
err,
);
});
})
.then((dispose) => {
if (disposed) {
void dispose();
return;
}
cleanup = () => void dispose();
})
.catch((err) => {
console.error("[huddle] TTS subscription failed:", err);
});
return () => {
disposed = true;
cleanup?.();
window.clearInterval(agentRefreshId);
};
}, [ephemeralChannelId]);
useTtsSubscription(ephemeralChannelId, selfPubkeyRef);
// Pipeline hot-start — check if voice models finished downloading mid-huddle
React.useEffect(() => {
@@ -620,6 +601,14 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) {
voiceInputMode,
setVoiceInputMode,
activeSpeakers,
audioDevices,
selectedDeviceId,
setSelectedDeviceId,
micGain,
setMicGain,
outputDevices,
selectedOutputDevice,
setSelectedOutputDevice,
startHuddle,
joinHuddle,
leaveHuddle,
@@ -2,9 +2,11 @@ import { invoke } from "@tauri-apps/api/core";
import { Bot } from "lucide-react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
@@ -85,62 +87,73 @@ export function AddAgentDialog({
}}
open
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogContent className="flex max-h-[60vh] max-w-sm flex-col gap-0 p-0">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>Add Agent to Huddle</DialogTitle>
<DialogDescription>
Select a running agent to join the huddle.
</DialogDescription>
</DialogHeader>
{error && (
<p className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="flex-1 overflow-y-auto px-6 py-4">
{error && (
<p className="mb-3 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
{warning && (
<div className="flex items-start justify-between gap-2 rounded-md bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
<span>{warning}</span>
<button
className="shrink-0 font-medium underline-offset-2 hover:underline"
onClick={onClose}
type="button"
>
Dismiss
</button>
</div>
)}
{warning && (
<div className="mb-3 flex items-start justify-between gap-2 rounded-md bg-amber-500/10 px-3 py-2 text-sm text-amber-700 dark:text-amber-400">
<span>{warning}</span>
<button
className="shrink-0 font-medium underline-offset-2 hover:underline"
onClick={onClose}
type="button"
>
Dismiss
</button>
</div>
)}
{loading ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Loading agents
</p>
) : runningAgents.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{agents.filter((a) => a.status === "running").length > 0
? "All running agents are already in this huddle."
: "No running agents found."}
</p>
) : (
<ul className="flex flex-col gap-1">
{runningAgents.map((agent) => (
<li key={agent.pubkey}>
<button
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
disabled={adding === agent.pubkey}
onClick={() => void handleAdd(agent.pubkey)}
type="button"
>
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate font-medium">
{agent.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{agent.status}
</span>
</button>
</li>
))}
</ul>
)}
{loading ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Loading agents
</p>
) : runningAgents.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{agents.filter((a) => a.status === "running").length > 0
? "All running agents are already in this huddle."
: "No running agents found."}
</p>
) : (
<ul className="flex flex-col gap-1">
{runningAgents.map((agent) => (
<li key={agent.pubkey}>
<button
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
disabled={adding === agent.pubkey}
onClick={() => void handleAdd(agent.pubkey)}
type="button"
>
<Bot className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate font-medium">
{agent.name}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{agent.status}
</span>
</button>
</li>
))}
</ul>
)}
</div>
<div className="border-t px-6 py-4">
<Button className="w-full" onClick={onClose} variant="outline">
Cancel
</Button>
</div>
</DialogContent>
</Dialog>
);
@@ -1,25 +1,16 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import {
Mic,
MicOff,
PhoneOff,
Plus,
Users,
Volume2,
VolumeX,
} from "lucide-react";
import { Headphones, PhoneOff, Plus } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { useHuddle } from "../HuddleContext";
import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog";
import { MicControls, SpeakerControls } from "./MicControls";
import { ParticipantList } from "./ParticipantList";
// Shape returned by the `get_huddle_state` Tauri command.
// NOTE: This mirrors the HuddleState struct in the Rust backend (src-tauri/src/huddle/mod.rs).
// If you add/remove fields here, update the Rust struct (and vice versa).
// Mirrors HuddleState in src-tauri/src/huddle/mod.rs.
type HuddleState = {
phase:
| "idle"
@@ -46,6 +37,7 @@ export function HuddleBar({ className }: HuddleBarProps) {
localAudioTrack,
leaveHuddle,
endHuddle,
isStarting,
micConnected,
micLevel,
pttActive,
@@ -54,13 +46,19 @@ export function HuddleBar({ className }: HuddleBarProps) {
activeSpeakers,
huddleError,
clearHuddleError,
audioDevices,
selectedDeviceId,
setSelectedDeviceId,
micGain,
setMicGain,
outputDevices,
selectedOutputDevice,
setSelectedOutputDevice,
} = useHuddle();
const isPttMode = voiceInputMode === "push_to_talk";
const [state, setState] = React.useState<HuddleState | null>(null);
const [isMuted, setIsMuted] = React.useState(false);
// Derive TTS enabled from backend state (single source of truth).
// Fall back to true if state hasn't loaded yet.
const ttsEnabled = state?.tts_enabled ?? true;
const [isLeaving, setIsLeaving] = React.useState(false);
const [showAddAgent, setShowAddAgent] = React.useState(false);
@@ -69,8 +67,7 @@ export function HuddleBar({ className }: HuddleBarProps) {
moonshine: string;
kokoro: string;
} | null>(null);
// Huddle state: event-driven primary path + 10s fallback poll.
// Huddle state: event-driven + 10s fallback poll.
React.useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | null = null;
@@ -112,15 +109,12 @@ export function HuddleBar({ className }: HuddleBarProps) {
};
}, []);
// Poll model download status while huddle is active
const huddlePhase = state?.phase;
React.useEffect(() => {
if (huddlePhase !== "active" && huddlePhase !== "connected") return;
let cancelled = false;
// ModelStatus serializes as: "ready" | "not_downloaded" (strings)
// or { downloading: { progress_percent: N } } | { error: "msg" } (objects).
const fmt = (s: unknown): string => {
if (typeof s === "string") return s === "ready" ? "ready" : "pending";
if (typeof s === "object" && s !== null) {
@@ -161,7 +155,6 @@ export function HuddleBar({ className }: HuddleBarProps) {
};
}, [huddlePhase]);
// Sync mute state to the audio track
React.useEffect(() => {
if (localAudioTrack) {
localAudioTrack.enabled = !isMuted;
@@ -179,8 +172,7 @@ export function HuddleBar({ className }: HuddleBarProps) {
if (backendClean) {
setState(null);
}
// If backend cleanup failed, keep the bar visible so the user can retry.
// leaveHuddle retains rustActiveRef=true for the next attempt.
// If cleanup failed, keep the bar visible so the user can retry.
} catch (e) {
console.error("Failed to leave huddle:", e);
} finally {
@@ -200,7 +192,7 @@ export function HuddleBar({ className }: HuddleBarProps) {
if (backendClean) {
setState(null);
}
// If backend cleanup failed, keep the bar visible so the user can retry.
// If cleanup failed, keep the bar visible so the user can retry.
} catch (e) {
console.error("Failed to end huddle:", e);
} finally {
@@ -211,13 +203,11 @@ export function HuddleBar({ className }: HuddleBarProps) {
return (
<div
className={cn(
"fixed bottom-4 left-1/2 z-50 -translate-x-1/2",
"flex items-center gap-3 rounded-xl px-4 py-2",
"bg-background/95 shadow-lg ring-1 ring-border backdrop-blur-sm",
"flex items-center gap-3 border-t bg-background px-4 py-2",
className,
)}
>
{/* Error banner — dismissible, shown when start/join fails */}
{/* Error banner */}
{huddleError && (
<div
role="alert"
@@ -235,15 +225,6 @@ export function HuddleBar({ className }: HuddleBarProps) {
</div>
)}
{/* Room label */}
<span className="text-xs font-medium text-foreground">Huddle</span>
{/* Huddle status */}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Users className="h-3 w-3" />
<span>In huddle</span>
</div>
{/* Model download progress */}
{modelStatus &&
(modelStatus.moonshine !== "ready" ||
@@ -260,89 +241,117 @@ export function HuddleBar({ className }: HuddleBarProps) {
</output>
)}
{/* Participant avatars */}
{state.participants.length > 0 && (
{/* Participants */}
<div className="flex items-center gap-2">
<Headphones className="h-4 w-4 text-muted-foreground" />
<ParticipantList
participants={state.participants}
activeSpeakers={activeSpeakers}
agentPubkeys={state.agent_pubkeys}
onRemoveAgent={async (pubkey) => {
if (!state.ephemeral_channel_id) return;
const confirmed = window.confirm(
"Remove this agent from the huddle?",
);
if (!confirmed) return;
try {
await invoke("remove_channel_member", {
channelId: state.ephemeral_channel_id,
pubkey,
});
// Optimistically remove from local state — the backend's
// 15s membership poll will eventually converge.
setState((prev) => {
if (!prev) return prev;
return {
...prev,
participants: prev.participants.filter((p) => p !== pubkey),
agent_pubkeys: prev.agent_pubkeys.filter((p) => p !== pubkey),
};
});
} catch (e) {
console.error("Failed to remove agent from huddle:", e);
}
}}
/>
)}
{/* Voice input mode indicator */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{micConnected ? (
isPttMode ? (
<>
<div
className={cn(
"h-2.5 w-2.5 rounded-full transition-colors",
pttActive && !isMuted
? "bg-green-500 animate-pulse"
: "bg-zinc-500",
)}
title={
isMuted
? "Muted (PTT overridden)"
: pttActive
? "Transmitting"
: "Push Ctrl+Space to talk"
}
/>
<span>PTT</span>
<span className="text-[10px] opacity-60">Ctrl+Space</span>
</>
) : (
<>
<div
className="h-2.5 w-2.5 rounded-full transition-colors"
style={{
backgroundColor:
micLevel > 0.05
? `rgba(34, 197, 94, ${0.4 + micLevel * 0.6})`
: "rgba(100, 116, 139, 0.4)",
}}
title={`Mic level: ${Math.round(micLevel * 100)}%`}
/>
<span>VAD</span>
</>
)
) : (
<span className="text-destructive/70">no mic</span>
)}
<Button
aria-label="Add agent to huddle"
className="h-7 w-7"
onClick={() => setShowAddAgent(true)}
size="icon"
variant="ghost"
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* Voice input mode toggle */}
<Button
aria-label={
isPttMode
? "Switch to voice activity mode"
: "Switch to push-to-talk mode"
}
aria-pressed={isPttMode}
className="h-6 px-1.5 text-[10px]"
onClick={() =>
void setVoiceInputMode(isPttMode ? "voice_activity" : "push_to_talk")
}
size="sm"
variant="ghost"
title={
isPttMode ? "Switch to Voice Activity" : "Switch to Push-to-Talk"
}
>
{isPttMode ? "→ VAD" : "→ PTT"}
</Button>
{/* Add agent button */}
<Button
aria-label="Add agent to huddle"
className="h-8 w-8"
onClick={() => setShowAddAgent(true)}
size="icon"
variant="secondary"
>
<Plus className="h-4 w-4" />
</Button>
{/* Voice input mode — single toggle combining indicator + switch */}
{micConnected ? (
<>
<Button
aria-label={
isPttMode
? "Push to Talk mode — click to switch to Auto"
: "Auto mode — click to switch to Push to Talk"
}
className="h-7 gap-1.5 px-2 text-xs"
onClick={() =>
void setVoiceInputMode(
isPttMode ? "voice_activity" : "push_to_talk",
)
}
size="sm"
variant="ghost"
title={
isPttMode
? "Push to Talk — hold Ctrl+Space to transmit. Click to switch to Auto."
: "Auto — mic is always live. Click to switch to Push to Talk."
}
>
{isPttMode ? (
<>
<div
className={cn(
"h-2 w-2 rounded-full transition-colors",
pttActive && !isMuted
? "animate-pulse bg-green-500"
: "bg-zinc-500",
)}
/>
Push to Talk
</>
) : (
<>
<div
className="h-2 w-2 rounded-full transition-colors"
style={{
backgroundColor:
micLevel > 0.05
? `rgba(34, 197, 94, ${0.4 + micLevel * 0.6})`
: "rgba(100, 116, 139, 0.4)",
}}
/>
Auto
</>
)}
</Button>
{isPttMode && (
<kbd className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{navigator.platform?.includes("Mac") ? "⌃Space" : "Ctrl+Space"}
</kbd>
)}
</>
) : (
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div
className={cn(
"h-2 w-2 rounded-full",
isStarting ? "animate-pulse bg-green-500" : "bg-destructive/70",
)}
/>
{isStarting ? "Connecting…" : "No mic"}
</span>
)}
{agentAddError && (
<span className="max-w-[180px] truncate rounded bg-destructive/10 px-2 py-1 text-xs text-destructive">
@@ -357,9 +366,14 @@ export function HuddleBar({ className }: HuddleBarProps) {
onAdd={async (pubkey: string): Promise<AgentAddResult> => {
setAgentAddError(null);
try {
return await invoke<AgentAddResult>("add_agent_to_huddle", {
agentPubkey: pubkey,
});
const result = await invoke<AgentAddResult>(
"add_agent_to_huddle",
{ agentPubkey: pubkey },
);
// Refresh huddle state so the participant list updates immediately.
const s = await invoke<HuddleState>("get_huddle_state");
setState(s);
return result;
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
setAgentAddError(`Failed to add agent: ${msg}`);
@@ -369,83 +383,65 @@ export function HuddleBar({ className }: HuddleBarProps) {
/>
)}
{/* Mute toggle — in PTT mode acts as hard mute override (even PTT won't transmit) */}
<Button
aria-label={
isMuted
? "Unmute microphone"
: isPttMode
? "Force mute (overrides PTT)"
: "Mute microphone"
}
aria-pressed={isMuted}
className={cn(
"h-8 w-8",
isPttMode &&
pttActive &&
!isMuted &&
"ring-2 ring-green-500 ring-offset-1 ring-offset-background",
)}
onClick={() => setIsMuted((m) => !m)}
size="icon"
variant={isMuted ? "destructive" : "secondary"}
>
{isMuted ? <MicOff className="h-4 w-4" /> : <Mic className="h-4 w-4" />}
</Button>
<MicControls
isMuted={isMuted}
onToggleMute={() => setIsMuted((m) => !m)}
isPttMode={isPttMode}
pttActive={pttActive}
micConnected={micConnected}
audioDevices={audioDevices}
selectedDeviceId={selectedDeviceId}
onSelectDevice={setSelectedDeviceId}
micGain={micGain}
onGainChange={setMicGain}
/>
{/* TTS toggle */}
<Button
aria-label={ttsEnabled ? "Mute agent speech" : "Unmute agent speech"}
aria-pressed={!ttsEnabled}
className="h-8 w-8"
onClick={async () => {
const next = !ttsEnabled;
<SpeakerControls
ttsEnabled={ttsEnabled}
onToggleTts={async () => {
try {
await invoke("set_tts_enabled", { enabled: next });
// Refresh state immediately so the UI reflects the change
await invoke("set_tts_enabled", { enabled: !ttsEnabled });
const s = await invoke<HuddleState>("get_huddle_state");
setState(s);
} catch (e) {
console.error("Failed to toggle TTS:", e);
}
}}
size="icon"
variant={ttsEnabled ? "secondary" : "destructive"}
>
{ttsEnabled ? (
<Volume2 className="h-4 w-4" />
) : (
<VolumeX className="h-4 w-4" />
)}
</Button>
outputDevices={outputDevices}
selectedOutputDevice={selectedOutputDevice}
onSelectOutputDevice={setSelectedOutputDevice}
/>
{/* Leave / End buttons — available to all participants */}
<Button
aria-label="Leave huddle"
className="h-8 w-8"
disabled={isLeaving}
aria-busy={isLeaving}
onClick={() => void handleLeave()}
size="icon"
variant="destructive"
title="Leave huddle (press Escape to dismiss dialogs first)"
>
<PhoneOff className="h-4 w-4" />
</Button>
{state?.is_creator && (
{/* Leave / End buttons — pushed to the right */}
<div className="ml-auto flex items-center gap-2">
<Button
aria-label="End huddle for everyone"
className="h-6 px-1.5 text-[10px]"
aria-label="Leave huddle"
className="h-8 gap-1.5 px-3"
disabled={isLeaving}
onClick={() => void handleEnd()}
aria-busy={isLeaving}
onClick={() => void handleLeave()}
size="sm"
variant="ghost"
title="End huddle for everyone (archives channel)"
variant="destructive"
title="Leave huddle"
>
End all
<PhoneOff className="h-4 w-4" />
Leave
</Button>
)}
{state?.is_creator && (
<Button
aria-label="End huddle for everyone"
className="h-8 gap-1.5 px-3"
disabled={isLeaving}
onClick={() => void handleEnd()}
size="sm"
variant="destructive"
title="End huddle for everyone"
>
<PhoneOff className="h-4 w-4" />
End for all
</Button>
)}
</div>
{/* Screen reader announcements for huddle state changes */}
<output aria-live="polite" className="sr-only">
@@ -1,3 +1,4 @@
import { listen } from "@tauri-apps/api/event";
import { Headphones } from "lucide-react";
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
@@ -164,6 +165,29 @@ export function HuddleIndicator({
};
}, [channelId]);
// When the local user ends/leaves a huddle, the backend transitions to idle
// and emits huddle-state-changed. Clear the indicator immediately rather than
// waiting for the relay's 48103 event (which may arrive late or not at all
// if the relay connection tears down first).
React.useEffect(() => {
let unlisten: (() => void) | null = null;
let cancelled = false;
listen<{ phase: string }>("huddle-state-changed", (event) => {
if (!cancelled && event.payload.phase === "idle") {
setActiveHuddle(null);
}
}).then((fn) => {
if (cancelled) fn();
else unlisten = fn;
});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
// No active huddle — render the start button (if onStart provided).
if (!activeHuddle) {
if (!onStart) return null;
@@ -0,0 +1,225 @@
import { Check, ChevronUp, Mic, MicOff, Volume2, VolumeX } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
type MicControlsProps = {
isMuted: boolean;
onToggleMute: () => void;
isPttMode: boolean;
pttActive: boolean;
micConnected: boolean;
audioDevices: MediaDeviceInfo[];
selectedDeviceId: string;
onSelectDevice: (id: string) => void;
micGain: number;
onGainChange: (value: number) => void;
};
export function MicControls({
isMuted,
onToggleMute,
isPttMode,
pttActive,
micConnected,
audioDevices,
selectedDeviceId,
onSelectDevice,
micGain,
onGainChange,
}: MicControlsProps) {
return (
<Popover>
<div
className={cn(
"flex items-center rounded-md",
isPttMode &&
pttActive &&
!isMuted &&
"ring-2 ring-green-500 ring-offset-1 ring-offset-background",
)}
>
<Button
aria-label={
isMuted
? "Unmute microphone"
: isPttMode
? "Force mute (overrides PTT)"
: "Mute microphone"
}
aria-pressed={isMuted}
className="h-8 w-8 rounded-r-none"
onClick={onToggleMute}
size="icon"
variant={isMuted ? "destructive" : "secondary"}
>
{isMuted ? (
<MicOff className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
</Button>
<PopoverTrigger asChild>
<Button
aria-label="Audio settings"
className="h-8 w-5 rounded-l-none border-l px-0"
size="icon"
variant="secondary"
>
<ChevronUp className="h-3 w-3" />
</Button>
</PopoverTrigger>
</div>
<PopoverContent side="top" className="w-64">
<div className="flex flex-col gap-3">
<DeviceList
label="Microphone"
devices={audioDevices.map((d) => ({
id: d.deviceId,
label: d.label || `Mic ${d.deviceId.slice(0, 8)}`,
}))}
selectedId={selectedDeviceId}
onSelect={onSelectDevice}
showChangeHint={!!selectedDeviceId && micConnected}
/>
<div>
<label
htmlFor="mic-volume"
className="mb-1 block text-xs font-medium"
>
Input Volume
</label>
<div className="flex items-center gap-2">
<input
id="mic-volume"
type="range"
min={0}
max={1}
step={0.01}
value={micGain}
onChange={(e) => onGainChange(Number(e.target.value))}
className="h-1.5 w-full cursor-pointer appearance-none rounded-full bg-muted accent-foreground"
/>
<span className="w-8 text-right text-xs text-muted-foreground">
{Math.round(micGain * 100)}%
</span>
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}
type SpeakerControlsProps = {
ttsEnabled: boolean;
onToggleTts: () => void;
outputDevices: { name: string; is_default: boolean }[];
selectedOutputDevice: string;
onSelectOutputDevice: (name: string) => void;
};
export function SpeakerControls({
ttsEnabled,
onToggleTts,
outputDevices,
selectedOutputDevice,
onSelectOutputDevice,
}: SpeakerControlsProps) {
return (
<Popover>
<div className="flex items-center">
<Button
aria-label={ttsEnabled ? "Mute agent speech" : "Unmute agent speech"}
aria-pressed={!ttsEnabled}
className="h-8 w-8 rounded-r-none"
onClick={onToggleTts}
size="icon"
variant={ttsEnabled ? "secondary" : "destructive"}
>
{ttsEnabled ? (
<Volume2 className="h-4 w-4" />
) : (
<VolumeX className="h-4 w-4" />
)}
</Button>
<PopoverTrigger asChild>
<Button
aria-label="Speaker settings"
className="h-8 w-5 rounded-l-none border-l px-0"
size="icon"
variant="secondary"
>
<ChevronUp className="h-3 w-3" />
</Button>
</PopoverTrigger>
</div>
<PopoverContent side="top" className="w-64">
<DeviceList
label="Speaker"
devices={outputDevices.map((d) => ({ id: d.name, label: d.name }))}
selectedId={selectedOutputDevice}
onSelect={onSelectOutputDevice}
showChangeHint={!!selectedOutputDevice}
/>
</PopoverContent>
</Popover>
);
}
export function DeviceList({
label,
devices,
selectedId,
onSelect,
showChangeHint,
}: {
label: string;
devices: { id: string; label: string }[];
selectedId: string;
onSelect: (id: string) => void;
showChangeHint: boolean;
}) {
return (
<div>
<span className="mb-1 block text-xs font-medium">{label}</span>
<ul className="flex flex-col">
<li>
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent"
onClick={() => onSelect("")}
type="button"
>
<Check
className={cn("h-3 w-3 shrink-0", selectedId && "invisible")}
/>
System default
</button>
</li>
{devices.map((d) => {
const isSelected = selectedId === d.id;
return (
<li key={d.id}>
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-accent"
onClick={() => onSelect(d.id)}
type="button"
>
<Check
className={cn("h-3 w-3 shrink-0", !isSelected && "invisible")}
/>
<span className="truncate">{d.label}</span>
</button>
</li>
);
})}
</ul>
{showChangeHint && (
<p className="mt-1 text-[10px] text-muted-foreground">
Change takes effect on next huddle
</p>
)}
</div>
);
}
@@ -1,3 +1,4 @@
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
@@ -10,6 +11,8 @@ type ParticipantListProps = {
activeSpeakers?: string[];
/** Pubkeys of agent participants — rendered with a bot badge */
agentPubkeys?: string[];
/** Called when the user clicks the remove button on an agent avatar */
onRemoveAgent?: (pubkey: string) => void;
className?: string;
};
@@ -17,6 +20,7 @@ export function ParticipantList({
participants,
activeSpeakers,
agentPubkeys,
onRemoveAgent,
className,
}: ParticipantListProps) {
const { data } = useUsersBatchQuery(participants);
@@ -38,7 +42,7 @@ export function ParticipantList({
const ariaLabel = `${profile?.displayName || `Participant ${pubkey.slice(0, 8)}`}${isAgent ? " (agent)" : ""}`;
return (
<li key={pubkey} className="relative">
<li key={pubkey} className="group/participant relative">
{hasProfile ? (
<div
aria-label={ariaLabel}
@@ -62,15 +66,25 @@ export function ParticipantList({
ariaLabel={ariaLabel}
/>
)}
{isAgent && (
<span
aria-hidden="true"
className="absolute -bottom-1 -right-1 text-[9px] leading-none"
title="Agent"
>
🤖
</span>
)}
{isAgent &&
(onRemoveAgent ? (
<button
aria-label={`Remove ${profile?.displayName || "agent"} from huddle`}
className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-destructive text-destructive-foreground opacity-0 transition-opacity group-hover/participant:opacity-100"
onClick={() => onRemoveAgent(pubkey)}
type="button"
>
<X className="h-2.5 w-2.5" />
</button>
) : (
<span
aria-hidden="true"
className="absolute -bottom-1 -right-1 text-[9px] leading-none"
title="Agent"
>
🤖
</span>
))}
</li>
);
})}
@@ -24,6 +24,8 @@ export type AudioWorkletHandle = {
/** Switch voice input mode. In VAD mode, always transmitting (PTT events ignored).
* In PTT mode, gated by Ctrl+Space. */
setMode: (mode: "push_to_talk" | "voice_activity") => void;
/** Set mic input gain (01). Adjusts the GainNode between source and worklet. */
setGain: (value: number) => void;
};
/**
@@ -65,11 +67,15 @@ export async function setupAudioWorklet(
new MediaStream([audioTrack]),
);
// Create gain node for volume control
const gainNode = audioContext.createGain();
// Create worklet node
const workletNode = new AudioWorkletNode(audioContext, "stt-tap-processor");
// Connect: mic → worklet (tap only — no playback)
source.connect(workletNode);
// Connect: mic → gain → worklet (tap only — no playback)
source.connect(gainNode);
gainNode.connect(workletNode);
// Set initial PTT state (worklet defaults to transmitting=true).
// In PTT mode, immediately gate audio until the user presses the key.
@@ -122,6 +128,7 @@ export async function setupAudioWorklet(
workletNode.port.onmessage = null;
pttUnlisten?.();
source.disconnect();
gainNode.disconnect();
workletNode.disconnect();
void audioContext.close();
},
@@ -137,5 +144,8 @@ export async function setupAudioWorklet(
active: mode === "voice_activity",
});
},
setGain: (value: number) => {
gainNode.gain.value = value;
},
};
}
@@ -0,0 +1,56 @@
import * as React from "react";
import type { AudioWorkletHandle } from "./audioWorklet";
/**
* Manages audio input device enumeration, device selection, and mic gain.
* Extracted from HuddleContext to keep file sizes manageable.
*/
export function useAudioDevices(
workletRef: React.RefObject<AudioWorkletHandle | null>,
) {
const [audioDevices, setAudioDevices] = React.useState<MediaDeviceInfo[]>([]);
const [selectedDeviceId, setSelectedDeviceId] = React.useState("");
const [micGain, setMicGainState] = React.useState(1);
const micGainRef = React.useRef(1);
// Enumerate audio input devices on mount and when devices change.
React.useEffect(() => {
function refreshDevices() {
navigator.mediaDevices
.enumerateDevices()
.then((devices) =>
setAudioDevices(devices.filter((d) => d.kind === "audioinput")),
)
.catch(() => {
/* best-effort */
});
}
refreshDevices();
navigator.mediaDevices.addEventListener("devicechange", refreshDevices);
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
refreshDevices,
);
};
}, []);
const setMicGain = React.useCallback(
(value: number) => {
const clamped = Math.max(0, Math.min(1, value));
micGainRef.current = clamped;
setMicGainState(clamped);
workletRef.current?.setGain(clamped);
},
[workletRef],
);
return {
audioDevices,
selectedDeviceId,
setSelectedDeviceId,
micGain,
setMicGain,
};
}
@@ -0,0 +1,111 @@
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
/**
* Subscribe to agent TTS messages on the ephemeral huddle channel.
* Pipes agent kind:9 messages to `speak_agent_message` on the Rust backend.
*
* Extracted from HuddleContext to keep file sizes manageable.
*/
export function useTtsSubscription(
ephemeralChannelId: string | null,
selfPubkeyRef: React.RefObject<string | null>,
) {
React.useEffect(() => {
if (!ephemeralChannelId) return;
let disposed = false;
let cleanup: (() => void) | null = null;
// ── Agent identity (authoritative, fail-closed) ───────────────────────
//
// Fetch the ephemeral channel's member list from the relay REST API and
// identify agents by their "bot" role. This is authoritative — it works
// for both creators and joiners, and reflects mid-huddle agent additions.
//
// FAIL-CLOSED: agentsLoaded starts false. Until the fetch succeeds and
// populates agentPubkeys, NO messages are spoken. An empty set after a
// successful fetch means "no agents in the huddle" → still mute.
let agentsLoaded = false;
const agentPubkeys = new Set<string>();
async function loadAgentPubkeys() {
try {
const pubkeys = await invoke<string[]>("get_huddle_agent_pubkeys");
agentPubkeys.clear();
for (const pk of pubkeys) agentPubkeys.add(pk);
agentsLoaded = true;
} catch (e) {
// Fail-closed on ALL failures, including refresh after prior success.
// Clear the set and mark as not loaded — TTS goes mute until the
// next successful refresh. Stale membership must never authorize speech.
agentPubkeys.clear();
agentsLoaded = false;
console.error("[huddle] Failed to load agent pubkeys:", e);
}
}
// Initial load + periodic refresh (catches mid-huddle agent additions).
void loadAgentPubkeys();
const agentRefreshId = window.setInterval(() => {
void loadAgentPubkeys();
}, 10_000);
// ── Live-only subscription ───────────────────────────────────────────
// subscribeToChannelLive uses `since: now` — the relay never sends
// historical backlog. Every event delivered is a live message.
// Event-ID dedup handles reconnect replay (same event arriving twice).
const seenEventIds = new Set<string>();
const seenOrder: string[] = [];
const MAX_SEEN_EVENTS = 5000;
relayClient
.subscribeToChannelLive(ephemeralChannelId, (event) => {
if (disposed) return;
// Defense-in-depth: subscription already filters to kind:9 only.
if (event.kind !== 9) return;
// Dedup by event ID (covers reconnect replay).
if (seenEventIds.has(event.id)) return;
seenEventIds.add(event.id);
seenOrder.push(event.id);
if (seenOrder.length > MAX_SEEN_EVENTS) {
const oldest = seenOrder.shift();
if (oldest !== undefined) seenEventIds.delete(oldest);
}
// Fail-closed: don't speak until agent list is loaded.
if (!agentsLoaded) return;
// Only speak agent messages — skip human STT transcripts.
if (!agentPubkeys.has(event.pubkey)) return;
if (event.pubkey === selfPubkeyRef.current) return;
if (event.content.trim().length <= 1) return;
// Legacy: skip [System]-prefixed messages from before kind:48106.
if (event.content.startsWith("[System]")) return;
invoke("speak_agent_message", { text: event.content }).catch((err) => {
console.warn(
"[huddle] TTS speak failed (backpressure or pipeline unavailable):",
err,
);
});
})
.then((dispose) => {
if (disposed) {
void dispose();
return;
}
cleanup = () => void dispose();
})
.catch((err) => {
console.error("[huddle] TTS subscription failed:", err);
});
return () => {
disposed = true;
cleanup?.();
window.clearInterval(agentRefreshId);
};
}, [ephemeralChannelId, selfPubkeyRef]);
}
+3 -3
View File
@@ -145,7 +145,7 @@ const SidebarProvider = React.forwardRef<
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
"group/sidebar-wrapper flex h-full min-h-0 w-full has-[[data-variant=inset]]:bg-sidebar",
className,
)}
ref={ref}
@@ -223,7 +223,7 @@ const Sidebar = React.forwardRef<
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
className="group peer relative hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
@@ -242,7 +242,7 @@ const Sidebar = React.forwardRef<
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
"absolute inset-y-0 z-10 hidden h-full w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",