Show live task details in the agent command center (#42455)

## What changed

- Add bounded detail previews for the selected task, including pending approval or user-input requests, live reasoning headings, and the latest agent message.
- Prefer the most urgent child task with useful detail while keeping the task list compact.
- Populate historical last messages from existing thread reads and update live details from already-delivered events without attaching to additional threads.
- Clear stale previews after reverts, event-stream gaps, reconnects, and thread lifecycle changes.

## Testing

- Add coverage and snapshots for attention requests, live activity, historical messages, stale refreshes, and attachment changes.

GitOrigin-RevId: 5de4b8aa3bc5bf9653a40c44b827f7aa85c8cf89
This commit is contained in:
Eric Traut
2026-09-03 05:33:57 +00:00
committed by copyberry
parent c9fecd3fa0
commit 8ff74cc9b1
20 changed files with 757 additions and 42 deletions
+1
View File
@@ -206,6 +206,7 @@ mod agent_navigation;
mod agent_picker;
mod agent_status_feed;
mod agents_overview;
mod agents_overview_details;
mod agents_overview_threads;
mod agents_overview_view;
pub(crate) use agents_overview::AGENTS_OVERVIEW_VIEW_ID;
+15
View File
@@ -28,6 +28,8 @@ pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview";
pub(super) struct AgentsOverviewState {
/// Missing metadata records a local resume until the next metadata refresh.
pub(super) threads: HashMap<ThreadId, Option<Thread>>,
pub(super) last_messages: HashMap<ThreadId, String>,
pub(super) activity: HashMap<ThreadId, super::agents_overview_details::AgentsOverviewActivity>,
pub(super) initialized: bool,
pub(super) request_id: Option<Uuid>,
pub(super) refresh_pending: bool,
@@ -133,10 +135,15 @@ impl App {
match result {
Ok(refresh) => {
self.agents_overview.initialized = refresh.recent_seed_complete;
self.agents_overview
.last_messages
.extend(refresh.last_messages);
for (thread_id, thread) in refresh.threads {
if let Some(mut thread) = thread {
if thread.ephemeral {
self.agents_overview.threads.remove(&thread_id);
self.agents_overview.last_messages.remove(&thread_id);
self.agents_overview.activity.remove(&thread_id);
continue;
}
thread.turns.clear();
@@ -162,6 +169,13 @@ impl App {
std::mem::take(&mut self.agents_overview.refresh_notifications).into_values()
{
for notification in notifications {
if let ServerNotification::ThreadReverted(reverted) = &notification
&& let Ok(thread_id) = ThreadId::from_string(&reverted.thread_id)
{
// Discard stale read results without clearing activity received after the revert.
self.agents_overview.last_messages.remove(&thread_id);
continue;
}
self.track_agents_overview_notification(&notification);
}
}
@@ -253,6 +267,7 @@ impl App {
continue;
};
rows.push(AgentsOverviewRow {
details: self.agents_overview_details(root, &children),
thread: root.clone(),
thread_id,
group,
@@ -0,0 +1,253 @@
//! Bounded overview previews from existing reads and already-delivered events.
//! Observing activity never attaches to a thread or fetches additional history.
use super::App;
use super::ThreadBufferedEvent;
use super::ThreadEventAttachment;
use super::agents_overview_view::AgentsOverviewGroup;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadActiveFlag;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStatus;
use codex_protocol::ThreadId;
use ratatui::style::Stylize;
use ratatui::text::Line;
use std::collections::HashMap;
const PREVIEW_CHARS: usize = 512;
pub(super) fn preview_text(text: &str) -> String {
text.chars()
.map(|ch| if ch.is_whitespace() { ' ' } else { ch })
.filter(|ch| !ch.is_control())
.take(PREVIEW_CHARS)
.collect()
}
#[derive(Default)]
pub(super) struct AgentsOverviewActivity {
reasoning: Option<ReasoningPreview>,
last_message: Option<String>,
}
struct ReasoningPreview {
turn_id: String,
item_id: String,
summary_index: i64,
buffer: String,
header: Option<String>,
}
impl App {
pub(super) fn track_agents_overview_activity(
&mut self,
thread_id: ThreadId,
notification: &ServerNotification,
) {
if !self.agents_overview.threads.contains_key(&thread_id) {
return;
}
let activity = self.agents_overview.activity.entry(thread_id).or_default();
let changed = match notification {
ServerNotification::ReasoningSummaryTextDelta(delta) => {
let had_header = activity
.reasoning
.as_ref()
.is_some_and(|reasoning| reasoning.header.is_some());
if activity.reasoning.as_ref().is_none_or(|reasoning| {
reasoning.turn_id != delta.turn_id
|| reasoning.item_id != delta.item_id
|| reasoning.summary_index != delta.summary_index
}) {
activity.reasoning = Some(ReasoningPreview {
turn_id: delta.turn_id.clone(),
item_id: delta.item_id.clone(),
summary_index: delta.summary_index,
buffer: String::new(),
header: None,
});
}
let Some(reasoning) = activity.reasoning.as_mut() else {
return;
};
if reasoning.header.is_some() {
return;
}
let remaining = PREVIEW_CHARS.saturating_sub(reasoning.buffer.chars().count());
reasoning.buffer.extend(delta.delta.chars().take(remaining));
reasoning.header = crate::chatwidget::extract_first_bold(&reasoning.buffer)
.map(|header| preview_text(&header));
had_header || reasoning.header.is_some()
}
ServerNotification::ItemCompleted(ItemCompletedNotification {
item: ThreadItem::AgentMessage { text, .. },
..
}) => {
activity.last_message = Some(preview_text(text));
activity.reasoning = None;
true
}
ServerNotification::TurnStarted(_)
| ServerNotification::TurnCompleted(_)
| ServerNotification::ReasoningSummaryPartAdded(_)
| ServerNotification::ItemStarted(_) => activity.reasoning.take().is_some(),
ServerNotification::ThreadStatusChanged(status)
if !matches!(status.status, ThreadStatus::Active { .. }) =>
{
activity.reasoning.take().is_some()
}
_ => false,
};
if changed {
self.repaint_agents_overview();
}
}
pub(super) fn agents_overview_details(
&self,
root: &Thread,
children: &HashMap<String, Vec<&Thread>>,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Urgency comes first; on ties, prefer available current detail over history.
let priority = |thread: &Thread| {
let group = AgentsOverviewGroup::for_status(&thread.status);
let has_detail = ThreadId::from_string(&thread.id).is_ok_and(|id| match group {
AgentsOverviewGroup::NeedsYou => self.agents_overview_request_preview(id).is_some(),
AgentsOverviewGroup::Working => {
self.thread_event_channels
.get(&id)
.is_none_or(|channel| channel.attachment() == ThreadEventAttachment::Live)
&& self
.agents_overview
.activity
.get(&id)
.and_then(|activity| activity.reasoning.as_ref())
.is_some_and(|reasoning| reasoning.header.is_some())
}
AgentsOverviewGroup::Ready | AgentsOverviewGroup::Finished => false,
});
(group, !has_detail)
};
let mut source = root;
let mut pending = vec![root];
while let Some(thread) = pending.pop() {
if priority(thread) < priority(source) {
source = thread;
}
pending.extend(children.get(&thread.id).into_iter().flatten().copied());
pending.sort_by(|left, right| right.id.cmp(&left.id));
}
let Ok(thread_id) = ThreadId::from_string(&source.id) else {
return lines;
};
let channel = self.thread_event_channels.get(&thread_id);
let is_child = source.id != root.id;
if is_child {
let name = source
.agent_nickname
.as_deref()
.or(source.name.as_deref())
.unwrap_or(&source.id);
lines.push(Line::default());
lines.push(vec!["Agent: ".dim(), preview_text(name).into()].into());
}
if AgentsOverviewGroup::for_status(&source.status) == AgentsOverviewGroup::NeedsYou {
if !is_child {
lines.extend([Line::default(), "Needs attention".red().into()]);
}
if let Some(request) = self.agents_overview_request_preview(thread_id) {
lines.push(request.into());
}
lines.push("Open task to review.".dim().into());
match &source.status {
ThreadStatus::Active { active_flags } => {
if active_flags.contains(&ThreadActiveFlag::WaitingOnApproval) {
lines.push("Waiting for approval.".into());
}
if active_flags.contains(&ThreadActiveFlag::WaitingOnUserInput) {
lines.push("Waiting for your response.".into());
}
}
ThreadStatus::SystemError => lines.push("Task encountered an error.".into()),
ThreadStatus::NotLoaded | ThreadStatus::Idle => {}
}
}
let activity = self.agents_overview.activity.get(&thread_id).filter(|_| {
channel.is_none_or(|channel| channel.attachment() == ThreadEventAttachment::Live)
});
if matches!(source.status, ThreadStatus::Active { .. })
&& let Some(header) = activity
.and_then(|activity| activity.reasoning.as_ref())
.and_then(|reasoning| reasoning.header.as_ref())
{
if !is_child {
lines.push(Line::default());
}
lines.extend(["Latest activity".dim().into(), header.clone().into()]);
}
if let Some(message) = activity
.and_then(|activity| activity.last_message.as_ref())
.or_else(|| self.agents_overview.last_messages.get(&thread_id))
.filter(|message| !message.trim().is_empty())
{
lines.extend([
Line::default(),
"Last message".dim().into(),
message.clone().into(),
]);
}
lines
}
fn agents_overview_request_preview(&self, thread_id: ThreadId) -> Option<String> {
self.agents_overview
.dispatched_requests
.get(&thread_id)
.and_then(|requests| requests.iter().find_map(request_preview))
.or_else(|| {
let channel = self
.thread_event_channels
.get(&thread_id)
.filter(|channel| channel.attachment() == ThreadEventAttachment::Live)?;
let store = channel.store.try_lock().ok()?;
store.buffer.iter().find_map(|event| {
if let ThreadBufferedEvent::Request(request) = event
&& store
.pending_interactive_replay
.should_replay_snapshot_request(request)
{
request_preview(request)
} else {
None
}
})
})
}
}
fn request_preview(request: &ServerRequest) -> Option<String> {
let text = match request {
ServerRequest::CommandExecutionRequestApproval { params, .. } => {
params.reason.as_deref().or(params.command.as_deref())
}
ServerRequest::FileChangeRequestApproval { params, .. } => params.reason.as_deref(),
ServerRequest::PermissionsRequestApproval { params, .. } => params.reason.as_deref(),
ServerRequest::McpServerElicitationRequest { params, .. } => match &params.request {
McpServerElicitationRequest::Form { message, .. }
| McpServerElicitationRequest::OpenAiForm { message, .. }
| McpServerElicitationRequest::OpenAiElicitationForm { message, .. }
| McpServerElicitationRequest::Url { message, .. } => Some(message.as_str()),
},
ServerRequest::ToolRequestUserInput { params, .. } => params
.questions
.first()
.map(|question| question.question.as_str()),
_ => None,
}?;
Some(preview_text(text))
}
@@ -10,6 +10,7 @@ use crate::test_support::test_path_buf;
use crate::test_support::test_path_display;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::CurrentTimeReadParams;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadActiveFlag;
@@ -23,6 +24,8 @@ use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputQuestion;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
@@ -315,6 +318,7 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re
&app_server,
request_id,
Ok(AgentsOverviewThreadRefresh {
last_messages: HashMap::new(),
threads: HashMap::from([
(retained, Some(retained_thread.clone())),
(archived, Some(archived_thread)),
@@ -337,6 +341,7 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re
&app_server,
request_id,
Ok(AgentsOverviewThreadRefresh {
last_messages: HashMap::new(),
threads: HashMap::from([(retained, None)]),
recent_seed_complete: true,
}),
@@ -350,6 +355,50 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re
Err("temporarily unavailable".to_string()),
);
assert_eq!(app.agents_overview.threads, expected);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let stale_messages = HashMap::from([(retained, "Removed answer".into())]);
for event in [
AppServerEvent::ServerNotification(Box::new(ServerNotification::ThreadReverted(
codex_app_server_protocol::ThreadRevertedNotification {
thread_id: retained.to_string(),
},
))),
AppServerEvent::Lagged { skipped: 1 },
] {
let old_request = Uuid::new_v4();
app.agents_overview.request_id = Some(old_request);
app.agents_overview.activity.entry(retained).or_default();
app.agents_overview.last_messages = stale_messages.clone();
app.handle_app_server_event(&app_server, event).await;
assert!(!app.agents_overview.activity.contains_key(&retained));
assert!(app.agents_overview.last_messages.is_empty());
// Fresh activity must survive an older read arriving after invalidation.
app.track_agents_overview_notification(&reasoning_delta(
retained,
"new-reasoning",
"**Checking the revised task**",
));
let current_request = app.agents_overview.request_id.unwrap();
for (request_id, last_messages) in [
(old_request, stale_messages.clone()),
(current_request, HashMap::new()),
] {
app.apply_agents_overview_thread_refresh(
&app_server,
request_id,
Ok(AgentsOverviewThreadRefresh {
threads: HashMap::new(),
last_messages,
recent_seed_complete: true,
}),
);
assert!(app.agents_overview.last_messages.is_empty());
}
assert!(app.agents_overview.activity.contains_key(&retained));
assert!(app.agents_overview.request_id.is_none());
}
app_server.shutdown().await?;
Ok(())
}
@@ -383,6 +432,22 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res
.expect("materialize historical session"),
)?);
}
let message_path = app_test_support::rollout_path(
&app.config.codex_home,
"2025-01-21T12-00-00",
&ids[20].to_string(),
);
let mut history = std::fs::read_to_string(&message_path)?;
history.push_str(
&serde_json::json!({
"timestamp": "2025-01-21T12:00:01Z",
"type": "event_msg",
"payload": { "type": "agent_message", "message": "Found the regression in the parser." }
})
.to_string(),
);
history.push('\n');
std::fs::write(message_path, history)?;
let config = app.config.clone();
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
for thread_id in [ids[0], ids[21]] {
@@ -409,6 +474,12 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res
expected.insert(ids[0]);
let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect();
assert_eq!(retained, expected);
assert_eq!(
app.agents_overview.last_messages,
HashMap::from([(ids[20], "Found the regression in the parser.".to_string())])
);
let thread = app.agents_overview.threads[&ids[20]].as_ref().unwrap();
assert_eq!(thread.status, ThreadStatus::NotLoaded);
let created = app_server.start_thread(&config).await?.session.thread_id;
// Closing the view must not cancel a metadata refresh or forget unloaded entries.
@@ -595,6 +666,219 @@ async fn hidden_system_thread_does_not_refresh_shared_overview() {
app_server.shutdown().await.expect("shutdown app server");
}
#[tokio::test]
async fn agents_overview_details_show_available_attention_without_expanding_rows() -> Result<()> {
let mut app = make_test_app().await;
let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let [root, child, unloaded] = std::array::from_fn(|_| ThreadId::new());
let waiting = ThreadStatus::Active {
active_flags: vec![
ThreadActiveFlag::WaitingOnApproval,
ThreadActiveFlag::WaitingOnUserInput,
],
};
// The child question takes priority over the generic waiting parent.
let mut threads = [
(root, None, "Repair authentication", waiting.clone()),
(child, Some(root), "Check dependencies", waiting),
(
unloaded,
None,
"Investigate parser",
ThreadStatus::NotLoaded,
),
]
.map(|(id, parent, title, status)| overview_thread(id, parent, title, status))
.to_vec();
threads[0].name = None;
threads[2].name = None;
threads[2].preview = "Investigate parser\nInclude edge cases\n".repeat(8);
app.agents_overview.threads = threads
.iter()
.cloned()
.map(|thread| (ThreadId::from_string(&thread.id).unwrap(), Some(thread)))
.collect();
app.agents_overview.last_messages.insert(
unloaded,
super::super::agents_overview_details::preview_text(
&"Found the regression\nin the parser. ".repeat(20),
),
);
app.agents_overview.dispatched_requests.insert(
child,
vec![ServerRequest::ToolRequestUserInput {
request_id: RequestId::Integer(42),
params: ToolRequestUserInputParams {
thread_id: child.to_string(),
turn_id: "turn".into(),
item_id: "question".into(),
questions: vec![ToolRequestUserInputQuestion {
id: "version".into(),
header: "Version".into(),
question: "Which dependency version should I use?".into(),
is_other: false,
is_secret: false,
options: None,
}],
is_blocking: true,
auto_resolution_ms: None,
},
}],
);
let view = app.agents_overview_view(threads.clone(), Some(root));
app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view));
let project = test_path_display("/tmp/project");
let normalized_group = format!(
"/tmp/project 2{}",
" ".repeat(project.len().saturating_sub("/tmp/project".len()))
);
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_attention", render_bottom_popup(&app.chat_widget, /*width*/ 96).replace(&format!("{project} 2"), &normalized_group).replace(&project, "/tmp/project"));
});
app.handle_app_server_event(
&app_server,
AppServerEvent::ServerNotification(Box::new(ServerNotification::ServerRequestResolved(
codex_app_server_protocol::ServerRequestResolvedNotification {
thread_id: child.to_string(),
request_id: RequestId::Integer(42),
},
))),
)
.await;
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
assert!(!rendered.contains("Which dependency version"));
assert!(rendered.contains("Waiting for approval."));
assert!(app.thread_event_channels.is_empty());
assert!(app.agents_overview.request_id.is_none());
let view = app.agents_overview_view(threads, Some(unloaded));
app.chat_widget.show_bottom_pane_view(Box::new(view));
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_last_message", render_bottom_popup(&app.chat_widget, /*width*/ 96).replace(&format!("{project} 2"), &normalized_group).replace(&project, "/tmp/project"));
});
app.track_agents_overview_notification(&ServerNotification::ThreadReverted(
codex_app_server_protocol::ThreadRevertedNotification {
thread_id: unloaded.to_string(),
},
));
assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Last message"));
app_server.shutdown().await?;
Ok(())
}
fn reasoning_delta(thread_id: ThreadId, item_id: &str, delta: &str) -> ServerNotification {
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id: thread_id.to_string(),
turn_id: "turn".into(),
item_id: item_id.into(),
summary_index: 0,
delta: delta.into(),
})
}
#[tokio::test]
async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachment() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let mut thread = overview_thread(
thread_id,
/*parent_thread_id*/ None,
"Check startup performance",
ThreadStatus::Active {
active_flags: Vec::new(),
},
);
thread.preview.clear();
app.agents_overview
.threads
.insert(thread_id, Some(thread.clone()));
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 8));
let mut view = app.agents_overview_view(vec![thread.clone()], Some(thread_id));
view.handle_paste("Keep this draft".into());
app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view));
for delta in ["**Checking", " cold-start regressions**\nFurther reasoning"] {
app.track_agents_overview_notification(&reasoning_delta(thread_id, "reasoning", delta));
}
let project = test_path_display("/tmp/project");
let normalized_group = format!(
"/tmp/project 1{}",
" ".repeat(project.len().saturating_sub("/tmp/project".len()))
);
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_live_activity", render_bottom_popup(&app.chat_widget, /*width*/ 96).replace(&format!("{project} 1"), &normalized_group).replace(&project, "/tmp/project"));
});
assert!(app.agents_overview.request_id.is_none());
// A working child can stream through a server attachment without a local channel.
let parent = overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
"Parent",
thread.status.clone(),
);
let parent_id = ThreadId::from_string(&parent.id).unwrap();
app.agents_overview
.threads
.insert(parent_id, Some(parent.clone()));
app.track_agents_overview_notification(&ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification {
thread_id: parent.id.clone(),
turn_id: "previous-turn".into(),
completed_at_ms: 0,
item: ThreadItem::AgentMessage {
id: "answer".into(),
text: "Previous answer".into(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
},
));
let channel = app.thread_event_channels.remove(&thread_id).unwrap();
let details = app.agents_overview_details(
&parent,
&HashMap::from([(parent.id.clone(), vec![&thread])]),
);
assert!(
details
.iter()
.any(|line| line.to_string().contains("Checking cold-start regressions"))
);
app.thread_event_channels.insert(thread_id, channel);
// A new, oversized header clears the previous one and cannot grow the parsing buffer indefinitely.
for delta in [format!("**{}", "界".repeat(10_000)), "**".into()] {
app.track_agents_overview_notification(&reasoning_delta(thread_id, "oversized", &delta));
}
assert_eq!(
app.agents_overview_details(&thread, &HashMap::new()),
Vec::<Line>::new()
);
app.track_agents_overview_notification(&reasoning_delta(
thread_id,
"next",
"**Running tests**",
));
app.thread_event_channels
.get_mut(&thread_id)
.unwrap()
.mark_replay_only();
assert_eq!(
app.agents_overview_details(&thread, &HashMap::new()),
Vec::<Line>::new()
);
app.track_agents_overview_notification(&ServerNotification::ThreadClosed(
ThreadClosedNotification {
thread_id: thread_id.to_string(),
},
));
assert!(!app.agents_overview.activity.contains_key(&thread_id));
}
#[tokio::test]
async fn shared_overview_shows_only_root_sessions() {
assert_eq!(
@@ -4,6 +4,7 @@
use super::App;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use super::agents_overview_details::preview_text;
use super::app_server_event_targets::ServerNotificationThreadTarget;
use super::app_server_event_targets::server_notification_thread_target;
use crate::AppServerTarget;
@@ -40,6 +41,7 @@ impl App {
else {
return;
};
self.track_agents_overview_activity(thread_id, notification);
let thread = self
.agents_overview
.threads
@@ -55,14 +57,22 @@ impl App {
self.agents_overview.threads.insert(thread_id, Some(thread));
}
ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => {
self.agents_overview.activity.remove(&thread_id);
self.agents_overview.last_messages.remove(&thread_id);
self.agents_overview.threads.remove(&thread_id);
self.agents_overview.refresh_thread_ids.remove(&thread_id);
}
ServerNotification::ThreadClosed(_) => {
self.agents_overview.activity.remove(&thread_id);
if let Some(thread) = thread {
thread.status = ThreadStatus::NotLoaded;
}
}
ServerNotification::ThreadReverted(_) => {
self.agents_overview.activity.remove(&thread_id);
self.agents_overview.last_messages.remove(&thread_id);
self.repaint_agents_overview();
}
ServerNotification::ThreadStatusChanged(status) => {
if let Some(thread) = thread {
thread.status = status.status.clone();
@@ -83,7 +93,9 @@ impl App {
}
_ => return,
}
if self.agents_overview.threads.contains_key(&thread_id) {
if !matches!(notification, ServerNotification::ThreadReverted(_))
&& self.agents_overview.threads.contains_key(&thread_id)
{
self.agents_overview.refresh_thread_ids.insert(thread_id);
}
if self.agents_overview.request_id.is_some() {
@@ -151,6 +163,7 @@ impl App {
let refresh_task = tokio::spawn(async move {
let result = async {
let mut threads = HashMap::new();
let mut last_messages = HashMap::new();
let mut recent_seed_complete = true;
if !initialized {
let loaded = request_handle.request_typed::<ThreadLoadedListResponse>(
@@ -281,6 +294,7 @@ impl App {
.await
{
Ok(mut response) => {
let mut last_message = None;
if let Ok(turns) = request_handle
.request_typed::<ThreadTurnsListResponse>(
ClientRequest::ThreadTurnsList {
@@ -297,14 +311,24 @@ impl App {
},
)
.await
&& let Some(ThreadItem::UserMessage { content, .. }) =
turns.data.first().and_then(|turn| turn.items.first())
&& let Some(turn) = turns.data.first()
{
response.thread.preview =
ChatWidget::user_message_display_from_inputs(content)
.message;
if let Some(ThreadItem::UserMessage { content, .. }) =
turn.items.first()
{
response.thread.preview =
ChatWidget::user_message_display_from_inputs(content)
.message;
}
last_message =
turn.items.iter().rev().find_map(|item| match item {
ThreadItem::AgentMessage { text, .. } => {
Some(preview_text(text))
}
_ => None,
});
}
Some((thread_id, response.thread))
Some((thread_id, response.thread, last_message))
}
Err(error) => {
tracing::warn!(%thread_id, %error, "failed to read agent thread");
@@ -313,18 +337,26 @@ impl App {
}
});
if reads.len() >= 16
&& let Some(Ok(Some((thread_id, thread)))) = reads.join_next().await
&& let Some(Ok(Some((thread_id, thread, last_message)))) =
reads.join_next().await
{
threads.insert(thread_id, Some(thread));
if let Some(message) = last_message {
last_messages.insert(thread_id, message);
}
}
}
while let Some(result) = reads.join_next().await {
if let Ok(Some((thread_id, thread))) = result {
if let Ok(Some((thread_id, thread, last_message))) = result {
threads.insert(thread_id, Some(thread));
if let Some(message) = last_message {
last_messages.insert(thread_id, message);
}
}
}
Ok(AgentsOverviewThreadRefresh {
threads,
last_messages,
recent_seed_complete,
})
}
+41 -24
View File
@@ -84,12 +84,18 @@ impl AgentsOverviewGroup {
#[derive(Clone)]
pub(super) struct AgentsOverviewRow {
pub(super) details: Vec<Line<'static>>,
pub(super) thread: Thread,
pub(super) thread_id: ThreadId,
pub(super) group: AgentsOverviewGroup,
pub(super) is_current: bool,
}
fn display_title(thread: &Thread) -> &str {
let title = thread.name.as_deref().unwrap_or(&thread.preview);
title.trim().lines().next().unwrap_or("Untitled task")
}
#[derive(Default)]
pub(super) struct AgentsOverviewViewState {
// Search and rename never borrow the new-task draft.
@@ -380,12 +386,6 @@ impl AgentsOverviewView {
} else {
" ".into()
};
let title = row
.thread
.name
.as_deref()
.or_else(|| (!row.thread.preview.is_empty()).then_some(row.thread.preview.as_str()))
.unwrap_or("Untitled task");
let (status, dot) = Self::status(row);
let current = if row.is_current { " current" } else { "" };
let mut spans = vec![
@@ -393,7 +393,7 @@ impl AgentsOverviewView {
" ".into(),
dot,
" ".into(),
title.into(),
display_title(&row.thread).into(),
current.dim(),
];
if project_grouping {
@@ -409,10 +409,14 @@ impl AgentsOverviewView {
return;
};
let (status, dot) = Self::status(row);
let width = usize::from(area.width);
let mut lines = vec![
Line::from("Task details".bold()),
Line::default(),
Line::from(row.thread.name.as_deref().unwrap_or("Untitled task").bold()),
crate::line_truncation::truncate_line_with_ellipsis_if_overflow(
display_title(&row.thread).to_owned().bold().into(),
width,
),
Line::from(vec![dot, " ".into(), status.into()]),
Line::default(),
Line::from("Project".dim()),
@@ -428,23 +432,36 @@ impl AgentsOverviewView {
lines.push("Branch".dim().into());
lines.push(branch.clone().into());
}
let preview = crate::text_formatting::truncate_text(
&row.thread.preview,
usize::from(area.width) * usize::from(area.height),
);
lines.extend([
Line::default(),
Line::from("Latest activity".dim()),
Line::from(match preview.as_str() {
"" => "No activity yet.",
let preview = crate::text_formatting::truncate_text(&row.thread.preview, width * 2);
lines.extend([Line::default(), Line::from("Prompt".dim())]);
let mut prompt = crate::wrapping::word_wrap_lines(
match preview.as_str() {
"" => "No prompt available.",
preview => preview,
}),
]);
Paragraph::new(crate::wrapping::word_wrap_lines(
lines,
usize::from(area.width),
))
.render(area, buf);
}
.lines()
.map(Line::from),
width,
);
if prompt.len() > 2 {
prompt.truncate(2);
prompt[1] = "…".dim().into();
}
lines.extend(prompt);
let details_start = crate::wrapping::word_wrap_lines(lines[..4].to_vec(), width).len();
let mut lines = crate::wrapping::word_wrap_lines(lines, width);
if self.state().connection_notice.is_none() {
let mut details = crate::wrapping::word_wrap_lines(row.details.clone(), width);
let available = usize::from(area.height).saturating_sub(lines.len());
if details.len() > available {
details.truncate(available);
if let Some(last) = details.last_mut() {
*last = "…".dim().into();
}
}
lines.splice(details_start..details_start, details);
}
Paragraph::new(lines).render(area, buf);
}
}
+17
View File
@@ -68,15 +68,32 @@ impl App {
);
self.refresh_mcp_startup_expected_servers_from_config();
self.chat_widget.finish_mcp_startup_after_lag();
if let Some(task) = self.agents_overview.refresh_task.take() {
task.abort();
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_pending = false;
self.agents_overview.refresh_notifications.clear();
self.agents_overview.activity.clear();
self.agents_overview.last_messages.clear();
self.repaint_agents_overview();
self.refresh_agents_overview_threads(app_server_client);
}
AppServerEvent::ServerNotification(notification) => {
let request_resolved = matches!(
notification.as_ref(),
ServerNotification::ServerRequestResolved(_)
);
self.handle_server_notification_event(app_server_client, *notification)
.await;
if request_resolved {
self.repaint_agents_overview();
}
}
AppServerEvent::ServerRequest(request) => {
self.handle_server_request_event(app_server_client, *request)
.await;
self.repaint_agents_overview();
}
AppServerEvent::Disconnected { message } => {
if self.begin_reconnect() {
+2
View File
@@ -186,6 +186,8 @@ impl App {
self.agents_overview.request_id = None;
self.agents_overview.refresh_pending = false;
self.agents_overview.refresh_notifications.clear();
self.agents_overview.activity.clear();
self.agents_overview.last_messages.clear();
self.reconnect.presentation = if self
.chat_widget
.selected_index_for_active_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID)
@@ -446,6 +446,7 @@ impl App {
(session, turns, false)
}
};
self.agents_overview.activity.remove(&thread_id);
let recap_progress =
if live_attached && let Some(channel) = self.thread_event_channels.remove(&thread_id) {
let store = channel.store.lock().await;
@@ -701,6 +702,7 @@ impl App {
pub(super) fn reset_thread_event_state(&mut self) {
self.abort_all_thread_event_listeners();
self.thread_event_channels.clear();
self.agents_overview.activity.clear();
self.agent_navigation.clear();
self.side_threads.clear();
self.active_thread_id = None;
+1
View File
@@ -515,6 +515,7 @@ impl App {
});
self.abort_thread_event_listener(thread_id);
self.thread_event_channels.remove(&thread_id);
self.agents_overview.activity.remove(&thread_id);
self.side_threads.remove(&thread_id);
self.agent_navigation.remove(thread_id);
if self.active_thread_id == Some(thread_id) {
@@ -128,6 +128,9 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
.map(|thread| Ok((ThreadId::from_string(&thread.id)?, Some(thread.clone()))))
.collect::<Result<HashMap<_, _>>>()?;
app.agents_overview.threads = stale_threads.clone();
app.agents_overview
.last_messages
.insert(selected, "Pre-disconnect answer".into());
app.agents_overview.initialized = overview_initialized;
let view = app.agents_overview_view(
stale.clone(),
@@ -248,6 +251,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
let disconnected = session.next_event().await.unwrap();
app.handle_app_server_event(&session, disconnected).await;
assert!(app.reconnect.offline);
assert!(app.agents_overview.last_messages.is_empty());
assert!(refresh.await.unwrap_err().is_cancelled());
assert_eq!(
(
@@ -260,6 +264,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
&session,
stale_request,
Ok(AgentsOverviewThreadRefresh {
last_messages: HashMap::new(),
threads: HashMap::new(),
recent_seed_complete: false,
}),
@@ -381,6 +386,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
&session,
stale_request,
Ok(AgentsOverviewThreadRefresh {
last_messages: HashMap::new(),
threads: stale_threads,
recent_seed_complete: true,
}),
@@ -13,7 +13,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│ Project
│ /tmp/project
│
│ Latest activity
│ Prompt
│ Selected task
│
│
@@ -13,7 +13,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│ Project
│ /tmp/project
│
│ Latest activity
│ Prompt
│ Selected task
│
│
@@ -13,7 +13,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
│ Project
│ /tmp/project
│
│ Latest activity
│ Prompt
│ New task
│
│
+1
View File
@@ -218,6 +218,7 @@ pub(crate) enum RecapTrigger {
#[derive(Debug)]
pub(crate) struct AgentsOverviewThreadRefresh {
pub(crate) threads: std::collections::HashMap<ThreadId, Option<Thread>>,
pub(crate) last_messages: std::collections::HashMap<ThreadId, String>,
pub(crate) recent_seed_complete: bool,
}
+1 -1
View File
@@ -2024,7 +2024,7 @@ const SIDE_PLACEHOLDER: &str = "Ask a follow-up question";
// Extract the first bold (Markdown) element in the form **...** from `s`.
// Returns the inner text if found; otherwise `None`.
fn extract_first_bold(s: &str) -> Option<String> {
pub(crate) fn extract_first_bold(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut i = 0usize;
while i + 1 < bytes.len() {
@@ -9,17 +9,17 @@ expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{proj
› ● Build the dashboard current Needs input │
● Repair authentication Working │ Build the dashboard
✓ Review yesterday's changes Finished │ ● Needs input
│
│ Agent: Inspect keyboard shortcuts
│ Open task to review.
│ Waiting for approval.
│
│ Project
│ /tmp/project
│
│ Latest activity
│ Prompt
│ Build the dashboard
│
│
│
│
│
New task
› First line
@@ -0,0 +1,28 @@
---
source: tui/src/app/agents_overview_tests.rs
expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&normalized_group).replace(&project, \"/tmp/project\")"
---
Agent command center
1 need input 0 working 0 ready
────────────────────────────────────────────────────────────────────────────────────────────
/tmp/project 2 │ Task details
› ● Repair authentication Needs input │
✓ Investigate parser Finished │ Repair authentication
│ ● Needs input
│
│ Agent: Check dependencies
│ Which dependency version should I use?
│ Open task to review.
│ Waiting for approval.
│ Waiting for your response.
│
│ Project
│ /tmp/project
│
│ Prompt
│ Repair authentication
New task
› Describe a new task
enter create task ctrl+j newline esc tasks
@@ -0,0 +1,28 @@
---
source: tui/src/app/agents_overview_tests.rs
expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 2\"),\n&normalized_group).replace(&project, \"/tmp/project\")"
---
Agent command center
1 need input 0 working 0 ready
────────────────────────────────────────────────────────────────────────────────────────────
/tmp/project 2 │ Task details
● Repair authentication Needs input │
› ✓ Investigate parser Finished │ Investigate parser
│ ✓ Finished
│
│ Last message
│ Found the regression in the parser.
│ Found the regression in the parser.
│ …
│
│ Project
│ /tmp/project
│
│ Prompt
│ Investigate parser
│ …
New task
› Describe a new task
enter create task ctrl+j newline esc tasks
@@ -0,0 +1,28 @@
---
source: tui/src/app/agents_overview_tests.rs
expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&format!(\"{project} 1\"),\n&normalized_group).replace(&project, \"/tmp/project\")"
---
Agent command center
0 need input 1 working 0 ready
────────────────────────────────────────────────────────────────────────────────────────────
/tmp/project 1 │ Task details
› ● Check startup performance Working │
│ Check startup performance
│ ● Working
│
│ Latest activity
│ Checking cold-start regressions
│
│ Project
│ /tmp/project
│
│ Prompt
│ No prompt available.
│
│
│
New task
› Keep this draft
enter create task ctrl+j newline esc tasks