Simplify sidebar internals

Mikayla Maki created

Change summary

crates/sidebar/src/sidebar.rs | 332 ++++++++++++++++++++++--------------
1 file changed, 199 insertions(+), 133 deletions(-)

Detailed changes

crates/sidebar/src/sidebar.rs 🔗

@@ -116,6 +116,18 @@ struct ThreadEntry {
     diff_stats: DiffStats,
 }
 
+#[derive(Clone, Debug, PartialEq)]
+enum ActiveEntry {
+    Thread(acp::SessionId),
+    Draft(Entity<Workspace>),
+}
+
+impl ActiveEntry {
+    fn is_draft(&self) -> bool {
+        matches!(self, ActiveEntry::Draft(_))
+    }
+}
+
 #[derive(Clone)]
 enum ListEntry {
     ProjectHeader {
@@ -135,7 +147,6 @@ enum ListEntry {
     NewThread {
         path_list: PathList,
         workspace: Entity<Workspace>,
-        is_active_draft: bool,
     },
 }
 
@@ -237,17 +248,11 @@ pub struct Sidebar {
     filter_editor: Entity<Editor>,
     list_state: ListState,
     contents: SidebarContents,
-    /// The index of the list item that currently has the keyboard focus
-    ///
-    /// Note: This is NOT the same as the active item.
+    /// The index of the list item that currently has the keyboard focus.
     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>,
-    agent_panel_visible: bool,
-    active_thread_is_draft: bool,
+    /// Tracks which entry is "active" — the thread or draft the user is
+    /// currently working with.
+    active_entry: Option<ActiveEntry>,
     hovered_thread_index: Option<usize>,
     collapsed_groups: HashSet<PathList>,
     expanded_groups: HashMap<PathList, usize>,
@@ -280,6 +285,7 @@ impl Sidebar {
             window,
             |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
                 MultiWorkspaceEvent::ActiveWorkspaceChanged => {
+                    this.active_entry = None;
                     this.observe_draft_editor(cx);
                     this.update_entries(cx);
                 }
@@ -337,9 +343,7 @@ impl Sidebar {
             list_state: ListState::new(0, gpui::ListAlignment::Top, px(1000.)),
             contents: SidebarContents::default(),
             selection: None,
-            focused_thread: None,
-            agent_panel_visible: false,
-            active_thread_is_draft: false,
+            active_entry: None,
             hovered_thread_index: None,
             collapsed_groups: HashSet::new(),
             expanded_groups: HashMap::new(),
@@ -351,6 +355,12 @@ impl Sidebar {
         }
     }
 
+    fn is_agent_panel_visible(&self, cx: &App) -> bool {
+        self.multi_workspace.upgrade().map_or(false, |mw| {
+            AgentPanel::is_visible(mw.read(cx).workspace(), cx)
+        })
+    }
+
     fn is_active_workspace(&self, workspace: &Entity<Workspace>, cx: &App) -> bool {
         self.multi_workspace
             .upgrade()
@@ -415,9 +425,6 @@ impl Sidebar {
 
         if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
             self.subscribe_to_agent_panel(&agent_panel, window, cx);
-            if self.is_active_workspace(workspace, cx) {
-                self.agent_panel_visible = AgentPanel::is_visible(workspace, cx);
-            }
             self.observe_draft_editor(cx);
         }
     }
@@ -438,7 +445,10 @@ 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(multi_workspace) = this.multi_workspace.upgrade() {
+                            let workspace = multi_workspace.read(cx).workspace().clone();
+                            this.active_entry = Some(ActiveEntry::Draft(workspace));
+                        }
                     }
                     this.observe_draft_editor(cx);
                     this.update_entries(cx);
@@ -465,14 +475,7 @@ impl Sidebar {
                 let Some(workspace) = workspace.upgrade() else {
                     return;
                 };
-                if !this.is_active_workspace(&workspace, cx) {
-                    return;
-                }
-
-                let is_visible = AgentPanel::is_visible(&workspace, cx);
-
-                if this.agent_panel_visible != is_visible {
-                    this.agent_panel_visible = is_visible;
+                if this.is_active_workspace(&workspace, cx) {
                     cx.notify();
                 }
             })
@@ -614,23 +617,12 @@ impl Sidebar {
 
         let query = self.filter_editor.read(cx).text(cx);
 
-        // Re-derive agent_panel_visible from the active workspace so it stays
-        // correct after workspace switches.
-        self.agent_panel_visible = active_workspace
-            .as_ref()
-            .map_or(false, |ws| AgentPanel::is_visible(ws, cx));
-
-        // Derive active_thread_is_draft BEFORE focused_thread so we can
-        // use it as a guard below.
-        self.active_thread_is_draft = active_workspace
-            .as_ref()
-            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
-            .map_or(false, |panel| panel.read(cx).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.
+        // Derive active_entry from the active workspace's agent panel.
+        // Thread when the panel has a non-draft thread, Draft when the
+        // workspace has no active thread (or no panel at all).
+        // Only overwrite with Thread on a positive signal — if the panel
+        // transiently returns None while loading, 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))
@@ -640,8 +632,12 @@ impl Sidebar {
                     .active_conversation_view()
                     .and_then(|cv| cv.read(cx).parent_id(cx))
             });
-        if panel_focused.is_some() && !self.active_thread_is_draft {
-            self.focused_thread = panel_focused;
+        if let Some(session_id) = panel_focused {
+            self.active_entry = Some(ActiveEntry::Thread(session_id));
+        } else if self.active_entry.is_none() {
+            if let Some(workspace) = active_workspace.as_ref() {
+                self.active_entry = Some(ActiveEntry::Draft(workspace.clone()));
+            }
         }
 
         let previous = mem::take(&mut self.contents);
@@ -1033,9 +1029,8 @@ impl Sidebar {
                 }
             } else {
                 let thread_count = threads.len();
-                let is_draft_for_workspace = self.agent_panel_visible
-                    && self.active_thread_is_draft
-                    && self.focused_thread.is_none()
+                let is_draft_for_workspace = self.is_agent_panel_visible(cx)
+                    && self.active_entry.as_ref().is_some_and(|e| e.is_draft())
                     && is_active;
 
                 let show_new_thread_entry = thread_count == 0 || is_draft_for_workspace;
@@ -1059,7 +1054,6 @@ impl Sidebar {
                     entries.push(ListEntry::NewThread {
                         path_list: path_list.clone(),
                         workspace: workspace.clone(),
-                        is_active_draft: is_draft_for_workspace,
                     });
                 }
 
@@ -1084,10 +1078,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(|entry| {
+                                entry == &ActiveEntry::Thread(session_id.clone())
+                            });
                         if is_promoted {
                             promoted_threads.insert(session_id.clone());
                         }
@@ -1211,10 +1204,7 @@ impl Sidebar {
             ListEntry::NewThread {
                 path_list,
                 workspace,
-                is_active_draft,
-            } => {
-                self.render_new_thread(ix, path_list, workspace, *is_active_draft, is_selected, cx)
-            }
+            } => self.render_new_thread(ix, path_list, workspace, is_selected, cx),
         };
 
         if is_group_header_after_first {
@@ -1254,13 +1244,6 @@ impl Sidebar {
             IconName::ChevronDown
         };
 
-        let has_new_thread_entry = self
-            .contents
-            .entries
-            .get(ix + 1)
-            .is_some_and(|entry| matches!(entry, ListEntry::NewThread { .. }));
-        let show_new_thread_button = !has_new_thread_entry && !self.has_filter_query(cx);
-
         let workspace_for_remove = workspace.clone();
         let workspace_for_menu = workspace.clone();
         let workspace_for_open = workspace.clone();
@@ -1397,7 +1380,7 @@ impl Sidebar {
                             .tooltip(Tooltip::text("Activate Workspace"))
                             .on_click(cx.listener({
                                 move |this, _, window, cx| {
-                                    this.focused_thread = None;
+                                    this.active_entry = None;
                                     if let Some(multi_workspace) = this.multi_workspace.upgrade() {
                                         multi_workspace.update(cx, |multi_workspace, cx| {
                                             multi_workspace
@@ -1413,7 +1396,7 @@ impl Sidebar {
                             })),
                         )
                     })
-                    .when(show_new_thread_button, |this| {
+                    .when(!self.has_filter_query(cx), |this| {
                         this.child(
                             IconButton::new(
                                 SharedString::from(format!(
@@ -2062,10 +2045,10 @@ 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(session_info.session_id.clone());
+        self.active_entry = Some(ActiveEntry::Thread(session_info.session_id.clone()));
 
         multi_workspace.update(cx, |multi_workspace, cx| {
             multi_workspace.activate(workspace.clone(), cx);
@@ -2105,7 +2088,7 @@ impl Sidebar {
                 .and_then(|sidebar| sidebar.downcast::<Self>().ok())
             {
                 target_sidebar.update(cx, |sidebar, cx| {
-                    sidebar.focused_thread = Some(target_session_id);
+                    sidebar.active_entry = Some(ActiveEntry::Thread(target_session_id));
                     sidebar.update_entries(cx);
                 });
             }
@@ -2382,7 +2365,7 @@ 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() == Some(&ActiveEntry::Thread(session_id.clone())) {
             let current_pos = self.contents.entries.iter().position(|entry| {
                 matches!(entry, ListEntry::Thread(t) if &t.session_info.session_id == session_id)
             });
@@ -2436,7 +2419,8 @@ impl Sidebar {
             });
 
             if let Some(next) = next_thread {
-                self.focused_thread = Some(next.session_info.session_id.clone());
+                self.active_entry =
+                    Some(ActiveEntry::Thread(next.session_info.session_id.clone()));
 
                 // Use the thread's own workspace when it has one open (e.g. an absorbed
                 // linked worktree thread that appears under the main workspace's header
@@ -2464,7 +2448,7 @@ impl Sidebar {
                     }
                 }
             } else {
-                self.focused_thread = None;
+                self.active_entry = None;
                 if let Some(workspace) = &group_workspace {
                     if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
                         agent_panel.update(cx, |panel, cx| {
@@ -2519,8 +2503,9 @@ impl Sidebar {
         let thread_workspace = thread.workspace.clone();
 
         let is_hovered = self.hovered_thread_index == Some(ix);
-        let is_selected = self.agent_panel_visible
-            && self.focused_thread.as_ref() == Some(&session_info.session_id);
+        let is_selected = self.is_agent_panel_visible(cx)
+            && self.active_entry.as_ref()
+                == Some(&ActiveEntry::Thread(session_info.session_id.clone()));
         let is_running = matches!(
             thread.status,
             AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
@@ -2789,11 +2774,11 @@ impl Sidebar {
             return;
         };
 
-        // Clear focused_thread immediately so no existing thread stays
+        // Set active_entry to Draft 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(), cx);
@@ -2814,11 +2799,13 @@ impl Sidebar {
         ix: usize,
         _path_list: &PathList,
         workspace: &Entity<Workspace>,
-        is_active_draft: bool,
         is_selected: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
-        let is_active = is_active_draft && self.agent_panel_visible && self.active_thread_is_draft;
+        let is_active = self
+            .active_entry
+            .as_ref()
+            .is_some_and(|entry| entry == &ActiveEntry::Draft(workspace.clone()));
 
         let label: SharedString = if is_active {
             self.active_draft_text(cx)
@@ -3613,6 +3600,86 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_activate_empty_workspace_marks_new_thread_active(cx: &mut TestAppContext) {
+        let project_a = init_test_project("/project-a", 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 path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
+
+        // Workspace A has a thread.
+        save_thread_metadata(
+            acp::SessionId::new(Arc::from("thread-a1")),
+            "Thread A1".into(),
+            chrono::TimeZone::with_ymd_and_hms(&Utc, 2024, 1, 1, 0, 0, 0).unwrap(),
+            path_list_a.clone(),
+            cx,
+        )
+        .await;
+        cx.run_until_parked();
+
+        // Add workspace B with no threads.
+        let fs = cx.update(|_, cx| <dyn fs::Fs>::global(cx));
+        fs.as_fake()
+            .insert_tree("/project-b", serde_json::json!({ "src": {} }))
+            .await;
+        let project_b = project::Project::test(fs, ["/project-b".as_ref()], cx).await;
+        let _workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.test_add_workspace(project_b.clone(), window, cx)
+        });
+        cx.run_until_parked();
+
+        // After adding workspace B, it becomes active. Its new-thread entry
+        // should be marked active since it has no threads.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project-a]",
+                "  Thread A1",
+                "v [project-b]",
+                "  [+ New Thread]",
+            ]
+        );
+
+        // Switch back to workspace A.
+        multi_workspace.update_in(cx, |mw, _window, cx| {
+            let workspace_a = mw.workspaces()[0].clone();
+            mw.activate(workspace_a, cx);
+        });
+        cx.run_until_parked();
+
+        // Workspace A is now active; workspace B's new-thread should no
+        // longer be marked active.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project-a]",
+                "  Thread A1",
+                "v [project-b]",
+                "  [+ New Thread]",
+            ]
+        );
+
+        // Switch back to workspace B.
+        multi_workspace.update_in(cx, |mw, _window, cx| {
+            let workspace_b = mw.workspaces()[1].clone();
+            mw.activate(workspace_b, cx);
+        });
+        cx.run_until_parked();
+
+        // Workspace B is active again; its new-thread entry should be active.
+        assert_eq!(
+            visible_entries_as_strings(&sidebar, cx),
+            vec![
+                "v [project-a]",
+                "  Thread A1",
+                "v [project-b]",
+                "  [+ New Thread]",
+            ]
+        );
+    }
     #[gpui::test]
     async fn test_view_more_pagination(cx: &mut TestAppContext) {
         let project = init_test_project("/my-project", cx).await;
@@ -5102,7 +5169,7 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
+    async fn test_active_entry_tracks_user_intent(cx: &mut TestAppContext) {
         let project_a = init_test_project_with_agent_panel("/project-a", cx).await;
         let (multi_workspace, cx) = cx
             .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
@@ -5134,12 +5201,11 @@ mod tests {
 
         let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
 
-        // ── 1. Initial state: focused thread derived from active panel ─────
+        // ── 1. Initial state: workspace_b is active (no threads) so Draft ────
         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!(
+                sidebar.active_entry.as_ref().is_some_and(|e| e.is_draft()),
+                "Workspace B is active with no threads, so active_entry should be Draft"
             );
         });
 
@@ -5163,8 +5229,8 @@ mod tests {
 
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
-                sidebar.focused_thread.as_ref(),
-                Some(&session_id_a),
+                sidebar.active_entry.as_ref(),
+                Some(&ActiveEntry::Thread(session_id_a.clone())),
                 "After clicking a thread, it should be the focused thread"
             );
             assert!(
@@ -5218,8 +5284,8 @@ mod tests {
 
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
-                sidebar.focused_thread.as_ref(),
-                Some(&session_id_b),
+                sidebar.active_entry.as_ref(),
+                Some(&ActiveEntry::Thread(session_id_b.clone())),
                 "Clicking a thread in another workspace should focus that thread"
             );
             assert!(
@@ -5235,9 +5301,9 @@ mod tests {
 
         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"
+                sidebar.active_entry.as_ref(),
+                Some(&ActiveEntry::Thread(session_id_a.clone())),
+                "Switching workspace should seed active_entry from the new active panel"
             );
             assert!(
                 has_thread_entry(sidebar, &session_id_a),
@@ -5256,14 +5322,14 @@ mod tests {
         cx.run_until_parked();
 
         // Panel B is not the active workspace's panel (workspace A is
-        // active), so opening a thread there should not change focused_thread.
+        // active), so opening a thread there should not change active_entry.
         // 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"
+                sidebar.active_entry.as_ref(),
+                Some(&ActiveEntry::Thread(session_id_a.clone())),
+                "Opening a thread in a non-active panel should not change active_entry"
             );
         });
 
@@ -5274,14 +5340,14 @@ mod tests {
 
         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"
+                sidebar.active_entry.as_ref(),
+                Some(&ActiveEntry::Thread(session_id_a.clone())),
+                "Defocusing the sidebar should not change active_entry"
             );
         });
 
         // Switching workspaces via the multi_workspace (simulates clicking
-        // a workspace header) should clear focused_thread.
+        // a workspace header) should clear active_entry.
         multi_workspace.update_in(cx, |mw, window, cx| {
             if let Some(index) = mw.workspaces().iter().position(|w| w == &workspace_b) {
                 mw.activate_index(index, window, cx);
@@ -5291,9 +5357,9 @@ mod tests {
 
         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"
+                sidebar.active_entry.as_ref(),
+                Some(&ActiveEntry::Thread(session_id_b2.clone())),
+                "Switching workspace should seed active_entry from the new active panel"
             );
             assert!(
                 has_thread_entry(sidebar, &session_id_b2),
@@ -5301,10 +5367,10 @@ mod tests {
             );
         });
 
-        // ── 8. Focusing the agent panel thread keeps focused_thread ────
+        // ── 8. Focusing the agent panel thread keeps active_entry ────
         // Workspace B still has session_id_b2 loaded in the agent panel.
         // Clicking into the thread (simulated by focusing its view) should
-        // keep focused_thread since it was already seeded on workspace switch.
+        // keep active_entry since it was already seeded on workspace switch.
         panel_b.update_in(cx, |panel, window, cx| {
             if let Some(thread_view) = panel.active_conversation_view() {
                 thread_view.read(cx).focus_handle(cx).focus(window, cx);
@@ -5314,9 +5380,9 @@ mod tests {
 
         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"
+                sidebar.active_entry.as_ref(),
+                Some(&ActiveEntry::Thread(session_id_b2.clone())),
+                "Focusing the agent panel thread should set active_entry"
             );
             assert!(
                 has_thread_entry(sidebar, &session_id_b2),
@@ -5356,8 +5422,8 @@ mod tests {
         // because the panel has a thread with messages.
         sidebar.read_with(cx, |sidebar, _cx| {
             assert!(
-                !sidebar.active_thread_is_draft,
-                "Panel has a thread with messages, so it should not be a draft"
+                !sidebar.active_entry.as_ref().is_some_and(|e| e.is_draft()),
+                "Panel has a thread with messages, so active_entry should not be a draft"
             );
         });
 
@@ -5384,13 +5450,13 @@ mod tests {
         );
 
         // 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.
+        // "active/draft" state). Verify that `active_entry` is not a
+        // draft — the panel still has the old thread with messages.
         sidebar.read_with(cx, |sidebar, _cx| {
             assert!(
-                !sidebar.active_thread_is_draft,
+                !sidebar.active_entry.as_ref().is_some_and(|e| e.is_draft()),
                 "After adding a folder the panel still has a thread with messages, \
-                 so active_thread_is_draft should be false"
+                 so active_entry should not be a draft"
             );
         });
 
@@ -5406,7 +5472,7 @@ mod tests {
         // state (no messages on the new thread).
         sidebar.read_with(cx, |sidebar, _cx| {
             assert!(
-                sidebar.active_thread_is_draft,
+                sidebar.active_entry.as_ref().is_some_and(|e| e.is_draft()),
                 "After creating a new thread the panel should be in draft state"
             );
         });
@@ -5460,11 +5526,11 @@ mod tests {
 
         sidebar.read_with(cx, |sidebar, _cx| {
             assert!(
-                sidebar.focused_thread.is_none(),
-                "focused_thread should be cleared after Cmd-N"
+                sidebar.active_entry.as_ref().is_some_and(|e| e.is_draft()),
+                "active_entry should be a Draft after Cmd-N"
             );
             assert!(
-                sidebar.active_thread_is_draft,
+                sidebar.active_entry.as_ref().is_some_and(|e| e.is_draft()),
                 "the new blank thread should be a draft"
             );
         });
@@ -5592,11 +5658,11 @@ mod tests {
 
         sidebar.read_with(cx, |sidebar, _cx| {
             assert!(
-                sidebar.focused_thread.is_none(),
-                "focused_thread should be cleared after Cmd-N"
+                sidebar.active_entry.as_ref().is_some_and(|e| e.is_draft()),
+                "active_entry should be a Draft after Cmd-N"
             );
             assert!(
-                sidebar.active_thread_is_draft,
+                sidebar.active_entry.as_ref().is_some_and(|e| e.is_draft()),
                 "the new blank thread should be a draft"
             );
         });
@@ -6749,9 +6815,9 @@ mod tests {
             "should activate the window that already owns the matching workspace"
         );
         sidebar.read_with(cx_a, |sidebar, _| {
-            assert_eq!(
-                sidebar.focused_thread, None,
-                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
+            assert!(
+                sidebar.active_entry.as_ref().is_some_and(|e| e.is_draft()),
+                "source window's sidebar should show Draft for its own workspace, not claim a thread from another window"
             );
         });
     }
@@ -6825,15 +6891,15 @@ mod tests {
             "should activate the window that already owns the matching workspace"
         );
         sidebar_a.read_with(cx_a, |sidebar, _| {
-            assert_eq!(
-                sidebar.focused_thread, None,
-                "source window's sidebar should not eagerly claim focus for a thread opened in another window"
+            assert!(
+                sidebar.active_entry.as_ref().is_some_and(|e| e.is_draft()),
+                "source window's sidebar should show Draft for its own workspace, not claim a thread from another window"
             );
         });
         sidebar_b.read_with(cx_b, |sidebar, _| {
             assert_eq!(
-                sidebar.focused_thread.as_ref(),
-                Some(&session_id),
+                sidebar.active_entry.as_ref(),
+                Some(&ActiveEntry::Thread(session_id.clone())),
                 "target window's sidebar should eagerly focus the activated archived thread"
             );
         });
@@ -6887,8 +6953,8 @@ mod tests {
         );
         sidebar_a.read_with(cx_a, |sidebar, _| {
             assert_eq!(
-                sidebar.focused_thread.as_ref(),
-                Some(&session_id),
+                sidebar.active_entry.as_ref(),
+                Some(&ActiveEntry::Thread(session_id.clone())),
                 "current window's sidebar should eagerly focus the activated archived thread"
             );
         });
@@ -7044,11 +7110,11 @@ mod tests {
 
         // 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());
+        let focused = sidebar.read_with(cx, |s, _| s.active_entry.clone());
         assert_eq!(
             focused,
-            Some(thread2_session_id.clone()),
-            "focused thread should be Thread 2 before archiving: {:?}",
+            Some(ActiveEntry::Thread(thread2_session_id.clone())),
+            "focused entry should be Thread 2 before archiving: {:?}",
             focused
         );