Branch conversations when editing earlier TUI prompts (#33201)

## What changed

- Fork the conversation immediately before the selected prompt instead of rolling back the current thread in place, preserving the original conversation.
- Reopen the selected prompt in the new conversation with its images, text elements, and mention bindings intact.
- Start a fresh conversation when editing the first prompt, show a dedicated continuation notice, and restore the prompt with an error if branching fails.

## Testing

- Cover branching before first and later prompts, source preservation, prompt restoration, hidden review prompts, mid-turn steering, in-progress turns, stale transcript entries, and mention bindings.

GitOrigin-RevId: 15473535b3f9733b0300711a498969e6d389db79
This commit is contained in:
Felipe Coury authored and copyberry committed 2026-07-15 01:36:44 +00:00
1 parent 72b41c55fb
commit 469ce0db51
17 files changed
+928 -549

No files matched your search

@@ -742,6 +742,28 @@ async fn forked_thread_history_line_without_name_shows_id_once_snapshot() {
assert_chatwidget_snapshot!("forked_thread_history_line_without_name", combined);
}
#[tokio::test]
async fn prompt_edit_thread_history_line_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.emit_prompt_edit_thread_event();
let history_cell = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 2), async {
loop {
match rx.recv().await {
Some(AppEvent::InsertHistoryCell(cell)) => break cell,
Some(_) => continue,
None => panic!("app event channel closed before prompt edit history was emitted"),
}
}
})
.await
.expect("timed out waiting for prompt edit history");
let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80));
assert_chatwidget_snapshot!("prompt_edit_thread_history_line", combined);
}
#[tokio::test]
async fn app_server_forked_thread_history_line_uses_app_server_title_snapshot() {
let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+15 -9
View File
@@ -1,6 +1,12 @@
use super::*;
use pretty_assertions::assert_eq;
fn set_composer_text(chat: &mut ChatWidget, text: &str) {
chat.bottom_pane
.set_composer_text(text.to_string(), Vec::new(), Vec::new());
chat.refresh_plan_mode_nudge();
}
#[test]
fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() {
assert!(contains_plan_keyword("plan"));
@@ -14,19 +20,19 @@ fn plan_mode_nudge_matches_only_standalone_plain_text_keyword() {
#[tokio::test]
async fn plan_mode_nudge_shows_only_for_eligible_default_mode_drafts() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
chat.set_composer_text("/plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "/plan");
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
chat.set_composer_text("!plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "!plan");
chat.pre_draw_tick();
assert!(!chat.bottom_pane.plan_mode_nudge_visible());
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
.expect("expected plan collaboration mode");
chat.set_collaboration_mask(plan_mask);
@@ -37,7 +43,7 @@ async fn plan_mode_nudge_shows_only_for_eligible_default_mode_drafts() {
#[tokio::test]
async fn plan_mode_nudge_hides_while_task_or_modal_is_active() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
@@ -65,7 +71,7 @@ async fn plan_mode_nudge_dismissal_is_scoped_to_current_thread() {
let first_thread = ThreadId::new();
let second_thread = ThreadId::new();
chat.thread_id = Some(first_thread);
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
@@ -89,7 +95,7 @@ async fn plan_mode_nudge_dismissal_is_scoped_to_current_thread() {
#[tokio::test]
async fn plan_mode_nudge_shift_tab_uses_existing_mode_cycle_path() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert!(chat.bottom_pane.plan_mode_nudge_visible());
@@ -105,7 +111,7 @@ async fn plan_mode_nudge_snapshot() {
chat.set_token_info(Some(make_token_info(
/*total_tokens*/ 50_000, /*context_window*/ 100_000,
)));
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert_chatwidget_snapshot!("plan_mode_nudge", render_bottom_popup(&chat, /*width*/ 80));
@@ -114,7 +120,7 @@ async fn plan_mode_nudge_snapshot() {
#[tokio::test]
async fn plan_mode_nudge_narrow_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_composer_text("make a plan".to_string(), Vec::new(), Vec::new());
set_composer_text(&mut chat, "make a plan");
chat.pre_draw_tick();
assert_chatwidget_snapshot!(