diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c8d7d6161..6de069420 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -4620,13 +4620,7 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::ResumeSessionByIdOrName(id_or_name) => { - match crate::lookup_session_target_with_app_server( - app_server, - self.config.codex_home.as_path(), - &id_or_name, - ) - .await? - { + match crate::lookup_session_target_with_app_server(app_server, &id_or_name).await? { Some(target_session) => { return self .resume_target_session(tui, app_server, target_session) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3d1c8ec60..9fe233044 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -226,7 +226,6 @@ use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_protocol::user_input::UserInput; -use codex_rollout::find_thread_name_by_id; use codex_terminal_detection::Multiplexer; use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; @@ -2186,45 +2185,16 @@ impl ChatWidget { } fn emit_forked_thread_event(&mut self, forked_from_id: ThreadId) { - let app_event_tx = self.app_event_tx.clone(); - let codex_home = self.config.codex_home.clone(); - tokio::spawn(async move { - let forked_from_id_text = forked_from_id.to_string(); - let send_name_and_id = |name: String| { - let line: Line<'static> = vec![ - "• ".dim(), - "Thread forked from ".into(), - name.cyan(), - " (".into(), - forked_from_id_text.clone().cyan(), - ")".into(), - ] - .into(); - app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - PlainHistoryCell::new(vec![line]), - ))); - }; - let send_id_only = || { - let line: Line<'static> = vec![ - "• ".dim(), - "Thread forked from ".into(), - forked_from_id_text.clone().cyan(), - ] - .into(); - app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - PlainHistoryCell::new(vec![line]), - ))); - }; - - match find_thread_name_by_id(&codex_home, &forked_from_id).await { - Ok(Some(name)) if !name.trim().is_empty() => send_name_and_id(name), - Ok(_) => send_id_only(), - Err(err) => { - tracing::warn!("Failed to read forked thread name: {err}"); - send_id_only(); - } - } - }); + let forked_from_id_text = forked_from_id.to_string(); + let line: Line<'static> = vec![ + "• ".dim(), + "Thread forked from ".into(), + forked_from_id_text.cyan(), + ] + .into(); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + PlainHistoryCell::new(vec![line]), + ))); } fn on_thread_name_updated(&mut self, event: codex_protocol::protocol::ThreadNameUpdatedEvent) { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__forked_thread_history_line.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__forked_thread_history_line.snap deleted file mode 100644 index d089f5963..000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__forked_thread_history_line.snap +++ /dev/null @@ -1,5 +0,0 @@ ---- -source: tui/src/chatwidget/tests.rs -expression: combined ---- -• Thread forked from named-thread (e9f18a88-8081-4e51-9d4e-8af5cde2d8dd) diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 71987e590..80fa79e1d 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -387,45 +387,6 @@ async fn replayed_user_message_with_only_local_images_does_not_render_history_ce assert!(!found_user_history_cell); } -#[tokio::test] -async fn forked_thread_history_line_includes_name_and_id_snapshot() { - let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let mut chat = chat; - let temp = tempdir().expect("tempdir"); - chat.config.codex_home = - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(temp.path()) - .expect("temp dir is absolute"); - - let forked_from_id = - ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id"); - let session_index_entry = format!( - "{{\"id\":\"{forked_from_id}\",\"thread_name\":\"named-thread\",\"updated_at\":\"2024-01-02T00:00:00Z\"}}\n" - ); - std::fs::write(temp.path().join("session_index.jsonl"), session_index_entry) - .expect("write session index"); - - chat.emit_forked_thread_event(forked_from_id); - - let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async { - loop { - match rx.recv().await { - Some(AppEvent::InsertHistoryCell(cell)) => break cell, - Some(_) => continue, - None => panic!("app event channel closed before forked thread history was emitted"), - } - } - }) - .await - .expect("timed out waiting for forked thread history"); - let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80)); - - assert!( - combined.contains("Thread forked from"), - "expected forked thread message in history" - ); - assert_chatwidget_snapshot!("forked_thread_history_line", combined); -} - #[tokio::test] async fn forked_thread_history_line_without_name_shows_id_once_snapshot() { let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0b6887cdd..7e5d03ecd 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -49,7 +49,6 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::TurnContextItem; -use codex_rollout::find_thread_meta_by_name_str; use codex_rollout::read_session_meta_line; use codex_rollout::state_db::get_state_db; use codex_state::log_db; @@ -509,7 +508,6 @@ fn session_target_from_app_server_thread( async fn lookup_session_target_by_name_with_app_server( app_server: &mut AppServerSession, - codex_home: &Path, name: &str, ) -> color_eyre::Result> { let mut cursor = None; @@ -535,15 +533,7 @@ async fn lookup_session_target_by_name_with_app_server( return Ok(session_target_from_app_server_thread(thread)); } if response.next_cursor.is_none() { - if app_server.is_remote() { - return Ok(None); - } - return Ok(find_thread_meta_by_name_str(codex_home, name).await?.map( - |(path, session_meta)| resume_picker::SessionTarget { - path: Some(path), - thread_id: session_meta.meta.id, - }, - )); + return Ok(None); } cursor = response.next_cursor; } @@ -551,7 +541,6 @@ async fn lookup_session_target_by_name_with_app_server( async fn lookup_session_target_with_app_server( app_server: &mut AppServerSession, - codex_home: &Path, id_or_name: &str, ) -> color_eyre::Result> { if Uuid::parse_str(id_or_name).is_ok() { @@ -582,7 +571,7 @@ async fn lookup_session_target_with_app_server( }; } - lookup_session_target_by_name_with_app_server(app_server, codex_home, id_or_name).await + lookup_session_target_by_name_with_app_server(app_server, id_or_name).await } async fn lookup_latest_session_target_with_app_server( @@ -1202,13 +1191,7 @@ async fn run_ratatui_app( let Some(startup_app_server) = app_server.as_mut() else { unreachable!("app server should be initialized for --fork "); }; - match lookup_session_target_with_app_server( - startup_app_server, - config.codex_home.as_path(), - id_str, - ) - .await? - { + match lookup_session_target_with_app_server(startup_app_server, id_str).await? { Some(target_session) => resume_picker::SessionSelection::Fork(target_session), None => { shutdown_app_server_if_present(app_server.take()).await; @@ -1269,13 +1252,7 @@ async fn run_ratatui_app( let Some(startup_app_server) = app_server.as_mut() else { unreachable!("app server should be initialized for --resume "); }; - match lookup_session_target_with_app_server( - startup_app_server, - config.codex_home.as_path(), - id_str, - ) - .await? - { + match lookup_session_target_with_app_server(startup_app_server, id_str).await? { Some(target_session) => resume_picker::SessionSelection::Resume(target_session), None => { shutdown_app_server_if_present(app_server.take()).await; @@ -2107,12 +2084,8 @@ mod tests { AppServerSession::new(codex_app_server_client::AppServerClient::InProcess( start_test_embedded_app_server(config).await?, )); - let target = lookup_session_target_by_name_with_app_server( - &mut app_server, - temp_dir.path(), - "saved-session", - ) - .await?; + let target = + lookup_session_target_by_name_with_app_server(&mut app_server, "saved-session").await?; let target = target.expect("name lookup should find the saved thread"); assert_eq!(target.path, Some(rollout_path)); assert_eq!(target.thread_id, thread_id); @@ -2121,58 +2094,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn lookup_session_target_by_name_falls_back_to_legacy_index() -> color_eyre::Result<()> { - let temp_dir = TempDir::new()?; - let config = build_config(&temp_dir).await?; - let thread_id = ThreadId::new(); - let rollout_path = temp_dir - .path() - .join("sessions/2025/02/01") - .join(format!("rollout-2025-02-01T10-00-00-{thread_id}.jsonl")); - std::fs::create_dir_all(rollout_path.parent().expect("rollout parent"))?; - let session_meta = SessionMeta { - id: thread_id, - timestamp: "2025-02-01T10:00:00Z".to_string(), - model_provider: Some(config.model_provider_id.clone()), - ..SessionMeta::default() - }; - let line = RolloutLine { - timestamp: session_meta.timestamp.clone(), - item: RolloutItem::SessionMeta(SessionMetaLine { - meta: session_meta, - git: None, - }), - }; - std::fs::write( - &rollout_path, - format!("{}\n", serde_json::to_string(&line)?), - )?; - std::fs::write( - temp_dir.path().join("session_index.jsonl"), - format!( - "{{\"id\":\"{thread_id}\",\"thread_name\":\"hello\",\"updated_at\":\"2025-02-02T10:00:00Z\"}}\n" - ), - )?; - - let mut app_server = - AppServerSession::new(codex_app_server_client::AppServerClient::InProcess( - start_test_embedded_app_server(config).await?, - )); - let target = lookup_session_target_by_name_with_app_server( - &mut app_server, - temp_dir.path(), - "hello", - ) - .await?; - let target = target.expect("legacy name lookup should find the saved thread"); - assert_eq!(target.path, Some(rollout_path)); - assert_eq!(target.thread_id, thread_id); - - app_server.shutdown().await?; - Ok(()) - } - #[tokio::test] async fn embedded_app_server_start_failure_is_returned() -> color_eyre::Result<()> { let temp_dir = TempDir::new()?; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 0d856b1a1..9243f3376 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -1,4 +1,3 @@ -use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; @@ -17,16 +16,9 @@ use chrono::DateTime; use chrono::Utc; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadListParams; -use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; +use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; use codex_protocol::ThreadId; -use codex_rollout::Cursor; -use codex_rollout::INTERACTIVE_SESSION_SOURCES; -use codex_rollout::RolloutRecorder; -use codex_rollout::ThreadItem; -use codex_rollout::ThreadSortKey; -use codex_rollout::ThreadsPage; -use codex_rollout::find_thread_names_by_ids; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -127,8 +119,6 @@ enum BackgroundEvent { #[derive(Clone)] enum PageCursor { - #[allow(dead_code)] - Rollout(Cursor), AppServer(String), } @@ -139,31 +129,21 @@ struct PickerPage { reached_scan_cap: bool, } -/// Interactive session picker that lists recorded rollout files with simple -/// search and pagination. +/// Interactive session picker that lists app-server threads with simple search +/// and pagination. /// /// The picker displays sessions in a table with timestamp columns (created/updated), /// git branch, working directory, and conversation preview. Users can toggle /// between sorting by creation time and last-updated time using the Tab key. /// /// Sessions are loaded on-demand via cursor-based pagination. The backend -/// `RolloutRecorder::list_threads` returns pages ordered by the selected sort key, -/// and the picker deduplicates across pages to handle overlapping windows when -/// new sessions appear during pagination. +/// `thread/list` API returns pages ordered by the selected sort key, and the +/// picker deduplicates across pages to handle overlapping windows when new +/// sessions appear during pagination. /// /// Filtering happens in two layers: -/// 1. Provider and source filtering at the backend (only interactive CLI sessions -/// for the current model provider). +/// 1. Provider and source filtering at the backend. /// 2. Working-directory filtering at the picker (unless `--all` is passed). -#[allow(dead_code)] -pub async fn run_resume_picker( - tui: &mut Tui, - config: &Config, - show_all: bool, -) -> Result { - run_session_picker(tui, config, show_all, SessionPickerAction::Resume).await -} - pub async fn run_resume_picker_with_app_server( tui: &mut Tui, config: &Config, @@ -217,26 +197,6 @@ pub async fn run_fork_picker_with_app_server( .await } -#[allow(dead_code)] -async fn run_session_picker( - tui: &mut Tui, - config: &Config, - show_all: bool, - action: SessionPickerAction, -) -> Result { - let (bg_tx, bg_rx) = mpsc::unbounded_channel(); - run_session_picker_with_loader( - tui, - config, - show_all, - action, - /*is_remote*/ false, - spawn_rollout_page_loader(config, bg_tx), - bg_rx, - ) - .await -} - async fn run_session_picker_with_loader( tui: &mut Tui, config: &Config, @@ -252,7 +212,6 @@ async fn run_session_picker_with_loader( } else { ProviderFilter::MatchDefault(config.model_provider_id.to_string()) }; - let codex_home = config.codex_home.as_path(); let filter_cwd = if show_all || is_remote { // Remote sessions live in the server's filesystem namespace, so the client // process cwd is not a meaningful row filter. If the user provided an @@ -263,7 +222,6 @@ async fn run_session_picker_with_loader( }; let mut state = PickerState::new( - codex_home.to_path_buf(), alt.tui.frame_requester(), page_loader, provider_filter, @@ -311,48 +269,6 @@ async fn run_session_picker_with_loader( Ok(SessionSelection::StartFresh) } -#[allow(dead_code)] -fn spawn_rollout_page_loader( - config: &Config, - bg_tx: mpsc::UnboundedSender, -) -> PageLoader { - let config = config.clone(); - let loader_tx = bg_tx; - Arc::new(move |request: PageLoadRequest| { - let tx = loader_tx.clone(); - let config = config.clone(); - tokio::spawn(async move { - let default_provider = match request.provider_filter { - ProviderFilter::Any => None, - ProviderFilter::MatchDefault(default_provider) => Some(default_provider), - }; - let cursor = match request.cursor.as_ref() { - Some(PageCursor::Rollout(cursor)) => Some(cursor), - Some(PageCursor::AppServer(_)) => None, - None => None, - }; - let page = RolloutRecorder::list_threads( - &config, - PAGE_SIZE, - cursor, - request.sort_key, - codex_rollout::SortDirection::Desc, - INTERACTIVE_SESSION_SOURCES.as_slice(), - default_provider.as_ref().map(std::slice::from_ref), - default_provider.as_deref().unwrap_or_default(), - /*search_term*/ None, - ) - .await - .map(picker_page_from_rollout_page); - let _ = tx.send(BackgroundEvent::PageLoaded { - request_token: request.request_token, - search_token: request.search_token, - page, - }); - }); - }) -} - fn spawn_app_server_page_loader( app_server: AppServerSession, cwd_filter: Option, @@ -364,11 +280,7 @@ fn spawn_app_server_page_loader( tokio::spawn(async move { let mut app_server = app_server; while let Some(request) = request_rx.recv().await { - let cursor = match request.cursor { - Some(PageCursor::AppServer(cursor)) => Some(cursor), - Some(PageCursor::Rollout(_)) => None, - None => None, - }; + let cursor = request.cursor.map(|PageCursor::AppServer(cursor)| cursor); let page = load_app_server_page( &mut app_server, cursor, @@ -424,7 +336,6 @@ impl Drop for AltScreenGuard<'_> { } struct PickerState { - codex_home: PathBuf, requester: FrameRequester, relative_time_reference: Option>, pagination: PaginationState, @@ -444,7 +355,6 @@ struct PickerState { filter_cwd: Option, action: SessionPickerAction, sort_key: ThreadSortKey, - thread_name_cache: HashMap>, inline_error: Option, } @@ -574,7 +484,6 @@ impl Row { impl PickerState { fn new( - codex_home: PathBuf, requester: FrameRequester, page_loader: PageLoader, provider_filter: ProviderFilter, @@ -583,7 +492,6 @@ impl PickerState { action: SessionPickerAction, ) -> Self { Self { - codex_home, requester, relative_time_reference: None, pagination: PaginationState { @@ -608,7 +516,6 @@ impl PickerState { filter_cwd, action, sort_key: ThreadSortKey::UpdatedAt, - thread_name_cache: HashMap::new(), inline_error: None, } } @@ -810,7 +717,6 @@ impl PickerState { self.pagination.loading = LoadingState::Idle; let page = page.map_err(color_eyre::Report::from)?; self.ingest_page(page); - self.update_thread_names().await; let completed_token = pending.search_token.or(search_token); self.continue_search_if_token_matches(completed_token); } @@ -852,51 +758,6 @@ impl PickerState { self.apply_filter(); } - async fn update_thread_names(&mut self) { - let mut missing_ids = HashSet::new(); - for row in &self.all_rows { - let Some(thread_id) = row.thread_id else { - continue; - }; - if self.thread_name_cache.contains_key(&thread_id) { - continue; - } - missing_ids.insert(thread_id); - } - - if missing_ids.is_empty() { - return; - } - - let names = find_thread_names_by_ids(&self.codex_home, &missing_ids) - .await - .unwrap_or_default(); - for thread_id in missing_ids { - let thread_name = names.get(&thread_id).cloned(); - self.thread_name_cache.insert(thread_id, thread_name); - } - - let mut updated = false; - for row in self.all_rows.iter_mut() { - let Some(thread_id) = row.thread_id else { - continue; - }; - let Some(thread_name) = self.thread_name_cache.get(&thread_id).cloned().flatten() - else { - continue; - }; - if row.thread_name.as_ref() == Some(&thread_name) { - continue; - } - row.thread_name = Some(thread_name); - updated = true; - } - - if updated { - self.apply_filter(); - } - } - fn apply_filter(&mut self) { let base_iter = self .all_rows @@ -1095,50 +956,6 @@ impl PickerState { } } -#[cfg_attr(not(test), allow(dead_code))] -fn picker_page_from_rollout_page(page: ThreadsPage) -> PickerPage { - PickerPage { - rows: rows_from_items(page.items), - next_cursor: page.next_cursor.map(PageCursor::Rollout), - num_scanned_files: page.num_scanned_files, - reached_scan_cap: page.reached_scan_cap, - } -} - -#[cfg_attr(not(test), allow(dead_code))] -fn rows_from_items(items: Vec) -> Vec { - items.into_iter().map(|item| head_to_row(&item)).collect() -} - -#[cfg_attr(not(test), allow(dead_code))] -fn head_to_row(item: &ThreadItem) -> Row { - let created_at = item.created_at.as_deref().and_then(parse_timestamp_str); - let updated_at = item - .updated_at - .as_deref() - .and_then(parse_timestamp_str) - .or(created_at); - - let preview = item - .first_user_message - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| String::from("(no message yet)")); - - Row { - path: Some(item.path.clone()), - preview, - thread_id: item.thread_id, - thread_name: None, - created_at, - updated_at, - cwd: item.cwd.clone(), - git_branch: item.git_branch.clone(), - } -} - fn row_from_app_server_thread(thread: Thread) -> Option { let thread_id = match ThreadId::from_string(&thread.id) { Ok(thread_id) => thread_id, @@ -1176,10 +993,7 @@ fn thread_list_params( ThreadListParams { cursor, limit: Some(PAGE_SIZE as u32), - sort_key: Some(match sort_key { - ThreadSortKey::CreatedAt => AppServerThreadSortKey::CreatedAt, - ThreadSortKey::UpdatedAt => AppServerThreadSortKey::UpdatedAt, - }), + sort_key: Some(sort_key), sort_direction: None, model_providers: match provider_filter { ProviderFilter::Any => None, @@ -1694,198 +1508,37 @@ mod tests { use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; - use serde_json::json; - use std::fs::FileTimes; - use std::fs::OpenOptions; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; - fn make_item(path: &str, ts: &str, preview: &str) -> ThreadItem { - ThreadItem { - path: PathBuf::from(path), - first_user_message: Some(preview.to_string()), - created_at: Some(ts.to_string()), - updated_at: Some(ts.to_string()), - ..Default::default() - } - } - - fn cursor_from_str(repr: &str) -> Cursor { - serde_json::from_str::(&format!("\"{repr}\"")) - .expect("cursor format should deserialize") - } - fn page( - items: Vec, - next_cursor: Option, + rows: Vec, + next_cursor: Option<&str>, num_scanned_files: usize, reached_scan_cap: bool, ) -> PickerPage { - picker_page_from_rollout_page(ThreadsPage { - items, - next_cursor, + PickerPage { + rows, + next_cursor: next_cursor.map(|cursor| PageCursor::AppServer(cursor.to_string())), num_scanned_files, reached_scan_cap, - }) + } } - #[allow(dead_code)] - fn set_rollout_mtime(path: &Path, updated_at: DateTime) { - let times = FileTimes::new().set_modified(updated_at.into()); - OpenOptions::new() - .append(true) - .open(path) - .expect("open rollout") - .set_times(times) - .expect("set times"); - } - - // TODO(jif) fix - // #[tokio::test] - // async fn resume_picker_orders_by_updated_at() { - // use uuid::Uuid; - // - // let tempdir = tempfile::tempdir().expect("tempdir"); - // let sessions_root = tempdir.path().join("sessions"); - // std::fs::create_dir_all(&sessions_root).expect("mkdir sessions root"); - // - // let now = Utc::now(); - // - // let write_rollout = |ts: DateTime, preview: &str| -> PathBuf { - // let dir = sessions_root - // .join(ts.format("%Y").to_string()) - // .join(ts.format("%m").to_string()) - // .join(ts.format("%d").to_string()); - // std::fs::create_dir_all(&dir).expect("mkdir date dirs"); - // let filename = format!( - // "rollout-{}-{}.jsonl", - // ts.format("%Y-%m-%dT%H-%M-%S"), - // Uuid::new_v4() - // ); - // let path = dir.join(filename); - // let meta = SessionMeta { - // id: ThreadId::new(), - // forked_from_id: None, - // timestamp: ts.to_rfc3339(), - // cwd: PathBuf::from("/tmp"), - // originator: String::from("user"), - // cli_version: String::from("0.0.0"), - // source: SessionSource::Cli, - // model_provider: Some(String::from("openai")), - // base_instructions: None, - // dynamic_tools: None, - // }; - // let meta_line = RolloutLine { - // timestamp: ts.to_rfc3339(), - // item: RolloutItem::SessionMeta(SessionMetaLine { meta, git: None }), - // }; - // let user_line = RolloutLine { - // timestamp: ts.to_rfc3339(), - // item: RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { - // message: preview.to_string(), - // images: None, - // text_elements: Vec::new(), - // local_images: Vec::new(), - // })), - // }; - // let meta_json = serde_json::to_string(&meta_line).expect("serialize meta"); - // let user_json = serde_json::to_string(&user_line).expect("serialize user"); - // std::fs::write(&path, format!("{meta_json}\n{user_json}\n")).expect("write rollout"); - // path - // }; - // - // let created_a = now - Duration::minutes(1); - // let created_b = now - Duration::minutes(2); - // - // let path_a = write_rollout(created_a, "A (created newer)"); - // let path_b = write_rollout(created_b, "B (created older)"); - // - // set_rollout_mtime(&path_a, now - Duration::minutes(10)); - // set_rollout_mtime(&path_b, now - Duration::seconds(10)); - // - // let page = RolloutRecorder::list_threads( - // tempdir.path(), - // PAGE_SIZE, - // None, - // ThreadSortKey::UpdatedAt, - // INTERACTIVE_SESSION_SOURCES, - // Some(&[String::from("openai")]), - // "openai", - // ) - // .await - // .expect("list threads"); - // - // let rows = rows_from_items(page.items); - // let previews: Vec = rows.iter().map(|row| row.preview.clone()).collect(); - // - // assert_eq!( - // previews, - // vec![ - // "B (created older)".to_string(), - // "A (created newer)".to_string() - // ] - // ); - // } - - #[test] - fn head_to_row_uses_first_user_message() { - let item = ThreadItem { - path: PathBuf::from("/tmp/a.jsonl"), - first_user_message: Some("real question".to_string()), - created_at: Some("2025-01-01T00:00:00Z".into()), - updated_at: Some("2025-01-01T00:00:00Z".into()), - ..Default::default() - }; - let row = head_to_row(&item); - assert_eq!(row.preview, "real question"); - } - - #[test] - fn rows_from_items_preserves_backend_order() { - // Construct two items with different timestamps and real user text. - let a = ThreadItem { - path: PathBuf::from("/tmp/a.jsonl"), - first_user_message: Some("A".to_string()), - created_at: Some("2025-01-01T00:00:00Z".into()), - updated_at: Some("2025-01-01T00:00:00Z".into()), - ..Default::default() - }; - let b = ThreadItem { - path: PathBuf::from("/tmp/b.jsonl"), - first_user_message: Some("B".to_string()), - created_at: Some("2025-01-02T00:00:00Z".into()), - updated_at: Some("2025-01-02T00:00:00Z".into()), - ..Default::default() - }; - let rows = rows_from_items(vec![a, b]); - assert_eq!(rows.len(), 2); - // Preserve the given order even if timestamps differ; backend already provides newest-first. - assert!(rows[0].preview.contains('A')); - assert!(rows[1].preview.contains('B')); - } - - #[test] - fn row_uses_tail_timestamp_for_updated_at() { - let item = ThreadItem { - path: PathBuf::from("/tmp/a.jsonl"), - first_user_message: Some("Hello".to_string()), - created_at: Some("2025-01-01T00:00:00Z".into()), - updated_at: Some("2025-01-01T01:00:00Z".into()), - ..Default::default() - }; - - let row = head_to_row(&item); - let expected_created = chrono::DateTime::parse_from_rfc3339("2025-01-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - let expected_updated = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z") - .unwrap() - .with_timezone(&Utc); - - assert_eq!(row.created_at, Some(expected_created)); - assert_eq!(row.updated_at, Some(expected_updated)); + fn make_row(path: &str, ts: &str, preview: &str) -> Row { + let timestamp = parse_timestamp_str(ts).expect("timestamp should parse"); + Row { + path: Some(PathBuf::from(path)), + preview: preview.to_string(), + thread_id: None, + thread_name: None, + created_at: Some(timestamp), + updated_at: Some(timestamp), + cwd: None, + git_branch: None, + } } #[test] @@ -1942,7 +1595,6 @@ mod tests { fn remote_picker_does_not_filter_rows_by_local_cwd() { let loader: PageLoader = Arc::new(|_| {}); let state = PickerState::new( - PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, ProviderFilter::Any, @@ -1973,7 +1625,6 @@ mod tests { let loader: PageLoader = Arc::new(|_| {}); let mut state = PickerState::new( - PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), @@ -2052,7 +1703,6 @@ mod tests { let loader: PageLoader = Arc::new(|_| {}); let mut state = PickerState::new( - PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), @@ -2081,333 +1731,10 @@ mod tests { assert_snapshot!("resume_picker_search_error", snapshot); } - // TODO(jif) fix - // #[tokio::test] - // async fn resume_picker_screen_snapshot() { - // use crate::custom_terminal::Terminal; - // use crate::test_backend::VT100Backend; - // use uuid::Uuid; - // - // // Create real rollout files so the snapshot uses the actual listing pipeline. - // let tempdir = tempfile::tempdir().expect("tempdir"); - // let sessions_root = tempdir.path().join("sessions"); - // std::fs::create_dir_all(&sessions_root).expect("mkdir sessions root"); - // - // let now = Utc::now(); - // - // // Helper to write a rollout file with minimal meta + one user message. - // let write_rollout = |ts: DateTime, cwd: &str, branch: &str, preview: &str| { - // let dir = sessions_root - // .join(ts.format("%Y").to_string()) - // .join(ts.format("%m").to_string()) - // .join(ts.format("%d").to_string()); - // std::fs::create_dir_all(&dir).expect("mkdir date dirs"); - // let filename = format!( - // "rollout-{}-{}.jsonl", - // ts.format("%Y-%m-%dT%H-%M-%S"), - // Uuid::new_v4() - // ); - // let path = dir.join(filename); - // let meta = serde_json::json!({ - // "timestamp": ts.to_rfc3339(), - // "item": { - // "SessionMeta": { - // "meta": { - // "id": Uuid::new_v4(), - // "timestamp": ts.to_rfc3339(), - // "cwd": cwd, - // "originator": "user", - // "cli_version": "0.0.0", - // "source": "Cli", - // "model_provider": "openai", - // } - // } - // } - // }); - // let user = serde_json::json!({ - // "timestamp": ts.to_rfc3339(), - // "item": { - // "EventMsg": { - // "UserMessage": { - // "message": preview, - // "images": null - // } - // } - // } - // }); - // let branch_meta = serde_json::json!({ - // "timestamp": ts.to_rfc3339(), - // "item": { - // "EventMsg": { - // "SessionMeta": { - // "meta": { - // "git_branch": branch - // } - // } - // } - // } - // }); - // std::fs::write(&path, format!("{meta}\n{user}\n{branch_meta}\n")) - // .expect("write rollout"); - // }; - // - // write_rollout( - // now - Duration::seconds(42), - // "/tmp/project", - // "feature/resume", - // "Fix resume picker timestamps", - // ); - // write_rollout( - // now - Duration::minutes(35), - // "/tmp/other", - // "main", - // "Investigate lazy pagination cap", - // ); - // - // let loader: PageLoader = Arc::new(|_| {}); - // let mut state = PickerState::new( - // PathBuf::from("/tmp"), - // FrameRequester::test_dummy(), - // loader, - // String::from("openai"), - // true, - // None, - // SessionPickerAction::Resume, - // ); - // - // let page = RolloutRecorder::list_threads( - // &state.codex_home, - // PAGE_SIZE, - // None, - // ThreadSortKey::CreatedAt, - // INTERACTIVE_SESSION_SOURCES, - // Some(&[String::from("openai")]), - // "openai", - // ) - // .await - // .expect("list conversations"); - // - // let rows = rows_from_items(page.items); - // state.all_rows = rows.clone(); - // state.filtered_rows = rows; - // state.view_rows = Some(4); - // state.selected = 0; - // state.scroll_top = 0; - // state.update_view_rows(4); - // - // let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); - // - // let width: u16 = 80; - // let height: u16 = 9; - // let backend = VT100Backend::new(width, height); - // let mut terminal = Terminal::with_options(backend).expect("terminal"); - // terminal.set_viewport_area(Rect::new(0, 0, width, height)); - // - // { - // let mut frame = terminal.get_frame(); - // let area = frame.area(); - // let [header, search, columns, list, hint] = Layout::vertical([ - // Constraint::Length(1), - // Constraint::Length(1), - // Constraint::Length(1), - // Constraint::Min(area.height.saturating_sub(4)), - // Constraint::Length(1), - // ]) - // .areas(area); - // - // frame.render_widget_ref( - // Line::from(vec![ - // "Resume a previous session".bold().cyan(), - // " ".into(), - // "Sort:".dim(), - // " ".into(), - // "Created at".magenta(), - // ]), - // header, - // ); - // - // frame.render_widget_ref(Line::from("Type to search".dim()), search); - // - // render_column_headers(&mut frame, columns, &metrics, state.sort_key); - // render_list(&mut frame, list, &state, &metrics); - // - // let hint_line: Line = vec![ - // key_hint::plain(KeyCode::Enter).into(), - // " to resume ".dim(), - // " ".dim(), - // key_hint::plain(KeyCode::Esc).into(), - // " to start new ".dim(), - // " ".dim(), - // key_hint::ctrl(KeyCode::Char('c')).into(), - // " to quit ".dim(), - // " ".dim(), - // key_hint::plain(KeyCode::Tab).into(), - // " to toggle sort ".dim(), - // ] - // .into(); - // frame.render_widget_ref(hint_line, hint); - // } - // terminal.flush().expect("flush"); - // - // let snapshot = terminal.backend().to_string(); - // assert_snapshot!("resume_picker_screen", snapshot); - // } - - #[tokio::test] - async fn resume_picker_thread_names_snapshot() { - use crate::custom_terminal::Terminal; - use crate::test_backend::VT100Backend; - use ratatui::layout::Constraint; - use ratatui::layout::Layout; - - let tempdir = tempfile::tempdir().expect("tempdir"); - let session_index_path = tempdir.path().join("session_index.jsonl"); - - let id1 = - ThreadId::from_string("11111111-1111-1111-1111-111111111111").expect("thread id 1"); - let id2 = - ThreadId::from_string("22222222-2222-2222-2222-222222222222").expect("thread id 2"); - let entries = vec![ - json!({ - "id": id1, - "thread_name": "Keep this for now", - "updated_at": "2025-01-01T00:00:00Z", - }), - json!({ - "id": id2, - "thread_name": "Named thread", - "updated_at": "2025-01-01T00:00:00Z", - }), - ]; - let mut out = String::new(); - for entry in entries { - out.push_str(&serde_json::to_string(&entry).expect("session index entry")); - out.push('\n'); - } - std::fs::write(&session_index_path, out).expect("write session index"); - - let loader: PageLoader = Arc::new(|_| {}); - let mut state = PickerState::new( - tempdir.path().to_path_buf(), - FrameRequester::test_dummy(), - loader, - ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, - /*filter_cwd*/ None, - SessionPickerAction::Resume, - ); - - let now = Utc::now(); - let rows = vec![ - Row { - path: Some(PathBuf::from("/tmp/a.jsonl")), - preview: String::from("First message preview"), - thread_id: Some(id1), - thread_name: None, - created_at: None, - updated_at: Some(now - Duration::days(2)), - cwd: None, - git_branch: None, - }, - Row { - path: Some(PathBuf::from("/tmp/b.jsonl")), - preview: String::from("Second message preview"), - thread_id: Some(id2), - thread_name: None, - created_at: None, - updated_at: Some(now - Duration::days(3)), - cwd: None, - git_branch: None, - }, - ]; - state.all_rows = rows.clone(); - state.filtered_rows = rows; - state.view_rows = Some(2); - state.selected = 0; - state.scroll_top = 0; - state.update_view_rows(/*rows*/ 2); - - state.update_thread_names().await; - - state.relative_time_reference = Some(now); - let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all, now); - - let width: u16 = 80; - let height: u16 = 5; - let backend = VT100Backend::new(width, height); - let mut terminal = Terminal::with_options(backend).expect("terminal"); - terminal.set_viewport_area(Rect::new(0, 0, width, height)); - - { - let mut frame = terminal.get_frame(); - let area = frame.area(); - let segments = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(area); - render_column_headers(&mut frame, segments[0], &metrics, state.sort_key); - render_list(&mut frame, segments[1], &state, &metrics); - } - terminal.flush().expect("flush"); - - let snapshot = terminal.backend().to_string(); - assert_snapshot!("resume_picker_thread_names", snapshot); - } - - #[tokio::test] - async fn update_thread_names_prefers_local_session_index_names() { - let tempdir = tempfile::tempdir().expect("tempdir"); - let thread_id = - ThreadId::from_string("11111111-1111-1111-1111-111111111111").expect("thread id"); - let session_index_entry = json!({ - "id": thread_id, - "thread_name": "Saved session name", - "updated_at": "2025-01-01T00:00:00Z", - }); - std::fs::write( - tempdir.path().join("session_index.jsonl"), - format!("{session_index_entry}\n"), - ) - .expect("write session index"); - - let loader: PageLoader = Arc::new(|_| {}); - let mut state = PickerState::new( - tempdir.path().to_path_buf(), - FrameRequester::test_dummy(), - loader, - ProviderFilter::MatchDefault(String::from("openai")), - /*show_all*/ true, - /*filter_cwd*/ None, - SessionPickerAction::Resume, - ); - - state.all_rows = vec![Row { - path: Some(PathBuf::from("/tmp/a.jsonl")), - preview: String::from("First prompt"), - thread_id: Some(thread_id), - thread_name: Some(String::from("stale backend title")), - created_at: None, - updated_at: None, - cwd: None, - git_branch: None, - }]; - state.filtered_rows = state.all_rows.clone(); - - state.update_thread_names().await; - - assert_eq!( - state.all_rows[0].thread_name, - Some(String::from("Saved session name")) - ); - assert_eq!( - state.filtered_rows[0].display_preview(), - "Saved session name" - ); - } - #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { let loader: PageLoader = Arc::new(|_| {}); let mut state = PickerState::new( - PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), @@ -2419,30 +1746,26 @@ mod tests { state.reset_pagination(); state.ingest_page(page( vec![ - make_item("/tmp/a.jsonl", "2025-01-03T00:00:00Z", "third"), - make_item("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "second"), + make_row("/tmp/a.jsonl", "2025-01-03T00:00:00Z", "third"), + make_row("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "second"), ], - Some(cursor_from_str("2025-01-02T00:00:00Z")), + Some("2025-01-02T00:00:00Z"), /*num_scanned_files*/ 2, /*reached_scan_cap*/ false, )); state.ingest_page(page( vec![ - make_item("/tmp/a.jsonl", "2025-01-03T00:00:00Z", "duplicate"), - make_item("/tmp/c.jsonl", "2025-01-01T00:00:00Z", "first"), + make_row("/tmp/a.jsonl", "2025-01-03T00:00:00Z", "duplicate"), + make_row("/tmp/c.jsonl", "2025-01-01T00:00:00Z", "first"), ], - Some(cursor_from_str("2025-01-01T00:00:00Z")), + Some("2025-01-01T00:00:00Z"), /*num_scanned_files*/ 2, /*reached_scan_cap*/ false, )); state.ingest_page(page( - vec![make_item( - "/tmp/d.jsonl", - "2024-12-31T23:00:00Z", - "very old", - )], + vec![make_row("/tmp/d.jsonl", "2024-12-31T23:00:00Z", "very old")], /*next_cursor*/ None, /*num_scanned_files*/ 1, /*reached_scan_cap*/ false, @@ -2472,7 +1795,6 @@ mod tests { }); let mut state = PickerState::new( - PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), @@ -2483,10 +1805,10 @@ mod tests { state.reset_pagination(); state.ingest_page(page( vec![ - make_item("/tmp/a.jsonl", "2025-01-01T00:00:00Z", "one"), - make_item("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "two"), + make_row("/tmp/a.jsonl", "2025-01-01T00:00:00Z", "one"), + make_row("/tmp/b.jsonl", "2025-01-02T00:00:00Z", "two"), ], - Some(cursor_from_str("2025-01-03T00:00:00Z")), + Some("2025-01-03T00:00:00Z"), /*num_scanned_files*/ 2, /*reached_scan_cap*/ false, )); @@ -2551,7 +1873,6 @@ mod tests { }); let mut state = PickerState::new( - PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), @@ -2581,7 +1902,6 @@ mod tests { async fn page_navigation_uses_view_rows() { let loader: PageLoader = Arc::new(|_| {}); let mut state = PickerState::new( - PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), @@ -2595,7 +1915,7 @@ mod tests { let ts = format!("2025-01-{:02}T00:00:00Z", idx + 1); let preview = format!("item-{idx}"); let path = format!("/tmp/item-{idx}.jsonl"); - items.push(make_item(&path, &ts, &preview)); + items.push(make_row(&path, &ts, &preview)); } state.reset_pagination(); @@ -2629,7 +1949,6 @@ mod tests { async fn enter_on_row_without_resolvable_thread_id_shows_inline_error() { let loader: PageLoader = Arc::new(|_| {}); let mut state = PickerState::new( - PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), @@ -2669,7 +1988,6 @@ mod tests { async fn enter_on_pathless_thread_uses_thread_id() { let loader: PageLoader = Arc::new(|_| {}); let mut state = PickerState::new( - PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), @@ -2739,7 +2057,6 @@ mod tests { async fn up_at_bottom_does_not_scroll_when_visible() { let loader: PageLoader = Arc::new(|_| {}); let mut state = PickerState::new( - PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), @@ -2753,7 +2070,7 @@ mod tests { let ts = format!("2025-02-{:02}T00:00:00Z", idx + 1); let preview = format!("item-{idx}"); let path = format!("/tmp/item-{idx}.jsonl"); - items.push(make_item(&path, &ts, &preview)); + items.push(make_row(&path, &ts, &preview)); } state.reset_pagination(); @@ -2787,7 +2104,6 @@ mod tests { }); let mut state = PickerState::new( - PathBuf::from("/tmp"), FrameRequester::test_dummy(), loader, ProviderFilter::MatchDefault(String::from("openai")), @@ -2797,12 +2113,12 @@ mod tests { ); state.reset_pagination(); state.ingest_page(page( - vec![make_item( + vec![make_row( "/tmp/start.jsonl", "2025-01-01T00:00:00Z", "alpha", )], - Some(cursor_from_str("2025-01-02T00:00:00Z")), + Some("2025-01-02T00:00:00Z"), /*num_scanned_files*/ 1, /*reached_scan_cap*/ false, )); @@ -2820,8 +2136,8 @@ mod tests { request_token: first_request.request_token, search_token: first_request.search_token, page: Ok(page( - vec![make_item("/tmp/beta.jsonl", "2025-01-02T00:00:00Z", "beta")], - Some(cursor_from_str("2025-01-03T00:00:00Z")), + vec![make_row("/tmp/beta.jsonl", "2025-01-02T00:00:00Z", "beta")], + Some("2025-01-03T00:00:00Z"), /*num_scanned_files*/ 5, /*reached_scan_cap*/ false, )), @@ -2842,12 +2158,12 @@ mod tests { request_token: second_request.request_token, search_token: second_request.search_token, page: Ok(page( - vec![make_item( + vec![make_row( "/tmp/match.jsonl", "2025-01-03T00:00:00Z", "target log", )], - Some(cursor_from_str("2025-01-04T00:00:00Z")), + Some("2025-01-04T00:00:00Z"), /*num_scanned_files*/ 7, /*reached_scan_cap*/ false, )),