mirror of
https://github.com/openai/codex.git
synced 2026-10-21 23:19:44 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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) = ¬ification
|
||||
&& 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(¬ification);
|
||||
}
|
||||
}
|
||||
@@ -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 ¶ms.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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
│ Project
|
||||
│ /tmp/project
|
||||
│
|
||||
│ Latest activity
|
||||
│ Prompt
|
||||
│ Selected task
|
||||
│
|
||||
│
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
│ Project
|
||||
│ /tmp/project
|
||||
│
|
||||
│ Latest activity
|
||||
│ Prompt
|
||||
│ Selected task
|
||||
│
|
||||
│
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ expression: "render_bottom_popup(&app.chat_widget, 100)"
|
||||
│ Project
|
||||
│ /tmp/project
|
||||
│
|
||||
│ Latest activity
|
||||
│ Prompt
|
||||
│ New task
|
||||
│
|
||||
│
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+28
@@ -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
|
||||
+28
@@ -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
|
||||
+28
@@ -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
|
||||
Reference in New Issue
Block a user