Retain draft threads in the sidebar

Mikayla Maki created

Change summary

crates/acp_thread/src/acp_thread.rs |   4 
crates/agent_ui/src/agent_panel.rs  |  14 
crates/sidebar/src/sidebar.rs       | 249 ++++++++++++++++++++----------
crates/sidebar/src/sidebar_tests.rs | 206 ++++++++++++++++++------
4 files changed, 327 insertions(+), 146 deletions(-)

Detailed changes

crates/acp_thread/src/acp_thread.rs 🔗

@@ -1276,6 +1276,10 @@ impl AcpThread {
         self.provisional_title.is_some()
     }
 
+    pub fn is_draft(&self) -> bool {
+        self.entries.is_empty()
+    }
+
     pub fn entries(&self) -> &[AgentThreadEntry] {
         &self.entries
     }

crates/agent_ui/src/agent_panel.rs 🔗

@@ -2611,13 +2611,11 @@ impl AgentPanel {
         );
     }
 
-    fn active_thread_has_messages(&self, cx: &App) -> bool {
-        self.active_agent_thread(cx)
-            .is_some_and(|thread| !thread.read(cx).entries().is_empty())
-    }
-
     pub fn active_thread_is_draft(&self, cx: &App) -> bool {
-        self.active_conversation_view().is_some() && !self.active_thread_has_messages(cx)
+        self.active_conversation_view().is_some()
+            && self
+                .active_agent_thread(cx)
+                .map_or(true, |thread| thread.read(cx).is_draft())
     }
 
     fn handle_first_send_requested(
@@ -4090,7 +4088,9 @@ impl AgentPanel {
 
         let show_history_menu = self.has_history_for_selected_agent(cx);
         let has_v2_flag = cx.has_flag::<AgentV2FeatureFlag>();
-        let is_empty_state = !self.active_thread_has_messages(cx);
+        let is_empty_state = !self
+            .active_agent_thread(cx)
+            .is_some_and(|thread| !thread.read(cx).is_draft());
 
         let is_in_history_or_config = matches!(
             &self.active_view,

crates/sidebar/src/sidebar.rs 🔗

@@ -114,14 +114,24 @@ enum ActiveEntry {
         session_id: acp::SessionId,
         workspace: Entity<Workspace>,
     },
-    Draft(Entity<Workspace>),
+    Draft {
+        session_id: Option<acp::SessionId>,
+        workspace: Entity<Workspace>,
+    },
 }
 
 impl ActiveEntry {
+    fn draft_for_workspace(workspace: Entity<Workspace>) -> Self {
+        ActiveEntry::Draft {
+            session_id: None,
+            workspace,
+        }
+    }
+
     fn workspace(&self) -> &Entity<Workspace> {
         match self {
             ActiveEntry::Thread { workspace, .. } => workspace,
-            ActiveEntry::Draft(workspace) => workspace,
+            ActiveEntry::Draft { workspace, .. } => workspace,
         }
     }
 
@@ -129,18 +139,29 @@ impl ActiveEntry {
         matches!(self, ActiveEntry::Thread { session_id: id, .. } if id == session_id)
     }
 
-    fn matches_entry(&self, entry: &ListEntry) -> bool {
+    fn matches_entry(&self, entry: &ListEntry, cx: &App) -> bool {
         match (self, entry) {
             (ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => {
                 thread.metadata.session_id == *session_id
             }
             (
-                ActiveEntry::Draft(workspace),
+                ActiveEntry::Draft {
+                    session_id,
+                    workspace,
+                },
                 ListEntry::NewThread {
                     workspace: entry_workspace,
+                    draft_thread,
                     ..
                 },
-            ) => workspace == entry_workspace,
+            ) => {
+                workspace == entry_workspace
+                    && match (session_id, draft_thread) {
+                        (Some(id), Some(thread)) => thread.read(cx).session_id() == id,
+                        (None, None) => true,
+                        _ => false,
+                    }
+            }
             _ => false,
         }
     }
@@ -224,6 +245,7 @@ enum ListEntry {
         path_list: PathList,
         workspace: Entity<Workspace>,
         worktrees: Vec<WorktreeInfo>,
+        draft_thread: Option<Entity<acp_thread::AcpThread>>,
     },
 }
 
@@ -241,9 +263,13 @@ impl ListEntry {
         }
     }
 
-    fn session_id(&self) -> Option<&acp::SessionId> {
+    fn session_id<'a>(&'a self, cx: &'a App) -> Option<&'a acp::SessionId> {
         match self {
             ListEntry::Thread(thread_entry) => Some(&thread_entry.metadata.session_id),
+            ListEntry::NewThread {
+                draft_thread: Some(thread),
+                ..
+            } => Some(thread.read(cx).session_id()),
             _ => None,
         }
     }
@@ -569,7 +595,8 @@ impl Sidebar {
                             .upgrade()
                             .map(|mw| mw.read(cx).workspace().clone())
                         {
-                            this.active_entry = Some(ActiveEntry::Draft(active_workspace));
+                            this.active_entry =
+                                Some(ActiveEntry::draft_for_workspace(active_workspace));
                         }
                     }
                     this.observe_draft_editor(cx);
@@ -631,48 +658,12 @@ impl Sidebar {
             });
     }
 
-    fn active_draft_text(&self, cx: &App) -> Option<SharedString> {
-        let mw = self.multi_workspace.upgrade()?;
-        let workspace = mw.read(cx).workspace();
-        let panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
-        let conversation_view = panel.read(cx).active_conversation_view()?;
-        let thread_view = conversation_view.read(cx).active_thread()?;
-        let raw = thread_view.read(cx).message_editor.read(cx).text(cx);
-        let cleaned = Self::clean_mention_links(&raw);
-        let mut text: String = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
-        if text.is_empty() {
-            None
-        } else {
-            const MAX_CHARS: usize = 250;
-            if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
-                text.truncate(truncate_at);
-            }
-            Some(text.into())
-        }
-    }
-
-    fn clean_mention_links(input: &str) -> String {
-        let mut result = String::with_capacity(input.len());
-        let mut remaining = input;
-
-        while let Some(start) = remaining.find("[@") {
-            result.push_str(&remaining[..start]);
-            let after_bracket = &remaining[start + 1..]; // skip '['
-            if let Some(close_bracket) = after_bracket.find("](") {
-                let mention = &after_bracket[..close_bracket]; // "@something"
-                let after_link_start = &after_bracket[close_bracket + 2..]; // after "]("
-                if let Some(close_paren) = after_link_start.find(')') {
-                    result.push_str(mention);
-                    remaining = &after_link_start[close_paren + 1..];
-                    continue;
-                }
-            }
-            // Couldn't parse full link syntax — emit the literal "[@" and move on.
-            result.push_str("[@");
-            remaining = &remaining[start + 2..];
-        }
-        result.push_str(remaining);
-        result
+    fn draft_text_from_thread(
+        thread: &Entity<acp_thread::AcpThread>,
+        cx: &App,
+    ) -> Option<SharedString> {
+        let blocks = thread.read(cx).draft_prompt()?;
+        summarize_content_blocks(blocks)
     }
 
     /// Rebuilds the sidebar contents from current workspace and thread state.
@@ -704,14 +695,6 @@ impl Sidebar {
 
         let query = self.filter_editor.read(cx).text(cx);
 
-        // Derive active_entry from the active workspace's agent panel.
-        // Draft is checked first because a conversation can have a session_id
-        // before any messages are sent. However, a thread that's still loading
-        // also appears as a "draft" (no messages yet), so when we already have
-        // an eager Thread write for this workspace we preserve it. A session_id
-        // on a non-draft is a positive Thread signal. The remaining case
-        // (conversation exists, not draft, no session_id) is a genuine
-        // mid-load — keep the previous value.
         if let Some(active_ws) = &active_workspace {
             if let Some(panel) = active_ws.read(cx).panel::<AgentPanel>(cx) {
                 if panel.read(cx).active_thread_is_draft(cx)
@@ -721,7 +704,14 @@ impl Sidebar {
                         matches!(&self.active_entry, Some(ActiveEntry::Thread { .. }))
                             && self.active_entry_workspace() == Some(active_ws);
                     if !preserving_thread {
-                        self.active_entry = Some(ActiveEntry::Draft(active_ws.clone()));
+                        let draft_session_id = panel
+                            .read(cx)
+                            .active_conversation_view()
+                            .and_then(|cv| cv.read(cx).parent_id(cx));
+                        self.active_entry = Some(ActiveEntry::Draft {
+                            session_id: draft_session_id,
+                            workspace: active_ws.clone(),
+                        });
                     }
                 } else if let Some(session_id) = panel
                     .read(cx)
@@ -1007,10 +997,6 @@ impl Sidebar {
                     entries.push(thread.into());
                 }
             } else {
-                let is_draft_for_workspace = is_active
-                    && matches!(&self.active_entry, Some(ActiveEntry::Draft(_)))
-                    && self.active_entry_workspace() == Some(representative_workspace);
-
                 project_header_indices.push(entries.len());
                 entries.push(ListEntry::ProjectHeader {
                     path_list: path_list.clone(),
@@ -1026,28 +1012,92 @@ impl Sidebar {
                     continue;
                 }
 
-                // Emit "New Thread" entries for threadless workspaces
-                // and active drafts, right after the header.
+                // Collect draft threads from agent panels in this group.
+                // Collect draft conversations from agent panels in this
+                // group. A draft is a conversation with no messages but a
+                // valid server session.
+                struct DraftEntry {
+                    workspace: Entity<Workspace>,
+                    thread: Entity<acp_thread::AcpThread>,
+                }
+                let mut draft_entries: Vec<DraftEntry> = Vec::new();
+                for workspace in &group.workspaces {
+                    if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+                        let panel = panel.read(cx);
+                        let conversation_views: Vec<_> = panel
+                            .active_conversation_view()
+                            .into_iter()
+                            .chain(panel.background_threads().values())
+                            .collect();
+                        for cv in conversation_views {
+                            let cv = cv.read(cx);
+                            if let Some(thread_view) = cv.active_thread()
+                                && thread_view.read(cx).thread.read(cx).is_draft()
+                            {
+                                draft_entries.push(DraftEntry {
+                                    workspace: workspace.clone(),
+                                    thread: thread_view.read(cx).thread.clone(),
+                                });
+                            }
+                        }
+                    }
+                }
+
+                // Emit "New Thread" entries for threadless workspaces.
+                // If a threadless workspace has a draft, attach it.
+                let mut used_draft_indices: HashSet<usize> = HashSet::new();
                 for (workspace, worktrees) in &threadless_workspaces {
+                    let draft_index = draft_entries.iter().position(|d| &d.workspace == workspace);
+                    let draft_thread = draft_index.map(|i| {
+                        used_draft_indices.insert(i);
+                        draft_entries[i].thread.clone()
+                    });
                     entries.push(ListEntry::NewThread {
                         path_list: path_list.clone(),
                         workspace: workspace.clone(),
                         worktrees: worktrees.clone(),
+                        draft_thread,
                     });
                 }
-                if is_draft_for_workspace
-                    && !threadless_workspaces
-                        .iter()
-                        .any(|(ws, _)| ws == representative_workspace)
-                {
-                    let ws_path_list = workspace_path_list(representative_workspace, cx);
+                // Emit NewThread for each remaining draft (including
+                // multiple drafts per workspace and drafts in workspaces
+                // that have saved threads).
+                for (i, draft) in draft_entries.iter().enumerate() {
+                    if used_draft_indices.contains(&i) {
+                        continue;
+                    }
+                    let ws_path_list = workspace_path_list(&draft.workspace, cx);
                     let worktrees = worktree_info_from_thread_paths(&ws_path_list, &project_groups);
                     entries.push(ListEntry::NewThread {
                         path_list: path_list.clone(),
-                        workspace: representative_workspace.clone(),
+                        workspace: draft.workspace.clone(),
                         worktrees,
+                        draft_thread: Some(draft.thread.clone()),
                     });
                 }
+                // Also emit NewThread if active_entry is Draft for a
+                // workspace in this group but no draft_thread was collected
+                // (e.g. the draft has no server session yet).
+                if let Some(ActiveEntry::Draft {
+                    workspace: draft_ws,
+                    ..
+                }) = &self.active_entry
+                {
+                    if group.workspaces.contains(draft_ws)
+                        && !threadless_workspaces.iter().any(|(ws, _)| ws == draft_ws)
+                        && !draft_entries.iter().any(|d| &d.workspace == draft_ws)
+                    {
+                        let ws_path_list = workspace_path_list(draft_ws, cx);
+                        let worktrees =
+                            worktree_info_from_thread_paths(&ws_path_list, &project_groups);
+                        entries.push(ListEntry::NewThread {
+                            path_list: path_list.clone(),
+                            workspace: draft_ws.clone(),
+                            worktrees,
+                            draft_thread: None,
+                        });
+                    }
+                }
 
                 let total = threads.len();
 
@@ -1071,7 +1121,7 @@ impl Sidebar {
                             || thread.status == AgentThreadStatus::WaitingForConfirmation
                             || notified_threads.contains(session_id)
                             || self.active_entry.as_ref().is_some_and(|active| {
-                                active.matches_entry(&ListEntry::Thread(thread.clone()))
+                                active.matches_entry(&ListEntry::Thread(thread.clone()), cx)
                             });
                         if is_promoted {
                             promoted_threads.insert(session_id.clone());
@@ -1174,7 +1224,7 @@ impl Sidebar {
         let is_active = self
             .active_entry
             .as_ref()
-            .is_some_and(|active| active.matches_entry(entry));
+            .is_some_and(|active| active.matches_entry(entry, cx));
 
         let rendered = match entry {
             ListEntry::ProjectHeader {
@@ -1207,12 +1257,14 @@ impl Sidebar {
                 path_list,
                 workspace,
                 worktrees,
+                draft_thread,
             } => self.render_new_thread(
                 ix,
                 path_list,
                 workspace,
                 is_active,
                 worktrees,
+                draft_thread.as_ref(),
                 is_selected,
                 cx,
             ),
@@ -1431,8 +1483,9 @@ impl Sidebar {
                             .tooltip(Tooltip::text("Activate Workspace"))
                             .on_click(cx.listener({
                                 move |this, _, window, cx| {
-                                    this.active_entry =
-                                        Some(ActiveEntry::Draft(workspace_for_open.clone()));
+                                    this.active_entry = Some(ActiveEntry::draft_for_workspace(
+                                        workspace_for_open.clone(),
+                                    ));
                                     if let Some(multi_workspace) = this.multi_workspace.upgrade() {
                                         multi_workspace.update(cx, |multi_workspace, cx| {
                                             multi_workspace.activate(
@@ -2437,7 +2490,7 @@ impl Sidebar {
                 }
             } else {
                 if let Some(workspace) = &group_workspace {
-                    self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
+                    self.active_entry = Some(ActiveEntry::draft_for_workspace(workspace.clone()));
                     if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
                         agent_panel.update(cx, |panel, cx| {
                             panel.new_thread(&NewThread, window, cx);
@@ -3016,7 +3069,7 @@ impl Sidebar {
             return;
         };
 
-        self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
+        self.active_entry = Some(ActiveEntry::draft_for_workspace(workspace.clone()));
 
         multi_workspace.update(cx, |multi_workspace, cx| {
             multi_workspace.activate(workspace.clone(), window, cx);
@@ -3039,15 +3092,13 @@ impl Sidebar {
         workspace: &Entity<Workspace>,
         is_active: bool,
         worktrees: &[WorktreeInfo],
+        draft_thread: Option<&Entity<acp_thread::AcpThread>>,
         is_selected: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
-        let label: SharedString = if is_active {
-            self.active_draft_text(cx)
-                .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
-        } else {
-            DEFAULT_THREAD_TITLE.into()
-        };
+        let label: SharedString = draft_thread
+            .and_then(|thread| Self::draft_text_from_thread(thread, cx))
+            .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
 
         let workspace = workspace.clone();
         let id = SharedString::from(format!("new-thread-btn-{}", ix));
@@ -3573,6 +3624,38 @@ impl Render for Sidebar {
     }
 }
 
+fn summarize_content_blocks(blocks: &[acp::ContentBlock]) -> Option<SharedString> {
+    const MAX_CHARS: usize = 250;
+
+    let mut text = String::new();
+    for block in blocks {
+        match block {
+            acp::ContentBlock::Text(text_content) => {
+                text.push_str(&text_content.text);
+            }
+            acp::ContentBlock::ResourceLink(link) => {
+                text.push_str(&format!("@{}", link.name));
+            }
+            acp::ContentBlock::Image(_) => {
+                text.push_str("[image]");
+            }
+            _ => {}
+        }
+        if text.len() > MAX_CHARS {
+            break;
+        }
+    }
+    let mut text: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
+    if text.is_empty() {
+        None
+    } else {
+        if let Some((truncate_at, _)) = text.char_indices().nth(MAX_CHARS) {
+            text.truncate(truncate_at);
+        }
+        Some(text.into())
+    }
+}
+
 fn all_thread_infos_for_workspace(
     workspace: &Entity<Workspace>,
     cx: &App,

crates/sidebar/src/sidebar_tests.rs 🔗

@@ -45,7 +45,7 @@ fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &st
 #[track_caller]
 fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
     assert!(
-        matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == workspace),
+        matches!(&sidebar.active_entry, Some(ActiveEntry::Draft { workspace: ws, .. }) if ws == workspace),
         "{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
         workspace.entity_id(),
         sidebar.active_entry,
@@ -244,7 +244,11 @@ fn visible_entries_as_strings(
                             format!("  + View More{}", selected)
                         }
                     }
-                    ListEntry::NewThread { worktrees, .. } => {
+                    ListEntry::NewThread {
+                        worktrees,
+                        draft_thread,
+                        ..
+                    } => {
                         let worktree = if worktrees.is_empty() {
                             String::new()
                         } else {
@@ -258,7 +262,11 @@ fn visible_entries_as_strings(
                             }
                             format!(" {}", chips.join(", "))
                         };
-                        format!("  [+ New Thread{}]{}", worktree, selected)
+                        let label = draft_thread
+                            .as_ref()
+                            .and_then(|thread| Sidebar::draft_text_from_thread(thread, _cx))
+                            .unwrap_or_else(|| DEFAULT_THREAD_TITLE.into());
+                        format!("  [+ {}{}]{}", label, worktree, selected)
                     }
                 }
             })
@@ -323,41 +331,40 @@ async fn test_serialization_round_trip(cx: &mut TestAppContext) {
 }
 
 #[test]
-fn test_clean_mention_links() {
-    // Simple mention link
-    assert_eq!(
-        Sidebar::clean_mention_links("check [@Button.tsx](file:///path/to/Button.tsx)"),
-        "check @Button.tsx"
-    );
+fn test_summarize_content_blocks() {
+    use agent_client_protocol as acp;
 
-    // Multiple mention links
     assert_eq!(
-        Sidebar::clean_mention_links(
-            "look at [@foo.rs](file:///foo.rs) and [@bar.rs](file:///bar.rs)"
-        ),
-        "look at @foo.rs and @bar.rs"
+        summarize_content_blocks(&[acp::ContentBlock::Text(acp::TextContent::new(
+            "check this out".to_string()
+        ))]),
+        Some(SharedString::from("check this out"))
     );
 
-    // No mention links — passthrough
     assert_eq!(
-        Sidebar::clean_mention_links("plain text with no mentions"),
-        "plain text with no mentions"
+        summarize_content_blocks(&[
+            acp::ContentBlock::Text(acp::TextContent::new("look at ".to_string())),
+            acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
+                "foo.rs".to_string(),
+                "file:///foo.rs".to_string()
+            )),
+            acp::ContentBlock::Text(acp::TextContent::new(" and ".to_string())),
+            acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
+                "bar.rs".to_string(),
+                "file:///bar.rs".to_string()
+            )),
+        ]),
+        Some(SharedString::from("look at @foo.rs and @bar.rs"))
     );
 
-    // Incomplete link syntax — preserved as-is
-    assert_eq!(
-        Sidebar::clean_mention_links("broken [@mention without closing"),
-        "broken [@mention without closing"
-    );
+    assert_eq!(summarize_content_blocks(&[]), None);
 
-    // Regular markdown link (no @) — not touched
     assert_eq!(
-        Sidebar::clean_mention_links("see [docs](https://example.com)"),
-        "see [docs](https://example.com)"
+        summarize_content_blocks(&[acp::ContentBlock::Text(acp::TextContent::new(
+            "  lots   of    spaces  ".to_string()
+        ))]),
+        Some(SharedString::from("lots of spaces"))
     );
-
-    // Empty input
-    assert_eq!(Sidebar::clean_mention_links(""), "");
 }
 
 #[gpui::test]
@@ -2381,6 +2388,24 @@ async fn test_draft_with_server_session_shows_as_draft(cx: &mut TestAppContext)
             "Draft with server session should be Draft, not Thread",
         );
     });
+
+    // Now activate the saved thread. The draft should still appear in
+    // the sidebar — drafts don't disappear when you navigate away.
+    sidebar.update_in(cx, |sidebar, window, cx| {
+        let metadata = ThreadMetadataStore::global(cx)
+            .read(cx)
+            .entries()
+            .find(|m| m.session_id == saved_session_id)
+            .expect("saved thread should exist in metadata store");
+        sidebar.activate_thread_locally(&metadata, &workspace, window, cx);
+    });
+    cx.run_until_parked();
+
+    assert_eq!(
+        visible_entries_as_strings(&sidebar, cx),
+        vec!["v [my-project]", "  [+ New Thread]", "  Hello *"],
+        "Draft should still be visible after navigating to a saved thread"
+    );
 }
 
 #[gpui::test]
@@ -4881,9 +4906,10 @@ mod property_test {
     enum Operation {
         SaveThread { workspace_index: usize },
         SaveWorktreeThread { worktree_index: usize },
-        DeleteThread { index: usize },
+        ArchiveThread { index: usize },
         ToggleAgentPanel,
         CreateDraftThread,
+        ActivateSavedThread { index: usize },
         AddWorkspace,
         OpenWorktreeAsWorkspace { worktree_index: usize },
         RemoveWorkspace { index: usize },
@@ -4892,17 +4918,18 @@ mod property_test {
     }
 
     // Distribution (out of 20 slots):
-    //   SaveThread:              5 slots (~23%)
-    //   SaveWorktreeThread:      2 slots (~9%)
-    //   DeleteThread:            2 slots (~9%)
-    //   ToggleAgentPanel:        2 slots (~9%)
-    //   CreateDraftThread:       2 slots (~9%)
-    //   AddWorkspace:            1 slot  (~5%)
-    //   OpenWorktreeAsWorkspace: 1 slot  (~5%)
-    //   RemoveWorkspace:         1 slot  (~5%)
-    //   SwitchWorkspace:         2 slots (~9%)
-    //   AddLinkedWorktree:       4 slots (~18%)
-    const DISTRIBUTION_SLOTS: u32 = 22;
+    //   SaveThread:              4 slots (~17%)
+    //   SaveWorktreeThread:      2 slots (~8%)
+    //   ArchiveThread:           2 slots (~8%)
+    //   ToggleAgentPanel:        2 slots (~8%)
+    //   CreateDraftThread:       2 slots (~8%)
+    //   ActivateSavedThread:     2 slots (~8%)
+    //   AddWorkspace:            1 slot  (~4%)
+    //   OpenWorktreeAsWorkspace: 1 slot  (~4%)
+    //   RemoveWorkspace:         1 slot  (~4%)
+    //   SwitchWorkspace:         2 slots (~8%)
+    //   AddLinkedWorktree:       4 slots (~17%)
+    const DISTRIBUTION_SLOTS: u32 = 23;
 
     impl TestState {
         fn generate_operation(&self, raw: u32) -> Operation {
@@ -4919,7 +4946,7 @@ mod property_test {
                 5..=6 => Operation::SaveThread {
                     workspace_index: extra % workspace_count,
                 },
-                7..=8 if !self.saved_thread_ids.is_empty() => Operation::DeleteThread {
+                7..=8 if !self.saved_thread_ids.is_empty() => Operation::ArchiveThread {
                     index: extra % self.saved_thread_ids.len(),
                 },
                 7..=8 => Operation::SaveThread {
@@ -4927,24 +4954,28 @@ mod property_test {
                 },
                 9..=10 => Operation::ToggleAgentPanel,
                 11..=12 => Operation::CreateDraftThread,
-                13 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace {
+                13..=14 if !self.saved_thread_ids.is_empty() => Operation::ActivateSavedThread {
+                    index: extra % self.saved_thread_ids.len(),
+                },
+                13..=14 => Operation::CreateDraftThread,
+                15 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace {
                     worktree_index: extra % self.unopened_worktrees.len(),
                 },
-                13 => Operation::AddWorkspace,
-                14 if workspace_count > 1 => Operation::RemoveWorkspace {
+                15 => Operation::AddWorkspace,
+                16 if workspace_count > 1 => Operation::RemoveWorkspace {
                     index: extra % workspace_count,
                 },
-                14 => Operation::AddWorkspace,
-                15..=16 => Operation::SwitchWorkspace {
+                16 => Operation::AddWorkspace,
+                17..=18 => Operation::SwitchWorkspace {
                     index: extra % workspace_count,
                 },
-                17..=21 if !self.main_repo_indices.is_empty() => {
+                19..=22 if !self.main_repo_indices.is_empty() => {
                     let main_index = self.main_repo_indices[extra % self.main_repo_indices.len()];
                     Operation::AddLinkedWorktree {
                         workspace_index: main_index,
                     }
                 }
-                17..=21 => Operation::SaveThread {
+                19..=22 => Operation::SaveThread {
                     workspace_index: extra % workspace_count,
                 },
                 _ => unreachable!(),
@@ -4996,11 +5027,10 @@ mod property_test {
                 let path_list = PathList::new(&[std::path::PathBuf::from(&worktree.path)]);
                 save_thread_to_path(state, path_list, cx);
             }
-            Operation::DeleteThread { index } => {
+            Operation::ArchiveThread { index } => {
                 let session_id = state.remove_thread(index);
-                cx.update(|_, cx| {
-                    ThreadMetadataStore::global(cx)
-                        .update(cx, |store, cx| store.delete(session_id, cx));
+                _sidebar.update_in(cx, |sidebar, window, cx| {
+                    sidebar.archive_thread(&session_id, window, cx);
                 });
             }
             Operation::ToggleAgentPanel => {
@@ -5028,6 +5058,46 @@ mod property_test {
                     workspace.focus_panel::<AgentPanel>(window, cx);
                 });
             }
+            Operation::ActivateSavedThread { index } => {
+                let session_id = state.saved_thread_ids[index].clone();
+                let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+                let metadata = cx.update(|_, cx| {
+                    ThreadMetadataStore::global(cx)
+                        .read(cx)
+                        .entries()
+                        .find(|m| m.session_id == session_id)
+                });
+                if let Some(metadata) = metadata {
+                    let panel =
+                        workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
+                    if let Some(panel) = panel {
+                        let connection = StubAgentConnection::new();
+                        connection.set_next_prompt_updates(vec![
+                            acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
+                                metadata.title.to_string().into(),
+                            )),
+                        ]);
+                        open_thread_with_connection(&panel, connection, cx);
+                        send_message(&panel, cx);
+                        let panel_session_id = active_session_id(&panel, cx);
+                        // Replace the old metadata entry with one that
+                        // uses the panel's actual session ID.
+                        let old_session_id = metadata.session_id.clone();
+                        let mut updated_metadata = metadata.clone();
+                        updated_metadata.session_id = panel_session_id.clone();
+                        cx.update(|_, cx| {
+                            ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+                                store.delete(old_session_id, cx);
+                                store.save(updated_metadata, cx);
+                            });
+                        });
+                        state.saved_thread_ids[index] = panel_session_id;
+                    }
+                    _sidebar.update_in(cx, |sidebar, _window, cx| {
+                        sidebar.update_entries(cx);
+                    });
+                }
+            }
             Operation::AddWorkspace => {
                 let path = state.next_workspace_path();
                 state
@@ -5227,7 +5297,7 @@ mod property_test {
             .contents
             .entries
             .iter()
-            .filter_map(|entry| entry.session_id().cloned())
+            .filter_map(|entry| entry.session_id(cx).cloned())
             .collect();
 
         let mut metadata_thread_ids: HashSet<acp::SessionId> = HashSet::default();
@@ -5248,11 +5318,35 @@ mod property_test {
                     }
                 }
             }
+
+            // Draft conversations live in the agent panel but aren't in the
+            // metadata store yet. They should still appear as thread entries.
+            if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+                let panel = panel.read(cx);
+                if let Some(cv) = panel.active_conversation_view() {
+                    let cv = cv.read(cx);
+                    if let Some(session_id) = cv.parent_id(cx) {
+                        if let Some(thread) = cv.active_thread() {
+                            if thread.read(cx).thread.read(cx).is_draft() {
+                                metadata_thread_ids.insert(session_id);
+                            }
+                        }
+                    }
+                }
+                for (session_id, cv) in panel.background_threads() {
+                    let cv = cv.read(cx);
+                    if let Some(thread) = cv.active_thread() {
+                        if thread.read(cx).thread.read(cx).is_draft() {
+                            metadata_thread_ids.insert(session_id.clone());
+                        }
+                    }
+                }
+            }
         }
 
         anyhow::ensure!(
             sidebar_thread_ids == metadata_thread_ids,
-            "sidebar threads don't match metadata store: sidebar has {:?}, store has {:?}",
+            "sidebar threads don't match expected: sidebar has {:?}, expected {:?}",
             sidebar_thread_ids,
             metadata_thread_ids,
         );
@@ -5288,7 +5382,7 @@ mod property_test {
         let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
         if panel.read(cx).active_thread_is_draft(cx) {
             anyhow::ensure!(
-                matches!(entry, ActiveEntry::Draft(_)),
+                matches!(entry, ActiveEntry::Draft { .. }),
                 "panel shows a draft but active_entry is {:?}",
                 entry,
             );
@@ -5311,7 +5405,7 @@ mod property_test {
             .contents
             .entries
             .iter()
-            .filter(|e| entry.matches_entry(e))
+            .filter(|e| entry.matches_entry(e, cx))
             .count();
         anyhow::ensure!(
             matching_count == 1,