Track resume picker pagination and selection metadata per backend (#56543)

## What changed

- Add a thread catalog that tracks cursors, pending requests, and exhaustion per backend, preserves cursors on errors for retry, and rejects stale completions after a reset.
- Tag listed rows with their backend and use that backend when selecting a session.
- Store history mode on each row, preserving updates from later duplicate pages.

## Testing

Add coverage for independent source pagination, retry cursors, stale completions, and selection routing. Extend the history-mode test to verify that later metadata updates are used when resuming.

GitOrigin-RevId: ed5cf33b2df0e91394682f58ad5b353b949255ff
This commit is contained in:
Benjamin Carlsson
2026-10-23 17:52:51 +00:00
committed by copyberry
parent 7c568b331b
commit f970f4be67
6 changed files with 327 additions and 17 deletions
+105 -17
View File
@@ -73,6 +73,7 @@ use unicode_width::UnicodeWidthStr;
use uuid::Uuid;
mod archive;
mod catalog;
mod layout;
mod page_loading;
@@ -758,16 +759,26 @@ fn spawn_app_server_page_loader(
tokio::spawn(async move {
let mut app_server = app_server;
let mut page_cwd_filter = PageCwdFilter::default();
let mut catalog = catalog::ThreadCatalog::default();
while let Some(request) = request_rx.recv().await {
match request {
PickerLoadRequest::Page(request) => {
if request.cursor.is_none() {
catalog.reset();
}
let Some(catalog_request) = catalog.begin_page(app_server.backend_id()) else {
continue;
};
let cwd_filter = page_cwd_filter.for_request(
request.cursor.as_ref(),
request.cwd_filter.as_deref(),
request.filter_mode,
uses_remote_filesystem,
);
let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor);
let cursor = catalog_request
.cursor
.clone()
.map(|PageCursor::AppServer(cursor)| cursor);
let params = thread_list_params(
cursor,
cwd_filter,
@@ -778,6 +789,9 @@ fn spawn_app_server_page_loader(
matches!(request.mode, PageLoadMode::StateDbOnly),
);
let page = load_app_server_page(&mut app_server, params).await;
let Some(page) = catalog.finish_page(catalog_request, page) else {
continue;
};
let _ = bg_tx.send(BackgroundEvent::Page {
request_token: request.request_token,
search_token: request.search_token,
@@ -900,7 +914,6 @@ struct PickerState {
pagination: PaginationState,
all_rows: Vec<Row>,
filtered_rows: Vec<Row>,
thread_history_modes: HashMap<ThreadId, ThreadHistoryMode>,
seen_rows: HashSet<SeenRowKey>,
selected: usize,
scroll_top: usize,
@@ -1020,6 +1033,8 @@ impl SearchState {
#[derive(Clone)]
#[cfg_attr(test, derive(Debug, PartialEq))]
struct Row {
backend_id: Option<crate::backend_id::BackendId>,
history_mode: Option<ThreadHistoryMode>,
path: Option<PathBuf>,
preview: String,
thread_id: Option<ThreadId>,
@@ -1103,7 +1118,6 @@ impl PickerState {
pagination: PaginationState::new(),
all_rows: Vec::new(),
filtered_rows: Vec::new(),
thread_history_modes: HashMap::new(),
seen_rows: HashSet::new(),
selected: 0,
scroll_top: 0,
@@ -1248,7 +1262,6 @@ impl PickerState {
self.request_frame();
return;
};
match self.transcript_cells.get(&thread_id) {
Some(SessionTranscriptState::Loaded(_)) => {
self.begin_transcript_loading(thread_id);
@@ -1376,11 +1389,11 @@ impl PickerState {
return Ok(None);
}
return Ok(Some(self.action.selection(SessionTarget {
backend_id: self.backend_id,
backend_id: row.backend_id.unwrap_or(self.backend_id),
path,
thread_id,
cwd: row.cwd.clone(),
history_mode: self.thread_history_modes.get(&thread_id).copied(),
history_mode: row.history_mode,
})));
}
self.inline_error = Some(match path {
@@ -1517,7 +1530,6 @@ impl PickerState {
self.reset_pagination();
self.all_rows.clear();
self.filtered_rows.clear();
self.thread_history_modes.clear();
self.seen_rows.clear();
self.local_cwd_matches.clear();
self.selected = 0;
@@ -1673,9 +1685,10 @@ impl PickerState {
} = page;
self.pagination
.complete_page(next_cursor, num_scanned_files, reached_scan_cap);
self.thread_history_modes.extend(history_modes);
for row in rows {
for mut row in rows {
if let Some(thread_id) = row.thread_id {
row.history_mode = history_modes.get(&thread_id).copied();
}
if let Some(cwd) = row.cwd.as_ref()
&& !self.local_cwd_matches.contains_key(cwd)
{
@@ -1683,8 +1696,15 @@ impl PickerState {
self.local_cwd_matches.insert(cwd.clone(), matches);
}
if let Some(seen_key) = row.seen_key() {
if self.seen_rows.insert(seen_key) {
if self.seen_rows.insert(seen_key.clone()) {
self.all_rows.push(row);
} else if let Some(existing) = self.all_rows.iter_mut().find(|existing| {
existing.seen_key().as_ref() == Some(&seen_key)
&& existing.backend_id == row.backend_id
&& existing.thread_id == row.thread_id
}) {
// Preserve the pre-catalog behavior where metadata from a later page wins.
existing.history_mode = row.history_mode;
}
} else {
self.all_rows.push(row);
@@ -2133,6 +2153,8 @@ fn row_from_app_server_thread(thread: Thread) -> Option<Row> {
};
let preview = thread.preview.trim();
Some(Row {
backend_id: None,
history_mode: None,
path: thread.path,
preview: if preview.is_empty() {
String::from("(no message yet)")
@@ -3699,7 +3721,7 @@ mod tests {
use std::sync::Mutex;
use tempfile::tempdir;
fn page(
pub(super) fn page(
rows: Vec<Row>,
next_cursor: Option<&str>,
num_scanned_files: usize,
@@ -3742,9 +3764,11 @@ mod tests {
Ok(page(rows, next_cursor, n, /*reached_scan_cap*/ false))
}
fn make_row(path: &str, ts: &str, preview: &str) -> Row {
pub(super) fn make_row(path: &str, ts: &str, preview: &str) -> Row {
let timestamp = parse_timestamp_str(ts).expect("timestamp should parse");
Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from(path)),
preview: preview.to_string(),
thread_id: None,
@@ -3756,7 +3780,7 @@ mod tests {
}
}
fn local_db_first_state() -> (PickerState, Arc<Mutex<Vec<PageLoadRequest>>>) {
pub(super) fn local_db_first_state() -> (PickerState, Arc<Mutex<Vec<PageLoadRequest>>>) {
let requests = Arc::new(Mutex::new(Vec::new()));
let request_sink = Arc::clone(&requests);
let loader = page_only_loader(move |request| {
@@ -3807,7 +3831,7 @@ mod tests {
.join("\n")
}
fn render_picker_list(state: &PickerState, width: u16, height: u16) -> String {
pub(super) fn render_picker_list(state: &PickerState, width: u16, height: u16) -> String {
use crate::custom_terminal::Terminal;
use crate::test_backend::VT100Backend;
@@ -3826,6 +3850,8 @@ mod tests {
#[test]
fn row_display_preview_prefers_thread_name() {
let row = Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("first message"),
thread_id: None,
@@ -4181,7 +4207,7 @@ mod tests {
}
#[tokio::test]
async fn local_db_first_accepts_db_thread_id_without_rollout_validation() {
async fn local_db_first_accepts_db_thread_id_and_latest_history_mode() {
let (mut state, requests) = local_db_first_state();
state.start_initial_load();
let db_request = requests.lock().unwrap()[0].clone();
@@ -4200,6 +4226,20 @@ mod tests {
.insert(thread_id, ThreadHistoryMode::Legacy);
deliver_page(&mut state, &db_request, Ok(listed_page)).await;
let mut updated_row = make_row(
"/tmp/missing-rollout.jsonl",
"2025-01-02T00:00:00Z",
"updated indexed metadata",
);
updated_row.thread_id = Some(thread_id);
updated_row.cwd = Some(PathBuf::from("/tmp/saved-cwd"));
let mut updated_page = ok_page(vec![updated_row], /*next_cursor*/ None)
.expect("updated indexed thread page should be available");
updated_page
.history_modes
.insert(thread_id, ThreadHistoryMode::Paginated);
state.ingest_page(updated_page);
let selection = state
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
@@ -4209,7 +4249,7 @@ mod tests {
Some(SessionSelection::Resume(SessionTarget {
thread_id: selected_thread_id,
cwd: Some(cwd),
history_mode: Some(ThreadHistoryMode::Legacy),
history_mode: Some(ThreadHistoryMode::Paginated),
..
})) if selected_thread_id == thread_id && cwd == Path::new("/tmp/saved-cwd")
));
@@ -4220,6 +4260,8 @@ mod tests {
let thread_id =
ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id");
let row = Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("first message"),
thread_id: Some(thread_id),
@@ -4282,6 +4324,8 @@ mod tests {
state.clock_format = ClockFormat::TwentyFourHour;
state.relative_time_reference = parse_timestamp_str("2026-05-02T14:48:19Z");
let row = Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("first message"),
thread_id: Some(thread_id),
@@ -4530,6 +4574,8 @@ mod tests {
}
let row = Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("remote session"),
thread_id: Some(ThreadId::new()),
@@ -4556,6 +4602,8 @@ mod tests {
SessionPickerAction::Resume,
);
let row = Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("remote session"),
thread_id: Some(ThreadId::new()),
@@ -4585,6 +4633,8 @@ mod tests {
let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp");
let rows = vec![
Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("Fix resume picker timestamps"),
thread_id: None,
@@ -4595,6 +4645,8 @@ mod tests {
git_branch: None,
},
Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from("/tmp/b.jsonl")),
preview: String::from("Investigate lazy pagination cap"),
thread_id: None,
@@ -4605,6 +4657,8 @@ mod tests {
git_branch: None,
},
Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from("/tmp/c.jsonl")),
preview: String::from("Explain the codebase"),
thread_id: None,
@@ -5046,6 +5100,8 @@ mod tests {
SessionPickerAction::Resume,
);
state.filtered_rows = vec![Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("preview"),
thread_id: Some(thread_id),
@@ -5085,6 +5141,8 @@ mod tests {
);
state.filtered_rows = vec![
Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("one"),
thread_id: Some(ThreadId::new()),
@@ -5095,6 +5153,8 @@ mod tests {
git_branch: None,
},
Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("two"),
thread_id: Some(ThreadId::new()),
@@ -5222,6 +5282,8 @@ mod tests {
state.pending_transcript_open = Some(thread_id);
state.filtered_rows = vec![
Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("Find pending threads and emails"),
thread_id: Some(thread_id),
@@ -5232,6 +5294,8 @@ mod tests {
git_branch: None,
},
Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("Plan raw scrollback mode"),
thread_id: Some(ThreadId::new()),
@@ -5288,6 +5352,8 @@ mod tests {
SessionPickerAction::Resume,
);
state.filtered_rows = vec![Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("preview"),
thread_id: Some(thread_id),
@@ -5319,6 +5385,8 @@ mod tests {
SessionPickerAction::Resume,
);
state.filtered_rows = vec![Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("preview"),
thread_id: None,
@@ -5462,6 +5530,8 @@ mod tests {
SessionPickerAction::Resume,
);
state.filtered_rows = vec![Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("preview"),
thread_id: Some(thread_id),
@@ -5631,6 +5701,8 @@ session_picker_view = "dense"
SessionPickerAction::Resume,
);
state.filtered_rows = vec![Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("preview"),
thread_id: Some(thread_id),
@@ -5671,6 +5743,8 @@ session_picker_view = "dense"
SessionPickerAction::Resume,
);
state.filtered_rows = vec![Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("preview"),
thread_id: Some(thread_id),
@@ -5747,6 +5821,8 @@ session_picker_view = "dense"
fn dense_snapshot_row() -> Row {
Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from(
"Propose session picker redesign with enough title text to exercise truncation",
@@ -5983,6 +6059,8 @@ session_picker_view = "dense"
let thread_id =
ThreadId::from_string("019dabc1-0ef5-7431-b81c-03037f51f62c").expect("thread id");
let row = Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("Investigate picker expansion"),
thread_id: Some(thread_id),
@@ -6054,6 +6132,8 @@ session_picker_view = "dense"
let loader = page_only_loader(|_| {});
let row = Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from("/tmp/a.jsonl")),
preview: String::from("Investigate picker expansion"),
thread_id: Some(
@@ -6116,6 +6196,8 @@ session_picker_view = "dense"
let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp");
state.all_rows = (0..5)
.map(|idx| Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from(format!("/tmp/{idx}.jsonl"))),
preview: format!("item-{idx}"),
thread_id: None,
@@ -6169,6 +6251,8 @@ session_picker_view = "dense"
let now = parse_timestamp_str("2026-04-28T16:30:00Z").expect("timestamp");
state.all_rows = (0..4)
.map(|idx| Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from(format!("/tmp/{idx}.jsonl"))),
preview: format!("item-{idx}"),
thread_id: None,
@@ -6767,6 +6851,8 @@ session_picker_view = "dense"
);
let row = Row {
backend_id: None,
history_mode: None,
path: Some(PathBuf::from("/tmp/missing.jsonl")),
preview: String::from("missing metadata"),
thread_id: None,
@@ -6807,6 +6893,8 @@ session_picker_view = "dense"
);
let thread_id = ThreadId::new();
let row = Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("pathless thread"),
thread_id: Some(thread_id),
@@ -50,6 +50,8 @@ fn archive_picker_state() -> (PickerState, Arc<Mutex<Vec<ThreadId>>>) {
fn set_selected_session(state: &mut PickerState, thread_id: ThreadId) {
state.all_rows = vec![Row {
backend_id: None,
history_mode: None,
path: None,
preview: String::from("Selected session"),
thread_id: Some(thread_id),
+73
View File
@@ -0,0 +1,73 @@
//! Coordinates independently paginated sources and tags rows with their backend.
//! Resetting a listing invalidates pending pages without mixing source cursors.
use super::PageCursor;
use super::PickerPage;
use crate::backend_id::BackendId;
use std::collections::HashMap;
#[derive(Default)]
pub(super) struct ThreadCatalog {
sources: HashMap<BackendId, Source>,
next_request: u64,
}
#[derive(Default)]
struct Source {
cursor: Option<PageCursor>,
pending: Option<u64>,
exhausted: bool,
}
pub(super) struct PageRequest {
backend: BackendId,
token: u64,
pub(super) cursor: Option<PageCursor>,
}
impl ThreadCatalog {
pub(super) fn reset(&mut self) {
self.sources.clear();
}
/// A slow or exhausted source does not prevent another source from loading.
pub(super) fn begin_page(&mut self, backend: BackendId) -> Option<PageRequest> {
let source = self.sources.entry(backend).or_default();
if source.pending.is_some() || source.exhausted {
return None;
}
let token = self.next_request;
self.next_request += 1;
source.pending = Some(token);
Some(PageRequest {
backend,
token,
cursor: source.cursor.clone(),
})
}
/// Errors retain the cursor for an explicit retry and never become empty pages.
pub(super) fn finish_page(
&mut self,
request: PageRequest,
page: std::io::Result<PickerPage>,
) -> Option<std::io::Result<PickerPage>> {
let source = self.sources.get_mut(&request.backend)?;
if source.pending != Some(request.token) {
return None;
}
source.pending = None;
Some(page.map(|mut page| {
source.cursor = page.next_cursor.clone();
source.exhausted = source.cursor.is_none();
for row in &mut page.rows {
row.backend_id = Some(request.backend);
}
page
}))
}
}
#[cfg(test)]
#[path = "catalog_tests.rs"]
mod tests;
@@ -0,0 +1,141 @@
//! Source pagination, stale completions, and row-specific source routing.
use super::*;
use crate::resume_picker::tests::make_row;
use crate::resume_picker::tests::page;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
fn listed(thread: ThreadId, cursor: Option<&str>) -> PickerPage {
let mut row = make_row(
&format!("/tmp/{thread}.jsonl"),
"2025-01-01T00:00:00Z",
"conversation",
);
row.thread_id = Some(thread);
page(
vec![row],
cursor,
/*num_scanned_files*/ 1,
/*reached_scan_cap*/ false,
)
}
fn cursor(request: &PageRequest) -> Option<&str> {
request
.cursor
.as_ref()
.map(|PageCursor::AppServer(value)| value.as_str())
}
#[test]
fn sources_progress_independently_and_retry_their_own_cursor() {
let mut catalog = ThreadCatalog::default();
let slow = BackendId::new();
let failed = BackendId::new();
let healthy = BackendId::new();
let slow_request = catalog.begin_page(slow).unwrap();
let first = catalog.begin_page(failed).unwrap();
catalog
.finish_page(first, Ok(listed(ThreadId::new(), Some("failed-next"))))
.unwrap()
.unwrap();
let failing_request = catalog.begin_page(failed).unwrap();
assert_eq!(cursor(&failing_request), Some("failed-next"));
let error = catalog
.finish_page(failing_request, Err(std::io::Error::other("unavailable")))
.unwrap()
.err()
.unwrap();
assert_eq!(error.to_string(), "unavailable");
let healthy_request = catalog.begin_page(healthy).unwrap();
let thread = ThreadId::new();
let result = catalog
.finish_page(healthy_request, Ok(listed(thread, /*cursor*/ None)))
.unwrap()
.unwrap();
assert_eq!(result.rows[0].backend_id, Some(healthy));
assert!(catalog.begin_page(healthy).is_none());
assert!(catalog.begin_page(slow).is_none());
let retry = catalog.begin_page(failed).unwrap();
assert_eq!(cursor(&retry), Some("failed-next"));
catalog
.finish_page(retry, Ok(listed(ThreadId::new(), /*cursor*/ None)))
.unwrap()
.unwrap();
catalog
.finish_page(slow_request, Ok(listed(ThreadId::new(), /*cursor*/ None)))
.unwrap()
.unwrap();
assert!(catalog.begin_page(failed).is_none());
assert!(catalog.begin_page(slow).is_none());
}
#[test]
fn reset_rejects_old_completions_without_finishing_new_requests() {
let mut catalog = ThreadCatalog::default();
let backend = BackendId::new();
let old = catalog.begin_page(backend).unwrap();
catalog.reset();
let current = catalog.begin_page(backend).unwrap();
assert!(
catalog
.finish_page(old, Ok(listed(ThreadId::new(), /*cursor*/ None)))
.is_none()
);
assert!(catalog.begin_page(backend).is_none());
let thread = ThreadId::new();
let result = catalog
.finish_page(current, Ok(listed(thread, /*cursor*/ None)))
.unwrap()
.unwrap();
assert_eq!(result.rows[0].backend_id, Some(backend));
}
#[tokio::test]
async fn picker_selections_retain_each_rows_source() {
use crate::resume_picker::SessionSelection;
use crate::resume_picker::tests::local_db_first_state;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (mut picker, _) = local_db_first_state();
let owner = BackendId::new();
let thread = ThreadId::new();
let mut catalog = ThreadCatalog::default();
let request = catalog.begin_page(owner).unwrap();
let page = catalog
.finish_page(request, Ok(listed(thread, /*cursor*/ None)))
.unwrap()
.unwrap();
picker.ingest_page(page);
let other = BackendId::new();
let other_thread = ThreadId::new();
let other_request = catalog.begin_page(other).unwrap();
let other_page = catalog
.finish_page(other_request, Ok(listed(other_thread, /*cursor*/ None)))
.unwrap()
.unwrap();
picker.ingest_page(other_page);
let selection = picker
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.unwrap();
let Some(SessionSelection::Resume(target)) = selection else {
panic!("expected selection")
};
assert_eq!((target.thread_id, target.backend_id), (thread, owner));
picker
.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))
.await
.unwrap();
let selection = picker
.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
.await
.unwrap();
let Some(SessionSelection::Resume(target)) = selection else {
panic!("expected selection")
};
assert_eq!((target.thread_id, target.backend_id), (other_thread, other));
}
@@ -15,6 +15,8 @@ fn thread_colors_and_selection_contrast_in_both_picker_layouts() {
.clone(),
);
let row = Row {
backend_id: None,
history_mode: None,
path: None,
thread_id: Some(ThreadId::from_u128(/*value*/ 42)),
thread_name: Some("Named task".into()),
@@ -161,6 +163,8 @@ fn compact_picker_keeps_metadata_and_labeled_primary_actions() {
state.relative_time_reference = timestamp;
state.filtered_rows = (1..=6)
.map(|id| Row {
backend_id: None,
history_mode: None,
path: None,
thread_id: Some(ThreadId::from_u128(id)),
thread_name: Some(format!("Session {id}")),
@@ -35,6 +35,8 @@ async fn later_page_error_preserves_loaded_sessions() {
search_token: request.search_token,
page: Ok(PickerPage {
rows: vec![Row {
backend_id: None,
history_mode: None,
path: None,
preview: "Loaded session".to_string(),
thread_id: Some(thread_id),