diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 4df6adaaa4d303402d622b393cb3899257d79f13..9761c9f0ad835cf4cc103700c5f70b715f1b9427 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -132,6 +132,7 @@ enum ListEntry { NewThread { path_list: PathList, workspace: Entity, + 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, + is_active_draft: bool, is_selected: bool, cx: &mut Context, ) -> 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::(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| ::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::(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,