diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 54565997e15f5f79e4f242680403d2f1f75ca6eb..02989b08e267bb2d0980c73cf1d8ca818ae2799a 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/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 } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index e1c26bc1a3078d18fa7f085271d9fef69d5f37e9..27264571b15c842e7742a3a1770e4f7ae50b4444 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/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::(); - 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, diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 6d7be34ab5314c963652f768b5f84ff1896c4a21..0311c4c6c3157b1c5db49408ce5f443999c010fe 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -114,14 +114,24 @@ enum ActiveEntry { session_id: acp::SessionId, workspace: Entity, }, - Draft(Entity), + Draft { + session_id: Option, + workspace: Entity, + }, } impl ActiveEntry { + fn draft_for_workspace(workspace: Entity) -> Self { + ActiveEntry::Draft { + session_id: None, + workspace, + } + } + fn workspace(&self) -> &Entity { 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, worktrees: Vec, + draft_thread: Option>, }, } @@ -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 { - let mw = self.multi_workspace.upgrade()?; - let workspace = mw.read(cx).workspace(); - let panel = workspace.read(cx).panel::(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::>().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, + cx: &App, + ) -> Option { + 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::(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, + thread: Entity, + } + let mut draft_entries: Vec = Vec::new(); + for workspace in &group.workspaces { + if let Some(panel) = workspace.read(cx).panel::(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 = 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::(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, is_active: bool, worktrees: &[WorktreeInfo], + draft_thread: Option<&Entity>, is_selected: bool, cx: &mut Context, ) -> 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 { + 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::>().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, cx: &App, diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index fb1519ec2c0f12cce023359084d566974685e2e5..a8f23dffca8a189537c2bfab768d4c7fe49f8627 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/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, 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::(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::(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 = 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::(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::(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,