mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
Persist local workspace side chats and add tab close controls (#60152)
## What changed - Save side chats as attachments to their parent conversation when local workspace history supports them, and let users reopen them from the sidebar after a restart. - Show chat names or first-prompt previews in sidebar labels, and allow saved side chats to be renamed with `/rename`. - Add tab close buttons, middle-click closing, and the configurable `close_sidebar_tab` shortcut, defaulting to `Ctrl-X W`. Closing a saved chat keeps it available in the picker; archiving removes it from Side chats. - Confirm actions that stop running work, discard unsent input, or lose temporary history. Delete saved TUI side chats when deleting their parent conversation. ## Testing Add coverage for reopening saved side chats after restart, archive handling, close confirmations, and preventing close-confirmation keys from accepting a replacement approval dialog. GitOrigin-RevId: 3d47533d007286a7354660c08df13a83dd48d5d7
This commit is contained in:
@@ -145,6 +145,8 @@ pub struct TuiGlobalKeymap {
|
||||
pub focus_sidebar_tabs: Option<KeybindingsSpec>,
|
||||
/// Hide the focused sidebar pane.
|
||||
pub hide_pane: Option<KeybindingsSpec>,
|
||||
/// Close the active sidebar tab.
|
||||
pub close_sidebar_tab: Option<KeybindingsSpec>,
|
||||
/// Focus the parent conversation.
|
||||
pub focus_pane_left: Option<KeybindingsSpec>,
|
||||
/// Focus the open side conversation.
|
||||
|
||||
@@ -5368,6 +5368,7 @@
|
||||
"global": {
|
||||
"change_host": null,
|
||||
"clear_terminal": null,
|
||||
"close_sidebar_tab": null,
|
||||
"copy": null,
|
||||
"equalize_pane_widths": null,
|
||||
"find_transcript": null,
|
||||
@@ -6197,6 +6198,14 @@
|
||||
],
|
||||
"description": "Clear the terminal UI."
|
||||
},
|
||||
"close_sidebar_tab": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Close the active sidebar tab."
|
||||
},
|
||||
"copy": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -6535,6 +6544,7 @@
|
||||
"default": {
|
||||
"change_host": null,
|
||||
"clear_terminal": null,
|
||||
"close_sidebar_tab": null,
|
||||
"copy": null,
|
||||
"equalize_pane_widths": null,
|
||||
"find_transcript": null,
|
||||
|
||||
@@ -165,6 +165,7 @@ pub use session_index::append_thread_name;
|
||||
pub use session_index::find_thread_meta_by_name_str;
|
||||
pub use session_index::find_thread_meta_candidates_by_name_str;
|
||||
pub use session_index::find_thread_name_by_id;
|
||||
pub use session_index::find_thread_name_entries_by_ids;
|
||||
pub use session_index::find_thread_names_by_ids;
|
||||
pub use session_index::remove_thread_name_entries;
|
||||
pub use state_db::StateDbHandle;
|
||||
|
||||
@@ -173,6 +173,18 @@ pub async fn find_thread_names_by_ids(
|
||||
codex_home: &Path,
|
||||
thread_ids: &HashSet<ThreadId>,
|
||||
) -> std::io::Result<HashMap<ThreadId, String>> {
|
||||
Ok(find_thread_name_entries_by_ids(codex_home, thread_ids)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(id, entry)| (id, entry.thread_name.trim().to_owned()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Returns each thread's latest indexed name and when it was written.
|
||||
pub async fn find_thread_name_entries_by_ids(
|
||||
codex_home: &Path,
|
||||
thread_ids: &HashSet<ThreadId>,
|
||||
) -> std::io::Result<HashMap<ThreadId, SessionIndexEntry>> {
|
||||
if thread_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
@@ -191,7 +203,7 @@ pub async fn find_thread_names_by_ids(
|
||||
let name = entry.thread_name.trim();
|
||||
// The first nonempty name seen for an id is its latest usable name.
|
||||
if !name.is_empty() && remaining_ids.remove(&entry.id) {
|
||||
names.insert(entry.id, name.to_string());
|
||||
names.insert(entry.id, entry);
|
||||
if remaining_ids.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
+15
-1
@@ -281,6 +281,7 @@ mod side_companion;
|
||||
mod side_focus;
|
||||
mod side_pane;
|
||||
mod sidebar;
|
||||
mod sidebar_close;
|
||||
mod startup;
|
||||
pub(crate) mod startup_bootstrap;
|
||||
mod startup_prompts;
|
||||
@@ -853,13 +854,23 @@ impl App {
|
||||
self.transcript_view
|
||||
.sync_patch_approval(self.chat_widget.current_patch_approval());
|
||||
// A chooser action must never decide a modal that has not been painted yet.
|
||||
if self.conversation_frame.chooser_area().is_some()
|
||||
if (self.conversation_frame.chooser_area().is_some()
|
||||
|| self.sidebar().is_some_and(|pane| pane.close.is_some()))
|
||||
&& self.chat_widget.has_active_modal()
|
||||
&& matches!(
|
||||
&event,
|
||||
TuiEvent::Key(_) | TuiEvent::Mouse(_) | TuiEvent::Paste(_)
|
||||
)
|
||||
{
|
||||
if let TuiEvent::Key(key) = &event
|
||||
&& key.kind != KeyEventKind::Release
|
||||
{
|
||||
self.chat_widget.suppress_current_view_key_until_release(
|
||||
KeyBinding::from_event(*key),
|
||||
Instant::now(),
|
||||
crate::activation_key_guard::ActivationKeySuppression::ViewTransition,
|
||||
);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
@@ -871,6 +882,9 @@ impl App {
|
||||
{
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
if Box::pin(sidebar_close::handle(self, tui, app_server, &event)).await {
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
if self.handle_pane_resize(tui, &event).await {
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
|
||||
@@ -222,6 +222,18 @@ impl App {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if matches!(notification, ServerNotification::ThreadArchived(_))
|
||||
&& (self.side_threads.contains_key(&thread_id)
|
||||
|| self
|
||||
.sidebars
|
||||
.values()
|
||||
.any(|pane| pane.chat_tabs().choice_index(thread_id).is_some()))
|
||||
{
|
||||
self.app_event_tx.send(AppEvent::SideChatArchived {
|
||||
backend_id: app_server.backend_id(),
|
||||
target: thread_id,
|
||||
});
|
||||
}
|
||||
let refresh_uncached_thread = matches!(visibility, AgentPickerThreadVisibility::Visible)
|
||||
&& self.agent_navigation.get(&thread_id).is_none();
|
||||
self.set_agent_picker_thread_visibility(thread_id, visibility);
|
||||
|
||||
@@ -208,7 +208,7 @@ impl App {
|
||||
),
|
||||
AgentsOverviewAction::Delete => (
|
||||
format!("Permanently delete “{name}”?"),
|
||||
"This stops any running work in this task and its child agents, then permanently deletes their history. This cannot be undone.",
|
||||
"Tasks with saved side chats stay intact. Otherwise, deleting this task stops its running work and permanently deletes its history and its child agents’ history.",
|
||||
"Permanently delete task and child agents",
|
||||
),
|
||||
};
|
||||
|
||||
@@ -91,6 +91,9 @@ impl App {
|
||||
| AppEvent::CommitRealtimeTranscriptHistory
|
||||
| AppEvent::ParkSideCompanion { .. }
|
||||
| AppEvent::SideCreated { .. }
|
||||
| AppEvent::SideChatRemovalFinished { .. }
|
||||
| AppEvent::SideChatArchived { .. }
|
||||
| AppEvent::SavedSideChatsLoaded { .. }
|
||||
| AppEvent::SideReplayFinished(_)
|
||||
| AppEvent::ResetTranscriptForThreadSwitch
|
||||
| AppEvent::ResetTranscriptForThreadSwitchPreservingScreen
|
||||
@@ -3388,6 +3391,55 @@ impl App {
|
||||
} => {
|
||||
Box::pin(self.finish_side_creation(tui, app_server, parent, request, result)).await;
|
||||
}
|
||||
AppEvent::SideChatRemovalFinished { backend_id, target, request, action, result } => {
|
||||
if backend_id == app_server.backend_id()
|
||||
&& let Some(side) = self.side_threads.get_mut(&target)
|
||||
&& side.close_request == Some(request)
|
||||
{
|
||||
side.close_request = None;
|
||||
match (action, result) {
|
||||
(sidebar_close::Action::Archive, Ok(())) => {
|
||||
sidebar_close::forget_archived(self, tui, app_server, target).await?;
|
||||
}
|
||||
(sidebar_close::Action::CloseTab, Ok(())) => {
|
||||
self.abandoned_side_threads.insert(target);
|
||||
self.discard_thread_local_state(target).await;
|
||||
}
|
||||
(_, Err(message)) => {
|
||||
if action == sidebar_close::Action::Archive {
|
||||
// The server may have stopped the writer before the file move failed.
|
||||
if let Some(channel) = self.thread_event_channels.get_mut(&target) {
|
||||
channel.mark_replay_only();
|
||||
}
|
||||
self.mark_agent_picker_thread_closed(target);
|
||||
}
|
||||
self.add_agents_overview_error(message);
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
AppEvent::SideChatArchived { backend_id, target } => {
|
||||
if backend_id == app_server.backend_id() {
|
||||
sidebar_close::forget_archived(self, tui, app_server, target).await?;
|
||||
}
|
||||
}
|
||||
AppEvent::SavedSideChatsLoaded { backend_id, parent, request, result } => {
|
||||
let visible = self.current_displayed_thread_id() == Some(parent);
|
||||
if let Some(pane) = self.sidebars.get_mut(&Some(parent))
|
||||
&& pane.saved_chats_request == Some(request)
|
||||
{
|
||||
pane.set_saved_chats_request(/*request*/ None);
|
||||
if backend_id == app_server.backend_id() {
|
||||
match result {
|
||||
Ok(chats) => pane.update_saved_chats(chats),
|
||||
Err(error) if visible => self.chat_widget.add_error_message(error),
|
||||
Err(error) => tracing::warn!(%parent, %error),
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
AppEvent::OpenSkillsList => {
|
||||
self.chat_widget.open_skills_list();
|
||||
}
|
||||
@@ -4232,7 +4284,9 @@ impl App {
|
||||
let result = async {
|
||||
self.stop_voice_for_removed_thread(app_server, thread_id)
|
||||
.await?;
|
||||
app_server.thread_delete(thread_id).await
|
||||
app_server
|
||||
.delete_thread_with_saved_side_chats(thread_id)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
Ok(match result {
|
||||
|
||||
@@ -660,7 +660,9 @@ impl App {
|
||||
}
|
||||
|
||||
if side_return_shortcut_matches(key_event)
|
||||
&& self.maybe_return_from_side(tui, app_server).await
|
||||
&& self
|
||||
.maybe_return_from_side(tui, app_server, key_event)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ pub(super) struct ConversationFrame {
|
||||
pub(super) chat_strip: Option<Rect>,
|
||||
pub(super) sidebar_input: Option<(usize, Option<ThreadId>, usize)>,
|
||||
pub(super) leader_hud: Option<Rect>,
|
||||
pub(super) close_buttons: [Rect; 2],
|
||||
/// A dialog can obscure neighboring content in the terminal's completed buffer.
|
||||
behind_dialog: Option<ratatui::buffer::Buffer>,
|
||||
caller_images: Vec<ImagePlacement>,
|
||||
@@ -249,6 +250,12 @@ impl App {
|
||||
self.conversation_frame = ConversationFrame::default();
|
||||
}
|
||||
let caller_inset = self.conversation_frame.inset;
|
||||
let parent = self.sidebar_parent();
|
||||
if !self.conversation_focus_available(tui)
|
||||
&& let Some(pane) = self.sidebars.get_mut(&parent)
|
||||
{
|
||||
pane.close = None;
|
||||
}
|
||||
let layout = sidebar::paint_layout(self, tui, screen_size.into());
|
||||
let displayed = self.current_displayed_thread_id();
|
||||
let chat = match displayed.and_then(|id| self.side_pair_key(id)) {
|
||||
@@ -365,6 +372,15 @@ impl App {
|
||||
ratatui::widgets::Paragraph::new(text)
|
||||
.wrap(ratatui::widgets::Wrap { trim: false })
|
||||
.render(body, frame.buffer);
|
||||
} else if self
|
||||
.sidebar()
|
||||
.is_some_and(|pane| pane.saved_chats_request.is_some())
|
||||
{
|
||||
ratatui::widgets::Clear.render(*sidebar, frame.buffer);
|
||||
Line::from("Loading side chats…").dim().render(
|
||||
sidebar.inner(ratatui::layout::Margin::new(1, 1)),
|
||||
frame.buffer,
|
||||
);
|
||||
} else if let Some(pane) = self.sidebar() {
|
||||
let footer = if let Some(hint) = resize_hint {
|
||||
let row = crate::sidebar_chooser::body_row(*sidebar, sidebar.y);
|
||||
@@ -421,6 +437,27 @@ impl App {
|
||||
if let Some(divider) = divider {
|
||||
side_pane::paint_divider(frame, divider.x, self.divider_drag, highlight_divider);
|
||||
}
|
||||
self.conversation_frame.close_buttons = Default::default();
|
||||
if let Some(close) = self.sidebar().and_then(|pane| pane.close.as_ref()) {
|
||||
// A resize can collapse an unfocused sidebar while its confirmation is open.
|
||||
let viewport = frame.area();
|
||||
let column = self
|
||||
.conversation_frame
|
||||
.chooser_area()
|
||||
.or(strip)
|
||||
.unwrap_or(viewport);
|
||||
let area = Rect {
|
||||
x: column.x,
|
||||
width: column.width,
|
||||
..viewport
|
||||
};
|
||||
let buttons = close.paint(area, frame.buffer);
|
||||
frame
|
||||
.image_placements
|
||||
.retain(|image| !area.intersects(image.area));
|
||||
frame.cursor_position = None;
|
||||
self.conversation_frame.close_buttons = buttons;
|
||||
}
|
||||
self.paint_centered_dialog(frame);
|
||||
self.conversation_frame.leader_hud = hud.map(|hud| {
|
||||
let area = hud.render_overlay(frame.area(), frame.buffer);
|
||||
|
||||
@@ -10,6 +10,7 @@ use super::app_server_event_targets::server_notification_thread_target;
|
||||
use super::app_server_event_targets::server_request_thread_id;
|
||||
use super::*;
|
||||
use crate::app_server_session::SessionAccount;
|
||||
use crate::app_server_session::SideChatStorage;
|
||||
use crate::app_server_session::is_server_shutting_down_error;
|
||||
use crate::app_server_session::source_agent_path;
|
||||
use crate::app_server_session::thread_blocks_direct_input;
|
||||
@@ -385,13 +386,26 @@ impl App {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let (session, turns, live_attached) = match app_server
|
||||
.resume_thread(
|
||||
&self.local_settings,
|
||||
self.config.clone(),
|
||||
thread_id,
|
||||
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
|
||||
)
|
||||
use crate::app_server_session::ResumeModelSettings;
|
||||
let side = self.side_threads.get(&thread_id);
|
||||
let mut config = self.config.clone();
|
||||
let resume_settings = if side.is_some_and(|side| side.storage == SideChatStorage::Attached)
|
||||
&& self
|
||||
.agent_navigation
|
||||
.get(&thread_id)
|
||||
.is_some_and(|entry| entry.is_closed)
|
||||
{
|
||||
config.developer_instructions = Some(
|
||||
codex_context_fragments::SideConversationPolicy::Attached
|
||||
.instructions(config.developer_instructions.as_deref()),
|
||||
);
|
||||
ResumeModelSettings::PreserveSideConversation
|
||||
} else {
|
||||
ResumeModelSettings::PreserveExistingThread
|
||||
};
|
||||
let is_side = side.is_some();
|
||||
let (mut session, turns, live_attached) = match app_server
|
||||
.resume_thread(&self.local_settings, config, thread_id, resume_settings)
|
||||
.await
|
||||
{
|
||||
Ok(started) => {
|
||||
@@ -466,6 +480,11 @@ impl App {
|
||||
(session, turns, false)
|
||||
}
|
||||
};
|
||||
if is_side {
|
||||
session.forked_from_id = None;
|
||||
session.fork_parent_title = None;
|
||||
session.daybreak_enabled = false;
|
||||
}
|
||||
// A reattach can discover output missed while the server was disconnected.
|
||||
let replay_may_have_output = if self.side_companions.contains_key(&thread_id) {
|
||||
match self.thread_event_channels.get(&thread_id) {
|
||||
@@ -570,6 +589,18 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<()> {
|
||||
if self
|
||||
.side_threads
|
||||
.get(&thread_id)
|
||||
.is_some_and(|side| side.close_request.is_some())
|
||||
{
|
||||
self.chat_widget.add_info_message(
|
||||
"Side chat is still closing. Try again in a moment.".to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let pending_profile_on_unavailable_thread = self.thread_unavailable(thread_id)
|
||||
&& self.pending_server_profiles.contains_key(&thread_id);
|
||||
let cached_session = if self.thread_unavailable(thread_id) {
|
||||
|
||||
+117
-86
@@ -1,6 +1,6 @@
|
||||
//! Transient side-conversation threads.
|
||||
//! Side-conversation thread lifecycle.
|
||||
//!
|
||||
//! A side conversation is an ephemeral fork used for a quick /side question while keeping the
|
||||
//! A side conversation is a fork used for a quick /side question while keeping the
|
||||
//! primary thread focused. This module owns the app-level lifecycle for those forks: switching into
|
||||
//! them, returning to their parent, retaining them across normal navigation, and explicitly
|
||||
//! discarding them. The fork receives hidden developer instructions that make inherited history
|
||||
@@ -9,14 +9,10 @@
|
||||
|
||||
use super::*;
|
||||
use crate::app_event::SideStartMode;
|
||||
use crate::app_server_session::SideChatStorage;
|
||||
use crate::chatwidget::InterruptedTurnNoticeMode;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeParams;
|
||||
use codex_app_server_protocol::ThreadUnsubscribeResponse;
|
||||
use codex_app_server_protocol::TurnInterruptParams;
|
||||
use codex_app_server_protocol::TurnInterruptResponse;
|
||||
use codex_context_fragments::SideConversationPolicy;
|
||||
|
||||
const SIDE_RENAME_BLOCK_MESSAGE: &str = "Side conversations are ephemeral and cannot be renamed.";
|
||||
const SIDE_RENAME_BLOCK_MESSAGE: &str = "Side conversations cannot be renamed.";
|
||||
const SIDE_MAIN_THREAD_UNAVAILABLE_MESSAGE: &str =
|
||||
"'/side' is unavailable until the main thread is ready.";
|
||||
const SIDE_NO_STARTED_CONVERSATION_MESSAGE: &str = concat!(
|
||||
@@ -80,6 +76,7 @@ impl SideParentStatus {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_context_fragments::SideConversationPolicy;
|
||||
use codex_context_fragments::side_conversation_boundary;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -185,6 +182,8 @@ pub(super) struct SideThreadState {
|
||||
/// Parent-thread condition that changed while this side thread is visible.
|
||||
pub(super) parent_status: Option<SideParentStatus>,
|
||||
pub(super) opening_message: Option<crate::chatwidget::UserMessage>,
|
||||
pub(super) close_request: Option<uuid::Uuid>,
|
||||
pub(super) storage: SideChatStorage,
|
||||
}
|
||||
|
||||
impl SideThreadState {
|
||||
@@ -193,6 +192,8 @@ impl SideThreadState {
|
||||
parent_thread_id,
|
||||
parent_status: None,
|
||||
opening_message: None,
|
||||
close_request: None,
|
||||
storage: SideChatStorage::Temporary,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,10 +222,10 @@ impl App {
|
||||
clear_side_ui(&mut self.chat_widget);
|
||||
return;
|
||||
};
|
||||
let Some((parent_thread_id, parent_status)) = self
|
||||
let Some((parent_thread_id, parent_status, storage)) = self
|
||||
.side_threads
|
||||
.get(&active_thread_id)
|
||||
.map(|state| (state.parent_thread_id, state.parent_status))
|
||||
.map(|state| (state.parent_thread_id, state.parent_status, state.storage))
|
||||
else {
|
||||
clear_side_ui(&mut self.chat_widget);
|
||||
if self
|
||||
@@ -245,8 +246,12 @@ impl App {
|
||||
return;
|
||||
};
|
||||
|
||||
self.chat_widget
|
||||
.set_thread_rename_block_message(SIDE_RENAME_BLOCK_MESSAGE);
|
||||
match storage {
|
||||
SideChatStorage::Attached => self.chat_widget.clear_thread_rename_block(),
|
||||
SideChatStorage::Temporary => self
|
||||
.chat_widget
|
||||
.set_thread_rename_block_message(SIDE_RENAME_BLOCK_MESSAGE),
|
||||
}
|
||||
self.chat_widget
|
||||
.set_side_conversation_active(/*active*/ true);
|
||||
self.chat_widget
|
||||
@@ -268,8 +273,15 @@ impl App {
|
||||
) {
|
||||
label_parts.push(format!("{} to switch", binding.display_label()));
|
||||
}
|
||||
let action = if storage == SideChatStorage::Attached
|
||||
&& self.local_settings.workspace_enabled(&self.config)
|
||||
{
|
||||
"archive"
|
||||
} else {
|
||||
"close"
|
||||
};
|
||||
label_parts.push(format!(
|
||||
"{} to close",
|
||||
"{} to {action}",
|
||||
crate::key_hint::ctrl(KeyCode::Char('c')).display_label()
|
||||
));
|
||||
self.chat_widget
|
||||
@@ -398,14 +410,35 @@ impl App {
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
key: KeyEvent,
|
||||
) -> bool {
|
||||
if self.overlay.is_none()
|
||||
&& self.chat_widget.no_modal_or_popup_active()
|
||||
&& self.chat_widget.composer_is_empty()
|
||||
&& let Some(parent_thread_id) = self.active_side_parent_thread_id()
|
||||
&& let Some(target) = self.current_displayed_thread_id()
|
||||
&& let Some(side) = self.side_threads.get(&target)
|
||||
{
|
||||
if matches!(key.code, KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
|
||||
&& self.side_retention_enabled(tui)
|
||||
&& self.sidebar_chooser_available()
|
||||
&& !self.transcript_view.owns_interaction_key(key)
|
||||
&& side.storage == SideChatStorage::Attached
|
||||
{
|
||||
if let Err(err) = sidebar_close::request(
|
||||
self,
|
||||
tui,
|
||||
app_server,
|
||||
target,
|
||||
sidebar_close::Action::Archive,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.chat_widget.add_error_message(err.to_string());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if self
|
||||
.select_agent_thread_and_discard_side(tui, app_server, parent_thread_id)
|
||||
.select_agent_thread_and_discard_side(tui, app_server, side.parent_thread_id)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
@@ -504,57 +537,36 @@ impl App {
|
||||
self.abandoned_side_threads.insert(thread_id);
|
||||
self.pending_app_server_requests
|
||||
.cancel_thread_verification(&thread_id.to_string());
|
||||
let turn_id = self
|
||||
.active_turn_id_for_thread(thread_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let request_handle = app_server.request_handle();
|
||||
let interrupt_request_id = app_server.next_request_id();
|
||||
let retry_interrupt_request_id = app_server.next_request_id();
|
||||
let unsubscribe_request_id = app_server.next_request_id();
|
||||
let turn_id = self.active_turn_id_for_thread(thread_id).await;
|
||||
let attachment = self
|
||||
.thread_event_channels
|
||||
.get(&thread_id)
|
||||
.map(ThreadEventChannel::attachment);
|
||||
let mut view = app_server.request_view();
|
||||
|
||||
let saved_parent = self
|
||||
.side_threads
|
||||
.get(&thread_id)
|
||||
.filter(|side| side.storage == SideChatStorage::Attached)
|
||||
.map(|side| side.parent_thread_id);
|
||||
self.discard_thread_local_state(thread_id).await;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let interrupt_result = request_handle
|
||||
.request_typed::<TurnInterruptResponse>(ClientRequest::TurnInterrupt {
|
||||
request_id: interrupt_request_id,
|
||||
params: TurnInterruptParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
},
|
||||
})
|
||||
.await;
|
||||
let interrupt_result = if let Err(error) = &interrupt_result
|
||||
&& let Some(actual_turn_id) = active_turn_interrupt_race(error)
|
||||
let cleanup = tokio::spawn(async move {
|
||||
if let Err(error) = interrupt_for_close(&mut view, thread_id, turn_id, attachment).await
|
||||
{
|
||||
request_handle
|
||||
.request_typed::<TurnInterruptResponse>(ClientRequest::TurnInterrupt {
|
||||
request_id: retry_interrupt_request_id,
|
||||
params: TurnInterruptParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: actual_turn_id,
|
||||
},
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
interrupt_result
|
||||
};
|
||||
if let Err(error) = interrupt_result {
|
||||
tracing::warn!(%error, "failed to interrupt side conversation");
|
||||
}
|
||||
if let Err(error) = request_handle
|
||||
.request_typed::<ThreadUnsubscribeResponse>(ClientRequest::ThreadUnsubscribe {
|
||||
request_id: unsubscribe_request_id,
|
||||
params: ThreadUnsubscribeParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
{
|
||||
if let Err(error) = view.thread_unsubscribe(thread_id).await {
|
||||
tracing::warn!(%error, "failed to unsubscribe side conversation");
|
||||
}
|
||||
});
|
||||
if let Some(parent) = saved_parent {
|
||||
self.sidebars
|
||||
.entry(Some(parent))
|
||||
.or_default()
|
||||
.closing_chats
|
||||
.insert(thread_id, cleanup);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn discard_closed_side_thread(&mut self, thread_id: ThreadId) {
|
||||
@@ -588,7 +600,7 @@ impl App {
|
||||
if let Some(side) = self.side_threads.remove(&thread_id)
|
||||
&& let Some(pane) = self.sidebars.get_mut(&Some(side.parent_thread_id))
|
||||
{
|
||||
pane.remove_chat(thread_id);
|
||||
pane.remove_chat(thread_id, side.storage);
|
||||
}
|
||||
self.side_companions.remove(&thread_id);
|
||||
self.agent_navigation.remove(thread_id);
|
||||
@@ -606,28 +618,16 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) -> std::result::Result<(), String> {
|
||||
let interrupt_result =
|
||||
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
|
||||
app_server.turn_interrupt(thread_id, turn_id).await
|
||||
} else {
|
||||
app_server.startup_interrupt(thread_id).await
|
||||
};
|
||||
// Replay-only sides may still be running after reconnect, so always interrupt.
|
||||
// If the ephemeral thread is gone, let unsubscribe and local cleanup finish.
|
||||
if let Err(TypedRequestError::Server { method, source }) = &interrupt_result
|
||||
&& method == "turn/interrupt"
|
||||
&& source.code == -32600
|
||||
&& source.message == format!("thread not found: {thread_id}")
|
||||
&& self
|
||||
.thread_event_channels
|
||||
.get(&thread_id)
|
||||
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ReplayOnly)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
interrupt_result.map_err(|err| {
|
||||
format!("Failed to close side conversation {thread_id}; it is still open: {err}")
|
||||
})
|
||||
let turn_id = self.active_turn_id_for_thread(thread_id).await;
|
||||
let attachment = self
|
||||
.thread_event_channels
|
||||
.get(&thread_id)
|
||||
.map(ThreadEventChannel::attachment);
|
||||
interrupt_for_close(app_server, thread_id, turn_id, attachment)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!("Failed to close side conversation {thread_id}; it is still open: {err}")
|
||||
})
|
||||
}
|
||||
|
||||
async fn keep_side_thread_visible_after_cleanup_failure(
|
||||
@@ -645,7 +645,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
async fn discard_side_thread_or_keep_visible(
|
||||
pub(super) async fn discard_side_thread_or_keep_visible(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
@@ -668,12 +668,7 @@ impl App {
|
||||
}
|
||||
fork_config.model_reasoning_effort = self.chat_widget.current_reasoning_effort();
|
||||
fork_config.service_tier = self.chat_widget.configured_service_tier();
|
||||
fork_config.ephemeral = true;
|
||||
fork_config.daybreak_enabled = false;
|
||||
fork_config.developer_instructions = Some(
|
||||
SideConversationPolicy::Temporary
|
||||
.instructions(fork_config.developer_instructions.as_deref()),
|
||||
);
|
||||
fork_config
|
||||
}
|
||||
|
||||
@@ -824,6 +819,11 @@ impl App {
|
||||
self.side_fork_config(),
|
||||
parent_thread_id,
|
||||
self.selected_server_profile(parent_thread_id),
|
||||
if workspace && app_server.can_list_saved_side_chats(parent_thread_id) {
|
||||
SideChatStorage::Attached
|
||||
} else {
|
||||
SideChatStorage::Temporary
|
||||
},
|
||||
);
|
||||
let create = async move {
|
||||
fork.await
|
||||
@@ -961,6 +961,7 @@ impl App {
|
||||
Self::install_side_thread_snapshot(&mut store, forked.session, forked.turns);
|
||||
}
|
||||
let mut child = SideThreadState::new(parent_thread_id);
|
||||
child.storage = fork.storage;
|
||||
child.parent_status = self
|
||||
.side_thread_for_parent(parent_thread_id)
|
||||
.and_then(|id| self.side_threads.get(&id))
|
||||
@@ -1039,3 +1040,33 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A cached turn can finish or be replaced before the server handles an interrupt.
|
||||
pub(super) async fn interrupt_for_close(
|
||||
server: &mut AppServerSession,
|
||||
thread: ThreadId,
|
||||
turn: Option<String>,
|
||||
attachment: Option<ThreadEventAttachment>,
|
||||
) -> std::result::Result<(), TypedRequestError> {
|
||||
let mut result = match turn {
|
||||
Some(turn) => server.turn_interrupt(thread, turn).await,
|
||||
None => server.startup_interrupt(thread).await,
|
||||
};
|
||||
if let Err(error) = &result
|
||||
&& let Some(current) = active_turn_interrupt_race(error)
|
||||
{
|
||||
result = server.turn_interrupt(thread, current).await;
|
||||
}
|
||||
match result {
|
||||
Err(TypedRequestError::Server { method, source })
|
||||
if method == "turn/interrupt"
|
||||
&& source.code == -32600
|
||||
&& (source.message == "no active turn to interrupt"
|
||||
|| (attachment == Some(ThreadEventAttachment::ReplayOnly)
|
||||
&& source.message == format!("thread not found: {thread}"))) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
use super::sidebar::PaneFocus;
|
||||
use super::sidebar::SidebarContent;
|
||||
use super::sidebar_close::Action::CloseTab;
|
||||
use super::*;
|
||||
use crate::activation_key_guard::ActivationKeySuppression;
|
||||
use crate::app_event::SideStartMode;
|
||||
use crate::app_server_session::SideChatStorage;
|
||||
use crate::bottom_pane::ScrollState;
|
||||
use crate::keymap::ListAction;
|
||||
use crate::sidebar_chooser;
|
||||
@@ -25,7 +27,7 @@ impl App {
|
||||
}
|
||||
let pane = self.sidebar()?;
|
||||
let tabs = pane.chat_tabs().strip();
|
||||
let TabTarget::Tab(id) = tabs.hit(strip, point)? else {
|
||||
let (TabTarget::Tab(id), _) = tabs.hit(strip, point)? else {
|
||||
return None;
|
||||
};
|
||||
if self.conversation_frame.chat_strip.is_some() {
|
||||
@@ -55,8 +57,35 @@ impl App {
|
||||
self.focus_returned_parent_composer(parent).await?;
|
||||
}
|
||||
self.zoomed_thread = None;
|
||||
self.sidebar_mut()
|
||||
.show(SidebarContent::Chooser, PaneFocus::Sidebar);
|
||||
let parent = self.current_displayed_thread_id();
|
||||
let pane = self.sidebar_mut();
|
||||
pane.closing_chats.retain(|_, task| !task.is_finished());
|
||||
let was_open = pane.visible_content() == Some(SidebarContent::Chooser);
|
||||
pane.show(SidebarContent::Chooser, PaneFocus::Sidebar);
|
||||
if !was_open
|
||||
&& pane.creation.is_none()
|
||||
&& pane.saved_chats_request.is_none()
|
||||
&& let Some(parent) = parent
|
||||
&& server.can_list_saved_side_chats(parent)
|
||||
{
|
||||
let request = uuid::Uuid::new_v4();
|
||||
pane.set_saved_chats_request(Some(request));
|
||||
let codex_home = self.config.codex_home.clone();
|
||||
let mut view = server.request_view();
|
||||
let tx = self.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = view
|
||||
.list_saved_side_chats(parent, codex_home.as_path())
|
||||
.await
|
||||
.map_err(|err| format!("Failed to load saved side chats: {err}"));
|
||||
tx.send(AppEvent::SavedSideChatsLoaded {
|
||||
backend_id: view.backend_id(),
|
||||
parent,
|
||||
request,
|
||||
result,
|
||||
});
|
||||
});
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(())
|
||||
}
|
||||
@@ -71,49 +100,55 @@ impl App {
|
||||
return false;
|
||||
}
|
||||
if let TuiEvent::Mouse(mouse) = event
|
||||
&& mouse.kind == MouseEventKind::Down(MouseButton::Left)
|
||||
&& [
|
||||
self.conversation_frame.chat_strip,
|
||||
self.conversation_frame.chooser_area(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|area| area.contains((mouse.column, mouse.row).into()))
|
||||
&& self.conversation_frame.sidebar_input
|
||||
!= self.sidebar().map(sidebar::SidebarPane::input_state)
|
||||
{
|
||||
// A tab may have closed or the chooser scrolled since this frame was painted.
|
||||
tui.frame_requester().schedule_frame();
|
||||
return true;
|
||||
}
|
||||
if let TuiEvent::Mouse(mouse) = event
|
||||
&& mouse.kind == MouseEventKind::Down(MouseButton::Left)
|
||||
&& let Some(pane) = self.sidebar()
|
||||
&& (self.conversation_frame.chat_strip.is_some() || pane.creation.is_none())
|
||||
&& let Some(strip) = self.conversation_frame.sidebar_tab_strip()
|
||||
&& strip.contains((mouse.column, mouse.row).into())
|
||||
&& matches!(
|
||||
mouse.kind,
|
||||
MouseEventKind::Down(MouseButton::Left | MouseButton::Middle)
|
||||
)
|
||||
{
|
||||
let point = (mouse.column, mouse.row).into();
|
||||
let target = pane.chat_tabs().strip().hit(strip, point);
|
||||
let result = if matches!(target, Some(TabTarget::Add)) {
|
||||
self.open_sidebar_tabs(tui, server).await
|
||||
} else if let Some(TabTarget::Tab(id)) = target
|
||||
&& self.current_displayed_thread_id() != Some(id)
|
||||
let frame = &self.conversation_frame;
|
||||
let chooser = frame.chooser_area();
|
||||
let pane = self.sidebar();
|
||||
if (frame.chat_strip.is_some_and(|area| area.contains(point))
|
||||
|| chooser.is_some_and(|area| area.contains(point)))
|
||||
&& frame.sidebar_input != pane.map(sidebar::SidebarPane::input_state)
|
||||
{
|
||||
let zoomed = self.zoomed_thread.is_some()
|
||||
&& self.zoomed_thread == self.current_displayed_thread_id();
|
||||
let result = self.select_agent_thread(tui, server, id).await;
|
||||
if zoomed && self.current_displayed_thread_id() == Some(id) {
|
||||
self.zoomed_thread = Some(id);
|
||||
}
|
||||
result
|
||||
} else {
|
||||
// A tab may have closed or the chooser scrolled since this frame was painted.
|
||||
tui.frame_requester().schedule_frame();
|
||||
return true;
|
||||
}
|
||||
if let Some(pane) = pane
|
||||
&& (frame.chat_strip.is_some()
|
||||
|| (pane.creation.is_none() && pane.saved_chats_request.is_none()))
|
||||
&& let Some(strip) = frame.sidebar_tab_strip()
|
||||
&& strip.contains(point)
|
||||
{
|
||||
let target = pane.chat_tabs().strip().hit(strip, point);
|
||||
let middle = mouse.kind == MouseEventKind::Down(MouseButton::Middle);
|
||||
let result = if let Some((TabTarget::Tab(id), tab)) = target
|
||||
&& (middle || crate::sidebar_chooser::close_cell(tab).contains(point))
|
||||
{
|
||||
sidebar_close::request(self, tui, server, id, CloseTab).await
|
||||
} else if !middle && matches!(target, Some((TabTarget::Add, _))) {
|
||||
self.open_sidebar_tabs(tui, server).await
|
||||
} else if let Some((TabTarget::Tab(id), _)) = target
|
||||
&& self.current_displayed_thread_id() != Some(id)
|
||||
{
|
||||
let zoomed = self.zoomed_thread.is_some()
|
||||
&& self.zoomed_thread == self.current_displayed_thread_id();
|
||||
let result = self.select_agent_thread(tui, server, id).await;
|
||||
if zoomed && self.current_displayed_thread_id() == Some(id) {
|
||||
self.zoomed_thread = Some(id);
|
||||
}
|
||||
result
|
||||
} else {
|
||||
return true;
|
||||
};
|
||||
if let Err(err) = result {
|
||||
self.chat_widget.add_error_message(err.to_string());
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if let Err(err) = result {
|
||||
self.chat_widget.add_error_message(err.to_string());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if self.chooser_focus().is_none() {
|
||||
return false;
|
||||
@@ -132,7 +167,7 @@ impl App {
|
||||
};
|
||||
let pane = self.sidebar_mut();
|
||||
pane.show(SidebarContent::Chooser, PaneFocus::Sidebar);
|
||||
if pane.creation.is_some() {
|
||||
if pane.creation.is_some() || pane.saved_chats_request.is_some() {
|
||||
return true;
|
||||
}
|
||||
let choice = pane.choice;
|
||||
@@ -199,6 +234,9 @@ impl App {
|
||||
|
||||
let open = matches!(action, ChooserAction::Open(_));
|
||||
let pane = self.sidebar_mut();
|
||||
if open && pane.saved_chats_request.is_some() {
|
||||
return true;
|
||||
}
|
||||
if let Some(creation) = &pane.creation {
|
||||
match (&creation.state, open) {
|
||||
(sidebar::CreationState::Waiting { .. }, true) => {}
|
||||
@@ -232,9 +270,35 @@ impl App {
|
||||
ChooserAction::Hide => None,
|
||||
};
|
||||
if let Some(id) = target {
|
||||
let pane = self.sidebar_mut();
|
||||
if pane
|
||||
.closing_chats
|
||||
.get(&id)
|
||||
.is_some_and(|task| !task.is_finished())
|
||||
{
|
||||
self.chat_widget.add_info_message(
|
||||
"Side chat is still closing. Try again in a moment.".to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
let saved = !pane.chat_tabs().ids.contains(&id);
|
||||
if saved && let Some(parent) = self.current_displayed_thread_id() {
|
||||
self.abandoned_side_threads.remove(&id);
|
||||
self.side_threads.insert(
|
||||
id,
|
||||
side::SideThreadState {
|
||||
storage: SideChatStorage::Attached,
|
||||
..side::SideThreadState::new(parent)
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Err(err) = self.select_agent_thread(tui, server, id).await {
|
||||
self.chat_widget.add_error_message(err.to_string());
|
||||
}
|
||||
if saved && self.current_displayed_thread_id() != Some(id) {
|
||||
self.side_threads.remove(&id);
|
||||
}
|
||||
} else if open && let Some(parent_thread_id) = self.chat_widget.thread_id() {
|
||||
self.app_event_tx.send(AppEvent::StartSide {
|
||||
parent_thread_id,
|
||||
@@ -267,6 +331,9 @@ impl App {
|
||||
key: KeyEvent,
|
||||
) -> bool {
|
||||
let bindings = &self.keymap.app;
|
||||
let close = self.current_displayed_thread_id().filter(|id| {
|
||||
bindings.close_sidebar_tab.is_pressed(key) && self.side_threads.contains_key(id)
|
||||
});
|
||||
let left = bindings.focus_pane_left.is_pressed(key);
|
||||
let right = bindings.focus_pane_right.is_pressed(key);
|
||||
let main =
|
||||
@@ -300,7 +367,15 @@ impl App {
|
||||
}
|
||||
let resize = bindings.resize_panes.is_pressed(key) && can_resize;
|
||||
let resizing = resize || width_change != 0 || focused_percent.is_some();
|
||||
if !(tabs || left || right || main || sidebar || hide || zoom_target.is_some() || resizing)
|
||||
if !(close.is_some()
|
||||
|| tabs
|
||||
|| left
|
||||
|| right
|
||||
|| main
|
||||
|| sidebar
|
||||
|| hide
|
||||
|| zoom_target.is_some()
|
||||
|| resizing)
|
||||
|| !self.conversation_focus_available(tui)
|
||||
{
|
||||
return false;
|
||||
@@ -327,7 +402,9 @@ impl App {
|
||||
&& !on_child
|
||||
&& (self.transcript_view.has_active_interaction()
|
||||
|| self.backtrack.overlay_preview_active);
|
||||
let result = if tabs {
|
||||
let result = if let Some(id) = close {
|
||||
sidebar_close::request(self, tui, app_server, id, CloseTab).await
|
||||
} else if tabs {
|
||||
self.open_sidebar_tabs(tui, app_server).await
|
||||
} else if resize {
|
||||
self.begin_pane_resize(tui)
|
||||
@@ -374,10 +451,7 @@ impl App {
|
||||
result
|
||||
} else if sidebar && child.is_none() {
|
||||
self.focus_current_parent_view(tui);
|
||||
self.sidebar_mut()
|
||||
.show(SidebarContent::Chooser, PaneFocus::Sidebar);
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(())
|
||||
self.open_sidebar_tabs(tui, app_server).await
|
||||
} else if hide || sidebar {
|
||||
self.hide_focused_side_pane(tui, app_server).await
|
||||
} else {
|
||||
|
||||
+112
-14
@@ -5,10 +5,17 @@ use super::App;
|
||||
use super::pane_motion::PaneMotion;
|
||||
use super::side_pane;
|
||||
use super::side_pane::SidePaneLayout;
|
||||
use crate::app_server_session::SideChat;
|
||||
use crate::app_server_session::SideChatStorage;
|
||||
use crate::app_server_session::side_chat_prompt_preview;
|
||||
use crate::sidebar_chooser::ChatTabs;
|
||||
use crate::tui::Tui;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ThreadItemPayload;
|
||||
use codex_protocol::ThreadId;
|
||||
use ratatui::layout::Rect;
|
||||
use std::collections::HashMap;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(super) enum SidebarContent {
|
||||
@@ -52,8 +59,13 @@ impl SideCreation {
|
||||
#[derive(Default)]
|
||||
pub(super) struct SidebarPane {
|
||||
pub(super) creation: Option<SideCreation>,
|
||||
pub(super) close: Option<super::sidebar_close::Confirmation>,
|
||||
content: SidebarContent,
|
||||
tabs: Vec<ThreadId>,
|
||||
// The chooser keeps the same order when an attached chat becomes a live tab.
|
||||
choices: Vec<SideChat>,
|
||||
pub(super) saved_chats_request: Option<uuid::Uuid>,
|
||||
pub(super) closing_chats: HashMap<ThreadId, JoinHandle<()>>,
|
||||
// Visit order is independent of the order in which tabs appear.
|
||||
visits: Vec<ThreadId>,
|
||||
pub(super) choice: usize,
|
||||
@@ -66,18 +78,12 @@ impl SidebarPane {
|
||||
pub(super) fn show(&mut self, content: SidebarContent, focus: PaneFocus) {
|
||||
match content {
|
||||
SidebarContent::Chat(id) => {
|
||||
if !self.tabs.contains(&id) {
|
||||
self.tabs.push(id);
|
||||
self.tabs_revision += 1;
|
||||
}
|
||||
self.add_tab(id);
|
||||
self.visits.retain(|visited| *visited != id);
|
||||
self.visits.push(id);
|
||||
}
|
||||
SidebarContent::Chooser if self.content != content || self.focus.is_none() => {
|
||||
self.choice = self
|
||||
.selected_chat()
|
||||
.and_then(|id| self.chat_tabs().choice_index(id))
|
||||
.unwrap_or(0);
|
||||
self.reset_choice();
|
||||
}
|
||||
SidebarContent::Chooser => {}
|
||||
}
|
||||
@@ -88,10 +94,50 @@ impl SidebarPane {
|
||||
pub(super) fn chat_tabs(&self) -> ChatTabs<'_> {
|
||||
ChatTabs {
|
||||
ids: &self.tabs,
|
||||
choices: &self.choices,
|
||||
selected: self.selected_chat(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_saved_chats_request(&mut self, request: Option<uuid::Uuid>) {
|
||||
self.saved_chats_request = request;
|
||||
self.tabs_revision += 1;
|
||||
}
|
||||
|
||||
pub(super) fn update_saved_chats(&mut self, mut chats: Vec<SideChat>) {
|
||||
for chat in &mut self.choices {
|
||||
if !self.tabs.contains(&chat.id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(saved) = chats.iter_mut().find(|saved| saved.id == chat.id) {
|
||||
std::mem::swap(saved, chat);
|
||||
} else {
|
||||
chats.push(std::mem::replace(chat, chat.id.into()));
|
||||
}
|
||||
}
|
||||
let reordered = self
|
||||
.choices
|
||||
.iter()
|
||||
.map(|chat| chat.id)
|
||||
.ne(chats.iter().map(|chat| chat.id));
|
||||
self.choices = chats;
|
||||
if reordered {
|
||||
self.tabs_revision += 1;
|
||||
self.reset_choice();
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_choice(&mut self) {
|
||||
self.choice = self
|
||||
.selected_chat()
|
||||
.and_then(|id| self.chat_tabs().choice_index(id))
|
||||
.unwrap_or(usize::from(!self.choices.is_empty()));
|
||||
}
|
||||
|
||||
pub(super) fn previous_chat(&self, target: ThreadId) -> Option<ThreadId> {
|
||||
self.visits.iter().rev().copied().find(|id| *id != target)
|
||||
}
|
||||
|
||||
pub(super) fn selected_chat(&self) -> Option<ThreadId> {
|
||||
self.visits.last().copied()
|
||||
}
|
||||
@@ -100,11 +146,20 @@ impl SidebarPane {
|
||||
(self.tabs_revision, self.selected_chat(), self.choice)
|
||||
}
|
||||
|
||||
fn add_tab(&mut self, id: ThreadId) {
|
||||
if !self.tabs.contains(&id) {
|
||||
if self.chat_tabs().choice_index(id).is_none() {
|
||||
self.choices.push(id.into());
|
||||
}
|
||||
self.tabs.push(id);
|
||||
self.tabs_revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn chat_ready(&mut self, child: ThreadId) {
|
||||
self.creation = None;
|
||||
if !self.tabs.contains(&child) {
|
||||
self.tabs.push(child);
|
||||
self.tabs_revision += 1;
|
||||
self.add_tab(child);
|
||||
// A completed background fork must not replace the user's selection.
|
||||
self.visits.insert(0, child);
|
||||
}
|
||||
@@ -119,17 +174,17 @@ impl SidebarPane {
|
||||
self.focus = None;
|
||||
}
|
||||
|
||||
pub(super) fn remove_chat(&mut self, id: ThreadId) {
|
||||
pub(super) fn remove_chat(&mut self, id: ThreadId, storage: SideChatStorage) {
|
||||
let Some(index) = self.tabs.iter().position(|tab| *tab == id) else {
|
||||
return;
|
||||
};
|
||||
self.close.take_if(|close| close.target == id);
|
||||
self.tabs.remove(index);
|
||||
self.tabs_revision += 1;
|
||||
self.visits.retain(|visited| *visited != id);
|
||||
if index + 1 < self.choice {
|
||||
self.choice -= 1;
|
||||
if storage == SideChatStorage::Temporary {
|
||||
self.remove_choice(id);
|
||||
}
|
||||
self.choice = self.choice.min(self.tabs.len());
|
||||
if self.content == SidebarContent::Chat(id) {
|
||||
self.content = match self.selected_chat() {
|
||||
Some(child) => SidebarContent::Chat(child),
|
||||
@@ -142,12 +197,55 @@ impl SidebarPane {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn remove_choice(&mut self, id: ThreadId) {
|
||||
if let Some(index) = self.chat_tabs().choice_index(id) {
|
||||
self.choices.remove(index - 1);
|
||||
if index < self.choice {
|
||||
self.choice -= 1;
|
||||
}
|
||||
self.choice = self.choice.min(self.choices.len());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn visible_content(&self) -> Option<SidebarContent> {
|
||||
self.focus.map(|_| self.content)
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn track_side_chat_title(
|
||||
&mut self,
|
||||
id: ThreadId,
|
||||
notification: &ServerNotification,
|
||||
) {
|
||||
let (name, prompt) = match notification {
|
||||
ServerNotification::ThreadNameUpdated(update) => (Some(&update.thread_name), None),
|
||||
ServerNotification::ItemCompleted(update) => match &update.item.payload {
|
||||
ThreadItemPayload::UserMessage { content, .. } => (None, Some(content)),
|
||||
_ => return,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let Some(chat) = self
|
||||
.sidebars
|
||||
.values_mut()
|
||||
.find_map(|pane| pane.choices.iter_mut().find(|chat| chat.id == id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(name) = name {
|
||||
chat.name.clone_from(name);
|
||||
} else if chat.preview.is_empty()
|
||||
&& let Some(preview) = prompt.and_then(|content| side_chat_prompt_preview(content))
|
||||
{
|
||||
chat.preview = preview.to_owned();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
self.chat_widget.request_redraw();
|
||||
}
|
||||
|
||||
fn side_creation_pending(&self, owner: crate::backend_id::BackendId) -> bool {
|
||||
self.sidebars.values().any(|pane| {
|
||||
pane.creation
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Closing a tab keeps saved chats in the picker. Archiving also removes the saved entry.
|
||||
//! Confirmation belongs to the parent and does not select the target tab.
|
||||
|
||||
use super::*;
|
||||
use crate::app_server_session::SideChatStorage;
|
||||
use crate::footer_hint;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Margin;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
pub(super) use crate::app_event::SideChatRemoval as Action;
|
||||
|
||||
pub(super) struct Confirmation {
|
||||
action: Action,
|
||||
pub(super) target: ThreadId,
|
||||
message: String,
|
||||
confirm: bool,
|
||||
}
|
||||
|
||||
impl Confirmation {
|
||||
pub(super) fn paint(&self, area: Rect, buf: &mut Buffer) -> [Rect; 2] {
|
||||
ratatui::widgets::Clear.render(area, buf);
|
||||
if area.width < 3 || area.height < 3 {
|
||||
return Default::default();
|
||||
}
|
||||
let body = area.inner(Margin::new(1, 0));
|
||||
let lines = textwrap::wrap(&self.message, usize::from(body.width));
|
||||
let offset = lines.len() + 2;
|
||||
let accept = match self.action {
|
||||
Action::CloseTab => " Close ",
|
||||
Action::Archive => " Archive ",
|
||||
};
|
||||
if body.width < 10 + accept.len() as u16 || offset >= usize::from(area.height) {
|
||||
Line::from("Resize to review · Esc to cancel").render(body, buf);
|
||||
return Default::default();
|
||||
}
|
||||
for (line, row) in lines.into_iter().zip(body.rows().skip(1)) {
|
||||
Line::from(line).render(row, buf);
|
||||
}
|
||||
let y = area.y + offset as u16;
|
||||
let buttons = [
|
||||
Rect::new(body.x, y, 8, 1).intersection(area),
|
||||
Rect::new(body.x.saturating_add(10), y, accept.len() as u16, 1).intersection(area),
|
||||
];
|
||||
for (index, (label, button)) in [" Cancel ", accept].into_iter().zip(buttons).enumerate() {
|
||||
let style = if self.confirm == (index == 1) {
|
||||
crate::style::selection_style()
|
||||
} else {
|
||||
crate::style::secondary_text_style()
|
||||
};
|
||||
Line::from(label).style(style).render(button, buf);
|
||||
}
|
||||
if let Some(footer) = body.rows().nth(offset + 2) {
|
||||
footer_hint::HintLine::new(
|
||||
[
|
||||
footer_hint::shortcut("←/→", "choose"),
|
||||
footer_hint::shortcut("Enter", "confirm"),
|
||||
footer_hint::shortcut("Esc", "cancel"),
|
||||
],
|
||||
footer.width,
|
||||
)
|
||||
.line
|
||||
.render(footer, buf);
|
||||
}
|
||||
buttons
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn request(
|
||||
app: &mut App,
|
||||
tui: &mut tui::Tui,
|
||||
server: &mut AppServerSession,
|
||||
target: ThreadId,
|
||||
action: Action,
|
||||
) -> Result<()> {
|
||||
let Some(side) = app.side_threads.get(&target) else {
|
||||
return Ok(());
|
||||
};
|
||||
if side.close_request.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let displayed = app.current_displayed_thread_id() == Some(target);
|
||||
let mut losses = Vec::new();
|
||||
if app.active_turn_id_for_thread(target).await.is_some()
|
||||
|| (displayed && app.chat_widget.is_agent_turn_running())
|
||||
{
|
||||
losses.push("stop the running turn");
|
||||
}
|
||||
let has_unsent_input = side.opening_message.is_some()
|
||||
|| if displayed {
|
||||
app.chat_widget.has_unsent_input()
|
||||
} else if let Some(channel) = app.thread_event_channels.get(&target) {
|
||||
channel
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.input_state
|
||||
.as_ref()
|
||||
.is_some_and(crate::chatwidget::ThreadInputState::has_unsent_input)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if has_unsent_input {
|
||||
losses.push("discard unsent input");
|
||||
}
|
||||
if side.storage == SideChatStorage::Temporary {
|
||||
losses.push("lose this temporary chat’s history");
|
||||
}
|
||||
if action == Action::Archive {
|
||||
losses.push("remove it from Side chats");
|
||||
}
|
||||
if losses.is_empty() {
|
||||
return execute(app, tui, server, target, action).await;
|
||||
}
|
||||
let Some(pane) = app.sidebar() else {
|
||||
return Ok(());
|
||||
};
|
||||
let tabs = pane.chat_tabs();
|
||||
let Some(index) = tabs.choice_index(target) else {
|
||||
return Ok(());
|
||||
};
|
||||
let title = crate::sidebar_chooser::side_chat_label(&tabs.choices[index - 1])
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| format!("Side chat {index}"));
|
||||
app.conversation_frame.close_buttons = Default::default();
|
||||
let verb = match action {
|
||||
Action::CloseTab => "Close",
|
||||
Action::Archive => "Archive",
|
||||
};
|
||||
app.sidebar_mut().close = Some(Confirmation {
|
||||
target,
|
||||
action,
|
||||
message: format!(
|
||||
"This will {}. {verb} “{}”?",
|
||||
losses.join(" and "),
|
||||
crate::line_truncation::truncate_line_with_ellipsis_if_overflow(
|
||||
title.into(),
|
||||
/*max_width*/ 24
|
||||
)
|
||||
),
|
||||
confirm: false,
|
||||
});
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn handle(
|
||||
app: &mut App,
|
||||
tui: &mut tui::Tui,
|
||||
server: &mut AppServerSession,
|
||||
event: &TuiEvent,
|
||||
) -> bool {
|
||||
let parent = app.sidebar_parent();
|
||||
let available = app.conversation_focus_available(tui);
|
||||
let Some(pane) = app.sidebars.get_mut(&parent) else {
|
||||
return false;
|
||||
};
|
||||
let Some(confirmation) = pane.close.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if !available {
|
||||
pane.close = None;
|
||||
tui.frame_requester().schedule_frame();
|
||||
return matches!(
|
||||
event,
|
||||
TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::Mouse(_)
|
||||
);
|
||||
}
|
||||
let buttons = app.conversation_frame.close_buttons;
|
||||
let accept = match event {
|
||||
TuiEvent::Key(key) if key.kind == KeyEventKind::Press && key.modifiers.is_empty() => {
|
||||
match key.code {
|
||||
KeyCode::Tab | KeyCode::Left | KeyCode::Right => {
|
||||
confirmation.confirm = !confirmation.confirm;
|
||||
tui.frame_requester().schedule_frame();
|
||||
return true;
|
||||
}
|
||||
KeyCode::Esc => false,
|
||||
KeyCode::Enter if buttons[1].is_empty() => return true,
|
||||
KeyCode::Enter => confirmation.confirm,
|
||||
_ => return true,
|
||||
}
|
||||
}
|
||||
TuiEvent::Mouse(mouse) if mouse.kind == MouseEventKind::Down(MouseButton::Left) => {
|
||||
let Some(index) = buttons
|
||||
.iter()
|
||||
.position(|button| button.contains((mouse.column, mouse.row).into()))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
index == 1
|
||||
}
|
||||
TuiEvent::Key(_) | TuiEvent::Paste(_) | TuiEvent::Mouse(_) => return true,
|
||||
_ => return false,
|
||||
};
|
||||
let target = confirmation.target;
|
||||
let action = confirmation.action;
|
||||
pane.close = None;
|
||||
tui.frame_requester().schedule_frame();
|
||||
if accept && let Err(err) = execute(app, tui, server, target, action).await {
|
||||
app.chat_widget.add_error_message(err.to_string());
|
||||
}
|
||||
if let TuiEvent::Key(key) = event {
|
||||
app.chat_widget.suppress_current_view_key_until_release(
|
||||
KeyBinding::from_event(*key),
|
||||
Instant::now(),
|
||||
crate::activation_key_guard::ActivationKeySuppression::ViewTransition,
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
app: &mut App,
|
||||
tui: &mut tui::Tui,
|
||||
server: &mut AppServerSession,
|
||||
target: ThreadId,
|
||||
action: Action,
|
||||
) -> Result<()> {
|
||||
let Some(side) = app.side_threads.get(&target) else {
|
||||
return Ok(());
|
||||
};
|
||||
if app.current_displayed_thread_id() == Some(target) {
|
||||
let next = match action {
|
||||
Action::CloseTab => app.sidebar().and_then(|pane| pane.previous_chat(target)),
|
||||
Action::Archive => None,
|
||||
}
|
||||
.unwrap_or(side.parent_thread_id);
|
||||
app.select_agent_thread(tui, server, next).await?;
|
||||
if app.current_displayed_thread_id() != Some(next) {
|
||||
return Ok(());
|
||||
}
|
||||
if action == Action::Archive {
|
||||
app.focus_returned_parent_composer(next).await?;
|
||||
}
|
||||
}
|
||||
let Some(side) = app.side_threads.get_mut(&target) else {
|
||||
return Ok(());
|
||||
};
|
||||
if side.close_request.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let request = uuid::Uuid::new_v4();
|
||||
side.close_request = Some(request);
|
||||
let turn = app.active_turn_id_for_thread(target).await;
|
||||
let attachment = app
|
||||
.thread_event_channels
|
||||
.get(&target)
|
||||
.map(ThreadEventChannel::attachment);
|
||||
let mut view = server.request_view();
|
||||
let tx = app.app_event_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = match action {
|
||||
Action::Archive => view
|
||||
.thread_archive(target)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to archive side conversation {target}: {err}")),
|
||||
Action::CloseTab => {
|
||||
async {
|
||||
super::side::interrupt_for_close(&mut view, target, turn, attachment)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!("Failed to interrupt side conversation {target}: {err}")
|
||||
})?;
|
||||
view.thread_unsubscribe(target)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to close side conversation {target}: {err}"))
|
||||
}
|
||||
.await
|
||||
}
|
||||
};
|
||||
tx.send(AppEvent::SideChatRemovalFinished {
|
||||
backend_id: view.backend_id(),
|
||||
target,
|
||||
request,
|
||||
action,
|
||||
result,
|
||||
});
|
||||
});
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reconcile both locally requested archives and notifications from another client.
|
||||
pub(super) async fn forget_archived(
|
||||
app: &mut App,
|
||||
tui: &mut tui::Tui,
|
||||
server: &mut AppServerSession,
|
||||
target: ThreadId,
|
||||
) -> Result<()> {
|
||||
let Some(parent) = app
|
||||
.side_threads
|
||||
.get(&target)
|
||||
.map(|side| side.parent_thread_id)
|
||||
.or_else(|| {
|
||||
app.sidebars
|
||||
.iter()
|
||||
.find_map(|(parent, pane)| pane.chat_tabs().choice_index(target).and(*parent))
|
||||
})
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if app.current_displayed_thread_id() == Some(target) {
|
||||
app.select_agent_thread(tui, server, parent).await?;
|
||||
if app.current_displayed_thread_id() == Some(parent) {
|
||||
app.focus_returned_parent_composer(parent).await?;
|
||||
}
|
||||
}
|
||||
let hide = app
|
||||
.sidebars
|
||||
.get(&Some(parent))
|
||||
.and_then(sidebar::SidebarPane::visible_content)
|
||||
== Some(sidebar::SidebarContent::Chat(target));
|
||||
app.abandoned_side_threads.insert(target);
|
||||
app.discard_thread_local_state(target).await;
|
||||
if let Some(pane) = app.sidebars.get_mut(&Some(parent)) {
|
||||
pane.set_saved_chats_request(/*request*/ None);
|
||||
pane.remove_choice(target);
|
||||
if hide {
|
||||
pane.hide();
|
||||
}
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(())
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: tui/src/app/agents_overview_actions_tests.rs
|
||||
expression: "render_bottom_popup(&app.chat_widget, 80)"
|
||||
expression: "normalize_agent_center_snapshot(render_bottom_popup(&app.chat_widget, 80))"
|
||||
---
|
||||
Agent command center Group: Project g
|
||||
All 1 Needs you 0 Working 0 Ready 1 Inactive 0
|
||||
@@ -9,10 +9,10 @@ expression: "render_bottom_popup(&app.chat_widget, 80)"
|
||||
/tmp/project 1
|
||||
› ○ Current task Ready 2m ago
|
||||
|
||||
|
||||
Permanently delete “Current task”?
|
||||
This stops any running work in this task and its child agents, then
|
||||
permanently deletes their history. This cannot be undone.
|
||||
Tasks with saved side chats stay intact. Otherwise, deleting this
|
||||
task stops its running work and permanently deletes its history and
|
||||
its child agents’ history.
|
||||
|
||||
|
||||
› 1. Cancel
|
||||
|
||||
@@ -1532,7 +1532,8 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
parked_side_completions.entry(*backend_id).or_default().push(event);
|
||||
continue;
|
||||
}
|
||||
AppEvent::StartupThreadStarted { backend_id, .. } => {
|
||||
AppEvent::SavedSideChatsLoaded { backend_id, .. }
|
||||
| AppEvent::StartupThreadStarted { backend_id, .. } => {
|
||||
backends.slot(*backend_id).unwrap_or(displayed_session)
|
||||
}
|
||||
_ => displayed_session,
|
||||
|
||||
@@ -3325,6 +3325,356 @@ async fn docked_replay_finishes_while_skills_refresh_is_pending() -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sidebar_reopens_saved_side_chat_after_restart() -> Result<()> {
|
||||
use crate::app_server_session::ResumeModelSettings;
|
||||
use crate::app_server_session::SideChatStorage;
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
|
||||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||||
let model = core_test_support::responses::start_mock_server().await;
|
||||
app_test_support::MockResponsesConfig::new(&model.uri()).write(&app.config.codex_home)?;
|
||||
app.config = app
|
||||
.rebuild_config_for_cwd(app.config.cwd.to_path_buf())
|
||||
.await?;
|
||||
let config = app.config.clone();
|
||||
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
|
||||
let parent = server.start_thread(&config).await?.session.thread_id;
|
||||
side_chat_policy_tests::ask(&mut server, &model, parent, "Parent-only prompt").await?;
|
||||
server.thread_set_name(parent, "New name".into()).await?;
|
||||
let incomplete = server
|
||||
.fork_side_thread(
|
||||
&app.local_settings,
|
||||
config.clone(),
|
||||
parent,
|
||||
/*selected_profile*/ None,
|
||||
SideChatStorage::Attached,
|
||||
)
|
||||
.await?
|
||||
.session
|
||||
.thread_id;
|
||||
let fork = server
|
||||
.sidebar_fork(
|
||||
app.local_settings.clone(),
|
||||
config.clone(),
|
||||
parent,
|
||||
/*profile*/ None,
|
||||
SideChatStorage::Attached,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(fork.boundary, Ok(()));
|
||||
let child = server
|
||||
.fork_side_thread(
|
||||
&app.local_settings,
|
||||
config.clone(),
|
||||
parent,
|
||||
/*selected_profile*/ None,
|
||||
SideChatStorage::Attached,
|
||||
)
|
||||
.await?
|
||||
.session
|
||||
.thread_id;
|
||||
// Older daemons copy the parent's name before the client can write the side-chat boundary.
|
||||
server.thread_set_name(child, "New name".into()).await?;
|
||||
server
|
||||
.thread_inject_items(
|
||||
child,
|
||||
codex_context_fragments::SideConversationPolicy::Attached.fork_items(),
|
||||
)
|
||||
.await?;
|
||||
let children = [fork.thread.session.thread_id, child];
|
||||
server.shutdown().await?;
|
||||
|
||||
let child_id = child.to_string();
|
||||
// A cold resume before the first prompt can seed the summary from the parent's history.
|
||||
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
|
||||
let started = server
|
||||
.resume_thread(
|
||||
&app.local_settings,
|
||||
config.clone(),
|
||||
child,
|
||||
ResumeModelSettings::PreserveExistingThread,
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
started.turns.is_empty(),
|
||||
"inherited turns should stay hidden"
|
||||
);
|
||||
side_chat_policy_tests::ask(&mut server, &model, child, "Child-only prompt").await?;
|
||||
let expected_child_turn = server
|
||||
.thread_read(child, /*include_turns*/ true)
|
||||
.await?
|
||||
.turns
|
||||
.pop()
|
||||
.expect("completed child turn");
|
||||
server.shutdown().await?;
|
||||
|
||||
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
|
||||
let started = server
|
||||
.resume_thread(
|
||||
&app.local_settings,
|
||||
config,
|
||||
parent,
|
||||
ResumeModelSettings::PreserveExistingThread,
|
||||
)
|
||||
.await?;
|
||||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||||
.await?;
|
||||
while events.try_recv().is_ok() {}
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
enable_side_retention(&mut app)?;
|
||||
app.local_settings.tui.animations = false;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
KeyCode::Char('t').into(),
|
||||
] {
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))).await?;
|
||||
}
|
||||
let size = Size::new(/*width*/ 208, /*height*/ 12);
|
||||
let loading = side_frame(&mut app, &mut tui, size)?;
|
||||
let (_, loading) = loading
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.split_once('│')
|
||||
.filter(|(_, pane)| pane.contains("Loading side chats"))
|
||||
})
|
||||
.expect("pending side-chat discovery");
|
||||
insta::assert_snapshot!(loading.trim(), @"Loading side chats…");
|
||||
loop {
|
||||
let event = time::timeout(Duration::from_secs(5), events.recv())
|
||||
.await?
|
||||
.expect("event stream");
|
||||
let loaded = matches!(event, AppEvent::SavedSideChatsLoaded { .. });
|
||||
boxed_future!(app.handle_event(&mut tui, &mut server, event)).await?;
|
||||
if loaded {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let frame = side_frame(&mut app, &mut tui, size)?;
|
||||
let picker = frame
|
||||
.lines()
|
||||
.filter_map(|line| line.split_once('│').map(|(_, pane)| pane.trim_end()))
|
||||
.skip_while(|line| !line.contains("Open in sidebar"))
|
||||
.take_while(|line| !line.contains(" open · "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!(picker.trim_end(), @"
|
||||
Open in sidebar
|
||||
|
||||
+ New side chat Start from the main chat
|
||||
|
||||
Side chats 2
|
||||
› ○ Side chat 1
|
||||
○ Child-only prompt
|
||||
");
|
||||
app.local_settings.tui.keymap = toml::from_str("[list]\naccept = ['f5 g']")?;
|
||||
enable_side_retention(&mut app)?;
|
||||
for key in [KeyCode::Char('j'), KeyCode::F(5), KeyCode::Char('g')] {
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key.into())))
|
||||
.await?;
|
||||
}
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(child));
|
||||
let tabs = app.sidebar().unwrap().chat_tabs();
|
||||
assert_eq!(
|
||||
[tabs.choice(/*index*/ 1), tabs.choice(/*index*/ 2)],
|
||||
[Some(children[0]), Some(child)]
|
||||
);
|
||||
assert_eq!(app.active_side_parent_thread_id(), Some(parent));
|
||||
let channel = &app.thread_event_channels[&child];
|
||||
assert_eq!(channel.attachment(), ThreadEventAttachment::Live);
|
||||
{
|
||||
let store = channel.store.lock().await;
|
||||
let [restored] = store.turns.as_slice() else {
|
||||
panic!("expected only the completed child turn");
|
||||
};
|
||||
assert_eq!(restored.items, expected_child_turn.items);
|
||||
}
|
||||
drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?;
|
||||
let release = KeyEvent::new_with_kind(
|
||||
KeyCode::Char('g'),
|
||||
KeyModifiers::NONE,
|
||||
KeyEventKind::Release,
|
||||
);
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(release))).await?;
|
||||
app.chat_widget.insert_str("/rename New name");
|
||||
boxed_future!(app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
TuiEvent::Key(KeyCode::Enter.into())
|
||||
))
|
||||
.await?;
|
||||
drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?;
|
||||
// The name notification still updates the tab after focus returns to the parent.
|
||||
app.select_agent_thread(&mut tui, &mut server, parent)
|
||||
.await?;
|
||||
drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?;
|
||||
loop {
|
||||
let event = time::timeout(Duration::from_secs(/*secs*/ 5), server.next_event())
|
||||
.await?
|
||||
.expect("app-server event stream");
|
||||
let renamed = matches!(
|
||||
&event.event,
|
||||
AppServerEvent::ServerNotification(notification)
|
||||
if matches!(notification.as_ref(), ServerNotification::ThreadNameUpdated(update)
|
||||
if update.thread_id == child_id && update.thread_name.as_deref() == Some("New name"))
|
||||
);
|
||||
app.handle_app_server_event(&server, event.event).await;
|
||||
if renamed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let frame = side_frame(&mut app, &mut tui, size)?;
|
||||
assert!(
|
||||
frame.lines().next().unwrap().contains("New name"),
|
||||
"{frame}"
|
||||
);
|
||||
let saved = server
|
||||
.list_saved_side_chats(parent, app.config.codex_home.as_path())
|
||||
.await?;
|
||||
assert_eq!(
|
||||
saved
|
||||
.iter()
|
||||
.find(|chat| chat.id == child)
|
||||
.and_then(|chat| chat.name.as_deref()),
|
||||
Some("New name")
|
||||
);
|
||||
app.select_agent_thread(&mut tui, &mut server, child)
|
||||
.await?;
|
||||
drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?;
|
||||
let archive = KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL);
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(archive))).await?;
|
||||
let frame = side_frame(&mut app, &mut tui, size)?;
|
||||
let prompt = frame
|
||||
.lines()
|
||||
.filter_map(|line| line.split_once('│'))
|
||||
.map(|(_, pane)| pane.trim())
|
||||
.find(|line| line.contains("remove it"))
|
||||
.unwrap_or_else(|| panic!("expected the archive confirmation in {frame}"));
|
||||
insta::assert_snapshot!(prompt, @"This will remove it from Side chats. Archive “New name”?");
|
||||
let enter = TuiEvent::Key(KeyCode::Enter.into());
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, enter)).await?;
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(child));
|
||||
let rollout = server
|
||||
.thread_read(child, /*include_turns*/ false)
|
||||
.await?
|
||||
.path
|
||||
.expect("saved child");
|
||||
let destination = app
|
||||
.config
|
||||
.codex_home
|
||||
.join("archived_sessions")
|
||||
.join(rollout.file_name().unwrap());
|
||||
std::fs::create_dir_all(&destination)?;
|
||||
for blocked in [true, false] {
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(archive))).await?;
|
||||
side_frame(&mut app, &mut tui, size)?;
|
||||
for key in [KeyCode::Right.into(), KeyCode::Enter.into()] {
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))).await?;
|
||||
}
|
||||
loop {
|
||||
let event = time::timeout(Duration::from_secs(/*secs*/ 5), events.recv())
|
||||
.await?
|
||||
.expect("archive result");
|
||||
let finished = matches!(&event, AppEvent::SideChatRemovalFinished { target, .. } if *target == child);
|
||||
boxed_future!(app.handle_event(&mut tui, &mut server, event)).await?;
|
||||
if finished {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(parent));
|
||||
if blocked {
|
||||
assert_eq!(
|
||||
app.thread_event_channels[&child].attachment(),
|
||||
ThreadEventAttachment::ReplayOnly
|
||||
);
|
||||
std::fs::remove_dir(&destination)?;
|
||||
app.select_agent_thread(&mut tui, &mut server, child)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
app.thread_event_channels[&child].attachment(),
|
||||
ThreadEventAttachment::Live
|
||||
);
|
||||
side_chat_policy_tests::ask(&mut server, &model, child, "After failed archive").await?;
|
||||
}
|
||||
}
|
||||
let saved = server
|
||||
.request_view()
|
||||
.list_saved_side_chats(parent, app.config.codex_home.as_path())
|
||||
.await?;
|
||||
for chats in [app.sidebar().unwrap().chat_tabs().choices, saved.as_slice()] {
|
||||
assert_eq!(
|
||||
chats.iter().map(|chat| chat.id).collect::<Vec<_>>(),
|
||||
[children[0]]
|
||||
);
|
||||
}
|
||||
// An archive performed outside this TUI also removes a saved, closed tab from the chooser.
|
||||
server.request_view().thread_archive(children[0]).await?;
|
||||
loop {
|
||||
let event = time::timeout(Duration::from_secs(/*secs*/ 5), server.next_event())
|
||||
.await?
|
||||
.expect("server event");
|
||||
let archived = matches!(&event.event, AppServerEvent::ServerNotification(n)
|
||||
if matches!(n.as_ref(), ServerNotification::ThreadArchived(a) if a.thread_id == children[0].to_string()));
|
||||
app.handle_app_server_event(&server, event.event).await;
|
||||
if archived {
|
||||
break;
|
||||
}
|
||||
}
|
||||
drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?;
|
||||
assert!(app.sidebar().unwrap().chat_tabs().choices.is_empty());
|
||||
|
||||
let other_attachment: codex_app_server_protocol::ThreadForkResponse = server
|
||||
.request_handle()
|
||||
.request_typed(ClientRequest::ThreadFork {
|
||||
request_id: server.next_request_id(),
|
||||
params: codex_app_server_protocol::ThreadForkParams {
|
||||
thread_id: parent.to_string(),
|
||||
attach_to_source: true,
|
||||
exclude_turns: true,
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?;
|
||||
app.delete_current_thread(&mut tui, &mut server).await?;
|
||||
let errors = std::iter::from_fn(|| events.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
Some(lines_to_single_string(&cell.display_lines(/*width*/ 160)))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
errors
|
||||
.iter()
|
||||
.any(|message| message.contains("an attachment outside the TUI sidebar")),
|
||||
"{errors:?}"
|
||||
);
|
||||
let ids = [parent, incomplete, children[0], children[1]];
|
||||
for id in ids {
|
||||
server.thread_read(id, /*include_turns*/ false).await?;
|
||||
}
|
||||
|
||||
server
|
||||
.thread_delete(ThreadId::from_string(&other_attachment.thread.id)?)
|
||||
.await?;
|
||||
app.delete_current_thread(&mut tui, &mut server).await?;
|
||||
for id in ids {
|
||||
let error = server
|
||||
.thread_read(id, /*include_turns*/ false)
|
||||
.await
|
||||
.expect_err("parent deletion removes the saved side chat");
|
||||
assert!(
|
||||
format!("{error:#}").contains(&format!("thread not loaded: {id}")),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()> {
|
||||
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
|
||||
@@ -4507,11 +4857,10 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
|
||||
|
||||
// Open a sibling from the chooser, then revisit each retained conversation.
|
||||
let chooser_key = KeyEvent::from(KeyCode::Char('t'));
|
||||
let [up, down, enter] =
|
||||
[KeyCode::Char('k'), KeyCode::Char('j'), KeyCode::Enter].map(KeyEvent::from);
|
||||
let [up, enter] = [KeyCode::Char('k'), KeyCode::Enter].map(KeyEvent::from);
|
||||
side_frame(&mut app, &mut tui, even)?;
|
||||
let sidebar_x = app.conversation_frame.chat_strip.unwrap().x;
|
||||
keys!(prefix, chooser_key, down, enter);
|
||||
keys!(prefix, chooser_key, up, enter);
|
||||
app.local_settings.tui.keymap = toml::from_str("[global]\nleader = 'f12'")?;
|
||||
enable_side_retention(&mut app)?;
|
||||
app.local_settings.tui.animations = false;
|
||||
@@ -4526,6 +4875,32 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
|
||||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||||
read_side_history(&mut app, &mut tui, "sibling")?;
|
||||
app.chat_widget.insert_str("sibling draft");
|
||||
for (thread, prompt) in [
|
||||
(side_thread_id, "Investigate rendering"),
|
||||
(sibling, "Fix cache"),
|
||||
(side_thread_id, "Later prompt"),
|
||||
] {
|
||||
app.enqueue_thread_notification(
|
||||
thread,
|
||||
ServerNotification::ItemCompleted(
|
||||
codex_app_server_protocol::ItemCompletedNotification::new(
|
||||
thread.to_string(),
|
||||
"side-turn".to_string(),
|
||||
ThreadItemPayload::UserMessage {
|
||||
id: prompt.to_string(),
|
||||
client_id: None,
|
||||
content: vec![UserInput::Text {
|
||||
text: prompt.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
},
|
||||
/*started_at_ms*/ None,
|
||||
/*completed_at_ms*/ 0,
|
||||
),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let frame = side_frame(&mut app, &mut tui, even)?;
|
||||
let header = frame.lines().next().unwrap().split_once('│').unwrap().1;
|
||||
assert_app_snapshot!("side_chat_tabs", header.trim_end());
|
||||
@@ -4650,6 +5025,120 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<()
|
||||
assert!(app.conversation_frame.chooser_area().is_some());
|
||||
assert_eq!(divider_at(&tui, 4), target);
|
||||
|
||||
// Closing from the chooser must not switch to the tab before confirmation.
|
||||
let chooser_focus = app.chooser_focus();
|
||||
let chooser = app.conversation_frame.chooser_area().unwrap();
|
||||
let close_x = (chooser.x..chooser.right())
|
||||
.rfind(|&x| tui.terminal.previous_buffer()[(x, chooser.y)].symbol() == "×")
|
||||
.expect("close button on the second tab");
|
||||
let click_close = mouse(down, close_x, chooser.y);
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, click_close)).await?;
|
||||
let frame = side_frame(&mut app, &mut tui, even)?;
|
||||
let dialog = frame
|
||||
.lines()
|
||||
.filter_map(|row| row.split_once('│').map(|(_, s)| s.trim_end()))
|
||||
.take(7)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert_app_snapshot!("close_side_chat_tab", dialog.trim_end());
|
||||
keys!(KeyCode::Esc.into());
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(parent_thread_id));
|
||||
assert_eq!(app.chooser_focus(), chooser_focus);
|
||||
side_frame(&mut app, &mut tui, even)?;
|
||||
boxed_future!(app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
mouse(down, close_x - 1, chooser.y)
|
||||
))
|
||||
.await?;
|
||||
drain_app_events(&mut app, &mut tui, &mut app_server, &mut app_event_rx).await?;
|
||||
assert_eq!(
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"sibling draft"
|
||||
);
|
||||
// An idle, saved tab with only a queued follow-up still needs confirmation.
|
||||
app.side_threads.get_mut(&sibling).unwrap().storage =
|
||||
crate::app_server_session::SideChatStorage::Attached;
|
||||
app.chat_widget.apply_external_edit(String::new());
|
||||
app.chat_widget
|
||||
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
||||
assert!(app.chat_widget.queue_user_message_with_options(
|
||||
"queued follow-up".into(),
|
||||
crate::bottom_pane::QueuedInputAction::Plain,
|
||||
Vec::new(),
|
||||
));
|
||||
keys!(prefix, chooser_key);
|
||||
side_frame(&mut app, &mut tui, even)?;
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, mouse(down, close_x, chooser.y)))
|
||||
.await?;
|
||||
assert!(side_frame(&mut app, &mut tui, even)?.contains("discard unsent input"));
|
||||
keys!(KeyCode::Esc.into());
|
||||
app.select_agent_thread(&mut tui, &mut app_server, sibling)
|
||||
.await?;
|
||||
keys!(prefix, KeyCode::Char('w').into());
|
||||
side_frame(&mut app, &mut tui, even)?;
|
||||
keys!(KeyCode::Right.into(), enter);
|
||||
while app.side_threads.contains_key(&sibling) {
|
||||
let event = time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv())
|
||||
.await?
|
||||
.expect("app event");
|
||||
boxed_future!(app.handle_event(&mut tui, &mut app_server, event)).await?;
|
||||
}
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(side_thread_id));
|
||||
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn side_close_key_cannot_accept_a_replacing_approval() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
enable_side_retention(&mut app)?;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
let parent = ThreadId::new();
|
||||
let side_thread_id = ThreadId::new();
|
||||
app.primary_thread_id = Some(parent);
|
||||
app.active_thread_id = Some(side_thread_id);
|
||||
app.side_threads
|
||||
.insert(side_thread_id, SideThreadState::new(parent));
|
||||
app.sidebar_mut().show(
|
||||
sidebar::SidebarContent::Chat(side_thread_id),
|
||||
sidebar::PaneFocus::Sidebar,
|
||||
);
|
||||
sidebar_close::request(
|
||||
&mut app,
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
side_thread_id,
|
||||
sidebar_close::Action::CloseTab,
|
||||
)
|
||||
.await?;
|
||||
let even = Size::new(/*width*/ 180, /*height*/ 24);
|
||||
let enter = KeyEvent::from(KeyCode::Enter);
|
||||
side_frame(&mut app, &mut tui, even)?;
|
||||
assert!(app.sidebar().unwrap().close.is_some());
|
||||
let request = exec_approval_request(side_thread_id, "turn", "call", /*approval_id*/ None);
|
||||
let Some(ThreadInteractiveRequest::Approval(approval)) = app
|
||||
.interactive_request_for_thread_request(side_thread_id, &request)
|
||||
.await?
|
||||
else {
|
||||
panic!("command approval");
|
||||
};
|
||||
app.chat_widget.push_approval_request(approval);
|
||||
assert!(
|
||||
app.chat_widget.has_active_modal(),
|
||||
"approval is visible to the input router"
|
||||
);
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(enter))).await?;
|
||||
assert!(app.chat_widget.has_active_modal());
|
||||
side_frame(&mut app, &mut tui, even)?;
|
||||
assert!(app.sidebar().unwrap().close.is_none());
|
||||
let repeat = KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat);
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Key(repeat))).await?;
|
||||
assert!(app.chat_widget.has_active_modal());
|
||||
|
||||
app_server.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -6631,7 +7120,8 @@ async fn side_fork_config_is_ephemeral_and_appends_developer_guardrails() {
|
||||
let original_approval_policy = app.config.permissions.approval_policy.value();
|
||||
let original_sandbox_policy = app.config.legacy_sandbox_policy();
|
||||
|
||||
let fork_config = app.side_fork_config();
|
||||
let mut fork_config = app.side_fork_config();
|
||||
crate::app_server_session::SideChatStorage::Temporary.configure_fork(&mut fork_config);
|
||||
|
||||
assert!(fork_config.ephemeral);
|
||||
assert_eq!(
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::keymap::KeymapContext;
|
||||
use crate::pager_overlay::TranscriptHistoryState;
|
||||
use crate::test_support::test_path_display;
|
||||
use crate::tui::Tui;
|
||||
use codex_app_server_protocol::ThreadHistoryMode;
|
||||
use codex_app_server_protocol::ToolRequestUserInputOption;
|
||||
use codex_app_server_protocol::ToolRequestUserInputParams;
|
||||
use codex_app_server_protocol::ToolRequestUserInputQuestion;
|
||||
@@ -1239,6 +1240,7 @@ async fn sidebar_chooser_preserves_the_parent_draft_and_starts_a_side_chat() ->
|
||||
super::enable_side_retention(&mut app)?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
let parent = ThreadId::new();
|
||||
server.remember_thread_history_mode(parent, ThreadHistoryMode::Legacy);
|
||||
app.chat_widget
|
||||
.handle_thread_session(test_thread_session(parent, app.config.cwd.to_path_buf()));
|
||||
app.chat_widget.local_settings.tui.status_line = Some(vec!["model-with-reasoning".into()]);
|
||||
|
||||
@@ -364,7 +364,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
|
||||
while app.agents_overview.request_id.is_some() {
|
||||
let event = events.recv().await.expect("inventory refresh event");
|
||||
if matches!(event, AppEvent::AgentsOverviewThreadsLoaded { .. }) {
|
||||
app.handle_event(&mut tui, &mut session, event).await?;
|
||||
Box::pin(app.handle_event(&mut tui, &mut session, event)).await?;
|
||||
} else {
|
||||
deferred.push(event);
|
||||
}
|
||||
@@ -498,7 +498,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
|
||||
app.chat_widget.composer_text_with_pending(),
|
||||
"latest draft!"
|
||||
);
|
||||
app.handle_event(&mut tui, &mut session, AppEvent::OpenAgentsOverview)
|
||||
Box::pin(app.handle_event(&mut tui, &mut session, AppEvent::OpenAgentsOverview))
|
||||
.await?;
|
||||
assert!(app.chat_widget.has_active_view());
|
||||
app.handle_tui_event(
|
||||
@@ -545,11 +545,11 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
|
||||
);
|
||||
}
|
||||
available.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
app.handle_event(
|
||||
Box::pin(app.handle_event(
|
||||
&mut tui,
|
||||
&mut session,
|
||||
AppEvent::SelectAgentsOverviewThread { thread_id: id },
|
||||
)
|
||||
))
|
||||
.await?;
|
||||
assert!(!app.thread_unavailable(id));
|
||||
assert_eq!(
|
||||
@@ -595,7 +595,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv
|
||||
.await?;
|
||||
while let Ok(event) = events.try_recv() {
|
||||
if matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })) {
|
||||
app.handle_event(&mut tui, &mut session, event).await?;
|
||||
Box::pin(app.handle_event(&mut tui, &mut session, event)).await?;
|
||||
}
|
||||
}
|
||||
let channel = app.thread_event_channels.get(&id).unwrap();
|
||||
|
||||
@@ -424,6 +424,7 @@ async fn custom_permission_selection_scenario() -> Result<()> {
|
||||
app.agents_overview
|
||||
.requested_permission_profiles
|
||||
.get(&thread_id),
|
||||
crate::app_server_session::SideChatStorage::Temporary,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -140,7 +140,7 @@ async fn next_turn_started(
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_turn_completed(
|
||||
pub(super) async fn wait_for_turn_completed(
|
||||
app: &mut App,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
|
||||
@@ -1635,6 +1635,7 @@ async fn delete_current_thread_navigates_only_after_success() -> Result<()> {
|
||||
side_config.clone(),
|
||||
thread_id,
|
||||
/*selected_profile*/ None,
|
||||
crate::app_server_session::SideChatStorage::Temporary,
|
||||
)
|
||||
.await?;
|
||||
let side_id = side.session.thread_id;
|
||||
@@ -1651,7 +1652,8 @@ async fn delete_current_thread_navigates_only_after_success() -> Result<()> {
|
||||
&app.local_settings,
|
||||
side_config,
|
||||
thread_id,
|
||||
/*selected_profile*/ None
|
||||
/*selected_profile*/ None,
|
||||
crate::app_server_session::SideChatStorage::Temporary,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
@@ -1946,6 +1948,7 @@ async fn archive_current_thread_returns_to_agents() -> Result<()> {
|
||||
side_config,
|
||||
thread_id,
|
||||
/*selected_profile*/ None,
|
||||
crate::app_server_session::SideChatStorage::Temporary,
|
||||
)
|
||||
.await?
|
||||
.session
|
||||
@@ -3901,6 +3904,7 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> {
|
||||
side_config,
|
||||
paginated_thread_id,
|
||||
/*selected_profile*/ None,
|
||||
crate::app_server_session::SideChatStorage::Temporary,
|
||||
))
|
||||
.await?;
|
||||
|
||||
@@ -4873,8 +4877,24 @@ terminal_visualization_instructions = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changing_directory_preserves_project_trust_permissions_history_and_hooks() -> Result<()> {
|
||||
#[test]
|
||||
fn changing_directory_preserves_project_trust_permissions_history_and_hooks() -> Result<()> {
|
||||
// The TUI and embedded app-server load full configurations on the same test thread.
|
||||
const TEST_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024;
|
||||
std::thread::Builder::new()
|
||||
.name("tui-change-directory".into())
|
||||
.stack_size(TEST_STACK_SIZE_BYTES)
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?
|
||||
.block_on(boxed_future!(change_directory_scenario()))
|
||||
})?
|
||||
.join()
|
||||
.expect("directory change test thread panicked")
|
||||
}
|
||||
|
||||
async fn change_directory_scenario() -> Result<()> {
|
||||
use codex_protocol::config_types::TrustLevel as T;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
|
||||
@@ -11,7 +11,7 @@ use codex_app_server_protocol::TurnStartResponse;
|
||||
use core_test_support::responses;
|
||||
|
||||
/// Captures one real model request and waits until its turn can be inherited by a fork.
|
||||
async fn ask(
|
||||
pub(super) async fn ask(
|
||||
server: &mut AppServerSession,
|
||||
model: &wiremock::MockServer,
|
||||
thread: ThreadId,
|
||||
@@ -77,6 +77,7 @@ async fn temporary_side_chat_sends_policy_on_its_first_request() -> Result<()> {
|
||||
app.side_fork_config(),
|
||||
parent,
|
||||
/*profile*/ None,
|
||||
crate::app_server_session::SideChatStorage::Temporary,
|
||||
)
|
||||
.await?;
|
||||
fork.boundary.expect("side-chat context injection");
|
||||
|
||||
@@ -1074,6 +1074,7 @@ impl App {
|
||||
if self.abandoned_side_threads.contains(&thread_id) {
|
||||
return Ok(());
|
||||
}
|
||||
self.track_side_chat_title(thread_id, ¬ification);
|
||||
if self.current_displayed_thread_id() == Some(thread_id)
|
||||
&& let ServerNotification::TurnCompleted(notification) = ¬ification
|
||||
{
|
||||
@@ -2146,7 +2147,9 @@ impl App {
|
||||
// thread, so unrelated shutdowns cannot consume this marker.
|
||||
self.pending_shutdown_exit_thread_id = None;
|
||||
}
|
||||
// Sidebar chats use their first prompt until they receive an explicit name.
|
||||
let automatic_title_user_message = if self.chat_widget.thread_name().is_none()
|
||||
&& !(self.side_retention_enabled(tui) && self.active_side_parent_thread_id().is_some())
|
||||
&& let ThreadBufferedEvent::Notification(notification) = &event
|
||||
&& let ServerNotification::ItemCompleted(notification) = notification.as_ref()
|
||||
&& let ThreadItemPayload::UserMessage { content, .. } = ¬ification.item.payload
|
||||
|
||||
@@ -61,6 +61,10 @@ impl App {
|
||||
"toggle_pane_zoom" if self.zoomed_thread.is_some() => (2, "Restore layout"),
|
||||
"toggle_pane_zoom" => (2, "Zoom pane"),
|
||||
"move_divider_left" | "move_divider_right" => continue,
|
||||
"close_sidebar_tab" if self.active_side_parent_thread_id().is_some() => {
|
||||
(2, "Close tab")
|
||||
}
|
||||
"close_sidebar_tab" => continue,
|
||||
"hide_pane" if side => (2, "Hide pane"),
|
||||
"focus_pane_left" if side && can_switch => (1, "Pane left"),
|
||||
"focus_pane_right" if !side && can_switch && self.open_pane_focus().is_some() => {
|
||||
|
||||
@@ -466,7 +466,7 @@ pub(crate) enum AppEvent {
|
||||
/// Switch the active thread to the selected agent.
|
||||
SelectAgentThread(ThreadId),
|
||||
|
||||
/// Open a transient side conversation for the current thread.
|
||||
/// Open a side conversation for the current thread.
|
||||
StartSide {
|
||||
parent_thread_id: ThreadId,
|
||||
user_message: Option<UserMessage>,
|
||||
@@ -480,6 +480,25 @@ pub(crate) enum AppEvent {
|
||||
result: Result<Box<crate::app_server_session::SidebarFork>, String>,
|
||||
},
|
||||
|
||||
SideChatRemovalFinished {
|
||||
backend_id: crate::backend_id::BackendId,
|
||||
target: ThreadId,
|
||||
request: uuid::Uuid,
|
||||
action: SideChatRemoval,
|
||||
result: Result<(), String>,
|
||||
},
|
||||
SideChatArchived {
|
||||
backend_id: crate::backend_id::BackendId,
|
||||
target: ThreadId,
|
||||
},
|
||||
|
||||
SavedSideChatsLoaded {
|
||||
backend_id: crate::backend_id::BackendId,
|
||||
parent: ThreadId,
|
||||
request: uuid::Uuid,
|
||||
result: Result<Vec<crate::app_server_session::SideChat>, String>,
|
||||
},
|
||||
|
||||
/// Submit an op to the specified thread, regardless of current focus.
|
||||
SubmitThreadOp {
|
||||
thread_id: ThreadId,
|
||||
@@ -1949,3 +1968,10 @@ pub(crate) enum SideStartMode {
|
||||
Reuse,
|
||||
New,
|
||||
}
|
||||
|
||||
/// Whether a sidebar action closes a live tab or also archives its saved conversation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SideChatRemoval {
|
||||
CloseTab,
|
||||
Archive,
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ mod read_state;
|
||||
mod realtime;
|
||||
mod rollout_history;
|
||||
pub(crate) use read_state::unread_revision;
|
||||
mod side_chat_history;
|
||||
use side_chat_history::side_context_checkpoint;
|
||||
mod startup_launch;
|
||||
mod thread_list;
|
||||
pub(crate) mod user_message_submission;
|
||||
@@ -35,6 +37,8 @@ pub(crate) use history::HISTORY_ITEM_SCAN_LIMIT;
|
||||
pub(crate) use history::HistoryHydrationScope;
|
||||
pub(crate) use history::INITIAL_HISTORY_TURN_LIMIT;
|
||||
pub(crate) use history::thread_items_page_params;
|
||||
pub(crate) use side_chat_history::SideChat;
|
||||
pub(crate) use side_chat_history::side_chat_prompt_preview;
|
||||
pub(crate) use startup_launch::StartupLaunchChoices;
|
||||
|
||||
use crate::app_event::PermissionProfileSelection;
|
||||
@@ -202,7 +206,32 @@ enum EmptyCatalogFallback {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum ForkPresentation {
|
||||
Regular,
|
||||
SideConversation,
|
||||
SideConversation(SideChatStorage),
|
||||
}
|
||||
|
||||
pub(crate) const ATTACHED_SIDE_CHAT_SOURCE: &str = "tui_side_chat";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum SideChatStorage {
|
||||
Temporary,
|
||||
Attached,
|
||||
}
|
||||
|
||||
impl SideChatStorage {
|
||||
fn policy(self) -> SideConversationPolicy {
|
||||
match self {
|
||||
Self::Temporary => SideConversationPolicy::Temporary,
|
||||
Self::Attached => SideConversationPolicy::Attached,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn configure_fork(self, config: &mut Config) {
|
||||
config.ephemeral = self == Self::Temporary;
|
||||
config.developer_instructions = Some(
|
||||
self.policy()
|
||||
.instructions(config.developer_instructions.as_deref()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -427,6 +456,8 @@ pub(crate) enum ResumeModelSettings {
|
||||
RestoreFromThread,
|
||||
/// Rejoins a loaded thread without changing any of its existing settings.
|
||||
PreserveExistingThread,
|
||||
/// Restore the saved settings and reapply the side-chat instructions on cold resume.
|
||||
PreserveSideConversation,
|
||||
}
|
||||
|
||||
impl ThreadParamsMode {
|
||||
@@ -452,6 +483,7 @@ pub(crate) struct SidebarFork {
|
||||
pub(crate) thread: AppServerStartedThread,
|
||||
pub(crate) boundary: Result<(), String>,
|
||||
pub(crate) inherited_profile: bool,
|
||||
pub(crate) storage: SideChatStorage,
|
||||
legacy: bool,
|
||||
}
|
||||
|
||||
@@ -1216,6 +1248,7 @@ impl AppServerSession {
|
||||
config: Config,
|
||||
parent: ThreadId,
|
||||
profile: Option<PermissionProfileSelection>,
|
||||
preferred_storage: SideChatStorage,
|
||||
) -> impl Future<Output = Result<SidebarFork>> + Send + use<> {
|
||||
let mut view = self
|
||||
.request_view()
|
||||
@@ -1224,21 +1257,58 @@ impl AppServerSession {
|
||||
view.remember_task_tool_thread(parent);
|
||||
}
|
||||
async move {
|
||||
let thread = view
|
||||
.fork_side_thread(&settings, config, parent, profile.as_ref())
|
||||
.await?;
|
||||
let boundary = view
|
||||
.thread_inject_items(
|
||||
thread.session.thread_id,
|
||||
SideConversationPolicy::Temporary.fork_items(),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| err.to_string());
|
||||
let mut storage = SideChatStorage::Temporary;
|
||||
if preferred_storage == SideChatStorage::Attached
|
||||
&& view.attached_side_chat_query(parent).await?.is_some()
|
||||
&& {
|
||||
let thread = view.thread_read(parent, /*include_turns*/ false).await?;
|
||||
!thread.ephemeral && thread.history_mode == ThreadHistoryMode::Paginated
|
||||
}
|
||||
{
|
||||
storage = SideChatStorage::Attached;
|
||||
}
|
||||
let fork = view
|
||||
.fork_side_thread(&settings, config.clone(), parent, profile.as_ref(), storage)
|
||||
.await;
|
||||
let thread = match fork {
|
||||
Err(err)
|
||||
if storage == SideChatStorage::Attached
|
||||
&& side_chat_history::is_attached_thread_store_unsupported(&err) =>
|
||||
{
|
||||
storage = SideChatStorage::Temporary;
|
||||
view.fork_side_thread(&settings, config, parent, profile.as_ref(), storage)
|
||||
.await?
|
||||
}
|
||||
result => result?,
|
||||
};
|
||||
let child = thread.session.thread_id;
|
||||
let items = storage.policy().fork_items();
|
||||
let boundary = match view.thread_inject_items(child, items).await {
|
||||
Ok(_) if storage == SideChatStorage::Attached => {
|
||||
match side_context_checkpoint(thread.session.rollout_path.as_deref()).await {
|
||||
Ok(Some(_)) => Ok(()),
|
||||
Ok(None) => Err("Side conversation context was not saved".to_string()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
result => result.map(|_| ()).map_err(|err| err.to_string()),
|
||||
};
|
||||
if let Err(message) = &boundary
|
||||
&& storage == SideChatStorage::Attached
|
||||
{
|
||||
if let Err(error) = view.thread_delete(child).await {
|
||||
tracing::warn!(%child, %error, "failed to roll back attached side chat");
|
||||
} else {
|
||||
color_eyre::eyre::bail!(
|
||||
"Failed to prepare side conversation {child}: {message}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(SidebarFork {
|
||||
thread,
|
||||
boundary,
|
||||
inherited_profile: profile.is_some(),
|
||||
storage,
|
||||
legacy: view.history_support == ThreadHistorySupport::LegacyOnly,
|
||||
})
|
||||
}
|
||||
@@ -1256,10 +1326,12 @@ impl AppServerSession {
|
||||
pub(crate) async fn fork_side_thread(
|
||||
&mut self,
|
||||
local_settings: &LocalSettings,
|
||||
config: Config,
|
||||
mut config: Config,
|
||||
thread_id: ThreadId,
|
||||
selected_profile: Option<&PermissionProfileSelection>,
|
||||
storage: SideChatStorage,
|
||||
) -> Result<AppServerStartedThread> {
|
||||
storage.configure_fork(&mut config);
|
||||
self.fork_thread_at_with_presentation(
|
||||
local_settings,
|
||||
config,
|
||||
@@ -1267,7 +1339,7 @@ impl AppServerSession {
|
||||
/*last_turn_id*/ None,
|
||||
/*before_turn_id*/ None,
|
||||
ForkGoalContinuation::StartIfIdle,
|
||||
ForkPresentation::SideConversation,
|
||||
ForkPresentation::SideConversation(storage),
|
||||
selected_profile,
|
||||
ForkPermissionMode::InheritSaved,
|
||||
ForkConfigSource::Session,
|
||||
@@ -1301,13 +1373,13 @@ impl AppServerSession {
|
||||
.thread_read(thread_id, /*include_turns*/ false)
|
||||
.await
|
||||
.ok(),
|
||||
ForkPresentation::SideConversation => None,
|
||||
ForkPresentation::SideConversation(_) => None,
|
||||
};
|
||||
let exclude_turns = self.history_support == ThreadHistorySupport::Paginated
|
||||
&& (fork_parent
|
||||
.as_ref()
|
||||
.is_some_and(|thread| thread.history_mode == ThreadHistoryMode::Paginated)
|
||||
|| presentation == ForkPresentation::SideConversation);
|
||||
|| presentation != ForkPresentation::Regular);
|
||||
let request_id = self.next_request_id();
|
||||
let session_config = if config.model.is_none() {
|
||||
// Avoid inferring a tier from the stale client default model.
|
||||
@@ -1316,6 +1388,8 @@ impl AppServerSession {
|
||||
self.session_config_with_effective_service_tier(&config)
|
||||
};
|
||||
let mut params = ThreadForkParams {
|
||||
attach_to_source: presentation
|
||||
== ForkPresentation::SideConversation(SideChatStorage::Attached),
|
||||
last_turn_id,
|
||||
before_turn_id,
|
||||
defer_goal_continuation: goal_continuation == ForkGoalContinuation::DeferUntilNextTurn,
|
||||
@@ -1327,6 +1401,10 @@ impl AppServerSession {
|
||||
self.remote_cwd_override.as_deref(),
|
||||
)
|
||||
};
|
||||
if params.attach_to_source {
|
||||
params.thread_source =
|
||||
Some(ThreadSource::Feature(ATTACHED_SIDE_CHAT_SOURCE.to_string()));
|
||||
}
|
||||
params.model_provider = match config_source {
|
||||
ForkConfigSource::Local => self
|
||||
.model_provider_override
|
||||
@@ -1374,7 +1452,9 @@ impl AppServerSession {
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(TypedRequestError::Server { source, .. })
|
||||
if params.exclude_turns && is_history_pagination_unsupported(&source) =>
|
||||
if params.exclude_turns
|
||||
&& !params.attach_to_source
|
||||
&& is_history_pagination_unsupported(&source) =>
|
||||
{
|
||||
self.history_support = ThreadHistorySupport::LegacyOnly;
|
||||
params.exclude_turns = false;
|
||||
@@ -1430,7 +1510,7 @@ impl AppServerSession {
|
||||
if let Some(permissions) = selected_permissions {
|
||||
started.session.permission_profile = permissions;
|
||||
}
|
||||
if presentation == ForkPresentation::SideConversation {
|
||||
if presentation != ForkPresentation::Regular {
|
||||
started.session.daybreak_enabled = false;
|
||||
}
|
||||
started.session.fork_parent_title = fork_parent.and_then(|thread| thread.name);
|
||||
@@ -2569,10 +2649,24 @@ fn thread_resume_params_from_config(
|
||||
permission_overrides: crate::resume_permissions::ResumePermissions,
|
||||
) -> ThreadResumeParams {
|
||||
if thread_params_mode.settings_owner() == ThreadSettingsOwner::Server
|
||||
|| model_settings == ResumeModelSettings::PreserveExistingThread
|
||||
|| matches!(
|
||||
model_settings,
|
||||
ResumeModelSettings::PreserveExistingThread
|
||||
| ResumeModelSettings::PreserveSideConversation
|
||||
)
|
||||
{
|
||||
return ThreadResumeParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
developer_instructions: if model_settings
|
||||
== ResumeModelSettings::PreserveSideConversation
|
||||
{
|
||||
with_terminal_visualization_instructions(
|
||||
&config,
|
||||
config.developer_instructions.clone(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
..ThreadResumeParams::default()
|
||||
};
|
||||
}
|
||||
@@ -2600,9 +2694,9 @@ fn thread_resume_params_from_config(
|
||||
config.model.clone(),
|
||||
provider_selection::explicit_provider(&config),
|
||||
),
|
||||
ResumeModelSettings::RestoreFromThread | ResumeModelSettings::PreserveExistingThread => {
|
||||
(None, None)
|
||||
}
|
||||
ResumeModelSettings::RestoreFromThread
|
||||
| ResumeModelSettings::PreserveExistingThread
|
||||
| ResumeModelSettings::PreserveSideConversation => (None, None),
|
||||
};
|
||||
let mut params = ThreadResumeParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
@@ -4476,6 +4570,7 @@ mod tests {
|
||||
ephemeral_config,
|
||||
source_thread_id,
|
||||
/*selected_profile*/ None,
|
||||
crate::app_server_session::SideChatStorage::Temporary,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -4603,6 +4698,7 @@ mod tests {
|
||||
side_config,
|
||||
source_thread_id,
|
||||
/*selected_profile*/ None,
|
||||
crate::app_server_session::SideChatStorage::Temporary,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -4706,6 +4802,7 @@ mod tests {
|
||||
config,
|
||||
thread_id,
|
||||
/*selected_profile*/ None,
|
||||
crate::app_server_session::SideChatStorage::Temporary,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -162,7 +162,9 @@ impl AppServerSession {
|
||||
}
|
||||
let session_config = if matches!(
|
||||
model_settings,
|
||||
ResumeModelSettings::RestoreFromThread | ResumeModelSettings::PreserveExistingThread
|
||||
ResumeModelSettings::RestoreFromThread
|
||||
| ResumeModelSettings::PreserveExistingThread
|
||||
| ResumeModelSettings::PreserveSideConversation
|
||||
) {
|
||||
config.clone()
|
||||
} else {
|
||||
|
||||
@@ -164,6 +164,7 @@ async fn remote_resume_restores_saved_server_profile_without_permission_override
|
||||
client_config,
|
||||
thread_id,
|
||||
/*selected_profile*/ None,
|
||||
crate::app_server_session::SideChatStorage::Temporary,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Discovers local side chats from their own rollouts and reads their visible labels.
|
||||
|
||||
use super::*;
|
||||
use codex_app_server_protocol::SortDirection;
|
||||
use codex_app_server_protocol::ThreadItemPayload;
|
||||
use codex_app_server_protocol::ThreadItemsListResponse;
|
||||
use codex_context_fragments::ContextualUserFragment;
|
||||
use codex_context_fragments::SIDE_BOUNDARY_TITLE;
|
||||
use codex_context_fragments::SideConversationPolicy;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_rollout::ResponseItemEnvelope;
|
||||
use codex_rollout::RolloutItem;
|
||||
use codex_rollout::read_session_meta_line;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) async fn side_context_checkpoint(path: Option<&Path>) -> io::Result<Option<String>> {
|
||||
let Some(path) = path else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut reader = codex_rollout::open_rollout_line_reader(path).await?;
|
||||
// The two bootstrap items are written before the first child turn. Inspect
|
||||
// only the child's own records, even when its parent has side context.
|
||||
let mut has_policy = false;
|
||||
while let Some(line) = reader.next_line().await? {
|
||||
let record = codex_rollout::parse_rollout_line(&line)?;
|
||||
match record.item {
|
||||
RolloutItem::SessionMeta(meta) if !meta.meta.attached_thread => return Ok(None),
|
||||
RolloutItem::ResponseItem(ResponseItemEnvelope {
|
||||
item: ResponseItem::Message { role, content, .. },
|
||||
..
|
||||
}) => {
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
continue;
|
||||
};
|
||||
match role.as_str() {
|
||||
"developer" => has_policy |= SideConversationPolicy::matches_text(text),
|
||||
"user" if has_policy && text.lines().next() == Some(SIDE_BOUNDARY_TITLE) => {
|
||||
return Ok(Some(record.timestamp));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
RolloutItem::EventMsg(codex_protocol::protocol::EventMsg::TurnStarted(_)) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SideChat {
|
||||
pub(crate) id: ThreadId,
|
||||
pub(crate) name: Option<String>,
|
||||
pub(crate) preview: String,
|
||||
}
|
||||
|
||||
impl From<ThreadId> for SideChat {
|
||||
fn from(id: ThreadId) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name: None,
|
||||
preview: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn side_chat_prompt_preview(content: &[UserInput]) -> Option<&str> {
|
||||
if crate::chatwidget::realtime_delegation_input(content).is_some() {
|
||||
return None;
|
||||
}
|
||||
content
|
||||
.iter()
|
||||
.find_map(|input| match input {
|
||||
UserInput::Text { text, .. } => {
|
||||
codex_protocol::protocol::strip_user_message_prefix(text)
|
||||
.lines()
|
||||
.next()
|
||||
.map(str::trim)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.or_else(|| {
|
||||
content.iter().find_map(|input| match input {
|
||||
UserInput::Image { .. } | UserInput::LocalImage { .. } => Some("[Image]"),
|
||||
UserInput::Audio { .. } | UserInput::LocalAudio { .. } => Some("[Audio]"),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn is_attached_thread_store_unsupported(err: &color_eyre::Report) -> bool {
|
||||
matches!(
|
||||
err.downcast_ref::<TypedRequestError>(),
|
||||
Some(TypedRequestError::Server { source, .. })
|
||||
if source.code == JSONRPC_METHOD_NOT_FOUND
|
||||
&& source.message == "attached threads is not supported yet"
|
||||
)
|
||||
}
|
||||
|
||||
impl AppServerSession {
|
||||
pub(crate) fn can_list_saved_side_chats(&self, parent: ThreadId) -> bool {
|
||||
!self.uses_remote_workspace()
|
||||
&& self.history_support == ThreadHistorySupport::Paginated
|
||||
&& self
|
||||
.history_pagination
|
||||
.get(&parent)
|
||||
.is_none_or(|state| state.history_mode == ThreadHistoryMode::Paginated)
|
||||
}
|
||||
|
||||
pub(super) async fn attached_side_chat_query(
|
||||
&mut self,
|
||||
parent: ThreadId,
|
||||
) -> Result<Option<ThreadListParams>> {
|
||||
if self.uses_remote_workspace() {
|
||||
return Ok(None);
|
||||
}
|
||||
let query = ThreadListParams {
|
||||
attached_to_thread_id: Some(parent.to_string()),
|
||||
current_account_only: true,
|
||||
archived: Some(false),
|
||||
model_providers: Some(Vec::new()),
|
||||
sort_direction: Some(SortDirection::Asc),
|
||||
use_state_db_only: true,
|
||||
cursor: None,
|
||||
limit: None,
|
||||
sort_key: None,
|
||||
source_kinds: None,
|
||||
section_id: None,
|
||||
project_id: None,
|
||||
cwd: None,
|
||||
parent_thread_id: None,
|
||||
ancestor_thread_id: None,
|
||||
search_term: None,
|
||||
originators: None,
|
||||
excluded_thread_ids: None,
|
||||
};
|
||||
if matches!(self.request_handle, AppServerRequestHandle::InProcess(_)) {
|
||||
return Ok(Some(query));
|
||||
}
|
||||
// Older local daemons can silently ignore unknown fields. Require the
|
||||
// validation error for these incompatible filters before using them.
|
||||
let probe = ThreadListParams {
|
||||
parent_thread_id: query.attached_to_thread_id.clone(),
|
||||
limit: Some(1),
|
||||
..query.clone()
|
||||
};
|
||||
let Err(err) = self.thread_list(probe).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(TypedRequestError::Server { source, .. }) = err.downcast_ref() else {
|
||||
return Err(err);
|
||||
};
|
||||
Ok((source.code == JSONRPC_INVALID_REQUEST
|
||||
&& source.message
|
||||
== "attachedToThreadId cannot be combined with parentThreadId or ancestorThreadId")
|
||||
.then_some(query))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_saved_side_chats(
|
||||
&mut self,
|
||||
parent: ThreadId,
|
||||
codex_home: &Path,
|
||||
) -> Result<Vec<SideChat>> {
|
||||
let Some(query) = self.attached_side_chat_query(parent).await? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut chats = Vec::new();
|
||||
let threads = self.list_attached_threads(query).await?;
|
||||
let named_ids = threads
|
||||
.iter()
|
||||
.filter(|thread| thread.name.is_some())
|
||||
.filter_map(|thread| ThreadId::from_string(&thread.id).ok())
|
||||
.collect();
|
||||
let indexed_names = codex_rollout::find_thread_name_entries_by_ids(codex_home, &named_ids)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
tracing::debug!(%err, "could not read side-chat name timestamps");
|
||||
Default::default()
|
||||
});
|
||||
for thread in threads {
|
||||
let Ok(id) = ThreadId::from_string(&thread.id) else {
|
||||
continue;
|
||||
};
|
||||
let checkpoint = match side_context_checkpoint(thread.path.as_deref()).await {
|
||||
Ok(Some(timestamp)) => timestamp,
|
||||
Ok(None) => continue,
|
||||
Err(error) => {
|
||||
tracing::warn!(%id, %error, "failed to read side chat context");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Older servers write the inherited name during fork, before the TUI installs the
|
||||
// side-chat boundary. A later name write is an explicit rename, even to the same text.
|
||||
let name = thread.name.filter(|name| {
|
||||
if let Some(entry) = indexed_names.get(&id)
|
||||
&& entry.thread_name == *name
|
||||
&& let Ok(written) = chrono::DateTime::parse_from_rfc3339(&entry.updated_at)
|
||||
&& let Ok(opened) = chrono::DateTime::parse_from_rfc3339(&checkpoint)
|
||||
{
|
||||
return written > opened;
|
||||
}
|
||||
true
|
||||
});
|
||||
let preview = if name.is_none() {
|
||||
self.first_side_chat_prompt(id)
|
||||
.await
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::debug!(%id, %error, "failed to load side-chat prompt");
|
||||
String::new()
|
||||
})
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
chats.push(SideChat { id, name, preview });
|
||||
}
|
||||
Ok(chats)
|
||||
}
|
||||
|
||||
/// Delete the parent's TUI side chats before deleting the parent itself.
|
||||
pub(crate) async fn delete_thread_with_saved_side_chats(
|
||||
&mut self,
|
||||
parent: ThreadId,
|
||||
) -> Result<()> {
|
||||
if self.can_list_saved_side_chats(parent)
|
||||
&& let Some(mut query) = self.attached_side_chat_query(parent).await?
|
||||
{
|
||||
// Resolve the complete list before deleting anything. Archived and partially
|
||||
// initialized side chats also block the app-server's parent delete.
|
||||
let mut attached = self.list_attached_threads(query.clone()).await?;
|
||||
query.archived = Some(true);
|
||||
attached.extend(self.list_attached_threads(query).await?);
|
||||
let mut children = HashSet::new();
|
||||
for thread in attached {
|
||||
let child = ThreadId::from_string(&thread.id)?;
|
||||
let path = thread.path.as_deref().ok_or_else(|| {
|
||||
color_eyre::eyre::eyre!("No rollout found for attached thread {child}")
|
||||
})?;
|
||||
// The thread list omits fork origins. Verify the child's own metadata
|
||||
// before passing its ID to the delete API.
|
||||
let meta = read_session_meta_line(path)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to verify attached thread {child}"))?
|
||||
.meta;
|
||||
color_eyre::eyre::ensure!(
|
||||
meta.id == child
|
||||
&& meta.attached_thread
|
||||
&& meta.forked_from_id == Some(parent)
|
||||
&& matches!(
|
||||
meta.thread_source.as_ref(),
|
||||
Some(codex_protocol::protocol::ThreadSource::Feature(source))
|
||||
if source == ATTACHED_SIDE_CHAT_SOURCE
|
||||
),
|
||||
"The conversation has an attachment outside the TUI sidebar. Remove it before deleting the conversation."
|
||||
);
|
||||
children.insert(child);
|
||||
}
|
||||
for child in children {
|
||||
self.thread_delete(child)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to delete saved side chat {child}"))?;
|
||||
}
|
||||
}
|
||||
self.thread_delete(parent).await
|
||||
}
|
||||
|
||||
async fn list_attached_threads(&mut self, mut query: ThreadListParams) -> Result<Vec<Thread>> {
|
||||
let mut threads = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut cursors = HashSet::new();
|
||||
loop {
|
||||
let page = match self.thread_list(query.clone()).await {
|
||||
Err(err) if is_attached_thread_store_unsupported(&err) => return Ok(Vec::new()),
|
||||
result => result?,
|
||||
};
|
||||
threads.extend(
|
||||
page.data
|
||||
.into_iter()
|
||||
.filter(|thread| seen.insert(thread.id.clone())),
|
||||
);
|
||||
let Some(cursor) = page.next_cursor else {
|
||||
return Ok(threads);
|
||||
};
|
||||
color_eyre::eyre::ensure!(
|
||||
cursors.insert(cursor.clone()),
|
||||
"Repeated side chat list cursor"
|
||||
);
|
||||
query.cursor = Some(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
async fn first_side_chat_prompt(&mut self, id: ThreadId) -> Result<String> {
|
||||
// The summary preview can come from inherited model context. Item pages only contain
|
||||
// the child's visible history, including for chats saved by older clients.
|
||||
let mut cursor = None;
|
||||
let mut cursors = HashSet::new();
|
||||
loop {
|
||||
let mut params =
|
||||
thread_items_page_params(id, /*turn_id*/ None, cursor, /*limit*/ 16);
|
||||
params.sort_direction = Some(SortDirection::Asc);
|
||||
let request_id = self.next_request_id();
|
||||
let page: ThreadItemsListResponse = self
|
||||
.request_handle
|
||||
.request_typed(ClientRequest::ThreadItemsList { request_id, params })
|
||||
.await?;
|
||||
if let Some(preview) = page
|
||||
.data
|
||||
.iter()
|
||||
.find_map(|entry| match &entry.item.payload {
|
||||
ThreadItemPayload::UserMessage { content, .. } => {
|
||||
side_chat_prompt_preview(content)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
return Ok(preview.to_owned());
|
||||
}
|
||||
let Some(next) = page.next_cursor else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
color_eyre::eyre::ensure!(
|
||||
cursors.insert(next.clone()),
|
||||
"Repeated side chat item cursor"
|
||||
);
|
||||
cursor = Some(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,7 @@ stream_max_retries = 0
|
||||
session_config,
|
||||
thread_id,
|
||||
/*selected_profile*/ None,
|
||||
crate::app_server_session::SideChatStorage::Temporary,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(side.session.runtime_workspace_roots, expected_roots);
|
||||
|
||||
@@ -633,7 +633,7 @@ pub(crate) fn side_conversation_context_line(label: &str) -> Line<'static> {
|
||||
if index > 0 {
|
||||
line.push_span(" · ".set_style(secondary_text_style()));
|
||||
}
|
||||
if let Some((keys, action)) = [" to switch", " to close", " for side"]
|
||||
if let Some((keys, action)) = [" to switch", " to close", " to archive", " for side"]
|
||||
.into_iter()
|
||||
.find_map(|action| part.strip_suffix(action).map(|keys| (keys, action)))
|
||||
{
|
||||
|
||||
@@ -339,6 +339,7 @@ mod tests {
|
||||
commands,
|
||||
vec![
|
||||
SlashCommand::Ide,
|
||||
SlashCommand::Rename,
|
||||
SlashCommand::Agents,
|
||||
SlashCommand::Copy,
|
||||
SlashCommand::Export,
|
||||
|
||||
@@ -291,7 +291,8 @@ impl ChatWidget {
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
title: Some("Delete this session?".to_string()),
|
||||
subtitle: Some(
|
||||
"Cannot be undone. Subagent threads will also be deleted.".to_string(),
|
||||
"Also deletes subagents and saved side chats. Cannot be undone."
|
||||
.to_string(),
|
||||
),
|
||||
footer_hint: Some(standard_popup_hint_line()),
|
||||
items: vec![
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests/slash_commands.rs
|
||||
expression: popup
|
||||
---
|
||||
Delete this session?
|
||||
› As Cannot be undone. Subagent threads will also be deleted.
|
||||
› As Also deletes subagents and saved side chats. Cannot be undone.
|
||||
|
||||
GP
|
||||
› 1. No, keep this session Return to the current
|
||||
|
||||
@@ -322,6 +322,12 @@ impl ChatWidget {
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn has_unsent_input(&self) -> bool {
|
||||
!self.composer_is_empty()
|
||||
|| self.pending_image_submission.is_some()
|
||||
|| self.has_queued_follow_up_messages()
|
||||
}
|
||||
|
||||
pub(crate) fn has_queued_follow_up_messages(&self) -> bool {
|
||||
self.input_queue.has_queued_follow_up_messages()
|
||||
}
|
||||
|
||||
@@ -893,6 +893,12 @@ impl ThreadInputState {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn has_unsent_input(&self) -> bool {
|
||||
self.composer.is_some()
|
||||
|| !self.queued_user_messages.is_empty()
|
||||
|| !self.rejected_steers_queue.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn has_unconfirmed_messages(&self) -> bool {
|
||||
self.queued_user_messages
|
||||
.iter()
|
||||
|
||||
@@ -108,6 +108,7 @@ pub(crate) struct AppKeymap {
|
||||
pub(crate) focus_sidebar_content: Vec<KeyBinding>,
|
||||
pub(crate) focus_sidebar_tabs: Vec<KeyBinding>,
|
||||
pub(crate) hide_pane: Vec<KeyBinding>,
|
||||
pub(crate) close_sidebar_tab: Vec<KeyBinding>,
|
||||
pub(crate) focus_pane_left: Vec<KeyBinding>,
|
||||
pub(crate) focus_pane_right: Vec<KeyBinding>,
|
||||
pub(crate) minimize_pane_width: Vec<KeyBinding>,
|
||||
@@ -710,6 +711,7 @@ impl RuntimeKeymap {
|
||||
classic_keymap.global.focus_sidebar_content = None;
|
||||
classic_keymap.global.focus_sidebar_tabs = None;
|
||||
classic_keymap.global.hide_pane = None;
|
||||
classic_keymap.global.close_sidebar_tab = None;
|
||||
classic_keymap.global.focus_pane_left = None;
|
||||
classic_keymap.global.focus_pane_right = None;
|
||||
&classic_keymap
|
||||
@@ -785,6 +787,11 @@ impl RuntimeKeymap {
|
||||
&[],
|
||||
"tui.keymap.global.hide_pane",
|
||||
)?,
|
||||
close_sidebar_tab: resolve_bindings(
|
||||
keymap.global.close_sidebar_tab.as_ref(),
|
||||
&[],
|
||||
"tui.keymap.global.close_sidebar_tab",
|
||||
)?,
|
||||
focus_pane_left: resolve_bindings(
|
||||
keymap.global.focus_pane_left.as_ref(),
|
||||
&[],
|
||||
@@ -1809,6 +1816,7 @@ impl RuntimeKeymap {
|
||||
focus_sidebar_content: Vec::new(),
|
||||
focus_sidebar_tabs: Vec::new(),
|
||||
hide_pane: Vec::new(),
|
||||
close_sidebar_tab: Vec::new(),
|
||||
focus_pane_left: Vec::new(),
|
||||
focus_pane_right: Vec::new(),
|
||||
minimize_pane_width: Vec::new(),
|
||||
@@ -2189,6 +2197,7 @@ impl RuntimeKeymap {
|
||||
("global.move_divider_right", &self.app.move_divider_right),
|
||||
("global.toggle_pane_zoom", &self.app.toggle_pane_zoom),
|
||||
("global.hide_pane", &self.app.hide_pane),
|
||||
("global.close_sidebar_tab", &self.app.close_sidebar_tab),
|
||||
("chat.open_presets", &self.chat.open_presets),
|
||||
("chat.toggle_voice", &self.chat.toggle_voice),
|
||||
] {
|
||||
@@ -2239,6 +2248,7 @@ impl RuntimeKeymap {
|
||||
("move_divider_right", divider_right),
|
||||
("toggle_pane_zoom", self.app.toggle_pane_zoom.as_slice()),
|
||||
("hide_pane", self.app.hide_pane.as_slice()),
|
||||
("close_sidebar_tab", self.app.close_sidebar_tab.as_slice()),
|
||||
("open_agents", self.app.open_agents.as_slice()),
|
||||
("open_transcript", self.app.open_transcript.as_slice()),
|
||||
("find_transcript", self.app.find_transcript.as_slice()),
|
||||
@@ -2426,6 +2436,7 @@ impl RuntimeKeymap {
|
||||
("move_divider_right", divider_right),
|
||||
("toggle_pane_zoom", self.app.toggle_pane_zoom.as_slice()),
|
||||
("hide_pane", self.app.hide_pane.as_slice()),
|
||||
("close_sidebar_tab", self.app.close_sidebar_tab.as_slice()),
|
||||
("open_agents", self.app.open_agents.as_slice()),
|
||||
("open_transcript", self.app.open_transcript.as_slice()),
|
||||
("find_transcript", self.app.find_transcript.as_slice()),
|
||||
@@ -2582,6 +2593,10 @@ impl RuntimeKeymap {
|
||||
self.app.toggle_pane_zoom.as_slice(),
|
||||
),
|
||||
("global.hide_pane", self.app.hide_pane.as_slice()),
|
||||
(
|
||||
"global.close_sidebar_tab",
|
||||
self.app.close_sidebar_tab.as_slice(),
|
||||
),
|
||||
(
|
||||
"global.select_transcript",
|
||||
self.app.select_transcript.as_slice(),
|
||||
@@ -2645,6 +2660,10 @@ impl RuntimeKeymap {
|
||||
self.app.toggle_pane_zoom.as_slice(),
|
||||
),
|
||||
("global.hide_pane", self.app.hide_pane.as_slice()),
|
||||
(
|
||||
"global.close_sidebar_tab",
|
||||
self.app.close_sidebar_tab.as_slice(),
|
||||
),
|
||||
("global.focus_activity", self.app.focus_activity.as_slice()),
|
||||
(
|
||||
"global.find_transcript",
|
||||
|
||||
@@ -111,6 +111,7 @@ impl KeymapActionId {
|
||||
| "focus_sidebar_content"
|
||||
| "focus_sidebar_tabs"
|
||||
| "hide_pane"
|
||||
| "close_sidebar_tab"
|
||||
| "focus_pane_left"
|
||||
| "focus_pane_right"
|
||||
| "minimize_pane_width"
|
||||
@@ -319,6 +320,7 @@ define_runtime_action_bindings! {
|
||||
focus_sidebar_content,
|
||||
focus_sidebar_tabs,
|
||||
hide_pane,
|
||||
close_sidebar_tab,
|
||||
focus_pane_left,
|
||||
focus_pane_right,
|
||||
minimize_pane_width,
|
||||
|
||||
@@ -47,6 +47,7 @@ pub(in crate::keymap) fn register(
|
||||
(Global, "resize_panes", Char('r'), "r"),
|
||||
(Global, "minimize_pane_width", Char(','), ","),
|
||||
(Global, "maximize_pane_width", Char('.'), "."),
|
||||
(Global, "close_sidebar_tab", Char('w'), "w"),
|
||||
(Global, "focus_sidebar_tabs", Char('t'), "t"),
|
||||
] {
|
||||
if remaining == 0 {
|
||||
|
||||
@@ -122,6 +122,7 @@ define_keymap_actions! {
|
||||
focus_sidebar_tabs [Workspace] => "Open the sidebar tab chooser.",
|
||||
focus_sidebar_content [Workspace] => "Focus the side conversation, or hide it when already focused.",
|
||||
hide_pane [Workspace] => "Hide the focused sidebar pane.",
|
||||
close_sidebar_tab [Workspace] => "Close the active sidebar tab.",
|
||||
focus_pane_left [Workspace] => "Focus the parent conversation.",
|
||||
focus_pane_right [Workspace] => "Focus the open side conversation.",
|
||||
minimize_pane_width [Workspace] => "Minimize the focused pane width.",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Durable ownership and attachments survive the bounded, process-local operation registry.
|
||||
//! Creation does not change the caller's cwd or grant filesystem permissions.
|
||||
|
||||
use crate::app_server_session::ATTACHED_SIDE_CHAT_SOURCE;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::legacy_core::config::ConfigBuilder;
|
||||
use codex_app_server_client::AppServerRequestHandle;
|
||||
@@ -15,6 +16,7 @@ use codex_app_server_protocol::ThreadAttachmentListParams;
|
||||
use codex_app_server_protocol::ThreadAttachmentListResponse;
|
||||
use codex_app_server_protocol::ThreadReadParams;
|
||||
use codex_app_server_protocol::ThreadReadResponse;
|
||||
use codex_app_server_protocol::ThreadSource;
|
||||
use codex_worktree::CreateWorktree;
|
||||
use codex_worktree::WorktreeManager;
|
||||
use codex_worktree::WorktreeSettings;
|
||||
@@ -185,8 +187,13 @@ impl ManagedWorktreeTools {
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
if response.thread.ephemeral {
|
||||
return Err("Ephemeral side conversations cannot retain worktree attachments. Request a new worktree in the main conversation.".to_owned());
|
||||
if response.thread.ephemeral
|
||||
|| matches!(
|
||||
response.thread.thread_source.as_ref(),
|
||||
Some(ThreadSource::Feature(source)) if source == ATTACHED_SIDE_CHAT_SOURCE
|
||||
)
|
||||
{
|
||||
return Err("Side conversations cannot retain worktree attachments. Request a new worktree in the main conversation.".to_owned());
|
||||
}
|
||||
// Verify capability before allocating a checkout, including SQLite availability.
|
||||
attachments(&handle, &thread_id, /*cursor*/ None).await?;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
use crate::app_server_session::SideChatStorage;
|
||||
use crate::legacy_core::config::ConfigOverrides;
|
||||
use crate::local_settings::LocalSettings;
|
||||
use crate::test_support::boxed_future;
|
||||
use codex_config::LoaderOverrides;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -140,16 +142,29 @@ async fn creation_attaches_to_original_task_and_survives_service_restart_scenari
|
||||
let mut ephemeral_config = config.clone();
|
||||
ephemeral_config.ephemeral = true;
|
||||
let ephemeral = Box::pin(server.start_thread(&ephemeral_config)).await?;
|
||||
let refused = tools
|
||||
.execute(
|
||||
server.request_handle(),
|
||||
ephemeral.session.thread_id.to_string(),
|
||||
"create_worktree",
|
||||
json!({"allowAsync":true}),
|
||||
)
|
||||
.await;
|
||||
assert!(!refused.success, "{refused:?}");
|
||||
assert!(format!("{refused:?}").contains("Ephemeral side conversations"));
|
||||
let saved_side = Box::pin(server.fork_side_thread(
|
||||
&LocalSettings::from(&config),
|
||||
config.clone(),
|
||||
started.session.thread_id,
|
||||
/*selected_profile*/ None,
|
||||
SideChatStorage::Attached,
|
||||
))
|
||||
.await?;
|
||||
for blocked in [ephemeral, saved_side] {
|
||||
let refused = tools
|
||||
.execute(
|
||||
server.request_handle(),
|
||||
blocked.session.thread_id.to_string(),
|
||||
"create_worktree",
|
||||
json!({"allowAsync":true}),
|
||||
)
|
||||
.await;
|
||||
assert!(!refused.success, "{refused:?}");
|
||||
assert!(
|
||||
format!("{refused:?}")
|
||||
.contains("Side conversations cannot retain worktree attachments")
|
||||
);
|
||||
}
|
||||
assert!(!tools.manager.settings().root.exists());
|
||||
assert!(tools.operations.lock().await.is_empty());
|
||||
let call = |tool: &'static str, arguments| {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! The sidebar chooser belongs to its parent conversation and can remain open without taking input.
|
||||
|
||||
use crate::app_server_session::SideChat;
|
||||
use crate::footer_hint;
|
||||
use crate::keymap::KeymapContext;
|
||||
use crate::keymap::ListAction;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::sidebar_tab_strip::TabState;
|
||||
use crate::sidebar_tab_strip::TabStrip;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -92,25 +94,36 @@ pub(crate) fn footer(
|
||||
|
||||
const CHAT_TAB: &str = " Side chat ";
|
||||
|
||||
pub(crate) fn close_cell(tab: Rect) -> Rect {
|
||||
Rect::new(
|
||||
tab.right().saturating_sub(1),
|
||||
tab.y,
|
||||
u16::from(tab.width >= 3),
|
||||
tab.height,
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct ChatTabs<'a> {
|
||||
pub(crate) ids: &'a [ThreadId],
|
||||
// Chooser index zero is reserved for New side chat.
|
||||
pub(crate) choices: &'a [SideChat],
|
||||
pub(crate) selected: Option<ThreadId>,
|
||||
}
|
||||
|
||||
impl<'a> ChatTabs<'a> {
|
||||
pub(crate) fn choice_count(self) -> usize {
|
||||
self.ids.len() + 1
|
||||
self.choices.len() + 1
|
||||
}
|
||||
|
||||
pub(crate) fn choice(self, index: usize) -> Option<ThreadId> {
|
||||
self.ids.get(index.checked_sub(1)?).copied()
|
||||
Some(self.choices.get(index.checked_sub(1)?)?.id)
|
||||
}
|
||||
|
||||
pub(crate) fn choice_index(self, id: ThreadId) -> Option<usize> {
|
||||
self.ids
|
||||
self.choices
|
||||
.iter()
|
||||
.position(|choice| *choice == id)
|
||||
.position(|choice| choice.id == id)
|
||||
.map(|index| index + 1)
|
||||
}
|
||||
|
||||
@@ -131,14 +144,40 @@ pub(crate) fn tab_strip(
|
||||
hovered_tab: Option<ThreadId>,
|
||||
buf: &mut Buffer,
|
||||
) {
|
||||
tabs.strip()
|
||||
.render(area, buf, chooser, chat, hovered_tab, |index, _| {
|
||||
if tabs.ids.len() > 1 {
|
||||
Line::from(format!(" Side {} ", index + 1))
|
||||
} else {
|
||||
Line::from(CHAT_TAB)
|
||||
}
|
||||
});
|
||||
tabs.strip().render(
|
||||
area,
|
||||
buf,
|
||||
chooser,
|
||||
chat,
|
||||
hovered_tab,
|
||||
|id, area, style, buf| {
|
||||
let close = close_cell(area);
|
||||
let label = Rect::new(area.x, area.y, area.width - close.width, area.height);
|
||||
let title = match tabs.choice_index(id) {
|
||||
Some(number) => match side_chat_label(&tabs.choices[number - 1]) {
|
||||
Some(name) => truncate_line_with_ellipsis_if_overflow(
|
||||
Line::from(format!(" {name}")),
|
||||
usize::from(label.width),
|
||||
),
|
||||
None if tabs.choices.len() > 1 => Line::from(format!(" Side {number} ")),
|
||||
None => Line::from(CHAT_TAB),
|
||||
},
|
||||
None => Line::from(CHAT_TAB),
|
||||
};
|
||||
title.style(style).render(label, buf);
|
||||
Line::from("×").style(style).render(close, buf);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn side_chat_label(chat: &SideChat) -> Option<&str> {
|
||||
chat.name
|
||||
.as_deref()
|
||||
.unwrap_or(&chat.preview)
|
||||
.trim()
|
||||
.lines()
|
||||
.next()
|
||||
.map(str::trim)
|
||||
}
|
||||
|
||||
struct ChoiceRows {
|
||||
@@ -245,24 +284,28 @@ pub(crate) fn paint(
|
||||
);
|
||||
}
|
||||
for (offset, index) in rows.chats.enumerate() {
|
||||
let id = tabs.ids[index - 1];
|
||||
let chat = &tabs.choices[index - 1];
|
||||
let selected = index == choice && focused;
|
||||
let (marker, fill) = row_style(index);
|
||||
let title = if count == 2 {
|
||||
let title = if let Some(label) = side_chat_label(chat) {
|
||||
label.to_owned()
|
||||
} else if count == 2 {
|
||||
"Side chat".to_string()
|
||||
} else {
|
||||
format!("Side chat {index}")
|
||||
};
|
||||
let title = if use_theme_colors && !selected {
|
||||
let identity = crate::thread_color::thread_color(id);
|
||||
let identity = crate::thread_color::thread_color(chat.id);
|
||||
title.fg(crate::style::readable_color_on(identity, fill.bg))
|
||||
} else {
|
||||
title.into()
|
||||
};
|
||||
let symbol = Span::styled("○ ", if selected { style } else { secondary });
|
||||
let y = rows.chat_start + offset as u16;
|
||||
let line = Line::from(vec![format!("{marker} ").into(), symbol, title]).style(fill);
|
||||
paint(
|
||||
rows.chat_start + offset as u16,
|
||||
Line::from(vec![format!("{marker} ").into(), symbol, title]).style(fill),
|
||||
y,
|
||||
truncate_line_with_ellipsis_if_overflow(line, usize::from(row(area.y).width)),
|
||||
);
|
||||
}
|
||||
if footer_y > area.y {
|
||||
|
||||
@@ -5,6 +5,7 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
@@ -53,12 +54,14 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> {
|
||||
(targets, Rect::new(area.x + available, area.y, plus, height))
|
||||
}
|
||||
|
||||
pub(crate) fn hit(self, area: Rect, point: Position) -> Option<TabTarget<Id>> {
|
||||
pub(crate) fn hit(self, area: Rect, point: Position) -> Option<(TabTarget<Id>, Rect)> {
|
||||
let (mut tabs, add) = self.strip_targets(area);
|
||||
if add.contains(point) {
|
||||
Some(TabTarget::Add)
|
||||
Some((TabTarget::Add, add))
|
||||
} else {
|
||||
tabs.find_map(|(_, id, rect)| rect.contains(point).then_some(TabTarget::Tab(id)))
|
||||
tabs.find_map(|(_, id, rect)| {
|
||||
rect.contains(point).then_some((TabTarget::Tab(id), rect))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +72,7 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> {
|
||||
add_state: TabState,
|
||||
selected_state: TabState,
|
||||
hovered_tab: Option<Id>,
|
||||
label: impl Fn(usize, Id) -> Line<'a>,
|
||||
paint: impl Fn(Id, Rect, Style, &mut Buffer),
|
||||
) {
|
||||
let (tabs, add) = self.strip_targets(area);
|
||||
let style = |state| match state {
|
||||
@@ -80,7 +83,7 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> {
|
||||
.not_underlined()
|
||||
.not_reversed(),
|
||||
};
|
||||
for (index, id, rect) in tabs {
|
||||
for (_, id, rect) in tabs {
|
||||
let tab_style = if selected_state != TabState::Absent && Some(id) == self.selected {
|
||||
style(selected_state)
|
||||
} else if hovered_tab == Some(id) {
|
||||
@@ -95,7 +98,7 @@ impl<'a, Id: Copy + Eq> TabStrip<'a, Id> {
|
||||
} else {
|
||||
style(TabState::Absent)
|
||||
};
|
||||
label(index, id).style(tab_style).render(rect, buf);
|
||||
paint(id, rect, tab_style, buf);
|
||||
}
|
||||
Line::from(" + ").style(style(add_state)).render(add, buf);
|
||||
}
|
||||
|
||||
@@ -143,9 +143,7 @@ impl SlashCommand {
|
||||
SlashCommand::Goal => "set or view the goal for a long-running task",
|
||||
SlashCommand::Agents => "open the agent command center",
|
||||
SlashCommand::MultiAgents => "switch between this session's subagents",
|
||||
SlashCommand::Side | SlashCommand::Btw => {
|
||||
"start a side conversation in an ephemeral fork"
|
||||
}
|
||||
SlashCommand::Side | SlashCommand::Btw => "start a side conversation",
|
||||
SlashCommand::Permissions => "choose what Codex is allowed to do",
|
||||
SlashCommand::Keymap => "remap TUI shortcuts",
|
||||
SlashCommand::Vim => "toggle Vim mode for the composer",
|
||||
@@ -215,6 +213,7 @@ impl SlashCommand {
|
||||
| SlashCommand::Pwd
|
||||
| SlashCommand::Usage
|
||||
| SlashCommand::Ide
|
||||
| SlashCommand::Rename
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/app/tests.rs
|
||||
expression: dialog.trim_end()
|
||||
---
|
||||
|
||||
This will discard unsent input and lose this temporary chat’s history. Close “Fix cache”?
|
||||
|
||||
Cancel Close
|
||||
|
||||
←/→ choose · Enter confirm · Esc cancel
|
||||
+2
-2
@@ -23,7 +23,7 @@ parent-29
|
||||
New activity · esc latest
|
||||
|
||||
100 columns, reopened
|
||||
parent-24 │ Side chat +
|
||||
parent-24 │ Side chat× +
|
||||
│child-24
|
||||
parent-25 │
|
||||
│child-25
|
||||
@@ -43,7 +43,7 @@ parent-29 │
|
||||
│ Side from main thread · ⌃/ to swit
|
||||
|
||||
99 columns, reopened
|
||||
Side chat +
|
||||
Side chat× +
|
||||
child-24
|
||||
|
||||
child-25
|
||||
|
||||
@@ -3,7 +3,7 @@ source: tui/src/app/tests.rs
|
||||
expression: "format!(\"Zoomed\\n{zoomed}\\nRestored\\n{restored}\\nEqualized\\n{equalized}\")"
|
||||
---
|
||||
Zoomed
|
||||
Side chat +
|
||||
Side chat× +
|
||||
• unfinished answer
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Zoomed
|
||||
GPT-6.1-Sol default · Agent
|
||||
tab to queue message Side from main thread · ⌃/ to switch · ⌃c to close
|
||||
Restored
|
||||
parent-24 │ Side chat +
|
||||
parent-24 │ Side chat× +
|
||||
│• unfinished answer
|
||||
parent-25 │
|
||||
│
|
||||
@@ -41,7 +41,7 @@ parent-29
|
||||
GPT-6.1-Sol default │ GPT-6.1-Sol default · Agent
|
||||
│ tab to queue message Side from main thread · ⌃/ to switch · ⌃c to close
|
||||
Equalized
|
||||
parent-24 │ Side chat +
|
||||
parent-24 │ Side chat× +
|
||||
│• unfinished answer
|
||||
parent-25 │
|
||||
│
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/app/tests.rs
|
||||
assertion_line: 4182
|
||||
expression: header.trim_end()
|
||||
---
|
||||
Side 1 Side 2 +
|
||||
Investig…× Fix cache× +
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
source: tui/src/app/tests.rs
|
||||
expression: "chooser.lines().filter_map(|line|\nline.split_once('│').map(|(_, sidebar)|\nsidebar.trim_end())).collect::<Vec<_>>().join(\"\\n\")"
|
||||
---
|
||||
Side chat +
|
||||
Side chat× +
|
||||
|
||||
Open in sidebar
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
source: tui/src/app/tests.rs
|
||||
expression: panel
|
||||
---
|
||||
parent-24 │ Side chat +
|
||||
parent-24 │ Side chat× +
|
||||
│child-24
|
||||
parent-25 │
|
||||
│child-25
|
||||
|
||||
+1
-1
@@ -6,6 +6,7 @@ Focus Main Composer | leader c (ctrl-x c) | Global focus_main_composer Focus Mai
|
||||
Focus Sidebar Tabs | leader t (ctrl-x t) | Global focus_sidebar_tabs Focus Sidebar Tabs Open the sidebar tab chooser. leader t (ctrl-x t) Default
|
||||
Focus Sidebar Content | leader s (ctrl-x s) | Global focus_sidebar_content Focus Sidebar Content Focus the side conversation, or hide it when already focused. leader s (ctrl-x s) Default
|
||||
Hide Pane | leader - (ctrl-x -) | Global hide_pane Hide Pane Hide the focused sidebar pane. leader - (ctrl-x -) Default
|
||||
Close Sidebar Tab | leader w (ctrl-x w) | Global close_sidebar_tab Close Sidebar Tab Close the active sidebar tab. leader w (ctrl-x w) Default
|
||||
Focus Pane Left | leader left (ctrl-x left), leader h (ctrl-x h) | Global focus_pane_left Focus Pane Left Focus the parent conversation. leader left (ctrl-x left), leader h (ctrl-x h) Default
|
||||
Focus Pane Right | leader right (ctrl-x right), leader l (ctrl-x l) | Global focus_pane_right Focus Pane Right Focus the open side conversation. leader right (ctrl-x right), leader l (ctrl-x l) Default
|
||||
Minimize Pane Width | leader , (ctrl-x ,) | Global minimize_pane_width Minimize Pane Width Minimize the focused pane width. leader , (ctrl-x ,) Default
|
||||
@@ -14,4 +15,3 @@ Equalize Pane Widths | leader = (ctrl-x =) | Global equalize_pane_widths Equaliz
|
||||
Resize Panes | leader r (ctrl-x r) | Global resize_panes Resize Panes Enter keyboard resize mode for visible dividers. leader r (ctrl-x r) Default
|
||||
Move Divider Left | leader shift-h (ctrl-x shift-h) | Global move_divider_left Move Divider Left Move the divider left when the side pane has focus. leader shift-h (ctrl-x shift-h) Default
|
||||
Move Divider Right | leader shift-l (ctrl-x shift-l) | Global move_divider_right Move Divider Right Move the divider right when the side pane has focus. leader shift-l (ctrl-x shift-l) Default
|
||||
Toggle Pane Zoom | leader z (ctrl-x z) | Global toggle_pane_zoom Toggle Pane Zoom Zoom or restore the focused pane. leader z (ctrl-x z) Default
|
||||
|
||||
Reference in New Issue
Block a user