mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Keep the TUI responsive while opening saved side chats (#60599)
## Why Opening a saved side chat waits for thread resume and history loading. Keep the chooser usable during that work and switch conversations only if the user still wants to open the chat. ## What changed - Prepare saved chats in the background and show opening, waiting, and retry states in the sidebar. Allow cancellation and keep only the latest queued destination, with at most two preparations in flight. - Ignore stale completions after navigation, archiving, disconnects, or backend changes, and release unclaimed subscriptions on their original connection. - Preserve live events, settings, read receipts, and pending approvals received during loading. Anchor paginated history to the resumed snapshot to avoid overlap with live events. - Carry held-key suppression across conversation switches. ## Testing Add regression tests for retry and supersession, cancellation, stale completions, subscription cleanup, live updates during attachment, and held keys, plus snapshots for waiting and retry rows. GitOrigin-RevId: 2c7d94482078f16895049ce79b242d3eb0d92f40
This commit is contained in:
@@ -282,10 +282,12 @@ mod side_focus;
|
||||
mod side_pane;
|
||||
mod sidebar;
|
||||
mod sidebar_close;
|
||||
pub(crate) mod sidebar_loading;
|
||||
mod startup;
|
||||
pub(crate) mod startup_bootstrap;
|
||||
mod startup_prompts;
|
||||
mod startup_warnings;
|
||||
pub(crate) mod thread_attachment;
|
||||
mod thread_event_buffer;
|
||||
mod thread_events;
|
||||
mod thread_goal_actions;
|
||||
@@ -661,6 +663,7 @@ pub(crate) struct App {
|
||||
agent_navigation: AgentNavigationState,
|
||||
agents_overview: agents_overview::AgentsOverviewState,
|
||||
side_threads: HashMap<ThreadId, SideThreadState>,
|
||||
saved_openings: sidebar_loading::Openings,
|
||||
sidebars: HashMap<Option<ThreadId>, sidebar::SidebarPane>,
|
||||
/// A parked parent view survives child cleanup until its own replay finishes.
|
||||
side_companions: HashMap<ThreadId, SideCompanion>,
|
||||
|
||||
@@ -265,8 +265,8 @@ impl App {
|
||||
return;
|
||||
}
|
||||
// Hidden helper threads must not enter visible thread routing or overview refreshes.
|
||||
if let ServerNotificationThreadTarget::Thread(thread_id) =
|
||||
server_notification_thread_target(¬ification)
|
||||
let target = server_notification_thread_target(¬ification);
|
||||
if let ServerNotificationThreadTarget::Thread(thread_id) = target
|
||||
&& let Some(sender) = self.temporary_structured_requests.get(&thread_id)
|
||||
{
|
||||
if matches!(
|
||||
@@ -492,8 +492,16 @@ impl App {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match server_notification_thread_target(¬ification) {
|
||||
match target {
|
||||
ServerNotificationThreadTarget::Thread(thread_id) => {
|
||||
if self.saved_openings.observe(
|
||||
app_server_client.backend_id(),
|
||||
thread_id,
|
||||
self.thread_event_channels.get(&thread_id),
|
||||
¬ification,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if self.current_displayed_thread_id() != Some(thread_id)
|
||||
&& let ServerNotification::ItemCompleted(item) = ¬ification
|
||||
&& let ThreadItemPayload::UserMessage {
|
||||
|
||||
@@ -373,6 +373,7 @@ impl App {
|
||||
bootstrap: AppServerBootstrap,
|
||||
) {
|
||||
let outgoing = backends.displayed_owner();
|
||||
self.saved_openings.abandon();
|
||||
self.capture_session_inputs().await;
|
||||
for channel in self.thread_event_channels.values_mut() {
|
||||
channel.backend_id.get_or_insert(outgoing);
|
||||
|
||||
@@ -93,6 +93,8 @@ impl App {
|
||||
| AppEvent::SideCreated { .. }
|
||||
| AppEvent::SideChatRemovalFinished { .. }
|
||||
| AppEvent::SideChatArchived { .. }
|
||||
| AppEvent::SavedChatPrepared { .. } | AppEvent::SavedChatReleased { .. }
|
||||
| AppEvent::StartQueuedSavedChat
|
||||
| AppEvent::SavedSideChatsLoaded { .. }
|
||||
| AppEvent::SideReplayFinished(_)
|
||||
| AppEvent::ResetTranscriptForThreadSwitch
|
||||
@@ -3449,6 +3451,20 @@ impl App {
|
||||
sidebar_close::forget_archived(self, tui, app_server, target).await?;
|
||||
}
|
||||
}
|
||||
AppEvent::SavedChatPrepared { target, request, result } => {
|
||||
Box::pin(self.finish_saved_chat(tui, app_server, target, request, *result)).await?;
|
||||
}
|
||||
AppEvent::SavedChatReleased { target, request } => {
|
||||
self.finish_saved_release(target, request);
|
||||
}
|
||||
AppEvent::StartQueuedSavedChat => {
|
||||
if let Some(target) = self.waiting_saved_chat()
|
||||
&& target.backend_id == app_server.backend_id()
|
||||
&& self.wants_saved_chat(target)
|
||||
{
|
||||
Box::pin(self.open_saved_chat(tui, app_server, target)).await?;
|
||||
}
|
||||
}
|
||||
AppEvent::SavedSideChatsLoaded { backend_id, parent, request, result } => {
|
||||
use crate::app_event::SavedSideChatData;
|
||||
use crate::sidebar_chooser::Discovery;
|
||||
|
||||
@@ -233,6 +233,10 @@ impl App {
|
||||
owner.reset_realtime_conversation();
|
||||
}
|
||||
self.retire_background_voice();
|
||||
self.saved_openings.abandon();
|
||||
if let Some(pane) = self.sidebars.get_mut(&self.sidebar_parent()) {
|
||||
pane.opening = None;
|
||||
}
|
||||
self.reconnect.offline = true;
|
||||
self.account_email_request_id = None;
|
||||
// Cached blank sessions are usable only while this connection owns a subscription.
|
||||
|
||||
@@ -8,6 +8,10 @@ use super::agent_picker::AGENT_PICKER_VIEW_ID;
|
||||
use super::app_server_event_targets::ServerNotificationThreadTarget;
|
||||
use super::app_server_event_targets::server_notification_thread_target;
|
||||
use super::app_server_event_targets::server_request_thread_id;
|
||||
use super::thread_attachment::ChannelMode;
|
||||
use super::thread_attachment::PreparedAttachment;
|
||||
use super::thread_attachment::Selection;
|
||||
use super::thread_attachment::SelectionMode;
|
||||
use super::*;
|
||||
use crate::app_server_session::SessionAccount;
|
||||
use crate::app_server_session::SideChatStorage;
|
||||
@@ -379,43 +383,60 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<bool> {
|
||||
let was_external_writer = self
|
||||
.thread_event_channels
|
||||
.get(&thread_id)
|
||||
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ExternalWriter);
|
||||
if !self.should_attach_live_thread_for_selection(thread_id) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
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) => {
|
||||
let prepared = super::thread_attachment::prepare(
|
||||
app_server,
|
||||
&self.local_settings,
|
||||
&self.config,
|
||||
thread_id,
|
||||
resume_settings,
|
||||
)
|
||||
.await?;
|
||||
Ok(self
|
||||
.install_thread_attachment(app_server, thread_id, prepared, ChannelMode::ReplaceLive)
|
||||
.await)
|
||||
}
|
||||
|
||||
pub(super) async fn install_thread_attachment(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
prepared: PreparedAttachment,
|
||||
channel_mode: ChannelMode<'_>,
|
||||
) -> bool {
|
||||
let incoming = match channel_mode {
|
||||
ChannelMode::ReplaceLive => None,
|
||||
ChannelMode::Reuse(updates) => Some(updates),
|
||||
};
|
||||
let closed = incoming
|
||||
.is_some_and(|updates| updates.close == super::thread_attachment::CloseState::Closed);
|
||||
let was_external_writer = self
|
||||
.thread_event_channels
|
||||
.get(&thread_id)
|
||||
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::ExternalWriter);
|
||||
let (mut session, turns, live_attached) = match prepared {
|
||||
PreparedAttachment::Live(started) => {
|
||||
if let Some(entry) = self.agent_navigation.get(&thread_id).cloned() {
|
||||
self.upsert_agent_picker_thread(
|
||||
thread_id,
|
||||
entry.agent_nickname,
|
||||
entry.agent_role,
|
||||
/*is_closed*/ false,
|
||||
closed,
|
||||
);
|
||||
self.sync_agent_picker_thread(thread_id);
|
||||
}
|
||||
@@ -424,43 +445,7 @@ impl App {
|
||||
}
|
||||
(started.session, started.turns, true)
|
||||
}
|
||||
Err(resume_err) => {
|
||||
tracing::warn!(
|
||||
thread_id = %thread_id,
|
||||
error = %resume_err,
|
||||
"failed to resume live thread for selection; falling back to thread/read"
|
||||
);
|
||||
let mut thread = app_server
|
||||
.thread_read(thread_id, /*include_turns*/ false)
|
||||
.await?;
|
||||
if thread.ephemeral {
|
||||
return Err(color_eyre::eyre::eyre!(
|
||||
"Agent thread {thread_id} is not yet available for replay or live attach."
|
||||
));
|
||||
}
|
||||
match app_server
|
||||
.hydrate_initial_thread_history(
|
||||
&mut thread,
|
||||
/*turn_cursor*/ None,
|
||||
/*item_cursor*/ None,
|
||||
Some(&self.config),
|
||||
Some(&self.local_settings),
|
||||
crate::app_server_session::HistoryHydrationScope::Initial,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(err) if Self::can_fallback_from_include_turns_error(&err) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
let turns = thread.turns.clone();
|
||||
if turns.is_empty() {
|
||||
// A `thread/read` fallback without turns would create a blank local replay
|
||||
// channel with no live listener attached, which blocks later real re-attach.
|
||||
return Err(color_eyre::eyre::eyre!(
|
||||
"Agent thread {thread_id} is not yet available for replay or live attach."
|
||||
));
|
||||
}
|
||||
PreparedAttachment::ReplayOnly(thread) => {
|
||||
let mut session = self.session_state_for_thread_read(thread_id, &thread).await;
|
||||
// Reads have no settings. Keep this cached thread's permissions rather than
|
||||
// inferring them from the conversation that is currently displayed.
|
||||
@@ -478,10 +463,11 @@ impl App {
|
||||
if was_external_writer {
|
||||
app_server.freeze_viewed_receipt(thread_id);
|
||||
}
|
||||
(session, turns, false)
|
||||
(session, thread.turns, false)
|
||||
}
|
||||
};
|
||||
if is_side {
|
||||
let live_attached = live_attached && !closed;
|
||||
if self.side_threads.contains_key(&thread_id) {
|
||||
session.forked_from_id = None;
|
||||
session.fork_parent_title = None;
|
||||
session.daybreak_enabled = false;
|
||||
@@ -499,18 +485,22 @@ impl App {
|
||||
false
|
||||
};
|
||||
self.agents_overview.activity.remove(&thread_id);
|
||||
let recap_progress =
|
||||
if live_attached && let Some(channel) = self.thread_event_channels.remove(&thread_id) {
|
||||
let store = channel.store.lock().await;
|
||||
if let Some(input) = store.input_state.clone() {
|
||||
self.agents_overview.input_states.insert(thread_id, input);
|
||||
}
|
||||
store.recap_progress()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let recap_progress = if incoming.is_none()
|
||||
&& live_attached
|
||||
&& let Some(channel) = self.thread_event_channels.remove(&thread_id)
|
||||
{
|
||||
let store = channel.store.lock().await;
|
||||
if let Some(input) = store.input_state.clone() {
|
||||
self.agents_overview.input_states.insert(thread_id, input);
|
||||
}
|
||||
store.recap_progress()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let channel = self.ensure_thread_channel(thread_id);
|
||||
if !live_attached {
|
||||
if live_attached {
|
||||
channel.mark_live();
|
||||
} else {
|
||||
if was_external_writer {
|
||||
channel.mark_external_writer();
|
||||
} else {
|
||||
@@ -518,11 +508,33 @@ impl App {
|
||||
}
|
||||
}
|
||||
let mut store = channel.store.lock().await;
|
||||
if let Some(settings) = incoming.and_then(|updates| updates.settings.as_ref()) {
|
||||
super::thread_settings::apply_thread_settings_to_session(&mut session, settings);
|
||||
}
|
||||
let live_turn = incoming.filter(|updates| updates.turn_changed).map(|_| {
|
||||
(
|
||||
store.active_turn_id.clone(),
|
||||
store.latest_turn_id.clone(),
|
||||
store.evicted_active_turn.take(),
|
||||
store.active_reasoning_item.clone(),
|
||||
)
|
||||
});
|
||||
store.set_session(session, turns);
|
||||
if let Some((active, latest, evicted, reasoning)) = live_turn {
|
||||
store.active_turn_id = active;
|
||||
store.latest_turn_id = latest;
|
||||
store.evicted_active_turn = evicted;
|
||||
store.active_reasoning_item = reasoning;
|
||||
}
|
||||
if closed {
|
||||
store.clear_active_turn_id();
|
||||
store.active_reasoning_item = None;
|
||||
store.pending_interrupt_turn_id = None;
|
||||
}
|
||||
store.replay_may_have_output |= replay_may_have_output;
|
||||
store.merge_recap_progress(recap_progress);
|
||||
store.rebase_buffer_after_session_refresh();
|
||||
Ok(live_attached)
|
||||
live_attached
|
||||
}
|
||||
|
||||
/// Replaces the chat widget, carrying composer preferences and the editor yank, and
|
||||
@@ -532,6 +544,7 @@ impl App {
|
||||
/// This helper copies every known nickname/role from `AgentNavigationState` into the
|
||||
/// replacement widget so that replayed collab items render agent names immediately.
|
||||
pub(super) fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) {
|
||||
chat_widget.inherit_view_transition_key_guard(&mut self.chat_widget);
|
||||
chat_widget.fork_in_progress = self.chat_widget.fork_in_progress;
|
||||
self.pending_right_click_paste = None;
|
||||
self.config_notification_test_pending = None;
|
||||
@@ -617,14 +630,7 @@ impl App {
|
||||
if self.active_thread_id == Some(thread_id) && !self.thread_unavailable(thread_id) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.reject_pending_permission_root_switch() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.windows_sandbox_blocks_thread_switch() {
|
||||
self.chat_widget.add_info_message(
|
||||
"Finish Windows sandbox setup before switching threads.".to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
if !self.can_select_thread() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.active_thread_id == Some(thread_id) {
|
||||
@@ -645,23 +651,15 @@ impl App {
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
let mut is_replay_only = self
|
||||
.agent_navigation
|
||||
.get(&thread_id)
|
||||
.is_some_and(|entry| entry.is_closed);
|
||||
let mut attached_replay_only = false;
|
||||
if self.should_attach_live_thread_for_selection(thread_id) {
|
||||
let mode = if self.should_attach_live_thread_for_selection(thread_id) {
|
||||
match self
|
||||
.attach_live_thread_for_selection(app_server, thread_id)
|
||||
.await
|
||||
{
|
||||
Ok(live_attached) => {
|
||||
attached_replay_only = !live_attached;
|
||||
is_replay_only = attached_replay_only;
|
||||
}
|
||||
Ok(true) => SelectionMode::Live,
|
||||
Ok(false) => SelectionMode::ReplayOnly,
|
||||
Err(_) if self.thread_event_channels.contains_key(&thread_id) => {
|
||||
is_replay_only = true;
|
||||
attached_replay_only = true;
|
||||
SelectionMode::ReplayOnly
|
||||
}
|
||||
Err(err) => {
|
||||
self.add_agents_overview_error(format!(
|
||||
@@ -670,7 +668,56 @@ impl App {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else if self
|
||||
.agent_navigation
|
||||
.get(&thread_id)
|
||||
.is_some_and(|entry| entry.is_closed)
|
||||
{
|
||||
SelectionMode::Closed
|
||||
} else {
|
||||
SelectionMode::Live
|
||||
};
|
||||
self.commit_thread_selection(
|
||||
tui,
|
||||
app_server,
|
||||
thread_id,
|
||||
Selection {
|
||||
mode,
|
||||
cached_session,
|
||||
pending_profile_on_unavailable_thread,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) fn can_select_thread(&mut self) -> bool {
|
||||
if self.reject_pending_permission_root_switch() {
|
||||
return false;
|
||||
}
|
||||
if self.windows_sandbox_blocks_thread_switch() {
|
||||
self.chat_widget.add_info_message(
|
||||
"Finish Windows sandbox setup before switching threads.".to_string(),
|
||||
/*hint*/ None,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) async fn commit_thread_selection(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
selection: Selection,
|
||||
) -> Result<()> {
|
||||
let Selection {
|
||||
mode,
|
||||
cached_session,
|
||||
pending_profile_on_unavailable_thread,
|
||||
} = selection;
|
||||
let mut is_replay_only = mode != SelectionMode::Live;
|
||||
let mut attached_replay_only = mode == SelectionMode::ReplayOnly;
|
||||
let previous_thread_id = self.active_thread_id;
|
||||
// Notifications already routed to the active channel must reach the old
|
||||
// widget before its transcript state is transferred. Events routed after
|
||||
|
||||
@@ -7,7 +7,6 @@ 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;
|
||||
@@ -326,6 +325,19 @@ impl App {
|
||||
self.guard_chooser_key(activation);
|
||||
return true;
|
||||
}
|
||||
if matches!(action, ChooserAction::Back)
|
||||
&& pane
|
||||
.opening
|
||||
.take()
|
||||
.is_some_and(|o| !matches!(o.state, sidebar_chooser::OpenState::Failed(_)))
|
||||
{
|
||||
self.guard_chooser_key(activation);
|
||||
return true;
|
||||
}
|
||||
if let ChooserAction::Open(index) = action {
|
||||
pane.choice = index;
|
||||
}
|
||||
pane.opening = None;
|
||||
if let Some(creation) = &pane.creation {
|
||||
match (&creation.state, open) {
|
||||
(sidebar::CreationState::Waiting { .. }, true) => {}
|
||||
@@ -373,21 +385,17 @@ impl App {
|
||||
}
|
||||
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 {
|
||||
let target = super::sidebar_loading::Target {
|
||||
backend_id: server.backend_id(),
|
||||
parent,
|
||||
child: id,
|
||||
};
|
||||
if let Err(err) = Box::pin(self.open_saved_chat(tui, server, target)).await {
|
||||
self.chat_widget.add_error_message(err.to_string());
|
||||
}
|
||||
} else 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,
|
||||
|
||||
@@ -84,6 +84,7 @@ pub(super) struct SidebarPane {
|
||||
choices: Vec<SideChat>,
|
||||
// A snapshot of the visible rows at the last explicit chooser refresh.
|
||||
pub(super) latest_prompts: HashMap<ThreadId, String>,
|
||||
pub(super) opening: Option<crate::sidebar_chooser::Opening>,
|
||||
pub(super) discovery: Discovery,
|
||||
pub(super) closing_chats: HashMap<ThreadId, JoinHandle<()>>,
|
||||
// Visit order is independent of the order in which tabs appear.
|
||||
@@ -107,6 +108,9 @@ impl SidebarPane {
|
||||
}
|
||||
SidebarContent::Chooser => {}
|
||||
}
|
||||
if content != SidebarContent::Chooser || focus == PaneFocus::Main {
|
||||
self.opening = None;
|
||||
}
|
||||
self.content = content;
|
||||
self.focus = Some(focus);
|
||||
}
|
||||
@@ -114,6 +118,7 @@ impl SidebarPane {
|
||||
pub(super) fn chat_tabs(&self) -> ChatTabs<'_> {
|
||||
ChatTabs {
|
||||
discovery: &self.discovery,
|
||||
opening: self.opening.as_ref(),
|
||||
ids: &self.tabs,
|
||||
choices: &self.choices,
|
||||
latest_prompts: &self.latest_prompts,
|
||||
@@ -222,6 +227,7 @@ impl SidebarPane {
|
||||
}
|
||||
|
||||
pub(super) fn hide(&mut self) {
|
||||
self.opening = None;
|
||||
self.focus = None;
|
||||
}
|
||||
|
||||
@@ -249,6 +255,13 @@ impl SidebarPane {
|
||||
}
|
||||
|
||||
pub(super) fn remove_choice(&mut self, id: ThreadId) {
|
||||
if self
|
||||
.opening
|
||||
.as_ref()
|
||||
.is_some_and(|opening| opening.target.child == id)
|
||||
{
|
||||
self.opening = None;
|
||||
}
|
||||
if let Some(index) = self.chat_tabs().choice_index(id) {
|
||||
self.choices.remove(index - 1);
|
||||
if index < self.choice {
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! A saved-child subscription is prepared on its originating connection. Only the
|
||||
//! latest chooser intent may install it. Release is serialized with reopening that child.
|
||||
|
||||
use super::thread_attachment;
|
||||
use super::thread_attachment::CloseState;
|
||||
use super::thread_attachment::Incoming;
|
||||
use super::thread_attachment::Prepared;
|
||||
use super::*;
|
||||
use crate::app_server_session::SideChatStorage;
|
||||
use crate::backend_id::BackendId;
|
||||
use crate::sidebar_chooser::OpenState;
|
||||
use crate::sidebar_chooser::Opening;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) use super::thread_attachment::Target;
|
||||
|
||||
struct Flight {
|
||||
request: Uuid,
|
||||
started: Instant,
|
||||
retired: bool,
|
||||
// None while releasing an unclaimed subscription.
|
||||
incoming: Option<Incoming>,
|
||||
}
|
||||
|
||||
pub(super) struct Openings {
|
||||
active: HashMap<(BackendId, ThreadId), Flight>,
|
||||
// The app event channel rotates on reconnect. This pair has the lifetime of the flights.
|
||||
tx: AppEventSender,
|
||||
completions: mpsc::UnboundedReceiver<AppEvent>,
|
||||
}
|
||||
|
||||
impl Default for Openings {
|
||||
fn default() -> Self {
|
||||
let (tx, completions) = mpsc::unbounded_channel();
|
||||
Self {
|
||||
active: HashMap::new(),
|
||||
tx: AppEventSender::new(tx),
|
||||
completions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Openings {
|
||||
pub(super) async fn next_event(
|
||||
&mut self,
|
||||
events: &mut mpsc::UnboundedReceiver<AppEvent>,
|
||||
) -> Option<AppEvent> {
|
||||
tokio::select! {
|
||||
event = events.recv() => event,
|
||||
event = self.completions.recv() => event,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn preparing(&self, child: ThreadId, channel: Option<&ThreadEventChannel>) -> bool {
|
||||
channel.is_some_and(|channel| {
|
||||
channel
|
||||
.backend_id
|
||||
.and_then(|owner| self.active.get(&(owner, child)))
|
||||
.and_then(|flight| flight.incoming.as_ref())
|
||||
.is_some_and(|incoming| incoming.uses_channel(channel))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn abandon(&mut self) {
|
||||
for flight in self.active.values_mut() {
|
||||
flight.retired = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// A provisional close is applied only after checking which runtime the resume attached.
|
||||
pub(super) fn observe(
|
||||
&mut self,
|
||||
owner: BackendId,
|
||||
child: ThreadId,
|
||||
channel: Option<&ThreadEventChannel>,
|
||||
notification: &ServerNotification,
|
||||
) -> bool {
|
||||
let Some(updates) = self
|
||||
.active
|
||||
.get_mut(&(owner, child))
|
||||
.filter(|flight| !flight.retired)
|
||||
.and_then(|flight| flight.incoming.as_mut())
|
||||
.filter(|incoming| incoming.owns_channel(channel, owner))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let updates = &mut updates.received;
|
||||
match notification {
|
||||
ServerNotification::ThreadSettingsUpdated(n) => {
|
||||
updates.settings = Some(n.thread_settings.clone())
|
||||
}
|
||||
ServerNotification::ThreadReadStateChanged(_) => updates.read_state_changed = true,
|
||||
ServerNotification::TurnStarted(_) | ServerNotification::TurnCompleted(_) => {
|
||||
updates.turn_changed = true
|
||||
}
|
||||
ServerNotification::ThreadClosed(_) => {
|
||||
updates.close = CloseState::Reported;
|
||||
return true;
|
||||
}
|
||||
ServerNotification::ThreadStarted(_) => updates.close = CloseState::Open,
|
||||
_ => {}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn waiting_saved_chat(&self) -> Option<Target> {
|
||||
let opening = self.sidebar()?.opening.as_ref()?;
|
||||
matches!(opening.state, OpenState::Waiting).then_some(opening.target)
|
||||
}
|
||||
|
||||
pub(super) fn wants_saved_chat(&self, target: Target) -> bool {
|
||||
!self.reconnect.offline
|
||||
&& self.current_displayed_thread_id() == Some(target.parent)
|
||||
&& self.chooser_focus() == Some(sidebar::PaneFocus::Sidebar)
|
||||
&& self
|
||||
.sidebar()
|
||||
.and_then(|pane| pane.opening.as_ref())
|
||||
.is_some_and(|o| o.target == target && !matches!(o.state, OpenState::Failed(_)))
|
||||
}
|
||||
|
||||
pub(super) async fn open_saved_chat(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
server: &mut AppServerSession,
|
||||
target: Target,
|
||||
) -> Result<()> {
|
||||
if !self.can_select_thread() {
|
||||
return Ok(());
|
||||
}
|
||||
let child = target.child;
|
||||
let flight = self.saved_openings.active.get(&target.key());
|
||||
let state = if let Some(flight) = flight
|
||||
&& flight.incoming.is_some()
|
||||
&& !flight.retired
|
||||
{
|
||||
OpenState::Loading(flight.started)
|
||||
} else if flight.is_some() || self.saved_openings.active.len() >= 2 {
|
||||
OpenState::Waiting
|
||||
} else {
|
||||
let side = side::SideThreadState {
|
||||
storage: SideChatStorage::Attached,
|
||||
..side::SideThreadState::new(target.parent)
|
||||
};
|
||||
self.abandoned_side_threads.remove(&child);
|
||||
if !self.should_attach_live_thread_for_selection(child)
|
||||
&& let Some(channel) = self.thread_event_channels.get(&child)
|
||||
&& channel.backend_id.is_none_or(|id| id == target.backend_id)
|
||||
&& channel.store.lock().await.session.is_some()
|
||||
{
|
||||
self.side_threads.insert(child, side);
|
||||
return self.select_agent_thread(tui, server, child).await;
|
||||
}
|
||||
let request = Uuid::new_v4();
|
||||
let started = Instant::now();
|
||||
let incoming = Incoming::begin(self, server, target).await;
|
||||
self.saved_openings.active.insert(
|
||||
target.key(),
|
||||
Flight {
|
||||
request,
|
||||
started,
|
||||
retired: false,
|
||||
incoming: Some(incoming),
|
||||
},
|
||||
);
|
||||
let view = server.attachment_view(child);
|
||||
let config = self.config.clone();
|
||||
let settings = self.local_settings.clone();
|
||||
let tx = self.saved_openings.tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let prepared =
|
||||
thread_attachment::prepare_saved(view, &settings, &config, child).await;
|
||||
tx.send(AppEvent::SavedChatPrepared {
|
||||
target,
|
||||
request,
|
||||
result: Box::new(prepared),
|
||||
});
|
||||
});
|
||||
OpenState::Loading(started)
|
||||
};
|
||||
self.sidebar_mut().opening = Some(Opening { target, state });
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn finish_saved_chat(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
server: &mut AppServerSession,
|
||||
target: Target,
|
||||
request: Uuid,
|
||||
mut prepared: Prepared,
|
||||
) -> Result<()> {
|
||||
let child = target.child;
|
||||
let wants = server.backend_id() == target.backend_id
|
||||
&& self.wants_saved_chat(target)
|
||||
&& self.conversation_focus_available(tui)
|
||||
&& self.sidebar_chooser_available()
|
||||
&& self.can_select_thread();
|
||||
let Some(flight) = self
|
||||
.saved_openings
|
||||
.active
|
||||
.get_mut(&target.key())
|
||||
.filter(|f| f.request == request)
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(mut incoming) = flight.incoming.take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let current = wants
|
||||
&& !flight.retired
|
||||
&& incoming.owns_channel(self.thread_event_channels.get(&child), target.backend_id);
|
||||
if current
|
||||
&& incoming.received.close == CloseState::Reported
|
||||
&& matches!(
|
||||
prepared.result,
|
||||
Ok(thread_attachment::PreparedAttachment::Live(_))
|
||||
)
|
||||
{
|
||||
incoming.received.close = CloseState::Checking;
|
||||
flight.incoming = Some(incoming);
|
||||
let mut view = server.request_view();
|
||||
let tx = self.saved_openings.tx.clone();
|
||||
tokio::spawn(async move {
|
||||
prepared.close_check = match view.thread_read(child, /*include_turns*/ false).await
|
||||
{
|
||||
Ok(thread) => {
|
||||
Ok(thread.status == codex_app_server_protocol::ThreadStatus::NotLoaded)
|
||||
}
|
||||
Err(err) if App::is_terminal_thread_read_error(&err) => Ok(true),
|
||||
Err(err) => Err(err.to_string()),
|
||||
};
|
||||
tx.send(AppEvent::SavedChatPrepared {
|
||||
target,
|
||||
request,
|
||||
result: Box::new(prepared),
|
||||
});
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
if incoming.received.close == CloseState::Checking {
|
||||
match prepared.close_check {
|
||||
Ok(true) => incoming.received.close = CloseState::Closed,
|
||||
Ok(false) => incoming.received.close = CloseState::Open,
|
||||
Err(error) => prepared.result = Err(error),
|
||||
}
|
||||
}
|
||||
if current && let Ok(attachment) = prepared.result {
|
||||
self.finish_saved_release(target, request);
|
||||
return self
|
||||
.install_saved_chat(
|
||||
tui,
|
||||
server,
|
||||
target,
|
||||
attachment,
|
||||
prepared.metadata,
|
||||
&incoming.received,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let release = incoming
|
||||
.discard(self, target, prepared.metadata.subscribed)
|
||||
.await;
|
||||
if let Some(pane) = self.sidebars.get_mut(&Some(target.parent))
|
||||
&& pane
|
||||
.opening
|
||||
.as_ref()
|
||||
.is_some_and(|o| o.target == target && matches!(o.state, OpenState::Loading(_)))
|
||||
{
|
||||
pane.opening = if current {
|
||||
prepared.result.err().map(|message| Opening {
|
||||
target,
|
||||
state: OpenState::Failed(message),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
if let Some(release) = release {
|
||||
let tx = self.saved_openings.tx.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = release.await {
|
||||
tracing::warn!(%child, %error, "failed to release saved chat subscription");
|
||||
}
|
||||
tx.send(AppEvent::SavedChatReleased { target, request });
|
||||
});
|
||||
} else {
|
||||
self.finish_saved_release(target, request);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn finish_saved_release(&mut self, target: Target, request: Uuid) {
|
||||
if self
|
||||
.saved_openings
|
||||
.active
|
||||
.get(&target.key())
|
||||
.is_some_and(|flight| flight.request == request)
|
||||
{
|
||||
self.saved_openings.active.remove(&target.key());
|
||||
if self.waiting_saved_chat().is_some() {
|
||||
self.saved_openings.tx.send(AppEvent::StartQueuedSavedChat);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1069,6 +1069,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
agent_navigation: AgentNavigationState::default(),
|
||||
agents_overview,
|
||||
side_threads: HashMap::new(),
|
||||
saved_openings: Default::default(),
|
||||
sidebars: HashMap::new(),
|
||||
side_companions: HashMap::new(),
|
||||
zoomed_thread: None,
|
||||
@@ -1498,7 +1499,7 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
}
|
||||
AppRunControl::Continue
|
||||
}
|
||||
Some(event) = app_event_rx.recv() => {
|
||||
Some(event) = app.saved_openings.next_event(&mut app_event_rx) => {
|
||||
let switch_backend = match &event {
|
||||
AppEvent::ChooseNewSessionCheckout { name } | AppEvent::NewSession { name }
|
||||
if multi_backend => app.other_new_session_backend(&backends).await
|
||||
|
||||
@@ -127,6 +127,7 @@ pub(crate) async fn make_test_app() -> Box<App> {
|
||||
agent_navigation: AgentNavigationState::default(),
|
||||
agents_overview: Default::default(),
|
||||
side_threads: HashMap::new(),
|
||||
saved_openings: Default::default(),
|
||||
sidebars: HashMap::new(),
|
||||
side_companions: HashMap::new(),
|
||||
zoomed_thread: None,
|
||||
|
||||
@@ -94,6 +94,8 @@ mod recap_generation;
|
||||
#[path = "tests/resume_shutdown_tests.rs"]
|
||||
mod resume_shutdown_tests;
|
||||
mod safety_buffering;
|
||||
#[path = "tests/saved_chat_loading_tests.rs"]
|
||||
mod saved_chat_loading;
|
||||
#[path = "tests/session_lifecycle_requests.rs"]
|
||||
mod session_lifecycle_requests;
|
||||
#[path = "tests/side_chat_policy_tests.rs"]
|
||||
@@ -3327,6 +3329,32 @@ async fn docked_replay_finishes_while_skills_refresh_is_pending() -> Result<()>
|
||||
|
||||
#[tokio::test]
|
||||
async fn sidebar_reopens_saved_side_chat_after_restart() -> Result<()> {
|
||||
boxed_future!(reopen_saved_side_chat(DuringOpen::Navigate)).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn closed_saved_chat_keeps_its_live_read_receipt_during_install() -> Result<()> {
|
||||
boxed_future!(reopen_saved_side_chat(DuringOpen::Close)).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_chat_replays_approval_after_a_stale_runtime_close() -> Result<()> {
|
||||
boxed_future!(reopen_saved_side_chat(DuringOpen::Approval)).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fork_releases_a_provisional_saved_chat_on_its_original_connection() -> Result<()> {
|
||||
boxed_future!(reopen_saved_side_chat(DuringOpen::Fork)).await
|
||||
}
|
||||
|
||||
enum DuringOpen {
|
||||
Navigate,
|
||||
Close,
|
||||
Approval,
|
||||
Fork,
|
||||
}
|
||||
|
||||
async fn reopen_saved_side_chat(during: DuringOpen) -> Result<()> {
|
||||
use crate::app_server_session::ResumeModelSettings;
|
||||
use crate::app_server_session::SideChatStorage;
|
||||
use codex_app_server_client::AppServerEvent;
|
||||
@@ -3334,7 +3362,9 @@ async fn sidebar_reopens_saved_side_chat_after_restart() -> Result<()> {
|
||||
|
||||
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_test_support::MockResponsesConfig::new(&model.uri())
|
||||
.with_root_config("thread_unload_delay_secs = 0")
|
||||
.write(&app.config.codex_home)?;
|
||||
app.config = app
|
||||
.rebuild_config_for_cwd(app.config.cwd.to_path_buf())
|
||||
.await?;
|
||||
@@ -3420,6 +3450,25 @@ async fn sidebar_reopens_saved_side_chat_after_restart() -> Result<()> {
|
||||
.expect("completed child turn");
|
||||
server.shutdown().await?;
|
||||
|
||||
// Preparing an already known child keeps its tool availability and visible history.
|
||||
let (mut remote, _, proxy) =
|
||||
session_lifecycle_requests::start_recording_remote_app_server(&config).await?;
|
||||
remote.remember_task_tool_thread(child);
|
||||
let mut view = remote.attachment_view(child);
|
||||
let reopened = view
|
||||
.resume_thread(
|
||||
&app.local_settings,
|
||||
config.clone(),
|
||||
child,
|
||||
ResumeModelSettings::PreserveSideConversation,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(reopened.session.thread_id, child);
|
||||
assert_eq!(reopened.turns.len(), 1);
|
||||
assert!(reopened.task_tools_available);
|
||||
remote.shutdown().await?;
|
||||
proxy.await??;
|
||||
|
||||
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
|
||||
let started = server
|
||||
.resume_thread(
|
||||
@@ -3624,20 +3673,275 @@ async fn sidebar_reopens_saved_side_chat_after_restart() -> Result<()> {
|
||||
finish_discovery(&mut app, &mut tui, &mut server).await?;
|
||||
capture("Readable reply", side_frame(&mut app, &mut tui, narrow)?);
|
||||
insta::assert_snapshot!("saved_chat_discovery_states", states.join("\n\n"));
|
||||
|
||||
app.pending_server_profiles.insert(
|
||||
parent,
|
||||
PermissionProfileSelection {
|
||||
profile_id: "pending".into(),
|
||||
display_label: "pending".into(),
|
||||
approval_policy: None,
|
||||
approvals_reviewer: None,
|
||||
},
|
||||
);
|
||||
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?;
|
||||
let frame = side_frame(&mut app, &mut tui, size)?;
|
||||
assert_eq!(
|
||||
frame
|
||||
.matches("Wait for permissions to update before switching tasks.")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(parent));
|
||||
app.pending_server_profiles.remove(&parent);
|
||||
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?;
|
||||
}
|
||||
let opening = side_frame(&mut app, &mut tui, size)?;
|
||||
let status = opening
|
||||
.lines()
|
||||
.filter_map(|row| row.split_once('│').map(|(_, pane)| pane.trim()))
|
||||
.find(|row| row.contains("Opening…"));
|
||||
insta::assert_snapshot!(status.expect("opening row"), @"› ◌ Child-only prompt Opening…");
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(parent));
|
||||
// Hold the completed request at the foreground installation boundary. Moving away,
|
||||
// cancelling and reselecting the same child must share that request.
|
||||
let completed = loop {
|
||||
let event = app
|
||||
.saved_openings
|
||||
.next_event(&mut events)
|
||||
.await
|
||||
.expect("child preparation");
|
||||
if matches!(&event, AppEvent::SavedChatPrepared { .. }) {
|
||||
break event;
|
||||
}
|
||||
boxed_future!(app.handle_event(&mut tui, &mut server, event)).await?;
|
||||
};
|
||||
let wait_for_close = async |server: &mut AppServerSession| {
|
||||
while let Some(event) = server.next_event().await {
|
||||
if matches!(&event.event, AppServerEvent::ServerNotification(n)
|
||||
if matches!(n.as_ref(), ServerNotification::ThreadClosed(c) if c.thread_id == child.to_string()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("connection closed before the child");
|
||||
};
|
||||
if matches!(during, DuringOpen::Approval | DuringOpen::Fork) {
|
||||
// Pending approvals replay before the resumed child's history finishes loading.
|
||||
app.handle_app_server_event(
|
||||
&server,
|
||||
AppServerEvent::ServerRequest(Box::new(exec_approval_request(
|
||||
child,
|
||||
"during-open",
|
||||
"saved-child-approval",
|
||||
/*approval_id*/ None,
|
||||
))),
|
||||
)
|
||||
.await;
|
||||
app.surface_pending_inactive_thread_interactive_requests()
|
||||
.await?;
|
||||
assert!(!app.chat_widget.has_active_modal());
|
||||
}
|
||||
if matches!(during, DuringOpen::Fork) {
|
||||
let terminal_size = tui.terminal.size()?;
|
||||
side_frame(&mut app, &mut tui, terminal_size)?;
|
||||
for key in [
|
||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||
KeyCode::Char('c').into(),
|
||||
] {
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key))).await?;
|
||||
}
|
||||
boxed_future!(app.handle_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
AppEvent::ForkCurrentSession { name: None }
|
||||
))
|
||||
.await?;
|
||||
assert_ne!(app.current_displayed_thread_id(), Some(parent));
|
||||
let mut replacement =
|
||||
boxed_future!(crate::start_embedded_app_server_for_picker(&app.config)).await?;
|
||||
boxed_future!(app.handle_event(&mut tui, &mut replacement, completed)).await?;
|
||||
// A reconnect rotates the UI event receiver while the old connection still has a release to do.
|
||||
let (tx, mut new_events) = tokio::sync::mpsc::unbounded_channel();
|
||||
app.app_event_tx = AppEventSender::new(tx);
|
||||
drop(events);
|
||||
let released = app
|
||||
.saved_openings
|
||||
.next_event(&mut new_events)
|
||||
.await
|
||||
.expect("original subscription released");
|
||||
assert!(
|
||||
matches!(released, AppEvent::SavedChatReleased { target, .. } if target.child == child)
|
||||
);
|
||||
boxed_future!(app.handle_event(&mut tui, &mut replacement, released)).await?;
|
||||
replacement.shutdown().await?;
|
||||
wait_for_close(&mut server).await;
|
||||
server.shutdown().await?;
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(during, DuringOpen::Close | DuringOpen::Approval) {
|
||||
use codex_app_server_protocol::ThreadReadState;
|
||||
use codex_app_server_protocol::ThreadReadStateChangedNotification;
|
||||
use codex_app_server_protocol::ThreadUnreadPosition;
|
||||
|
||||
let closed = matches!(during, DuringOpen::Close);
|
||||
if closed {
|
||||
server.thread_unsubscribe(child).await?;
|
||||
wait_for_close(&mut server).await;
|
||||
}
|
||||
let receipt = ThreadReadState {
|
||||
first_unread: Some(ThreadUnreadPosition::ThreadStart),
|
||||
revision: "during-open".into(),
|
||||
};
|
||||
server.remember_read_state(child, Some(receipt.clone()));
|
||||
for notification in [
|
||||
ServerNotification::ThreadReadStateChanged(ThreadReadStateChangedNotification {
|
||||
thread_id: child.to_string(),
|
||||
read_state: receipt,
|
||||
}),
|
||||
thread_closed_notification(child),
|
||||
] {
|
||||
app.handle_app_server_event(
|
||||
&server,
|
||||
AppServerEvent::ServerNotification(Box::new(notification)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
boxed_future!(app.handle_event(&mut tui, &mut server, completed)).await?;
|
||||
while app.current_displayed_thread_id() == Some(parent) {
|
||||
let event = app
|
||||
.saved_openings
|
||||
.next_event(&mut events)
|
||||
.await
|
||||
.expect("verified runtime");
|
||||
boxed_future!(app.handle_event(&mut tui, &mut server, event)).await?;
|
||||
}
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(child));
|
||||
assert_eq!(
|
||||
(
|
||||
app.thread_event_channels[&child].attachment(),
|
||||
app.agent_navigation.get(&child).unwrap().is_closed,
|
||||
server.viewed_revision(child, /*frozen*/ false)
|
||||
),
|
||||
(
|
||||
if closed {
|
||||
ThreadEventAttachment::ReplayOnly
|
||||
} else {
|
||||
ThreadEventAttachment::Live
|
||||
},
|
||||
closed,
|
||||
Some("during-open".into())
|
||||
)
|
||||
);
|
||||
if !closed {
|
||||
assert!(render_bottom_popup(&app.chat_widget, /*width*/ 80).contains("echo hello"));
|
||||
}
|
||||
server.shutdown().await?;
|
||||
return Ok(());
|
||||
}
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into())))
|
||||
.await?;
|
||||
// A legacy terminal reports a held Escape as another press.
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(KeyCode::Esc.into())))
|
||||
.await?;
|
||||
assert_eq!(app.chooser_focus(), Some(sidebar::PaneFocus::Sidebar));
|
||||
boxed_future!(app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
TuiEvent::Key(KeyEvent::new_with_kind(
|
||||
KeyCode::Esc,
|
||||
KeyModifiers::NONE,
|
||||
KeyEventKind::Release
|
||||
))
|
||||
))
|
||||
.await?;
|
||||
assert!(app.sidebar().unwrap().opening.is_none());
|
||||
assert!(app.sidebar().unwrap().chat_tabs().ids.is_empty());
|
||||
for key in [KeyCode::F(5), KeyCode::Char('g')] {
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key.into())))
|
||||
.await?;
|
||||
}
|
||||
server
|
||||
.thread_settings_update(codex_app_server_protocol::ThreadSettingsUpdateParams {
|
||||
thread_id: child.to_string(),
|
||||
approval_policy: Some(AskForApproval::OnRequest),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let settings = next_thread_settings_updated(&mut server, child).await;
|
||||
let live_policy = settings.thread_settings.approval_policy;
|
||||
app.handle_app_server_event(
|
||||
&server,
|
||||
AppServerEvent::ServerNotification(Box::new(ServerNotification::ThreadSettingsUpdated(
|
||||
settings,
|
||||
))),
|
||||
)
|
||||
.await;
|
||||
let notifications = [
|
||||
turn_started_notification(child, "during-open"),
|
||||
agent_message_delta_notification(child, "during-open", "live-item", "first"),
|
||||
agent_message_delta_notification(child, "during-open", "live-item", " second"),
|
||||
];
|
||||
for notification in notifications {
|
||||
app.handle_app_server_event(
|
||||
&server,
|
||||
AppServerEvent::ServerNotification(Box::new(notification)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
boxed_future!(app.handle_event(&mut tui, &mut server, completed)).await?;
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(child));
|
||||
let held_accept = |kind| KeyEvent::new_with_kind(KeyCode::Char('g'), KeyModifiers::NONE, kind);
|
||||
assert!(
|
||||
app.chat_widget
|
||||
.consume_view_transition_key_event(held_accept(KeyEventKind::Repeat))
|
||||
);
|
||||
assert!(
|
||||
app.chat_widget
|
||||
.consume_view_transition_key_event(held_accept(KeyEventKind::Release))
|
||||
);
|
||||
assert!(
|
||||
!app.chat_widget
|
||||
.consume_view_transition_key_event(held_accept(KeyEventKind::Press))
|
||||
);
|
||||
{
|
||||
let store = app.thread_event_channels[&child].store.lock().await;
|
||||
assert_eq!(store.active_turn_id(), Some("during-open"));
|
||||
assert_eq!(store.session.as_ref().unwrap().approval_policy, live_policy);
|
||||
let deltas: Vec<_> = store
|
||||
.buffer
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
ThreadBufferedEvent::Notification(n) => match n.as_ref() {
|
||||
ServerNotification::AgentMessageDelta(d) => Some(d.delta.as_str()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(deltas, ["first second"]);
|
||||
}
|
||||
let completed = turn_completed_notification(child, "during-open", TurnStatus::Completed);
|
||||
app.handle_app_server_event(
|
||||
&server,
|
||||
AppServerEvent::ServerNotification(Box::new(completed)),
|
||||
)
|
||||
.await;
|
||||
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));
|
||||
assert!(app.agent_navigation.get(&child).is_some());
|
||||
let channel = &app.thread_event_channels[&child];
|
||||
assert_eq!(channel.attachment(), ThreadEventAttachment::Live);
|
||||
{
|
||||
@@ -3662,9 +3966,11 @@ async fn sidebar_reopens_saved_side_chat_after_restart() -> Result<()> {
|
||||
))
|
||||
.await?;
|
||||
drain_app_events(&mut app, &mut tui, &mut server, &mut events).await?;
|
||||
// The reopened child participates in keyboard navigation back to its parent.
|
||||
let previous = KeyEvent::new(KeyCode::Left, KeyModifiers::ALT);
|
||||
boxed_future!(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(previous))).await?;
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(parent));
|
||||
// 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())
|
||||
@@ -8704,6 +9010,7 @@ async fn make_test_app() -> Box<App> {
|
||||
pending_server_profiles: HashMap::new(),
|
||||
agents_overview: Default::default(),
|
||||
side_threads: HashMap::new(),
|
||||
saved_openings: Default::default(),
|
||||
sidebars: HashMap::new(),
|
||||
side_companions: HashMap::new(),
|
||||
zoomed_thread: None,
|
||||
@@ -8843,6 +9150,7 @@ pub(super) async fn make_test_app_with_channels() -> (
|
||||
pending_server_profiles: HashMap::new(),
|
||||
agents_overview: Default::default(),
|
||||
side_threads: HashMap::new(),
|
||||
saved_openings: Default::default(),
|
||||
sidebars: HashMap::new(),
|
||||
side_companions: HashMap::new(),
|
||||
zoomed_thread: None,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_chat_retry_and_supersession_with_pending_requests() -> Result<()> {
|
||||
let (mut app, mut events, _ops) = make_test_app_with_channels().await;
|
||||
let parent = ThreadId::new();
|
||||
let children = std::array::from_fn::<_, 4, _>(|_| ThreadId::new());
|
||||
app.chat_widget
|
||||
.handle_thread_session(test_thread_session(parent, app.config.cwd.to_path_buf()));
|
||||
app.chat_widget.replay_thread_turns(
|
||||
vec![test_turn("parent-turn", TurnStatus::Completed, Vec::new())],
|
||||
crate::chatwidget::ReplayKind::ResumeInitialMessages,
|
||||
);
|
||||
while events.try_recv().is_ok() {}
|
||||
app.active_thread_id = Some(parent);
|
||||
app.primary_thread_id = Some(parent);
|
||||
app.sidebar_mut().show(
|
||||
sidebar::SidebarContent::Chooser,
|
||||
sidebar::PaneFocus::Sidebar,
|
||||
);
|
||||
app.sidebar_mut()
|
||||
.update_saved_chats(children.map(Into::into).into());
|
||||
enable_side_retention(&mut app)?;
|
||||
app.local_settings.tui.animations = false;
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let endpoint = crate::resolve_remote_addr(&format!("ws://{}", listener.local_addr()?))?;
|
||||
let (requests, mut held) = tokio::sync::mpsc::unbounded_channel();
|
||||
let proxy = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
disconnect::serve_reconnect_requests(tokio_tungstenite::accept_async(stream).await?, |request| {
|
||||
assert_eq!(request.method, "thread/read");
|
||||
let (release, wait) = tokio::sync::oneshot::channel();
|
||||
requests.send((request.params.unwrap()["threadId"].clone(), release)).unwrap();
|
||||
async move {
|
||||
wait.await.expect("release the read");
|
||||
Some(serde_json::json!({"error":{"code":-32000,"message":"thread not loaded: offline"}}))
|
||||
}
|
||||
}).await
|
||||
});
|
||||
let mut server = AppServerSession::new(
|
||||
crate::backend_id::BackendId::new(),
|
||||
crate::connect_remote_app_server(endpoint).await?,
|
||||
crate::app_server_session::ThreadParamsMode::Remote,
|
||||
);
|
||||
let backend_id = server.backend_id();
|
||||
let target = |child| sidebar_loading::Target {
|
||||
backend_id,
|
||||
parent,
|
||||
child,
|
||||
};
|
||||
// The first two reads can run. Only the most recent extra destination runs afterwards.
|
||||
app.open_saved_chat(&mut tui, &mut server, target(children[0]))
|
||||
.await?;
|
||||
let mut first = Some(held.recv().await.expect("first held read"));
|
||||
for child in children {
|
||||
app.open_saved_chat(&mut tui, &mut server, target(child))
|
||||
.await?;
|
||||
}
|
||||
let size = Size::new(/*width*/ 208, /*height*/ 16);
|
||||
let sidebar_rows = |frame: String| {
|
||||
frame
|
||||
.lines()
|
||||
.filter_map(|line| line.split_once('│'))
|
||||
.map(|(_, sidebar)| sidebar.trim_end())
|
||||
.filter(|row| !row.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
app.sidebar_mut().choice = 4;
|
||||
let waiting = sidebar_rows(side_frame(&mut app, &mut tui, size)?);
|
||||
let [first_child, second_child, _, latest] = children;
|
||||
for (index, expected) in [first_child, second_child, latest, latest, latest, latest]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let (id, release) = match first.take() {
|
||||
Some(first) => first,
|
||||
None => held.recv().await.expect("held request"),
|
||||
};
|
||||
assert_eq!(id, serde_json::json!(expected));
|
||||
if index == 3 {
|
||||
// A handoff revokes the intent even if the destination has the same native IDs.
|
||||
app.saved_openings.abandon();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
app.app_event_tx = AppEventSender::new(tx);
|
||||
events = rx;
|
||||
} else if index == 4 {
|
||||
// Switching away and back cannot resurrect the original parent's intent.
|
||||
app.store_active_thread_receiver().await;
|
||||
} else if index == 5 {
|
||||
// An archive from another client wins over a read that is already in flight.
|
||||
boxed_future!(app.handle_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
AppEvent::SideChatArchived {
|
||||
backend_id,
|
||||
target: expected,
|
||||
}
|
||||
))
|
||||
.await?;
|
||||
assert!(
|
||||
app.sidebar()
|
||||
.unwrap()
|
||||
.chat_tabs()
|
||||
.choice_index(expected)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
release.send(()).unwrap();
|
||||
loop {
|
||||
let event = app
|
||||
.saved_openings
|
||||
.next_event(&mut events)
|
||||
.await
|
||||
.expect("preparation result");
|
||||
let finished = matches!(&event, AppEvent::SavedChatPrepared { target, .. } if target.child == expected);
|
||||
boxed_future!(app.handle_event(&mut tui, &mut server, event)).await?;
|
||||
if finished {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if index == 0 {
|
||||
let event = app
|
||||
.saved_openings
|
||||
.next_event(&mut events)
|
||||
.await
|
||||
.expect("queued destination");
|
||||
assert!(matches!(event, AppEvent::StartQueuedSavedChat));
|
||||
boxed_future!(app.handle_event(&mut tui, &mut server, event)).await?;
|
||||
}
|
||||
if index == 2 {
|
||||
let retry = sidebar_rows(side_frame(&mut app, &mut tui, size)?);
|
||||
insta::assert_snapshot!(
|
||||
"waiting_and_retry_rows",
|
||||
format!("Waiting\n{waiting}\n\nRetry\n{retry}")
|
||||
);
|
||||
app.sidebar_mut().choice = 4;
|
||||
boxed_future!(app.handle_tui_event(
|
||||
&mut tui,
|
||||
&mut server,
|
||||
TuiEvent::Key(KeyCode::Enter.into())
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
if matches!(index, 3 | 4) {
|
||||
assert!(app.sidebar().unwrap().opening.is_none());
|
||||
app.open_saved_chat(&mut tui, &mut server, target(expected))
|
||||
.await?;
|
||||
}
|
||||
assert_eq!(app.current_displayed_thread_id(), Some(parent));
|
||||
}
|
||||
assert!(app.sidebar().unwrap().opening.is_none());
|
||||
assert!(app.sidebar().unwrap().chat_tabs().ids.is_empty());
|
||||
server.shutdown().await?;
|
||||
proxy.await??;
|
||||
Ok(())
|
||||
}
|
||||
@@ -2422,7 +2422,8 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths_sc
|
||||
resumed["config"]["mcp_servers.codex_tui"],
|
||||
starts[0]["config"]["mcp_servers.codex_tui"]
|
||||
);
|
||||
Box::pin(app_server.resume_thread(
|
||||
let mut attachment = app_server.attachment_view(delegation_source);
|
||||
Box::pin(attachment.resume_thread(
|
||||
&app.local_settings,
|
||||
app.config.clone(),
|
||||
delegation_source,
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
source: tui/src/app/tests/saved_chat_loading_tests.rs
|
||||
expression: "format!(\"Waiting\\n{waiting}\\n\\nRetry\\n{retry}\")"
|
||||
---
|
||||
Waiting
|
||||
+
|
||||
Open in sidebar
|
||||
+ New side chat Start from the main chat
|
||||
Side chats 4
|
||||
○ Side chat 1
|
||||
○ Side chat 2
|
||||
○ Side chat 3
|
||||
› ○ Side chat 4 Waiting to open…
|
||||
enter open · esc cancel opening · ↑/↓ move · ⌃x - hide pane
|
||||
|
||||
Retry
|
||||
+
|
||||
Open in sidebar
|
||||
+ New side chat Start from the main chat
|
||||
Side chats 4
|
||||
○ Side chat 1
|
||||
○ Side chat 2
|
||||
○ Side chat 3
|
||||
› ○ Side chat 4 Retry · thread not loaded: offline
|
||||
enter retry · esc back · ↑/↓ move · ⌃x - hide pane
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Prepare the live session or replay fallback before transferring the local thread view.
|
||||
|
||||
use super::*;
|
||||
use crate::app_server_session::ResumeModelSettings;
|
||||
use crate::backend_id::BackendId;
|
||||
use codex_app_server_protocol::Thread;
|
||||
use codex_app_server_protocol::ThreadStatus;
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Target {
|
||||
pub(crate) backend_id: BackendId,
|
||||
pub(super) parent: ThreadId,
|
||||
pub(crate) child: ThreadId,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
pub(super) fn key(self) -> (BackendId, ThreadId) {
|
||||
(self.backend_id, self.child)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum PreparedAttachment {
|
||||
Live(Box<AppServerStartedThread>),
|
||||
ReplayOnly(Box<Thread>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum SelectionMode {
|
||||
Live,
|
||||
Closed,
|
||||
ReplayOnly,
|
||||
}
|
||||
|
||||
pub(super) struct Selection {
|
||||
pub(super) mode: SelectionMode,
|
||||
pub(super) cached_session: Option<ThreadSessionState>,
|
||||
pub(super) pending_profile_on_unavailable_thread: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Prepared {
|
||||
pub(super) result: Result<PreparedAttachment, String>,
|
||||
pub(super) metadata: crate::app_server_session::AttachmentMetadata,
|
||||
pub(super) close_check: Result<bool, String>,
|
||||
}
|
||||
|
||||
/// A close identifies a thread, but multiple runtimes can have used that thread ID.
|
||||
#[derive(Default, PartialEq, Eq)]
|
||||
pub(super) enum CloseState {
|
||||
#[default]
|
||||
Open,
|
||||
Reported,
|
||||
Checking,
|
||||
Closed,
|
||||
}
|
||||
|
||||
/// Live notifications received after an attachment request began. They take precedence
|
||||
/// over the earlier thread snapshot when the subscription is installed.
|
||||
#[derive(Default)]
|
||||
pub(super) struct SubscriptionUpdates {
|
||||
pub(super) settings: Option<codex_app_server_protocol::ThreadSettings>,
|
||||
pub(super) turn_changed: bool,
|
||||
pub(super) read_state_changed: bool,
|
||||
pub(super) close: CloseState,
|
||||
}
|
||||
|
||||
/// Choose whether to replace a live channel or install into the one opened for this attachment.
|
||||
pub(super) enum ChannelMode<'a> {
|
||||
ReplaceLive,
|
||||
Reuse(&'a SubscriptionUpdates),
|
||||
}
|
||||
|
||||
/// Owns the input buffer and unsubscribe request for one attachment attempt.
|
||||
pub(super) struct Incoming {
|
||||
pub(super) received: SubscriptionUpdates,
|
||||
created_channel: bool,
|
||||
store: Arc<Mutex<ThreadEventStore>>,
|
||||
release: BoxFuture<'static, Result<()>>,
|
||||
}
|
||||
|
||||
impl Incoming {
|
||||
pub(super) async fn begin(
|
||||
app: &mut App,
|
||||
server: &mut AppServerSession,
|
||||
target: Target,
|
||||
) -> Self {
|
||||
let created_channel = !app.thread_event_channels.contains_key(&target.child);
|
||||
let channel = app.ensure_thread_channel(target.child);
|
||||
channel.backend_id = Some(target.backend_id);
|
||||
let store = channel.store.clone();
|
||||
{
|
||||
let mut events = store.lock().await;
|
||||
events.attachment_prefix = Some(events.buffer.len());
|
||||
}
|
||||
Self {
|
||||
received: SubscriptionUpdates::default(),
|
||||
created_channel,
|
||||
store,
|
||||
release: Box::pin(server.thread_unsubscribe(target.child)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn uses_channel(&self, channel: &ThreadEventChannel) -> bool {
|
||||
Arc::ptr_eq(&channel.store, &self.store)
|
||||
}
|
||||
|
||||
pub(super) fn owns_channel(
|
||||
&self,
|
||||
channel: Option<&ThreadEventChannel>,
|
||||
owner: BackendId,
|
||||
) -> bool {
|
||||
channel
|
||||
.is_some_and(|channel| channel.backend_id == Some(owner) && self.uses_channel(channel))
|
||||
}
|
||||
|
||||
/// Drop our provisional channel and return the release for an unclaimed live subscription.
|
||||
pub(super) async fn discard(
|
||||
self,
|
||||
app: &mut App,
|
||||
target: Target,
|
||||
subscribed: bool,
|
||||
) -> Option<BoxFuture<'static, Result<()>>> {
|
||||
let child = target.child;
|
||||
let same_channel =
|
||||
self.owns_channel(app.thread_event_channels.get(&child), target.backend_id);
|
||||
self.store.lock().await.attachment_prefix = None;
|
||||
let claimed = app
|
||||
.thread_event_channels
|
||||
.get(&child)
|
||||
.is_some_and(|channel| {
|
||||
channel
|
||||
.backend_id
|
||||
.is_none_or(|owner| owner == target.backend_id)
|
||||
&& (!self.uses_channel(channel)
|
||||
|| app.active_thread_id == Some(child)
|
||||
|| app.side_threads.contains_key(&child))
|
||||
});
|
||||
let release =
|
||||
subscribed && !claimed && !app.agents_overview.dispatched_requests.contains_key(&child);
|
||||
if same_channel && self.created_channel && !claimed {
|
||||
// A later resume can replay the same still-pending approval after we give up this buffer.
|
||||
for request in self.store.lock().await.pending_replay_requests() {
|
||||
app.pending_app_server_requests
|
||||
.resolve_notification(&child.to_string(), request.id());
|
||||
}
|
||||
app.thread_event_channels.remove(&child);
|
||||
}
|
||||
release.then_some(self.release)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn prepare_saved(
|
||||
mut view: AppServerSession,
|
||||
settings: &crate::local_settings::LocalSettings,
|
||||
config: &Config,
|
||||
child: ThreadId,
|
||||
) -> Prepared {
|
||||
let result = async {
|
||||
let mode = match view.thread_read(child, /*include_turns*/ false).await {
|
||||
Ok(thread) if thread.status == ThreadStatus::NotLoaded => {
|
||||
ResumeModelSettings::PreserveSideConversation
|
||||
}
|
||||
Err(error) if App::is_terminal_thread_read_error(&error) => {
|
||||
return Err(error);
|
||||
}
|
||||
_ => ResumeModelSettings::PreserveExistingThread,
|
||||
};
|
||||
prepare(&mut view, settings, config, child, mode).await
|
||||
}
|
||||
.await
|
||||
.map_err(
|
||||
|err| match err.downcast_ref::<codex_app_server_client::TypedRequestError>() {
|
||||
Some(codex_app_server_client::TypedRequestError::Server { source, .. }) => {
|
||||
source.message.clone()
|
||||
}
|
||||
_ => err.to_string(),
|
||||
},
|
||||
);
|
||||
let metadata = view.take_attachment_metadata(child);
|
||||
Prepared {
|
||||
result,
|
||||
metadata,
|
||||
close_check: Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn prepare(
|
||||
app_server: &mut AppServerSession,
|
||||
local_settings: &crate::local_settings::LocalSettings,
|
||||
config: &Config,
|
||||
thread_id: ThreadId,
|
||||
resume_settings: ResumeModelSettings,
|
||||
) -> Result<PreparedAttachment> {
|
||||
let mut resume_config = config.clone();
|
||||
if resume_settings == ResumeModelSettings::PreserveSideConversation {
|
||||
resume_config.developer_instructions = Some(
|
||||
codex_context_fragments::SideConversationPolicy::Attached
|
||||
.instructions(resume_config.developer_instructions.as_deref()),
|
||||
);
|
||||
}
|
||||
match app_server
|
||||
.resume_thread(local_settings, resume_config, thread_id, resume_settings)
|
||||
.await
|
||||
{
|
||||
Ok(started) => Ok(PreparedAttachment::Live(Box::new(started))),
|
||||
// Once subscribed, a second unanchored read could overlap live events.
|
||||
Err(err) if app_server.attachment_subscribed == Some(true) => Err(err),
|
||||
Err(resume_err) => {
|
||||
tracing::warn!(
|
||||
thread_id = %thread_id,
|
||||
error = %resume_err,
|
||||
"failed to resume live thread for selection; falling back to thread/read"
|
||||
);
|
||||
let mut thread = app_server
|
||||
.thread_read(thread_id, /*include_turns*/ false)
|
||||
.await?;
|
||||
if thread.ephemeral {
|
||||
return Err(color_eyre::eyre::eyre!(
|
||||
"Agent thread {thread_id} is not yet available for replay or live attach."
|
||||
));
|
||||
}
|
||||
match app_server
|
||||
.hydrate_initial_thread_history(
|
||||
&mut thread,
|
||||
/*turn_cursor*/ None,
|
||||
/*item_cursor*/ None,
|
||||
Some(config),
|
||||
Some(local_settings),
|
||||
crate::app_server_session::HistoryHydrationScope::Initial,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(err) if App::can_fallback_from_include_turns_error(&err) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
if thread.turns.is_empty() {
|
||||
// A `thread/read` fallback without turns would create a blank local replay
|
||||
// channel with no live listener attached, which blocks later real re-attach.
|
||||
return Err(color_eyre::eyre::eyre!(
|
||||
"Agent thread {thread_id} is not yet available for replay or live attach."
|
||||
));
|
||||
}
|
||||
Ok(PreparedAttachment::ReplayOnly(Box::new(thread)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Install the anchored snapshot before transferring either conversation's input and transcript.
|
||||
pub(super) async fn install_saved_chat(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
server: &mut AppServerSession,
|
||||
target: Target,
|
||||
attachment: PreparedAttachment,
|
||||
metadata: crate::app_server_session::AttachmentMetadata,
|
||||
updates: &SubscriptionUpdates,
|
||||
) -> Result<()> {
|
||||
use crate::app_server_session::SideChatStorage;
|
||||
debug_assert_eq!(server.backend_id(), target.backend_id);
|
||||
let Target { parent, child, .. } = target;
|
||||
server.install_attachment_metadata(child, metadata, updates.read_state_changed);
|
||||
self.side_threads.insert(
|
||||
child,
|
||||
side::SideThreadState {
|
||||
storage: SideChatStorage::Attached,
|
||||
..side::SideThreadState::new(parent)
|
||||
},
|
||||
);
|
||||
let is_closed = updates.close == CloseState::Closed
|
||||
|| matches!(&attachment, PreparedAttachment::ReplayOnly(thread) if thread.status == ThreadStatus::NotLoaded);
|
||||
let live = self
|
||||
.install_thread_attachment(server, child, attachment, ChannelMode::Reuse(updates))
|
||||
.await;
|
||||
if self.agent_navigation.get(&child).is_none() {
|
||||
self.upsert_agent_picker_thread(
|
||||
child, /*agent_nickname*/ None, /*agent_role*/ None, is_closed,
|
||||
);
|
||||
self.sync_agent_picker_thread(child);
|
||||
} else if is_closed {
|
||||
self.mark_agent_picker_thread_closed(child);
|
||||
}
|
||||
self.commit_thread_selection(
|
||||
tui,
|
||||
server,
|
||||
child,
|
||||
Selection {
|
||||
mode: if live {
|
||||
SelectionMode::Live
|
||||
} else {
|
||||
SelectionMode::ReplayOnly
|
||||
},
|
||||
cached_session: None,
|
||||
pending_profile_on_unavailable_thread: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ impl ThreadEventStore {
|
||||
}
|
||||
|
||||
if let ServerNotification::AgentMessageDelta(delta) = notification.as_ref()
|
||||
&& self.attachment_prefix != Some(self.buffer.len())
|
||||
&& let Some(ThreadBufferedEvent::Notification(previous)) = self.buffer.back_mut()
|
||||
&& let ServerNotification::AgentMessageDelta(previous) = previous.as_mut()
|
||||
&& previous.thread_id == delta.thread_id
|
||||
@@ -58,6 +59,9 @@ impl ThreadEventStore {
|
||||
let Some(removed) = self.buffer.pop_front() else {
|
||||
break;
|
||||
};
|
||||
if let Some(prefix) = &mut self.attachment_prefix {
|
||||
*prefix = prefix.saturating_sub(1);
|
||||
}
|
||||
match removed {
|
||||
ThreadBufferedEvent::Notification(notification) => {
|
||||
if let ServerNotification::AgentMessageDelta(delta) = notification.as_ref() {
|
||||
|
||||
@@ -151,6 +151,20 @@ fn thread_event_store_coalesces_only_adjacent_matching_agent_message_deltas() {
|
||||
);
|
||||
assert!(!store.has_pending_thread_approvals());
|
||||
|
||||
// A refreshed snapshot already includes earlier deltas. Later deltas for the same item
|
||||
// must still replay, even if buffering the live turn evicts an event before the boundary.
|
||||
let mut installing = ThreadEventStore::new(/*capacity*/ 2);
|
||||
installing.push_notification(delta("old", "old", "evicted"));
|
||||
installing.push_notification(delta("turn", "item", "already in snapshot"));
|
||||
installing.attachment_prefix = Some(installing.buffer.len());
|
||||
installing.push_notification(delta("turn", "item", "during"));
|
||||
installing.push_notification(delta("turn", "item", " install"));
|
||||
installing.rebase_buffer_after_session_refresh();
|
||||
assert_eq!(
|
||||
snapshot_events(&installing),
|
||||
vec![serde_json::to_value(delta("turn", "item", "during install")).unwrap()]
|
||||
);
|
||||
|
||||
let mut store = ThreadEventStore::new(/*capacity*/ THREAD_EVENT_CHANNEL_CAPACITY);
|
||||
store.push_request(exec_approval_request(
|
||||
thread_id,
|
||||
|
||||
@@ -99,6 +99,7 @@ pub(super) struct ThreadEventStore {
|
||||
pub(super) session: Option<ThreadSessionState>,
|
||||
pub(super) turns: Vec<Turn>,
|
||||
pub(super) buffer: VecDeque<ThreadBufferedEvent>,
|
||||
pub(super) attachment_prefix: Option<usize>,
|
||||
pub(super) pending_interactive_replay: PendingInteractiveReplayState,
|
||||
pub(super) active_turn_id: Option<String>,
|
||||
// Replay still needs the current turn's start after it falls out of the event buffer.
|
||||
@@ -150,6 +151,7 @@ impl ThreadEventStore {
|
||||
session: None,
|
||||
turns: Vec::new(),
|
||||
buffer: VecDeque::new(),
|
||||
attachment_prefix: None,
|
||||
pending_interactive_replay: PendingInteractiveReplayState::default(),
|
||||
active_turn_id: None,
|
||||
evicted_active_turn: None,
|
||||
@@ -184,8 +186,15 @@ impl ThreadEventStore {
|
||||
}
|
||||
|
||||
pub(super) fn rebase_buffer_after_session_refresh(&mut self) {
|
||||
let tail = self
|
||||
.attachment_prefix
|
||||
.take()
|
||||
.map(|prefix| self.buffer.split_off(prefix.min(self.buffer.len())));
|
||||
self.buffer.retain_mut(Self::event_survives_session_refresh);
|
||||
self.buffered_agent_message_delta_bytes = 0;
|
||||
for event in tail.into_iter().flatten() {
|
||||
self.push_buffered_event(event);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_turns(&mut self, turns: Vec<Turn>) {
|
||||
@@ -659,6 +668,10 @@ impl ThreadEventChannel {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mark_live(&mut self) {
|
||||
self.attachment = ThreadEventAttachment::Live;
|
||||
}
|
||||
|
||||
pub(super) fn mark_replay_only(&mut self) {
|
||||
self.attachment = ThreadEventAttachment::ReplayOnly;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ impl App {
|
||||
let Some(active_id) = self.active_thread_id else {
|
||||
return;
|
||||
};
|
||||
if let Some(pane) = self.sidebars.get_mut(&Some(active_id)) {
|
||||
pane.opening = None;
|
||||
}
|
||||
let input_state = self.chat_widget.capture_thread_input_state();
|
||||
let recap_progress = self.recap.progress();
|
||||
if let Some(channel) = self.thread_event_channels.get_mut(&active_id) {
|
||||
@@ -158,7 +161,12 @@ impl App {
|
||||
.keys()
|
||||
.copied()
|
||||
.chain(self.agent_navigation.tracked_thread_ids())
|
||||
.filter(|id| !self.side_threads.contains_key(id))
|
||||
.filter(|id| {
|
||||
!self.side_threads.contains_key(id)
|
||||
&& !self
|
||||
.saved_openings
|
||||
.preparing(*id, self.thread_event_channels.get(id))
|
||||
})
|
||||
{
|
||||
self.agents_overview
|
||||
.dispatched_requests
|
||||
@@ -304,6 +312,12 @@ impl App {
|
||||
thread_id: ThreadId,
|
||||
request: &ServerRequest,
|
||||
) -> std::io::Result<Option<ThreadInteractiveRequest>> {
|
||||
if self
|
||||
.saved_openings
|
||||
.preparing(thread_id, self.thread_event_channels.get(&thread_id))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let thread_label = Some(self.thread_label(thread_id));
|
||||
Ok(match request {
|
||||
ServerRequest::CommandExecutionRequestApproval { params, .. } => {
|
||||
@@ -1032,7 +1046,11 @@ impl App {
|
||||
continue;
|
||||
}
|
||||
|
||||
if store.lock().await.has_pending_thread_approvals() {
|
||||
let pending = {
|
||||
let store = store.lock().await;
|
||||
store.attachment_prefix.is_none() && store.has_pending_thread_approvals()
|
||||
};
|
||||
if pending {
|
||||
pending_thread_ids.insert(thread_id);
|
||||
// Approval requests can arrive before the thread's metadata.
|
||||
if self.agent_navigation.get(&thread_id).is_none() {
|
||||
@@ -1164,7 +1182,10 @@ impl App {
|
||||
// Lifecycle responses already contain authoritative session state. Their rollout may
|
||||
// not exist until the first turn, so inferring it again can wait through reader retries.
|
||||
let already_has_session = match self.thread_event_channels.get(&thread_id) {
|
||||
Some(channel) => channel.store.lock().await.session.is_some(),
|
||||
Some(channel) => {
|
||||
let store = channel.store.lock().await;
|
||||
store.session.is_some() || store.attachment_prefix.is_some()
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
|
||||
@@ -256,7 +256,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: &ThreadSettings) {
|
||||
pub(super) fn apply_thread_settings_to_session(
|
||||
session: &mut ThreadSessionState,
|
||||
settings: &ThreadSettings,
|
||||
) {
|
||||
if settings.collaboration_mode.mode == ModeKind::Default {
|
||||
session.model = settings.model.clone();
|
||||
session.reasoning_effort = settings.effort.clone();
|
||||
|
||||
@@ -505,6 +505,16 @@ pub(crate) enum AppEvent {
|
||||
target: ThreadId,
|
||||
},
|
||||
|
||||
SavedChatPrepared {
|
||||
target: crate::app::sidebar_loading::Target,
|
||||
request: uuid::Uuid,
|
||||
result: Box<crate::app::thread_attachment::Prepared>,
|
||||
},
|
||||
SavedChatReleased {
|
||||
target: crate::app::sidebar_loading::Target,
|
||||
request: uuid::Uuid,
|
||||
},
|
||||
StartQueuedSavedChat,
|
||||
SavedSideChatsLoaded {
|
||||
backend_id: crate::backend_id::BackendId,
|
||||
parent: ThreadId,
|
||||
|
||||
@@ -364,6 +364,15 @@ pub(crate) struct AppServerBootstrap {
|
||||
pub(crate) allowed_approval_policies: Option<Vec<AskForApproval>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AttachmentMetadata {
|
||||
pagination: Option<history::ThreadHistoryPagination>,
|
||||
unread_revision: Option<String>,
|
||||
history_support: ThreadHistorySupport,
|
||||
task_tools: bool,
|
||||
pub(crate) subscribed: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct AppServerSession {
|
||||
backend_id: crate::backend_id::BackendId,
|
||||
/// Owns events and the connection lifetime for live sessions. Request-only views share the
|
||||
@@ -376,6 +385,7 @@ pub(crate) struct AppServerSession {
|
||||
/// The newest docked replay refresh on this connection.
|
||||
pub(crate) pending_side_skills_refresh: Option<Uuid>,
|
||||
history_pagination: HashMap<ThreadId, history::ThreadHistoryPagination>,
|
||||
pub(crate) attachment_subscribed: Option<bool>,
|
||||
pub(crate) focused_conversation: Option<ThreadId>,
|
||||
pub(crate) attempted_revision: Option<String>,
|
||||
pub(crate) frozen_receipt: Option<(ThreadId, String)>,
|
||||
@@ -594,6 +604,7 @@ impl AppServerSession {
|
||||
next_request_id: 1,
|
||||
automatic_name_writes: HashMap::new(),
|
||||
pending_side_skills_refresh: None,
|
||||
attachment_subscribed: None,
|
||||
history_pagination: HashMap::new(),
|
||||
focused_conversation: None,
|
||||
attempted_revision: None,
|
||||
@@ -618,6 +629,56 @@ impl AppServerSession {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attachment_view(&self, id: ThreadId) -> Self {
|
||||
let mut view = self
|
||||
.request_view()
|
||||
.with_thread_tool_transport(self.thread_tool_transport());
|
||||
view.attachment_subscribed = Some(false);
|
||||
view.task_tool_capabilities_dir = self.task_tool_capabilities_dir.clone();
|
||||
if self.task_tool_threads.contains(&id) {
|
||||
view.task_tool_threads.insert(id);
|
||||
}
|
||||
if let Some(page) = self.history_pagination.get(&id) {
|
||||
view.history_pagination.insert(id, page.clone());
|
||||
}
|
||||
view
|
||||
}
|
||||
|
||||
pub(crate) fn take_attachment_metadata(&mut self, id: ThreadId) -> AttachmentMetadata {
|
||||
AttachmentMetadata {
|
||||
pagination: self.history_pagination.remove(&id),
|
||||
unread_revision: self.unread_revisions.remove(&id),
|
||||
history_support: self.history_support,
|
||||
task_tools: self.task_tools_available(id),
|
||||
subscribed: self.attachment_subscribed == Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn install_attachment_metadata(
|
||||
&mut self,
|
||||
id: ThreadId,
|
||||
metadata: AttachmentMetadata,
|
||||
observed_receipt: bool,
|
||||
) {
|
||||
if let Some(page) = metadata.pagination {
|
||||
self.history_pagination.insert(id, page);
|
||||
} else {
|
||||
self.history_pagination.remove(&id);
|
||||
}
|
||||
if metadata.history_support == ThreadHistorySupport::LegacyOnly {
|
||||
self.history_support = ThreadHistorySupport::LegacyOnly;
|
||||
}
|
||||
if metadata.task_tools {
|
||||
self.remember_task_tool_thread(id);
|
||||
}
|
||||
if !observed_receipt {
|
||||
self.unread_revisions.remove(&id);
|
||||
if let Some(revision) = metadata.unread_revision {
|
||||
self.unread_revisions.insert(id, revision);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn backend_id(&self) -> crate::backend_id::BackendId {
|
||||
self.backend_id
|
||||
}
|
||||
@@ -782,7 +843,7 @@ impl AppServerSession {
|
||||
}
|
||||
|
||||
pub(crate) fn uses_embedded_app_server(&self) -> bool {
|
||||
matches!(&self.connection_owner, Some(AppServerClient::InProcess(_)))
|
||||
matches!(&self.request_handle, AppServerRequestHandle::InProcess(_))
|
||||
}
|
||||
|
||||
/// Carry capabilities that may exist only in memory when the optional cache is unwritable.
|
||||
@@ -2022,19 +2083,24 @@ impl AppServerSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_unsubscribe(&mut self, thread_id: ThreadId) -> Result<()> {
|
||||
pub(crate) fn thread_unsubscribe(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
) -> impl Future<Output = Result<()>> + use<> {
|
||||
let request_id = self.next_request_id();
|
||||
let _: ThreadUnsubscribeResponse = self
|
||||
.request_handle
|
||||
.request_typed(ClientRequest::ThreadUnsubscribe {
|
||||
request_id,
|
||||
params: ThreadUnsubscribeParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("thread/unsubscribe failed in TUI")?;
|
||||
Ok(())
|
||||
let handle = self.request_handle();
|
||||
async move {
|
||||
let _: ThreadUnsubscribeResponse = handle
|
||||
.request_typed(ClientRequest::ThreadUnsubscribe {
|
||||
request_id,
|
||||
params: ThreadUnsubscribeParams {
|
||||
thread_id: thread_id.to_string(),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.wrap_err("thread/unsubscribe failed in TUI")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_compact_start(&mut self, thread_id: ThreadId) -> Result<()> {
|
||||
|
||||
@@ -376,13 +376,21 @@ impl AppServerSession {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let page = self
|
||||
.thread_turns_page(thread_id, turn_cursor, INITIAL_HISTORY_TURN_LIMIT)
|
||||
.await?;
|
||||
thread.turns = page.data.into_iter().rev().collect();
|
||||
// A missing cursor in a paginated saved-chat resume means this stream was
|
||||
// empty when subscribed. An unanchored page would also contain later live events.
|
||||
let anchored = self.attachment_subscribed == Some(true);
|
||||
let next_turn_cursor = if !anchored || turn_cursor.is_some() {
|
||||
let page = self
|
||||
.thread_turns_page(thread_id, turn_cursor, INITIAL_HISTORY_TURN_LIMIT)
|
||||
.await?;
|
||||
thread.turns = page.data.into_iter().rev().collect();
|
||||
page.next_cursor
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut state = ThreadHistoryPagination {
|
||||
history_mode: ThreadHistoryMode::Paginated,
|
||||
next_turn_cursor: page.next_cursor,
|
||||
next_turn_cursor,
|
||||
next_item_cursor: item_cursor,
|
||||
..ThreadHistoryPagination::default()
|
||||
};
|
||||
@@ -392,6 +400,10 @@ impl AppServerSession {
|
||||
let mut scanned_items = 0;
|
||||
let mut rendered_rows = 0;
|
||||
while let Some(limit) = budget.next_page_size(rendered_rows, scanned_items) {
|
||||
if anchored && state.next_item_cursor.is_none() {
|
||||
break;
|
||||
}
|
||||
|
||||
let page = self
|
||||
.thread_items_page(
|
||||
thread_id,
|
||||
|
||||
@@ -246,6 +246,9 @@ impl AppServerSession {
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Some(subscribed) = &mut self.attachment_subscribed {
|
||||
*subscribed = true;
|
||||
}
|
||||
self.hydrate_initial_thread_history(
|
||||
&mut response.thread,
|
||||
response.turns_backwards_cursor.clone(),
|
||||
|
||||
@@ -309,7 +309,7 @@ pub(crate) struct BottomPane {
|
||||
warnings_view: Option<warnings_view::WarningsView>,
|
||||
/// A keep press can close the viewer; its remaining repeats must not edit the draft.
|
||||
pub(crate) suppress_warning_keep_repeat: bool,
|
||||
view_transition_key_guard: Option<crate::activation_key_guard::ActivationKeyGuard>,
|
||||
pub(crate) view_transition_key_guard: Option<crate::activation_key_guard::ActivationKeyGuard>,
|
||||
pub(crate) questions: Option<Box<AsyncQuestions>>,
|
||||
delayed_approval_requests: VecDeque<DelayedApprovalRequest>,
|
||||
last_composer_activity_at: Option<Instant>,
|
||||
|
||||
@@ -105,6 +105,13 @@ impl ChatWidget {
|
||||
.suppress_current_view_key_until_release(key, activated_at, suppression);
|
||||
}
|
||||
|
||||
/// Held keys belong to the terminal input stream across conversation switches.
|
||||
pub(crate) fn inherit_view_transition_key_guard(&mut self, previous: &mut Self) {
|
||||
if let Some(guard) = previous.bottom_pane.view_transition_key_guard.take() {
|
||||
self.bottom_pane.view_transition_key_guard = Some(guard);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn consume_view_transition_key_event(&mut self, key_event: KeyEvent) -> bool {
|
||||
self.bottom_pane
|
||||
.consume_view_transition_key_event(key_event)
|
||||
|
||||
@@ -88,14 +88,30 @@ pub(crate) fn footer(
|
||||
let (hints, actions): (Vec<_>, Vec<_>) = [
|
||||
(
|
||||
hint(ListAction::Accept).filter(|_| new_chat == NewChatState::Available),
|
||||
if tabs.retry_row(choice) {
|
||||
if tabs.retry_row(choice)
|
||||
|| tabs.opening.is_some_and(|o| {
|
||||
Some(o.target.child) == tabs.choice(choice)
|
||||
&& matches!(o.state, OpenState::Failed(_))
|
||||
})
|
||||
{
|
||||
"retry"
|
||||
} else {
|
||||
"open"
|
||||
},
|
||||
Some(ChooserAction::Open(choice)),
|
||||
),
|
||||
(hint(ListAction::Cancel), "back", Some(ChooserAction::Back)),
|
||||
(
|
||||
hint(ListAction::Cancel),
|
||||
if tabs
|
||||
.opening
|
||||
.is_some_and(|o| !matches!(o.state, OpenState::Failed(_)))
|
||||
{
|
||||
"cancel opening"
|
||||
} else {
|
||||
"back"
|
||||
},
|
||||
Some(ChooserAction::Back),
|
||||
),
|
||||
((!movement.is_empty()).then_some(movement), "move", None),
|
||||
(hide, "hide pane", Some(ChooserAction::Hide)),
|
||||
]
|
||||
@@ -140,9 +156,21 @@ impl Discovery {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Opening {
|
||||
pub(crate) target: crate::app::thread_attachment::Target,
|
||||
pub(crate) state: OpenState,
|
||||
}
|
||||
|
||||
pub(crate) enum OpenState {
|
||||
Waiting,
|
||||
Loading(std::time::Instant),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ChatTabs<'a> {
|
||||
pub(crate) discovery: &'a Discovery,
|
||||
pub(crate) opening: Option<&'a Opening>,
|
||||
pub(crate) ids: &'a [ThreadId],
|
||||
// Chooser index zero is reserved for New side chat.
|
||||
pub(crate) choices: &'a [SideChat],
|
||||
@@ -457,8 +485,37 @@ pub(crate) fn paint(
|
||||
} else {
|
||||
title.into()
|
||||
};
|
||||
let symbol = Span::styled("○ ", if selected { style } else { secondary });
|
||||
let line = Line::from(vec![format!("{marker} ").into(), symbol, title]).style(fill);
|
||||
let opening = tabs.opening.filter(|o| o.target.child == chat.id);
|
||||
let symbol = if let Some(Opening {
|
||||
state: OpenState::Loading(started),
|
||||
..
|
||||
}) = opening
|
||||
{
|
||||
Span::styled(
|
||||
crate::motion::loading_glyph(*started, sidebar_style.motion, sidebar_style.frames),
|
||||
fill,
|
||||
)
|
||||
} else {
|
||||
Span::styled("○", if selected { style } else { secondary })
|
||||
};
|
||||
let mut spans = vec![format!("{marker} ").into(), symbol, " ".into()];
|
||||
if let Some(opening) = opening {
|
||||
let status = match &opening.state {
|
||||
OpenState::Waiting => "Waiting to open…".dim(),
|
||||
OpenState::Loading(_) => "Opening…".dim(),
|
||||
OpenState::Failed(error) => format!("Retry · {error}").red(),
|
||||
};
|
||||
let width = usize::from(row(area.y).width.saturating_sub(6));
|
||||
let title = truncate_line_with_ellipsis_if_overflow(
|
||||
Line::from(title),
|
||||
width.saturating_sub(status.width()).max(width / 3),
|
||||
);
|
||||
spans.extend(title.spans);
|
||||
spans.extend([" ".into(), status]);
|
||||
} else {
|
||||
spans.push(title);
|
||||
}
|
||||
let line = Line::from(spans).style(fill);
|
||||
paint(
|
||||
lines.start,
|
||||
truncate_line_with_ellipsis_if_overflow(line, width),
|
||||
|
||||
Reference in New Issue
Block a user