From b2424cb635094fbd439f15aa755e862fe03986b6 Mon Sep 17 00:00:00 2001 From: pap-openai Date: Thu, 5 Feb 2026 13:24:55 +0000 Subject: [PATCH] adding fork information (UI) when forking (#10246) - shows `/fork` command that ran in prev session - shows `session forked from name (uuid) || uuid (if name is not set)` as an event in new session --- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout/mod.rs | 1 + codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/chatwidget.rs | 49 ++++++++++++++ ...et__tests__forked_thread_history_line.snap | 5 ++ ...rked_thread_history_line_without_name.snap | 5 ++ codex-rs/tui/src/chatwidget/tests.rs | 64 +++++++++++++++++++ 7 files changed, 127 insertions(+) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__forked_thread_history_line.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__forked_thread_history_line_without_name.snap diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 50a1f8538..7b69c4370 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -110,6 +110,7 @@ pub use rollout::SessionMeta; pub use rollout::find_archived_thread_path_by_id_str; #[deprecated(note = "use find_thread_path_by_id_str")] pub use rollout::find_conversation_path_by_id_str; +pub use rollout::find_thread_name_by_id; pub use rollout::find_thread_path_by_id_str; pub use rollout::find_thread_path_by_name_str; pub use rollout::list::Cursor; diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 60775d04b..5e6bd9bbf 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -24,6 +24,7 @@ pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str; pub use list::rollout_date_parts; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; +pub use session_index::find_thread_name_by_id; pub use session_index::find_thread_path_by_name_str; #[cfg(test)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 99164efa6..e2d35dc08 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1409,6 +1409,8 @@ impl App { self.chat_widget.thread_id(), self.chat_widget.thread_name(), ); + self.chat_widget + .add_plain_history_lines(vec!["/fork".magenta().into()]); if let Some(path) = self.chat_widget.rollout_path() { match self .server diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ee8400bbf..bbe6321a3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -37,6 +37,7 @@ use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; use codex_core::features::FEATURES; use codex_core::features::Feature; +use codex_core::find_thread_name_by_id; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::models_manager::manager::ModelsManager; @@ -808,6 +809,7 @@ impl ChatWidget { self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); let initial_messages = event.initial_messages.clone(); + let forked_from_id = event.forked_from_id; let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); self.current_collaboration_mode = self.current_collaboration_mode.with_updates( @@ -843,11 +845,58 @@ impl ChatWidget { if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } + if let Some(forked_from_id) = forked_from_id { + self.emit_forked_thread_event(forked_from_id); + } if !self.suppress_session_configured_redraw { self.request_redraw(); } } + fn emit_forked_thread_event(&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(); + } + } + }); + } + fn on_thread_name_updated(&mut self, event: codex_core::protocol::ThreadNameUpdatedEvent) { if self.thread_id == Some(event.thread_id) { self.thread_name = event.thread_name; 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 new file mode 100644 index 000000000..d089f5963 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__forked_thread_history_line.snap @@ -0,0 +1,5 @@ +--- +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/snapshots/codex_tui__chatwidget__tests__forked_thread_history_line_without_name.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__forked_thread_history_line_without_name.snap new file mode 100644 index 000000000..f25eb5364 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__forked_thread_history_line_without_name.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: combined +--- +• Thread forked from 019c2d47-4935-7423-a190-05691f566092 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ba2816009..90b275b15 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -246,6 +246,70 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { assert_eq!(stored_images, local_images); } +#[tokio::test] +async fn forked_thread_history_line_includes_name_and_id_snapshot() { + let (chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + let mut chat = chat; + let temp = tempdir().expect("tempdir"); + chat.config.codex_home = temp.path().to_path_buf(); + + 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(80)); + + assert!( + combined.contains("Thread forked from"), + "expected forked thread message in history" + ); + assert_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(None).await; + let mut chat = chat; + let temp = tempdir().expect("tempdir"); + chat.config.codex_home = temp.path().to_path_buf(); + + let forked_from_id = + ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id"); + 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(80)); + + assert_snapshot!("forked_thread_history_line_without_name", combined); +} + #[tokio::test] async fn submission_preserves_text_elements_and_local_images() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;