Allow empty project groups in sidebar (#53852)
Previously, the sidebar would automatically create an empty draft thread for any project group that had no threads (via `reconcile_groups`). This added complexity and felt janky — the user would see a phantom "Draft" entry appear for every workspace they opened, even before they intended to start a conversation. Now, project groups can simply be empty. When a group has no threads: - The fold indicator renders as closed and grayed out (disabled — no click handler or tooltip) - The plus button is always visible on the right side of the header (not hidden behind hover) - Keyboard fold toggling is disabled for the group - Clicking the plus button creates the first thread and expands the group Draft threads are no longer auto-created anywhere: - `AgentPanel::load` no longer creates a draft when there is no previously active conversation - Archiving the last thread in a group clears the panel instead of creating a replacement draft - `show_or_create_empty_draft` and `clear_active_thread` are removed This removes a significant amount of supporting infrastructure that only existed to maintain the "no group may be empty" invariant: - `WorkspaceSidebarDelegate` trait and `AgentPanelSidebarDelegate` struct - `reconcile_groups` method and its 12 call sites - `clear_empty_group_drafts` method and its 3 call sites - `show_or_create_empty_draft` and `clear_active_thread` methods - `reconciling` field on `Sidebar` - `sidebar_delegate` field, setter, and getter on `Workspace` Release Notes: - Improved the sidebar to show empty project groups with a visible plus button instead of auto-creating placeholder draft threads. --------- Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
This commit is contained in:
1 parent
24019b0ae4
commit
491fb57b06
4 files changed
+145
-573
No files matched your search
@@ -86,7 +86,7 @@ use ui::{
|
||||
use util::{ResultExt as _, debug_panic};
|
||||
use workspace::{
|
||||
CollaboratorId, DraggedSelection, DraggedTab, PathList, SerializedPathList,
|
||||
ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId, WorkspaceSidebarDelegate,
|
||||
ToggleWorkspaceSidebar, ToggleZoom, Workspace, WorkspaceId,
|
||||
dock::{DockPosition, Panel, PanelEvent},
|
||||
};
|
||||
|
||||
@@ -109,39 +109,6 @@ impl MaxIdleRetainedThreads {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AgentPanelSidebarDelegate;
|
||||
|
||||
impl WorkspaceSidebarDelegate for AgentPanelSidebarDelegate {
|
||||
fn reconcile_group(
|
||||
&self,
|
||||
workspace: &mut Workspace,
|
||||
group_key: &workspace::ProjectGroupKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> bool {
|
||||
if workspace.project_group_key(cx) != *group_key {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
panel.update(cx, |panel, cx| {
|
||||
if panel.pending_thread_loads > 0 {
|
||||
return false;
|
||||
}
|
||||
if panel.draft_thread_ids(cx).is_empty() {
|
||||
panel.create_thread(window, cx);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct LastUsedAgent {
|
||||
agent: Agent,
|
||||
@@ -218,7 +185,6 @@ struct SerializedActiveThread {
|
||||
pub fn init(cx: &mut App) {
|
||||
cx.observe_new(
|
||||
|workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
|
||||
workspace.set_sidebar_delegate(Arc::new(AgentPanelSidebarDelegate));
|
||||
workspace
|
||||
.register_action(|workspace, action: &NewThread, window, cx| {
|
||||
if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
|
||||
@@ -1022,10 +988,6 @@ impl AgentPanel {
|
||||
cx,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.show_or_create_empty_draft(window, cx);
|
||||
});
|
||||
}
|
||||
panel
|
||||
})?;
|
||||
@@ -1358,30 +1320,16 @@ impl AgentPanel {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Clear any active conversation while preserving a real empty draft.
|
||||
/// Running non-draft threads are retained in the background.
|
||||
pub fn clear_active_thread(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.show_or_create_empty_draft(window, cx);
|
||||
}
|
||||
|
||||
fn show_or_create_empty_draft(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.active_thread_is_draft(cx) {
|
||||
self.clear_overlay_state();
|
||||
self.refresh_base_view_subscriptions(window, cx);
|
||||
self.serialize(cx);
|
||||
cx.emit(AgentPanelEvent::ActiveViewChanged);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(draft_id) = self.draft_thread_ids(cx).into_iter().next() {
|
||||
self.activate_retained_thread(draft_id, false, window, cx);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
let id = self.create_thread(window, cx);
|
||||
self.activate_retained_thread(id, false, window, cx);
|
||||
/// Clear the active view, retaining any running thread in the background.
|
||||
pub fn clear_base_view(&mut self, cx: &mut Context<Self>) {
|
||||
let old_view = std::mem::replace(&mut self.base_view, BaseView::Uninitialized);
|
||||
self.retain_running_thread(old_view, cx);
|
||||
self.clear_overlay_state();
|
||||
self._thread_view_subscription = None;
|
||||
self._active_thread_focus_subscription = None;
|
||||
self._base_view_observation = None;
|
||||
self.serialize(cx);
|
||||
cx.emit(AgentPanelEvent::ActiveViewChanged);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -5510,8 +5458,8 @@ mod tests {
|
||||
);
|
||||
});
|
||||
|
||||
// Workspace B should restore its own agent type and seed an empty draft.
|
||||
loaded_b.read_with(cx, |panel, cx| {
|
||||
// Workspace B should restore its own agent type but have no active thread.
|
||||
loaded_b.read_with(cx, |panel, _cx| {
|
||||
assert_eq!(
|
||||
panel.selected_agent,
|
||||
Agent::Custom {
|
||||
@@ -5520,12 +5468,8 @@ mod tests {
|
||||
"workspace B agent type should be restored"
|
||||
);
|
||||
assert!(
|
||||
panel.active_conversation_view().is_some(),
|
||||
"workspace B should show an empty draft"
|
||||
);
|
||||
assert!(
|
||||
panel.active_thread_is_draft(cx),
|
||||
"workspace B should seed a draft in an empty workspace"
|
||||
panel.active_conversation_view().is_none(),
|
||||
"workspace B should have no active thread when it had no prior conversation"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -5587,14 +5531,10 @@ mod tests {
|
||||
.expect("panel load should succeed");
|
||||
cx.run_until_parked();
|
||||
|
||||
loaded.read_with(cx, |panel, cx| {
|
||||
loaded.read_with(cx, |panel, _cx| {
|
||||
assert!(
|
||||
panel.active_conversation_view().is_some(),
|
||||
"empty workspaces should still show a draft after load"
|
||||
);
|
||||
assert!(
|
||||
panel.active_thread_is_draft(cx),
|
||||
"thread without metadata should not be restored; the panel should fall back to a fresh draft"
|
||||
panel.active_conversation_view().is_none(),
|
||||
"thread without metadata should not be restored; the panel should have no active thread"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -6265,69 +6205,6 @@ mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_clear_active_thread_creates_real_empty_draft(cx: &mut TestAppContext) {
|
||||
let (panel, mut cx) = setup_panel(cx).await;
|
||||
|
||||
let connection = StubAgentConnection::new();
|
||||
connection.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
|
||||
acp::ContentChunk::new("done".into()),
|
||||
)]);
|
||||
open_thread_with_connection(&panel, connection, &mut cx);
|
||||
send_message(&panel, &mut cx);
|
||||
|
||||
panel.read_with(&cx, |panel, cx| {
|
||||
assert!(
|
||||
panel.draft_thread_ids(cx).is_empty(),
|
||||
"sent thread should not leave any draft entries before clearing"
|
||||
);
|
||||
});
|
||||
|
||||
panel.update_in(&mut cx, |panel, window, cx| {
|
||||
panel.clear_active_thread(window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
panel.read_with(&cx, |panel, cx| {
|
||||
assert!(panel.active_thread_is_draft(cx));
|
||||
assert_eq!(panel.draft_thread_ids(cx).len(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_clear_active_thread_reuses_retained_empty_draft(cx: &mut TestAppContext) {
|
||||
let (panel, mut cx) = setup_panel(cx).await;
|
||||
|
||||
let connection_a = StubAgentConnection::new();
|
||||
connection_a.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
|
||||
acp::ContentChunk::new("done".into()),
|
||||
)]);
|
||||
open_thread_with_connection(&panel, connection_a, &mut cx);
|
||||
send_message(&panel, &mut cx);
|
||||
|
||||
panel.update_in(&mut cx, |panel, window, cx| {
|
||||
panel.new_thread(&NewThread, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let retained_draft_id = panel.read_with(&cx, |panel, cx| {
|
||||
let ids = panel.draft_thread_ids(cx);
|
||||
assert_eq!(ids.len(), 1);
|
||||
ids[0]
|
||||
});
|
||||
|
||||
panel.update_in(&mut cx, |panel, window, cx| {
|
||||
panel.clear_active_thread(window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
panel.read_with(&cx, |panel, cx| {
|
||||
assert_eq!(panel.active_thread_id(cx), Some(retained_draft_id));
|
||||
assert!(panel.active_thread_is_draft(cx));
|
||||
assert_eq!(panel.draft_thread_ids(cx), vec![retained_draft_id]);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_thread_target_local_project(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
+74
-192
@@ -448,7 +448,6 @@ pub struct Sidebar {
|
||||
project_header_menu_ix: Option<usize>,
|
||||
_subscriptions: Vec<gpui::Subscription>,
|
||||
_draft_observations: Vec<gpui::Subscription>,
|
||||
reconciling: bool,
|
||||
}
|
||||
|
||||
impl Sidebar {
|
||||
@@ -476,21 +475,17 @@ impl Sidebar {
|
||||
this.sync_active_entry_from_active_workspace(cx);
|
||||
this.observe_draft_editors(cx);
|
||||
this.update_entries(cx);
|
||||
this.reconcile_groups(window, cx);
|
||||
}
|
||||
MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
|
||||
this.subscribe_to_workspace(workspace, window, cx);
|
||||
this.update_entries(cx);
|
||||
this.reconcile_groups(window, cx);
|
||||
}
|
||||
MultiWorkspaceEvent::WorkspaceRemoved(_) => {
|
||||
this.update_entries(cx);
|
||||
this.reconcile_groups(window, cx);
|
||||
}
|
||||
MultiWorkspaceEvent::ProjectGroupKeyUpdated { old_key, new_key } => {
|
||||
this.move_threads_for_key_change(old_key, new_key, cx);
|
||||
this.update_entries(cx);
|
||||
this.reconcile_groups(window, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -521,7 +516,6 @@ impl Sidebar {
|
||||
this.subscribe_to_workspace(workspace, window, cx);
|
||||
}
|
||||
this.update_entries(cx);
|
||||
this.reconcile_groups(window, cx);
|
||||
});
|
||||
|
||||
Self {
|
||||
@@ -546,7 +540,6 @@ impl Sidebar {
|
||||
project_header_menu_ix: None,
|
||||
_subscriptions: Vec::new(),
|
||||
_draft_observations: Vec::new(),
|
||||
reconciling: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,19 +613,17 @@ impl Sidebar {
|
||||
cx.subscribe_in(
|
||||
&project,
|
||||
window,
|
||||
|this, project, event, window, cx| match event {
|
||||
|this, project, event, _window, cx| match event {
|
||||
ProjectEvent::WorktreeAdded(_)
|
||||
| ProjectEvent::WorktreeRemoved(_)
|
||||
| ProjectEvent::WorktreeOrderChanged => {
|
||||
this.observe_draft_editors(cx);
|
||||
this.update_entries(cx);
|
||||
this.reconcile_groups(window, cx);
|
||||
}
|
||||
ProjectEvent::WorktreePathsChanged { old_worktree_paths } => {
|
||||
this.move_thread_paths(project, old_worktree_paths, cx);
|
||||
this.observe_draft_editors(cx);
|
||||
this.update_entries(cx);
|
||||
this.reconcile_groups(window, cx);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
@@ -666,7 +657,6 @@ impl Sidebar {
|
||||
if let Ok(agent_panel) = view.clone().downcast::<AgentPanel>() {
|
||||
this.subscribe_to_agent_panel(&agent_panel, window, cx);
|
||||
this.update_entries(cx);
|
||||
this.reconcile_groups(window, cx);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -784,29 +774,19 @@ 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 resolved_pending_activation =
|
||||
this.sync_active_entry_from_panel(_agent_panel, cx);
|
||||
if resolved_pending_activation {
|
||||
let active_workspace = this.active_workspace(cx);
|
||||
if let Some(active_workspace) = active_workspace {
|
||||
this.clear_empty_group_drafts(&active_workspace, cx);
|
||||
}
|
||||
}
|
||||
this.sync_active_entry_from_panel(_agent_panel, cx);
|
||||
this.observe_draft_editors(cx);
|
||||
this.update_entries(cx);
|
||||
this.reconcile_groups(window, cx);
|
||||
}
|
||||
AgentPanelEvent::ThreadFocused | AgentPanelEvent::RetainedThreadChanged => {
|
||||
this.sync_active_entry_from_panel(_agent_panel, cx);
|
||||
this.update_entries(cx);
|
||||
this.reconcile_groups(window, cx);
|
||||
}
|
||||
AgentPanelEvent::MessageSentOrQueued { thread_id } => {
|
||||
this.record_thread_message_sent(thread_id);
|
||||
this.update_entries(cx);
|
||||
this.reconcile_groups(window, cx);
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1499,79 +1479,6 @@ impl Sidebar {
|
||||
};
|
||||
}
|
||||
|
||||
/// Gives each workspace's sidebar delegate a chance to reconcile its
|
||||
/// project group (e.g. create a draft when the group is empty).
|
||||
///
|
||||
/// Called at the top of `render` so we have `Window` + `Context`
|
||||
/// available for panel mutations.
|
||||
fn reconcile_groups(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.reconciling
|
||||
|| self.pending_thread_activation.is_some()
|
||||
|| !self.restoring_tasks.is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.reconciling = true;
|
||||
|
||||
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
|
||||
self.reconciling = false;
|
||||
return;
|
||||
};
|
||||
|
||||
let empty_group_keys: Vec<ProjectGroupKey> = self
|
||||
.contents
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
ListEntry::ProjectHeader {
|
||||
key,
|
||||
has_threads: false,
|
||||
..
|
||||
} => Some(key.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if empty_group_keys.is_empty() {
|
||||
self.reconciling = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let mut did_reconcile = false;
|
||||
|
||||
for key in &empty_group_keys {
|
||||
let workspace = {
|
||||
let mw = multi_workspace.read(cx);
|
||||
let active = mw.workspace().clone();
|
||||
if active.read(cx).project_group_key(cx) == *key {
|
||||
Some(active)
|
||||
} else {
|
||||
mw.workspace_for_paths(key.path_list(), key.host().as_ref(), cx)
|
||||
}
|
||||
};
|
||||
|
||||
let Some(workspace) = workspace else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(delegate) = workspace.read(cx).sidebar_delegate() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let changed = workspace.update(cx, |workspace, cx| {
|
||||
delegate.reconcile_group(workspace, key, window, cx)
|
||||
});
|
||||
|
||||
did_reconcile |= changed;
|
||||
}
|
||||
|
||||
if did_reconcile {
|
||||
self.update_entries(cx);
|
||||
}
|
||||
|
||||
self.reconciling = false;
|
||||
}
|
||||
|
||||
/// Rebuilds the sidebar's visible entries from already-cached state.
|
||||
fn update_entries(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
|
||||
@@ -1720,7 +1627,7 @@ impl Sidebar {
|
||||
let disclosure_id = SharedString::from(format!("disclosure-{ix}"));
|
||||
let group_name = SharedString::from(format!("{id_prefix}header-group-{ix}"));
|
||||
|
||||
let is_collapsed = self.is_group_collapsed(key, cx);
|
||||
let is_collapsed = !has_threads || self.is_group_collapsed(key, cx);
|
||||
let (disclosure_icon, disclosure_tooltip) = if is_collapsed {
|
||||
(IconName::ChevronRight, "Expand Project")
|
||||
} else {
|
||||
@@ -1792,12 +1699,19 @@ impl Sidebar {
|
||||
IconButton::new(disclosure_id, disclosure_icon)
|
||||
.shape(ui::IconButtonShape::Square)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.5)))
|
||||
.tooltip(Tooltip::text(disclosure_tooltip))
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.selection = None;
|
||||
this.toggle_collapse(&key_for_toggle, window, cx);
|
||||
})),
|
||||
.icon_color(if has_threads {
|
||||
Color::Custom(cx.theme().colors().icon_muted.opacity(0.5))
|
||||
} else {
|
||||
Color::Custom(cx.theme().colors().icon_disabled)
|
||||
})
|
||||
.when(has_threads, |this| {
|
||||
this.tooltip(Tooltip::text(disclosure_tooltip)).on_click(
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
this.selection = None;
|
||||
this.toggle_collapse(&key_for_toggle, window, cx);
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(label)
|
||||
.when_some(
|
||||
@@ -1845,7 +1759,7 @@ impl Sidebar {
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.child(self.render_project_header_ellipsis_menu(ix, id_prefix, key, cx))
|
||||
.when(view_more_expanded && !is_collapsed, |this| {
|
||||
.when(has_threads && view_more_expanded && !is_collapsed, |this| {
|
||||
this.child(
|
||||
IconButton::new(
|
||||
SharedString::from(format!(
|
||||
@@ -2369,9 +2283,13 @@ impl Sidebar {
|
||||
};
|
||||
|
||||
match entry {
|
||||
ListEntry::ProjectHeader { key, .. } => {
|
||||
let key = key.clone();
|
||||
self.toggle_collapse(&key, window, cx);
|
||||
ListEntry::ProjectHeader {
|
||||
key, has_threads, ..
|
||||
} => {
|
||||
if *has_threads {
|
||||
let key = key.clone();
|
||||
self.toggle_collapse(&key, window, cx);
|
||||
}
|
||||
}
|
||||
ListEntry::Thread(thread) => {
|
||||
let metadata = thread.metadata.clone();
|
||||
@@ -2509,40 +2427,6 @@ impl Sidebar {
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
fn clear_empty_group_drafts(&mut self, workspace: &Entity<Workspace>, cx: &mut Context<Self>) {
|
||||
let Some(multi_workspace) = self.multi_workspace.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let group_key = workspace.read(cx).project_group_key(cx);
|
||||
let group_workspaces: Vec<_> = multi_workspace
|
||||
.read(cx)
|
||||
.workspaces()
|
||||
.filter(|candidate| candidate.read(cx).project_group_key(cx) == group_key)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
for group_workspace in group_workspaces {
|
||||
group_workspace.update(cx, |workspace, cx| {
|
||||
let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
panel.update(cx, |panel, cx| {
|
||||
let empty_draft_ids: Vec<ThreadId> = panel
|
||||
.draft_thread_ids(cx)
|
||||
.into_iter()
|
||||
.filter(|id| panel.editor_text(*id, cx).is_none())
|
||||
.collect();
|
||||
|
||||
for id in empty_draft_ids {
|
||||
panel.remove_thread(id, cx);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_thread_locally(
|
||||
&mut self,
|
||||
metadata: &ThreadMetadata,
|
||||
@@ -2592,7 +2476,6 @@ impl Sidebar {
|
||||
self.observe_draft_editors(cx);
|
||||
} else {
|
||||
Self::load_agent_thread_in_workspace(workspace, metadata, true, window, cx);
|
||||
self.clear_empty_group_drafts(workspace, cx);
|
||||
}
|
||||
|
||||
self.update_entries(cx);
|
||||
@@ -2635,7 +2518,6 @@ impl Sidebar {
|
||||
workspace: workspace_for_entry.clone(),
|
||||
});
|
||||
sidebar.record_thread_access(&target_session_id);
|
||||
sidebar.clear_empty_group_drafts(&workspace_for_entry, cx);
|
||||
sidebar.update_entries(cx);
|
||||
});
|
||||
}
|
||||
@@ -2919,15 +2801,19 @@ impl Sidebar {
|
||||
let Some(ix) = self.selection else { return };
|
||||
|
||||
match self.contents.entries.get(ix) {
|
||||
Some(ListEntry::ProjectHeader { key, .. }) => {
|
||||
let key = key.clone();
|
||||
if self.is_group_collapsed(&key, cx) {
|
||||
self.set_group_expanded(&key, true, cx);
|
||||
self.update_entries(cx);
|
||||
} else if ix + 1 < self.contents.entries.len() {
|
||||
self.selection = Some(ix + 1);
|
||||
self.list_state.scroll_to_reveal_item(ix + 1);
|
||||
cx.notify();
|
||||
Some(ListEntry::ProjectHeader {
|
||||
key, has_threads, ..
|
||||
}) => {
|
||||
if *has_threads {
|
||||
let key = key.clone();
|
||||
if self.is_group_collapsed(&key, cx) {
|
||||
self.set_group_expanded(&key, true, cx);
|
||||
self.update_entries(cx);
|
||||
} else if ix + 1 < self.contents.entries.len() {
|
||||
self.selection = Some(ix + 1);
|
||||
self.list_state.scroll_to_reveal_item(ix + 1);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -2943,11 +2829,15 @@ impl Sidebar {
|
||||
let Some(ix) = self.selection else { return };
|
||||
|
||||
match self.contents.entries.get(ix) {
|
||||
Some(ListEntry::ProjectHeader { key, .. }) => {
|
||||
let key = key.clone();
|
||||
if !self.is_group_collapsed(&key, cx) {
|
||||
self.set_group_expanded(&key, false, cx);
|
||||
self.update_entries(cx);
|
||||
Some(ListEntry::ProjectHeader {
|
||||
key, has_threads, ..
|
||||
}) => {
|
||||
if *has_threads {
|
||||
let key = key.clone();
|
||||
if !self.is_group_collapsed(&key, cx) {
|
||||
self.set_group_expanded(&key, false, cx);
|
||||
self.update_entries(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ListEntry::Thread(_) | ListEntry::ViewMore { .. }) => {
|
||||
@@ -2987,16 +2877,20 @@ impl Sidebar {
|
||||
};
|
||||
|
||||
if let Some(header_ix) = header_ix {
|
||||
if let Some(ListEntry::ProjectHeader { key, .. }) = self.contents.entries.get(header_ix)
|
||||
if let Some(ListEntry::ProjectHeader {
|
||||
key, has_threads, ..
|
||||
}) = self.contents.entries.get(header_ix)
|
||||
{
|
||||
let key = key.clone();
|
||||
if self.is_group_collapsed(&key, cx) {
|
||||
self.set_group_expanded(&key, true, cx);
|
||||
} else {
|
||||
self.selection = Some(header_ix);
|
||||
self.set_group_expanded(&key, false, cx);
|
||||
if *has_threads {
|
||||
let key = key.clone();
|
||||
if self.is_group_collapsed(&key, cx) {
|
||||
self.set_group_expanded(&key, true, cx);
|
||||
} else {
|
||||
self.selection = Some(header_ix);
|
||||
self.set_group_expanded(&key, false, cx);
|
||||
}
|
||||
self.update_entries(cx);
|
||||
}
|
||||
self.update_entries(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3415,11 +3309,7 @@ impl Sidebar {
|
||||
});
|
||||
if panel_shows_archived {
|
||||
panel.update(cx, |panel, cx| {
|
||||
// Replace the archived thread with a
|
||||
// tracked draft so the panel isn't left
|
||||
// in Uninitialized state.
|
||||
let id = panel.create_thread(window, cx);
|
||||
panel.activate_retained_thread(id, false, window, cx);
|
||||
panel.clear_base_view(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3449,27 +3339,20 @@ impl Sidebar {
|
||||
}
|
||||
}
|
||||
|
||||
// No neighbor or its workspace isn't open — fall back to a new
|
||||
// draft. Use the group workspace (main project) rather than the
|
||||
// active entry workspace, which may be a linked worktree that is
|
||||
// about to be cleaned up or already removed.
|
||||
let fallback_workspace = thread_folder_paths
|
||||
.and_then(|folder_paths| {
|
||||
let mw = self.multi_workspace.upgrade()?;
|
||||
let mw = mw.read(cx);
|
||||
let thread_workspace = mw.workspace_for_paths(folder_paths, None, cx)?;
|
||||
let group_key = thread_workspace.read(cx).project_group_key(cx);
|
||||
mw.workspace_for_paths(group_key.path_list(), None, cx)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.multi_workspace
|
||||
.upgrade()
|
||||
.map(|mw| mw.read(cx).workspace().clone())
|
||||
});
|
||||
|
||||
if let Some(workspace) = fallback_workspace {
|
||||
self.activate_workspace(&workspace, window, cx);
|
||||
self.create_new_thread(&workspace, window, cx);
|
||||
// No neighbor or its workspace isn't open — just clear the
|
||||
// panel so the group is left empty.
|
||||
if let Some(folder_paths) = thread_folder_paths {
|
||||
let workspace = self
|
||||
.multi_workspace
|
||||
.upgrade()
|
||||
.and_then(|mw| mw.read(cx).workspace_for_paths(folder_paths, None, cx));
|
||||
if let Some(workspace) = workspace {
|
||||
if let Some(panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
|
||||
panel.update(cx, |panel, cx| {
|
||||
panel.clear_base_view(cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4281,7 +4164,6 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
self.update_entries(cx);
|
||||
self.reconcile_groups(window, cx);
|
||||
}
|
||||
|
||||
/// Cleans, collapses whitespace, and truncates raw editor text
|
||||
|
||||
@@ -590,7 +590,7 @@ async fn test_single_workspace_no_threads(cx: &mut TestAppContext) {
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&_sidebar, cx),
|
||||
vec!["v [my-project]", " [~ Draft]"]
|
||||
vec!["v [my-project]"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1496,10 +1496,10 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
|
||||
let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
|
||||
|
||||
// An empty project has the header and an auto-created draft.
|
||||
// An empty project has only the header (no auto-created draft).
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [empty-project]", " [~ Draft]"]
|
||||
vec!["v [empty-project]"]
|
||||
);
|
||||
|
||||
// Focus sidebar — focus_in does not set a selection
|
||||
@@ -1510,17 +1510,17 @@ async fn test_keyboard_navigation_on_empty_list(cx: &mut TestAppContext) {
|
||||
cx.dispatch_action(SelectNext);
|
||||
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
|
||||
|
||||
// SelectNext advances to index 1 (draft entry)
|
||||
cx.dispatch_action(SelectNext);
|
||||
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(1));
|
||||
|
||||
// At the end (two entries), wraps back to first entry
|
||||
// SelectNext with only one entry stays at index 0
|
||||
cx.dispatch_action(SelectNext);
|
||||
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
|
||||
|
||||
// SelectPrevious from first entry clears selection (returns to editor)
|
||||
cx.dispatch_action(SelectPrevious);
|
||||
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), None);
|
||||
|
||||
// SelectPrevious from None selects the last entry
|
||||
cx.dispatch_action(SelectPrevious);
|
||||
assert_eq!(sidebar.read_with(cx, |s, _| s.selection), Some(0));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -2443,7 +2443,7 @@ async fn test_confirm_on_historical_thread_preserves_historical_timestamp_and_or
|
||||
historical_entries_after,
|
||||
vec![
|
||||
" Newer Historical Thread".to_string(),
|
||||
" Older Historical Thread".to_string(),
|
||||
" Older Historical Thread <== selected".to_string(),
|
||||
],
|
||||
"activating an older historical thread should not reorder it ahead of a newer historical thread"
|
||||
);
|
||||
@@ -3114,26 +3114,6 @@ async fn test_draft_title_updates_from_editor_text(cx: &mut TestAppContext) {
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
|
||||
|
||||
// The reconciliation-created draft should show the default title.
|
||||
let draft_title = sidebar.read_with(cx, |sidebar, _cx| {
|
||||
sidebar
|
||||
.contents
|
||||
.entries
|
||||
.iter()
|
||||
.find_map(|entry| match entry {
|
||||
ListEntry::Thread(thread) if thread.is_draft => {
|
||||
Some(thread.metadata.display_title())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("should have a draft entry")
|
||||
});
|
||||
assert_eq!(
|
||||
draft_title.as_ref(),
|
||||
"New Agent Thread",
|
||||
"draft should start with default title"
|
||||
);
|
||||
|
||||
// Create a new thread (activates the draft as base view and connects).
|
||||
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
||||
let panel = workspace.read_with(cx, |ws, cx| ws.panel::<AgentPanel>(cx).unwrap());
|
||||
@@ -3189,26 +3169,6 @@ async fn test_draft_title_updates_across_two_groups(cx: &mut TestAppContext) {
|
||||
let panel_b = add_agent_panel(&workspace_b, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
// Both groups should have reconciliation drafts.
|
||||
let draft_titles: Vec<(SharedString, bool)> = sidebar.read_with(cx, |sidebar, _cx| {
|
||||
sidebar
|
||||
.contents
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
ListEntry::Thread(thread) if thread.is_draft => {
|
||||
Some((thread.metadata.display_title(), false))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
assert_eq!(
|
||||
draft_titles.len(),
|
||||
2,
|
||||
"should have two drafts, one per group"
|
||||
);
|
||||
|
||||
// Open a thread in each group's panel to get Connected state.
|
||||
let workspace_a =
|
||||
multi_workspace.read_with(cx, |mw, _cx| mw.workspaces().next().unwrap().clone());
|
||||
@@ -5537,7 +5497,6 @@ async fn test_linked_worktree_threads_not_duplicated_across_groups(cx: &mut Test
|
||||
vec![
|
||||
//
|
||||
"v [other, project]",
|
||||
" [~ Draft]",
|
||||
"v [project]",
|
||||
" Worktree Thread {wt-feature-a}",
|
||||
]
|
||||
@@ -6023,11 +5982,11 @@ async fn test_archive_thread_active_entry_management(cx: &mut TestAppContext) {
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Should fall back to a draft on the same workspace.
|
||||
// Archiving the active thread clears active_entry (no draft is created).
|
||||
sidebar.read_with(cx, |sidebar, _| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_b),
|
||||
"expected Draft(workspace_b) after archiving active thread, got: {:?}",
|
||||
sidebar.active_entry.is_none(),
|
||||
"expected None after archiving active thread, got: {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
});
|
||||
@@ -6340,9 +6299,9 @@ async fn test_unarchive_into_new_workspace_does_not_create_duplicate_real_thread
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppContext) {
|
||||
// When a workspace already exists with an empty draft (from
|
||||
// reconcile_groups) and a thread is unarchived into it, the draft
|
||||
// should be replaced — not kept alongside the loaded thread.
|
||||
// When a workspace already exists with an empty draft and a thread
|
||||
// is unarchived into it, the draft should be replaced — not kept
|
||||
// alongside the loaded thread.
|
||||
agent_ui::test_support::init_test(cx);
|
||||
cx.update(|cx| {
|
||||
ThreadStore::init_global(cx);
|
||||
@@ -6372,19 +6331,12 @@ async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppC
|
||||
let session_id = agent_ui::test_support::active_session_id(&panel, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
// Archive the thread — this creates a draft to replace it.
|
||||
// Archive the thread — the group is left empty (no draft created).
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.archive_thread(&session_id, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// Verify the draft exists before unarchive.
|
||||
let entries = visible_entries_as_strings(&sidebar, cx);
|
||||
assert!(
|
||||
entries.iter().any(|e| e.contains("Draft")),
|
||||
"expected a draft after archiving, got: {entries:?}"
|
||||
);
|
||||
|
||||
// Un-archive the thread.
|
||||
let thread_id = cx.update(|_, cx| {
|
||||
ThreadMetadataStore::global(cx)
|
||||
@@ -6416,88 +6368,6 @@ async fn test_unarchive_into_existing_workspace_replaces_draft(cx: &mut TestAppC
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_pending_thread_activation_suppresses_reconcile_draft_creation(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
agent_ui::test_support::init_test(cx);
|
||||
cx.update(|cx| {
|
||||
cx.set_global(agent_ui::MaxIdleRetainedThreads(1));
|
||||
ThreadStore::init_global(cx);
|
||||
ThreadMetadataStore::init_global(cx);
|
||||
language_model::LanguageModelRegistry::test(cx);
|
||||
prompt_store::init(cx);
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree("/project-a", serde_json::json!({ "src": {} }))
|
||||
.await;
|
||||
fs.insert_tree("/project-b", serde_json::json!({ "src": {} }))
|
||||
.await;
|
||||
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
|
||||
|
||||
let project_a = project::Project::test(fs.clone(), ["/project-a".as_ref()], cx).await;
|
||||
let project_b = project::Project::test(fs.clone(), ["/project-b".as_ref()], cx).await;
|
||||
|
||||
let (multi_workspace, cx) =
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
|
||||
let sidebar = setup_sidebar(&multi_workspace, cx);
|
||||
|
||||
let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
mw.test_add_workspace(project_b.clone(), window, cx)
|
||||
});
|
||||
let panel_b = add_agent_panel(&workspace_b, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
let preexisting_empty_draft_ids = panel_b.read_with(cx, |panel, cx| {
|
||||
panel
|
||||
.draft_thread_ids(cx)
|
||||
.into_iter()
|
||||
.filter(|id| panel.editor_text(*id, cx).is_none())
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
if !preexisting_empty_draft_ids.is_empty() {
|
||||
panel_b.update(cx, |panel, cx| {
|
||||
for draft_id in &preexisting_empty_draft_ids {
|
||||
panel.remove_thread(*draft_id, cx);
|
||||
}
|
||||
});
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
let project_b_key = project_b.read_with(cx, |project, cx| project.project_group_key(cx));
|
||||
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
assert!(
|
||||
panel_b.read(cx).draft_thread_ids(cx).is_empty(),
|
||||
"expected target panel to start without drafts after clearing setup state"
|
||||
);
|
||||
|
||||
sidebar.pending_thread_activation = Some(ThreadId::new());
|
||||
sidebar.reconcile_groups(window, cx);
|
||||
|
||||
assert!(
|
||||
panel_b.read(cx).draft_thread_ids(cx).is_empty(),
|
||||
"expected pending_thread_activation to suppress reconcile-driven fallback draft creation"
|
||||
);
|
||||
|
||||
sidebar.pending_thread_activation = None;
|
||||
sidebar.update_entries(cx);
|
||||
sidebar.reconcile_groups(window, cx);
|
||||
|
||||
let created_draft_ids = panel_b.read(cx).draft_thread_ids(cx);
|
||||
assert_eq!(
|
||||
created_draft_ids.len(),
|
||||
1,
|
||||
"expected reconcile_groups to create a fallback draft again once the activation guard is cleared for the empty group {project_b_key:?}"
|
||||
);
|
||||
assert!(
|
||||
panel_b.read(cx).editor_text(created_draft_ids[0], cx).is_none(),
|
||||
"expected the reconciled draft to be empty"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_unarchive_into_inactive_existing_workspace_does_not_leave_active_draft(
|
||||
cx: &mut TestAppContext,
|
||||
@@ -6876,10 +6746,12 @@ async fn test_unarchive_does_not_create_duplicate_real_thread_metadata(cx: &mut
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut TestAppContext) {
|
||||
async fn test_switch_to_workspace_with_archived_thread_shows_no_active_entry(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
// When a thread is archived while the user is in a different workspace,
|
||||
// the archiving code replaces the thread with a tracked draft in its
|
||||
// panel. Switching back to that workspace should show the draft.
|
||||
// the group is left empty (no draft is created). Switching back to that
|
||||
// workspace should show no active entry.
|
||||
agent_ui::test_support::init_test(cx);
|
||||
cx.update(|cx| {
|
||||
ThreadStore::init_global(cx);
|
||||
@@ -6925,7 +6797,7 @@ async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut Test
|
||||
cx.run_until_parked();
|
||||
|
||||
// Switch back to project-a. Its panel was cleared during archiving,
|
||||
// so active_entry should be Draft.
|
||||
// so active_entry should be None (no draft is created).
|
||||
let workspace_a =
|
||||
multi_workspace.read_with(cx, |mw, _| mw.workspaces().next().unwrap().clone());
|
||||
multi_workspace.update_in(cx, |mw, window, cx| {
|
||||
@@ -6940,8 +6812,8 @@ async fn test_switch_to_workspace_with_archived_thread_shows_draft(cx: &mut Test
|
||||
|
||||
sidebar.read_with(cx, |sidebar, _| {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry { workspace: ws, .. }) if ws == &workspace_a),
|
||||
"expected Draft(workspace_a) after switching to workspace with archived thread, got: {:?}",
|
||||
sidebar.active_entry.is_none(),
|
||||
"expected no active entry after switching to workspace with archived thread, got: {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
});
|
||||
@@ -7189,12 +7061,12 @@ async fn test_archive_last_thread_on_linked_worktree_does_not_create_new_thread_
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_creates_draft_on_main(
|
||||
async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_leaves_group_empty(
|
||||
cx: &mut TestAppContext,
|
||||
) {
|
||||
// When a linked worktree thread is the ONLY thread in the project group
|
||||
// (no threads on the main repo either), archiving it should create a
|
||||
// draft on the main workspace, not the linked worktree workspace.
|
||||
// (no threads on the main repo either), archiving it should leave the
|
||||
// group empty with no active entry.
|
||||
agent_ui::test_support::init_test(cx);
|
||||
cx.update(|cx| {
|
||||
ThreadStore::init_global(cx);
|
||||
@@ -7299,12 +7171,12 @@ async fn test_archive_last_thread_on_linked_worktree_with_no_siblings_creates_dr
|
||||
"no entry should reference the archived worktree, got: {entries_after:?}"
|
||||
);
|
||||
|
||||
// The active entry should be a draft on the main workspace.
|
||||
// The active entry should be None — no draft is created.
|
||||
sidebar.read_with(cx, |s, _| {
|
||||
assert_active_draft(
|
||||
s,
|
||||
&main_workspace,
|
||||
"active entry should be a draft on the main workspace",
|
||||
assert!(
|
||||
s.active_entry.is_none(),
|
||||
"expected no active entry after archiving the last thread, got: {:?}",
|
||||
s.active_entry,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -7378,12 +7250,6 @@ async fn test_unarchive_linked_worktree_thread_into_project_group_shows_only_res
|
||||
let _main_panel = add_agent_panel(&main_workspace, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
let entries_before = visible_entries_as_strings(&sidebar, cx);
|
||||
assert!(
|
||||
entries_before.iter().any(|entry| entry.contains("Draft")),
|
||||
"expected main workspace to start with a fallback draft, got entries: {entries_before:?}"
|
||||
);
|
||||
|
||||
let session_id = acp::SessionId::new(Arc::from("linked-worktree-unarchive"));
|
||||
let original_thread_id = ThreadId::new();
|
||||
let main_paths = PathList::new(&[PathBuf::from("/project")]);
|
||||
@@ -8317,9 +8183,9 @@ async fn test_linked_worktree_workspace_reachable_after_adding_unrelated_project
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_startup_failed_restoration_shows_draft(cx: &mut TestAppContext) {
|
||||
// Rule 4: When the app starts and the AgentPanel fails to restore its
|
||||
// last thread (no metadata), a draft should appear in the sidebar.
|
||||
async fn test_startup_failed_restoration_shows_no_draft(cx: &mut TestAppContext) {
|
||||
// Empty project groups no longer auto-create drafts via reconciliation.
|
||||
// A fresh startup with no restorable thread should show only the header.
|
||||
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));
|
||||
@@ -8328,10 +8194,10 @@ async fn test_startup_failed_restoration_shows_draft(cx: &mut TestAppContext) {
|
||||
let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
||||
|
||||
let entries = visible_entries_as_strings(&sidebar, cx);
|
||||
assert_eq!(entries.len(), 2, "should have header + draft: {entries:?}");
|
||||
assert!(
|
||||
entries[1].contains("Draft"),
|
||||
"second entry should be a draft: {entries:?}"
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec!["v [my-project]"],
|
||||
"empty group should show only the header, no auto-created draft"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8365,49 +8231,6 @@ async fn test_startup_successful_restoration_no_spurious_draft(cx: &mut TestAppC
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_delete_last_draft_in_empty_group_shows_placeholder(cx: &mut TestAppContext) {
|
||||
// Deleting the last draft in a threadless group should
|
||||
// leave a placeholder draft entry (not an empty group).
|
||||
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);
|
||||
|
||||
// Reconciliation creates a draft for the empty group.
|
||||
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
||||
let entries = visible_entries_as_strings(&sidebar, cx);
|
||||
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
|
||||
assert_eq!(
|
||||
draft_count, 1,
|
||||
"should start with 1 draft from reconciliation"
|
||||
);
|
||||
|
||||
// Find and delete the draft.
|
||||
let draft_thread_id = sidebar.read_with(cx, |_sidebar, cx| {
|
||||
let panel = workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
|
||||
panel
|
||||
.read(cx)
|
||||
.draft_thread_ids(cx)
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap()
|
||||
});
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
sidebar.remove_draft(draft_thread_id, &workspace, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
// The group has no threads and no tracked drafts, so a
|
||||
// placeholder draft should appear via reconciliation.
|
||||
let entries = visible_entries_as_strings(&sidebar, cx);
|
||||
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
|
||||
assert_eq!(
|
||||
draft_count, 1,
|
||||
"placeholder draft should appear after deleting all tracked drafts"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_project_header_click_restores_last_viewed(cx: &mut TestAppContext) {
|
||||
// Rule 9: Clicking a project header should restore whatever the
|
||||
@@ -8521,10 +8344,10 @@ async fn test_plus_button_reuses_empty_draft(cx: &mut TestAppContext) {
|
||||
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
|
||||
let (sidebar, _panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
|
||||
|
||||
// Start: panel has 1 draft from set_active.
|
||||
// Start: no drafts from reconciliation.
|
||||
let entries = visible_entries_as_strings(&sidebar, cx);
|
||||
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
|
||||
assert_eq!(draft_count, 1, "should start with 1 draft");
|
||||
assert_eq!(draft_count, 0, "should start with 0 drafts");
|
||||
|
||||
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
||||
let simulate_plus_button =
|
||||
@@ -8532,7 +8355,17 @@ async fn test_plus_button_reuses_empty_draft(cx: &mut TestAppContext) {
|
||||
sidebar.create_new_thread(&workspace, window, cx);
|
||||
};
|
||||
|
||||
// + click with empty draft: should reuse it, not create a new one.
|
||||
// First + click: should create a draft.
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
simulate_plus_button(sidebar, window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
let entries = visible_entries_as_strings(&sidebar, cx);
|
||||
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
|
||||
assert_eq!(draft_count, 1, "first + click should create a draft");
|
||||
|
||||
// Second + click with empty draft: should reuse it, not create a new one.
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
simulate_plus_button(sidebar, window, cx);
|
||||
});
|
||||
@@ -8542,7 +8375,7 @@ async fn test_plus_button_reuses_empty_draft(cx: &mut TestAppContext) {
|
||||
let draft_count = entries.iter().filter(|e| e.contains("Draft")).count();
|
||||
assert_eq!(
|
||||
draft_count, 1,
|
||||
"+ click should reuse the existing empty draft, not create a new one"
|
||||
"second + click should reuse the existing empty draft, not create a new one"
|
||||
);
|
||||
|
||||
// The draft should be active.
|
||||
|
||||
@@ -216,16 +216,6 @@ pub trait DebuggerProvider {
|
||||
fn active_thread_state(&self, cx: &App) -> Option<ThreadStatus>;
|
||||
}
|
||||
|
||||
pub trait WorkspaceSidebarDelegate: Send + Sync {
|
||||
fn reconcile_group(
|
||||
&self,
|
||||
workspace: &mut Workspace,
|
||||
group_key: &ProjectGroupKey,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Workspace>,
|
||||
) -> bool;
|
||||
}
|
||||
|
||||
/// Opens a file or directory.
|
||||
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
|
||||
#[action(namespace = workspace)]
|
||||
@@ -1382,7 +1372,6 @@ pub struct Workspace {
|
||||
_panels_task: Option<Task<Result<()>>>,
|
||||
sidebar_focus_handle: Option<FocusHandle>,
|
||||
multi_workspace: Option<WeakEntity<MultiWorkspace>>,
|
||||
sidebar_delegate: Option<Arc<dyn WorkspaceSidebarDelegate>>,
|
||||
}
|
||||
|
||||
impl EventEmitter<Event> for Workspace {}
|
||||
@@ -1811,7 +1800,6 @@ impl Workspace {
|
||||
removing: false,
|
||||
sidebar_focus_handle: None,
|
||||
multi_workspace,
|
||||
sidebar_delegate: None,
|
||||
open_in_dev_container: false,
|
||||
_dev_container_task: None,
|
||||
}
|
||||
@@ -2472,14 +2460,6 @@ impl Workspace {
|
||||
self.multi_workspace = Some(multi_workspace);
|
||||
}
|
||||
|
||||
pub fn set_sidebar_delegate(&mut self, delegate: Arc<dyn WorkspaceSidebarDelegate>) {
|
||||
self.sidebar_delegate = Some(delegate);
|
||||
}
|
||||
|
||||
pub fn sidebar_delegate(&self) -> Option<Arc<dyn WorkspaceSidebarDelegate>> {
|
||||
self.sidebar_delegate.clone()
|
||||
}
|
||||
|
||||
pub fn app_state(&self) -> &Arc<AppState> {
|
||||
&self.app_state
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user