mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
feat(tui): keep side conversations live
This commit is contained in:
@@ -8,6 +8,7 @@ use std::ops::Deref;
|
||||
use std::ops::DerefMut;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -169,6 +170,10 @@ impl ConversationPanes {
|
||||
if self.dispatch == Some(PaneSlot::Side) {
|
||||
self.dispatch = None;
|
||||
}
|
||||
if let Some(side) = &self.side {
|
||||
side.commit_anim_running
|
||||
.store(/*val*/ false, Ordering::Release);
|
||||
}
|
||||
self.side.take()
|
||||
}
|
||||
|
||||
|
||||
@@ -75,11 +75,17 @@ async fn taking_focused_side_restores_parent_selection() {
|
||||
assert!(panes.install_side(side).is_ok());
|
||||
assert!(panes.focus(PaneSlot::Side));
|
||||
let side_origin = panes.conversation_origin().expect("side origin");
|
||||
panes
|
||||
.by_slot(PaneSlot::Side)
|
||||
.expect("side pane")
|
||||
.commit_anim_running
|
||||
.store(/*val*/ true, Ordering::Release);
|
||||
assert!(panes.dispatch_to(side_origin));
|
||||
|
||||
let removed = panes.take_side().expect("installed side pane");
|
||||
|
||||
assert_eq!(removed.origin(), Some(side_origin));
|
||||
assert!(!removed.commit_anim_running.load(Ordering::Acquire));
|
||||
assert_eq!(panes.focused_slot(), PaneSlot::Parent);
|
||||
assert_eq!(panes.clear_dispatch(), None);
|
||||
assert!(panes.finish_dispatch(side_origin));
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use super::conversation_events::normalize_conversation_event;
|
||||
use super::resize_reflow::trailing_run_start;
|
||||
use super::*;
|
||||
use crate::app_event::ConversationOrigin;
|
||||
use crate::config_update::format_config_error;
|
||||
use crate::external_agent_config_migration_flow::ExternalAgentConfigMigrationFlowOutcome;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -38,7 +39,9 @@ impl App {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let result = self.handle_event_inner(tui, app_server, event).await;
|
||||
let result = self
|
||||
.handle_event_inner(tui, app_server, conversation_origin, event)
|
||||
.await;
|
||||
if let Some(previous_sender) = previous_sender {
|
||||
self.app_event_tx = previous_sender;
|
||||
}
|
||||
@@ -52,6 +55,7 @@ impl App {
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
conversation_origin: Option<ConversationOrigin>,
|
||||
event: AppEvent,
|
||||
) -> Result<AppRunControl> {
|
||||
match event {
|
||||
@@ -356,6 +360,20 @@ impl App {
|
||||
self.chat_widget.on_commit_tick();
|
||||
}
|
||||
AppEvent::Exit(mode) => {
|
||||
if mode == ExitMode::ShutdownFirst
|
||||
&& conversation_origin.is_some_and(|origin| origin.pane == PaneSlot::Side)
|
||||
&& let Some(side_thread_id) = self
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.and_then(|pane| pane.active_thread_id.or(pane.chat_widget.thread_id()))
|
||||
{
|
||||
if self.discard_side_thread(app_server, side_thread_id).await {
|
||||
self.surface_pending_inactive_thread_interactive_requests()
|
||||
.await?;
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
if mode == ExitMode::ShutdownFirst {
|
||||
self.show_shutdown_feedback(tui)?;
|
||||
}
|
||||
|
||||
@@ -482,6 +482,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn reset_thread_event_state(&mut self) {
|
||||
self.chat_widget.take_side();
|
||||
self.abort_all_thread_event_listeners();
|
||||
self.thread_event_channels.clear();
|
||||
self.agent_navigation.clear();
|
||||
|
||||
+269
-126
@@ -7,8 +7,12 @@
|
||||
//! material only and steer the agent away from mutations unless the side conversation explicitly asks
|
||||
//! for them.
|
||||
|
||||
use super::conversation_panes::ConversationPaneInit;
|
||||
use super::*;
|
||||
use crate::app_event::PaneSlot;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::chatwidget::InterruptedTurnNoticeMode;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
@@ -218,55 +222,127 @@ impl SideThreadState {
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_side_thread_ui(chat_widget: &mut ChatWidget) {
|
||||
chat_widget.set_side_conversation_context_label(/*label*/ None);
|
||||
chat_widget.set_side_conversation_active(/*active*/ false);
|
||||
chat_widget.clear_thread_rename_block();
|
||||
chat_widget.set_interrupted_turn_notice_mode(InterruptedTurnNoticeMode::Default);
|
||||
}
|
||||
|
||||
fn configure_side_thread_ui(
|
||||
chat_widget: &mut ChatWidget,
|
||||
parent_is_main: bool,
|
||||
parent_label: Option<&str>,
|
||||
parent_status: Option<SideParentStatus>,
|
||||
) {
|
||||
chat_widget.set_thread_rename_block_message(SIDE_RENAME_BLOCK_MESSAGE);
|
||||
chat_widget.set_side_conversation_active(/*active*/ true);
|
||||
chat_widget.set_interrupted_turn_notice_mode(InterruptedTurnNoticeMode::Suppress);
|
||||
let mut label_parts = if parent_is_main {
|
||||
vec!["from main thread".to_string()]
|
||||
} else {
|
||||
vec![format!(
|
||||
"from parent thread ({})",
|
||||
parent_label.unwrap_or("unknown")
|
||||
)]
|
||||
};
|
||||
if let Some(parent_status) = parent_status {
|
||||
label_parts.push(parent_status.label(parent_is_main).to_string());
|
||||
}
|
||||
label_parts.push("Ctrl+C to return".to_string());
|
||||
chat_widget
|
||||
.set_side_conversation_context_label(Some(format!("Side {}", label_parts.join(" · "))));
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn installed_side_thread_id(&self) -> Option<ThreadId> {
|
||||
let pane = self.chat_widget.by_slot(PaneSlot::Side)?;
|
||||
pane.active_thread_id.or(pane.thread_id())
|
||||
}
|
||||
|
||||
fn focused_side_thread_id(&self) -> Option<ThreadId> {
|
||||
if self.chat_widget.focused_slot() != PaneSlot::Side {
|
||||
return None;
|
||||
}
|
||||
self.installed_side_thread_id()
|
||||
}
|
||||
|
||||
pub(super) fn sync_side_thread_ui(&mut self) {
|
||||
let clear_side_ui = |chat_widget: &mut crate::chatwidget::ChatWidget| {
|
||||
chat_widget.set_side_conversation_context_label(/*label*/ None);
|
||||
chat_widget.set_side_conversation_active(/*active*/ false);
|
||||
chat_widget.clear_thread_rename_block();
|
||||
chat_widget.set_interrupted_turn_notice_mode(InterruptedTurnNoticeMode::Default);
|
||||
};
|
||||
let Some(active_thread_id) = self.current_displayed_thread_id() else {
|
||||
clear_side_ui(&mut self.chat_widget);
|
||||
return;
|
||||
};
|
||||
let Some((parent_thread_id, parent_status)) = self
|
||||
.side_threads
|
||||
.get(&active_thread_id)
|
||||
.map(|state| (state.parent_thread_id, state.parent_status))
|
||||
if let Some(parent) = self.chat_widget.by_slot_mut(PaneSlot::Parent) {
|
||||
clear_side_thread_ui(&mut parent.chat_widget);
|
||||
}
|
||||
|
||||
let side_state = self
|
||||
.installed_side_thread_id()
|
||||
.and_then(|thread_id| self.side_threads.get(&thread_id))
|
||||
.cloned();
|
||||
let Some((parent_thread_id, parent_status)) =
|
||||
side_state.map(|state| (state.parent_thread_id, state.parent_status))
|
||||
else {
|
||||
clear_side_ui(&mut self.chat_widget);
|
||||
if let Some(side) = self.chat_widget.by_slot_mut(PaneSlot::Side) {
|
||||
clear_side_thread_ui(&mut side.chat_widget);
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
self.chat_widget
|
||||
.set_thread_rename_block_message(SIDE_RENAME_BLOCK_MESSAGE);
|
||||
self.chat_widget
|
||||
.set_side_conversation_active(/*active*/ true);
|
||||
self.chat_widget
|
||||
.set_interrupted_turn_notice_mode(InterruptedTurnNoticeMode::Suppress);
|
||||
let mut label_parts = Vec::new();
|
||||
let parent_is_main = self.primary_thread_id == Some(parent_thread_id);
|
||||
if parent_is_main {
|
||||
label_parts.push("from main thread".to_string());
|
||||
} else {
|
||||
let parent_label = self.thread_label(parent_thread_id);
|
||||
label_parts.push(format!("from parent thread ({parent_label})"));
|
||||
}
|
||||
if let Some(parent_status) = parent_status {
|
||||
label_parts.push(parent_status.label(parent_is_main).to_string());
|
||||
}
|
||||
label_parts.push("Ctrl+C to return".to_string());
|
||||
self.chat_widget
|
||||
.set_side_conversation_context_label(Some(format!("Side {}", label_parts.join(" · "))));
|
||||
let parent_label = (!parent_is_main).then(|| self.thread_label(parent_thread_id));
|
||||
let Some(side) = self.chat_widget.by_slot_mut(PaneSlot::Side) else {
|
||||
return;
|
||||
};
|
||||
configure_side_thread_ui(
|
||||
&mut side.chat_widget,
|
||||
parent_is_main,
|
||||
parent_label.as_deref(),
|
||||
parent_status,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn active_side_parent_thread_id(&self) -> Option<ThreadId> {
|
||||
self.current_displayed_thread_id()
|
||||
self.installed_side_thread_id()
|
||||
.and_then(|thread_id| self.side_threads.get(&thread_id))
|
||||
.map(|state| state.parent_thread_id)
|
||||
}
|
||||
|
||||
fn add_side_thread_error(&mut self, thread_id: ThreadId, message: String) {
|
||||
if self.installed_side_thread_id() == Some(thread_id)
|
||||
&& let Some(side) = self.chat_widget.by_slot_mut(PaneSlot::Side)
|
||||
{
|
||||
side.add_error_message(message);
|
||||
} else {
|
||||
self.chat_widget.add_error_message(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn replay_side_thread_snapshot(&mut self, snapshot: ThreadEventSnapshot) -> bool {
|
||||
let Some(side_origin) = self
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.and_then(|pane| pane.origin())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let previous_origin = self
|
||||
.app_event_tx
|
||||
.conversation_origin()
|
||||
.filter(|origin| self.chat_widget.by_origin(*origin).is_some());
|
||||
if !self.chat_widget.dispatch_to(side_origin) {
|
||||
return false;
|
||||
}
|
||||
let sender = self.chat_widget.conversation_event_sender();
|
||||
let previous_sender = std::mem::replace(&mut self.app_event_tx, sender);
|
||||
self.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
|
||||
self.app_event_tx = previous_sender;
|
||||
if let Some(previous_origin) = previous_origin {
|
||||
let restored = self.chat_widget.dispatch_to(previous_origin);
|
||||
debug_assert!(restored);
|
||||
} else {
|
||||
let cleared = self.chat_widget.clear_dispatch();
|
||||
debug_assert_eq!(cleared, Some(PaneSlot::Side));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn set_side_parent_status(
|
||||
&mut self,
|
||||
parent_thread_id: ThreadId,
|
||||
@@ -331,30 +407,38 @@ impl App {
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
) -> 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()
|
||||
{
|
||||
if self
|
||||
.select_agent_thread_and_discard_side(tui, app_server, parent_thread_id)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.active_side_parent_thread_id().is_none()
|
||||
} else {
|
||||
false
|
||||
let Some(side_thread_id) = self.focused_side_thread_id() else {
|
||||
return false;
|
||||
};
|
||||
let can_return = self.overlay.is_none()
|
||||
&& self
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Side)
|
||||
.is_some_and(|side| side.no_modal_or_popup_active() && side.composer_is_empty());
|
||||
if !can_return {
|
||||
return false;
|
||||
}
|
||||
|
||||
if self.discard_side_thread(app_server, side_thread_id).await {
|
||||
if let Err(err) = self
|
||||
.surface_pending_inactive_thread_interactive_requests()
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%err, "failed to surface pending requests after closing side pane");
|
||||
}
|
||||
} else {
|
||||
self.keep_side_thread_visible_after_cleanup_failure(tui, side_thread_id);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn side_thread_to_discard_after_switch(
|
||||
&self,
|
||||
target_thread_id: ThreadId,
|
||||
) -> Option<ThreadId> {
|
||||
let side_thread_id = self.current_displayed_thread_id()?;
|
||||
if target_thread_id == side_thread_id || !self.side_threads.contains_key(&side_thread_id) {
|
||||
let side_thread_id = self.installed_side_thread_id()?;
|
||||
if target_thread_id == side_thread_id {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -368,14 +452,14 @@ impl App {
|
||||
) -> bool {
|
||||
if let Err(message) = self.interrupt_side_thread(app_server, thread_id).await {
|
||||
tracing::warn!("{message}");
|
||||
self.chat_widget.add_error_message(message);
|
||||
self.add_side_thread_error(thread_id, message);
|
||||
return false;
|
||||
}
|
||||
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
|
||||
let message =
|
||||
format!("Failed to close side conversation {thread_id}; it is still open: {err}");
|
||||
tracing::warn!("{message}");
|
||||
self.chat_widget.add_error_message(message);
|
||||
self.add_side_thread_error(thread_id, message);
|
||||
return false;
|
||||
}
|
||||
self.discard_thread_local_state(thread_id).await;
|
||||
@@ -387,11 +471,15 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) async fn discard_thread_local_state(&mut self, thread_id: ThreadId) {
|
||||
let remove_side_pane = self.installed_side_thread_id() == Some(thread_id);
|
||||
self.abort_thread_event_listener(thread_id);
|
||||
self.thread_event_channels.remove(&thread_id);
|
||||
self.side_threads.remove(&thread_id);
|
||||
self.agent_navigation.remove(thread_id);
|
||||
if self.chat_widget.active_thread_id == Some(thread_id) {
|
||||
if remove_side_pane {
|
||||
self.chat_widget.take_side();
|
||||
self.refresh_pending_thread_approvals().await;
|
||||
} else if self.chat_widget.active_thread_id == Some(thread_id) {
|
||||
self.clear_active_thread().await;
|
||||
} else {
|
||||
self.refresh_pending_thread_approvals().await;
|
||||
@@ -415,18 +503,14 @@ impl App {
|
||||
})
|
||||
}
|
||||
|
||||
async fn keep_side_thread_visible_after_cleanup_failure(
|
||||
fn keep_side_thread_visible_after_cleanup_failure(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) {
|
||||
if self.chat_widget.active_thread_id != Some(thread_id)
|
||||
&& let Err(err) = self.select_agent_thread(tui, app_server, thread_id).await
|
||||
{
|
||||
tracing::warn!(
|
||||
"failed to restore side conversation after cleanup failure for {thread_id}: {err}"
|
||||
);
|
||||
if self.installed_side_thread_id() == Some(thread_id) {
|
||||
self.chat_widget.focus(PaneSlot::Side);
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,12 +523,25 @@ impl App {
|
||||
if self.discard_side_thread(app_server, thread_id).await {
|
||||
true
|
||||
} else {
|
||||
self.keep_side_thread_visible_after_cleanup_failure(tui, app_server, thread_id)
|
||||
.await;
|
||||
self.keep_side_thread_visible_after_cleanup_failure(tui, thread_id);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn fail_side_start(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
user_message: Option<crate::chatwidget::UserMessage>,
|
||||
message: String,
|
||||
) {
|
||||
self.discard_side_thread_or_keep_visible(tui, app_server, thread_id)
|
||||
.await;
|
||||
self.restore_side_user_message(user_message);
|
||||
self.add_side_thread_error(thread_id, message);
|
||||
}
|
||||
|
||||
fn side_developer_instructions(existing_instructions: Option<&str>) -> String {
|
||||
match existing_instructions {
|
||||
Some(existing_instructions) if !existing_instructions.trim().is_empty() => {
|
||||
@@ -484,7 +581,7 @@ impl App {
|
||||
pub(super) fn side_start_block_message(&self) -> Option<&'static str> {
|
||||
if self.primary_thread_id.is_none() {
|
||||
Some(SIDE_MAIN_THREAD_UNAVAILABLE_MESSAGE)
|
||||
} else if !self.side_threads.is_empty() {
|
||||
} else if self.chat_widget.has_side() || !self.side_threads.is_empty() {
|
||||
Some(SIDE_ALREADY_OPEN_MESSAGE)
|
||||
} else {
|
||||
None
|
||||
@@ -508,8 +605,10 @@ impl App {
|
||||
user_message: Option<crate::chatwidget::UserMessage>,
|
||||
) {
|
||||
if let Some(user_message) = user_message {
|
||||
self.chat_widget
|
||||
.restore_user_message_to_composer(user_message);
|
||||
let slot = self.chat_widget.focused_slot();
|
||||
if let Some(pane) = self.chat_widget.by_slot_mut(slot) {
|
||||
pane.restore_user_message_to_composer(user_message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,24 +629,22 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
) -> Result<()> {
|
||||
let active_thread_id_before_switch = self.chat_widget.active_thread_id;
|
||||
let side_thread_to_discard = self.side_thread_to_discard_after_switch(thread_id);
|
||||
self.select_agent_thread(tui, app_server, thread_id).await?;
|
||||
if self.chat_widget.active_thread_id == Some(thread_id)
|
||||
&& let Some(side_thread_id) = side_thread_to_discard
|
||||
{
|
||||
if self.discard_side_thread(app_server, side_thread_id).await {
|
||||
self.surface_pending_inactive_thread_interactive_requests()
|
||||
.await?;
|
||||
} else if active_thread_id_before_switch == Some(side_thread_id) {
|
||||
self.keep_side_thread_visible_after_cleanup_failure(
|
||||
tui,
|
||||
app_server,
|
||||
side_thread_id,
|
||||
)
|
||||
.await;
|
||||
if self.installed_side_thread_id() == Some(thread_id) {
|
||||
self.chat_widget.focus(PaneSlot::Side);
|
||||
tui.frame_requester().schedule_frame();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(side_thread_id) = self.side_thread_to_discard_after_switch(thread_id) {
|
||||
if !self.discard_side_thread(app_server, side_thread_id).await {
|
||||
self.keep_side_thread_visible_after_cleanup_failure(tui, side_thread_id);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.chat_widget.focus(PaneSlot::Parent);
|
||||
self.select_agent_thread(tui, app_server, thread_id).await?;
|
||||
self.surface_pending_inactive_thread_interactive_requests()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -574,7 +671,10 @@ impl App {
|
||||
.await;
|
||||
|
||||
let fork_config = self.side_fork_config();
|
||||
match app_server.fork_thread(fork_config, parent_thread_id).await {
|
||||
match app_server
|
||||
.fork_thread(fork_config.clone(), parent_thread_id)
|
||||
.await
|
||||
{
|
||||
Ok(forked) => {
|
||||
let child_thread_id = forked.session.thread_id;
|
||||
let channel = self.ensure_thread_channel(child_thread_id);
|
||||
@@ -584,55 +684,98 @@ impl App {
|
||||
}
|
||||
self.side_threads
|
||||
.insert(child_thread_id, SideThreadState::new(parent_thread_id));
|
||||
|
||||
let init = self.chatwidget_init_for_forked_or_resumed_thread(
|
||||
tui,
|
||||
fork_config,
|
||||
/*initial_user_message*/ None,
|
||||
);
|
||||
let side_widget = ChatWidget::new_with_app_event_for_pane(init, PaneSlot::Side);
|
||||
let file_search = FileSearchManager::new(
|
||||
side_widget.config_ref().cwd.to_path_buf(),
|
||||
side_widget.conversation_event_sender(),
|
||||
);
|
||||
let owned_screen = if self.has_owned_screen() {
|
||||
Self::owned_screen_for_behavior(
|
||||
crate::AltScreenBehavior::Owned,
|
||||
&side_widget,
|
||||
self.keymap.pager.clone(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if self
|
||||
.chat_widget
|
||||
.install_side(ConversationPaneInit {
|
||||
chat_widget: side_widget,
|
||||
file_search,
|
||||
owned_screen,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
self.fail_side_start(
|
||||
tui,
|
||||
app_server,
|
||||
child_thread_id,
|
||||
user_message.take(),
|
||||
format!("Failed to install side conversation {child_thread_id}."),
|
||||
)
|
||||
.await;
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
|
||||
let Some((receiver, snapshot)) =
|
||||
self.activate_thread_for_replay(child_thread_id).await
|
||||
else {
|
||||
self.fail_side_start(
|
||||
tui,
|
||||
app_server,
|
||||
child_thread_id,
|
||||
user_message.take(),
|
||||
format!("Failed to attach side conversation {child_thread_id}."),
|
||||
)
|
||||
.await;
|
||||
return Ok(AppRunControl::Continue);
|
||||
};
|
||||
let Some(side) = self.chat_widget.by_slot_mut(PaneSlot::Side) else {
|
||||
unreachable!("side pane was installed above");
|
||||
};
|
||||
side.attach_thread(child_thread_id, Some(receiver));
|
||||
if !self.replay_side_thread_snapshot(snapshot) {
|
||||
self.fail_side_start(
|
||||
tui,
|
||||
app_server,
|
||||
child_thread_id,
|
||||
user_message.take(),
|
||||
format!("Failed to initialize side conversation {child_thread_id}."),
|
||||
)
|
||||
.await;
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
self.chat_widget.focus(PaneSlot::Side);
|
||||
self.sync_side_thread_ui();
|
||||
|
||||
if let Err(err) = app_server
|
||||
.thread_inject_items(child_thread_id, vec![Self::side_boundary_prompt_item()])
|
||||
.await
|
||||
{
|
||||
self.discard_side_thread_or_keep_visible(tui, app_server, child_thread_id)
|
||||
.await;
|
||||
self.restore_side_user_message(user_message.take());
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to prepare side conversation {child_thread_id}: {err}"
|
||||
));
|
||||
self.fail_side_start(
|
||||
tui,
|
||||
app_server,
|
||||
child_thread_id,
|
||||
user_message.take(),
|
||||
format!("Failed to prepare side conversation {child_thread_id}: {err}"),
|
||||
)
|
||||
.await;
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
if let Err(err) = self
|
||||
.select_agent_thread_and_discard_side(tui, app_server, child_thread_id)
|
||||
.await
|
||||
|
||||
if let Some(user_message) = user_message.take()
|
||||
&& let Some(side) = self.chat_widget.by_slot_mut(PaneSlot::Side)
|
||||
{
|
||||
let discarded = self
|
||||
.discard_side_thread_or_keep_visible(tui, app_server, child_thread_id)
|
||||
.await;
|
||||
if discarded
|
||||
&& self.chat_widget.active_thread_id != Some(parent_thread_id)
|
||||
&& let Err(restore_err) = self
|
||||
.select_agent_thread(tui, app_server, parent_thread_id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"failed to restore parent thread after side conversation switch failure: {restore_err}"
|
||||
);
|
||||
}
|
||||
self.restore_side_user_message(user_message.take());
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to switch into side conversation {child_thread_id}: {err}"
|
||||
));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
if self.chat_widget.active_thread_id == Some(child_thread_id) {
|
||||
if let Some(user_message) = user_message.take() {
|
||||
let _ = self
|
||||
.chat_widget
|
||||
.submit_user_message_as_plain_user_turn(user_message);
|
||||
}
|
||||
} else {
|
||||
self.discard_side_thread_or_keep_visible(tui, app_server, child_thread_id)
|
||||
.await;
|
||||
self.restore_side_user_message(user_message.take());
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to switch into side conversation {child_thread_id}."
|
||||
));
|
||||
let _ = side.submit_user_message_as_plain_user_turn(user_message);
|
||||
}
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
Err(err) => {
|
||||
self.restore_side_user_message(user_message.take());
|
||||
|
||||
+211
-38
@@ -12,6 +12,7 @@ use crate::app_backtrack::user_count;
|
||||
|
||||
use crate::chatwidget::ChatWidgetInit;
|
||||
use crate::chatwidget::create_initial_user_message;
|
||||
use crate::chatwidget::tests::constructor::make_chatwidget_for_pane_with_sender;
|
||||
use crate::chatwidget::tests::helpers::render_bottom_popup;
|
||||
use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
|
||||
use crate::chatwidget::tests::set_chatgpt_auth;
|
||||
@@ -128,6 +129,49 @@ fn conversation_event_payload(event: AppEvent) -> AppEvent {
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_test_side_pane(
|
||||
app: &mut App,
|
||||
parent_thread_id: ThreadId,
|
||||
side_thread_id: ThreadId,
|
||||
) {
|
||||
app.chat_widget
|
||||
.by_slot_mut(PaneSlot::Parent)
|
||||
.expect("parent pane")
|
||||
.attach_thread(parent_thread_id, /*receiver*/ None);
|
||||
let side_widget =
|
||||
make_chatwidget_for_pane_with_sender(PaneSlot::Side, app.app_event_tx.clone()).await;
|
||||
let file_search = FileSearchManager::new(
|
||||
side_widget.config_ref().cwd.to_path_buf(),
|
||||
side_widget.conversation_event_sender(),
|
||||
);
|
||||
let installed = app.chat_widget.install_side(ConversationPaneInit {
|
||||
chat_widget: side_widget,
|
||||
file_search,
|
||||
owned_screen: None,
|
||||
});
|
||||
assert!(installed.is_ok(), "side pane should install");
|
||||
app.chat_widget
|
||||
.by_slot_mut(PaneSlot::Side)
|
||||
.expect("side pane")
|
||||
.attach_thread(side_thread_id, /*receiver*/ None);
|
||||
app.side_threads
|
||||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||||
assert!(app.chat_widget.focus(PaneSlot::Side));
|
||||
app.sync_side_thread_ui();
|
||||
}
|
||||
|
||||
async fn attach_test_pane_channel(app: &mut App, slot: PaneSlot, thread_id: ThreadId) {
|
||||
app.set_thread_active(thread_id, /*active*/ true).await;
|
||||
let receiver = app
|
||||
.thread_event_channels
|
||||
.get_mut(&thread_id)
|
||||
.and_then(|channel| channel.receiver.take());
|
||||
app.chat_widget
|
||||
.by_slot_mut(slot)
|
||||
.expect("installed pane")
|
||||
.attach_thread(thread_id, receiver);
|
||||
}
|
||||
|
||||
async fn next_thread_settings_updated(
|
||||
app_server: &mut AppServerSession,
|
||||
thread_id: ThreadId,
|
||||
@@ -2294,7 +2338,7 @@ async fn inactive_thread_approval_bubbles_into_active_view() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn side_defers_parent_approval_overlay_until_parent_replay() -> Result<()> {
|
||||
async fn side_keeps_parent_approval_live_without_stealing_focus() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let parent_thread_id =
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000011").expect("valid thread");
|
||||
@@ -2302,9 +2346,6 @@ async fn side_defers_parent_approval_overlay_until_parent_replay() -> Result<()>
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000022").expect("valid thread");
|
||||
|
||||
app.primary_thread_id = Some(parent_thread_id);
|
||||
app.chat_widget.active_thread_id = Some(side_thread_id);
|
||||
app.side_threads
|
||||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||||
app.thread_event_channels.insert(
|
||||
parent_thread_id,
|
||||
ThreadEventChannel::new_with_session(
|
||||
@@ -2313,6 +2354,8 @@ async fn side_defers_parent_approval_overlay_until_parent_replay() -> Result<()>
|
||||
Vec::new(),
|
||||
),
|
||||
);
|
||||
install_test_side_pane(&mut app, parent_thread_id, side_thread_id).await;
|
||||
attach_test_pane_channel(&mut app, PaneSlot::Parent, parent_thread_id).await;
|
||||
|
||||
app.enqueue_thread_request(
|
||||
parent_thread_id,
|
||||
@@ -2333,20 +2376,20 @@ async fn side_defers_parent_approval_overlay_until_parent_replay() -> Result<()>
|
||||
.and_then(|state| state.parent_status),
|
||||
Some(SideParentStatus::NeedsApproval)
|
||||
);
|
||||
let (slot, event) = app.chat_widget.recv_thread_event().await;
|
||||
assert_eq!(slot, PaneSlot::Parent);
|
||||
assert!(app.chat_widget.focus(PaneSlot::Parent));
|
||||
app.handle_thread_event_replay(event.expect("parent approval event"));
|
||||
assert!(app.chat_widget.focus(PaneSlot::Side));
|
||||
|
||||
let snapshot = {
|
||||
let channel = app
|
||||
.thread_event_channels
|
||||
.get(&parent_thread_id)
|
||||
.expect("parent thread channel");
|
||||
let store = channel.store.lock().await;
|
||||
store.snapshot()
|
||||
};
|
||||
app.side_threads.remove(&side_thread_id);
|
||||
app.chat_widget.active_thread_id = Some(parent_thread_id);
|
||||
app.replay_thread_snapshot(snapshot, /*resume_restored_queue*/ false);
|
||||
|
||||
assert_eq!(app.chat_widget.has_active_view(), true);
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
|
||||
assert!(!app.chat_widget.has_active_view());
|
||||
assert!(
|
||||
app.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.expect("parent pane")
|
||||
.has_active_view()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2409,9 +2452,7 @@ async fn side_defers_subagent_approval_overlay_until_side_exits() -> Result<()>
|
||||
ThreadId::from_string("00000000-0000-0000-0000-000000000033").expect("valid thread");
|
||||
|
||||
app.primary_thread_id = Some(main_thread_id);
|
||||
app.chat_widget.active_thread_id = Some(side_thread_id);
|
||||
app.side_threads
|
||||
.insert(side_thread_id, SideThreadState::new(main_thread_id));
|
||||
install_test_side_pane(&mut app, main_thread_id, side_thread_id).await;
|
||||
app.thread_event_channels.insert(
|
||||
agent_thread_id,
|
||||
ThreadEventChannel::new_with_session(
|
||||
@@ -2449,8 +2490,7 @@ async fn side_defers_subagent_approval_overlay_until_side_exits() -> Result<()>
|
||||
&["Robie [explorer]".to_string()]
|
||||
);
|
||||
|
||||
app.side_threads.remove(&side_thread_id);
|
||||
app.chat_widget.active_thread_id = Some(main_thread_id);
|
||||
app.discard_closed_side_thread(side_thread_id).await;
|
||||
app.surface_pending_inactive_thread_interactive_requests()
|
||||
.await?;
|
||||
|
||||
@@ -3623,10 +3663,9 @@ async fn active_side_thread_renders_live_mcp_startup_notifications() {
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let side_thread_id = ThreadId::new();
|
||||
app.primary_thread_id = Some(parent_thread_id);
|
||||
app.side_threads
|
||||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||||
app.ensure_thread_channel(side_thread_id);
|
||||
app.activate_thread_channel(side_thread_id).await;
|
||||
install_test_side_pane(&mut app, parent_thread_id, side_thread_id).await;
|
||||
attach_test_pane_channel(&mut app, PaneSlot::Side, side_thread_id).await;
|
||||
app.replay_thread_snapshot(
|
||||
ThreadEventSnapshot {
|
||||
session: Some(test_thread_session(
|
||||
@@ -3676,7 +3715,7 @@ async fn active_side_thread_renders_live_mcp_startup_notifications() {
|
||||
|
||||
let mut rendered_cells = Vec::new();
|
||||
while let Ok(event) = app_event_rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryCell(cell) = event {
|
||||
if let AppEvent::InsertHistoryCell(cell) = conversation_event_payload(event) {
|
||||
rendered_cells.push(lines_to_single_string(&cell.display_lines(/*width*/ 120)));
|
||||
}
|
||||
}
|
||||
@@ -3704,14 +3743,111 @@ async fn side_restore_user_message_puts_inline_question_back_in_composer() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_side_installs_live_child_without_replacing_parent() -> Result<()> {
|
||||
Box::pin(async {
|
||||
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
|
||||
let mut app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
let started = app_server
|
||||
.start_thread(app.chat_widget.config_ref())
|
||||
.await?;
|
||||
let parent_thread_id = started.session.thread_id;
|
||||
app_server
|
||||
.thread_inject_items(parent_thread_id, vec![App::side_boundary_prompt_item()])
|
||||
.await?;
|
||||
app.enqueue_primary_thread_session(started.session, started.turns)
|
||||
.await?;
|
||||
app.chat_widget
|
||||
.set_composer_text("parent draft".to_string(), Vec::new(), Vec::new());
|
||||
while app_event_rx.try_recv().is_ok() {}
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
|
||||
let control = app
|
||||
.handle_start_side(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
parent_thread_id,
|
||||
Some(crate::chatwidget::UserMessage::from("side question")),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(matches!(control, AppRunControl::Continue));
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
|
||||
let parent = app
|
||||
.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.expect("parent pane");
|
||||
assert_eq!(parent.active_thread_id, Some(parent_thread_id));
|
||||
assert!(parent.active_thread_rx.is_some());
|
||||
assert_eq!(parent.composer_text_with_pending(), "parent draft");
|
||||
let side = app.chat_widget.by_slot(PaneSlot::Side).expect("side pane");
|
||||
let side_thread_id = side.active_thread_id.expect("side thread id");
|
||||
assert_ne!(side_thread_id, parent_thread_id);
|
||||
assert!(side.active_thread_rx.is_some());
|
||||
assert!(side.side_conversation_active());
|
||||
let side_origin = side.conversation_origin().expect("side origin");
|
||||
assert_eq!(side_origin.pane, PaneSlot::Side);
|
||||
assert_eq!(
|
||||
app.side_threads
|
||||
.get(&side_thread_id)
|
||||
.map(|state| state.parent_thread_id),
|
||||
Some(parent_thread_id)
|
||||
);
|
||||
for thread_id in [parent_thread_id, side_thread_id] {
|
||||
let store = &app
|
||||
.thread_event_channels
|
||||
.get(&thread_id)
|
||||
.expect("live thread channel")
|
||||
.store;
|
||||
assert!(store.lock().await.active);
|
||||
}
|
||||
|
||||
let mut submitted_to_side = false;
|
||||
while let Ok(event) = app_event_rx.try_recv() {
|
||||
if let AppEvent::ConversationOp {
|
||||
target,
|
||||
op: Op::UserTurn { items, .. },
|
||||
} = event
|
||||
{
|
||||
assert_eq!(target.thread_id, side_thread_id);
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![UserInput::Text {
|
||||
text: "side question".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}]
|
||||
);
|
||||
submitted_to_side = true;
|
||||
}
|
||||
}
|
||||
assert!(submitted_to_side);
|
||||
|
||||
let control = app
|
||||
.handle_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
AppEvent::FromConversation {
|
||||
target: side_origin,
|
||||
event: Box::new(AppEvent::Exit(ExitMode::ShutdownFirst)),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(matches!(control, AppRunControl::Continue));
|
||||
assert!(!app.chat_widget.has_side());
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
assert_eq!(app.chat_widget.active_thread_id, Some(parent_thread_id));
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn side_discard_selection_keeps_current_side_thread() {
|
||||
let mut app = make_test_app().await;
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let side_thread_id = ThreadId::new();
|
||||
app.chat_widget.active_thread_id = Some(side_thread_id);
|
||||
app.side_threads
|
||||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||||
install_test_side_pane(&mut app, parent_thread_id, side_thread_id).await;
|
||||
|
||||
assert_eq!(
|
||||
app.side_thread_to_discard_after_switch(side_thread_id),
|
||||
@@ -3733,8 +3869,8 @@ async fn discard_side_thread_removes_agent_navigation_entry() -> Result<()> {
|
||||
side_config.ephemeral = true;
|
||||
let started = app_server.start_thread(&side_config).await?;
|
||||
let side_thread_id = started.session.thread_id;
|
||||
app.side_threads
|
||||
.insert(side_thread_id, SideThreadState::new(ThreadId::new()));
|
||||
let parent_thread_id = ThreadId::new();
|
||||
install_test_side_pane(&mut app, parent_thread_id, side_thread_id).await;
|
||||
app.agent_navigation.upsert(
|
||||
side_thread_id,
|
||||
Some("Side".to_string()),
|
||||
@@ -3749,6 +3885,8 @@ async fn discard_side_thread_removes_agent_navigation_entry() -> Result<()> {
|
||||
|
||||
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
|
||||
assert!(!app.side_threads.contains_key(&side_thread_id));
|
||||
assert!(!app.chat_widget.has_side());
|
||||
assert_eq!(app.chat_widget.active_thread_id, Some(parent_thread_id));
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
@@ -3762,9 +3900,7 @@ async fn discard_side_thread_keeps_local_state_when_server_close_fails() -> Resu
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let side_thread_id = ThreadId::new();
|
||||
app.chat_widget.active_thread_id = Some(side_thread_id);
|
||||
app.side_threads
|
||||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||||
install_test_side_pane(&mut app, parent_thread_id, side_thread_id).await;
|
||||
app.agent_navigation.upsert(
|
||||
side_thread_id,
|
||||
Some("Side".to_string()),
|
||||
@@ -3777,7 +3913,15 @@ async fn discard_side_thread_keeps_local_state_when_server_close_fails() -> Resu
|
||||
.await
|
||||
);
|
||||
|
||||
assert!(app.chat_widget.has_side());
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Side);
|
||||
assert_eq!(app.chat_widget.active_thread_id, Some(side_thread_id));
|
||||
assert_eq!(
|
||||
app.chat_widget
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.and_then(|pane| pane.active_thread_id),
|
||||
Some(parent_thread_id)
|
||||
);
|
||||
assert_eq!(
|
||||
app.side_threads
|
||||
.get(&side_thread_id)
|
||||
@@ -3795,9 +3939,7 @@ async fn discard_closed_side_thread_removes_local_state_without_server_rpc() {
|
||||
let mut app = make_test_app().await;
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let side_thread_id = ThreadId::new();
|
||||
app.chat_widget.active_thread_id = Some(side_thread_id);
|
||||
app.side_threads
|
||||
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
|
||||
install_test_side_pane(&mut app, parent_thread_id, side_thread_id).await;
|
||||
app.thread_event_channels
|
||||
.insert(side_thread_id, ThreadEventChannel::new(/*capacity*/ 4));
|
||||
app.agent_navigation.upsert(
|
||||
@@ -3809,12 +3951,43 @@ async fn discard_closed_side_thread_removes_local_state_without_server_rpc() {
|
||||
|
||||
app.discard_closed_side_thread(side_thread_id).await;
|
||||
|
||||
assert_eq!(app.chat_widget.active_thread_id, None);
|
||||
assert!(!app.chat_widget.has_side());
|
||||
assert_eq!(app.chat_widget.active_thread_id, Some(parent_thread_id));
|
||||
assert!(!app.side_threads.contains_key(&side_thread_id));
|
||||
assert!(!app.thread_event_channels.contains_key(&side_thread_id));
|
||||
assert_eq!(app.agent_navigation.get(&side_thread_id), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn side_thread_closed_removes_only_side_and_keeps_parent_live() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
let parent_thread_id = ThreadId::new();
|
||||
let side_thread_id = ThreadId::new();
|
||||
app.primary_thread_id = Some(parent_thread_id);
|
||||
install_test_side_pane(&mut app, parent_thread_id, side_thread_id).await;
|
||||
app.chat_widget
|
||||
.by_slot_mut(PaneSlot::Parent)
|
||||
.expect("parent pane")
|
||||
.set_composer_text("parent draft".to_string(), Vec::new(), Vec::new());
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
let mut app_server =
|
||||
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
|
||||
|
||||
app.handle_active_thread_event(
|
||||
&mut tui,
|
||||
&mut app_server,
|
||||
ThreadBufferedEvent::Notification(thread_closed_notification(side_thread_id)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert!(!app.chat_widget.has_side());
|
||||
assert_eq!(app.chat_widget.focused_slot(), PaneSlot::Parent);
|
||||
assert_eq!(app.chat_widget.active_thread_id, Some(parent_thread_id));
|
||||
assert_eq!(app.chat_widget.composer_text_with_pending(), "parent draft");
|
||||
assert!(!app.side_threads.contains_key(&side_thread_id));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_non_primary_shutdown_target_returns_none_for_non_shutdown_event() -> Result<()> {
|
||||
let mut app = make_test_app().await;
|
||||
|
||||
@@ -1574,6 +1574,25 @@ impl App {
|
||||
) && self.pending_shutdown_exit_thread_id
|
||||
== self.chat_widget.active_thread_id;
|
||||
|
||||
if matches!(
|
||||
&event,
|
||||
ThreadBufferedEvent::Notification(ServerNotification::ThreadClosed(_))
|
||||
) && let Some(side_thread_id) = self.chat_widget.active_thread_id
|
||||
&& self.side_threads.contains_key(&side_thread_id)
|
||||
{
|
||||
if pending_shutdown_exit_completed {
|
||||
self.pending_shutdown_exit_thread_id = None;
|
||||
}
|
||||
self.mark_agent_picker_thread_closed(side_thread_id);
|
||||
self.discard_closed_side_thread(side_thread_id).await;
|
||||
self.chat_widget.add_info_message(
|
||||
format!("Side conversation {side_thread_id} closed. Returned to parent."),
|
||||
/*hint*/ None,
|
||||
);
|
||||
tui.frame_requester().schedule_frame();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Processing order matters:
|
||||
//
|
||||
// 1. handle unexpected non-primary shutdown failover first;
|
||||
|
||||
@@ -43,6 +43,15 @@ pub(crate) async fn make_chatwidget_for_pane(
|
||||
(ChatWidget::new_with_app_event_for_pane(init, pane), rx)
|
||||
}
|
||||
|
||||
pub(crate) async fn make_chatwidget_for_pane_with_sender(
|
||||
pane: PaneSlot,
|
||||
app_event_tx: AppEventSender,
|
||||
) -> ChatWidget {
|
||||
let (mut init, _rx) = test_init().await;
|
||||
init.app_event_tx = app_event_tx;
|
||||
ChatWidget::new_with_app_event_for_pane(init, pane)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_event_constructor_defaults_to_parent_pane() {
|
||||
let (init, _rx) = test_init().await;
|
||||
|
||||
Reference in New Issue
Block a user