From bf0fcfbbac4ad7f042c424ccb66372ae844ac71d Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 14 Apr 2026 15:05:34 -0400 Subject: [PATCH] Preserve archived thread project paths after worktree removal (#53893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a thread created in a git worktree is archived, the archive flow removes the linked worktree from the project. If a subsequent thread event fires after this (e.g. an async title-generation task completing), `handle_thread_event` recalculates `worktree_paths` from the project's current state — which no longer includes the removed worktree — and overwrites the archived thread's stored paths with empty ones. This causes restoring the thread to prompt the user to re-associate a project, instead of restoring it directly. **Fix:** return early from `handle_thread_event` when the thread is already archived, so the stored paths are never overwritten. Release Notes: - Fixed archived threads in git worktrees losing their project association. --- crates/agent_ui/src/thread_metadata_store.rs | 133 ++++++++++++++++++- 1 file changed, 129 insertions(+), 4 deletions(-) diff --git a/crates/agent_ui/src/thread_metadata_store.rs b/crates/agent_ui/src/thread_metadata_store.rs index 442f186a0716937f7e733d63a7b7db46b6893f28..44353111e9da35b97c39295f29ae525456bd8f94 100644 --- a/crates/agent_ui/src/thread_metadata_store.rs +++ b/crates/agent_ui/src/thread_metadata_store.rs @@ -638,6 +638,10 @@ impl ThreadMetadataStore { cx: &mut Context, ) { if let Some(thread) = self.threads.get(&thread_id) { + debug_assert!( + !thread.archived, + "update_working_directories called on archived thread" + ); self.save_internal(ThreadMetadata { worktree_paths: WorktreePaths::from_path_lists( thread.main_worktree_paths().clone(), @@ -664,6 +668,12 @@ impl ThreadMetadataStore { if thread.worktree_paths == worktree_paths { continue; } + // Don't overwrite paths for archived threads — the + // project may no longer include the worktree that was + // removed during the archive flow. + if thread.archived { + continue; + } self.save_internal(ThreadMetadata { worktree_paths: worktree_paths.clone(), ..thread.clone() @@ -1102,10 +1112,24 @@ impl ThreadMetadataStore { let agent_id = thread_ref.connection().agent_id(); - let project = thread_ref.project().read(cx); - let worktree_paths = project.worktree_paths(cx); - - let remote_connection = project.remote_connection_options(cx); + // Preserve project-dependent fields for archived threads. + // The worktree may already have been removed from the + // project as part of the archive flow, so re-evaluating + // these from the current project state would yield + // empty/incorrect results. + let (worktree_paths, remote_connection) = + if let Some(existing) = existing_thread.filter(|t| t.archived) { + ( + existing.worktree_paths.clone(), + existing.remote_connection.clone(), + ) + } else { + let project = thread_ref.project().read(cx); + ( + project.worktree_paths(cx), + project.remote_connection_options(cx), + ) + }; // Threads without a folder path (e.g. started in an empty // window) are archived by default so they don't get lost, @@ -3543,4 +3567,105 @@ mod tests { let result = WorktreePaths::from_path_lists(main, folder); assert!(result.is_err()); } + + /// Regression test: archiving a thread created in a git worktree must + /// preserve the thread's folder paths so that restoring it later does + /// not prompt the user to re-associate a project. + #[gpui::test] + async fn test_archived_thread_retains_paths_after_worktree_removal(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/worktrees/feature", + serde_json::json!({ "src": { "main.rs": "" } }), + ) + .await; + let project = Project::test(fs, [Path::new("/worktrees/feature")], cx).await; + let connection = StubAgentConnection::new(); + + let (panel, mut vcx) = setup_panel_with_project(project.clone(), cx); + crate::test_support::open_thread_with_connection(&panel, connection, &mut vcx); + + let thread = panel.read_with(&vcx, |panel, cx| panel.active_agent_thread(cx).unwrap()); + let thread_id = crate::test_support::active_thread_id(&panel, &vcx); + + // Push content so the event handler saves metadata with the + // project's worktree paths. + thread.update_in(&mut vcx, |thread, _window, cx| { + thread.push_user_content_block(None, "Hello".into(), cx); + }); + vcx.run_until_parked(); + + // Verify paths were saved correctly. + let (folder_paths_before, main_paths_before) = cx.read(|cx| { + let store = ThreadMetadataStore::global(cx).read(cx); + let entry = store.entry(thread_id).unwrap(); + assert!( + !entry.folder_paths().is_empty(), + "thread should have folder paths before archiving" + ); + ( + entry.folder_paths().clone(), + entry.main_worktree_paths().clone(), + ) + }); + + // Archive the thread. + cx.update(|cx| { + ThreadMetadataStore::global(cx).update(cx, |store, cx| { + store.archive(thread_id, None, cx); + }); + }); + cx.run_until_parked(); + + // Remove the worktree from the project, simulating what the + // archive flow does for linked git worktrees. + let worktree_id = cx.update(|cx| { + project + .read(cx) + .visible_worktrees(cx) + .next() + .unwrap() + .read(cx) + .id() + }); + project.update(cx, |project, cx| { + project.remove_worktree(worktree_id, cx); + }); + cx.run_until_parked(); + + // Trigger a thread event after archiving + worktree removal. + // In production this happens when an async title-generation task + // completes after the thread was archived. + thread.update_in(&mut vcx, |thread, _window, cx| { + thread.set_title("Generated title".into(), cx).detach(); + }); + vcx.run_until_parked(); + + // The archived thread must still have its original folder paths. + cx.read(|cx| { + let store = ThreadMetadataStore::global(cx).read(cx); + let entry = store.entry(thread_id).unwrap(); + assert!(entry.archived, "thread should still be archived"); + assert_eq!( + entry.display_title().as_ref(), + "Generated title", + "title should still be updated for archived threads" + ); + assert_eq!( + entry.folder_paths(), + &folder_paths_before, + "archived thread must retain its folder paths after worktree \ + removal + subsequent thread event, otherwise restoring it \ + will prompt the user to re-associate a project" + ); + assert_eq!( + entry.main_worktree_paths(), + &main_paths_before, + "archived thread must retain its main worktree paths after \ + worktree removal + subsequent thread event" + ); + }); + } }