agent_ui: Move fully complete plan to the thread view (#52462)

Danilo Leal created

When a plan generate by the plan tool fully completes, there's no need
for that to be in the activity bar anymore. It's complete and in the
next turn, the agent may come up with another plan and the cycle
restarts. So, this PR moves a fully complete plan to the thread view, so
that it stays as part of a given turn:

<img width="600" height="1858" alt="image"
src="https://github.com/user-attachments/assets/43ad4eb0-49d0-488c-bbbf-ab7956c1dd5a"
/>

The way this PR does this is by adding a new entry to `AgentThreadEntry`
and snapshotting the completed plan so we can display it properly in the
thread.

Release Notes:

- N/A

Change summary

crates/acp_thread/src/acp_thread.rs                  | 37 +++++
crates/agent/src/agent.rs                            |  3 
crates/agent_ui/src/conversation_view/thread_view.rs | 84 +++++++++++++
crates/agent_ui/src/entry_view_state.rs              | 18 ++
4 files changed, 130 insertions(+), 12 deletions(-)

Detailed changes

crates/acp_thread/src/acp_thread.rs 🔗

@@ -160,6 +160,7 @@ pub enum AgentThreadEntry {
     UserMessage(UserMessage),
     AssistantMessage(AssistantMessage),
     ToolCall(ToolCall),
+    CompletedPlan(Vec<PlanEntry>),
 }
 
 impl AgentThreadEntry {
@@ -168,6 +169,7 @@ impl AgentThreadEntry {
             Self::UserMessage(message) => message.indented,
             Self::AssistantMessage(message) => message.indented,
             Self::ToolCall(_) => false,
+            Self::CompletedPlan(_) => false,
         }
     }
 
@@ -176,6 +178,14 @@ impl AgentThreadEntry {
             Self::UserMessage(message) => message.to_markdown(cx),
             Self::AssistantMessage(message) => message.to_markdown(cx),
             Self::ToolCall(tool_call) => tool_call.to_markdown(cx),
+            Self::CompletedPlan(entries) => {
+                let mut md = String::from("## Plan\n\n");
+                for entry in entries {
+                    let source = entry.content.read(cx).source().to_string();
+                    md.push_str(&format!("- [x] {}\n", source));
+                }
+                md
+            }
         }
     }
 
@@ -1298,7 +1308,9 @@ impl AcpThread {
                     status: ToolCallStatus::WaitingForConfirmation { .. },
                     ..
                 }) => return true,
-                AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
+                AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::AssistantMessage(_)
+                | AgentThreadEntry::CompletedPlan(_) => {}
             }
         }
         false
@@ -1320,7 +1332,9 @@ impl AcpThread {
                 ) if call.diffs().next().is_some() => {
                     return true;
                 }
-                AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
+                AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::AssistantMessage(_)
+                | AgentThreadEntry::CompletedPlan(_) => {}
             }
         }
 
@@ -1337,7 +1351,9 @@ impl AcpThread {
                 }) => {
                     return true;
                 }
-                AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
+                AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::AssistantMessage(_)
+                | AgentThreadEntry::CompletedPlan(_) => {}
             }
         }
 
@@ -1348,7 +1364,9 @@ impl AcpThread {
         for entry in self.entries.iter().rev() {
             match entry {
                 AgentThreadEntry::UserMessage(..) => return false,
-                AgentThreadEntry::AssistantMessage(..) => continue,
+                AgentThreadEntry::AssistantMessage(..) | AgentThreadEntry::CompletedPlan(..) => {
+                    continue;
+                }
                 AgentThreadEntry::ToolCall(..) => return true,
             }
         }
@@ -2065,6 +2083,13 @@ impl AcpThread {
         cx.notify();
     }
 
+    pub fn snapshot_completed_plan(&mut self, cx: &mut Context<Self>) {
+        if !self.plan.is_empty() && self.plan.stats().pending == 0 {
+            let completed_entries = std::mem::take(&mut self.plan.entries);
+            self.push_entry(AgentThreadEntry::CompletedPlan(completed_entries), cx);
+        }
+    }
+
     fn clear_completed_plan_entries(&mut self, cx: &mut Context<Self>) {
         self.plan
             .entries
@@ -2223,6 +2248,10 @@ impl AcpThread {
                             this.mark_pending_tools_as_canceled();
                         }
 
+                        if !canceled {
+                            this.snapshot_completed_plan(cx);
+                        }
+
                         // Handle refusal - distinguish between user prompt and tool call refusals
                         if let acp::StopReason::Refusal = r.stop_reason {
                             this.had_error = true;

crates/agent/src/agent.rs 🔗

@@ -942,6 +942,9 @@ impl NativeAgent {
                 NativeAgentConnection::handle_thread_events(events, acp_thread.downgrade(), cx)
             })
             .await?;
+            acp_thread.update(cx, |thread, cx| {
+                thread.snapshot_completed_plan(cx);
+            });
             Ok(acp_thread)
         })
     }

crates/agent_ui/src/conversation_view/thread_view.rs 🔗

@@ -1,7 +1,10 @@
-use crate::{DEFAULT_THREAD_TITLE, SelectPermissionGranularity};
+use crate::{
+    DEFAULT_THREAD_TITLE, SelectPermissionGranularity,
+    agent_configuration::configure_context_server_modal::default_markdown_style,
+};
 use std::cell::RefCell;
 
-use acp_thread::ContentBlock;
+use acp_thread::{ContentBlock, PlanEntry};
 use cloud_api_types::{SubmitAgentThreadFeedbackBody, SubmitAgentThreadFeedbackCommentsBody};
 use editor::actions::OpenExcerpts;
 
@@ -2789,6 +2792,76 @@ impl ThreadView {
             .into_any_element()
     }
 
+    fn render_completed_plan(
+        &self,
+        entries: &[PlanEntry],
+        window: &Window,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        v_flex()
+            .px_5()
+            .py_1p5()
+            .w_full()
+            .child(
+                v_flex()
+                    .w_full()
+                    .rounded_md()
+                    .border_1()
+                    .border_color(self.tool_card_border_color(cx))
+                    .child(
+                        h_flex()
+                            .px_2()
+                            .py_1()
+                            .gap_1()
+                            .bg(self.tool_card_header_bg(cx))
+                            .border_b_1()
+                            .border_color(self.tool_card_border_color(cx))
+                            .child(
+                                Label::new("Completed Plan")
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                            )
+                            .child(
+                                Label::new(format!(
+                                    "— {} {}",
+                                    entries.len(),
+                                    if entries.len() == 1 { "step" } else { "steps" }
+                                ))
+                                .size(LabelSize::Small)
+                                .color(Color::Muted),
+                            ),
+                    )
+                    .child(
+                        v_flex().children(entries.iter().enumerate().map(|(index, entry)| {
+                            h_flex()
+                                .py_1()
+                                .px_2()
+                                .gap_1p5()
+                                .when(index < entries.len() - 1, |this| {
+                                    this.border_b_1().border_color(cx.theme().colors().border)
+                                })
+                                .child(
+                                    Icon::new(IconName::TodoComplete)
+                                        .size(IconSize::Small)
+                                        .color(Color::Success),
+                                )
+                                .child(
+                                    div()
+                                        .max_w_full()
+                                        .overflow_x_hidden()
+                                        .text_xs()
+                                        .text_color(cx.theme().colors().text_muted)
+                                        .child(MarkdownElement::new(
+                                            entry.content.clone(),
+                                            default_markdown_style(window, cx),
+                                        )),
+                                )
+                        })),
+                    ),
+            )
+            .into_any()
+    }
+
     fn render_edits_summary(
         &self,
         changed_buffers: &BTreeMap<Entity<Buffer>, Entity<BufferDiff>>,
@@ -4546,6 +4619,9 @@ impl ThreadView {
                     cx,
                 )
                 .into_any(),
+            AgentThreadEntry::CompletedPlan(entries) => {
+                self.render_completed_plan(entries, window, cx)
+            }
         };
 
         let is_subagent_output = self.is_subagent()
@@ -5411,7 +5487,9 @@ impl ThreadView {
                         return false;
                     }
                 }
-                AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
+                AgentThreadEntry::ToolCall(_)
+                | AgentThreadEntry::AssistantMessage(_)
+                | AgentThreadEntry::CompletedPlan(_) => {}
             }
         }
 

crates/agent_ui/src/entry_view_state.rs 🔗

@@ -235,6 +235,11 @@ impl EntryViewState {
                 };
                 entry.sync(message);
             }
+            AgentThreadEntry::CompletedPlan(_) => {
+                if !matches!(self.entries.get(index), Some(Entry::CompletedPlan)) {
+                    self.set_entry(index, Entry::CompletedPlan);
+                }
+            }
         };
     }
 
@@ -253,7 +258,9 @@ impl EntryViewState {
     pub fn agent_ui_font_size_changed(&mut self, cx: &mut App) {
         for entry in self.entries.iter() {
             match entry {
-                Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {}
+                Entry::UserMessage { .. }
+                | Entry::AssistantMessage { .. }
+                | Entry::CompletedPlan => {}
                 Entry::ToolCall(ToolCallEntry { content }) => {
                     for view in content.values() {
                         if let Ok(diff_editor) = view.clone().downcast::<Editor>() {
@@ -320,6 +327,7 @@ pub enum Entry {
     UserMessage(Entity<MessageEditor>),
     AssistantMessage(AssistantMessageEntry),
     ToolCall(ToolCallEntry),
+    CompletedPlan,
 }
 
 impl Entry {
@@ -327,14 +335,14 @@ impl Entry {
         match self {
             Self::UserMessage(editor) => Some(editor.read(cx).focus_handle(cx)),
             Self::AssistantMessage(message) => Some(message.focus_handle.clone()),
-            Self::ToolCall(_) => None,
+            Self::ToolCall(_) | Self::CompletedPlan => None,
         }
     }
 
     pub fn message_editor(&self) -> Option<&Entity<MessageEditor>> {
         match self {
             Self::UserMessage(editor) => Some(editor),
-            Self::AssistantMessage(_) | Self::ToolCall(_) => None,
+            Self::AssistantMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None,
         }
     }
 
@@ -361,7 +369,7 @@ impl Entry {
     ) -> Option<ScrollHandle> {
         match self {
             Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix),
-            Self::UserMessage(_) | Self::ToolCall(_) => None,
+            Self::UserMessage(_) | Self::ToolCall(_) | Self::CompletedPlan => None,
         }
     }
 
@@ -376,7 +384,7 @@ impl Entry {
     pub fn has_content(&self) -> bool {
         match self {
             Self::ToolCall(ToolCallEntry { content }) => !content.is_empty(),
-            Self::UserMessage(_) | Self::AssistantMessage(_) => false,
+            Self::UserMessage(_) | Self::AssistantMessage(_) | Self::CompletedPlan => false,
         }
     }
 }