From 7924f95421a326bb66426a3ce232b72550948470 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Sat, 14 Nov 2026 07:34:26 +0000 Subject: [PATCH] Keep archived threads read-only and support unarchiving in the TUI (#61486) ## What changed - Show an archived-thread notice and freeze input while preserving the cached transcript, drafts, pending steers, and question answers. - Allow `U` to unarchive and resume the displayed thread, and automatically resume it when another client unarchives it. - Preserve archived state across reconnects and thread selection, and retain drafts when recovery fails or encounters a writer conflict. - Dismiss transient composer searches and suppress automatic submissions when entering a read-only view. ## Testing Add regression tests and notice snapshots covering archive recovery, external unarchiving, writer conflicts, reconnects, input preservation, and agent navigation. GitOrigin-RevId: 695c19790b87df83e51095b400f605e37b309fc1 --- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/archived_thread.rs | 76 +++++++ codex-rs/tui/src/app/event_dispatch.rs | 8 + codex-rs/tui/src/app/input.rs | 41 ++-- codex-rs/tui/src/app/reconnect.rs | 28 ++- codex-rs/tui/src/app/session_lifecycle.rs | 31 ++- codex-rs/tui/src/app/sidebar_loading.rs | 4 +- .../src/app/tests/active_reconnect_tests.rs | 42 ++-- .../src/app/tests/agents_navigation_tests.rs | 34 ++- .../src/app/tests/archived_thread_tests.rs | 207 ++++++++++++++++++ .../app/tests/session_lifecycle_requests.rs | 3 + ...ead_tests__archived_thread_notice_100.snap | 8 + ...read_tests__archived_thread_notice_40.snap | 11 + codex-rs/tui/src/app/thread_events.rs | 3 + codex-rs/tui/src/app/thread_routing.rs | 1 + codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/bottom_pane/mod.rs | 7 +- codex-rs/tui/src/chatwidget.rs | 33 +-- codex-rs/tui/src/chatwidget/constructor.rs | 2 +- codex-rs/tui/src/chatwidget/input_flow.rs | 5 +- codex-rs/tui/src/chatwidget/input_restore.rs | 5 + codex-rs/tui/src/chatwidget/read_only.rs | 68 ++++++ codex-rs/tui/src/chatwidget/rendering.rs | 49 +++-- .../tui/src/chatwidget/rendering_tests.rs | 2 +- .../chatwidget/tests/composer_submission.rs | 40 ++++ .../src/chatwidget/tests/questions_tests.rs | 17 ++ 26 files changed, 603 insertions(+), 126 deletions(-) create mode 100644 codex-rs/tui/src/app/archived_thread.rs create mode 100644 codex-rs/tui/src/app/tests/archived_thread_tests.rs create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__archived_thread_tests__archived_thread_notice_100.snap create mode 100644 codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__archived_thread_tests__archived_thread_notice_40.snap create mode 100644 codex-rs/tui/src/chatwidget/read_only.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8be6926502..bbbac3c6d4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -249,6 +249,7 @@ mod new_session; mod quick_dials; mod turn_tips; pub(crate) use new_session::has_launch_setting; +mod archived_thread; mod clipboard; mod footer_selection; mod native_history; diff --git a/codex-rs/tui/src/app/archived_thread.rs b/codex-rs/tui/src/app/archived_thread.rs new file mode 100644 index 0000000000..b6d5c3a992 --- /dev/null +++ b/codex-rs/tui/src/app/archived_thread.rs @@ -0,0 +1,76 @@ +//! Archive notifications freeze cached threads; external unarchive resumes the displayed thread. +//! Archive metadata is separate from attachment; archived channels only replay cached events. + +use super::*; +use crate::chatwidget::ReadOnlyThread; + +pub(super) fn update_archived_thread( + app: &mut App, + thread_id: ThreadId, + notification: &ServerNotification, +) { + let Some(channel) = app.thread_event_channels.get_mut(&thread_id) else { + return; + }; + match notification { + ServerNotification::ThreadArchived(_) => { + channel.archived = true; + channel.mark_replay_only(); + } + ServerNotification::ThreadUnarchived(_) if channel.archived => { + channel.archived = false; + } + _ => return, + } + let resume = !channel.archived; + if app.current_displayed_thread_id() == Some(thread_id) { + show_cached_read_only_thread(app, thread_id); + if resume { + app.app_event_tx + .send(AppEvent::ResumeUnarchivedThread(thread_id)); + } + } +} + +pub(super) async fn resume_displayed_thread( + app: &mut App, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + thread_id: ThreadId, +) { + let target = SessionTarget { + backend_id: app_server.backend_id(), + path: app + .primary_session_configured + .as_ref() + .and_then(|session| session.rollout_path.clone()), + thread_id, + cwd: None, + history_mode: None, + }; + if let Ok(AppRunControl::Exit(_)) = app.resume_target_session(tui, app_server, target).await { + app.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate)); + } + // A writer conflict does not establish that the thread was unarchived. + // Keep the archive flag if resume could not replace the archived view. + if app.current_displayed_thread_id() == Some(thread_id) + && !app.chat_widget.is_archived_view() + && let Some(channel) = app.thread_event_channels.get_mut(&thread_id) + { + channel.archived = false; + } +} + +pub(super) fn show_cached_read_only_thread(app: &mut App, thread_id: ThreadId) { + let Some(channel) = app.thread_event_channels.get(&thread_id) else { + return; + }; + let reason = if channel.archived { + ReadOnlyThread::Archived + } else if channel.attachment() == ThreadEventAttachment::ExternalWriter { + ReadOnlyThread::ExternalWriter + } else { + return; + }; + app.chat_widget.show_read_only_thread(reason); +} diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 1da04329b3..6b21f3168b 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -608,6 +608,14 @@ impl App { } tui.frame_requester().schedule_frame(); } + AppEvent::ResumeUnarchivedThread(thread_id) => { + if self.current_displayed_thread_id() == Some(thread_id) + && self.chat_widget.is_archived_view() + && self.thread_event_channels.get(&thread_id).is_some_and(|channel| !channel.archived) + { + archived_thread::resume_displayed_thread(self, tui, app_server, thread_id).await; + } + } AppEvent::ResumeSessionByIdOrName(id_or_name) => { match crate::lookup_session_target_with_app_server( crate::resume_picker::SessionPickerAction::Resume, diff --git a/codex-rs/tui/src/app/input.rs b/codex-rs/tui/src/app/input.rs index cf17c8275d..f19bf129f1 100644 --- a/codex-rs/tui/src/app/input.rs +++ b/codex-rs/tui/src/app/input.rs @@ -496,7 +496,8 @@ impl App { self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate)); return; } - if matches!(key_event.code, KeyCode::Char('f' | 'F')) + if self.chat_widget.is_external_writer_view() + && matches!(key_event.code, KeyCode::Char('f' | 'F')) && (modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT) { self.chat_widget.fork_in_progress = true; @@ -504,25 +505,35 @@ impl App { .send(AppEvent::ForkCurrentSession { name: None }); return; } - if matches!(key_event.code, KeyCode::Char('r' | 'R')) + let recovery_key = if self.chat_widget.is_archived_view() { + matches!(key_event.code, KeyCode::Char('u' | 'U')) + } else { + matches!(key_event.code, KeyCode::Char('r' | 'R')) + }; + if recovery_key && (modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT) && let Some(thread_id) = self.current_displayed_thread_id() { - let target = SessionTarget { - backend_id: app_server.backend_id(), - path: self - .primary_session_configured - .as_ref() - .and_then(|session| session.rollout_path.clone()), - thread_id, - cwd: None, - history_mode: None, - }; - if let Ok(AppRunControl::Exit(_)) = - self.resume_target_session(tui, app_server, target).await + if self.chat_widget.is_archived_view() + && let Err(error) = app_server.thread_unarchive(thread_id).await + && !crate::app_server_session::is_active_writer_error(&error) { - self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate)); + // Another client may have restored it before its notification arrived. + let already_unarchived = error.chain().any(|cause| { + cause.to_string().contains(&format!( + "no archived rollout found for thread id {thread_id}" + )) + }) && app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + .is_ok(); + if !already_unarchived { + self.chat_widget + .add_error_message(format!("Failed to unarchive thread: {error}")); + return; + } } + archived_thread::resume_displayed_thread(self, tui, app_server, thread_id).await; return; } } diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index e5993062a0..12c82e1984 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -27,6 +27,7 @@ pub(super) struct Reconnected { pub(super) session: AppServerSession, bootstrap: AppServerBootstrap, thread: Option, + archived_thread: Option, } #[expect( @@ -79,7 +80,7 @@ pub(super) async fn reconnect( .with_thread_tool_transport(task_tools.clone()); stage = "loading session settings"; let bootstrap = session.bootstrap(&config).await?; - let thread = if let Some(thread_id) = thread_id { + let (thread, archived_thread) = if let Some(thread_id) = thread_id { stage = "resuming the conversation"; match session .resume_thread( @@ -90,7 +91,7 @@ pub(super) async fn reconnect( ) .await { - Ok(thread) => Some(thread), + Ok(thread) => (Some(thread), None), Err(error) if matches!( error.downcast_ref::(), @@ -111,23 +112,34 @@ pub(super) async fn reconnect( { return Err(error); } + Err(error) + if matches!( + error.downcast_ref::(), + Some(TypedRequestError::Server { method, source }) + if method == "thread/resume" && source.code == -32600 + && source.message.starts_with(&format!("session {thread_id} is archived. ")) + ) => + { + (None, Some(thread_id)) + } Err(error) if matches!( error.downcast_ref::(), Some(TypedRequestError::Server { source, .. }) if source.code == -32600 ) || presentation == ReconnectPresentation::Overview => { - None + (None, None) } Err(error) => return Err(error), } } else { - None + (None, None) }; Ok::<_, color_eyre::Report>(Reconnected { session, bootstrap, thread, + archived_thread, }) }; let result = tokio::time::timeout_at(deadline, attempt).await; @@ -367,6 +379,7 @@ impl App { mut session, bootstrap, thread, + archived_thread, } = connected; session.model_provider_override = self.harness_overrides.model_provider.clone(); let selected = self @@ -470,10 +483,13 @@ impl App { ); // Move cached UI state into fresh channels. Old producers retain the old sender/store, // so their late requests cannot leak into recovery. Background threads attach on selection. - for channel in self.thread_event_channels.values_mut() { + for (&thread_id, channel) in &mut self.thread_event_channels { let mut replacement = ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY); + replacement.archived = channel.archived || archived_thread == Some(thread_id); replacement.backend_id = channel.backend_id; - if channel.attachment() == ThreadEventAttachment::ExternalWriter { + if !replacement.archived + && channel.attachment() == ThreadEventAttachment::ExternalWriter + { replacement.mark_external_writer(); } else { replacement.mark_replay_only(); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index df45b4ca65..5d0d85dd32 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -392,6 +392,13 @@ impl App { app_server: &mut AppServerSession, thread_id: ThreadId, ) -> Result { + if self + .thread_event_channels + .get(&thread_id) + .is_some_and(|channel| channel.archived) + { + return Ok(false); + } if !self.should_attach_live_thread_for_selection(thread_id) { return Ok(true); } @@ -475,7 +482,12 @@ impl App { (session, thread.turns, false) } }; - let live_attached = live_attached && !closed; + let live_attached = live_attached + && !closed + && !self + .thread_event_channels + .get(&thread_id) + .is_some_and(|channel| channel.archived); if self.side_threads.contains_key(&thread_id) { session.forked_from_id = None; session.fork_parent_title = None; @@ -876,7 +888,10 @@ impl App { && self .thread_event_channels .get(&thread_id) - .is_none_or(|channel| channel.attachment() != ThreadEventAttachment::ExternalWriter) + .is_none_or(|channel| { + !channel.archived + && channel.attachment() != ThreadEventAttachment::ExternalWriter + }) { self.chat_widget.pause_unavailable_thread(); let message = if attached_replay_only { @@ -921,13 +936,7 @@ impl App { .set_task_mentions_enabled(app_server.task_tools_available(thread_id)); self.chat_widget .note_rendered_width(tui.terminal.last_known_screen_size.width); - let external_writer = self - .thread_event_channels - .get(&thread_id) - .is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ExternalWriter); - if external_writer { - self.chat_widget.show_external_writer_thread(); - } + archived_thread::show_cached_read_only_thread(self, thread_id); if self.agent_navigation.is_parent_owned(thread_id) { self.chat_widget.set_parent_owned_thread(); } @@ -963,9 +972,7 @@ impl App { { self.adopt_server_permissions(); } - if external_writer { - self.chat_widget.show_external_writer_thread(); - } + archived_thread::show_cached_read_only_thread(self, thread_id); if retain_side_view { // Hold drawing and input through the history events emitted by replay. self.chat_widget diff --git a/codex-rs/tui/src/app/sidebar_loading.rs b/codex-rs/tui/src/app/sidebar_loading.rs index ab241ab264..65648819ba 100644 --- a/codex-rs/tui/src/app/sidebar_loading.rs +++ b/codex-rs/tui/src/app/sidebar_loading.rs @@ -144,9 +144,9 @@ impl App { ..side::SideThreadState::new(target.parent) }; self.abandoned_side_threads.remove(&child); - if !self.should_attach_live_thread_for_selection(child) - && let Some(channel) = self.thread_event_channels.get(&child) + if let Some(channel) = self.thread_event_channels.get(&child) && channel.backend_id.is_none_or(|id| id == target.backend_id) + && (channel.archived || !self.should_attach_live_thread_for_selection(child)) && channel.store.lock().await.session.is_some() { self.side_threads.insert(child, side); diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index ad94b93e71..a56a3badc7 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -674,7 +674,7 @@ async fn reconnect_exhaustion_and_unknown_initial_thread_stay_offline() -> Resul #[tokio::test] async fn reconnect_allows_slow_hydration_but_bounds_a_stalled_server() -> Result<()> { - for delay in [15, 150] { + for (delay, archived) in [(15, false), (15, true), (150, false)] { let (mut app, mut events, _) = make_test_app_with_channels().await; app.config.model = Some("gpt-test".into()); let listener = TcpListener::bind("127.0.0.1:0").await?; @@ -692,6 +692,12 @@ async fn reconnect_allows_slow_hydration_but_bounds_a_stalled_server() -> Result tokio::time::pause(); tokio::time::sleep(Duration::from_secs(delay)).await; tokio::time::resume(); + if archived { + return Some(json!({"error": { + "code": -32600, + "message": format!("session {id} is archived. Run `codex unarchive {id}` to unarchive it first."), + }})); + } } else { assert_eq!(request.method, "thread/read"); } @@ -749,19 +755,27 @@ async fn reconnect_allows_slow_hydration_but_bounds_a_stalled_server() -> Result app.chat_widget.composer_text_with_pending(), "unavailable draft" ); - let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?; - let history = &history[history.find("• This conversation is unavailable").unwrap()..]; - assert_snapshot!( - "unavailable_conversation", - format!( - "{history}\n{}", - render_bottom_popup(&app.chat_widget, /*width*/ 80) - ) - .replace( - &test_path_buf("/tmp/project").display().to_string(), - "/tmp/project" - ) - ); + assert_eq!(app.chat_widget.is_archived_view(), archived); + if archived { + let notice = render_bottom_popup(&app.chat_widget, /*width*/ 80); + assert!(notice.contains("This thread was archived")); + assert!(notice.contains("Press U to unarchive")); + } else { + let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?; + let history = + &history[history.find("• This conversation is unavailable").unwrap()..]; + assert_snapshot!( + "unavailable_conversation", + format!( + "{history}\n{}", + render_bottom_popup(&app.chat_widget, /*width*/ 80) + ) + .replace( + &test_path_buf("/tmp/project").display().to_string(), + "/tmp/project" + ) + ); + } session.shutdown().await?; assert!((15..120).contains(&elapsed), "{elapsed}"); let methods = server.await??; diff --git a/codex-rs/tui/src/app/tests/agents_navigation_tests.rs b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs index bc1f0a6e58..c7cd96d685 100644 --- a/codex-rs/tui/src/app/tests/agents_navigation_tests.rs +++ b/codex-rs/tui/src/app/tests/agents_navigation_tests.rs @@ -64,6 +64,11 @@ async fn externally_archived_thread_keeps_advertised_agents_navigation() -> Resu /*initial_user_message*/ None, ); app.replace_chat_widget(ChatWidget::new_with_app_event(init)); + app.chat_widget.remote_connection = + crate::status::remote_connection::remote_connection_status_value( + &app.app_server_target, + /*server_version*/ None, + ); let id = ThreadId::new(); app.active_thread_id = Some(id); app.primary_thread_id = Some(id); @@ -81,26 +86,17 @@ async fn externally_archived_thread_keeps_advertised_agents_navigation() -> Resu .await; while events.try_recv().is_ok() {} - // Left must still edit an existing draft before it can navigate from an empty composer. + // The archived view retains its draft without accepting editing keys. app.chat_widget .restore_user_message_to_composer("ab".into()); - app.handle_tui_event( - &mut tui, - &mut app_server, - TuiEvent::Key(KeyCode::Left.into()), - ) - .await?; app.handle_tui_event( &mut tui, &mut app_server, TuiEvent::Key(KeyCode::Char('!').into()), ) .await?; - assert_eq!(app.chat_widget.composer_text_with_pending(), "a!b"); - assert!( - !std::iter::from_fn(|| events.try_recv().ok()) - .any(|event| matches!(event, AppEvent::OpenAgentsOverview)) - ); + assert_eq!(app.chat_widget.composer_text_with_pending(), "ab"); + assert!(app.chat_widget.no_modal_or_popup_active()); for key in [ KeyCode::End.into(), KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL), @@ -108,8 +104,8 @@ async fn externally_archived_thread_keeps_advertised_agents_navigation() -> Resu app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(key)) .await?; } - assert!(app.chat_widget.composer_is_empty()); - assert!(render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("for agents")); + assert_eq!(app.chat_widget.composer_text_with_pending(), "ab"); + assert!(render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("command center")); app.handle_tui_event( &mut tui, &mut app_server, @@ -120,19 +116,15 @@ async fn externally_archived_thread_keeps_advertised_agents_navigation() -> Resu )), ) .await?; - assert!( - !std::iter::from_fn(|| events.try_recv().ok()) - .any(|event| matches!(event, AppEvent::OpenAgentsOverview)) - ); + assert!(app.chat_widget.no_modal_or_popup_active()); app.handle_tui_event( &mut tui, &mut app_server, TuiEvent::Key(KeyCode::Left.into()), ) .await?; - let event = events.try_recv()?; - assert_matches!(event, AppEvent::OpenAgentsOverview); - app.handle_event(&mut tui, &mut app_server, event).await?; + assert!(!app.chat_widget.no_modal_or_popup_active()); + assert_eq!(app.chat_widget.composer_text_with_pending(), "ab"); insta::assert_snapshot!( "externally_archived_thread_agents", render_bottom_popup(&app.chat_widget, /*width*/ 96) diff --git a/codex-rs/tui/src/app/tests/archived_thread_tests.rs b/codex-rs/tui/src/app/tests/archived_thread_tests.rs new file mode 100644 index 0000000000..a8b51e9c38 --- /dev/null +++ b/codex-rs/tui/src/app/tests/archived_thread_tests.rs @@ -0,0 +1,207 @@ +#![cfg(test)] + +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn archived_thread_notice_blocks_input_and_unarchives_with_draft() -> Result<()> { + for recovery in [ + "unarchive", + "already unarchived", + "external unarchive", + "archived writer", + ] { + let (mut app, _codex_home) = Box::pin(make_history_test_app()).await?; + let (tx, mut events) = unbounded_channel(); + app.app_event_tx = AppEventSender::new(tx); + let thread_id = + create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "Saved user message")?; + let background_id = + create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "Background message")?; + let mut app_server = + Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?; + let started = Box::pin(app_server.resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + )) + .await?; + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + app.chat_widget.insert_str("Background draft"); + let background_input = app.chat_widget.capture_thread_input_state().unwrap(); + let channel = ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + test_thread_session(background_id, app.config.cwd.to_path_buf()), + Vec::new(), + ); + channel.store.lock().await.input_state = Some(background_input.clone()); + app.thread_event_channels.insert(background_id, channel); + app.chat_widget.apply_external_edit("Retained draft".into()); + while events.try_recv().is_ok() {} + app_server.thread_archive(thread_id).await?; + loop { + let event = + tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_server.next_event()) + .await? + .expect("app-server event stream remains open"); + let archived = matches!(&event.event, AppServerEvent::ServerNotification(notification) + if matches!(notification.as_ref(), ServerNotification::ThreadArchived(_))); + app.handle_app_server_event(&app_server, event.event).await; + if archived { + break; + } + } + assert!(app.chat_widget.is_archived_view()); + let mut tui = crate::tui::test_support::make_test_tui()?; + for key in [ + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), + ] { + app.handle_key_event(&mut tui, &mut app_server, key).await; + } + app.chat_widget.handle_paste("unexpected paste".into()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained draft" + ); + for width in [40, 100] { + assert_snapshot!( + format!("archived_thread_notice_{width}"), + render_bottom_popup(&app.chat_widget, width) + ); + } + crate::legacy_core::config::set_project_trust_level( + app.config.codex_home.as_path(), + app.config.cwd.as_path(), + codex_protocol::config_types::TrustLevel::Trusted, + ) + .map_err(std::io::Error::other)?; + app.harness_overrides.cwd = Some(app.config.cwd.to_path_buf()); + if recovery == "archived writer" { + let writer = std::sync::Arc::new(codex_rollout::WriterLockCoordinator::new( + app.config.codex_home.as_path(), + )); + let lock = writer.acquire(thread_id)?; + let error = app_server.thread_unarchive(thread_id).await.unwrap_err(); + assert!(crate::app_server_session::is_active_writer_error(&error)); + crate::boxed_future!(app.handle_key_event( + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE), + )) + .await; + assert!(app.thread_event_channels[&thread_id].archived); + assert!(app.chat_widget.is_archived_view()); + drop(lock); + } + if recovery != "unarchive" { + app_server.thread_unarchive(thread_id).await?; + if recovery == "already unarchived" { + app_server + .resume_thread( + &app.local_settings, + app.config.clone(), + thread_id, + crate::app_server_session::ResumeModelSettings::PreserveExistingThread, + ) + .await?; + } + } + if matches!(recovery, "external unarchive" | "archived writer") { + loop { + let event = + tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_server.next_event()) + .await? + .expect("app-server event stream remains open"); + let unarchived = matches!(&event.event, AppServerEvent::ServerNotification(notification) + if matches!(notification.as_ref(), ServerNotification::ThreadUnarchived(_))); + app.handle_app_server_event(&app_server, event.event).await; + if unarchived { + break; + } + } + let event = std::iter::from_fn(|| events.try_recv().ok()) + .find(|event| matches!(event, AppEvent::ResumeUnarchivedThread(id) if *id == thread_id)) + .expect("automatic recovery event"); + app.handle_event(&mut tui, &mut app_server, event).await?; + } else { + Box::pin(app.handle_key_event( + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE), + )) + .await; + } + assert!(!app.chat_widget.is_read_only_view()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained draft" + ); + Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ResumeSessionByIdOrName(background_id.to_string()), + )) + .await?; + assert_eq!( + app.chat_widget.capture_thread_input_state(), + Some(background_input) + ); + } + Ok(()) +} + +#[tokio::test] +async fn archived_thread_notice_retains_draft_when_recovery_fails() -> Result<()> { + let (mut app, _codex_home) = make_history_test_app().await?; + let thread_id = ThreadId::new(); + app.enqueue_primary_thread_session( + test_thread_session(thread_id, test_path_buf("/tmp/project")), + Vec::new(), + ) + .await?; + app.chat_widget.insert_str("Retained draft"); + app.enqueue_thread_notification( + thread_id, + ServerNotification::ThreadArchived(ThreadArchivedNotification { + thread_id: thread_id.to_string(), + }), + ) + .await?; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.handle_key_event( + &mut tui, + &mut app_server, + KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE), + ) + .await; + assert!(app.chat_widget.is_archived_view()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained draft" + ); + app.enqueue_thread_notification( + thread_id, + ServerNotification::ThreadUnarchived( + codex_app_server_protocol::ThreadUnarchivedNotification { + thread_id: thread_id.to_string(), + }, + ), + ) + .await?; + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::ResumeUnarchivedThread(thread_id), + ) + .await?; + assert!(app.chat_widget.is_archived_view()); + assert_eq!( + app.chat_widget.composer_text_with_pending(), + "Retained draft" + ); + Ok(()) +} diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 7e5c322cc3..90a4f36ddb 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -1,3 +1,6 @@ +#[path = "archived_thread_tests.rs"] +mod archived_thread_tests; + use super::*; use crate::app::session_lifecycle::FreshSessionStart; use crate::app_event::TranscriptExportDestination; diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__archived_thread_tests__archived_thread_notice_100.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__archived_thread_tests__archived_thread_notice_100.snap new file mode 100644 index 0000000000..5b11793f7e --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__archived_thread_tests__archived_thread_notice_100.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/app/tests/archived_thread_tests.rs +expression: "render_bottom_popup(&app.chat_widget, width)" +--- + This thread was archived u to unarchive + Press U to unarchive this thread and continue here. + + u unarchive ←/esc command center ⌃c/q exit ⌃t transcript diff --git a/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__archived_thread_tests__archived_thread_notice_40.snap b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__archived_thread_tests__archived_thread_notice_40.snap new file mode 100644 index 0000000000..3f9538fa9e --- /dev/null +++ b/codex-rs/tui/src/app/tests/snapshots/codex_tui__app__tests__session_lifecycle_requests__archived_thread_tests__archived_thread_notice_40.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/app/tests/archived_thread_tests.rs +expression: "render_bottom_popup(&app.chat_widget, width)" +--- + This thread was archived + u to unarchive + Press U to unarchive this thread and + continue here. + + u unarchive ←/esc command center + ⌃c/q exit ⌃t transcript diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index b8f4e97ddc..c65182c129 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -656,6 +656,7 @@ pub(super) struct ThreadEventChannel { /// Live Attach restored delivery without reading the history from the gap. pub(super) history_may_be_incomplete: bool, attachment: ThreadEventAttachment, + pub(super) archived: bool, } impl ThreadEventChannel { @@ -668,6 +669,7 @@ impl ThreadEventChannel { backend_id: None, history_may_be_incomplete: false, attachment: ThreadEventAttachment::Live, + archived: false, } } @@ -703,6 +705,7 @@ impl ThreadEventChannel { backend_id: None, history_may_be_incomplete: false, attachment: ThreadEventAttachment::Live, + archived: false, } } } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 4b49130539..507182cb0e 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1175,6 +1175,7 @@ impl App { { return Ok(()); } + archived_thread::update_archived_thread(self, thread_id, ¬ification); let mut confirmed_profile = None; if let ServerNotification::ThreadSettingsUpdated(notification) = ¬ification { self.apply_thread_settings_to_cached_session(thread_id, ¬ification.thread_settings) diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 0e95e08683..fc69731656 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -790,6 +790,9 @@ pub(crate) enum AppEvent { /// Resume a thread by UUID or thread name inside the running TUI session. ResumeSessionByIdOrName(String), + /// Reattach an externally unarchived thread only while it remains displayed and unarchived. + ResumeUnarchivedThread(ThreadId), + /// Attach a conversation through the registered backend that owns it. ResumeBackendSession(crate::resume_picker::SessionTarget), diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b4e79d173e..3b4a836079 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -19,7 +19,8 @@ //! Inline banners sit above the composer. Number shortcuts apply only to an empty, idle composer; //! drafts, paste bursts, and active dialogs keep their normal input routing. //! Owned transcripts separate activity from the transcript and reserve their shared hint row -//! below activity and previews, above the composer. +//! below activity and previews, above the composer. Disabling composer input dismisses transient +//! searches and restores the draft they temporarily replaced. pub(crate) use chat_composer::CommandPopupPlacement; pub(crate) use chat_composer::ComposerRenderOptions; pub(crate) use chat_composer::TranscriptFooter; @@ -1224,6 +1225,10 @@ impl BottomPane { enabled: bool, placeholder: Option, ) { + if !enabled { + self.composer.cancel_vim_search(); + self.composer.cancel_history_search(); + } self.composer.set_input_enabled(enabled, placeholder); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e298aaaada..2f056f7e65 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -380,7 +380,9 @@ pub(crate) use realtime::tests::commit_realtime_history_events; pub(crate) use realtime::tests::start_voice_attempt_for_test; mod reasoning_shortcuts; use self::realtime::RealtimeConversationUiState; +mod read_only; mod rendering; +pub(crate) use read_only::ReadOnlyThread; mod replay; mod review; mod review_popups; @@ -689,7 +691,7 @@ pub(crate) struct ChatWidget { thread_rename_block_message: Option, active_side_conversation: bool, blocks_direct_input: bool, - external_writer_view: bool, + read_only_thread: Option, read_only_history: bool, thread_settings_confirmed: bool, provider_error: Option, @@ -1868,30 +1870,6 @@ impl ChatWidget { self.bottom_pane.clear_esc_backtrack_hint(); } - pub(crate) fn show_external_writer_thread(&mut self) { - self.cancel_image_submission(); - self.blocks_direct_input = true; - self.external_writer_view = true; - self.pause_unavailable_thread(); - if let Some(cell) = self.transcript.active_cell.as_mut() { - if let Some(exec) = cell.as_any_mut().downcast_mut::() { - exec.freeze_snapshot(); - } else if let Some(tool) = cell.as_any_mut().downcast_mut::() { - tool.freeze_snapshot(); - } - } - self.bottom_pane - .set_composer_input_enabled(/*enabled*/ false, /*placeholder*/ None); - } - - pub(crate) fn is_external_writer_view(&self) -> bool { - self.external_writer_view - } - - pub(crate) fn is_read_only_view(&self) -> bool { - self.is_external_writer_view() - } - fn refresh_skills_for_current_cwd(&mut self, force_reload: bool) { self.submit_op(AppCommand::list_skills( vec![self.config.cwd.to_path_buf()], @@ -1914,9 +1892,8 @@ impl ChatWidget { AppCommand::UserTurn { .. } | AppCommand::Review { .. } | AppCommand::Compact ) { - self.add_error_message(if self.external_writer_view { - "This thread is open elsewhere. Close it there and retry resume to continue." - .to_string() + self.add_error_message(if let Some(reason) = self.read_only_thread { + reason.message().to_string() } else { PARENT_OWNED_INPUT_MESSAGE.to_string() }); diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 8abb0ed8c9..ec16ef1b7f 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -245,7 +245,7 @@ impl ChatWidget { thread_rename_block_message: None, active_side_conversation: false, blocks_direct_input: false, - external_writer_view: false, + read_only_thread: None, read_only_history: false, thread_settings_confirmed: false, provider_error: None, diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index f4fa6cecb5..7762f9884d 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -401,9 +401,8 @@ impl ChatWidget { mut collaboration_mode: CollaborationModeMask, ) { if self.blocks_direct_input { - self.add_error_message(if self.external_writer_view { - "This thread is open elsewhere. Close it there and retry resume to continue." - .to_string() + self.add_error_message(if let Some(reason) = self.read_only_thread { + reason.message().to_string() } else { PARENT_OWNED_INPUT_MESSAGE.to_string() }); diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index 90c77fbd1a..888521b82a 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -442,6 +442,11 @@ impl ChatWidget { let send_pending_steers_immediately = self.input_queue.submit_pending_steers_after_interrupt; self.input_queue.submit_pending_steers_after_interrupt = false; + if send_pending_steers_immediately && self.is_archived_view() { + // Keep the original steer records for recovery instead of retrying in read-only mode. + self.request_redraw(); + return; + } if self.interrupted_turn_notice_mode != InterruptedTurnNoticeMode::Suppress { if send_pending_steers_immediately { self.add_to_history(history_cell::new_info_event( diff --git a/codex-rs/tui/src/chatwidget/read_only.rs b/codex-rs/tui/src/chatwidget/read_only.rs new file mode 100644 index 0000000000..33dfff2986 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/read_only.rs @@ -0,0 +1,68 @@ +//! Frozen transcript views preserve drafts while their live attachment is unavailable. + +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReadOnlyThread { + ExternalWriter, + Archived, +} + +impl ReadOnlyThread { + pub(super) fn message(self) -> &'static str { + match self { + Self::ExternalWriter => "Close it there and press R to continue here.", + Self::Archived => "Press U to unarchive this thread and continue here.", + } + } + + pub(super) fn action(self) -> (&'static str, &'static str) { + match self { + Self::ExternalWriter => ("r", "retry"), + Self::Archived => ("u", "unarchive"), + } + } +} + +impl ChatWidget { + pub(crate) fn show_external_writer_thread(&mut self) { + self.show_read_only_thread(ReadOnlyThread::ExternalWriter); + } + + pub(crate) fn show_read_only_thread(&mut self, reason: ReadOnlyThread) { + self.cancel_image_submission(); + self.blocks_direct_input = true; + self.read_only_thread = Some(reason); + self.input_queue.suppress_queue_autosend = true; + self.set_initial_user_message_submit_suppressed(/*suppressed*/ true); + if let Some(message) = self.initial_user_message.take() { + self.restore_user_message_to_composer(message); + } + self.pause_unavailable_thread(); + if let Some(questions) = &mut self.bottom_pane.questions { + questions.set_expanded(/*expanded*/ false); + } + if let Some(cell) = self.transcript.active_cell.as_mut() { + if let Some(exec) = cell.as_any_mut().downcast_mut::() { + exec.freeze_snapshot(); + } else if let Some(tool) = cell.as_any_mut().downcast_mut::() { + tool.freeze_snapshot(); + } + } + self.bottom_pane + .set_composer_input_enabled(/*enabled*/ false, /*placeholder*/ None); + self.request_redraw(); + } + + pub(crate) fn is_external_writer_view(&self) -> bool { + self.read_only_thread == Some(ReadOnlyThread::ExternalWriter) + } + + pub(crate) fn is_archived_view(&self) -> bool { + self.read_only_thread == Some(ReadOnlyThread::Archived) + } + + pub(crate) fn is_read_only_view(&self) -> bool { + self.read_only_thread.is_some() + } +} diff --git a/codex-rs/tui/src/chatwidget/rendering.rs b/codex-rs/tui/src/chatwidget/rendering.rs index eb7e9d3e67..9611f6aeed 100644 --- a/codex-rs/tui/src/chatwidget/rendering.rs +++ b/codex-rs/tui/src/chatwidget/rendering.rs @@ -14,12 +14,13 @@ use ratatui::text::Span; use ratatui::widgets::Block; use std::cell::Cell; -struct ExternalWriterNotice { +struct ReadOnlyThreadNotice { + reason: ReadOnlyThread, agents_navigation_key_available: bool, transcript_hint: Option, } -impl Renderable for ExternalWriterNotice { +impl Renderable for ReadOnlyThreadNotice { fn render(&self, area: Rect, buf: &mut Buffer) { let content_width = area.width.saturating_sub(/*rhs*/ 4); let card_lines = self.card_lines(content_width); @@ -57,17 +58,19 @@ impl Renderable for ExternalWriterNotice { } } -impl ExternalWriterNotice { +impl ReadOnlyThreadNotice { fn card_lines(&self, width: u16) -> Vec> { - let title: Line<'static> = vec![ - "🔒".into(), - " ".into(), - "This conversation is open in another app".bold(), - ] - .into(); + let (prefix, title, indent) = match self.reason { + ReadOnlyThread::ExternalWriter => { + ("🔒 ", "This conversation is open in another app", " ") + } + ReadOnlyThread::Archived => ("", "This thread was archived", ""), + }; + let title: Line<'static> = vec![prefix.into(), title.bold()].into(); + let (key, action) = self.reason.action(); let retry: Line<'static> = vec![ - Span::styled("r", crate::style::accent_style()), - " to retry".into(), + Span::styled(key, crate::style::accent_style()), + format!(" to {action}").into(), ] .into(); let mut lines = word_wrap_lines(&[title], usize::from(width)); @@ -79,21 +82,20 @@ impl ExternalWriterNotice { lines.push(retry); } lines.extend(word_wrap_lines( - &[Line::from( - "Close it there and press R to continue here.".dim(), - )], + &[Line::from(self.reason.message().dim())], RtOptions::new(usize::from(width)) - .initial_indent(" ".into()) - .subsequent_indent(" ".into()), + .initial_indent(indent.into()) + .subsequent_indent(indent.into()), )); lines } fn footer_lines(&self, width: u16) -> Vec> { - let mut items = vec![ - ("r".to_string(), "retry".to_string()), - ("f".to_string(), "fork".to_string()), - ]; + let (key, action) = self.reason.action(); + let mut items = vec![(key.to_string(), action.to_string())]; + if self.reason == ReadOnlyThread::ExternalWriter { + items.push(("f".to_string(), "fork".to_string())); + } let escape = crate::key_hint::plain(KeyCode::Esc).display_label(); let quit_keys = [ crate::key_hint::ctrl(KeyCode::Char('c')).display_label(), @@ -269,8 +271,11 @@ impl ChatWidget { .filter(|remote| !remote.is_local_daemon) .map(|remote| format!("Configuration on {}", remote.address)), })) - } else if self.external_writer_view && !self.bottom_pane.has_active_view() { - RenderableItem::Owned(Box::new(ExternalWriterNotice { + } else if let Some(reason) = self.read_only_thread + && !self.bottom_pane.has_active_view() + { + RenderableItem::Owned(Box::new(ReadOnlyThreadNotice { + reason, agents_navigation_key_available: self.agents_navigation_key_available(), transcript_hint: self.bottom_pane.transcript_shortcut_hint(), })) diff --git a/codex-rs/tui/src/chatwidget/rendering_tests.rs b/codex-rs/tui/src/chatwidget/rendering_tests.rs index 28108e8952..e29f9c2d48 100644 --- a/codex-rs/tui/src/chatwidget/rendering_tests.rs +++ b/codex-rs/tui/src/chatwidget/rendering_tests.rs @@ -268,7 +268,7 @@ async fn external_writer_view_shows_notice_instead_of_composer() { async fn external_writer_fork_progress_restores_the_previous_view() { let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await; for locked in [false, true] { - widget.external_writer_view = locked; + widget.read_only_thread = locked.then_some(ReadOnlyThread::ExternalWriter); let previous = render_frame(&widget, /*width*/ 60); widget.fork_in_progress = true; let frame = render_frame(&widget, /*width*/ 60); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 90f41e3bcc..7037d11ea7 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -3269,6 +3269,46 @@ fn image_preparation_keeps_input_responsive_and_preserves_pending_input() { } } +#[tokio::test] +async fn archived_view_preserves_pending_steer_when_interrupt_arrives() { + let (mut chat, _events, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; + handle_turn_started(&mut chat, "turn-1"); + let mut steer = pending_steer("pending steer"); + steer.admitted_turn_id = Some("turn-1".into()); + chat.input_queue.pending_steers.push_back(steer.clone()); + chat.input_queue.submit_pending_steers_after_interrupt = true; + chat.restore_user_message_to_composer("retained draft".into()); + + chat.show_read_only_thread(ReadOnlyThread::Archived); + handle_turn_interrupted(&mut chat, "turn-1"); + + let saved = chat.capture_thread_input_state().unwrap(); + assert_eq!(saved.pending_steers, VecDeque::from([steer])); + assert_eq!(chat.bottom_pane.composer_text(), "retained draft"); + assert_no_submit_op(&mut ops); +} + +#[tokio::test] +async fn archived_view_preserves_initial_prompt_and_dismisses_search() { + for vim in [false, true] { + let (mut chat, _events, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.initial_user_message = Some("CLI prompt".into()); + chat.restore_user_message_to_composer("typed draft".into()); + chat.bottom_pane.set_vim_enabled(vim); + chat.handle_key_event(if vim { + KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE) + } else { + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL) + }); + assert!(!chat.no_modal_or_popup_active()); + chat.show_read_only_thread(ReadOnlyThread::Archived); + chat.submit_initial_user_message_if_pending(); + assert!(chat.no_modal_or_popup_active()); + assert_eq!(chat.composer_text_with_pending(), "CLI prompt\ntyped draft"); + assert_no_submit_op(&mut ops); + } +} + #[tokio::test] async fn provider_error_recovers_only_the_rejected_steer() { let (mut chat, _events, mut operations) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/questions_tests.rs b/codex-rs/tui/src/chatwidget/tests/questions_tests.rs index 7962e84691..96061226cc 100644 --- a/codex-rs/tui/src/chatwidget/tests/questions_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/questions_tests.rs @@ -1174,3 +1174,20 @@ async fn retried_question_answers_keep_separate_envelopes_and_order() { assert_eq!(retried, original); } } + +#[tokio::test] +async fn archived_view_collapses_questions_and_retains_answers() { + let (mut chat, _events, mut ops) = make_chatwidget_manual(/*model_override*/ None).await; + chat.add_async_questions("message", &questions()); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); + chat.bottom_pane.handle_paste("kept answer".into()); + assert!(!chat.no_modal_or_popup_active()); + chat.show_read_only_thread(ReadOnlyThread::Archived); + assert!(chat.no_modal_or_popup_active()); + let questions = chat.bottom_pane.questions.as_mut().unwrap(); + assert!(!questions.delivery_enabled); + questions.set_expanded(/*expanded*/ true); + chat.read_only_thread = None; + assert!(render_bottom_popup(&chat, /*width*/ 100).contains("kept answer")); + assert_no_submit_op(&mut ops); +}