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>, pub delete: Option<KeybindingsSpec>,
/// Hide the selected task until explicitly resumed or the TUI restarts. /// Hide the selected task until explicitly resumed or the TUI restarts.
pub hide: Option<KeybindingsSpec>, 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. /// Toggle grouping tasks by status or project.
pub toggle_grouping: Option<KeybindingsSpec>, pub toggle_grouping: Option<KeybindingsSpec>,
} }
+12 -2
View File
@@ -4615,7 +4615,8 @@
"resume": null, "resume": null,
"search": null, "search": null,
"stop": null, "stop": null,
"toggle_grouping": null "toggle_grouping": null,
"toggle_pin": null
}, },
"approval": { "approval": {
"approve": null, "approve": null,
@@ -5040,6 +5041,14 @@
} }
], ],
"description": "Toggle grouping tasks by status or project." "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" "type": "object"
@@ -5569,7 +5578,8 @@
"resume": null, "resume": null,
"search": null, "search": null,
"stop": null, "stop": null,
"toggle_grouping": null "toggle_grouping": null,
"toggle_pin": null
} }
}, },
"approval": { "approval": {
+5 -1
View File
@@ -35,6 +35,7 @@ impl AgentsOverviewView {
&self.agents_keymap.archive, &self.agents_keymap.archive,
&self.agents_keymap.delete, &self.agents_keymap.delete,
&self.agents_keymap.hide, &self.agents_keymap.hide,
&self.agents_keymap.toggle_pin,
&self.agents_keymap.toggle_grouping, &self.agents_keymap.toggle_grouping,
].into_iter().any(|bindings| bindings.contains(binding))) ].into_iter().any(|bindings| bindings.contains(binding)))
}) })
@@ -89,8 +90,11 @@ impl AgentsOverviewView {
("archive", &self.agents_keymap.archive, "Archive"), ("archive", &self.agents_keymap.archive, "Archive"),
("hide", &self.agents_keymap.hide, "Hide"), ("hide", &self.agents_keymap.hide, "Hide"),
("delete", &self.agents_keymap.delete, "Delete"), ("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); 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 task = &self.rows[index];
let rect = row(viewport, offset as u16, /*height*/ 1); let rect = row(viewport, offset as u16, /*height*/ 1);
if matches!(entry, CenterRow::Group(_)) { if matches!(entry, CenterRow::Group(_)) {
let group = match state.grouping { let group = if self.is_pinned(index) {
AgentsOverviewGrouping::Project => { "Pinned".to_owned()
format_directory_display( } else {
&self.project_groups[index].heading, match state.grouping {
/*max_width*/ None, 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 let count = indices
.iter() .iter()
@@ -164,7 +168,9 @@ impl AgentsOverviewView {
} else { } else {
format!("{count} of {total}") 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( crate::text_formatting::center_truncate_path(
&group, &group,
usize::from(rect.width) usize::from(rect.width)
+15 -1
View File
@@ -75,8 +75,10 @@ async fn live_center_columns() {
ThreadStatus::Idle, ThreadStatus::Idle,
)); ));
crate::chatwidget::activate_voice_for_thread(&mut app.chat_widget, child); 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)); let mut view = app.agents_overview_view(threads, Some(current));
insta::assert_snapshot!(screen(&view, /*width*/ 160, /*height*/ 22)); insta::assert_snapshot!(screen(&view, /*width*/ 160, /*height*/ 22));
view.pinned_thread_ranks = None;
let mut selected_status_styles = Vec::new(); let mut selected_status_styles = Vec::new();
for _ in 0..fixtures.len() { for _ in 0..fixtures.len() {
let area = Rect::new( let area = Rect::new(
@@ -466,15 +468,27 @@ page_up = []
[agents] [agents]
archive = [] archive = []
rename = 'z r' rename = 'z r'
toggle_pin = 'z p'
"#, "#,
) )
.unwrap(); .unwrap();
app.keymap = RuntimeKeymap::from_config(&config).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!( insta::assert_snapshot!(
"live_center_custom_footer", "live_center_custom_footer",
screen(&view, /*width*/ 100, /*height*/ 12) 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()); view.handle_key_event(KeyCode::Char('?').into());
insta::assert_snapshot!( insta::assert_snapshot!(
"live_center_custom_help", "live_center_custom_help",
+21 -2
View File
@@ -49,11 +49,16 @@ pub(super) struct AgentsOverviewState {
pub(super) initialized: bool, pub(super) initialized: bool,
pub(super) discovery: super::agents_overview_discovery::AgentsOverviewDiscovery, pub(super) discovery: super::agents_overview_discovery::AgentsOverviewDiscovery,
pub(super) show_more_requested: bool, pub(super) show_more_requested: bool,
pub(super) refresh_show_more: bool,
/// Vacancies left by lifecycle removals, filled without expanding the visible window. /// Vacancies left by lifecycle removals, filled without expanding the visible window.
pub(super) refill_count: usize, pub(super) refill_count: usize,
pub(super) request_id: Option<Uuid>, pub(super) request_id: Option<Uuid>,
pub(super) refresh_pending: bool, pub(super) refresh_pending: bool,
pub(super) pin_refresh_requested: bool,
pub(super) refresh_thread_ids: HashSet<ThreadId>, 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_task: Option<tokio::task::AbortHandle>,
pub(super) refresh_notifications: HashMap<ThreadId, Vec<ServerNotification>>, pub(super) refresh_notifications: HashMap<ThreadId, Vec<ServerNotification>>,
pub(super) rendered_full_screen: bool, pub(super) rendered_full_screen: bool,
@@ -170,6 +175,8 @@ impl App {
return; return;
} }
self.agents_overview.request_id = None; 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; self.agents_overview.refresh_task = None;
let refill_succeeded = result let refill_succeeded = result
.as_ref() .as_ref()
@@ -185,6 +192,9 @@ impl App {
} }
match result { match result {
Ok(refresh) => { 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; self.agents_overview.initialized = refresh.recent_seed_complete;
if let Some(discovery) = refresh.discovery { if let Some(discovery) = refresh.discovery {
if !discovery.has_more() { if !discovery.has_more() {
@@ -380,7 +390,7 @@ impl App {
.lock() .lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) .unwrap_or_else(std::sync::PoisonError::into_inner)
.vim_enabled = self.chat_widget.composer_is_vim_enabled(); .vim_enabled = self.chat_widget.composer_is_vim_enabled();
AgentsOverviewView::new( let mut view = AgentsOverviewView::new(
rows, rows,
selected_thread_id, selected_thread_id,
self.config.features.enabled(Feature::Worktrees) self.config.features.enabled(Feature::Worktrees)
@@ -392,7 +402,16 @@ impl App {
self.app_event_tx.clone(), self.app_event_tx.clone(),
self.keymap.clone(), self.keymap.clone(),
Arc::clone(&self.agents_overview.view_state), 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( pub(super) async fn select_agents_overview_thread(
@@ -12,10 +12,15 @@ use crate::chatwidget::ChatWidget;
use crate::render::renderable::Renderable; use crate::render::renderable::Renderable;
use crate::tui; use crate::tui;
use crate::wrapping::word_wrap_lines; 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::SessionSource;
use codex_app_server_protocol::ThreadLoadedListParams; 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::ThreadId;
use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::SubAgentSource;
use codex_state::PINNED_THREAD_SECTION_ID;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Stylize; use ratatui::style::Stylize;
@@ -25,6 +30,7 @@ use std::collections::HashSet;
use std::future::Future; use std::future::Future;
use tokio_stream::Stream; use tokio_stream::Stream;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use uuid::Uuid;
struct LifecycleHeader(Vec<Line<'static>>); struct LifecycleHeader(Vec<Line<'static>>);
@@ -104,6 +110,90 @@ async fn run_lifecycle_modal<T>(
} }
impl App { 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( pub(super) fn confirm_agents_overview_action(
&mut self, &mut self,
thread_id: ThreadId, thread_id: ThreadId,
@@ -227,6 +317,8 @@ impl App {
refresh.abort(); refresh.abort();
} }
self.agents_overview.request_id = None; self.agents_overview.request_id = None;
self.agents_overview.refresh_show_more = false;
self.agents_overview.active_refresh_thread_ids.clear();
self.agents_overview self.agents_overview
.view_state .view_state
.lock() .lock()
@@ -1,4 +1,5 @@
use super::*; use super::*;
use crate::app::agents_overview_discovery::list_pinned_threads;
use crate::app_event::AgentsOverviewAction; use crate::app_event::AgentsOverviewAction;
use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId; 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 core_test_support::streaming_sse::start_streaming_sse_server;
use pretty_assertions::assert_eq; 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] #[tokio::test]
async fn archive_confirmation_number_keys_act_immediately() { async fn archive_confirmation_number_keys_act_immediately() {
for key in ['1', '2'] { 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(), last_messages: HashMap::new(),
recent_seed_complete: true, recent_seed_complete: true,
discovery: None, discovery: None,
pinned_thread_ids: None,
}), }),
); );
assert_eq!( assert_eq!(
@@ -865,6 +912,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb
last_messages: HashMap::new(), last_messages: HashMap::new(),
recent_seed_complete: true, recent_seed_complete: true,
discovery: None, discovery: None,
pinned_thread_ids: None,
}), }),
); );
assert_eq!( assert_eq!(
@@ -967,6 +1015,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb
last_messages: HashMap::new(), last_messages: HashMap::new(),
recent_seed_complete: true, recent_seed_complete: true,
discovery: None, discovery: None,
pinned_thread_ids: None,
}), }),
}); });
Box::pin(app.handle_event(&mut tui, &mut app_server, confirmed)).await?; 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::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadSourceKind;
use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::SubAgentSource;
use codex_state::PINNED_THREAD_SECTION_ID;
use std::collections::VecDeque; use std::collections::VecDeque;
use uuid::Uuid; use uuid::Uuid;
const PAGE_SIZE: u32 = 10; 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)] #[derive(Clone, Debug)]
struct SourcePage { struct SourcePage {
cursor: Option<String>, cursor: Option<String>,
@@ -128,14 +199,8 @@ impl SourcePage {
Ok(page) => { Ok(page) => {
self.exhausted = page.next_cursor.is_none(); self.exhausted = page.next_cursor.is_none();
self.cursor = page.next_cursor; self.cursor = page.next_cursor;
self.rows.extend(page.data.into_iter().filter(|thread| { self.rows
!thread.ephemeral .extend(page.data.into_iter().filter(is_overview_thread));
&& thread.parent_thread_id.is_none()
&& !matches!(
thread.source,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
)
}));
return true; return true;
} }
Err(error) => { Err(error) => {
@@ -7,6 +7,9 @@ use futures::SinkExt;
use futures::StreamExt; use futures::StreamExt;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use serde_json::json; use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
@@ -47,12 +50,21 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
) )
}) })
.collect(); .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] let expected_first_twenty: HashSet<_> = rows[1..21]
.iter() .iter()
.map(|row| ThreadId::from_string(&row.id).unwrap()) .map(|row| ThreadId::from_string(&row.id).unwrap())
.collect(); .collect();
let listener = TcpListener::bind("127.0.0.1:0").await?; let listener = TcpListener::bind("127.0.0.1:0").await?;
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?; 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 server = tokio::spawn(async move {
let (stream, _) = listener.accept().await?; let (stream, _) = listener.accept().await?;
let mut socket = tokio_tungstenite::accept_async(stream).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"}), "initialize" => json!({"userAgent": "overview-test/1.0"}),
"thread/loaded/list" => json!({"data": [], "nextCursor": null}), "thread/loaded/list" => json!({"data": [], "nextCursor": null}),
"thread/list" => { "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["sortKey"], "recency_at");
assert_eq!(params["limit"], 10); assert_eq!(params["limit"], 10);
{ {
@@ -113,6 +153,7 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
app.refresh_agents_overview_threads(&session); app.refresh_agents_overview_threads(&session);
finish_overview_refresh(&mut app, &session, &mut rx).await; finish_overview_refresh(&mut app, &session, &mut rx).await;
assert_eq!(app.agents_overview.threads.len(), 10); 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()); assert!(app.agents_overview.discovery.has_more());
app.track_agents_overview_notification(&ServerNotification::ThreadArchived( app.track_agents_overview_notification(&ServerNotification::ThreadArchived(
ThreadArchivedNotification { ThreadArchivedNotification {
@@ -162,6 +203,17 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
finish_overview_refresh(&mut app, &session, &mut rx).await; finish_overview_refresh(&mut app, &session, &mut rx).await;
assert_eq!(app.agents_overview.threads, expected); assert_eq!(app.agents_overview.threads, expected);
assert!(!app.agents_overview.discovery.has_more()); 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?; session.shutdown().await?;
assert_eq!( assert_eq!(
server.await??, server.await??,
@@ -18,12 +18,65 @@ pub(super) fn model_name(thread: &Thread) -> &str {
} }
impl AgentsOverviewView { 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( pub(super) fn same_group(
&self, &self,
grouping: AgentsOverviewGrouping, grouping: AgentsOverviewGrouping,
left: usize, left: usize,
right: usize, right: usize,
) -> bool { ) -> bool {
if self.is_pinned(left) || self.is_pinned(right) {
return self.is_pinned(left) && self.is_pinned(right);
}
match grouping { match grouping {
AgentsOverviewGrouping::Project => { AgentsOverviewGrouping::Project => {
self.project_groups[left].key == self.project_groups[right].key 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, recent_seed_complete: true,
discovery: None, discovery: None,
pinned_thread_ids: None,
}), }),
); );
retained_thread.name = Some("New name".to_string()); 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)]), threads: HashMap::from([(retained, None)]),
recent_seed_complete: true, recent_seed_complete: true,
discovery: None, discovery: None,
pinned_thread_ids: None,
}), }),
); );
assert_eq!(app.agents_overview.threads, expected); 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, last_messages,
recent_seed_complete: true, recent_seed_complete: true,
discovery: None, discovery: None,
pinned_thread_ids: None,
}), }),
); );
assert!(app.agents_overview.last_messages.is_empty()); 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(), last_messages: HashMap::new(),
recent_seed_complete: false, recent_seed_complete: false,
discovery: None, discovery: None,
pinned_thread_ids: None,
}), }),
] { ] {
let request_id = Uuid::new_v4(); 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(), last_messages: HashMap::new(),
recent_seed_complete: true, recent_seed_complete: true,
discovery: None, discovery: None,
pinned_thread_ids: None,
}), }),
); );
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 48), before); 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, ThreadStatus::Idle,
); );
target.cwd = test_path_buf("/tmp/checkout/subdir").abs(); 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)); 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 { for _ in 0..3 {
view.handle_key_event(KeyCode::Char('n').into()); view.handle_key_event(KeyCode::Char('n').into());
assert!( 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()); 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()); view.handle_key_event(KeyCode::Char(character).into());
} }
assert!(rx.try_recv().is_err()); assert!(rx.try_recv().is_err());
view.handle_key_event(KeyCode::Enter.into()); view.handle_key_event(KeyCode::Enter.into());
assert!( 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); let mut empty = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
empty.handle_key_event(KeyCode::Char('n').into()); empty.handle_key_event(KeyCode::Char('n').into());
@@ -5,6 +5,7 @@
use super::App; use super::App;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID; use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use super::agents_overview_details::preview_agent_message; 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::ServerNotificationThreadTarget;
use super::app_server_event_targets::server_notification_thread_target; use super::app_server_event_targets::server_notification_thread_target;
use crate::AppServerTarget; use crate::AppServerTarget;
@@ -218,6 +219,7 @@ impl App {
} }
pub(super) fn refresh_agents_overview_threads(&mut self, app_server: &AppServerSession) { 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.refresh_thread_ids.extend(
self.agents_overview self.agents_overview
.threads .threads
@@ -266,16 +268,19 @@ impl App {
&& self.agents_overview.refresh_thread_ids.is_empty() && self.agents_overview.refresh_thread_ids.is_empty()
&& !self.agents_overview.show_more_requested && !self.agents_overview.show_more_requested
&& !(self.agents_overview.refill_count > 0 && self.agents_overview.discovery.has_more()) && !(self.agents_overview.refill_count > 0 && self.agents_overview.discovery.has_more())
&& !self.agents_overview.pin_refresh_requested
{ {
return; return;
} }
self.agents_overview.pin_refresh_requested = false;
let request_id = Uuid::new_v4(); let request_id = Uuid::new_v4();
self.agents_overview.request_id = Some(request_id); self.agents_overview.request_id = Some(request_id);
let initialized = self.agents_overview.initialized; let initialized = self.agents_overview.initialized;
let refill = self.agents_overview.refill_count; let refill = self.agents_overview.refill_count;
let show_more = let show_more =
refill == 0 && std::mem::take(&mut self.agents_overview.show_more_requested); refill == 0 && std::mem::take(&mut self.agents_overview.show_more_requested);
self.agents_overview.refresh_show_more = show_more;
let discover = let discover =
!initialized || show_more || (refill > 0 && self.agents_overview.discovery.has_more()); !initialized || show_more || (refill > 0 && self.agents_overview.discovery.has_more());
let limit = if initialized && refill > 0 { 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); 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 request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone(); let app_event_tx = self.app_event_tx.clone();
self.agents_overview self.agents_overview
@@ -304,6 +310,23 @@ impl App {
let mut threads = HashMap::new(); let mut threads = HashMap::new();
let mut last_messages = HashMap::new(); let mut last_messages = HashMap::new();
let mut recent_seed_complete = true; 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 { if let Some(discovery) = &mut discovery {
let loaded = async { let loaded = async {
if initialized { if initialized {
@@ -423,6 +446,7 @@ impl App {
last_messages, last_messages,
recent_seed_complete, recent_seed_complete,
discovery, discovery,
pinned_thread_ids,
}) })
} }
.await; .await;
@@ -103,6 +103,7 @@ async fn selected_usage_is_cached_and_account_changes_discard_old_results() -> R
last_messages: HashMap::new(), last_messages: HashMap::new(),
recent_seed_complete: true, recent_seed_complete: true,
discovery: None, discovery: None,
pinned_thread_ids: None,
}), }),
); );
assert_eq!( 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::AGENTS_OVERVIEW_VIEW_ID;
use super::agents_overview_details::AgentsOverviewDetails; use super::agents_overview_details::AgentsOverviewDetails;
use super::agents_overview_discovery::supports_shared_pinning;
use crate::app_event::AgentsOverviewAction; use crate::app_event::AgentsOverviewAction;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
@@ -54,6 +55,7 @@ use ratatui::text::Span;
use ratatui::widgets::Clear; use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget; use ratatui::widgets::Widget;
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
@@ -181,6 +183,8 @@ pub(super) struct AgentsOverviewView {
use_theme_colors: bool, use_theme_colors: bool,
pub(super) rows: Vec<AgentsOverviewRow>, pub(super) rows: Vec<AgentsOverviewRow>,
project_groups: Vec<AgentsOverviewProjectGroup>, project_groups: Vec<AgentsOverviewProjectGroup>,
pub(super) pinned_thread_ranks: Option<HashMap<ThreadId, usize>>,
pub(super) pin_action_pending: bool,
selected: usize, selected: usize,
state: Arc<Mutex<AgentsOverviewViewState>>, state: Arc<Mutex<AgentsOverviewViewState>>,
app_event_tx: AppEventSender, app_event_tx: AppEventSender,
@@ -251,6 +255,8 @@ impl AgentsOverviewView {
use_theme_colors, use_theme_colors,
rows, rows,
project_groups, project_groups,
pinned_thread_ranks: None,
pin_action_pending: false,
selected, selected,
state, state,
app_event_tx, app_event_tx,
@@ -302,44 +308,12 @@ impl AgentsOverviewView {
.filter(|_| self.visible_indices().contains(&self.selected)) .filter(|_| self.visible_indices().contains(&self.selected))
} }
fn visible_indices(&self) -> Vec<usize> { fn can_toggle_selected_pin(&self) -> bool {
let state = self.state.lock().unwrap_or_else(PoisonError::into_inner); !self.pin_action_pending
let search = state.search.to_lowercase(); && self.pinned_thread_ranks.is_some()
let (_, status_group) = command_center::TASK_FILTERS[state.status_filter]; && self
let mut visible = self .selected_row()
.rows .is_some_and(|row| supports_shared_pinning(&row.thread.source))
.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 move_selection(&mut self, forward: bool) { fn move_selection(&mut self, forward: bool) {
@@ -682,6 +656,18 @@ impl BottomPaneView for AgentsOverviewView {
.send(AppEvent::PersistAgentsOverviewGrouping(state.grouping)); .send(AppEvent::PersistAgentsOverviewGrouping(state.grouping));
return; 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) { if self.agents_keymap.new_task.is_pressed(key) {
self.app_event_tx.send(AppEvent::NewAgentsOverviewSession { self.app_event_tx.send(AppEvent::NewAgentsOverviewSession {
cwd: self.selected_row().map(|row| row.thread.cwd.clone()), cwd: self.selected_row().map(|row| row.thread.cwd.clone()),
@@ -78,6 +78,8 @@ impl App {
task.abort(); task.abort();
} }
self.agents_overview.request_id = None; 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_pending = false;
self.agents_overview.initialized = false; self.agents_overview.initialized = false;
self.agents_overview.refresh_notifications.clear(); self.agents_overview.refresh_notifications.clear();
+14
View File
@@ -67,6 +67,7 @@ impl App {
| AppEvent::FinishPromptRevert { .. } | AppEvent::FinishPromptRevert { .. }
| AppEvent::ManagedWorktreeCreated(_) | AppEvent::ManagedWorktreeCreated(_)
| AppEvent::AgentsOverviewWorktreeCreated(_) | AppEvent::AgentsOverviewWorktreeCreated(_)
| AppEvent::AgentsOverviewPinToggled { .. }
| AppEvent::AppendMessageHistoryEntry { .. } | AppEvent::AppendMessageHistoryEntry { .. }
| AppEvent::BeginInitialHistoryReplayBuffer | AppEvent::BeginInitialHistoryReplayBuffer
| AppEvent::BeginThreadSwitchHistoryReplayBuffer | 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 { AppEvent::SuggestThreadName {
thread_id, thread_id,
request_id, request_id,
+5
View File
@@ -214,8 +214,11 @@ impl App {
task.abort(); task.abort();
} }
self.agents_overview.request_id = None; 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_pending = false;
self.agents_overview.refresh_notifications.clear(); self.agents_overview.refresh_notifications.clear();
self.agents_overview.pending_pin_change = None;
self.agents_overview.pending_usage = None; self.agents_overview.pending_usage = None;
self.agents_overview.usage_disabled = false; self.agents_overview.usage_disabled = false;
self.agents_overview.usage.clear(); self.agents_overview.usage.clear();
@@ -357,6 +360,8 @@ impl App {
self.sync_thread_title_progress(); self.sync_thread_title_progress();
self.agents_overview.dispatched_requests.clear(); self.agents_overview.dispatched_requests.clear();
self.agents_overview.request_id = None; 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_pending = false;
for input in self.agents_overview.input_states.values_mut() { for input in self.agents_overview.input_states.values_mut() {
input.reconnect_pending = true; 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 All 6 Needs you 2 Working 1 Ready 2 Inactive 1 tab/⇧tab filter
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Tasks Status Updated │ Task details Tasks Status Updated │ Task details
/tmp/project 6 │ Pinned 2 │
› ○ Current task Ready now │ Current task › ○ 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 │ ! Failed task Error 1h ago │
● Needs an answer Needs input 2h ago │ Model: Unknown ● Working task Working 1d ago │ Project
● Working task Working 1d ago │ Reasoning: Unknown ○ Unloaded task Inactive 2d ago │ /tmp/project
○ Unloaded task Inactive 2d ago │
│ Project
│ /tmp/project
│ │
│ Prompt │ Prompt
│ Current task │ Current task
@@ -1,22 +1,21 @@
--- ---
source: tui/src/app/agent_center_tests.rs source: tui/src/app/agent_center_tests.rs
assertion_line: 450
expression: "screen(&view, 100, 24)" expression: "screen(&view, 100, 24)"
--- ---
Agent command center Group: Project g 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 Task shortcuts
Navigate Tasks View Navigate Tasks View
k Up n New tab/⇧tab Filter k Up n New tab/⇧tab Filter
j Down o Resume f3 Search j Down o Resume f3 Search
enter Open f Fork g Group enter Open f Fork g Group
pgdn Page down z r Rename pgdn Page down z r Rename
⌃c Quit x Stop ⌃c Quit x Stop
h Hide h Hide
backspace Delete 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(), threads: HashMap::new(),
recent_seed_complete: false, recent_seed_complete: false,
discovery: None, discovery: None,
pinned_thread_ids: None,
}), }),
); );
assert_eq!(app.agents_overview.visible_thread_ids.len(), 2); 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, threads: stale_threads,
recent_seed_complete: true, recent_seed_complete: true,
discovery: None, discovery: None,
pinned_thread_ids: None,
}), }),
); );
assert!(!app.agents_overview.visible_thread_ids.contains(&vanished)); 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" } else if request.method == "thread/list"
&& params.is_some_and(|params| params["sortKey"] == "recency_at")
&& std::mem::take(&mut reject_thread_list) && std::mem::take(&mut reject_thread_list)
{ {
JSONRPCMessage::Error(JSONRPCError { 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 let mut sort_keys = list_requests
.iter() .iter()
.map(|params| params["sortKey"].as_str().unwrap()) .map(|params| params["sortKey"].as_str().unwrap())
.filter(|sort_key| *sort_key != "section_position")
.collect::<Vec<_>>(); .collect::<Vec<_>>();
sort_keys.sort_unstable(); sort_keys.sort_unstable();
assert_eq!(sort_keys, expected_sort_keys); 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) last_messages: std::collections::HashMap<ThreadId, String>,
pub(crate) recent_seed_complete: bool, pub(crate) recent_seed_complete: bool,
pub(crate) discovery: Option<crate::app::agents_overview_discovery::AgentsOverviewDiscovery>, pub(crate) discovery: Option<crate::app::agents_overview_discovery::AgentsOverviewDiscovery>,
pub(crate) pinned_thread_ids: Option<Option<Vec<ThreadId>>>,
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -335,6 +336,18 @@ pub(crate) enum AppEvent {
thread_id: ThreadId, thread_id: ThreadId,
name: String, 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. /// Generate an editable title suggestion for the active rename prompt.
SuggestThreadName { SuggestThreadName {
thread_id: ThreadId, thread_id: ThreadId,
+5
View File
@@ -432,6 +432,7 @@ pub(crate) struct AgentsKeymap {
pub(crate) archive: Vec<KeyBinding>, pub(crate) archive: Vec<KeyBinding>,
pub(crate) delete: Vec<KeyBinding>, pub(crate) delete: Vec<KeyBinding>,
pub(crate) hide: Vec<KeyBinding>, pub(crate) hide: Vec<KeyBinding>,
pub(crate) toggle_pin: Vec<KeyBinding>,
pub(crate) toggle_grouping: Vec<KeyBinding>, pub(crate) toggle_grouping: Vec<KeyBinding>,
chord_hints: Arc<RuntimeChordKeymap>, chord_hints: Arc<RuntimeChordKeymap>,
} }
@@ -1361,6 +1362,7 @@ impl RuntimeKeymap {
archive: resolve_local!(keymap, defaults, agents, archive), archive: resolve_local!(keymap, defaults, agents, archive),
delete: resolve_local!(keymap, defaults, agents, delete), delete: resolve_local!(keymap, defaults, agents, delete),
hide: resolve_local!(keymap, defaults, agents, hide), 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), toggle_grouping: resolve_local!(keymap, defaults, agents, toggle_grouping),
chord_hints: Arc::clone(&chords), chord_hints: Arc::clone(&chords),
}; };
@@ -1380,6 +1382,7 @@ impl RuntimeKeymap {
(keymap.agents.archive.as_ref(), &mut agents.archive), (keymap.agents.archive.as_ref(), &mut agents.archive),
(keymap.agents.delete.as_ref(), &mut agents.delete), (keymap.agents.delete.as_ref(), &mut agents.delete),
(keymap.agents.hide.as_ref(), &mut agents.hide), (keymap.agents.hide.as_ref(), &mut agents.hide),
(keymap.agents.toggle_pin.as_ref(), &mut agents.toggle_pin),
( (
keymap.agents.toggle_grouping.as_ref(), keymap.agents.toggle_grouping.as_ref(),
&mut agents.toggle_grouping, &mut agents.toggle_grouping,
@@ -1546,6 +1549,7 @@ impl RuntimeKeymap {
(keymap.agents.archive.as_ref(), &mut agents.archive), (keymap.agents.archive.as_ref(), &mut agents.archive),
(keymap.agents.delete.as_ref(), &mut agents.delete), (keymap.agents.delete.as_ref(), &mut agents.delete),
(keymap.agents.hide.as_ref(), &mut agents.hide), (keymap.agents.hide.as_ref(), &mut agents.hide),
(keymap.agents.toggle_pin.as_ref(), &mut agents.toggle_pin),
( (
keymap.agents.toggle_grouping.as_ref(), keymap.agents.toggle_grouping.as_ref(),
&mut agents.toggle_grouping, &mut agents.toggle_grouping,
@@ -1920,6 +1924,7 @@ impl RuntimeKeymap {
archive: default_bindings![plain(KeyCode::Char('a'))], archive: default_bindings![plain(KeyCode::Char('a'))],
delete: default_bindings![plain(KeyCode::Backspace)], delete: default_bindings![plain(KeyCode::Backspace)],
hide: default_bindings![plain(KeyCode::Char('h'))], hide: default_bindings![plain(KeyCode::Char('h'))],
toggle_pin: default_bindings![plain(KeyCode::Char('p'))],
toggle_grouping: default_bindings![plain(KeyCode::Char('g'))], toggle_grouping: default_bindings![plain(KeyCode::Char('g'))],
chord_hints: Arc::default(), chord_hints: Arc::default(),
}, },
+1
View File
@@ -428,6 +428,7 @@ define_runtime_action_bindings! {
archive, archive,
delete, delete,
hide, hide,
toggle_pin,
toggle_grouping, toggle_grouping,
], ],
"approval" => Approval, approval, approval [ "approval" => Approval, approval, approval [