Show worktree thread status in sidebar (#51879)

Before you mark this PR as ready for review, make sure that you have:
- [X] Added a solid test coverage and/or screenshots from doing manual
testing
- [X] Done a self-review taking into account security and performance
aspects
- [X] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- N/A

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
This commit is contained in:
Eric HolkandMax Brunsfeld authored and GitHub committed 2026-03-18 23:10:49 +00:00
1 parent 14885feca5
commit 35059adb27
1 file changed
+139 -9
+139 -9
View File
@@ -723,14 +723,7 @@ impl Sidebar {
let is_collapsed = self.collapsed_groups.contains(&path_list);
let should_load_threads = !is_collapsed || !query.is_empty();
let live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
let has_running_threads = live_infos
.iter()
.any(|info| info.status == AgentThreadStatus::Running);
let waiting_thread_count = live_infos
.iter()
.filter(|info| info.status == AgentThreadStatus::WaitingForConfirmation)
.count();
let mut live_infos = Self::all_thread_infos_for_workspace(workspace, cx);
let mut threads: Vec<ThreadEntry> = Vec::new();
@@ -807,7 +800,13 @@ impl Sidebar {
{
let target_workspace =
match absorbed_workspace_by_path.get(worktree_path.as_ref()) {
Some(&idx) => ThreadEntryWorkspace::Open(workspaces[idx].clone()),
Some(&idx) => {
live_infos.extend(Self::all_thread_infos_for_workspace(
&workspaces[idx],
cx,
));
ThreadEntryWorkspace::Open(workspaces[idx].clone())
}
None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()),
};
@@ -915,6 +914,16 @@ impl Sidebar {
});
}
// Compute running/waiting counts after live_infos has been
// extended with any absorbed worktree workspaces.
let has_running_threads = live_infos
.iter()
.any(|info| info.status == AgentThreadStatus::Running);
let waiting_thread_count = live_infos
.iter()
.filter(|info| info.status == AgentThreadStatus::WaitingForConfirmation)
.count();
if !query.is_empty() {
let workspace_highlight_positions =
fuzzy_match_positions(&query, &label).unwrap_or_default();
@@ -5241,6 +5250,127 @@ mod tests {
);
}
#[gpui::test]
async fn test_absorbed_worktree_running_thread_shows_live_status(cx: &mut TestAppContext) {
// When a worktree workspace is absorbed under the main repo, a
// running thread in the worktree's agent panel should still show
// live status (spinner + "(running)") in the sidebar.
agent_ui::test_support::init_test(cx);
cx.update(|cx| {
cx.update_flags(false, vec!["agent-v2".into()]);
ThreadStore::init_global(cx);
ThreadMetadataStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
prompt_store::init(cx);
});
let fs = FakeFs::new(cx.executor());
// Main repo with a linked worktree.
fs.insert_tree(
"/project",
serde_json::json!({
".git": {
"worktrees": {
"feature-a": {
"commondir": "../../",
"HEAD": "ref: refs/heads/feature-a",
},
},
},
"src": {},
}),
)
.await;
// Worktree checkout pointing back to the main repo.
fs.insert_tree(
"/wt-feature-a",
serde_json::json!({
".git": "gitdir: /project/.git/worktrees/feature-a",
"src": {},
}),
)
.await;
fs.with_git_state(std::path::Path::new("/project/.git"), false, |state| {
state.worktrees.push(git::repository::Worktree {
path: std::path::PathBuf::from("/wt-feature-a"),
ref_name: "refs/heads/feature-a".into(),
sha: "aaa".into(),
});
})
.unwrap();
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
let main_project = project::Project::test(fs.clone(), ["/project".as_ref()], cx).await;
let worktree_project =
project::Project::test(fs.clone(), ["/wt-feature-a".as_ref()], cx).await;
main_project
.update(cx, |p, cx| p.git_scans_complete(cx))
.await;
worktree_project
.update(cx, |p, cx| p.git_scans_complete(cx))
.await;
// Create the MultiWorkspace with both projects.
let (multi_workspace, cx) = cx.add_window_view(|window, cx| {
MultiWorkspace::test_new(main_project.clone(), window, cx)
});
let worktree_workspace = multi_workspace.update_in(cx, |mw, window, cx| {
mw.test_add_workspace(worktree_project.clone(), window, cx)
});
// Add an agent panel to the worktree workspace so we can run a
// thread inside it.
let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
// Switch back to the main workspace before setting up the sidebar.
multi_workspace.update_in(cx, |mw, window, cx| {
mw.activate_index(0, window, cx);
});
let sidebar = setup_sidebar(&multi_workspace, cx);
// Start a thread in the worktree workspace's panel and keep it
// generating (don't resolve it).
let connection = StubAgentConnection::new();
open_thread_with_connection(&worktree_panel, connection.clone(), cx);
send_message(&worktree_panel, cx);
let session_id = active_session_id(&worktree_panel, cx);
// Save metadata so the sidebar knows about this thread.
let wt_paths = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
save_test_thread_metadata(&session_id, wt_paths, cx).await;
// Keep the thread generating by sending a chunk without ending
// the turn.
cx.update(|_, cx| {
connection.send_update(
session_id.clone(),
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
cx,
);
});
cx.run_until_parked();
// The worktree thread should be absorbed under the main project
// and show live running status.
let entries = visible_entries_as_strings(&sidebar, cx);
assert_eq!(
entries,
vec![
"v [project]",
" [+ New Thread]",
" Hello {wt-feature-a} * (running)",
]
);
}
#[gpui::test]
async fn test_clicking_worktree_thread_opens_workspace_when_none_exists(
cx: &mut TestAppContext,