Search task history from the command center (#60286)

## Why

Command center search only filtered tasks already loaded for browsing, leaving older tasks undiscoverable without manually loading more history.

## What changed

- Scan history on the active backend for case-insensitive matches in task names, previews, and working directories, using a separate paging cursor for each query.
- Add loading, retry, and `Show more matches` states. Cancel superseded searches and ignore stale results.
- Keep history matches separate from browse results and restore the previous selection when leaving search.
- Preserve the original task preview for search while displaying the latest prompt in task details.

## Testing

Extend discovery and command center tests to cover older history matches, fresh queries after browse pagination is exhausted, selection restoration, actions on search results, loading and retry states, and separate original previews and latest prompts.

GitOrigin-RevId: 9864ee006a14bd232e9cfd7ab2ee099c84667597
This commit is contained in:
Eric Traut
2026-11-08 19:35:45 +00:00
committed by copyberry
parent ee51bb132e
commit 4e4767ffa7
22 changed files with 465 additions and 59 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ pub(super) const TASK_FILTERS: &[(&str, Option<AgentsOverviewGroup>)] = &[
impl AgentsOverviewView {
pub(in crate::app::agents_overview_view) fn selectable_indices(&self) -> Vec<usize> {
let mut indices = self.visible_indices();
if self.state().has_more {
if self.state().more_available() {
indices.push(usize::MAX);
}
indices
+4 -1
View File
@@ -154,12 +154,14 @@ impl Renderable for AgentsOverviewView {
inset(layout.header),
buf,
);
let history = state.history.as_ref();
let labels = TASK_FILTERS
.iter()
.map(|(label, group)| {
let count = self
.rows
.iter()
.filter(|row| state.includes_row(row))
.filter(|row| group.is_none_or(|group| group == row.group))
.count();
format!("{label} {count}")
@@ -229,7 +231,8 @@ impl Renderable for AgentsOverviewView {
})
})
.or_else(|| {
(state.refresh_failed && !state.loading).then(|| "Error loading tasks".to_owned())
let failed = state.refresh_failed || history.is_some_and(|s| s.failed);
(failed && !state.loading).then(|| "Error loading tasks".to_owned())
})
.or_else(|| state.server_version_notice.clone());
if let Some(notice) = notice.filter(|_| !state.help && state.key_chord_hint.is_none()) {
+12 -1
View File
@@ -50,6 +50,8 @@ impl AgentsOverviewView {
line(
if state.connection_notice.is_some() {
"Reconnecting…"
} else if state.history.is_some() {
"No matching tasks"
} else if state.loading && self.rows.is_empty() {
"Loading tasks…"
} else if state.refresh_failed {
@@ -121,7 +123,15 @@ impl AgentsOverviewView {
},
style,
);
let label = if state.loading {
let label = if let Some(search) = &state.history {
if search.loading {
"Searching history…"
} else if search.failed {
"Show more (retry)"
} else {
"Show more matches"
}
} else if state.loading {
"Loading more…"
} else if state.refresh_failed {
"Show more (retry)"
@@ -160,6 +170,7 @@ impl AgentsOverviewView {
})
.count();
let total = (0..self.rows.len())
.filter(|&i| state.includes_row(&self.rows[i]))
.filter(|&candidate| self.same_group(state.grouping, candidate, index))
.count();
let count = if count == total {
+10 -2
View File
@@ -4,7 +4,7 @@ use super::*;
use assert_matches::assert_matches;
use pretty_assertions::assert_eq;
fn screen(view: &AgentsOverviewView, width: u16, height: u16) -> String {
pub(super) fn screen(view: &AgentsOverviewView, width: u16, height: u16) -> String {
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
let mut buf = ratatui::buffer::Buffer::empty(area);
view.render(area, &mut buf);
@@ -819,6 +819,9 @@ async fn backspace_edits_search_and_rename_without_deleting_tasks() {
view.handle_paste("!".into());
view.handle_key_event(KeyCode::Backspace.into());
assert_eq!(screen(&view, /*width*/ 88, /*height*/ 16), initial);
if key == '/' {
assert_matches!(rx.try_recv(), Ok(AppEvent::SearchAgentsOverview(..)));
}
assert!(rx.try_recv().is_err());
view.handle_key_event(KeyCode::Esc.into());
}
@@ -895,10 +898,15 @@ async fn live_center_show_more_navigation_search_and_loading() {
}
view.handle_key_event(KeyCode::Char('/').into());
view.handle_paste("Older task".into());
let id = assert_matches!(rx.try_recv(), Ok(AppEvent::SearchAgentsOverview(id, _)) => id);
insta::assert_snapshot!(screen(&view, /*width*/ 80, /*height*/ 12).lines().nth(6).unwrap(), @" › Searching history…");
app.apply_agents_overview_search_page(id, Vec::new(), Default::default(), /*ok*/ false);
insta::assert_snapshot!(
"live_center_show_more_search_retry",
screen(&view, /*width*/ 80, /*height*/ 12)
);
view.handle_key_event(KeyCode::Enter.into());
assert_matches!(rx.try_recv(), Ok(AppEvent::ShowMoreAgentsOverview));
let id = assert_matches!(rx.try_recv(), Ok(AppEvent::SearchAgentsOverview(id, _)) => id);
app.apply_agents_overview_search_page(id, Vec::new(), Default::default(), /*ok*/ true);
insta::assert_snapshot!(screen(&view, /*width*/ 80, /*height*/ 12).lines().nth(6).unwrap(), @" › Show more matches");
}
+59 -6
View File
@@ -38,6 +38,9 @@ pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview";
pub(super) struct AgentsOverviewState {
/// Missing metadata records a local resume until the next metadata refresh.
pub(super) threads: HashMap<ThreadId, Option<Thread>>,
/// Identifies the query that populated the cached search results.
pub(super) search_id: Uuid,
pub(super) search_matches: HashMap<ThreadId, Thread>,
/// Lifecycle removals take precedence over delayed tool registration responses.
pub(super) removed_threads: HashSet<ThreadId>,
/// Local visibility only; activity and metadata refreshes never reveal hidden roots.
@@ -173,7 +176,8 @@ impl App {
if self.agents_overview.removed_threads.contains(&thread_id) {
continue;
}
if let Some((mut thread, unread)) = thread {
if let Some((mut thread, unread, latest_prompt)) = thread {
self.agents_overview.search_matches.remove(&thread_id);
if unread {
self.agents_overview.unread.insert(thread_id);
} else {
@@ -197,6 +201,11 @@ impl App {
self.agents_overview.refill_count =
self.agents_overview.refill_count.saturating_sub(1);
}
self.agents_overview
.activity
.entry(thread_id)
.or_default()
.latest_prompt = latest_prompt;
thread.turns.clear();
self.agents_overview.threads.insert(thread_id, Some(thread));
} else {
@@ -216,6 +225,9 @@ impl App {
&& let Ok(thread_id) = ThreadId::from_string(&reverted.thread_id)
{
// Discard stale read results without clearing activity received after the revert.
if let Some(activity) = self.agents_overview.activity.get_mut(&thread_id) {
activity.latest_prompt = None;
}
self.agents_overview.last_messages.remove(&thread_id);
if let Some(usage) = self.agents_overview.usage.get_mut(&thread_id) {
usage.tokens = None;
@@ -268,6 +280,18 @@ impl App {
.cloned()
.collect();
let view = self.agents_overview_view(threads, selected_thread_id);
let matches = &mut self.agents_overview.search_matches;
if !matches.is_empty() {
let visible: HashSet<_> = view
.visible_indices()
.into_iter()
.map(|i| view.rows[i].thread_id)
.collect();
matches.retain(|id, _| {
visible.contains(id) || self.agents_overview.hidden_threads.contains(id)
});
}
view.load_search_matches(/*additional*/ 0);
self.agents_overview.visible_thread_ids = view.thread_ids();
let next_thread_id = view
.selected_index()
@@ -304,6 +328,35 @@ impl App {
mut threads: Vec<Thread>,
selected_thread_id: Option<ThreadId>,
) -> AgentsOverviewView {
{
let overview = &self.agents_overview;
let mut state = overview
.view_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.vim_enabled = self.chat_widget.composer_is_vim_enabled();
if state
.history
.as_ref()
.is_some_and(|search| search.id == overview.search_id)
{
let query = state.search.to_lowercase();
threads.extend(
overview
.search_matches
.values()
.filter(|thread| {
super::agents_overview_discovery::matches_query(thread, &query)
})
.cloned(),
);
} else if let Some(target) = state
.rename_target
.and_then(|id| overview.search_matches.get(&id))
{
threads.push(target.clone());
}
}
threads.retain(|thread| !thread.ephemeral);
for thread in &mut threads {
if thread.parent_thread_id.is_none()
@@ -349,6 +402,11 @@ impl App {
continue;
}
rows.push(AgentsOverviewRow {
search_id: self
.agents_overview
.search_matches
.contains_key(&thread_id)
.then_some(self.agents_overview.search_id),
unread: self.agents_overview.unread.contains(&thread_id),
details: self.agents_overview_details(root, &children),
thread: root.clone(),
@@ -359,11 +417,6 @@ impl App {
});
}
self.agents_overview
.view_state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.vim_enabled = self.chat_widget.composer_is_vim_enabled();
let mut view = AgentsOverviewView::new(
rows,
selected_thread_id,
@@ -190,6 +190,7 @@ impl App {
.threads
.get(&thread_id)
.and_then(Option::as_ref)
.or_else(|| self.agents_overview.search_matches.get(&thread_id))
else {
return;
};
@@ -137,9 +137,11 @@ async fn lifecycle_shortcuts_target_filtered_task_in_any_state() {
);
view.handle_key_event(KeyCode::Esc.into());
view.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
for character in "Target".chars() {
view.handle_key_event(KeyCode::Char(character).into());
}
view.handle_paste("Target".into());
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::SearchAgentsOverview(..))
));
for (key, expected) in [
(5, AgentsOverviewAction::Archive),
(6, AgentsOverviewAction::Delete),
@@ -664,7 +666,7 @@ async fn hidden_task_stays_hidden_through_activity_and_seed_until_explicit_resum
request_id,
Ok(AgentsOverviewThreadRefresh {
cleared_unread: Vec::new(),
threads: HashMap::from([(id, Some((thread.clone(), false)))]),
threads: HashMap::from([(id, Some((thread.clone(), false, None)))]),
last_messages: HashMap::new(),
recent_seed_complete: true,
discovery: None,
@@ -948,7 +950,7 @@ async fn lifecycle_removes_background_and_current_tasks_without_losing_the_dashb
cleared_unread: Vec::new(),
threads: stale_threads
.into_iter()
.map(|(id, thread)| (id, thread.map(|thread| (thread, false))))
.map(|(id, thread)| (id, thread.map(|thread| (thread, false, None))))
.collect(),
last_messages: HashMap::new(),
recent_seed_complete: true,
@@ -1126,6 +1128,12 @@ async fn fork_shortcut_respects_metadata_editing() {
for editor in ['r', '/'] {
view.handle_key_event(KeyCode::Char(editor).into());
view.handle_key_event(KeyCode::Char('f').into());
if editor == '/' {
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::SearchAgentsOverview(..))
));
}
assert!(rx.try_recv().is_err());
view.handle_key_event(KeyCode::Esc.into());
}
@@ -35,6 +35,7 @@ pub(super) fn preview_markdown(text: &str) -> String {
#[derive(Clone, Default)]
pub(super) struct AgentsOverviewDetails {
pub(super) latest_prompt: Option<String>,
pub(super) lines: Vec<Line<'static>>,
pub(super) usage_lines: Vec<Line<'static>>,
pub(super) last_message: Option<(String, AbsolutePathBuf)>,
@@ -50,6 +51,7 @@ pub(super) fn preview_text(text: &str) -> String {
#[derive(Default)]
pub(super) struct AgentsOverviewActivity {
pub(super) latest_prompt: Option<String>,
reasoning: Option<ReasoningPreview>,
last_message: Option<String>,
}
@@ -219,10 +221,17 @@ impl App {
.or_else(|| self.agents_overview.last_messages.get(&thread_id))
.filter(|message| !message.trim().is_empty())
.map(|message| (message.clone(), source.cwd.clone()));
let root_id = ThreadId::from_string(&root.id).ok();
AgentsOverviewDetails {
latest_prompt: root_id.and_then(|id| {
self.agents_overview
.activity
.get(&id)?
.latest_prompt
.clone()
}),
lines,
usage_lines: ThreadId::from_string(&root.id)
.ok()
usage_lines: root_id
.and_then(|id| self.agents_overview.usage.get(&id))
.map(|usage| {
super::agents_overview_usage::usage_lines(
@@ -1,6 +1,8 @@
//! Demand-driven history discovery. Each source retains its cursor and unshown rows.
//! One activation requests at most one page per source, then publishes up to ten tasks.
//! Each new query scans the active backend from the start with its own paging cursor.
use super::App;
use crate::app_event::AppEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::ClientRequest;
@@ -14,6 +16,8 @@ use codex_app_server_protocol::ThreadSourceKind;
use codex_protocol::protocol::SubAgentSource;
use codex_state::PINNED_THREAD_SECTION_ID;
use std::collections::VecDeque;
use std::sync::PoisonError;
use tokio_util::task::AbortOnDropHandle;
use uuid::Uuid;
const PAGE_SIZE: u32 = 10;
@@ -110,6 +114,7 @@ struct SourcePage {
rows: VecDeque<(Thread, bool)>,
exhausted: bool,
sort_key: ThreadSortKey,
page_size: u32,
}
impl Default for SourcePage {
@@ -119,6 +124,7 @@ impl Default for SourcePage {
rows: VecDeque::new(),
exhausted: false,
sort_key: ThreadSortKey::RecencyAt,
page_size: PAGE_SIZE,
}
}
}
@@ -192,7 +198,7 @@ impl SourcePage {
attached_to_thread_id: None,
originators: None,
cursor: self.cursor.clone(),
limit: Some(PAGE_SIZE),
limit: Some(self.page_size),
sort_key: Some(self.sort_key),
sort_direction: None,
model_providers: Some(Vec::new()),
@@ -218,6 +224,9 @@ impl SourcePage {
self.cursor = None;
}
Ok(page) => {
if page.next_cursor.is_some() && page.next_cursor == self.cursor {
return false;
}
self.exhausted = page.next_cursor.is_none();
self.cursor = page.next_cursor.clone();
self.rows.extend(
@@ -233,3 +242,108 @@ impl SourcePage {
}
}
}
#[derive(Default)]
pub(super) struct HistorySearch {
pub(super) id: Uuid,
pub(super) cursor: AgentsOverviewDiscovery,
/// Requested number of matches visible under the active status filter.
pub(super) target: usize,
pub(super) loading: bool,
pub(super) failed: bool,
pub(super) task: Option<AbortOnDropHandle<()>>,
}
pub(super) fn matches_query(thread: &Thread, query: &str) -> bool {
let name = thread.name.as_deref().unwrap_or_default();
let cwd = thread.cwd.display();
format!("{name} {} {cwd}", thread.preview)
.to_lowercase()
.contains(query)
}
impl App {
pub(super) fn search_agents_overview(
&mut self,
handle: AppServerRequestHandle,
id: Uuid,
limit: usize,
) {
let shared = &self.agents_overview.view_state;
let mut state = shared.lock().unwrap_or_else(PoisonError::into_inner);
let query = state.search.to_lowercase();
let Some(search) = state.history.as_mut().filter(|search| search.id == id) else {
return;
};
let first = self.agents_overview.search_id != id;
let mut cursor = std::mem::take(&mut search.cursor);
if first {
self.agents_overview.search_matches.clear();
self.agents_overview.search_id = id;
}
for source in &mut cursor.sources {
source.page_size = 100;
}
if matches!(handle, AppServerRequestHandle::Cloud(_)) {
cursor.sources[1].exhausted = true;
}
let tx = self.app_event_tx.clone();
search.task = Some(AbortOnDropHandle::new(tokio::spawn(async move {
if first {
tokio::time::sleep(std::time::Duration::from_millis(180)).await;
}
loop {
let (mut rows, ok) = cursor.next_batch(&handle, limit).await;
rows.retain(|(thread, _)| matches_query(thread, &query));
if rows.is_empty() && ok && cursor.has_more() {
continue;
}
tx.send(AppEvent::AgentsOverviewSearchPage {
id,
rows,
cursor,
ok,
});
return;
}
})));
}
pub(super) fn apply_agents_overview_search_page(
&mut self,
id: Uuid,
rows: Vec<(Thread, bool)>,
cursor: AgentsOverviewDiscovery,
ok: bool,
) {
let overview = &mut self.agents_overview;
let mut state = overview
.view_state
.lock()
.unwrap_or_else(PoisonError::into_inner);
let Some(search) = state.history.as_mut().filter(|s| s.id == id) else {
return;
};
for (thread, unread) in rows {
if let Ok(id) = codex_protocol::ThreadId::from_string(&thread.id)
&& !overview.threads.get(&id).is_some_and(Option::is_some)
&& !overview.removed_threads.contains(&id)
&& !overview.hidden_threads.contains(&id)
&& let std::collections::hash_map::Entry::Vacant(entry) =
overview.search_matches.entry(id)
{
entry.insert(thread);
if unread {
overview.unread.insert(id);
} else {
overview.unread.remove(&id);
}
}
}
search.cursor = cursor;
search.loading = false;
search.failed = !ok;
drop(state);
self.repaint_agents_overview();
}
}
@@ -1,6 +1,7 @@
//! Demand-driven discovery, cursor retry, and visible Show more behavior.
use super::*;
use crate::app_event::AgentsOverviewAction;
use crate::app_server_session::ThreadParamsMode;
use codex_app_server_protocol::JSONRPCMessage;
use futures::SinkExt;
@@ -32,6 +33,9 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
if mixed_sources && (index == 0 || index >= 21) {
thread.source = SessionSource::Exec;
}
if index == 23 {
thread.name = None;
}
thread.updated_at += 24 - index;
thread.recency_at = Some(thread.updated_at);
thread
@@ -107,22 +111,26 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
continue;
}
assert_eq!(params["sortKey"], "recency_at");
assert_eq!(params["limit"], 10);
let limit = params["limit"].as_u64().unwrap() as usize;
assert!(params["searchTerm"].is_null());
{
let interactive = params["sourceKinds"] == json!([]);
let source_rows: Vec<_> = rows
let mut source_rows: Vec<_> = rows
.iter()
.filter(|row| matches!(row.source, SessionSource::Exec) != interactive)
.collect();
if interactive && server_show_reopened_pin.load(Ordering::SeqCst) {
source_rows.insert(0, &reopened_pin);
}
let cursor = params["cursor"].as_str().unwrap_or("0").parse::<usize>()?;
if interactive {
if interactive && limit == 10 {
cursors.push(cursor);
}
if !mixed_sources && interactive && cursors == [0, 10] {
if limit == 10 && !mixed_sources && interactive && cursors == [0, 10] {
socket.send(Message::Text(json!({"id": request.id, "error": {"code": -32603, "message": "transient"}}).to_string().into())).await?;
continue;
}
let end = (cursor + 10).min(source_rows.len());
let end = (cursor + limit).min(source_rows.len());
json!({"data": source_rows[cursor..end], "nextCursor": (end < source_rows.len()).then(|| end.to_string())})
}
}
@@ -133,7 +141,15 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
.unwrap();
json!({"thread": thread, "readState": {"revision": "read", "firstUnread": null}})
}
"thread/turns/list" if params["threadId"] == rows[23].id => json!({"data": [{
"id":"latest", "status":"completed", "items":[{"type":"userMessage","id":"prompt",
"content":[{"type":"text","text":"A later prompt","textElements":[]}]}]
}], "nextCursor":null}),
"thread/turns/list" => json!({"data": [], "nextCursor": null}),
"thread/archive" | "thread/delete" => {
socket.send(Message::Text(json!({"id": request.id, "error":{"code":-32603,"message":"try again"}}).to_string().into())).await?;
continue;
}
method => panic!("unexpected request: {method}"),
};
socket
@@ -146,7 +162,7 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
}
Ok::<_, color_eyre::Report>(cursors)
});
let session = AppServerSession::new(
let mut session = AppServerSession::new(
crate::backend_id::BackendId::new(),
crate::connect_remote_app_server(endpoint).await?,
ThreadParamsMode::Remote,
@@ -160,6 +176,39 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
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());
let browse = app.agents_overview.threads.clone();
let browse_rows = || browse.values().flatten().cloned().collect();
let mut view = app.agents_overview_view(browse_rows(), Some(ids[4]));
view.handle_key_event(KeyCode::Char('/').into());
view.handle_paste("TASK 23".into());
let mut tui = crate::tui::test_support::make_test_tui()?;
tui.pause_events();
finish_history_search(&mut app, &mut tui, &mut session, &mut rx).await?;
assert_eq!(app.agents_overview.search_matches.len(), 1);
view = app.agents_overview_view(browse_rows(), /*selected_thread_id*/ None);
assert_eq!(view.thread_ids()[view.selected_index().unwrap()], ids[23]);
let action = if mixed_sources {
AgentsOverviewAction::Archive
} else {
AgentsOverviewAction::Delete
};
Box::pin(app.run_agents_overview_action(&mut tui, &mut session, ids[23], action)).await?;
finish_overview_refresh(&mut app, &session, &mut rx).await;
app.chat_widget.handle_key_event(KeyCode::Enter.into());
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 100).contains("Task 23"));
view.handle_key_event(KeyCode::Esc.into());
assert_eq!(view.thread_ids()[view.selected_index().unwrap()], ids[4]);
assert_eq!(app.agents_overview.threads, browse);
view.handle_key_event(KeyCode::Char('/').into());
view.handle_paste("TASK 22".into());
assert!(super::command_center::screen(&view, /*width*/ 100, /*height*/ 16).contains("All 10 "));
finish_history_search(&mut app, &mut tui, &mut session, &mut rx).await?;
view = app.agents_overview_view(browse_rows(), /*selected_thread_id*/ None);
let rendered = super::command_center::screen(&view, /*width*/ 100, /*height*/ 16);
assert!(rendered.contains("All 11 "));
assert!(rendered.contains("1 of 10"), "{rendered}"); // The pinned task has its own group.
view.handle_key_event(KeyCode::Esc.into());
app.track_agents_overview_notification(&ServerNotification::ThreadArchived(
ThreadArchivedNotification {
thread_id: ids[0].to_string(),
@@ -211,10 +260,28 @@ 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());
let old = app.agents_overview.threads[&ids[23]].as_ref().unwrap();
assert_eq!(old.preview, "Task 23");
assert_eq!(
app.agents_overview_details(old, &HashMap::new())
.latest_prompt
.as_deref(),
Some("A later prompt")
);
// Another CLI inserts a matching session ahead of the exhausted browse cursor.
show_reopened_pin.store(true, Ordering::SeqCst);
view.handle_key_event(KeyCode::Char('/').into());
view.handle_paste("Pinned after reopen".into());
finish_history_search(&mut app, &mut tui, &mut session, &mut rx).await?;
assert!(
app.agents_overview
.search_matches
.contains_key(&reopened_pin_id)
);
view.handle_key_event(KeyCode::Esc.into());
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!(
@@ -233,3 +300,26 @@ async fn check_discovery(mixed_sources: bool) -> Result<()> {
);
Ok(())
}
async fn finish_history_search(
app: &mut App,
tui: &mut crate::tui::Tui,
session: &mut AppServerSession,
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) -> Result<()> {
while app
.agents_overview
.view_state
.lock()
.unwrap()
.history
.as_ref()
.is_some_and(|s| s.loading)
{
let event = tokio::time::timeout(Duration::from_secs(10), rx.recv())
.await?
.expect("search event");
app.handle_event(tui, session, event).await?;
}
Ok(())
}
@@ -3,6 +3,7 @@
use super::super::App;
use super::super::agents_overview::AgentsOverviewState;
use super::super::agents_overview_discovery::matches_query;
use super::AgentsOverviewView;
use crate::legacy_core::config::edit::ConfigEdit;
use crate::legacy_core::config::edit::ConfigEditsBuilder;
@@ -53,7 +54,7 @@ impl AgentsOverviewView {
.is_some_and(|ranks| ranks.contains_key(&self.rows[index].thread_id))
}
pub(super) fn visible_indices(&self) -> Vec<usize> {
pub(in crate::app) 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];
@@ -62,16 +63,10 @@ impl AgentsOverviewView {
.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)))
(state.rename_target == Some(row.thread_id)
|| (state.includes_row(row)
&& (search.is_empty() || matches_query(&row.thread, &search))
&& status_group.is_none_or(|group| group == row.group)))
.then_some(index)
})
.partition(|index| self.is_pinned(*index));
+11 -3
View File
@@ -384,9 +384,9 @@ async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Re
cleared_unread: Vec::new(),
last_messages: HashMap::new(),
threads: HashMap::from([
(retained, Some((retained_thread.clone(), false))),
(archived, Some((archived_thread, false))),
(deleted, Some((deleted_thread, false))),
(retained, Some((retained_thread.clone(), false, None))),
(archived, Some((archived_thread, false, None))),
(deleted, Some((deleted_thread, false, None))),
]),
recent_seed_complete: true,
discovery: None,
@@ -1438,6 +1438,10 @@ async fn shared_overview_shows_only_root_sessions() {
);
action_view.handle_key_event(KeyCode::Char('/').into());
assert!(action_view.handle_paste("Repair authentication".into()));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::SearchAgentsOverview(..))
));
action_view.handle_key_event(KeyCode::Enter.into());
assert!(
matches!(event_rx.try_recv(), Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == second_root)
@@ -1580,6 +1584,10 @@ async fn filtered_dashboard_actions_use_configured_shortcuts() {
view.handle_key_event(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
assert!(view.handle_paste("Second task".to_string()));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::SearchAgentsOverview(..))
));
view.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
assert!(matches!(
event_rx.try_recv(),
@@ -57,6 +57,7 @@ fn detail_thread_ids<'a>(threads: impl Iterator<Item = &'a Thread>) -> Vec<Threa
impl App {
pub(super) fn remove_agents_overview_thread(&mut self, thread_id: ThreadId) {
self.prepare_agents_overview_removal(&std::collections::HashSet::from([thread_id]));
self.agents_overview.search_matches.remove(&thread_id);
self.agents_overview.removed_threads.insert(thread_id);
self.agents_overview.unread.remove(&thread_id);
if let Some(Some(thread)) = self.agents_overview.threads.remove(&thread_id)
@@ -100,7 +101,8 @@ impl App {
.agents_overview
.threads
.get_mut(&thread_id)
.and_then(Option::as_mut);
.and_then(Option::as_mut)
.or_else(|| self.agents_overview.search_matches.get_mut(&thread_id));
match notification {
ServerNotification::ThreadReadStateChanged(change) => {
if change.read_state.first_unread.is_some() {
@@ -127,6 +129,7 @@ impl App {
self.agents_overview.removed_threads.remove(&thread_id);
let mut thread = started.thread.clone();
thread.turns.clear();
self.agents_overview.search_matches.remove(&thread_id);
self.agents_overview.threads.insert(thread_id, Some(thread));
}
ServerNotification::ThreadArchived(_) | ServerNotification::ThreadDeleted(_) => {
@@ -333,7 +336,7 @@ impl App {
for (thread, is_unread) in pinned_threads {
if let Ok(thread_id) = ThreadId::from_string(&thread.id) {
ids.push(thread_id);
threads.insert(thread_id, Some((thread, is_unread)));
threads.insert(thread_id, Some((thread, is_unread, None)));
}
}
Some(Some(ids))
@@ -377,7 +380,7 @@ impl App {
thread_ids.extend(detail_thread_ids(recent.iter().map(|(thread, _)| thread)));
for (thread, is_unread) in recent {
if let Ok(thread_id) = ThreadId::from_string(&thread.id) {
threads.insert(thread_id, Some((thread, is_unread)));
threads.insert(thread_id, Some((thread, is_unread, None)));
}
}
}
@@ -417,7 +420,8 @@ impl App {
})
.await
{
Ok(mut response) => {
Ok(response) => {
let mut latest_prompt = None;
let mut last_message = None;
if let Ok(turns) = request_handle
.request_typed::<ThreadTurnsListResponse>(
@@ -441,9 +445,13 @@ impl App {
content, ..
}) = turn.item_payloads().next()
{
response.thread.preview =
ChatWidget::user_message_display_from_inputs(content)
.message;
latest_prompt =
Some(super::agents_overview_details::preview_markdown(
&ChatWidget::user_message_display_from_inputs(
content,
)
.message,
));
}
last_message =
turn.item_payloads().rev().find_map(|item| match item {
@@ -456,7 +464,11 @@ impl App {
let unread = response
.read_state
.is_some_and(|state| state.first_unread.is_some());
Some((thread_id, (response.thread, unread), last_message))
Some((
thread_id,
(response.thread, unread, latest_prompt),
last_message,
))
}
Err(error) => {
tracing::warn!(%thread_id, %error, "failed to read agent thread");
@@ -105,7 +105,9 @@ impl App {
return;
}
self.agents_overview.pending_usage = None;
if self.agents_overview.threads.contains_key(&thread_id) {
if self.agents_overview.threads.contains_key(&thread_id)
|| self.agents_overview.search_matches.contains_key(&thread_id)
{
let usage = self.agents_overview.usage.entry(thread_id).or_default();
usage.fetched_at = Some(Instant::now());
match result {
@@ -100,7 +100,7 @@ async fn selected_usage_is_cached_and_account_changes_discard_old_results() -> R
request_id,
Ok(AgentsOverviewThreadRefresh {
cleared_unread: Vec::new(),
threads: HashMap::from([(selected, Some((threads[0].clone(), false)))]),
threads: HashMap::from([(selected, Some((threads[0].clone(), false, None)))]),
last_messages: HashMap::new(),
recent_seed_complete: true,
discovery: None,
+82 -5
View File
@@ -13,6 +13,7 @@ use grouping::model_name;
use super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use super::agents_overview_details::AgentsOverviewDetails;
use super::agents_overview_discovery::HistorySearch;
use super::agents_overview_discovery::supports_shared_pinning;
use crate::app_event::AgentsOverviewAction;
use crate::app_event::AppEvent;
@@ -107,6 +108,7 @@ fn is_subagent(thread: &Thread) -> bool {
#[derive(Clone)]
pub(super) struct AgentsOverviewRow {
pub(super) search_id: Option<uuid::Uuid>,
pub(super) details: AgentsOverviewDetails,
pub(super) thread: Thread,
pub(super) thread_id: ThreadId,
@@ -173,7 +175,9 @@ pub(super) struct AgentsOverviewViewState {
/// Browse only while the server holds its lifecycle lock.
pub(super) removal: Option<AgentsOverviewAction>,
pub(super) server_version_notice: Option<String>,
search: String,
pub(super) search: String,
pub(super) history: Option<HistorySearch>,
browse_selection: Option<ThreadId>,
searching: bool,
pub(super) grouping: AgentsOverviewGrouping,
pub(super) rename_target: Option<ThreadId>,
@@ -192,6 +196,29 @@ impl AgentsOverviewViewState {
self.input = input;
}
pub(super) fn restart_history_search(&mut self) {
self.history =
(!self.search.is_empty() && self.connection_notice.is_none()).then(|| HistorySearch {
id: uuid::Uuid::new_v4(),
target: self.history.as_ref().map_or(10, |search| search.target),
..Default::default()
});
}
fn includes_row(&self, row: &AgentsOverviewRow) -> bool {
row.search_id.is_none()
|| row.search_id == self.history.as_ref().map(|search| search.id)
|| self.rename_target == Some(row.thread_id)
}
fn more_available(&self) -> bool {
self.history
.as_ref()
.map_or(self.has_more && !self.searching, |search| {
search.cursor.has_more()
})
}
pub(super) fn editing_metadata(&self) -> bool {
self.searching || self.rename_target.is_some()
}
@@ -362,7 +389,36 @@ impl AgentsOverviewView {
self.state().show_more_selected = self.selected == usize::MAX;
}
pub(super) fn load_search_matches(&self, additional: usize) {
if self.state().history.is_none() {
return;
}
let visible = self.visible_indices().len();
let mut state = self.state();
if state.more_available()
&& let Some(search) = &mut state.history
&& !search.loading
{
if additional > 0 {
search.target = visible + additional;
search.failed = false;
}
if search.failed || visible >= search.target {
return;
}
search.loading = true;
self.app_event_tx.send(AppEvent::SearchAgentsOverview(
search.id,
search.target - visible,
));
}
}
fn activate(&mut self) {
if self.selected == usize::MAX && self.state().searching {
self.load_search_matches(/*additional*/ 10);
return;
}
if self.selected == usize::MAX && self.state().has_more {
if !self.state().loading {
self.state().loading = true;
@@ -392,6 +448,7 @@ impl AgentsOverviewView {
});
if self.state().searching {
let mut state = self.state();
state.history = None;
state.search.clear();
state.searching = false;
}
@@ -403,9 +460,12 @@ impl AgentsOverviewView {
let searching = state.searching;
if searching {
edit(&mut state.search);
state.restart_history_search();
}
drop(state);
if searching {
self.load_search_matches(/*additional*/ 10);
self.state().show_more_selected = false;
self.selected = self
.selectable_indices()
.first()
@@ -493,7 +553,12 @@ impl AgentsOverviewView {
})
.collect::<Vec<_>>()
};
let preview = super::agents_overview_details::preview_markdown(&row.thread.preview);
let preview = super::agents_overview_details::preview_markdown(
row.details
.latest_prompt
.as_deref()
.unwrap_or(&row.thread.preview),
);
let prompt_start = crate::wrapping::word_wrap_lines(lines.clone(), width).len();
lines.extend([Line::default(), Line::from("Prompt".dim())]);
let prompt = crate::markdown_render::render_markdown_lines_with_width_and_cwd(
@@ -588,11 +653,19 @@ impl BottomPaneView for AgentsOverviewView {
fn on_ctrl_c(&mut self) -> CancellationEvent {
let mut state = self.state();
if state.editing_metadata() {
let restore = state.searching.then_some(state.browse_selection);
state.history = None;
state.searching = false;
state.rename_target = None;
state.search.clear();
state.input.set_text_clearing_elements("");
drop(state);
if let Some(selected) = restore {
self.selected = selected
.and_then(|id| self.rows.iter().position(|row| row.thread_id == id))
.unwrap_or(usize::MAX);
self.state().show_more_selected = self.selected == usize::MAX;
}
self.reconcile_command_center_selection();
return CancellationEvent::Handled;
}
@@ -650,9 +723,12 @@ impl BottomPaneView for AgentsOverviewView {
if self.agents_keymap.search.is_pressed(key) {
let mut state = self.state();
if state.rename_target.is_none() {
state.searching = !state.searching;
if !state.searching {
state.search.clear();
if state.searching {
drop(state);
self.on_ctrl_c();
} else {
state.browse_selection = self.rows.get(self.selected).map(|row| row.thread_id);
state.searching = true;
}
}
return;
@@ -740,6 +816,7 @@ impl BottomPaneView for AgentsOverviewView {
row.thread.name.as_deref().unwrap_or_default(),
&self.editor_keymap,
);
state.history = None;
state.search.clear();
state.searching = false;
state.rename_target = Some(row.thread_id);
@@ -386,6 +386,7 @@ impl App {
let destination = backends.session_at_mut(destination_slot);
self.store_active_thread_receiver().await;
self.clear_active_thread().await;
self.agents_overview.search_matches.clear();
self.agents_overview.pending_usage = None;
self.agents_overview.usage_disabled = false;
self.app_event_tx = AppEventSender::new(self.app_event_tx.app_event_tx.clone());
@@ -393,6 +394,7 @@ impl App {
self.reconnect = Default::default();
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.connection_notice = None;
state.restart_history_search();
}
self.rate_limit_hard_stop_generation = self.rate_limit_hard_stop_generation.wrapping_add(1);
self.rate_limit_refresh_state.invalidate_recovery();
+5
View File
@@ -415,6 +415,7 @@ impl App {
if !thread.ephemeral
&& !self.agents_overview.removed_threads.contains(&thread_id)
&& !self.agents_overview.threads.get(&thread_id).is_some_and(Option::is_some) {
self.agents_overview.search_matches.remove(&thread_id);
self.agents_overview.threads.insert(thread_id, Some(thread));
self.agents_overview.refresh_thread_ids.insert(thread_id);
}
@@ -3192,6 +3193,10 @@ impl App {
}
}
AppEvent::OpenAgentsOverview => self.open_agents_overview(app_server),
AppEvent::SearchAgentsOverview(id, limit) => self.search_agents_overview(app_server.request_handle(), id, limit),
AppEvent::AgentsOverviewSearchPage { id, rows, cursor, ok } => {
self.apply_agents_overview_search_page(id, rows, cursor, ok);
}
AppEvent::ShowMoreAgentsOverview => self.show_more_agents_overview(app_server),
AppEvent::RefreshViewedThread { backend_id, thread_id, revision } => {
if app_server.backend_id() == backend_id {
+1
View File
@@ -378,6 +378,7 @@ impl App {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.connection_notice = None;
state.restart_history_search();
if state.creating_worktree {
state.creating_worktree = false;
self.pending_managed_worktree_creation = false;
@@ -10,7 +10,7 @@ expression: "screen(&view, area.width, area.height)"
/tmp/project 1
› ○ Task to find Ready 2m ago
Searching history…
@@ -438,7 +438,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
last_messages: HashMap::new(),
threads: stale_threads
.into_iter()
.map(|(id, thread)| (id, thread.map(|thread| (thread, false))))
.map(|(id, thread)| (id, thread.map(|thread| (thread, false, None))))
.collect(),
recent_seed_complete: true,
discovery: None,
+8 -1
View File
@@ -281,7 +281,7 @@ pub(crate) enum RecapTrigger {
#[derive(Debug)]
pub(crate) struct AgentsOverviewThreadRefresh {
pub(crate) cleared_unread: Vec<ThreadId>,
pub(crate) threads: std::collections::HashMap<ThreadId, Option<(Thread, bool)>>,
pub(crate) threads: std::collections::HashMap<ThreadId, Option<(Thread, bool, Option<String>)>>,
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>,
@@ -367,6 +367,13 @@ pub(crate) enum AppEvent {
/// Open the live command center for recent and locally retained root sessions.
OpenAgentsOverview,
ShowMoreAgentsOverview,
SearchAgentsOverview(Uuid, usize),
AgentsOverviewSearchPage {
id: Uuid,
rows: Vec<(Thread, bool)>,
cursor: crate::app::agents_overview_discovery::AgentsOverviewDiscovery,
ok: bool,
},
RefreshViewedThread {
backend_id: crate::backend_id::BackendId,
thread_id: ThreadId,