From 58d722cb5d2acfc8c5ebeb7c596d18b6562fdc85 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 22 Sep 2026 22:21:22 +0000 Subject: [PATCH] Show a voice badge in the agents overview (#47382) ## What changed Display a `voice` badge on the task whose session owns the running voice conversation, including when a child thread owns it. Refresh the overview after voice controls and realtime conversation resets so the badge clears when background voice closes asynchronously. ## Testing Add snapshot coverage for a child thread owning voice and a regression test for clearing the badge after an asynchronous background voice close. GitOrigin-RevId: 815dfad4ce1a31945d2dd0d3f9cecc5abfa419d6 --- codex-rs/tui/src/app/agent_center/rows.rs | 10 ++++ codex-rs/tui/src/app/agent_center_tests.rs | 51 ++++++++++++++++++- codex-rs/tui/src/app/agents_overview.rs | 6 +++ codex-rs/tui/src/app/agents_overview_view.rs | 1 + codex-rs/tui/src/app/event_dispatch.rs | 3 ++ ...__command_center__live_center_columns.snap | 2 +- codex-rs/tui/src/app/voice_owner.rs | 1 + codex-rs/tui/src/app_event.rs | 1 + codex-rs/tui/src/app_event_sender.rs | 1 + codex-rs/tui/src/chatwidget/realtime.rs | 4 ++ .../chatwidget/realtime_tests/lifecycle.rs | 9 ++-- 11 files changed, 84 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/app/agent_center/rows.rs b/codex-rs/tui/src/app/agent_center/rows.rs index 8178ab1c89..07c10f4826 100644 --- a/codex-rs/tui/src/app/agent_center/rows.rs +++ b/codex-rs/tui/src/app/agent_center/rows.rs @@ -183,6 +183,16 @@ impl AgentsOverviewView { title.width -= 10; line(Line::from(" current").style(style), badge, buf); } + if task.has_voice && title.width >= 8 { + let badge = Rect::new( + title.right() - 8, + title.y, + /*width*/ 8, + /*height*/ 1, + ); + title.width -= 8; + line(Line::from(" voice").style(style), badge, buf); + } let title_style = if index == self.selected { style } else { diff --git a/codex-rs/tui/src/app/agent_center_tests.rs b/codex-rs/tui/src/app/agent_center_tests.rs index 2408927964..f72576acdc 100644 --- a/codex-rs/tui/src/app/agent_center_tests.rs +++ b/codex-rs/tui/src/app/agent_center_tests.rs @@ -52,7 +52,7 @@ async fn live_center_columns() { ), ("Failed task", ThreadStatus::SystemError, 3630), ]; - let threads = fixtures + let mut threads = fixtures .iter() .enumerate() .map(|(index, (title, status, age))| { @@ -66,6 +66,14 @@ async fn live_center_columns() { thread }) .collect::>(); + let child = ThreadId::from_u128(/*value*/ 48); + threads.push(overview_thread( + child, + Some(ThreadId::from_u128(/*value*/ 43)), + "Child", + ThreadStatus::Idle, + )); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, child); let mut view = app.agents_overview_view(threads, Some(current)); insta::assert_snapshot!(screen(&view, /*width*/ 160, /*height*/ 22)); let mut selected_status_styles = Vec::new(); @@ -504,3 +512,44 @@ async fn backspace_edits_search_and_rename_without_deleting_tasks() { view.handle_key_event(KeyCode::Esc.into()); } } + +#[tokio::test] +async fn overview_clears_voice_badge_after_async_close() -> Result<()> { + let (mut app, mut events, _) = crate::app::tests::make_test_app_with_channels().await; + let owner = ThreadId::new(); + crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, owner); + app.chat_widget.park_voice(); + let (visible, _, _, _) = crate::chatwidget::tests::make_chatwidget_manual_with_sender().await; + app.background_voice = Some(Box::new(std::mem::replace(&mut app.chat_widget, visible))); + app.primary_thread_id = Some(owner); + let thread = overview_thread( + owner, + /*parent_thread_id*/ None, + "Voice owner", + ThreadStatus::Idle, + ); + app.agents_overview + .threads + .insert(owner, Some(thread.clone())); + let view = app.agents_overview_view(vec![thread], Some(owner)); + app.agents_overview.visible_thread_ids = view.thread_ids(); + app.chat_widget.show_bottom_pane_view(Box::new(view)); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 100).contains(" voice")); + let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.deliver_background_voice_notification( + owner, + &ServerNotification::ThreadRealtimeClosed( + codex_app_server_protocol::ThreadRealtimeClosedNotification { + thread_id: owner.to_string(), + reason: Some("requested".into()), + }, + ), + ); + while let Ok(event) = events.try_recv() { + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + } + assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 100).contains(" voice")); + server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index d6e2c55ee0..b88f392ffd 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -291,6 +291,11 @@ impl App { } } + let voice_owner = self.voice_owner_thread_id().map(|id| id.to_string()); + let voice_session = threads + .iter() + .find(|thread| Some(&thread.id) == voice_owner.as_ref()) + .map(|thread| &thread.session_id); let mut roots = threads .iter() .filter(|thread| thread.parent_thread_id.is_none()) @@ -316,6 +321,7 @@ impl App { thread_id, group, is_current: self.primary_thread_id == Some(thread_id), + has_voice: voice_session == Some(&root.session_id), }); } diff --git a/codex-rs/tui/src/app/agents_overview_view.rs b/codex-rs/tui/src/app/agents_overview_view.rs index 489a3562f3..678ca669a1 100644 --- a/codex-rs/tui/src/app/agents_overview_view.rs +++ b/codex-rs/tui/src/app/agents_overview_view.rs @@ -95,6 +95,7 @@ pub(super) struct AgentsOverviewRow { pub(super) thread_id: ThreadId, pub(super) group: AgentsOverviewGroup, pub(super) is_current: bool, + pub(super) has_voice: bool, } fn display_title(thread: &Thread) -> &str { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index abd19e8e28..7fc9161a24 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1927,6 +1927,9 @@ impl App { self.background_voice_error = Some((thread_id, message)); } } + AppEvent::RealtimeConversationStateChanged => { + self.repaint_agents_overview(); + } AppEvent::VoiceControl { thread_id, control } => { if thread_id == self.chat_widget.thread_id() || self.voice_owner_thread_id().is_some() { self.control_voice(control); diff --git a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_columns.snap b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_columns.snap index da22de3f86..29f2742a1b 100644 --- a/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_columns.snap +++ b/codex-rs/tui/src/app/snapshots/codex_tui__app__agents_overview__tests__command_center__live_center_columns.snap @@ -8,7 +8,7 @@ expression: "screen(&view, 160, 22)" Tasks Status Updated │ Task details /tmp/project 6 │ › ○ Current task current Ready now │ Current task - ○ A longer task title that uses the available column Ready 5m ago │ ○ Ready + ○ A longer task title that uses the available column voice Ready 5m ago │ ○ Ready ! Failed task Error 1h ago │ ● Needs an answer Needs input 2h ago │ Project ● Working task Working 1d ago │ /tmp/project diff --git a/codex-rs/tui/src/app/voice_owner.rs b/codex-rs/tui/src/app/voice_owner.rs index 6a76c56424..5200cf6a6e 100644 --- a/codex-rs/tui/src/app/voice_owner.rs +++ b/codex-rs/tui/src/app/voice_owner.rs @@ -64,6 +64,7 @@ impl App { } else if matches!(control, VoiceControl::Mute) { self.chat_widget.toggle_realtime_microphone(); } + self.repaint_agents_overview(); } pub(super) fn retire_background_voice(&mut self) { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 79a87ba9f4..4dbdc87bf5 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1094,6 +1094,7 @@ pub(crate) enum AppEvent { thread_id: Option, control: VoiceControl, }, + RealtimeConversationStateChanged, BackgroundVoiceError { thread_id: ThreadId, message: String, diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index 67f8717517..7d064523c9 100644 --- a/codex-rs/tui/src/app_event_sender.rs +++ b/codex-rs/tui/src/app_event_sender.rs @@ -52,6 +52,7 @@ impl AppEventSender { ) | AppEvent::RealtimeWebrtcOfferCreated { .. } | AppEvent::RealtimeWebrtcConnected { .. } | AppEvent::StopRealtimeConversation { .. } + | AppEvent::RealtimeConversationStateChanged | AppEvent::BackgroundVoiceError { .. } | AppEvent::SyncThreadGitBranch { .. } | AppEvent::RefreshRateLimits { .. } diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index 7722bada96..e2e8afde17 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -1747,6 +1747,10 @@ impl ChatWidget { } pub(crate) fn reset_realtime_conversation(&mut self) -> Option { + if self.realtime_conversation_is_running() { + self.app_event_tx + .send(AppEvent::RealtimeConversationStateChanged); + } self.finish_realtime_session_metrics(); let should_refresh_terminal_title = self.realtime_conversation.phase != RealtimeConversationPhase::Inactive diff --git a/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs b/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs index 75822931b1..bf7d71e293 100644 --- a/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/realtime_tests/lifecycle.rs @@ -151,9 +151,12 @@ async fn audio_failure_cancels_pending_voice_and_reports_the_device_error() { chat.on_realtime_error("speaker stream failed: device disconnected".to_string()); commit_realtime_history_events(&mut chat, &mut events); - let Ok(AppEvent::InsertHistoryCell(cell)) = events.try_recv() else { - panic!("voice should report the speaker failure"); - }; + let cell = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => Some(cell), + _ => None, + }) + .expect("voice should report the speaker failure"); assert!( cell.display_lines(/*width*/ 80) .iter()