Fix thread's worktree being discarded upon archiving the previous thread in the sidebar (#52271)

This PR fixes a bug where archiving a thread could cause the *next*
thread to be opened in the current workspace, even though it belonged to
a different worktree.

Release Notes:

- N/A
This commit is contained in:
Max Brunsfeld authored and GitHub committed 2026-03-23 17:23:33 -07:00
1 parent 3aa069bfd7
commit f2f8d179a0
1 file changed
+186 -1
+186 -1
View File
@@ -2401,7 +2401,17 @@ impl Sidebar {
if let Some(next) = next_thread {
self.focused_thread = Some(next.session_info.session_id.clone());
if let Some(workspace) = &group_workspace {
// Use the thread's own workspace when it has one open (e.g. an absorbed
// linked worktree thread that appears under the main workspace's header
// but belongs to its own workspace). Loading into the wrong panel binds
// the thread to the wrong project, which corrupts its stored folder_paths
// when metadata is saved via ThreadMetadata::from_thread.
let target_workspace = match &next.workspace {
ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
ThreadEntryWorkspace::Closed(_) => group_workspace,
};
if let Some(workspace) = target_workspace {
if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
agent_panel.update(cx, |panel, cx| {
panel.load_agent_thread(
@@ -6693,4 +6703,179 @@ mod tests {
"other windows should not be activated just because they also match the saved paths"
);
}
#[gpui::test]
async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppContext) {
// Regression test: archive_thread previously always loaded the next thread
// through group_workspace (the main workspace's ProjectHeader), even when
// the next thread belonged to an absorbed linked-worktree workspace. That
// caused the worktree thread to be loaded in the main panel, which bound it
// to the main project and corrupted its stored folder_paths.
//
// The fix: use next.workspace (ThreadEntryWorkspace::Open) when available,
// falling back to group_workspace only for Closed workspaces.
agent_ui::test_support::init_test(cx);
cx.update(|cx| {
cx.update_flags(false, vec!["agent-v2".into()]);
ThreadStore::init_global(cx);
SidebarThreadMetadataStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
prompt_store::init(cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
"/project",
serde_json::json!({
".git": {
"worktrees": {
"feature-a": {
"commondir": "../../",
"HEAD": "ref: refs/heads/feature-a",
},
},
},
"src": {},
}),
)
.await;
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: Some("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;
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)
});
// Activate main workspace so the sidebar tracks the main panel.
multi_workspace.update_in(cx, |mw, window, cx| {
mw.activate_index(0, window, cx);
});
let sidebar = setup_sidebar(&multi_workspace, cx);
let main_workspace = multi_workspace.read_with(cx, |mw, _| mw.workspaces()[0].clone());
let main_panel = add_agent_panel(&main_workspace, &main_project, cx);
let _worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
// Open Thread 2 in the main panel and keep it running.
let connection = StubAgentConnection::new();
open_thread_with_connection(&main_panel, connection.clone(), cx);
send_message(&main_panel, cx);
let thread2_session_id = active_session_id(&main_panel, cx);
cx.update(|_, cx| {
connection.send_update(
thread2_session_id.clone(),
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("working...".into())),
cx,
);
});
// Save thread 2's metadata with a newer timestamp so it sorts above thread 1.
save_thread_metadata(
thread2_session_id.clone(),
"Thread 2".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 2, 0, 0, 0).unwrap(),
PathList::new(&[std::path::PathBuf::from("/project")]),
cx,
)
.await;
// Save thread 1's metadata with the worktree path and an older timestamp so
// it sorts below thread 2. archive_thread will find it as the "next" candidate.
let thread1_session_id = acp::SessionId::new(Arc::from("thread1-worktree-session"));
save_thread_metadata(
thread1_session_id.clone(),
"Thread 1".into(),
chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]),
cx,
)
.await;
cx.run_until_parked();
// Verify the sidebar absorbed thread 1 under [project] with the worktree chip.
let entries_before = visible_entries_as_strings(&sidebar, cx);
assert!(
entries_before.iter().any(|s| s.contains("{wt-feature-a}")),
"Thread 1 should appear with the linked-worktree chip before archiving: {:?}",
entries_before
);
// The sidebar should track T2 as the focused thread (derived from the
// main panel's active view).
let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone());
assert_eq!(
focused,
Some(thread2_session_id.clone()),
"focused thread should be Thread 2 before archiving: {:?}",
focused
);
// Archive thread 2.
sidebar.update_in(cx, |sidebar, window, cx| {
sidebar.archive_thread(&thread2_session_id, window, cx);
});
cx.run_until_parked();
// The main panel's active thread must still be thread 2.
let main_active = main_panel.read_with(cx, |panel, cx| {
panel
.active_agent_thread(cx)
.map(|t| t.read(cx).session_id().clone())
});
assert_eq!(
main_active,
Some(thread2_session_id.clone()),
"main panel should not have been taken over by loading the linked-worktree thread T1; \
before the fix, archive_thread used group_workspace instead of next.workspace, \
causing T1 to be loaded in the wrong panel"
);
// Thread 1 should still appear in the sidebar with its worktree chip
// (Thread 2 was archived so it is gone from the list).
let entries_after = visible_entries_as_strings(&sidebar, cx);
assert!(
entries_after.iter().any(|s| s.contains("{wt-feature-a}")),
"T1 should still carry its linked-worktree chip after archiving T2: {:?}",
entries_after
);
}
}