mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
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:
committed by
copyberry
parent
6ddb87e700
commit
7924f95421
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+8
@@ -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
|
||||
+11
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user