Move sidebar back out of the panel (#51723)

Max Brunsfeld created

Reverts https://github.com/zed-industries/zed/pull/51241

Release Notes:

- N/A

Change summary

Cargo.lock                                           |  33 +
Cargo.toml                                           |   3 
crates/agent_ui/Cargo.toml                           |   1 
crates/agent_ui/src/agent_panel.rs                   | 297 --------
crates/agent_ui/src/agent_ui.rs                      |   5 
crates/agent_ui/src/conversation_view.rs             |   4 
crates/agent_ui/src/conversation_view/thread_view.rs |   2 
crates/debugger_ui/src/tests/stack_frame_list.rs     |   4 
crates/platform_title_bar/src/platform_title_bar.rs  |  40 +
crates/sidebar/Cargo.toml                            |  54 +
crates/sidebar/LICENSE-GPL                           |   1 
crates/sidebar/src/sidebar.rs                        | 453 ++++++-------
crates/title_bar/Cargo.toml                          |   1 
crates/title_bar/src/title_bar.rs                    |  66 +
crates/workspace/src/multi_workspace.rs              | 355 ++++++++++
crates/workspace/src/persistence.rs                  |  10 
crates/workspace/src/persistence/model.rs            |   9 
crates/workspace/src/status_bar.rs                   |  14 
crates/workspace/src/workspace.rs                    |  29 
crates/zed/Cargo.toml                                |   1 
crates/zed/src/visual_test_runner.rs                 |  42 +
crates/zed/src/zed.rs                                |  15 
22 files changed, 834 insertions(+), 605 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -15848,6 +15848,37 @@ version = "1.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
 
+[[package]]
+name = "sidebar"
+version = "0.1.0"
+dependencies = [
+ "acp_thread",
+ "action_log",
+ "agent",
+ "agent-client-protocol",
+ "agent_ui",
+ "anyhow",
+ "assistant_text_thread",
+ "chrono",
+ "editor",
+ "feature_flags",
+ "fs",
+ "git",
+ "gpui",
+ "language_model",
+ "menu",
+ "pretty_assertions",
+ "project",
+ "prompt_store",
+ "serde_json",
+ "settings",
+ "theme",
+ "ui",
+ "util",
+ "workspace",
+ "zed_actions",
+]
+
 [[package]]
 name = "signal-hook"
 version = "0.3.18"
@@ -17695,6 +17726,7 @@ dependencies = [
  "client",
  "cloud_api_types",
  "db",
+ "feature_flags",
  "git_ui",
  "gpui",
  "notifications",
@@ -21921,6 +21953,7 @@ dependencies = [
  "settings_profile_selector",
  "settings_ui",
  "shellexpand 2.1.2",
+ "sidebar",
  "smol",
  "snippet_provider",
  "snippets_ui",

Cargo.toml 🔗

@@ -173,6 +173,7 @@ members = [
     "crates/settings_profile_selector",
     "crates/settings_ui",
     "crates/shell_command_parser",
+    "crates/sidebar",
     "crates/snippet",
     "crates/snippet_provider",
     "crates/snippets_ui",
@@ -411,6 +412,7 @@ rules_library = { path = "crates/rules_library" }
 scheduler = { path = "crates/scheduler" }
 search = { path = "crates/search" }
 session = { path = "crates/session" }
+sidebar = { path = "crates/sidebar" }
 settings = { path = "crates/settings" }
 settings_content = { path = "crates/settings_content" }
 settings_json = { path = "crates/settings_json" }
@@ -902,6 +904,7 @@ refineable = { codegen-units = 1 }
 release_channel = { codegen-units = 1 }
 reqwest_client = { codegen-units = 1 }
 session = { codegen-units = 1 }
+sidebar = { codegen-units = 1 }
 snippet = { codegen-units = 1 }
 snippets_ui = { codegen-units = 1 }
 story = { codegen-units = 1 }

crates/agent_ui/Cargo.toml 🔗

@@ -132,6 +132,7 @@ languages = { workspace = true, features = ["test-support"] }
 language_model = { workspace = true, "features" = ["test-support"] }
 pretty_assertions.workspace = true
 project = { workspace = true, features = ["test-support"] }
+
 semver.workspace = true
 reqwest_client.workspace = true
 

crates/agent_ui/src/agent_panel.rs 🔗

@@ -62,10 +62,9 @@ use extension_host::ExtensionStore;
 use fs::Fs;
 use git::repository::validate_worktree_directory;
 use gpui::{
-    Action, Animation, AnimationExt, AnyElement, AnyView, App, AsyncWindowContext, ClipboardItem,
-    Corner, DismissEvent, DragMoveEvent, Entity, EventEmitter, ExternalPaths, FocusHandle,
-    Focusable, KeyContext, MouseButton, Pixels, Subscription, Task, UpdateGlobal, WeakEntity,
-    deferred, prelude::*, pulsating_between,
+    Action, Animation, AnimationExt, AnyElement, App, AsyncWindowContext, ClipboardItem, Corner,
+    DismissEvent, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, KeyContext, Pixels,
+    Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
 };
 use language::LanguageRegistry;
 use language_model::{ConfigurationError, LanguageModelRegistry};
@@ -77,16 +76,14 @@ use search::{BufferSearchBar, buffer_search};
 use settings::{Settings, update_settings_file};
 use theme::ThemeSettings;
 use ui::{
-    Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, Indicator, KeyBinding,
-    PopoverMenu, PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize,
+    Button, Callout, ContextMenu, ContextMenuEntry, DocumentationSide, KeyBinding, PopoverMenu,
+    PopoverMenuHandle, SpinnerLabel, Tab, Tooltip, prelude::*, utils::WithRemSize,
 };
 use util::{ResultExt as _, debug_panic};
 use workspace::{
-    CollaboratorId, DraggedSelection, DraggedSidebar, DraggedTab, FocusWorkspaceSidebar,
-    MultiWorkspace, OpenResult, PathList, SIDEBAR_RESIZE_HANDLE_SIZE, SerializedPathList,
-    ToggleWorkspaceSidebar, ToggleZoom, ToolbarItemView, Workspace, WorkspaceId,
+    CollaboratorId, DraggedSelection, DraggedTab, OpenResult, PathList, SerializedPathList,
+    ToggleZoom, ToolbarItemView, Workspace, WorkspaceId,
     dock::{DockPosition, Panel, PanelEvent},
-    multi_workspace_enabled,
 };
 use zed_actions::{
     DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize,
@@ -98,55 +95,6 @@ const AGENT_PANEL_KEY: &str = "agent_panel";
 const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
 const DEFAULT_THREAD_TITLE: &str = "New Thread";
 
-#[derive(Default)]
-struct SidebarsByWindow(
-    collections::HashMap<gpui::WindowId, gpui::WeakEntity<crate::sidebar::Sidebar>>,
-);
-
-impl gpui::Global for SidebarsByWindow {}
-
-pub(crate) fn sidebar_is_open(window: &Window, cx: &App) -> bool {
-    if !multi_workspace_enabled(cx) {
-        return false;
-    }
-    let window_id = window.window_handle().window_id();
-    cx.try_global::<SidebarsByWindow>()
-        .and_then(|sidebars| sidebars.0.get(&window_id)?.upgrade())
-        .is_some_and(|sidebar| sidebar.read(cx).is_open())
-}
-
-fn find_or_create_sidebar_for_window(
-    window: &mut Window,
-    cx: &mut App,
-) -> Option<Entity<crate::sidebar::Sidebar>> {
-    let window_id = window.window_handle().window_id();
-    let multi_workspace = window.root::<MultiWorkspace>().flatten()?;
-
-    if !cx.has_global::<SidebarsByWindow>() {
-        cx.set_global(SidebarsByWindow::default());
-    }
-
-    cx.global_mut::<SidebarsByWindow>()
-        .0
-        .retain(|_, weak| weak.upgrade().is_some());
-
-    let existing = cx
-        .global::<SidebarsByWindow>()
-        .0
-        .get(&window_id)
-        .and_then(|weak| weak.upgrade());
-
-    if let Some(sidebar) = existing {
-        return Some(sidebar);
-    }
-
-    let sidebar = cx.new(|cx| crate::sidebar::Sidebar::new(multi_workspace, window, cx));
-    cx.global_mut::<SidebarsByWindow>()
-        .0
-        .insert(window_id, sidebar.downgrade());
-    Some(sidebar)
-}
-
 fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option<SerializedAgentPanel> {
     let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
     let key = i64::from(workspace_id).to_string();
@@ -467,38 +415,6 @@ pub fn init(cx: &mut App) {
                             panel.cycle_start_thread_in(cx);
                         });
                     }
-                })
-                .register_action(|workspace, _: &ToggleWorkspaceSidebar, window, cx| {
-                    if !multi_workspace_enabled(cx) {
-                        return;
-                    }
-                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
-                        if let Some(sidebar) = panel.read(cx).sidebar.clone() {
-                            let was_open = sidebar.read(cx).is_open();
-                            sidebar.update(cx, |sidebar, cx| {
-                                sidebar.toggle(window, cx);
-                            });
-                            // When closing the sidebar, restore focus to the active pane
-                            // to avoid "zombie focus" on the now-hidden sidebar elements
-                            if was_open {
-                                let active_pane = workspace.active_pane().clone();
-                                let pane_focus = active_pane.read(cx).focus_handle(cx);
-                                window.focus(&pane_focus, cx);
-                            }
-                        }
-                    }
-                })
-                .register_action(|workspace, _: &FocusWorkspaceSidebar, window, cx| {
-                    if !multi_workspace_enabled(cx) {
-                        return;
-                    }
-                    if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
-                        if let Some(sidebar) = panel.read(cx).sidebar.clone() {
-                            sidebar.update(cx, |sidebar, cx| {
-                                sidebar.focus_or_unfocus(workspace, window, cx);
-                            });
-                        }
-                    }
                 });
         },
     )
@@ -838,7 +754,6 @@ pub struct AgentPanel {
     last_configuration_error_telemetry: Option<String>,
     on_boarding_upsell_dismissed: AtomicBool,
     _active_view_observation: Option<Subscription>,
-    pub(crate) sidebar: Option<Entity<crate::sidebar::Sidebar>>,
 }
 
 impl AgentPanel {
@@ -1021,6 +936,7 @@ impl AgentPanel {
         let client = workspace.client().clone();
         let workspace_id = workspace.database_id();
         let workspace = workspace.weak_handle();
+
         let context_server_registry =
             cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
 
@@ -1175,17 +1091,10 @@ impl AgentPanel {
             last_configuration_error_telemetry: None,
             on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed()),
             _active_view_observation: None,
-            sidebar: None,
         };
 
         // Initial sync of agent servers from extensions
         panel.sync_agent_servers_from_extensions(cx);
-
-        cx.defer_in(window, move |this, window, cx| {
-            this.sidebar = find_or_create_sidebar_for_window(window, cx);
-            cx.notify();
-        });
-
         panel
     }
 
@@ -3727,130 +3636,9 @@ impl AgentPanel {
             })
     }
 
-    fn sidebar_info(&self, cx: &App) -> Option<(AnyView, Pixels, bool)> {
-        if !multi_workspace_enabled(cx) {
-            return None;
-        }
-        let sidebar = self.sidebar.as_ref()?;
-        let is_open = sidebar.read(cx).is_open();
-        let width = sidebar.read(cx).width(cx);
-        let view: AnyView = sidebar.clone().into();
-        Some((view, width, is_open))
-    }
-
-    fn render_sidebar_toggle(&self, docked_right: bool, cx: &Context<Self>) -> Option<AnyElement> {
-        if !multi_workspace_enabled(cx) {
-            return None;
-        }
-        let sidebar = self.sidebar.as_ref()?;
-        let sidebar_read = sidebar.read(cx);
-        if sidebar_read.is_open() {
-            return None;
-        }
-        let has_notifications = sidebar_read.has_notifications(cx);
-
-        let icon = if docked_right {
-            IconName::ThreadsSidebarRightClosed
-        } else {
-            IconName::ThreadsSidebarLeftClosed
-        };
-
-        Some(
-            h_flex()
-                .h_full()
-                .px_1()
-                .map(|this| {
-                    if docked_right {
-                        this.border_l_1()
-                    } else {
-                        this.border_r_1()
-                    }
-                })
-                .border_color(cx.theme().colors().border_variant)
-                .child(
-                    IconButton::new("toggle-workspace-sidebar", icon)
-                        .icon_size(IconSize::Small)
-                        .when(has_notifications, |button| {
-                            button
-                                .indicator(Indicator::dot().color(Color::Accent))
-                                .indicator_border_color(Some(
-                                    cx.theme().colors().tab_bar_background,
-                                ))
-                        })
-                        .tooltip(move |_, cx| {
-                            Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
-                        })
-                        .on_click(|_, window, cx| {
-                            window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
-                        }),
-                )
-                .into_any_element(),
-        )
-    }
-
-    fn render_sidebar(&self, cx: &Context<Self>) -> Option<AnyElement> {
-        let (sidebar_view, sidebar_width, is_open) = self.sidebar_info(cx)?;
-        if !is_open {
-            return None;
-        }
-
-        let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
-        let sidebar = self.sidebar.as_ref()?.downgrade();
-
-        let resize_handle = deferred(
-            div()
-                .id("sidebar-resize-handle")
-                .absolute()
-                .when(docked_right, |this| {
-                    this.left(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
-                })
-                .when(!docked_right, |this| {
-                    this.right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
-                })
-                .top(px(0.))
-                .h_full()
-                .w(SIDEBAR_RESIZE_HANDLE_SIZE)
-                .cursor_col_resize()
-                .on_drag(DraggedSidebar, |dragged, _, _, cx| {
-                    cx.stop_propagation();
-                    cx.new(|_| dragged.clone())
-                })
-                .on_mouse_down(MouseButton::Left, |_, _, cx| {
-                    cx.stop_propagation();
-                })
-                .on_mouse_up(MouseButton::Left, move |event, _, cx| {
-                    if event.click_count == 2 {
-                        sidebar
-                            .update(cx, |sidebar, cx| {
-                                sidebar.set_width(None, cx);
-                            })
-                            .ok();
-                        cx.stop_propagation();
-                    }
-                })
-                .occlude(),
-        );
-
-        Some(
-            div()
-                .id("sidebar-container")
-                .relative()
-                .h_full()
-                .w(sidebar_width)
-                .flex_shrink_0()
-                .when(docked_right, |this| this.border_l_1())
-                .when(!docked_right, |this| this.border_r_1())
-                .border_color(cx.theme().colors().border)
-                .child(sidebar_view)
-                .child(resize_handle)
-                .into_any_element(),
-        )
-    }
-
     fn render_toolbar(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let agent_server_store = self.project.read(cx).agent_server_store().clone();
         let focus_handle = self.focus_handle(cx);
-        let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
 
         let (selected_agent_custom_icon, selected_agent_label) =
             if let AgentType::Custom { id, .. } = &self.selected_agent_type {
@@ -4144,12 +3932,6 @@ impl AgentPanel {
         let use_v2_empty_toolbar =
             has_v2_flag && is_empty_state && !is_in_history_or_config && !is_text_thread;
 
-        let is_sidebar_open = self
-            .sidebar
-            .as_ref()
-            .map(|s| s.read(cx).is_open())
-            .unwrap_or(false);
-
         let base_container = h_flex()
             .id("agent-panel-toolbar")
             .h(Tab::container_height(cx))
@@ -4213,11 +3995,8 @@ impl AgentPanel {
                 .child(
                     h_flex()
                         .size_full()
-                        .gap_1()
-                        .when(is_sidebar_open || docked_right, |this| this.pl_1())
-                        .when(!docked_right, |this| {
-                            this.children(self.render_sidebar_toggle(false, cx))
-                        })
+                        .gap(DynamicSpacing::Base04.rems(cx))
+                        .pl(DynamicSpacing::Base04.rems(cx))
                         .child(agent_selector_menu)
                         .child(self.render_start_thread_in_selector(cx)),
                 )
@@ -4235,10 +4014,7 @@ impl AgentPanel {
                                 cx,
                             ))
                         })
-                        .child(self.render_panel_options_menu(window, cx))
-                        .when(docked_right, |this| {
-                            this.children(self.render_sidebar_toggle(true, cx))
-                        }),
+                        .child(self.render_panel_options_menu(window, cx)),
                 )
                 .into_any_element()
         } else {
@@ -4265,16 +4041,8 @@ impl AgentPanel {
                 .child(
                     h_flex()
                         .size_full()
-                        .map(|this| {
-                            if is_sidebar_open || docked_right {
-                                this.pl_1().gap_1()
-                            } else {
-                                this.pl_0().gap_0p5()
-                            }
-                        })
-                        .when(!docked_right, |this| {
-                            this.children(self.render_sidebar_toggle(false, cx))
-                        })
+                        .gap(DynamicSpacing::Base04.rems(cx))
+                        .pl(DynamicSpacing::Base04.rems(cx))
                         .child(match &self.active_view {
                             ActiveView::History { .. } | ActiveView::Configuration => {
                                 self.render_toolbar_back_button(cx).into_any_element()
@@ -4298,10 +4066,7 @@ impl AgentPanel {
                                 cx,
                             ))
                         })
-                        .child(self.render_panel_options_menu(window, cx))
-                        .when(docked_right, |this| {
-                            this.children(self.render_sidebar_toggle(true, cx))
-                        }),
+                        .child(self.render_panel_options_menu(window, cx)),
                 )
                 .into_any_element()
         }
@@ -4848,44 +4613,14 @@ impl Render for AgentPanel {
             })
             .children(self.render_trial_end_upsell(window, cx));
 
-        let sidebar = self.render_sidebar(cx);
-        let has_sidebar = sidebar.is_some();
-        let docked_right = agent_panel_dock_position(cx) == DockPosition::Right;
-
-        let panel = h_flex()
-            .size_full()
-            .when(has_sidebar, |this| {
-                this.on_drag_move(cx.listener(
-                    move |this, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
-                        if let Some(sidebar) = &this.sidebar {
-                            let width = if docked_right {
-                                e.bounds.right() - e.event.position.x
-                            } else {
-                                e.event.position.x
-                            };
-                            sidebar.update(cx, |sidebar, cx| {
-                                sidebar.set_width(Some(width), cx);
-                            });
-                        }
-                    },
-                ))
-            })
-            .map(|this| {
-                if docked_right {
-                    this.child(content).children(sidebar)
-                } else {
-                    this.children(sidebar).child(content)
-                }
-            });
-
         match self.active_view.which_font_size_used() {
             WhichFontSize::AgentFont => {
                 WithRemSize::new(ThemeSettings::get_global(cx).agent_ui_font_size(cx))
                     .size_full()
-                    .child(panel)
+                    .child(content)
                     .into_any()
             }
-            _ => panel.into_any(),
+            _ => content.into_any(),
         }
     }
 }

crates/agent_ui/src/agent_ui.rs 🔗

@@ -23,7 +23,6 @@ mod mode_selector;
 mod model_selector;
 mod model_selector_popover;
 mod profile_selector;
-pub mod sidebar;
 mod slash_command;
 mod slash_command_picker;
 mod terminal_codegen;
@@ -34,8 +33,8 @@ mod text_thread_editor;
 mod text_thread_history;
 mod thread_history;
 mod thread_history_view;
-mod thread_metadata_store;
-mod threads_archive_view;
+pub mod thread_metadata_store;
+pub mod threads_archive_view;
 mod ui;
 
 use std::rc::Rc;

crates/agent_ui/src/conversation_view.rs 🔗

@@ -2130,7 +2130,7 @@ impl ConversationView {
         acp_thread.connection().clone().downcast()
     }
 
-    pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
+    pub fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
         let acp_thread = self.active_thread()?.read(cx).thread.read(cx);
         self.as_native_connection(cx)?
             .thread(acp_thread.session_id(), cx)
@@ -2342,7 +2342,7 @@ impl ConversationView {
         }
 
         if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
-            crate::agent_panel::sidebar_is_open(window, cx)
+            multi_workspace.read(cx).sidebar_open()
                 || self.agent_panel_visible(&multi_workspace, cx)
         } else {
             self.workspace

crates/agent_ui/src/conversation_view/thread_view.rs 🔗

@@ -532,7 +532,7 @@ impl ThreadView {
         acp_thread.connection().clone().downcast()
     }
 
-    pub(crate) fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
+    pub fn as_native_thread(&self, cx: &App) -> Option<Entity<agent::Thread>> {
         let acp_thread = self.thread.read(cx);
         self.as_native_connection(cx)?
             .thread(acp_thread.session_id(), cx)

crates/debugger_ui/src/tests/stack_frame_list.rs 🔗

@@ -1211,9 +1211,7 @@ async fn test_stack_frame_filter_persistence(
     cx.run_until_parked();
 
     let workspace_id = workspace
-        .update(cx, |workspace, _window, cx| {
-            workspace.active_workspace_database_id(cx)
-        })
+        .update(cx, |workspace, _window, cx| workspace.database_id(cx))
         .ok()
         .flatten()
         .expect("workspace id has to be some for this test to work properly");

crates/platform_title_bar/src/platform_title_bar.rs 🔗

@@ -31,6 +31,8 @@ pub struct PlatformTitleBar {
     children: SmallVec<[AnyElement; 2]>,
     should_move: bool,
     system_window_tabs: Entity<SystemWindowTabs>,
+    workspace_sidebar_open: bool,
+    sidebar_has_notifications: bool,
 }
 
 impl PlatformTitleBar {
@@ -44,6 +46,8 @@ impl PlatformTitleBar {
             children: SmallVec::new(),
             should_move: false,
             system_window_tabs,
+            workspace_sidebar_open: false,
+            sidebar_has_notifications: false,
         }
     }
 
@@ -70,6 +74,28 @@ impl PlatformTitleBar {
         SystemWindowTabs::init(cx);
     }
 
+    pub fn is_workspace_sidebar_open(&self) -> bool {
+        self.workspace_sidebar_open
+    }
+
+    pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
+        self.workspace_sidebar_open = open;
+        cx.notify();
+    }
+
+    pub fn sidebar_has_notifications(&self) -> bool {
+        self.sidebar_has_notifications
+    }
+
+    pub fn set_sidebar_has_notifications(
+        &mut self,
+        has_notifications: bool,
+        cx: &mut Context<Self>,
+    ) {
+        self.sidebar_has_notifications = has_notifications;
+        cx.notify();
+    }
+
     pub fn is_multi_workspace_enabled(cx: &App) -> bool {
         cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
     }
@@ -84,6 +110,9 @@ impl Render for PlatformTitleBar {
         let close_action = Box::new(workspace::CloseWindow);
         let children = mem::take(&mut self.children);
 
+        let is_multiworkspace_sidebar_open =
+            PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open();
+
         let title_bar = h_flex()
             .window_control_area(WindowControlArea::Drag)
             .w_full()
@@ -132,7 +161,9 @@ impl Render for PlatformTitleBar {
             .map(|this| {
                 if window.is_fullscreen() {
                     this.pl_2()
-                } else if self.platform_style == PlatformStyle::Mac {
+                } else if self.platform_style == PlatformStyle::Mac
+                    && !is_multiworkspace_sidebar_open
+                {
                     this.pl(px(TRAFFIC_LIGHT_PADDING))
                 } else {
                     this.pl_2()
@@ -144,9 +175,10 @@ impl Render for PlatformTitleBar {
                     .when(!(tiling.top || tiling.right), |el| {
                         el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING)
                     })
-                    .when(!(tiling.top || tiling.left), |el| {
-                        el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING)
-                    })
+                    .when(
+                        !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open,
+                        |el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING),
+                    )
                     // this border is to avoid a transparent gap in the rounded corners
                     .mt(px(-1.))
                     .mb(px(-1.))

crates/sidebar/Cargo.toml 🔗

@@ -0,0 +1,54 @@
+[package]
+name = "sidebar"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/sidebar.rs"
+
+[features]
+default = []
+
+[dependencies]
+acp_thread.workspace = true
+action_log.workspace = true
+agent.workspace = true
+agent-client-protocol.workspace = true
+agent_ui.workspace = true
+anyhow.workspace = true
+chrono.workspace = true
+editor.workspace = true
+feature_flags.workspace = true
+fs.workspace = true
+gpui.workspace = true
+menu.workspace = true
+project.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+util.workspace = true
+workspace.workspace = true
+zed_actions.workspace = true
+
+[dev-dependencies]
+acp_thread = { workspace = true, features = ["test-support"] }
+agent = { workspace = true, features = ["test-support"] }
+agent_ui = { workspace = true, features = ["test-support"] }
+assistant_text_thread = { workspace = true, features = ["test-support"] }
+editor.workspace = true
+language_model = { workspace = true, features = ["test-support"] }
+pretty_assertions.workspace = true
+prompt_store.workspace = true
+serde_json.workspace = true
+feature_flags.workspace = true
+fs = { workspace = true, features = ["test-support"] }
+git.workspace = true
+gpui = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+settings = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }

crates/agent_ui/src/sidebar.rs → crates/sidebar/src/sidebar.rs 🔗

@@ -1,13 +1,11 @@
-use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
-use crate::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent};
-use crate::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread};
 use acp_thread::ThreadStatus;
 use action_log::DiffStats;
 use agent::ThreadStore;
 use agent_client_protocol::{self as acp};
-use agent_settings::AgentSettings;
+use agent_ui::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
+use agent_ui::threads_archive_view::{ThreadsArchiveView, ThreadsArchiveViewEvent};
+use agent_ui::{Agent, AgentPanel, AgentPanelEvent, NewThread, RemoveSelectedThread};
 use chrono::Utc;
-use db::kvp::KEY_VALUE_STORE;
 use editor::Editor;
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagViewExt as _};
 use gpui::{
@@ -16,7 +14,7 @@ use gpui::{
 };
 use menu::{Cancel, Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
 use project::{AgentId, Event as ProjectEvent};
-use settings::Settings;
+
 use std::collections::{HashMap, HashSet};
 use std::mem;
 use std::path::Path;
@@ -29,8 +27,10 @@ use ui::{
 use util::ResultExt as _;
 use util::path_list::PathList;
 use workspace::{
-    MultiWorkspace, MultiWorkspaceEvent, ToggleWorkspaceSidebar, Workspace, multi_workspace_enabled,
+    MultiWorkspace, MultiWorkspaceEvent, Sidebar as WorkspaceSidebar, ToggleWorkspaceSidebar,
+    Workspace,
 };
+
 use zed_actions::editor::{MoveDown, MoveUp};
 
 actions!(
@@ -47,7 +47,6 @@ const DEFAULT_WIDTH: Pixels = px(320.0);
 const MIN_WIDTH: Pixels = px(200.0);
 const MAX_WIDTH: Pixels = px(800.0);
 const DEFAULT_THREADS_SHOWN: usize = 5;
-const SIDEBAR_STATE_KEY: &str = "sidebar_state";
 
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
 enum SidebarView {
@@ -56,26 +55,6 @@ enum SidebarView {
     Archive,
 }
 
-fn read_sidebar_open_state(multi_workspace_id: u64) -> bool {
-    KEY_VALUE_STORE
-        .scoped(SIDEBAR_STATE_KEY)
-        .read(&multi_workspace_id.to_string())
-        .log_err()
-        .flatten()
-        .and_then(|json| serde_json::from_str::<bool>(&json).ok())
-        .unwrap_or(false)
-}
-
-async fn save_sidebar_open_state(multi_workspace_id: u64, is_open: bool) {
-    if let Ok(json) = serde_json::to_string(&is_open) {
-        KEY_VALUE_STORE
-            .scoped(SIDEBAR_STATE_KEY)
-            .write(multi_workspace_id.to_string(), json)
-            .await
-            .log_err();
-    }
-}
-
 #[derive(Clone, Debug)]
 struct ActiveThreadInfo {
     session_id: acp::SessionId,
@@ -233,8 +212,6 @@ fn workspace_label_from_path_list(path_list: &PathList) -> SharedString {
 
 pub struct Sidebar {
     multi_workspace: WeakEntity<MultiWorkspace>,
-    persistence_key: Option<u64>,
-    is_open: bool,
     width: Pixels,
     focus_handle: FocusHandle,
     filter_editor: Entity<Editor>,
@@ -276,6 +253,7 @@ impl Sidebar {
             window,
             |this, _multi_workspace, event: &MultiWorkspaceEvent, window, cx| match event {
                 MultiWorkspaceEvent::ActiveWorkspaceChanged => {
+                    this.focused_thread = None;
                     this.update_entries(false, cx);
                 }
                 MultiWorkspaceEvent::WorkspaceAdded(workspace) => {
@@ -318,16 +296,9 @@ impl Sidebar {
             this.update_entries(false, cx);
         });
 
-        let persistence_key = multi_workspace.read(cx).database_id().map(|id| id.0);
-        let is_open = persistence_key
-            .map(read_sidebar_open_state)
-            .unwrap_or(false);
-
         Self {
             _update_entries_task: None,
             multi_workspace: multi_workspace.downgrade(),
-            persistence_key,
-            is_open,
             width: DEFAULT_WIDTH,
             focus_handle,
             filter_editor,
@@ -413,10 +384,25 @@ impl Sidebar {
         cx.subscribe_in(
             agent_panel,
             window,
-            |this, _agent_panel, event: &AgentPanelEvent, _window, cx| match event {
-                AgentPanelEvent::ActiveViewChanged
-                | AgentPanelEvent::ThreadFocused
-                | AgentPanelEvent::BackgroundThreadChanged => {
+            |this, agent_panel, event: &AgentPanelEvent, _window, cx| match event {
+                AgentPanelEvent::ActiveViewChanged => {
+                    this.focused_thread = agent_panel
+                        .read(cx)
+                        .active_conversation()
+                        .and_then(|cv| cv.read(cx).parent_id(cx));
+                    this.update_entries(false, cx);
+                }
+                AgentPanelEvent::ThreadFocused => {
+                    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);
                 }
             },
@@ -483,12 +469,6 @@ impl Sidebar {
         let workspaces = mw.workspaces().to_vec();
         let active_workspace = mw.workspaces().get(mw.active_workspace_index()).cloned();
 
-        self.focused_thread = active_workspace
-            .as_ref()
-            .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
-            .and_then(|panel| panel.read(cx).active_conversation().cloned())
-            .and_then(|cv| cv.read(cx).parent_id(cx));
-
         let mut threads_by_paths: HashMap<PathList, Vec<ThreadMetadata>> = HashMap::new();
         for row in thread_entries {
             threads_by_paths
@@ -918,7 +898,7 @@ impl Sidebar {
         let Some(multi_workspace) = self.multi_workspace.upgrade() else {
             return;
         };
-        if !multi_workspace_enabled(cx) {
+        if !multi_workspace.read(cx).multi_workspace_enabled(cx) {
             return;
         }
 
@@ -983,8 +963,6 @@ impl Sidebar {
         let is_group_header_after_first =
             ix > 0 && matches!(entry, ListEntry::ProjectHeader { .. });
 
-        let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
-
         let rendered = match entry {
             ListEntry::ProjectHeader {
                 path_list,
@@ -1001,12 +979,9 @@ impl Sidebar {
                 highlight_positions,
                 *has_threads,
                 is_selected,
-                docked_right,
                 cx,
             ),
-            ListEntry::Thread(thread) => {
-                self.render_thread(ix, thread, is_selected, docked_right, cx)
-            }
+            ListEntry::Thread(thread) => self.render_thread(ix, thread, is_selected, cx),
             ListEntry::ViewMore {
                 path_list,
                 remaining_count,
@@ -1047,7 +1022,6 @@ impl Sidebar {
         highlight_positions: &[usize],
         has_threads: bool,
         is_selected: bool,
-        docked_right: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
         let id_prefix = if is_sticky { "sticky-" } else { "" };
@@ -1063,7 +1037,6 @@ impl Sidebar {
         };
         let workspace_for_new_thread = workspace.clone();
         let workspace_for_remove = workspace.clone();
-        // let workspace_for_activate = workspace.clone();
 
         let path_list_for_toggle = path_list.clone();
         let path_list_for_collapse = path_list.clone();
@@ -1094,7 +1067,6 @@ impl Sidebar {
             .group_name(group_name)
             .toggle_state(is_active_workspace)
             .focused(is_selected)
-            .docked_right(docked_right)
             .child(
                 h_flex()
                     .relative()
@@ -1179,7 +1151,6 @@ impl Sidebar {
 
     fn render_sticky_header(
         &self,
-        docked_right: bool,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Option<AnyElement> {
@@ -1223,7 +1194,6 @@ impl Sidebar {
             &highlight_positions,
             *has_threads,
             is_selected,
-            docked_right,
             cx,
         );
 
@@ -1265,6 +1235,8 @@ impl Sidebar {
             return;
         };
 
+        self.focused_thread = None;
+
         multi_workspace.update(cx, |multi_workspace, cx| {
             multi_workspace.activate(workspace.clone(), cx);
         });
@@ -1693,7 +1665,6 @@ impl Sidebar {
         ix: usize,
         thread: &ThreadEntry,
         is_focused: bool,
-        docked_right: bool,
         cx: &mut Context<Self>,
     ) -> AnyElement {
         let has_notification = self
@@ -1763,7 +1734,6 @@ impl Sidebar {
             })
             .selected(is_selected)
             .focused(is_focused)
-            .docked_right(docked_right)
             .hovered(is_hovered)
             .on_hover(cx.listener(move |this, is_hovered: &bool, _window, cx| {
                 if *is_hovered {
@@ -1938,92 +1908,75 @@ impl Sidebar {
 
     fn render_thread_list_header(
         &self,
-        docked_right: bool,
+        window: &Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
         let has_query = self.has_filter_query(cx);
+        let needs_traffic_light_padding = cfg!(target_os = "macos") && !window.is_fullscreen();
 
-        h_flex()
-            .h(Tab::container_height(cx))
+        v_flex()
             .flex_none()
-            .gap_1p5()
-            .border_b_1()
-            .border_color(cx.theme().colors().border)
-            .when(!docked_right, |this| {
-                this.child(self.render_sidebar_toggle_button(false, cx))
-            })
-            .child(self.render_filter_input())
             .child(
                 h_flex()
-                    .gap_0p5()
-                    .when(!docked_right, |this| this.pr_1p5())
-                    .when(has_query, |this| {
-                        this.child(
-                            IconButton::new("clear_filter", IconName::Close)
-                                .shape(IconButtonShape::Square)
-                                .tooltip(Tooltip::text("Clear Search"))
-                                .on_click(cx.listener(|this, _, window, cx| {
-                                    this.reset_filter_editor_text(window, cx);
-                                    this.update_entries(false, cx);
-                                })),
-                        )
+                    .h(Tab::container_height(cx) - px(1.))
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border)
+                    .when(needs_traffic_light_padding, |this| {
+                        this.pl(px(ui::utils::TRAFFIC_LIGHT_PADDING))
                     })
+                    .child(self.render_sidebar_toggle_button(cx)),
+            )
+            .child(
+                h_flex()
+                    .h(Tab::container_height(cx))
+                    .gap_1p5()
+                    .px_1p5()
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border)
+                    .child(self.render_filter_input())
                     .child(
-                        IconButton::new("archive", IconName::Archive)
-                            .icon_size(IconSize::Small)
-                            .tooltip(Tooltip::text("Archive"))
-                            .on_click(cx.listener(|this, _, window, cx| {
-                                this.show_archive(window, cx);
-                            })),
+                        h_flex()
+                            .gap_0p5()
+                            .when(has_query, |this| {
+                                this.child(
+                                    IconButton::new("clear_filter", IconName::Close)
+                                        .shape(IconButtonShape::Square)
+                                        .tooltip(Tooltip::text("Clear Search"))
+                                        .on_click(cx.listener(|this, _, window, cx| {
+                                            this.reset_filter_editor_text(window, cx);
+                                            this.update_entries(false, cx);
+                                        })),
+                                )
+                            })
+                            .child(
+                                IconButton::new("archive", IconName::Archive)
+                                    .icon_size(IconSize::Small)
+                                    .tooltip(Tooltip::text("Archive"))
+                                    .on_click(cx.listener(|this, _, window, cx| {
+                                        this.show_archive(window, cx);
+                                    })),
+                            ),
                     ),
             )
-            .when(docked_right, |this| {
-                this.pl_2()
-                    .pr_0p5()
-                    .child(self.render_sidebar_toggle_button(true, cx))
-            })
     }
 
-    fn render_sidebar_toggle_button(
-        &self,
-        docked_right: bool,
-        cx: &mut Context<Self>,
-    ) -> impl IntoElement {
-        let icon = if docked_right {
-            IconName::ThreadsSidebarRightOpen
-        } else {
-            IconName::ThreadsSidebarLeftOpen
-        };
+    fn render_sidebar_toggle_button(&self, _cx: &mut Context<Self>) -> impl IntoElement {
+        let icon = IconName::ThreadsSidebarLeftOpen;
 
-        h_flex()
-            .h_full()
-            .px_1()
-            .map(|this| {
-                if docked_right {
-                    this.pr_1p5().border_l_1()
-                } else {
-                    this.border_r_1()
-                }
-            })
-            .border_color(cx.theme().colors().border_variant)
-            .child(
-                IconButton::new("sidebar-close-toggle", icon)
-                    .icon_size(IconSize::Small)
-                    .tooltip(move |_, cx| {
-                        Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx)
-                    })
-                    .on_click(|_, window, cx| {
-                        window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
-                    }),
-            )
+        h_flex().h_full().child(
+            IconButton::new("sidebar-close-toggle", icon)
+                .icon_size(IconSize::Small)
+                .tooltip(move |_, cx| {
+                    Tooltip::for_action("Close Threads Sidebar", &ToggleWorkspaceSidebar, cx)
+                })
+                .on_click(|_, window, cx| {
+                    window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+                }),
+        )
     }
 }
 
 impl Sidebar {
-    pub fn is_open(&self) -> bool {
-        self.is_open
-    }
-
     fn show_archive(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let Some(active_workspace) = self.multi_workspace.upgrade().and_then(|w| {
             w.read(cx)
@@ -2088,61 +2041,19 @@ impl Sidebar {
         window.focus(&self.focus_handle, cx);
         cx.notify();
     }
+}
 
-    pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
-        if self.is_open == open {
-            return;
-        }
-        self.is_open = open;
-        cx.notify();
-        if let Some(key) = self.persistence_key {
-            let is_open = self.is_open;
-            cx.background_spawn(async move {
-                save_sidebar_open_state(key, is_open).await;
-            })
-            .detach();
-        }
-    }
-
-    pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let new_state = !self.is_open;
-        self.set_open(new_state, cx);
-        if new_state {
-            cx.focus_self(window);
-        }
-    }
-
-    pub fn focus_or_unfocus(
-        &mut self,
-        workspace: &mut Workspace,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        if self.is_open {
-            let sidebar_is_focused = self.focus_handle(cx).contains_focused(window, cx);
-            if sidebar_is_focused {
-                let active_pane = workspace.active_pane().clone();
-                let pane_focus = active_pane.read(cx).focus_handle(cx);
-                window.focus(&pane_focus, cx);
-            } else {
-                cx.focus_self(window);
-            }
-        } else {
-            self.set_open(true, cx);
-            cx.focus_self(window);
-        }
-    }
-
-    pub fn width(&self, _cx: &App) -> Pixels {
+impl WorkspaceSidebar for Sidebar {
+    fn width(&self, _cx: &App) -> Pixels {
         self.width
     }
 
-    pub fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
+    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>) {
         self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH);
         cx.notify();
     }
 
-    pub fn has_notifications(&self, _cx: &App) -> bool {
+    fn has_notifications(&self, _cx: &App) -> bool {
         !self.contents.notified_threads.is_empty()
     }
 }
@@ -2155,9 +2066,9 @@ impl Focusable for Sidebar {
 
 impl Render for Sidebar {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let _titlebar_height = ui::utils::platform_title_bar_height(window);
         let ui_font = theme::setup_ui_font(window, cx);
-        let docked_right = AgentSettings::get_global(cx).dock == settings::DockPosition::Right;
-        let sticky_header = self.render_sticky_header(docked_right, window, cx);
+        let sticky_header = self.render_sticky_header(window, cx);
 
         v_flex()
             .id("workspace-sidebar")
@@ -2175,11 +2086,14 @@ impl Render for Sidebar {
             .on_action(cx.listener(Self::cancel))
             .on_action(cx.listener(Self::remove_selected_thread))
             .font(ui_font)
-            .size_full()
+            .h_full()
+            .w(self.width)
             .bg(cx.theme().colors().surface_background)
+            .border_r_1()
+            .border_color(cx.theme().colors().border)
             .map(|this| match self.view {
                 SidebarView::ThreadList => this
-                    .child(self.render_thread_list_header(docked_right, cx))
+                    .child(self.render_thread_list_header(window, cx))
                     .child(
                         v_flex()
                             .relative()
@@ -2210,21 +2124,25 @@ impl Render for Sidebar {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::test_support::{active_session_id, open_thread_with_connection, send_message};
     use acp_thread::StubAgentConnection;
     use agent::ThreadStore;
+    use agent_ui::test_support::{active_session_id, open_thread_with_connection, send_message};
     use assistant_text_thread::TextThreadStore;
     use chrono::DateTime;
     use feature_flags::FeatureFlagAppExt as _;
     use fs::FakeFs;
     use gpui::TestAppContext;
     use pretty_assertions::assert_eq;
+    use settings::SettingsStore;
     use std::{path::PathBuf, sync::Arc};
     use util::path_list::PathList;
 
     fn init_test(cx: &mut TestAppContext) {
-        crate::test_support::init_test(cx);
         cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            theme::init(theme::LoadThemes::JustBase, cx);
+            editor::init(cx);
             cx.update_flags(false, vec!["agent-v2".into()]);
             ThreadStore::init_global(cx);
             ThreadMetadataStore::init_global(cx);
@@ -2249,33 +2167,14 @@ mod tests {
         multi_workspace: &Entity<MultiWorkspace>,
         cx: &mut gpui::VisualTestContext,
     ) -> Entity<Sidebar> {
-        let (sidebar, _panel) = setup_sidebar_with_agent_panel(multi_workspace, cx);
-        sidebar
-    }
-
-    fn setup_sidebar_with_agent_panel(
-        multi_workspace: &Entity<MultiWorkspace>,
-        cx: &mut gpui::VisualTestContext,
-    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
-        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
-        let project = workspace.read_with(cx, |ws, _cx| ws.project().clone());
-        let panel = add_agent_panel(&workspace, &project, cx);
-        workspace.update_in(cx, |workspace, window, cx| {
-            workspace.right_dock().update(cx, |dock, cx| {
-                if let Some(panel_ix) = dock.panel_index_for_type::<AgentPanel>() {
-                    dock.activate_panel(panel_ix, window, cx);
-                }
-                dock.set_open(true, window, cx);
-            });
+        let multi_workspace = multi_workspace.clone();
+        let sidebar =
+            cx.update(|window, cx| cx.new(|cx| Sidebar::new(multi_workspace.clone(), window, cx)));
+        multi_workspace.update(cx, |mw, _cx| {
+            mw.register_sidebar(sidebar.clone());
         });
         cx.run_until_parked();
-        let sidebar = panel.read_with(cx, |panel, _cx| {
-            panel
-                .sidebar
-                .clone()
-                .expect("AgentPanel should have created a sidebar")
-        });
-        (sidebar, panel)
+        sidebar
     }
 
     async fn save_n_test_threads(
@@ -2350,9 +2249,16 @@ mod tests {
     }
 
     fn open_and_focus_sidebar(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) {
+        let multi_workspace = sidebar.read_with(cx, |s, _| s.multi_workspace.upgrade());
+        if let Some(multi_workspace) = multi_workspace {
+            multi_workspace.update_in(cx, |mw, window, cx| {
+                if !mw.sidebar_open() {
+                    mw.toggle_sidebar(window, cx);
+                }
+            });
+        }
         cx.run_until_parked();
-        sidebar.update_in(cx, |sidebar, window, cx| {
-            sidebar.set_open(true, cx);
+        sidebar.update_in(cx, |_, window, cx| {
             cx.focus_self(window);
         });
         cx.run_until_parked();
@@ -3009,9 +2915,6 @@ mod tests {
         });
         cx.run_until_parked();
 
-        // Add an agent panel to workspace 1 so the sidebar renders when it's active.
-        setup_sidebar_with_agent_panel(&multi_workspace, cx);
-
         let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
         save_n_test_threads(1, &path_list, cx).await;
         multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
@@ -3244,6 +3147,26 @@ mod tests {
         );
     }
 
+    async fn init_test_project_with_agent_panel(
+        worktree_path: &str,
+        cx: &mut TestAppContext,
+    ) -> Entity<project::Project> {
+        agent_ui::test_support::init_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(false, vec!["agent-v2".into()]);
+            ThreadStore::init_global(cx);
+            ThreadMetadataStore::init_global(cx);
+            language_model::LanguageModelRegistry::test(cx);
+            prompt_store::init(cx);
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(worktree_path, serde_json::json!({ "src": {} }))
+            .await;
+        cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
+        project::Project::test(fs, [worktree_path.as_ref()], cx).await
+    }
+
     fn add_agent_panel(
         workspace: &Entity<Workspace>,
         project: &Entity<project::Project>,
@@ -3257,12 +3180,23 @@ mod tests {
         })
     }
 
+    fn setup_sidebar_with_agent_panel(
+        multi_workspace: &Entity<MultiWorkspace>,
+        project: &Entity<project::Project>,
+        cx: &mut gpui::VisualTestContext,
+    ) -> (Entity<Sidebar>, Entity<AgentPanel>) {
+        let sidebar = setup_sidebar(multi_workspace, cx);
+        let workspace = multi_workspace.read_with(cx, |mw, _cx| mw.workspace().clone());
+        let panel = add_agent_panel(&workspace, project, cx);
+        (sidebar, panel)
+    }
+
     #[gpui::test]
     async fn test_parallel_threads_shown_with_live_status(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
+        let project = init_test_project_with_agent_panel("/my-project", cx).await;
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
-        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
 
         let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
 
@@ -3305,10 +3239,10 @@ mod tests {
 
     #[gpui::test]
     async fn test_background_thread_completion_triggers_notification(cx: &mut TestAppContext) {
-        let project_a = init_test_project("/project-a", cx).await;
+        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));
-        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
 
         let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
 
@@ -4002,10 +3936,10 @@ mod tests {
 
     #[gpui::test]
     async fn test_thread_title_update_propagates_to_sidebar(cx: &mut TestAppContext) {
-        let project = init_test_project("/my-project", cx).await;
+        let project = init_test_project_with_agent_panel("/my-project", cx).await;
         let (multi_workspace, cx) =
             cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
-        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+        let (sidebar, panel) = setup_sidebar_with_agent_panel(&multi_workspace, &project, cx);
 
         let path_list = PathList::new(&[std::path::PathBuf::from("/my-project")]);
 
@@ -4050,10 +3984,10 @@ mod tests {
 
     #[gpui::test]
     async fn test_focused_thread_tracks_user_intent(cx: &mut TestAppContext) {
-        let project_a = init_test_project("/project-a", cx).await;
+        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));
-        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, cx);
+        let (sidebar, panel_a) = setup_sidebar_with_agent_panel(&multi_workspace, &project_a, cx);
 
         let path_list_a = PathList::new(&[std::path::PathBuf::from("/project-a")]);
 
@@ -4082,8 +4016,7 @@ mod tests {
         let workspace_a = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].clone());
 
         // ── 1. Initial state: no focused thread ──────────────────────────────
-        // Workspace B is active (just added) and has no thread, so its header
-        // is the active entry.
+        // Workspace B is active (just added), so its header is the active entry.
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
                 sidebar.focused_thread, None,
@@ -4098,7 +4031,6 @@ mod tests {
             );
         });
 
-        // ── 2. Click thread in workspace A via sidebar ───────────────────────
         sidebar.update_in(cx, |sidebar, window, cx| {
             sidebar.activate_thread(
                 Agent::NativeAgent,
@@ -4143,7 +4075,6 @@ mod tests {
             );
         });
 
-        // ── 3. Open thread in workspace B, then click it via sidebar ─────────
         let connection_b = StubAgentConnection::new();
         connection_b.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
             acp::ContentChunk::new("Thread B".into()),
@@ -4155,16 +4086,6 @@ mod tests {
         save_test_thread_metadata(&session_id_b, path_list_b.clone(), cx).await;
         cx.run_until_parked();
 
-        // Opening a thread in a non-active workspace should NOT change
-        // focused_thread — it's derived from the active workspace.
-        sidebar.read_with(cx, |sidebar, _cx| {
-            assert_eq!(
-                sidebar.focused_thread.as_ref(),
-                Some(&session_id_a),
-                "Opening a thread in a non-active workspace should not affect focused_thread"
-            );
-        });
-
         // Workspace A is currently active. Click a thread in workspace B,
         // which also triggers a workspace switch.
         sidebar.update_in(cx, |sidebar, window, cx| {
@@ -4200,30 +4121,25 @@ mod tests {
             );
         });
 
-        // ── 4. Switch workspace → focused_thread reflects new workspace ──────
         multi_workspace.update_in(cx, |mw, window, cx| {
             mw.activate_next_workspace(window, cx);
         });
         cx.run_until_parked();
 
-        // Workspace A is now active. Its agent panel still has session_id_a
-        // loaded, so focused_thread should reflect that.
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
-                sidebar.focused_thread.as_ref(),
-                Some(&session_id_a),
-                "Switching workspaces should derive focused_thread from the new active workspace"
+                sidebar.focused_thread, None,
+                "External workspace switch should clear 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 workspace_a's active thread"
+                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
+                "Active entry should be the workspace header after external switch"
             );
         });
 
-        // ── 5. Opening a thread in a non-active workspace is ignored ──────────
         let connection_b2 = StubAgentConnection::new();
         connection_b2.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
             acp::ContentChunk::new("New thread".into()),
@@ -4234,48 +4150,69 @@ mod tests {
         save_test_thread_metadata(&session_id_b2, path_list_b.clone(), cx).await;
         cx.run_until_parked();
 
-        // Workspace A is still active, so focused_thread stays on session_id_a.
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
                 sidebar.focused_thread.as_ref(),
-                Some(&session_id_a),
-                "Opening a thread in a non-active workspace should not affect focused_thread"
+                Some(&session_id_b2),
+                "Opening a thread externally should set focused_thread"
             );
         });
 
-        // ── 6. Activating workspace B shows its active thread ────────────────
-        sidebar.update_in(cx, |sidebar, window, cx| {
-            sidebar.activate_workspace(&workspace_b, window, cx);
+        workspace_b.update_in(cx, |workspace, window, cx| {
+            workspace.focus_handle(cx).focus(window, cx);
         });
         cx.run_until_parked();
 
-        // Workspace B is now active with session_id_b2 loaded.
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
                 sidebar.focused_thread.as_ref(),
                 Some(&session_id_b2),
-                "Activating workspace_b should show workspace_b's active thread"
+                "Defocusing the sidebar should not clear focused_thread"
+            );
+        });
+
+        sidebar.update_in(cx, |sidebar, window, cx| {
+            sidebar.activate_workspace(&workspace_b, window, cx);
+        });
+        cx.run_until_parked();
+
+        sidebar.read_with(cx, |sidebar, _cx| {
+            assert_eq!(
+                sidebar.focused_thread, None,
+                "Clicking a workspace header should clear 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 workspace_b's active thread"
+                matches!(active_entry, Some(ListEntry::ProjectHeader { .. })),
+                "Active entry should be the workspace header"
             );
         });
 
-        // ── 7. Switching back to workspace A reflects its thread ─────────────
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.activate_next_workspace(window, cx);
+        // ── 8. Focusing the agent panel thread restores focused_thread ────
+        // Workspace B still has session_id_b2 loaded in the agent panel.
+        // Clicking into the thread (simulated by focusing its view) should
+        // set focused_thread via the ThreadFocused event.
+        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);
+            }
         });
         cx.run_until_parked();
 
         sidebar.read_with(cx, |sidebar, _cx| {
             assert_eq!(
                 sidebar.focused_thread.as_ref(),
-                Some(&session_id_a),
-                "Switching back to workspace_a should show its active thread"
+                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"
             );
         });
     }

crates/title_bar/Cargo.toml 🔗

@@ -38,6 +38,7 @@ chrono.workspace = true
 client.workspace = true
 cloud_api_types.workspace = true
 db.workspace = true
+feature_flags.workspace = true
 git_ui.workspace = true
 gpui = { workspace = true, features = ["screen-capture"] }
 notifications.workspace = true

crates/title_bar/src/title_bar.rs 🔗

@@ -24,13 +24,16 @@ use auto_update::AutoUpdateStatus;
 use call::ActiveCall;
 use client::{Client, UserStore, zed_urls};
 use cloud_api_types::Plan;
+use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
 use gpui::{
     Action, AnyElement, App, Context, Corner, Element, Empty, Entity, Focusable,
     InteractiveElement, IntoElement, MouseButton, ParentElement, Render,
     StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div,
 };
 use onboarding_banner::OnboardingBanner;
-use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees};
+use project::{
+    DisableAiSettings, Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees,
+};
 use remote::RemoteConnectionOptions;
 use settings::Settings;
 use settings::WorktreeId;
@@ -44,7 +47,8 @@ use ui::{
 use update_version::UpdateVersion;
 use util::ResultExt;
 use workspace::{
-    MultiWorkspace, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt,
+    MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace,
+    notifications::NotifyResultExt,
 };
 use zed_actions::OpenRemote;
 
@@ -170,6 +174,7 @@ impl Render for TitleBar {
                     let mut render_project_items = title_bar_settings.show_branch_name
                         || title_bar_settings.show_project_items;
                     title_bar
+                        .children(self.render_workspace_sidebar_toggle(window, cx))
                         .when_some(
                             self.application_menu.clone().filter(|_| !show_menus),
                             |title_bar, menu| {
@@ -352,6 +357,7 @@ impl TitleBar {
 
         // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar.
         {
+            let platform_titlebar = platform_titlebar.clone();
             let window_handle = window.window_handle();
             cx.spawn(async move |this: WeakEntity<TitleBar>, cx| {
                 let Some(multi_workspace_handle) = window_handle.downcast::<MultiWorkspace>()
@@ -364,8 +370,26 @@ impl TitleBar {
                         return;
                     };
 
+                    let is_open = multi_workspace.read(cx).sidebar_open();
+                    let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx);
+                    platform_titlebar.update(cx, |titlebar, cx| {
+                        titlebar.set_workspace_sidebar_open(is_open, cx);
+                        titlebar.set_sidebar_has_notifications(has_notifications, cx);
+                    });
+
+                    let platform_titlebar = platform_titlebar.clone();
+                    let subscription = cx.observe(&multi_workspace, move |mw, cx| {
+                        let is_open = mw.read(cx).sidebar_open();
+                        let has_notifications = mw.read(cx).sidebar_has_notifications(cx);
+                        platform_titlebar.update(cx, |titlebar, cx| {
+                            titlebar.set_workspace_sidebar_open(is_open, cx);
+                            titlebar.set_sidebar_has_notifications(has_notifications, cx);
+                        });
+                    });
+
                     if let Some(this) = this.upgrade() {
                         this.update(cx, |this, _| {
+                            this._subscriptions.push(subscription);
                             this.multi_workspace = Some(multi_workspace.downgrade());
                         });
                     }
@@ -663,6 +687,44 @@ impl TitleBar {
         )
     }
 
+    fn render_workspace_sidebar_toggle(
+        &self,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<AnyElement> {
+        if !cx.has_flag::<AgentV2FeatureFlag>() || DisableAiSettings::get_global(cx).disable_ai {
+            return None;
+        }
+
+        let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open();
+
+        if is_sidebar_open {
+            return None;
+        }
+
+        let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications();
+
+        Some(
+            IconButton::new(
+                "toggle-workspace-sidebar",
+                IconName::ThreadsSidebarLeftClosed,
+            )
+            .icon_size(IconSize::Small)
+            .when(has_notifications, |button| {
+                button
+                    .indicator(Indicator::dot().color(Color::Accent))
+                    .indicator_border_color(Some(cx.theme().colors().title_bar_background))
+            })
+            .tooltip(move |_, cx| {
+                Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
+            })
+            .on_click(|_, window, cx| {
+                window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+            })
+            .into_any_element(),
+        )
+    }
+
     pub fn render_project_name(&self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let workspace = self.workspace.clone();
 

crates/workspace/src/multi_workspace.rs 🔗

@@ -1,8 +1,9 @@
 use anyhow::Result;
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
 use gpui::{
-    App, Context, Entity, EntityId, EventEmitter, Focusable, ManagedView, Pixels, Render,
-    Subscription, Task, Tiling, Window, WindowId, actions, px,
+    AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
+    ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, WindowId,
+    actions, deferred, px,
 };
 use project::{DisableAiSettings, Project};
 use settings::Settings;
@@ -11,12 +12,11 @@ use std::path::PathBuf;
 use ui::prelude::*;
 use util::ResultExt;
 
-pub const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
+const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
 
 use crate::{
     CloseIntent, CloseWindow, DockPosition, Event as WorkspaceEvent, Item, ModalView, Panel, Toast,
     Workspace, WorkspaceId, client_side_decorations, notifications::NotificationId,
-    persistence::model::MultiWorkspaceId,
 };
 
 actions!(
@@ -41,6 +41,22 @@ pub enum MultiWorkspaceEvent {
     WorkspaceRemoved(EntityId),
 }
 
+pub trait Sidebar: Focusable + Render + Sized {
+    fn width(&self, cx: &App) -> Pixels;
+    fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
+    fn has_notifications(&self, cx: &App) -> bool;
+}
+
+pub trait SidebarHandle: 'static + Send + Sync {
+    fn width(&self, cx: &App) -> Pixels;
+    fn set_width(&self, width: Option<Pixels>, cx: &mut App);
+    fn focus_handle(&self, cx: &App) -> FocusHandle;
+    fn focus(&self, window: &mut Window, cx: &mut App);
+    fn has_notifications(&self, cx: &App) -> bool;
+    fn to_any(&self) -> AnyView;
+    fn entity_id(&self) -> EntityId;
+}
+
 #[derive(Clone)]
 pub struct DraggedSidebar;
 
@@ -50,11 +66,43 @@ impl Render for DraggedSidebar {
     }
 }
 
+impl<T: Sidebar> SidebarHandle for Entity<T> {
+    fn width(&self, cx: &App) -> Pixels {
+        self.read(cx).width(cx)
+    }
+
+    fn set_width(&self, width: Option<Pixels>, cx: &mut App) {
+        self.update(cx, |this, cx| this.set_width(width, cx))
+    }
+
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.read(cx).focus_handle(cx)
+    }
+
+    fn focus(&self, window: &mut Window, cx: &mut App) {
+        let handle = self.read(cx).focus_handle(cx);
+        window.focus(&handle, cx);
+    }
+
+    fn has_notifications(&self, cx: &App) -> bool {
+        self.read(cx).has_notifications(cx)
+    }
+
+    fn to_any(&self) -> AnyView {
+        self.clone().into()
+    }
+
+    fn entity_id(&self) -> EntityId {
+        Entity::entity_id(self)
+    }
+}
+
 pub struct MultiWorkspace {
     window_id: WindowId,
     workspaces: Vec<Entity<Workspace>>,
-    database_id: Option<MultiWorkspaceId>,
     active_workspace_index: usize,
+    sidebar: Option<Box<dyn SidebarHandle>>,
+    sidebar_open: bool,
     pending_removal_tasks: Vec<Task<()>>,
     _serialize_task: Option<Task<()>>,
     _create_task: Option<Task<()>>,
@@ -63,10 +111,6 @@ pub struct MultiWorkspace {
 
 impl EventEmitter<MultiWorkspaceEvent> for MultiWorkspace {}
 
-pub fn multi_workspace_enabled(cx: &App) -> bool {
-    cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
-}
-
 impl MultiWorkspace {
     pub fn new(workspace: Entity<Workspace>, window: &mut Window, cx: &mut Context<Self>) -> Self {
         let release_subscription = cx.on_release(|this: &mut MultiWorkspace, _cx| {
@@ -81,19 +125,118 @@ impl MultiWorkspace {
             }
         });
         let quit_subscription = cx.on_app_quit(Self::app_will_quit);
+        let settings_subscription =
+            cx.observe_global_in::<settings::SettingsStore>(window, |this, window, cx| {
+                if DisableAiSettings::get_global(cx).disable_ai && this.sidebar_open {
+                    this.close_sidebar(window, cx);
+                }
+            });
         Self::subscribe_to_workspace(&workspace, cx);
         Self {
             window_id: window.window_handle().window_id(),
-            database_id: None,
             workspaces: vec![workspace],
             active_workspace_index: 0,
+            sidebar: None,
+            sidebar_open: false,
             pending_removal_tasks: Vec::new(),
             _serialize_task: None,
             _create_task: None,
-            _subscriptions: vec![release_subscription, quit_subscription],
+            _subscriptions: vec![
+                release_subscription,
+                quit_subscription,
+                settings_subscription,
+            ],
         }
     }
 
+    pub fn register_sidebar<T: Sidebar>(&mut self, sidebar: Entity<T>) {
+        self.sidebar = Some(Box::new(sidebar));
+    }
+
+    pub fn sidebar(&self) -> Option<&dyn SidebarHandle> {
+        self.sidebar.as_deref()
+    }
+
+    pub fn sidebar_open(&self) -> bool {
+        self.sidebar_open
+    }
+
+    pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
+        self.sidebar
+            .as_ref()
+            .map_or(false, |s| s.has_notifications(cx))
+    }
+
+    pub fn multi_workspace_enabled(&self, cx: &App) -> bool {
+        cx.has_flag::<AgentV2FeatureFlag>() && !DisableAiSettings::get_global(cx).disable_ai
+    }
+
+    pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if !self.multi_workspace_enabled(cx) {
+            return;
+        }
+
+        if self.sidebar_open {
+            self.close_sidebar(window, cx);
+        } else {
+            self.open_sidebar(cx);
+            if let Some(sidebar) = &self.sidebar {
+                sidebar.focus(window, cx);
+            }
+        }
+    }
+
+    pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if !self.multi_workspace_enabled(cx) {
+            return;
+        }
+
+        if self.sidebar_open {
+            let sidebar_is_focused = self
+                .sidebar
+                .as_ref()
+                .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx));
+
+            if sidebar_is_focused {
+                let pane = self.workspace().read(cx).active_pane().clone();
+                let pane_focus = pane.read(cx).focus_handle(cx);
+                window.focus(&pane_focus, cx);
+            } else if let Some(sidebar) = &self.sidebar {
+                sidebar.focus(window, cx);
+            }
+        } else {
+            self.open_sidebar(cx);
+            if let Some(sidebar) = &self.sidebar {
+                sidebar.focus(window, cx);
+            }
+        }
+    }
+
+    pub fn open_sidebar(&mut self, cx: &mut Context<Self>) {
+        self.sidebar_open = true;
+        for workspace in &self.workspaces {
+            workspace.update(cx, |workspace, cx| {
+                workspace.set_workspace_sidebar_open(true, cx);
+            });
+        }
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.sidebar_open = false;
+        for workspace in &self.workspaces {
+            workspace.update(cx, |workspace, cx| {
+                workspace.set_workspace_sidebar_open(false, cx);
+            });
+        }
+        let pane = self.workspace().read(cx).active_pane().clone();
+        let pane_focus = pane.read(cx).focus_handle(cx);
+        window.focus(&pane_focus, cx);
+        self.serialize(cx);
+        cx.notify();
+    }
+
     pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context<Self>) {
         cx.spawn_in(window, async move |this, cx| {
             let workspaces = this.update(cx, |multi_workspace, _cx| {
@@ -142,7 +285,7 @@ impl MultiWorkspace {
     }
 
     pub fn activate(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
-        if !multi_workspace_enabled(cx) {
+        if !self.multi_workspace_enabled(cx) {
             self.workspaces[0] = workspace;
             self.active_workspace_index = 0;
             cx.emit(MultiWorkspaceEvent::ActiveWorkspaceChanged);
@@ -178,6 +321,11 @@ impl MultiWorkspace {
         if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
             index
         } else {
+            if self.sidebar_open {
+                workspace.update(cx, |workspace, cx| {
+                    workspace.set_workspace_sidebar_open(true, cx);
+                });
+            }
             Self::subscribe_to_workspace(&workspace, cx);
             self.workspaces.push(workspace.clone());
             cx.emit(MultiWorkspaceEvent::WorkspaceAdded(workspace));
@@ -186,14 +334,6 @@ impl MultiWorkspace {
         }
     }
 
-    pub fn database_id(&self) -> Option<MultiWorkspaceId> {
-        self.database_id
-    }
-
-    pub fn set_database_id(&mut self, id: Option<MultiWorkspaceId>) {
-        self.database_id = id;
-    }
-
     pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
         debug_assert!(
             index < self.workspaces.len(),
@@ -231,6 +371,7 @@ impl MultiWorkspace {
         let window_id = self.window_id;
         let state = crate::persistence::model::MultiWorkspaceState {
             active_workspace_id: self.workspace().read(cx).database_id(),
+            sidebar_open: self.sidebar_open,
         };
         self._serialize_task = Some(cx.background_spawn(async move {
             crate::persistence::write_multi_workspace_state(window_id, state).await;
@@ -349,7 +490,7 @@ impl MultiWorkspace {
         self.workspace().read(cx).items_of_type::<T>(cx)
     }
 
-    pub fn active_workspace_database_id(&self, cx: &App) -> Option<WorkspaceId> {
+    pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
         self.workspace().read(cx).database_id()
     }
 
@@ -392,7 +533,7 @@ impl MultiWorkspace {
     }
 
     pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if !multi_workspace_enabled(cx) {
+        if !self.multi_workspace_enabled(cx) {
             return;
         }
         let app_state = self.workspace().read(cx).app_state().clone();
@@ -501,7 +642,7 @@ impl MultiWorkspace {
     ) -> Task<Result<Entity<Workspace>>> {
         let workspace = self.workspace().clone();
 
-        if multi_workspace_enabled(cx) {
+        if self.multi_workspace_enabled(cx) {
             workspace.update(cx, |workspace, cx| {
                 workspace.open_workspace_for_paths(true, paths, window, cx)
             })
@@ -528,6 +669,57 @@ impl MultiWorkspace {
 
 impl Render for MultiWorkspace {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let multi_workspace_enabled = self.multi_workspace_enabled(cx);
+
+        let sidebar: Option<AnyElement> = if multi_workspace_enabled && self.sidebar_open() {
+            self.sidebar.as_ref().map(|sidebar_handle| {
+                let weak = cx.weak_entity();
+
+                let sidebar_width = sidebar_handle.width(cx);
+                let resize_handle = deferred(
+                    div()
+                        .id("sidebar-resize-handle")
+                        .absolute()
+                        .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.)
+                        .top(px(0.))
+                        .h_full()
+                        .w(SIDEBAR_RESIZE_HANDLE_SIZE)
+                        .cursor_col_resize()
+                        .on_drag(DraggedSidebar, |dragged, _, _, cx| {
+                            cx.stop_propagation();
+                            cx.new(|_| dragged.clone())
+                        })
+                        .on_mouse_down(MouseButton::Left, |_, _, cx| {
+                            cx.stop_propagation();
+                        })
+                        .on_mouse_up(MouseButton::Left, move |event, _, cx| {
+                            if event.click_count == 2 {
+                                weak.update(cx, |this, cx| {
+                                    if let Some(sidebar) = this.sidebar.as_mut() {
+                                        sidebar.set_width(None, cx);
+                                    }
+                                })
+                                .ok();
+                                cx.stop_propagation();
+                            }
+                        })
+                        .occlude(),
+                );
+
+                div()
+                    .id("sidebar-container")
+                    .relative()
+                    .h_full()
+                    .w(sidebar_width)
+                    .flex_shrink_0()
+                    .child(sidebar_handle.to_any())
+                    .child(resize_handle)
+                    .into_any_element()
+            })
+        } else {
+            None
+        };
+
         let ui_font = theme::setup_ui_font(window, cx);
         let text_color = cx.theme().colors().text;
 
@@ -557,6 +749,32 @@ impl Render for MultiWorkspace {
                         this.activate_previous_workspace(window, cx);
                     },
                 ))
+                .when(self.multi_workspace_enabled(cx), |this| {
+                    this.on_action(cx.listener(
+                        |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| {
+                            this.toggle_sidebar(window, cx);
+                        },
+                    ))
+                    .on_action(cx.listener(
+                        |this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| {
+                            this.focus_sidebar(window, cx);
+                        },
+                    ))
+                })
+                .when(
+                    self.sidebar_open() && self.multi_workspace_enabled(cx),
+                    |this| {
+                        this.on_drag_move(cx.listener(
+                            |this: &mut Self, e: &DragMoveEvent<DraggedSidebar>, _window, cx| {
+                                if let Some(sidebar) = &this.sidebar {
+                                    let new_width = e.event.position.x;
+                                    sidebar.set_width(Some(new_width), cx);
+                                }
+                            },
+                        ))
+                        .children(sidebar)
+                    },
+                )
                 .child(
                     div()
                         .flex()
@@ -569,9 +787,98 @@ impl Render for MultiWorkspace {
             window,
             cx,
             Tiling {
-                left: false,
+                left: multi_workspace_enabled && self.sidebar_open(),
                 ..Tiling::default()
             },
         )
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use fs::FakeFs;
+    use gpui::TestAppContext;
+    use settings::SettingsStore;
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+            theme::init(theme::LoadThemes::JustBase, cx);
+            DisableAiSettings::register(cx);
+            cx.update_flags(false, vec!["agent-v2".into()]);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, [], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+        multi_workspace.read_with(cx, |mw, cx| {
+            assert!(mw.multi_workspace_enabled(cx));
+        });
+
+        multi_workspace.update_in(cx, |mw, _window, cx| {
+            mw.open_sidebar(cx);
+            assert!(mw.sidebar_open());
+        });
+
+        cx.update(|_window, cx| {
+            DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
+        });
+        cx.run_until_parked();
+
+        multi_workspace.read_with(cx, |mw, cx| {
+            assert!(
+                !mw.sidebar_open(),
+                "Sidebar should be closed when disable_ai is true"
+            );
+            assert!(
+                !mw.multi_workspace_enabled(cx),
+                "Multi-workspace should be disabled when disable_ai is true"
+            );
+        });
+
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.toggle_sidebar(window, cx);
+        });
+        multi_workspace.read_with(cx, |mw, _cx| {
+            assert!(
+                !mw.sidebar_open(),
+                "Sidebar should remain closed when toggled with disable_ai true"
+            );
+        });
+
+        cx.update(|_window, cx| {
+            DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
+        });
+        cx.run_until_parked();
+
+        multi_workspace.read_with(cx, |mw, cx| {
+            assert!(
+                mw.multi_workspace_enabled(cx),
+                "Multi-workspace should be enabled after re-enabling AI"
+            );
+            assert!(
+                !mw.sidebar_open(),
+                "Sidebar should still be closed after re-enabling AI (not auto-opened)"
+            );
+        });
+
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.toggle_sidebar(window, cx);
+        });
+        multi_workspace.read_with(cx, |mw, _cx| {
+            assert!(
+                mw.sidebar_open(),
+                "Sidebar should open when toggled after re-enabling AI"
+            );
+        });
+    }
+}

crates/workspace/src/persistence.rs 🔗

@@ -341,7 +341,6 @@ pub fn read_serialized_multi_workspaces(
                 .map(read_multi_workspace_state)
                 .unwrap_or_default();
             model::SerializedMultiWorkspace {
-                id: window_id.map(|id| model::MultiWorkspaceId(id.as_u64())),
                 workspaces: group,
                 state,
             }
@@ -3886,6 +3885,7 @@ mod tests {
             window_10,
             MultiWorkspaceState {
                 active_workspace_id: Some(WorkspaceId(2)),
+                sidebar_open: true,
             },
         )
         .await;
@@ -3894,6 +3894,7 @@ mod tests {
             window_20,
             MultiWorkspaceState {
                 active_workspace_id: Some(WorkspaceId(3)),
+                sidebar_open: false,
             },
         )
         .await;
@@ -3931,20 +3932,23 @@ mod tests {
         // Should produce 3 groups: window 10, window 20, and the orphan.
         assert_eq!(results.len(), 3);
 
-        // Window 10 group: 2 workspaces, active_workspace_id = 2.
+        // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open.
         let group_10 = &results[0];
         assert_eq!(group_10.workspaces.len(), 2);
         assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2)));
+        assert_eq!(group_10.state.sidebar_open, true);
 
-        // Window 20 group: 1 workspace, active_workspace_id = 3.
+        // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed.
         let group_20 = &results[1];
         assert_eq!(group_20.workspaces.len(), 1);
         assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3)));
+        assert_eq!(group_20.state.sidebar_open, false);
 
         // Orphan group: no window_id, so state is default.
         let group_none = &results[2];
         assert_eq!(group_none.workspaces.len(), 1);
         assert_eq!(group_none.state.active_workspace_id, None);
+        assert_eq!(group_none.state.sidebar_open, false);
     }
 
     #[gpui::test]

crates/workspace/src/persistence/model.rs 🔗

@@ -63,19 +63,18 @@ pub struct SessionWorkspace {
 #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
 pub struct MultiWorkspaceState {
     pub active_workspace_id: Option<WorkspaceId>,
+    pub sidebar_open: bool,
 }
 
-/// The serialized state of a single MultiWorkspace window from a previous session.
+/// The serialized state of a single MultiWorkspace window from a previous session:
+/// all workspaces that shared the window, which one was active, and whether the
+/// sidebar was open.
 #[derive(Debug, Clone)]
 pub struct SerializedMultiWorkspace {
-    pub id: Option<MultiWorkspaceId>,
     pub workspaces: Vec<SessionWorkspace>,
     pub state: MultiWorkspaceState,
 }
 
-#[derive(Debug, Clone, Copy)]
-pub struct MultiWorkspaceId(pub u64);
-
 #[derive(Debug, PartialEq, Clone)]
 pub(crate) struct SerializedWorkspace {
     pub(crate) id: WorkspaceId,

crates/workspace/src/status_bar.rs 🔗

@@ -34,6 +34,7 @@ pub struct StatusBar {
     right_items: Vec<Box<dyn StatusItemViewHandle>>,
     active_pane: Entity<Pane>,
     _observe_active_pane: Subscription,
+    workspace_sidebar_open: bool,
 }
 
 impl Render for StatusBar {
@@ -51,9 +52,10 @@ impl Render for StatusBar {
                     .when(!(tiling.bottom || tiling.right), |el| {
                         el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING)
                     })
-                    .when(!(tiling.bottom || tiling.left), |el| {
-                        el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING)
-                    })
+                    .when(
+                        !(tiling.bottom || tiling.left) && !self.workspace_sidebar_open,
+                        |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING),
+                    )
                     // This border is to avoid a transparent gap in the rounded corners
                     .mb(px(-1.))
                     .border_b(px(1.0))
@@ -91,11 +93,17 @@ impl StatusBar {
             _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| {
                 this.update_active_pane_item(window, cx)
             }),
+            workspace_sidebar_open: false,
         };
         this.update_active_pane_item(window, cx);
         this
     }
 
+    pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context<Self>) {
+        self.workspace_sidebar_open = open;
+        cx.notify();
+    }
+
     pub fn add_left_item<T>(&mut self, item: Entity<T>, window: &mut Window, cx: &mut Context<Self>)
     where
         T: 'static + StatusItemView,

crates/workspace/src/workspace.rs 🔗

@@ -28,8 +28,8 @@ pub use crate::notifications::NotificationFrame;
 pub use dock::Panel;
 pub use multi_workspace::{
     DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, MultiWorkspaceEvent,
-    NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow,
-    SIDEBAR_RESIZE_HANDLE_SIZE, ToggleWorkspaceSidebar, multi_workspace_enabled,
+    NewWorkspaceInWindow, NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarHandle,
+    ToggleWorkspaceSidebar,
 };
 pub use path_list::{PathList, SerializedPathList};
 pub use toast_layer::{ToastAction, ToastLayer, ToastView};
@@ -80,8 +80,8 @@ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace};
 pub use persistence::{
     DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items,
     model::{
-        DockStructure, ItemId, MultiWorkspaceId, SerializedMultiWorkspace,
-        SerializedWorkspaceLocation, SessionWorkspace,
+        DockStructure, ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation,
+        SessionWorkspace,
     },
     read_serialized_multi_workspaces,
 };
@@ -2153,6 +2153,12 @@ impl Workspace {
         &self.status_bar
     }
 
+    pub fn set_workspace_sidebar_open(&self, open: bool, cx: &mut App) {
+        self.status_bar.update(cx, |status_bar, cx| {
+            status_bar.set_workspace_sidebar_open(open, cx);
+        });
+    }
+
     pub fn status_bar_visible(&self, cx: &App) -> bool {
         StatusBarSettings::get_global(cx).show
     }
@@ -8206,11 +8212,7 @@ pub async fn restore_multiworkspace(
     app_state: Arc<AppState>,
     cx: &mut AsyncApp,
 ) -> anyhow::Result<MultiWorkspaceRestoreResult> {
-    let SerializedMultiWorkspace {
-        workspaces,
-        state,
-        id: window_id,
-    } = multi_workspace;
+    let SerializedMultiWorkspace { workspaces, state } = multi_workspace;
     let mut group_iter = workspaces.into_iter();
     let first = group_iter
         .next()
@@ -8274,7 +8276,6 @@ pub async fn restore_multiworkspace(
     if let Some(target_id) = state.active_workspace_id {
         window_handle
             .update(cx, |multi_workspace, window, cx| {
-                multi_workspace.set_database_id(window_id);
                 let target_index = multi_workspace
                     .workspaces()
                     .iter()
@@ -8296,6 +8297,14 @@ pub async fn restore_multiworkspace(
             .ok();
     }
 
+    if state.sidebar_open {
+        window_handle
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.open_sidebar(cx);
+            })
+            .ok();
+    }
+
     window_handle
         .update(cx, |_, window, _cx| {
             window.activate_window();

crates/zed/Cargo.toml 🔗

@@ -182,6 +182,7 @@ settings.workspace = true
 settings_profile_selector.workspace = true
 settings_ui.workspace = true
 shellexpand.workspace = true
+sidebar.workspace = true
 smol.workspace = true
 snippet_provider.workspace = true
 snippets_ui.workspace = true

crates/zed/src/visual_test_runner.rs 🔗

@@ -103,8 +103,8 @@ use {
     feature_flags::FeatureFlagAppExt as _,
     git_ui::project_diff::ProjectDiff,
     gpui::{
-        Action as _, App, AppContext as _, Bounds, KeyBinding, Modifiers, VisualTestAppContext,
-        WindowBounds, WindowHandle, WindowOptions, point, px, size,
+        App, AppContext as _, Bounds, KeyBinding, Modifiers, VisualTestAppContext, WindowBounds,
+        WindowHandle, WindowOptions, point, px, size,
     },
     image::RgbaImage,
     project::AgentId,
@@ -2650,6 +2650,22 @@ fn run_multi_workspace_sidebar_visual_tests(
 
     cx.run_until_parked();
 
+    // Create the sidebar and register it on the MultiWorkspace
+    let sidebar = multi_workspace_window
+        .update(cx, |_multi_workspace, window, cx| {
+            let multi_workspace_handle = cx.entity();
+            cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx))
+        })
+        .context("Failed to create sidebar")?;
+
+    multi_workspace_window
+        .update(cx, |multi_workspace, _window, _cx| {
+            multi_workspace.register_sidebar(sidebar.clone());
+        })
+        .context("Failed to register sidebar")?;
+
+    cx.run_until_parked();
+
     // Save test threads to the ThreadStore for each workspace
     let save_tasks = multi_workspace_window
         .update(cx, |multi_workspace, _window, cx| {
@@ -2727,8 +2743,8 @@ fn run_multi_workspace_sidebar_visual_tests(
 
     // Open the sidebar
     multi_workspace_window
-        .update(cx, |_multi_workspace, window, cx| {
-            window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx);
+        .update(cx, |multi_workspace, window, cx| {
+            multi_workspace.toggle_sidebar(window, cx);
         })
         .context("Failed to toggle sidebar")?;
 
@@ -3166,10 +3182,24 @@ edition = "2021"
 
     cx.run_until_parked();
 
+    // Create and register the workspace sidebar
+    let sidebar = workspace_window
+        .update(cx, |_multi_workspace, window, cx| {
+            let multi_workspace_handle = cx.entity();
+            cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx))
+        })
+        .context("Failed to create sidebar")?;
+
+    workspace_window
+        .update(cx, |multi_workspace, _window, _cx| {
+            multi_workspace.register_sidebar(sidebar.clone());
+        })
+        .context("Failed to register sidebar")?;
+
     // Open the sidebar
     workspace_window
-        .update(cx, |_multi_workspace, window, cx| {
-            window.dispatch_action(workspace::ToggleWorkspaceSidebar.boxed_clone(), cx);
+        .update(cx, |multi_workspace, window, cx| {
+            multi_workspace.toggle_sidebar(window, cx);
         })
         .context("Failed to toggle sidebar")?;
 

crates/zed/src/zed.rs 🔗

@@ -68,6 +68,7 @@ use settings::{
     initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content,
     update_settings_file,
 };
+use sidebar::Sidebar;
 use std::time::Duration;
 use std::{
     borrow::Cow,
@@ -388,6 +389,20 @@ pub fn initialize_workspace(
                 })
                 .unwrap_or(true)
         });
+
+        let window_handle = window.window_handle();
+        let multi_workspace_handle = cx.entity();
+        cx.defer(move |cx| {
+            window_handle
+                .update(cx, |_, window, cx| {
+                    let sidebar =
+                        cx.new(|cx| Sidebar::new(multi_workspace_handle.clone(), window, cx));
+                    multi_workspace_handle.update(cx, |multi_workspace, _cx| {
+                        multi_workspace.register_sidebar(sidebar);
+                    });
+                })
+                .ok();
+        });
     })
     .detach();