Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src-tauri/src/commands/native_voice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,66 @@ pub async fn stop_native_voice_conversation(
Ok(status(&app, &state))
}

#[tauri::command]
#[allow(clippy::too_many_arguments)] // Tauri injects four guards beside the exact lifecycle payload.
pub async fn stop_native_voice_conversation_for_replacement(
app: AppHandle,
state: State<'_, NativeVoiceState>,
capture: State<'_, VoiceCaptureState>,
window_sessions: State<'_, super::window_session::WindowSessionRegistry>,
webview_window: WebviewWindow,
renderer_id: String,
renderer_epoch: u64,
session_id: String,
expected_revision: u64,
target_session_id: String,
) -> Result<NativeVoiceStatus, String> {
let target_session_id = target_session_id.trim();
if target_session_id.is_empty() || target_session_id.len() > 256 {
return Err("target session id must be between 1 and 256 bytes".to_string());
}
let target_owner = window_sessions.label_for(target_session_id);
let owns_foreground_session = capture.foreground_session_matches(
webview_window.label(),
&renderer_id,
renderer_epoch,
target_session_id,
)?;
if !replacement_caller_matches_target(
webview_window.label(),
target_owner.as_deref(),
owns_foreground_session,
) {
return Err("Only the target session window can replace a voice conversation.".to_string());
}
if !webview_window
.is_focused()
Comment thread
johnmatthewtennant marked this conversation as resolved.
.map_err(|error| format!("Could not confirm the target session window focus: {error}"))?
{
return Err(
"Only the focused target session can replace a voice conversation.".to_string(),
);
}
state
.stop_active_for_lifecycle(&app, &capture, &session_id, expected_revision)
.await?;
Ok(status(&app, &state))
}

fn replacement_caller_matches_target(
caller_window_label: &str,
target_owner: Option<&str>,
owns_foreground_session: bool,
) -> bool {
if !owns_foreground_session {
return false;
}
match target_owner {
Some(owner_window_label) => owner_window_label == caller_window_label,
None => caller_window_label == "main",
}
}

fn native_owner_id(session_id: &str) -> String {
format!("native-voice:{session_id}")
}
Expand Down Expand Up @@ -2055,6 +2115,32 @@ mod tests {
assert!(!software_microphone_mute(false, false));
}

#[test]
fn replacement_stop_requires_the_target_session_window() {
assert!(replacement_caller_matches_target("main", None, true));
assert!(!replacement_caller_matches_target("main", None, false));
assert!(!replacement_caller_matches_target(
"main",
Some("session:target"),
true,
));
assert!(replacement_caller_matches_target(
"session:target",
Some("session:target"),
true,
));
assert!(!replacement_caller_matches_target(
"session:other",
Some("session:target"),
true,
));
assert!(!replacement_caller_matches_target(
"voice-buddy",
None,
true,
));
}

#[test]
fn speaker_playback_blocks_vad_ingestion_until_all_guards_finish() {
let state = NativeVoiceState::default();
Expand Down
153 changes: 149 additions & 4 deletions src-tauri/src/commands/voice_capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use std::{collections::HashMap, sync::Mutex};

use serde::Deserialize;
use tauri::{State, WebviewWindow};

const MAX_ID_LEN: usize = 256;
Expand All @@ -14,11 +15,29 @@ struct MicrophoneOwner {
owner_id: String,
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct ForegroundSessionClaim {
renderer_id: String,
renderer_epoch: u64,
generation: u64,
session_id: Option<String>,
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ForegroundSessionRequest {
renderer_id: String,
renderer_epoch: u64,
generation: u64,
session_id: Option<String>,
}

#[derive(Default)]
struct CaptureState {
renderer_epoch: u64,
pending_renderers: HashMap<String, (String, u64)>,
current_renderers: HashMap<String, (String, u64)>,
foreground_sessions: HashMap<String, ForegroundSessionClaim>,
microphone_owner: Option<MicrophoneOwner>,
}

Expand Down Expand Up @@ -67,10 +86,18 @@ impl CaptureState {
_ => return Err("Voice renderer instance is not registered".to_string()),
}

self.current_renderers.insert(
window_label.to_string(),
(renderer_id.to_string(), renderer_epoch),
);
let replaced_renderer = self
.current_renderers
.insert(
window_label.to_string(),
(renderer_id.to_string(), renderer_epoch),
)
.is_some_and(|(active_renderer, active_epoch)| {
active_renderer != renderer_id || active_epoch != renderer_epoch
});
if replaced_renderer {
self.foreground_sessions.remove(window_label);
}
if self
.microphone_owner
.as_ref()
Expand Down Expand Up @@ -131,6 +158,70 @@ impl VoiceCaptureState {
.activate_renderer(window_label, renderer_id, renderer_epoch)
}

pub fn set_foreground_session(
&self,
window_label: &str,
renderer_id: &str,
renderer_epoch: u64,
generation: u64,
session_id: Option<&str>,
) -> Result<(), String> {
validate_id("renderer", renderer_id)?;
if let Some(session_id) = session_id {
validate_id("session", session_id)?;
}
let mut state = self
.state
.lock()
.map_err(|_| "Voice capture state lock was poisoned".to_string())?;
state.activate_renderer(window_label, renderer_id, renderer_epoch)?;
if state
.foreground_sessions
.get(window_label)
.is_some_and(|claim| {
claim.renderer_id == renderer_id
&& claim.renderer_epoch == renderer_epoch
&& claim.generation >= generation
})
{
return Ok(());
}
state.foreground_sessions.insert(
window_label.to_string(),
ForegroundSessionClaim {
renderer_id: renderer_id.to_string(),
renderer_epoch,
generation,
session_id: session_id.map(ToString::to_string),
},
);
Ok(())
}

pub fn foreground_session_matches(
&self,
window_label: &str,
renderer_id: &str,
renderer_epoch: u64,
session_id: &str,
) -> Result<bool, String> {
validate_id("renderer", renderer_id)?;
validate_id("session", session_id)?;
let mut state = self
.state
.lock()
.map_err(|_| "Voice capture state lock was poisoned".to_string())?;
state.activate_renderer(window_label, renderer_id, renderer_epoch)?;
Ok(state
.foreground_sessions
.get(window_label)
.is_some_and(|claim| {
claim.renderer_id == renderer_id
&& claim.renderer_epoch == renderer_epoch
&& claim.session_id.as_deref() == Some(session_id)
}))
}

pub fn claim_microphone(
&self,
window_label: String,
Expand Down Expand Up @@ -209,9 +300,25 @@ impl VoiceCaptureState {
}
state.current_renderers.remove(window_label);
state.pending_renderers.remove(window_label);
state.foreground_sessions.remove(window_label);
}
}

#[tauri::command]
pub fn set_voice_renderer_foreground_session(
state: State<'_, VoiceCaptureState>,
webview_window: WebviewWindow,
request: ForegroundSessionRequest,
) -> Result<(), String> {
state.set_foreground_session(
webview_window.label(),
&request.renderer_id,
request.renderer_epoch,
request.generation,
request.session_id.as_deref(),
)
}

#[tauri::command]
pub fn register_voice_renderer_instance(
state: State<'_, VoiceCaptureState>,
Expand Down Expand Up @@ -397,4 +504,42 @@ mod tests {
.is_err());
assert!(!operation_ran.get());
}

#[test]
fn foreground_session_claim_rejects_a_stale_navigation_target() {
let capture = VoiceCaptureState::default();
let epoch = capture.register_renderer_for_test("main", "renderer-1");
capture
.set_foreground_session("main", "renderer-1", epoch, 1, Some("session-b"))
.expect("claim session B");
assert!(capture
.foreground_session_matches("main", "renderer-1", epoch, "session-b")
.expect("authorize session B"));

capture
.set_foreground_session("main", "renderer-1", epoch, 2, Some("session-c"))
.expect("navigate to session C");
assert!(!capture
.foreground_session_matches("main", "renderer-1", epoch, "session-b")
.expect("reject stale session B"));
assert!(capture
.foreground_session_matches("main", "renderer-1", epoch, "session-c")
.expect("authorize session C"));
}

#[test]
fn foreground_session_claim_ignores_out_of_order_updates() {
let capture = VoiceCaptureState::default();
let epoch = capture.register_renderer_for_test("main", "renderer-1");
capture
.set_foreground_session("main", "renderer-1", epoch, 2, Some("session-c"))
.expect("claim newest session");
capture
.set_foreground_session("main", "renderer-1", epoch, 1, Some("session-b"))
.expect("ignore stale claim");

assert!(capture
.foreground_session_matches("main", "renderer-1", epoch, "session-c")
.expect("retain newest session"));
}
}
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,13 +650,15 @@ pub fn run() {
commands::native_voice::reject_native_voice_conversation_transcript,
commands::native_voice::start_native_voice_conversation,
commands::native_voice::stop_native_voice_conversation,
commands::native_voice::stop_native_voice_conversation_for_replacement,
commands::native_voice::push_native_voice_audio,
commands::voice_buddy::open_voice_conversation_session,
commands::voice_buddy::show_voice_conversation_controls,
commands::voice_buddy::set_voice_conversation_controls_suppressed,
commands::voice_buddy::stop_voice_conversation_from_buddy,
commands::notifications::should_suppress_completion_notification,
commands::voice_capture::register_voice_renderer_instance,
commands::voice_capture::set_voice_renderer_foreground_session,
commands::window_session::get_session_window_support,
commands::window_session::open_session_window,
commands::window_session::release_session,
Expand Down
1 change: 1 addition & 0 deletions src/app/AppShell.berdctl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ vi.mock(
releaseNativeVoiceConversationStartBlock: vi
.fn()
.mockResolvedValue(undefined),
setVoiceConversationForegroundSession: vi.fn().mockResolvedValue(undefined),
}),
);

Expand Down
11 changes: 10 additions & 1 deletion src/app/AppShell.navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import type { Message } from "@/shared/types/messages";
import type { GitState } from "@/shared/types/git";
import { setMultiWorkspaceEnabled } from "@/features/workspaces/multiWorkspacePreference";
import { OPEN_SETTINGS_EVENT } from "@/features/settings/lib/settingsEvents";
import { useVoiceConversationStore } from "@/features/voice-conversation/stores/voiceConversationStore";
import { SHORTCUT_PREFERENCES_STORAGE_KEY } from "@/features/shortcuts/lib/shortcutRegistry";
import { useShortcutsDialogStore } from "@/features/shortcuts/stores/shortcutsDialogStore";
import { useProjectStore } from "@/features/projects/stores/projectStore";
Expand All @@ -37,6 +36,7 @@ import {
import {
blockNativeVoiceConversationStarts,
releaseNativeVoiceConversationStartBlock,
setVoiceConversationForegroundSession,
} from "@/features/voice-conversation/api/voiceConversation";
import { dispatchOnboarding } from "@/features/onboarding/model";
import {
Expand Down Expand Up @@ -74,6 +74,7 @@ vi.mock(
releaseNativeVoiceConversationStartBlock: vi
.fn()
.mockResolvedValue(undefined),
setVoiceConversationForegroundSession: vi.fn().mockResolvedValue(undefined),
}),
);

Expand Down Expand Up @@ -963,6 +964,9 @@ describe("AppShell global navigation", () => {
vi.mocked(releaseNativeVoiceConversationStartBlock)
.mockReset()
.mockResolvedValue(undefined);
vi.mocked(setVoiceConversationForegroundSession)
.mockReset()
.mockResolvedValue(undefined);
mockListExtensions.mockReset();
mockListExtensions.mockResolvedValue([]);
mockAcpCreateSession.mockReset();
Expand Down Expand Up @@ -1987,6 +1991,11 @@ describe("AppShell global navigation", () => {
expect(screen.getByTestId("rendered-session-id")).toHaveTextContent(
"session-2",
);
await waitFor(() =>
expect(setVoiceConversationForegroundSession).toHaveBeenLastCalledWith(
"session-2",
),
);
});

it("keeps archive UI active until the backend succeeds and rolls back archivedAt on failure", async () => {
Expand Down
13 changes: 12 additions & 1 deletion src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,10 @@ import {
blockVoiceConversationStarts,
useVoiceConversationStore,
} from "@/features/voice-conversation/stores/voiceConversationStore";
import { listenToVoiceConversationOpenSession } from "@/features/voice-conversation/api/voiceConversation";
import {
listenToVoiceConversationOpenSession,
setVoiceConversationForegroundSession,
} from "@/features/voice-conversation/api/voiceConversation";
import { usePocketVoiceSetup } from "@/features/voice-conversation/hooks/usePocketVoiceSetup";
import { useSiriVoiceSetup } from "@/features/voice-conversation/hooks/useSiriVoiceSetup";
import { useVoiceOutputPreference } from "@/features/voice-conversation/lib/voiceOutputPreference";
Expand Down Expand Up @@ -772,6 +775,14 @@ export function AppShell({
}, [capabilities.voiceConversation, stopVoiceConversation]);
const sessions = useChatSessionStore(selectSessions);
const activeSessionId = useChatSessionStore(selectActiveSessionId);
useLayoutEffect(() => {
const foregroundSessionId = activeView === "chat" ? activeSessionId : null;
void setVoiceConversationForegroundSession(foregroundSessionId).catch(
(error) => {
console.warn("Failed to publish the foreground voice session", error);
},
);
}, [activeSessionId, activeView]);
const messagesBySession = useChatStore((state) => state.messagesBySession);
const previousActiveSessionIdRef = useRef(activeSessionId);
useEffect(() => {
Expand Down
Loading