diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs index b143f8ea269e190442c66d09fb6ccc02a3f34c71..6d0eaea56a749ebe22033bc22eb4978fbd5649d1 100644 --- a/crates/sidebar/src/sidebar.rs +++ b/crates/sidebar/src/sidebar.rs @@ -55,11 +55,11 @@ const MIN_WIDTH: Pixels = px(200.0); const MAX_WIDTH: Pixels = px(800.0); const DEFAULT_THREADS_SHOWN: usize = 5; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Debug, Default)] enum SidebarView { #[default] ThreadList, - Archive, + Archive(Entity), } #[derive(Clone, Debug)] @@ -218,6 +218,10 @@ fn workspace_label_from_path_list(path_list: &PathList) -> SharedString { } } +/// The sidebar re-derives its entire entry list from scratch on every +/// change via `update_entries` → `rebuild_contents`. Avoid adding +/// incremental or inter-event coordination state — if something can +/// be computed from the current world state, compute it in the rebuild. pub struct Sidebar { multi_workspace: WeakEntity, width: Pixels, @@ -229,25 +233,20 @@ pub struct Sidebar { /// /// Note: This is NOT the same as the active item. selection: Option, + /// 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, agent_panel_visible: bool, - /// Set to true when WorkspaceRemoved fires so the subsequent - /// ActiveWorkspaceChanged event knows not to clear focused_thread. - /// A workspace removal changes the active workspace as a side-effect, but - /// that should not reset the user's thread focus the way an explicit - /// workspace switch does. - pending_workspace_removal: bool, - - active_entry_index: Option, hovered_thread_index: Option, collapsed_groups: HashSet, expanded_groups: HashMap, - promoted_threads: HashSet, + threads_by_paths: HashMap>, view: SidebarView, - archive_view: Option>, recent_projects_popover_handle: PopoverMenuHandle, _subscriptions: Vec, - _update_entries_task: Option>, + _list_threads_task: Option>, _draft_observation: Option, } @@ -273,50 +272,15 @@ impl Sidebar { window, |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event { MultiWorkspaceEvent::ActiveWorkspaceChanged => { - if mem::take(&mut this.pending_workspace_removal) { - // If the removed workspace had no focused thread, seed - // from the new active panel so its current thread gets - // highlighted — same logic as subscribe_to_workspace. - if this.focused_thread.is_none() { - if let Some(mw) = this.multi_workspace.upgrade() { - let ws = mw.read(cx).workspace(); - if let Some(panel) = ws.read(cx).panel::(cx) { - this.focused_thread = panel - .read(cx) - .active_conversation() - .and_then(|cv| cv.read(cx).parent_id(cx)); - } - } - } - } else { - // Seed focused_thread from the new active panel so - // the sidebar highlights the correct thread. - this.focused_thread = this - .multi_workspace - .upgrade() - .and_then(|mw| { - let ws = mw.read(cx).workspace(); - ws.read(cx).panel::(cx) - }) - .and_then(|panel| { - panel - .read(cx) - .active_conversation() - .and_then(|cv| cv.read(cx).parent_id(cx)) - }); - } this.observe_draft_editor(cx); - this.update_entries(false, cx); + this.update_entries(cx); } MultiWorkspaceEvent::WorkspaceAdded(workspace) => { this.subscribe_to_workspace(workspace, window, cx); - this.update_entries(false, cx); + this.update_entries(cx); } MultiWorkspaceEvent::WorkspaceRemoved(_) => { - // Signal that the upcoming ActiveWorkspaceChanged event is - // a consequence of this removal, not a user workspace switch. - this.pending_workspace_removal = true; - this.update_entries(false, cx); + this.update_entries(cx); } }, ) @@ -328,18 +292,21 @@ impl Sidebar { if !query.is_empty() { this.selection.take(); } - this.update_entries(!query.is_empty(), cx); + this.update_entries(cx); + if !query.is_empty() { + this.select_first_entry(); + } } }) .detach(); cx.observe(&ThreadMetadataStore::global(cx), |this, _store, cx| { - this.update_entries(false, cx); + this.list_threads(cx); }) .detach(); cx.observe_flag::(window, |_is_enabled, this, _window, cx| { - this.update_entries(false, cx); + this.update_entries(cx); }) .detach(); @@ -348,11 +315,11 @@ impl Sidebar { for workspace in &workspaces { this.subscribe_to_workspace(workspace, window, cx); } - this.update_entries(false, cx); + this.update_entries(cx); }); Self { - _update_entries_task: None, + _list_threads_task: None, multi_workspace: multi_workspace.downgrade(), width: DEFAULT_WIDTH, focus_handle, @@ -362,14 +329,11 @@ impl Sidebar { selection: None, focused_thread: None, agent_panel_visible: false, - pending_workspace_removal: false, - active_entry_index: None, hovered_thread_index: None, collapsed_groups: HashSet::new(), expanded_groups: HashMap::new(), - promoted_threads: HashSet::new(), + threads_by_paths: HashMap::new(), view: SidebarView::default(), - archive_view: None, recent_projects_popover_handle: PopoverMenuHandle::default(), _subscriptions: Vec::new(), _draft_observation: None, @@ -390,7 +354,7 @@ impl Sidebar { ProjectEvent::WorktreeAdded(_) | ProjectEvent::WorktreeRemoved(_) | ProjectEvent::WorktreeOrderChanged => { - this.update_entries(false, cx); + this.update_entries(cx); } _ => {} }, @@ -411,7 +375,7 @@ impl Sidebar { ) ) { this.prune_stale_worktree_workspaces(window, cx); - this.update_entries(false, cx); + this.update_entries(cx); } }, ) @@ -435,13 +399,6 @@ impl Sidebar { if let Some(agent_panel) = workspace.read(cx).panel::(cx) { self.subscribe_to_agent_panel(&agent_panel, window, cx); self.agent_panel_visible = AgentPanel::is_visible(workspace, cx); - // Seed the initial focused_thread so the correct thread item is - // highlighted right away, without waiting for the panel to emit - // an event (which only happens on *changes*, not on first load). - self.focused_thread = agent_panel - .read(cx) - .active_conversation() - .and_then(|cv| cv.read(cx).parent_id(cx)); self.observe_draft_editor(cx); } } @@ -455,44 +412,13 @@ impl Sidebar { cx.subscribe_in( agent_panel, window, - |this, agent_panel, event: &AgentPanelEvent, _window, cx| { - // Check whether the panel that emitted this event belongs to - // the currently active workspace. Only the active workspace's - // panel should drive focused_thread — otherwise running threads - // in background workspaces would continuously overwrite it, - // causing the selection highlight to jump around. - let is_active_panel = this - .multi_workspace - .upgrade() - .and_then(|mw| mw.read(cx).workspace().read(cx).panel::(cx)) - .is_some_and(|active_panel| active_panel == *agent_panel); - - match event { - AgentPanelEvent::ActiveViewChanged => { - if is_active_panel { - this.focused_thread = agent_panel - .read(cx) - .active_conversation() - .and_then(|cv| cv.read(cx).parent_id(cx)); - this.observe_draft_editor(cx); - } - this.update_entries(false, cx); - } - AgentPanelEvent::ThreadFocused => { - if is_active_panel { - let new_focused = agent_panel - .read(cx) - .active_conversation() - .and_then(|cv| cv.read(cx).parent_id(cx)); - if new_focused.is_some() && new_focused != this.focused_thread { - this.focused_thread = new_focused; - this.update_entries(false, cx); - } - } - } - AgentPanelEvent::BackgroundThreadChanged => { - this.update_entries(false, cx); - } + |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event { + AgentPanelEvent::ActiveViewChanged => { + this.observe_draft_editor(cx); + this.update_entries(cx); + } + AgentPanelEvent::ThreadFocused | AgentPanelEvent::BackgroundThreadChanged => { + this.update_entries(cx); } }, ) @@ -635,7 +561,9 @@ impl Sidebar { .collect() } - fn rebuild_contents(&mut self, thread_entries: Vec, cx: &App) { + /// When modifying this thread, aim for a single forward pass over workspaces + /// and threads plus an O(T log T) sort. Avoid adding extra scans over the data. + fn rebuild_contents(&mut self, cx: &App) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; @@ -643,14 +571,6 @@ impl Sidebar { let workspaces = mw.workspaces().to_vec(); let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned(); - let mut threads_by_paths: HashMap> = HashMap::new(); - for row in thread_entries { - threads_by_paths - .entry(row.folder_paths.clone()) - .or_default() - .push(row); - } - // Build a lookup for agent icons from the first workspace's AgentServerStore. let agent_server_store = workspaces .first() @@ -658,20 +578,24 @@ impl Sidebar { let query = self.filter_editor.read(cx).text(cx); - let previous = mem::take(&mut self.contents); + // 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::(cx)) + .and_then(|panel| { + panel + .read(cx) + .active_conversation() + .and_then(|cv| cv.read(cx).parent_id(cx)) + }); + if panel_focused.is_some() { + self.focused_thread = panel_focused; + } - // Collect the session IDs that were visible before this rebuild so we - // can distinguish a thread that was deleted/removed (was in the list, - // now gone) from a brand-new thread that hasn't been saved to the - // metadata store yet (never was in the list). - let previous_session_ids: HashSet = previous - .entries - .iter() - .filter_map(|entry| match entry { - ListEntry::Thread(t) => Some(t.session_info.session_id.clone()), - _ => None, - }) - .collect(); + let previous = mem::take(&mut self.contents); let old_statuses: HashMap = previous .entries @@ -686,11 +610,8 @@ impl Sidebar { let mut entries = Vec::new(); let mut notified_threads = previous.notified_threads; - // Track all session IDs we add to entries so we can prune stale - // notifications without a separate pass at the end. let mut current_session_ids: HashSet = HashSet::new(); - // Compute active_entry_index inline during the build pass. - let mut active_entry_index: Option = None; + let mut project_header_indices: Vec = Vec::new(); // Identify absorbed workspaces in a single pass. A workspace is // "absorbed" when it points at a git worktree checkout whose main @@ -755,12 +676,14 @@ impl Sidebar { let mut live_infos = Self::all_thread_infos_for_workspace(workspace, cx); let mut threads: Vec = Vec::new(); + let mut has_running_threads = false; + let mut waiting_thread_count: usize = 0; if should_load_threads { let mut seen_session_ids: HashSet = HashSet::new(); // Read threads from SidebarDb for this workspace's path list. - if let Some(rows) = threads_by_paths.get(&path_list) { + if let Some(rows) = self.threads_by_paths.get(&path_list) { for row in rows { seen_session_ids.insert(row.session_id.clone()); let (agent, icon, icon_from_external_svg) = match &row.agent_id { @@ -839,7 +762,7 @@ impl Sidebar { None => ThreadEntryWorkspace::Closed(worktree_path_list.clone()), }; - if let Some(rows) = threads_by_paths.get(worktree_path_list) { + if let Some(rows) = self.threads_by_paths.get(worktree_path_list) { for row in rows { if !seen_session_ids.insert(row.session_id.clone()) { continue; @@ -889,19 +812,30 @@ impl Sidebar { } } - if !live_infos.is_empty() { - let thread_index_by_session: HashMap = threads - .iter() - .enumerate() - .map(|(i, t)| (t.session_info.session_id.clone(), i)) - .collect(); + // Build a lookup from live_infos and compute running/waiting + // counts in a single pass. + let mut live_info_by_session: HashMap<&acp::SessionId, &ActiveThreadInfo> = + HashMap::new(); + for info in &live_infos { + live_info_by_session.insert(&info.session_id, info); + if info.status == AgentThreadStatus::Running { + has_running_threads = true; + } + if info.status == AgentThreadStatus::WaitingForConfirmation { + waiting_thread_count += 1; + } + } - for info in &live_infos { - let Some(&idx) = thread_index_by_session.get(&info.session_id) else { - continue; - }; + // Merge live info into threads and update notification state + // in a single pass. + let is_active_workspace = active_workspace + .as_ref() + .is_some_and(|active| active == workspace); - let thread = &mut threads[idx]; + for thread in &mut threads { + let session_id = &thread.session_info.session_id; + + if let Some(info) = live_info_by_session.get(session_id) { thread.session_info.title = Some(info.title.clone()); thread.status = info.status; thread.icon = info.icon; @@ -911,15 +845,7 @@ impl Sidebar { thread.is_title_generating = info.is_title_generating; thread.diff_stats = info.diff_stats; } - } - // Update notification state for live threads in the same pass. - let is_active_workspace = active_workspace - .as_ref() - .is_some_and(|active| active == workspace); - - for thread in &threads { - let session_id = &thread.session_info.session_id; if thread.is_background && thread.status == AgentThreadStatus::Completed { notified_threads.insert(session_id.clone()); } else if thread.status == AgentThreadStatus::Completed @@ -934,25 +860,22 @@ impl Sidebar { } } - // Sort by created_at (newest first), falling back to updated_at - // for threads without a created_at (e.g., ACP sessions). threads.sort_by(|a, b| { let a_time = a.session_info.created_at.or(a.session_info.updated_at); let b_time = b.session_info.created_at.or(b.session_info.updated_at); b_time.cmp(&a_time) }); + } else { + for info in &live_infos { + if info.status == AgentThreadStatus::Running { + has_running_threads = true; + } + if info.status == AgentThreadStatus::WaitingForConfirmation { + waiting_thread_count += 1; + } + } } - // Compute running/waiting counts after live_infos has been - // extended with any absorbed worktree workspaces. - let has_running_threads = live_infos - .iter() - .any(|info| info.status == AgentThreadStatus::Running); - let waiting_thread_count = live_infos - .iter() - .filter(|info| info.status == AgentThreadStatus::WaitingForConfirmation) - .count(); - if !query.is_empty() { let workspace_highlight_positions = fuzzy_match_positions(&query, &label).unwrap_or_default(); @@ -987,6 +910,7 @@ impl Sidebar { continue; } + project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, @@ -996,20 +920,12 @@ impl Sidebar { waiting_thread_count, }); - // Track session IDs and compute active_entry_index as we add - // thread entries. for thread in matched_threads { current_session_ids.insert(thread.session_info.session_id.clone()); - if active_entry_index.is_none() { - if let Some(focused) = &self.focused_thread { - if &thread.session_info.session_id == focused { - active_entry_index = Some(entries.len()); - } - } - } entries.push(thread.into()); } } else { + project_header_indices.push(entries.len()); entries.push(ListEntry::ProjectHeader { path_list: path_list.clone(), label, @@ -1035,7 +951,7 @@ impl Sidebar { DEFAULT_THREADS_SHOWN + (extra_batches * DEFAULT_THREADS_SHOWN); let count = threads_to_show.min(total); - self.promoted_threads.clear(); + let mut promoted_threads: HashSet = HashSet::new(); // Build visible entries in a single pass. Threads within // the cutoff are always shown. Threads beyond it are shown @@ -1054,25 +970,18 @@ impl Sidebar { .as_ref() .is_some_and(|id| id == session_id); if is_promoted { - self.promoted_threads.insert(session_id.clone()); + promoted_threads.insert(session_id.clone()); } - if !self.promoted_threads.contains(session_id) { + if !promoted_threads.contains(session_id) { continue; } } current_session_ids.insert(session_id.clone()); - if active_entry_index.is_none() { - if let Some(focused) = &self.focused_thread { - if &thread.session_info.session_id == focused { - active_entry_index = Some(entries.len()); - } - } - } entries.push(thread.into()); } - let visible = count + self.promoted_threads.len(); + let visible = count + promoted_threads.len(); let is_fully_expanded = visible >= total; if total > DEFAULT_THREADS_SHOWN { @@ -1089,29 +998,6 @@ impl Sidebar { // the build pass (no extra scan needed). notified_threads.retain(|id| current_session_ids.contains(id)); - let project_header_indices = entries - .iter() - .enumerate() - .filter_map(|(i, e)| matches!(e, ListEntry::ProjectHeader { .. }).then_some(i)) - .collect(); - - // If focused_thread points to a thread that was previously in the - // list but is now gone (deleted, or its workspace was removed), clear - // it. We don't try to redirect to a thread in a different project - // group — the delete_thread method already handles within-group - // neighbor selection. If it was never in the list it's a brand-new - // thread that hasn't been saved to the metadata store yet — leave - // things alone and wait for the next rebuild. - let focused_thread_was_known = self - .focused_thread - .as_ref() - .is_some_and(|id| previous_session_ids.contains(id)); - - if focused_thread_was_known && active_entry_index.is_none() { - self.focused_thread = None; - } - - self.active_entry_index = active_entry_index; self.contents = SidebarContents { entries, notified_threads, @@ -1119,7 +1005,31 @@ impl Sidebar { }; } - fn update_entries(&mut self, select_first_thread: bool, cx: &mut Context) { + fn list_threads(&mut self, cx: &mut Context) { + let list_task = ThreadMetadataStore::global(cx).read(cx).list(cx); + self._list_threads_task = Some(cx.spawn(async move |this, cx| { + let Some(thread_entries) = list_task.await.log_err() else { + return; + }; + this.update(cx, |this, cx| { + let mut threads_by_paths: HashMap> = HashMap::new(); + for row in thread_entries { + threads_by_paths + .entry(row.folder_paths.clone()) + .or_default() + .push(row); + } + this.threads_by_paths = threads_by_paths; + this.update_entries(cx); + }) + .ok(); + })); + } + + /// Rebuilds the sidebar's visible entries from already-cached state. + /// Data fetching happens elsewhere (e.g. `list_threads`); this just + /// re-derives the entry list, list state, and notifications. + fn update_entries(&mut self, cx: &mut Context) { let Some(multi_workspace) = self.multi_workspace.upgrade() else { return; }; @@ -1128,47 +1038,35 @@ impl Sidebar { } let had_notifications = self.has_notifications(cx); - let scroll_position = self.list_state.logical_scroll_top(); - let list_thread_entries_task = ThreadMetadataStore::global(cx).read(cx).list(cx); + self.rebuild_contents(cx); - self._update_entries_task.take(); - self._update_entries_task = Some(cx.spawn(async move |this, cx| { - let Some(thread_entries) = list_thread_entries_task.await.log_err() else { - return; - }; - this.update(cx, |this, cx| { - this.rebuild_contents(thread_entries, cx); + self.list_state.reset(self.contents.entries.len()); + self.list_state.scroll_to(scroll_position); - if select_first_thread { - this.selection = this - .contents - .entries - .iter() - .position(|entry| matches!(entry, ListEntry::Thread(_))) - .or_else(|| { - if this.contents.entries.is_empty() { - None - } else { - Some(0) - } - }); - } + if had_notifications != self.has_notifications(cx) { + multi_workspace.update(cx, |_, cx| { + cx.notify(); + }); + } - this.list_state.reset(this.contents.entries.len()); - this.list_state.scroll_to(scroll_position); + cx.notify(); + } - if had_notifications != this.has_notifications(cx) { - multi_workspace.update(cx, |_, cx| { - cx.notify(); - }); + fn select_first_entry(&mut self) { + self.selection = self + .contents + .entries + .iter() + .position(|entry| matches!(entry, ListEntry::Thread(_))) + .or_else(|| { + if self.contents.entries.is_empty() { + None + } else { + Some(0) } - - cx.notify(); - }) - .ok(); - })); + }); } fn render_list_entry( @@ -1366,7 +1264,7 @@ impl Sidebar { move |this, _, _window, cx| { this.selection = None; this.expanded_groups.remove(&path_list_for_collapse); - this.update_entries(false, cx); + this.update_entries(cx); } })), ) @@ -1546,11 +1444,11 @@ impl Sidebar { } else { self.collapsed_groups.insert(path_list.clone()); } - self.update_entries(false, cx); + self.update_entries(cx); } fn focus_in(&mut self, window: &mut Window, cx: &mut Context) { - if matches!(self.view, SidebarView::Archive) { + if matches!(self.view, SidebarView::Archive(_)) { return; } @@ -1561,7 +1459,7 @@ impl Sidebar { fn cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context) { if self.reset_filter_editor_text(window, cx) { - self.update_entries(false, cx); + self.update_entries(cx); } else { self.selection = None; self.filter_editor.focus_handle(cx).focus(window, cx); @@ -1725,7 +1623,7 @@ impl Sidebar { let current = self.expanded_groups.get(&path_list).copied().unwrap_or(0); self.expanded_groups.insert(path_list, current + 1); } - self.update_entries(false, cx); + self.update_entries(cx); } ListEntry::NewThread { workspace, .. } => { let workspace = workspace.clone(); @@ -1818,7 +1716,7 @@ impl Sidebar { Self::load_agent_thread_in_workspace(workspace, agent, session_info, window, cx); - self.update_entries(false, cx); + self.update_entries(cx); } fn activate_thread_in_other_window( @@ -1851,7 +1749,7 @@ impl Sidebar { { target_sidebar.update(cx, |sidebar, cx| { sidebar.focused_thread = Some(target_session_id); - sidebar.update_entries(false, cx); + sidebar.update_entries(cx); }); } } @@ -1991,7 +1889,7 @@ impl Sidebar { if self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.remove(&path_list); - self.update_entries(false, 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); @@ -2015,7 +1913,7 @@ impl Sidebar { if !self.collapsed_groups.contains(path_list) { let path_list = path_list.clone(); self.collapsed_groups.insert(path_list); - self.update_entries(false, cx); + self.update_entries(cx); } } Some( @@ -2028,7 +1926,7 @@ impl Sidebar { let path_list = path_list.clone(); self.selection = Some(i); self.collapsed_groups.insert(path_list); - self.update_entries(false, cx); + self.update_entries(cx); break; } } @@ -2070,7 +1968,7 @@ impl Sidebar { self.selection = Some(header_ix); self.collapsed_groups.insert(path_list); } - self.update_entries(false, cx); + self.update_entries(cx); } } } @@ -2086,7 +1984,7 @@ impl Sidebar { self.collapsed_groups.insert(path_list.clone()); } } - self.update_entries(false, cx); + self.update_entries(cx); } fn unfold_all( @@ -2096,7 +1994,7 @@ impl Sidebar { cx: &mut Context, ) { self.collapsed_groups.clear(); - self.update_entries(false, cx); + self.update_entries(cx); } fn stop_thread(&mut self, session_id: &acp::SessionId, cx: &mut Context) { @@ -2477,7 +2375,7 @@ impl Sidebar { let current = this.expanded_groups.get(&path_list).copied().unwrap_or(0); this.expanded_groups.insert(path_list.clone(), current + 1); } - this.update_entries(false, cx); + this.update_entries(cx); })) .into_any_element() } @@ -2701,7 +2599,7 @@ impl Sidebar { .tooltip(Tooltip::text("Clear Search")) .on_click(cx.listener(|this, _, window, cx| { this.reset_filter_editor_text(window, cx); - this.update_entries(false, cx); + this.update_entries(cx); })), ) }), @@ -2799,14 +2697,12 @@ impl Sidebar { ); self._subscriptions.push(subscription); - self.archive_view = Some(archive_view); - self.view = SidebarView::Archive; + self.view = SidebarView::Archive(archive_view); cx.notify(); } fn show_thread_list(&mut self, window: &mut Window, cx: &mut Context) { self.view = SidebarView::ThreadList; - self.archive_view = None; self._subscriptions.clear(); window.focus(&self.focus_handle, cx); cx.notify(); @@ -2890,7 +2786,7 @@ impl Render for Sidebar { .bg(bg) .border_r_1() .border_color(cx.theme().colors().border) - .map(|this| match self.view { + .map(|this| match &self.view { SidebarView::ThreadList => this .child(self.render_sidebar_header(empty_state, window, cx)) .map(|this| { @@ -2915,13 +2811,7 @@ impl Render for Sidebar { ) } }), - SidebarView::Archive => { - if let Some(archive_view) = &self.archive_view { - this.child(archive_view.clone()) - } else { - this - } - } + SidebarView::Archive(archive_view) => this.child(archive_view.clone()), }) } } @@ -2956,6 +2846,12 @@ mod tests { }); } + fn has_thread_entry(sidebar: &Sidebar, session_id: &acp::SessionId) -> bool { + sidebar.contents.entries.iter().any(|entry| { + matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id) + }) + } + async fn init_test_project( worktree_path: &str, cx: &mut TestAppContext, @@ -3359,7 +3255,7 @@ mod tests { sidebar.update_in(cx, |s, _window, cx| { let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(false, cx); + s.update_entries(cx); }); cx.run_until_parked(); @@ -3372,7 +3268,7 @@ mod tests { sidebar.update_in(cx, |s, _window, cx| { let current = s.expanded_groups.get(&path_list).copied().unwrap_or(0); s.expanded_groups.insert(path_list.clone(), current + 1); - s.update_entries(false, cx); + s.update_entries(cx); }); cx.run_until_parked(); @@ -3385,7 +3281,7 @@ mod tests { // Click collapse - should go back to showing 5 threads sidebar.update_in(cx, |s, _window, cx| { s.expanded_groups.remove(&path_list); - s.update_entries(false, cx); + s.update_entries(cx); }); cx.run_until_parked(); @@ -4856,15 +4752,12 @@ mod tests { let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone()); - // ── 1. Initial state: no focused thread ────────────────────────────── + // ── 1. Initial state: focused thread derived from active panel ───── sidebar.read_with(cx, |sidebar, _cx| { assert_eq!( - sidebar.focused_thread, None, - "Initially no thread should be focused" - ); - assert_eq!( - sidebar.active_entry_index, None, - "No active entry when no thread is focused" + sidebar.focused_thread.as_ref(), + Some(&session_id_a), + "The active panel's thread should be focused on startup" ); }); @@ -4892,11 +4785,9 @@ mod tests { Some(&session_id_a), "After clicking a thread, it should be the focused thread" ); - let active_entry = sidebar.active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a), - "Active entry should be the clicked thread" + has_thread_entry(sidebar, &session_id_a), + "The clicked thread should be present in the entries" ); }); @@ -4949,12 +4840,9 @@ mod tests { Some(&session_id_b), "Clicking a thread in another workspace should focus that thread" ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b), - "Active entry should be the cross-workspace thread" + has_thread_entry(sidebar, &session_id_b), + "The cross-workspace thread should be present in the entries" ); }); @@ -4969,12 +4857,9 @@ mod tests { Some(&session_id_a), "Switching workspace should seed focused_thread from the new active panel" ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_a), - "Active entry should be the seeded thread" + has_thread_entry(sidebar, &session_id_a), + "The seeded thread should be present in the entries" ); }); @@ -5028,12 +4913,9 @@ mod tests { Some(&session_id_b2), "Switching workspace should seed focused_thread from the new active panel" ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2), - "Active entry should be the seeded thread" + has_thread_entry(sidebar, &session_id_b2), + "The seeded thread should be present in the entries" ); }); @@ -5054,12 +4936,9 @@ mod tests { Some(&session_id_b2), "Focusing the agent panel thread should set focused_thread" ); - let active_entry = sidebar - .active_entry_index - .and_then(|ix| sidebar.contents.entries.get(ix)); assert!( - matches!(active_entry, Some(ListEntry::Thread(thread)) if thread.session_info.session_id == session_id_b2), - "Active entry should be the focused thread" + has_thread_entry(sidebar, &session_id_b2), + "The focused thread should be present in the entries" ); }); }