mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
refactor(tui): run live conversation panes
This commit is contained in:
+5
-10
@@ -10,6 +10,7 @@ use crate::app_event::AppEvent;
|
||||
use crate::app_event::ExitMode;
|
||||
use crate::app_event::FeedbackCategory;
|
||||
use crate::app_event::HistoryLookupResponse;
|
||||
use crate::app_event::PaneSlot;
|
||||
use crate::app_event::PermissionProfileSelection;
|
||||
use crate::app_event::PluginLocation;
|
||||
use crate::app_event::PluginRemoteSectionError;
|
||||
@@ -1173,22 +1174,16 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
Err(err) => break Err(err),
|
||||
}
|
||||
}
|
||||
active = async {
|
||||
if let Some(rx) = app.chat_widget.active_thread_rx.as_mut() {
|
||||
rx.recv().await
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}, if App::should_handle_active_thread_events(
|
||||
(pane, active) = app.chat_widget.recv_thread_event(), if App::should_handle_active_thread_events(
|
||||
waiting_for_initial_session_configured,
|
||||
app.chat_widget.active_thread_rx.is_some()
|
||||
app.chat_widget.has_thread_event_receiver()
|
||||
) => {
|
||||
if let Some(event) = active {
|
||||
if let Err(err) = app.handle_active_thread_event(tui, &mut app_server, event).await {
|
||||
if let Err(err) = app.handle_pane_thread_event(tui, &mut app_server, pane, event).await {
|
||||
break Err(err);
|
||||
}
|
||||
} else {
|
||||
app.clear_active_thread().await;
|
||||
app.clear_pane_thread(pane).await;
|
||||
}
|
||||
AppRunControl::Continue
|
||||
}
|
||||
|
||||
@@ -71,9 +71,9 @@ impl App {
|
||||
if let Some(request) = self
|
||||
.pending_app_server_requests
|
||||
.resolve_notification(thread_id, ¬ification.request_id)
|
||||
&& self.chat_widget.active_thread_id == Some(thread_id)
|
||||
&& let Some(pane) = self.chat_widget.by_thread_id_mut(thread_id)
|
||||
{
|
||||
self.chat_widget.dismiss_app_server_request(&request);
|
||||
pane.dismiss_app_server_request(&request);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
|
||||
@@ -58,7 +58,7 @@ impl App {
|
||||
|
||||
fn mcp_inventory_request_thread_id(&self, thread_id: Option<ThreadId>) -> Option<ThreadId> {
|
||||
thread_id.filter(|thread_id| {
|
||||
self.chat_widget.active_thread_id == Some(*thread_id)
|
||||
self.chat_widget.contains_thread(*thread_id)
|
||||
&& self
|
||||
.agent_navigation
|
||||
.get(thread_id)
|
||||
|
||||
@@ -9,10 +9,10 @@ use crate::app_event::ConversationOrigin;
|
||||
|
||||
pub(super) fn normalize_conversation_event(
|
||||
event: AppEvent,
|
||||
current_origin: Option<ConversationOrigin>,
|
||||
is_live_origin: impl FnOnce(ConversationOrigin) -> bool,
|
||||
) -> Option<(Option<ConversationOrigin>, AppEvent)> {
|
||||
match event {
|
||||
AppEvent::FromConversation { target, event } if current_origin == Some(target) => {
|
||||
AppEvent::FromConversation { target, event } if is_live_origin(target) => {
|
||||
Some((Some(target), *event))
|
||||
}
|
||||
AppEvent::FromConversation { event, .. } if survives_conversation_retirement(&event) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ fn current_generation_dispatches_to_its_conversation() {
|
||||
target: current,
|
||||
event: Box::new(AppEvent::RawOutputModeChanged { enabled: true }),
|
||||
},
|
||||
Some(current),
|
||||
|origin| origin == current,
|
||||
)
|
||||
.expect("current event should be delivered");
|
||||
|
||||
@@ -41,7 +41,7 @@ fn stale_presentation_event_is_dropped() {
|
||||
target: stale,
|
||||
event: Box::new(AppEvent::RawOutputModeChanged { enabled: true }),
|
||||
},
|
||||
Some(current),
|
||||
|origin| origin == current,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
@@ -58,7 +58,7 @@ fn stale_widget_completion_is_dropped() {
|
||||
target: stale,
|
||||
event: Box::new(AppEvent::PluginMentionsLoaded { plugins: None }),
|
||||
},
|
||||
Some(current),
|
||||
|origin| origin == current,
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
@@ -73,7 +73,7 @@ fn stale_durable_event_is_delivered_without_pane_dispatch() {
|
||||
target: stale,
|
||||
event: Box::new(AppEvent::Exit(ExitMode::ShutdownFirst)),
|
||||
},
|
||||
Some(current),
|
||||
|origin| origin == current,
|
||||
)
|
||||
.expect("durable event should be delivered");
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Conversation-local state for the application-owned conversation surface.
|
||||
//!
|
||||
//! This first stage places the current conversation behind a pane boundary. Later split-screen
|
||||
//! layers can add another pane without returning these fields to application-global state.
|
||||
//! The owned screen supports a fixed parent pane and an optional side pane. Focus controls where
|
||||
//! user input goes, while a temporary dispatch slot lets asynchronous events update their origin
|
||||
//! pane without changing focus.
|
||||
|
||||
use std::ops::Deref;
|
||||
use std::ops::DerefMut;
|
||||
@@ -14,6 +15,7 @@ use tokio::sync::mpsc;
|
||||
use super::InitialHistoryReplayBuffer;
|
||||
use super::owned_screen::OwnedScreen;
|
||||
use super::thread_events::ThreadBufferedEvent;
|
||||
use crate::app_event::ConversationOrigin;
|
||||
use crate::app_event::PaneSlot;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::file_search::FileSearchManager;
|
||||
@@ -27,6 +29,7 @@ pub(super) struct ConversationPaneInit {
|
||||
}
|
||||
|
||||
pub(crate) struct ConversationPane {
|
||||
slot: PaneSlot,
|
||||
pub(super) chat_widget: ChatWidget,
|
||||
pub(super) file_search: FileSearchManager,
|
||||
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
|
||||
@@ -39,16 +42,17 @@ pub(crate) struct ConversationPane {
|
||||
}
|
||||
|
||||
impl ConversationPane {
|
||||
fn new(init: ConversationPaneInit) -> Result<Self, ConversationPaneInit> {
|
||||
fn new(init: ConversationPaneInit, slot: PaneSlot) -> Result<Self, ConversationPaneInit> {
|
||||
if init
|
||||
.chat_widget
|
||||
.conversation_origin()
|
||||
.is_some_and(|origin| origin.pane != PaneSlot::Parent)
|
||||
.is_some_and(|origin| origin.pane != slot)
|
||||
{
|
||||
return Err(init);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
slot,
|
||||
chat_widget: init.chat_widget,
|
||||
file_search: init.file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
@@ -61,6 +65,16 @@ impl ConversationPane {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn origin(&self) -> Option<ConversationOrigin> {
|
||||
self.chat_widget
|
||||
.conversation_origin()
|
||||
.filter(|origin| origin.pane == self.slot)
|
||||
}
|
||||
|
||||
fn thread_id(&self) -> Option<ThreadId> {
|
||||
self.active_thread_id.or(self.chat_widget.thread_id())
|
||||
}
|
||||
|
||||
pub(super) fn attach_thread(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
@@ -96,17 +110,155 @@ impl DerefMut for ConversationPane {
|
||||
|
||||
pub(crate) struct ConversationPanes {
|
||||
parent: ConversationPane,
|
||||
side: Option<ConversationPane>,
|
||||
focused: PaneSlot,
|
||||
dispatch: Option<PaneSlot>,
|
||||
}
|
||||
|
||||
// Rendering and `/side` lifecycle consume the remaining APIs in the next stack layers.
|
||||
#[allow(dead_code)]
|
||||
impl ConversationPanes {
|
||||
pub(super) fn new_parent(init: ConversationPaneInit) -> Result<Self, ConversationPaneInit> {
|
||||
Ok(Self {
|
||||
parent: ConversationPane::new(init)?,
|
||||
parent: ConversationPane::new(init, PaneSlot::Parent)?,
|
||||
side: None,
|
||||
focused: PaneSlot::Parent,
|
||||
dispatch: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn focused_slot(&self) -> PaneSlot {
|
||||
self.focused
|
||||
}
|
||||
|
||||
pub(super) fn focus(&mut self, slot: PaneSlot) -> bool {
|
||||
if self.by_slot(slot).is_none() {
|
||||
return false;
|
||||
}
|
||||
self.focused = slot;
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn install_side(
|
||||
&mut self,
|
||||
init: ConversationPaneInit,
|
||||
) -> Result<Option<ConversationPane>, ConversationPaneInit> {
|
||||
let pane = ConversationPane::new(init, PaneSlot::Side)?;
|
||||
Ok(self.side.replace(pane))
|
||||
}
|
||||
|
||||
pub(super) fn take_side(&mut self) -> Option<ConversationPane> {
|
||||
if self.focused == PaneSlot::Side {
|
||||
self.focused = PaneSlot::Parent;
|
||||
}
|
||||
if self.dispatch == Some(PaneSlot::Side) {
|
||||
self.dispatch = None;
|
||||
}
|
||||
self.side.take()
|
||||
}
|
||||
|
||||
pub(super) fn by_slot(&self, slot: PaneSlot) -> Option<&ConversationPane> {
|
||||
match slot {
|
||||
PaneSlot::Parent => Some(&self.parent),
|
||||
PaneSlot::Side => self.side.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn by_slot_mut(&mut self, slot: PaneSlot) -> Option<&mut ConversationPane> {
|
||||
match slot {
|
||||
PaneSlot::Parent => Some(&mut self.parent),
|
||||
PaneSlot::Side => self.side.as_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn by_origin(&self, origin: ConversationOrigin) -> Option<&ConversationPane> {
|
||||
self.by_slot(origin.pane)
|
||||
.filter(|pane| pane.origin() == Some(origin))
|
||||
}
|
||||
|
||||
pub(super) fn by_thread_id(&self, thread_id: ThreadId) -> Option<&ConversationPane> {
|
||||
if self.parent.thread_id() == Some(thread_id) {
|
||||
return Some(&self.parent);
|
||||
}
|
||||
self.side
|
||||
.as_ref()
|
||||
.filter(|pane| pane.thread_id() == Some(thread_id))
|
||||
}
|
||||
|
||||
pub(super) fn by_thread_id_mut(
|
||||
&mut self,
|
||||
thread_id: ThreadId,
|
||||
) -> Option<&mut ConversationPane> {
|
||||
if self.parent.thread_id() == Some(thread_id) {
|
||||
return Some(&mut self.parent);
|
||||
}
|
||||
self.side
|
||||
.as_mut()
|
||||
.filter(|pane| pane.thread_id() == Some(thread_id))
|
||||
}
|
||||
|
||||
pub(super) fn contains_thread(&self, thread_id: ThreadId) -> bool {
|
||||
self.by_thread_id(thread_id).is_some()
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_to(&mut self, origin: ConversationOrigin) -> bool {
|
||||
if self.by_origin(origin).is_none() {
|
||||
return false;
|
||||
}
|
||||
self.dispatch = Some(origin.pane);
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn clear_dispatch(&mut self) -> Option<PaneSlot> {
|
||||
self.dispatch.take()
|
||||
}
|
||||
|
||||
pub(super) fn finish_dispatch(&mut self, origin: ConversationOrigin) -> bool {
|
||||
match self.clear_dispatch() {
|
||||
Some(slot) => slot == origin.pane,
|
||||
None => self.by_origin(origin).is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn selected_mut(&mut self) -> &mut ConversationPane {
|
||||
&mut self.parent
|
||||
let slot = self.dispatch.unwrap_or(self.focused);
|
||||
self.by_slot_mut(slot)
|
||||
.expect("focused or dispatch pane must be installed")
|
||||
}
|
||||
|
||||
pub(super) fn has_thread_event_receiver(&self) -> bool {
|
||||
self.parent.active_thread_rx.is_some()
|
||||
|| self
|
||||
.side
|
||||
.as_ref()
|
||||
.is_some_and(|pane| pane.active_thread_rx.is_some())
|
||||
}
|
||||
|
||||
/// Waits fairly for an event from either installed pane.
|
||||
pub(super) async fn recv_thread_event(&mut self) -> (PaneSlot, Option<ThreadBufferedEvent>) {
|
||||
let parent_rx = self.parent.active_thread_rx.as_mut();
|
||||
let side_rx = self
|
||||
.side
|
||||
.as_mut()
|
||||
.and_then(|pane| pane.active_thread_rx.as_mut());
|
||||
|
||||
match (parent_rx, side_rx) {
|
||||
(Some(parent_rx), Some(side_rx)) => {
|
||||
tokio::select! {
|
||||
event = parent_rx.recv() => (PaneSlot::Parent, event),
|
||||
event = side_rx.recv() => (PaneSlot::Side, event),
|
||||
}
|
||||
}
|
||||
(Some(parent_rx), None) => (PaneSlot::Parent, parent_rx.recv().await),
|
||||
(None, Some(side_rx)) => (PaneSlot::Side, side_rx.recv().await),
|
||||
(None, None) => (self.focused, None),
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(&self) -> &ConversationPane {
|
||||
let slot = self.dispatch.unwrap_or(self.focused);
|
||||
self.by_slot(slot)
|
||||
.expect("focused or dispatch pane must be installed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +266,16 @@ impl Deref for ConversationPanes {
|
||||
type Target = ConversationPane;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.parent
|
||||
self.selected()
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for ConversationPanes {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.parent
|
||||
self.selected_mut()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "conversation_panes_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use super::*;
|
||||
use crate::app_event::HistoryLookupResponse;
|
||||
use crate::chatwidget::tests::constructor::make_chatwidget_for_pane;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
async fn pane_init(
|
||||
slot: PaneSlot,
|
||||
) -> (
|
||||
ConversationPaneInit,
|
||||
tokio::sync::mpsc::UnboundedReceiver<crate::app_event::AppEvent>,
|
||||
) {
|
||||
let (chat_widget, rx) = make_chatwidget_for_pane(slot).await;
|
||||
let file_search = FileSearchManager::new(
|
||||
chat_widget.config_ref().cwd.to_path_buf(),
|
||||
chat_widget.conversation_event_sender(),
|
||||
);
|
||||
(
|
||||
ConversationPaneInit {
|
||||
chat_widget,
|
||||
file_search,
|
||||
owned_screen: None,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
fn buffered_event(offset: usize) -> ThreadBufferedEvent {
|
||||
ThreadBufferedEvent::HistoryEntryResponse(HistoryLookupResponse {
|
||||
offset,
|
||||
log_id: offset as u64,
|
||||
entry: Some(format!("entry {offset}")),
|
||||
})
|
||||
}
|
||||
|
||||
fn event_offset(event: ThreadBufferedEvent) -> usize {
|
||||
let ThreadBufferedEvent::HistoryEntryResponse(response) = event else {
|
||||
panic!("expected history entry response");
|
||||
};
|
||||
response.offset
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn focus_and_dispatch_select_different_panes() {
|
||||
let (parent, _parent_rx) = pane_init(PaneSlot::Parent).await;
|
||||
let (side, _side_rx) = pane_init(PaneSlot::Side).await;
|
||||
let Ok(mut panes) = ConversationPanes::new_parent(parent) else {
|
||||
panic!("parent pane should install");
|
||||
};
|
||||
assert!(matches!(panes.install_side(side), Ok(None)));
|
||||
let parent_origin = panes
|
||||
.by_slot(PaneSlot::Parent)
|
||||
.and_then(ConversationPane::origin)
|
||||
.expect("parent origin");
|
||||
let side_origin = panes
|
||||
.by_slot(PaneSlot::Side)
|
||||
.and_then(ConversationPane::origin)
|
||||
.expect("side origin");
|
||||
|
||||
assert!(panes.focus(PaneSlot::Side));
|
||||
assert_eq!(panes.focused_slot(), PaneSlot::Side);
|
||||
assert_eq!(panes.conversation_origin(), Some(side_origin));
|
||||
assert!(panes.dispatch_to(parent_origin));
|
||||
assert_eq!(panes.conversation_origin(), Some(parent_origin));
|
||||
assert_eq!(panes.clear_dispatch(), Some(PaneSlot::Parent));
|
||||
assert_eq!(panes.conversation_origin(), Some(side_origin));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn taking_focused_side_restores_parent_selection() {
|
||||
let (parent, _parent_rx) = pane_init(PaneSlot::Parent).await;
|
||||
let (side, _side_rx) = pane_init(PaneSlot::Side).await;
|
||||
let Ok(mut panes) = ConversationPanes::new_parent(parent) else {
|
||||
panic!("parent pane should install");
|
||||
};
|
||||
assert!(panes.install_side(side).is_ok());
|
||||
assert!(panes.focus(PaneSlot::Side));
|
||||
let side_origin = panes.conversation_origin().expect("side origin");
|
||||
assert!(panes.dispatch_to(side_origin));
|
||||
|
||||
let removed = panes.take_side().expect("installed side pane");
|
||||
|
||||
assert_eq!(removed.origin(), Some(side_origin));
|
||||
assert_eq!(panes.focused_slot(), PaneSlot::Parent);
|
||||
assert_eq!(panes.clear_dispatch(), None);
|
||||
assert!(panes.finish_dispatch(side_origin));
|
||||
assert!(panes.by_slot(PaneSlot::Side).is_none());
|
||||
assert_eq!(
|
||||
panes.conversation_origin().map(|origin| origin.pane),
|
||||
Some(PaneSlot::Parent)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_closed_receiver_does_not_starve_the_other_pane() {
|
||||
let (parent, _parent_app_rx) = pane_init(PaneSlot::Parent).await;
|
||||
let (side, _side_app_rx) = pane_init(PaneSlot::Side).await;
|
||||
let Ok(mut panes) = ConversationPanes::new_parent(parent) else {
|
||||
panic!("parent pane should install");
|
||||
};
|
||||
assert!(panes.install_side(side).is_ok());
|
||||
let (parent_tx, parent_rx) = mpsc::channel(/*buffer*/ 1);
|
||||
let (side_tx, side_rx) = mpsc::channel(/*buffer*/ 1);
|
||||
panes
|
||||
.by_slot_mut(PaneSlot::Parent)
|
||||
.expect("parent pane")
|
||||
.attach_thread(ThreadId::new(), Some(parent_rx));
|
||||
panes
|
||||
.by_slot_mut(PaneSlot::Side)
|
||||
.expect("side pane")
|
||||
.attach_thread(ThreadId::new(), Some(side_rx));
|
||||
|
||||
drop(parent_tx);
|
||||
let (slot, event) = panes.recv_thread_event().await;
|
||||
assert_eq!(slot, PaneSlot::Parent);
|
||||
assert!(event.is_none());
|
||||
panes
|
||||
.by_slot_mut(PaneSlot::Parent)
|
||||
.expect("parent pane")
|
||||
.clear_thread();
|
||||
side_tx
|
||||
.send(buffered_event(/*offset*/ 3))
|
||||
.await
|
||||
.expect("side receiver");
|
||||
|
||||
let (slot, event) = panes.recv_thread_event().await;
|
||||
assert_eq!(slot, PaneSlot::Side);
|
||||
assert_eq!(event_offset(event.expect("side event")), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn both_pane_receivers_are_serviced() {
|
||||
let (parent, _parent_app_rx) = pane_init(PaneSlot::Parent).await;
|
||||
let (side, _side_app_rx) = pane_init(PaneSlot::Side).await;
|
||||
let Ok(mut panes) = ConversationPanes::new_parent(parent) else {
|
||||
panic!("parent pane should install");
|
||||
};
|
||||
assert!(panes.install_side(side).is_ok());
|
||||
let (parent_tx, parent_rx) = mpsc::channel(/*buffer*/ 1);
|
||||
let (side_tx, side_rx) = mpsc::channel(/*buffer*/ 1);
|
||||
let parent_thread = ThreadId::new();
|
||||
let side_thread = ThreadId::new();
|
||||
panes
|
||||
.by_slot_mut(PaneSlot::Parent)
|
||||
.expect("parent pane")
|
||||
.attach_thread(parent_thread, Some(parent_rx));
|
||||
panes
|
||||
.by_slot_mut(PaneSlot::Side)
|
||||
.expect("side pane")
|
||||
.attach_thread(side_thread, Some(side_rx));
|
||||
parent_tx
|
||||
.send(buffered_event(/*offset*/ 1))
|
||||
.await
|
||||
.expect("parent receiver");
|
||||
side_tx
|
||||
.send(buffered_event(/*offset*/ 2))
|
||||
.await
|
||||
.expect("side receiver");
|
||||
|
||||
let first = panes.recv_thread_event().await;
|
||||
let second = panes.recv_thread_event().await;
|
||||
let mut received = [
|
||||
(first.0, event_offset(first.1.expect("first event"))),
|
||||
(second.0, event_offset(second.1.expect("second event"))),
|
||||
];
|
||||
received.sort_by_key(|(slot, _)| match slot {
|
||||
PaneSlot::Parent => 0,
|
||||
PaneSlot::Side => 1,
|
||||
});
|
||||
|
||||
assert_eq!(received, [(PaneSlot::Parent, 1), (PaneSlot::Side, 2)]);
|
||||
assert!(panes.contains_thread(parent_thread));
|
||||
assert!(panes.contains_thread(side_thread));
|
||||
assert!(panes.has_thread_event_receiver());
|
||||
}
|
||||
@@ -20,14 +20,18 @@ impl App {
|
||||
app_server: &mut AppServerSession,
|
||||
event: AppEvent,
|
||||
) -> Result<AppRunControl> {
|
||||
let Some((conversation_origin, event)) =
|
||||
normalize_conversation_event(event, self.chat_widget.conversation_origin())
|
||||
else {
|
||||
let Some((conversation_origin, event)) = normalize_conversation_event(event, |origin| {
|
||||
self.chat_widget.by_origin(origin).is_some()
|
||||
}) else {
|
||||
tracing::debug!("dropping event from retired conversation");
|
||||
return Ok(AppRunControl::Continue);
|
||||
};
|
||||
|
||||
let previous_sender = if let Some(origin) = conversation_origin {
|
||||
if !self.chat_widget.dispatch_to(origin) {
|
||||
tracing::debug!(?origin, "dropping event from unavailable conversation pane");
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
let sender = self.chat_widget.conversation_event_sender();
|
||||
debug_assert_eq!(sender.conversation_origin(), Some(origin));
|
||||
Some(std::mem::replace(&mut self.app_event_tx, sender))
|
||||
@@ -38,6 +42,9 @@ impl App {
|
||||
if let Some(previous_sender) = previous_sender {
|
||||
self.app_event_tx = previous_sender;
|
||||
}
|
||||
if let Some(origin) = conversation_origin {
|
||||
debug_assert!(self.chat_widget.finish_dispatch(origin));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
@@ -839,7 +839,7 @@ impl App {
|
||||
|
||||
let mut pending_thread_ids = Vec::new();
|
||||
for (thread_id, store) in channels {
|
||||
if Some(thread_id) == self.chat_widget.active_thread_id
|
||||
if self.chat_widget.contains_thread(thread_id)
|
||||
|| Some(thread_id) == side_parent_thread_id
|
||||
{
|
||||
continue;
|
||||
@@ -1020,7 +1020,7 @@ impl App {
|
||||
thread_id: ThreadId,
|
||||
request: ServerRequest,
|
||||
) -> Result<()> {
|
||||
let inactive_interactive_request = if self.chat_widget.active_thread_id != Some(thread_id) {
|
||||
let inactive_interactive_request = if !self.chat_widget.contains_thread(thread_id) {
|
||||
self.interactive_request_for_thread_request(thread_id, &request)
|
||||
.await?
|
||||
} else {
|
||||
@@ -1509,6 +1509,52 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn clear_pane_thread(&mut self, pane: PaneSlot) {
|
||||
let Some(active_thread_id) = self
|
||||
.chat_widget
|
||||
.by_slot(pane)
|
||||
.and_then(|pane| pane.active_thread_id)
|
||||
else {
|
||||
if let Some(pane) = self.chat_widget.by_slot_mut(pane) {
|
||||
pane.clear_thread();
|
||||
}
|
||||
return;
|
||||
};
|
||||
self.set_thread_active(active_thread_id, /*active*/ false)
|
||||
.await;
|
||||
if let Some(pane) = self.chat_widget.by_slot_mut(pane) {
|
||||
pane.clear_thread();
|
||||
}
|
||||
self.refresh_pending_thread_approvals().await;
|
||||
}
|
||||
|
||||
pub(super) async fn handle_pane_thread_event(
|
||||
&mut self,
|
||||
tui: &mut tui::Tui,
|
||||
app_server: &mut AppServerSession,
|
||||
pane: PaneSlot,
|
||||
event: ThreadBufferedEvent,
|
||||
) -> Result<()> {
|
||||
let Some(origin) = self
|
||||
.chat_widget
|
||||
.by_slot(pane)
|
||||
.and_then(|pane| pane.origin())
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if !self.chat_widget.dispatch_to(origin) {
|
||||
return Ok(());
|
||||
}
|
||||
let sender = self.chat_widget.conversation_event_sender();
|
||||
let previous_sender = std::mem::replace(&mut self.app_event_tx, sender);
|
||||
let result = self
|
||||
.handle_active_thread_event(tui, app_server, event)
|
||||
.await;
|
||||
self.app_event_tx = previous_sender;
|
||||
debug_assert!(self.chat_widget.finish_dispatch(origin));
|
||||
result
|
||||
}
|
||||
|
||||
/// Handles an event emitted by the currently active thread.
|
||||
///
|
||||
/// This function enforces shutdown intent routing: unexpected non-primary
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
use super::*;
|
||||
|
||||
impl ChatWidget {
|
||||
pub(crate) fn new_with_app_event(mut common: ChatWidgetInit) -> Self {
|
||||
common.app_event_tx = common.app_event_tx.scoped_to_conversation(PaneSlot::Parent);
|
||||
pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self {
|
||||
Self::new_with_app_event_for_pane(common, PaneSlot::Parent)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn new_with_app_event_for_pane(mut common: ChatWidgetInit, pane: PaneSlot) -> Self {
|
||||
common.app_event_tx = common.app_event_tx.scoped_to_conversation(pane);
|
||||
Self::new_with_op_target(common, CodexOpTarget::AppEvent)
|
||||
}
|
||||
|
||||
|
||||
@@ -236,6 +236,7 @@ mod approval_requests;
|
||||
mod composer_submission;
|
||||
#[path = "tests/config_errors_tests.rs"]
|
||||
mod config_errors;
|
||||
pub(crate) mod constructor;
|
||||
mod exec_flow;
|
||||
mod goal_menu;
|
||||
mod goal_validation;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
use super::*;
|
||||
|
||||
async fn test_init() -> (
|
||||
ChatWidgetInit,
|
||||
tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
) {
|
||||
let config = test_config().await;
|
||||
let model = get_model_offline_for_tests(config.model.as_deref());
|
||||
let session_telemetry = test_session_telemetry(&config, model.as_str());
|
||||
let model_catalog = test_model_catalog(&config);
|
||||
let (tx, rx) = unbounded_channel();
|
||||
|
||||
(
|
||||
ChatWidgetInit {
|
||||
config,
|
||||
frame_requester: FrameRequester::test_dummy(),
|
||||
app_event_tx: AppEventSender::new(tx),
|
||||
workspace_command_runner: None,
|
||||
initial_user_message: None,
|
||||
enhanced_keys_supported: false,
|
||||
has_chatgpt_account: false,
|
||||
has_codex_backend_auth: false,
|
||||
model_catalog,
|
||||
feedback: codex_feedback::CodexFeedback::new(),
|
||||
is_first_run: false,
|
||||
status_account_display: None,
|
||||
runtime_model_provider_base_url: None,
|
||||
initial_plan_type: None,
|
||||
model: Some(model),
|
||||
startup_tooltip_override: None,
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(/*v*/ false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(/*v*/ false)),
|
||||
session_telemetry,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn make_chatwidget_for_pane(
|
||||
pane: PaneSlot,
|
||||
) -> (ChatWidget, tokio::sync::mpsc::UnboundedReceiver<AppEvent>) {
|
||||
let (init, rx) = test_init().await;
|
||||
(ChatWidget::new_with_app_event_for_pane(init, pane), rx)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_event_constructor_defaults_to_parent_pane() {
|
||||
let (init, _rx) = test_init().await;
|
||||
let widget = ChatWidget::new_with_app_event(init);
|
||||
|
||||
assert_eq!(
|
||||
widget.conversation_origin().map(|origin| origin.pane),
|
||||
Some(PaneSlot::Parent)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_event_constructor_uses_requested_pane_and_fresh_generation() {
|
||||
let (first, _first_rx) = make_chatwidget_for_pane(PaneSlot::Side).await;
|
||||
let (second, _second_rx) = make_chatwidget_for_pane(PaneSlot::Side).await;
|
||||
let first_origin = first.conversation_origin().expect("scoped first widget");
|
||||
let second_origin = second.conversation_origin().expect("scoped second widget");
|
||||
|
||||
assert_eq!(first_origin.pane, PaneSlot::Side);
|
||||
assert_eq!(second_origin.pane, PaneSlot::Side);
|
||||
assert_ne!(first_origin.generation, second_origin.generation);
|
||||
}
|
||||
Reference in New Issue
Block a user