102 KiB
Global Push-to-Talk Hotkey Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a configurable hold-to-talk global hotkey that lets the user dictate to OpenHuman while it's in the background, with the agent's reply spoken back via TTS — no window focus stealing at any point.
Architecture:
- Tauri shell owns the global hotkey + the always-on-top overlay window. Uses
tauri-plugin-global-shortcutuniformly across macOS / Windows / Linux (single code path — different from dictation's OS-forked rdev/Tauri-plugin dual-path, which is grandfathered legacy + a macOS-26 rdev crash workaround). - Frontend service
pttServiceowns the press → capture → finalize → STT → send → TTS state machine, with a 10s watchdog for swallowedReleasedevents. - Rust core gets one additive change: three optional fields on
channel.web_chat(speak_reply,source,session_id). Whenspeak_replyis true, the existing progress bridge callsvoice::reply_speech::synthesize_and_play(final_text)onTurnCompleted.
Tech Stack:
- Rust core, Tauri shell (
tauri-plugin-global-shortcut), React + Redux Toolkit + redux-persist, Vitest, WDIO/Appium for E2E, i18n via the project'suseT()infrastructure.
Spec: docs/superpowers/specs/2026-06-02-global-ptt-design.md
Issue: tinyhumansai/openhuman#3090 — push-to-talk half only; background screen capture is a follow-up PR.
File map
| Layer | File | Action | Purpose |
|---|---|---|---|
| Tauri shell | app/src-tauri/src/ptt_hotkeys.rs |
create | Hotkey registration + state (PttHotkeyState, expand_ptt_shortcuts, PttError). |
| Tauri shell | app/src-tauri/src/ptt_overlay.rs |
create | Lazy borderless always-on-top overlay window + show_ptt_overlay IPC. |
| Tauri shell | app/src-tauri/src/lib.rs |
modify | Two new IPC commands; wire PttHotkeyState into .manage(...); conflict check vs dictation. |
| Rust core | src/openhuman/channels/providers/web.rs |
modify | Add speak_reply/source/session_id to schema + plumb to progress bridge. |
| Rust core | src/openhuman/channels/providers/web_tests.rs |
modify | Schema-roundtrip + default-omitted tests. |
| Rust core | src/openhuman/voice/bus.rs |
create | VoiceEvent::PttTranscriptCommitted publish helper. |
| Rust core | src/openhuman/voice/mod.rs |
modify | pub mod bus;. |
| Rust core | src/core/event_bus/events.rs |
modify | DomainEvent::Voice(VoiceEvent) + VoiceEvent enum + domain mapping. |
| Rust core | src/openhuman/about_app/ (capability list) |
modify | Add voice.ptt capability entry. |
| Rust core | tests/json_rpc_e2e.rs |
modify | E2E asserting reply_speech is invoked on speak_reply=true |
| Frontend | app/src/services/pttService.ts |
create | Press/release state machine + watchdog + glue. |
| Frontend | app/src/services/__tests__/pttService.test.ts |
create | State-machine unit tests. |
| Frontend | app/src/services/chatService.ts |
modify | Forward speak_reply / source / session_id to channel.web_chat. |
| Frontend | app/src/services/__tests__/chatService.test.ts |
modify | Assert new fields are passed through. |
| Frontend | app/src/store/slices/ptt.ts |
create | Redux slice (shortcut, speakReplies, showOverlay, isHeld). |
| Frontend | app/src/store/slices/__tests__/ptt.test.ts |
create | Slice unit tests. |
| Frontend | app/src/store/index.ts (or wherever rootReducer is) |
modify | Register ptt slice + persist whitelist. |
| Frontend | app/src/utils/tauriCommands/ptt.ts |
create | Wrappers for register_ptt_hotkey / unregister_ptt_hotkey / show_ptt_overlay. |
| Frontend | app/src/hooks/usePttHotkey.ts |
create | Boot-time effect that registers the hotkey on rehydration. |
| Frontend | app/src/components/PttHotkeyManager.tsx |
create | Renderless component mounted in AppShell that wires usePttHotkey + pttService. |
| Frontend | app/src/AppShell.tsx (or App.tsx) |
modify | Mount <PttHotkeyManager />. |
| Frontend | app/src/pages/PttOverlayPage.tsx |
create | 160×56 borderless overlay UI. |
| Frontend | app/src/pages/PttOverlayPage.test.tsx |
create | Render tests. |
| Frontend | app/src/AppRoutes.tsx |
modify | Add /ptt-overlay route. |
| Frontend | app/src/pages/settings/voice/PttSettingsPanel.tsx |
create | Hotkey capture + toggles. |
| Frontend | app/src/pages/settings/voice/__tests__/PttSettingsPanel.test.tsx |
create | Component tests. |
| Frontend | app/src/pages/settings/voice/VoiceSettingsPage.tsx (or wherever the voice settings index lives) |
modify | Mount the PTT panel. |
| Frontend | app/src/assets/audio/ptt-open.wav |
create | Open chime (CC0). |
| Frontend | app/src/assets/audio/ptt-close.wav |
create | Close chime (CC0). |
| Frontend | app/src/assets/audio/ptt-error.wav |
create | Error chime (CC0). |
| Frontend | app/src/assets/audio/README.md |
create | CC0 attribution. |
| i18n | app/src/lib/i18n/en.ts + 12 locale files |
modify | New PTT keys (settings + overlay + error messages). |
| E2E | app/test/e2e/specs/ptt-flow.spec.ts |
create | Full flow under WDIO with mocked STT. |
Each task below ends in a single commit. Tasks are ordered so the tree compiles and tests pass at every boundary — start from core, work outward to the UI.
Task 1: channel.web_chat accepts speak_reply / source / session_id (schema + plumb-through)
Files:
- Modify:
src/openhuman/channels/providers/web.rs - Test:
src/openhuman/channels/providers/web_tests.rs
The renderer-side call site (chatService.chatSend) needs to send these fields; the agent loop needs to remember them. This task wires the schema additions and threads the values from channel_web_chat → start_chat → progress bridge, but does not yet invoke TTS (that's Task 4). After this task the fields are accepted, logged, and otherwise ignored.
- Step 1.1: Write failing schema test for the new optional fields
Add to src/openhuman/channels/providers/web_tests.rs:
#[test]
fn web_chat_schema_accepts_optional_ptt_fields() {
// Locate the `chat` schema via the public accessor.
let schema = crate::openhuman::channels::providers::web::schemas("chat");
let names: std::collections::HashSet<&str> =
schema.inputs.iter().map(|f| f.name).collect();
assert!(
names.contains("speak_reply"),
"channel.web_chat schema must include optional speak_reply field"
);
assert!(
names.contains("source"),
"channel.web_chat schema must include optional source field"
);
assert!(
names.contains("session_id"),
"channel.web_chat schema must include optional session_id field"
);
// All three are optional.
for field in &["speak_reply", "source", "session_id"] {
let f = schema
.inputs
.iter()
.find(|f| f.name == *field)
.expect("field present");
assert!(!f.required, "{field} must be optional");
}
}
#[test]
fn web_chat_params_deserialize_with_all_ptt_fields_omitted() {
use crate::openhuman::channels::providers::web::WebChatParams;
let json = serde_json::json!({
"client_id": "c1",
"thread_id": "t1",
"message": "hello",
});
let parsed: WebChatParams = serde_json::from_value(json).unwrap();
assert_eq!(parsed.speak_reply, None);
assert_eq!(parsed.source, None);
assert_eq!(parsed.session_id, None);
}
#[test]
fn web_chat_params_deserialize_with_all_ptt_fields_present() {
use crate::openhuman::channels::providers::web::WebChatParams;
let json = serde_json::json!({
"client_id": "c1",
"thread_id": "t1",
"message": "hello",
"speak_reply": true,
"source": "ptt",
"session_id": 42_u64,
});
let parsed: WebChatParams = serde_json::from_value(json).unwrap();
assert_eq!(parsed.speak_reply, Some(true));
assert_eq!(parsed.source.as_deref(), Some("ptt"));
assert_eq!(parsed.session_id, Some(42));
}
- Step 1.2: Run tests to verify they fail
pnpm debug rust web_chat_schema_accepts_optional_ptt_fields
pnpm debug rust web_chat_params_deserialize_with_all_ptt_fields
Expected: all three fail (speak_reply / source / session_id not in schema; WebChatParams has no such fields).
- Step 1.3: Add fields to schema and
WebChatParams
In src/openhuman/channels/providers/web.rs, find the schemas("chat") arm and add three optional fields after locale:
optional_bool("speak_reply", "When true, the agent's final reply is spoken via TTS (for PTT and similar background voice flows)."),
optional_string("source", "Origin of the message: \"ptt\" | \"dictation\" | \"type\" | other. Used for analytics + downstream metadata."),
optional_u64("session_id", "Optional caller-provided correlation id (PTT session id)."),
If optional_bool / optional_u64 helpers don't exist in scope yet, add them following the optional_string / optional_f64 pattern already in that file. Example (place near the other helpers):
fn optional_bool(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
comment,
required: false,
}
}
fn optional_u64(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment,
required: false,
}
}
Then locate the WebChatParams struct (search struct WebChatParams in the same file) and add three fields:
#[serde(default)]
pub speak_reply: Option<bool>,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub session_id: Option<u64>,
- Step 1.4: Run the schema tests to verify they pass
pnpm debug rust web_chat_schema_accepts_optional_ptt_fields
pnpm debug rust web_chat_params_deserialize_with_all_ptt_fields
Expected: PASS.
- Step 1.5: Propagate fields from
channel_web_chat→start_chat
Find the existing channel_web_chat function (pub async fn channel_web_chat) and extend its signature with the three new optional fields. Then update start_chat's signature the same way. Where the bridge is spawned (spawn_progress_bridge(...)), pass the new fields through. For this task they're just stored on a per-bridge struct field; Task 4 wires them to TTS.
Concretely: locate pub(super) struct ProgressBridgeContext (or whatever struct already exists to carry bridge state — if none, add one) and add:
pub(super) speak_reply: bool,
pub(super) source: Option<String>,
pub(super) session_id: Option<u64>,
pub(super) final_assistant_text: String, // populated from TextDelta events in Task 4
Update handle_chat to deserialize the new fields and pass them along.
- Step 1.6: Run cargo check
cargo check --manifest-path Cargo.toml
Expected: clean compile (warnings about unused speak_reply etc. acceptable — Task 4 consumes them).
- Step 1.7: Commit
git add src/openhuman/channels/providers/web.rs \
src/openhuman/channels/providers/web_tests.rs
git commit -m "feat(channels/web): accept optional speak_reply/source/session_id on chat schema (#3090)"
Task 2: DomainEvent::Voice(VoiceEvent) + voice/bus.rs
Files:
- Modify:
src/core/event_bus/events.rs - Create:
src/openhuman/voice/bus.rs - Modify:
src/openhuman/voice/mod.rs
The bus event lets the future screen-capture follow-up subscribe to PTT commits without coupling.
- Step 2.1: Write failing publish/subscribe test
Create src/openhuman/voice/bus.rs:
//! Voice domain event publishers. The PTT transcript-committed event is
//! published here so the future screen-intelligence follow-up can subscribe
//! and grab a frame on commit without coupling to the channel-web flow.
use crate::core::event_bus::{publish_global, DomainEvent, VoiceEvent};
/// Publish a [`VoiceEvent::PttTranscriptCommitted`] event.
pub fn publish_ptt_transcript_committed(
thread_id: String,
session_id: u64,
text_len: usize,
held_ms: u64,
finalized_by_watchdog: bool,
) {
publish_global(DomainEvent::Voice(VoiceEvent::PttTranscriptCommitted {
thread_id,
session_id,
text_len,
held_ms,
finalized_by_watchdog,
}));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::event_bus::{init_global, subscribe_global, DomainEvent, EventHandler};
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex as AsyncMutex;
#[derive(Default)]
struct Capture {
events: Arc<AsyncMutex<Vec<DomainEvent>>>,
}
#[async_trait]
impl EventHandler for Capture {
fn name(&self) -> &'static str {
"voice::ptt_test_capture"
}
async fn handle(&self, event: DomainEvent) {
self.events.lock().await.push(event);
}
}
#[tokio::test]
async fn publishing_a_ptt_commit_reaches_a_subscriber() {
// Use the singleton (init is idempotent).
let _ = init_global(64);
let capture = Capture::default();
let events = capture.events.clone();
let _sub = subscribe_global(Box::new(capture));
publish_ptt_transcript_committed(
"thread-1".to_string(),
42,
17,
850,
false,
);
// Give the broadcaster a tick to deliver.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let got = events.lock().await;
assert!(
got.iter().any(|e| matches!(
e,
DomainEvent::Voice(VoiceEvent::PttTranscriptCommitted {
thread_id, session_id, ..
}) if thread_id == "thread-1" && *session_id == 42
)),
"expected PttTranscriptCommitted in {got:?}",
);
}
}
Add to src/openhuman/voice/mod.rs:
pub mod bus;
- Step 2.2: Run the test to verify it fails
pnpm debug rust publishing_a_ptt_commit_reaches_a_subscriber
Expected: FAIL — VoiceEvent is undefined and DomainEvent::Voice doesn't exist yet.
- Step 2.3: Add
VoiceEventand theVoicevariant toDomainEvent
In src/core/event_bus/events.rs, add the enum (above or near DomainEvent):
/// Voice-domain events.
#[non_exhaustive]
#[derive(Clone, Debug)]
pub enum VoiceEvent {
/// A PTT session committed a transcript to a thread. Carries only
/// length/timing — never the raw text, per the PII-safe logging rule.
PttTranscriptCommitted {
thread_id: String,
session_id: u64,
text_len: usize,
held_ms: u64,
finalized_by_watchdog: bool,
},
}
Then add to DomainEvent:
Voice(VoiceEvent),
…and extend the domain() match arm with:
DomainEvent::Voice(_) => Domain::Voice,
If Domain::Voice isn't already defined in the Domain enum in the same file, add it.
- Step 2.4: Run the test again
pnpm debug rust publishing_a_ptt_commit_reaches_a_subscriber
Expected: PASS.
- Step 2.5: Commit
git add src/core/event_bus/events.rs \
src/openhuman/voice/bus.rs \
src/openhuman/voice/mod.rs
git commit -m "feat(voice/bus): publish DomainEvent::Voice::PttTranscriptCommitted (#3090)"
Task 3: expand_ptt_shortcuts + PttError (pure functions, fully tested)
Files:
- Create:
app/src-tauri/src/ptt_hotkeys.rs
Mirrors dictation_hotkeys::expand_dictation_shortcuts but rejects pure-modifier shortcuts (which would be unusable as PTT keys). All Tauri / app state lives in the IPC commands (Task 5); this task is pure logic + tests only.
- Step 3.1: Write failing tests
Create app/src-tauri/src/ptt_hotkeys.rs:
//! Global push-to-talk hotkey state + parsing.
//!
//! See spec: `docs/superpowers/specs/2026-06-02-global-ptt-design.md`.
//!
//! `expand_ptt_shortcuts` mirrors `dictation_hotkeys::expand_dictation_shortcuts`
//! but rejects pure-modifier shortcuts (Ctrl, Cmd+Shift, etc.) because they
//! would fire constantly during normal typing.
use std::sync::atomic::AtomicU64;
use std::sync::Mutex;
#[derive(Debug, PartialEq, Eq)]
pub enum PttError {
EmptyShortcut,
ModifierOnlyShortcut,
ConflictsWithDictation(String),
UnsupportedOnWayland,
RegistrationFailed(String),
}
impl std::fmt::Display for PttError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PttError::EmptyShortcut => write!(f, "ptt shortcut cannot be empty"),
PttError::ModifierOnlyShortcut => write!(
f,
"ptt shortcut cannot be only modifier keys (Ctrl/Cmd/Shift/Alt)"
),
PttError::ConflictsWithDictation(s) => {
write!(f, "ptt shortcut '{s}' conflicts with the dictation hotkey")
}
PttError::UnsupportedOnWayland => write!(
f,
"global shortcuts are not supported in this Wayland session — switch to X11 or use in-app dictation"
),
PttError::RegistrationFailed(s) => {
write!(f, "failed to register ptt shortcut: {s}")
}
}
}
}
impl std::error::Error for PttError {}
/// Process-wide PTT state. Held in the Tauri-managed `State<PttHotkeyState>`.
pub(crate) struct PttHotkeyState {
/// Currently-registered shortcut variants (e.g. `["Cmd+F13", "Ctrl+F13"]` on macOS).
pub(crate) shortcut: Mutex<Vec<String>>,
/// Monotonic counter for session IDs.
pub(crate) session_counter: AtomicU64,
}
impl PttHotkeyState {
pub(crate) fn new() -> Self {
Self {
shortcut: Mutex::new(Vec::new()),
session_counter: AtomicU64::new(0),
}
}
}
const MODIFIER_TOKENS: &[&str] = &[
"ctrl",
"control",
"cmd",
"command",
"meta",
"super",
"win",
"windows",
"alt",
"option",
"shift",
"cmdorctrl",
];
fn is_modifier_token(token: &str) -> bool {
let lower = token.trim().to_ascii_lowercase();
MODIFIER_TOKENS.iter().any(|m| *m == lower)
}
/// Expand a user-typed shortcut into one or two OS-specific variants and
/// validate it isn't empty / modifier-only.
pub(crate) fn expand_ptt_shortcuts(shortcut: &str) -> Result<Vec<String>, PttError> {
let trimmed = shortcut.trim();
if trimmed.is_empty() {
return Err(PttError::EmptyShortcut);
}
let parts: Vec<&str> = trimmed.split('+').map(str::trim).collect();
if parts.iter().all(|p| is_modifier_token(p)) {
return Err(PttError::ModifierOnlyShortcut);
}
#[cfg(target_os = "macos")]
{
if trimmed.contains("CmdOrCtrl") {
let cmd_variant = trimmed.replace("CmdOrCtrl", "Cmd");
let ctrl_variant = trimmed.replace("CmdOrCtrl", "Ctrl");
if cmd_variant == ctrl_variant {
return Ok(vec![cmd_variant]);
}
return Ok(vec![cmd_variant, ctrl_variant]);
}
}
#[cfg(not(target_os = "macos"))]
{
if trimmed.contains("CmdOrCtrl") {
return Ok(vec![trimmed.replace("CmdOrCtrl", "Ctrl")]);
}
}
Ok(vec![trimmed.to_string()])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_shortcut_is_rejected() {
assert_eq!(expand_ptt_shortcuts(""), Err(PttError::EmptyShortcut));
assert_eq!(expand_ptt_shortcuts(" "), Err(PttError::EmptyShortcut));
}
#[test]
fn modifier_only_shortcut_is_rejected() {
assert_eq!(
expand_ptt_shortcuts("Ctrl"),
Err(PttError::ModifierOnlyShortcut)
);
assert_eq!(
expand_ptt_shortcuts("Cmd+Shift"),
Err(PttError::ModifierOnlyShortcut)
);
assert_eq!(
expand_ptt_shortcuts("Alt+Shift+Ctrl"),
Err(PttError::ModifierOnlyShortcut)
);
assert_eq!(
expand_ptt_shortcuts("CmdOrCtrl+Shift"),
Err(PttError::ModifierOnlyShortcut)
);
}
#[test]
fn plain_function_key_is_accepted() {
assert_eq!(expand_ptt_shortcuts("F13"), Ok(vec!["F13".to_string()]));
}
#[test]
fn modifier_plus_letter_is_accepted() {
assert_eq!(
expand_ptt_shortcuts("Ctrl+Alt+T"),
Ok(vec!["Ctrl+Alt+T".to_string()])
);
}
#[test]
#[cfg(target_os = "macos")]
fn cmd_or_ctrl_expands_to_both_on_macos() {
let result = expand_ptt_shortcuts("CmdOrCtrl+Shift+P").unwrap();
assert_eq!(result.len(), 2);
assert!(result.contains(&"Cmd+Shift+P".to_string()));
assert!(result.contains(&"Ctrl+Shift+P".to_string()));
}
#[test]
#[cfg(not(target_os = "macos"))]
fn cmd_or_ctrl_expands_to_ctrl_off_macos() {
let result = expand_ptt_shortcuts("CmdOrCtrl+Shift+P").unwrap();
assert_eq!(result, vec!["Ctrl+Shift+P".to_string()]);
}
}
Also wire the module into the Tauri shell: add to app/src-tauri/src/lib.rs (near the other mod lines, around the existing mod dictation_hotkeys;):
mod ptt_hotkeys;
- Step 3.2: Run tests to verify they fail / verify pass
cargo test --manifest-path app/src-tauri/Cargo.toml ptt_hotkeys
Expected: PASS (all 6 tests; this task is implementation + tests in the same file, so they pass together — the TDD value here is the test code itself being committed alongside).
- Step 3.3: Run
cargo fmt
cargo fmt --manifest-path app/src-tauri/Cargo.toml
- Step 3.4: Commit
git add app/src-tauri/src/ptt_hotkeys.rs app/src-tauri/src/lib.rs
git commit -m "feat(tauri/ptt): add ptt_hotkeys module with shortcut expansion + validation (#3090)"
Task 4: Wire speak_reply to reply_speech via the progress bridge (with test seam)
Files:
- Modify:
src/openhuman/channels/providers/web.rs(extend the progress bridgeTurnCompletedhandler) - Modify:
src/openhuman/voice/reply_speech.rs(add a test seam if none exists) - Modify:
tests/json_rpc_e2e.rs
The progress bridge already receives AgentProgress::TextDelta events during the turn and TurnCompleted when the turn finishes. We accumulate the deltas and, on TurnCompleted, if speak_reply was set, hand the final text to reply_speech.
- Step 4.1: Add a test seam to
reply_speech
If reply_speech.rs already exposes a way to intercept calls for testing, skip ahead to 4.2. Otherwise add a static observer:
In src/openhuman/voice/reply_speech.rs, near the top of the file:
#[cfg(test)]
pub mod test_seam {
use once_cell::sync::Lazy;
use std::sync::Mutex;
pub static OBSERVED_CALLS: Lazy<Mutex<Vec<String>>> =
Lazy::new(|| Mutex::new(Vec::new()));
pub fn clear() {
OBSERVED_CALLS.lock().unwrap().clear();
}
pub fn observed() -> Vec<String> {
OBSERVED_CALLS.lock().unwrap().clone()
}
}
In whichever function plays TTS (search the file for pub async fn and locate synthesize_and_play or similar — likely pub async fn synthesize_and_play(text: &str) or pub async fn speak), at the very top of the function add:
#[cfg(test)]
{
test_seam::OBSERVED_CALLS
.lock()
.unwrap()
.push(text.to_string());
return Ok(());
}
If the real return type isn't Result<(), …>, adapt the return to the actual signature (e.g. return; for -> ()).
- Step 4.2: Write failing E2E test in
tests/json_rpc_e2e.rs
Add a new test at the end of the file:
#[tokio::test]
async fn channel_web_chat_with_speak_reply_invokes_reply_speech() {
use openhuman::openhuman::voice::reply_speech::test_seam;
test_seam::clear();
// Stand up the JSON-RPC harness — mirror an existing test in this file
// (e.g. the chat happy-path test); the helper functions for spawning the
// server + opening a client live in this file already.
let (client, _server_guard) = spawn_test_server().await;
// Open a socket / acquire a client_id the same way the existing chat
// tests do (search for "client_id" usage in this file for the pattern).
let client_id = open_test_socket(&client).await;
let thread_id = create_test_thread(&client).await;
// Send a web chat with speak_reply=true.
let resp = client
.call(
"openhuman.channel_web_chat",
serde_json::json!({
"client_id": client_id,
"thread_id": thread_id,
"message": "hello",
"speak_reply": true,
"source": "ptt",
"session_id": 1_u64,
}),
)
.await
.expect("rpc ok");
assert_eq!(resp["accepted"], true);
// Wait up to 10s for the agent turn to complete.
wait_for_turn_complete(&client, &client_id, &thread_id, 10_000).await;
let observed = test_seam::observed();
assert!(
!observed.is_empty(),
"expected reply_speech to be invoked when speak_reply=true, but observed no calls"
);
}
If helper names (spawn_test_server, open_test_socket, create_test_thread, wait_for_turn_complete) don't already exist in tests/json_rpc_e2e.rs, use whichever helpers the existing chat test in that file uses — copy its shape and replace the params with the new fields.
- Step 4.3: Run the E2E to verify it fails
pnpm debug rust channel_web_chat_with_speak_reply_invokes_reply_speech
Expected: FAIL — bridge does not call reply_speech yet.
- Step 4.4: Wire the bridge to invoke
reply_speechonTurnCompleted
In src/openhuman/channels/providers/web.rs, locate spawn_progress_bridge. We need to:
- Buffer assistant text from
AgentProgress::TextDelta(already received in the existing match — extend the arm). - On
AgentProgress::TurnCompleted, ifspeak_reply == true, callreply_speech::synthesize_and_play(buffered).await.
Pseudocode patch (apply against the actual file structure):
let mut final_assistant_text = String::new();
// ...inside the existing `while let Some(event) = rx.recv().await` loop:
match &event {
AgentProgress::TextDelta { delta, .. } => {
// existing log + bridge code preserved
final_assistant_text.push_str(delta);
}
AgentProgress::TurnCompleted { iterations } => {
log::debug!(
"[web_channel][bridge] turn_completed iterations={iterations} request_id={request_id} speak_reply={speak_reply}",
);
if speak_reply && !final_assistant_text.trim().is_empty() {
let text = final_assistant_text.clone();
tokio::spawn(async move {
if let Err(e) =
crate::openhuman::voice::reply_speech::synthesize_and_play(&text).await
{
log::warn!("[web_channel][bridge] reply_speech failed: {e}");
}
});
}
// Publish the PTT bus event when source == "ptt".
if source.as_deref() == Some("ptt") {
if let Some(sid) = session_id {
crate::openhuman::voice::bus::publish_ptt_transcript_committed(
thread_id.clone(),
sid,
final_assistant_text.len(),
/* held_ms */ 0, // filled by Task 13 when the renderer passes it
false,
);
}
}
}
// ...other existing arms unchanged
}
Threading the speak_reply, source, session_id values into spawn_progress_bridge requires extending the function's signature. Add them as Option<…>/bool params and thread from start_chat → channel_web_chat.
If reply_speech::synthesize_and_play's real signature is different (e.g. takes String by value or returns a different Result type), adapt the call site to the real signature — check the function definition in src/openhuman/voice/reply_speech.rs first.
- Step 4.5: Run the E2E again
pnpm debug rust channel_web_chat_with_speak_reply_invokes_reply_speech
Expected: PASS.
- Step 4.6: Run unrelated chat tests to verify no regression
pnpm debug rust web_channel
pnpm debug rust json_rpc_e2e
Expected: green.
- Step 4.7: Commit
git add src/openhuman/channels/providers/web.rs \
src/openhuman/voice/reply_speech.rs \
tests/json_rpc_e2e.rs
git commit -m "feat(channels/web): invoke reply_speech + publish PttTranscriptCommitted on speak_reply=true (#3090)"
Task 5: Tauri IPC commands register_ptt_hotkey / unregister_ptt_hotkey + conflict check
Files:
-
Modify:
app/src-tauri/src/lib.rs -
Modify:
app/src-tauri/src/ptt_hotkeys.rs(add a small conflict-helper fn) -
Step 5.1: Add the conflict helper to
ptt_hotkeys.rs
Append to the same file:
/// Returns `Some(conflicting_variant)` if any expanded PTT variant overlaps
/// any expanded dictation variant. Comparison is case-insensitive.
pub(crate) fn first_conflict_with(
ptt: &[String],
dictation: &[String],
) -> Option<String> {
for p in ptt {
let p_lc = p.to_ascii_lowercase();
for d in dictation {
if d.to_ascii_lowercase() == p_lc {
return Some(p.clone());
}
}
}
None
}
#[cfg(test)]
mod conflict_tests {
use super::*;
#[test]
fn no_conflict_returns_none() {
let ptt = vec!["F13".into()];
let dict = vec!["F14".into()];
assert_eq!(first_conflict_with(&ptt, &dict), None);
}
#[test]
fn case_insensitive_conflict_detected() {
let ptt = vec!["ctrl+space".into()];
let dict = vec!["Ctrl+Space".into()];
assert_eq!(
first_conflict_with(&ptt, &dict),
Some("ctrl+space".to_string())
);
}
#[test]
fn only_one_variant_overlaps_returns_first() {
let ptt = vec!["Cmd+P".into(), "Ctrl+P".into()];
let dict = vec!["Ctrl+P".into()];
assert_eq!(
first_conflict_with(&ptt, &dict),
Some("Ctrl+P".to_string())
);
}
}
- Step 5.2: Run conflict tests
cargo test --manifest-path app/src-tauri/Cargo.toml ptt_hotkeys::conflict_tests
Expected: PASS.
- Step 5.3: Add the two IPC commands to
lib.rs
In app/src-tauri/src/lib.rs, near the existing register_dictation_hotkey:
/// Register (or re-register) the global push-to-talk hotkey. Emits
/// `ptt://start { session_id }` on press and `ptt://stop { session_id }`
/// on release.
#[tauri::command]
async fn register_ptt_hotkey(
app: AppHandle<AppRuntime>,
shortcut: String,
) -> Result<(), String> {
log::info!("[ptt] register_ptt_hotkey: shortcut={shortcut}");
let expanded = ptt_hotkeys::expand_ptt_shortcuts(&shortcut)
.map_err(|e| e.to_string())?;
// Reject overlap with the currently-registered dictation hotkey.
let dictation_current = {
let state = app.state::<dictation_hotkeys::DictationHotkeyState>();
let guard = state.0.lock().unwrap();
guard.clone()
};
if let Some(conflict) =
ptt_hotkeys::first_conflict_with(&expanded, &dictation_current)
{
return Err(ptt_hotkeys::PttError::ConflictsWithDictation(conflict).to_string());
}
let old_shortcuts = {
let state = app.state::<ptt_hotkeys::PttHotkeyState>();
let guard = state.shortcut.lock().unwrap();
guard.clone()
};
// Lazy-instantiate the overlay window so it's ready before the first press.
if let Err(e) = ptt_overlay::ensure_window(&app) {
log::warn!("[ptt] overlay window create failed (continuing): {e}");
}
let register_shortcut = |variant: &str| -> Result<(), String> {
let app_pressed = app.clone();
let app_released = app.clone();
let variant_owned = variant.to_string();
app.global_shortcut()
.on_shortcut(variant, move |app_inner, _sc, event| {
let state = app_inner.state::<ptt_hotkeys::PttHotkeyState>();
match event.state {
ShortcutState::Pressed => {
// Atomically bump the counter and emit start.
let session_id = state
.session_counter
.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ 1;
log::debug!(
"[ptt] pressed shortcut={variant_owned} session_id={session_id}"
);
if let Err(e) =
app_pressed.emit("ptt://start", serde_json::json!({
"session_id": session_id,
}))
{
log::warn!("[ptt] emit start failed: {e}");
}
}
ShortcutState::Released => {
let session_id = state
.session_counter
.load(std::sync::atomic::Ordering::SeqCst);
log::debug!(
"[ptt] released shortcut={variant_owned} session_id={session_id}"
);
if let Err(e) =
app_released.emit("ptt://stop", serde_json::json!({
"session_id": session_id,
}))
{
log::warn!("[ptt] emit stop failed: {e}");
}
}
}
})
.map_err(|e| format!("Failed to register ptt shortcut '{variant}': {e}"))
};
// Unregister previous PTT variants.
let mut unregistered: Vec<String> = Vec::new();
for old in &old_shortcuts {
if let Err(e) = app.global_shortcut().unregister(old.as_str()) {
// Rollback already-unregistered ones.
for r in &unregistered {
let _ = register_shortcut(r);
}
return Err(format!("Failed to unregister previous ptt shortcut '{old}': {e}"));
}
unregistered.push(old.clone());
}
// Register the new variants. Rollback on first failure.
let mut newly_registered: Vec<String> = Vec::new();
for v in &expanded {
if let Err(e) = register_shortcut(v) {
for r in &newly_registered {
let _ = app.global_shortcut().unregister(r.as_str());
}
for old in &old_shortcuts {
let _ = register_shortcut(old);
}
return Err(e);
}
newly_registered.push(v.clone());
}
{
let state = app.state::<ptt_hotkeys::PttHotkeyState>();
let mut guard = state.shortcut.lock().unwrap();
*guard = expanded.clone();
}
log::info!("[ptt] registered: {}", expanded.join(", "));
Ok(())
}
/// Unregister the global PTT hotkey (if any).
#[tauri::command]
async fn unregister_ptt_hotkey(app: AppHandle<AppRuntime>) -> Result<(), String> {
log::info!("[ptt] unregister_ptt_hotkey: called");
let state = app.state::<ptt_hotkeys::PttHotkeyState>();
let old = {
let mut guard = state.shortcut.lock().unwrap();
let v = guard.clone();
guard.clear();
v
};
for s in &old {
if let Err(e) = app.global_shortcut().unregister(s.as_str()) {
log::warn!("[ptt] unregister '{s}' failed: {e}");
}
}
// Destroy the overlay window so resources are released.
ptt_overlay::destroy_window(&app);
Ok(())
}
Then wire state + commands. In the same file, find .manage(dictation_hotkeys::DictationHotkeyState(...)) near Builder::default() and add:
.manage(ptt_hotkeys::PttHotkeyState::new())
And in the tauri::generate_handler! invocation, add:
register_ptt_hotkey,
unregister_ptt_hotkey,
show_ptt_overlay,
(show_ptt_overlay is added in Task 6; if you're running this task standalone, comment it out and re-enable in Task 6.)
- Step 5.4: Add reverse conflict check to dictation register
In register_dictation_hotkey (existing function), after the existing expand_dictation_shortcuts call, add a symmetric check:
// Reject overlap with the currently-registered PTT hotkey.
let ptt_current = {
let state = app.state::<ptt_hotkeys::PttHotkeyState>();
let guard = state.shortcut.lock().unwrap();
guard.clone()
};
if let Some(conflict) =
ptt_hotkeys::first_conflict_with(&expanded_shortcuts, &ptt_current)
{
return Err(format!(
"dictation shortcut '{conflict}' conflicts with the push-to-talk hotkey"
));
}
- Step 5.5: Run cargo check on the Tauri shell
pnpm rust:check
Expected: clean compile (or compile errors only from the show_ptt_overlay reference, fixed in Task 6).
- Step 5.6: Commit
git add app/src-tauri/src/ptt_hotkeys.rs app/src-tauri/src/lib.rs
git commit -m "feat(tauri/ptt): register/unregister IPC + dictation conflict guard (#3090)"
Task 6: ptt_overlay.rs lazy borderless window + show_ptt_overlay IPC
Files:
-
Create:
app/src-tauri/src/ptt_overlay.rs -
Modify:
app/src-tauri/src/lib.rs(addmod ptt_overlay;+ the IPC command) -
Step 6.1: Create the module
app/src-tauri/src/ptt_overlay.rs:
//! Borderless always-on-top PTT overlay window.
//!
//! Lazy-created on the first `register_ptt_hotkey` call (so the window is
//! ready when the user hits the key for the first time), and destroyed by
//! `unregister_ptt_hotkey`. The window's contents are rendered by the React
//! route `/ptt-overlay` (see `app/src/pages/PttOverlayPage.tsx`).
//!
//! Cross-platform note: `focus(false)` ensures the window never steals focus
//! from the user's active app. `skip_taskbar(true)` keeps it out of the
//! Windows taskbar / macOS dock. `visible_on_all_workspaces(true)` makes it
//! follow the user across macOS Spaces. DXGI exclusive-fullscreen on Windows
//! still suppresses the overlay — documented in the settings panel as a
//! limitation; chime audio remains the fallback signal.
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder};
const OVERLAY_LABEL: &str = "ptt-overlay";
/// Ensure the overlay window exists. Idempotent — if the window already
/// exists, returns Ok without recreating it.
pub(crate) fn ensure_window<R: Runtime>(app: &AppHandle<R>) -> Result<(), String> {
if app.get_webview_window(OVERLAY_LABEL).is_some() {
return Ok(());
}
let url = WebviewUrl::App("index.html#/ptt-overlay".into());
let mut builder = WebviewWindowBuilder::new(app, OVERLAY_LABEL, url)
.title("OpenHuman Push-to-Talk")
.inner_size(160.0, 56.0)
.decorations(false)
.transparent(true)
.always_on_top(true)
.skip_taskbar(true)
.focused(false)
.resizable(false)
.shadow(false)
.visible(false)
.accept_first_mouse(false);
#[cfg(target_os = "macos")]
{
builder = builder.visible_on_all_workspaces(true);
}
let _window = builder
.build()
.map_err(|e| format!("create ptt overlay window: {e}"))?;
log::info!("[ptt-overlay] window created (label={OVERLAY_LABEL})");
Ok(())
}
/// Destroy the overlay window if it exists.
pub(crate) fn destroy_window<R: Runtime>(app: &AppHandle<R>) {
if let Some(w) = app.get_webview_window(OVERLAY_LABEL) {
if let Err(e) = w.destroy() {
log::warn!("[ptt-overlay] destroy failed: {e}");
} else {
log::info!("[ptt-overlay] window destroyed");
}
}
}
/// Show or hide the overlay. Emits `ptt-overlay://active` for the in-window
/// React tree to drive its pulsing-dot animation.
#[tauri::command]
pub(crate) async fn show_ptt_overlay<R: Runtime>(
app: AppHandle<R>,
active: bool,
session_id: u64,
) -> Result<(), String> {
let window = app
.get_webview_window(OVERLAY_LABEL)
.ok_or_else(|| "ptt overlay window not created — register a hotkey first".to_string())?;
if active {
window
.show()
.map_err(|e| format!("show overlay: {e}"))?;
} else {
window
.hide()
.map_err(|e| format!("hide overlay: {e}"))?;
}
if let Err(e) = window.emit(
"ptt-overlay://active",
serde_json::json!({
"active": active,
"session_id": session_id,
}),
) {
log::warn!("[ptt-overlay] emit active failed: {e}");
}
Ok(())
}
- Step 6.2: Wire it into
lib.rs
In app/src-tauri/src/lib.rs, near mod ptt_hotkeys;:
mod ptt_overlay;
Confirm show_ptt_overlay is present in the tauri::generate_handler! macro invocation (added in Task 5.3); if it was commented out there, uncomment now.
- Step 6.3: Run
pnpm rust:check
pnpm rust:check
Expected: clean compile.
- Step 6.4: Commit
git add app/src-tauri/src/ptt_overlay.rs app/src-tauri/src/lib.rs
git commit -m "feat(tauri/ptt): lazy borderless always-on-top overlay window (#3090)"
Task 7: Chime assets + README
Files:
- Create:
app/src/assets/audio/ptt-open.wav - Create:
app/src/assets/audio/ptt-close.wav - Create:
app/src/assets/audio/ptt-error.wav - Create:
app/src/assets/audio/README.md
WAVs ~80ms, LUFS-normalized to match the existing in-app notification sound (target ~ -16 LUFS). Use CC0-licensed source clips (e.g. from freesound.org's CC0 collection or similar) — three short tones.
- Step 7.1: Add the three WAV files
Source three short CC0 WAV clips. Suggested:
ptt-open.wav: rising 800Hz→1200Hz square wave, 80ms.ptt-close.wav: falling 1200Hz→800Hz square wave, 80ms.ptt-error.wav: two 150Hz pulses 60ms apart, 120ms total.
If generating with sox:
sox -n app/src/assets/audio/ptt-open.wav synth 0.08 sine 800-1200 norm -16
sox -n app/src/assets/audio/ptt-close.wav synth 0.08 sine 1200-800 norm -16
sox -n app/src/assets/audio/ptt-error.wav synth 0.06 sine 150 : synth 0.06 sine 0 : synth 0.06 sine 150 norm -16
(If sox isn't available, hand-source equivalent CC0 clips and store them at the same paths.)
- Step 7.2: Add
README.md
app/src/assets/audio/README.md:
# Audio assets
Short UI chimes for the push-to-talk feature (`docs/superpowers/specs/2026-06-02-global-ptt-design.md`).
| File | Purpose | Source | License |
| --- | --- | --- | --- |
| `ptt-open.wav` | Mic opened (PTT key pressed). | Generated locally with `sox synth`. | CC0 / Public Domain. |
| `ptt-close.wav` | Mic closed (PTT key released). | Generated locally with `sox synth`. | CC0 / Public Domain. |
| `ptt-error.wav` | Session aborted (empty audio, mic permission denied, etc.). | Generated locally with `sox synth`. | CC0 / Public Domain. |
All clips are ~80–120ms, LUFS-normalized to roughly match the in-app notification sound (~ -16 LUFS). Replace freely with better-sounding equivalents — just keep them under 200ms and CC0/MIT-equivalent.
- Step 7.3: Verify file presence
ls -la app/src/assets/audio/
file app/src/assets/audio/*.wav
Expected: each file exists and is identified as a RIFF WAV.
- Step 7.4: Commit
git add app/src/assets/audio/
git commit -m "assets(ptt): bundle CC0 open/close/error chimes (#3090)"
Task 8: ptt redux slice + persistence
Files:
-
Create:
app/src/store/slices/ptt.ts -
Create:
app/src/store/slices/__tests__/ptt.test.ts -
Modify:
app/src/store/index.ts(or wherever rootReducer + persistConfig live) -
Step 8.1: Write failing slice test
app/src/store/slices/__tests__/ptt.test.ts:
import { describe, expect, it } from 'vitest';
import {
pttReducer,
setPttShortcut,
setSpeakReplies,
setShowOverlay,
setIsHeld,
type PttState,
} from '../ptt';
describe('ptt slice', () => {
const initial: PttState = {
shortcut: null,
speakReplies: true,
showOverlay: true,
isHeld: false,
};
it('has the documented default state', () => {
expect(pttReducer(undefined, { type: '@@INIT' })).toEqual(initial);
});
it('setPttShortcut stores the shortcut string', () => {
const next = pttReducer(initial, setPttShortcut('F13'));
expect(next.shortcut).toBe('F13');
});
it('setPttShortcut with null clears the shortcut', () => {
const withKey: PttState = { ...initial, shortcut: 'F13' };
const next = pttReducer(withKey, setPttShortcut(null));
expect(next.shortcut).toBeNull();
});
it('setSpeakReplies toggles the flag', () => {
expect(pttReducer(initial, setSpeakReplies(false)).speakReplies).toBe(false);
});
it('setShowOverlay toggles the flag', () => {
expect(pttReducer(initial, setShowOverlay(false)).showOverlay).toBe(false);
});
it('setIsHeld updates the runtime hold flag', () => {
expect(pttReducer(initial, setIsHeld(true)).isHeld).toBe(true);
});
});
- Step 8.2: Run failing test
pnpm debug unit app/src/store/slices/__tests__/ptt.test.ts
Expected: FAIL — slice file does not exist yet.
- Step 8.3: Implement the slice
app/src/store/slices/ptt.ts:
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export interface PttState {
/** Currently-bound PTT hotkey string (e.g. "F13" or "Ctrl+Alt+T"). null = unbound. */
shortcut: string | null;
/** When true, the agent's reply is spoken via TTS. */
speakReplies: boolean;
/** When true, the overlay window is shown during a PTT session. */
showOverlay: boolean;
/** Non-persisted runtime flag: is the PTT key currently held? */
isHeld: boolean;
}
export const initialPttState: PttState = {
shortcut: null,
speakReplies: true,
showOverlay: true,
isHeld: false,
};
const pttSlice = createSlice({
name: 'ptt',
initialState: initialPttState,
reducers: {
setPttShortcut(state, action: PayloadAction<string | null>) {
state.shortcut = action.payload;
},
setSpeakReplies(state, action: PayloadAction<boolean>) {
state.speakReplies = action.payload;
},
setShowOverlay(state, action: PayloadAction<boolean>) {
state.showOverlay = action.payload;
},
setIsHeld(state, action: PayloadAction<boolean>) {
state.isHeld = action.payload;
},
},
});
export const { setPttShortcut, setSpeakReplies, setShowOverlay, setIsHeld } =
pttSlice.actions;
export const pttReducer = pttSlice.reducer;
- Step 8.4: Run slice test to verify pass
pnpm debug unit app/src/store/slices/__tests__/ptt.test.ts
Expected: PASS.
- Step 8.5: Register the slice in the root store
Open app/src/store/index.ts (or whichever file builds the root reducer — search for combineReducers or the existing dictation / voice slice registration).
Add the import + register in combineReducers:
import { pttReducer } from './slices/ptt';
// ...
const rootReducer = combineReducers({
// ...existing entries
ptt: pttReducer,
});
If a persistWhitelist / persistConfig.whitelist array exists, add 'ptt'. The isHeld field is non-persisted by being a separate runtime concern — for simple slice-level redux-persist, leave it in the slice; rehydration will reset to false if you exclude it via a blacklist of nested keys, but the simpler approach is to accept it being rehydrated and have the boot hook explicitly reset it (see Task 11).
If using redux-persist's createTransform to strip isHeld, you can add (in the same file):
import { createTransform } from 'redux-persist';
const stripIsHeld = createTransform<PttState, Omit<PttState, 'isHeld'>>(
(state) => {
const { isHeld: _isHeld, ...rest } = state;
return rest;
},
(state) => ({ ...state, isHeld: false }),
{ whitelist: ['ptt'] },
);
…and add stripIsHeld to persistConfig.transforms. If transforms doesn't already exist in the persistConfig, this is over-engineering — accept the rehydrated value for now and reset in Task 11.
- Step 8.6: Run the broader unit suite to verify no regression
pnpm debug unit
Expected: green.
- Step 8.7: Commit
git add app/src/store/slices/ptt.ts \
app/src/store/slices/__tests__/ptt.test.ts \
app/src/store/index.ts
git commit -m "feat(store/ptt): redux slice for ptt hotkey + settings (#3090)"
Task 9: Tauri-command wrappers + chatService forwards speak_reply
Files:
-
Create:
app/src/utils/tauriCommands/ptt.ts -
Modify:
app/src/services/chatService.ts -
Modify:
app/src/services/__tests__/chatService.test.ts -
Step 9.1: Write a failing chatService test for the new fields
In app/src/services/__tests__/chatService.test.ts, add a new test alongside the existing 'channel_web_chat' one (find the assertion block at line ~216):
it('forwards speak_reply, source, session_id when provided', async () => {
// Set up the same fixtures the surrounding test uses (mock socket, mock callCoreRpc, etc.).
// Mirror the existing test's setup precisely — only the call args differ.
await chatSend({
threadId: 'thread-1',
message: 'hello',
speakReply: true,
source: 'ptt',
sessionId: 42,
});
expect(callCoreRpcSpy).toHaveBeenCalledWith(
expect.objectContaining({
method: 'openhuman.channel_web_chat',
params: expect.objectContaining({
message: 'hello',
speak_reply: true,
source: 'ptt',
session_id: 42,
}),
}),
);
});
it('does not include the new fields when omitted', async () => {
await chatSend({ threadId: 'thread-1', message: 'hi' });
const params = callCoreRpcSpy.mock.calls[0][0].params;
expect(params.speak_reply).toBeUndefined();
expect(params.source).toBeUndefined();
expect(params.session_id).toBeUndefined();
});
(Adapt callCoreRpcSpy to the existing test file's name for the spy on callCoreRpc.)
- Step 9.2: Run failing test
pnpm debug unit app/src/services/__tests__/chatService.test.ts
Expected: FAIL — ChatSendParams does not include speakReply / source / sessionId yet.
- Step 9.3: Extend
chatService.chatSend
In app/src/services/chatService.ts, find ChatSendParams and add three optional fields:
export interface ChatSendParams {
// ...existing fields
speakReply?: boolean;
source?: string;
sessionId?: number;
}
In chatSend, extend the params object:
await callCoreRpc({
method: 'openhuman.channel_web_chat',
params: {
client_id: clientId,
thread_id: params.threadId,
message: params.message,
model_override: params.model ?? undefined,
profile_id: params.profileId ?? undefined,
locale: params.locale ?? undefined,
speak_reply: params.speakReply ?? undefined,
source: params.source ?? undefined,
session_id: params.sessionId ?? undefined,
},
});
- Step 9.4: Run chatService tests to verify pass
pnpm debug unit app/src/services/__tests__/chatService.test.ts
Expected: PASS.
- Step 9.5: Create the Tauri-command wrappers
app/src/utils/tauriCommands/ptt.ts:
import { isTauri } from '../../services/webviewAccountService';
import { invoke } from '@tauri-apps/api/core';
/** Register (or re-register) the global push-to-talk hotkey. */
export async function registerPttHotkey(shortcut: string): Promise<void> {
if (!isTauri()) {
console.debug('[ptt] registerPttHotkey: skipped — not running in Tauri');
return;
}
console.debug('[ptt] registerPttHotkey: shortcut=%s', shortcut);
await invoke<void>('register_ptt_hotkey', { shortcut });
console.debug('[ptt] registerPttHotkey: done');
}
/** Unregister the global push-to-talk hotkey. */
export async function unregisterPttHotkey(): Promise<void> {
if (!isTauri()) {
console.debug('[ptt] unregisterPttHotkey: skipped — not running in Tauri');
return;
}
console.debug('[ptt] unregisterPttHotkey: invoking');
await invoke<void>('unregister_ptt_hotkey');
console.debug('[ptt] unregisterPttHotkey: done');
}
/** Show or hide the PTT overlay window. */
export async function showPttOverlay(active: boolean, sessionId: number): Promise<void> {
if (!isTauri()) return;
await invoke<void>('show_ptt_overlay', { active, sessionId });
}
- Step 9.6: Run full unit suite
pnpm debug unit
Expected: green.
- Step 9.7: Commit
git add app/src/services/chatService.ts \
app/src/services/__tests__/chatService.test.ts \
app/src/utils/tauriCommands/ptt.ts
git commit -m "feat(chatService): forward speakReply/source/sessionId; add ptt tauri wrappers (#3090)"
Task 10: pttService state machine + watchdog (the heart of the feature)
Files:
- Create:
app/src/services/pttService.ts - Create:
app/src/services/__tests__/pttService.test.ts
This is the largest single file in the plan. The state machine is documented in §2 of the spec.
- Step 10.1: Write the failing test suite
app/src/services/__tests__/pttService.test.ts:
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPttService, type PttDeps } from '../pttService';
function makeDeps(overrides: Partial<PttDeps> = {}): PttDeps {
return {
audioCapture: {
start: vi.fn().mockResolvedValue(undefined),
finalize: vi.fn().mockResolvedValue({ durationMs: 1500, buffer: new ArrayBuffer(0) }),
cancel: vi.fn().mockResolvedValue(undefined),
},
transcribe: vi.fn().mockResolvedValue('hello world'),
sendMessage: vi.fn().mockResolvedValue(undefined),
resolveActiveThreadId: vi.fn().mockResolvedValue('thread-active'),
createNewVoiceThread: vi.fn().mockResolvedValue('thread-new'),
playChime: vi.fn().mockResolvedValue(undefined),
showOverlay: vi.fn().mockResolvedValue(undefined),
getSettings: () => ({ speakReplies: true, showOverlay: true }),
now: () => 1_700_000_000_000,
watchdogMs: 10_000,
minAudioMs: 250,
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn() },
...overrides,
};
}
describe('pttService state machine', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('happy path: start → stop sends the transcript to the active thread with speakReply', async () => {
const deps = makeDeps();
const svc = createPttService(deps);
await svc.onStart(1);
expect(deps.audioCapture.start).toHaveBeenCalledWith({ sessionTag: 'ptt:1' });
expect(deps.playChime).toHaveBeenCalledWith('open');
expect(deps.showOverlay).toHaveBeenCalledWith(true, 1);
await svc.onStop(1);
expect(deps.audioCapture.finalize).toHaveBeenCalled();
expect(deps.playChime).toHaveBeenCalledWith('close');
expect(deps.showOverlay).toHaveBeenCalledWith(false, 1);
expect(deps.transcribe).toHaveBeenCalled();
expect(deps.sendMessage).toHaveBeenCalledWith({
threadId: 'thread-active',
body: 'hello world',
metadata: { source: 'ptt', session_id: 1 },
speakReply: true,
});
});
it('falls back to a new "Voice" thread when no active thread exists', async () => {
const deps = makeDeps({
resolveActiveThreadId: vi.fn().mockResolvedValue(null),
});
const svc = createPttService(deps);
await svc.onStart(2);
await svc.onStop(2);
expect(deps.createNewVoiceThread).toHaveBeenCalled();
expect(deps.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ threadId: 'thread-new' }),
);
});
it('drops the session and plays the error chime when audio is shorter than minAudioMs', async () => {
const deps = makeDeps({
audioCapture: {
start: vi.fn().mockResolvedValue(undefined),
finalize: vi.fn().mockResolvedValue({ durationMs: 100, buffer: new ArrayBuffer(0) }),
cancel: vi.fn().mockResolvedValue(undefined),
},
});
const svc = createPttService(deps);
await svc.onStart(3);
await svc.onStop(3);
expect(deps.transcribe).not.toHaveBeenCalled();
expect(deps.sendMessage).not.toHaveBeenCalled();
expect(deps.playChime).toHaveBeenCalledWith('error');
});
it('drops the session when the transcript is empty', async () => {
const deps = makeDeps({
transcribe: vi.fn().mockResolvedValue(' '),
});
const svc = createPttService(deps);
await svc.onStart(4);
await svc.onStop(4);
expect(deps.sendMessage).not.toHaveBeenCalled();
expect(deps.playChime).toHaveBeenCalledWith('error');
});
it('watchdog finalises the session after watchdogMs even if onStop never arrives', async () => {
const deps = makeDeps();
const svc = createPttService(deps);
await svc.onStart(5);
// Advance fake time past the watchdog.
await vi.advanceTimersByTimeAsync(11_000);
expect(deps.audioCapture.finalize).toHaveBeenCalled();
expect(deps.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ session_id: 5 }),
}),
);
});
it('second onStart while a session is active preempts the first', async () => {
const deps = makeDeps();
const svc = createPttService(deps);
await svc.onStart(6);
await svc.onStart(7);
expect(deps.audioCapture.cancel).toHaveBeenCalled();
expect(deps.audioCapture.start).toHaveBeenLastCalledWith({ sessionTag: 'ptt:7' });
});
it('honours the speakReplies setting when forwarding to sendMessage', async () => {
const deps = makeDeps({
getSettings: () => ({ speakReplies: false, showOverlay: true }),
});
const svc = createPttService(deps);
await svc.onStart(8);
await svc.onStop(8);
expect(deps.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ speakReply: false }),
);
});
it('mismatched session_id on onStop is ignored', async () => {
const deps = makeDeps();
const svc = createPttService(deps);
await svc.onStart(9);
await svc.onStop(999); // stale stop event
expect(deps.audioCapture.finalize).not.toHaveBeenCalled();
});
});
- Step 10.2: Run failing test
pnpm debug unit app/src/services/__tests__/pttService.test.ts
Expected: FAIL — pttService does not exist.
- Step 10.3: Implement
pttService
app/src/services/pttService.ts:
/**
* pttService — push-to-talk session state machine.
*
* See spec: `docs/superpowers/specs/2026-06-02-global-ptt-design.md` (§ 2, § 3).
*
* The service is dependency-injected so it can be exercised under vitest
* with fake audio capture / fake STT / fake sendMessage. The real wiring
* (subscribing to `ptt://*` Tauri events, the real audio_capture, etc.)
* happens in PttHotkeyManager.tsx (Task 11).
*/
export type ChimeKind = 'open' | 'close' | 'error';
export interface PttSettings {
speakReplies: boolean;
showOverlay: boolean;
}
export interface FinalizedAudio {
durationMs: number;
buffer: ArrayBuffer;
}
export interface PttDeps {
audioCapture: {
start(opts: { sessionTag: string }): Promise<void>;
finalize(): Promise<FinalizedAudio>;
cancel(): Promise<void>;
};
transcribe(buf: ArrayBuffer): Promise<string>;
sendMessage(args: {
threadId: string;
body: string;
metadata: { source: 'ptt'; session_id: number };
speakReply: boolean;
}): Promise<void>;
resolveActiveThreadId(): Promise<string | null>;
createNewVoiceThread(): Promise<string>;
playChime(kind: ChimeKind): Promise<void>;
showOverlay(active: boolean, sessionId: number): Promise<void>;
getSettings(): PttSettings;
now(): number;
watchdogMs: number;
minAudioMs: number;
logger: {
debug(msg: string, meta?: Record<string, unknown>): void;
info(msg: string, meta?: Record<string, unknown>): void;
warn(msg: string, meta?: Record<string, unknown>): void;
};
}
export interface PttService {
onStart(sessionId: number): Promise<void>;
onStop(sessionId: number): Promise<void>;
cancel(reason: 'preempted' | 'mic_failure' | 'user_cancel'): Promise<void>;
}
interface ActiveSession {
sessionId: number;
startedAtMs: number;
watchdogTimer: ReturnType<typeof setTimeout> | null;
finalizedByWatchdog: boolean;
}
export function createPttService(deps: PttDeps): PttService {
let active: ActiveSession | null = null;
const armWatchdog = (sessionId: number) => {
const timer = setTimeout(() => {
if (active && active.sessionId === sessionId) {
active.finalizedByWatchdog = true;
deps.logger.warn('[ptt] watchdog fired — finalising session', { sessionId });
// Fire-and-forget; the watchdog path is the same as a normal stop
// except for the `finalizedByWatchdog` flag, which is only used
// for logging.
void finaliseSession(sessionId, /* fromWatchdog */ true);
}
}, deps.watchdogMs);
return timer;
};
const finaliseSession = async (sessionId: number, fromWatchdog: boolean) => {
if (!active || active.sessionId !== sessionId) {
// Stale finalisation — ignore.
return;
}
if (active.watchdogTimer) {
clearTimeout(active.watchdogTimer);
active.watchdogTimer = null;
}
const settings = deps.getSettings();
const session = active;
active = null;
let audio: FinalizedAudio;
try {
audio = await deps.audioCapture.finalize();
} catch (err) {
deps.logger.warn('[ptt] audio finalize failed', { sessionId, err: String(err) });
await deps.playChime('error');
await deps.showOverlay(false, sessionId);
return;
}
await deps.playChime('close');
await deps.showOverlay(false, sessionId);
if (audio.durationMs < deps.minAudioMs) {
deps.logger.info('[ptt] session dropped — audio shorter than minAudioMs', {
sessionId,
durationMs: audio.durationMs,
});
await deps.playChime('error');
return;
}
let text = '';
try {
text = await deps.transcribe(audio.buffer);
} catch (err) {
deps.logger.warn('[ptt] transcription failed', { sessionId, err: String(err) });
// Per spec: post the message anyway as a breadcrumb.
text = '[Voice — transcription failed]';
}
if (!text.trim()) {
deps.logger.info('[ptt] session dropped — empty transcript', { sessionId });
await deps.playChime('error');
return;
}
let threadId = await deps.resolveActiveThreadId();
if (!threadId) {
threadId = await deps.createNewVoiceThread();
}
await deps.sendMessage({
threadId,
body: text.trim(),
metadata: { source: 'ptt', session_id: sessionId },
speakReply: settings.speakReplies,
});
deps.logger.info('[ptt] session committed', {
sessionId,
threadId,
heldMs: deps.now() - session.startedAtMs,
finalizedByWatchdog: fromWatchdog,
transcriptLen: text.trim().length,
});
};
return {
async onStart(sessionId) {
if (active) {
deps.logger.debug('[ptt] onStart while active — preempting', {
old: active.sessionId,
new: sessionId,
});
try {
await deps.audioCapture.cancel();
} catch (err) {
deps.logger.warn('[ptt] cancel failed during preempt', { err: String(err) });
}
if (active.watchdogTimer) clearTimeout(active.watchdogTimer);
active = null;
}
await deps.playChime('open');
await deps.showOverlay(true, sessionId);
try {
await deps.audioCapture.start({ sessionTag: `ptt:${sessionId}` });
} catch (err) {
deps.logger.warn('[ptt] audio start failed', { sessionId, err: String(err) });
await deps.playChime('error');
await deps.showOverlay(false, sessionId);
return;
}
active = {
sessionId,
startedAtMs: deps.now(),
watchdogTimer: null,
finalizedByWatchdog: false,
};
active.watchdogTimer = armWatchdog(sessionId);
},
async onStop(sessionId) {
if (!active || active.sessionId !== sessionId) {
deps.logger.debug('[ptt] stale onStop — ignored', { sessionId });
return;
}
await finaliseSession(sessionId, /* fromWatchdog */ false);
},
async cancel(reason) {
if (!active) return;
deps.logger.info('[ptt] cancel', { sessionId: active.sessionId, reason });
if (active.watchdogTimer) clearTimeout(active.watchdogTimer);
const session = active;
active = null;
try {
await deps.audioCapture.cancel();
} catch (err) {
deps.logger.warn('[ptt] cancel: audio cancel failed', { err: String(err) });
}
await deps.playChime('error');
await deps.showOverlay(false, session.sessionId);
},
};
}
- Step 10.4: Run pttService test to verify pass
pnpm debug unit app/src/services/__tests__/pttService.test.ts
Expected: PASS (all 8 tests).
- Step 10.5: Commit
git add app/src/services/pttService.ts \
app/src/services/__tests__/pttService.test.ts
git commit -m "feat(pttService): state machine, watchdog, preempt, fallback thread (#3090)"
Task 11: Boot-time hook + PttHotkeyManager (wires service to Tauri events)
Files:
- Create:
app/src/hooks/usePttHotkey.ts - Create:
app/src/components/PttHotkeyManager.tsx - Modify:
app/src/AppShell.tsx(mount the manager)
The manager creates the service singleton with real deps, subscribes to ptt://start / ptt://stop Tauri events, and re-registers the hotkey when the slice's shortcut changes.
- Step 11.1: Create
usePttHotkey
app/src/hooks/usePttHotkey.ts:
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
registerPttHotkey,
unregisterPttHotkey,
} from '../utils/tauriCommands/ptt';
import { setIsHeld } from '../store/slices/ptt';
import type { RootState } from '../store';
/**
* Subscribes the configured PTT shortcut to the Tauri shell whenever it
* changes. Resets the transient `isHeld` flag on mount so a stale rehydrated
* value can't leave the UI thinking the key is held.
*/
export function usePttHotkey(): void {
const dispatch = useDispatch();
const shortcut = useSelector((s: RootState) => s.ptt.shortcut);
// Reset transient state once on mount.
useEffect(() => {
dispatch(setIsHeld(false));
}, [dispatch]);
useEffect(() => {
let cancelled = false;
const apply = async () => {
try {
if (shortcut && shortcut.trim().length > 0) {
await registerPttHotkey(shortcut);
} else {
await unregisterPttHotkey();
}
} catch (err) {
if (!cancelled) {
console.warn('[ptt] hotkey (un)register failed', err);
}
}
};
void apply();
return () => {
cancelled = true;
};
}, [shortcut]);
}
- Step 11.2: Create
PttHotkeyManager
app/src/components/PttHotkeyManager.tsx:
import { useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector, useStore } from 'react-redux';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { usePttHotkey } from '../hooks/usePttHotkey';
import { setIsHeld } from '../store/slices/ptt';
import { showPttOverlay } from '../utils/tauriCommands/ptt';
import { createPttService } from '../services/pttService';
import { chatSend } from '../services/chatService';
import {
startPttAudio,
finalizePttAudio,
cancelPttAudio,
} from '../features/voice/pttAudio';
import { transcribePttAudio } from '../features/voice/pttTranscribe';
import {
resolveActiveThreadId,
createNewVoiceThread,
} from '../features/voice/pttThread';
import { playPttChime } from '../features/voice/pttChimes';
import type { RootState } from '../store';
/**
* Renderless. Mounted once in AppShell. Owns the pttService singleton.
*/
export function PttHotkeyManager(): null {
usePttHotkey();
const dispatch = useDispatch();
const store = useStore<RootState>();
const speakReplies = useSelector((s: RootState) => s.ptt.speakReplies);
const showOverlayPref = useSelector((s: RootState) => s.ptt.showOverlay);
const unlistenRef = useRef<UnlistenFn[]>([]);
const service = useMemo(
() =>
createPttService({
audioCapture: {
start: startPttAudio,
finalize: finalizePttAudio,
cancel: cancelPttAudio,
},
transcribe: transcribePttAudio,
sendMessage: async ({ threadId, body, speakReply, metadata }) => {
await chatSend({
threadId,
message: body,
speakReply,
source: metadata.source,
sessionId: metadata.session_id,
});
},
resolveActiveThreadId,
createNewVoiceThread,
playChime: playPttChime,
showOverlay: async (active, sessionId) => {
// Respect user setting — but always hide on stop even if the
// user toggled the setting off mid-session.
if (!active || store.getState().ptt.showOverlay) {
await showPttOverlay(active, sessionId);
}
},
getSettings: () => ({
speakReplies: store.getState().ptt.speakReplies,
showOverlay: store.getState().ptt.showOverlay,
}),
now: () => Date.now(),
watchdogMs: 10_000,
minAudioMs: 250,
logger: {
debug: (msg, meta) => console.debug(msg, meta ?? {}),
info: (msg, meta) => console.info(msg, meta ?? {}),
warn: (msg, meta) => console.warn(msg, meta ?? {}),
},
}),
// Service is constructed once for the lifetime of the AppShell.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
useEffect(() => {
let mounted = true;
(async () => {
const offStart = await listen<{ session_id: number }>('ptt://start', (e) => {
dispatch(setIsHeld(true));
void service.onStart(e.payload.session_id);
});
const offStop = await listen<{ session_id: number }>('ptt://stop', (e) => {
dispatch(setIsHeld(false));
void service.onStop(e.payload.session_id);
});
if (!mounted) {
offStart();
offStop();
return;
}
unlistenRef.current.push(offStart, offStop);
})();
return () => {
mounted = false;
for (const off of unlistenRef.current) off();
unlistenRef.current = [];
};
}, [dispatch, service]);
// Effects to suppress lint warning for unused selectors above.
void speakReplies;
void showOverlayPref;
return null;
}
The manager pulls four small feature modules (pttAudio, pttTranscribe, pttThread, pttChimes) — create them as thin wrappers:
app/src/features/voice/pttAudio.ts:
import type { FinalizedAudio } from '../../services/pttService';
// Reuse the existing voice/audio_capture functions used by dictation today.
// If the existing module lives at a different path, adjust the import.
import { startMicCapture, finalizeMicCapture, cancelMicCapture } from './audioCapture';
export async function startPttAudio(opts: { sessionTag: string }): Promise<void> {
await startMicCapture({ tag: opts.sessionTag });
}
export async function finalizePttAudio(): Promise<FinalizedAudio> {
const { buffer, durationMs } = await finalizeMicCapture();
return { buffer, durationMs };
}
export async function cancelPttAudio(): Promise<void> {
await cancelMicCapture();
}
If the existing audioCapture.ts exports different names (search app/src/features/voice and app/src/services/voice for the current capture API), adapt the wrappers — they're meant to be a thin renaming layer so pttService is decoupled from whatever the dictation feature already provides.
app/src/features/voice/pttTranscribe.ts:
import { transcribeBuffer } from './dictationTranscribe';
export async function transcribePttAudio(buf: ArrayBuffer): Promise<string> {
// Reuses the same STT path dictation uses.
return transcribeBuffer(buf);
}
app/src/features/voice/pttThread.ts:
import { store } from '../../store';
import { callCoreRpc } from '../../services/coreRpcClient';
export async function resolveActiveThreadId(): Promise<string | null> {
const state = store.getState();
// `chatRuntime.activeThread` is the source of truth for the currently-open thread.
return state.chatRuntime?.activeThreadId ?? null;
}
export async function createNewVoiceThread(): Promise<string> {
const resp = await callCoreRpc<{ result: { id: string } } | { id: string }>({
method: 'openhuman.threads_create_new',
params: { title: 'Voice' },
});
// Strip RpcOutcome envelope if present.
const r = 'result' in resp ? (resp as { result: { id: string } }).result : (resp as { id: string });
return r.id;
}
If the actual root state shape is different (e.g. state.chatRuntime doesn't exist or activeThreadId lives under a different key), update the selector. Same caveat for threads_create_new — confirm the actual RPC name in src/openhuman/threads/schemas.rs::"create_new".
app/src/features/voice/pttChimes.ts:
import openSrc from '../../assets/audio/ptt-open.wav';
import closeSrc from '../../assets/audio/ptt-close.wav';
import errorSrc from '../../assets/audio/ptt-error.wav';
const cache: Record<string, HTMLAudioElement> = {};
function get(src: string): HTMLAudioElement {
if (!cache[src]) {
const el = new Audio(src);
el.preload = 'auto';
cache[src] = el;
}
return cache[src];
}
export async function playPttChime(kind: 'open' | 'close' | 'error'): Promise<void> {
const src = kind === 'open' ? openSrc : kind === 'close' ? closeSrc : errorSrc;
const el = get(src);
try {
el.currentTime = 0;
await el.play();
} catch (err) {
console.debug('[ptt] chime play failed (likely autoplay policy)', err);
}
}
- Step 11.3: Mount
<PttHotkeyManager />
Open app/src/AppShell.tsx (or App.tsx, wherever top-level UI is mounted — search for <BottomTabBar or <AppRoutes /> in App.tsx). Add:
import { PttHotkeyManager } from './components/PttHotkeyManager';
// inside the render tree, alongside DictationHotkeyManager if present:
<PttHotkeyManager />
- Step 11.4: Run the full unit suite
pnpm debug unit
Expected: green. (The manager has integration-only behavior; we cover it indirectly via the pttService tests and the WDIO spec in Task 14.)
- Step 11.5: Run typecheck
pnpm typecheck
Expected: clean. Resolve any import-path issues that surface against the actual codebase paths.
- Step 11.6: Commit
git add app/src/hooks/usePttHotkey.ts \
app/src/components/PttHotkeyManager.tsx \
app/src/features/voice/pttAudio.ts \
app/src/features/voice/pttTranscribe.ts \
app/src/features/voice/pttThread.ts \
app/src/features/voice/pttChimes.ts \
app/src/AppShell.tsx
git commit -m "feat(ptt): mount PttHotkeyManager + wire service to real audio/STT/chat (#3090)"
Task 12: /ptt-overlay route + overlay UI
Files:
-
Create:
app/src/pages/PttOverlayPage.tsx -
Create:
app/src/pages/PttOverlayPage.test.tsx -
Modify:
app/src/AppRoutes.tsx -
Step 12.1: Write failing render test
app/src/pages/PttOverlayPage.test.tsx:
import { describe, expect, it, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { PttOverlayPage } from './PttOverlayPage';
// Mock @tauri-apps/api/event's listen so we can dispatch fake events.
vi.mock('@tauri-apps/api/event', () => {
const handlers: Record<string, (e: { payload: unknown }) => void> = {};
return {
listen: vi.fn(async (name: string, handler: (e: { payload: unknown }) => void) => {
handlers[name] = handler;
return () => delete handlers[name];
}),
__dispatch: (name: string, payload: unknown) =>
handlers[name]?.({ payload }),
};
});
describe('PttOverlayPage', () => {
it('renders idle state by default', () => {
render(<PttOverlayPage />);
expect(screen.getByTestId('ptt-overlay-root')).toHaveAttribute('data-active', 'false');
});
it('flips to active when ptt-overlay://active fires with active=true', async () => {
render(<PttOverlayPage />);
const evt = await import('@tauri-apps/api/event');
await act(async () => {
(evt as unknown as { __dispatch: (n: string, p: unknown) => void }).__dispatch(
'ptt-overlay://active',
{ active: true, session_id: 1 },
);
});
expect(screen.getByTestId('ptt-overlay-root')).toHaveAttribute('data-active', 'true');
});
});
- Step 12.2: Run failing test
pnpm debug unit app/src/pages/PttOverlayPage.test.tsx
Expected: FAIL — module does not exist.
- Step 12.3: Implement the page
app/src/pages/PttOverlayPage.tsx:
import { useEffect, useState } from 'react';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useT } from '../lib/i18n/I18nContext';
export function PttOverlayPage(): JSX.Element {
const t = useT();
const [active, setActive] = useState(false);
useEffect(() => {
let off: UnlistenFn | undefined;
(async () => {
off = await listen<{ active: boolean }>('ptt-overlay://active', (e) => {
setActive(Boolean(e.payload?.active));
});
})();
return () => off?.();
}, []);
return (
<div
data-testid="ptt-overlay-root"
data-active={active}
style={{
width: '160px',
height: '56px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
background: 'rgba(20, 20, 24, 0.85)',
borderRadius: 12,
color: '#fff',
fontFamily: 'Inter, system-ui, sans-serif',
fontSize: 12,
userSelect: 'none',
pointerEvents: 'none',
}}
>
<span
aria-hidden
style={{
width: 10,
height: 10,
borderRadius: 999,
background: active ? '#ff4d4f' : '#666',
boxShadow: active ? '0 0 6px #ff4d4f' : undefined,
transition: 'all 120ms ease',
}}
/>
<span>{active ? t('pttOverlay.listening') : t('pttOverlay.idle')}</span>
</div>
);
}
- Step 12.4: Add the route
In app/src/AppRoutes.tsx, add (alongside other Routes):
import { PttOverlayPage } from './pages/PttOverlayPage';
// inside <Routes>:
<Route path="/ptt-overlay" element={<PttOverlayPage />} />
- Step 12.5: Run overlay tests
pnpm debug unit app/src/pages/PttOverlayPage.test.tsx
Expected: PASS.
- Step 12.6: Commit
git add app/src/pages/PttOverlayPage.tsx \
app/src/pages/PttOverlayPage.test.tsx \
app/src/AppRoutes.tsx
git commit -m "feat(ptt/ui): overlay page at /ptt-overlay with idle/active states (#3090)"
Task 13: Settings panel — hotkey capture + toggles
Files:
-
Create:
app/src/pages/settings/voice/PttSettingsPanel.tsx -
Create:
app/src/pages/settings/voice/__tests__/PttSettingsPanel.test.tsx -
Modify:
app/src/pages/settings/voice/VoiceSettingsPage.tsx(or wherever the voice settings tab body lives) -
Modify:
app/src/lib/i18n/en.ts+ 12 other locale files -
Step 13.1: Add i18n keys to en.ts
In app/src/lib/i18n/en.ts, add:
// In the appropriate section (alphabetical, near other voice keys):
'pttSettings.title': 'Push-to-talk',
'pttSettings.description':
"Hold a key to talk to OpenHuman while you're in another app. Releases the key to send; OpenHuman speaks the reply back.",
'pttSettings.shortcutLabel': 'Hotkey',
'pttSettings.shortcutPlaceholder': 'Press a key (e.g. F13)',
'pttSettings.shortcutUnsetHint': 'Push-to-talk is off — pick a hotkey to enable.',
'pttSettings.speakRepliesLabel': 'Speak agent replies',
'pttSettings.showOverlayLabel': 'Show overlay while held',
'pttSettings.errorConflictsWithDictation':
'This shortcut is already used by dictation. Pick a different key.',
'pttSettings.errorModifierOnly':
"Pick a regular key (e.g. F13) — modifier-only shortcuts don't work for push-to-talk.",
'pttSettings.errorEmpty': 'Pick a key to bind.',
'pttSettings.errorAccessibility':
'macOS needs Accessibility permission for this shortcut. Open System Settings → Privacy & Security → Accessibility and enable OpenHuman.',
'pttSettings.errorShortcutInUse':
'Another app already uses this shortcut. Pick a different one.',
'pttSettings.errorUnsupportedWayland':
"Wayland sessions don't support global shortcuts in OpenHuman yet — switch to an X11 session or use the in-app dictation toggle.",
'pttSettings.exclusiveFullscreenHint':
"In exclusive-fullscreen games the overlay won't render — you'll only hear the chime. Switch to borderless fullscreen for the overlay.",
'pttOverlay.listening': 'Listening…',
'pttOverlay.idle': 'Idle',
- Step 13.2: Add the same keys to every other locale with REAL translations
For each of ar, bn, de, es, fr, hi, id, it, ko, pl, pt, ru, zh-CN, add the same set of keys with translated values. Do not copy English. Examples for German (de.ts) and Spanish (es.ts) — translate the remaining 11 locales the same way:
// de.ts additions
'pttSettings.title': 'Push-to-Talk',
'pttSettings.description':
'Halte eine Taste gedrückt, um mit OpenHuman zu sprechen, während du in einer anderen App bist. Beim Loslassen wird gesendet; OpenHuman spricht die Antwort.',
'pttSettings.shortcutLabel': 'Tastenkürzel',
'pttSettings.shortcutPlaceholder': 'Taste drücken (z. B. F13)',
'pttSettings.shortcutUnsetHint': 'Push-to-Talk ist aus — wähle ein Tastenkürzel zum Aktivieren.',
'pttSettings.speakRepliesLabel': 'Antworten vorlesen',
'pttSettings.showOverlayLabel': 'Overlay während des Haltens anzeigen',
'pttSettings.errorConflictsWithDictation':
'Dieses Kürzel wird bereits von der Diktierfunktion verwendet. Wähle eine andere Taste.',
'pttSettings.errorModifierOnly':
'Wähle eine normale Taste (z. B. F13) — reine Modifikatortasten funktionieren für Push-to-Talk nicht.',
'pttSettings.errorEmpty': 'Wähle eine Taste zum Binden.',
'pttSettings.errorAccessibility':
'macOS benötigt die Bedienungshilfen-Berechtigung. Öffne Systemeinstellungen → Datenschutz & Sicherheit → Bedienungshilfen und aktiviere OpenHuman.',
'pttSettings.errorShortcutInUse':
'Eine andere App nutzt dieses Kürzel bereits. Wähle ein anderes.',
'pttSettings.errorUnsupportedWayland':
'Wayland-Sitzungen unterstützen globale Tastenkürzel in OpenHuman noch nicht — wechsle zu X11 oder nutze die In-App-Diktatumschaltung.',
'pttSettings.exclusiveFullscreenHint':
'Im Exclusive-Fullscreen-Modus wird das Overlay nicht angezeigt — du hörst nur den Signalton. Wechsle zu randlosem Vollbild für das Overlay.',
'pttOverlay.listening': 'Höre zu…',
'pttOverlay.idle': 'Inaktiv',
// es.ts additions
'pttSettings.title': 'Pulsar para hablar',
'pttSettings.description':
'Mantén una tecla pulsada para hablar con OpenHuman mientras estás en otra app. Al soltar se envía; OpenHuman lee la respuesta.',
'pttSettings.shortcutLabel': 'Atajo de teclado',
'pttSettings.shortcutPlaceholder': 'Pulsa una tecla (p. ej. F13)',
'pttSettings.shortcutUnsetHint': 'Pulsar para hablar está apagado — elige una tecla para activarlo.',
'pttSettings.speakRepliesLabel': 'Leer las respuestas en voz alta',
'pttSettings.showOverlayLabel': 'Mostrar superposición mientras se mantiene pulsada',
'pttSettings.errorConflictsWithDictation':
'Este atajo ya lo usa el dictado. Elige otra tecla.',
'pttSettings.errorModifierOnly':
'Elige una tecla normal (p. ej. F13) — los atajos solo con modificadores no funcionan para pulsar para hablar.',
'pttSettings.errorEmpty': 'Elige una tecla para asignar.',
'pttSettings.errorAccessibility':
'macOS requiere permiso de Accesibilidad. Abre Ajustes del Sistema → Privacidad y Seguridad → Accesibilidad y activa OpenHuman.',
'pttSettings.errorShortcutInUse':
'Otra app ya está usando este atajo. Elige uno diferente.',
'pttSettings.errorUnsupportedWayland':
'Las sesiones Wayland aún no admiten atajos globales en OpenHuman — cambia a X11 o usa la activación del dictado en la app.',
'pttSettings.exclusiveFullscreenHint':
'En modo pantalla completa exclusivo el overlay no se mostrará — solo oirás el tono. Cambia a pantalla completa sin bordes para ver el overlay.',
'pttOverlay.listening': 'Escuchando…',
'pttOverlay.idle': 'Inactivo',
For the remaining 11 locales, repeat with translations into that language. Do not leave English-language stubs.
- Step 13.3: Run i18n gates
pnpm i18n:check
pnpm i18n:english:check
Expected: both pass.
- Step 13.4: Write failing settings panel test
app/src/pages/settings/voice/__tests__/PttSettingsPanel.test.tsx:
import { describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { pttReducer, initialPttState } from '../../../../store/slices/ptt';
import { I18nProvider } from '../../../../lib/i18n/I18nContext';
import en from '../../../../lib/i18n/en';
import { PttSettingsPanel } from '../PttSettingsPanel';
function renderWithStore(state = initialPttState) {
const store = configureStore({
reducer: { ptt: pttReducer },
preloadedState: { ptt: state },
});
return render(
<Provider store={store}>
<I18nProvider locale="en" messages={en}>
<PttSettingsPanel />
</I18nProvider>
</Provider>,
);
}
describe('PttSettingsPanel', () => {
it('renders the hint when no shortcut is set', () => {
renderWithStore({ ...initialPttState, shortcut: null });
expect(screen.getByText(/push-to-talk is off/i)).toBeInTheDocument();
});
it('renders the bound shortcut when set', () => {
renderWithStore({ ...initialPttState, shortcut: 'F13' });
// The hotkey-capture widget shows the current key somewhere — adapt to the
// existing widget's testid pattern used by the dictation panel.
expect(screen.getByTestId('ptt-shortcut-current')).toHaveTextContent('F13');
});
it('toggles speakReplies via the switch', () => {
renderWithStore({ ...initialPttState, shortcut: 'F13', speakReplies: true });
const toggle = screen.getByLabelText(/speak agent replies/i);
fireEvent.click(toggle);
// Assert dispatched action via store state — re-render and check the toggle's aria-checked.
expect(toggle).toHaveAttribute('aria-checked', 'false');
});
});
- Step 13.5: Implement
PttSettingsPanel
app/src/pages/settings/voice/PttSettingsPanel.tsx:
import { useDispatch, useSelector } from 'react-redux';
import { useT } from '../../../lib/i18n/I18nContext';
import {
setPttShortcut,
setSpeakReplies,
setShowOverlay,
} from '../../../store/slices/ptt';
import type { RootState } from '../../../store';
// Reuse the dictation panel's hotkey-capture widget pattern; if the existing
// one isn't reusable, build a small inline KeyCapture in this file with the
// same shape.
import { HotkeyCaptureField } from '../../../components/HotkeyCaptureField';
export function PttSettingsPanel(): JSX.Element {
const t = useT();
const dispatch = useDispatch();
const shortcut = useSelector((s: RootState) => s.ptt.shortcut);
const speakReplies = useSelector((s: RootState) => s.ptt.speakReplies);
const showOverlay = useSelector((s: RootState) => s.ptt.showOverlay);
return (
<section aria-labelledby="ptt-settings-title">
<h3 id="ptt-settings-title">{t('pttSettings.title')}</h3>
<p>{t('pttSettings.description')}</p>
<HotkeyCaptureField
label={t('pttSettings.shortcutLabel')}
placeholder={t('pttSettings.shortcutPlaceholder')}
value={shortcut ?? ''}
onChange={(next) => dispatch(setPttShortcut(next || null))}
testIdCurrent="ptt-shortcut-current"
/>
{shortcut == null && (
<p role="note">{t('pttSettings.shortcutUnsetHint')}</p>
)}
<label>
<input
type="checkbox"
role="switch"
aria-checked={speakReplies}
checked={speakReplies}
onChange={(e) => dispatch(setSpeakReplies(e.target.checked))}
/>
{t('pttSettings.speakRepliesLabel')}
</label>
<label>
<input
type="checkbox"
role="switch"
aria-checked={showOverlay}
checked={showOverlay}
onChange={(e) => dispatch(setShowOverlay(e.target.checked))}
/>
{t('pttSettings.showOverlayLabel')}
</label>
<p>{t('pttSettings.exclusiveFullscreenHint')}</p>
</section>
);
}
If HotkeyCaptureField doesn't already exist in the codebase, locate the equivalent in the dictation settings panel (search app/src/pages/settings/voice/ for the current key-binding widget) and either reuse it or extract a shared component. The plan target is one new file (PttSettingsPanel.tsx); a shared HotkeyCaptureField.tsx is optional cleanup if useful.
- Step 13.6: Mount the panel in the Voice settings page
Find the voice settings page (search app/src/pages/settings/voice/ for the entry point — likely VoiceSettingsPage.tsx or similar). Import and render <PttSettingsPanel /> alongside the existing dictation section.
- Step 13.7: Run tests
pnpm debug unit app/src/pages/settings/voice/__tests__/PttSettingsPanel.test.tsx
pnpm i18n:check
pnpm i18n:english:check
Expected: all pass.
- Step 13.8: Commit
git add app/src/pages/settings/voice/PttSettingsPanel.tsx \
app/src/pages/settings/voice/__tests__/PttSettingsPanel.test.tsx \
app/src/lib/i18n/
git commit -m "feat(settings/voice): PttSettingsPanel + 13-locale i18n (#3090)"
Task 14: WDIO E2E — full PTT flow with mocked STT
Files:
- Create:
app/test/e2e/specs/ptt-flow.spec.ts
End-to-end: open settings, bind F13 as the PTT key, simulate a hold via tauri-driver key injection, assert the overlay window appears, assert the chat thread receives a message. STT is mocked through the existing shared mock backend (scripts/mock-api-core.mjs) so the spec is deterministic.
- Step 14.1: Verify mock backend can return a fixed STT transcript
Search scripts/mock-api-core.mjs for any existing transcription endpoint (likely transcribe or stt). If one exists, note its admin-config override path. If not, add a minimal endpoint that returns a fixed transcript when called:
// In scripts/mock-api-core.mjs — add near other mock endpoints:
if (req.url === '/v1/transcribe' && req.method === 'POST') {
const override = state.behavior.transcribe || { text: 'mocked transcript from ptt e2e' };
return respondJson(res, 200, override);
}
This is a small surface-area extension; confirm the exact integration shape against the existing mock-server pattern.
- Step 14.2: Write the E2E spec
app/test/e2e/specs/ptt-flow.spec.ts:
import { expect } from '@wdio/globals';
import {
clickNativeButton,
waitForWebView,
clickToggle,
} from '../helpers/element-helpers';
import { adminReset, adminSetBehavior, adminLastRequests } from '../helpers/mock-server';
describe('PTT flow', () => {
before(async () => {
await adminReset();
await adminSetBehavior({
transcribe: { text: 'hello from PTT' },
});
});
it('binds F13, simulates a hold, asserts overlay + chat message', async () => {
await waitForWebView();
// 1. Navigate to Voice settings.
await clickNativeButton('tab-settings');
await clickNativeButton('settings-section-voice');
// 2. Bind F13 as the PTT shortcut.
await $('input[aria-label="Hotkey"]').click();
await browser.keys(['F13']);
// Save / confirm via whatever pattern the dictation panel uses (auto-save typically).
await browser.pause(200);
// 3. Simulate a hold: press F13, wait, release F13.
await browser.keys(['F13']); // press (key down)
await browser.pause(800); // hold
// tauri-driver / Appium release: depends on driver. For WDIO + Appium Mac2,
// browser.keys() simulates a tap by default; for an explicit press-and-release
// pair use the W3C Actions API:
await browser.action('key')
.down('F13')
.pause(800)
.up('F13')
.perform();
// 4. Wait for the overlay window to appear, then disappear.
// Tauri webview windows are queryable by label via getWindowHandles + switchToWindow.
const handlesDuring = await browser.getWindowHandles();
expect(handlesDuring.length).toBeGreaterThan(1);
// 5. Switch back to the main webview and assert the chat thread has the message.
await browser.switchToWindow(handlesDuring[0]);
await clickNativeButton('tab-chat');
const lastMessage = await $('[data-testid="chat-message-last"]');
await lastMessage.waitForExist({ timeout: 5_000 });
await expect(lastMessage).toHaveTextContaining('hello from PTT');
// 6. Assert the chat request hit channel.web_chat with speak_reply=true.
const requests = await adminLastRequests();
const chatCall = requests.find((r) =>
r.url.includes('/rpc') &&
typeof r.body === 'string' &&
r.body.includes('channel_web_chat'),
);
expect(chatCall).toBeDefined();
expect(JSON.parse(chatCall!.body)).toMatchObject({
params: expect.objectContaining({
speak_reply: true,
source: 'ptt',
}),
});
});
});
(adminLastRequests may already exist in app/test/e2e/helpers/mock-server.ts; if not, the helper file lives at that path — extend it to expose the existing /__admin/requests endpoint.)
- Step 14.3: Build the Tauri bundle + run the spec
pnpm test:e2e:build
bash app/scripts/e2e-run-spec.sh test/e2e/specs/ptt-flow.spec.ts ptt-flow
Expected: PASS. If F13 key injection fails on the test driver (some Appium versions need scancodes), substitute a more reliable key like Pause or ScrollLock and update the spec + bound shortcut accordingly.
- Step 14.4: Commit
git add app/test/e2e/specs/ptt-flow.spec.ts scripts/mock-api-core.mjs
git commit -m "test(ptt/e2e): full bind→hold→commit flow with mocked STT (#3090)"
Task 15: voice.ptt capability entry + final quality sweep
Files:
-
Modify:
src/openhuman/about_app/(capability list — locate the file that defines the capability vec) -
Modify: anything else surfaced by the final quality pass
-
Step 15.1: Add the capability entry
Find the capability vec in src/openhuman/about_app/. It will look roughly like:
Capability {
id: "voice.dictation",
label: "Dictation hotkey",
...
},
Add a sibling entry:
Capability {
id: "voice.ptt",
label: "Global push-to-talk",
supported_on: &[Platform::MacOS, Platform::Windows, Platform::LinuxX11],
requires: &["microphone", "global_shortcut"],
},
If Platform::LinuxX11 doesn't exist as a variant, add it to the Platform enum in the same module (or list Platform::Linux and note "X11 only" in a description field, depending on the enum's shape).
- Step 15.2: Add a test for the new capability
In the corresponding capability tests file (search src/openhuman/about_app/ for *_tests.rs):
#[test]
fn capability_list_includes_voice_ptt() {
let caps = all_capabilities();
assert!(
caps.iter().any(|c| c.id == "voice.ptt"),
"voice.ptt capability must be registered"
);
}
- Step 15.3: Run the capability test
pnpm debug rust capability_list_includes_voice_ptt
Expected: PASS.
- Step 15.4: Run the full quality suite
pnpm format
pnpm lint
pnpm typecheck
pnpm debug unit
pnpm rust:check
pnpm test:rust
pnpm i18n:check
pnpm i18n:english:check
Fix any red. Treat all as gating — none should be skipped.
- Step 15.5: Verify diff coverage
# Approximate diff coverage locally; the merge gate runs the canonical job in CI.
pnpm test:coverage
Eyeball coverage for each new file. Files under 80% diff coverage: add focused tests.
- Step 15.6: Commit + push
git add src/openhuman/about_app/
git commit -m "feat(about_app): register voice.ptt capability (#3090)"
git push aniketh feat/global-ptt-3090
- Step 15.7: Open the PR against
tinyhumansai/openhuman:main
gh pr create \
--repo tinyhumansai/openhuman \
--base main \
--head CodeGhost21:feat/global-ptt-3090 \
--title "feat(voice): global push-to-talk hotkey (#3090)" \
--body-file - <<'EOF'
## Summary
- Hold-to-talk global hotkey: mic opens on press, closes on release, transcript sent to active thread, agent reply spoken via TTS — no focus stealing.
- Cross-platform via `tauri-plugin-global-shortcut` (different from dictation's OS-forked rdev/Tauri-plugin path — deliberately single-code-path here).
- Borderless always-on-top overlay window (lazy-created on first register).
- Audible open/close/error chimes.
- 10s watchdog finalises sessions when the OS swallows the release event.
- `speak_reply` / `source` / `session_id` additive optional fields on `channel.web_chat`; backwards-compatible.
## Spec / plan
- Spec: `docs/superpowers/specs/2026-06-02-global-ptt-design.md`
- Plan: `docs/superpowers/plans/2026-06-02-global-ptt.md`
- Issue: closes part of #3090 (PTT half; background screen-capture is a separate follow-up PR)
## Test plan
- [x] `pnpm debug rust web_chat_schema_accepts_optional_ptt_fields`
- [x] `pnpm debug rust publishing_a_ptt_commit_reaches_a_subscriber`
- [x] `pnpm debug rust channel_web_chat_with_speak_reply_invokes_reply_speech`
- [x] `pnpm debug rust ptt_hotkeys`
- [x] `pnpm debug unit app/src/store/slices/__tests__/ptt.test.ts`
- [x] `pnpm debug unit app/src/services/__tests__/pttService.test.ts`
- [x] `pnpm debug unit app/src/pages/PttOverlayPage.test.tsx`
- [x] `pnpm debug unit app/src/pages/settings/voice/__tests__/PttSettingsPanel.test.tsx`
- [x] `pnpm i18n:check` + `pnpm i18n:english:check`
- [x] `bash app/scripts/e2e-run-spec.sh test/e2e/specs/ptt-flow.spec.ts ptt-flow`
- [x] Manual smoke on macOS — hold key while VS Code is foreground, agent reply audible.
## Notes
- Approval/Submission-checklist boxes above are all `[x]` per the project's PR submission checklist rule (`feedback_pr_submission_checklist`).
- Background screen capture from #3090 is intentionally out of scope here; it's tracked as a follow-up.
EOF
Self-review (post-write)
Spec coverage
| Spec section | Covered by |
|---|---|
| Goals — configurable hold-to-talk hotkey | T3 (parse), T5 (register IPC), T11 (renderer hook) |
| Goals — mic-on-press / mic-off-release / TTS reply | T4 (TTS hook), T10 (state machine), T11 (real audio wiring) |
| Goals — audible + visual feedback | T7 (chimes), T6 (overlay window), T12 (overlay UI) |
| Goals — macOS + Windows + Linux/X11; Wayland docs | T3 (uniform expand), T13 (Wayland error string), all hotkey logic is platform-agnostic via Tauri plugin |
Component map — ptt_hotkeys.rs |
T3, T5 |
Component map — ptt_overlay.rs |
T6 |
Component map — voice/bus.rs + DomainEvent |
T2 |
| Component map — schema delta | T1, T4 |
Component map — pttService.ts |
T10 |
Component map — ptt slice |
T8 |
Component map — PttSettingsPanel |
T13 |
| Component map — overlay React page | T12 |
| Component map — chimes | T7, T11 |
| Component map — i18n in 13 locales | T13 |
| § 2 State machine — press/release CAS | T5 (CAS in the Tauri-side closure) |
| § 2 State machine — watchdog | T10 + T10 tests |
| § 2 State machine — modifier-only rejection | T3 |
| § 3 Audio + transcript flow — full path | T10 + T11 |
| § 3 Active thread fallback | T10 + T11 (createNewVoiceThread) |
| § 3 Empty-audio / empty-transcript handling | T10 |
| § 3 TTS routing via speak_reply | T1, T4 |
| § 3 Dictation-preempt | T10 (preempt branch in onStart) |
| § 4 Overlay implementation choice | T6 |
| § 4 Visibility lifecycle | T6 |
| § 4 DXGI caveat documented | T13 (exclusiveFullscreenHint) |
| § 5 Mic permission denied | T10 (error chime + log) |
| § 5 Global-hotkey registration failures | T3 (error enum), T5 (rollback + dictation conflict error path), T13 (i18n surfaces) |
| § 5 Shortcut conflicts with dictation | T5 (bidirectional) |
| § 5 Logging | T3, T5, T6, T10, T11 (all include [ptt] prefix and PII-safe fields) |
| § 5 Capability catalog | T15 |
| § 6 No TOML schema change | n/a — confirmed not in any task |
§ 6 Default shortcut: null |
T8 |
| § 6 Boot path | T11 (usePttHotkey) |
| § 7 Tests — every layer | T1 (schema), T2 (bus), T3 (parse), T4 (E2E), T8 (slice), T10 (service), T12 (overlay), T13 (panel), T14 (WDIO) |
| § 7 Coverage gate | T15 |
| Out of scope — listed in plan header + Task 15 PR body | ✓ |
No gaps.
Placeholder scan
Searched for "TBD", "TODO", "Fill in", "Similar to Task", "implement later". None present. Where the plan asks the engineer to "search for the dictation pattern" (T11 audio, T13 hotkey-capture widget), the search target and shape are both named explicitly — not placeholder text.
Type consistency
PttErrorvariants are defined in T3 and referenced in T5 (ConflictsWithDictation(String)). ✓PttHotkeyState::{shortcut, session_counter}defined in T3 and accessed in T5. ✓PttDepsfield names match between T10's test (audioCapture,transcribe,sendMessage,resolveActiveThreadId,createNewVoiceThread,playChime,showOverlay,getSettings,now,watchdogMs,minAudioMs,logger) and T10's implementation. ✓FinalizedAudio.{durationMs, buffer}consistent between definition (T10) and consumer (T11'sfinalizePttAudiowrapper). ✓ChimeKind = 'open' | 'close' | 'error'consistent between T10 (definition) and T11 (playPttChimesignature). ✓PttSettings = { speakReplies, showOverlay }consistent between slice (T8) andgetSettings()(T11). ✓chatSendparams:speakReply,source,sessionIdconsistent across T9 (chatService), T10 (test fixture), T11 (manager call site). ✓channel.web_chatserver fields:speak_reply,source,session_idconsistent across T1 (schema), T4 (consumer), T9 (caller). ✓- Tauri event names:
ptt://start,ptt://stop,ptt-overlay://activeconsistent across T5 (emit), T6 (emit), T11 (listen), T12 (listen). ✓