Show read-only conversations when resume encounters an active writer (#43253)

## Why

Resuming a conversation that is open in another app fails with an active-writer error. Allow users to inspect its transcript and retry after closing it in the other app.

## What changed

- Fall back to a read-only history snapshot on active-writer conflicts during startup and session selection.
- Replace the composer with an explanatory notice, `R` to retry, exit shortcuts, and the configured transcript shortcut.
- Block input and freeze active command and MCP animations while viewing the snapshot. Preserve initial prompts and existing drafts for a successful retry.
- Keep the subscription when retrying the same thread and preserve read-only state across thread switches and reconnects.

## Testing

Add regression tests for conflict detection, history viewing, draft preservation, same-thread retry without unsubscribing, exit shortcuts, and notice and frozen-tool rendering.

GitOrigin-RevId: fa864cc73143cad9139c22e33b7958569eec20cf
This commit is contained in:
Felipe Coury
2026-09-06 17:27:44 +00:00
committed by copyberry
parent a9896da3fe
commit 4aec23384e
29 changed files with 869 additions and 109 deletions
+44
View File
@@ -233,6 +233,43 @@ impl App {
app_server: &mut AppServerSession,
key_event: KeyEvent,
) {
if self.chat_widget.is_external_writer_view()
&& self.overlay.is_none()
&& self.chat_widget.no_modal_or_popup_active()
&& key_event.kind == KeyEventKind::Press
{
let modifiers = key_event.modifiers;
let quit = match key_event.code {
KeyCode::Esc => modifiers == KeyModifiers::NONE,
KeyCode::Char('q' | 'Q') => {
modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT
}
KeyCode::Char('c' | 'C') => {
modifiers == KeyModifiers::CONTROL
|| modifiers == (KeyModifiers::CONTROL | KeyModifiers::SHIFT)
}
_ => false,
};
if quit {
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
return;
}
if matches!(key_event.code, KeyCode::Char('r' | 'R'))
&& (modifiers == KeyModifiers::NONE || modifiers == KeyModifiers::SHIFT)
&& let Some(thread_id) = self.current_displayed_thread_id()
{
let target = SessionTarget {
path: self
.primary_session_configured
.as_ref()
.and_then(|session| session.rollout_path.clone()),
thread_id,
history_mode: None,
};
let _ = self.resume_target_session(tui, app_server, target).await;
return;
}
}
// Some terminals, especially on macOS, encode Option+Left/Right as Option+b/f unless
// enhanced keyboard reporting is available. We only treat those word-motion fallbacks as
// agent-switch shortcuts when the composer is empty so we never steal the expected
@@ -376,6 +413,13 @@ impl App {
return;
}
if self.chat_widget.is_external_writer_view()
&& self.overlay.is_none()
&& self.chat_widget.no_modal_or_popup_active()
{
return;
}
if self.should_handle_unavailable_thread_key(key_event) {
self.chat_widget.handle_disconnected_key(key_event);
return;
+7 -3
View File
@@ -158,7 +158,7 @@ impl App {
&& self
.thread_event_channels
.get(&id)
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ReplayOnly)
.is_some_and(|channel| channel.attachment() != ThreadEventAttachment::Live)
}
pub(super) fn recover_transport_error(&mut self, error: &color_eyre::Report) -> bool {
@@ -284,7 +284,11 @@ impl App {
// so their late requests cannot leak into recovery. Background threads attach on selection.
for channel in self.thread_event_channels.values_mut() {
let mut replacement = ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY);
replacement.mark_replay_only();
if channel.attachment() == ThreadEventAttachment::ExternalWriter {
replacement.mark_external_writer();
} else {
replacement.mark_replay_only();
}
let mut store = std::mem::replace(
&mut *channel.store.lock().await,
ThreadEventStore::new(THREAD_EVENT_CHANNEL_CAPACITY),
@@ -349,7 +353,7 @@ impl App {
)?;
self.config = self.chat_widget.config_ref().clone();
self.refresh_pending_thread_approvals().await;
if self.thread_unavailable(id) {
if self.thread_unavailable(id) && !self.chat_widget.is_external_writer_view() {
self.agent_navigation.mark_stopped(id);
self.chat_widget.pause_unavailable_thread();
self.chat_widget.add_info_message("This conversation is unavailable. Its cached transcript and draft remain here; input is paused. Open the agent picker or return to the parent to continue.".into(), /*hint*/ None);
+114 -53
View File
@@ -13,6 +13,7 @@ use super::app_server_event_targets::server_request_thread_id;
use super::*;
use crate::app_server_session::source_agent_path;
use crate::app_server_session::thread_blocks_direct_input;
use crate::chatwidget::ThreadInputStateRestoreMode;
use std::collections::HashSet;
#[derive(Clone, Copy)]
@@ -366,6 +367,10 @@ impl App {
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> Result<bool> {
let was_external_writer = self
.thread_event_channels
.get(&thread_id)
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ExternalWriter);
if self
.thread_event_channels
.get(&thread_id)
@@ -459,7 +464,11 @@ impl App {
};
let channel = self.ensure_thread_channel(thread_id);
if !live_attached {
channel.mark_replay_only();
if was_external_writer {
channel.mark_external_writer();
} else {
channel.mark_replay_only();
}
}
let mut store = channel.store.lock().await;
store.set_session(session, turns);
@@ -627,7 +636,12 @@ impl App {
self.schedule_recap_check(thread_id, now);
self.render_thread_snapshot(tui, app_server, thread_id, snapshot, !is_replay_only)?;
if is_replay_only {
if is_replay_only
&& self
.thread_event_channels
.get(&thread_id)
.is_none_or(|channel| channel.attachment() != ThreadEventAttachment::ExternalWriter)
{
self.chat_widget.pause_unavailable_thread();
let message = if attached_replay_only {
format!(
@@ -661,11 +675,21 @@ 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();
}
if self.agent_navigation.is_parent_owned(thread_id) {
self.chat_widget.set_parent_owned_thread();
}
self.reset_for_thread_switch(tui)?;
self.replay_thread_snapshot(snapshot, resume_restored_queue);
if external_writer {
self.chat_widget.show_external_writer_thread();
}
Ok(())
}
@@ -1133,69 +1157,106 @@ impl App {
if let Some(history_mode) = target_session.history_mode {
app_server.remember_thread_history_mode(target_session.thread_id, history_mode);
}
match app_server
let resumed = app_server
.resume_thread(
&local_settings,
resume_config.clone(),
target_session.thread_id,
self.resume_model_settings(),
)
.await
{
Ok(resumed) => {
let resumed_thread_id = resumed.session.thread_id;
self.shutdown_current_thread(app_server).await;
self.local_settings = local_settings;
self.config = resume_config;
tui.set_notification_settings(
self.local_settings.tui.notification_settings.method,
self.local_settings.tui.notification_settings.condition,
);
self.file_search
.update_search_dir(self.config.cwd.to_path_buf());
match self
.replace_chat_widget_with_app_server_thread(
tui,
resumed,
ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
)
.await
{
Ok(()) => {
self.backfill_loaded_subagent_threads(app_server).await;
self.replay_agents_overview_requests(app_server, resumed_thread_id)
.await;
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(usage_line) = summary.usage_line {
lines.push(usage_line.into());
}
if let Some(command) = summary.resume_hint {
let spans =
vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
self.maybe_prompt_resume_paused_goal_after_resume(
app_server,
resumed_thread_id,
)
.await;
}
Err(err) => {
self.add_session_picker_error(format!(
"Failed to attach to resumed app-server thread: {err}"
));
}
.await;
let (resumed, read_only) = match resumed {
Ok(resumed) => (resumed, false),
Err(err) if crate::app_server_session::is_active_writer_error(&err) => match app_server
.read_thread_for_viewing(&resume_config, &local_settings, target_session.thread_id)
.await
{
Ok(thread) => (thread, true),
Err(read_err) => {
self.add_session_picker_error(format!(
"Failed to view thread open elsewhere: {read_err}"
));
return Ok(AppRunControl::Continue);
}
}
},
Err(err) => {
let path_display = target_session.display_label();
self.add_session_picker_error(format!(
"Failed to resume session from {path_display}: {err}"
));
return Ok(AppRunControl::Continue);
}
};
let resumed_thread_id = resumed.session.thread_id;
let retained_input = (self.chat_widget.is_external_writer_view()
&& self.chat_widget.thread_id() == Some(resumed_thread_id))
.then(|| self.chat_widget.capture_thread_input_state())
.flatten();
if self.chat_widget.thread_id() == Some(resumed_thread_id) {
// The successful resume has already subscribed this connection to the same thread.
self.shutdown_side_threads(app_server).await;
} else {
self.shutdown_current_thread(app_server).await;
}
self.local_settings = local_settings;
self.config = resume_config;
tui.set_notification_settings(
self.local_settings.tui.notification_settings.method,
self.local_settings.tui.notification_settings.condition,
);
self.file_search
.update_search_dir(self.config.cwd.to_path_buf());
match self
.replace_chat_widget_with_app_server_thread(
tui,
resumed,
ThreadAttachPresentation::SessionLineage,
/*initial_user_message*/ None,
)
.await
{
Ok(()) => {
if let Some(input) = retained_input {
self.chat_widget.restore_thread_input_state(
Some(input),
ThreadInputStateRestoreMode {
preserve_in_flight_turn: false,
},
);
}
if read_only {
self.ensure_thread_channel(resumed_thread_id)
.mark_external_writer();
self.chat_widget.show_external_writer_thread();
}
self.backfill_loaded_subagent_threads(app_server).await;
if !read_only {
self.replay_agents_overview_requests(app_server, resumed_thread_id)
.await;
}
if let Some(summary) = summary {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(usage_line) = summary.usage_line {
lines.push(usage_line.into());
}
if let Some(command) = summary.resume_hint {
let spans = vec!["To continue this session, run ".into(), command.cyan()];
lines.push(spans.into());
}
self.chat_widget.add_plain_history_lines(lines);
}
if !read_only {
self.maybe_prompt_resume_paused_goal_after_resume(
app_server,
resumed_thread_id,
)
.await;
}
}
Err(err) => {
self.add_session_picker_error(format!(
"Failed to attach to resumed app-server thread: {err}"
));
}
}
+51 -16
View File
@@ -363,6 +363,7 @@ impl App {
);
let start_in_agents_overview =
matches!(&session_selection, SessionSelection::AgentsOverview);
let mut read_only_thread = false;
let (mut chat_widget, initial_started_thread) = match session_selection {
SessionSelection::StartFresh
| SessionSelection::Exit
@@ -450,24 +451,50 @@ impl App {
)
.await
{
Ok(Ok(resumed)) => Ok(resumed),
Ok(Err(err)) if crate::app_server_session::is_active_writer_error(&err) => {
read_only_thread = true;
match startup_draft
.run_until(
tui,
app_server.read_thread_for_viewing(
&config,
&local_settings,
target_session.thread_id,
),
)
.await
{
Ok(result) => result,
Err(err) => return shutdown_on_startup_error(app_server, err).await,
}
}
Ok(resumed) => resumed,
Err(err) => return shutdown_on_startup_error(app_server, err).await,
};
let action = SessionStartAction::Resume(model_settings);
let Some(resumed) = complete_session_start(
&mut app_server,
&config,
&target_session,
action,
resumed,
async || {
startup_draft.flush_pending_events(tui).await?;
run_unarchive_prompt(tui, target_session.thread_id, action).await
},
)
.await?
else {
return Ok(cancel_session_start(app_server).await);
let resumed = if read_only_thread {
match resumed {
Ok(resumed) => resumed,
Err(err) => return shutdown_on_startup_error(app_server, err).await,
}
} else {
let action = SessionStartAction::Resume(model_settings);
let Some(resumed) = complete_session_start(
&mut app_server,
&config,
&target_session,
action,
resumed,
async || {
startup_draft.flush_pending_events(tui).await?;
run_unarchive_prompt(tui, target_session.thread_id, action).await
},
)
.await?
else {
return Ok(cancel_session_start(app_server).await);
};
resumed
};
let init = crate::chatwidget::ChatWidgetInit {
local_settings: local_settings.clone(),
@@ -691,6 +718,9 @@ See the Codex keymap documentation for supported actions and examples."
if started.blocks_direct_input {
app.mark_primary_thread_parent_owned(thread_id);
}
if read_only_thread {
app.chat_widget.show_external_writer_thread();
}
match startup_draft
.run_until(
tui,
@@ -701,7 +731,12 @@ See the Codex keymap documentation for supported actions and examples."
Ok(result) => result?,
Err(err) => return shutdown_on_startup_error(app_server, err).await,
}
if should_prompt_for_paused_goal_after_startup_resume
if read_only_thread {
app.ensure_thread_channel(thread_id).mark_external_writer();
app.chat_widget.show_external_writer_thread();
}
if !read_only_thread
&& should_prompt_for_paused_goal_after_startup_resume
&& let Err(err) = startup_draft
.run_until(
tui,
+10 -6
View File
@@ -568,11 +568,7 @@ async fn resolved_buffered_approval_does_not_become_actionable_after_drain() ->
Ok(())
}
#[tokio::test]
async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_submit() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let initial_prompt = "follow-up after replay".to_string();
fn set_test_initial_prompt(app: &mut App, initial_prompt: String) {
let config = app.config.clone();
let model = get_model_offline_for_tests(config.model.as_deref());
app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit {
@@ -583,7 +579,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
app_event_tx: app.app_event_tx.clone(),
workspace_command_runner: None,
initial_user_message: create_initial_user_message(
Some(initial_prompt.clone()),
Some(initial_prompt),
Vec::new(),
Vec::new(),
),
@@ -602,6 +598,14 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm
terminal_title_invalid_items_warned: app.terminal_title_invalid_items_warned.clone(),
session_telemetry: app.session_telemetry.clone(),
});
}
#[tokio::test]
async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_submit() -> Result<()> {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let initial_prompt = "follow-up after replay".to_string();
set_test_initial_prompt(&mut app, initial_prompt.clone());
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
@@ -1,6 +1,95 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn external_writer_view_quits_with_escape_ctrl_c_or_q() -> Result<()> {
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
app.chat_widget.show_external_writer_thread();
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
while events.try_recv().is_ok() {}
app.keymap.app.open_external_editor = vec![crate::key_hint::ctrl(KeyCode::Char('g'))];
app.handle_key_event(
&mut tui,
&mut app_server,
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL),
)
.await;
assert_eq!(
app.chat_widget.external_editor_state(),
ExternalEditorState::Closed
);
assert!(
!events
.try_recv()
.is_ok_and(|event| matches!(event, AppEvent::LaunchExternalEditor))
);
for key in [
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
] {
app.handle_key_event(&mut tui, &mut app_server, key).await;
assert!(matches!(
events.try_recv(),
Ok(AppEvent::Exit(ExitMode::Immediate))
));
}
Ok(())
}
#[tokio::test]
async fn external_writer_view_preserves_draft_from_keys_and_paste() -> Result<()> {
let (mut app, _events, _operations) = make_test_app_with_channels().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.app_server_target = AppServerTarget::Remote {
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
websocket_url: "wss://example.com/".to_string(),
auth_token: None,
},
};
app.ensure_thread_channel(thread_id).mark_external_writer();
app.chat_widget.insert_str("Retained draft");
app.chat_widget.show_external_writer_thread();
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
for offline in [false, true] {
app.reconnect.offline = offline;
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
)
.await?;
app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Paste("pasted".into()))
.await?;
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"Retained draft"
);
}
app.reconnect.offline = false;
app.handle_tui_event(
&mut tui,
&mut app_server,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)),
)
.await?;
assert!(app.overlay.is_some());
Ok(())
}
#[tokio::test]
async fn daemon_disconnect_exit_summary_includes_reconnect_and_stop_instructions() -> Result<()> {
let (mut app, _, _) = make_test_app_with_channels().await;
@@ -41,6 +41,63 @@ use tokio_tungstenite::tungstenite::Message;
pub(super) type RecordedRequests = Arc<Mutex<Vec<JSONRPCRequest>>>;
pub(super) type RecordingAppServer = (AppServerSession, RecordedRequests, JoinHandle<Result<()>>);
#[tokio::test]
async fn same_thread_retry_keeps_subscription_and_restores_draft() -> Result<()> {
let (mut app, codex_home) = make_history_test_app().await?;
let thread_id = ThreadId::from_string(
&create_fake_rollout(
codex_home.path(),
"2026-01-01T00-00-00",
"2026-01-01T00:00:00Z",
"Saved user message",
Some(app.config.model_provider_id.as_str()),
/*git_info*/ None,
)
.expect("create rollout"),
)?;
let path = Some(rollout_path(
codex_home.path(),
"2026-01-01T00-00-00",
&thread_id.to_string(),
));
let (mut app_server, requests, proxy) = start_recording_app_server(
&app.config,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
)
.await?;
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
app.ensure_thread_channel(thread_id).mark_external_writer();
app.chat_widget.insert_str("Retained draft");
app.chat_widget.show_external_writer_thread();
app.harness_overrides.cwd = Some(app.config.cwd.to_path_buf());
requests.lock().expect("request recorder lock").clear();
let mut tui = crate::tui::test_support::make_test_tui()?;
app.resume_target_session(
&mut tui,
&mut app_server,
crate::resume_picker::SessionTarget {
path,
thread_id,
history_mode: None,
},
)
.await?;
assert_eq!(recorded_params(&requests, "thread/resume").len(), 1);
assert!(recorded_params(&requests, "thread/unsubscribe").is_empty());
assert!(!app.chat_widget.is_external_writer_view());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"Retained draft"
);
proxy.abort();
Ok(())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum HistoryCapabilities {
Current,
+68
View File
@@ -1366,3 +1366,71 @@ async fn ignore_same_thread_resume_allows_reattaching_displayed_inactive_thread(
assert!(!ignored);
assert!(app.transcript_cells.is_empty());
}
#[tokio::test]
async fn ignore_same_thread_resume_allows_retrying_read_only_view() -> Result<()> {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let session = test_thread_session(thread_id, test_path_buf("/tmp/project"));
app.chat_widget.handle_thread_session(session.clone());
let mut channel = ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
session.clone(),
Vec::new(),
);
channel.mark_external_writer();
app.thread_event_channels.insert(thread_id, channel);
app.activate_thread_channel(thread_id).await;
assert!(
!app.ignore_same_thread_resume(&crate::resume_picker::SessionTarget {
path: Some(test_path_buf("/tmp/project")),
thread_id,
history_mode: None,
})
);
let app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
app.render_thread_snapshot(
&mut tui,
&app_server,
thread_id,
ThreadEventSnapshot {
session: Some(session),
turns: vec![test_turn("running", TurnStatus::InProgress, Vec::new())],
events: Vec::new(),
input_state: None,
},
/*resume_restored_queue*/ false,
)?;
assert!(app.chat_widget.is_external_writer_view());
assert!(!app.chat_widget.is_task_running_for_test());
Ok(())
}
#[tokio::test]
async fn external_writer_startup_keeps_initial_prompt_as_draft() -> Result<()> {
let (mut app, mut events, _operations) = make_test_app_with_channels().await;
let thread_id = ThreadId::new();
let prompt = "Continue after the other app closes";
set_test_initial_prompt(&mut app, prompt.to_string());
app.chat_widget.show_external_writer_thread();
app.enqueue_primary_thread_session(
test_thread_session(thread_id, test_path_buf("/tmp/project")),
Vec::new(),
)
.await?;
assert_eq!(app.chat_widget.composer_text_with_pending(), prompt);
assert!(!std::iter::from_fn(|| events.try_recv().ok()).any(|event| {
matches!(
event,
AppEvent::SubmitThreadOp {
op: Op::UserTurn { .. },
..
} | AppEvent::CodexOp(Op::UserTurn { .. })
)
}));
Ok(())
}
+5
View File
@@ -36,6 +36,7 @@ pub(super) struct FeedbackThreadEvent {
pub(super) enum ThreadEventAttachment {
Live,
ReplayOnly,
ExternalWriter,
}
#[derive(Debug)]
@@ -386,6 +387,10 @@ impl ThreadEventChannel {
self.attachment = ThreadEventAttachment::ReplayOnly;
}
pub(super) fn mark_external_writer(&mut self) {
self.attachment = ThreadEventAttachment::ExternalWriter;
}
pub(super) fn attachment(&self) -> ThreadEventAttachment {
self.attachment
}
+15 -6
View File
@@ -16,10 +16,7 @@ use codex_app_server_protocol::WarningNotification;
impl App {
pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) {
let side_thread_ids: Vec<ThreadId> = self.side_threads.keys().copied().collect();
for side_thread_id in side_thread_ids {
self.discard_side_thread(app_server, side_thread_id).await;
}
self.shutdown_side_threads(app_server).await;
if let Some(thread_id) = self.chat_widget.thread_id() {
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
tracing::warn!("failed to unsubscribe thread {thread_id}: {err}");
@@ -28,6 +25,13 @@ impl App {
}
}
pub(super) async fn shutdown_side_threads(&mut self, app_server: &mut AppServerSession) {
let side_thread_ids: Vec<ThreadId> = self.side_threads.keys().copied().collect();
for side_thread_id in side_thread_ids {
self.discard_side_thread(app_server, side_thread_id).await;
}
}
pub(super) fn abort_thread_event_listener(&mut self, thread_id: ThreadId) {
if let Some(handle) = self.thread_event_listener_tasks.remove(&thread_id) {
handle.abort();
@@ -175,7 +179,12 @@ impl App {
&mut self,
target_session: &crate::resume_picker::SessionTarget,
) -> bool {
if self.active_thread_id != Some(target_session.thread_id) {
if self.active_thread_id != Some(target_session.thread_id)
|| self
.thread_event_channels
.get(&target_session.thread_id)
.is_some_and(|channel| channel.attachment() != ThreadEventAttachment::Live)
{
return false;
};
@@ -445,7 +454,7 @@ impl App {
) -> Result<()> {
if self.thread_unavailable(thread_id) {
self.chat_widget.add_error_message(
"This conversation is unavailable; no operation was sent.".into(),
"This conversation is read-only or unavailable; no operation was sent.".into(),
);
return Ok(());
}
+5
View File
@@ -349,6 +349,11 @@ pub(crate) struct AppServerStartedThread {
pub(crate) task_tools_available: bool,
}
pub(crate) fn is_active_writer_error(err: &color_eyre::eyre::Report) -> bool {
err.chain()
.any(|cause| cause.to_string().contains("already has an active writer"))
}
pub(crate) fn source_agent_path(source: &SessionSource) -> Option<String> {
match source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { agent_path, .. }) => {
@@ -9,8 +9,10 @@ use super::bootstrap_request_error;
use super::is_history_pagination_unsupported;
use super::started_thread_from_resume_response;
use super::thread_resume_params_from_config;
use super::thread_session_state_from_thread_response;
use crate::legacy_core::config::Config;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadResumeResponse;
@@ -19,6 +21,53 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Result;
impl AppServerSession {
/// Read a conflicting thread without taking its writer lease. This is a snapshot, not an
/// attachment: the caller must not route turns through this session.
pub(crate) async fn read_thread_for_viewing(
&mut self,
config: &Config,
local_settings: &crate::local_settings::LocalSettings,
thread_id: ThreadId,
) -> Result<AppServerStartedThread> {
let mut thread = self.thread_read(thread_id, /*include_turns*/ false).await?;
self.hydrate_initial_thread_history(
&mut thread,
/*turn_cursor*/ None,
/*item_cursor*/ None,
Some(config),
Some(local_settings),
HistoryHydrationScope::Initial,
)
.await?;
let session = thread_session_state_from_thread_response(
&thread.id,
thread.forked_from_id.clone(),
thread.name.clone(),
thread.path.clone(),
thread.model.clone().unwrap_or_default(),
thread.model_provider.clone(),
config.service_tier.clone(),
AskForApproval::from(config.permissions.approval_policy.value()),
config.approvals_reviewer,
config.permissions.permission_profile().clone(),
config.permissions.active_permission_profile(),
thread.cwd.clone(),
config.workspace_roots.clone(),
Vec::new(),
thread.reasoning_effort,
config.personality,
local_settings,
)
.await
.map_err(color_eyre::eyre::Report::msg)?;
Ok(AppServerStartedThread {
session,
turns: thread.turns,
blocks_direct_input: false,
task_tools_available: false,
})
}
pub(crate) fn with_local_codex_home(mut self, codex_home: &AbsolutePathBuf) -> Self {
self.task_tool_capabilities_dir = (!self.uses_embedded_app_server())
.then(|| codex_home.join("tui-thread-reference-capabilities"));
@@ -19,6 +19,49 @@ async fn build_config(temp_dir: &TempDir) -> Config {
.expect("config should build")
}
#[tokio::test]
async fn viewing_thread_reads_history_without_resuming_it() -> Result<()> {
let codex_home = tempfile::tempdir()?;
let config = build_config(&codex_home).await;
let thread_id = ThreadId::from_string(
&create_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some(config.model_provider_id.as_str()),
/*git_info*/ None,
)
.expect("create source rollout"),
)?;
let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?;
let next_request_id = app_server.next_request_id;
let viewed = app_server
.read_thread_for_viewing(
&config,
&crate::local_settings::LocalSettings::from(&config),
thread_id,
)
.await?;
assert_eq!(viewed.session.thread_id, thread_id);
assert!(!viewed.turns.is_empty());
assert!(!viewed.blocks_direct_input);
assert!(app_server.next_request_id > next_request_id);
app_server.shutdown().await?;
Ok(())
}
#[test]
fn only_active_writer_failures_offer_read_only_view() {
let conflict = color_eyre::eyre::eyre!("thread already has an active writer (code -32600)");
let unrelated = color_eyre::eyre::eyre!("thread not found (code -32600)");
assert!(crate::app_server_session::is_active_writer_error(&conflict));
assert!(!crate::app_server_session::is_active_writer_error(
&unrelated
));
}
#[tokio::test]
async fn legacy_resume_preserves_history_mode_after_picker_server_replacement() -> Result<()> {
let codex_home = tempfile::tempdir().expect("tempdir");
+1 -1
View File
@@ -870,7 +870,7 @@ pub(crate) fn footer_hint_items_width(items: &[(String, String)]) -> u16 {
footer_hint_items_line(items).width() as u16
}
fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static> {
pub(crate) fn footer_hint_items_line(items: &[(String, String)]) -> Line<'static> {
let mut spans = Vec::with_capacity(items.len() * 4);
for (idx, (key, label)) in items.iter().enumerate() {
spans.push(" ".into());
+5
View File
@@ -475,6 +475,11 @@ impl BottomPane {
self.request_redraw();
}
pub(crate) fn transcript_shortcut_hint(&self) -> Option<crate::key_hint::ShortcutHint> {
self.keymap
.primary_hint(KeymapContext::Global, "open_transcript")
}
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
pub(crate) fn drain_pending_submission_state(&mut self) {
let _ = self.take_recent_submission_images_with_placeholders();
+26 -1
View File
@@ -690,6 +690,7 @@ pub(crate) struct ChatWidget {
thread_rename_block_message: Option<String>,
active_side_conversation: bool,
blocks_direct_input: bool,
external_writer_view: bool,
misalignment_policy_violation: Option<misalignment_policy::MisalignmentViolation>,
normal_placeholder_text: String,
side_placeholder_text: String,
@@ -1746,6 +1747,25 @@ impl ChatWidget {
self.bottom_pane.clear_esc_backtrack_hint();
}
pub(crate) fn show_external_writer_thread(&mut self) {
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
}
fn refresh_skills_for_current_cwd(&mut self, force_reload: bool) {
self.submit_op(AppCommand::list_skills(
vec![self.config.cwd.to_path_buf()],
@@ -1768,7 +1788,12 @@ impl ChatWidget {
AppCommand::UserTurn { .. } | AppCommand::Review { .. } | AppCommand::Compact
)
{
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
self.add_error_message(if self.external_writer_view {
"This thread is open elsewhere. Close it there and retry resume to continue."
.to_string()
} else {
PARENT_OWNED_INPUT_MESSAGE.to_string()
});
return false;
}
self.prepare_local_op_submission(&op);
@@ -217,6 +217,7 @@ impl ChatWidget {
thread_rename_block_message: None,
active_side_conversation: false,
blocks_direct_input: false,
external_writer_view: false,
misalignment_policy_violation: None,
normal_placeholder_text: placeholder,
side_placeholder_text: side_placeholder,
+6 -1
View File
@@ -265,7 +265,12 @@ impl ChatWidget {
mut collaboration_mode: CollaborationModeMask,
) {
if self.blocks_direct_input {
self.add_error_message(PARENT_OWNED_INPUT_MESSAGE.to_string());
self.add_error_message(if self.external_writer_view {
"This thread is open elsewhere. Close it there and retry resume to continue."
.to_string()
} else {
PARENT_OWNED_INPUT_MESSAGE.to_string()
});
return;
}
if collaboration_mode.mode == Some(ModeKind::Plan)
+4 -1
View File
@@ -271,7 +271,7 @@ impl ChatWidget {
}
pub(crate) fn can_launch_external_editor(&self) -> bool {
self.bottom_pane.can_launch_external_editor()
!self.external_writer_view && self.bottom_pane.can_launch_external_editor()
}
pub(crate) fn can_run_ctrl_l_clear_now(&mut self) -> bool {
@@ -512,6 +512,9 @@ impl ChatWidget {
}
pub(crate) fn handle_paste(&mut self, text: String) {
if self.external_writer_view {
return;
}
self.bottom_pane.handle_paste(text);
}
+3
View File
@@ -67,6 +67,9 @@ impl ChatWidget {
}
pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) {
if self.external_writer_view {
return;
}
if self.handle_question_key(key) {
return;
}
+123 -5
View File
@@ -3,9 +3,121 @@
use super::transcript::ActiveCellLayoutCache;
use super::transcript::ActiveCellLayoutCacheKey;
use super::*;
use crate::render::RectExt;
use crate::terminal_hyperlinks::HyperlinkParagraph;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
use ratatui::style::Styled as _;
use ratatui::text::Span;
use ratatui::widgets::Block;
use std::cell::Cell;
struct ExternalWriterNotice {
transcript_hint: Option<crate::key_hint::ShortcutHint>,
}
impl Renderable for ExternalWriterNotice {
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);
let card_height = (card_lines.len() as u16).saturating_add(/*rhs*/ 2);
let card = Rect::new(area.x, area.y, area.width, card_height.min(area.height));
Widget::render(
Block::default().style(crate::style::user_message_style()),
card,
buf,
);
let content = card.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 2, /*bottom*/ 1, /*right*/ 2,
));
Renderable::render(&Paragraph::new(card_lines), content, buf);
let footer_y = card.bottom();
if footer_y < area.bottom() {
let footer = Rect::new(
area.x.saturating_add(/*rhs*/ 2),
footer_y,
area.width.saturating_sub(/*rhs*/ 2),
area.bottom().saturating_sub(footer_y),
);
Renderable::render(
&Paragraph::new(self.footer_lines(footer.width)),
footer,
buf,
);
}
}
fn desired_height(&self, width: u16) -> u16 {
(self.card_lines(width.saturating_sub(/*rhs*/ 4)).len() as u16)
.saturating_add(/*rhs*/ 2)
.saturating_add(self.footer_lines(width.saturating_sub(/*rhs*/ 2)).len() as u16)
}
}
impl ExternalWriterNotice {
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 retry: Line<'static> = vec![
Span::styled("R", crate::style::accent_style()),
" to Retry".into(),
]
.into();
let mut lines = word_wrap_lines(&[title], usize::from(width));
if lines.len() == 1 && lines[0].width() + retry.width() + 2 <= usize::from(width) {
let gap = usize::from(width) - lines[0].width() - retry.width();
lines[0].spans.push(" ".repeat(gap).into());
lines[0].spans.extend(retry.spans);
} else {
lines.push(retry);
}
lines.extend(word_wrap_lines(
&[Line::from(
"Close it there and press R to continue here.".dim(),
)],
RtOptions::new(usize::from(width))
.initial_indent(" ".into())
.subsequent_indent(" ".into()),
));
lines
}
fn footer_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut items = vec![
("r".to_string(), "retry".to_string()),
(
format!(
"{}/{}/{}",
crate::key_hint::plain(KeyCode::Esc).display_label(),
crate::key_hint::ctrl(KeyCode::Char('c')).display_label(),
crate::key_hint::plain(KeyCode::Char('q')).display_label(),
)
.replace(" + ", "+"),
"exit".to_string(),
),
];
if let Some(hint) = self.transcript_hint {
items.push((
hint.display_label().replace(" + ", "+"),
"transcript".to_string(),
));
}
let mut spans = vec![" ".set_style(crate::style::footer_hint_label_style())];
for (idx, (key, label)) in items.into_iter().enumerate() {
if idx > 0 {
spans.push(" ".set_style(crate::style::footer_hint_label_style()));
}
spans.push(key.set_style(crate::style::footer_hint_key_style()));
spans.push(format!(" {label}").set_style(crate::style::footer_hint_label_style()));
}
word_wrap_lines(&[Line::from(spans)], usize::from(width))
}
}
impl ChatWidget {
pub(crate) fn as_renderable(&self) -> RenderableItem<'_> {
if self
@@ -68,13 +180,19 @@ impl ChatWidget {
})),
);
}
flex.push(
/*flex*/ 0,
let bottom = if self.external_writer_view && !self.bottom_pane.has_active_view() {
RenderableItem::Owned(Box::new(ExternalWriterNotice {
transcript_hint: self.bottom_pane.transcript_shortcut_hint(),
}))
} else {
self.bottom_pane
.as_renderable_with_composer_right_reserve(active_cell_right_reserve)
.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
};
flex.push(
/*flex*/ 0,
bottom.inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
);
RenderableItem::Owned(Box::new(flex))
}
@@ -75,6 +75,60 @@ fn contains_text(buffer: &Buffer, text: &str) -> bool {
})
}
#[tokio::test]
async fn external_writer_view_shows_notice_instead_of_composer() {
let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await;
widget.show_external_writer_thread();
let frame = crate::terminal_palette::with_test_default_colors(
crate::terminal_probe::DefaultColors {
fg: (230, 230, 230),
bg: (20, 20, 20),
},
|| render_frame(&widget, /*width*/ 60),
);
let rows: Vec<String> = frame
.content
.chunks(usize::from(frame.area.width))
.map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect())
.collect();
insta::assert_snapshot!(
rows.iter()
.map(|row| row.trim_end())
.collect::<Vec<_>>()
.join("\n")
);
assert_ne!(frame[(0, 1)].bg, ratatui::style::Color::Reset);
assert_eq!(frame[(0, 1)].bg, frame[(59, 4)].bg);
assert!(
!frame[(3, 5)]
.style()
.add_modifier
.intersects(ratatui::style::Modifier::DIM | ratatui::style::Modifier::BOLD)
);
assert!(
frame[(5, 5)]
.style()
.add_modifier
.contains(ratatui::style::Modifier::DIM)
);
assert!(!widget.bottom_pane.composer_input_enabled());
}
#[tokio::test]
async fn external_writer_notice_uses_current_transcript_shortcut() {
let (mut widget, _sender, _events, _operations) = make_chatwidget_manual_with_sender().await;
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.app.open_transcript = vec![crate::key_hint::ctrl(KeyCode::Char('k'))];
widget.bottom_pane.set_keymap_bindings(&keymap);
widget.show_external_writer_thread();
assert!(contains_text(
&render_frame(&widget, /*width*/ 80),
"ctrl+k transcript"
));
}
#[test]
fn active_transcript_preserves_clipped_markdown_hyperlinks() {
let cell = history_cell::AgentMarkdownCell::new(
@@ -0,0 +1,10 @@
---
source: tui/src/chatwidget/rendering_tests.rs
expression: "rows.iter().map(|row| row.trim_end()).collect::<Vec<_>>().join(\"\\n\")"
---
🔒 This conversation is open in another app R to Retry
Close it there and press R to continue here.
r retry esc/ctrl+c/q exit ctrl+t transcript
@@ -1,6 +1,51 @@
use super::*;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn external_writer_snapshot_freezes_active_command_and_mcp_rows() {
let mut rendered = Vec::new();
for active_mcp in [false, true] {
let (mut chat, _events, _operations) =
make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
if active_mcp {
chat.transcript.active_cell = Some(Box::new(history_cell::new_active_mcp_tool_call(
"mcp-running".to_string(),
McpInvocation {
server: "server".to_string(),
tool: "tool".to_string(),
arguments: None,
},
/*animations_enabled*/ true,
)));
} else {
begin_exec(&mut chat, "call-running", "sleep 5");
}
chat.show_external_writer_thread();
assert!(!chat.is_task_running_for_test());
let cell = chat
.transcript
.active_cell
.as_ref()
.expect("active tool row");
rendered.push(lines_to_single_string(&cell.display_lines(/*width*/ 80)));
if active_mcp {
assert!(cell.transcript_animation_tick().is_none());
} else {
assert!(
!cell
.as_any()
.downcast_ref::<ExecCell>()
.expect("active command row")
.animations_enabled()
);
}
}
insta::assert_snapshot!(rendered.join("\n---\n"));
}
#[tokio::test]
async fn replayed_command_completion_preserves_tracking_without_duplicate_starts() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -0,0 +1,8 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
expression: "rendered.join(\"\\n---\\n\")"
---
• Running sleep 5
---
• Calling server.tool()
+4
View File
@@ -179,6 +179,10 @@ impl ExecCell {
self.animations_enabled
}
pub(crate) fn freeze_snapshot(&mut self) {
self.animations_enabled = false;
}
pub(crate) fn iter_calls(&self) -> impl Iterator<Item = &ExecCall> {
self.calls.iter()
}
+4
View File
@@ -120,6 +120,10 @@ impl McpToolCallCell {
self.result = Some(Err("interrupted".to_string()));
}
pub(crate) fn freeze_snapshot(&mut self) {
self.animations_enabled = false;
}
fn result_kind(&self) -> McpResultKind {
if is_node_repl_backed_server(&self.invocation.server) && self.invocation.tool == "js" {
McpResultKind::NodeRepl
+2 -16
View File
@@ -21,6 +21,8 @@ use crate::markdown_render::render_streaming_markdown_lines_with_width_and_cwd a
use crate::pager_overlay::Overlay;
use crate::session_resume::resolve_session_thread_id;
use crate::status::format_directory_display;
use crate::style::footer_hint_key_style;
use crate::style::footer_hint_label_style;
use crate::terminal_palette::best_color;
use crate::terminal_palette::default_bg;
use crate::text_formatting::truncate_text;
@@ -2630,22 +2632,6 @@ fn fit_footer_hint_refs(
Some(spans.into())
}
fn footer_hint_key_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::Black)
} else {
Style::default()
}
}
fn footer_hint_label_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::DarkGray)
} else {
Style::default().dim()
}
}
fn footer_hints_width(
hints: &[&PickerFooterHint],
mode: FooterHintLabelMode,
+16
View File
@@ -61,6 +61,22 @@ pub(crate) fn accent_style() -> Style {
accent_style_for(default_bg())
}
pub(crate) fn footer_hint_key_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::Black)
} else {
Style::default()
}
}
pub(crate) fn footer_hint_label_style() -> Style {
if default_bg().is_some_and(is_light) {
Style::default().fg(Color::DarkGray)
} else {
Style::default().dim()
}
}
/// Returns the style for a user-authored message using the provided terminal background.
pub fn user_message_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
match terminal_bg {