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
This commit is contained in:
Benjamin Carlsson
2026-11-14 07:46:36 +00:00
committed by copyberry
parent 6ddb87e700
commit 7924f95421
26 changed files with 603 additions and 126 deletions
+1
View File
@@ -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;
+76
View File
@@ -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);
}
+8
View File
@@ -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,
+26 -15
View File
@@ -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;
}
}
+22 -6
View File
@@ -27,6 +27,7 @@ pub(super) struct Reconnected {
pub(super) session: AppServerSession,
bootstrap: AppServerBootstrap,
thread: Option<AppServerStartedThread>,
archived_thread: Option<ThreadId>,
}
#[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::<TypedRequestError>(),
@@ -111,23 +112,34 @@ pub(super) async fn reconnect(
{
return Err(error);
}
Err(error)
if matches!(
error.downcast_ref::<TypedRequestError>(),
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::<TypedRequestError>(),
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();
+19 -12
View File
@@ -392,6 +392,13 @@ impl App {
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> Result<bool> {
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
+2 -2
View File
@@ -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);
@@ -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??;
@@ -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)
@@ -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(())
}
@@ -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;
@@ -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
@@ -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
+3
View File
@@ -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,
}
}
}
+1
View File
@@ -1175,6 +1175,7 @@ impl App {
{
return Ok(());
}
archived_thread::update_archived_thread(self, thread_id, &notification);
let mut confirmed_profile = None;
if let ServerNotification::ThreadSettingsUpdated(notification) = &notification {
self.apply_thread_settings_to_cached_session(thread_id, &notification.thread_settings)
+3
View File
@@ -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),
+6 -1
View File
@@ -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<String>,
) {
if !enabled {
self.composer.cancel_vim_search();
self.composer.cancel_history_search();
}
self.composer.set_input_enabled(enabled, placeholder);
self.request_redraw();
}
+5 -28
View File
@@ -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<String>,
active_side_conversation: bool,
blocks_direct_input: bool,
external_writer_view: bool,
read_only_thread: Option<ReadOnlyThread>,
read_only_history: bool,
thread_settings_confirmed: bool,
provider_error: Option<codex_app_server_protocol::ModelProviderError>,
@@ -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::<ExecCell>() {
exec.freeze_snapshot();
} else if let Some(tool) = cell.as_any_mut().downcast_mut::<McpToolCallCell>() {
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()
});
+1 -1
View File
@@ -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,
+2 -3
View File
@@ -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()
});
@@ -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(
+68
View File
@@ -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::<ExecCell>() {
exec.freeze_snapshot();
} else if let Some(tool) = cell.as_any_mut().downcast_mut::<McpToolCallCell>() {
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()
}
}
+27 -22
View File
@@ -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<crate::key_hint::ShortcutHint>,
}
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<Line<'static>> {
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<Line<'static>> {
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(),
}))
@@ -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);
@@ -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;
@@ -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);
}