Simplify sidebar active state (#52799)
This changes the terminology from "focused entry" to "active entry", and adds 4 properties that rebuild_contents should maintain for the active entry: - We should always have an active_entry after rebuild_contents - The active entry's workspace should always be == to the active multiworkspace - If there's a thread, the active entry should always reflect that thread - There should always be exactly 1 active entry 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 Closes #ISSUE Release Notes: - N/A
This commit is contained in:
1 parent
c61ad775ce
commit
556dbd7352
2 files changed
+378
-204
No files matched your search
+149
-83
@@ -108,6 +108,44 @@ enum SidebarView {
|
||||
Archive(Entity<ThreadsArchiveView>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum ActiveEntry {
|
||||
Thread {
|
||||
session_id: acp::SessionId,
|
||||
workspace: Entity<Workspace>,
|
||||
},
|
||||
Draft(Entity<Workspace>),
|
||||
}
|
||||
|
||||
impl ActiveEntry {
|
||||
fn workspace(&self) -> &Entity<Workspace> {
|
||||
match self {
|
||||
ActiveEntry::Thread { workspace, .. } => workspace,
|
||||
ActiveEntry::Draft(workspace) => workspace,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active_thread(&self, session_id: &acp::SessionId) -> bool {
|
||||
matches!(self, ActiveEntry::Thread { session_id: id, .. } if id == session_id)
|
||||
}
|
||||
|
||||
fn matches_entry(&self, entry: &ListEntry) -> bool {
|
||||
match (self, entry) {
|
||||
(ActiveEntry::Thread { session_id, .. }, ListEntry::Thread(thread)) => {
|
||||
thread.metadata.session_id == *session_id
|
||||
}
|
||||
(
|
||||
ActiveEntry::Draft(workspace),
|
||||
ListEntry::NewThread {
|
||||
workspace: entry_workspace,
|
||||
..
|
||||
},
|
||||
) => workspace == entry_workspace,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ActiveThreadInfo {
|
||||
session_id: acp::SessionId,
|
||||
@@ -185,7 +223,6 @@ enum ListEntry {
|
||||
NewThread {
|
||||
path_list: PathList,
|
||||
workspace: Entity<Workspace>,
|
||||
is_active_draft: bool,
|
||||
worktrees: Vec<WorktreeInfo>,
|
||||
},
|
||||
}
|
||||
@@ -322,11 +359,8 @@ pub struct Sidebar {
|
||||
///
|
||||
/// Note: This is NOT the same as the active item.
|
||||
selection: Option<usize>,
|
||||
/// Derived from the active panel's thread in `rebuild_contents`.
|
||||
/// Only updated when the panel returns `Some` — never cleared by
|
||||
/// derivation, since the panel may transiently return `None` while
|
||||
/// loading. User actions may write directly for immediate feedback.
|
||||
focused_thread: Option<acp::SessionId>,
|
||||
/// Tracks which sidebar entry is currently active (highlighted).
|
||||
active_entry: Option<ActiveEntry>,
|
||||
hovered_thread_index: Option<usize>,
|
||||
collapsed_groups: HashSet<PathList>,
|
||||
expanded_groups: HashMap<PathList, usize>,
|
||||
@@ -423,7 +457,7 @@ impl Sidebar {
|
||||
list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
|
||||
contents: SidebarContents::default(),
|
||||
selection: None,
|
||||
focused_thread: None,
|
||||
active_entry: None,
|
||||
hovered_thread_index: None,
|
||||
collapsed_groups: HashSet::new(),
|
||||
expanded_groups: HashMap::new(),
|
||||
@@ -443,29 +477,16 @@ impl Sidebar {
|
||||
cx.emit(workspace::SidebarEvent::SerializeNeeded);
|
||||
}
|
||||
|
||||
fn active_entry_workspace(&self) -> Option<&Entity<Workspace>> {
|
||||
self.active_entry.as_ref().map(|entry| entry.workspace())
|
||||
}
|
||||
|
||||
fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
|
||||
self.multi_workspace
|
||||
.upgrade()
|
||||
.map_or(false, |mw| mw.read(cx).workspace() == workspace)
|
||||
}
|
||||
|
||||
fn agent_panel_visible(&self, cx: &App) -> bool {
|
||||
self.multi_workspace.upgrade().map_or(false, |mw| {
|
||||
let workspace = mw.read(cx).workspace();
|
||||
AgentPanel::is_visible(&workspace, cx)
|
||||
})
|
||||
}
|
||||
|
||||
fn active_thread_is_draft(&self, cx: &App) -> bool {
|
||||
self.multi_workspace
|
||||
.upgrade()
|
||||
.and_then(|mw| {
|
||||
let workspace = mw.read(cx).workspace();
|
||||
workspace.read(cx).panel::<AgentPanel>(cx)
|
||||
})
|
||||
.map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx))
|
||||
}
|
||||
|
||||
fn subscribe_to_workspace(
|
||||
&mut self,
|
||||
workspace: &Entity<Workspace>,
|
||||
@@ -543,7 +564,13 @@ impl Sidebar {
|
||||
.active_conversation_view()
|
||||
.is_some_and(|cv| cv.read(cx).parent_id(cx).is_none());
|
||||
if is_new_draft {
|
||||
this.focused_thread = None;
|
||||
if let Some(active_workspace) = this
|
||||
.multi_workspace
|
||||
.upgrade()
|
||||
.map(|mw| mw.read(cx).workspace().clone())
|
||||
{
|
||||
this.active_entry = Some(ActiveEntry::Draft(active_workspace));
|
||||
}
|
||||
}
|
||||
this.observe_draft_editor(cx);
|
||||
this.update_entries(cx);
|
||||
@@ -677,24 +704,38 @@ impl Sidebar {
|
||||
|
||||
let query = self.filter_editor.read(cx).text(cx);
|
||||
|
||||
let agent_panel_visible = self.agent_panel_visible(cx);
|
||||
let active_thread_is_draft = self.active_thread_is_draft(cx);
|
||||
|
||||
// Derive focused_thread from the active workspace's agent panel.
|
||||
// Only update when the panel gives us a positive signal — if the
|
||||
// panel returns None (e.g. still loading after a thread activation),
|
||||
// keep the previous value so eager writes from user actions survive.
|
||||
let panel_focused = active_workspace
|
||||
.as_ref()
|
||||
.and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
|
||||
.and_then(|panel| {
|
||||
panel
|
||||
// Derive active_entry from the active workspace's agent panel.
|
||||
// Draft is checked first because a conversation can have a session_id
|
||||
// before any messages are sent. However, a thread that's still loading
|
||||
// also appears as a "draft" (no messages yet), so when we already have
|
||||
// an eager Thread write for this workspace we preserve it. A session_id
|
||||
// on a non-draft is a positive Thread signal. The remaining case
|
||||
// (conversation exists, not draft, no session_id) is a genuine
|
||||
// mid-load — keep the previous value.
|
||||
if let Some(active_ws) = &active_workspace {
|
||||
if let Some(panel) = active_ws.read(cx).panel::<AgentPanel>(cx) {
|
||||
if panel.read(cx).active_thread_is_draft(cx)
|
||||
|| panel.read(cx).active_conversation_view().is_none()
|
||||
{
|
||||
let preserving_thread =
|
||||
matches!(&self.active_entry, Some(ActiveEntry::Thread { .. }))
|
||||
&& self.active_entry_workspace() == Some(active_ws);
|
||||
if !preserving_thread {
|
||||
self.active_entry = Some(ActiveEntry::Draft(active_ws.clone()));
|
||||
}
|
||||
} else if let Some(session_id) = panel
|
||||
.read(cx)
|
||||
.active_conversation_view()
|
||||
.and_then(|cv| cv.read(cx).parent_id(cx))
|
||||
});
|
||||
if panel_focused.is_some() && !active_thread_is_draft {
|
||||
self.focused_thread = panel_focused;
|
||||
{
|
||||
self.active_entry = Some(ActiveEntry::Thread {
|
||||
session_id,
|
||||
workspace: active_ws.clone(),
|
||||
});
|
||||
}
|
||||
// else: conversation exists, not a draft, but no session_id
|
||||
// yet — thread is mid-load. Keep previous value.
|
||||
}
|
||||
}
|
||||
|
||||
let previous = mem::take(&mut self.contents);
|
||||
@@ -966,10 +1007,9 @@ impl Sidebar {
|
||||
entries.push(thread.into());
|
||||
}
|
||||
} else {
|
||||
let is_draft_for_workspace = agent_panel_visible
|
||||
&& active_thread_is_draft
|
||||
&& self.focused_thread.is_none()
|
||||
&& is_active;
|
||||
let is_draft_for_workspace = is_active
|
||||
&& matches!(&self.active_entry, Some(ActiveEntry::Draft(_)))
|
||||
&& self.active_entry_workspace() == Some(representative_workspace);
|
||||
|
||||
project_header_indices.push(entries.len());
|
||||
entries.push(ListEntry::ProjectHeader {
|
||||
@@ -989,11 +1029,9 @@ impl Sidebar {
|
||||
// Emit "New Thread" entries for threadless workspaces
|
||||
// and active drafts, right after the header.
|
||||
for (workspace, worktrees) in &threadless_workspaces {
|
||||
let is_draft = is_draft_for_workspace && workspace == representative_workspace;
|
||||
entries.push(ListEntry::NewThread {
|
||||
path_list: path_list.clone(),
|
||||
workspace: workspace.clone(),
|
||||
is_active_draft: is_draft,
|
||||
worktrees: worktrees.clone(),
|
||||
});
|
||||
}
|
||||
@@ -1007,7 +1045,6 @@ impl Sidebar {
|
||||
entries.push(ListEntry::NewThread {
|
||||
path_list: path_list.clone(),
|
||||
workspace: representative_workspace.clone(),
|
||||
is_active_draft: true,
|
||||
worktrees,
|
||||
});
|
||||
}
|
||||
@@ -1033,10 +1070,9 @@ impl Sidebar {
|
||||
let is_promoted = thread.status == AgentThreadStatus::Running
|
||||
|| thread.status == AgentThreadStatus::WaitingForConfirmation
|
||||
|| notified_threads.contains(session_id)
|
||||
|| self
|
||||
.focused_thread
|
||||
.as_ref()
|
||||
.is_some_and(|id| id == session_id);
|
||||
|| self.active_entry.as_ref().is_some_and(|active| {
|
||||
active.matches_entry(&ListEntry::Thread(thread.clone()))
|
||||
});
|
||||
if is_promoted {
|
||||
promoted_threads.insert(session_id.clone());
|
||||
}
|
||||
@@ -1135,6 +1171,11 @@ impl Sidebar {
|
||||
let is_group_header_after_first =
|
||||
ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
|
||||
|
||||
let is_active = self
|
||||
.active_entry
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.matches_entry(entry));
|
||||
|
||||
let rendered = match entry {
|
||||
ListEntry::ProjectHeader {
|
||||
path_list,
|
||||
@@ -1143,7 +1184,7 @@ impl Sidebar {
|
||||
highlight_positions,
|
||||
has_running_threads,
|
||||
waiting_thread_count,
|
||||
is_active,
|
||||
is_active: is_active_group,
|
||||
} => self.render_project_header(
|
||||
ix,
|
||||
false,
|
||||
@@ -1153,11 +1194,11 @@ impl Sidebar {
|
||||
highlight_positions,
|
||||
*has_running_threads,
|
||||
*waiting_thread_count,
|
||||
*is_active,
|
||||
*is_active_group,
|
||||
is_selected,
|
||||
cx,
|
||||
),
|
||||
ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
|
||||
ListEntry::Thread(thread) => self.render_thread(ix, thread, is_active, is_selected, cx),
|
||||
ListEntry::ViewMore {
|
||||
path_list,
|
||||
is_fully_expanded,
|
||||
@@ -1165,13 +1206,12 @@ impl Sidebar {
|
||||
ListEntry::NewThread {
|
||||
path_list,
|
||||
workspace,
|
||||
is_active_draft,
|
||||
worktrees,
|
||||
} => self.render_new_thread(
|
||||
ix,
|
||||
path_list,
|
||||
workspace,
|
||||
*is_active_draft,
|
||||
is_active,
|
||||
worktrees,
|
||||
is_selected,
|
||||
cx,
|
||||
@@ -1391,7 +1431,8 @@ impl Sidebar {
|
||||
.tooltip(Tooltip::text("Activate Workspace"))
|
||||
.on_click(cx.listener({
|
||||
move |this, _, window, cx| {
|
||||
this.focused_thread = None;
|
||||
this.active_entry =
|
||||
Some(ActiveEntry::Draft(workspace_for_open.clone()));
|
||||
if let Some(multi_workspace) = this.multi_workspace.upgrade() {
|
||||
multi_workspace.update(cx, |multi_workspace, cx| {
|
||||
multi_workspace.activate(
|
||||
@@ -1987,10 +2028,13 @@ impl Sidebar {
|
||||
return;
|
||||
};
|
||||
|
||||
// Set focused_thread eagerly so the sidebar highlight updates
|
||||
// Set active_entry eagerly so the sidebar highlight updates
|
||||
// immediately, rather than waiting for a deferred AgentPanel
|
||||
// event which can race with ActiveWorkspaceChanged clearing it.
|
||||
self.focused_thread = Some(metadata.session_id.clone());
|
||||
self.active_entry = Some(ActiveEntry::Thread {
|
||||
session_id: metadata.session_id.clone(),
|
||||
workspace: workspace.clone(),
|
||||
});
|
||||
self.record_thread_access(&metadata.session_id);
|
||||
|
||||
multi_workspace.update(cx, |multi_workspace, cx| {
|
||||
@@ -2010,6 +2054,7 @@ impl Sidebar {
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let target_session_id = metadata.session_id.clone();
|
||||
let workspace_for_entry = workspace.clone();
|
||||
|
||||
let activated = target_window
|
||||
.update(cx, |multi_workspace, window, cx| {
|
||||
@@ -2030,7 +2075,10 @@ impl Sidebar {
|
||||
.and_then(|sidebar| sidebar.downcast::<Self>().ok())
|
||||
{
|
||||
target_sidebar.update(cx, |sidebar, cx| {
|
||||
sidebar.focused_thread = Some(target_session_id.clone());
|
||||
sidebar.active_entry = Some(ActiveEntry::Thread {
|
||||
session_id: target_session_id.clone(),
|
||||
workspace: workspace_for_entry.clone(),
|
||||
});
|
||||
sidebar.record_thread_access(&target_session_id);
|
||||
sidebar.update_entries(cx);
|
||||
});
|
||||
@@ -2296,7 +2344,11 @@ impl Sidebar {
|
||||
// nearest thread within the same project group. We never cross group
|
||||
// boundaries — if the group has no other threads, clear focus and open
|
||||
// a blank new thread in the panel instead.
|
||||
if self.focused_thread.as_ref() == Some(session_id) {
|
||||
if self
|
||||
.active_entry
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.is_active_thread(session_id))
|
||||
{
|
||||
let current_pos = self.contents.entries.iter().position(|entry| {
|
||||
matches!(entry, ListEntry::Thread(t) if &t.metadata.session_id == session_id)
|
||||
});
|
||||
@@ -2360,7 +2412,12 @@ impl Sidebar {
|
||||
ThreadEntryWorkspace::Open(ws) => Some(ws.clone()),
|
||||
ThreadEntryWorkspace::Closed(_) => group_workspace,
|
||||
};
|
||||
self.focused_thread = Some(next_metadata.session_id.clone());
|
||||
if let Some(ref ws) = target_workspace {
|
||||
self.active_entry = Some(ActiveEntry::Thread {
|
||||
session_id: next_metadata.session_id.clone(),
|
||||
workspace: ws.clone(),
|
||||
});
|
||||
}
|
||||
self.record_thread_access(&next_metadata.session_id);
|
||||
|
||||
if let Some(workspace) = target_workspace {
|
||||
@@ -2379,8 +2436,8 @@ impl Sidebar {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.focused_thread = None;
|
||||
if let Some(workspace) = &group_workspace {
|
||||
self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
|
||||
if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
|
||||
agent_panel.update(cx, |panel, cx| {
|
||||
panel.new_thread(&NewThread, window, cx);
|
||||
@@ -2543,11 +2600,13 @@ impl Sidebar {
|
||||
|
||||
let weak_multi_workspace = self.multi_workspace.clone();
|
||||
|
||||
let original_metadata = self
|
||||
.focused_thread
|
||||
.as_ref()
|
||||
.and_then(|focused_id| entries.iter().find(|e| &e.session_id == focused_id))
|
||||
.map(|e| e.metadata.clone());
|
||||
let original_metadata = match &self.active_entry {
|
||||
Some(ActiveEntry::Thread { session_id, .. }) => entries
|
||||
.iter()
|
||||
.find(|e| &e.session_id == session_id)
|
||||
.map(|e| e.metadata.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let original_workspace = self
|
||||
.multi_workspace
|
||||
.upgrade()
|
||||
@@ -2569,7 +2628,10 @@ impl Sidebar {
|
||||
mw.activate(workspace.clone(), window, cx);
|
||||
});
|
||||
}
|
||||
this.focused_thread = Some(metadata.session_id.clone());
|
||||
this.active_entry = Some(ActiveEntry::Thread {
|
||||
session_id: metadata.session_id.clone(),
|
||||
workspace: workspace.clone(),
|
||||
});
|
||||
this.update_entries(cx);
|
||||
Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
|
||||
let focus = thread_switcher.focus_handle(cx);
|
||||
@@ -2585,7 +2647,10 @@ impl Sidebar {
|
||||
});
|
||||
}
|
||||
this.record_thread_access(&metadata.session_id);
|
||||
this.focused_thread = Some(metadata.session_id.clone());
|
||||
this.active_entry = Some(ActiveEntry::Thread {
|
||||
session_id: metadata.session_id.clone(),
|
||||
workspace: workspace.clone(),
|
||||
});
|
||||
this.update_entries(cx);
|
||||
Self::load_agent_thread_in_workspace(workspace, metadata, false, window, cx);
|
||||
this.dismiss_thread_switcher(cx);
|
||||
@@ -2602,7 +2667,12 @@ impl Sidebar {
|
||||
}
|
||||
}
|
||||
if let Some(metadata) = &original_metadata {
|
||||
this.focused_thread = Some(metadata.session_id.clone());
|
||||
if let Some(original_ws) = &original_workspace {
|
||||
this.active_entry = Some(ActiveEntry::Thread {
|
||||
session_id: metadata.session_id.clone(),
|
||||
workspace: original_ws.clone(),
|
||||
});
|
||||
}
|
||||
this.update_entries(cx);
|
||||
if let Some(original_ws) = &original_workspace {
|
||||
Self::load_agent_thread_in_workspace(
|
||||
@@ -2651,7 +2721,10 @@ impl Sidebar {
|
||||
mw.activate(workspace.clone(), window, cx);
|
||||
});
|
||||
}
|
||||
self.focused_thread = Some(metadata.session_id.clone());
|
||||
self.active_entry = Some(ActiveEntry::Thread {
|
||||
session_id: metadata.session_id.clone(),
|
||||
workspace: workspace.clone(),
|
||||
});
|
||||
self.update_entries(cx);
|
||||
Self::load_agent_thread_in_workspace(&workspace, &metadata, false, window, cx);
|
||||
}
|
||||
@@ -2663,6 +2736,7 @@ impl Sidebar {
|
||||
&self,
|
||||
ix: usize,
|
||||
thread: &ThreadEntry,
|
||||
is_active: bool,
|
||||
is_focused: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
@@ -2675,8 +2749,7 @@ impl Sidebar {
|
||||
let thread_workspace = thread.workspace.clone();
|
||||
|
||||
let is_hovered = self.hovered_thread_index == Some(ix);
|
||||
let is_selected = self.agent_panel_visible(cx)
|
||||
&& self.focused_thread.as_ref() == Some(&metadata.session_id);
|
||||
let is_selected = is_active;
|
||||
let is_running = matches!(
|
||||
thread.status,
|
||||
AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
|
||||
@@ -2943,11 +3016,7 @@ impl Sidebar {
|
||||
return;
|
||||
};
|
||||
|
||||
// Clear focused_thread immediately so no existing thread stays
|
||||
// highlighted while the new blank thread is being shown. Without this,
|
||||
// if the target workspace is already active (so ActiveWorkspaceChanged
|
||||
// never fires), the previous thread's highlight would linger.
|
||||
self.focused_thread = None;
|
||||
self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
|
||||
|
||||
multi_workspace.update(cx, |multi_workspace, cx| {
|
||||
multi_workspace.activate(workspace.clone(), window, cx);
|
||||
@@ -2968,14 +3037,11 @@ impl Sidebar {
|
||||
ix: usize,
|
||||
_path_list: &PathList,
|
||||
workspace: &Entity<Workspace>,
|
||||
is_active_draft: bool,
|
||||
is_active: bool,
|
||||
worktrees: &[WorktreeInfo],
|
||||
is_selected: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> AnyElement {
|
||||
let is_active =
|
||||
is_active_draft && self.agent_panel_visible(cx) && self.active_thread_is_draft(cx);
|
||||
|
||||
let label: SharedString = if is_active {
|
||||
self.active_draft_text(cx)
|
||||
.unwrap_or_else(|| DEFAULT_THREAD_TITLE.into())
|
||||
|
||||
+229
-121
@@ -30,6 +30,28 @@ fn init_test(cx: &mut TestAppContext) {
|
||||
});
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_active_thread(sidebar: &Sidebar, session_id: &acp::SessionId, msg: &str) {
|
||||
assert!(
|
||||
sidebar
|
||||
.active_entry
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.is_active_thread(session_id)),
|
||||
"{msg}: expected active_entry to be Thread({session_id:?}), got {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
}
|
||||
|
||||
#[track_caller]
|
||||
fn assert_active_draft(sidebar: &Sidebar, workspace: &Entity<Workspace>, msg: &str) {
|
||||
assert!(
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Draft(ws)) if ws == workspace),
|
||||
"{msg}: expected active_entry to be Draft for workspace {:?}, got {:?}",
|
||||
workspace.entity_id(),
|
||||
sidebar.active_entry,
|
||||
);
|
||||
}
|
||||
|
||||
fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool {
|
||||
sidebar
|
||||
.contents
|
||||
@@ -1979,10 +2001,10 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
|
||||
|
||||
// ── 1. Initial state: focused thread derived from active panel ─────
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread.as_ref(),
|
||||
Some(&session_id_a),
|
||||
"The active panel's thread should be focused on startup"
|
||||
assert_active_thread(
|
||||
sidebar,
|
||||
&session_id_a,
|
||||
"The active panel's thread should be focused on startup",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2005,10 +2027,10 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread.as_ref(),
|
||||
Some(&session_id_a),
|
||||
"After clicking a thread, it should be the focused thread"
|
||||
assert_active_thread(
|
||||
sidebar,
|
||||
&session_id_a,
|
||||
"After clicking a thread, it should be the focused thread",
|
||||
);
|
||||
assert!(
|
||||
has_thread_entry(sidebar, &session_id_a),
|
||||
@@ -2060,10 +2082,10 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread.as_ref(),
|
||||
Some(&session_id_b),
|
||||
"Clicking a thread in another workspace should focus that thread"
|
||||
assert_active_thread(
|
||||
sidebar,
|
||||
&session_id_b,
|
||||
"Clicking a thread in another workspace should focus that thread",
|
||||
);
|
||||
assert!(
|
||||
has_thread_entry(sidebar, &session_id_b),
|
||||
@@ -2078,10 +2100,10 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread.as_ref(),
|
||||
Some(&session_id_a),
|
||||
"Switching workspace should seed focused_thread from the new active panel"
|
||||
assert_active_thread(
|
||||
sidebar,
|
||||
&session_id_a,
|
||||
"Switching workspace should seed focused_thread from the new active panel",
|
||||
);
|
||||
assert!(
|
||||
has_thread_entry(sidebar, &session_id_a),
|
||||
@@ -2104,10 +2126,10 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
|
||||
// This prevents running threads in background workspaces from causing
|
||||
// the selection highlight to jump around.
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread.as_ref(),
|
||||
Some(&session_id_a),
|
||||
"Opening a thread in a non-active panel should not change focused_thread"
|
||||
assert_active_thread(
|
||||
sidebar,
|
||||
&session_id_a,
|
||||
"Opening a thread in a non-active panel should not change focused_thread",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2117,10 +2139,10 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread.as_ref(),
|
||||
Some(&session_id_a),
|
||||
"Defocusing the sidebar should not change focused_thread"
|
||||
assert_active_thread(
|
||||
sidebar,
|
||||
&session_id_a,
|
||||
"Defocusing the sidebar should not change focused_thread",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2135,10 +2157,10 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread.as_ref(),
|
||||
Some(&session_id_b2),
|
||||
"Switching workspace should seed focused_thread from the new active panel"
|
||||
assert_active_thread(
|
||||
sidebar,
|
||||
&session_id_b2,
|
||||
"Switching workspace should seed focused_thread from the new active panel",
|
||||
);
|
||||
assert!(
|
||||
has_thread_entry(sidebar, &session_id_b2),
|
||||
@@ -2158,10 +2180,10 @@ async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
|
||||
cx.run_until_parked();
|
||||
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread.as_ref(),
|
||||
Some(&session_id_b2),
|
||||
"Focusing the agent panel thread should set focused_thread"
|
||||
assert_active_thread(
|
||||
sidebar,
|
||||
&session_id_b2,
|
||||
"Focusing the agent panel thread should set focused_thread",
|
||||
);
|
||||
assert!(
|
||||
has_thread_entry(sidebar, &session_id_b2),
|
||||
@@ -2199,10 +2221,11 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
|
||||
|
||||
// The "New Thread" button should NOT be in "active/draft" state
|
||||
// because the panel has a thread with messages.
|
||||
sidebar.read_with(cx, |sidebar, cx| {
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert!(
|
||||
!sidebar.active_thread_is_draft(cx),
|
||||
"Panel has a thread with messages, so it should not be a draft"
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
|
||||
"Panel has a thread with messages, so active_entry should be Thread, got {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2229,11 +2252,12 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
|
||||
// The "New Thread" button must still be clickable (not stuck in
|
||||
// "active/draft" state). Verify that `active_thread_is_draft` is
|
||||
// false — the panel still has the old thread with messages.
|
||||
sidebar.read_with(cx, |sidebar, cx| {
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert!(
|
||||
!sidebar.active_thread_is_draft(cx),
|
||||
matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { .. })),
|
||||
"After adding a folder the panel still has a thread with messages, \
|
||||
so active_thread_is_draft should be false"
|
||||
so active_entry should be Thread, got {:?}",
|
||||
sidebar.active_entry,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2247,10 +2271,11 @@ async fn test_new_thread_button_works_after_adding_folder(cx: &mut TestAppContex
|
||||
|
||||
// After creating a new thread, the panel should now be in draft
|
||||
// state (no messages on the new thread).
|
||||
sidebar.read_with(cx, |sidebar, cx| {
|
||||
assert!(
|
||||
sidebar.active_thread_is_draft(cx),
|
||||
"After creating a new thread the panel should be in draft state"
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_active_draft(
|
||||
sidebar,
|
||||
&workspace,
|
||||
"After creating a new thread active_entry should be Draft",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -2301,14 +2326,59 @@ async fn test_cmd_n_shows_new_thread_entry(cx: &mut TestAppContext) {
|
||||
"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"
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_active_draft(
|
||||
sidebar,
|
||||
&workspace,
|
||||
"active_entry should be Draft after Cmd-N",
|
||||
);
|
||||
assert!(
|
||||
sidebar.active_thread_is_draft(cx),
|
||||
"the new blank thread should be a draft"
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_draft_with_server_session_shows_as_draft(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, &project, cx);
|
||||
|
||||
let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
|
||||
|
||||
// Create a saved thread so the workspace has history.
|
||||
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 saved_session_id = active_session_id(&panel, cx);
|
||||
save_test_thread_metadata(&saved_session_id, path_list.clone(), cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [my-project]", " Hello *"]
|
||||
);
|
||||
|
||||
// Open a new draft thread via a server connection. This gives the
|
||||
// conversation a parent_id (session assigned by the server) but
|
||||
// no messages have been sent, so active_thread_is_draft() is true.
|
||||
let draft_connection = StubAgentConnection::new();
|
||||
open_thread_with_connection(&panel, draft_connection, cx);
|
||||
cx.run_until_parked();
|
||||
|
||||
assert_eq!(
|
||||
visible_entries_as_strings(&sidebar, cx),
|
||||
vec!["v [my-project]", " [+ New Thread]", " Hello *"],
|
||||
"Draft with a server session should still show as [+ New Thread]"
|
||||
);
|
||||
|
||||
let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_active_draft(
|
||||
sidebar,
|
||||
&workspace,
|
||||
"Draft with server session should be Draft, not Thread",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -2437,14 +2507,11 @@ async fn test_cmd_n_shows_new_thread_entry_in_absorbed_worktree(cx: &mut TestApp
|
||||
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(cx),
|
||||
"the new blank thread should be a draft"
|
||||
sidebar.read_with(cx, |sidebar, _cx| {
|
||||
assert_active_draft(
|
||||
sidebar,
|
||||
&worktree_workspace,
|
||||
"active_entry should be Draft after Cmd-N",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -3894,8 +3961,8 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window(cx: &m
|
||||
"should activate the window that already owns the matching workspace"
|
||||
);
|
||||
sidebar.read_with(cx_a, |sidebar, _| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread, None,
|
||||
assert!(
|
||||
!matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id: id, .. }) if id == &session_id),
|
||||
"source window's sidebar should not eagerly claim focus for a thread opened in another window"
|
||||
);
|
||||
});
|
||||
@@ -3970,16 +4037,16 @@ async fn test_activate_archived_thread_reuses_workspace_in_another_window_with_t
|
||||
"should activate the window that already owns the matching workspace"
|
||||
);
|
||||
sidebar_a.read_with(cx_a, |sidebar, _| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread, None,
|
||||
assert!(
|
||||
!matches!(&sidebar.active_entry, Some(ActiveEntry::Thread { session_id: id, .. }) if id == &session_id),
|
||||
"source window's sidebar should not eagerly claim focus for a thread opened in another window"
|
||||
);
|
||||
});
|
||||
sidebar_b.read_with(cx_b, |sidebar, _| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread.as_ref(),
|
||||
Some(&session_id),
|
||||
"target window's sidebar should eagerly focus the activated archived thread"
|
||||
assert_active_thread(
|
||||
sidebar,
|
||||
&session_id,
|
||||
"target window's sidebar should eagerly focus the activated archived thread",
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -4031,10 +4098,10 @@ async fn test_activate_archived_thread_prefers_current_window_for_matching_paths
|
||||
"should keep activation in the current window when it already has a matching workspace"
|
||||
);
|
||||
sidebar_a.read_with(cx_a, |sidebar, _| {
|
||||
assert_eq!(
|
||||
sidebar.focused_thread.as_ref(),
|
||||
Some(&session_id),
|
||||
"current window's sidebar should eagerly focus the activated archived thread"
|
||||
assert_active_thread(
|
||||
sidebar,
|
||||
&session_id,
|
||||
"current window's sidebar should eagerly focus the activated archived thread",
|
||||
);
|
||||
});
|
||||
assert_eq!(
|
||||
@@ -4188,13 +4255,13 @@ async fn test_archive_thread_uses_next_threads_own_workspace(cx: &mut TestAppCon
|
||||
|
||||
// The sidebar should track T2 as the focused thread (derived from the
|
||||
// main panel's active view).
|
||||
let focused = sidebar.read_with(cx, |s, _| s.focused_thread.clone());
|
||||
assert_eq!(
|
||||
focused,
|
||||
Some(thread2_session_id.clone()),
|
||||
"focused thread should be Thread 2 before archiving: {:?}",
|
||||
focused
|
||||
);
|
||||
sidebar.read_with(cx, |s, _| {
|
||||
assert_active_thread(
|
||||
s,
|
||||
&thread2_session_id,
|
||||
"focused thread should be Thread 2 before archiving",
|
||||
);
|
||||
});
|
||||
|
||||
// Archive thread 2.
|
||||
sidebar.update_in(cx, |sidebar, window, cx| {
|
||||
@@ -4816,6 +4883,7 @@ mod property_test {
|
||||
SaveWorktreeThread { worktree_index: usize },
|
||||
DeleteThread { index: usize },
|
||||
ToggleAgentPanel,
|
||||
CreateDraftThread,
|
||||
AddWorkspace,
|
||||
OpenWorktreeAsWorkspace { worktree_index: usize },
|
||||
RemoveWorkspace { index: usize },
|
||||
@@ -4824,16 +4892,17 @@ mod property_test {
|
||||
}
|
||||
|
||||
// Distribution (out of 20 slots):
|
||||
// SaveThread: 5 slots (25%)
|
||||
// SaveWorktreeThread: 2 slots (10%)
|
||||
// DeleteThread: 2 slots (10%)
|
||||
// ToggleAgentPanel: 2 slots (10%)
|
||||
// AddWorkspace: 1 slot (5%)
|
||||
// OpenWorktreeAsWorkspace: 1 slot (5%)
|
||||
// RemoveWorkspace: 1 slot (5%)
|
||||
// SwitchWorkspace: 2 slots (10%)
|
||||
// AddLinkedWorktree: 4 slots (20%)
|
||||
const DISTRIBUTION_SLOTS: u32 = 20;
|
||||
// SaveThread: 5 slots (~23%)
|
||||
// SaveWorktreeThread: 2 slots (~9%)
|
||||
// DeleteThread: 2 slots (~9%)
|
||||
// ToggleAgentPanel: 2 slots (~9%)
|
||||
// CreateDraftThread: 2 slots (~9%)
|
||||
// AddWorkspace: 1 slot (~5%)
|
||||
// OpenWorktreeAsWorkspace: 1 slot (~5%)
|
||||
// RemoveWorkspace: 1 slot (~5%)
|
||||
// SwitchWorkspace: 2 slots (~9%)
|
||||
// AddLinkedWorktree: 4 slots (~18%)
|
||||
const DISTRIBUTION_SLOTS: u32 = 22;
|
||||
|
||||
impl TestState {
|
||||
fn generate_operation(&self, raw: u32) -> Operation {
|
||||
@@ -4857,24 +4926,25 @@ mod property_test {
|
||||
workspace_index: extra % workspace_count,
|
||||
},
|
||||
9..=10 => Operation::ToggleAgentPanel,
|
||||
11 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace {
|
||||
11..=12 => Operation::CreateDraftThread,
|
||||
13 if !self.unopened_worktrees.is_empty() => Operation::OpenWorktreeAsWorkspace {
|
||||
worktree_index: extra % self.unopened_worktrees.len(),
|
||||
},
|
||||
11 => Operation::AddWorkspace,
|
||||
12 if workspace_count > 1 => Operation::RemoveWorkspace {
|
||||
13 => Operation::AddWorkspace,
|
||||
14 if workspace_count > 1 => Operation::RemoveWorkspace {
|
||||
index: extra % workspace_count,
|
||||
},
|
||||
12 => Operation::AddWorkspace,
|
||||
13..=14 => Operation::SwitchWorkspace {
|
||||
14 => Operation::AddWorkspace,
|
||||
15..=16 => Operation::SwitchWorkspace {
|
||||
index: extra % workspace_count,
|
||||
},
|
||||
15..=19 if !self.main_repo_indices.is_empty() => {
|
||||
17..=21 if !self.main_repo_indices.is_empty() => {
|
||||
let main_index = self.main_repo_indices[extra % self.main_repo_indices.len()];
|
||||
Operation::AddLinkedWorktree {
|
||||
workspace_index: main_index,
|
||||
}
|
||||
}
|
||||
15..=19 => Operation::SaveThread {
|
||||
17..=21 => Operation::SaveThread {
|
||||
workspace_index: extra % workspace_count,
|
||||
},
|
||||
_ => unreachable!(),
|
||||
@@ -4910,7 +4980,7 @@ mod property_test {
|
||||
operation: Operation,
|
||||
state: &mut TestState,
|
||||
multi_workspace: &Entity<MultiWorkspace>,
|
||||
sidebar: &Entity<Sidebar>,
|
||||
_sidebar: &Entity<Sidebar>,
|
||||
cx: &mut gpui::VisualTestContext,
|
||||
) {
|
||||
match operation {
|
||||
@@ -4936,7 +5006,7 @@ mod property_test {
|
||||
Operation::ToggleAgentPanel => {
|
||||
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
||||
let panel_open =
|
||||
sidebar.read_with(cx, |sidebar, cx| sidebar.agent_panel_visible(cx));
|
||||
workspace.read_with(cx, |_, cx| AgentPanel::is_visible(&workspace, cx));
|
||||
workspace.update_in(cx, |workspace, window, cx| {
|
||||
if panel_open {
|
||||
workspace.close_panel::<AgentPanel>(window, cx);
|
||||
@@ -4945,6 +5015,19 @@ mod property_test {
|
||||
}
|
||||
});
|
||||
}
|
||||
Operation::CreateDraftThread => {
|
||||
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
|
||||
let panel =
|
||||
workspace.read_with(cx, |workspace, cx| workspace.panel::<AgentPanel>(cx));
|
||||
if let Some(panel) = panel {
|
||||
let connection = StubAgentConnection::new();
|
||||
open_thread_with_connection(&panel, connection, cx);
|
||||
cx.run_until_parked();
|
||||
}
|
||||
workspace.update_in(cx, |workspace, window, cx| {
|
||||
workspace.focus_panel::<AgentPanel>(window, cx);
|
||||
});
|
||||
}
|
||||
Operation::AddWorkspace => {
|
||||
let path = state.next_workspace_path();
|
||||
state
|
||||
@@ -5184,34 +5267,59 @@ mod property_test {
|
||||
anyhow::bail!("sidebar should still have an associated multi-workspace");
|
||||
};
|
||||
|
||||
let workspace = multi_workspace.read(cx).workspace();
|
||||
let active_workspace = multi_workspace.read(cx).workspace();
|
||||
|
||||
// TODO: The focused_thread should _always_ be Some(item-in-the-list) after
|
||||
// update_entries. If the activated workspace's agent panel has an active thread,
|
||||
// this item should match the one in the list. There may be a slight delay where
|
||||
// a thread is loading so the agent panel returns None initially, and the
|
||||
// focused_thread is often optimistically set to the thread the agent panel is
|
||||
// going to be.
|
||||
if sidebar.agent_panel_visible(cx) && !sidebar.active_thread_is_draft(cx) {
|
||||
let panel_active_session_id =
|
||||
workspace
|
||||
.read(cx)
|
||||
.panel::<AgentPanel>(cx)
|
||||
.and_then(|panel| {
|
||||
panel
|
||||
.read(cx)
|
||||
.active_conversation_view()
|
||||
.and_then(|cv| cv.read(cx).parent_id(cx))
|
||||
});
|
||||
if let Some(panel_session_id) = panel_active_session_id {
|
||||
anyhow::ensure!(
|
||||
sidebar.focused_thread.as_ref() == Some(&panel_session_id),
|
||||
"agent panel is visible with active session {:?} but sidebar focused_thread is {:?}",
|
||||
panel_session_id,
|
||||
sidebar.focused_thread,
|
||||
);
|
||||
}
|
||||
// 1. active_entry must always be Some after rebuild_contents.
|
||||
let entry = sidebar
|
||||
.active_entry
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("active_entry must always be Some"))?;
|
||||
|
||||
// 2. The entry's workspace must agree with the multi-workspace's
|
||||
// active workspace.
|
||||
anyhow::ensure!(
|
||||
entry.workspace().entity_id() == active_workspace.entity_id(),
|
||||
"active_entry workspace ({:?}) != active workspace ({:?})",
|
||||
entry.workspace().entity_id(),
|
||||
active_workspace.entity_id(),
|
||||
);
|
||||
|
||||
// 3. The entry must match the agent panel's current state.
|
||||
let panel = active_workspace.read(cx).panel::<AgentPanel>(cx).unwrap();
|
||||
if panel.read(cx).active_thread_is_draft(cx) {
|
||||
anyhow::ensure!(
|
||||
matches!(entry, ActiveEntry::Draft(_)),
|
||||
"panel shows a draft but active_entry is {:?}",
|
||||
entry,
|
||||
);
|
||||
} else if let Some(session_id) = panel
|
||||
.read(cx)
|
||||
.active_conversation_view()
|
||||
.and_then(|cv| cv.read(cx).parent_id(cx))
|
||||
{
|
||||
anyhow::ensure!(
|
||||
matches!(entry, ActiveEntry::Thread { session_id: id, .. } if id == &session_id),
|
||||
"panel has session {:?} but active_entry is {:?}",
|
||||
session_id,
|
||||
entry,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Exactly one entry in sidebar contents must be uniquely
|
||||
// identified by the active_entry.
|
||||
let matching_count = sidebar
|
||||
.contents
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| entry.matches_entry(e))
|
||||
.count();
|
||||
anyhow::ensure!(
|
||||
matching_count == 1,
|
||||
"expected exactly 1 sidebar entry matching active_entry {:?}, found {}",
|
||||
entry,
|
||||
matching_count,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user