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
This commit is contained in:
Eric Traut
2026-09-22 22:47:12 +00:00
committed by copyberry
parent a837a656ff
commit 58d722cb5d
11 changed files with 84 additions and 5 deletions
+10
View File
@@ -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 {
+50 -1
View File
@@ -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::<Vec<_>>();
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(())
}
+6
View File
@@ -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),
});
}
@@ -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 {
+3
View File
@@ -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);
@@ -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
+1
View File
@@ -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) {
+1
View File
@@ -1094,6 +1094,7 @@ pub(crate) enum AppEvent {
thread_id: Option<ThreadId>,
control: VoiceControl,
},
RealtimeConversationStateChanged,
BackgroundVoiceError {
thread_id: ThreadId,
message: String,
+1
View File
@@ -52,6 +52,7 @@ impl AppEventSender {
) | AppEvent::RealtimeWebrtcOfferCreated { .. }
| AppEvent::RealtimeWebrtcConnected { .. }
| AppEvent::StopRealtimeConversation { .. }
| AppEvent::RealtimeConversationStateChanged
| AppEvent::BackgroundVoiceError { .. }
| AppEvent::SyncThreadGitBranch { .. }
| AppEvent::RefreshRateLimits { .. }
+4
View File
@@ -1747,6 +1747,10 @@ impl ChatWidget {
}
pub(crate) fn reset_realtime_conversation(&mut self) -> Option<ThreadId> {
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
@@ -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()