fix(tui): interjection delivery, turn cancel on agent switch, resume-or-new
Three turn-lifecycle bugs Casey hit in daily use.
④ Interjections: the backend drains the queue between tool rounds and
after the final LLM call, but one arriving during the chunked
stream-out — or after the turn ended — just sat in the queue, never
delivered ("she isn't getting that message"). On turn completion
(Done / channel close) any still-queued interjections now open a
fresh turn. submit() is split into spawn_turn() so this delivery
reuses the Interjection bubbles instead of duplicating a User bubble.
⑤ Agent switch mid-stream: select_agent() dropped the chat surface
without cancelling the running turn, so run_turn kept executing
orphaned and resurfaced as "doing the same message again" on a later
/resume. select_agent() now fires the turn's cancel token first
(cancel_active_turn) — the backend commits partial output and stops.
⑥ Resume-or-new: entering chat silently auto-created a conversation.
ChatState::connect now offers it — offer_resume_or_new() lists the
agent's prior conversations (excluding the just-created empty one)
and raises the ConversationPicker with an [n] new option; silent
when the agent has no prior conversation.
This commit is contained in:
parent
a2d89036c2
commit
332dd4bf65
2 changed files with 115 additions and 13 deletions
|
|
@ -328,6 +328,12 @@ impl App {
|
||||||
self.agent_status.name = agent_name.to_string();
|
self.agent_status.name = agent_name.to_string();
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
|
// Cancel any in-flight turn before dropping the chat surface —
|
||||||
|
// otherwise the turn keeps running orphaned and resurfaces as
|
||||||
|
// "she's doing the same message again" on a later /resume.
|
||||||
|
if let Some(chat) = &self.chat {
|
||||||
|
chat.cancel_active_turn();
|
||||||
|
}
|
||||||
self.chat = None;
|
self.chat = None;
|
||||||
self.chat_error = None;
|
self.chat_error = None;
|
||||||
self.settings = None;
|
self.settings = None;
|
||||||
|
|
@ -1142,6 +1148,12 @@ impl App {
|
||||||
chat.accept_conversation_pick();
|
chat.accept_conversation_pick();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('n') => {
|
||||||
|
// Keep the fresh conversation connect() already made.
|
||||||
|
chat.overlay = Overlay::None;
|
||||||
|
chat.system_message("New conversation.".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
chat.overlay = Overlay::None;
|
chat.overlay = Overlay::None;
|
||||||
return;
|
return;
|
||||||
|
|
@ -1247,7 +1259,10 @@ impl App {
|
||||||
// Chat — lazily connect to a backend on first entry.
|
// Chat — lazily connect to a backend on first entry.
|
||||||
if self.chat.is_none() {
|
if self.chat.is_none() {
|
||||||
match ChatState::connect(self.config.clone(), &self.agent_pref).await {
|
match ChatState::connect(self.config.clone(), &self.agent_pref).await {
|
||||||
Ok(c) => {
|
Ok(mut c) => {
|
||||||
|
// Fresh chat surface (first entry, or after an
|
||||||
|
// agent switch): offer resume-or-new.
|
||||||
|
c.offer_resume_or_new();
|
||||||
self.chat = Some(c);
|
self.chat = Some(c);
|
||||||
self.chat_error = None;
|
self.chat_error = None;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
src/ui/chat.rs
111
src/ui/chat.rs
|
|
@ -406,6 +406,9 @@ pub struct ChatState {
|
||||||
pub switch_rx: Option<oneshot::Receiver<Result<(String, Vec<crate::core::session::ConversationMessage>)>>>,
|
pub switch_rx: Option<oneshot::Receiver<Result<(String, Vec<crate::core::session::ConversationMessage>)>>>,
|
||||||
/// Conversation ID waiting for the current turn to finalize before loading.
|
/// Conversation ID waiting for the current turn to finalize before loading.
|
||||||
pub switch_pending: Option<String>,
|
pub switch_pending: Option<String>,
|
||||||
|
/// True when the conversation list was requested as an on-entry
|
||||||
|
/// resume-or-new offer — silent on empty, raises the picker on non-empty.
|
||||||
|
pub resume_offer: bool,
|
||||||
/// `/btw` fork state — an ephemeral side-quest conversation running
|
/// `/btw` fork state — an ephemeral side-quest conversation running
|
||||||
/// in parallel to the main chat. Rendered as a floating bordered pane.
|
/// in parallel to the main chat. Rendered as a floating bordered pane.
|
||||||
pub btw_state: BtwState,
|
pub btw_state: BtwState,
|
||||||
|
|
@ -511,6 +514,7 @@ impl ChatState {
|
||||||
convos_rx: None,
|
convos_rx: None,
|
||||||
switch_rx: None,
|
switch_rx: None,
|
||||||
switch_pending: None,
|
switch_pending: None,
|
||||||
|
resume_offer: false,
|
||||||
btw_state: BtwState::Idle,
|
btw_state: BtwState::Idle,
|
||||||
btw_rx: None,
|
btw_rx: None,
|
||||||
tool_cards_expanded: false,
|
tool_cards_expanded: false,
|
||||||
|
|
@ -591,13 +595,21 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal chat message
|
// Normal chat message — a User bubble, then a turn for it.
|
||||||
let text = trimmed;
|
let text = trimmed;
|
||||||
let ts = Instant::now();
|
self.messages.push(ChatMessage::User { text: text.clone(), ts: Instant::now() });
|
||||||
self.messages.push(ChatMessage::User { text: text.clone(), ts });
|
self.spawn_turn(text);
|
||||||
// No pre-created Assistant bubble — if the agent starts with tool
|
true
|
||||||
// calls, the empty bubble would finalize as a tiny blank box.
|
}
|
||||||
// `append_streaming()` creates one on first text token.
|
|
||||||
|
/// Start a backend turn for `text`: set up the turn channel, cancel
|
||||||
|
/// token, and relay task. Does NOT push a user bubble — the caller owns
|
||||||
|
/// what the user sees (a normal `User` bubble for [`submit`], or the
|
||||||
|
/// already-shown `Interjection` bubbles for a deferred interjection
|
||||||
|
/// delivery). No pre-created Assistant bubble — `append_streaming()`
|
||||||
|
/// makes one on the first text token, so a tool-first turn doesn't
|
||||||
|
/// finalize a blank box.
|
||||||
|
fn spawn_turn(&mut self, text: String) {
|
||||||
self.busy = true;
|
self.busy = true;
|
||||||
self.tool_calls_this_turn = 0;
|
self.tool_calls_this_turn = 0;
|
||||||
self.phase = TurnPhase::Thinking;
|
self.phase = TurnPhase::Thinking;
|
||||||
|
|
@ -607,7 +619,8 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
|
||||||
let (tx, rx) = mpsc::channel::<BackendEvent>(64);
|
let (tx, rx) = mpsc::channel::<BackendEvent>(64);
|
||||||
self.turn_rx = Some(rx);
|
self.turn_rx = Some(rx);
|
||||||
|
|
||||||
// Cancellation token for this turn — Esc fires it.
|
// Cancellation token for this turn — Esc raises a hand, an agent
|
||||||
|
// switch cancels it outright (see cancel_active_turn).
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
self.cancel_token = Some(cancel.clone());
|
self.cancel_token = Some(cancel.clone());
|
||||||
|
|
||||||
|
|
@ -641,7 +654,47 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
|
||||||
}
|
}
|
||||||
let _ = tx.send(BackendEvent::Done).await;
|
let _ = tx.send(BackendEvent::Done).await;
|
||||||
});
|
});
|
||||||
true
|
}
|
||||||
|
|
||||||
|
/// Cancel any in-flight turn — used when the chat surface is torn down
|
||||||
|
/// (an agent switch) so the turn does not keep running orphaned, still
|
||||||
|
/// working the message the user has already walked away from. The
|
||||||
|
/// backend reads the cancel token, commits whatever partial output
|
||||||
|
/// exists, and stops making new calls.
|
||||||
|
pub fn cancel_active_turn(&self) {
|
||||||
|
if let Some(token) = &self.cancel_token {
|
||||||
|
if !token.is_cancelled() {
|
||||||
|
token.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deliver interjections the backend did not consume before the turn
|
||||||
|
/// ended. The user typed them while she was working; if her turn
|
||||||
|
/// finished without picking them up, they must still reach her — so we
|
||||||
|
/// open a fresh turn carrying them, rather than leaving them stranded
|
||||||
|
/// in the queue (the bug where "she isn't getting that message").
|
||||||
|
fn deliver_pending_interjections(&mut self) {
|
||||||
|
if self.busy {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pending: Vec<String> = self
|
||||||
|
.pending_interjections
|
||||||
|
.lock()
|
||||||
|
.ok()
|
||||||
|
.map(|mut q| q.drain(..).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if pending.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The Interjection bubbles already show what the user said — mark
|
||||||
|
// them delivered rather than pushing a duplicate User bubble.
|
||||||
|
for msg in &mut self.messages {
|
||||||
|
if let ChatMessage::Interjection { delivered, .. } = msg {
|
||||||
|
*delivered = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.spawn_turn(pending.join("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_slash_command(&mut self, input: &str) -> bool {
|
fn handle_slash_command(&mut self, input: &str) -> bool {
|
||||||
|
|
@ -742,6 +795,23 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
|
||||||
self.new_conv_rx = Some(rx);
|
self.new_conv_rx = Some(rx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// On chat entry, quietly check whether this agent has earlier
|
||||||
|
/// conversations. If it does, the resolved list raises the
|
||||||
|
/// ConversationPicker so the user chooses resume-or-new; if it does not,
|
||||||
|
/// nothing is shown and the fresh conversation simply begins. Unlike
|
||||||
|
/// `/resume`, this prints no "loading" / "none" chatter.
|
||||||
|
pub fn offer_resume_or_new(&mut self) {
|
||||||
|
let backend = self.backend.clone();
|
||||||
|
let agent_id = self.agent_id.clone();
|
||||||
|
let (tx, rx) = oneshot::channel();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = backend.list_conversations(&agent_id).await;
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
self.resume_offer = true;
|
||||||
|
self.convos_rx = Some(rx);
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_list_conversations(&mut self) {
|
fn handle_list_conversations(&mut self) {
|
||||||
let backend = self.backend.clone();
|
let backend = self.backend.clone();
|
||||||
let agent_id = self.agent_id.clone();
|
let agent_id = self.agent_id.clone();
|
||||||
|
|
@ -959,10 +1029,19 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
|
||||||
// Check for /resume conversation list result → show picker overlay
|
// Check for /resume conversation list result → show picker overlay
|
||||||
if let Some(rx) = self.convos_rx.as_mut() {
|
if let Some(rx) = self.convos_rx.as_mut() {
|
||||||
if let Ok(result) = rx.try_recv() {
|
if let Ok(result) = rx.try_recv() {
|
||||||
|
// `offer` = this list was an on-entry resume-or-new check, not
|
||||||
|
// an explicit `/resume`. It stays silent when there's nothing
|
||||||
|
// to resume, and excludes the just-created empty conversation.
|
||||||
|
let offer = std::mem::take(&mut self.resume_offer);
|
||||||
match result {
|
match result {
|
||||||
Ok(convos) => {
|
Ok(mut convos) => {
|
||||||
|
if offer {
|
||||||
|
convos.retain(|c| c.id != self.conversation_id);
|
||||||
|
}
|
||||||
if convos.is_empty() {
|
if convos.is_empty() {
|
||||||
self.system_message("No saved conversations.".to_string());
|
if !offer {
|
||||||
|
self.system_message("No saved conversations.".to_string());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.overlay = Overlay::ConversationPicker {
|
self.overlay = Overlay::ConversationPicker {
|
||||||
selected: 0,
|
selected: 0,
|
||||||
|
|
@ -971,7 +1050,9 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
self.system_message(format!("Failed to list conversations: {}", e));
|
if !offer {
|
||||||
|
self.system_message(format!("Failed to list conversations: {}", e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.convos_rx = None;
|
self.convos_rx = None;
|
||||||
|
|
@ -1244,6 +1325,10 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
|
||||||
self.tool_calls_this_turn = 0;
|
self.tool_calls_this_turn = 0;
|
||||||
if let Some(conv_id) = self.switch_pending.take() {
|
if let Some(conv_id) = self.switch_pending.take() {
|
||||||
self.initiate_switch_load(conv_id);
|
self.initiate_switch_load(conv_id);
|
||||||
|
} else {
|
||||||
|
// Turn ended — any interjection that arrived too late
|
||||||
|
// to be drained gets its own fresh turn now.
|
||||||
|
self.deliver_pending_interjections();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1260,6 +1345,8 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
|
||||||
self.tool_calls_this_turn = 0;
|
self.tool_calls_this_turn = 0;
|
||||||
if let Some(conv_id) = self.switch_pending.take() {
|
if let Some(conv_id) = self.switch_pending.take() {
|
||||||
self.initiate_switch_load(conv_id);
|
self.initiate_switch_load(conv_id);
|
||||||
|
} else {
|
||||||
|
self.deliver_pending_interjections();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2803,7 +2890,7 @@ fn draw_overlay(f: &mut Frame, state: &ChatState, full_area: Rect, input_area: R
|
||||||
let inner_width = (width as usize).saturating_sub(4);
|
let inner_width = (width as usize).saturating_sub(4);
|
||||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
lines.push(Line::from(Span::styled(
|
lines.push(Line::from(Span::styled(
|
||||||
" Conversations — ↑↓ select · Enter switch · Esc cancel",
|
" Conversations — ↑↓ select · Enter resume · n new · Esc dismiss",
|
||||||
Style::default().fg(state.palette.agent_dim).add_modifier(Modifier::ITALIC),
|
Style::default().fg(state.palette.agent_dim).add_modifier(Modifier::ITALIC),
|
||||||
)));
|
)));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue