diff --git a/crates/agent_ui/src/draft_prompt_store.rs b/crates/agent_ui/src/draft_prompt_store.rs index b32b8e811cf..34d17d49994 100644 --- a/crates/agent_ui/src/draft_prompt_store.rs +++ b/crates/agent_ui/src/draft_prompt_store.rs @@ -12,6 +12,7 @@ use agent_client_protocol::schema as acp; use anyhow::Context as _; use db::kvp::KeyValueStore; use gpui::{App, AppContext as _, Entity, Task}; +use itertools::Itertools; use ui::SharedString; use util::ResultExt as _; use workspace::Workspace; @@ -128,7 +129,6 @@ pub fn display_label_for_draft( acp::ContentBlock::ResourceLink(link) => Some(link.uri.as_str()), _ => None, }) - .collect::>() .join(" "); truncate_draft_label(&raw) } diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index 735421f858d..a86960f3fb5 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -220,6 +220,32 @@ impl ThreadEntryWorkspace { } } +fn draft_display_label_for_thread_metadata( + metadata: &ThreadMetadata, + workspace: &ThreadEntryWorkspace, + cx: &App, +) -> Option { + let workspace = match workspace { + ThreadEntryWorkspace::Open(workspace) => Some(workspace), + ThreadEntryWorkspace::Closed { .. } => None, + }; + agent_ui::draft_prompt_store::display_label_for_draft(workspace, metadata.thread_id, cx) +} + +fn thread_metadata_would_render_sidebar_row( + metadata: &ThreadMetadata, + workspace: &ThreadEntryWorkspace, + hidden_draft_thread_ids: &HashSet, + cx: &App, +) -> bool { + if !metadata.is_draft() { + return true; + } + + !hidden_draft_thread_ids.contains(&metadata.thread_id) + && draft_display_label_for_thread_metadata(metadata, workspace, cx).is_some() +} + #[derive(Clone)] struct ThreadEntry { metadata: ThreadMetadata, @@ -1385,19 +1411,18 @@ impl Sidebar { let mut has_running_threads = false; let mut waiting_thread_count: usize = 0; let group_host = group_key.host(); + let hidden_draft_thread_ids: HashSet = group_workspaces + .iter() + .filter_map(|ws| { + ws.read(cx) + .panel::(cx) + .and_then(|panel| panel.read(cx).ephemeral_draft_thread_id(cx)) + }) + .collect(); if should_load_threads { let thread_store = ThreadMetadataStore::global(cx); - let ephemeral_drafts: HashSet = group_workspaces - .iter() - .filter_map(|ws| { - ws.read(cx) - .panel::(cx) - .and_then(|panel| panel.read(cx).ephemeral_draft_thread_id(cx)) - }) - .collect(); - let make_thread_entry = |row: ThreadMetadata, workspace: ThreadEntryWorkspace| -> ThreadEntry { let (icon, icon_from_external_svg) = resolve_agent_icon(&row.agent_id); @@ -1503,20 +1528,18 @@ impl Sidebar { } } - if !ephemeral_drafts.is_empty() { - threads.retain(|thread| !ephemeral_drafts.contains(&thread.metadata.thread_id)); + if !hidden_draft_thread_ids.is_empty() { + threads.retain(|thread| { + !hidden_draft_thread_ids.contains(&thread.metadata.thread_id) + }); } for thread in &mut threads { if !thread.is_draft { continue; } - let workspace = match &thread.workspace { - ThreadEntryWorkspace::Open(workspace) => Some(workspace), - ThreadEntryWorkspace::Closed { .. } => None, - }; - thread.metadata.title = agent_ui::draft_prompt_store::display_label_for_draft( - workspace, - thread.metadata.thread_id, + thread.metadata.title = draft_display_label_for_thread_metadata( + &thread.metadata, + &thread.workspace, cx, ); } @@ -1582,19 +1605,33 @@ impl Sidebar { } } - let has_threads = if !threads.is_empty() || !terminals.is_empty() { - true - } else { + let has_visible_rows = !threads.is_empty() || !terminals.is_empty(); + let has_stored_thread_rows = !should_load_threads && !has_visible_rows && { let store = ThreadMetadataStore::global(cx).read(cx); store .entries_for_main_worktree_path(group_key.path_list(), group_host.as_ref()) - .next() - .is_some() + .any(|metadata| { + let workspace = resolve_workspace(metadata.folder_paths()); + thread_metadata_would_render_sidebar_row( + metadata, + &workspace, + &hidden_draft_thread_ids, + cx, + ) + }) || store .entries_for_path(group_key.path_list(), group_host.as_ref()) - .next() - .is_some() + .any(|metadata| { + let workspace = resolve_workspace(metadata.folder_paths()); + thread_metadata_would_render_sidebar_row( + metadata, + &workspace, + &hidden_draft_thread_ids, + cx, + ) + }) }; + let has_threads = has_visible_rows || has_stored_thread_rows; if !query.is_empty() { let workspace_highlight_positions = diff --git a/crates/sidebar/src/sidebar_tests.rs b/crates/sidebar/src/sidebar_tests.rs index cb385828dc6..982f0c6cd24 100644 --- a/crates/sidebar/src/sidebar_tests.rs +++ b/crates/sidebar/src/sidebar_tests.rs @@ -95,6 +95,34 @@ fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { .any(|entry| matches!(entry, ListEntry::Thread(t) if t.metadata.session_id.as_ref() == Some(session_id))) } +#[track_caller] +fn assert_project_header_has_threads( + sidebar: &Entity, + project_name: &str, + expected_has_threads: bool, + cx: &mut gpui::VisualTestContext, +) { + sidebar.read_with(cx, |sidebar, _cx| { + let has_threads = sidebar.contents.entries.iter().find_map(|entry| { + if let ListEntry::ProjectHeader { + label, has_threads, .. + } = entry + && label.as_ref() == project_name + { + Some(*has_threads) + } else { + None + } + }); + + assert_eq!( + has_threads, + Some(expected_has_threads), + "expected project header `{project_name}` to have has_threads={expected_has_threads}, got {has_threads:?}" + ); + }); +} + #[track_caller] fn assert_remote_project_integration_sidebar_state( sidebar: &mut Sidebar, @@ -1540,6 +1568,70 @@ async fn test_agent_panel_terminals_appear_in_sidebar_and_search(cx: &mut TestAp ); } +#[gpui::test] +async fn test_closing_last_agent_panel_terminal_restores_empty_header(cx: &mut TestAppContext) { + 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, cx); + + assert_project_header_has_threads(&sidebar, "my-project", false, cx); + + let terminal_id = panel + .update_in(cx, |panel, window, cx| { + panel.insert_test_terminal("Dev Server", true, window, cx) + }) + .expect("test terminal should be inserted"); + cx.run_until_parked(); + + assert_project_header_has_threads(&sidebar, "my-project", true, cx); + + let (terminal_metadata, terminal_workspace) = sidebar.read_with(cx, |sidebar, _cx| { + sidebar + .contents + .entries + .iter() + .find_map(|entry| match entry { + ListEntry::Terminal(terminal) if terminal.metadata.terminal_id == terminal_id => { + Some((terminal.metadata.clone(), terminal.workspace.clone())) + } + _ => None, + }) + .expect("terminal should be visible in sidebar") + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.close_terminal(&terminal_metadata, &terminal_workspace, window, cx); + }); + cx.run_until_parked(); + + panel.read_with(cx, |panel, cx| { + assert!(!panel.has_terminal(terminal_id)); + assert!( + panel.active_view_is_new_draft(cx), + "closing the active terminal should leave the panel on a hidden empty draft" + ); + }); + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["v [my-project]"] + ); + assert_project_header_has_threads(&sidebar, "my-project", false, cx); + + let project_group_key = multi_workspace.read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace().read(cx).project_group_key(cx) + }); + sidebar.update_in(cx, |sidebar, window, cx| { + sidebar.toggle_collapse(&project_group_key, window, cx); + }); + cx.run_until_parked(); + + assert_eq!( + visible_entries_as_strings(&sidebar, cx), + vec!["> [my-project]"] + ); + assert_project_header_has_threads(&sidebar, "my-project", false, cx); +} + #[gpui::test] async fn test_agent_panel_terminal_metadata_remains_visible_after_panel_is_removed( cx: &mut TestAppContext,