From e17e272d248a6b036ac11684b6379645db65cc2f Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 9 Jun 2026 13:34:38 +0200 Subject: [PATCH] agent: Improve compaction UX (#58913) Makes it so that we show "Compaction cancelled" when the user interrupts generation. Also fixes another small issue where the chevron of the expanded state would not reflect if it is expanded/collapsed while there is no summary yet. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 78 ++++++++++++------- crates/agent/src/thread.rs | 48 ++++++++++-- .../src/conversation_view/thread_view.rs | 23 +++--- 3 files changed, 106 insertions(+), 43 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 421a3afb7f0..cff95f0b62a 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -298,12 +298,20 @@ pub enum AgentThreadEntry { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ContextCompactionId(pub Arc); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContextCompactionStatus { + InProgress, + Completed, + Canceled, +} + /// A point in the thread where the conversation history was compacted to free /// up room in the model's context window. The summary can be expanded to inspect /// what the model retained. #[derive(Debug)] pub struct ContextCompaction { pub id: ContextCompactionId, + pub status: ContextCompactionStatus, /// The compaction summary, streamed in as the model produces it. This is /// `None` for provider-native compaction, which produces no summary to show. pub summary: Option>, @@ -313,6 +321,7 @@ pub struct ContextCompaction { pub struct ContextCompactionUpdate { pub id: ContextCompactionId, pub summary_delta: String, + pub status: Option, } impl AgentThreadEntry { @@ -2063,19 +2072,25 @@ impl AcpThread { return; }; - if compaction.summary.is_none() { - compaction.summary = Some(cx.new(|cx| { - Markdown::new( - update.summary_delta.into(), - Some(language_registry), - None, - cx, - ) - })); - } else if let Some(summary) = compaction.summary.clone() { - summary.update(cx, |markdown, cx| { - markdown.append(&update.summary_delta, cx) - }); + if !update.summary_delta.is_empty() { + if compaction.summary.is_none() { + compaction.summary = Some(cx.new(|cx| { + Markdown::new( + update.summary_delta.into(), + Some(language_registry), + None, + cx, + ) + })); + } else if let Some(summary) = compaction.summary.clone() { + summary.update(cx, |markdown, cx| { + markdown.append(&update.summary_delta, cx) + }); + } + } + + if let Some(status) = update.status { + compaction.status = status; } cx.emit(AcpThreadEvent::EntryUpdated(ix)); @@ -2669,7 +2684,7 @@ impl AcpThread { let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled); if canceled { - this.mark_pending_tools_as_canceled(); + this.mark_pending_entries_as_canceled(cx); } if !canceled { @@ -2745,25 +2760,34 @@ impl AcpThread { self.connection.cancel(&self.session_id, cx); Self::flush_streaming_text(&mut self.streaming_text_buffer, cx); - self.mark_pending_tools_as_canceled(); + self.mark_pending_entries_as_canceled(cx); // Wait for the send task to complete cx.background_spawn(turn.send_task) } - fn mark_pending_tools_as_canceled(&mut self) { - for entry in self.entries.iter_mut() { - if let AgentThreadEntry::ToolCall(call) = entry { - let cancel = matches!( - call.status, - ToolCallStatus::Pending - | ToolCallStatus::WaitingForConfirmation { .. } - | ToolCallStatus::InProgress - ); - - if cancel { - call.status = ToolCallStatus::Canceled; + fn mark_pending_entries_as_canceled(&mut self, cx: &mut Context) { + for (ix, entry) in self.entries.iter_mut().enumerate() { + match entry { + AgentThreadEntry::ToolCall(call) => { + let cancel = matches!( + call.status, + ToolCallStatus::Pending + | ToolCallStatus::WaitingForConfirmation { .. } + | ToolCallStatus::InProgress + ); + if cancel { + call.status = ToolCallStatus::Canceled; + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } } + AgentThreadEntry::ContextCompaction(compaction) => { + if compaction.status == ContextCompactionStatus::InProgress { + compaction.status = ContextCompactionStatus::Canceled; + cx.emit(AcpThreadEvent::EntryUpdated(ix)); + } + } + _ => {} } } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 22c96859083..77fb60f2954 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1407,11 +1407,17 @@ impl Thread { ); match info { CompactionInfo::Summary(summary) => { - stream.send_context_compaction(compaction_id.clone()); + stream.send_context_compaction( + compaction_id.clone(), + acp_thread::ContextCompactionStatus::Completed, + ); stream.send_context_compaction_update(compaction_id.clone(), summary); } CompactionInfo::ProviderNative { .. } => { - stream.send_context_compaction(compaction_id); + stream.send_context_compaction( + compaction_id, + acp_thread::ContextCompactionStatus::Completed, + ); } } } @@ -2685,7 +2691,10 @@ impl Thread { ) -> Result> { log::debug!("Running compaction"); let compaction_id = acp_thread::ContextCompactionId(Uuid::new_v4().to_string().into()); - event_stream.send_context_compaction(compaction_id.clone()); + event_stream.send_context_compaction( + compaction_id.clone(), + acp_thread::ContextCompactionStatus::InProgress, + ); let stream = futures::select! { result = model.stream_completion(request, cx).fuse() => result, _ = cancellation_rx.changed().fuse() => { @@ -2749,6 +2758,10 @@ impl Thread { } log::debug!("Compaction succeeded:\n{summary}"); + event_stream.update_context_compaction_status( + compaction_id, + acp_thread::ContextCompactionStatus::Completed, + ); this.update(cx, |this, cx| { let compaction = Arc::new(Message::Compaction(CompactionInfo::Summary(summary.into()))); @@ -4632,10 +4645,18 @@ impl ThreadEventStream { self.0.unbounded_send(Ok(ThreadEvent::Retry(status))).ok(); } - fn send_context_compaction(&self, id: acp_thread::ContextCompactionId) { + fn send_context_compaction( + &self, + id: acp_thread::ContextCompactionId, + status: acp_thread::ContextCompactionStatus, + ) { self.0 .unbounded_send(Ok(ThreadEvent::ContextCompaction( - acp_thread::ContextCompaction { id, summary: None }, + acp_thread::ContextCompaction { + id, + status, + summary: None, + }, ))) .ok(); } @@ -4650,6 +4671,23 @@ impl ThreadEventStream { acp_thread::ContextCompactionUpdate { id, summary_delta: summary_delta.to_string(), + status: None, + }, + ))) + .ok(); + } + + fn update_context_compaction_status( + &self, + id: acp_thread::ContextCompactionId, + status: acp_thread::ContextCompactionStatus, + ) { + self.0 + .unbounded_send(Ok(ThreadEvent::ContextCompactionUpdate( + acp_thread::ContextCompactionUpdate { + id, + summary_delta: String::new(), + status: Some(status), }, ))) .ok(); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 58f2e804c95..000cb5d6c2f 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -3594,16 +3594,13 @@ impl ThreadView { fn render_context_compaction( &self, entry_ix: usize, - total_entries: usize, compaction: &acp_thread::ContextCompaction, window: &Window, cx: &Context, ) -> AnyElement { - let is_compacting = entry_ix + 1 == total_entries - && self.thread.read(cx).status() == acp_thread::ThreadStatus::Generating; + let is_compacting = compaction.status == acp_thread::ContextCompactionStatus::InProgress; let summary = compaction.summary.clone(); - let summary_available = summary.is_some(); - let is_expanded = summary_available && self.expanded_compactions.contains(&entry_ix); + let is_expanded = self.expanded_compactions.contains(&entry_ix); let header = h_flex() .id(("context-compaction", entry_ix)) @@ -3621,10 +3618,12 @@ impl ThreadView { .color(Color::Muted), ) .child( - Label::new(if is_compacting { - "Compacting context…" - } else { - "Context compacted" + Label::new(match compaction.status { + acp_thread::ContextCompactionStatus::InProgress => { + "Compacting context…" + } + acp_thread::ContextCompactionStatus::Completed => "Context compacted", + acp_thread::ContextCompactionStatus::Canceled => "Compaction cancelled", }) .size(LabelSize::Custom(self.tool_name_font_size())) .color(Color::Muted), @@ -3644,7 +3643,9 @@ impl ThreadView { this.toggle_compaction_expansion(entry_ix, cx); })); - if let Some(summary) = summary.filter(|_| is_expanded) { + if let Some(summary) = summary + && is_expanded + { v_flex() .w_full() .child(header) @@ -5653,7 +5654,7 @@ impl ThreadView { self.render_completed_plan(entries, window, cx) } AgentThreadEntry::ContextCompaction(compaction) => { - self.render_context_compaction(entry_ix, total_entries, compaction, window, cx) + self.render_context_compaction(entry_ix, compaction, window, cx) } };