sidebar: Fix stale sidebar thread header state (#57017)

There was a case where if you archived or closed all threads, you
wouldn't see the empty state again.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
This commit is contained in:
Ben Brandt 2026-05-19 16:43:38 +02:00 committed by GitHub
parent c5f6fca756
commit ad437c93c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 155 additions and 26 deletions

View file

@ -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::<Vec<_>>()
.join(" ");
truncate_draft_label(&raw)
}

View file

@ -220,6 +220,32 @@ impl ThreadEntryWorkspace {
}
}
fn draft_display_label_for_thread_metadata(
metadata: &ThreadMetadata,
workspace: &ThreadEntryWorkspace,
cx: &App,
) -> Option<SharedString> {
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<ThreadId>,
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<ThreadId> = group_workspaces
.iter()
.filter_map(|ws| {
ws.read(cx)
.panel::<AgentPanel>(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<ThreadId> = group_workspaces
.iter()
.filter_map(|ws| {
ws.read(cx)
.panel::<AgentPanel>(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 =

View file

@ -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<Sidebar>,
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,