Fix thread summarization properly

Mikayla Maki created

Change summary

crates/acp_thread/src/acp_thread.rs                |  8 ++++++++
crates/agent/src/agent.rs                          |  9 +++++++++
crates/agent/src/db.rs                             |  5 +++++
crates/agent/src/thread.rs                         | 14 +++++++++++++-
crates/agent/src/thread_store.rs                   |  1 +
crates/agent_ui/src/connection_view/thread_view.rs |  2 +-
crates/sidebar/src/sidebar.rs                      | 13 +++++++++++++
crates/ui/src/components/ai/thread_item.rs         | 15 +++++++++++++--
crates/zed/src/visual_test_runner.rs               |  1 +
9 files changed, 64 insertions(+), 4 deletions(-)

Detailed changes

crates/acp_thread/src/acp_thread.rs 🔗

@@ -1453,6 +1453,14 @@ impl AcpThread {
     /// (e.g. first 20 chars of the user message) that should be shown
     /// immediately but replaced once the LLM generates a proper title via
     /// `set_title`.
+    pub fn has_provisional_title(&self) -> bool {
+        self.provisional_title.is_some()
+    }
+
+    pub fn provisional_title(&self) -> Option<&SharedString> {
+        self.provisional_title.as_ref()
+    }
+
     pub fn set_provisional_title(&mut self, title: SharedString, cx: &mut Context<Self>) {
         self.provisional_title = Some(title);
         cx.emit(AcpThreadEvent::TitleUpdated);

crates/agent/src/agent.rs 🔗

@@ -332,6 +332,7 @@ impl NativeAgent {
         let parent_session_id = thread.parent_thread_id();
         let title = thread.title();
         let draft_prompt = thread.draft_prompt().map(Vec::from);
+        let provisional_title = thread.provisional_title().cloned();
         let scroll_position = thread.ui_scroll_position();
         let token_usage = thread.latest_token_usage();
         let project = thread.project.clone();
@@ -355,6 +356,12 @@ impl NativeAgent {
             acp_thread
         });
 
+        if let Some(provisional_title) = provisional_title {
+            acp_thread.update(cx, |acp_thread, cx| {
+                acp_thread.set_provisional_title(provisional_title, cx);
+            });
+        }
+
         let registry = LanguageModelRegistry::read_global(cx);
         let summarization_model = registry.thread_summary_model().map(|c| c.model);
 
@@ -980,9 +987,11 @@ impl NativeAgent {
         );
 
         let draft_prompt = session.acp_thread.read(cx).draft_prompt().map(Vec::from);
+        let provisional_title = session.acp_thread.read(cx).provisional_title().cloned();
         let database_future = ThreadsDatabase::connect(cx);
         let db_thread = thread.update(cx, |thread, cx| {
             thread.set_draft_prompt(draft_prompt);
+            thread.set_provisional_title(provisional_title);
             thread.to_db(cx)
         });
         let thread_store = self.thread_store.clone();

crates/agent/src/db.rs 🔗

@@ -81,6 +81,8 @@ pub struct DbThread {
     #[serde(default)]
     pub draft_prompt: Option<Vec<acp::ContentBlock>>,
     #[serde(default)]
+    pub provisional_title: Option<SharedString>,
+    #[serde(default)]
     pub ui_scroll_position: Option<SerializedScrollPosition>,
 }
 
@@ -130,6 +132,7 @@ impl SharedThread {
             thinking_enabled: false,
             thinking_effort: None,
             draft_prompt: None,
+            provisional_title: None,
             ui_scroll_position: None,
         }
     }
@@ -309,6 +312,7 @@ impl DbThread {
             thinking_enabled: false,
             thinking_effort: None,
             draft_prompt: None,
+            provisional_title: None,
             ui_scroll_position: None,
         })
     }
@@ -694,6 +698,7 @@ mod tests {
             thinking_enabled: false,
             thinking_effort: None,
             draft_prompt: None,
+            provisional_title: None,
             ui_scroll_position: None,
         }
     }

crates/agent/src/thread.rs 🔗

@@ -920,6 +920,7 @@ pub struct Thread {
     subagent_context: Option<SubagentContext>,
     /// The user's unsent prompt text, persisted so it can be restored when reloading the thread.
     draft_prompt: Option<Vec<acp::ContentBlock>>,
+    provisional_title: Option<SharedString>,
     ui_scroll_position: Option<gpui::ListOffset>,
     /// Weak references to running subagent threads for cancellation propagation
     running_subagents: Vec<WeakEntity<Thread>>,
@@ -1036,6 +1037,7 @@ impl Thread {
             imported: false,
             subagent_context: None,
             draft_prompt: None,
+            provisional_title: None,
             ui_scroll_position: None,
             running_subagents: Vec::new(),
         }
@@ -1252,6 +1254,7 @@ impl Thread {
             imported: db_thread.imported,
             subagent_context: db_thread.subagent_context,
             draft_prompt: db_thread.draft_prompt,
+            provisional_title: db_thread.provisional_title.clone(),
             ui_scroll_position: db_thread.ui_scroll_position.map(|sp| gpui::ListOffset {
                 item_ix: sp.item_ix,
                 offset_in_item: gpui::px(sp.offset_in_item),
@@ -1263,7 +1266,7 @@ impl Thread {
     pub fn to_db(&self, cx: &App) -> Task<DbThread> {
         let initial_project_snapshot = self.initial_project_snapshot.clone();
         let mut thread = DbThread {
-            title: self.title(),
+            title: self.title.clone().unwrap_or_default(),
             messages: self.messages.clone(),
             updated_at: self.updated_at,
             detailed_summary: self.summary.clone(),
@@ -1281,6 +1284,7 @@ impl Thread {
             thinking_enabled: self.thinking_enabled,
             thinking_effort: self.thinking_effort.clone(),
             draft_prompt: self.draft_prompt.clone(),
+            provisional_title: self.provisional_title.clone(),
             ui_scroll_position: self.ui_scroll_position.map(|lo| {
                 crate::db::SerializedScrollPosition {
                     item_ix: lo.item_ix,
@@ -1336,6 +1340,14 @@ impl Thread {
         self.draft_prompt = prompt;
     }
 
+    pub fn provisional_title(&self) -> Option<&SharedString> {
+        self.provisional_title.as_ref()
+    }
+
+    pub fn set_provisional_title(&mut self, title: Option<SharedString>) {
+        self.provisional_title = title;
+    }
+
     pub fn ui_scroll_position(&self) -> Option<gpui::ListOffset> {
         self.ui_scroll_position
     }

crates/agent/src/thread_store.rs 🔗

@@ -162,6 +162,7 @@ mod tests {
             thinking_enabled: false,
             thinking_effort: None,
             draft_prompt: None,
+            provisional_title: None,
             ui_scroll_position: None,
         }
     }

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

@@ -1010,7 +1010,7 @@ impl ThreadView {
                     .join(" ");
                 let text = text.lines().next().unwrap_or("").trim();
                 if !text.is_empty() {
-                    let title: SharedString = util::truncate_and_trailoff(text, 20).into();
+                    let title: SharedString = text.chars().take(100).collect::<String>().into();
                     thread.update(cx, |thread, cx| {
                         thread.set_provisional_title(title, cx);
                     })?;

crates/sidebar/src/sidebar.rs 🔗

@@ -50,6 +50,7 @@ const DEFAULT_THREADS_SHOWN: usize = 5;
 struct ActiveThreadInfo {
     session_id: acp::SessionId,
     title: SharedString,
+    is_provisional: bool,
     status: AgentThreadStatus,
     icon: IconName,
     icon_from_external_svg: Option<SharedString>,
@@ -78,6 +79,7 @@ struct ThreadEntry {
     workspace: Entity<Workspace>,
     is_live: bool,
     is_background: bool,
+    is_provisional: bool,
     highlight_positions: Vec<usize>,
 }
 
@@ -388,6 +390,7 @@ impl Sidebar {
                 let title = thread.title();
                 let session_id = thread.session_id().clone();
                 let is_background = agent_panel_ref.is_background_thread(&session_id);
+                let is_provisional = thread.has_provisional_title();
 
                 let status = if thread.is_waiting_for_confirmation() {
                     AgentThreadStatus::WaitingForConfirmation
@@ -403,6 +406,7 @@ impl Sidebar {
                 ActiveThreadInfo {
                     session_id,
                     title,
+                    is_provisional,
                     status,
                     icon,
                     icon_from_external_svg,
@@ -463,6 +467,7 @@ impl Sidebar {
                             workspace: workspace.clone(),
                             is_live: false,
                             is_background: false,
+                            is_provisional: false,
                             highlight_positions: Vec::new(),
                         });
                     }
@@ -489,6 +494,7 @@ impl Sidebar {
                         thread.icon_from_external_svg = info.icon_from_external_svg.clone();
                         thread.is_live = true;
                         thread.is_background = info.is_background;
+                        thread.is_provisional = info.is_provisional;
                     }
                 }
 
@@ -1173,6 +1179,7 @@ impl Sidebar {
             .when_some(thread.icon_from_external_svg.clone(), |this, svg| {
                 this.custom_icon_from_external_svg(svg)
             })
+            .provisional(thread.is_provisional)
             .highlight_positions(thread.highlight_positions.to_vec())
             .status(thread.status)
             .notified(has_notification)
@@ -1562,6 +1569,7 @@ mod tests {
             thinking_effort: None,
             draft_prompt: None,
             ui_scroll_position: None,
+            provisional_title: None,
         }
     }
 
@@ -2032,6 +2040,7 @@ mod tests {
                     workspace: workspace.clone(),
                     is_live: false,
                     is_background: false,
+                    is_provisional: false,
                     highlight_positions: Vec::new(),
                 }),
                 // Active thread with Running status
@@ -2050,6 +2059,7 @@ mod tests {
                     workspace: workspace.clone(),
                     is_live: true,
                     is_background: false,
+                    is_provisional: false,
                     highlight_positions: Vec::new(),
                 }),
                 // Active thread with Error status
@@ -2068,6 +2078,7 @@ mod tests {
                     workspace: workspace.clone(),
                     is_live: true,
                     is_background: false,
+                    is_provisional: false,
                     highlight_positions: Vec::new(),
                 }),
                 // Thread with WaitingForConfirmation status, not active
@@ -2086,6 +2097,7 @@ mod tests {
                     workspace: workspace.clone(),
                     is_live: false,
                     is_background: false,
+                    is_provisional: false,
                     highlight_positions: Vec::new(),
                 }),
                 // Background thread that completed (should show notification)
@@ -2104,6 +2116,7 @@ mod tests {
                     workspace: workspace.clone(),
                     is_live: true,
                     is_background: true,
+                    is_provisional: false,
                     highlight_positions: Vec::new(),
                 }),
                 // View More entry

crates/ui/src/components/ai/thread_item.rs 🔗

@@ -29,6 +29,7 @@ pub struct ThreadItem {
     added: Option<usize>,
     removed: Option<usize>,
     worktree: Option<SharedString>,
+    provisional: bool,
     highlight_positions: Vec<usize>,
     worktree_highlight_positions: Vec<usize>,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
@@ -50,6 +51,7 @@ impl ThreadItem {
             selected: false,
             focused: false,
             hovered: false,
+            provisional: false,
             added: None,
             removed: None,
             worktree: None,
@@ -127,6 +129,11 @@ impl ThreadItem {
         self
     }
 
+    pub fn provisional(mut self, provisional: bool) -> Self {
+        self.provisional = provisional;
+        self
+    }
+
     pub fn on_click(
         mut self,
         handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -209,9 +216,13 @@ impl RenderOnce for ThreadItem {
         let title = self.title;
         let highlight_positions = self.highlight_positions;
         let title_label = if highlight_positions.is_empty() {
-            Label::new(title).into_any_element()
+            Label::new(title)
+                .when(self.provisional, |label| label.italic())
+                .into_any_element()
         } else {
-            HighlightedLabel::new(title, highlight_positions).into_any_element()
+            HighlightedLabel::new(title, highlight_positions)
+                .when(self.provisional, |label| label.italic())
+                .into_any_element()
         };
 
         let base_bg = if self.selected {

crates/zed/src/visual_test_runner.rs 🔗

@@ -2719,6 +2719,7 @@ fn run_multi_workspace_sidebar_visual_tests(
                             thinking_effort: None,
                             ui_scroll_position: None,
                             draft_prompt: None,
+                            provisional_title: None,
                         },
                         path_list,
                         cx,