sidebar: Fix highlighting "new thread" element after cmd-n (#52105)

Danilo Leal created

Change summary

crates/sidebar/src/sidebar.rs | 227 +++++++++++++++++++++++++++++++++++-
1 file changed, 216 insertions(+), 11 deletions(-)

Detailed changes

crates/sidebar/src/sidebar.rs 🔗

@@ -132,6 +132,7 @@ enum ListEntry {
     NewThread {
         path_list: PathList,
         workspace: Entity<Workspace>,
+        is_active_draft: bool,
     },
 }
 
@@ -427,8 +428,15 @@ impl Sidebar {
         cx.subscribe_in(
             agent_panel,
             window,
-            |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
+            |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
                 AgentPanelEvent::ActiveViewChanged => {
+                    let is_new_draft = agent_panel
+                        .read(cx)
+                        .active_conversation()
+                        .is_some_and(|cv| cv.read(cx).parent_id(cx).is_none());
+                    if is_new_draft {
+                        this.focused_thread = None;
+                    }
                     this.observe_draft_editor(cx);
                     this.update_entries(cx);
                 }
@@ -695,6 +703,10 @@ impl Sidebar {
             .iter()
             .any(|ws| !workspace_path_list(ws, cx).paths().is_empty());
 
+        let active_ws_index = active_workspace
+            .as_ref()
+            .and_then(|active| workspaces.iter().position(|ws| ws == active));
+
         for (ws_index, workspace) in workspaces.iter().enumerate() {
             if absorbed.contains_key(&ws_index) {
                 continue;
@@ -972,9 +984,12 @@ impl Sidebar {
                 let is_draft_for_workspace = self.agent_panel_visible
                     && self.active_thread_is_draft
                     && self.focused_thread.is_none()
-                    && active_workspace
-                        .as_ref()
-                        .is_some_and(|active| active == workspace);
+                    && active_ws_index.is_some_and(|active_idx| {
+                        active_idx == ws_index
+                            || absorbed
+                                .get(&active_idx)
+                                .is_some_and(|(main_idx, _)| *main_idx == ws_index)
+                    });
 
                 let show_new_thread_entry = thread_count == 0 || is_draft_for_workspace;
 
@@ -996,6 +1011,7 @@ impl Sidebar {
                     entries.push(ListEntry::NewThread {
                         path_list: path_list.clone(),
                         workspace: workspace.clone(),
+                        is_active_draft: is_draft_for_workspace,
                     });
                 }
 
@@ -1145,7 +1161,10 @@ impl Sidebar {
             ListEntry::NewThread {
                 path_list,
                 workspace,
-            } => self.render_new_thread(ix, path_list, workspace, is_selected, cx),
+                is_active_draft,
+            } => {
+                self.render_new_thread(ix, path_list, workspace, *is_active_draft, is_selected, cx)
+            }
         };
 
         if is_group_header_after_first {
@@ -2679,15 +2698,11 @@ impl Sidebar {
         ix: usize,
         _path_list: &PathList,
         workspace: &Entity<Workspace>,
+        is_active_draft: bool,
         is_selected: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
-        let is_active = self.agent_panel_visible
-            && self.active_thread_is_draft
-            && self
-                .multi_workspace
-                .upgrade()
-                .map_or(false, |mw| mw.read(cx).workspace() == workspace);
+        let is_active = is_active_draft && self.agent_panel_visible && self.active_thread_is_draft;
 
         let label: SharedString = if is_active {
             self.active_draft_text(cx)
@@ -5248,6 +5263,196 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
+        // When the user presses Cmd-N (NewThread action) while viewing a
+        // non-empty thread, the sidebar should show the "New Thread" entry.
+        // This exercises the same code path as the workspace action handler
+        // (which bypasses the sidebar's create_new_thread method).
+        let project = init_test_project_with_agent_panel("/my-project", cx).await;
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
+
+        let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
+
+        // Create a non-empty thread (has messages).
+        let connection = StubAgentConnection::new();
+        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+            acp::ContentChunk::new("Done".into()),
+        )]);
+        open_thread_with_connection(&panel, connection, cx);
+        send_message(&panel, cx);
+
+        let session_id = active_session_id(&panel, cx);
+        save_test_thread_metadata(&session_id, path_list.clone(), cx).await;
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  Hello *"]
+        );
+
+        // Simulate cmd-n
+        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
+        panel.update_in(cx, |panel, window, cx| {
+            panel.new_thread(&NewThread, window, cx);
+        });
+        workspace.update_in(cx, |workspace, window, cx| {
+            workspace.focus_panel::<AgentPanel>(window, cx);
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [my-project]", "  [+ New Thread]", "  Hello *"],
+            "After Cmd-N the sidebar should show a highlighted New Thread entry"
+        );
+
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert!(
+                sidebar.focused_thread.is_none(),
+                "focused_thread should be cleared after Cmd-N"
+            );
+            assert!(
+                sidebar.active_thread_is_draft,
+                "the new blank thread should be a draft"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestAppContext) {
+        // When the active workspace is an absorbed git worktree, cmd-n
+        // should still show the "New Thread" entry under the main repo's
+        // header and highlight it as active.
+        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());
+
+        // 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: 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)
+        });
+
+        let worktree_panel = add_agent_panel(&worktree_workspace, &worktree_project, cx);
+
+        // Switch to the worktree workspace.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(1, window, cx);
+        });
+
+        let sidebar = setup_sidebar(&multi_workspace, cx);
+
+        // Create a non-empty thread in the worktree workspace.
+        let connection = StubAgentConnection::new();
+        connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
+            acp::ContentChunk::new("Done".into()),
+        )]);
+        open_thread_with_connection(&worktree_panel, connection, cx);
+        send_message(&worktree_panel, cx);
+
+        let session_id = active_session_id(&worktree_panel, cx);
+        let wt_path_list = PathList::new(&[std::path::PathBuf::from("/wt-feature-a")]);
+        save_test_thread_metadata(&session_id, wt_path_list, cx).await;
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec!["v [project]", "  Hello {wt-feature-a} *"]
+        );
+
+        // Simulate Cmd-N in the worktree workspace.
+        worktree_panel.update_in(cx, |panel, window, cx| {
+            panel.new_thread(&NewThread, window, cx);
+        });
+        worktree_workspace.update_in(cx, |workspace, window, cx| {
+            workspace.focus_panel::<AgentPanel>(window, cx);
+        });
+        cx.run_until_parked();
+
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project]",
+                "  [+ New Thread]",
+                "  Hello {wt-feature-a} *"
+            ],
+            "After Cmd-N in an absorbed worktree, the sidebar should show \
+             a highlighted New Thread entry under the main repo header"
+        );
+
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert!(
+                sidebar.focused_thread.is_none(),
+                "focused_thread should be cleared after Cmd-N"
+            );
+            assert!(
+                sidebar.active_thread_is_draft,
+                "the new blank thread should be a draft"
+            );
+        });
+    }
+
     async fn init_test_project_with_git(
         worktree_path: &str,
         cx: &mut TestAppContext,