sidebar: Don't block worktree archival on empty drafts (#57145)

If we have empty drafts, they don't show up in the UI, so you can't get
rid of them. But they currently blocked worktree archival. Which is
particularly troublesome with terminal agents in a few cases.

This should hopefully solve the issue for terminals, but I think I need
to do a follow-up to investigate what happens when the last draft is
closed.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
This commit is contained in:
Ben Brandt 2026-05-19 23:08:39 +02:00 committed by GitHub
parent a39f1d163d
commit 0535f47291
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 2195 additions and 224 deletions

View file

@ -2758,6 +2758,25 @@ impl AgentPanel {
/// the panel and deletes its metadata row. Used by the sidebar when
/// the user dismisses a parked draft.
pub fn remove_thread(&mut self, id: ThreadId, window: &mut Window, cx: &mut Context<Self>) {
self.remove_thread_internal(id, true, window, cx);
}
pub fn remove_thread_without_activating_draft(
&mut self,
id: ThreadId,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.remove_thread_internal(id, false, window, cx);
}
fn remove_thread_internal(
&mut self,
id: ThreadId,
activate_draft_after_remove: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.retained_threads.remove(&id);
ThreadMetadataStore::global(cx).update(cx, |store, cx| {
store.delete(id, cx);
@ -2774,7 +2793,12 @@ impl AgentPanel {
if self.active_thread_id(cx) == Some(id) {
self.clear_overlay_state();
self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx);
if activate_draft_after_remove {
self.activate_draft(false, AgentThreadSource::AgentPanel, window, cx);
} else {
self.base_view = BaseView::Uninitialized;
self.refresh_base_view_subscriptions(window, cx);
}
self.serialize(cx);
cx.emit(AgentPanelEvent::ActiveViewChanged);
cx.notify();
@ -2845,6 +2869,37 @@ impl AgentPanel {
}
}
pub fn draft_prompt_blocks_if_in_memory(
&self,
id: ThreadId,
cx: &App,
) -> Option<Vec<acp::ContentBlock>> {
let cv = self
.retained_threads
.get(&id)
.or_else(|| {
self.draft_thread
.as_ref()
.filter(|draft| draft.read(cx).thread_id == id)
})
.or_else(|| match &self.base_view {
BaseView::AgentThread { conversation_view }
if conversation_view.read(cx).thread_id == id =>
{
Some(conversation_view)
}
_ => None,
})?;
let thread_view = cv.read(cx).root_thread_view()?;
let thread_view = thread_view.read(cx);
Some(
thread_view
.message_editor
.read(cx)
.draft_content_blocks_snapshot(cx),
)
}
fn new_native_agent_thread_from_summary(
&mut self,
action: &NewNativeAgentThreadFromSummary,
@ -6681,6 +6736,184 @@ mod tests {
}
}
#[gpui::test]
async fn test_draft_prompt_blocks_use_current_editor_snapshot(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project", json!({ "file.txt": "" })).await;
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
let _stub_connection =
crate::test_support::set_stub_agent_connection(StubAgentConnection::new());
panel.update_in(cx, |panel, window, cx| {
panel.selected_agent = Agent::Stub;
panel.activate_draft(true, AgentThreadSource::AgentPanel, window, cx);
});
cx.run_until_parked();
let thread_id = active_thread_id(&panel, cx);
let thread = panel.read_with(cx, |panel, cx| {
panel
.active_agent_thread(cx)
.expect("draft thread should be active")
});
let message_editor = panel.read_with(cx, |panel, cx| {
panel
.active_thread_view(cx)
.expect("draft thread view should be active")
.read(cx)
.message_editor
.clone()
});
thread.update(cx, |thread, cx| {
thread.set_draft_prompt(
Some(vec![acp::ContentBlock::Text(acp::TextContent::new(
"stale prompt",
))]),
cx,
);
});
message_editor.update_in(cx, |editor, window, cx| {
editor.set_text("fresh prompt", window, cx);
});
let blocks = panel.read_with(cx, |panel, cx| {
panel
.draft_prompt_blocks_if_in_memory(thread_id, cx)
.expect("draft should be in memory")
});
assert_eq!(blocks.len(), 1);
assert_eq!(expect_text_block(&blocks[0]), "fresh prompt");
thread.update(cx, |thread, cx| {
thread.set_draft_prompt(
Some(vec![acp::ContentBlock::Text(acp::TextContent::new(
"stale prompt after clear",
))]),
cx,
);
});
message_editor.update_in(cx, |editor, window, cx| {
editor.set_text("", window, cx);
});
let blocks = panel.read_with(cx, |panel, cx| {
panel
.draft_prompt_blocks_if_in_memory(thread_id, cx)
.expect("draft should be in memory")
});
assert!(
blocks.is_empty(),
"cleared editor snapshot should override stale saved draft prompt"
);
}
#[gpui::test]
async fn test_draft_has_user_content_checks_all_live_copies(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree("/project_a", json!({ "file.txt": "" }))
.await;
fs.insert_tree("/project_b", json!({ "file.txt": "" }))
.await;
let project_a = Project::test(fs.clone(), [Path::new("/project_a")], cx).await;
let project_b = Project::test(fs.clone(), [Path::new("/project_b")], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
let workspace_a = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
let workspace_b = multi_workspace
.update(cx, |multi_workspace, window, cx| {
multi_workspace.test_add_workspace(project_b.clone(), window, cx)
})
.unwrap();
let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
let _stub_connection =
crate::test_support::set_stub_agent_connection(StubAgentConnection::new());
panel_a.update_in(cx, |panel, window, cx| {
panel.selected_agent = Agent::Stub;
panel.activate_draft(true, AgentThreadSource::AgentPanel, window, cx);
});
cx.run_until_parked();
let thread_id = active_thread_id(&panel_a, cx);
panel_b.update_in(cx, |panel, window, cx| {
panel.load_agent_thread(
Agent::Stub,
thread_id,
Some(PathList::new(&[PathBuf::from("/project_b")])),
None,
false,
AgentThreadSource::AgentPanel,
window,
cx,
);
});
cx.run_until_parked();
crate::test_support::type_draft_prompt(&panel_b, "content in second panel", cx);
let panel_a_blocks = panel_a.read_with(cx, |panel, cx| {
panel
.draft_prompt_blocks_if_in_memory(thread_id, cx)
.expect("draft should be live in first panel")
});
assert!(
panel_a_blocks.is_empty(),
"first live draft copy should be empty"
);
let has_user_content = cx.update(|_, cx| {
crate::draft_prompt_store::draft_has_user_content(
thread_id,
[&workspace_a, &workspace_b],
cx,
)
});
assert!(
has_user_content,
"a later live draft copy with content should keep the draft"
);
}
#[test]
fn test_build_conflict_resolution_prompt_single_conflict() {
let conflicts = vec![ConflictContent {

View file

@ -29,7 +29,7 @@ pub fn read(thread_id: ThreadId, cx: &App) -> Option<Vec<acp::ContentBlock>> {
let kvp = KeyValueStore::global(cx);
let raw = kvp
.scoped(NAMESPACE)
.read(&thread_id_key(thread_id))
.read(&thread_id.to_key_string())
.log_err()
.flatten()?;
serde_json::from_str(&raw).log_err()
@ -41,7 +41,7 @@ pub fn write(
cx: &App,
) -> Task<anyhow::Result<()>> {
let kvp = KeyValueStore::global(cx);
let key = thread_id_key(thread_id);
let key = thread_id.to_key_string();
let payload = match serde_json::to_string(prompt).context("serializing draft prompt") {
Ok(payload) => payload,
Err(err) => return Task::ready(Err(err)),
@ -51,12 +51,43 @@ pub fn write(
pub fn delete(thread_id: ThreadId, cx: &App) -> Task<anyhow::Result<()>> {
let kvp = KeyValueStore::global(cx);
let key = thread_id_key(thread_id);
let key = thread_id.to_key_string();
cx.background_spawn(async move { kvp.scoped(NAMESPACE).delete(key).await })
}
fn thread_id_key(thread_id: ThreadId) -> String {
thread_id.to_key_string()
pub fn draft_has_user_content<'a>(
thread_id: ThreadId,
workspaces: impl IntoIterator<Item = &'a Entity<Workspace>>,
cx: &App,
) -> bool {
let mut found_live_copy = false;
for blocks in workspaces
.into_iter()
.filter_map(|workspace| workspace.read(cx).panel::<AgentPanel>(cx))
.filter_map(|panel| {
panel
.read(cx)
.draft_prompt_blocks_if_in_memory(thread_id, cx)
})
{
found_live_copy = true;
if blocks_have_user_content(&blocks) {
return true;
}
}
if found_live_copy {
false
} else {
read(thread_id, cx).is_some_and(|blocks| blocks_have_user_content(&blocks))
}
}
fn blocks_have_user_content(blocks: &[acp::ContentBlock]) -> bool {
blocks.iter().any(|block| match block {
acp::ContentBlock::Text(text) => !text.text.trim().is_empty(),
_ => true,
})
}
/// Rewrites `[@Something](scheme://...)` mention links as `@Something` so the

View file

@ -347,6 +347,20 @@ impl ThreadMetadata {
pub fn main_worktree_paths(&self) -> &PathList {
self.worktree_paths.main_worktree_path_list()
}
pub fn references_folder_path(&self, path: &Path) -> bool {
self.folder_paths()
.paths()
.iter()
.any(|folder_path| folder_path.as_path() == path)
}
pub fn matches_remote_connection(
&self,
remote_connection: Option<&RemoteConnectionOptions>,
) -> bool {
same_remote_connection_identity(self.remote_connection.as_ref(), remote_connection)
}
}
/// Derives worktree display info from a thread's stored path list.
@ -615,9 +629,7 @@ impl ThreadMetadataStore {
.flatten()
.filter_map(|s| self.threads.get(s))
.filter(|s| !s.archived)
.filter(move |s| {
same_remote_connection_identity(s.remote_connection.as_ref(), remote_connection)
})
.filter(move |s| s.matches_remote_connection(remote_connection))
}
/// Returns threads whose `main_worktree_paths` matches the given path list
@ -639,9 +651,7 @@ impl ThreadMetadataStore {
.flatten()
.filter_map(|s| self.threads.get(s))
.filter(|s| !s.archived)
.filter(move |s| {
same_remote_connection_identity(s.remote_connection.as_ref(), remote_connection)
})
.filter(move |s| s.matches_remote_connection(remote_connection))
}
fn reload(&mut self, cx: &mut Context<Self>) -> Shared<Task<()>> {
@ -858,19 +868,28 @@ impl ThreadMetadataStore {
thread_id: Option<ThreadId>,
path: &Path,
remote_connection: Option<&RemoteConnectionOptions>,
) -> bool {
self.path_is_referenced_by_unarchived_threads_matching(
thread_id,
path,
remote_connection,
|_| true,
)
}
pub fn path_is_referenced_by_unarchived_threads_matching(
&self,
thread_id: Option<ThreadId>,
path: &Path,
remote_connection: Option<&RemoteConnectionOptions>,
matches: impl Fn(&ThreadMetadata) -> bool,
) -> bool {
self.entries().any(|thread| {
Some(thread.thread_id) != thread_id
&& !thread.archived
&& same_remote_connection_identity(
thread.remote_connection.as_ref(),
remote_connection,
)
&& thread
.folder_paths()
.paths()
.iter()
.any(|other_path| other_path.as_path() == path)
&& thread.matches_remote_connection(remote_connection)
&& thread.references_folder_path(path)
&& matches(thread)
})
}
@ -1123,6 +1142,26 @@ impl ThreadMetadataStore {
cx.notify();
}
pub fn unarchived_draft_ids_matching(
&self,
matches: impl Fn(&ThreadMetadata) -> bool,
) -> Vec<ThreadId> {
self.entries()
.filter(|thread| thread.is_draft() && !thread.archived && matches(thread))
.map(|thread| thread.thread_id)
.collect()
}
pub fn delete_all(
&mut self,
thread_ids: impl IntoIterator<Item = ThreadId>,
cx: &mut Context<Self>,
) {
for thread_id in thread_ids {
self.delete(thread_id, cx);
}
}
fn new(db: ThreadMetadataDb, cx: &mut Context<Self>) -> Self {
let weak_store = cx.weak_entity();

View file

@ -831,6 +831,27 @@ pub fn all_open_workspaces(cx: &App) -> Vec<Entity<Workspace>> {
.collect()
}
pub fn workspaces_for_archive(
multi_workspace: Option<&Entity<MultiWorkspace>>,
cx: &App,
) -> Vec<Entity<Workspace>> {
let mut workspaces = multi_workspace
.map(|multi_workspace| {
multi_workspace
.read(cx)
.workspaces()
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
for workspace in all_open_workspaces(cx) {
if !workspaces.contains(&workspace) {
workspaces.push(workspace);
}
}
workspaces
}
fn current_app_state(cx: &mut AsyncApp) -> Option<Arc<AppState>> {
cx.update(|cx| {
all_open_workspaces(cx)

View file

@ -475,10 +475,6 @@ fn linked_worktree_path_lists_for_workspaces(
.collect()
}
fn workspace_has_terminal_metadata(workspace: &Entity<Workspace>, cx: &App) -> bool {
workspace_has_terminal_metadata_except(workspace, None, cx)
}
fn workspace_has_terminal_metadata_except(
workspace: &Entity<Workspace>,
except_terminal_id: Option<TerminalId>,
@ -3657,6 +3653,7 @@ impl Sidebar {
}
fn should_load_closed_workspace_for_archive(
&self,
folder_paths: &PathList,
project_group_key: &ProjectGroupKey,
remote_connection: Option<&RemoteConnectionOptions>,
@ -3668,13 +3665,17 @@ impl Sidebar {
return false;
}
let archive_workspaces = self.archive_workspaces(cx);
let thread_store = ThreadMetadataStore::global(cx);
let thread_store = thread_store.read(cx);
if folder_paths.ordered_paths().any(|path| {
thread_store.path_is_referenced_by_unarchived_threads(
Self::path_is_referenced_by_unarchived_threads_for_archive(
&thread_store,
except_thread_id,
path,
remote_connection,
&archive_workspaces,
cx,
)
}) {
return false;
@ -3692,6 +3693,208 @@ impl Sidebar {
})
}
fn path_is_referenced_by_unarchived_threads_for_archive(
thread_store: &ThreadMetadataStore,
except_thread_id: Option<ThreadId>,
path: &Path,
remote_connection: Option<&RemoteConnectionOptions>,
archive_workspaces: &[Entity<Workspace>],
cx: &App,
) -> bool {
thread_store.path_is_referenced_by_unarchived_threads_matching(
except_thread_id,
path,
remote_connection,
|thread| Self::thread_blocks_worktree_archive(thread, archive_workspaces, cx),
)
}
fn archive_workspaces(&self, cx: &App) -> Vec<Entity<Workspace>> {
let multi_workspace = self.multi_workspace.upgrade();
thread_worktree_archive::workspaces_for_archive(multi_workspace.as_ref(), cx)
}
fn count_threads_blocking_worktree_archive(
&self,
path_list: &PathList,
remote_connection: Option<&RemoteConnectionOptions>,
except_thread_id: Option<ThreadId>,
cx: &App,
) -> usize {
let archive_workspaces = self.archive_workspaces(cx);
ThreadMetadataStore::global(cx)
.read(cx)
.entries_for_path(path_list, remote_connection)
.filter(|thread| Some(thread.thread_id) != except_thread_id)
.filter(|thread| Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx))
.count()
}
fn roots_to_archive_for_paths(
&self,
folder_paths: &PathList,
remote_connection: Option<&RemoteConnectionOptions>,
except_thread_id: Option<ThreadId>,
except_terminal_id: Option<TerminalId>,
cx: &App,
) -> Vec<thread_worktree_archive::RootPlan> {
let workspaces = self.archive_workspaces(cx);
folder_paths
.ordered_paths()
.filter_map(|path| {
thread_worktree_archive::build_root_plan(path, remote_connection, &workspaces, cx)
})
.filter(|plan| {
let store = ThreadMetadataStore::global(cx);
let store = store.read(cx);
!Self::path_is_referenced_by_unarchived_threads_for_archive(
&store,
except_thread_id,
plan.root_path.as_path(),
remote_connection,
&workspaces,
cx,
)
})
.filter(|root| {
TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| {
!terminal_store.read(cx).path_is_referenced_by_terminal(
except_terminal_id,
root.root_path.as_path(),
remote_connection,
)
})
})
.collect()
}
fn linked_worktree_workspace_to_remove(
&self,
folder_paths: &PathList,
remote_connection: Option<&RemoteConnectionOptions>,
except_thread_id: Option<ThreadId>,
except_terminal_id: Option<TerminalId>,
roots_to_archive: &[thread_worktree_archive::RootPlan],
cx: &App,
) -> Option<Entity<Workspace>> {
if folder_paths.is_empty() {
return None;
}
let remaining = self.count_threads_blocking_worktree_archive(
folder_paths,
remote_connection,
except_thread_id,
cx,
);
if remaining > 0 {
return None;
}
let multi_workspace = self.multi_workspace.upgrade()?;
let workspace =
multi_workspace
.read(cx)
.workspace_for_paths(folder_paths, remote_connection, cx)?;
if workspace_has_terminal_metadata_except(&workspace, except_terminal_id, cx) {
return None;
}
if !roots_to_archive.is_empty() {
let archive_paths: HashSet<&Path> = roots_to_archive
.iter()
.map(|root| root.root_path.as_path())
.collect();
let project = workspace.read(cx).project().clone();
let visible_worktree_paths = project
.read(cx)
.visible_worktrees(cx)
.map(|worktree| worktree.read(cx).abs_path())
.collect::<Vec<_>>();
return (!visible_worktree_paths.is_empty()
&& visible_worktree_paths
.iter()
.all(|path| archive_paths.contains(path.as_ref())))
.then_some(workspace);
}
let group_key = workspace.read(cx).project_group_key(cx);
(group_key.path_list() != folder_paths).then_some(workspace)
}
fn delete_empty_drafts_for_archive_roots(
&self,
roots: &[thread_worktree_archive::RootPlan],
cx: &mut Context<Self>,
) {
self.delete_empty_drafts_for_archive_targets(
roots
.iter()
.map(|root| (root.root_path.as_path(), root.remote_connection.as_ref())),
cx,
);
}
fn delete_empty_drafts_for_archive_paths(
&self,
paths: &PathList,
remote_connection: Option<&RemoteConnectionOptions>,
cx: &mut Context<Self>,
) {
self.delete_empty_drafts_for_archive_targets(
paths
.ordered_paths()
.map(|path| (path.as_path(), remote_connection)),
cx,
);
}
fn delete_empty_drafts_for_archive_targets<'a>(
&self,
targets: impl IntoIterator<Item = (&'a Path, Option<&'a RemoteConnectionOptions>)>,
cx: &mut Context<Self>,
) {
let targets = targets.into_iter().collect::<Vec<_>>();
if targets.is_empty() {
return;
}
let archive_workspaces = self.archive_workspaces(cx);
let draft_thread_ids = ThreadMetadataStore::global(cx)
.read(cx)
.unarchived_draft_ids_matching(|thread| {
targets.iter().any(|(path, remote_connection)| {
thread.matches_remote_connection(*remote_connection)
&& thread.references_folder_path(path)
}) && !Self::thread_blocks_worktree_archive(thread, &archive_workspaces, cx)
});
if draft_thread_ids.is_empty() {
return;
}
ThreadMetadataStore::global(cx).update(cx, |store, cx| {
store.delete_all(draft_thread_ids, cx);
});
}
fn thread_blocks_worktree_archive(
thread: &ThreadMetadata,
archive_workspaces: &[Entity<Workspace>],
cx: &App,
) -> bool {
if !thread.is_draft() {
return true;
}
agent_ui::draft_prompt_store::draft_has_user_content(
thread.thread_id,
archive_workspaces,
cx,
)
}
async fn wait_for_archive_workspace_metadata(
workspace: &Entity<Workspace>,
cx: &mut gpui::AsyncApp,
@ -3821,7 +4024,7 @@ impl Sidebar {
folder_paths,
project_group_key,
} = workspace
&& Self::should_load_closed_workspace_for_archive(
&& self.should_load_closed_workspace_for_archive(
folder_paths,
project_group_key,
metadata.remote_connection.as_ref(),
@ -3859,83 +4062,22 @@ impl Sidebar {
.and_then(|position| self.neighboring_activatable_entry(position));
let terminal_folder_paths = metadata.folder_paths().clone();
let roots_to_archive = {
let mut workspaces = self
.multi_workspace
.upgrade()
.map(|multi_workspace| {
multi_workspace
.read(cx)
.workspaces()
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
for workspace in thread_worktree_archive::all_open_workspaces(cx) {
if !workspaces.contains(&workspace) {
workspaces.push(workspace);
}
}
let roots_to_archive = self.roots_to_archive_for_paths(
metadata.folder_paths(),
metadata.remote_connection.as_ref(),
None,
Some(terminal_id),
cx,
);
metadata
.folder_paths()
.ordered_paths()
.filter_map(|path| {
thread_worktree_archive::build_root_plan(
path,
metadata.remote_connection.as_ref(),
&workspaces,
cx,
)
})
.filter(|plan| {
let store = ThreadMetadataStore::global(cx);
let store = store.read(cx);
!store.path_is_referenced_by_unarchived_threads(
None,
&plan.root_path,
metadata.remote_connection.as_ref(),
)
})
.filter(|root| {
TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| {
!terminal_store.read(cx).path_is_referenced_by_terminal(
Some(terminal_id),
root.root_path.as_path(),
metadata.remote_connection.as_ref(),
)
})
})
.collect::<Vec<_>>()
};
let workspace_to_remove = if terminal_folder_paths.is_empty() {
None
} else {
let remaining = ThreadMetadataStore::global(cx)
.read(cx)
.entries_for_path(&terminal_folder_paths, metadata.remote_connection.as_ref())
.count();
if remaining > 0 {
None
} else {
let workspace = self.multi_workspace.upgrade().and_then(|multi_workspace| {
multi_workspace
.read(cx)
.workspace_for_paths(&terminal_folder_paths, None, cx)
});
workspace.and_then(|workspace| {
if workspace_has_terminal_metadata_except(&workspace, Some(terminal_id), cx) {
return None;
}
let group_key = workspace.read(cx).project_group_key(cx);
(group_key.path_list() != &terminal_folder_paths).then_some(workspace)
})
}
};
let workspace_to_remove = self.linked_worktree_workspace_to_remove(
&terminal_folder_paths,
metadata.remote_connection.as_ref(),
None,
Some(terminal_id),
&roots_to_archive,
cx,
);
let mut workspaces_to_remove: Vec<Entity<Workspace>> =
workspace_to_remove.into_iter().collect();
@ -4003,6 +4145,13 @@ impl Sidebar {
}
this.update_in(cx, |this, window, cx| {
if terminal_workspace_removed {
this.delete_empty_drafts_for_archive_paths(
metadata.folder_paths(),
metadata.remote_connection.as_ref(),
cx,
);
}
// If the terminal's workspace has already been removed,
// don't synthesize a fallback draft in the detached
// AgentPanel.
@ -4193,33 +4342,44 @@ impl Sidebar {
) {
let store = ThreadMetadataStore::global(cx);
let metadata = store.read(cx).entry_by_session(session_id).cloned();
let active_workspace = metadata.as_ref().and_then(|metadata| {
let metadata_thread_id = metadata.as_ref().map(|metadata| metadata.thread_id);
let thread_entry = self.contents.entries.iter().find_map(|entry| match entry {
ListEntry::Thread(thread) => metadata_thread_id
.map_or_else(
|| thread.metadata.session_id.as_ref() == Some(session_id),
|thread_id| thread.metadata.thread_id == thread_id,
)
.then(|| thread.clone()),
_ => None,
});
let thread_id = metadata_thread_id.or_else(|| {
thread_entry
.as_ref()
.map(|thread| thread.metadata.thread_id)
});
let active_workspace = thread_id.and_then(|thread_id| {
self.active_entry.as_ref().and_then(|entry| {
if entry.is_active_thread(&metadata.thread_id) {
if entry.is_active_thread(&thread_id) {
Some(entry.workspace().clone())
} else {
None
}
})
});
let thread_id = metadata.as_ref().map(|metadata| metadata.thread_id);
let thread_folder_paths = metadata
.as_ref()
.map(|metadata| metadata.folder_paths().clone())
.or_else(|| {
thread_entry
.as_ref()
.map(|thread| thread.metadata.folder_paths().clone())
})
.or_else(|| {
active_workspace
.as_ref()
.map(|workspace| PathList::new(&workspace.read(cx).root_paths(cx)))
});
let thread_entry_workspace = self.contents.entries.iter().find_map(|entry| match entry {
ListEntry::Thread(thread) => thread_id
.map_or_else(
|| thread.metadata.session_id.as_ref() == Some(session_id),
|tid| thread.metadata.thread_id == tid,
)
.then(|| thread.workspace.clone()),
_ => None,
});
let thread_entry_workspace = thread_entry.map(|thread| thread.workspace);
if let (
Some(metadata),
@ -4228,7 +4388,7 @@ impl Sidebar {
project_group_key,
}),
) = (metadata.as_ref(), thread_entry_workspace)
&& Self::should_load_closed_workspace_for_archive(
&& self.should_load_closed_workspace_for_archive(
&folder_paths,
&project_group_key,
metadata.remote_connection.as_ref(),
@ -4255,52 +4415,13 @@ impl Sidebar {
let roots_to_archive = metadata
.as_ref()
.map(|metadata| {
let mut workspaces = self
.multi_workspace
.upgrade()
.map(|multi_workspace| {
multi_workspace
.read(cx)
.workspaces()
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
for workspace in thread_worktree_archive::all_open_workspaces(cx) {
if !workspaces.contains(&workspace) {
workspaces.push(workspace);
}
}
metadata
.folder_paths()
.ordered_paths()
.filter_map(|path| {
thread_worktree_archive::build_root_plan(
path,
metadata.remote_connection.as_ref(),
&workspaces,
cx,
)
})
.filter(|plan| {
thread_id.map_or(true, |tid| {
!store.read(cx).path_is_referenced_by_unarchived_threads(
Some(tid),
&plan.root_path,
metadata.remote_connection.as_ref(),
)
})
})
.filter(|root| {
TerminalThreadMetadataStore::try_global(cx).is_none_or(|terminal_store| {
!terminal_store.read(cx).path_is_referenced_by_terminal(
None,
root.root_path.as_path(),
metadata.remote_connection.as_ref(),
)
})
})
.collect::<Vec<_>>()
self.roots_to_archive_for_paths(
metadata.folder_paths(),
metadata.remote_connection.as_ref(),
thread_id,
None,
cx,
)
})
.unwrap_or_default();
@ -4317,35 +4438,16 @@ impl Sidebar {
// Check if archiving this thread would leave its worktree workspace
// with no threads, requiring workspace removal.
let workspace_to_remove = thread_folder_paths.as_ref().and_then(|folder_paths| {
if folder_paths.is_empty() {
return None;
}
let thread_remote_connection =
metadata.as_ref().and_then(|m| m.remote_connection.as_ref());
let remaining = ThreadMetadataStore::global(cx)
.read(cx)
.entries_for_path(folder_paths, thread_remote_connection)
.filter(|t| t.session_id.as_ref() != Some(session_id))
.count();
if remaining > 0 {
return None;
}
let multi_workspace = self.multi_workspace.upgrade()?;
let workspace = multi_workspace
.read(cx)
.workspace_for_paths(folder_paths, None, cx)?;
if workspace_has_terminal_metadata(&workspace, cx) {
return None;
}
let group_key = workspace.read(cx).project_group_key(cx);
let is_linked_worktree = group_key.path_list() != folder_paths;
is_linked_worktree.then_some(workspace)
self.linked_worktree_workspace_to_remove(
folder_paths,
thread_remote_connection,
thread_id,
None,
&roots_to_archive,
cx,
)
});
// Also find workspaces for root plans that aren't covered by
@ -4407,6 +4509,9 @@ impl Sidebar {
});
let thread_folder_paths = thread_folder_paths.clone();
let thread_remote_connection = metadata
.as_ref()
.and_then(|metadata| metadata.remote_connection.clone());
cx.spawn_in(window, async move |this, cx| {
if !remove_task.await? {
return anyhow::Ok(());
@ -4418,6 +4523,13 @@ impl Sidebar {
}
this.update_in(cx, |this, window, cx| {
if let Some(thread_folder_paths) = thread_folder_paths.as_ref() {
this.delete_empty_drafts_for_archive_paths(
thread_folder_paths,
thread_remote_connection.as_ref(),
cx,
);
}
let in_flight = thread_id.and_then(|tid| {
this.start_archive_worktree_task(tid, roots_to_archive, cx)
});
@ -4426,6 +4538,7 @@ impl Sidebar {
thread_id,
neighbor.as_ref(),
thread_folder_paths.as_ref(),
thread_remote_connection.as_ref(),
in_flight,
window,
cx,
@ -4437,6 +4550,9 @@ impl Sidebar {
} else if !close_item_tasks.is_empty() {
let session_id = session_id.clone();
let thread_folder_paths = thread_folder_paths.clone();
let thread_remote_connection = metadata
.as_ref()
.and_then(|metadata| metadata.remote_connection.clone());
cx.spawn_in(window, async move |this, cx| {
for task in close_item_tasks {
let result: anyhow::Result<()> = task.await;
@ -4452,6 +4568,7 @@ impl Sidebar {
thread_id,
neighbor.as_ref(),
thread_folder_paths.as_ref(),
thread_remote_connection.as_ref(),
in_flight,
window,
cx,
@ -4468,6 +4585,9 @@ impl Sidebar {
thread_id,
neighbor.as_ref(),
thread_folder_paths.as_ref(),
metadata
.as_ref()
.and_then(|metadata| metadata.remote_connection.as_ref()),
in_flight,
window,
cx,
@ -4497,6 +4617,7 @@ impl Sidebar {
thread_id: Option<agent_ui::ThreadId>,
neighbor: Option<&ActivatableEntry>,
thread_folder_paths: Option<&PathList>,
thread_remote_connection: Option<&RemoteConnectionOptions>,
in_flight_archive: Option<(Task<()>, async_channel::Sender<()>)>,
window: &mut Window,
cx: &mut Context<Self>,
@ -4521,11 +4642,10 @@ impl Sidebar {
// archived thread from its workspace's panel so that switching
// to that workspace later doesn't show a stale thread.
if let Some(folder_paths) = thread_folder_paths {
if let Some(workspace) = self
.multi_workspace
.upgrade()
.and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx))
{
if let Some(workspace) = self.multi_workspace.upgrade().and_then(|mw| {
mw.read(cx)
.workspace_for_paths(folder_paths, thread_remote_connection, cx)
}) {
if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
let panel_shows_archived = panel
.read(cx)
@ -4552,10 +4672,10 @@ impl Sidebar {
// No neighbor or its workspace isn't open — just clear the
// panel so the group is left empty.
if let Some(folder_paths) = thread_folder_paths {
let workspace = self
.multi_workspace
.upgrade()
.and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx));
let workspace = self.multi_workspace.upgrade().and_then(|mw| {
mw.read(cx)
.workspace_for_paths(folder_paths, thread_remote_connection, cx)
});
if let Some(workspace) = workspace {
if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
@ -4576,6 +4696,8 @@ impl Sidebar {
return None;
}
self.delete_empty_drafts_for_archive_roots(&roots, cx);
let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1);
let task = cx.spawn(async move |_this, cx| {
match Self::archive_worktree_roots(roots, cancel_rx, cx).await {
@ -4610,6 +4732,8 @@ impl Sidebar {
return;
}
self.delete_empty_drafts_for_archive_roots(&roots, cx);
let (cancel_tx, cancel_rx) = async_channel::bounded::<()>(1);
cx.spawn(async move |_this, cx| {
let outcome = Self::archive_worktree_roots(roots, cancel_rx, cx).await;
@ -4617,7 +4741,7 @@ impl Sidebar {
match outcome {
Ok(ArchiveWorktreeOutcome::Success | ArchiveWorktreeOutcome::Cancelled) => {}
Err(error) => {
log::error!("Failed to archive worktree after closing terminal: {error:#}");
log::error!("Failed to archive worktree after closing sidebar item: {error:#}");
}
}
})
@ -4706,11 +4830,9 @@ impl Sidebar {
AgentThreadStatus::Completed | AgentThreadStatus::Error => {}
}
if thread.is_draft {
if let ThreadEntryWorkspace::Open(workspace) = &thread.workspace {
let workspace = workspace.clone();
let draft_id = thread.metadata.thread_id;
self.remove_draft(draft_id, &workspace, window, cx);
}
let workspace = thread.workspace.clone();
let draft_id = thread.metadata.thread_id;
self.remove_draft(draft_id, &workspace, window, cx);
} else if let Some(session_id) = thread.metadata.session_id.clone() {
self.archive_thread(&session_id, window, cx);
}
@ -5263,9 +5385,12 @@ impl Sidebar {
.on_click({
let thread_workspace = thread_workspace.clone();
cx.listener(move |this, _, window, cx| {
if let ThreadEntryWorkspace::Open(workspace) = &thread_workspace {
this.remove_draft(thread_id_for_actions, workspace, window, cx);
}
this.remove_draft(
thread_id_for_actions,
&thread_workspace,
window,
cx,
);
})
}),
)
@ -5460,30 +5585,301 @@ impl Sidebar {
}
}
/// Deletes a parked draft thread (its metadata row, any kvp-stored
/// draft prompt) and promotes a sibling in the same group, if any, to
/// the active entry.
fn remove_draft(
/// Closed linked-worktree drafts need an open workspace so archive root
/// planning can inspect repositories before deleting the worktree.
fn open_workspace_and_remove_draft(
&mut self,
draft_id: ThreadId,
workspace: &Entity<Workspace>,
folder_paths: PathList,
project_group_key: ProjectGroupKey,
window: &mut Window,
cx: &mut Context<Self>,
) {
workspace.update(cx, |ws, cx| {
if let Some(panel) = ws.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
panel.remove_thread(draft_id, window, cx);
});
}
});
let Some((open_task, modal_workspace)) =
self.open_workspace_for_archive(folder_paths, project_group_key, window, cx)
else {
return;
};
cx.spawn_in(window, async move |this, cx| {
let result = open_task.await;
remote_connection::dismiss_connection_modal(&modal_workspace, cx);
let workspace = result?;
Self::wait_for_archive_workspace_metadata(&workspace, cx).await;
this.update_in(cx, |this, window, cx| {
let workspace = ThreadEntryWorkspace::Open(workspace);
this.remove_draft(draft_id, &workspace, window, cx);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
}
fn remove_draft(
&mut self,
draft_id: ThreadId,
workspace: &ThreadEntryWorkspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
let metadata = ThreadMetadataStore::global(cx)
.read(cx)
.entry(draft_id)
.cloned();
if let ThreadEntryWorkspace::Closed {
folder_paths,
project_group_key,
} = workspace
&& self.should_load_closed_workspace_for_archive(
folder_paths,
project_group_key,
metadata
.as_ref()
.and_then(|metadata| metadata.remote_connection.as_ref()),
Some(draft_id),
None,
cx,
)
{
self.open_workspace_and_remove_draft(
draft_id,
folder_paths.clone(),
project_group_key.clone(),
window,
cx,
);
return;
}
let draft_folder_paths = metadata
.as_ref()
.map(|metadata| metadata.folder_paths().clone())
.or_else(|| match workspace {
ThreadEntryWorkspace::Open(workspace) => {
Some(PathList::new(&workspace.read(cx).root_paths(cx)))
}
ThreadEntryWorkspace::Closed { folder_paths, .. } => Some(folder_paths.clone()),
});
let draft_remote_connection = metadata
.as_ref()
.and_then(|metadata| metadata.remote_connection.clone());
let roots_to_archive = metadata
.as_ref()
.map(|metadata| {
self.roots_to_archive_for_paths(
metadata.folder_paths(),
metadata.remote_connection.as_ref(),
Some(draft_id),
None,
cx,
)
})
.unwrap_or_default();
let was_active = self
.active_entry
.as_ref()
.is_some_and(|e| e.is_active_thread(&draft_id));
.is_some_and(|entry| entry.is_active_thread(&draft_id));
let neighbor = self
.contents
.entries
.iter()
.position(|entry| {
matches!(
entry,
ListEntry::Thread(thread) if thread.metadata.thread_id == draft_id
)
})
.and_then(|position| self.neighboring_activatable_entry(position));
let workspace_to_remove = draft_folder_paths.as_ref().and_then(|folder_paths| {
self.linked_worktree_workspace_to_remove(
folder_paths,
draft_remote_connection.as_ref(),
Some(draft_id),
None,
&roots_to_archive,
cx,
)
});
let mut workspaces_to_remove: Vec<Entity<Workspace>> =
workspace_to_remove.into_iter().collect();
let close_item_tasks = self.close_items_for_archived_worktrees(
&roots_to_archive,
&mut workspaces_to_remove,
window,
cx,
);
if !workspaces_to_remove.is_empty() {
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
return;
};
let draft_workspace_removed = matches!(
workspace,
ThreadEntryWorkspace::Open(workspace) if workspaces_to_remove.contains(workspace)
);
let (fallback_paths, project_group_key) = neighbor
.as_ref()
.map(|neighbor| neighbor.project_location(cx))
.unwrap_or_else(|| {
workspaces_to_remove
.first()
.map(|workspace| {
let key = workspace.read(cx).project_group_key(cx);
(key.path_list().clone(), key)
})
.unwrap_or_default()
});
let excluded = workspaces_to_remove.clone();
let remove_task = multi_workspace.update(cx, |multi_workspace, cx| {
multi_workspace.remove(
workspaces_to_remove,
move |this, window, cx| {
let active_workspace = this.workspace().clone();
this.find_or_create_workspace(
fallback_paths,
project_group_key.host(),
Some(project_group_key),
|options, window, cx| {
connect_remote(active_workspace, options, window, cx)
},
&excluded,
None,
OpenMode::Activate,
window,
cx,
)
},
window,
cx,
)
});
let workspace = workspace.clone();
cx.spawn_in(window, async move |this, cx| {
if !remove_task.await? {
return anyhow::Ok(());
}
for task in close_item_tasks {
let result: anyhow::Result<()> = task.await;
result.log_err();
}
this.update_in(cx, |this, window, cx| {
if draft_workspace_removed {
if let Some(draft_folder_paths) = draft_folder_paths.as_ref() {
this.delete_empty_drafts_for_archive_paths(
draft_folder_paths,
draft_remote_connection.as_ref(),
cx,
);
}
}
this.remove_draft_entry(
draft_id,
&workspace,
was_active,
neighbor.as_ref(),
!draft_workspace_removed,
roots_to_archive,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else if !close_item_tasks.is_empty() {
let workspace = workspace.clone();
cx.spawn_in(window, async move |this, cx| {
for task in close_item_tasks {
let result: anyhow::Result<()> = task.await;
result.log_err();
}
this.update_in(cx, |this, window, cx| {
this.remove_draft_entry(
draft_id,
&workspace,
was_active,
neighbor.as_ref(),
true,
roots_to_archive,
window,
cx,
);
})?;
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else {
self.remove_draft_entry(
draft_id,
workspace,
was_active,
neighbor.as_ref(),
true,
roots_to_archive,
window,
cx,
);
}
}
fn remove_draft_entry(
&mut self,
draft_id: ThreadId,
workspace: &ThreadEntryWorkspace,
was_active: bool,
neighbor: Option<&ActivatableEntry>,
activate_panel_draft: bool,
roots_to_archive: Vec<thread_worktree_archive::RootPlan>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let removed_from_panel = if let ThreadEntryWorkspace::Open(workspace) = workspace {
workspace.update(cx, |workspace, cx| {
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
panel.update(cx, |panel, cx| {
if activate_panel_draft {
panel.remove_thread(draft_id, window, cx);
} else {
panel.remove_thread_without_activating_draft(draft_id, window, cx);
}
});
true
} else {
false
}
})
} else {
false
};
if !removed_from_panel {
ThreadMetadataStore::global(cx).update(cx, |store, cx| {
store.delete(draft_id, cx);
});
}
self.start_detached_archive_worktree_task(roots_to_archive, cx);
if was_active {
self.active_entry = None;
if !activate_panel_draft {
if neighbor
.as_ref()
.is_some_and(|neighbor| self.activate_entry(neighbor, window, cx))
{
return;
}
self.sync_active_entry_from_active_workspace(cx);
}
}
self.update_entries(cx);

File diff suppressed because it is too large Load diff