Detailed changes
@@ -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
}
@@ -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,
@@ -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,
@@ -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,