mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1094,6 +1094,7 @@ pub(crate) enum AppEvent {
|
||||
thread_id: Option<ThreadId>,
|
||||
control: VoiceControl,
|
||||
},
|
||||
RealtimeConversationStateChanged,
|
||||
BackgroundVoiceError {
|
||||
thread_id: ThreadId,
|
||||
message: String,
|
||||
|
||||
@@ -52,6 +52,7 @@ impl AppEventSender {
|
||||
) | AppEvent::RealtimeWebrtcOfferCreated { .. }
|
||||
| AppEvent::RealtimeWebrtcConnected { .. }
|
||||
| AppEvent::StopRealtimeConversation { .. }
|
||||
| AppEvent::RealtimeConversationStateChanged
|
||||
| AppEvent::BackgroundVoiceError { .. }
|
||||
| AppEvent::SyncThreadGitBranch { .. }
|
||||
| AppEvent::RefreshRateLimits { .. }
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user