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
This commit is contained in:
Bennet Bo Fenner 2026-06-09 13:34:38 +02:00 committed by GitHub
parent 9baefe701e
commit e17e272d24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 106 additions and 43 deletions

View file

@ -298,12 +298,20 @@ pub enum AgentThreadEntry {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextCompactionId(pub Arc<str>);
#[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<Entity<Markdown>>,
@ -313,6 +321,7 @@ pub struct ContextCompaction {
pub struct ContextCompactionUpdate {
pub id: ContextCompactionId,
pub summary_delta: String,
pub status: Option<ContextCompactionStatus>,
}
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<Self>) {
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));
}
}
_ => {}
}
}
}

View file

@ -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<ControlFlow<()>> {
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();

View file

@ -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<Self>,
) -> 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)
}
};