mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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}"))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 0–1 */
|
||||
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 (0–1). 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]);
|
||||
}
|
||||
@@ -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)]",
|
||||
|
||||
Reference in New Issue
Block a user