Make the agents overview an interactive task dashboard (#39112)

## What changed

- Let users start tasks, open root sessions, rename tasks, and stop active work directly from the agents overview.
- Show details for the selected task on wide terminals and limit the overview to root sessions while still reflecting background activity.
- Preserve draft input and pending server requests when switching sessions so running roots and their subagents can continue in the background.
- Apply the selected project's working directory when dispatching from project grouping, including remote workspaces.

## Testing

- Expand overview rendering and interaction coverage for root filtering, dispatch, search, selection, renaming, and responsive input.
- Cover switching away from roots with running subagents and retaining background server requests.

GitOrigin-RevId: b7b0d33b7ecc077498efb756a0ad2fd425158b17
This commit is contained in:
Eric Traut
2026-08-17 23:47:55 +00:00
committed by copyberry
parent 632420e67a
commit 319b2f72b1
11 changed files with 1130 additions and 139 deletions
+394
View File
@@ -4,6 +4,7 @@ use super::agents_overview_view::AgentsOverviewGroup;
use super::agents_overview_view::AgentsOverviewRow;
use super::agents_overview_view::AgentsOverviewView;
use super::*;
use crate::chatwidget::ThreadInputStateRestoreMode;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
@@ -15,6 +16,9 @@ use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTurnsListParams;
use codex_app_server_protocol::ThreadTurnsListResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use codex_protocol::protocol::SubAgentSource;
pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview";
@@ -27,6 +31,8 @@ pub(super) struct AgentsOverviewState {
pub(super) visible_thread_ids: Vec<ThreadId>,
pub(super) view_state:
Arc<std::sync::Mutex<super::agents_overview_view::AgentsOverviewViewState>>,
pub(super) input_states: HashMap<ThreadId, ThreadInputState>,
pub(super) dispatched_requests: HashMap<ThreadId, Vec<ServerRequest>>,
}
impl App {
@@ -190,6 +196,14 @@ impl App {
};
let view = self.agents_overview_view(threads, selected_thread_id);
self.agents_overview.visible_thread_ids = view.thread_ids();
if selected_thread_id
.is_some_and(|thread_id| !self.agents_overview.visible_thread_ids.contains(&thread_id))
&& let Ok(mut state) = self.agents_overview.view_state.lock()
&& state.renaming
{
state.renaming = false;
state.input.clear();
}
self.chat_widget
.replace_bottom_pane_view_if_present(AGENTS_OVERVIEW_VIEW_ID, Box::new(view));
if std::mem::take(&mut self.agents_overview.refresh_pending) {
@@ -253,6 +267,386 @@ impl App {
Arc::clone(&self.agents_overview.view_state),
)
}
pub(super) async fn select_agents_overview_thread(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
root_thread_id: ThreadId,
) -> color_eyre::Result<AppRunControl> {
if self.current_displayed_thread_id() == Some(root_thread_id) {
return Ok(AppRunControl::Continue);
}
if self.primary_thread_id != Some(root_thread_id) {
let previous_displayed_thread_id = self.current_displayed_thread_id();
let mut previous_thread_ids =
Vec::from_iter(self.thread_event_channels.keys().copied());
previous_thread_ids.extend(
self.agent_navigation
.tracked_thread_ids()
.into_iter()
.filter(|thread_id| !self.thread_event_channels.contains_key(thread_id)),
);
let mut previous_running_thread_ids = Vec::new();
let mut previous_pending_requests = Vec::new();
for thread_id in &previous_thread_ids {
if self.side_threads.contains_key(thread_id) {
continue;
}
if let Some(channel) = self.thread_event_channels.get(thread_id) {
let store = channel.store.lock().await;
if store.active_turn_id().is_some() {
previous_running_thread_ids.push(*thread_id);
}
if let Some(input_state) = store.input_state.clone() {
self.agents_overview
.input_states
.insert(*thread_id, input_state);
}
let requests = store.pending_replay_requests();
if !requests.is_empty() {
previous_pending_requests.push((*thread_id, requests));
}
} else if self
.agent_navigation
.get(thread_id)
.is_some_and(|entry| entry.is_running)
{
previous_running_thread_ids.push(*thread_id);
}
}
if let Some(active_thread_id) = self.current_displayed_thread_id()
&& let Some(input_state) = self.chat_widget.capture_thread_input_state()
{
self.agents_overview
.input_states
.insert(active_thread_id, input_state);
}
let target_thread = match app_server
.thread_read(root_thread_id, /*include_turns*/ false)
.await
{
Ok(thread) => thread,
Err(error) => {
self.chat_widget.add_error_message(format!(
"Agent session {root_thread_id} is unavailable: {error}"
));
return Ok(AppRunControl::Continue);
}
};
let config_cwd = if self.app_server_target.uses_remote_workspace() {
self.config.cwd.to_path_buf()
} else {
target_thread.cwd.to_path_buf()
};
let current_cwd = self.config.cwd.to_path_buf();
let resume_config = match self
.rebuild_config_for_resume_or_fallback(&current_cwd, config_cwd)
.await
{
Ok(config) => config,
Err(error) => {
self.chat_widget
.add_error_message(format!("Failed to load task settings: {error}"));
return Ok(AppRunControl::Continue);
}
};
let baseline_approval = resume_config.permissions.approval_policy.value();
let baseline_permissions =
RuntimePermissionProfileOverride::from_config(&resume_config);
let resumed = match app_server
.resume_thread(
resume_config.clone(),
root_thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await
{
Ok(resumed) => resumed,
Err(error) => {
self.chat_widget
.add_error_message(format!("Failed to attach to task: {error}"));
return Ok(AppRunControl::Continue);
}
};
if !previous_running_thread_ids.is_empty() {
for side_thread_id in Vec::from_iter(self.side_threads.keys().copied()) {
if !self.discard_side_thread(app_server, side_thread_id).await {
let _ = app_server.thread_unsubscribe(root_thread_id).await;
return Ok(AppRunControl::Continue);
}
}
}
for (thread_id, requests) in previous_pending_requests {
self.agents_overview
.dispatched_requests
.entry(thread_id)
.or_default()
.extend(requests);
}
if !previous_running_thread_ids.is_empty() {
for thread_id in &previous_thread_ids {
if !self.side_threads.contains_key(thread_id) {
self.agents_overview
.dispatched_requests
.entry(*thread_id)
.or_default();
}
}
}
if previous_running_thread_ids.is_empty() {
self.shutdown_current_thread(app_server).await;
}
self.runtime_approval_policy_override = None;
self.runtime_permission_profile_override = None;
self.config = resume_config;
tui.set_notification_settings(
self.config.tui_notifications.method,
self.config.tui_notifications.condition,
);
self.file_search
.update_search_dir(self.config.cwd.to_path_buf());
if let Err(error) = self
.replace_chat_widget_with_app_server_thread(
tui,
resumed,
super::session_lifecycle::ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
)
.await
{
self.chat_widget
.add_error_message(format!("Failed to attach to task: {error}"));
return Ok(AppRunControl::Continue);
}
let mut destination_config = self.chat_widget.config_ref().clone();
if self.app_server_target.uses_remote_workspace() {
destination_config.cwd.clone_from(&self.config.cwd);
destination_config
.workspace_roots
.clone_from(&self.config.workspace_roots);
destination_config
.permissions
.set_workspace_roots(self.config.permissions.workspace_roots().to_vec());
}
self.config = destination_config;
if self.config.permissions.approval_policy.value() != baseline_approval {
self.runtime_approval_policy_override =
Some(self.config.permissions.approval_policy.value().into());
}
let destination_permissions =
RuntimePermissionProfileOverride::from_config(&self.config);
if destination_permissions != baseline_permissions {
self.runtime_permission_profile_override = Some(destination_permissions);
}
if !self
.backfill_loaded_subagent_threads(app_server)
.await
.completed
{
self.backfill_loaded_subagent_threads(app_server).await;
}
for thread_id in previous_thread_ids {
if previous_running_thread_ids.is_empty()
&& thread_id != root_thread_id
&& Some(thread_id) != previous_displayed_thread_id
&& let Err(error) = app_server.thread_unsubscribe(thread_id).await
{
tracing::warn!(%thread_id, %error, "failed to unsubscribe previous agent thread");
}
}
}
if self.current_displayed_thread_id() != Some(root_thread_id) {
self.select_agent_thread_and_discard_side(tui, app_server, root_thread_id)
.await?;
}
self.replay_agents_overview_requests(app_server, root_thread_id)
.await;
if self.current_displayed_thread_id() == Some(root_thread_id)
&& let Some(input_state) = self.agents_overview.input_states.remove(&root_thread_id)
{
let preserve_in_flight_turn = self
.active_turn_id_for_thread(root_thread_id)
.await
.is_some();
self.chat_widget.restore_thread_input_state(
Some(input_state),
ThreadInputStateRestoreMode {
preserve_in_flight_turn,
},
);
if !preserve_in_flight_turn {
self.chat_widget.maybe_send_next_queued_input();
}
}
self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id)
.await;
Ok(AppRunControl::Continue)
}
pub(super) async fn replay_agents_overview_requests(
&mut self,
app_server: &AppServerSession,
root_thread_id: ThreadId,
) {
let requests = self
.agents_overview
.dispatched_requests
.extract_if(|id, _| *id == root_thread_id || self.agent_navigation.get(id).is_some())
.flat_map(|(_, requests)| requests)
.collect::<Vec<_>>();
for request in requests {
self.handle_app_server_event(
app_server,
codex_app_server_client::AppServerEvent::ServerRequest(Box::new(request)),
)
.await;
}
}
pub(super) async fn dispatch_agents_overview_task(
&mut self,
app_server: &mut AppServerSession,
prompt: String,
cwd: Option<AbsolutePathBuf>,
) {
self.refresh_in_memory_config_from_disk_best_effort("starting a background task")
.await;
let remote_cwd = cwd
.as_ref()
.filter(|_| app_server.uses_remote_workspace())
.map(AbsolutePathBuf::to_path_buf);
let mut config = match cwd {
Some(cwd) if app_server.uses_remote_workspace() => {
let mut config = self.fresh_session_config();
config.cwd = cwd;
config
}
Some(cwd) => match self.rebuild_config_for_cwd(cwd.to_path_buf()).await {
Ok(config) => config,
Err(error) => {
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.input = prompt;
}
return self
.chat_widget
.add_error_message(format!("Failed to load project settings: {error}"));
}
},
None => self.fresh_session_config(),
};
if let Some(profile) = self.runtime_permission_profile_override.as_ref()
&& profile.active_permission_profile.is_some()
&& (RuntimePermissionProfileOverride::from_config(&config) != *profile
|| config.permissions.profile_workspace_roots()
!= self.config.permissions.profile_workspace_roots())
{
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.input = prompt;
}
return self
.chat_widget
.add_error_message("Permission profile has different settings.".to_string());
}
self.apply_runtime_policy_overrides(&mut config);
apply_managed_new_thread_defaults(
&mut config,
app_server.managed_new_thread_defaults(),
&self.cli_kv_overrides,
&self.harness_overrides,
);
match app_server
.start_thread_with_session_start_source(
&config,
/*session_start_source*/ None,
remote_cwd.as_deref(),
)
.await
{
Ok(started) => {
let thread_id = started.session.thread_id;
self.agents_overview
.dispatched_requests
.insert(thread_id, Vec::new());
self.submit_agents_overview_prompt(app_server, thread_id, prompt)
.await;
}
Err(error) => {
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.input = prompt;
}
self.chat_widget
.add_error_message(format!("Failed to start background task: {error}"));
}
}
}
pub(super) async fn submit_agents_overview_prompt(
&mut self,
app_server: &AppServerSession,
thread_id: ThreadId,
prompt: String,
) {
let result = app_server
.request_handle()
.request_typed::<TurnStartResponse>(ClientRequest::TurnStart {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: TurnStartParams {
thread_id: thread_id.to_string(),
input: vec![UserInput::Text {
text: prompt.clone(),
text_elements: Vec::new(),
}],
..Default::default()
},
})
.await;
if let Err(error) = result {
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.input = prompt;
}
self.chat_widget
.add_error_message(format!("Failed to send task message: {error}"));
}
}
pub(super) async fn stop_agents_overview_thread(
&mut self,
app_server: &mut AppServerSession,
thread_id: ThreadId,
) {
let active_turn_id = match self.active_turn_id_for_thread(thread_id).await {
Some(turn_id) => Some(turn_id),
None => match app_server
.thread_read(thread_id, /*include_turns*/ true)
.await
{
Ok(thread) => thread
.turns
.into_iter()
.find(|turn| turn.status == TurnStatus::InProgress)
.map(|turn| turn.id),
Err(error) => {
self.chat_widget
.add_error_message(format!("Failed to stop background task: {error}"));
self.refresh_agents_overview_threads(app_server);
return;
}
},
};
let Some(turn_id) = active_turn_id else {
return;
};
if let Err(error) = app_server.turn_interrupt(thread_id, turn_id).await {
self.chat_widget
.add_error_message(format!("Failed to stop background task: {error}"));
self.refresh_agents_overview_threads(app_server);
}
}
}
#[cfg(test)]
+284 -41
View File
@@ -1,68 +1,220 @@
use crate::app::agents_overview_view::AgentsOverviewGroup;
use super::*;
use crate::app::test_support::make_test_app;
use crate::bottom_pane::BottomPaneView;
use crate::chatwidget::tests::helpers::render_bottom_popup;
use crate::render::renderable::Renderable;
use crate::test_support::PathBufExt;
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::ServerRequest;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadActiveFlag;
use codex_app_server_protocol::ThreadStatus;
use codex_protocol::ThreadId;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_protocol::protocol::SubAgentSource;
static OVERVIEW_TIMESTAMP: std::sync::LazyLock<i64> =
std::sync::LazyLock::new(|| chrono::Utc::now().timestamp() - 120);
fn overview_thread(
thread_id: ThreadId,
parent_thread_id: Option<ThreadId>,
name: &str,
status: ThreadStatus,
) -> Thread {
Thread {
id: thread_id.to_string(),
extra: None,
project_id: None,
session_id: parent_thread_id.unwrap_or(thread_id).to_string(),
forked_from_id: None,
parent_thread_id: None,
preview: name.to_string(),
ephemeral: false,
section: None,
section_entered_at: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
created_at: *OVERVIEW_TIMESTAMP,
updated_at: *OVERVIEW_TIMESTAMP,
recency_at: Some(*OVERVIEW_TIMESTAMP),
status,
path: None,
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(),
source: parent_thread_id.map_or(SessionSource::Cli, |parent_thread_id| {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})
}),
can_accept_direct_input: Some(parent_thread_id.is_none()),
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
name: Some(name.to_string()),
turns: Vec::new(),
}
}
#[tokio::test]
async fn shared_agents_dashboard_is_rendered() {
async fn shared_overview_shows_only_root_sessions() {
assert_eq!(
AgentsOverviewGroup::for_status(&ThreadStatus::SystemError),
AgentsOverviewGroup::NeedsYou
);
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let started = app_server
.start_thread(&app.config)
.await
.expect("start example thread");
let mut thread = app_server
.thread_read(started.session.thread_id, /*include_turns*/ false)
.await
.expect("read example thread");
thread.name = Some("Example task".to_string());
thread.preview = "Inspect the current project".to_string();
thread.cwd = test_path_buf("/tmp/project").abs();
thread.status = ThreadStatus::Active {
active_flags: vec![ThreadActiveFlag::WaitingOnApproval],
};
app.primary_thread_id = Some(started.session.thread_id);
let mut view = app.agents_overview_view(vec![thread.clone()], /*selected_thread_id*/ None);
let first_root = ThreadId::from_string("00000000-0000-0000-0000-000000000101").unwrap();
let [child, second_root, side_thread] = std::array::from_fn(|_| ThreadId::new());
app.primary_thread_id = Some(first_root);
let mut threads = vec![
overview_thread(
first_root,
/*parent_thread_id*/ None,
"Build the dashboard",
ThreadStatus::Idle,
),
overview_thread(
child,
Some(first_root),
"Inspect keyboard shortcuts",
ThreadStatus::Active {
active_flags: vec![ThreadActiveFlag::WaitingOnApproval],
},
),
overview_thread(
second_root,
/*parent_thread_id*/ None,
"Repair authentication",
ThreadStatus::Active {
active_flags: Vec::new(),
},
),
];
let mut side = overview_thread(
side_thread,
/*parent_thread_id*/ None,
"Ephemeral side",
ThreadStatus::Idle,
);
side.ephemeral = true;
threads.push(side);
let view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
let mut action_view = AgentsOverviewView::new(
view.rows.clone(),
Some(first_root),
/*exit_on_cancel*/ false,
crate::app_event_sender::AppEventSender::new(event_tx),
app.keymap.clone(),
Arc::clone(&app.agents_overview.view_state),
);
let state = &app.agents_overview.view_state;
assert!(!state.lock().unwrap().status_grouping);
view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(state.lock().unwrap().status_grouping);
app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
assert!(state.lock().unwrap().status_grouping);
view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(
action_view.handle_paste("Use \u{1b}[31mthe\u{1b}[0m current project\u{7}".to_string())
);
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL));
action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::DispatchAgentsOverviewTask { prompt, cwd: None })
if prompt == "Use the current project"
));
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
assert!(action_view.handle_paste("Fix the flaky tests after all retries complete".to_string()));
let area = ratatui::layout::Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 12,
);
let mut buffer = ratatui::buffer::Buffer::empty(area);
action_view.render(area, &mut buffer);
let prompt = buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(prompt.contains("complete"));
action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::DispatchAgentsOverviewTask { prompt, cwd: Some(cwd) })
if prompt == "Fix the flaky tests after all retries complete"
&& cwd == test_path_buf("/tmp/project").abs()
));
assert!(action_view.handle_paste(" ".to_string()));
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL));
assert!(action_view.handle_paste("Repair authentication".to_string()));
action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == second_root
));
assert!(action_view.handle_paste("Continue working".to_string()));
action_view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::DispatchAgentsOverviewTask { prompt, .. })
if prompt.trim() == "Continue working"
));
action_view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
crate::chatwidget::tests::helpers::set_active_cell(
&mut app.chat_widget,
Box::new(crate::history_cell::PlainHistoryCell::new(vec![
Line::from("Previous session status"),
])),
);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let project = test_path_display("/tmp/project");
let normalized_project_group = format!(
"/tmp/project 2{}",
" ".repeat(project.len().saturating_sub("/tmp/project".len()))
);
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!(
"agents_overview",
render_bottom_popup(&app.chat_widget, /*width*/ 96)
.replace(&test_path_display("/tmp/project"), "/tmp/project")
.replace(&format!("{project} 2"), &normalized_project_group)
.replace(&project, "/tmp/project")
);
});
let threads = (0..20)
.map(|index| {
let mut candidate = thread.clone();
if index != 0 {
candidate.id = ThreadId::new().to_string();
candidate.status = ThreadStatus::Idle;
let thread_id = if index == 0 {
first_root
} else {
ThreadId::new()
};
let status = if index == 0 {
ThreadStatus::Active {
active_flags: vec![ThreadActiveFlag::WaitingOnApproval],
}
} else {
ThreadStatus::Idle
};
let mut candidate = overview_thread(
thread_id,
/*parent_thread_id*/ None,
&format!("Task {index}"),
status,
);
if index == 0 {
candidate.name = None;
candidate.preview = "Inspect unnamed task".to_string();
}
candidate.name = (index != 0).then(|| format!("Task {index}"));
candidate.updated_at = index;
candidate.cwd = if index == 0 {
test_path_buf("/tmp/project-selected").abs()
@@ -77,10 +229,12 @@ async fn shared_agents_dashboard_is_rendered() {
let view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
assert!(rendered.lines().any(
|line| line.contains("› ● Inspect the current project current")
&& line.contains("Needs input")
));
assert!(
rendered
.lines()
.any(|line| line.contains("› ● Inspect unnamed task current")
&& line.contains("Needs input"))
);
app.transcript_cells.push(std::sync::Arc::new(
crate::history_cell::PlainHistoryCell::new(vec![ratatui::text::Line::from(
@@ -98,6 +252,95 @@ async fn shared_agents_dashboard_is_rendered() {
.expect("restore conversation after closing dashboard");
assert!(tui.terminal.viewport_area.height < screen_size.height);
assert!(app.last_rendered_history_tail.is_some());
app_server.shutdown().await.expect("shutdown app server");
}
#[tokio::test]
async fn failed_root_switch_keeps_background_requests_on_the_active_session() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
app.primary_thread_id = Some(ThreadId::new());
app.ensure_thread_channel(ThreadId::new())
.store
.lock()
.await
.active_turn_id = Some("running-turn".to_string());
let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agents_overview_thread(&mut tui, &mut app_server, ThreadId::new())
.await?;
assert!(app.agents_overview.dispatched_requests.is_empty());
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn root_switch_preserves_idle_root_with_running_subagent() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let previous = app_server.start_thread(&app.config).await?;
let previous_root_id = previous.session.thread_id;
app.enqueue_primary_thread_session(previous.session, previous.turns)
.await?;
let target_thread_id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Target task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("materialize target rollout"),
)?;
let target = app_server
.resume_thread(
app.config.clone(),
target_thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?;
let child_id = ThreadId::new();
let idle_child_id = ThreadId::new();
app.ensure_thread_channel(idle_child_id);
app.upsert_agent_picker_thread(
child_id, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false,
);
app.agent_navigation.mark_running(child_id);
app.active_thread_id = Some(child_id);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agents_overview_thread(&mut tui, &mut app_server, target.session.thread_id)
.await?;
assert!(
app.agents_overview
.dispatched_requests
.contains_key(&idle_child_id)
);
app.handle_app_server_event(
&app_server,
AppServerEvent::ServerRequest(Box::new(ServerRequest::CurrentTimeRead {
request_id: RequestId::Integer(99),
params: CurrentTimeReadParams {
thread_id: idle_child_id.to_string(),
},
})),
)
.await;
assert!(app.agents_overview.dispatched_requests[&idle_child_id].is_empty());
let response: ThreadUnsubscribeResponse = app_server
.request_handle()
.request_typed(ClientRequest::ThreadUnsubscribe {
request_id: RequestId::String("verify-root-subscription".to_string()),
params: ThreadUnsubscribeParams {
thread_id: previous_root_id.to_string(),
},
})
.await?;
assert_eq!(response.status, ThreadUnsubscribeStatus::Unsubscribed);
app_server.shutdown().await?;
Ok(())
}
+282 -43
View File
@@ -1,10 +1,11 @@
//! Read-only dashboard of the daemon's active tasks.
//! Dashboard for inspecting and managing the daemon's active tasks.
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::ViewCompletion;
use crate::key_hint::is_plain_text_key_event;
use crate::keymap::KeymapContext;
use crate::keymap::KeymapContextSet;
use crate::keymap::ListAction;
@@ -23,14 +24,19 @@ use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Margin;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::MutexGuard;
use std::sync::PoisonError;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub(super) enum AgentsOverviewGroup {
@@ -76,10 +82,12 @@ pub(super) struct AgentsOverviewRow {
#[derive(Clone, Default)]
pub(super) struct AgentsOverviewViewState {
pub(super) input: String,
search: String,
searching: bool,
pub(super) status_grouping: bool,
pub(super) refresh_task: Option<tokio::task::AbortHandle>,
pub(super) renaming: bool,
}
pub(super) struct AgentsOverviewView {
@@ -139,6 +147,16 @@ impl AgentsOverviewView {
self.rows.iter().map(|row| row.thread_id).collect()
}
fn state(&self) -> MutexGuard<'_, AgentsOverviewViewState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
fn selected_row(&self) -> Option<&AgentsOverviewRow> {
self.rows
.get(self.selected)
.filter(|_| self.visible_indices().contains(&self.selected))
}
fn visible_indices(&self) -> Vec<usize> {
let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let search = state.search.to_lowercase();
@@ -169,6 +187,9 @@ impl AgentsOverviewView {
}
fn move_selection(&mut self, forward: bool) {
if self.state().renaming {
return;
}
let visible = self.visible_indices();
if visible.is_empty() {
return;
@@ -184,8 +205,67 @@ impl AgentsOverviewView {
};
}
fn activate(&mut self) {
let state = self.state().clone();
let input = state.input.clone();
if !state.searching && !input.is_empty() && input.trim().is_empty() {
return;
}
if !state.searching && !input.trim().is_empty() {
if state.renaming {
if let Some(row) = self.selected_row() {
self.app_event_tx
.send(AppEvent::RenameAgentsOverviewThread {
thread_id: row.thread_id,
name: input.trim().to_string(),
});
}
self.state().renaming = false;
} else {
self.app_event_tx
.send(AppEvent::DispatchAgentsOverviewTask {
prompt: input,
cwd: (!state.status_grouping)
.then(|| self.selected_row().map(|row| row.thread.cwd.clone()))
.flatten(),
});
}
self.state().input.clear();
} else if let Some(row) = self.selected_row().filter(|_| !state.renaming) {
self.app_event_tx
.send(AppEvent::SelectAgentsOverviewThread {
thread_id: row.thread_id,
});
if state.searching {
let mut state = self.state();
state.search.clear();
state.searching = false;
}
self.completion = Some(ViewCompletion::Accepted);
}
}
fn edit_input(&mut self, edit: impl FnOnce(&mut String)) -> bool {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let searching = state.searching;
edit(if searching {
&mut state.search
} else {
&mut state.input
});
drop(state);
if searching {
self.selected = self
.visible_indices()
.first()
.copied()
.unwrap_or(usize::MAX);
}
true
}
fn status(row: &AgentsOverviewRow) -> (&'static str, Span<'static>) {
match AgentsOverviewGroup::for_status(&row.thread.status) {
match row.group {
AgentsOverviewGroup::NeedsYou => ("Needs input", "●".red()),
AgentsOverviewGroup::Working => ("Working", "●".green()),
AgentsOverviewGroup::Ready => ("Ready", "○".cyan()),
@@ -284,6 +364,49 @@ impl AgentsOverviewView {
offset += 1;
}
}
fn render_details(&self, area: Rect, buf: &mut Buffer) {
let Some(row) = self.selected_row() else {
return;
};
let (status, dot) = Self::status(row);
let mut lines = vec![
Line::from("Task details".bold()),
Line::default(),
Line::from(row.thread.name.as_deref().unwrap_or("Untitled task").bold()),
Line::from(vec![dot, " ".into(), status.into()]),
Line::default(),
Line::from("Project".dim()),
Line::from(row.thread.cwd.display().to_string()),
];
if let Some(branch) = row
.thread
.git_info
.as_ref()
.and_then(|git| git.branch.as_ref())
{
lines.push(Line::default());
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.",
preview => preview,
}),
]);
Paragraph::new(crate::wrapping::word_wrap_lines(
lines,
usize::from(area.width),
))
.render(area, buf);
}
}
impl BottomPaneView for AgentsOverviewView {
@@ -311,22 +434,70 @@ impl BottomPaneView for AgentsOverviewView {
true
}
fn handle_paste(&mut self, pasted: String) -> bool {
self.edit_input(|input| {
input.push_str(&crate::history_cell::sanitize_user_text(pasted.into()))
})
}
fn handle_key_event(&mut self, key: KeyEvent) {
if key.code == KeyCode::Char('s') && key.modifiers == KeyModifiers::CONTROL {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
state.status_grouping = !state.status_grouping;
return;
}
if key.code == KeyCode::Char('f') && key.modifiers == KeyModifiers::CONTROL {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
state.searching = !state.searching;
if key.modifiers == KeyModifiers::CONTROL
&& matches!(key.code, KeyCode::Char('f' | 'n' | 'r' | 's' | 'x'))
{
match key.code {
KeyCode::Char('s') => {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
state.status_grouping = !state.status_grouping;
}
KeyCode::Char('f') => {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
if !state.renaming {
state.searching = !state.searching;
if !state.searching {
state.search.clear();
}
}
}
KeyCode::Char('n') => {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
state.search.clear();
state.searching = false;
state.renaming = false;
state.input.clear();
}
KeyCode::Char('r') => {
if let Some(row) = self.selected_row() {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
if state.input.is_empty() {
state.input = row.thread.name.clone().unwrap_or_default();
state.search.clear();
state.searching = false;
state.renaming = true;
}
}
}
KeyCode::Char('x') => {
if let Some(row) = self.selected_row()
&& matches!(row.thread.status, ThreadStatus::Active { .. })
{
self.app_event_tx.send(AppEvent::StopAgentsOverviewThread {
thread_id: row.thread_id,
});
}
}
_ => {}
}
return;
}
if let Some(action) = self.keymap.action_for(key)
&& (!matches!(key.code, KeyCode::Char(_))
|| self.state.lock().is_ok_and(|state| !state.searching))
if !is_plain_text_key_event(key)
&& let Some(action) = self.keymap.action_for(key)
{
if self.state().renaming
&& matches!(action, ListAction::JumpTop | ListAction::JumpBottom)
{
return;
}
match action {
ListAction::MoveUp => self.move_selection(/*forward*/ false),
ListAction::MoveDown => self.move_selection(/*forward*/ true),
@@ -336,12 +507,16 @@ impl BottomPaneView for AgentsOverviewView {
ListAction::JumpBottom => {
self.selected = self.visible_indices().last().copied().unwrap_or(0);
}
ListAction::Accept => self.activate(),
ListAction::Cancel => {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
if state.searching {
state.search.clear();
state.searching = false;
self.selected = 0;
} else if !state.input.is_empty() || state.renaming {
state.input.clear();
state.renaming = false;
} else {
if self.exit_on_cancel {
self.app_event_tx
@@ -355,32 +530,24 @@ impl BottomPaneView for AgentsOverviewView {
self.move_selection(action == ListAction::PageDown);
}
}
ListAction::Accept | ListAction::MoveLeft | ListAction::MoveRight => {}
ListAction::MoveLeft | ListAction::MoveRight => {}
}
return;
}
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
if !state.searching {
return;
}
match key.code {
KeyCode::Backspace => {
state.search.pop();
self.edit_input(|input| {
input.pop();
});
}
KeyCode::Char(character)
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
{
state.search.push(character);
self.edit_input(|input| input.push(character));
}
_ => return,
_ => {}
}
drop(state);
self.selected = self
.visible_indices()
.first()
.copied()
.unwrap_or(usize::MAX);
}
}
@@ -389,17 +556,34 @@ impl Renderable for AgentsOverviewView {
24
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
let state = self.state().clone();
let (label, input) = if state.searching {
(" Search › ", &state.search)
} else if state.renaming {
(" Rename › ", &state.input)
} else {
(" New task › ", &state.input)
};
let x = area
.x
.saturating_add((label.width() + input.width()) as u16)
.min(area.right().saturating_sub(3));
Some((x, area.bottom().saturating_sub(2)))
}
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.width < 12 || area.height < 8 {
return;
}
Clear.render(area, buf);
let [header, summary, divider, body, footer] = Layout::vertical([
let [header, summary, divider, body, prompt, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(area);
let inset =
@@ -419,22 +603,77 @@ impl Renderable for AgentsOverviewView {
.render(inset(summary), buf);
Line::from("─".repeat(usize::from(area.width.saturating_sub(4))).dim())
.render(inset(divider), buf);
self.render_rows(inset(body), buf);
let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let footer_line = if state.searching {
Line::from(vec!["Search › ".cyan().bold(), state.search.clone().into()])
} else {
Line::from(vec![
"↑↓".bold(),
" navigate ".dim(),
"ctrl+f".bold(),
" search ".dim(),
"ctrl+s".bold(),
" group ".dim(),
"esc".bold(),
" back".dim(),
let body = inset(body);
if body.width >= 90 {
let [list, gap, details] = Layout::horizontal([
Constraint::Min(46),
Constraint::Length(3),
Constraint::Length(38),
])
.areas(body);
for y in gap.y..gap.bottom() {
buf[(gap.x + 1, y)]
.set_symbol("│")
.set_style(Style::new().dim());
}
self.render_rows(list, buf);
self.render_details(details, buf);
} else {
self.render_rows(body, buf);
}
let state = self.state().clone();
let (label, input) = if state.searching {
("Search › ", &state.search)
} else if state.renaming {
("Rename › ", &state.input)
} else {
("New task › ", &state.input)
};
footer_line.render(inset(footer), buf);
let placeholder = if input.is_empty() && !state.searching && !state.renaming {
"Describe a task and press enter to dispatch it"
} else {
""
};
let available_width = usize::from(inset(prompt).width)
.saturating_sub(label.width())
.saturating_sub(1);
let mut visible_start = input.len();
let mut visible_width = 0;
for (index, character) in input.char_indices().rev() {
let width = character.width().unwrap_or(0);
if visible_width + width > available_width {
break;
}
visible_width += width;
visible_start = index;
}
let input = &input[visible_start..];
Line::from(vec![label.cyan().bold(), input.into(), placeholder.dim()])
.render(inset(prompt), buf);
let stop_hint = if self
.selected_row()
.is_some_and(|row| matches!(row.thread.status, ThreadStatus::Active { .. }))
{
"ctrl+x".bold()
} else {
"ctrl+x".dim()
};
Line::from(vec![
"↑↓".bold(),
" navigate ".dim(),
"enter".bold(),
" open ".dim(),
"ctrl+f".bold(),
" search ".dim(),
"ctrl+s".bold(),
" group ".dim(),
"ctrl+r".bold(),
" rename ".dim(),
stop_hint,
" stop ".dim(),
"esc".bold(),
" back".dim(),
])
.render(inset(footer), buf);
}
}
+66 -12
View File
@@ -87,6 +87,21 @@ impl App {
app_server_client: &AppServerSession,
notification: ServerNotification,
) {
if let ServerNotification::ThreadStarted(started) = &notification
&& let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) = &started.thread.source
&& self
.agents_overview
.dispatched_requests
.contains_key(parent_thread_id)
&& let Ok(thread_id) = codex_protocol::ThreadId::from_string(&started.thread.id)
{
self.agents_overview
.dispatched_requests
.entry(thread_id)
.or_default();
}
if matches!(
&notification,
ServerNotification::ThreadStarted(_)
@@ -105,6 +120,9 @@ impl App {
!matches!(event, ThreadBufferedEvent::Request(request)
if request.id() == &notification.request_id)
});
for requests in self.agents_overview.dispatched_requests.values_mut() {
requests.retain(|request| request.id() != &notification.request_id);
}
if let Some(request) = self
.pending_app_server_requests
.resolve_notification(&notification.request_id)
@@ -289,9 +307,32 @@ impl App {
.push_back(ThreadBufferedEvent::Request(Box::new(request)));
return;
}
let unsupported_request = matches!(
&request,
ServerRequest::DynamicToolCall { .. }
| ServerRequest::AttestationGenerate { .. }
| ServerRequest::CurrentTimeRead { .. }
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. }
);
if self
.pending_app_server_requests
.contains_server_request(&request)
{
return;
}
if let Some(thread_id) = thread_id
&& self.primary_thread_id != Some(thread_id)
&& !unsupported_request
&& let Some(requests) = self.agents_overview.dispatched_requests.get_mut(&thread_id)
{
requests.push(request);
return;
}
if thread_id.is_some()
&& self.primary_thread_id.is_none()
&& !self.pending_startup_thread_start
&& !unsupported_request
{
return;
}
@@ -301,6 +342,7 @@ impl App {
&& !self.thread_event_channels.contains_key(&thread_id)
&& self.agent_navigation.get(&thread_id).is_none()
&& !self.side_threads.contains_key(&thread_id)
&& !unsupported_request
{
let thread = app_server_client
.request_handle()
@@ -312,18 +354,30 @@ impl App {
},
})
.await;
if !matches!(
thread,
Ok(ThreadReadResponse { thread })
if matches!(thread.source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
..
}) if self.primary_thread_id == Some(parent_thread_id)
|| self.thread_event_channels.contains_key(&parent_thread_id)
|| self.agent_navigation.get(&parent_thread_id).is_some()
)
) {
let Ok(ThreadReadResponse { thread }) = thread else {
return;
};
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) = thread.source
else {
return;
};
if self.primary_thread_id != Some(parent_thread_id)
&& !self.thread_event_channels.contains_key(&parent_thread_id)
&& self.agent_navigation.get(&parent_thread_id).is_none()
{
if self
.agents_overview
.dispatched_requests
.contains_key(&parent_thread_id)
{
self.agents_overview
.dispatched_requests
.entry(thread_id)
.or_default()
.push(request);
}
return;
}
}
+15 -13
View File
@@ -355,12 +355,12 @@ impl PendingAppServerRequests {
.mcp_requests
.values()
.any(|pending_request_id| pending_request_id == request_id),
ServerRequest::ChatgptAuthTokensRefresh { .. } => true,
ServerRequest::DynamicToolCall { .. }
| ServerRequest::ChatgptAuthTokensRefresh { .. }
| ServerRequest::AttestationGenerate { .. }
| ServerRequest::CurrentTimeRead { .. }
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => true,
| ServerRequest::ExecCommandApproval { .. } => false,
}
}
@@ -721,18 +721,20 @@ mod tests {
#[test]
fn rejects_dynamic_tool_calls_as_unsupported() {
let mut pending = PendingAppServerRequests::default();
let request = ServerRequest::DynamicToolCall {
request_id: AppServerRequestId::Integer(99),
params: codex_app_server_protocol::DynamicToolCallParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
call_id: "tool-1".to_string(),
namespace: None,
tool: "tool".to_string(),
arguments: json!({}),
},
};
assert!(!pending.contains_server_request(&request));
let unsupported = pending
.note_server_request(&ServerRequest::DynamicToolCall {
request_id: AppServerRequestId::Integer(99),
params: codex_app_server_protocol::DynamicToolCallParams {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
call_id: "tool-1".to_string(),
namespace: None,
tool: "tool".to_string(),
arguments: json!({}),
},
})
.note_server_request(&request)
.expect("dynamic tool calls should be rejected");
assert_eq!(unsupported.request_id, AppServerRequestId::Integer(99));
+28
View File
@@ -445,6 +445,7 @@ impl App {
app_server
.start_thread_with_session_start_source(
&config, /*session_start_source*/ None,
/*remote_cwd_override*/ None,
)
.await
}
@@ -2320,6 +2321,33 @@ impl App {
AppEvent::AgentsOverviewThreadsLoaded { request_id, result } => {
self.apply_agents_overview_thread_refresh(app_server, request_id, result);
}
AppEvent::SelectAgentsOverviewThread { thread_id } => {
match self
.select_agents_overview_thread(tui, app_server, thread_id)
.await?
{
AppRunControl::Continue => {}
AppRunControl::Exit(reason) => return Ok(AppRunControl::Exit(reason)),
}
}
AppEvent::DispatchAgentsOverviewTask { prompt, cwd } => {
self.dispatch_agents_overview_task(app_server, prompt, cwd)
.await;
}
AppEvent::RenameAgentsOverviewThread { thread_id, name } => {
if let Err(error) = app_server.thread_set_name(thread_id, name.clone()).await {
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.input = name;
state.renaming = true;
}
self.chat_widget
.add_error_message(format!("Failed to rename task: {error}"));
}
}
AppEvent::StopAgentsOverviewThread { thread_id } => {
self.stop_agents_overview_thread(app_server, thread_id)
.await;
}
AppEvent::OpenAgentPicker => {
self.open_agent_picker(app_server).await;
}
+10 -1
View File
@@ -529,6 +529,9 @@ impl App {
&mut snapshot,
)
.await;
if snapshot.input_state.is_none() {
snapshot.input_state = self.agents_overview.input_states.remove(&thread_id);
}
let blocks_direct_input = self.agent_navigation.is_parent_owned(thread_id);
self.active_thread_id = Some(thread_id);
@@ -728,7 +731,11 @@ impl App {
}
self.config = config.clone();
match app_server
.start_thread_with_session_start_source(&config, session_start_source)
.start_thread_with_session_start_source(
&config,
session_start_source,
/*remote_cwd_override*/ None,
)
.await
{
Ok(mut started) => {
@@ -1091,6 +1098,8 @@ impl App {
{
Ok(()) => {
self.backfill_loaded_subagent_threads(app_server).await;
self.replay_agents_overview_requests(app_server, resumed_thread_id)
.await;
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(usage_line) = summary.usage_line {
+3 -1
View File
@@ -128,7 +128,9 @@ impl App {
.await
} else {
app_server
.start_thread_with_session_start_source(&config, /*session_start_source*/ None)
.start_thread_with_session_start_source(
&config, /*session_start_source*/ None, /*remote_cwd_override*/ None,
)
.await
};
let transitioned = match transitioned {
+19 -1
View File
@@ -195,13 +195,31 @@ pub(crate) enum TranscriptExportDestination {
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum AppEvent {
/// Open the daemon-wide overview of loaded root sessions and their subagents.
/// Open the daemon-wide overview of loaded root sessions.
OpenAgentsOverview,
/// Update the daemon-wide overview after a background thread listing finishes.
AgentsOverviewThreadsLoaded {
request_id: Uuid,
result: Result<Vec<Thread>, String>,
},
/// Switch to a root session selected from the shared dashboard.
SelectAgentsOverviewThread {
thread_id: ThreadId,
},
/// Start a background task directly from the shared dashboard.
DispatchAgentsOverviewTask {
prompt: String,
cwd: Option<AbsolutePathBuf>,
},
/// Rename a task directly from the shared dashboard.
RenameAgentsOverviewThread {
thread_id: ThreadId,
name: String,
},
/// Interrupt a task directly from the shared dashboard.
StopAgentsOverviewThread {
thread_id: ThreadId,
},
/// Open the agent picker for switching active threads.
OpenAgentPicker,
/// Merge a completed root-scoped agent-picker refresh without blocking terminal input.
+6 -3
View File
@@ -602,21 +602,24 @@ impl AppServerSession {
#[cfg(test)]
pub(crate) async fn start_thread(&mut self, config: &Config) -> Result<AppServerStartedThread> {
self.start_thread_with_session_start_source(config, /*session_start_source*/ None)
.await
self.start_thread_with_session_start_source(
config, /*session_start_source*/ None, /*remote_cwd_override*/ None,
)
.await
}
pub(crate) async fn start_thread_with_session_start_source(
&mut self,
config: &Config,
session_start_source: Option<ThreadStartSource>,
remote_cwd_override: Option<&std::path::Path>,
) -> Result<AppServerStartedThread> {
let request_id = self.next_request_id();
let session_config = self.session_config_with_effective_service_tier(config);
let mut params = thread_start_params_from_config(
&session_config,
self.thread_params_mode(),
self.remote_cwd_override.as_deref(),
remote_cwd_override.or(self.remote_cwd_override.as_deref()),
session_start_source,
);
if self.history_support == ThreadHistorySupport::LegacyOnly {
@@ -1,29 +1,28 @@
---
source: tui/src/app/agents_overview_tests.rs
assertion_line: 49
expression: "render_bottom_popup(&app.chat_widget, 96)"
expression: "render_bottom_popup(&app.chat_widget,\n96).replace(&test_path_display(\"/tmp/project\"), \"/tmp/project\")"
---
Agent command center
1 need input 0 working 0 ready
1 need input 1 working 0 ready
────────────────────────────────────────────────────────────────────────────────────────────
/tmp/project 1
› ● Example task current Needs input
↑↓ navigate ctrl+f search ctrl+s group esc back
/tmp/project 2 │ Task details
› ● Build the dashboard current Needs input │
● Repair authentication Working │ Build the dashboard
│ ● Needs input
│
│ Project
│ /tmp/project
│
│ Latest activity
│ Build the dashboard
│
│
│
│
│
│
│
│
│
New task › Describe a task and press enter to dispatch it
↑↓ navigate enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc back