Add shared task pinning to the agent command center (#51500)

## What changed

- Use `p` to pin or unpin the selected task, with a configurable `agents.toggle_pin` binding.
- Display pinned tasks first in a `Pinned` group, preserving shared section order and applying search and status filters.
- Load pins from the shared thread section, including tasks outside the recent-task window, and refresh after pin changes. Disable pinning when the server does not support shared sections.

## Testing

Add coverage for shared pin/unpin state, paginated pin discovery and refreshing pins on reopen, shortcut dispatch and duplicate-action suppression, and pinned-group and custom-help snapshots.

GitOrigin-RevId: d1a2cab6642d059c3c8df8480b5c59a10c493936
This commit is contained in:
Eric Traut
2026-10-06 23:04:26 +00:00
committed by copyberry
parent a9bc7bebfa
commit 7298a6e020
25 changed files with 513 additions and 77 deletions
+2
View File
@@ -503,6 +503,8 @@ pub struct TuiAgentsKeymap {
pub delete: Option<KeybindingsSpec>,
/// Hide the selected task until explicitly resumed or the TUI restarts.
pub hide: Option<KeybindingsSpec>,
/// Pin or unpin the selected task in the shared sidebar section.
pub toggle_pin: Option<KeybindingsSpec>,
/// Toggle grouping tasks by status or project.
pub toggle_grouping: Option<KeybindingsSpec>,
}
+12 -2
View File
@@ -4615,7 +4615,8 @@
"resume": null,
"search": null,
"stop": null,
"toggle_grouping": null
"toggle_grouping": null,
"toggle_pin": null
},
"approval": {
"approve": null,
@@ -5040,6 +5041,14 @@
}
],
"description": "Toggle grouping tasks by status or project."
},
"toggle_pin": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Pin or unpin the selected task in the shared sidebar section."
}
},
"type": "object"
@@ -5569,7 +5578,8 @@
"resume": null,
"search": null,
"stop": null,
"toggle_grouping": null
"toggle_grouping": null,
"toggle_pin": null
}
},
"approval": {
+5 -1
View File
@@ -35,6 +35,7 @@ impl AgentsOverviewView {
&self.agents_keymap.archive,
&self.agents_keymap.delete,
&self.agents_keymap.hide,
&self.agents_keymap.toggle_pin,
&self.agents_keymap.toggle_grouping,
].into_iter().any(|bindings| bindings.contains(binding)))
})
@@ -89,8 +90,11 @@ impl AgentsOverviewView {
("archive", &self.agents_keymap.archive, "Archive"),
("hide", &self.agents_keymap.hide, "Hide"),
("delete", &self.agents_keymap.delete, "Delete"),
("toggle_pin", &self.agents_keymap.toggle_pin, "Pin/unpin"),
] {
if action != "new_worktree" || self.worktrees_enabled {
if (action != "new_worktree" || self.worktrees_enabled)
&& (action != "toggle_pin" || self.can_toggle_selected_pin())
{
tasks.push(self.agents_keymap.primary_hint(action, bindings), label);
}
}
+15 -9
View File
@@ -140,15 +140,19 @@ impl AgentsOverviewView {
let task = &self.rows[index];
let rect = row(viewport, offset as u16, /*height*/ 1);
if matches!(entry, CenterRow::Group(_)) {
let group = match state.grouping {
AgentsOverviewGrouping::Project => {
format_directory_display(
&self.project_groups[index].heading,
/*max_width*/ None,
)
let group = if self.is_pinned(index) {
"Pinned".to_owned()
} else {
match state.grouping {
AgentsOverviewGrouping::Project => {
format_directory_display(
&self.project_groups[index].heading,
/*max_width*/ None,
)
}
AgentsOverviewGrouping::Status => task.group.label().to_owned(),
AgentsOverviewGrouping::Model => model_name(&task.thread).to_owned(),
}
AgentsOverviewGrouping::Status => task.group.label().to_owned(),
AgentsOverviewGrouping::Model => model_name(&task.thread).to_owned(),
};
let count = indices
.iter()
@@ -164,7 +168,9 @@ impl AgentsOverviewView {
} else {
format!("{count} of {total}")
};
let group = if state.grouping == AgentsOverviewGrouping::Project {
let group = if state.grouping == AgentsOverviewGrouping::Project
&& !self.is_pinned(index)
{
crate::text_formatting::center_truncate_path(
&group,
usize::from(rect.width)
+15 -1
View File
@@ -75,8 +75,10 @@ async fn live_center_columns() {
ThreadStatus::Idle,
));
crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, child);
app.agents_overview.pinned_thread_ids = Some(vec![current, ThreadId::from_u128(/*value*/ 44)]);
let mut view = app.agents_overview_view(threads, Some(current));
insta::assert_snapshot!(screen(&view, /*width*/ 160, /*height*/ 22));
view.pinned_thread_ranks = None;
let mut selected_status_styles = Vec::new();
for _ in 0..fixtures.len() {
let area = Rect::new(
@@ -466,15 +468,27 @@ page_up = []
[agents]
archive = []
rename = 'z r'
toggle_pin = 'z p'
"#,
)
.unwrap();
app.keymap = RuntimeKeymap::from_config(&config).unwrap();
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.agents_overview.pinned_thread_ids = Some(Vec::new());
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
insta::assert_snapshot!(
"live_center_custom_footer",
screen(&view, /*width*/ 100, /*height*/ 12)
);
let thread_id = ThreadId::from_u128(/*value*/ 42);
let mut view = app.agents_overview_view(
vec![overview_thread(
thread_id,
/*parent_thread_id*/ None,
"Task",
ThreadStatus::Idle,
)],
Some(thread_id),
);
view.handle_key_event(KeyCode::Char('?').into());
insta::assert_snapshot!(
"live_center_custom_help",
+21 -2
View File
@@ -49,11 +49,16 @@ pub(super) struct AgentsOverviewState {
pub(super) initialized: bool,
pub(super) discovery: super::agents_overview_discovery::AgentsOverviewDiscovery,
pub(super) show_more_requested: bool,
pub(super) refresh_show_more: bool,
/// Vacancies left by lifecycle removals, filled without expanding the visible window.
pub(super) refill_count: usize,
pub(super) request_id: Option<Uuid>,
pub(super) refresh_pending: bool,
pub(super) pin_refresh_requested: bool,
pub(super) refresh_thread_ids: HashSet<ThreadId>,
pub(super) active_refresh_thread_ids: HashSet<ThreadId>,
pub(super) pinned_thread_ids: Option<Vec<ThreadId>>,
pub(super) pending_pin_change: Option<Uuid>,
pub(super) refresh_task: Option<tokio::task::AbortHandle>,
pub(super) refresh_notifications: HashMap<ThreadId, Vec<ServerNotification>>,
pub(super) rendered_full_screen: bool,
@@ -170,6 +175,8 @@ impl App {
return;
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_show_more = false;
self.agents_overview.active_refresh_thread_ids.clear();
self.agents_overview.refresh_task = None;
let refill_succeeded = result
.as_ref()
@@ -185,6 +192,9 @@ impl App {
}
match result {
Ok(refresh) => {
if let Some(pinned_thread_ids) = refresh.pinned_thread_ids {
self.agents_overview.pinned_thread_ids = pinned_thread_ids;
}
self.agents_overview.initialized = refresh.recent_seed_complete;
if let Some(discovery) = refresh.discovery {
if !discovery.has_more() {
@@ -380,7 +390,7 @@ impl App {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.vim_enabled = self.chat_widget.composer_is_vim_enabled();
AgentsOverviewView::new(
let mut view = AgentsOverviewView::new(
rows,
selected_thread_id,
self.config.features.enabled(Feature::Worktrees)
@@ -392,7 +402,16 @@ impl App {
self.app_event_tx.clone(),
self.keymap.clone(),
Arc::clone(&self.agents_overview.view_state),
)
);
view.pinned_thread_ranks = self.agents_overview.pinned_thread_ids.as_ref().map(|ids| {
ids.iter()
.copied()
.enumerate()
.map(|(rank, id)| (id, rank))
.collect()
});
view.pin_action_pending = self.agents_overview.pending_pin_change.is_some();
view
}
pub(super) async fn select_agents_overview_thread(
@@ -12,10 +12,15 @@ use crate::chatwidget::ChatWidget;
use crate::render::renderable::Renderable;
use crate::tui;
use crate::wrapping::word_wrap_lines;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadSectionMoveParams;
use codex_app_server_protocol::ThreadSectionMoveResponse;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SubAgentSource;
use codex_state::PINNED_THREAD_SECTION_ID;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
@@ -25,6 +30,7 @@ use std::collections::HashSet;
use std::future::Future;
use tokio_stream::Stream;
use tokio_stream::StreamExt;
use uuid::Uuid;
struct LifecycleHeader(Vec<Line<'static>>);
@@ -104,6 +110,90 @@ async fn run_lifecycle_modal<T>(
}
impl App {
pub(super) fn toggle_agents_overview_pin(
&mut self,
app_server: &AppServerSession,
thread_id: ThreadId,
pinned: bool,
) {
if self.agents_overview.pending_pin_change.is_some() {
return;
}
let request_id = Uuid::new_v4();
self.agents_overview.pending_pin_change = Some(request_id);
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = request_handle
.request_typed::<ThreadSectionMoveResponse>(ClientRequest::ThreadSectionMove {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: ThreadSectionMoveParams {
thread_id: thread_id.to_string(),
section_id: pinned.then(|| PINNED_THREAD_SECTION_ID.to_string()),
before_thread_id: None,
},
})
.await
.map(|_| ())
.map_err(|error| error.to_string());
app_event_tx.send(AppEvent::AgentsOverviewPinToggled {
request_id,
thread_id,
pinned,
result,
});
});
}
pub(super) fn complete_agents_overview_pin(
&mut self,
app_server: &AppServerSession,
request_id: Uuid,
thread_id: ThreadId,
pinned: bool,
result: Result<(), String>,
) {
if self.agents_overview.pending_pin_change != Some(request_id) {
return;
}
self.agents_overview.pending_pin_change = None;
match result {
Ok(()) => {
if let Some(pinned_thread_ids) = self.agents_overview.pinned_thread_ids.as_mut() {
if pinned {
if !pinned_thread_ids.contains(&thread_id) {
pinned_thread_ids.push(thread_id);
}
} else {
pinned_thread_ids.retain(|id| *id != thread_id);
}
}
}
Err(error) => {
self.add_agents_overview_error(format!(
"Failed to {} task: {error}",
if pinned { "pin" } else { "unpin" }
));
}
}
let restore_show_more = self.agents_overview.refresh_show_more;
if let Some(refresh) = self.agents_overview.refresh_task.take() {
refresh.abort();
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_show_more = false;
self.agents_overview.refresh_notifications.clear();
let refresh_thread_ids =
std::mem::take(&mut self.agents_overview.active_refresh_thread_ids);
self.agents_overview
.refresh_thread_ids
.extend(refresh_thread_ids);
self.agents_overview.refresh_pending = false;
self.agents_overview.show_more_requested |= restore_show_more;
self.refresh_agents_overview_threads(app_server);
self.repaint_agents_overview();
}
pub(super) fn confirm_agents_overview_action(
&mut self,
thread_id: ThreadId,
@@ -227,6 +317,8 @@ impl App {
refresh.abort();
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_show_more = false;
self.agents_overview.active_refresh_thread_ids.clear();
self.agents_overview
.view_state
.lock()
@@ -1,4 +1,5 @@
use super::*;
use crate::app::agents_overview_discovery::list_pinned_threads;
use crate::app_event::AgentsOverviewAction;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
@@ -11,6 +12,51 @@ use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn pin_action_updates_shared_section_state() -> Result<()> {
let mut app = make_test_app().await;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let started = app_server.start_thread(&app.config).await?;
let thread_id = started.session.thread_id;
let mut tui = crate::tui::test_support::make_test_tui()?;
for pinned in [true, false] {
Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::ToggleAgentsOverviewPin { thread_id, pinned },
))
.await?;
assert!(app.agents_overview.pending_pin_change.is_some());
let completed = rx.recv().await.expect("pin request completes");
assert!(matches!(
&completed,
AppEvent::AgentsOverviewPinToggled { .. }
));
app.agents_overview
.refresh_notifications
.insert(thread_id, Vec::new());
Box::pin(app.handle_event(&mut tui, &mut app_server, completed)).await?;
assert_eq!(app.agents_overview.pending_pin_change, None);
assert!(app.agents_overview.refresh_notifications.is_empty());
let threads = list_pinned_threads(&app_server.request_handle())
.await
.map_err(color_eyre::eyre::Report::msg)?
.expect("embedded app server supports shared thread sections");
assert_eq!(
threads
.iter()
.any(|thread| thread.id == started.session.thread_id.to_string()),
pinned
);
}
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn archive_confirmation_number_keys_act_immediately() {
for key in ['1', '2'] {
@@ -586,6 +632,7 @@ async fn hidden_task_stays_hidden_through_activity_and_seed_until_explicit_resum
last_messages: HashMap::new(),
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
assert_eq!(
@@ -865,6 +912,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb
last_messages: HashMap::new(),
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
assert_eq!(
@@ -967,6 +1015,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb
last_messages: HashMap::new(),
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
});
Box::pin(app.handle_event(&mut tui, &mut app_server, confirmed)).await?;
@@ -12,11 +12,82 @@ use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_protocol::protocol::SubAgentSource;
use codex_state::PINNED_THREAD_SECTION_ID;
use std::collections::VecDeque;
use uuid::Uuid;
const PAGE_SIZE: u32 = 10;
pub(crate) async fn list_pinned_threads(
handle: &AppServerRequestHandle,
) -> Result<Option<Vec<Thread>>, String> {
let mut threads = Vec::new();
for source_kinds in [
Vec::new(),
vec![ThreadSourceKind::Exec, ThreadSourceKind::AppServer],
] {
let mut cursor = None;
loop {
let result = handle
.request_typed::<ThreadListResponse>(ClientRequest::ThreadList {
request_id: RequestId::String(Uuid::new_v4().to_string()),
params: ThreadListParams {
originators: None,
cursor: cursor.clone(),
limit: Some(100),
sort_key: Some(ThreadSortKey::SectionPosition),
sort_direction: None,
model_providers: Some(Vec::new()),
source_kinds: Some(source_kinds.clone()),
archived: Some(false),
section_id: Some(Some(PINNED_THREAD_SECTION_ID.to_string())),
project_id: None,
parent_thread_id: None,
ancestor_thread_id: None,
cwd: None,
use_state_db_only: true,
search_term: None,
},
})
.await;
match result {
Ok(page) => {
threads.extend(page.data.into_iter().filter(|thread| {
is_overview_thread(thread) && supports_shared_pinning(&thread.source)
}));
let Some(next_cursor) = page.next_cursor else {
break;
};
cursor = Some(next_cursor);
}
Err(TypedRequestError::Server { source, .. })
if matches!(source.code, -32602..=-32600) =>
{
return Ok(None);
}
Err(error) => return Err(error.to_string()),
}
}
}
Ok(Some(threads))
}
fn is_overview_thread(thread: &Thread) -> bool {
!thread.ephemeral
&& thread.parent_thread_id.is_none()
&& !matches!(
thread.source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
)
}
pub(super) fn supports_shared_pinning(source: &SessionSource) -> bool {
matches!(
source,
SessionSource::Cli | SessionSource::VsCode | SessionSource::Exec | SessionSource::AppServer
) || matches!(source, SessionSource::Custom(source) if matches!(source.as_str(), "atlas" | "chatgpt"))
}
#[derive(Clone, Debug)]
struct SourcePage {
cursor: Option<String>,
@@ -128,14 +199,8 @@ impl SourcePage {
Ok(page) => {
self.exhausted = page.next_cursor.is_none();
self.cursor = page.next_cursor;
self.rows.extend(page.data.into_iter().filter(|thread| {
!thread.ephemeral
&& thread.parent_thread_id.is_none()
&& !matches!(
thread.source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
)
}));
self.rows
.extend(page.data.into_iter().filter(is_overview_thread));
return true;
}
Err(error) => {
@@ -7,6 +7,9 @@ use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::Message;
@@ -47,12 +50,21 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
)
})
.collect();
let reopened_pin = overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
"Pinned after reopen",
ThreadStatus::NotLoaded,
);
let reopened_pin_id = ThreadId::from_string(&reopened_pin.id).unwrap();
let show_reopened_pin = Arc::new(AtomicBool::new(false));
let expected_first_twenty: HashSet<_> = rows[1..21]
.iter()
.map(|row| ThreadId::from_string(&row.id).unwrap())
.collect();
let listener = TcpListener::bind("127.0.0.1:0").await?;
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?;
let server_show_reopened_pin = Arc::clone(&show_reopened_pin);
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await?;
let mut socket = tokio_tungstenite::accept_async(stream).await?;
@@ -66,6 +78,34 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
"initialize" => json!({"userAgent": "overview-test/1.0"}),
"thread/loaded/list" => json!({"data": [], "nextCursor": null}),
"thread/list" => {
if params["sortKey"] == "section_position" {
assert_eq!(params["sectionId"], codex_state::PINNED_THREAD_SECTION_ID);
let interactive = params["sourceKinds"] == json!([]);
let cursor = params["cursor"].as_str();
let reopened = server_show_reopened_pin.load(Ordering::SeqCst);
let data = if reopened && interactive && cursor.is_none() {
vec![reopened_pin.clone()]
} else if interactive && cursor == Some("pinned-page-2") {
vec![rows[1].clone()]
} else {
Vec::new()
};
socket
.send(Message::Text(
json!({
"id": request.id,
"result": {
"data": data,
"nextCursor": (!reopened && interactive && cursor.is_none())
.then_some("pinned-page-2"),
}
})
.to_string()
.into(),
))
.await?;
continue;
}
assert_eq!(params["sortKey"], "recency_at");
assert_eq!(params["limit"], 10);
{
@@ -113,6 +153,7 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
app.refresh_agents_overview_threads(&session);
finish_overview_refresh(&mut app, &session, &mut rx).await;
assert_eq!(app.agents_overview.threads.len(), 10);
assert_eq!(app.agents_overview.pinned_thread_ids, Some(vec![ids[1]]));
assert!(app.agents_overview.discovery.has_more());
app.track_agents_overview_notification(&ServerNotification::ThreadArchived(
ThreadArchivedNotification {
@@ -162,6 +203,17 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
finish_overview_refresh(&mut app, &session, &mut rx).await;
assert_eq!(app.agents_overview.threads, expected);
assert!(!app.agents_overview.discovery.has_more());
app.agents_overview.threads.clear();
app.agents_overview.pinned_thread_ids = Some(Vec::new());
app.agents_overview.refill_count = 0;
show_reopened_pin.store(true, Ordering::SeqCst);
app.refresh_agents_overview_threads(&session);
finish_overview_refresh(&mut app, &session, &mut rx).await;
assert_eq!(
app.agents_overview.pinned_thread_ids,
Some(vec![reopened_pin_id])
);
assert!(app.agents_overview.threads.contains_key(&reopened_pin_id));
session.shutdown().await?;
assert_eq!(
server.await??,
@@ -18,12 +18,65 @@ pub(super) fn model_name(thread: &Thread) -> &str {
}
impl AgentsOverviewView {
pub(super) fn is_pinned(&self, index: usize) -> bool {
self.pinned_thread_ranks
.as_ref()
.is_some_and(|ranks| ranks.contains_key(&self.rows[index].thread_id))
}
pub(super) fn visible_indices(&self) -> Vec<usize> {
let state = self.state();
let search = state.search.to_lowercase();
let (_, status_group) = super::command_center::TASK_FILTERS[state.status_filter];
let (mut pinned, mut visible): (Vec<_>, Vec<_>) = self
.rows
.iter()
.enumerate()
.filter_map(|(index, row)| {
let searchable = format!(
"{} {} {}",
row.thread.name.as_deref().unwrap_or_default(),
row.thread.preview,
row.thread.cwd.display(),
)
.to_lowercase();
((search.is_empty() || searchable.contains(&search))
&& (state.rename_target == Some(row.thread_id)
|| status_group.is_none_or(|group| group == row.group)))
.then_some(index)
})
.partition(|index| self.is_pinned(*index));
if let Some(ranks) = &self.pinned_thread_ranks {
pinned.sort_by_key(|index| ranks.get(&self.rows[*index].thread_id).copied());
}
match state.grouping {
AgentsOverviewGrouping::Project => visible.sort_by_key(|index| {
(
&self.project_groups[*index].key,
std::cmp::Reverse(self.rows[*index].thread.updated_at),
)
}),
AgentsOverviewGrouping::Status => {}
AgentsOverviewGrouping::Model => visible.sort_by_key(|index| {
(
model_name(&self.rows[*index].thread),
std::cmp::Reverse(self.rows[*index].thread.updated_at),
)
}),
}
pinned.extend(visible);
pinned
}
pub(super) fn same_group(
&self,
grouping: AgentsOverviewGrouping,
left: usize,
right: usize,
) -> bool {
if self.is_pinned(left) || self.is_pinned(right) {
return self.is_pinned(left) && self.is_pinned(right);
}
match grouping {
AgentsOverviewGrouping::Project => {
self.project_groups[left].key == self.project_groups[right].key
+18 -2
View File
@@ -376,6 +376,7 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re
]),
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
retained_thread.name = Some("New name".to_string());
@@ -396,6 +397,7 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re
threads: HashMap::from([(retained, None)]),
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
assert_eq!(app.agents_overview.threads, expected);
@@ -445,6 +447,7 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re
last_messages,
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
assert!(app.agents_overview.last_messages.is_empty());
@@ -2697,6 +2700,7 @@ async fn command_center_refresh_failure_is_inline_and_clears_on_success() -> Res
last_messages: HashMap::new(),
recent_seed_complete: false,
discovery: None,
pinned_thread_ids: None,
}),
] {
let request_id = Uuid::new_v4();
@@ -2728,6 +2732,7 @@ async fn command_center_refresh_failure_is_inline_and_clears_on_success() -> Res
last_messages: HashMap::new(),
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 48), before);
@@ -3023,7 +3028,18 @@ async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone(
ThreadStatus::Idle,
);
target.cwd = test_path_buf("/tmp/checkout/subdir").abs();
app.agents_overview.pinned_thread_ids = Some(Vec::new());
let mut view = app.agents_overview_view(vec![target.clone()], Some(id));
view.handle_key_event(KeyCode::Char('p').into());
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::ToggleAgentsOverviewPin {
thread_id,
pinned: true,
}) if thread_id == id
));
view.handle_key_event(KeyCode::Char('p').into());
assert!(rx.try_recv().is_err());
for _ in 0..3 {
view.handle_key_event(KeyCode::Char('n').into());
assert!(
@@ -3040,13 +3056,13 @@ async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone(
));
}
view.handle_key_event(KeyCode::Char('r').into());
for character in "nwogrxfha".chars() {
for character in "nwogrxfhap".chars() {
view.handle_key_event(KeyCode::Char(character).into());
}
assert!(rx.try_recv().is_err());
view.handle_key_event(KeyCode::Enter.into());
assert!(
matches!(rx.try_recv(), Ok(AppEvent::RenameAgentsOverviewThread { name, .. }) if name.ends_with("nwogrxfha"))
matches!(rx.try_recv(), Ok(AppEvent::RenameAgentsOverviewThread { name, .. }) if name.ends_with("nwogrxfhap"))
);
let mut empty = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
empty.handle_key_event(KeyCode::Char('n').into());
@@ -5,6 +5,7 @@
use super::App;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use super::agents_overview_details::preview_agent_message;
use super::agents_overview_discovery::list_pinned_threads;
use super::app_server_event_targets::ServerNotificationThreadTarget;
use super::app_server_event_targets::server_notification_thread_target;
use crate::AppServerTarget;
@@ -218,6 +219,7 @@ impl App {
}
pub(super) fn refresh_agents_overview_threads(&mut self, app_server: &AppServerSession) {
self.agents_overview.pin_refresh_requested = true;
self.agents_overview.refresh_thread_ids.extend(
self.agents_overview
.threads
@@ -266,16 +268,19 @@ impl App {
&& self.agents_overview.refresh_thread_ids.is_empty()
&& !self.agents_overview.show_more_requested
&& !(self.agents_overview.refill_count > 0 && self.agents_overview.discovery.has_more())
&& !self.agents_overview.pin_refresh_requested
{
return;
}
self.agents_overview.pin_refresh_requested = false;
let request_id = Uuid::new_v4();
self.agents_overview.request_id = Some(request_id);
let initialized = self.agents_overview.initialized;
let refill = self.agents_overview.refill_count;
let show_more =
refill == 0 && std::mem::take(&mut self.agents_overview.show_more_requested);
self.agents_overview.refresh_show_more = show_more;
let discover =
!initialized || show_more || (refill > 0 && self.agents_overview.discovery.has_more());
let limit = if initialized && refill > 0 {
@@ -292,6 +297,7 @@ impl App {
}
});
let mut thread_ids = std::mem::take(&mut self.agents_overview.refresh_thread_ids);
self.agents_overview.active_refresh_thread_ids = thread_ids.clone();
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
self.agents_overview
@@ -304,6 +310,23 @@ impl App {
let mut threads = HashMap::new();
let mut last_messages = HashMap::new();
let mut recent_seed_complete = true;
let pinned_thread_ids = match list_pinned_threads(&request_handle).await {
Ok(Some(pinned_threads)) => {
let mut ids = Vec::with_capacity(pinned_threads.len());
for thread in pinned_threads {
if let Ok(thread_id) = ThreadId::from_string(&thread.id) {
ids.push(thread_id);
threads.insert(thread_id, Some(thread));
}
}
Some(Some(ids))
}
Ok(None) => Some(None),
Err(error) => {
tracing::warn!(%error, "failed to list pinned agent threads");
None
}
};
if let Some(discovery) = &mut discovery {
let loaded = async {
if initialized {
@@ -423,6 +446,7 @@ impl App {
last_messages,
recent_seed_complete,
discovery,
pinned_thread_ids,
})
}
.await;
@@ -103,6 +103,7 @@ async fn selected_usage_is_cached_and_account_changes_discard_old_results() -> R
last_messages: HashMap::new(),
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
assert_eq!(
+24 -38
View File
@@ -12,6 +12,7 @@ use grouping::model_name;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use super::agents_overview_details::AgentsOverviewDetails;
use super::agents_overview_discovery::supports_shared_pinning;
use crate::app_event::AgentsOverviewAction;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
@@ -54,6 +55,7 @@ use ratatui::text::Span;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
@@ -181,6 +183,8 @@ pub(super) struct AgentsOverviewView {
use_theme_colors: bool,
pub(super) rows: Vec<AgentsOverviewRow>,
project_groups: Vec<AgentsOverviewProjectGroup>,
pub(super) pinned_thread_ranks: Option<HashMap<ThreadId, usize>>,
pub(super) pin_action_pending: bool,
selected: usize,
state: Arc<Mutex<AgentsOverviewViewState>>,
app_event_tx: AppEventSender,
@@ -251,6 +255,8 @@ impl AgentsOverviewView {
use_theme_colors,
rows,
project_groups,
pinned_thread_ranks: None,
pin_action_pending: false,
selected,
state,
app_event_tx,
@@ -302,44 +308,12 @@ impl AgentsOverviewView {
.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();
let (_, status_group) = command_center::TASK_FILTERS[state.status_filter];
let mut visible = self
.rows
.iter()
.enumerate()
.filter_map(|(index, row)| {
let searchable = format!(
"{} {} {}",
row.thread.name.as_deref().unwrap_or_default(),
row.thread.preview,
row.thread.cwd.display(),
)
.to_lowercase();
((search.is_empty() || searchable.contains(&search))
&& (state.rename_target == Some(row.thread_id)
|| status_group.is_none_or(|group| group == row.group)))
.then_some(index)
})
.collect::<Vec<_>>();
match state.grouping {
AgentsOverviewGrouping::Project => visible.sort_by_key(|index| {
(
&self.project_groups[*index].key,
std::cmp::Reverse(self.rows[*index].thread.updated_at),
)
}),
AgentsOverviewGrouping::Status => {}
AgentsOverviewGrouping::Model => visible.sort_by_key(|index| {
(
model_name(&self.rows[*index].thread),
std::cmp::Reverse(self.rows[*index].thread.updated_at),
)
}),
}
visible
fn can_toggle_selected_pin(&self) -> bool {
!self.pin_action_pending
&& self.pinned_thread_ranks.is_some()
&& self
.selected_row()
.is_some_and(|row| supports_shared_pinning(&row.thread.source))
}
fn move_selection(&mut self, forward: bool) {
@@ -682,6 +656,18 @@ impl BottomPaneView for AgentsOverviewView {
.send(AppEvent::PersistAgentsOverviewGrouping(state.grouping));
return;
}
if self.agents_keymap.toggle_pin.is_pressed(key) {
if self.can_toggle_selected_pin()
&& let (Some(ranks), Some(row)) = (&self.pinned_thread_ranks, self.selected_row())
{
let thread_id = row.thread_id;
let pinned = !ranks.contains_key(&thread_id);
self.pin_action_pending = true;
self.app_event_tx
.send(AppEvent::ToggleAgentsOverviewPin { thread_id, pinned });
}
return;
}
if self.agents_keymap.new_task.is_pressed(key) {
self.app_event_tx.send(AppEvent::NewAgentsOverviewSession {
cwd: self.selected_row().map(|row| row.thread.cwd.clone()),
@@ -78,6 +78,8 @@ impl App {
task.abort();
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_show_more = false;
self.agents_overview.active_refresh_thread_ids.clear();
self.agents_overview.refresh_pending = false;
self.agents_overview.initialized = false;
self.agents_overview.refresh_notifications.clear();
+14
View File
@@ -67,6 +67,7 @@ impl App {
| AppEvent::FinishPromptRevert { .. }
| AppEvent::ManagedWorktreeCreated(_)
| AppEvent::AgentsOverviewWorktreeCreated(_)
| AppEvent::AgentsOverviewPinToggled { .. }
| AppEvent::AppendMessageHistoryEntry { .. }
| AppEvent::BeginInitialHistoryReplayBuffer
| AppEvent::BeginThreadSwitchHistoryReplayBuffer
@@ -2830,6 +2831,19 @@ impl App {
}
}
}
AppEvent::ToggleAgentsOverviewPin { thread_id, pinned } => {
self.toggle_agents_overview_pin(app_server, thread_id, pinned);
}
AppEvent::AgentsOverviewPinToggled {
request_id,
thread_id,
pinned,
result,
} => {
self.complete_agents_overview_pin(
app_server, request_id, thread_id, pinned, result,
);
}
AppEvent::SuggestThreadName {
thread_id,
request_id,
+5
View File
@@ -214,8 +214,11 @@ impl App {
task.abort();
}
self.agents_overview.request_id = None;
self.agents_overview.refresh_show_more = false;
self.agents_overview.active_refresh_thread_ids.clear();
self.agents_overview.refresh_pending = false;
self.agents_overview.refresh_notifications.clear();
self.agents_overview.pending_pin_change = None;
self.agents_overview.pending_usage = None;
self.agents_overview.usage_disabled = false;
self.agents_overview.usage.clear();
@@ -357,6 +360,8 @@ impl App {
self.sync_thread_title_progress();
self.agents_overview.dispatched_requests.clear();
self.agents_overview.request_id = None;
self.agents_overview.refresh_show_more = false;
self.agents_overview.active_refresh_thread_ids.clear();
self.agents_overview.refresh_pending = false;
for input in self.agents_overview.input_states.values_mut() {
input.reconnect_pending = true;
@@ -6,15 +6,15 @@ expression: "screen(&view, 160, 22)"
All 6 Needs you 2 Working 1 Ready 2 Inactive 1 tab/⇧tab filter
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Tasks Status Updated │ Task details
/tmp/project 6 │
Pinned 2 │
› ○ Current task Ready now │ Current task
○ A longer task title that uses the available column voice Ready 5m ago │ ○ Ready
● Needs an answer Needs input 2h ago │ ○ Ready
│
/tmp/project 4 │ Model: Unknown
○ A longer task title that uses the available column voice Ready 5m ago │ Reasoning: Unknown
! Failed task Error 1h ago │
● Needs an answer Needs input 2h ago │ Model: Unknown
● Working task Working 1d ago │ Reasoning: Unknown
○ Unloaded task Inactive 2d ago │
│ Project
│ /tmp/project
● Working task Working 1d ago │ Project
○ Unloaded task Inactive 2d ago │ /tmp/project
│
│ Prompt
│ Current task
@@ -1,22 +1,21 @@
---
source: tui/src/app/agent_center_tests.rs
assertion_line: 450
expression: "screen(&view, 100, 24)"
---
Agent command center Group: Project g
All 0 Needs you 0 Working 0 Ready 0 Inactive 0 tab/⇧tab filter
All 1 Needs you 0 Working 0 Ready 1 Inactive 0 tab/⇧tab filter
────────────────────────────────────────────────────────────────────────────────────────────────
Task shortcuts
Navigate Tasks View
k Up n New tab/⇧tab Filter
j Down o Resume f3 Search
enter Open f Fork g Group
Navigate Tasks View
k Up n New tab/⇧tab Filter
j Down o Resume f3 Search
enter Open f Fork g Group
pgdn Page down z r Rename
⌃c Quit x Stop
h Hide
backspace Delete
z p Pin/unpin
@@ -284,6 +284,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
threads: HashMap::new(),
recent_seed_complete: false,
discovery: None,
pinned_thread_ids: None,
}),
);
assert_eq!(app.agents_overview.visible_thread_ids.len(), 2);
@@ -436,6 +437,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
threads: stale_threads,
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
assert!(!app.agents_overview.visible_thread_ids.contains(&vanished));
@@ -634,6 +634,7 @@ pub(super) async fn start_recording_app_server_with_realtime_speech(
},
})
} else if request.method == "thread/list"
&& params.is_some_and(|params| params["sortKey"] == "recency_at")
&& std::mem::take(&mut reject_thread_list)
{
JSONRPCMessage::Error(JSONRPCError {
@@ -3297,6 +3298,7 @@ async fn agents_overview_seeds_loaded_threads_when_recent_listing_is_unavailable
let mut sort_keys = list_requests
.iter()
.map(|params| params["sortKey"].as_str().unwrap())
.filter(|sort_key| *sort_key != "section_position")
.collect::<Vec<_>>();
sort_keys.sort_unstable();
assert_eq!(sort_keys, expected_sort_keys);
+13
View File
@@ -275,6 +275,7 @@ pub(crate) struct AgentsOverviewThreadRefresh {
pub(crate) last_messages: std::collections::HashMap<ThreadId, String>,
pub(crate) recent_seed_complete: bool,
pub(crate) discovery: Option<crate::app::agents_overview_discovery::AgentsOverviewDiscovery>,
pub(crate) pinned_thread_ids: Option<Option<Vec<ThreadId>>>,
}
#[derive(Debug, Default)]
@@ -335,6 +336,18 @@ pub(crate) enum AppEvent {
thread_id: ThreadId,
name: String,
},
/// Move a task into or out of the shared pinned section.
ToggleAgentsOverviewPin {
thread_id: ThreadId,
pinned: bool,
},
/// Finish moving a task into or out of the shared pinned section.
AgentsOverviewPinToggled {
request_id: Uuid,
thread_id: ThreadId,
pinned: bool,
result: Result<(), String>,
},
/// Generate an editable title suggestion for the active rename prompt.
SuggestThreadName {
thread_id: ThreadId,
+5
View File
@@ -432,6 +432,7 @@ pub(crate) struct AgentsKeymap {
pub(crate) archive: Vec<KeyBinding>,
pub(crate) delete: Vec<KeyBinding>,
pub(crate) hide: Vec<KeyBinding>,
pub(crate) toggle_pin: Vec<KeyBinding>,
pub(crate) toggle_grouping: Vec<KeyBinding>,
chord_hints: Arc<RuntimeChordKeymap>,
}
@@ -1361,6 +1362,7 @@ impl RuntimeKeymap {
archive: resolve_local!(keymap, defaults, agents, archive),
delete: resolve_local!(keymap, defaults, agents, delete),
hide: resolve_local!(keymap, defaults, agents, hide),
toggle_pin: resolve_local!(keymap, defaults, agents, toggle_pin),
toggle_grouping: resolve_local!(keymap, defaults, agents, toggle_grouping),
chord_hints: Arc::clone(&chords),
};
@@ -1380,6 +1382,7 @@ impl RuntimeKeymap {
(keymap.agents.archive.as_ref(), &mut agents.archive),
(keymap.agents.delete.as_ref(), &mut agents.delete),
(keymap.agents.hide.as_ref(), &mut agents.hide),
(keymap.agents.toggle_pin.as_ref(), &mut agents.toggle_pin),
(
keymap.agents.toggle_grouping.as_ref(),
&mut agents.toggle_grouping,
@@ -1546,6 +1549,7 @@ impl RuntimeKeymap {
(keymap.agents.archive.as_ref(), &mut agents.archive),
(keymap.agents.delete.as_ref(), &mut agents.delete),
(keymap.agents.hide.as_ref(), &mut agents.hide),
(keymap.agents.toggle_pin.as_ref(), &mut agents.toggle_pin),
(
keymap.agents.toggle_grouping.as_ref(),
&mut agents.toggle_grouping,
@@ -1920,6 +1924,7 @@ impl RuntimeKeymap {
archive: default_bindings![plain(KeyCode::Char('a'))],
delete: default_bindings![plain(KeyCode::Backspace)],
hide: default_bindings![plain(KeyCode::Char('h'))],
toggle_pin: default_bindings![plain(KeyCode::Char('p'))],
toggle_grouping: default_bindings![plain(KeyCode::Char('g'))],
chord_hints: Arc::default(),
},
+1
View File
@@ -428,6 +428,7 @@ define_runtime_action_bindings! {
archive,
delete,
hide,
toggle_pin,
toggle_grouping,
],
"approval" => Approval, approval, approval [