New multi workspace (#47795)

Mikayla Maki , Claude Opus 4.5 , Danilo Leal , Anthony Eid , Danilo Leal , Richard Feldman , and Zed Zippy created

It's happeningggggg

Release Notes:

- Changed the Agent Panel so that the Active Thread is restored on
restart.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Anthony Eid <anthony@zed.dev>
Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Co-authored-by: Richard Feldman <richard@zed.dev>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>

Change summary

Cargo.lock                                                            |   31 
Cargo.toml                                                            |    3 
assets/icons/workspace_nav_closed.svg                                 |    5 
assets/icons/workspace_nav_open.svg                                   |    5 
assets/keymaps/default-linux.json                                     |    8 
assets/keymaps/default-macos.json                                     |    8 
assets/keymaps/default-windows.json                                   |    8 
crates/agent_ui/src/acp.rs                                            |    2 
crates/agent_ui/src/acp/message_editor.rs                             |   19 
crates/agent_ui/src/acp/thread_view.rs                                |  198 
crates/agent_ui/src/agent_diff.rs                                     |    8 
crates/agent_ui/src/agent_panel.rs                                    |  309 
crates/agent_ui/src/agent_ui.rs                                       |    8 
crates/agent_ui/src/inline_prompt_editor.rs                           |    9 
crates/agent_ui/src/mention_set.rs                                    |   17 
crates/agent_ui/src/ui/agent_notification.rs                          |   18 
crates/collab/tests/integration/channel_guest_tests.rs                |   24 
crates/collab/tests/integration/editor_tests.rs                       |   64 
crates/collab/tests/integration/following_tests.rs                    |   12 
crates/collab/tests/integration/git_tests.rs                          |   32 
crates/collab/tests/integration/remote_editing_collaboration_tests.rs |   24 
crates/collab/tests/integration/test_server.rs                        |   54 
crates/collab_ui/src/collab_panel.rs                                  |   27 
crates/db/src/kvp.rs                                                  |  127 
crates/debugger_ui/src/tests.rs                                       |   69 
crates/debugger_ui/src/tests/attach_modal.rs                          |   54 
crates/debugger_ui/src/tests/new_process_modal.rs                     |   32 
crates/debugger_ui/src/tests/stack_frame_list.rs                      |    6 
crates/dev_container/Cargo.toml                                       |    5 
crates/dev_container/src/devcontainer_api.rs                          |  566 
crates/dev_container/src/lib.rs                                       |   81 
crates/editor/src/editor.rs                                           |   22 
crates/editor/src/element.rs                                          |   21 
crates/feature_flags/src/flags.rs                                     |    4 
crates/file_finder/src/file_finder.rs                                 |    7 
crates/file_finder/src/file_finder_tests.rs                           |   14 
crates/git_ui/src/commit_view.rs                                      |    2 
crates/git_ui/src/file_diff_view.rs                                   |    9 
crates/git_ui/src/git_panel.rs                                        |    3 
crates/git_ui/src/project_diff.rs                                     |    3 
crates/git_ui/src/worktree_picker.rs                                  |   50 
crates/icons/src/icons.rs                                             |    2 
crates/inspector_ui/Cargo.toml                                        |    1 
crates/inspector_ui/src/inspector.rs                                  |    5 
crates/journal/src/journal.rs                                         |   25 
crates/keymap_editor/src/keymap_editor.rs                             |    2 
crates/miniprofiler_ui/src/miniprofiler_ui.rs                         |   27 
crates/onboarding/src/onboarding.rs                                   |   11 
crates/outline/src/outline.rs                                         |    5 
crates/platform_title_bar/Cargo.toml                                  |    1 
crates/platform_title_bar/src/platform_title_bar.rs                   |   65 
crates/platform_title_bar/src/platforms.rs                            |    1 
crates/platform_title_bar/src/platforms/platform_mac.rs               |   10 
crates/project_panel/src/project_panel.rs                             |   23 
crates/recent_projects/Cargo.toml                                     |    2 
crates/recent_projects/src/disconnected_overlay.rs                    |    6 
crates/recent_projects/src/recent_projects.rs                         |  210 
crates/recent_projects/src/remote_connections.rs                      |  177 
crates/recent_projects/src/remote_servers.rs                          |  119 
crates/recent_projects/src/wsl_picker.rs                              |    4 
crates/rules_library/src/rules_library.rs                             |   14 
crates/session/src/session.rs                                         |   14 
crates/settings_profile_selector/src/settings_profile_selector.rs     |    9 
crates/settings_ui/src/settings_ui.rs                                 |  283 
crates/sidebar/Cargo.toml                                             |   43 
crates/sidebar/LICENSE-GPL                                            |    1 
crates/sidebar/src/sidebar.rs                                         | 1304 
crates/title_bar/Cargo.toml                                           |    1 
crates/title_bar/src/project_dropdown.rs                              |   64 
crates/title_bar/src/title_bar.rs                                     |   96 
crates/ui/src/components/thread_item.rs                               |   56 
crates/ui/src/utils.rs                                                |    2 
crates/ui/src/utils/constants.rs                                      |   27 
crates/vim/src/state.rs                                               |   18 
crates/workspace/Cargo.toml                                           |    1 
crates/workspace/src/history_manager.rs                               |   11 
crates/workspace/src/multi_workspace.rs                               |  513 
crates/workspace/src/notifications.rs                                 |   77 
crates/workspace/src/pane.rs                                          |    7 
crates/workspace/src/persistence.rs                                   |  457 
crates/workspace/src/persistence/model.rs                             |   28 
crates/workspace/src/welcome.rs                                       |   25 
crates/workspace/src/workspace.rs                                     |  719 
crates/zed/Cargo.toml                                                 |    2 
crates/zed/src/main.rs                                                |  294 
crates/zed/src/visual_test_runner.rs                                  |  319 
crates/zed/src/zed.rs                                                 | 1092 
crates/zed/src/zed/migrate.rs                                         |   19 
crates/zed/src/zed/open_listener.rs                                   |  108 
89 files changed, 6,468 insertions(+), 1,800 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4942,6 +4942,7 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
 name = "dev_container"
 version = "0.1.0"
 dependencies = [
+ "fs",
  "futures 0.3.31",
  "gpui",
  "http 1.3.1",
@@ -4951,10 +4952,12 @@ dependencies = [
  "node_runtime",
  "paths",
  "picker",
+ "project",
  "serde",
  "serde_json",
  "settings",
  "smol",
+ "theme",
  "ui",
  "util",
  "workspace",
@@ -8492,7 +8495,6 @@ dependencies = [
  "fuzzy",
  "gpui",
  "language",
- "platform_title_bar",
  "project",
  "serde_json",
  "serde_json_lenient",
@@ -12380,6 +12382,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
 name = "platform_title_bar"
 version = "0.1.0"
 dependencies = [
+ "feature_flags",
  "gpui",
  "settings",
  "smallvec",
@@ -15339,6 +15342,30 @@ 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",
+ "agent_ui",
+ "db",
+ "editor",
+ "feature_flags",
+ "fs",
+ "fuzzy",
+ "gpui",
+ "picker",
+ "project",
+ "recent_projects",
+ "serde_json",
+ "settings",
+ "theme",
+ "ui",
+ "ui_input",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "signal-hook"
 version = "0.3.18"
@@ -17218,6 +17245,7 @@ dependencies = [
  "cloud_api_types",
  "collections",
  "db",
+ "feature_flags",
  "git_ui",
  "gpui",
  "http_client",
@@ -21103,6 +21131,7 @@ dependencies = [
  "settings_profile_selector",
  "settings_ui",
  "shellexpand 2.1.2",
+ "sidebar",
  "smol",
  "snippet_provider",
  "snippets_ui",

Cargo.toml 🔗

@@ -155,6 +155,7 @@ members = [
     "crates/schema_generator",
     "crates/search",
     "crates/session",
+    "crates/sidebar",
     "crates/settings",
     "crates/settings_content",
     "crates/settings_json",
@@ -395,6 +396,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" }
@@ -853,6 +855,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 }

assets/icons/workspace_nav_closed.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.2" width="7" height="12" rx="2" transform="matrix(-1 0 0 1 9 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="2" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/icons/workspace_nav_open.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="7" height="12" rx="2" transform="matrix(-1 0 0 1 9 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="2" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>

assets/keymaps/default-linux.json 🔗

@@ -594,6 +594,7 @@
       "ctrl-alt-b": "workspace::ToggleRightDock",
       "ctrl-b": "workspace::ToggleLeftDock",
       "ctrl-j": "workspace::ToggleBottomDock",
+      "ctrl-alt-j": "multi_workspace::ToggleWorkspaceSidebar",
       "ctrl-alt-y": "workspace::ToggleAllDocks",
       "ctrl-alt-0": "workspace::ResetActiveDockSize",
       // For 0px parameter, uses UI font size value.
@@ -653,6 +654,13 @@
       "ctrl-w": "workspace::CloseActiveDock",
     },
   },
+  {
+    "context": "WorkspaceSidebar",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-n": "multi_workspace::NewWorkspaceInWindow",
+    },
+  },
   {
     "context": "Workspace && debugger_running",
     "bindings": {

assets/keymaps/default-macos.json 🔗

@@ -655,6 +655,7 @@
       "cmd-alt-b": "workspace::ToggleRightDock",
       "cmd-r": "workspace::ToggleRightDock",
       "cmd-j": "workspace::ToggleBottomDock",
+      "cmd-alt-j": "multi_workspace::ToggleWorkspaceSidebar",
       "alt-cmd-y": "workspace::ToggleAllDocks",
       // For 0px parameter, uses UI font size value.
       "ctrl-alt-0": "workspace::ResetActiveDockSize",
@@ -714,6 +715,13 @@
       // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }],
     },
   },
+  {
+    "context": "WorkspaceSidebar",
+    "use_key_equivalents": true,
+    "bindings": {
+      "cmd-n": "multi_workspace::NewWorkspaceInWindow",
+    },
+  },
   {
     "context": "Workspace && debugger_running",
     "use_key_equivalents": true,

assets/keymaps/default-windows.json 🔗

@@ -589,6 +589,7 @@
       "ctrl-alt-b": "workspace::ToggleRightDock",
       "ctrl-b": "workspace::ToggleLeftDock",
       "ctrl-j": "workspace::ToggleBottomDock",
+      "ctrl-alt-j": "multi_workspace::ToggleWorkspaceSidebar",
       "ctrl-shift-y": "workspace::ToggleAllDocks",
       "alt-r": "workspace::ResetActiveDockSize",
       // For 0px parameter, uses UI font size value.
@@ -657,6 +658,13 @@
       "f5": "debugger::Continue",
     },
   },
+  {
+    "context": "WorkspaceSidebar",
+    "use_key_equivalents": true,
+    "bindings": {
+      "ctrl-n": "multi_workspace::NewWorkspaceInWindow",
+    },
+  },
   {
     "context": "ApplicationMenu",
     "use_key_equivalents": true,

crates/agent_ui/src/acp.rs 🔗

@@ -5,7 +5,7 @@ mod mode_selector;
 mod model_selector;
 mod model_selector_popover;
 mod thread_history;
-mod thread_view;
+pub(crate) mod thread_view;
 
 pub use mode_selector::ModeSelector;
 pub use model_selector::AcpModelSelector;

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -815,8 +815,13 @@ impl MessageEditor {
         }
 
         if self.prompt_capabilities.borrow().image
-            && let Some(task) =
-                paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx)
+            && let Some(task) = paste_images_as_context(
+                self.editor.clone(),
+                self.mention_set.clone(),
+                self.workspace.clone(),
+                window,
+                cx,
+            )
         {
             task.detach();
             return;
@@ -1084,6 +1089,7 @@ impl MessageEditor {
 
         let editor = self.editor.clone();
         let mention_set = self.mention_set.clone();
+        let workspace = self.workspace.clone();
 
         let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
             files: true,
@@ -1134,7 +1140,14 @@ impl MessageEditor {
                     images.push(gpui::Image::from_bytes(format, content));
                 }
 
-                crate::mention_set::insert_images_as_context(images, editor, mention_set, cx).await;
+                crate::mention_set::insert_images_as_context(
+                    images,
+                    editor,
+                    mention_set,
+                    workspace,
+                    cx,
+                )
+                .await;
                 Ok(())
             })
             .detach_and_log_err(cx);

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

@@ -57,7 +57,9 @@ use ui::{
 };
 use util::defer;
 use util::{ResultExt, size::format_file_size, time::duration_alt_display};
-use workspace::{CollaboratorId, NewTerminal, Toast, Workspace, notifications::NotificationId};
+use workspace::{
+    CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
+};
 use zed_actions::agent::{Chat, ToggleModelSelector};
 use zed_actions::assistant::OpenRulesLibrary;
 
@@ -1985,9 +1987,30 @@ impl AcpServerView {
         self.show_notification(caption, icon, window, cx);
     }
 
+    fn agent_is_visible(&self, window: &Window, cx: &App) -> bool {
+        if window.is_window_active() {
+            let workspace_is_foreground = window
+                .root::<MultiWorkspace>()
+                .flatten()
+                .and_then(|mw| {
+                    let mw = mw.read(cx);
+                    self.workspace.upgrade().map(|ws| mw.workspace() == &ws)
+                })
+                .unwrap_or(true);
+
+            if workspace_is_foreground {
+                if let Some(workspace) = self.workspace.upgrade() {
+                    return AgentPanel::is_visible(&workspace, cx);
+                }
+            }
+        }
+
+        false
+    }
+
     fn play_notification_sound(&self, window: &Window, cx: &mut App) {
         let settings = AgentSettings::get_global(cx);
-        if settings.play_sound_when_agent_done && !window.is_window_active() {
+        if settings.play_sound_when_agent_done && !self.agent_is_visible(window, cx) {
             Audio::play_sound(Sound::AgentDone, cx);
         }
     }
@@ -2005,14 +2028,7 @@ impl AcpServerView {
 
         let settings = AgentSettings::get_global(cx);
 
-        let window_is_inactive = !window.is_window_active();
-        let panel_is_hidden = self
-            .workspace
-            .upgrade()
-            .map(|workspace| AgentPanel::is_hidden(&workspace, cx))
-            .unwrap_or(true);
-
-        let should_notify = window_is_inactive || panel_is_hidden;
+        let should_notify = !self.agent_is_visible(window, cx);
 
         if !should_notify {
             return;
@@ -2075,19 +2091,22 @@ impl AcpServerView {
                 .push(cx.subscribe_in(&pop_up, window, {
                     |this, _, event, window, cx| match event {
                         AgentNotificationEvent::Accepted => {
-                            let handle = window.window_handle();
+                            let Some(handle) = window.window_handle().downcast::<MultiWorkspace>()
+                            else {
+                                log::error!("root view should be a MultiWorkspace");
+                                return;
+                            };
                             cx.activate(true);
 
                             let workspace_handle = this.workspace.clone();
 
-                            // If there are multiple Zed windows, activate the correct one.
                             cx.defer(move |cx| {
                                 handle
-                                    .update(cx, |_view, window, _cx| {
+                                    .update(cx, |multi_workspace, window, cx| {
                                         window.activate_window();
-
                                         if let Some(workspace) = workspace_handle.upgrade() {
-                                            workspace.update(_cx, |workspace, cx| {
+                                            multi_workspace.activate(workspace.clone(), cx);
+                                            workspace.update(cx, |workspace, cx| {
                                                 workspace.focus_panel::<AgentPanel>(window, cx);
                                             });
                                         }
@@ -2112,12 +2131,12 @@ impl AcpServerView {
                 .push({
                     let pop_up_weak = pop_up.downgrade();
 
-                    cx.observe_window_activation(window, move |_, window, cx| {
-                        if window.is_window_active()
+                    cx.observe_window_activation(window, move |this, window, cx| {
+                        if this.agent_is_visible(window, cx)
                             && let Some(pop_up) = pop_up_weak.upgrade()
                         {
-                            pop_up.update(cx, |_, cx| {
-                                cx.emit(AgentNotificationEvent::Dismissed);
+                            pop_up.update(cx, |notification, cx| {
+                                notification.dismiss(cx);
                             });
                         }
                     })
@@ -2368,6 +2387,7 @@ pub(crate) mod tests {
     use action_log::ActionLog;
     use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext};
     use agent_client_protocol::SessionId;
+    use assistant_text_thread::TextThreadStore;
     use editor::MultiBufferOffset;
     use fs::FakeFs;
     use gpui::{EventEmitter, TestAppContext, VisualTestContext};
@@ -2377,7 +2397,7 @@ pub(crate) mod tests {
     use std::any::Any;
     use std::path::Path;
     use std::rc::Rc;
-    use workspace::Item;
+    use workspace::{Item, MultiWorkspace};
 
     use super::*;
 
@@ -2677,6 +2697,138 @@ pub(crate) mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_notification_when_workspace_is_background_in_multi_workspace(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        // Enable multi-workspace feature flag and init globals needed by AgentPanel
+        let fs = FakeFs::new(cx.executor());
+
+        cx.update(|cx| {
+            cx.update_flags(true, vec!["agent-v2".to_string()]);
+            agent::ThreadStore::init_global(cx);
+            language_model::LanguageModelRegistry::test(cx);
+            <dyn Fs>::set_global(fs.clone(), cx);
+        });
+
+        let project1 = Project::test(fs.clone(), [], cx).await;
+
+        // Create a MultiWorkspace window with one workspace
+        let multi_workspace_handle =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx));
+
+        // Get workspace 1 (the initial workspace)
+        let workspace1 = multi_workspace_handle
+            .read_with(cx, |mw, _cx| mw.workspace().clone())
+            .unwrap();
+
+        let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx);
+
+        workspace1.update_in(cx, |workspace, window, cx| {
+            let text_thread_store =
+                cx.new(|cx| TextThreadStore::fake(workspace.project().clone(), cx));
+            let panel =
+                cx.new(|cx| crate::AgentPanel::new(workspace, text_thread_store, None, window, cx));
+            workspace.add_panel(panel, window, cx);
+
+            // Open the dock and activate the agent panel so it's visible
+            workspace.focus_panel::<crate::AgentPanel>(window, cx);
+        });
+
+        cx.run_until_parked();
+
+        cx.read(|cx| {
+            assert!(
+                crate::AgentPanel::is_visible(&workspace1, cx),
+                "AgentPanel should be visible in workspace1's dock"
+            );
+        });
+
+        // Set up thread view in workspace 1
+        let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
+        let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx)));
+
+        let agent = StubAgentServer::default_response();
+        let thread_view = cx.update(|window, cx| {
+            cx.new(|cx| {
+                AcpServerView::new(
+                    Rc::new(agent),
+                    None,
+                    None,
+                    workspace1.downgrade(),
+                    project1.clone(),
+                    Some(thread_store),
+                    None,
+                    history,
+                    window,
+                    cx,
+                )
+            })
+        });
+        cx.run_until_parked();
+
+        let message_editor = message_editor(&thread_view, cx);
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.set_text("Hello", window, cx);
+        });
+
+        // Create a second workspace and switch to it.
+        // This makes workspace1 the "background" workspace.
+        let project2 = Project::test(fs, [], cx).await;
+        multi_workspace_handle
+            .update(cx, |mw, window, cx| {
+                let workspace2 = cx.new(|cx| Workspace::test_new(project2, window, cx));
+                mw.activate(workspace2, cx);
+            })
+            .unwrap();
+
+        cx.run_until_parked();
+
+        // Verify workspace1 is no longer the active workspace
+        multi_workspace_handle
+            .read_with(cx, |mw, _cx| {
+                assert_eq!(mw.active_workspace_index(), 1);
+                assert_ne!(mw.workspace(), &workspace1);
+            })
+            .unwrap();
+
+        // Window is active, agent panel is visible in workspace1, but workspace1
+        // is in the background. The notification should show because the user
+        // can't actually see the agent panel.
+        active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx));
+
+        cx.run_until_parked();
+
+        assert!(
+            cx.windows()
+                .iter()
+                .any(|window| window.downcast::<AgentNotification>().is_some()),
+            "Expected notification when workspace is in background within MultiWorkspace"
+        );
+
+        // Also verify: clicking "View Panel" should switch to workspace1.
+        cx.windows()
+            .iter()
+            .find_map(|window| window.downcast::<AgentNotification>())
+            .unwrap()
+            .update(cx, |window, _, cx| window.accept(cx))
+            .unwrap();
+
+        cx.run_until_parked();
+
+        multi_workspace_handle
+            .read_with(cx, |mw, _cx| {
+                assert_eq!(
+                    mw.workspace(),
+                    &workspace1,
+                    "Expected workspace1 to become the active workspace after accepting notification"
+                );
+            })
+            .unwrap();
+    }
+
     #[gpui::test]
     async fn test_notification_respects_never_setting(cx: &mut TestAppContext) {
         init_test(cx);
@@ -2839,18 +2991,18 @@ pub(crate) mod tests {
         }
     }
 
-    struct StubAgentServer<C> {
+    pub(crate) struct StubAgentServer<C> {
         connection: C,
     }
 
     impl<C> StubAgentServer<C> {
-        fn new(connection: C) -> Self {
+        pub(crate) fn new(connection: C) -> Self {
             Self { connection }
         }
     }
 
     impl StubAgentServer<StubAgentConnection> {
-        fn default_response() -> Self {
+        pub(crate) fn default_response() -> Self {
             let conn = StubAgentConnection::new();
             conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk(
                 acp::ContentChunk::new("Default response".into()),

crates/agent_ui/src/agent_diff.rs 🔗

@@ -1352,10 +1352,10 @@ impl AgentDiff {
                     self.update_reviewing_editors(workspace, window, cx);
                 }
             }
-            AcpThreadEvent::Stopped
-            | AcpThreadEvent::Error
-            | AcpThreadEvent::LoadError(_)
-            | AcpThreadEvent::Refusal => {
+            AcpThreadEvent::Stopped => {
+                self.update_reviewing_editors(workspace, window, cx);
+            }
+            AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) | AcpThreadEvent::Refusal => {
                 self.update_reviewing_editors(workspace, window, cx);
             }
             AcpThreadEvent::TitleUpdated

crates/agent_ui/src/agent_panel.rs 🔗

@@ -81,10 +81,50 @@ const AGENT_PANEL_KEY: &str = "agent_panel";
 const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
 const DEFAULT_THREAD_TITLE: &str = "New Thread";
 
-#[derive(Serialize, Deserialize, Debug)]
+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();
+    scope
+        .read(&key)
+        .log_err()
+        .flatten()
+        .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
+}
+
+async fn save_serialized_panel(
+    workspace_id: workspace::WorkspaceId,
+    panel: SerializedAgentPanel,
+) -> Result<()> {
+    let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY);
+    let key = i64::from(workspace_id).to_string();
+    scope.write(key, serde_json::to_string(&panel)?).await?;
+    Ok(())
+}
+
+/// Migration: reads the original single-panel format stored under the
+/// `"agent_panel"` KVP key before per-workspace keying was introduced.
+fn read_legacy_serialized_panel() -> Option<SerializedAgentPanel> {
+    KEY_VALUE_STORE
+        .read_kvp(AGENT_PANEL_KEY)
+        .log_err()
+        .flatten()
+        .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
 struct SerializedAgentPanel {
     width: Option<Pixels>,
     selected_agent: Option<AgentType>,
+    #[serde(default)]
+    last_active_thread: Option<SerializedActiveThread>,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+struct SerializedActiveThread {
+    session_id: String,
+    agent_type: AgentType,
+    title: Option<String>,
+    cwd: Option<std::path::PathBuf>,
 }
 
 pub fn init(cx: &mut App) {
@@ -428,6 +468,7 @@ pub struct AgentPanel {
     focus_handle: FocusHandle,
     active_view: ActiveView,
     previous_view: Option<ActiveView>,
+    _active_view_observation: Option<Subscription>,
     new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
     agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
     agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -445,18 +486,44 @@ pub struct AgentPanel {
 
 impl AgentPanel {
     fn serialize(&mut self, cx: &mut Context<Self>) {
+        let workspace_id = self
+            .workspace
+            .read_with(cx, |workspace, _| workspace.database_id())
+            .ok()
+            .flatten();
+
+        let Some(workspace_id) = workspace_id else {
+            return;
+        };
+
         let width = self.width;
         let selected_agent = self.selected_agent.clone();
+
+        let last_active_thread = self.active_agent_thread(cx).map(|thread| {
+            let thread = thread.read(cx);
+            let title = thread.title();
+            SerializedActiveThread {
+                session_id: thread.session_id().0.to_string(),
+                agent_type: self.selected_agent.clone(),
+                title: if title.as_ref() != DEFAULT_THREAD_TITLE {
+                    Some(title.to_string())
+                } else {
+                    None
+                },
+                cwd: None,
+            }
+        });
+
         self.pending_serialization = Some(cx.background_spawn(async move {
-            KEY_VALUE_STORE
-                .write_kvp(
-                    AGENT_PANEL_KEY.into(),
-                    serde_json::to_string(&SerializedAgentPanel {
-                        width,
-                        selected_agent: Some(selected_agent),
-                    })?,
-                )
-                .await?;
+            save_serialized_panel(
+                workspace_id,
+                SerializedAgentPanel {
+                    width,
+                    selected_agent: Some(selected_agent),
+                    last_active_thread,
+                },
+            )
+            .await?;
             anyhow::Ok(())
         }));
     }
@@ -472,16 +539,18 @@ impl AgentPanel {
                 Ok(prompt_store) => prompt_store.await.ok(),
                 Err(_) => None,
             };
-            let serialized_panel = if let Some(panel) = cx
-                .background_spawn(async move { KEY_VALUE_STORE.read_kvp(AGENT_PANEL_KEY) })
-                .await
-                .log_err()
-                .flatten()
-            {
-                serde_json::from_str::<SerializedAgentPanel>(&panel).log_err()
-            } else {
-                None
-            };
+            let workspace_id = workspace
+                .read_with(cx, |workspace, _| workspace.database_id())
+                .ok()
+                .flatten();
+
+            let serialized_panel = cx
+                .background_spawn(async move {
+                    workspace_id
+                        .and_then(read_serialized_panel)
+                        .or_else(read_legacy_serialized_panel)
+                })
+                .await;
 
             let slash_commands = Arc::new(SlashCommandWorkingSet::default());
             let text_thread_store = workspace
@@ -500,15 +569,30 @@ impl AgentPanel {
                 let panel =
                     cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx));
 
-                if let Some(serialized_panel) = serialized_panel {
+                if let Some(serialized_panel) = &serialized_panel {
                     panel.update(cx, |panel, cx| {
                         panel.width = serialized_panel.width.map(|w| w.round());
-                        if let Some(selected_agent) = serialized_panel.selected_agent {
+                        if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
                             panel.selected_agent = selected_agent;
                         }
                         cx.notify();
                     });
                 }
+
+                if let Some(thread_info) = serialized_panel.and_then(|p| p.last_active_thread) {
+                    let agent_type = thread_info.agent_type.clone();
+                    let session_info = AgentSessionInfo {
+                        session_id: acp::SessionId::new(thread_info.session_id),
+                        cwd: thread_info.cwd,
+                        title: thread_info.title.map(SharedString::from),
+                        updated_at: None,
+                        meta: None,
+                    };
+                    panel.update(cx, |panel, cx| {
+                        panel.selected_agent = agent_type;
+                        panel.load_agent_thread(session_info, window, cx);
+                    });
+                }
                 panel
             })?;
 
@@ -516,7 +600,7 @@ impl AgentPanel {
         })
     }
 
-    fn new(
+    pub(crate) fn new(
         workspace: &Workspace,
         text_thread_store: Entity<assistant_text_thread::TextThreadStore>,
         prompt_store: Option<Entity<PromptStore>>,
@@ -646,6 +730,7 @@ impl AgentPanel {
             focus_handle: cx.focus_handle(),
             context_server_registry,
             previous_view: None,
+            _active_view_observation: None,
             new_thread_menu_handle: PopoverMenuHandle::default(),
             agent_panel_menu_handle: PopoverMenuHandle::default(),
             agent_navigation_menu_handle: PopoverMenuHandle::default(),
@@ -714,7 +799,7 @@ impl AgentPanel {
         &self.context_server_registry
     }
 
-    pub fn is_hidden(workspace: &Entity<Workspace>, cx: &App) -> bool {
+    pub fn is_visible(workspace: &Entity<Workspace>, cx: &App) -> bool {
         let workspace_read = workspace.read(cx);
 
         workspace_read
@@ -722,15 +807,13 @@ impl AgentPanel {
             .map(|panel| {
                 let panel_id = Entity::entity_id(&panel);
 
-                let is_visible = workspace_read.all_docks().iter().any(|dock| {
+                workspace_read.all_docks().iter().any(|dock| {
                     dock.read(cx)
                         .visible_panel()
                         .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id)
-                });
-
-                !is_visible
+                })
             })
-            .unwrap_or(true)
+            .unwrap_or(false)
     }
 
     pub(crate) fn active_thread_view(&self) -> Option<&Entity<AcpServerView>> {
@@ -1023,6 +1106,7 @@ impl AgentPanel {
             ActiveView::Configuration | ActiveView::History { .. } => {
                 if let Some(previous_view) = self.previous_view.take() {
                     self.active_view = previous_view;
+                    cx.emit(AgentPanelEvent::ActiveViewChanged);
 
                     match &self.active_view {
                         ActiveView::AgentThread { thread_view } => {
@@ -1419,7 +1503,7 @@ impl AgentPanel {
         }
     }
 
-    pub(crate) fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
+    pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
         match &self.active_view {
             ActiveView::AgentThread { thread_view, .. } => thread_view
                 .read(cx)
@@ -1475,9 +1559,21 @@ impl AgentPanel {
             self.active_view = new_view;
         }
 
+        self._active_view_observation = match &self.active_view {
+            ActiveView::AgentThread { thread_view } => {
+                Some(cx.observe(thread_view, |this, _, cx| {
+                    cx.emit(AgentPanelEvent::ActiveViewChanged);
+                    this.serialize(cx);
+                    cx.notify();
+                }))
+            }
+            _ => None,
+        };
+
         if focus {
             self.focus_handle(cx).focus(window, cx);
         }
+        cx.emit(AgentPanelEvent::ActiveViewChanged);
     }
 
     fn populate_recently_updated_menu_section(
@@ -1750,7 +1846,12 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition {
     AgentSettings::get_global(cx).dock.into()
 }
 
+pub enum AgentPanelEvent {
+    ActiveViewChanged,
+}
+
 impl EventEmitter<PanelEvent> for AgentPanel {}
+impl EventEmitter<AgentPanelEvent> for AgentPanel {}
 
 impl Panel for AgentPanel {
     fn persistent_name() -> &'static str {
@@ -3284,3 +3385,151 @@ impl AgentPanel {
         self.active_thread_view()
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::acp::thread_view::tests::{StubAgentServer, init_test};
+    use assistant_text_thread::TextThreadStore;
+    use feature_flags::FeatureFlagAppExt;
+    use fs::FakeFs;
+    use gpui::{TestAppContext, VisualTestContext};
+    use project::Project;
+    use workspace::{MultiWorkspace, Workspace};
+
+    #[gpui::test]
+    async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) {
+        init_test(cx);
+        cx.update(|cx| {
+            cx.update_flags(true, vec!["agent-v2".to_string()]);
+            agent::ThreadStore::init_global(cx);
+            language_model::LanguageModelRegistry::test(cx);
+        });
+
+        // --- Create a MultiWorkspace window with two workspaces ---
+        let fs = FakeFs::new(cx.executor());
+        let project_a = Project::test(fs.clone(), [], cx).await;
+        let project_b = Project::test(fs, [], cx).await;
+
+        let multi_workspace =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+
+        let workspace_a = multi_workspace
+            .read_with(cx, |multi_workspace, _cx| {
+                multi_workspace.workspace().clone()
+            })
+            .unwrap();
+
+        let workspace_b = multi_workspace
+            .update(cx, |multi_workspace, window, cx| {
+                let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx));
+                multi_workspace.activate(workspace.clone(), cx);
+                workspace
+            })
+            .unwrap();
+
+        workspace_a.update(cx, |workspace, _cx| {
+            workspace.set_random_database_id();
+        });
+        workspace_b.update(cx, |workspace, _cx| {
+            workspace.set_random_database_id();
+        });
+
+        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
+
+        // --- Set up workspace A: width=300, with an active thread ---
+        let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
+            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_a.clone(), cx));
+            cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
+        });
+
+        panel_a.update(cx, |panel, _cx| {
+            panel.width = Some(px(300.0));
+        });
+
+        panel_a.update_in(cx, |panel, window, cx| {
+            panel.open_external_thread_with_server(
+                Rc::new(StubAgentServer::default_response()),
+                window,
+                cx,
+            );
+        });
+
+        cx.run_until_parked();
+
+        panel_a.read_with(cx, |panel, cx| {
+            assert!(
+                panel.active_agent_thread(cx).is_some(),
+                "workspace A should have an active thread after connection"
+            );
+        });
+
+        let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone());
+
+        // --- Set up workspace B: ClaudeCode, width=400, no active thread ---
+        let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
+            let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_b.clone(), cx));
+            cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx))
+        });
+
+        panel_b.update(cx, |panel, _cx| {
+            panel.width = Some(px(400.0));
+            panel.selected_agent = AgentType::ClaudeCode;
+        });
+
+        // --- Serialize both panels ---
+        panel_a.update(cx, |panel, cx| panel.serialize(cx));
+        panel_b.update(cx, |panel, cx| panel.serialize(cx));
+        cx.run_until_parked();
+
+        // --- Load fresh panels for each workspace and verify independent state ---
+        let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap());
+
+        let async_cx = cx.update(|window, cx| window.to_async(cx));
+        let loaded_a = AgentPanel::load(workspace_a.downgrade(), prompt_builder.clone(), async_cx)
+            .await
+            .expect("panel A load should succeed");
+        cx.run_until_parked();
+
+        let async_cx = cx.update(|window, cx| window.to_async(cx));
+        let loaded_b = AgentPanel::load(workspace_b.downgrade(), prompt_builder.clone(), async_cx)
+            .await
+            .expect("panel B load should succeed");
+        cx.run_until_parked();
+
+        // Workspace A should restore its thread, width, and agent type
+        loaded_a.read_with(cx, |panel, _cx| {
+            assert_eq!(
+                panel.width,
+                Some(px(300.0)),
+                "workspace A width should be restored"
+            );
+            assert_eq!(
+                panel.selected_agent, agent_type_a,
+                "workspace A agent type should be restored"
+            );
+            assert!(
+                panel.active_thread_view().is_some(),
+                "workspace A should have its active thread restored"
+            );
+        });
+
+        // Workspace B should restore its own width and agent type, with no thread
+        loaded_b.read_with(cx, |panel, _cx| {
+            assert_eq!(
+                panel.width,
+                Some(px(400.0)),
+                "workspace B width should be restored"
+            );
+            assert_eq!(
+                panel.selected_agent,
+                AgentType::ClaudeCode,
+                "workspace B agent type should be restored"
+            );
+            assert!(
+                panel.active_thread_view().is_none(),
+                "workspace B should have no active thread"
+            );
+        });
+    }
+}

crates/agent_ui/src/agent_ui.rs 🔗

@@ -49,7 +49,7 @@ use std::any::TypeId;
 use workspace::Workspace;
 
 use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal};
-pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate};
+pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, ConcreteAssistantPanelDelegate};
 use crate::agent_registry_ui::AgentRegistryPage;
 pub use crate::inline_assistant::InlineAssistant;
 pub use agent_diff::{AgentDiffPane, AgentDiffToolbar};
@@ -418,6 +418,12 @@ fn update_command_palette_filter(cx: &mut App) {
                 filter.hide_action_types(&[TypeId::of::<zed_actions::agent::ToggleAgentPane>()]);
             }
         }
+
+        if agent_v2_enabled {
+            filter.show_namespace("multi_workspace");
+        } else {
+            filter.hide_namespace("multi_workspace");
+        }
     });
 }
 

crates/agent_ui/src/inline_prompt_editor.rs 🔗

@@ -417,8 +417,13 @@ impl<T: 'static> PromptEditor<T> {
 
     fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
         if inline_assistant_model_supports_images(cx)
-            && let Some(task) =
-                paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx)
+            && let Some(task) = paste_images_as_context(
+                self.editor.clone(),
+                self.mention_set.clone(),
+                self.workspace.clone(),
+                window,
+                cx,
+            )
         {
             task.detach();
         }

crates/agent_ui/src/mention_set.rs 🔗

@@ -297,8 +297,9 @@ impl MentionSet {
         self.mentions.insert(crease_id, (mention_uri, task.clone()));
 
         // Notify the user if we failed to load the mentioned context
-        cx.spawn_in(window, async move |this, cx| {
-            let result = task.await.notify_async_err(cx);
+        let workspace = workspace.downgrade();
+        cx.spawn(async move |this, mut cx| {
+            let result = task.await.notify_workspace_async_err(workspace, &mut cx);
             drop(tx);
             if result.is_none() {
                 this.update(cx, |this, cx| {
@@ -644,6 +645,7 @@ pub(crate) async fn insert_images_as_context(
     images: Vec<gpui::Image>,
     editor: Entity<Editor>,
     mention_set: Entity<MentionSet>,
+    workspace: WeakEntity<Workspace>,
     cx: &mut gpui::AsyncWindowContext,
 ) {
     if images.is_empty() {
@@ -723,7 +725,11 @@ pub(crate) async fn insert_images_as_context(
             mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone())
         });
 
-        if task.await.notify_async_err(cx).is_none() {
+        if task
+            .await
+            .notify_workspace_async_err(workspace.clone(), cx)
+            .is_none()
+        {
             editor.update(cx, |editor, cx| {
                 editor.edit([(start_anchor..end_anchor, "")], cx);
             });
@@ -737,11 +743,12 @@ pub(crate) async fn insert_images_as_context(
 pub(crate) fn paste_images_as_context(
     editor: Entity<Editor>,
     mention_set: Entity<MentionSet>,
+    workspace: WeakEntity<Workspace>,
     window: &mut Window,
     cx: &mut App,
 ) -> Option<Task<()>> {
     let clipboard = cx.read_from_clipboard()?;
-    Some(window.spawn(cx, async move |cx| {
+    Some(window.spawn(cx, async move |mut cx| {
         use itertools::Itertools;
         let (mut images, paths) = clipboard
             .into_entries()
@@ -788,7 +795,7 @@ pub(crate) fn paste_images_as_context(
         })
         .ok();
 
-        insert_images_as_context(images, editor, mention_set, cx).await;
+        insert_images_as_context(images, editor, mention_set, workspace, &mut cx).await;
     }))
 }
 

crates/agent_ui/src/ui/agent_notification.rs 🔗

@@ -75,6 +75,16 @@ pub enum AgentNotificationEvent {
 
 impl EventEmitter<AgentNotificationEvent> for AgentNotification {}
 
+impl AgentNotification {
+    pub fn accept(&mut self, cx: &mut Context<Self>) {
+        cx.emit(AgentNotificationEvent::Accepted);
+    }
+
+    pub fn dismiss(&mut self, cx: &mut Context<Self>) {
+        cx.emit(AgentNotificationEvent::Dismissed);
+    }
+}
+
 impl Render for AgentNotification {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let ui_font = theme::setup_ui_font(window, cx);
@@ -174,14 +184,14 @@ impl Render for AgentNotification {
                             .style(ButtonStyle::Tinted(ui::TintColor::Accent))
                             .full_width()
                             .on_click({
-                                cx.listener(move |_this, _event, _, cx| {
-                                    cx.emit(AgentNotificationEvent::Accepted);
+                                cx.listener(move |this, _event, _, cx| {
+                                    this.accept(cx);
                                 })
                             }),
                     )
                     .child(Button::new("dismiss", "Dismiss").full_width().on_click({
-                        cx.listener(move |_, _event, _, cx| {
-                            cx.emit(AgentNotificationEvent::Dismissed);
+                        cx.listener(move |this, _event, _, cx| {
+                            this.dismiss(cx);
                         })
                     })),
             )

crates/collab/tests/integration/channel_guest_tests.rs 🔗

@@ -34,9 +34,11 @@ async fn test_channel_guests(
     cx_a.executor().run_until_parked();
 
     // Client B joins channel A as a guest
-    cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
-        .await
-        .unwrap();
+    cx_b.update(|cx| {
+        workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx)
+    })
+    .await
+    .unwrap();
 
     // b should be following a in the shared project.
     // B is a guest,
@@ -76,9 +78,11 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
         .await;
 
     let project_a = client_a.build_test_project(cx_a).await;
-    cx_a.update(|cx| workspace::join_channel(channel_id, client_a.app_state.clone(), None, cx))
-        .await
-        .unwrap();
+    cx_a.update(|cx| {
+        workspace::join_channel(channel_id, client_a.app_state.clone(), None, None, cx)
+    })
+    .await
+    .unwrap();
 
     // Client A shares a project in the channel
     active_call_a
@@ -88,9 +92,11 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test
     cx_a.run_until_parked();
 
     // Client B joins channel A as a guest
-    cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx))
-        .await
-        .unwrap();
+    cx_b.update(|cx| {
+        workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx)
+    })
+    .await
+    .unwrap();
     cx_a.run_until_parked();
 
     // client B opens 1.txt as a guest

crates/collab/tests/integration/editor_tests.rs 🔗

@@ -19,7 +19,8 @@ use fs::Fs;
 use futures::{SinkExt, StreamExt, channel::mpsc, lock::Mutex};
 use git::repository::repo_path;
 use gpui::{
-    App, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext,
+    App, AppContext as _, Entity, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext,
+    VisualTestContext,
 };
 use indoc::indoc;
 use language::{FakeLspAdapter, language_settings::language_settings, rust_lang};
@@ -51,7 +52,7 @@ use std::{
 };
 use text::Point;
 use util::{path, rel_path::rel_path, uri};
-use workspace::{CloseIntent, Workspace};
+use workspace::{CloseIntent, MultiWorkspace, Workspace};
 
 #[gpui::test(iterations = 10)]
 async fn test_host_disconnect(
@@ -95,34 +96,46 @@ async fn test_host_disconnect(
 
     assert!(worktree_a.read_with(cx_a, |tree, _| tree.has_update_observer()));
 
-    let workspace_b = cx_b.add_window(|window, cx| {
-        Workspace::new(
-            None,
-            project_b.clone(),
-            client_b.app_state.clone(),
-            window,
-            cx,
-        )
+    let window_b = cx_b.add_window(|window, cx| {
+        let workspace = cx.new(|cx| {
+            Workspace::new(
+                None,
+                project_b.clone(),
+                client_b.app_state.clone(),
+                window,
+                cx,
+            )
+        });
+        MultiWorkspace::new(workspace, cx)
     });
-    let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b);
-    let workspace_b_view = workspace_b.root(cx_b).unwrap();
+    let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b);
+    let workspace_b = window_b
+        .root(cx_b)
+        .unwrap()
+        .read_with(cx_b, |multi_workspace, _| {
+            multi_workspace.workspace().clone()
+        });
 
-    let editor_b = workspace_b
-        .update(cx_b, |workspace, window, cx| {
+    let editor_b: Entity<Editor> = workspace_b
+        .update_in(cx_b, |workspace, window, cx| {
             workspace.open_path((worktree_id, rel_path("b.txt")), None, true, window, cx)
         })
-        .unwrap()
         .await
         .unwrap()
         .downcast::<Editor>()
         .unwrap();
 
     //TODO: focus
-    assert!(cx_b.update_window_entity(&editor_b, |editor, window, _| editor.is_focused(window)));
-    editor_b.update_in(cx_b, |editor, window, cx| editor.insert("X", window, cx));
+    assert!(
+        cx_b.update_window_entity(&editor_b, |editor: &mut Editor, window, _| editor
+            .is_focused(window))
+    );
+    editor_b.update_in(cx_b, |editor: &mut Editor, window, cx| {
+        editor.insert("X", window, cx)
+    });
 
     cx_b.update(|_, cx| {
-        assert!(workspace_b_view.read(cx).is_edited());
+        assert!(workspace_b.read(cx).is_edited());
     });
 
     // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared.
@@ -140,19 +153,16 @@ async fn test_host_disconnect(
     assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer()));
 
     // Ensure client B's edited state is reset and that the whole window is blurred.
-    workspace_b
-        .update(cx_b, |workspace, _, cx| {
-            assert!(workspace.active_modal::<DisconnectedOverlay>(cx).is_some());
-            assert!(!workspace.is_edited());
-        })
-        .unwrap();
+    workspace_b.update(cx_b, |workspace, cx| {
+        assert!(workspace.active_modal::<DisconnectedOverlay>(cx).is_some());
+        assert!(!workspace.is_edited());
+    });
 
     // Ensure client B is not prompted to save edits when closing window after disconnecting.
-    let can_close = workspace_b
-        .update(cx_b, |workspace, window, cx| {
+    let can_close: bool = workspace_b
+        .update_in(cx_b, |workspace, window, cx| {
             workspace.prepare_to_close(CloseIntent::Quit, window, cx)
         })
-        .unwrap()
         .await
         .unwrap();
     assert!(can_close);

crates/collab/tests/integration/following_tests.rs 🔗

@@ -17,7 +17,7 @@ use serde_json::json;
 use settings::SettingsStore;
 use text::{Point, ToPoint};
 use util::{path, rel_path::rel_path, test::sample_text};
-use workspace::{CollaboratorId, SplitDirection, Workspace, item::ItemHandle as _};
+use workspace::{CollaboratorId, MultiWorkspace, SplitDirection, Workspace, item::ItemHandle as _};
 
 use super::TestClient;
 
@@ -1555,9 +1555,9 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut
     let mut cx_b2 = VisualTestContext::from_window(window_b_project_a, cx_b);
 
     let workspace_b_project_a = window_b_project_a
-        .downcast::<Workspace>()
+        .downcast::<MultiWorkspace>()
         .unwrap()
-        .root(cx_b)
+        .read_with(cx_b, |mw, _| mw.workspace().clone())
         .unwrap();
 
     // assert that b is following a in project a in w.rs
@@ -1657,9 +1657,9 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut
         .unwrap();
     let cx_a2 = &mut VisualTestContext::from_window(window_a_project_b, cx_a);
     let workspace_a_project_b = window_a_project_b
-        .downcast::<Workspace>()
+        .downcast::<MultiWorkspace>()
         .unwrap()
-        .root(cx_a)
+        .read_with(cx_a, |mw, _| mw.workspace().clone())
         .unwrap();
 
     executor.run_until_parked();
@@ -2144,7 +2144,7 @@ pub(crate) async fn join_channel(
     client: &TestClient,
     cx: &mut TestAppContext,
 ) -> anyhow::Result<()> {
-    cx.update(|cx| workspace::join_channel(channel_id, client.app_state.clone(), None, cx))
+    cx.update(|cx| workspace::join_channel(channel_id, client.app_state.clone(), None, None, cx))
         .await
 }
 

crates/collab/tests/integration/git_tests.rs 🔗

@@ -3,11 +3,11 @@ use std::path::Path;
 use call::ActiveCall;
 use git::status::{FileStatus, StatusCode, TrackedStatus};
 use git_ui::project_diff::ProjectDiff;
-use gpui::{TestAppContext, VisualTestContext};
+use gpui::{AppContext as _, TestAppContext, VisualTestContext};
 use project::ProjectPath;
 use serde_json::json;
 use util::{path, rel_path::rel_path};
-use workspace::Workspace;
+use workspace::{MultiWorkspace, Workspace};
 
 //
 use crate::TestServer;
@@ -57,17 +57,25 @@ async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext)
     cx_b.update(editor::init);
     cx_b.update(git_ui::init);
     let project_b = client_b.join_remote_project(project_id, cx_b).await;
-    let workspace_b = cx_b.add_window(|window, cx| {
-        Workspace::new(
-            None,
-            project_b.clone(),
-            client_b.app_state.clone(),
-            window,
-            cx,
-        )
+    let window_b = cx_b.add_window(|window, cx| {
+        let workspace = cx.new(|cx| {
+            Workspace::new(
+                None,
+                project_b.clone(),
+                client_b.app_state.clone(),
+                window,
+                cx,
+            )
+        });
+        MultiWorkspace::new(workspace, cx)
     });
-    let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b);
-    let workspace_b = workspace_b.root(cx_b).unwrap();
+    let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b);
+    let workspace_b = window_b
+        .root(cx_b)
+        .unwrap()
+        .read_with(cx_b, |multi_workspace, _| {
+            multi_workspace.workspace().clone()
+        });
 
     cx_b.update(|window, cx| {
         window

crates/collab/tests/integration/remote_editing_collaboration_tests.rs 🔗

@@ -8,7 +8,9 @@ use editor::{Editor, EditorMode, MultiBuffer};
 use extension::ExtensionHostProxy;
 use fs::{FakeFs, Fs as _, RemoveOptions};
 use futures::StreamExt as _;
-use gpui::{AppContext as _, BackgroundExecutor, TestAppContext, UpdateGlobal as _, VisualContext};
+use gpui::{
+    AppContext as _, BackgroundExecutor, TestAppContext, UpdateGlobal as _, VisualContext as _,
+};
 use http_client::BlockedHttpClient;
 use language::{
     FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry,
@@ -663,7 +665,7 @@ async fn test_remote_server_debugger(
 
     let workspace_window = cx_a
         .window_handle()
-        .downcast::<workspace::Workspace>()
+        .downcast::<workspace::MultiWorkspace>()
         .unwrap();
 
     let session = debugger_ui::tests::start_debug_session(&workspace_window, cx_a, |_| {}).unwrap();
@@ -671,13 +673,16 @@ async fn test_remote_server_debugger(
     debug_panel.update(cx_a, |debug_panel, cx| {
         assert_eq!(
             debug_panel.active_session().unwrap().read(cx).session(cx),
-            session
+            session.clone()
         )
     });
 
-    session.update(cx_a, |session, _| {
-        assert_eq!(session.binary().unwrap().command.as_deref(), Some("mock"));
-    });
+    session.update(
+        cx_a,
+        |session: &mut project::debugger::session::Session, _| {
+            assert_eq!(session.binary().unwrap().command.as_deref(), Some("mock"));
+        },
+    );
 
     let shutdown_session = workspace.update(cx_a, |workspace, cx| {
         workspace.project().update(cx, |project, cx| {
@@ -772,7 +777,7 @@ async fn test_slow_adapter_startup_retries(
 
     let workspace_window = cx_a
         .window_handle()
-        .downcast::<workspace::Workspace>()
+        .downcast::<workspace::MultiWorkspace>()
         .unwrap();
 
     let count = Arc::new(AtomicUsize::new(0));
@@ -804,7 +809,10 @@ async fn test_slow_adapter_startup_retries(
     .unwrap();
     cx_a.run_until_parked();
 
-    let client = session.update(cx_a, |session, _| session.adapter_client().unwrap());
+    let client = session.update(
+        cx_a,
+        |session: &mut project::debugger::session::Session, _| session.adapter_client().unwrap(),
+    );
     client
         .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent {
             reason: dap::StoppedEventReason::Pause,

crates/collab/tests/integration/test_server.rs 🔗

@@ -45,7 +45,7 @@ use std::{
     },
 };
 use util::path;
-use workspace::{Workspace, WorkspaceStore};
+use workspace::{MultiWorkspace, Workspace, WorkspaceStore};
 
 use livekit_client::test::TestServer as LivekitTestServer;
 
@@ -843,7 +843,7 @@ impl TestClient {
         channel_id: ChannelId,
         cx: &'a mut TestAppContext,
     ) -> (Entity<Workspace>, &'a mut VisualTestContext) {
-        cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, cx))
+        cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, None, cx))
             .await
             .unwrap();
         cx.run_until_parked();
@@ -897,10 +897,19 @@ impl TestClient {
         project: &Entity<Project>,
         cx: &'a mut TestAppContext,
     ) -> (Entity<Workspace>, &'a mut VisualTestContext) {
-        cx.add_window_view(|window, cx| {
+        let app_state = self.app_state.clone();
+        let project = project.clone();
+        let window = cx.add_window(|window, cx| {
             window.activate_window();
-            Workspace::new(None, project.clone(), self.app_state.clone(), window, cx)
-        })
+            let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
+            MultiWorkspace::new(workspace, cx)
+        });
+        let cx = VisualTestContext::from_window(*window, cx).into_mut();
+        cx.run_until_parked();
+        let workspace = window
+            .read_with(cx, |mw, _| mw.workspace().clone())
+            .unwrap();
+        (workspace, cx)
     }
 
     pub async fn build_test_workspace<'a>(
@@ -908,19 +917,33 @@ impl TestClient {
         cx: &'a mut TestAppContext,
     ) -> (Entity<Workspace>, &'a mut VisualTestContext) {
         let project = self.build_test_project(cx).await;
-        cx.add_window_view(|window, cx| {
+        let app_state = self.app_state.clone();
+        let window = cx.add_window(|window, cx| {
             window.activate_window();
-            Workspace::new(None, project.clone(), self.app_state.clone(), window, cx)
-        })
+            let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
+            MultiWorkspace::new(workspace, cx)
+        });
+        let cx = VisualTestContext::from_window(*window, cx).into_mut();
+        let workspace = window
+            .read_with(cx, |mw, _| mw.workspace().clone())
+            .unwrap();
+        (workspace, cx)
     }
 
     pub fn active_workspace<'a>(
         &'a self,
         cx: &'a mut TestAppContext,
     ) -> (Entity<Workspace>, &'a mut VisualTestContext) {
-        let window = cx.update(|cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
+        let window = cx.update(|cx| {
+            cx.active_window()
+                .unwrap()
+                .downcast::<MultiWorkspace>()
+                .unwrap()
+        });
 
-        let entity = window.root(cx).unwrap();
+        let entity = window
+            .read_with(cx, |mw, _| mw.workspace().clone())
+            .unwrap();
         let cx = VisualTestContext::from_window(*window.deref(), cx).into_mut();
         // it might be nice to try and cleanup these at the end of each test.
         (entity, cx)
@@ -931,8 +954,15 @@ pub fn open_channel_notes(
     channel_id: ChannelId,
     cx: &mut VisualTestContext,
 ) -> Task<anyhow::Result<Entity<ChannelView>>> {
-    let window = cx.update(|_, cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
-    let entity = window.root(cx).unwrap();
+    let window = cx.update(|_, cx| {
+        cx.active_window()
+            .unwrap()
+            .downcast::<MultiWorkspace>()
+            .unwrap()
+    });
+    let entity = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
 
     cx.update(|window, cx| ChannelView::open(channel_id, None, entity.clone(), window, cx))
 }

crates/collab_ui/src/collab_panel.rs 🔗

@@ -36,7 +36,8 @@ use ui::{
 };
 use util::{ResultExt, TryFutureExt, maybe};
 use workspace::{
-    CopyRoomId, Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace,
+    CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, ScreenShare,
+    ShareProject, Workspace,
     dock::{DockPosition, Panel, PanelEvent},
     notifications::{DetachAndPromptErr, NotifyResultExt},
 };
@@ -120,6 +121,7 @@ pub fn init(cx: &mut App) {
 
             if let Some(room) = ActiveCall::global(cx).read(cx).room() {
                 let romo_id_fut = room.read(cx).room_id();
+                let workspace_handle = cx.weak_entity();
                 cx.spawn(async move |workspace, cx| {
                     let room_id = romo_id_fut.await.context("Failed to get livekit room")?;
                     workspace.update(cx, |workspace, cx| {
@@ -134,7 +136,7 @@ pub fn init(cx: &mut App) {
                         );
                     })
                 })
-                .detach_and_notify_err(window, cx);
+                .detach_and_notify_err(workspace_handle, window, cx);
             } else {
                 workspace.show_error(&"There’s no active call; join one first.", cx);
             }
@@ -2177,12 +2179,13 @@ impl CollabPanel {
                 &["Remove", "Cancel"],
                 cx,
             );
-            cx.spawn_in(window, async move |this, cx| {
+            let workspace = self.workspace.clone();
+            cx.spawn_in(window, async move |this, mut cx| {
                 if answer.await? == 0 {
                     channel_store
                         .update(cx, |channels, _| channels.remove_channel(channel_id))
                         .await
-                        .notify_async_err(cx);
+                        .notify_workspace_async_err(workspace, &mut cx);
                     this.update_in(cx, |_, window, cx| cx.focus_self(window))
                         .ok();
                 }
@@ -2211,12 +2214,13 @@ impl CollabPanel {
             &["Remove", "Cancel"],
             cx,
         );
-        cx.spawn_in(window, async move |_, cx| {
+        let workspace = self.workspace.clone();
+        cx.spawn_in(window, async move |_, mut cx| {
             if answer.await? == 0 {
                 user_store
                     .update(cx, |store, cx| store.remove_contact(user_id, cx))
                     .await
-                    .notify_async_err(cx);
+                    .notify_workspace_async_err(workspace, &mut cx);
             }
             anyhow::Ok(())
         })
@@ -2267,13 +2271,15 @@ impl CollabPanel {
         let Some(workspace) = self.workspace.upgrade() else {
             return;
         };
-        let Some(handle) = window.window_handle().downcast::<Workspace>() else {
+
+        let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
             return;
         };
         workspace::join_channel(
             channel_id,
             workspace.read(cx).app_state().clone(),
             Some(handle),
+            Some(self.workspace.clone()),
             cx,
         )
         .detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None)
@@ -2316,12 +2322,13 @@ impl CollabPanel {
                             .full_width()
                             .on_click(cx.listener(|this, _, window, cx| {
                                 let client = this.client.clone();
-                                cx.spawn_in(window, async move |_, cx| {
+                                let workspace = this.workspace.clone();
+                                cx.spawn_in(window, async move |_, mut cx| {
                                     client
-                                        .connect(true, cx)
+                                        .connect(true, &mut cx)
                                         .await
                                         .into_response()
-                                        .notify_async_err(cx);
+                                        .notify_workspace_async_err(workspace, &mut cx);
                                 })
                                 .detach()
                             })),

crates/db/src/kvp.rs 🔗

@@ -1,3 +1,4 @@
+use anyhow::Context as _;
 use gpui::App;
 use sqlez_macros::sql;
 use util::ResultExt as _;
@@ -13,12 +14,22 @@ pub struct KeyValueStore(crate::sqlez::thread_safe_connection::ThreadSafeConnect
 impl Domain for KeyValueStore {
     const NAME: &str = stringify!(KeyValueStore);
 
-    const MIGRATIONS: &[&str] = &[sql!(
-        CREATE TABLE IF NOT EXISTS kv_store(
-            key TEXT PRIMARY KEY,
-            value TEXT NOT NULL
-        ) STRICT;
-    )];
+    const MIGRATIONS: &[&str] = &[
+        sql!(
+            CREATE TABLE IF NOT EXISTS kv_store(
+                key TEXT PRIMARY KEY,
+                value TEXT NOT NULL
+            ) STRICT;
+        ),
+        sql!(
+            CREATE TABLE IF NOT EXISTS scoped_kv_store(
+                namespace TEXT NOT NULL,
+                key TEXT NOT NULL,
+                value TEXT NOT NULL,
+                PRIMARY KEY(namespace, key)
+            ) STRICT;
+        ),
+    ];
 }
 
 crate::static_connection!(KEY_VALUE_STORE, KeyValueStore, []);
@@ -69,6 +80,64 @@ impl KeyValueStore {
             DELETE FROM kv_store WHERE key = (?)
         }
     }
+
+    pub fn scoped<'a>(&'a self, namespace: &'a str) -> ScopedKeyValueStore<'a> {
+        ScopedKeyValueStore {
+            store: self,
+            namespace,
+        }
+    }
+}
+
+pub struct ScopedKeyValueStore<'a> {
+    store: &'a KeyValueStore,
+    namespace: &'a str,
+}
+
+impl ScopedKeyValueStore<'_> {
+    pub fn read(&self, key: &str) -> anyhow::Result<Option<String>> {
+        self.store.select_row_bound::<(&str, &str), String>(
+            "SELECT value FROM scoped_kv_store WHERE namespace = (?) AND key = (?)",
+        )?((self.namespace, key))
+        .context("Failed to read from scoped_kv_store")
+    }
+
+    pub async fn write(&self, key: String, value: String) -> anyhow::Result<()> {
+        let namespace = self.namespace.to_owned();
+        self.store
+            .write(move |connection| {
+                connection.exec_bound::<(&str, &str, &str)>(
+                    "INSERT OR REPLACE INTO scoped_kv_store(namespace, key, value) VALUES ((?), (?), (?))",
+                )?((&namespace, &key, &value))
+                .context("Failed to write to scoped_kv_store")
+            })
+            .await
+    }
+
+    pub async fn delete(&self, key: String) -> anyhow::Result<()> {
+        let namespace = self.namespace.to_owned();
+        self.store
+            .write(move |connection| {
+                connection.exec_bound::<(&str, &str)>(
+                    "DELETE FROM scoped_kv_store WHERE namespace = (?) AND key = (?)",
+                )?((&namespace, &key))
+                .context("Failed to delete from scoped_kv_store")
+            })
+            .await
+    }
+
+    pub async fn delete_all(&self) -> anyhow::Result<()> {
+        let namespace = self.namespace.to_owned();
+        self.store
+            .write(move |connection| {
+                connection
+                    .exec_bound::<&str>("DELETE FROM scoped_kv_store WHERE namespace = (?)")?(
+                    &namespace,
+                )
+                .context("Failed to delete_all from scoped_kv_store")
+            })
+            .await
+    }
 }
 
 #[cfg(test)]
@@ -99,6 +168,52 @@ mod tests {
         db.delete_kvp("key-1".to_string()).await.unwrap();
         assert_eq!(db.read_kvp("key-1").unwrap(), None);
     }
+
+    #[gpui::test]
+    async fn test_scoped_kvp() {
+        let db = KeyValueStore::open_test_db("test_scoped_kvp").await;
+
+        let scope_a = db.scoped("namespace-a");
+        let scope_b = db.scoped("namespace-b");
+
+        // Reading a missing key returns None
+        assert_eq!(scope_a.read("key-1").unwrap(), None);
+
+        // Writing and reading back a key works
+        scope_a
+            .write("key-1".to_string(), "value-a1".to_string())
+            .await
+            .unwrap();
+        assert_eq!(scope_a.read("key-1").unwrap(), Some("value-a1".to_string()));
+
+        // Two namespaces with the same key don't collide
+        scope_b
+            .write("key-1".to_string(), "value-b1".to_string())
+            .await
+            .unwrap();
+        assert_eq!(scope_a.read("key-1").unwrap(), Some("value-a1".to_string()));
+        assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string()));
+
+        // delete removes a single key without affecting others in the namespace
+        scope_a
+            .write("key-2".to_string(), "value-a2".to_string())
+            .await
+            .unwrap();
+        scope_a.delete("key-1".to_string()).await.unwrap();
+        assert_eq!(scope_a.read("key-1").unwrap(), None);
+        assert_eq!(scope_a.read("key-2").unwrap(), Some("value-a2".to_string()));
+        assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string()));
+
+        // delete_all removes all keys in a namespace without affecting other namespaces
+        scope_a
+            .write("key-3".to_string(), "value-a3".to_string())
+            .await
+            .unwrap();
+        scope_a.delete_all().await.unwrap();
+        assert_eq!(scope_a.read("key-2").unwrap(), None);
+        assert_eq!(scope_a.read("key-3").unwrap(), None);
+        assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string()));
+    }
 }
 
 pub struct GlobalKeyValueStore(ThreadSafeConnection);

crates/debugger_ui/src/tests.rs 🔗

@@ -8,7 +8,7 @@ use project::{Project, debugger::session::Session};
 use settings::SettingsStore;
 use task::SharedTaskContext;
 use terminal_view::terminal_panel::TerminalPanel;
-use workspace::Workspace;
+use workspace::MultiWorkspace;
 
 use crate::{debugger_panel::DebugPanel, session::DebugSession};
 
@@ -52,14 +52,16 @@ pub fn init_test(cx: &mut gpui::TestAppContext) {
 pub async fn init_test_workspace(
     project: &Entity<Project>,
     cx: &mut TestAppContext,
-) -> WindowHandle<Workspace> {
+) -> WindowHandle<MultiWorkspace> {
     let workspace_handle =
-        cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
 
     let debugger_panel = workspace_handle
-        .update(cx, |_, window, cx| {
-            cx.spawn_in(window, async move |this, cx| {
-                DebugPanel::load(this, cx).await
+        .update(cx, |multi, window, cx| {
+            multi.workspace().update(cx, |_workspace, cx| {
+                cx.spawn_in(window, async move |this, cx| {
+                    DebugPanel::load(this, cx).await
+                })
             })
         })
         .unwrap()
@@ -67,9 +69,10 @@ pub async fn init_test_workspace(
         .expect("Failed to load debug panel");
 
     let terminal_panel = workspace_handle
-        .update(cx, |_, window, cx| {
-            cx.spawn_in(window, async |this, cx| {
-                TerminalPanel::load(this, cx.clone()).await
+        .update(cx, |multi, window, cx| {
+            let weak_workspace = multi.workspace().downgrade();
+            cx.spawn_in(window, async move |_, cx| {
+                TerminalPanel::load(weak_workspace, cx.clone()).await
             })
         })
         .unwrap()
@@ -77,9 +80,11 @@ pub async fn init_test_workspace(
         .expect("Failed to load terminal panel");
 
     workspace_handle
-        .update(cx, |workspace, window, cx| {
-            workspace.add_panel(debugger_panel, window, cx);
-            workspace.add_panel(terminal_panel, window, cx);
+        .update(cx, |multi, window, cx| {
+            multi.workspace().update(cx, |workspace, cx| {
+                workspace.add_panel(debugger_panel, window, cx);
+                workspace.add_panel(terminal_panel, window, cx);
+            });
         })
         .unwrap();
     workspace_handle
@@ -87,39 +92,45 @@ pub async fn init_test_workspace(
 
 #[track_caller]
 pub fn active_debug_session_panel(
-    workspace: WindowHandle<Workspace>,
+    workspace: WindowHandle<MultiWorkspace>,
     cx: &mut TestAppContext,
 ) -> Entity<DebugSession> {
     workspace
-        .update(cx, |workspace, _window, cx| {
-            let debug_panel = workspace.panel::<DebugPanel>(cx).unwrap();
-            debug_panel
-                .update(cx, |this, _| this.active_session())
-                .unwrap()
+        .update(cx, |multi, _window, cx| {
+            multi.workspace().update(cx, |workspace, cx| {
+                let debug_panel = workspace.panel::<DebugPanel>(cx).unwrap();
+                debug_panel
+                    .update(cx, |this, _| this.active_session())
+                    .unwrap()
+            })
         })
         .unwrap()
 }
 
 pub fn start_debug_session_with<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
-    workspace: &WindowHandle<Workspace>,
+    workspace: &WindowHandle<MultiWorkspace>,
     cx: &mut gpui::TestAppContext,
     config: DebugTaskDefinition,
     configure: T,
 ) -> Result<Entity<Session>> {
     let _subscription = project::debugger::test::intercept_debug_sessions(cx, configure);
-    workspace.update(cx, |workspace, window, cx| {
-        workspace.start_debug_session(
-            config.to_scenario(),
-            SharedTaskContext::default(),
-            None,
-            None,
-            window,
-            cx,
-        )
+    workspace.update(cx, |multi, window, cx| {
+        multi.workspace().update(cx, |workspace, cx| {
+            workspace.start_debug_session(
+                config.to_scenario(),
+                SharedTaskContext::default(),
+                None,
+                None,
+                window,
+                cx,
+            )
+        })
     })?;
     cx.run_until_parked();
     let session = workspace.read_with(cx, |workspace, cx| {
         workspace
+            .workspace()
+            .read(cx)
             .panel::<DebugPanel>(cx)
             .and_then(|panel| panel.read(cx).active_session())
             .map(|session| session.read(cx).running_state().read(cx).session())
@@ -131,7 +142,7 @@ pub fn start_debug_session_with<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
 }
 
 pub fn start_debug_session<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
-    workspace: &WindowHandle<Workspace>,
+    workspace: &WindowHandle<MultiWorkspace>,
     cx: &mut gpui::TestAppContext,
     configure: T,
 ) -> Result<Entity<Session>> {

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

@@ -60,7 +60,13 @@ async fn test_direct_attach_to_process(executor: BackgroundExecutor, cx: &mut Te
     // assert we didn't show the attach modal
     workspace
         .update(cx, |workspace, _window, cx| {
-            assert!(workspace.active_modal::<AttachModal>(cx).is_none());
+            assert!(
+                workspace
+                    .workspace()
+                    .read(cx)
+                    .active_modal::<AttachModal>(cx)
+                    .is_none()
+            );
         })
         .unwrap();
 }
@@ -97,9 +103,9 @@ async fn test_show_attach_modal_and_select_process(
             });
         });
     let attach_modal = workspace
-        .update(cx, |workspace, window, cx| {
-            let workspace_handle = cx.weak_entity();
-            workspace.toggle_modal(window, cx, |window, cx| {
+        .update(cx, |multi, window, cx| {
+            let workspace_handle = multi.workspace().downgrade();
+            multi.toggle_modal(window, cx, |window, cx| {
                 AttachModal::with_processes(
                     workspace_handle,
                     vec![
@@ -133,7 +139,7 @@ async fn test_show_attach_modal_and_select_process(
                 )
             });
 
-            workspace.active_modal::<AttachModal>(cx).unwrap()
+            multi.active_modal::<AttachModal>(cx).unwrap()
         })
         .unwrap();
 
@@ -208,24 +214,26 @@ async fn test_attach_with_pick_pid_variable(executor: BackgroundExecutor, cx: &m
 
     let pick_pid_placeholder = task::VariableName::PickProcessId.template_value();
     workspace
-        .update(cx, |workspace, window, cx| {
-            workspace.start_debug_session(
-                DebugTaskDefinition {
-                    adapter: FakeAdapter::ADAPTER_NAME.into(),
-                    label: "attach with picker".into(),
-                    config: json!({
-                        "request": "attach",
-                        "process_id": pick_pid_placeholder,
-                    }),
-                    tcp_connection: None,
-                }
-                .to_scenario(),
-                SharedTaskContext::default(),
-                None,
-                None,
-                window,
-                cx,
-            )
+        .update(cx, |multi, window, cx| {
+            multi.workspace().update(cx, |workspace, cx| {
+                workspace.start_debug_session(
+                    DebugTaskDefinition {
+                        adapter: FakeAdapter::ADAPTER_NAME.into(),
+                        label: "attach with picker".into(),
+                        config: json!({
+                            "request": "attach",
+                            "process_id": pick_pid_placeholder,
+                        }),
+                        tcp_connection: None,
+                    }
+                    .to_scenario(),
+                    SharedTaskContext::default(),
+                    None,
+                    None,
+                    window,
+                    cx,
+                );
+            })
         })
         .unwrap();
 

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

@@ -145,15 +145,17 @@ async fn test_debug_session_substitutes_variables_and_relativizes_paths(
         };
 
         workspace
-            .update(cx, |workspace, window, cx| {
-                workspace.start_debug_session(
-                    scenario,
-                    task_context.clone(),
-                    None,
-                    None,
-                    window,
-                    cx,
-                )
+            .update(cx, |multi, window, cx| {
+                multi.workspace().update(cx, |workspace, cx| {
+                    workspace.start_debug_session(
+                        scenario,
+                        task_context.clone(),
+                        None,
+                        None,
+                        window,
+                        cx,
+                    );
+                })
             })
             .unwrap();
 
@@ -182,8 +184,10 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut
     let cx = &mut VisualTestContext::from_window(*workspace, cx);
 
     workspace
-        .update(cx, |workspace, window, cx| {
-            NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
+        .update(cx, |multi, window, cx| {
+            multi.workspace().update(cx, |workspace, cx| {
+                NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
+            });
         })
         .unwrap();
 
@@ -324,8 +328,10 @@ async fn test_debug_modal_subtitles_with_multiple_worktrees(
     let cx = &mut VisualTestContext::from_window(*workspace, cx);
 
     workspace
-        .update(cx, |workspace, window, cx| {
-            NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
+        .update(cx, |multi, window, cx| {
+            multi.workspace().update(cx, |workspace, cx| {
+                NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx);
+            });
         })
         .unwrap();
 

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

@@ -1113,8 +1113,8 @@ async fn test_stack_frame_filter_persistence(
     let workspace = init_test_workspace(&project, cx).await;
     let cx = &mut VisualTestContext::from_window(*workspace, cx);
     workspace
-        .update(cx, |workspace, _, _| {
-            workspace.set_random_database_id();
+        .update(cx, |workspace, _, cx| {
+            workspace.set_random_database_id(cx);
         })
         .unwrap();
 
@@ -1211,7 +1211,7 @@ async fn test_stack_frame_filter_persistence(
     cx.run_until_parked();
 
     let workspace_id = workspace
-        .update(cx, |workspace, _window, _cx| workspace.database_id())
+        .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/dev_container/Cargo.toml 🔗

@@ -23,7 +23,12 @@ util.workspace = true
 workspace.workspace = true
 
 [dev-dependencies]
+fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+settings = { workspace = true, features = ["test-support"] }
+theme.workspace = true
+workspace = { workspace = true, features = ["test-support"] }
 
 [lints]
 workspace = true

crates/dev_container/src/devcontainer_api.rs 🔗

@@ -2,18 +2,16 @@ use std::{
     collections::{HashMap, HashSet},
     fmt::Display,
     path::{Path, PathBuf},
-    sync::Arc,
 };
 
-use gpui::AsyncWindowContext;
 use node_runtime::NodeRuntime;
 use serde::Deserialize;
-use settings::{DevContainerConnection, Settings as _};
+use settings::DevContainerConnection;
 use smol::{fs, process::Command};
 use util::rel_path::RelPath;
 use workspace::Workspace;
 
-use crate::{DevContainerFeature, DevContainerSettings, DevContainerTemplate};
+use crate::{DevContainerContext, DevContainerFeature, DevContainerTemplate};
 
 /// Represents a discovered devcontainer configuration
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -59,6 +57,31 @@ pub(crate) struct DevContainerConfigurationOutput {
     configuration: DevContainerConfiguration,
 }
 
+pub(crate) struct DevContainerCli {
+    pub path: PathBuf,
+    node_runtime_path: Option<PathBuf>,
+}
+
+impl DevContainerCli {
+    fn command(&self, use_podman: bool) -> Command {
+        let mut command = if let Some(node_runtime_path) = &self.node_runtime_path {
+            let mut command = util::command::new_smol_command(
+                node_runtime_path.as_os_str().display().to_string(),
+            );
+            command.arg(self.path.display().to_string());
+            command
+        } else {
+            util::command::new_smol_command(self.path.display().to_string())
+        };
+
+        if use_podman {
+            command.arg("--docker-path");
+            command.arg("podman");
+        }
+        command
+    }
+}
+
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub enum DevContainerError {
     DockerNotAvailable,
@@ -99,58 +122,6 @@ impl Display for DevContainerError {
     }
 }
 
-pub(crate) async fn read_devcontainer_configuration_for_project(
-    cx: &mut AsyncWindowContext,
-    node_runtime: &NodeRuntime,
-) -> Result<DevContainerConfigurationOutput, DevContainerError> {
-    let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
-
-    let Some(directory) = project_directory(cx) else {
-        return Err(DevContainerError::NotInValidProject);
-    };
-
-    devcontainer_read_configuration(
-        &path_to_devcontainer_cli,
-        found_in_path,
-        node_runtime,
-        &directory,
-        None,
-        use_podman(cx),
-    )
-    .await
-}
-
-pub(crate) async fn apply_dev_container_template(
-    template: &DevContainerTemplate,
-    options_selected: &HashMap<String, String>,
-    features_selected: &HashSet<DevContainerFeature>,
-    cx: &mut AsyncWindowContext,
-    node_runtime: &NodeRuntime,
-) -> Result<DevContainerApply, DevContainerError> {
-    let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
-
-    let Some(directory) = project_directory(cx) else {
-        return Err(DevContainerError::NotInValidProject);
-    };
-
-    devcontainer_template_apply(
-        template,
-        options_selected,
-        features_selected,
-        &path_to_devcontainer_cli,
-        found_in_path,
-        node_runtime,
-        &directory,
-        false, // devcontainer template apply does not use --docker-path option
-    )
-    .await
-}
-
-fn use_podman(cx: &mut AsyncWindowContext) -> bool {
-    cx.update(|_, cx| DevContainerSettings::get_global(cx).use_podman)
-        .unwrap_or(false)
-}
-
 /// Finds all available devcontainer configurations in the project.
 ///
 /// This function scans for:
@@ -158,160 +129,124 @@ fn use_podman(cx: &mut AsyncWindowContext) -> bool {
 /// 2. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
 ///
 /// Returns a list of found configurations, or an empty list if none are found.
-pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec<DevContainerConfig> {
-    let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
-        log::debug!("find_devcontainer_configs: No workspace found");
-        return Vec::new();
-    };
-
-    let Ok(configs) = workspace.update(cx, |workspace, _, cx| {
-        let project = workspace.project().read(cx);
+pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec<DevContainerConfig> {
+    let project = workspace.project().read(cx);
 
-        let worktree = project
-            .visible_worktrees(cx)
-            .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
+    let worktree = project
+        .visible_worktrees(cx)
+        .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
 
-        let Some(worktree) = worktree else {
-            log::debug!("find_devcontainer_configs: No worktree found");
-            return Vec::new();
-        };
+    let Some(worktree) = worktree else {
+        log::debug!("find_devcontainer_configs: No worktree found");
+        return Vec::new();
+    };
 
-        let worktree = worktree.read(cx);
-        let mut configs = Vec::new();
+    let worktree = worktree.read(cx);
+    let mut configs = Vec::new();
 
-        let devcontainer_path = RelPath::unix(".devcontainer").expect("valid path");
+    let devcontainer_path = RelPath::unix(".devcontainer").expect("valid path");
 
-        let Some(devcontainer_entry) = worktree.entry_for_path(devcontainer_path) else {
-            log::debug!("find_devcontainer_configs: .devcontainer directory not found in worktree");
-            return Vec::new();
-        };
+    let Some(devcontainer_entry) = worktree.entry_for_path(devcontainer_path) else {
+        log::debug!("find_devcontainer_configs: .devcontainer directory not found in worktree");
+        return Vec::new();
+    };
 
-        if !devcontainer_entry.is_dir() {
-            log::debug!("find_devcontainer_configs: .devcontainer is not a directory");
-            return Vec::new();
-        }
+    if !devcontainer_entry.is_dir() {
+        log::debug!("find_devcontainer_configs: .devcontainer is not a directory");
+        return Vec::new();
+    }
 
-        log::debug!("find_devcontainer_configs: Scanning .devcontainer directory");
-        let devcontainer_json_path =
-            RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
-        for entry in worktree.child_entries(devcontainer_path) {
-            log::debug!(
-                "find_devcontainer_configs: Found entry: {:?}, is_file: {}, is_dir: {}",
-                entry.path.as_unix_str(),
-                entry.is_file(),
-                entry.is_dir()
-            );
+    log::debug!("find_devcontainer_configs: Scanning .devcontainer directory");
+    let devcontainer_json_path =
+        RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
+    for entry in worktree.child_entries(devcontainer_path) {
+        log::debug!(
+            "find_devcontainer_configs: Found entry: {:?}, is_file: {}, is_dir: {}",
+            entry.path.as_unix_str(),
+            entry.is_file(),
+            entry.is_dir()
+        );
 
-            if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
-                log::debug!("find_devcontainer_configs: Found default devcontainer.json");
-                configs.push(DevContainerConfig::default_config());
-            } else if entry.is_dir() {
-                let subfolder_name = entry
-                    .path
-                    .file_name()
-                    .map(|n| n.to_string())
-                    .unwrap_or_default();
-
-                let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str());
-                if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
-                    if worktree.entry_for_path(rel_config_path).is_some() {
-                        log::debug!(
-                            "find_devcontainer_configs: Found config in subfolder: {}",
-                            subfolder_name
-                        );
-                        configs.push(DevContainerConfig {
-                            name: subfolder_name,
-                            config_path: PathBuf::from(&config_json_path),
-                        });
-                    } else {
-                        log::debug!(
-                            "find_devcontainer_configs: Subfolder {} has no devcontainer.json",
-                            subfolder_name
-                        );
-                    }
+        if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
+            log::debug!("find_devcontainer_configs: Found default devcontainer.json");
+            configs.push(DevContainerConfig::default_config());
+        } else if entry.is_dir() {
+            let subfolder_name = entry
+                .path
+                .file_name()
+                .map(|n| n.to_string())
+                .unwrap_or_default();
+
+            let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str());
+            if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
+                if worktree.entry_for_path(rel_config_path).is_some() {
+                    log::debug!(
+                        "find_devcontainer_configs: Found config in subfolder: {}",
+                        subfolder_name
+                    );
+                    configs.push(DevContainerConfig {
+                        name: subfolder_name,
+                        config_path: PathBuf::from(&config_json_path),
+                    });
+                } else {
+                    log::debug!(
+                        "find_devcontainer_configs: Subfolder {} has no devcontainer.json",
+                        subfolder_name
+                    );
                 }
             }
         }
+    }
 
-        log::info!(
-            "find_devcontainer_configs: Found {} configurations",
-            configs.len()
-        );
-
-        configs.sort_by(|a, b| {
-            if a.name == "default" {
-                std::cmp::Ordering::Less
-            } else if b.name == "default" {
-                std::cmp::Ordering::Greater
-            } else {
-                a.name.cmp(&b.name)
-            }
-        });
+    log::info!(
+        "find_devcontainer_configs: Found {} configurations",
+        configs.len()
+    );
 
-        configs
-    }) else {
-        log::debug!("find_devcontainer_configs: Failed to update workspace");
-        return Vec::new();
-    };
+    configs.sort_by(|a, b| {
+        if a.name == "default" {
+            std::cmp::Ordering::Less
+        } else if b.name == "default" {
+            std::cmp::Ordering::Greater
+        } else {
+            a.name.cmp(&b.name)
+        }
+    });
 
     configs
 }
 
 pub async fn start_dev_container_with_config(
-    cx: &mut AsyncWindowContext,
-    node_runtime: NodeRuntime,
+    context: DevContainerContext,
     config: Option<DevContainerConfig>,
 ) -> Result<(DevContainerConnection, String), DevContainerError> {
-    let use_podman = use_podman(cx);
-    check_for_docker(use_podman).await?;
+    check_for_docker(context.use_podman).await?;
+    let cli = ensure_devcontainer_cli(&context.node_runtime).await?;
+    let config_path = config.map(|c| context.project_directory.join(&c.config_path));
 
-    let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
-
-    let Some(directory) = project_directory(cx) else {
-        return Err(DevContainerError::NotInValidProject);
-    };
-
-    let config_path = config.map(|c| directory.join(&c.config_path));
-
-    match devcontainer_up(
-        &path_to_devcontainer_cli,
-        found_in_path,
-        &node_runtime,
-        directory.clone(),
-        config_path.clone(),
-        use_podman,
-    )
-    .await
-    {
+    match devcontainer_up(&context, &cli, config_path.as_deref()).await {
         Ok(DevContainerUp {
             container_id,
             remote_workspace_folder,
             remote_user,
             ..
         }) => {
-            let project_name = match devcontainer_read_configuration(
-                &path_to_devcontainer_cli,
-                found_in_path,
-                &node_runtime,
-                &directory,
-                config_path.as_ref(),
-                use_podman,
-            )
-            .await
-            {
-                Ok(DevContainerConfigurationOutput {
-                    configuration:
-                        DevContainerConfiguration {
-                            name: Some(project_name),
-                        },
-                }) => project_name,
-                _ => get_backup_project_name(&remote_workspace_folder, &container_id),
-            };
+            let project_name =
+                match read_devcontainer_configuration(&context, &cli, config_path.as_deref()).await
+                {
+                    Ok(DevContainerConfigurationOutput {
+                        configuration:
+                            DevContainerConfiguration {
+                                name: Some(project_name),
+                            },
+                    }) => project_name,
+                    _ => get_backup_project_name(&remote_workspace_folder, &container_id),
+                };
 
             let connection = DevContainerConnection {
                 name: project_name,
-                container_id: container_id,
-                use_podman,
+                container_id,
+                use_podman: context.use_podman,
                 remote_user,
             };
 
@@ -355,9 +290,9 @@ async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
     }
 }
 
-async fn ensure_devcontainer_cli(
+pub(crate) async fn ensure_devcontainer_cli(
     node_runtime: &NodeRuntime,
-) -> Result<(PathBuf, bool), DevContainerError> {
+) -> Result<DevContainerCli, DevContainerError> {
     let mut command = util::command::new_smol_command(&dev_container_cli());
     command.arg("--version");
 
@@ -395,7 +330,10 @@ async fn ensure_devcontainer_cli(
             Ok(output) => {
                 if output.status.success() {
                     log::info!("Found devcontainer CLI in Data dir");
-                    return Ok((datadir_cli_path.clone(), false));
+                    return Ok(DevContainerCli {
+                        path: datadir_cli_path.clone(),
+                        node_runtime_path: Some(node_runtime_path.clone()),
+                    });
                 } else {
                     log::error!(
                         "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
@@ -435,32 +373,29 @@ async fn ensure_devcontainer_cli(
             );
             Err(DevContainerError::DevContainerCliNotAvailable)
         } else {
-            Ok((datadir_cli_path, false))
+            Ok(DevContainerCli {
+                path: datadir_cli_path,
+                node_runtime_path: Some(node_runtime_path),
+            })
         }
     } else {
         log::info!("Found devcontainer cli on $PATH, using it");
-        Ok((PathBuf::from(&dev_container_cli()), true))
+        Ok(DevContainerCli {
+            path: PathBuf::from(&dev_container_cli()),
+            node_runtime_path: None,
+        })
     }
 }
 
 async fn devcontainer_up(
-    path_to_cli: &PathBuf,
-    found_in_path: bool,
-    node_runtime: &NodeRuntime,
-    path: Arc<Path>,
-    config_path: Option<PathBuf>,
-    use_podman: bool,
+    context: &DevContainerContext,
+    cli: &DevContainerCli,
+    config_path: Option<&Path>,
 ) -> Result<DevContainerUp, DevContainerError> {
-    let Ok(node_runtime_path) = node_runtime.binary_path().await else {
-        log::error!("Unable to find node runtime path");
-        return Err(DevContainerError::NodeRuntimeNotAvailable);
-    };
-
-    let mut command =
-        devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
+    let mut command = cli.command(context.use_podman);
     command.arg("up");
     command.arg("--workspace-folder");
-    command.arg(path.display().to_string());
+    command.arg(context.project_directory.display().to_string());
 
     if let Some(config) = config_path {
         command.arg("--config");
@@ -493,24 +428,15 @@ async fn devcontainer_up(
     }
 }
 
-async fn devcontainer_read_configuration(
-    path_to_cli: &PathBuf,
-    found_in_path: bool,
-    node_runtime: &NodeRuntime,
-    path: &Arc<Path>,
-    config_path: Option<&PathBuf>,
-    use_podman: bool,
+pub(crate) async fn read_devcontainer_configuration(
+    context: &DevContainerContext,
+    cli: &DevContainerCli,
+    config_path: Option<&Path>,
 ) -> Result<DevContainerConfigurationOutput, DevContainerError> {
-    let Ok(node_runtime_path) = node_runtime.binary_path().await else {
-        log::error!("Unable to find node runtime path");
-        return Err(DevContainerError::NodeRuntimeNotAvailable);
-    };
-
-    let mut command =
-        devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
+    let mut command = cli.command(context.use_podman);
     command.arg("read-configuration");
     command.arg("--workspace-folder");
-    command.arg(path.display().to_string());
+    command.arg(context.project_directory.display().to_string());
 
     if let Some(config) = config_path {
         command.arg("--config");
@@ -540,23 +466,14 @@ async fn devcontainer_read_configuration(
     }
 }
 
-async fn devcontainer_template_apply(
+pub(crate) async fn apply_dev_container_template(
     template: &DevContainerTemplate,
     template_options: &HashMap<String, String>,
     features_selected: &HashSet<DevContainerFeature>,
-    path_to_cli: &PathBuf,
-    found_in_path: bool,
-    node_runtime: &NodeRuntime,
-    path: &Arc<Path>,
-    use_podman: bool,
+    context: &DevContainerContext,
+    cli: &DevContainerCli,
 ) -> Result<DevContainerApply, DevContainerError> {
-    let Ok(node_runtime_path) = node_runtime.binary_path().await else {
-        log::error!("Unable to find node runtime path");
-        return Err(DevContainerError::NodeRuntimeNotAvailable);
-    };
-
-    let mut command =
-        devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
+    let mut command = cli.command(context.use_podman);
 
     let Ok(serialized_options) = serde_json::to_string(template_options) else {
         log::error!("Unable to serialize options for {:?}", template_options);
@@ -566,7 +483,7 @@ async fn devcontainer_template_apply(
     command.arg("templates");
     command.arg("apply");
     command.arg("--workspace-folder");
-    command.arg(path.display().to_string());
+    command.arg(context.project_directory.display().to_string());
     command.arg("--template-id");
     command.arg(format!(
         "{}/{}",
@@ -630,28 +547,6 @@ fn parse_json_from_cli<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, D
         })
 }
 
-fn devcontainer_cli_command(
-    path_to_cli: &PathBuf,
-    found_in_path: bool,
-    node_runtime_path: &PathBuf,
-    use_podman: bool,
-) -> Command {
-    let mut command = if found_in_path {
-        util::command::new_smol_command(path_to_cli.display().to_string())
-    } else {
-        let mut command =
-            util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
-        command.arg(path_to_cli.display().to_string());
-        command
-    };
-
-    if use_podman {
-        command.arg("--docker-path");
-        command.arg("podman");
-    }
-    command
-}
-
 fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
     Path::new(remote_workspace_folder)
         .file_name()
@@ -660,22 +555,6 @@ fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) ->
         .unwrap_or_else(|| container_id.to_string())
 }
 
-fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
-    let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
-        return None;
-    };
-
-    match workspace.update(cx, |workspace, _, cx| {
-        workspace.project().read(cx).active_project_directory(cx)
-    }) {
-        Ok(dir) => dir,
-        Err(e) => {
-            log::error!("Error getting project directory from workspace: {:?}", e);
-            None
-        }
-    }
-}
-
 fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -> String {
     let features_map = features_selected
         .iter()
@@ -701,7 +580,160 @@ fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -
 
 #[cfg(test)]
 mod tests {
-    use crate::devcontainer_api::{DevContainerUp, parse_json_from_cli};
+    use crate::devcontainer_api::{DevContainerUp, find_devcontainer_configs, parse_json_from_cli};
+    use fs::FakeFs;
+    use gpui::TestAppContext;
+    use project::Project;
+    use serde_json::json;
+    use settings::SettingsStore;
+    use workspace::Workspace;
+
+    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);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_find_devcontainer_configs_no_devcontainer_dir(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/project",
+            json!({
+                "src": { "main.rs": "fn main() {}" }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, ["/project".as_ref()], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+
+        let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx));
+        assert!(
+            configs.is_empty(),
+            "Expected no configs when .devcontainer dir is absent, got: {configs:?}"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_find_devcontainer_configs_single_default(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/project",
+            json!({
+                ".devcontainer": {
+                    "devcontainer.json": r#"{"image": "ubuntu"}"#
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, ["/project".as_ref()], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+
+        let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx));
+        assert_eq!(
+            configs.len(),
+            1,
+            "Expected exactly one config, got: {configs:?}"
+        );
+        assert_eq!(configs[0].name, "default");
+        assert_eq!(
+            configs[0].config_path.to_str().unwrap(),
+            ".devcontainer/devcontainer.json"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_find_devcontainer_configs_multiple_subfolders(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/project",
+            json!({
+                ".devcontainer": {
+                    "python": { "devcontainer.json": r#"{}"# },
+                    "node": { "devcontainer.json": r#"{}"# }
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, ["/project".as_ref()], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+
+        let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx));
+        assert_eq!(configs.len(), 2, "Expected two configs, got: {configs:?}");
+        assert_eq!(configs[0].name, "node");
+        assert_eq!(configs[1].name, "python");
+    }
+
+    #[gpui::test]
+    async fn test_find_devcontainer_configs_default_plus_subfolders(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/project",
+            json!({
+                ".devcontainer": {
+                    "devcontainer.json": r#"{}"#,
+                    "python": { "devcontainer.json": r#"{}"# },
+                    "node": { "devcontainer.json": r#"{}"# }
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, ["/project".as_ref()], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+
+        let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx));
+        assert_eq!(configs.len(), 3, "Expected three configs, got: {configs:?}");
+        assert_eq!(
+            configs[0].name, "default",
+            "Default config should be sorted first"
+        );
+        assert_eq!(configs[1].name, "node");
+        assert_eq!(configs[2].name, "python");
+    }
+
+    #[gpui::test]
+    async fn test_find_devcontainer_configs_subfolder_without_json(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            "/project",
+            json!({
+                ".devcontainer": {
+                    "devcontainer.json": r#"{}"#,
+                    "has_config": { "devcontainer.json": r#"{}"# },
+                    "no_config": { "README.md": "not a devcontainer" }
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, ["/project".as_ref()], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
+
+        let configs = cx.read(|cx| find_devcontainer_configs(workspace.read(cx), cx));
+        assert_eq!(
+            configs.len(),
+            2,
+            "Subfolder without devcontainer.json should be skipped, got: {configs:?}"
+        );
+        assert_eq!(configs[0].name, "default");
+        assert_eq!(configs[1].name, "has_config");
+    }
 
     #[test]
     fn should_parse_from_devcontainer_json() {

crates/dev_container/src/lib.rs 🔗

@@ -1,3 +1,5 @@
+use std::path::Path;
+
 use gpui::AppContext;
 use gpui::Entity;
 use gpui::Task;
@@ -41,7 +43,8 @@ use http_client::{AsyncBody, HttpClient};
 
 mod devcontainer_api;
 
-use devcontainer_api::read_devcontainer_configuration_for_project;
+use devcontainer_api::ensure_devcontainer_cli;
+use devcontainer_api::read_devcontainer_configuration;
 
 use crate::devcontainer_api::DevContainerError;
 use crate::devcontainer_api::apply_dev_container_template;
@@ -50,11 +53,34 @@ pub use devcontainer_api::{
     DevContainerConfig, find_devcontainer_configs, start_dev_container_with_config,
 };
 
+pub struct DevContainerContext {
+    pub project_directory: Arc<Path>,
+    pub use_podman: bool,
+    pub node_runtime: node_runtime::NodeRuntime,
+}
+
+impl DevContainerContext {
+    pub fn from_workspace(workspace: &Workspace, cx: &App) -> Option<Self> {
+        let project_directory = workspace.project().read(cx).active_project_directory(cx)?;
+        let use_podman = DevContainerSettings::get_global(cx).use_podman;
+        let node_runtime = workspace.app_state().node_runtime.clone();
+        Some(Self {
+            project_directory,
+            use_podman,
+            node_runtime,
+        })
+    }
+}
+
 #[derive(RegisterSetting)]
 struct DevContainerSettings {
     use_podman: bool,
 }
 
+pub fn use_podman(cx: &App) -> bool {
+    DevContainerSettings::get_global(cx).use_podman
+}
+
 impl Settings for DevContainerSettings {
     fn from_settings(content: &settings::SettingsContent) -> Self {
         Self {
@@ -1419,22 +1445,41 @@ fn dispatch_apply_templates(
     cx: &mut Context<DevContainerModal>,
 ) {
     cx.spawn_in(window, async move |this, cx| {
-        if let Some(tree_id) = workspace.update(cx, |workspace, cx| {
-            let project = workspace.project().clone();
-            let worktree = project.read(cx).visible_worktrees(cx).find_map(|tree| {
-                tree.read(cx)
-                    .root_entry()?
-                    .is_dir()
-                    .then_some(tree.read(cx))
-            });
-            worktree.map(|w| w.id())
-        }) {
-            let node_runtime = workspace.read_with(cx, |workspace, _| {
-                workspace.app_state().node_runtime.clone()
-            });
+        let Some((tree_id, context)) = workspace.update(cx, |workspace, cx| {
+            let worktree = workspace
+                .project()
+                .read(cx)
+                .visible_worktrees(cx)
+                .find_map(|tree| {
+                    tree.read(cx)
+                        .root_entry()?
+                        .is_dir()
+                        .then_some(tree.read(cx))
+                });
+            let tree_id = worktree.map(|w| w.id())?;
+            let context = DevContainerContext::from_workspace(workspace, cx)?;
+            Some((tree_id, context))
+        }) else {
+            return;
+        };
 
+        let Ok(cli) = ensure_devcontainer_cli(&context.node_runtime).await else {
+            this.update_in(cx, |this, window, cx| {
+                this.accept_message(
+                    DevContainerMessage::FailedToWriteTemplate(
+                        DevContainerError::DevContainerCliNotAvailable,
+                    ),
+                    window,
+                    cx,
+                );
+            })
+            .log_err();
+            return;
+        };
+
+        {
             if check_for_existing
-                && read_devcontainer_configuration_for_project(cx, &node_runtime)
+                && read_devcontainer_configuration(&context, &cli, None)
                     .await
                     .is_ok()
             {
@@ -1453,8 +1498,8 @@ fn dispatch_apply_templates(
                 &template_entry.template,
                 &template_entry.options_selected,
                 &template_entry.features_selected,
-                cx,
-                &node_runtime,
+                &context,
+                &cli,
             )
             .await
             {
@@ -1496,8 +1541,6 @@ fn dispatch_apply_templates(
                 this.dismiss(&menu::Cancel, window, cx);
             })
             .ok();
-        } else {
-            return;
         }
     })
     .detach();

crates/editor/src/editor.rs 🔗

@@ -3105,6 +3105,24 @@ impl Editor {
         self.workspace.as_ref()?.0.upgrade()
     }
 
+    /// Detaches a task and shows an error notification in the workspace if available,
+    /// otherwise just logs the error.
+    pub fn detach_and_notify_err<R, E>(
+        &self,
+        task: Task<Result<R, E>>,
+        window: &mut Window,
+        cx: &mut App,
+    ) where
+        E: std::fmt::Debug + std::fmt::Display + 'static,
+        R: 'static,
+    {
+        if let Some(workspace) = self.workspace() {
+            task.detach_and_notify_err(workspace.downgrade(), window, cx);
+        } else {
+            task.detach_and_log_err(cx);
+        }
+    }
+
     /// Returns the workspace serialization ID if this editor should be serialized.
     fn workspace_serialization_id(&self, _cx: &App) -> Option<WorkspaceId> {
         self.workspace
@@ -11461,8 +11479,8 @@ impl Editor {
         let Some(project) = self.project.clone() else {
             return;
         };
-        self.reload(project, window, cx)
-            .detach_and_notify_err(window, cx);
+        let task = self.reload(project, window, cx);
+        self.detach_and_notify_err(task, window, cx);
     }
 
     pub fn restore_file(

crates/editor/src/element.rs 🔗

@@ -99,7 +99,6 @@ use workspace::{
     CollaboratorId, ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel,
     Workspace,
     item::{BreadcrumbText, Item, ItemBufferKind},
-    notifications::NotifyTaskExt,
 };
 
 /// Determines what kinds of highlights should be applied to a lines background.
@@ -541,21 +540,21 @@ impl EditorElement {
 
         register_action(editor, window, |editor, action, window, cx| {
             if let Some(task) = editor.format(action, window, cx) {
-                task.detach_and_notify_err(window, cx);
+                editor.detach_and_notify_err(task, window, cx);
             } else {
                 cx.propagate();
             }
         });
         register_action(editor, window, |editor, action, window, cx| {
             if let Some(task) = editor.format_selections(action, window, cx) {
-                task.detach_and_notify_err(window, cx);
+                editor.detach_and_notify_err(task, window, cx);
             } else {
                 cx.propagate();
             }
         });
         register_action(editor, window, |editor, action, window, cx| {
             if let Some(task) = editor.organize_imports(action, window, cx) {
-                task.detach_and_notify_err(window, cx);
+                editor.detach_and_notify_err(task, window, cx);
             } else {
                 cx.propagate();
             }
@@ -565,49 +564,49 @@ impl EditorElement {
         register_action(editor, window, Editor::show_character_palette);
         register_action(editor, window, |editor, action, window, cx| {
             if let Some(task) = editor.confirm_completion(action, window, cx) {
-                task.detach_and_notify_err(window, cx);
+                editor.detach_and_notify_err(task, window, cx);
             } else {
                 cx.propagate();
             }
         });
         register_action(editor, window, |editor, action, window, cx| {
             if let Some(task) = editor.confirm_completion_replace(action, window, cx) {
-                task.detach_and_notify_err(window, cx);
+                editor.detach_and_notify_err(task, window, cx);
             } else {
                 cx.propagate();
             }
         });
         register_action(editor, window, |editor, action, window, cx| {
             if let Some(task) = editor.confirm_completion_insert(action, window, cx) {
-                task.detach_and_notify_err(window, cx);
+                editor.detach_and_notify_err(task, window, cx);
             } else {
                 cx.propagate();
             }
         });
         register_action(editor, window, |editor, action, window, cx| {
             if let Some(task) = editor.compose_completion(action, window, cx) {
-                task.detach_and_notify_err(window, cx);
+                editor.detach_and_notify_err(task, window, cx);
             } else {
                 cx.propagate();
             }
         });
         register_action(editor, window, |editor, action, window, cx| {
             if let Some(task) = editor.confirm_code_action(action, window, cx) {
-                task.detach_and_notify_err(window, cx);
+                editor.detach_and_notify_err(task, window, cx);
             } else {
                 cx.propagate();
             }
         });
         register_action(editor, window, |editor, action, window, cx| {
             if let Some(task) = editor.rename(action, window, cx) {
-                task.detach_and_notify_err(window, cx);
+                editor.detach_and_notify_err(task, window, cx);
             } else {
                 cx.propagate();
             }
         });
         register_action(editor, window, |editor, action, window, cx| {
             if let Some(task) = editor.confirm_rename(action, window, cx) {
-                task.detach_and_notify_err(window, cx);
+                editor.detach_and_notify_err(task, window, cx);
             } else {
                 cx.propagate();
             }

crates/feature_flags/src/flags.rs 🔗

@@ -16,6 +16,10 @@ pub struct AgentV2FeatureFlag;
 
 impl FeatureFlag for AgentV2FeatureFlag {
     const NAME: &'static str = "agent-v2";
+
+    fn enabled_for_staff() -> bool {
+        true
+    }
 }
 
 pub struct AcpBetaFeatureFlag;

crates/file_finder/src/file_finder.rs 🔗

@@ -1566,9 +1566,12 @@ impl PickerDelegate for FileFinderDelegate {
                 .unwrap_or(0)
                 .saturating_sub(1);
             let finder = self.file_finder.clone();
+            let workspace = self.workspace.clone();
 
-            cx.spawn_in(window, async move |_, cx| {
-                let item = open_task.await.notify_async_err(cx)?;
+            cx.spawn_in(window, async move |_, mut cx| {
+                let item = open_task
+                    .await
+                    .notify_workspace_async_err(workspace, &mut cx)?;
                 if let Some(row) = row
                     && let Some(active_editor) = item.downcast::<Editor>()
                 {

crates/file_finder/src/file_finder_tests.rs 🔗

@@ -9,7 +9,9 @@ use project::{FS_WATCH_LATENCY, RemoveOptions};
 use serde_json::json;
 use settings::SettingsStore;
 use util::{path, rel_path::rel_path};
-use workspace::{AppState, CloseActiveItem, OpenOptions, ToggleFileFinder, Workspace, open_paths};
+use workspace::{
+    AppState, CloseActiveItem, MultiWorkspace, OpenOptions, ToggleFileFinder, Workspace, open_paths,
+};
 
 #[ctor::ctor]
 fn init_logger() {
@@ -2534,8 +2536,14 @@ async fn test_search_results_refreshed_on_standalone_file_creation(cx: &mut gpui
         .await;
 
     let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
-    let (workspace, cx) =
-        cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let window = cx.add_window({
+        let project = project.clone();
+        |window, cx| MultiWorkspace::test_new(project, window, cx)
+    });
+    let cx = VisualTestContext::from_window(*window, cx).into_mut();
+    let workspace = window
+        .read_with(cx, |mw, _| mw.workspace().clone())
+        .unwrap();
 
     cx.update(|_, cx| {
         open_paths(

crates/git_ui/src/commit_view.rs 🔗

@@ -774,7 +774,7 @@ impl CommitView {
                 callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?;
                 anyhow::Ok(())
             })
-            .detach_and_notify_err(window, cx);
+            .detach_and_notify_err(workspace.weak_handle(), window, cx);
     }
 
     async fn close_commit_view(

crates/git_ui/src/file_diff_view.rs 🔗

@@ -6,7 +6,7 @@ use editor::{Editor, EditorEvent, MultiBuffer};
 use futures::{FutureExt, select_biased};
 use gpui::{
     AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
-    Focusable, IntoElement, Render, Task, Window,
+    Focusable, IntoElement, Render, Task, WeakEntity, Window,
 };
 use language::{Buffer, LanguageRegistry};
 use project::Project;
@@ -39,11 +39,10 @@ impl FileDiffView {
     pub fn open(
         old_path: PathBuf,
         new_path: PathBuf,
-        workspace: &Workspace,
+        workspace: WeakEntity<Workspace>,
         window: &mut Window,
         cx: &mut App,
     ) -> Task<Result<Entity<Self>>> {
-        let workspace = workspace.weak_handle();
         window.spawn(cx, async move |cx| {
             let project = workspace.update(cx, |workspace, _| workspace.project().clone())?;
             let old_buffer = project
@@ -406,7 +405,7 @@ mod tests {
                 FileDiffView::open(
                     path!("/test/old_file.txt").into(),
                     path!("/test/new_file.txt").into(),
-                    workspace,
+                    workspace.weak_handle(),
                     window,
                     cx,
                 )
@@ -540,7 +539,7 @@ mod tests {
                 FileDiffView::open(
                     PathBuf::from(path!("/test/old_file.txt")),
                     PathBuf::from(path!("/test/new_file.txt")),
-                    workspace,
+                    workspace.weak_handle(),
                     window,
                     cx,
                 )

crates/git_ui/src/git_panel.rs 🔗

@@ -1274,10 +1274,11 @@ impl GitPanel {
                 })
                 .ok()?;
 
+            let workspace = self.workspace.clone();
             cx.spawn_in(window, async move |_, mut cx| {
                 let item = open_task
                     .await
-                    .notify_async_err(&mut cx)
+                    .notify_workspace_async_err(workspace, &mut cx)
                     .ok_or_else(|| anyhow::anyhow!("Failed to open file"))?;
                 if let Some(active_editor) = item.downcast::<Editor>() {
                     if let Some(diff_task) =

crates/git_ui/src/project_diff.rs 🔗

@@ -124,6 +124,7 @@ impl ProjectDiff {
             return;
         }
         let workspace = cx.entity();
+        let workspace_weak = workspace.downgrade();
         window
             .spawn(cx, async move |cx| {
                 let this = cx
@@ -138,7 +139,7 @@ impl ProjectDiff {
                     .ok();
                 anyhow::Ok(())
             })
-            .detach_and_notify_err(window, cx);
+            .detach_and_notify_err(workspace_weak, window, cx);
     }
 
     pub fn deploy_at(

crates/git_ui/src/worktree_picker.rs 🔗

@@ -4,8 +4,8 @@ use fuzzy::StringMatchCandidate;
 
 use git::repository::Worktree as GitWorktree;
 use gpui::{
-    Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
-    InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement,
+    Action, App, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle,
+    Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement,
     PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window,
     actions, rems,
 };
@@ -20,7 +20,7 @@ use remote_connection::{RemoteConnectionModal, connect};
 use std::{path::PathBuf, sync::Arc};
 use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
 use util::ResultExt;
-use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
+use workspace::{ModalView, MultiWorkspace, Workspace, notifications::DetachAndPromptErr};
 
 actions!(git, [WorktreeFromDefault, WorktreeFromDefaultOnWindow]);
 
@@ -289,7 +289,6 @@ impl WorktreeListDelegate {
         };
 
         let branch = worktree_branch.to_string();
-        let window_handle = window.window_handle();
         let workspace = self.workspace.clone();
         cx.spawn_in(window, async move |_, cx| {
             let Some(paths) = worktree_path.await? else {
@@ -355,7 +354,7 @@ impl WorktreeListDelegate {
                     connection_options,
                     vec![new_worktree_path],
                     app_state,
-                    window_handle,
+                    workspace.clone(),
                     replace_current_window,
                     cx,
                 )
@@ -407,13 +406,12 @@ impl WorktreeListDelegate {
                 |e, _, _| Some(e.to_string()),
             );
         } else if let Some(connection_options) = connection_options {
-            let window_handle = window.window_handle();
             cx.spawn_in(window, async move |_, cx| {
                 open_remote_worktree(
                     connection_options,
                     vec![path],
                     app_state,
-                    window_handle,
+                    workspace,
                     replace_current_window,
                     cx,
                 )
@@ -441,15 +439,16 @@ async fn open_remote_worktree(
     connection_options: RemoteConnectionOptions,
     paths: Vec<PathBuf>,
     app_state: Arc<workspace::AppState>,
-    window: gpui::AnyWindowHandle,
+    workspace: WeakEntity<Workspace>,
     replace_current_window: bool,
-    cx: &mut AsyncApp,
+    cx: &mut AsyncWindowContext,
 ) -> anyhow::Result<()> {
-    let workspace_window = window
-        .downcast::<Workspace>()
+    let workspace_window = cx
+        .window_handle()
+        .downcast::<MultiWorkspace>()
         .ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?;
 
-    let connect_task = workspace_window.update(cx, |workspace, window, cx| {
+    let connect_task = workspace.update_in(cx, |workspace, window, cx| {
         workspace.toggle_modal(window, cx, |window, cx| {
             RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
         });
@@ -473,17 +472,19 @@ async fn open_remote_worktree(
 
     let session = connect_task.await;
 
-    workspace_window.update(cx, |workspace, _window, cx| {
-        if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
-            prompt.update(cx, |prompt, cx| prompt.finished(cx))
-        }
-    })?;
+    workspace
+        .update_in(cx, |workspace, _window, cx| {
+            if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
+                prompt.update(cx, |prompt, cx| prompt.finished(cx))
+            }
+        })
+        .ok();
 
     let Some(Some(session)) = session else {
         return Ok(());
     };
 
-    let new_project: Entity<project::Project> = cx.update(|cx| {
+    let new_project: Entity<project::Project> = cx.update(|_, cx| {
         project::Project::remote(
             session,
             app_state.client.clone(),
@@ -494,29 +495,30 @@ async fn open_remote_worktree(
             true,
             cx,
         )
-    });
+    })?;
 
     let window_to_use = if replace_current_window {
         workspace_window
     } else {
         let workspace_position = cx
-            .update(|cx| {
+            .update(|_, cx| {
                 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
-            })
+            })?
             .await
             .context("fetching workspace position from db")?;
 
         let mut options =
-            cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
+            cx.update(|_, cx| (app_state.build_window_options)(workspace_position.display, cx))?;
         options.window_bounds = workspace_position.window_bounds;
 
         cx.open_window(options, |window, cx| {
-            cx.new(|cx| {
+            let workspace = cx.new(|cx| {
                 let mut workspace =
                     Workspace::new(None, new_project.clone(), app_state.clone(), window, cx);
                 workspace.centered_layout = workspace_position.centered_layout;
                 workspace
-            })
+            });
+            cx.new(|cx| MultiWorkspace::new(workspace, cx))
         })?
     };
 

crates/icons/src/icons.rs 🔗

@@ -265,6 +265,8 @@ pub enum IconName {
     UserRoundPen,
     Warning,
     WholeWord,
+    WorkspaceNavClosed,
+    WorkspaceNavOpen,
     XCircle,
     XCircleFilled,
     ZedAgent,

crates/inspector_ui/Cargo.toml 🔗

@@ -18,7 +18,6 @@ editor.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
 language.workspace = true
-platform_title_bar.workspace = true
 project.workspace = true
 serde_json.workspace = true
 serde_json_lenient.workspace = true

crates/inspector_ui/src/inspector.rs 🔗

@@ -1,8 +1,7 @@
 use anyhow::{Context as _, anyhow};
 use gpui::{App, DivInspectorState, Inspector, InspectorElementId, IntoElement, Window};
-use platform_title_bar::PlatformTitleBar;
 use std::{cell::OnceCell, path::Path, sync::Arc};
-use ui::{Label, Tooltip, prelude::*};
+use ui::{Label, Tooltip, prelude::*, utils::platform_title_bar_height};
 use util::{ResultExt as _, command::new_smol_command};
 use workspace::AppState;
 
@@ -61,7 +60,7 @@ fn render_inspector(
     let ui_font = theme::setup_ui_font(window, cx);
     let colors = cx.theme().colors();
     let inspector_id = inspector.active_element_id();
-    let toolbar_height = PlatformTitleBar::height(window);
+    let toolbar_height = platform_title_bar_height(window);
 
     v_flex()
         .size_full()

crates/journal/src/journal.rs 🔗

@@ -118,17 +118,20 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap
                     })?
                     .await?;
                 new_workspace
-                    .update(cx, |workspace, window, cx| {
-                        workspace.open_paths(
-                            vec![entry_path],
-                            workspace::OpenOptions {
-                                visible: Some(OpenVisible::All),
-                                ..Default::default()
-                            },
-                            None,
-                            window,
-                            cx,
-                        )
+                    .update(cx, |multi_workspace, window, cx| {
+                        let workspace = multi_workspace.workspace().clone();
+                        workspace.update(cx, |workspace, cx| {
+                            workspace.open_paths(
+                                vec![entry_path],
+                                workspace::OpenOptions {
+                                    visible: Some(OpenVisible::All),
+                                    ..Default::default()
+                                },
+                                None,
+                                window,
+                                cx,
+                            )
+                        })
                     })?
                     .await
             } else {

crates/keymap_editor/src/keymap_editor.rs 🔗

@@ -1319,7 +1319,7 @@ impl KeymapEditor {
         cx.spawn(async move |_, _| {
             remove_keybinding(to_remove, &fs, keyboard_mapper.as_ref()).await
         })
-        .detach_and_notify_err(window, cx);
+        .detach_and_notify_err(self.workspace.clone(), window, cx);
     }
 
     fn copy_context_to_clipboard(

crates/miniprofiler_ui/src/miniprofiler_ui.rs 🔗

@@ -8,7 +8,7 @@ use std::{
 use gpui::{
     App, AppContext, ClipboardItem, Context, Div, Entity, Hsla, InteractiveElement,
     ParentElement as _, Render, SerializedTaskTiming, SharedString, StatefulInteractiveElement,
-    Styled, Task, TaskTiming, TitlebarOptions, UniformListScrollHandle, WindowBounds, WindowHandle,
+    Styled, Task, TaskTiming, TitlebarOptions, UniformListScrollHandle, WeakEntity, WindowBounds,
     WindowOptions, div, prelude::FluentBuilder, px, relative, size, uniform_list,
 };
 use util::ResultExt;
@@ -22,13 +22,10 @@ use workspace::{
 use zed_actions::OpenPerformanceProfiler;
 
 pub fn init(startup_time: Instant, cx: &mut App) {
-    cx.observe_new(move |workspace: &mut workspace::Workspace, _, _| {
-        workspace.register_action(move |workspace, _: &OpenPerformanceProfiler, window, cx| {
-            let window_handle = window
-                .window_handle()
-                .downcast::<Workspace>()
-                .expect("Workspaces are root Windows");
-            open_performance_profiler(startup_time, workspace, window_handle, cx);
+    cx.observe_new(move |workspace: &mut workspace::Workspace, _, cx| {
+        let workspace_handle = cx.entity().downgrade();
+        workspace.register_action(move |_workspace, _: &OpenPerformanceProfiler, window, cx| {
+            open_performance_profiler(startup_time, workspace_handle.clone(), window, cx);
         });
     })
     .detach();
@@ -36,8 +33,8 @@ pub fn init(startup_time: Instant, cx: &mut App) {
 
 fn open_performance_profiler(
     startup_time: Instant,
-    _workspace: &mut workspace::Workspace,
-    workspace_handle: WindowHandle<Workspace>,
+    workspace_handle: WeakEntity<Workspace>,
+    _window: &mut gpui::Window,
     cx: &mut App,
 ) {
     let existing_window = cx
@@ -48,7 +45,7 @@ fn open_performance_profiler(
     if let Some(existing_window) = existing_window {
         existing_window
             .update(cx, |profiler_window, window, _cx| {
-                profiler_window.workspace = Some(workspace_handle);
+                profiler_window.workspace = Some(workspace_handle.clone());
                 window.activate_window();
             })
             .log_err();
@@ -97,14 +94,14 @@ pub struct ProfilerWindow {
     include_self_timings: ToggleState,
     autoscroll: bool,
     scroll_handle: UniformListScrollHandle,
-    workspace: Option<WindowHandle<Workspace>>,
+    workspace: Option<WeakEntity<Workspace>>,
     _refresh: Option<Task<()>>,
 }
 
 impl ProfilerWindow {
     pub fn new(
         startup_time: Instant,
-        workspace_handle: Option<WindowHandle<Workspace>>,
+        workspace_handle: Option<WeakEntity<Workspace>>,
         cx: &mut App,
     ) -> Entity<Self> {
         let entity = cx.new(|cx| ProfilerWindow {
@@ -280,7 +277,7 @@ impl Render for ProfilerWindow {
                                 Button::new("export-data", "Save")
                                     .style(ButtonStyle::Filled)
                                     .on_click(cx.listener(|this, _, _window, cx| {
-                                        let Some(workspace) = this.workspace else {
+                                        let Some(workspace) = this.workspace.as_ref() else {
                                             return;
                                         };
 
@@ -297,7 +294,7 @@ impl Render for ProfilerWindow {
                                             .log_err()
                                             .flatten()
                                             .and_then(|p| p.parent().map(|p| p.to_owned()))
-                                            .unwrap_or_else(|| PathBuf::default());
+                                            .unwrap_or_else(PathBuf::default);
 
                                         let path = cx.prompt_for_new_path(
                                             &active_path,

crates/onboarding/src/onboarding.rs 🔗

@@ -238,15 +238,16 @@ impl Onboarding {
         go_to_welcome_page(cx);
     }
 
-    fn handle_sign_in(_: &SignIn, window: &mut Window, cx: &mut App) {
+    fn handle_sign_in(&mut self, _: &SignIn, window: &mut Window, cx: &mut Context<Self>) {
         let client = Client::global(cx);
+        let workspace = self.workspace.clone();
 
         window
-            .spawn(cx, async move |cx| {
+            .spawn(cx, async move |mut cx| {
                 client
-                    .sign_in_with_optional_connect(true, cx)
+                    .sign_in_with_optional_connect(true, &cx)
                     .await
-                    .notify_async_err(cx);
+                    .notify_workspace_async_err(workspace, &mut cx);
             })
             .detach();
     }
@@ -274,7 +275,7 @@ impl Render for Onboarding {
             .size_full()
             .bg(cx.theme().colors().editor_background)
             .on_action(Self::on_finish)
-            .on_action(Self::handle_sign_in)
+            .on_action(cx.listener(Self::handle_sign_in))
             .on_action(Self::handle_open_account)
             .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| {
                 window.focus_next(cx);

crates/outline/src/outline.rs 🔗

@@ -20,7 +20,7 @@ use settings::Settings;
 use theme::{ActiveTheme, ThemeSettings};
 use ui::{ListItem, ListItemSpacing, prelude::*};
 use util::ResultExt;
-use workspace::{DismissDecision, ModalView, Workspace};
+use workspace::{DismissDecision, ModalView};
 
 pub fn init(cx: &mut App) {
     cx.observe_new(OutlineView::register).detach();
@@ -48,7 +48,8 @@ pub fn toggle(
         .snapshot(cx)
         .outline(Some(cx.theme().syntax()));
 
-    let workspace = window.root::<Workspace>().flatten();
+    let workspace = editor.read(cx).workspace();
+
     if let Some((workspace, outline)) = workspace.zip(outline) {
         workspace.update(cx, |workspace, cx| {
             workspace.toggle_modal(window, cx, |window, cx| {

crates/platform_title_bar/Cargo.toml 🔗

@@ -13,6 +13,7 @@ path = "src/platform_title_bar.rs"
 doctest = false
 
 [dependencies]
+feature_flags.workspace = true
 gpui.workspace = true
 settings.workspace = true
 smallvec.workspace = true

crates/platform_title_bar/src/platform_title_bar.rs 🔗

@@ -1,16 +1,21 @@
 mod platforms;
 mod system_window_tabs;
 
+use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
 use gpui::{
-    AnyElement, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, MouseButton,
-    ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, px,
+    AnyElement, App, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement,
+    MouseButton, ParentElement, StatefulInteractiveElement, Styled, Window, WindowControlArea, div,
+    px,
 };
 use smallvec::SmallVec;
 use std::mem;
-use ui::prelude::*;
+use ui::{
+    prelude::*,
+    utils::{TRAFFIC_LIGHT_PADDING, platform_title_bar_height},
+};
 
 use crate::{
-    platforms::{platform_linux, platform_mac, platform_windows},
+    platforms::{platform_linux, platform_windows},
     system_window_tabs::SystemWindowTabs,
 };
 
@@ -24,6 +29,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 {
@@ -37,20 +44,11 @@ impl PlatformTitleBar {
             children: SmallVec::new(),
             should_move: false,
             system_window_tabs,
+            workspace_sidebar_open: false,
+            sidebar_has_notifications: false,
         }
     }
 
-    #[cfg(not(target_os = "windows"))]
-    pub fn height(window: &mut Window) -> Pixels {
-        (1.75 * window.rem_size()).max(px(34.))
-    }
-
-    #[cfg(target_os = "windows")]
-    pub fn height(_window: &mut Window) -> Pixels {
-        // todo(windows) instead of hard coded size report the actual size to the Windows platform API
-        px(32.)
-    }
-
     pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context<Self>) -> Hsla {
         if cfg!(any(target_os = "linux", target_os = "freebsd")) {
             if window.is_window_active() && !self.should_move {
@@ -73,17 +71,46 @@ impl PlatformTitleBar {
     pub fn init(cx: &mut App) {
         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>()
+    }
 }
 
 impl Render for PlatformTitleBar {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let supported_controls = window.window_controls();
         let decorations = window.window_decorations();
-        let height = Self::height(window);
+        let height = platform_title_bar_height(window);
         let titlebar_color = self.title_bar_color(window, cx);
         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,8 +159,10 @@ impl Render for PlatformTitleBar {
             .map(|this| {
                 if window.is_fullscreen() {
                     this.pl_2()
-                } else if self.platform_style == PlatformStyle::Mac {
-                    this.pl(px(platform_mac::TRAFFIC_LIGHT_PADDING))
+                } else if self.platform_style == PlatformStyle::Mac
+                    && !is_multiworkspace_sidebar_open
+                {
+                    this.pl(px(TRAFFIC_LIGHT_PADDING))
                 } else {
                     this.pl_2()
                 }

crates/platform_title_bar/src/platforms/platform_mac.rs 🔗

@@ -1,10 +0,0 @@
-// Use pixels here instead of a rem-based size because the macOS traffic
-// lights are a static size, and don't scale with the rest of the UI.
-//
-// Magic number: There is one extra pixel of padding on the left side due to
-// the 1px border around the window on macOS apps.
-#[cfg(macos_sdk_26)]
-pub const TRAFFIC_LIGHT_PADDING: f32 = 78.;
-
-#[cfg(not(macos_sdk_26))]
-pub const TRAFFIC_LIGHT_PADDING: f32 = 71.;

crates/project_panel/src/project_panel.rs 🔗

@@ -756,7 +756,11 @@ impl ProjectPanel {
                         {
                             match project_panel.confirm_edit(false, window, cx) {
                                 Some(task) => {
-                                    task.detach_and_notify_err(window, cx);
+                                    task.detach_and_notify_err(
+                                        project_panel.workspace.clone(),
+                                        window,
+                                        cx,
+                                    );
                                 }
                                 None => {
                                     project_panel.discard_edit_state(window, cx);
@@ -1631,7 +1635,7 @@ impl ProjectPanel {
 
     fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(task) = self.confirm_edit(true, window, cx) {
-            task.detach_and_notify_err(window, cx);
+            task.detach_and_notify_err(self.workspace.clone(), window, cx);
         }
     }
 
@@ -3002,20 +3006,25 @@ impl ProjectPanel {
             }
 
             let item_count = paste_tasks.len();
+            let workspace = self.workspace.clone();
 
-            cx.spawn_in(window, async move |project_panel, cx| {
+            cx.spawn_in(window, async move |project_panel, mut cx| {
                 let mut last_succeed = None;
                 for task in paste_tasks {
                     match task {
                         PasteTask::Rename(task) => {
-                            if let Some(CreatedEntry::Included(entry)) =
-                                task.await.notify_async_err(cx)
+                            if let Some(CreatedEntry::Included(entry)) = task
+                                .await
+                                .notify_workspace_async_err(workspace.clone(), &mut cx)
                             {
                                 last_succeed = Some(entry);
                             }
                         }
                         PasteTask::Copy(task) => {
-                            if let Some(Some(entry)) = task.await.notify_async_err(cx) {
+                            if let Some(Some(entry)) = task
+                                .await
+                                .notify_workspace_async_err(workspace.clone(), &mut cx)
+                            {
                                 last_succeed = Some(entry);
                             }
                         }
@@ -3357,7 +3366,7 @@ impl ProjectPanel {
         if let Some((file_path1, file_path2)) = selected_files {
             self.workspace
                 .update(cx, |workspace, cx| {
-                    FileDiffView::open(file_path1, file_path2, workspace, window, cx)
+                    FileDiffView::open(file_path1, file_path2, workspace.weak_handle(), window, cx)
                         .detach_and_log_err(cx);
                 })
                 .ok();

crates/recent_projects/Cargo.toml 🔗

@@ -23,6 +23,7 @@ db.workspace = true
 dev_container.workspace = true
 editor.workspace = true
 extension_host.workspace = true
+fs.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
 gpui.workspace = true
@@ -66,6 +67,7 @@ language = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
 release_channel.workspace = true
 remote = { workspace = true, features = ["test-support"] }
+remote_connection = { workspace = true, features = ["test-support"] }
 remote_server.workspace = true
 serde_json.workspace = true
 settings = { workspace = true, features = ["test-support"] }

crates/recent_projects/src/disconnected_overlay.rs 🔗

@@ -7,7 +7,9 @@ use ui::{
     HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal,
     ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, Window, div, h_flex, rems,
 };
-use workspace::{ModalView, OpenOptions, Workspace, notifications::DetachAndPromptErr};
+use workspace::{
+    ModalView, MultiWorkspace, OpenOptions, Workspace, notifications::DetachAndPromptErr,
+};
 
 use crate::open_remote_project;
 
@@ -109,7 +111,7 @@ impl DisconnectedOverlay {
             return;
         };
 
-        let Some(window_handle) = window.window_handle().downcast::<Workspace>() else {
+        let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
             return;
         };
 

crates/recent_projects/src/recent_projects.rs 🔗

@@ -4,7 +4,9 @@ mod remote_connections;
 mod remote_servers;
 mod ssh_config;
 
-use std::path::PathBuf;
+use std::{path::PathBuf, sync::Arc};
+
+use fs::Fs;
 
 #[cfg(target_os = "windows")]
 mod wsl_picker;
@@ -27,11 +29,11 @@ use picker::{
 pub use remote_connections::RemoteSettings;
 pub use remote_servers::RemoteServerProjects;
 use settings::Settings;
-use std::{path::Path, sync::Arc};
+use std::path::Path;
 use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container};
 use util::{ResultExt, paths::PathExt};
 use workspace::{
-    CloseIntent, HistoryManager, ModalView, OpenOptions, PathList, SerializedWorkspaceLocation,
+    HistoryManager, ModalView, MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation,
     WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr,
     with_active_or_new_workspace,
 };
@@ -48,9 +50,10 @@ pub struct RecentProjectEntry {
 pub async fn get_recent_projects(
     current_workspace_id: Option<WorkspaceId>,
     limit: Option<usize>,
+    fs: Arc<dyn fs::Fs>,
 ) -> Vec<RecentProjectEntry> {
     let workspaces = WORKSPACE_DB
-        .recent_workspaces_on_disk()
+        .recent_workspaces_on_disk(fs.as_ref())
         .await
         .unwrap_or_default();
 
@@ -176,7 +179,7 @@ pub fn init(cx: &mut App) {
             let fs = workspace.project().read(cx).fs().clone();
             add_wsl_distro(fs, &open_wsl.distro, cx);
             let open_options = OpenOptions {
-                replace_window: window.window_handle().downcast::<Workspace>(),
+                replace_window: window.window_handle().downcast::<MultiWorkspace>(),
                 ..Default::default()
             };
 
@@ -232,10 +235,8 @@ pub fn init(cx: &mut App) {
 
     cx.on_action(|_: &OpenDevContainer, cx| {
         with_active_or_new_workspace(cx, move |workspace, window, cx| {
-            let is_local = workspace.project().read(cx).is_local();
-
-            cx.spawn_in(window, async move |_, cx| {
-                if !is_local {
+            if !workspace.project().read(cx).is_local() {
+                cx.spawn_in(window, async move |_, cx| {
                     cx.prompt(
                         gpui::PromptLevel::Critical,
                         "Cannot open Dev Container from remote project",
@@ -244,21 +245,16 @@ pub fn init(cx: &mut App) {
                     )
                     .await
                     .ok();
-                    return;
-                }
-
-                cx.update(|_, cx| {
-                    with_active_or_new_workspace(cx, move |workspace, window, cx| {
-                        let fs = workspace.project().read(cx).fs().clone();
-                        let handle = cx.entity().downgrade();
-                        workspace.toggle_modal(window, cx, |window, cx| {
-                            RemoteServerProjects::new_dev_container(fs, window, handle, cx)
-                        });
-                    });
                 })
-                .log_err();
-            })
-            .detach();
+                .detach();
+                return;
+            }
+
+            let fs = workspace.project().read(cx).fs().clone();
+            let handle = cx.entity().downgrade();
+            workspace.toggle_modal(window, cx, |window, cx| {
+                RemoteServerProjects::new_dev_container(fs, window, handle, cx)
+            });
         });
     });
 
@@ -334,6 +330,7 @@ impl ModalView for RecentProjects {}
 impl RecentProjects {
     fn new(
         delegate: RecentProjectsDelegate,
+        fs: Option<Arc<dyn Fs>>,
         rem_width: f32,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -350,8 +347,9 @@ impl RecentProjects {
         // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
         // out workspace locations once the future runs to completion.
         cx.spawn_in(window, async move |this, cx| {
+            let Some(fs) = fs else { return };
             let workspaces = WORKSPACE_DB
-                .recent_workspaces_on_disk()
+                .recent_workspaces_on_disk(fs.as_ref())
                 .await
                 .log_err()
                 .unwrap_or_default();
@@ -361,7 +359,7 @@ impl RecentProjects {
                     picker.update_matches(picker.query(cx), window, cx)
                 })
             })
-            .ok()
+            .ok();
         })
         .detach();
         Self {
@@ -379,10 +377,11 @@ impl RecentProjects {
         cx: &mut Context<Workspace>,
     ) {
         let weak = cx.entity().downgrade();
+        let fs = Some(workspace.app_state().fs.clone());
         workspace.toggle_modal(window, cx, |window, cx| {
             let delegate = RecentProjectsDelegate::new(weak, create_new_window, true, focus_handle);
 
-            Self::new(delegate, 34., window, cx)
+            Self::new(delegate, fs, 34., window, cx)
         })
     }
 
@@ -393,10 +392,13 @@ impl RecentProjects {
         window: &mut Window,
         cx: &mut App,
     ) -> Entity<Self> {
+        let fs = workspace
+            .upgrade()
+            .map(|ws| ws.read(cx).app_state().fs.clone());
         cx.new(|cx| {
             let delegate =
                 RecentProjectsDelegate::new(workspace, create_new_window, true, focus_handle);
-            let list = Self::new(delegate, 34., window, cx);
+            let list = Self::new(delegate, fs, 34., window, cx);
             list.picker.focus_handle(cx).focus(window, cx);
             list
         })
@@ -580,27 +582,21 @@ impl PickerDelegate for RecentProjectsDelegate {
                     SerializedWorkspaceLocation::Local => {
                         let paths = candidate_workspace_paths.paths().to_vec();
                         if replace_current_window {
-                            cx.spawn_in(window, async move |workspace, cx| {
-                                let continue_replacing = workspace
-                                    .update_in(cx, |workspace, window, cx| {
-                                        workspace.prepare_to_close(
-                                            CloseIntent::ReplaceWindow,
-                                            window,
-                                            cx,
-                                        )
-                                    })?
-                                    .await?;
-                                if continue_replacing {
-                                    workspace
-                                        .update_in(cx, |workspace, window, cx| {
-                                            workspace
-                                                .open_workspace_for_paths(true, paths, window, cx)
-                                        })?
-                                        .await
-                                } else {
-                                    Ok(())
-                                }
-                            })
+                            if let Some(handle) =
+                                window.window_handle().downcast::<MultiWorkspace>()
+                            {
+                                cx.defer(move |cx| {
+                                    if let Some(task) = handle
+                                        .update(cx, |multi_workspace, window, cx| {
+                                            multi_workspace.open_project(paths, window, cx)
+                                        })
+                                        .log_err()
+                                    {
+                                        task.detach_and_log_err(cx);
+                                    }
+                                });
+                            }
+                            return;
                         } else {
                             workspace.open_workspace_for_paths(false, paths, window, cx)
                         }
@@ -609,7 +605,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                         let app_state = workspace.app_state().clone();
 
                         let replace_window = if replace_current_window {
-                            window.window_handle().downcast::<Workspace>()
+                            window.window_handle().downcast::<MultiWorkspace>()
                         } else {
                             None
                         };
@@ -884,10 +880,18 @@ impl RecentProjectsDelegate {
     ) {
         if let Some(selected_match) = self.matches.get(ix) {
             let (workspace_id, _, _) = self.workspaces[selected_match.candidate_id];
+            let fs = self
+                .workspace
+                .upgrade()
+                .map(|ws| ws.read(cx).app_state().fs.clone());
             cx.spawn_in(window, async move |this, cx| {
-                let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
+                WORKSPACE_DB
+                    .delete_workspace_by_id(workspace_id)
+                    .await
+                    .log_err();
+                let Some(fs) = fs else { return };
                 let workspaces = WORKSPACE_DB
-                    .recent_workspaces_on_disk()
+                    .recent_workspaces_on_disk(fs.as_ref())
                     .await
                     .unwrap_or_default();
                 this.update_in(cx, move |picker, window, cx| {
@@ -904,6 +908,7 @@ impl RecentProjectsDelegate {
                             .update(cx, |this, cx| this.delete_history(workspace_id, cx));
                     }
                 })
+                .ok();
             })
             .detach();
         }
@@ -951,7 +956,7 @@ mod tests {
     use super::*;
 
     #[gpui::test]
-    async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) {
+    async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
         let app_state = init_test(cx);
 
         cx.update(|cx| {
@@ -975,6 +980,11 @@ mod tests {
                 }),
             )
             .await;
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/test/path"), json!({}))
+            .await;
         cx.update(|cx| {
             open_paths(
                 &[PathBuf::from(path!("/dir/main.ts"))],
@@ -987,31 +997,40 @@ mod tests {
         .unwrap();
         assert_eq!(cx.update(|cx| cx.windows().len()), 1);
 
-        let workspace = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
-        workspace
-            .update(cx, |workspace, _, _| assert!(!workspace.is_edited()))
+        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
+        multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                assert!(!multi_workspace.workspace().read(cx).is_edited())
+            })
             .unwrap();
 
-        let editor = workspace
-            .read_with(cx, |workspace, cx| {
-                workspace
+        let editor = multi_workspace
+            .read_with(cx, |multi_workspace, cx| {
+                multi_workspace
+                    .workspace()
+                    .read(cx)
                     .active_item(cx)
                     .unwrap()
                     .downcast::<Editor>()
                     .unwrap()
             })
             .unwrap();
-        workspace
+        multi_workspace
             .update(cx, |_, window, cx| {
                 editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
             })
             .unwrap();
-        workspace
-            .update(cx, |workspace, _, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project"))
+        multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                assert!(
+                    multi_workspace.workspace().read(cx).is_edited(),
+                    "After inserting more text into the editor without saving, we should have a dirty project"
+                )
+            })
             .unwrap();
 
-        let recent_projects_picker = open_recent_projects(&workspace, cx);
-        workspace
+        let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
+        multi_workspace
             .update(cx, |_, _, cx| {
                 recent_projects_picker.update(cx, |picker, cx| {
                     assert_eq!(picker.query(cx), "");
@@ -1035,47 +1054,64 @@ mod tests {
             !cx.has_pending_prompt(),
             "Should have no pending prompt on dirty project before opening the new recent project"
         );
-        cx.dispatch_action(*workspace, menu::Confirm);
-        workspace
-            .update(cx, |workspace, _, cx| {
+        let dirty_workspace = multi_workspace
+            .read_with(cx, |multi_workspace, _cx| {
+                multi_workspace.workspace().clone()
+            })
+            .unwrap();
+
+        cx.dispatch_action(*multi_workspace, menu::Confirm);
+        cx.run_until_parked();
+
+        multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
                 assert!(
-                    workspace.active_modal::<RecentProjects>(cx).is_none(),
+                    multi_workspace
+                        .workspace()
+                        .read(cx)
+                        .active_modal::<RecentProjects>(cx)
+                        .is_none(),
                     "Should remove the modal after selecting new recent project"
-                )
+                );
+
+                assert!(
+                    multi_workspace.workspaces().len() >= 2,
+                    "Should have at least 2 workspaces: the dirty one and the newly opened one"
+                );
+
+                assert!(
+                    multi_workspace.workspaces().contains(&dirty_workspace),
+                    "The original dirty workspace should still be present"
+                );
+
+                assert!(
+                    dirty_workspace.read(cx).is_edited(),
+                    "The original workspace should still be dirty"
+                );
             })
             .unwrap();
-        assert!(
-            cx.has_pending_prompt(),
-            "Dirty workspace should prompt before opening the new recent project"
-        );
-        cx.simulate_prompt_answer("Cancel");
+
         assert!(
             !cx.has_pending_prompt(),
-            "Should have no pending prompt after cancelling"
+            "No save prompt in multi-workspace mode — dirty workspace survives in background"
         );
-        workspace
-            .update(cx, |workspace, _, _| {
-                assert!(
-                    workspace.is_edited(),
-                    "Should be in the same dirty project after cancelling"
-                )
-            })
-            .unwrap();
     }
 
     fn open_recent_projects(
-        workspace: &WindowHandle<Workspace>,
+        multi_workspace: &WindowHandle<MultiWorkspace>,
         cx: &mut TestAppContext,
     ) -> Entity<Picker<RecentProjectsDelegate>> {
         cx.dispatch_action(
-            (*workspace).into(),
+            (*multi_workspace).into(),
             OpenRecent {
                 create_new_window: false,
             },
         );
-        workspace
-            .update(cx, |workspace, _, cx| {
-                workspace
+        multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace
+                    .workspace()
+                    .read(cx)
                     .active_modal::<RecentProjects>(cx)
                     .unwrap()
                     .read(cx)

crates/recent_projects/src/remote_connections.rs 🔗

@@ -19,7 +19,7 @@ use remote::{
 pub use settings::SshConnection;
 use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
 use util::paths::PathWithPosition;
-use workspace::{AppState, Workspace};
+use workspace::{AppState, MultiWorkspace, Workspace};
 
 pub use remote_connection::{
     RemoteClientDelegate, RemoteConnectionModal, RemoteConnectionPrompt, SshConnectionHeader,
@@ -131,8 +131,11 @@ pub async fn open_remote_project(
     cx: &mut AsyncApp,
 ) -> Result<()> {
     let created_new_window = open_options.replace_window.is_none();
-    let window = if let Some(window) = open_options.replace_window {
-        window
+    let (window, initial_workspace) = if let Some(window) = open_options.replace_window {
+        let workspace = window.update(cx, |multi_workspace, _, _| {
+            multi_workspace.workspace().clone()
+        })?;
+        (window, workspace)
     } else {
         let workspace_position = cx
             .update(|cx| {
@@ -145,7 +148,7 @@ pub async fn open_remote_project(
             cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
         options.window_bounds = workspace_position.window_bounds;
 
-        cx.open_window(options, |window, cx| {
+        let window = cx.open_window(options, |window, cx| {
             let project = project::Project::local(
                 app_state.client.clone(),
                 app_state.node_runtime.clone(),
@@ -159,12 +162,17 @@ pub async fn open_remote_project(
                 },
                 cx,
             );
-            cx.new(|cx| {
+            let workspace = cx.new(|cx| {
                 let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
                 workspace.centered_layout = workspace_position.centered_layout;
                 workspace
-            })
-        })?
+            });
+            cx.new(|cx| MultiWorkspace::new(workspace, cx))
+        })?;
+        let workspace = window.update(cx, |multi_workspace, _, _cx| {
+            multi_workspace.workspace().clone()
+        })?;
+        (window, workspace)
     };
 
     loop {
@@ -172,35 +180,38 @@ pub async fn open_remote_project(
         let delegate = window.update(cx, {
             let paths = paths.clone();
             let connection_options = connection_options.clone();
-            move |workspace, window, cx| {
+            let initial_workspace = initial_workspace.clone();
+            move |_multi_workspace: &mut MultiWorkspace, window, cx| {
                 window.activate_window();
-                workspace.hide_modal(window, cx);
-                workspace.toggle_modal(window, cx, |window, cx| {
-                    RemoteConnectionModal::new(&connection_options, paths, window, cx)
-                });
-
-                let ui = workspace
-                    .active_modal::<RemoteConnectionModal>(cx)?
-                    .read(cx)
-                    .prompt
-                    .clone();
-
-                ui.update(cx, |ui, _cx| {
-                    ui.set_cancellation_tx(cancel_tx);
-                });
-
-                Some(Arc::new(RemoteClientDelegate::new(
-                    window.window_handle(),
-                    ui.downgrade(),
-                    if let RemoteConnectionOptions::Ssh(options) = &connection_options {
-                        options
-                            .password
-                            .as_deref()
-                            .and_then(|pw| EncryptedPassword::try_from(pw).ok())
-                    } else {
-                        None
-                    },
-                )))
+                initial_workspace.update(cx, |workspace, cx| {
+                    workspace.hide_modal(window, cx);
+                    workspace.toggle_modal(window, cx, |window, cx| {
+                        RemoteConnectionModal::new(&connection_options, paths, window, cx)
+                    });
+
+                    let ui = workspace
+                        .active_modal::<RemoteConnectionModal>(cx)?
+                        .read(cx)
+                        .prompt
+                        .clone();
+
+                    ui.update(cx, |ui, _cx| {
+                        ui.set_cancellation_tx(cancel_tx);
+                    });
+
+                    Some(Arc::new(RemoteClientDelegate::new(
+                        window.window_handle(),
+                        ui.downgrade(),
+                        if let RemoteConnectionOptions::Ssh(options) = &connection_options {
+                            options
+                                .password
+                                .as_deref()
+                                .and_then(|pw| EncryptedPassword::try_from(pw).ok())
+                        } else {
+                            None
+                        },
+                    )))
+                })
             }
         })?;
 
@@ -209,13 +220,11 @@ pub async fn open_remote_project(
         let connection = remote::connect(connection_options.clone(), delegate.clone(), cx);
         let connection = select! {
             _ = cancel_rx => {
-                window
-                    .update(cx, |workspace, _, cx| {
-                        if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
-                            ui.update(cx, |modal, cx| modal.finished(cx))
-                        }
-                    })
-                    .ok();
+                initial_workspace.update(cx, |workspace, cx| {
+                    if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
+                        ui.update(cx, |modal, cx| modal.finished(cx))
+                    }
+                });
 
                 break;
             },
@@ -224,13 +233,11 @@ pub async fn open_remote_project(
         let remote_connection = match connection {
             Ok(connection) => connection,
             Err(e) => {
-                window
-                    .update(cx, |workspace, _, cx| {
-                        if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
-                            ui.update(cx, |modal, cx| modal.finished(cx))
-                        }
-                    })
-                    .ok();
+                initial_workspace.update(cx, |workspace, cx| {
+                    if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
+                        ui.update(cx, |modal, cx| modal.finished(cx))
+                    }
+                });
                 log::error!("Failed to open project: {e:#}");
                 let response = window
                     .update(cx, |_, window, cx| {
@@ -284,13 +291,11 @@ pub async fn open_remote_project(
             })
             .await;
 
-        window
-            .update(cx, |workspace, _, cx| {
-                if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
-                    ui.update(cx, |modal, cx| modal.finished(cx))
-                }
-            })
-            .ok();
+        initial_workspace.update(cx, |workspace, cx| {
+            if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
+                ui.update(cx, |modal, cx| modal.finished(cx))
+            }
+        });
 
         match opened_items {
             Err(e) => {
@@ -320,20 +325,20 @@ pub async fn open_remote_project(
                     continue;
                 }
 
-                window
-                    .update(cx, |workspace, window, cx| {
-                        if created_new_window {
-                            window.remove_window();
-                        }
-                        trusted_worktrees::track_worktree_trust(
-                            workspace.project().read(cx).worktree_store(),
-                            None,
-                            None,
-                            None,
-                            cx,
-                        );
-                    })
-                    .ok();
+                if created_new_window {
+                    window
+                        .update(cx, |_, window, _| window.remove_window())
+                        .ok();
+                }
+                initial_workspace.update(cx, |workspace, cx| {
+                    trusted_worktrees::track_worktree_trust(
+                        workspace.project().read(cx).worktree_store(),
+                        None,
+                        None,
+                        None,
+                        cx,
+                    );
+                });
             }
 
             Ok(items) => {
@@ -366,14 +371,20 @@ pub async fn open_remote_project(
         break;
     }
 
+    // Register the remote client with extensions. We use `multi_workspace.workspace()` here
+    // (not `initial_workspace`) because `open_remote_project_inner` activated the new remote
+    // workspace, so the active workspace is now the one with the remote project.
     window
-        .update(cx, |workspace, _, cx| {
-            if let Some(client) = workspace.project().read(cx).remote_client() {
-                if let Some(extension_store) = ExtensionStore::try_global(cx) {
-                    extension_store
-                        .update(cx, |store, cx| store.register_remote_client(client, cx));
+        .update(cx, |multi_workspace: &mut MultiWorkspace, _, cx| {
+            let workspace = multi_workspace.workspace().clone();
+            workspace.update(cx, |workspace, cx| {
+                if let Some(client) = workspace.project().read(cx).remote_client() {
+                    if let Some(extension_store) = ExtensionStore::try_global(cx) {
+                        extension_store
+                            .update(cx, |store, cx| store.register_remote_client(client, cx));
+                    }
                 }
-            }
+            });
         })
         .ok();
     Ok(())
@@ -500,12 +511,16 @@ mod tests {
         let windows = cx.update(|cx| cx.windows().len());
         assert_eq!(windows, 1, "Should have opened a window");
 
-        let workspace_handle = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
+        let multi_workspace_handle =
+            cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
 
-        workspace_handle
-            .update(cx, |workspace, _, cx| {
-                let project = workspace.project().read(cx);
-                assert!(project.is_remote(), "Project should be a remote project");
+        multi_workspace_handle
+            .update(cx, |multi_workspace, _, cx| {
+                let workspace = multi_workspace.workspace().clone();
+                workspace.update(cx, |workspace, cx| {
+                    let project = workspace.project().read(cx);
+                    assert!(project.is_remote(), "Project should be a remote project");
+                });
             })
             .unwrap();
     }

crates/recent_projects/src/remote_servers.rs 🔗

@@ -6,7 +6,8 @@ use crate::{
     ssh_config::parse_ssh_config_hosts,
 };
 use dev_container::{
-    DevContainerConfig, find_devcontainer_configs, start_dev_container_with_config,
+    DevContainerConfig, DevContainerContext, find_devcontainer_configs,
+    start_dev_container_with_config,
 };
 use editor::Editor;
 
@@ -51,7 +52,7 @@ use util::{
     rel_path::RelPath,
 };
 use workspace::{
-    ModalView, OpenLog, OpenOptions, Toast, Workspace,
+    ModalView, MultiWorkspace, OpenLog, OpenOptions, Toast, Workspace,
     notifications::{DetachAndPromptErr, NotificationId},
     open_remote_project_with_existing_connection,
 };
@@ -478,10 +479,11 @@ impl ProjectPicker {
                         .log_err()?;
                     let window = cx
                         .open_window(options, |window, cx| {
-                            cx.new(|cx| {
+                            let workspace = cx.new(|cx| {
                                 telemetry::event!("SSH Project Created");
                                 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
-                            })
+                            });
+                            cx.new(|cx| MultiWorkspace::new(workspace, cx))
                         })
                         .log_err()?;
 
@@ -808,11 +810,18 @@ impl RemoteServerProjects {
         workspace: WeakEntity<Workspace>,
         cx: &mut Context<Self>,
     ) -> Self {
-        let this = Self::new_inner(
-            Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new(
-                DevContainerCreationProgress::Creating,
-                cx,
-            )),
+        let configs = workspace
+            .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx))
+            .unwrap_or_default();
+
+        let initial_mode = if configs.len() > 1 {
+            DevContainerCreationProgress::SelectingConfig
+        } else {
+            DevContainerCreationProgress::Creating
+        };
+
+        let mut this = Self::new_inner(
+            Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new(initial_mode, cx)),
             false,
             fs,
             window,
@@ -820,35 +829,15 @@ impl RemoteServerProjects {
             cx,
         );
 
-        // Spawn a task to scan for configs and then start the container
-        cx.spawn_in(window, async move |entity, cx| {
-            let configs = find_devcontainer_configs(cx);
-
-            entity
-                .update_in(cx, |this, window, cx| {
-                    if configs.len() > 1 {
-                        // Multiple configs found - show selection UI
-                        let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
-                        this.dev_container_picker = Some(
-                            cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)),
-                        );
-
-                        let state = CreateRemoteDevContainer::new(
-                            DevContainerCreationProgress::SelectingConfig,
-                            cx,
-                        );
-                        this.mode = Mode::CreateRemoteDevContainer(state);
-                        cx.notify();
-                    } else {
-                        // Single or no config - proceed with opening
-                        let config = configs.into_iter().next();
-                        this.open_dev_container(config, window, cx);
-                        this.view_in_progress_dev_container(window, cx);
-                    }
-                })
-                .log_err();
-        })
-        .detach();
+        if configs.len() > 1 {
+            let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
+            this.dev_container_picker =
+                Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)));
+        } else {
+            let config = configs.into_iter().next();
+            this.open_dev_container(config, window, cx);
+            this.view_in_progress_dev_container(window, cx);
+        }
 
         this
     }
@@ -1551,7 +1540,9 @@ impl RemoteServerProjects {
 
                 let replace_window = match (create_new_window, secondary_confirm) {
                     (true, false) | (false, true) => None,
-                    (true, true) | (false, false) => window.window_handle().downcast::<Workspace>(),
+                    (true, true) | (false, false) => {
+                        window.window_handle().downcast::<MultiWorkspace>()
+                    }
                 };
 
                 cx.spawn_in(window, async move |_, cx| {
@@ -1803,25 +1794,25 @@ impl RemoteServerProjects {
     }
 
     fn init_dev_container_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        cx.spawn_in(window, async move |entity, cx| {
-            let configs = find_devcontainer_configs(cx);
+        let configs = self
+            .workspace
+            .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx))
+            .unwrap_or_default();
 
-            entity
-                .update_in(cx, |this, window, cx| {
-                    let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
-                    this.dev_container_picker =
-                        Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)));
+        if configs.len() > 1 {
+            let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
+            self.dev_container_picker =
+                Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)));
 
-                    let state = CreateRemoteDevContainer::new(
-                        DevContainerCreationProgress::SelectingConfig,
-                        cx,
-                    );
-                    this.mode = Mode::CreateRemoteDevContainer(state);
-                    cx.notify();
-                })
-                .log_err();
-        })
-        .detach();
+            let state =
+                CreateRemoteDevContainer::new(DevContainerCreationProgress::SelectingConfig, cx);
+            self.mode = Mode::CreateRemoteDevContainer(state);
+            cx.notify();
+        } else {
+            let config = configs.into_iter().next();
+            self.open_dev_container(config, window, cx);
+            self.view_in_progress_dev_container(window, cx);
+        }
     }
 
     fn open_dev_container(
@@ -1830,21 +1821,25 @@ impl RemoteServerProjects {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        let Some(app_state) = self
+        let Some((app_state, context)) = self
             .workspace
-            .read_with(cx, |workspace, _| workspace.app_state().clone())
+            .read_with(cx, |workspace, cx| {
+                let app_state = workspace.app_state().clone();
+                let context = DevContainerContext::from_workspace(workspace, cx)?;
+                Some((app_state, context))
+            })
             .log_err()
+            .flatten()
         else {
+            log::error!("No active project directory for Dev Container");
             return;
         };
 
-        let replace_window = window.window_handle().downcast::<Workspace>();
+        let replace_window = window.window_handle().downcast::<MultiWorkspace>();
 
         cx.spawn_in(window, async move |entity, cx| {
             let (connection, starting_dir) =
-                match start_dev_container_with_config(cx, app_state.node_runtime.clone(), config)
-                    .await
-                {
+                match start_dev_container_with_config(context, config).await {
                     Ok((c, s)) => (Connection::DevContainer(c), s),
                     Err(e) => {
                         log::error!("Failed to start dev container: {:?}", e);

crates/recent_projects/src/wsl_picker.rs 🔗

@@ -8,7 +8,7 @@ use ui::{
     Render, Styled, StyledExt, Toggleable, Window, div, h_flex, rems, v_flex,
 };
 use util::ResultExt as _;
-use workspace::{ModalView, Workspace};
+use workspace::{ModalView, MultiWorkspace};
 
 use crate::open_remote_project;
 
@@ -249,7 +249,7 @@ impl WslOpenModal {
             false => !secondary,
         };
         let replace_window = match replace_current_window {
-            true => window.window_handle().downcast::<Workspace>(),
+            true => window.window_handle().downcast::<MultiWorkspace>(),
             false => None,
         };
 

crates/rules_library/src/rules_library.rs 🔗

@@ -24,7 +24,7 @@ use theme::ThemeSettings;
 use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
 use ui_input::ErasedEditor;
 use util::{ResultExt, TryFutureExt};
-use workspace::{Workspace, WorkspaceSettings, client_side_decorations};
+use workspace::{MultiWorkspace, Workspace, WorkspaceSettings, client_side_decorations};
 use zed_actions::assistant::InlineAssist;
 
 use prompt_store::*;
@@ -968,12 +968,14 @@ impl RulesLibrary {
                 .assist(rule_editor, initial_prompt, window, cx);
         } else {
             for window in cx.windows() {
-                if let Some(workspace) = window.downcast::<Workspace>() {
-                    let panel = workspace
-                        .update(cx, |workspace, window, cx| {
+                if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
+                    let panel = multi_workspace
+                        .update(cx, |multi_workspace, window, cx| {
                             window.activate_window();
-                            self.inline_assist_delegate
-                                .focus_agent_panel(workspace, window, cx)
+                            multi_workspace.workspace().update(cx, |workspace, cx| {
+                                self.inline_assist_delegate
+                                    .focus_agent_panel(workspace, window, cx)
+                            })
                         })
                         .ok();
                     if panel == Some(true) {

crates/session/src/session.rs 🔗

@@ -47,6 +47,15 @@ impl Session {
         }
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn test_with_old_session(old_session_id: String) -> Self {
+        Self {
+            session_id: uuid::Uuid::new_v4().to_string(),
+            old_session_id: Some(old_session_id),
+            old_window_ids: None,
+        }
+    }
+
     pub fn id(&self) -> &str {
         &self.session_id
     }
@@ -109,6 +118,11 @@ impl AppSession {
         self.session.old_session_id.as_deref()
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn replace_session_for_test(&mut self, session: Session) {
+        self.session = session;
+    }
+
     pub fn last_session_window_stack(&self) -> Option<Vec<WindowId>> {
         self.session.old_window_ids.clone()
     }

crates/settings_profile_selector/src/settings_profile_selector.rs 🔗

@@ -287,7 +287,7 @@ mod tests {
     use serde_json::json;
     use settings::Settings;
     use theme::{self, ThemeSettings};
-    use workspace::{self, AppState};
+    use workspace::{self, AppState, MultiWorkspace};
     use zed_actions::settings_profile_selector;
 
     async fn init_test(
@@ -320,8 +320,11 @@ mod tests {
 
         let fs = FakeFs::new(cx.executor());
         let project = Project::test(fs, ["/test".as_ref()], cx).await;
-        let (workspace, cx) =
-            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
+        let cx = VisualTestContext::from_window(*window, cx).into_mut();
+        let workspace = window
+            .read_with(cx, |mw, _| mw.workspace().clone())
+            .unwrap();
 
         cx.update(|_, cx| {
             assert!(!cx.has_global::<ActiveSettingsProfileName>());

crates/settings_ui/src/settings_ui.rs 🔗

@@ -40,7 +40,9 @@ use ui::{
 };
 
 use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath};
-use workspace::{AppState, OpenOptions, OpenVisible, Workspace, client_side_decorations};
+use workspace::{
+    AppState, MultiWorkspace, OpenOptions, OpenVisible, Workspace, client_side_decorations,
+};
 use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt};
 
 use crate::components::{
@@ -394,7 +396,7 @@ pub fn init(cx: &mut App) {
                 |workspace, OpenSettingsAt { path }: &OpenSettingsAt, window, cx| {
                     let window_handle = window
                         .window_handle()
-                        .downcast::<Workspace>()
+                        .downcast::<MultiWorkspace>()
                         .expect("Workspaces are root Windows");
                     open_settings_editor(workspace, Some(&path), false, window_handle, cx);
                 },
@@ -402,14 +404,14 @@ pub fn init(cx: &mut App) {
             .register_action(|workspace, _: &OpenSettings, window, cx| {
                 let window_handle = window
                     .window_handle()
-                    .downcast::<Workspace>()
+                    .downcast::<MultiWorkspace>()
                     .expect("Workspaces are root Windows");
                 open_settings_editor(workspace, None, false, window_handle, cx);
             })
             .register_action(|workspace, _: &OpenProjectSettings, window, cx| {
                 let window_handle = window
                     .window_handle()
-                    .downcast::<Workspace>()
+                    .downcast::<MultiWorkspace>()
                     .expect("Workspaces are root Windows");
                 open_settings_editor(workspace, None, true, window_handle, cx);
             });
@@ -547,7 +549,7 @@ pub fn open_settings_editor(
     _workspace: &mut Workspace,
     path: Option<&str>,
     open_project_settings: bool,
-    workspace_handle: WindowHandle<Workspace>,
+    workspace_handle: WindowHandle<MultiWorkspace>,
     cx: &mut App,
 ) {
     telemetry::event!("Settings Viewed");
@@ -715,7 +717,7 @@ fn active_language_mut() -> Option<std::sync::RwLockWriteGuard<'static, Option<S
 
 pub struct SettingsWindow {
     title_bar: Option<Entity<PlatformTitleBar>>,
-    original_window: Option<WindowHandle<Workspace>>,
+    original_window: Option<WindowHandle<MultiWorkspace>>,
     files: Vec<(SettingsUiFile, FocusHandle)>,
     worktree_root_dirs: HashMap<WorktreeId, String>,
     current_file: SettingsUiFile,
@@ -1447,7 +1449,7 @@ impl SettingsUiFile {
 
 impl SettingsWindow {
     fn new(
-        original_window: Option<WindowHandle<Workspace>>,
+        original_window: Option<WindowHandle<MultiWorkspace>>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -1518,34 +1520,21 @@ impl SettingsWindow {
         .detach();
 
         if let Some(app_state) = AppState::global(cx).upgrade() {
-            for project in app_state
+            let workspaces: Vec<Entity<Workspace>> = app_state
                 .workspace_store
                 .read(cx)
                 .workspaces()
-                .iter()
-                .filter_map(|space| {
-                    space
-                        .read(cx)
-                        .ok()
-                        .map(|workspace| workspace.project().clone())
-                })
-                .collect::<Vec<_>>()
-            {
+                .filter_map(|weak| weak.upgrade())
+                .collect();
+
+            for workspace in workspaces {
+                let project = workspace.read(cx).project().clone();
                 cx.observe_release_in(&project, window, |this, _, window, cx| {
                     this.fetch_files(window, cx)
                 })
                 .detach();
                 cx.subscribe_in(&project, window, Self::handle_project_event)
                     .detach();
-            }
-
-            for workspace in app_state
-                .workspace_store
-                .read(cx)
-                .workspaces()
-                .iter()
-                .filter_map(|space| space.entity(cx).ok())
-            {
                 cx.observe_release_in(&workspace, window, |this, _, window, cx| {
                     this.fetch_files(window, cx)
                 })
@@ -3320,56 +3309,19 @@ impl SettingsWindow {
                     return;
                 };
                 original_window
-                    .update(cx, |workspace, window, cx| {
-                        workspace
-                            .with_local_or_wsl_workspace(window, cx, |workspace, window, cx| {
-                                let project = workspace.project().clone();
-
-                                cx.spawn_in(window, async move |workspace, cx| {
-                                    let (config_dir, settings_file) =
-                                        project.update(cx, |project, cx| {
-                                            (
-                                                project.try_windows_path_to_wsl(
-                                                    paths::config_dir().as_path(),
-                                                    cx,
-                                                ),
-                                                project.try_windows_path_to_wsl(
-                                                    paths::settings_file().as_path(),
-                                                    cx,
-                                                ),
-                                            )
-                                        });
-                                    let config_dir = config_dir.await?;
-                                    let settings_file = settings_file.await?;
-                                    project
-                                        .update(cx, |project, cx| {
-                                            project.find_or_create_worktree(&config_dir, false, cx)
-                                        })
-                                        .await
-                                        .ok();
-                                    workspace
-                                        .update_in(cx, |workspace, window, cx| {
-                                            workspace.open_paths(
-                                                vec![settings_file],
-                                                OpenOptions {
-                                                    visible: Some(OpenVisible::None),
-                                                    ..Default::default()
-                                                },
-                                                None,
-                                                window,
-                                                cx,
-                                            )
-                                        })?
-                                        .await;
-
-                                    workspace.update_in(cx, |_, window, cx| {
-                                        window.activate_window();
-                                        cx.notify();
-                                    })
-                                })
-                                .detach();
-                            })
-                            .detach();
+                    .update(cx, |multi_workspace, window, cx| {
+                        multi_workspace
+                            .workspace()
+                            .clone()
+                            .update(cx, |workspace, cx| {
+                                workspace
+                                    .with_local_or_wsl_workspace(
+                                        window,
+                                        cx,
+                                        open_user_settings_in_workspace,
+                                    )
+                                    .detach();
+                            });
                     })
                     .ok();
 
@@ -3381,22 +3333,22 @@ impl SettingsWindow {
                     return;
                 };
 
-                let Some((worktree, corresponding_workspace)) = app_state
+                let Some((workspace_window, worktree, corresponding_workspace)) = app_state
                     .workspace_store
                     .read(cx)
-                    .workspaces()
-                    .iter()
-                    .find_map(|workspace| {
+                    .workspaces_with_windows()
+                    .filter_map(|(window_handle, weak)| {
+                        let workspace = weak.upgrade()?;
+                        let window = window_handle.downcast::<MultiWorkspace>()?;
+                        Some((window, workspace))
+                    })
+                    .find_map(|(window, workspace): (_, Entity<Workspace>)| {
                         workspace
-                            .read_with(cx, |workspace, cx| {
-                                workspace
-                                    .project()
-                                    .read(cx)
-                                    .worktree_for_id(*worktree_id, cx)
-                            })
-                            .ok()
-                            .flatten()
-                            .zip(Some(*workspace))
+                            .read(cx)
+                            .project()
+                            .read(cx)
+                            .worktree_for_id(*worktree_id, cx)
+                            .map(|worktree| (window, worktree, workspace))
                     })
                 else {
                     log::error!(
@@ -3424,14 +3376,15 @@ impl SettingsWindow {
 
                 // TODO: move zed::open_local_file() APIs to this crate, and
                 // re-implement the "initial_contents" behavior
-                corresponding_workspace
+                let workspace_weak = corresponding_workspace.downgrade();
+                workspace_window
                     .update(cx, |_, window, cx| {
-                        cx.spawn_in(window, async move |workspace, cx| {
+                        cx.spawn_in(window, async move |_, cx| {
                             if let Some(create_task) = create_task {
                                 create_task.await.ok()?;
                             };
 
-                            workspace
+                            workspace_weak
                                 .update_in(cx, |workspace, window, cx| {
                                     workspace.open_path(
                                         (worktree_id, settings_path.clone()),
@@ -3445,7 +3398,7 @@ impl SettingsWindow {
                                 .await
                                 .log_err()?;
 
-                            workspace
+                            workspace_weak
                                 .update_in(cx, |_, window, cx| {
                                     window.activate_window();
                                     cx.notify();
@@ -3752,7 +3705,7 @@ impl Render for SettingsWindow {
 }
 
 fn all_projects(
-    window: Option<&WindowHandle<Workspace>>,
+    window: Option<&WindowHandle<MultiWorkspace>>,
     cx: &App,
 ) -> impl Iterator<Item = Entity<Project>> {
     let mut seen_project_ids = std::collections::HashSet::new();
@@ -3763,10 +3716,19 @@ fn all_projects(
                 .workspace_store
                 .read(cx)
                 .workspaces()
-                .iter()
-                .filter_map(|workspace| Some(workspace.read(cx).ok()?.project().clone()))
+                .filter_map(|weak| weak.upgrade())
+                .map(|workspace: Entity<Workspace>| workspace.read(cx).project().clone())
                 .chain(
-                    window.and_then(|workspace| Some(workspace.read(cx).ok()?.project().clone())),
+                    window
+                        .and_then(|handle| handle.read(cx).ok())
+                        .into_iter()
+                        .flat_map(|multi_workspace| {
+                            multi_workspace
+                                .workspaces()
+                                .iter()
+                                .map(|workspace| workspace.read(cx).project().clone())
+                                .collect::<Vec<_>>()
+                        }),
                 )
                 .filter(move |project| seen_project_ids.insert(project.entity_id()))
         })
@@ -3774,6 +3736,51 @@ fn all_projects(
         .flatten()
 }
 
+fn open_user_settings_in_workspace(
+    workspace: &mut Workspace,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let project = workspace.project().clone();
+
+    cx.spawn_in(window, async move |workspace, cx| {
+        let (config_dir, settings_file) = project.update(cx, |project, cx| {
+            (
+                project.try_windows_path_to_wsl(paths::config_dir().as_path(), cx),
+                project.try_windows_path_to_wsl(paths::settings_file().as_path(), cx),
+            )
+        });
+        let config_dir = config_dir.await?;
+        let settings_file = settings_file.await?;
+        project
+            .update(cx, |project, cx| {
+                project.find_or_create_worktree(&config_dir, false, cx)
+            })
+            .await
+            .ok();
+        workspace
+            .update_in(cx, |workspace, window, cx| {
+                workspace.open_paths(
+                    vec![settings_file],
+                    OpenOptions {
+                        visible: Some(OpenVisible::None),
+                        ..Default::default()
+                    },
+                    None,
+                    window,
+                    cx,
+                )
+            })?
+            .await;
+
+        workspace.update_in(cx, |_, window, cx| {
+            window.activate_window();
+            cx.notify();
+        })
+    })
+    .detach();
+}
+
 fn update_settings_file(
     file: SettingsUiFile,
     file_name: Option<&'static str>,
@@ -4754,29 +4761,33 @@ pub mod test {
             .await
             .expect("Failed to create worktree_c");
 
-        let (_workspace1, cx) = cx.add_window_view(|window, cx| {
-            Workspace::new(
-                Default::default(),
-                project1.clone(),
-                app_state.clone(),
-                window,
-                cx,
-            )
+        let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| {
+            let workspace = cx.new(|cx| {
+                Workspace::new(
+                    Default::default(),
+                    project1.clone(),
+                    app_state.clone(),
+                    window,
+                    cx,
+                )
+            });
+            MultiWorkspace::new(workspace, cx)
         });
 
-        let _workspace1_handle = cx.window_handle().downcast::<Workspace>().unwrap();
-
-        let (_workspace2, cx) = cx.add_window_view(|window, cx| {
-            Workspace::new(
-                Default::default(),
-                project2.clone(),
-                app_state.clone(),
-                window,
-                cx,
-            )
+        let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| {
+            let workspace = cx.new(|cx| {
+                Workspace::new(
+                    Default::default(),
+                    project2.clone(),
+                    app_state.clone(),
+                    window,
+                    cx,
+                )
+            });
+            MultiWorkspace::new(workspace, cx)
         });
 
-        let workspace2_handle = cx.window_handle().downcast::<Workspace>().unwrap();
+        let workspace2_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
 
         cx.run_until_parked();
 
@@ -4895,17 +4906,20 @@ pub mod test {
             .await
             .expect("Failed to create worktree_a");
 
-        let (_workspace1, cx) = cx.add_window_view(|window, cx| {
-            Workspace::new(
-                Default::default(),
-                project1.clone(),
-                app_state.clone(),
-                window,
-                cx,
-            )
+        let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| {
+            let workspace = cx.new(|cx| {
+                Workspace::new(
+                    Default::default(),
+                    project1.clone(),
+                    app_state.clone(),
+                    window,
+                    cx,
+                )
+            });
+            MultiWorkspace::new(workspace, cx)
         });
 
-        let workspace1_handle = cx.window_handle().downcast::<Workspace>().unwrap();
+        let workspace1_handle = cx.window_handle().downcast::<MultiWorkspace>().unwrap();
 
         cx.run_until_parked();
 
@@ -4942,14 +4956,17 @@ pub mod test {
             .await
             .expect("Failed to create worktree_b");
 
-        let (_workspace2, cx) = cx.add_window_view(|window, cx| {
-            Workspace::new(
-                Default::default(),
-                project2.clone(),
-                app_state.clone(),
-                window,
-                cx,
-            )
+        let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| {
+            let workspace = cx.new(|cx| {
+                Workspace::new(
+                    Default::default(),
+                    project2.clone(),
+                    app_state.clone(),
+                    window,
+                    cx,
+                )
+            });
+            MultiWorkspace::new(workspace, cx)
         });
 
         cx.run_until_parked();

crates/sidebar/Cargo.toml 🔗

@@ -0,0 +1,43 @@
+[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 = []
+test-support = []
+
+[dependencies]
+acp_thread.workspace = true
+agent_ui.workspace = true
+db.workspace = true
+fs.workspace = true
+fuzzy.workspace = true
+serde_json.workspace = true
+gpui.workspace = true
+picker.workspace = true
+project.workspace = true
+recent_projects.workspace = true
+theme.workspace = true
+ui.workspace = true
+ui_input.workspace = true
+util.workspace = true
+workspace.workspace = true
+
+[dev-dependencies]
+editor.workspace = true
+feature_flags.workspace = true
+fs = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+recent_projects = { workspace = true, features = ["test-support"] }
+settings = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }

crates/sidebar/src/sidebar.rs 🔗

@@ -0,0 +1,1304 @@
+use acp_thread::ThreadStatus;
+use agent_ui::{AgentPanel, AgentPanelEvent};
+use db::kvp::KEY_VALUE_STORE;
+use fs::Fs;
+use fuzzy::StringMatchCandidate;
+use gpui::{
+    App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString,
+    Subscription, Task, Window, px,
+};
+use picker::{Picker, PickerDelegate};
+use project::Event as ProjectEvent;
+use recent_projects::{RecentProjectEntry, get_recent_projects};
+
+use std::collections::{HashMap, HashSet};
+
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use theme::ActiveTheme;
+use ui::utils::TRAFFIC_LIGHT_PADDING;
+use ui::{CommonAnimationExt, Divider, HighlightedLabel, ListItem, Tab, Tooltip, prelude::*};
+use ui_input::ErasedEditor;
+use util::ResultExt as _;
+use workspace::{
+    MultiWorkspace, NewWorkspaceInWindow, Sidebar as WorkspaceSidebar, SidebarEvent,
+    ToggleWorkspaceSidebar, Workspace,
+};
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum AgentThreadStatus {
+    Running,
+    Completed,
+}
+
+#[derive(Clone, Debug)]
+struct AgentThreadInfo {
+    title: SharedString,
+    status: AgentThreadStatus,
+}
+
+const LAST_THREAD_TITLES_KEY: &str = "sidebar-last-thread-titles";
+
+const DEFAULT_WIDTH: Pixels = px(320.0);
+const MIN_WIDTH: Pixels = px(200.0);
+const MAX_WIDTH: Pixels = px(800.0);
+const MAX_MATCHES: usize = 100;
+
+#[derive(Clone)]
+struct WorkspaceThreadEntry {
+    index: usize,
+    worktree_label: SharedString,
+    full_path: SharedString,
+    thread_info: Option<AgentThreadInfo>,
+}
+
+impl WorkspaceThreadEntry {
+    fn new(
+        index: usize,
+        workspace: &Entity<Workspace>,
+        persisted_titles: &HashMap<String, String>,
+        cx: &App,
+    ) -> Self {
+        let workspace_ref = workspace.read(cx);
+
+        let worktrees: Vec<_> = workspace_ref
+            .worktrees(cx)
+            .map(|worktree| worktree.read(cx).abs_path())
+            .collect();
+
+        let worktree_names: Vec<String> = worktrees
+            .iter()
+            .filter_map(|path| {
+                path.file_name()
+                    .map(|name| name.to_string_lossy().to_string())
+            })
+            .collect();
+
+        let worktree_label: SharedString = if worktree_names.is_empty() {
+            format!("Workspace {}", index + 1).into()
+        } else {
+            worktree_names.join(", ").into()
+        };
+
+        let full_path: SharedString = worktrees
+            .iter()
+            .map(|path| path.to_string_lossy().to_string())
+            .collect::<Vec<_>>()
+            .join("\n")
+            .into();
+
+        let thread_info = Self::thread_info(workspace, cx).or_else(|| {
+            if worktrees.is_empty() {
+                return None;
+            }
+            let path_key = sorted_paths_key(&worktrees);
+            let title = persisted_titles.get(&path_key)?;
+            Some(AgentThreadInfo {
+                title: SharedString::from(title.clone()),
+                status: AgentThreadStatus::Completed,
+            })
+        });
+
+        Self {
+            index,
+            worktree_label,
+            full_path,
+            thread_info,
+        }
+    }
+
+    fn thread_info(workspace: &Entity<Workspace>, cx: &App) -> Option<AgentThreadInfo> {
+        let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
+        let thread = agent_panel.read(cx).active_agent_thread(cx)?;
+        let thread_ref = thread.read(cx);
+        let title = thread_ref.title();
+        let status = match thread_ref.status() {
+            ThreadStatus::Generating => AgentThreadStatus::Running,
+            ThreadStatus::Idle => AgentThreadStatus::Completed,
+        };
+        Some(AgentThreadInfo { title, status })
+    }
+}
+
+#[derive(Clone)]
+enum SidebarEntry {
+    Separator(SharedString),
+    WorkspaceThread(WorkspaceThreadEntry),
+    RecentProject(RecentProjectEntry),
+}
+
+impl SidebarEntry {
+    fn searchable_text(&self) -> &str {
+        match self {
+            SidebarEntry::Separator(_) => "",
+            SidebarEntry::WorkspaceThread(entry) => entry.worktree_label.as_ref(),
+            SidebarEntry::RecentProject(entry) => entry.name.as_ref(),
+        }
+    }
+}
+
+#[derive(Clone)]
+struct SidebarMatch {
+    entry: SidebarEntry,
+    positions: Vec<usize>,
+}
+
+struct WorkspacePickerDelegate {
+    multi_workspace: Entity<MultiWorkspace>,
+    entries: Vec<SidebarEntry>,
+    active_workspace_index: usize,
+    workspace_thread_count: usize,
+    /// All recent projects including what's filtered out of entries
+    /// used to add unopened projects to entries on rebuild
+    recent_projects: Vec<RecentProjectEntry>,
+    recent_project_thread_titles: HashMap<SharedString, SharedString>,
+    matches: Vec<SidebarMatch>,
+    selected_index: usize,
+    query: String,
+    notified_workspaces: HashSet<usize>,
+}
+
+impl WorkspacePickerDelegate {
+    fn new(multi_workspace: Entity<MultiWorkspace>) -> Self {
+        Self {
+            multi_workspace,
+            entries: Vec::new(),
+            active_workspace_index: 0,
+            workspace_thread_count: 0,
+            recent_projects: Vec::new(),
+            recent_project_thread_titles: HashMap::new(),
+            matches: Vec::new(),
+            selected_index: 0,
+            query: String::new(),
+            notified_workspaces: HashSet::new(),
+        }
+    }
+
+    fn set_entries(
+        &mut self,
+        workspace_threads: Vec<WorkspaceThreadEntry>,
+        active_workspace_index: usize,
+        cx: &App,
+    ) {
+        let old_statuses: HashMap<usize, AgentThreadStatus> = self
+            .entries
+            .iter()
+            .filter_map(|entry| match entry {
+                SidebarEntry::WorkspaceThread(thread) => thread
+                    .thread_info
+                    .as_ref()
+                    .map(|info| (thread.index, info.status.clone())),
+                _ => None,
+            })
+            .collect();
+
+        for thread in &workspace_threads {
+            if let Some(info) = &thread.thread_info {
+                if info.status == AgentThreadStatus::Completed
+                    && thread.index != active_workspace_index
+                {
+                    if old_statuses.get(&thread.index) == Some(&AgentThreadStatus::Running) {
+                        self.notified_workspaces.insert(thread.index);
+                    }
+                }
+            }
+        }
+
+        if self.active_workspace_index != active_workspace_index {
+            self.notified_workspaces.remove(&active_workspace_index);
+        }
+        self.active_workspace_index = active_workspace_index;
+        self.workspace_thread_count = workspace_threads.len();
+        self.rebuild_entries(workspace_threads, cx);
+    }
+
+    fn set_recent_projects(&mut self, recent_projects: Vec<RecentProjectEntry>, cx: &App) {
+        self.recent_project_thread_titles.clear();
+        if let Some(map) = read_thread_title_map() {
+            for entry in &recent_projects {
+                let path_key = sorted_paths_key(&entry.paths);
+                if let Some(title) = map.get(&path_key) {
+                    self.recent_project_thread_titles
+                        .insert(entry.full_path.clone(), title.clone().into());
+                }
+            }
+        }
+
+        self.recent_projects = recent_projects;
+
+        let workspace_threads: Vec<WorkspaceThreadEntry> = self
+            .entries
+            .iter()
+            .filter_map(|entry| match entry {
+                SidebarEntry::WorkspaceThread(thread) => Some(thread.clone()),
+                _ => None,
+            })
+            .collect();
+        self.rebuild_entries(workspace_threads, cx);
+    }
+
+    fn open_workspace_path_sets(&self, cx: &App) -> Vec<Vec<Arc<Path>>> {
+        self.multi_workspace
+            .read(cx)
+            .workspaces()
+            .iter()
+            .map(|workspace| {
+                let mut paths = workspace.read(cx).root_paths(cx);
+                paths.sort();
+                paths
+            })
+            .collect()
+    }
+
+    fn rebuild_entries(&mut self, workspace_threads: Vec<WorkspaceThreadEntry>, cx: &App) {
+        let open_path_sets = self.open_workspace_path_sets(cx);
+
+        self.entries.clear();
+
+        if !workspace_threads.is_empty() {
+            self.entries
+                .push(SidebarEntry::Separator("Active Workspaces".into()));
+            for thread in workspace_threads {
+                self.entries.push(SidebarEntry::WorkspaceThread(thread));
+            }
+        }
+
+        let recent: Vec<_> = self
+            .recent_projects
+            .iter()
+            .filter(|project| {
+                let mut project_paths: Vec<&Path> =
+                    project.paths.iter().map(|p| p.as_path()).collect();
+                project_paths.sort();
+                !open_path_sets.iter().any(|open_paths| {
+                    open_paths.len() == project_paths.len()
+                        && open_paths
+                            .iter()
+                            .zip(&project_paths)
+                            .all(|(a, b)| a.as_ref() == *b)
+                })
+            })
+            .cloned()
+            .collect();
+
+        if !recent.is_empty() {
+            self.entries
+                .push(SidebarEntry::Separator("Recent Projects".into()));
+            for project in recent {
+                self.entries.push(SidebarEntry::RecentProject(project));
+            }
+        }
+    }
+
+    fn open_recent_project(paths: Vec<PathBuf>, window: &mut Window, cx: &mut App) {
+        let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
+            return;
+        };
+
+        cx.defer(move |cx| {
+            if let Some(task) = handle
+                .update(cx, |multi_workspace, window, cx| {
+                    multi_workspace.open_project(paths, window, cx)
+                })
+                .log_err()
+            {
+                task.detach_and_log_err(cx);
+            }
+        });
+    }
+}
+
+impl PickerDelegate for WorkspacePickerDelegate {
+    type ListItem = AnyElement;
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) {
+        self.selected_index = ix;
+    }
+
+    fn can_select(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) -> bool {
+        match self.matches.get(ix) {
+            Some(SidebarMatch {
+                entry: SidebarEntry::Separator(_),
+                ..
+            }) => false,
+            _ => true,
+        }
+    }
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        "Search…".into()
+    }
+
+    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
+        if self.query.is_empty() {
+            None
+        } else {
+            Some("No threads match your search.".into())
+        }
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Task<()> {
+        self.query = query.clone();
+        let entries = self.entries.clone();
+
+        if query.is_empty() {
+            self.matches = entries
+                .into_iter()
+                .map(|entry| SidebarMatch {
+                    entry,
+                    positions: Vec::new(),
+                })
+                .collect();
+
+            let separator_offset = if self.workspace_thread_count > 0 {
+                1
+            } else {
+                0
+            };
+            self.selected_index = (self.active_workspace_index + separator_offset)
+                .min(self.matches.len().saturating_sub(1));
+            return Task::ready(());
+        }
+
+        let executor = cx.background_executor().clone();
+        cx.spawn_in(window, async move |picker, cx| {
+            let matches = cx
+                .background_spawn(async move {
+                    let data_entries: Vec<(usize, &SidebarEntry)> = entries
+                        .iter()
+                        .enumerate()
+                        .filter(|(_, entry)| !matches!(entry, SidebarEntry::Separator(_)))
+                        .collect();
+
+                    let candidates: Vec<StringMatchCandidate> = data_entries
+                        .iter()
+                        .enumerate()
+                        .map(|(candidate_index, (_, entry))| {
+                            StringMatchCandidate::new(candidate_index, entry.searchable_text())
+                        })
+                        .collect();
+
+                    let search_matches = fuzzy::match_strings(
+                        &candidates,
+                        &query,
+                        false,
+                        true,
+                        MAX_MATCHES,
+                        &Default::default(),
+                        executor,
+                    )
+                    .await;
+
+                    let mut workspace_matches = Vec::new();
+                    let mut project_matches = Vec::new();
+
+                    for search_match in search_matches {
+                        let (original_index, _) = data_entries[search_match.candidate_id];
+                        let entry = entries[original_index].clone();
+                        let sidebar_match = SidebarMatch {
+                            positions: search_match.positions,
+                            entry: entry.clone(),
+                        };
+                        match entry {
+                            SidebarEntry::WorkspaceThread(_) => {
+                                workspace_matches.push(sidebar_match)
+                            }
+                            SidebarEntry::RecentProject(_) => project_matches.push(sidebar_match),
+                            SidebarEntry::Separator(_) => {}
+                        }
+                    }
+
+                    let mut result = Vec::new();
+                    if !workspace_matches.is_empty() {
+                        result.push(SidebarMatch {
+                            entry: SidebarEntry::Separator("Active Workspaces".into()),
+                            positions: Vec::new(),
+                        });
+                        result.extend(workspace_matches);
+                    }
+                    if !project_matches.is_empty() {
+                        result.push(SidebarMatch {
+                            entry: SidebarEntry::Separator("Recent Projects".into()),
+                            positions: Vec::new(),
+                        });
+                        result.extend(project_matches);
+                    }
+                    result
+                })
+                .await;
+
+            picker
+                .update_in(cx, |picker, _window, _cx| {
+                    picker.delegate.matches = matches;
+                    if picker.delegate.matches.is_empty() {
+                        picker.delegate.selected_index = 0;
+                    } else {
+                        let first_selectable = picker
+                            .delegate
+                            .matches
+                            .iter()
+                            .position(|m| !matches!(m.entry, SidebarEntry::Separator(_)))
+                            .unwrap_or(0);
+                        picker.delegate.selected_index = first_selectable;
+                    }
+                })
+                .log_err();
+        })
+    }
+
+    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let Some(selected_match) = self.matches.get(self.selected_index) else {
+            return;
+        };
+
+        match &selected_match.entry {
+            SidebarEntry::Separator(_) => {}
+            SidebarEntry::WorkspaceThread(thread_entry) => {
+                let target_index = thread_entry.index;
+                self.multi_workspace.update(cx, |multi_workspace, cx| {
+                    multi_workspace.activate_index(target_index, window, cx);
+                });
+            }
+            SidebarEntry::RecentProject(project_entry) => {
+                let paths = project_entry.paths.clone();
+                Self::open_recent_project(paths, window, cx);
+            }
+        }
+    }
+
+    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
+
+    fn render_match(
+        &self,
+        index: usize,
+        selected: bool,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let match_entry = self.matches.get(index)?;
+        let SidebarMatch { entry, positions } = match_entry;
+
+        fn render_title(text: SharedString, positions: &[usize]) -> AnyElement {
+            if positions.is_empty() {
+                div()
+                    .p_0p5()
+                    .child(Label::new(text).truncate())
+                    .into_any_element()
+            } else {
+                div()
+                    .p_0p5()
+                    .child(HighlightedLabel::new(text, positions.to_vec()).truncate())
+                    .into_any_element()
+            }
+        }
+
+        fn render_thread_status_icon(
+            workspace_index: usize,
+            status: &AgentThreadStatus,
+            has_notification: bool,
+        ) -> AnyElement {
+            match status {
+                AgentThreadStatus::Running => Icon::new(IconName::LoadCircle)
+                    .size(IconSize::XSmall)
+                    .color(Color::Muted)
+                    .with_keyed_rotate_animation(
+                        SharedString::from(format!("workspace-{}-spinner", workspace_index)),
+                        3,
+                    )
+                    .into_any_element(),
+                AgentThreadStatus::Completed => {
+                    let color = if has_notification {
+                        Color::Accent
+                    } else {
+                        Color::Muted
+                    };
+                    Icon::new(IconName::Check)
+                        .size(IconSize::XSmall)
+                        .color(color)
+                        .into_any_element()
+                }
+            }
+        }
+
+        fn render_project_row(
+            title: AnyElement,
+            thread_subtitle: Option<SharedString>,
+            status_icon: Option<AnyElement>,
+            cx: &App,
+        ) -> Div {
+            h_flex()
+                .items_start()
+                .gap(DynamicSpacing::Base06.rems(cx))
+                .child(
+                    div().pt(px(4.0)).child(
+                        Icon::new(IconName::Folder)
+                            .color(Color::Muted)
+                            .size(IconSize::XSmall),
+                    ),
+                )
+                .child(v_flex().overflow_hidden().child(title).when_some(
+                    thread_subtitle,
+                    |this, subtitle| {
+                        this.child(
+                            h_flex()
+                                .gap_1()
+                                .items_center()
+                                .px_0p5()
+                                .when_some(status_icon, |this, icon| this.child(icon))
+                                .child(
+                                    Label::new(subtitle)
+                                        .size(LabelSize::Small)
+                                        .color(Color::Muted)
+                                        .truncate(),
+                                ),
+                        )
+                    },
+                ))
+        }
+
+        match entry {
+            SidebarEntry::Separator(title) => Some(
+                div()
+                    .px_0p5()
+                    .when(index > 0, |this| this.mt_1().child(Divider::horizontal()))
+                    .child(
+                        ListItem::new("section_header").selectable(false).child(
+                            Label::new(title.clone())
+                                .size(LabelSize::XSmall)
+                                .color(Color::Muted)
+                                .when(index > 0, |this| this.mt_1p5())
+                                .mb_1(),
+                        ),
+                    )
+                    .into_any_element(),
+            ),
+            SidebarEntry::WorkspaceThread(thread_entry) => {
+                let worktree_label = thread_entry.worktree_label.clone();
+                let full_path = thread_entry.full_path.clone();
+                let title = render_title(worktree_label.clone(), positions);
+                let thread_info = thread_entry.thread_info.clone();
+                let workspace_index = thread_entry.index;
+                let multi_workspace = self.multi_workspace.clone();
+                let workspace_count = self.multi_workspace.read(_cx).workspaces().len();
+
+                let close_button = if workspace_count > 1 {
+                    Some(
+                        IconButton::new(
+                            SharedString::from(format!("close-workspace-{}", workspace_index)),
+                            IconName::Close,
+                        )
+                        .icon_size(IconSize::XSmall)
+                        .icon_color(Color::Muted)
+                        .tooltip(Tooltip::text("Close Workspace"))
+                        .on_click({
+                            let multi_workspace = multi_workspace;
+                            move |_, window, cx| {
+                                multi_workspace.update(cx, |mw, cx| {
+                                    mw.remove_workspace(workspace_index, window, cx);
+                                });
+                            }
+                        }),
+                    )
+                } else {
+                    None
+                };
+
+                let has_notification = self.notified_workspaces.contains(&workspace_index);
+                let (thread_subtitle, status_icon) = match thread_info {
+                    Some(info) => (
+                        Some(info.title),
+                        Some(render_thread_status_icon(
+                            workspace_index,
+                            &info.status,
+                            has_notification,
+                        )),
+                    ),
+                    None => (None, None),
+                };
+
+                Some(
+                    ListItem::new(("workspace-item", thread_entry.index))
+                        .toggle_state(selected)
+                        .when_some(close_button, |item, button| item.end_hover_slot(button))
+                        .child(render_project_row(title, thread_subtitle, status_icon, _cx))
+                        .when(!full_path.is_empty(), |item| {
+                            item.tooltip(move |_, cx| {
+                                Tooltip::with_meta(
+                                    worktree_label.clone(),
+                                    None,
+                                    full_path.clone(),
+                                    cx,
+                                )
+                            })
+                        })
+                        .into_any_element(),
+                )
+            }
+            SidebarEntry::RecentProject(project_entry) => {
+                let name = project_entry.name.clone();
+                let full_path = project_entry.full_path.clone();
+                let title = render_title(name.clone(), positions);
+                let item_id: SharedString =
+                    format!("recent-project-{:?}", project_entry.workspace_id).into();
+                let thread_title = self
+                    .recent_project_thread_titles
+                    .get(&project_entry.full_path)
+                    .cloned();
+
+                Some(
+                    ListItem::new(item_id)
+                        .toggle_state(selected)
+                        .child(render_project_row(title, thread_title, None, _cx))
+                        .tooltip(move |_, cx| {
+                            Tooltip::with_meta(name.clone(), None, full_path.clone(), cx)
+                        })
+                        .into_any_element(),
+                )
+            }
+        }
+    }
+
+    fn render_editor(
+        &self,
+        editor: &Arc<dyn ErasedEditor>,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Div {
+        h_flex()
+            .h(Tab::container_height(cx))
+            .w_full()
+            .px_2()
+            .gap_2()
+            .justify_between()
+            .border_b_1()
+            .border_color(cx.theme().colors().border)
+            .child(
+                Icon::new(IconName::MagnifyingGlass)
+                    .color(Color::Muted)
+                    .size(IconSize::Small),
+            )
+            .child(editor.render(window, cx))
+    }
+}
+
+pub struct Sidebar {
+    multi_workspace: Entity<MultiWorkspace>,
+    width: Pixels,
+    picker: Entity<Picker<WorkspacePickerDelegate>>,
+    _subscription: Subscription,
+    _project_subscriptions: Vec<Subscription>,
+    _agent_panel_subscriptions: Vec<Subscription>,
+    _thread_subscriptions: Vec<Subscription>,
+    #[cfg(any(test, feature = "test-support"))]
+    test_thread_infos: HashMap<usize, AgentThreadInfo>,
+    #[cfg(any(test, feature = "test-support"))]
+    test_recent_project_thread_titles: HashMap<SharedString, SharedString>,
+    _fetch_recent_projects: Task<()>,
+}
+
+impl EventEmitter<SidebarEvent> for Sidebar {}
+
+impl Sidebar {
+    pub fn new(
+        multi_workspace: Entity<MultiWorkspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let delegate = WorkspacePickerDelegate::new(multi_workspace.clone());
+        let picker = cx.new(|cx| {
+            Picker::list(delegate, window, cx)
+                .max_height(None)
+                .show_scrollbar(true)
+                .modal(false)
+        });
+
+        let subscription = cx.observe_in(
+            &multi_workspace,
+            window,
+            |this, multi_workspace, window, cx| {
+                this.queue_refresh(multi_workspace, window, cx);
+            },
+        );
+
+        let fetch_recent_projects = {
+            let picker = picker.downgrade();
+            let fs = <dyn Fs>::global(cx);
+            cx.spawn_in(window, async move |_this, cx| {
+                let projects = get_recent_projects(None, None, fs).await;
+
+                cx.update(|window, cx| {
+                    if let Some(picker) = picker.upgrade() {
+                        picker.update(cx, |picker, cx| {
+                            picker.delegate.set_recent_projects(projects, cx);
+                            let query = picker.query(cx);
+                            picker.update_matches(query, window, cx);
+                        });
+                    }
+                })
+                .log_err();
+            })
+        };
+
+        let mut this = Self {
+            multi_workspace,
+            width: DEFAULT_WIDTH,
+            picker,
+            _subscription: subscription,
+            _project_subscriptions: Vec::new(),
+            _agent_panel_subscriptions: Vec::new(),
+            _thread_subscriptions: Vec::new(),
+            #[cfg(any(test, feature = "test-support"))]
+            test_thread_infos: HashMap::new(),
+            #[cfg(any(test, feature = "test-support"))]
+            test_recent_project_thread_titles: HashMap::new(),
+            _fetch_recent_projects: fetch_recent_projects,
+        };
+        this.queue_refresh(this.multi_workspace.clone(), window, cx);
+        this
+    }
+
+    fn subscribe_to_projects(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Vec<Subscription> {
+        let projects: Vec<_> = self
+            .multi_workspace
+            .read(cx)
+            .workspaces()
+            .iter()
+            .map(|w| w.read(cx).project().clone())
+            .collect();
+
+        projects
+            .iter()
+            .map(|project| {
+                cx.subscribe_in(
+                    project,
+                    window,
+                    |this, _project, event, window, cx| match event {
+                        ProjectEvent::WorktreeAdded(_)
+                        | ProjectEvent::WorktreeRemoved(_)
+                        | ProjectEvent::WorktreeOrderChanged => {
+                            this.queue_refresh(this.multi_workspace.clone(), window, cx);
+                        }
+                        _ => {}
+                    },
+                )
+            })
+            .collect()
+    }
+
+    fn build_workspace_thread_entries(
+        &self,
+        multi_workspace: &MultiWorkspace,
+        cx: &App,
+    ) -> (Vec<WorkspaceThreadEntry>, usize) {
+        let persisted_titles = read_thread_title_map().unwrap_or_default();
+
+        #[allow(unused_mut)]
+        let mut entries: Vec<WorkspaceThreadEntry> = multi_workspace
+            .workspaces()
+            .iter()
+            .enumerate()
+            .map(|(index, workspace)| {
+                WorkspaceThreadEntry::new(index, workspace, &persisted_titles, cx)
+            })
+            .collect();
+
+        #[cfg(any(test, feature = "test-support"))]
+        for (index, info) in &self.test_thread_infos {
+            if let Some(entry) = entries.get_mut(*index) {
+                entry.thread_info = Some(info.clone());
+            }
+        }
+
+        (entries, multi_workspace.active_workspace_index())
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn set_test_recent_projects(
+        &self,
+        projects: Vec<RecentProjectEntry>,
+        cx: &mut Context<Self>,
+    ) {
+        self.picker.update(cx, |picker, _cx| {
+            picker.delegate.recent_projects = projects;
+        });
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn set_test_thread_info(
+        &mut self,
+        index: usize,
+        title: SharedString,
+        status: AgentThreadStatus,
+    ) {
+        self.test_thread_infos
+            .insert(index, AgentThreadInfo { title, status });
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn set_test_recent_project_thread_title(
+        &mut self,
+        full_path: SharedString,
+        title: SharedString,
+        cx: &mut Context<Self>,
+    ) {
+        self.test_recent_project_thread_titles
+            .insert(full_path.clone(), title.clone());
+        self.picker.update(cx, |picker, _cx| {
+            picker
+                .delegate
+                .recent_project_thread_titles
+                .insert(full_path, title);
+        });
+    }
+
+    fn subscribe_to_agent_panels(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Vec<Subscription> {
+        let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
+
+        workspaces
+            .iter()
+            .map(|workspace| {
+                if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(cx) {
+                    cx.subscribe_in(
+                        &agent_panel,
+                        window,
+                        |this, _, _event: &AgentPanelEvent, window, cx| {
+                            this.queue_refresh(this.multi_workspace.clone(), window, cx);
+                        },
+                    )
+                } else {
+                    // Panel hasn't loaded yet — observe the workspace so we
+                    // re-subscribe once the panel appears on its dock.
+                    cx.observe_in(workspace, window, |this, _, window, cx| {
+                        this.queue_refresh(this.multi_workspace.clone(), window, cx);
+                    })
+                }
+            })
+            .collect()
+    }
+
+    fn subscribe_to_threads(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Vec<Subscription> {
+        let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
+
+        workspaces
+            .iter()
+            .filter_map(|workspace| {
+                let agent_panel = workspace.read(cx).panel::<AgentPanel>(cx)?;
+                let thread = agent_panel.read(cx).active_agent_thread(cx)?;
+                Some(cx.observe_in(&thread, window, |this, _, window, cx| {
+                    this.queue_refresh(this.multi_workspace.clone(), window, cx);
+                }))
+            })
+            .collect()
+    }
+
+    fn persist_thread_titles(
+        &self,
+        entries: &[WorkspaceThreadEntry],
+        multi_workspace: &Entity<MultiWorkspace>,
+        cx: &mut Context<Self>,
+    ) {
+        let mut map = read_thread_title_map().unwrap_or_default();
+        let workspaces = multi_workspace.read(cx).workspaces().to_vec();
+        let mut changed = false;
+
+        for (workspace, entry) in workspaces.iter().zip(entries.iter()) {
+            if let Some(ref info) = entry.thread_info {
+                let paths: Vec<_> = workspace
+                    .read(cx)
+                    .worktrees(cx)
+                    .map(|wt| wt.read(cx).abs_path())
+                    .collect();
+                if paths.is_empty() {
+                    continue;
+                }
+                let path_key = sorted_paths_key(&paths);
+                let title = info.title.to_string();
+                if map.get(&path_key) != Some(&title) {
+                    map.insert(path_key, title);
+                    changed = true;
+                }
+            }
+        }
+
+        if changed {
+            if let Some(json) = serde_json::to_string(&map).log_err() {
+                cx.background_spawn(async move {
+                    KEY_VALUE_STORE
+                        .write_kvp(LAST_THREAD_TITLES_KEY.into(), json)
+                        .await
+                        .log_err();
+                })
+                .detach();
+            }
+        }
+    }
+
+    fn queue_refresh(
+        &mut self,
+        multi_workspace: Entity<MultiWorkspace>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        cx.defer_in(window, move |this, window, cx| {
+            this._project_subscriptions = this.subscribe_to_projects(window, cx);
+            this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx);
+            this._thread_subscriptions = this.subscribe_to_threads(window, cx);
+            let (entries, active_index) = multi_workspace.read_with(cx, |multi_workspace, cx| {
+                this.build_workspace_thread_entries(multi_workspace, cx)
+            });
+
+            this.persist_thread_titles(&entries, &multi_workspace, cx);
+
+            let had_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty();
+            this.picker.update(cx, |picker, cx| {
+                picker.delegate.set_entries(entries, active_index, cx);
+                let query = picker.query(cx);
+                picker.update_matches(query, window, cx);
+            });
+            let has_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty();
+            if had_notifications != has_notifications {
+                multi_workspace.update(cx, |_, cx| cx.notify());
+            }
+        });
+    }
+}
+
+impl WorkspaceSidebar for Sidebar {
+    fn width(&self, _cx: &App) -> Pixels {
+        self.width
+    }
+
+    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();
+    }
+
+    fn has_notifications(&self, cx: &App) -> bool {
+        !self.picker.read(cx).delegate.notified_workspaces.is_empty()
+    }
+}
+
+impl Focusable for Sidebar {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.picker.read(cx).focus_handle(cx)
+    }
+}
+
+fn sorted_paths_key<P: AsRef<Path>>(paths: &[P]) -> String {
+    let mut sorted: Vec<String> = paths
+        .iter()
+        .map(|p| p.as_ref().to_string_lossy().to_string())
+        .collect();
+    sorted.sort();
+    sorted.join("\n")
+}
+
+fn read_thread_title_map() -> Option<HashMap<String, String>> {
+    let json = KEY_VALUE_STORE
+        .read_kvp(LAST_THREAD_TITLES_KEY)
+        .log_err()
+        .flatten()?;
+    serde_json::from_str(&json).log_err()
+}
+
+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);
+
+        v_flex()
+            .id("workspace-sidebar")
+            .key_context("WorkspaceSidebar")
+            .font(ui_font)
+            .h_full()
+            .w(self.width)
+            .bg(cx.theme().colors().surface_background)
+            .border_r_1()
+            .border_color(cx.theme().colors().border)
+            .child(
+                h_flex()
+                    .flex_none()
+                    .h(titlebar_height)
+                    .w_full()
+                    .mt_px()
+                    .pb_px()
+                    .pr_2()
+                    .when(cfg!(target_os = "macos"), |this| {
+                        this.pl(px(TRAFFIC_LIGHT_PADDING))
+                    })
+                    .justify_between()
+                    .border_b_1()
+                    .border_color(cx.theme().colors().border)
+                    .child(
+                        IconButton::new("close-sidebar", IconName::WorkspaceNavOpen)
+                            .icon_size(IconSize::Small)
+                            .tooltip(|_window, cx| {
+                                Tooltip::for_action("Close Sidebar", &ToggleWorkspaceSidebar, cx)
+                            })
+                            .on_click(cx.listener(|_this, _, _window, cx| {
+                                cx.emit(SidebarEvent::Close);
+                            })),
+                    )
+                    .child(
+                        IconButton::new("new-workspace", IconName::Plus)
+                            .icon_size(IconSize::Small)
+                            .tooltip(|_window, cx| {
+                                Tooltip::for_action("New Workspace", &NewWorkspaceInWindow, cx)
+                            })
+                            .on_click(cx.listener(|this, _, window, cx| {
+                                this.multi_workspace.update(cx, |multi_workspace, cx| {
+                                    multi_workspace.create_workspace(window, cx);
+                                });
+                            })),
+                    ),
+            )
+            .child(self.picker.clone())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use feature_flags::FeatureFlagAppExt as _;
+    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);
+            editor::init(cx);
+            cx.update_flags(false, vec!["agent-v2".into()]);
+        });
+    }
+
+    fn set_thread_info_and_refresh(
+        sidebar: &Entity<Sidebar>,
+        multi_workspace: &Entity<MultiWorkspace>,
+        index: usize,
+        title: &str,
+        status: AgentThreadStatus,
+        cx: &mut gpui::VisualTestContext,
+    ) {
+        sidebar.update_in(cx, |s, _window, _cx| {
+            s.set_test_thread_info(index, SharedString::from(title.to_string()), status.clone());
+        });
+        multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
+        cx.run_until_parked();
+    }
+
+    fn has_notifications(sidebar: &Entity<Sidebar>, cx: &mut gpui::VisualTestContext) -> bool {
+        sidebar.read_with(cx, |s, cx| s.has_notifications(cx))
+    }
+
+    #[gpui::test]
+    async fn test_notification_on_running_to_completed_transition(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
+        let project = project::Project::test(fs, [], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+        let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
+            let mw_handle = cx.entity();
+            cx.new(|cx| Sidebar::new(mw_handle, window, cx))
+        });
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.register_sidebar(sidebar.clone(), window, cx);
+        });
+        cx.run_until_parked();
+
+        // Create a second workspace and switch to it so workspace 0 is background.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.create_workspace(window, cx);
+        });
+        cx.run_until_parked();
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(1, window, cx);
+        });
+        cx.run_until_parked();
+
+        assert!(
+            !has_notifications(&sidebar, cx),
+            "should have no notifications initially"
+        );
+
+        set_thread_info_and_refresh(
+            &sidebar,
+            &multi_workspace,
+            0,
+            "Test Thread",
+            AgentThreadStatus::Running,
+            cx,
+        );
+
+        assert!(
+            !has_notifications(&sidebar, cx),
+            "Running status alone should not create a notification"
+        );
+
+        set_thread_info_and_refresh(
+            &sidebar,
+            &multi_workspace,
+            0,
+            "Test Thread",
+            AgentThreadStatus::Completed,
+            cx,
+        );
+
+        assert!(
+            has_notifications(&sidebar, cx),
+            "Running → Completed transition should create a notification"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_no_notification_for_active_workspace(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
+        let project = project::Project::test(fs, [], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+        let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
+            let mw_handle = cx.entity();
+            cx.new(|cx| Sidebar::new(mw_handle, window, cx))
+        });
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.register_sidebar(sidebar.clone(), window, cx);
+        });
+        cx.run_until_parked();
+
+        // Workspace 0 is the active workspace — thread completes while
+        // the user is already looking at it.
+        set_thread_info_and_refresh(
+            &sidebar,
+            &multi_workspace,
+            0,
+            "Test Thread",
+            AgentThreadStatus::Running,
+            cx,
+        );
+        set_thread_info_and_refresh(
+            &sidebar,
+            &multi_workspace,
+            0,
+            "Test Thread",
+            AgentThreadStatus::Completed,
+            cx,
+        );
+
+        assert!(
+            !has_notifications(&sidebar, cx),
+            "should not notify for the workspace the user is already looking at"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_notification_cleared_on_workspace_activation(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        cx.update(|cx| <dyn Fs>::set_global(fs.clone(), cx));
+        let project = project::Project::test(fs, [], cx).await;
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+        let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| {
+            let mw_handle = cx.entity();
+            cx.new(|cx| Sidebar::new(mw_handle, window, cx))
+        });
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.register_sidebar(sidebar.clone(), window, cx);
+        });
+        cx.run_until_parked();
+
+        // Create a second workspace so we can switch away and back.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.create_workspace(window, cx);
+        });
+        cx.run_until_parked();
+
+        // Switch to workspace 1 so workspace 0 becomes a background workspace.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(1, window, cx);
+        });
+        cx.run_until_parked();
+
+        // Thread on workspace 0 transitions Running → Completed while
+        // the user is looking at workspace 1.
+        set_thread_info_and_refresh(
+            &sidebar,
+            &multi_workspace,
+            0,
+            "Test Thread",
+            AgentThreadStatus::Running,
+            cx,
+        );
+        set_thread_info_and_refresh(
+            &sidebar,
+            &multi_workspace,
+            0,
+            "Test Thread",
+            AgentThreadStatus::Completed,
+            cx,
+        );
+
+        assert!(
+            has_notifications(&sidebar, cx),
+            "background workspace completion should create a notification"
+        );
+
+        // Switching back to workspace 0 should clear the notification.
+        multi_workspace.update_in(cx, |mw, window, cx| {
+            mw.activate_index(0, window, cx);
+        });
+        cx.run_until_parked();
+
+        assert!(
+            !has_notifications(&sidebar, cx),
+            "notification should be cleared when workspace becomes active"
+        );
+    }
+}

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"] }
 menu.workspace = true

crates/title_bar/src/project_dropdown.rs 🔗

@@ -11,7 +11,8 @@ use project::{Project, Worktree, git_store::Repository};
 use recent_projects::{RecentProjectEntry, delete_recent_project, get_recent_projects};
 use settings::WorktreeId;
 use ui::{ContextMenu, DocumentationAside, DocumentationSide, Tooltip, prelude::*};
-use workspace::{CloseIntent, Workspace};
+use util::ResultExt as _;
+use workspace::{MultiWorkspace, Workspace};
 
 actions!(project_dropdown, [RemoveSelectedFolder]);
 
@@ -66,8 +67,12 @@ impl ProjectDropdown {
         let recent_projects_for_fetch = recent_projects.clone();
         let menu_shell_for_fetch = menu_shell.clone();
         let workspace_for_fetch = workspace.clone();
+        let fs = workspace
+            .upgrade()
+            .map(|ws| ws.read(cx).app_state().fs.clone());
 
         cx.spawn_in(window, async move |_this, cx| {
+            let Some(fs) = fs else { return };
             let current_workspace_id = cx
                 .update(|_, cx| {
                     workspace_for_fetch
@@ -77,7 +82,7 @@ impl ProjectDropdown {
                 .ok()
                 .flatten();
 
-            let projects = get_recent_projects(current_workspace_id, None).await;
+            let projects = get_recent_projects(current_workspace_id, None, fs).await;
 
             cx.update(|window, cx| {
                 *recent_projects_for_fetch.borrow_mut() = projects;
@@ -88,7 +93,7 @@ impl ProjectDropdown {
                     });
                 }
             })
-            .ok()
+            .ok();
         })
         .detach();
 
@@ -396,36 +401,31 @@ impl ProjectDropdown {
         window: &mut Window,
         cx: &mut App,
     ) {
-        let Some(workspace) = workspace.upgrade() else {
-            return;
-        };
+        if create_new_window {
+            let Some(workspace) = workspace.upgrade() else {
+                return;
+            };
+            workspace.update(cx, |workspace, cx| {
+                workspace
+                    .open_workspace_for_paths(false, paths, window, cx)
+                    .detach_and_log_err(cx);
+            });
+        } else {
+            let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() else {
+                return;
+            };
 
-        workspace.update(cx, |workspace, cx| {
-            if create_new_window {
-                workspace.open_workspace_for_paths(false, paths, window, cx)
-            } else {
-                cx.spawn_in(window, {
-                    let paths = paths.clone();
-                    async move |workspace, cx| {
-                        let continue_replacing = workspace
-                            .update_in(cx, |workspace, window, cx| {
-                                workspace.prepare_to_close(CloseIntent::ReplaceWindow, window, cx)
-                            })?
-                            .await?;
-                        if continue_replacing {
-                            workspace
-                                .update_in(cx, |workspace, window, cx| {
-                                    workspace.open_workspace_for_paths(true, paths, window, cx)
-                                })?
-                                .await
-                        } else {
-                            Ok(())
-                        }
-                    }
-                })
-            }
-            .detach_and_log_err(cx);
-        });
+            cx.defer(move |cx| {
+                if let Some(task) = handle
+                    .update(cx, |multi_workspace, window, cx| {
+                        multi_workspace.open_project(paths, window, cx)
+                    })
+                    .log_err()
+                {
+                    task.detach_and_log_err(cx);
+                }
+            });
+        }
     }
 
     /// Get all projects sorted alphabetically with their branch info.

crates/title_bar/src/title_bar.rs 🔗

@@ -22,6 +22,7 @@ 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, Entity, FocusHandle, Focusable,
     InteractiveElement, IntoElement, MouseButton, ParentElement, Render,
@@ -38,10 +39,13 @@ use theme::ActiveTheme;
 use title_bar_settings::TitleBarSettings;
 use ui::{
     Avatar, ButtonLike, Chip, ContextMenu, IconWithIndicator, Indicator, PopoverMenu,
-    PopoverMenuHandle, TintColor, Tooltip, prelude::*,
+    PopoverMenuHandle, TintColor, Tooltip, prelude::*, utils::platform_title_bar_height,
 };
 use util::ResultExt;
-use workspace::{SwitchProject, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt};
+use workspace::{
+    MultiWorkspace, SwitchProject, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace,
+    notifications::NotifyResultExt,
+};
 use zed_actions::OpenRemote;
 
 pub use onboarding_banner::restore_banner;
@@ -158,7 +162,7 @@ impl Render for TitleBar {
 
         children.push(
             h_flex()
-                .gap_1()
+                .gap_0p5()
                 .map(|title_bar| {
                     let mut render_project_items = title_bar_settings.show_branch_name
                         || title_bar_settings.show_project_items;
@@ -171,6 +175,7 @@ impl Render for TitleBar {
                                 title_bar.child(menu)
                             },
                         )
+                        .children(self.render_workspace_sidebar_toggle(window, cx))
                         .children(self.render_restricted_mode(cx))
                         .when(render_project_items, |title_bar| {
                             title_bar
@@ -232,7 +237,7 @@ impl Render for TitleBar {
                 );
             });
 
-            let height = PlatformTitleBar::height(window);
+            let height = platform_title_bar_height(window);
             let title_bar_color = self.platform_titlebar.update(cx, |platform_titlebar, cx| {
                 platform_titlebar.title_bar_color(window, cx)
             });
@@ -340,6 +345,48 @@ impl TitleBar {
 
         let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx));
 
+        // 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>()
+                else {
+                    return;
+                };
+
+                let _ = cx.update(|cx| {
+                    let Ok(multi_workspace) = multi_workspace_handle.entity(cx) else {
+                        return;
+                    };
+
+                    let is_open = multi_workspace.read(cx).is_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).is_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);
+                        });
+                    }
+                });
+            })
+            .detach();
+        }
+
         Self {
             platform_titlebar,
             application_menu,
@@ -627,6 +674,41 @@ impl TitleBar {
         )
     }
 
+    fn render_workspace_sidebar_toggle(
+        &self,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<AnyElement> {
+        if !cx.has_flag::<AgentV2FeatureFlag>() {
+            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::WorkspaceNavClosed)
+                .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 Workspace Sidebar", &ToggleWorkspaceSidebar, cx)
+                })
+                .on_click(|_, window, cx| {
+                    window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx);
+                })
+                .into_any_element(),
+        )
+    }
+
     pub fn render_project_name(&self, cx: &mut Context<Self>) -> impl IntoElement {
         let workspace = self.workspace.clone();
 
@@ -911,16 +993,18 @@ impl TitleBar {
 
     pub fn render_sign_in_button(&mut self, _: &mut Context<Self>) -> Button {
         let client = self.client.clone();
+        let workspace = self.workspace.clone();
         Button::new("sign_in", "Sign In")
             .label_size(LabelSize::Small)
             .on_click(move |_, window, cx| {
                 let client = client.clone();
+                let workspace = workspace.clone();
                 window
-                    .spawn(cx, async move |cx| {
+                    .spawn(cx, async move |mut cx| {
                         client
                             .sign_in_with_optional_connect(true, cx)
                             .await
-                            .notify_async_err(cx);
+                            .notify_workspace_async_err(workspace, &mut cx);
                     })
                     .detach();
             })

crates/ui/src/components/thread_item.rs 🔗

@@ -1,5 +1,6 @@
 use crate::{
-    Chip, DecoratedIcon, DiffStat, IconDecoration, IconDecorationKind, SpinnerLabel, prelude::*,
+    DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel,
+    prelude::*,
 };
 use gpui::{ClickEvent, SharedString};
 
@@ -8,6 +9,7 @@ pub struct ThreadItem {
     id: ElementId,
     icon: IconName,
     title: SharedString,
+    highlight_positions: Vec<usize>,
     timestamp: SharedString,
     running: bool,
     generation_done: bool,
@@ -24,6 +26,7 @@ impl ThreadItem {
             id: id.into(),
             icon: IconName::ZedAgent,
             title: title.into(),
+            highlight_positions: Vec::new(),
             timestamp: "".into(),
             running: false,
             generation_done: false,
@@ -75,6 +78,11 @@ impl ThreadItem {
         self
     }
 
+    pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
+        self.highlight_positions = positions;
+        self
+    }
+
     pub fn on_click(
         mut self,
         handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -112,7 +120,17 @@ impl RenderOnce for ThreadItem {
             agent_icon.into_any_element()
         };
 
-        let has_no_changes = self.added.is_none() && self.removed.is_none();
+        // let has_no_changes = self.added.is_none() && self.removed.is_none();
+
+        let title = self.title;
+        let highlight_positions = self.highlight_positions;
+        let title_label = if highlight_positions.is_empty() {
+            Label::new(title).truncate().into_any_element()
+        } else {
+            HighlightedLabel::new(title, highlight_positions)
+                .truncate()
+                .into_any_element()
+        };
 
         v_flex()
             .id(self.id.clone())
@@ -127,7 +145,7 @@ impl RenderOnce for ThreadItem {
                     .w_full()
                     .gap_1p5()
                     .child(icon)
-                    .child(Label::new(self.title).truncate())
+                    .child(title_label)
                     .when(self.running, |this| {
                         this.child(icon_container().child(SpinnerLabel::new().color(Color::Accent)))
                     }),
@@ -137,26 +155,32 @@ impl RenderOnce for ThreadItem {
                     .gap_1p5()
                     .child(icon_container()) // Icon Spacing
                     .when_some(self.worktree, |this, name| {
-                        this.child(Chip::new(name).label_size(LabelSize::XSmall))
+                        this.child(Label::new(name).size(LabelSize::Small).color(Color::Muted))
                     })
-                    .child(
-                        Label::new(self.timestamp)
-                            .size(LabelSize::Small)
-                            .color(Color::Muted),
-                    )
                     .child(
                         Label::new("•")
                             .size(LabelSize::Small)
                             .color(Color::Muted)
                             .alpha(0.5),
                     )
-                    .when(has_no_changes, |this| {
-                        this.child(
-                            Label::new("No Changes")
-                                .size(LabelSize::Small)
-                                .color(Color::Muted),
-                        )
-                    })
+                    .child(
+                        Label::new(self.timestamp)
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    )
+                    // .child(
+                    //     Label::new("•")
+                    //         .size(LabelSize::Small)
+                    //         .color(Color::Muted)
+                    //         .alpha(0.5),
+                    // )
+                    // .when(has_no_changes, |this| {
+                    //     this.child(
+                    //         Label::new("No Changes")
+                    //             .size(LabelSize::Small)
+                    //             .color(Color::Muted),
+                    //     )
+                    // })
                     .when(self.added.is_some() || self.removed.is_some(), |this| {
                         this.child(DiffStat::new(
                             self.id,

crates/ui/src/utils.rs 🔗

@@ -5,6 +5,7 @@ use theme::ActiveTheme;
 
 mod apca_contrast;
 mod color_contrast;
+mod constants;
 mod corner_solver;
 mod format_distance;
 mod search_input;
@@ -12,6 +13,7 @@ mod with_rem_size;
 
 pub use apca_contrast::*;
 pub use color_contrast::*;
+pub use constants::*;
 pub use corner_solver::{CornerSolver, inner_corner_radius};
 pub use format_distance::*;
 pub use search_input::*;

crates/ui/src/utils/constants.rs 🔗

@@ -0,0 +1,27 @@
+use gpui::{Pixels, Window, px};
+
+// Use pixels here instead of a rem-based size because the macOS traffic
+// lights are a static size, and don't scale with the rest of the UI.
+//
+// Magic number: There is one extra pixel of padding on the left side due to
+// the 1px border around the window on macOS apps.
+#[cfg(macos_sdk_26)]
+pub const TRAFFIC_LIGHT_PADDING: f32 = 78.;
+
+#[cfg(not(macos_sdk_26))]
+pub const TRAFFIC_LIGHT_PADDING: f32 = 71.;
+
+/// Returns the platform-appropriate title bar height.
+///
+/// On Windows, this returns a fixed height of 32px.
+/// On other platforms, it scales with the window's rem size (1.75x) with a minimum of 34px.
+#[cfg(not(target_os = "windows"))]
+pub fn platform_title_bar_height(window: &Window) -> Pixels {
+    (1.75 * window.rem_size()).max(px(34.))
+}
+
+#[cfg(target_os = "windows")]
+pub fn platform_title_bar_height(_window: &Window) -> Pixels {
+    // todo(windows) instead of hard coded size report the actual size to the Windows platform API
+    px(32.)
+}

crates/vim/src/state.rs 🔗

@@ -36,7 +36,7 @@ use ui::{
 use util::ResultExt;
 use util::rel_path::RelPath;
 use workspace::searchable::Direction;
-use workspace::{Workspace, WorkspaceDb, WorkspaceId};
+use workspace::{MultiWorkspace, Workspace, WorkspaceDb, WorkspaceId};
 
 #[derive(Clone, Copy, Default, Debug, PartialEq, Serialize, Deserialize)]
 pub enum Mode {
@@ -731,12 +731,16 @@ impl VimGlobals {
                 });
                 GlobalCommandPaletteInterceptor::set(cx, command_interceptor);
                 for window in cx.windows() {
-                    if let Some(workspace) = window.downcast::<Workspace>() {
-                        workspace
-                            .update(cx, |workspace, _, cx| {
-                                Vim::update_globals(cx, |globals, cx| {
-                                    globals.register_workspace(workspace, cx)
-                                });
+                    if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
+                        multi_workspace
+                            .update(cx, |multi_workspace, _, cx| {
+                                for workspace in multi_workspace.workspaces() {
+                                    workspace.update(cx, |workspace, cx| {
+                                        Vim::update_globals(cx, |globals, cx| {
+                                            globals.register_workspace(workspace, cx)
+                                        });
+                                    });
+                                }
                             })
                             .ok();
                     }

crates/workspace/Cargo.toml 🔗

@@ -79,6 +79,7 @@ db = { workspace = true, features = ["test-support"] }
 fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
 project = { workspace = true, features = ["test-support"] }
+remote = { workspace = true, features = ["test-support"] }
 session = { workspace = true, features = ["test-support"] }
 settings = { workspace = true, features = ["test-support"] }
 http_client = { workspace = true, features = ["test-support"] }

crates/workspace/src/history_manager.rs 🔗

@@ -1,5 +1,6 @@
-use std::path::PathBuf;
+use std::{path::PathBuf, sync::Arc};
 
+use fs::Fs;
 use gpui::{AppContext, Entity, Global, MenuItem};
 use smallvec::SmallVec;
 use ui::{App, Context};
@@ -9,10 +10,10 @@ use crate::{
     NewWindow, SerializedWorkspaceLocation, WORKSPACE_DB, WorkspaceId, path_list::PathList,
 };
 
-pub fn init(cx: &mut App) {
+pub fn init(fs: Arc<dyn Fs>, cx: &mut App) {
     let manager = cx.new(|_| HistoryManager::new());
     HistoryManager::set_global(manager.clone(), cx);
-    HistoryManager::init(manager, cx);
+    HistoryManager::init(manager, fs, cx);
 }
 
 pub struct HistoryManager {
@@ -38,10 +39,10 @@ impl HistoryManager {
         }
     }
 
-    fn init(this: Entity<HistoryManager>, cx: &App) {
+    fn init(this: Entity<HistoryManager>, fs: Arc<dyn Fs>, cx: &App) {
         cx.spawn(async move |cx| {
             let recent_folders = WORKSPACE_DB
-                .recent_workspaces_on_disk()
+                .recent_workspaces_on_disk(fs.as_ref())
                 .await
                 .unwrap_or_default()
                 .into_iter()

crates/workspace/src/multi_workspace.rs 🔗

@@ -0,0 +1,513 @@
+use anyhow::Result;
+use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt};
+use gpui::{
+    AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, Focusable, ManagedView,
+    MouseButton, Pixels, Render, Subscription, Task, Window, actions, deferred, px,
+};
+use project::Project;
+use std::path::PathBuf;
+use ui::prelude::*;
+
+const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0);
+
+use crate::{
+    DockPosition, Item, ModalView, Panel, Workspace, WorkspaceId, client_side_decorations,
+};
+
+actions!(
+    multi_workspace,
+    [
+        /// Creates a new workspace within the current window.
+        NewWorkspaceInWindow,
+        /// Switches to the next workspace within the current window.
+        NextWorkspaceInWindow,
+        /// Switches to the previous workspace within the current window.
+        PreviousWorkspaceInWindow,
+        /// Toggles the workspace switcher sidebar.
+        ToggleWorkspaceSidebar,
+    ]
+);
+
+pub enum SidebarEvent {
+    Open,
+    Close,
+}
+
+pub trait Sidebar: EventEmitter<SidebarEvent> + 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(&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;
+
+impl Render for DraggedSidebar {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        gpui::Empty
+    }
+}
+
+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(&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 {
+    workspaces: Vec<Entity<Workspace>>,
+    active_workspace_index: usize,
+    sidebar: Option<Box<dyn SidebarHandle>>,
+    sidebar_open: bool,
+    _sidebar_subscription: Option<Subscription>,
+}
+
+impl MultiWorkspace {
+    pub fn new(workspace: Entity<Workspace>, _cx: &mut Context<Self>) -> Self {
+        Self {
+            workspaces: vec![workspace],
+            active_workspace_index: 0,
+            sidebar: None,
+            sidebar_open: false,
+            _sidebar_subscription: None,
+        }
+    }
+
+    pub fn register_sidebar<T: Sidebar>(
+        &mut self,
+        sidebar: Entity<T>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let subscription =
+            cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event {
+                SidebarEvent::Open => this.toggle_sidebar(window, cx),
+                SidebarEvent::Close => {
+                    this.close_sidebar(window, cx);
+                }
+            });
+        self.sidebar = Some(Box::new(sidebar));
+        self._sidebar_subscription = Some(subscription);
+    }
+
+    pub fn sidebar(&self) -> Option<&dyn SidebarHandle> {
+        self.sidebar.as_deref()
+    }
+
+    pub fn sidebar_open(&self) -> bool {
+        self.sidebar_open && self.sidebar.is_some()
+    }
+
+    pub fn sidebar_has_notifications(&self, cx: &App) -> bool {
+        self.sidebar
+            .as_ref()
+            .map_or(false, |s| s.has_notifications(cx))
+    }
+
+    pub(crate) fn multi_workspace_enabled(&self, cx: &App) -> bool {
+        cx.has_flag::<AgentV2FeatureFlag>()
+    }
+
+    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);
+            let pane = self.workspace().read(cx).active_pane().clone();
+            window.focus(&pane.read(cx).focus_handle(cx), cx);
+        } else {
+            self.open_sidebar(window, cx);
+            if let Some(sidebar) = &self.sidebar {
+                sidebar.focus(window, cx);
+            }
+        }
+    }
+
+    pub fn open_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.sidebar_open = true;
+        self.serialize(window, cx);
+        cx.notify();
+    }
+
+    fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.sidebar_open = false;
+        self.serialize(window, cx);
+        cx.notify();
+    }
+
+    pub fn is_sidebar_open(&self) -> bool {
+        self.sidebar_open
+    }
+
+    pub fn workspace(&self) -> &Entity<Workspace> {
+        &self.workspaces[self.active_workspace_index]
+    }
+
+    pub fn workspaces(&self) -> &[Entity<Workspace>] {
+        &self.workspaces
+    }
+
+    pub fn active_workspace_index(&self) -> usize {
+        self.active_workspace_index
+    }
+
+    pub fn activate(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
+        if !self.multi_workspace_enabled(cx) {
+            self.workspaces[0] = workspace;
+            self.active_workspace_index = 0;
+            cx.notify();
+            return;
+        }
+
+        let index = self.add_workspace(workspace, cx);
+        if self.active_workspace_index != index {
+            self.active_workspace_index = index;
+            cx.notify();
+        }
+    }
+
+    /// Adds a workspace to this window without changing which workspace is active.
+    /// Returns the index of the workspace (existing or newly inserted).
+    pub fn add_workspace(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) -> usize {
+        if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) {
+            index
+        } else {
+            self.workspaces.push(workspace);
+            cx.notify();
+            self.workspaces.len() - 1
+        }
+    }
+
+    pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
+        debug_assert!(
+            index < self.workspaces.len(),
+            "workspace index out of bounds"
+        );
+        self.active_workspace_index = index;
+        self.serialize(window, cx);
+        self.focus_active_workspace(window, cx);
+        cx.notify();
+    }
+
+    pub fn activate_next_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if self.workspaces.len() > 1 {
+            let next_index = (self.active_workspace_index + 1) % self.workspaces.len();
+            self.activate_index(next_index, window, cx);
+        }
+    }
+
+    pub fn activate_previous_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if self.workspaces.len() > 1 {
+            let prev_index = if self.active_workspace_index == 0 {
+                self.workspaces.len() - 1
+            } else {
+                self.active_workspace_index - 1
+            };
+            self.activate_index(prev_index, window, cx);
+        }
+    }
+
+    fn serialize(&self, window: &mut Window, cx: &mut App) {
+        let window_id = window.window_handle().window_id();
+        let state = crate::persistence::model::MultiWorkspaceState {
+            active_workspace_id: self.workspace().read(cx).database_id(),
+            sidebar_open: self.sidebar_open,
+        };
+        cx.background_spawn(async move {
+            crate::persistence::write_multi_workspace_state(window_id, state).await;
+        })
+        .detach();
+    }
+
+    fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) {
+        let pane = self.workspace().read(cx).active_pane().clone();
+        let focus_handle = pane.read(cx).focus_handle(cx);
+        window.focus(&focus_handle, cx);
+    }
+
+    pub fn panel<T: Panel>(&self, cx: &App) -> Option<Entity<T>> {
+        self.workspace().read(cx).panel::<T>(cx)
+    }
+
+    pub fn active_modal<V: ManagedView + 'static>(&self, cx: &App) -> Option<Entity<V>> {
+        self.workspace().read(cx).active_modal::<V>(cx)
+    }
+
+    pub fn add_panel<T: Panel>(
+        &mut self,
+        panel: Entity<T>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.workspace().update(cx, |workspace, cx| {
+            workspace.add_panel(panel, window, cx);
+        });
+    }
+
+    pub fn focus_panel<T: Panel>(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<Entity<T>> {
+        self.workspace()
+            .update(cx, |workspace, cx| workspace.focus_panel::<T>(window, cx))
+    }
+
+    pub fn toggle_modal<V: ModalView, B>(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+        build: B,
+    ) where
+        B: FnOnce(&mut Window, &mut gpui::Context<V>) -> V,
+    {
+        self.workspace().update(cx, |workspace, cx| {
+            workspace.toggle_modal(window, cx, build);
+        });
+    }
+
+    pub fn toggle_dock(
+        &mut self,
+        dock_side: DockPosition,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.workspace().update(cx, |workspace, cx| {
+            workspace.toggle_dock(dock_side, window, cx);
+        });
+    }
+
+    pub fn active_item_as<I: 'static>(&self, cx: &App) -> Option<Entity<I>> {
+        self.workspace().read(cx).active_item_as::<I>(cx)
+    }
+
+    pub fn items_of_type<'a, T: Item>(
+        &'a self,
+        cx: &'a App,
+    ) -> impl 'a + Iterator<Item = Entity<T>> {
+        self.workspace().read(cx).items_of_type::<T>(cx)
+    }
+
+    pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
+        self.workspace().read(cx).database_id()
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn set_random_database_id(&mut self, cx: &mut Context<Self>) {
+        self.workspace().update(cx, |workspace, _cx| {
+            workspace.set_random_database_id();
+        });
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn test_new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+        let workspace = cx.new(|cx| Workspace::test_new(project, window, cx));
+        Self::new(workspace, cx)
+    }
+
+    pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if !self.multi_workspace_enabled(cx) {
+            return;
+        }
+        let app_state = self.workspace().read(cx).app_state().clone();
+        let project = Project::local(
+            app_state.client.clone(),
+            app_state.node_runtime.clone(),
+            app_state.user_store.clone(),
+            app_state.languages.clone(),
+            app_state.fs.clone(),
+            None,
+            project::LocalProjectFlags::default(),
+            cx,
+        );
+        let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx));
+        self.activate(new_workspace, cx);
+        self.focus_active_workspace(window, cx);
+    }
+
+    pub fn remove_workspace(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
+        if self.workspaces.len() <= 1 || index >= self.workspaces.len() {
+            return;
+        }
+
+        self.workspaces.remove(index);
+
+        if self.active_workspace_index >= self.workspaces.len() {
+            self.active_workspace_index = self.workspaces.len() - 1;
+        } else if self.active_workspace_index > index {
+            self.active_workspace_index -= 1;
+        }
+
+        self.focus_active_workspace(window, cx);
+        cx.notify();
+    }
+
+    pub fn open_project(
+        &mut self,
+        paths: Vec<PathBuf>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        let workspace = self.workspace().clone();
+
+        if self.multi_workspace_enabled(cx) {
+            workspace.update(cx, |workspace, cx| {
+                workspace.open_workspace_for_paths(true, paths, window, cx)
+            })
+        } else {
+            cx.spawn_in(window, async move |_this, cx| {
+                let should_continue = workspace
+                    .update_in(cx, |workspace, window, cx| {
+                        workspace.prepare_to_close(crate::CloseIntent::ReplaceWindow, window, cx)
+                    })?
+                    .await?;
+                if should_continue {
+                    workspace
+                        .update_in(cx, |workspace, window, cx| {
+                            workspace.open_workspace_for_paths(true, paths, window, cx)
+                        })?
+                        .await
+                } else {
+                    Ok(())
+                }
+            })
+        }
+    }
+}
+
+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
+        };
+
+        client_side_decorations(
+            h_flex()
+                .key_context("Workspace")
+                .size_full()
+                .on_action(
+                    cx.listener(|this: &mut Self, _: &NewWorkspaceInWindow, window, cx| {
+                        this.create_workspace(window, cx);
+                    }),
+                )
+                .on_action(
+                    cx.listener(|this: &mut Self, _: &NextWorkspaceInWindow, window, cx| {
+                        this.activate_next_workspace(window, cx);
+                    }),
+                )
+                .on_action(cx.listener(
+                    |this: &mut Self, _: &PreviousWorkspaceInWindow, window, cx| {
+                        this.activate_previous_workspace(window, cx);
+                    },
+                ))
+                .on_action(cx.listener(
+                    |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| {
+                        this.toggle_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()
+                        .flex_1()
+                        .size_full()
+                        .overflow_hidden()
+                        .child(self.workspace().clone()),
+                ),
+            window,
+            cx,
+        )
+    }
+}

crates/workspace/src/notifications.rs 🔗

@@ -1,9 +1,9 @@
-use crate::{SuppressNotification, Toast, Workspace};
+use crate::{MultiWorkspace, SuppressNotification, Toast, Workspace};
 use anyhow::Context as _;
 use gpui::{
-    AnyEntity, AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, Context,
+    AnyEntity, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, ClickEvent, Context,
     DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle,
-    Task, TextStyleRefinement, UnderlineStyle, svg,
+    Task, TextStyleRefinement, UnderlineStyle, WeakEntity, svg,
 };
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 use parking_lot::Mutex;
@@ -1037,14 +1037,18 @@ pub fn show_app_notification<V: Notification + 'static>(
             .insert(id.clone(), build_notification.clone());
 
         for window in cx.windows() {
-            if let Some(workspace_window) = window.downcast::<Workspace>() {
-                workspace_window
-                    .update(cx, |workspace, _window, cx| {
-                        workspace.show_notification_without_handling_dismiss_events(
-                            &id,
-                            cx,
-                            |cx| build_notification(cx),
-                        );
+            if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
+                multi_workspace
+                    .update(cx, |multi_workspace, _window, cx| {
+                        for workspace in multi_workspace.workspaces() {
+                            workspace.update(cx, |workspace, cx| {
+                                workspace.show_notification_without_handling_dismiss_events(
+                                    &id,
+                                    cx,
+                                    |cx| build_notification(cx),
+                                );
+                            });
+                        }
                     })
                     .ok(); // Doesn't matter if the windows are dropped
             }
@@ -1058,11 +1062,15 @@ pub fn dismiss_app_notification(id: &NotificationId, cx: &mut App) {
     cx.defer(move |cx| {
         GLOBAL_APP_NOTIFICATIONS.lock().remove(&id);
         for window in cx.windows() {
-            if let Some(workspace_window) = window.downcast::<Workspace>() {
+            if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
                 let id = id.clone();
-                workspace_window
-                    .update(cx, |workspace, _window, cx| {
-                        workspace.dismiss_notification(&id, cx)
+                multi_workspace
+                    .update(cx, |multi_workspace, _window, cx| {
+                        for workspace in multi_workspace.workspaces() {
+                            workspace.update(cx, |workspace, cx| {
+                                workspace.dismiss_notification(&id, cx)
+                            });
+                        }
                     })
                     .ok();
             }
@@ -1076,7 +1084,11 @@ pub trait NotifyResultExt {
     fn notify_err(self, workspace: &mut Workspace, cx: &mut Context<Workspace>)
     -> Option<Self::Ok>;
 
-    fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<Self::Ok>;
+    fn notify_workspace_async_err(
+        self,
+        workspace: WeakEntity<Workspace>,
+        cx: &mut AsyncApp,
+    ) -> Option<Self::Ok>;
 
     /// Notifies the active workspace if there is one, otherwise notifies all workspaces.
     fn notify_app_err(self, cx: &mut App) -> Option<Self::Ok>;
@@ -1099,17 +1111,18 @@ where
         }
     }
 
-    fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<T> {
+    fn notify_workspace_async_err(
+        self,
+        workspace: WeakEntity<Workspace>,
+        cx: &mut AsyncApp,
+    ) -> Option<T> {
         match self {
             Ok(value) => Some(value),
             Err(err) => {
                 log::error!("{err:?}");
-                cx.update_root(|view, _, cx| {
-                    if let Ok(workspace) = view.downcast::<Workspace>() {
-                        workspace.update(cx, |workspace, cx| workspace.show_error(&err, cx))
-                    }
-                })
-                .ok();
+                workspace
+                    .update(cx, |workspace, cx| workspace.show_error(&err, cx))
+                    .ok();
                 None
             }
         }
@@ -1137,7 +1150,12 @@ where
 }
 
 pub trait NotifyTaskExt {
-    fn detach_and_notify_err(self, window: &mut Window, cx: &mut App);
+    fn detach_and_notify_err(
+        self,
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut App,
+    );
 }
 
 impl<R, E> NotifyTaskExt for Task<std::result::Result<R, E>>
@@ -1145,9 +1163,16 @@ where
     E: std::fmt::Debug + std::fmt::Display + Sized + 'static,
     R: 'static,
 {
-    fn detach_and_notify_err(self, window: &mut Window, cx: &mut App) {
+    fn detach_and_notify_err(
+        self,
+        workspace: WeakEntity<Workspace>,
+        window: &mut Window,
+        cx: &mut App,
+    ) {
         window
-            .spawn(cx, async move |cx| self.await.notify_async_err(cx))
+            .spawn(cx, async move |mut cx| {
+                self.await.notify_workspace_async_err(workspace, &mut cx)
+            })
             .detach();
     }
 }

crates/workspace/src/pane.rs 🔗

@@ -3881,9 +3881,10 @@ impl Pane {
                         .path_for_entry(project_entry_id, cx)
                     {
                         let load_path_task = workspace.load_path(project_path.clone(), window, cx);
-                        cx.spawn_in(window, async move |workspace, cx| {
-                            if let Some((project_entry_id, build_item)) =
-                                load_path_task.await.notify_async_err(cx)
+                        cx.spawn_in(window, async move |workspace, mut cx| {
+                            if let Some((project_entry_id, build_item)) = load_path_task
+                                .await
+                                .notify_workspace_async_err(workspace.clone(), &mut cx)
                             {
                                 let (to_pane, new_item_handle) = workspace
                                     .update_in(cx, |workspace, window, cx| {

crates/workspace/src/persistence.rs 🔗

@@ -8,6 +8,8 @@ use std::{
     sync::Arc,
 };
 
+use fs::Fs;
+
 use anyhow::{Context as _, Result, bail};
 use collections::{HashMap, HashSet, IndexSet};
 use db::{
@@ -48,7 +50,7 @@ use model::{
     SerializedPaneGroup, SerializedWorkspace,
 };
 
-use self::model::{DockStructure, SerializedWorkspaceLocation};
+use self::model::{DockStructure, SerializedWorkspaceLocation, SessionWorkspace};
 
 // https://www.sqlite.org/limits.html
 // > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER,
@@ -281,6 +283,64 @@ impl From<WindowBoundsJson> for WindowBounds {
     }
 }
 
+fn multi_workspace_states() -> db::kvp::ScopedKeyValueStore<'static> {
+    KEY_VALUE_STORE.scoped("multi_workspace_state")
+}
+
+fn read_multi_workspace_state(window_id: WindowId) -> model::MultiWorkspaceState {
+    multi_workspace_states()
+        .read(&window_id.as_u64().to_string())
+        .log_err()
+        .flatten()
+        .and_then(|json| serde_json::from_str(&json).ok())
+        .unwrap_or_default()
+}
+
+pub async fn write_multi_workspace_state(window_id: WindowId, state: model::MultiWorkspaceState) {
+    if let Ok(json_str) = serde_json::to_string(&state) {
+        multi_workspace_states()
+            .write(window_id.as_u64().to_string(), json_str)
+            .await
+            .log_err();
+    }
+}
+
+pub fn read_serialized_multi_workspaces(
+    session_workspaces: Vec<model::SessionWorkspace>,
+) -> Vec<model::SerializedMultiWorkspace> {
+    let mut window_groups: Vec<Vec<model::SessionWorkspace>> = Vec::new();
+    let mut window_id_to_group: HashMap<WindowId, usize> = HashMap::default();
+
+    for session_workspace in session_workspaces {
+        match session_workspace.window_id {
+            Some(window_id) => {
+                let group_index = *window_id_to_group.entry(window_id).or_insert_with(|| {
+                    window_groups.push(Vec::new());
+                    window_groups.len() - 1
+                });
+                window_groups[group_index].push(session_workspace);
+            }
+            None => {
+                window_groups.push(vec![session_workspace]);
+            }
+        }
+    }
+
+    window_groups
+        .into_iter()
+        .map(|group| {
+            let window_id = group.first().and_then(|sw| sw.window_id);
+            let state = window_id
+                .map(read_multi_workspace_state)
+                .unwrap_or_default();
+            model::SerializedMultiWorkspace {
+                workspaces: group,
+                state,
+            }
+        })
+        .collect()
+}
+
 const DEFAULT_DOCK_STATE_KEY: &str = "default_dock_state";
 
 pub fn read_default_dock_state() -> Option<DockStructure> {
@@ -1708,10 +1768,26 @@ impl WorkspaceDb {
         }
     }
 
+    async fn all_paths_exist_with_a_directory(paths: &[PathBuf], fs: &dyn Fs) -> bool {
+        let mut any_dir = false;
+        for path in paths {
+            match fs.metadata(path).await.ok().flatten() {
+                None => return false,
+                Some(meta) => {
+                    if meta.is_dir {
+                        any_dir = true;
+                    }
+                }
+            }
+        }
+        any_dir
+    }
+
     // Returns the recent locations which are still valid on disk and deletes ones which no longer
     // exist.
     pub async fn recent_workspaces_on_disk(
         &self,
+        fs: &dyn Fs,
     ) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
         let mut result = Vec::new();
         let mut delete_tasks = Vec::new();
@@ -1744,11 +1820,8 @@ impl WorkspaceDb {
             // If a local workspace points to WSL, this check will cause us to wait for the
             // WSL VM and file server to boot up. This can block for many seconds.
             // Supported scenarios use remote workspaces.
-            if !has_wsl_path && paths.paths().iter().all(|path| path.exists()) {
-                // Only show directories in recent projects
-                if paths.paths().iter().any(|path| path.is_dir()) {
-                    result.push((id, SerializedWorkspaceLocation::Local, paths));
-                }
+            if !has_wsl_path && Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
+                result.push((id, SerializedWorkspaceLocation::Local, paths));
             } else {
                 delete_tasks.push(self.delete_workspace_by_id(id));
             }
@@ -1760,65 +1833,67 @@ impl WorkspaceDb {
 
     pub async fn last_workspace(
         &self,
+        fs: &dyn Fs,
     ) -> Result<Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
-        Ok(self.recent_workspaces_on_disk().await?.into_iter().next())
+        Ok(self.recent_workspaces_on_disk(fs).await?.into_iter().next())
     }
 
     // Returns the locations of the workspaces that were still opened when the last
     // session was closed (i.e. when Zed was quit).
     // If `last_session_window_order` is provided, the returned locations are ordered
     // according to that.
-    pub fn last_session_workspace_locations(
+    pub async fn last_session_workspace_locations(
         &self,
         last_session_id: &str,
         last_session_window_stack: Option<Vec<WindowId>>,
-    ) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
+        fs: &dyn Fs,
+    ) -> Result<Vec<SessionWorkspace>> {
         let mut workspaces = Vec::new();
 
         for (workspace_id, paths, window_id, remote_connection_id) in
             self.session_workspaces(last_session_id.to_owned())?
         {
+            let window_id = window_id.map(WindowId::from);
+
             if let Some(remote_connection_id) = remote_connection_id {
-                workspaces.push((
+                workspaces.push(SessionWorkspace {
                     workspace_id,
-                    SerializedWorkspaceLocation::Remote(
+                    location: SerializedWorkspaceLocation::Remote(
                         self.remote_connection(remote_connection_id)?,
                     ),
                     paths,
-                    window_id.map(WindowId::from),
-                ));
+                    window_id,
+                });
             } else if paths.is_empty() {
                 // Empty workspace with items (drafts, files) - include for restoration
-                workspaces.push((
-                    workspace_id,
-                    SerializedWorkspaceLocation::Local,
-                    paths,
-                    window_id.map(WindowId::from),
-                ));
-            } else if paths.paths().iter().all(|path| path.exists())
-                && paths.paths().iter().any(|path| path.is_dir())
-            {
-                workspaces.push((
+                workspaces.push(SessionWorkspace {
                     workspace_id,
-                    SerializedWorkspaceLocation::Local,
+                    location: SerializedWorkspaceLocation::Local,
                     paths,
-                    window_id.map(WindowId::from),
-                ));
+                    window_id,
+                });
+            } else {
+                if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await {
+                    workspaces.push(SessionWorkspace {
+                        workspace_id,
+                        location: SerializedWorkspaceLocation::Local,
+                        paths,
+                        window_id,
+                    });
+                }
             }
         }
 
         if let Some(stack) = last_session_window_stack {
-            workspaces.sort_by_key(|(_, _, _, window_id)| {
-                window_id
+            workspaces.sort_by_key(|workspace| {
+                workspace
+                    .window_id
                     .and_then(|id| stack.iter().position(|&order_id| order_id == id))
                     .unwrap_or(usize::MAX)
             });
         }
 
-        Ok(workspaces
-            .into_iter()
-            .map(|(workspace_id, location, paths, _)| (workspace_id, location, paths))
-            .collect::<Vec<_>>())
+        Ok(workspaces)
     }
 
     fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result<SerializedPaneGroup> {
@@ -2272,11 +2347,12 @@ pub fn delete_unloaded_items(
 mod tests {
     use super::*;
     use crate::persistence::model::{
-        SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace,
+        SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace, SessionWorkspace,
     };
     use gpui;
     use pretty_assertions::assert_eq;
     use remote::SshConnectionOptions;
+    use serde_json::json;
     use std::{thread, time::Duration};
 
     #[gpui::test]
@@ -3040,12 +3116,18 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_last_session_workspace_locations() {
+    async fn test_last_session_workspace_locations(cx: &mut gpui::TestAppContext) {
         let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap();
         let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap();
         let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap();
         let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap();
 
+        let fs = fs::FakeFs::new(cx.executor());
+        fs.insert_tree(dir1.path(), json!({})).await;
+        fs.insert_tree(dir2.path(), json!({})).await;
+        fs.insert_tree(dir3.path(), json!({})).await;
+        fs.insert_tree(dir4.path(), json!({})).await;
+
         let db =
             WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces").await;
 
@@ -3088,47 +3170,55 @@ mod tests {
         ]));
 
         let locations = db
-            .last_session_workspace_locations("one-session", stack)
+            .last_session_workspace_locations("one-session", stack, fs.as_ref())
+            .await
             .unwrap();
         assert_eq!(
             locations,
             [
-                (
-                    WorkspaceId(4),
-                    SerializedWorkspaceLocation::Local,
-                    PathList::new(&[dir4.path()])
-                ),
-                (
-                    WorkspaceId(3),
-                    SerializedWorkspaceLocation::Local,
-                    PathList::new(&[dir3.path()])
-                ),
-                (
-                    WorkspaceId(2),
-                    SerializedWorkspaceLocation::Local,
-                    PathList::new(&[dir2.path()])
-                ),
-                (
-                    WorkspaceId(1),
-                    SerializedWorkspaceLocation::Local,
-                    PathList::new(&[dir1.path()])
-                ),
-                (
-                    WorkspaceId(5),
-                    SerializedWorkspaceLocation::Local,
-                    PathList::new(&[dir1.path(), dir2.path(), dir3.path()])
-                ),
-                (
-                    WorkspaceId(6),
-                    SerializedWorkspaceLocation::Local,
-                    PathList::new(&[dir4.path(), dir3.path(), dir2.path()])
-                ),
+                SessionWorkspace {
+                    workspace_id: WorkspaceId(4),
+                    location: SerializedWorkspaceLocation::Local,
+                    paths: PathList::new(&[dir4.path()]),
+                    window_id: Some(WindowId::from(2u64)),
+                },
+                SessionWorkspace {
+                    workspace_id: WorkspaceId(3),
+                    location: SerializedWorkspaceLocation::Local,
+                    paths: PathList::new(&[dir3.path()]),
+                    window_id: Some(WindowId::from(8u64)),
+                },
+                SessionWorkspace {
+                    workspace_id: WorkspaceId(2),
+                    location: SerializedWorkspaceLocation::Local,
+                    paths: PathList::new(&[dir2.path()]),
+                    window_id: Some(WindowId::from(5u64)),
+                },
+                SessionWorkspace {
+                    workspace_id: WorkspaceId(1),
+                    location: SerializedWorkspaceLocation::Local,
+                    paths: PathList::new(&[dir1.path()]),
+                    window_id: Some(WindowId::from(9u64)),
+                },
+                SessionWorkspace {
+                    workspace_id: WorkspaceId(5),
+                    location: SerializedWorkspaceLocation::Local,
+                    paths: PathList::new(&[dir1.path(), dir2.path(), dir3.path()]),
+                    window_id: Some(WindowId::from(3u64)),
+                },
+                SessionWorkspace {
+                    workspace_id: WorkspaceId(6),
+                    location: SerializedWorkspaceLocation::Local,
+                    paths: PathList::new(&[dir4.path(), dir3.path(), dir2.path()]),
+                    window_id: Some(WindowId::from(4u64)),
+                },
             ]
         );
     }
 
     #[gpui::test]
-    async fn test_last_session_workspace_locations_remote() {
+    async fn test_last_session_workspace_locations_remote(cx: &mut gpui::TestAppContext) {
+        let fs = fs::FakeFs::new(cx.executor());
         let db =
             WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces_remote")
                 .await;
@@ -3190,40 +3280,45 @@ mod tests {
         ]));
 
         let have = db
-            .last_session_workspace_locations("one-session", stack)
+            .last_session_workspace_locations("one-session", stack, fs.as_ref())
+            .await
             .unwrap();
         assert_eq!(have.len(), 4);
         assert_eq!(
             have[0],
-            (
-                WorkspaceId(4),
-                SerializedWorkspaceLocation::Remote(remote_connections[3].clone()),
-                PathList::default()
-            )
+            SessionWorkspace {
+                workspace_id: WorkspaceId(4),
+                location: SerializedWorkspaceLocation::Remote(remote_connections[3].clone()),
+                paths: PathList::default(),
+                window_id: Some(WindowId::from(2u64)),
+            }
         );
         assert_eq!(
             have[1],
-            (
-                WorkspaceId(3),
-                SerializedWorkspaceLocation::Remote(remote_connections[2].clone()),
-                PathList::default()
-            )
+            SessionWorkspace {
+                workspace_id: WorkspaceId(3),
+                location: SerializedWorkspaceLocation::Remote(remote_connections[2].clone()),
+                paths: PathList::default(),
+                window_id: Some(WindowId::from(8u64)),
+            }
         );
         assert_eq!(
             have[2],
-            (
-                WorkspaceId(2),
-                SerializedWorkspaceLocation::Remote(remote_connections[1].clone()),
-                PathList::default()
-            )
+            SessionWorkspace {
+                workspace_id: WorkspaceId(2),
+                location: SerializedWorkspaceLocation::Remote(remote_connections[1].clone()),
+                paths: PathList::default(),
+                window_id: Some(WindowId::from(5u64)),
+            }
         );
         assert_eq!(
             have[3],
-            (
-                WorkspaceId(1),
-                SerializedWorkspaceLocation::Remote(remote_connections[0].clone()),
-                PathList::default()
-            )
+            SessionWorkspace {
+                workspace_id: WorkspaceId(1),
+                location: SerializedWorkspaceLocation::Remote(remote_connections[0].clone()),
+                paths: PathList::default(),
+                window_id: Some(WindowId::from(9u64)),
+            }
         );
     }
 
@@ -3555,4 +3650,192 @@ mod tests {
         assert!(retrieved.display.is_some());
         assert_eq!(retrieved.display.unwrap(), display_uuid);
     }
+
+    #[gpui::test]
+    async fn test_last_session_workspace_locations_groups_by_window_id(
+        cx: &mut gpui::TestAppContext,
+    ) {
+        let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap();
+        let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap();
+        let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap();
+        let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap();
+        let dir5 = tempfile::TempDir::with_prefix("dir5").unwrap();
+
+        let fs = fs::FakeFs::new(cx.executor());
+        fs.insert_tree(dir1.path(), json!({})).await;
+        fs.insert_tree(dir2.path(), json!({})).await;
+        fs.insert_tree(dir3.path(), json!({})).await;
+        fs.insert_tree(dir4.path(), json!({})).await;
+        fs.insert_tree(dir5.path(), json!({})).await;
+
+        let db =
+            WorkspaceDb::open_test_db("test_last_session_workspace_locations_groups_by_window_id")
+                .await;
+
+        // Simulate two MultiWorkspace windows each containing two workspaces,
+        // plus one single-workspace window:
+        //   Window 10: workspace 1, workspace 2
+        //   Window 20: workspace 3, workspace 4
+        //   Window 30: workspace 5 (only one)
+        //
+        // On session restore, the caller should be able to group these by
+        // window_id to reconstruct the MultiWorkspace windows.
+        let workspaces_data: Vec<(i64, &Path, u64)> = vec![
+            (1, dir1.path(), 10),
+            (2, dir2.path(), 10),
+            (3, dir3.path(), 20),
+            (4, dir4.path(), 20),
+            (5, dir5.path(), 30),
+        ];
+
+        for (id, dir, window_id) in &workspaces_data {
+            db.save_workspace(SerializedWorkspace {
+                id: WorkspaceId(*id),
+                paths: PathList::new(&[*dir]),
+                location: SerializedWorkspaceLocation::Local,
+                center_group: Default::default(),
+                window_bounds: Default::default(),
+                display: Default::default(),
+                docks: Default::default(),
+                centered_layout: false,
+                session_id: Some("test-session".to_owned()),
+                breakpoints: Default::default(),
+                window_id: Some(*window_id),
+                user_toolchains: Default::default(),
+            })
+            .await;
+        }
+
+        let locations = db
+            .last_session_workspace_locations("test-session", None, fs.as_ref())
+            .await
+            .unwrap();
+
+        // All 5 workspaces should be returned with their window_ids.
+        assert_eq!(locations.len(), 5);
+
+        // Every entry should have a window_id so the caller can group them.
+        for session_workspace in &locations {
+            assert!(
+                session_workspace.window_id.is_some(),
+                "workspace {:?} missing window_id",
+                session_workspace.workspace_id
+            );
+        }
+
+        // Group by window_id, simulating what the restoration code should do.
+        let mut by_window: HashMap<WindowId, Vec<WorkspaceId>> = HashMap::default();
+        for session_workspace in &locations {
+            if let Some(window_id) = session_workspace.window_id {
+                by_window
+                    .entry(window_id)
+                    .or_default()
+                    .push(session_workspace.workspace_id);
+            }
+        }
+
+        // Should produce 3 windows, not 5.
+        assert_eq!(
+            by_window.len(),
+            3,
+            "Expected 3 window groups, got {}: {:?}",
+            by_window.len(),
+            by_window
+        );
+
+        // Window 10 should contain workspaces 1 and 2.
+        let window_10 = by_window.get(&WindowId::from(10u64)).unwrap();
+        assert_eq!(window_10.len(), 2);
+        assert!(window_10.contains(&WorkspaceId(1)));
+        assert!(window_10.contains(&WorkspaceId(2)));
+
+        // Window 20 should contain workspaces 3 and 4.
+        let window_20 = by_window.get(&WindowId::from(20u64)).unwrap();
+        assert_eq!(window_20.len(), 2);
+        assert!(window_20.contains(&WorkspaceId(3)));
+        assert!(window_20.contains(&WorkspaceId(4)));
+
+        // Window 30 should contain only workspace 5.
+        let window_30 = by_window.get(&WindowId::from(30u64)).unwrap();
+        assert_eq!(window_30.len(), 1);
+        assert!(window_30.contains(&WorkspaceId(5)));
+    }
+
+    #[gpui::test]
+    async fn test_read_serialized_multi_workspaces_with_state() {
+        use crate::persistence::model::MultiWorkspaceState;
+
+        // Write multi-workspace state for two windows via the scoped KVP.
+        let window_10 = WindowId::from(10u64);
+        let window_20 = WindowId::from(20u64);
+
+        write_multi_workspace_state(
+            window_10,
+            MultiWorkspaceState {
+                active_workspace_id: Some(WorkspaceId(2)),
+                sidebar_open: true,
+            },
+        )
+        .await;
+
+        write_multi_workspace_state(
+            window_20,
+            MultiWorkspaceState {
+                active_workspace_id: Some(WorkspaceId(3)),
+                sidebar_open: false,
+            },
+        )
+        .await;
+
+        // Build session workspaces: two in window 10, one in window 20, one with no window.
+        let session_workspaces = vec![
+            SessionWorkspace {
+                workspace_id: WorkspaceId(1),
+                location: SerializedWorkspaceLocation::Local,
+                paths: PathList::new(&["/a"]),
+                window_id: Some(window_10),
+            },
+            SessionWorkspace {
+                workspace_id: WorkspaceId(2),
+                location: SerializedWorkspaceLocation::Local,
+                paths: PathList::new(&["/b"]),
+                window_id: Some(window_10),
+            },
+            SessionWorkspace {
+                workspace_id: WorkspaceId(3),
+                location: SerializedWorkspaceLocation::Local,
+                paths: PathList::new(&["/c"]),
+                window_id: Some(window_20),
+            },
+            SessionWorkspace {
+                workspace_id: WorkspaceId(4),
+                location: SerializedWorkspaceLocation::Local,
+                paths: PathList::new(&["/d"]),
+                window_id: None,
+            },
+        ];
+
+        let results = read_serialized_multi_workspaces(session_workspaces);
+
+        // 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, 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, 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);
+    }
 }

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

@@ -10,7 +10,7 @@ use db::sqlez::{
     bindable::{Bind, Column, StaticColumnCount},
     statement::Statement,
 };
-use gpui::{AsyncWindowContext, Entity, WeakEntity};
+use gpui::{AsyncWindowContext, Entity, WeakEntity, WindowId};
 
 use language::{Toolchain, ToolchainScope};
 use project::{Project, debugger::breakpoint_store::SourceBreakpoint};
@@ -49,6 +49,32 @@ impl SerializedWorkspaceLocation {
     }
 }
 
+/// A workspace entry from a previous session, containing all the info needed
+/// to restore it including which window it belonged to (for MultiWorkspace grouping).
+#[derive(Debug, PartialEq, Clone)]
+pub struct SessionWorkspace {
+    pub workspace_id: WorkspaceId,
+    pub location: SerializedWorkspaceLocation,
+    pub paths: PathList,
+    pub window_id: Option<WindowId>,
+}
+
+/// Per-window state for a MultiWorkspace, persisted to KVP.
+#[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:
+/// all workspaces that shared the window, which one was active, and whether the
+/// sidebar was open.
+#[derive(Debug, Clone)]
+pub struct SerializedMultiWorkspace {
+    pub workspaces: Vec<SessionWorkspace>,
+    pub state: MultiWorkspaceState,
+}
+
 #[derive(Debug, PartialEq, Clone)]
 pub(crate) struct SerializedWorkspace {
     pub(crate) id: WorkspaceId,

crates/workspace/src/welcome.rs 🔗

@@ -114,7 +114,9 @@ impl RenderOnce for SectionButton {
                             .size(rems_from_px(12.)),
                     ),
             )
-            .on_click(move |_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx))
+            .on_click(move |_, window, cx| {
+                self.focus_handle.dispatch_action(&*self.action, window, cx)
+            })
     }
 }
 
@@ -225,9 +227,13 @@ impl WelcomePage {
             .detach();
 
         if fallback_to_recent_projects {
+            let fs = workspace
+                .upgrade()
+                .map(|ws| ws.read(cx).app_state().fs.clone());
             cx.spawn_in(window, async move |this: WeakEntity<Self>, cx| {
+                let Some(fs) = fs else { return };
                 let workspaces = WORKSPACE_DB
-                    .recent_workspaces_on_disk()
+                    .recent_workspaces_on_disk(fs.as_ref())
                     .await
                     .log_err()
                     .unwrap_or_default();
@@ -267,21 +273,18 @@ impl WelcomePage {
     ) {
         if let Some(recent_workspaces) = &self.recent_workspaces {
             if let Some((_workspace_id, location, paths)) = recent_workspaces.get(action.index) {
-                let paths = paths.clone();
-                let location = location.clone();
                 let is_local = matches!(location, SerializedWorkspaceLocation::Local);
-                let workspace = self.workspace.clone();
 
                 if is_local {
+                    let paths = paths.clone();
                     let paths = paths.paths().to_vec();
-                    cx.spawn_in(window, async move |_, cx| {
-                        let _ = workspace.update_in(cx, |workspace, window, cx| {
+                    self.workspace
+                        .update(cx, |workspace, cx| {
                             workspace
                                 .open_workspace_for_paths(true, paths, window, cx)
-                                .detach();
-                        });
-                    })
-                    .detach();
+                                .detach_and_log_err(cx);
+                        })
+                        .log_err();
                 } else {
                     use zed_actions::OpenRecent;
                     window.dispatch_action(OpenRecent::default().boxed_clone(), cx);

crates/workspace/src/workspace.rs 🔗

@@ -3,6 +3,7 @@ pub mod history_manager;
 pub mod invalid_item_view;
 pub mod item;
 mod modal_layer;
+mod multi_workspace;
 pub mod notifications;
 pub mod pane;
 pub mod pane_group;
@@ -22,6 +23,10 @@ mod workspace_settings;
 
 pub use crate::notifications::NotificationFrame;
 pub use dock::Panel;
+pub use multi_workspace::{
+    DraggedSidebar, MultiWorkspace, NewWorkspaceInWindow, NextWorkspaceInWindow,
+    PreviousWorkspaceInWindow, Sidebar, SidebarEvent, SidebarHandle, ToggleWorkspaceSidebar,
+};
 pub use path_list::PathList;
 pub use toast_layer::{ToastAction, ToastLayer, ToastView};
 
@@ -71,7 +76,8 @@ pub use pane_group::{
 use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace};
 pub use persistence::{
     DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items,
-    model::{ItemId, SerializedWorkspaceLocation},
+    model::{ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, SessionWorkspace},
+    read_serialized_multi_workspaces,
 };
 use postage::stream::Stream;
 use project::{
@@ -562,9 +568,27 @@ pub struct OpenTerminal {
     pub local: bool,
 }
 
-#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(
+    Clone,
+    Copy,
+    Debug,
+    Default,
+    Hash,
+    PartialEq,
+    Eq,
+    PartialOrd,
+    Ord,
+    serde::Serialize,
+    serde::Deserialize,
+)]
 pub struct WorkspaceId(i64);
 
+impl WorkspaceId {
+    pub fn from_i64(value: i64) -> Self {
+        Self(value)
+    }
+}
+
 impl StaticColumnCount for WorkspaceId {}
 impl Bind for WorkspaceId {
     fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
@@ -599,11 +623,14 @@ fn prompt_and_open_paths(app_state: Arc<AppState>, options: PathPromptOptions, c
                 cx.update(|cx| {
                     if let Some(workspace_window) = cx
                         .active_window()
-                        .and_then(|window| window.downcast::<Workspace>())
+                        .and_then(|window| window.downcast::<MultiWorkspace>())
                     {
                         workspace_window
-                            .update(cx, |workspace, _, cx| {
-                                workspace.show_portal_error(err.to_string(), cx);
+                            .update(cx, |multi_workspace, _, cx| {
+                                let workspace = multi_workspace.workspace().clone();
+                                workspace.update(cx, |workspace, cx| {
+                                    workspace.show_portal_error(err.to_string(), cx);
+                                });
                             })
                             .ok();
                     }
@@ -618,7 +645,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut App) {
     component::init();
     theme_preview::init(cx);
     toast_layer::init(cx);
-    history_manager::init(cx);
+    history_manager::init(app_state.fs.clone(), cx);
 
     cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx))
         .on_action(|_: &Reload, cx| reload(cx))
@@ -969,7 +996,7 @@ struct GlobalAppState(Weak<AppState>);
 impl Global for GlobalAppState {}
 
 pub struct WorkspaceStore {
-    workspaces: HashSet<WindowHandle<Workspace>>,
+    workspaces: HashSet<(gpui::AnyWindowHandle, WeakEntity<Workspace>)>,
     client: Arc<Client>,
     _subscriptions: Vec<client::Subscription>,
 }
@@ -1455,9 +1482,11 @@ impl Workspace {
 
         cx.emit(Event::PaneAdded(center_pane.clone()));
 
-        let window_handle = window.window_handle().downcast::<Workspace>().unwrap();
+        let any_window_handle = window.window_handle();
         app_state.workspace_store.update(cx, |store, _| {
-            store.workspaces.insert(window_handle);
+            store
+                .workspaces
+                .insert((any_window_handle, weak_handle.clone()));
         });
 
         let mut current_user = app_state.user_store.read(cx).watch_current_user();
@@ -1582,10 +1611,13 @@ impl Workspace {
                 GlobalTheme::reload_theme(cx);
                 GlobalTheme::reload_icon_theme(cx);
             }),
-            cx.on_release(move |this, cx| {
-                this.app_state.workspace_store.update(cx, move |store, _| {
-                    store.workspaces.remove(&window_handle);
-                })
+            cx.on_release({
+                let weak_handle = weak_handle.clone();
+                move |this, cx| {
+                    this.app_state.workspace_store.update(cx, move |store, _| {
+                        store.workspaces.retain(|(_, weak)| weak != &weak_handle);
+                    })
+                }
             }),
         ];
 
@@ -1659,13 +1691,13 @@ impl Workspace {
     pub fn new_local(
         abs_paths: Vec<PathBuf>,
         app_state: Arc<AppState>,
-        requesting_window: Option<WindowHandle<Workspace>>,
+        requesting_window: Option<WindowHandle<MultiWorkspace>>,
         env: Option<HashMap<String, String>>,
         init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
         cx: &mut App,
     ) -> Task<
         anyhow::Result<(
-            WindowHandle<Workspace>,
+            WindowHandle<MultiWorkspace>,
             Vec<Option<anyhow::Result<Box<dyn ItemHandle>>>>,
         )>,
     > {
@@ -1763,71 +1795,23 @@ impl Workspace {
                 });
             }
 
-            let window = if let Some(window) = requesting_window {
-                let centered_layout = serialized_workspace
-                    .as_ref()
-                    .map(|w| w.centered_layout)
-                    .unwrap_or(false);
-
-                cx.update_window(window.into(), |_, window, cx| {
-                    window.replace_root(cx, |window, cx| {
-                        let mut workspace = Workspace::new(
-                            Some(workspace_id),
-                            project_handle.clone(),
-                            app_state.clone(),
-                            window,
-                            cx,
-                        );
-
-                        workspace.centered_layout = centered_layout;
-
-                        // Call init callback to add items before window renders
-                        if let Some(init) = init {
-                            init(&mut workspace, window, cx);
-                        }
-
-                        workspace
-                    });
-                })?;
-                window
-            } else {
-                let window_bounds_override = window_bounds_env_override();
-
-                let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
-                    (Some(WindowBounds::Windowed(bounds)), None)
-                } else if let Some(workspace) = serialized_workspace.as_ref()
-                    && let Some(display) = workspace.display
-                    && let Some(bounds) = workspace.window_bounds.as_ref()
-                {
-                    // Reopening an existing workspace - restore its saved bounds
-                    (Some(bounds.0), Some(display))
-                } else if let Some((display, bounds)) = persistence::read_default_window_bounds() {
-                    // New or empty workspace - use the last known window bounds
-                    (Some(bounds), Some(display))
-                } else {
-                    // New window - let GPUI's default_bounds() handle cascading
-                    (None, None)
-                };
+            let (window, workspace): (WindowHandle<MultiWorkspace>, Entity<Workspace>) =
+                if let Some(window) = requesting_window {
+                    let centered_layout = serialized_workspace
+                        .as_ref()
+                        .map(|w| w.centered_layout)
+                        .unwrap_or(false);
 
-                // Use the serialized workspace to construct the new window
-                let mut options = cx.update(|cx| (app_state.build_window_options)(display, cx));
-                options.window_bounds = window_bounds;
-                let centered_layout = serialized_workspace
-                    .as_ref()
-                    .map(|w| w.centered_layout)
-                    .unwrap_or(false);
-                cx.open_window(options, {
-                    let app_state = app_state.clone();
-                    let project_handle = project_handle.clone();
-                    move |window, cx| {
-                        cx.new(|cx| {
+                    let workspace = window.update(cx, |multi_workspace, window, cx| {
+                        let workspace = cx.new(|cx| {
                             let mut workspace = Workspace::new(
                                 Some(workspace_id),
-                                project_handle,
-                                app_state,
+                                project_handle.clone(),
+                                app_state.clone(),
                                 window,
                                 cx,
                             );
+
                             workspace.centered_layout = centered_layout;
 
                             // Call init callback to add items before window renders
@@ -1836,10 +1820,69 @@ impl Workspace {
                             }
 
                             workspace
-                        })
-                    }
-                })?
-            };
+                        });
+                        multi_workspace.activate(workspace.clone(), cx);
+                        workspace
+                    })?;
+                    (window, workspace)
+                } else {
+                    let window_bounds_override = window_bounds_env_override();
+
+                    let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
+                        (Some(WindowBounds::Windowed(bounds)), None)
+                    } else if let Some(workspace) = serialized_workspace.as_ref()
+                        && let Some(display) = workspace.display
+                        && let Some(bounds) = workspace.window_bounds.as_ref()
+                    {
+                        // Reopening an existing workspace - restore its saved bounds
+                        (Some(bounds.0), Some(display))
+                    } else if let Some((display, bounds)) =
+                        persistence::read_default_window_bounds()
+                    {
+                        // New or empty workspace - use the last known window bounds
+                        (Some(bounds), Some(display))
+                    } else {
+                        // New window - let GPUI's default_bounds() handle cascading
+                        (None, None)
+                    };
+
+                    // Use the serialized workspace to construct the new window
+                    let mut options = cx.update(|cx| (app_state.build_window_options)(display, cx));
+                    options.window_bounds = window_bounds;
+                    let centered_layout = serialized_workspace
+                        .as_ref()
+                        .map(|w| w.centered_layout)
+                        .unwrap_or(false);
+                    let window = cx.open_window(options, {
+                        let app_state = app_state.clone();
+                        let project_handle = project_handle.clone();
+                        move |window, cx| {
+                            let workspace = cx.new(|cx| {
+                                let mut workspace = Workspace::new(
+                                    Some(workspace_id),
+                                    project_handle,
+                                    app_state,
+                                    window,
+                                    cx,
+                                );
+                                workspace.centered_layout = centered_layout;
+
+                                // Call init callback to add items before window renders
+                                if let Some(init) = init {
+                                    init(&mut workspace, window, cx);
+                                }
+
+                                workspace
+                            });
+                            cx.new(|cx| MultiWorkspace::new(workspace, cx))
+                        }
+                    })?;
+                    let workspace =
+                        window.update(cx, |multi_workspace: &mut MultiWorkspace, _, _cx| {
+                            multi_workspace.workspace().clone()
+                        })?;
+                    (window, workspace)
+                };
 
             notify_if_database_failed(window, cx);
             // Check if this is an empty workspace (no paths to open)
@@ -1852,8 +1895,10 @@ impl Workspace {
                 .unwrap_or(false);
 
             let opened_items = window
-                .update(cx, |_workspace, window, cx| {
-                    open_items(serialized_workspace, project_paths, window, cx)
+                .update(cx, |_, window, cx| {
+                    workspace.update(cx, |_workspace: &mut Workspace, cx| {
+                        open_items(serialized_workspace, project_paths, window, cx)
+                    })
                 })?
                 .await
                 .unwrap_or_default();
@@ -1865,29 +1910,30 @@ impl Workspace {
             if is_empty_workspace && !serialized_workspace_has_paths {
                 if let Some(default_docks) = persistence::read_default_dock_state() {
                     window
-                        .update(cx, |workspace, window, cx| {
-                            for (dock, serialized_dock) in [
-                                (&mut workspace.right_dock, default_docks.right),
-                                (&mut workspace.left_dock, default_docks.left),
-                                (&mut workspace.bottom_dock, default_docks.bottom),
-                            ]
-                            .iter_mut()
-                            {
-                                dock.update(cx, |dock, cx| {
-                                    dock.serialized_dock = Some(serialized_dock.clone());
-                                    dock.restore_state(window, cx);
-                                });
-                            }
-                            cx.notify();
+                        .update(cx, |_, window, cx| {
+                            workspace.update(cx, |workspace, cx| {
+                                for (dock, serialized_dock) in [
+                                    (&workspace.right_dock, &default_docks.right),
+                                    (&workspace.left_dock, &default_docks.left),
+                                    (&workspace.bottom_dock, &default_docks.bottom),
+                                ] {
+                                    dock.update(cx, |dock, cx| {
+                                        dock.serialized_dock = Some(serialized_dock.clone());
+                                        dock.restore_state(window, cx);
+                                    });
+                                }
+                                cx.notify();
+                            });
                         })
                         .log_err();
                 }
             }
 
             window
-                .update(cx, |workspace, window, cx| {
-                    window.activate_window();
-                    workspace.update_history(cx);
+                .update(cx, |_, _window, cx| {
+                    workspace.update(cx, |this: &mut Workspace, cx| {
+                        this.update_history(cx);
+                    });
                 })
                 .log_err();
             Ok((window, opened_items))
@@ -2493,8 +2539,11 @@ impl Workspace {
             let env = self.project.read(cx).cli_environment(cx);
             let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx);
             cx.spawn_in(window, async move |_vh, cx| {
-                let (workspace, _) = task.await?;
-                workspace.update(cx, callback)
+                let (multi_workspace_window, _) = task.await?;
+                multi_workspace_window.update(cx, |multi_workspace, window, cx| {
+                    let workspace = multi_workspace.workspace().clone();
+                    workspace.update(cx, |workspace, cx| callback(workspace, window, cx))
+                })
             })
         }
     }
@@ -2520,8 +2569,11 @@ impl Workspace {
             let env = self.project.read(cx).cli_environment(cx);
             let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx);
             cx.spawn_in(window, async move |_vh, cx| {
-                let (workspace, _) = task.await?;
-                workspace.update(cx, callback)
+                let (multi_workspace_window, _) = task.await?;
+                multi_workspace_window.update(cx, |multi_workspace, window, cx| {
+                    let workspace = multi_workspace.workspace().clone();
+                    workspace.update(cx, |workspace, cx| callback(workspace, window, cx))
+                })
             })
         }
     }
@@ -2623,7 +2675,7 @@ impl Workspace {
             let workspace_count = cx.update(|_window, cx| {
                 cx.windows()
                     .iter()
-                    .filter(|window| window.downcast::<Workspace>().is_some())
+                    .filter(|window| window.downcast::<MultiWorkspace>().is_some())
                     .count()
             })?;
 
@@ -2636,10 +2688,12 @@ impl Workspace {
                 let remaining_workspaces = cx.update(|_window, cx| {
                     cx.windows()
                         .iter()
-                        .filter_map(|window| window.downcast::<Workspace>())
-                        .filter_map(|workspace| {
-                            workspace
-                                .update(cx, |workspace, _, _| workspace.removing)
+                        .filter_map(|window| window.downcast::<MultiWorkspace>())
+                        .filter_map(|multi_workspace| {
+                            multi_workspace
+                                .update(cx, |multi_workspace, _, cx| {
+                                    multi_workspace.workspace().read(cx).removing
+                                })
                                 .ok()
                         })
                         .filter(|removing| !removing)
@@ -2675,13 +2729,18 @@ impl Workspace {
                 }
                 if close_intent == CloseIntent::ReplaceWindow {
                     _ = active_call.update(cx, |this, cx| {
-                        let workspace = cx
+                        let multi_workspace = cx
                             .windows()
                             .iter()
-                            .filter_map(|window| window.downcast::<Workspace>())
+                            .filter_map(|window| window.downcast::<MultiWorkspace>())
                             .next()
                             .unwrap();
-                        let project = workspace.read(cx)?.project.clone();
+                        let project = multi_workspace
+                            .read(cx)?
+                            .workspace()
+                            .read(cx)
+                            .project
+                            .clone();
                         if project.read(cx).is_shared() {
                             this.unshare_project(project, cx)?;
                         }
@@ -2889,7 +2948,7 @@ impl Workspace {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        let window_handle = window.window_handle().downcast::<Self>();
+        let window_handle = window.window_handle().downcast::<MultiWorkspace>();
         let is_remote = self.project.read(cx).is_via_collab();
         let has_worktree = self.project.read(cx).worktrees(cx).next().is_some();
         let has_dirty_items = self.items(cx).any(|item| item.is_dirty(cx));
@@ -5074,21 +5133,27 @@ impl Workspace {
             self.update_window_edited(window, cx);
             return;
         }
-        if let Some(window_handle) = window.window_handle().downcast::<Self>() {
-            let s = item.on_release(
-                cx,
-                Box::new(move |cx| {
-                    window_handle
-                        .update(cx, |this, window, cx| {
-                            this.dirty_items.remove(&item_id);
-                            this.update_window_edited(window, cx)
+
+        let workspace = self.weak_handle();
+        let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
+            return;
+        };
+        let on_release_callback = Box::new(move |cx: &mut App| {
+            window_handle
+                .update(cx, |_, window, cx| {
+                    workspace
+                        .update(cx, |workspace, cx| {
+                            workspace.dirty_items.remove(&item_id);
+                            workspace.update_window_edited(window, cx)
                         })
                         .ok();
-                }),
-            );
-            self.dirty_items.insert(item_id, s);
-            self.update_window_edited(window, cx);
-        }
+                })
+                .ok();
+        });
+
+        let s = item.on_release(cx, on_release_callback);
+        self.dirty_items.insert(item_id, s);
+        self.update_window_edited(window, cx);
     }
 
     fn render_notifications(&self, _window: &mut Window, _cx: &mut Context<Self>) -> Option<Div> {
@@ -7042,27 +7107,30 @@ enum ActivateInDirectionTarget {
     Dock(Entity<Dock>),
 }
 
-fn notify_if_database_failed(workspace: WindowHandle<Workspace>, cx: &mut AsyncApp) {
-    workspace
-        .update(cx, |workspace, _, cx| {
-            if (*db::ALL_FILE_DB_FAILED).load(std::sync::atomic::Ordering::Acquire) {
-                struct DatabaseFailedNotification;
-
-                workspace.show_notification(
-                    NotificationId::unique::<DatabaseFailedNotification>(),
-                    cx,
-                    |cx| {
-                        cx.new(|cx| {
-                            MessageNotification::new("Failed to load the database file.", cx)
-                                .primary_message("File an Issue")
-                                .primary_icon(IconName::Plus)
-                                .primary_on_click(|window, cx| {
-                                    window.dispatch_action(Box::new(FileBugReport), cx)
-                                })
-                        })
-                    },
-                );
-            }
+fn notify_if_database_failed(window: WindowHandle<MultiWorkspace>, cx: &mut AsyncApp) {
+    window
+        .update(cx, |multi_workspace, _, cx| {
+            let workspace = multi_workspace.workspace().clone();
+            workspace.update(cx, |workspace, cx| {
+                if (*db::ALL_FILE_DB_FAILED).load(std::sync::atomic::Ordering::Acquire) {
+                    struct DatabaseFailedNotification;
+
+                    workspace.show_notification(
+                        NotificationId::unique::<DatabaseFailedNotification>(),
+                        cx,
+                        |cx| {
+                            cx.new(|cx| {
+                                MessageNotification::new("Failed to load the database file.", cx)
+                                    .primary_message("File an Issue")
+                                    .primary_icon(IconName::Plus)
+                                    .primary_on_click(|window, cx| {
+                                        window.dispatch_action(Box::new(FileBugReport), cx)
+                                    })
+                            })
+                        },
+                    );
+                }
+            });
         })
         .log_err();
 }
@@ -7216,15 +7284,14 @@ impl Render for Workspace {
             .collect::<Vec<_>>();
         let bottom_dock_layout = WorkspaceSettings::get_global(cx).bottom_dock_layout;
 
-        client_side_decorations(
-            self.actions(div(), window, cx)
-                .key_context(context)
-                .relative()
-                .size_full()
-                .flex()
-                .flex_col()
-                .font(ui_font)
-                .gap_0()
+        self.actions(div(), window, cx)
+            .key_context(context)
+            .relative()
+            .size_full()
+            .flex()
+            .flex_col()
+            .font(ui_font)
+            .gap_0()
                 .justify_start()
                 .items_start()
                 .text_color(colors.text)
@@ -7707,10 +7774,7 @@ impl Render for Workspace {
                         })
                         .child(self.modal_layer.clone())
                         .child(self.toast_layer.clone()),
-                ),
-            window,
-            cx,
-        )
+                )
     }
 }
 
@@ -7755,16 +7819,22 @@ impl WorkspaceStore {
             };
 
             let mut response = proto::FollowResponse::default();
-            this.workspaces.retain(|workspace| {
-                workspace
-                    .update(cx, |workspace, window, cx| {
-                        let handler_response =
-                            workspace.handle_follow(follower.project_id, window, cx);
-                        if let Some(active_view) = handler_response.active_view
-                            && workspace.project.read(cx).remote_id() == follower.project_id
-                        {
-                            response.active_view = Some(active_view)
-                        }
+
+            this.workspaces.retain(|(window_handle, weak_workspace)| {
+                let Some(workspace) = weak_workspace.upgrade() else {
+                    return false;
+                };
+                window_handle
+                    .update(cx, |_, window, cx| {
+                        workspace.update(cx, |workspace, cx| {
+                            let handler_response =
+                                workspace.handle_follow(follower.project_id, window, cx);
+                            if let Some(active_view) = handler_response.active_view
+                                && workspace.project.read(cx).remote_id() == follower.project_id
+                            {
+                                response.active_view = Some(active_view)
+                            }
+                        });
                     })
                     .is_ok()
             });
@@ -7782,14 +7852,24 @@ impl WorkspaceStore {
         let update = envelope.payload;
 
         this.update(&mut cx, |this, cx| {
-            this.workspaces.retain(|workspace| {
-                workspace
-                    .update(cx, |workspace, window, cx| {
-                        let project_id = workspace.project.read(cx).remote_id();
-                        if update.project_id != project_id && update.project_id.is_some() {
-                            return;
-                        }
-                        workspace.handle_update_followers(leader_id, update.clone(), window, cx);
+            this.workspaces.retain(|(window_handle, weak_workspace)| {
+                let Some(workspace) = weak_workspace.upgrade() else {
+                    return false;
+                };
+                window_handle
+                    .update(cx, |_, window, cx| {
+                        workspace.update(cx, |workspace, cx| {
+                            let project_id = workspace.project.read(cx).remote_id();
+                            if update.project_id != project_id && update.project_id.is_some() {
+                                return;
+                            }
+                            workspace.handle_update_followers(
+                                leader_id,
+                                update.clone(),
+                                window,
+                                cx,
+                            );
+                        });
                     })
                     .is_ok()
             });
@@ -7797,8 +7877,14 @@ impl WorkspaceStore {
         })
     }
 
-    pub fn workspaces(&self) -> &HashSet<WindowHandle<Workspace>> {
-        &self.workspaces
+    pub fn workspaces(&self) -> impl Iterator<Item = &WeakEntity<Workspace>> {
+        self.workspaces.iter().map(|(_, weak)| weak)
+    }
+
+    pub fn workspaces_with_windows(
+        &self,
+    ) -> impl Iterator<Item = (gpui::AnyWindowHandle, &WeakEntity<Workspace>)> {
+        self.workspaces.iter().map(|(window, weak)| (*window, weak))
     }
 }
 
@@ -7850,19 +7936,119 @@ impl WorkspaceHandle for Entity<Workspace> {
     }
 }
 
-pub async fn last_opened_workspace_location()
--> Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)> {
-    DB.last_workspace().await.log_err().flatten()
+pub async fn last_opened_workspace_location(
+    fs: &dyn fs::Fs,
+) -> Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)> {
+    DB.last_workspace(fs).await.log_err().flatten()
 }
 
-pub fn last_session_workspace_locations(
+pub async fn last_session_workspace_locations(
     last_session_id: &str,
     last_session_window_stack: Option<Vec<WindowId>>,
-) -> Option<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
-    DB.last_session_workspace_locations(last_session_id, last_session_window_stack)
+    fs: &dyn fs::Fs,
+) -> Option<Vec<SessionWorkspace>> {
+    DB.last_session_workspace_locations(last_session_id, last_session_window_stack, fs)
+        .await
         .log_err()
 }
 
+pub async fn restore_multiworkspace(
+    multi_workspace: SerializedMultiWorkspace,
+    app_state: Arc<AppState>,
+    cx: &mut AsyncApp,
+) -> anyhow::Result<WindowHandle<MultiWorkspace>> {
+    let SerializedMultiWorkspace { workspaces, state } = multi_workspace;
+    let mut group_iter = workspaces.into_iter();
+    let first = group_iter
+        .next()
+        .context("window group must not be empty")?;
+
+    let window_handle = if first.paths.is_empty() {
+        cx.update(|cx| open_workspace_by_id(first.workspace_id, app_state.clone(), None, cx))
+            .await?
+    } else {
+        let (window, _items) = cx
+            .update(|cx| {
+                Workspace::new_local(
+                    first.paths.paths().to_vec(),
+                    app_state.clone(),
+                    None,
+                    None,
+                    None,
+                    cx,
+                )
+            })
+            .await?;
+        window
+    };
+
+    for session_workspace in group_iter {
+        if session_workspace.paths.is_empty() {
+            cx.update(|cx| {
+                open_workspace_by_id(
+                    session_workspace.workspace_id,
+                    app_state.clone(),
+                    Some(window_handle),
+                    cx,
+                )
+            })
+            .await?;
+        } else {
+            cx.update(|cx| {
+                Workspace::new_local(
+                    session_workspace.paths.paths().to_vec(),
+                    app_state.clone(),
+                    Some(window_handle),
+                    None,
+                    None,
+                    cx,
+                )
+            })
+            .await?;
+        }
+    }
+
+    if let Some(target_id) = state.active_workspace_id {
+        window_handle
+            .update(cx, |multi_workspace, window, cx| {
+                let target_index = multi_workspace
+                    .workspaces()
+                    .iter()
+                    .position(|ws| ws.read(cx).database_id() == Some(target_id));
+                if let Some(index) = target_index {
+                    multi_workspace.activate_index(index, window, cx);
+                } else if !multi_workspace.workspaces().is_empty() {
+                    multi_workspace.activate_index(0, window, cx);
+                }
+            })
+            .ok();
+    } else {
+        window_handle
+            .update(cx, |multi_workspace, window, cx| {
+                if !multi_workspace.workspaces().is_empty() {
+                    multi_workspace.activate_index(0, window, cx);
+                }
+            })
+            .ok();
+    }
+
+    if state.sidebar_open {
+        window_handle
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.open_sidebar(window, cx);
+            })
+            .ok();
+    }
+
+    window_handle
+        .update(cx, |_, window, _cx| {
+            window.activate_window();
+        })
+        .ok();
+
+    Ok(window_handle)
+}
+
 actions!(
     collab,
     [
@@ -7902,7 +8088,8 @@ actions!(
 async fn join_channel_internal(
     channel_id: ChannelId,
     app_state: &Arc<AppState>,
-    requesting_window: Option<WindowHandle<Workspace>>,
+    requesting_window: Option<WindowHandle<MultiWorkspace>>,
+    requesting_workspace: Option<WeakEntity<Workspace>>,
     active_call: &Entity<ActiveCall>,
     cx: &mut AsyncApp,
 ) -> Result<bool> {
@@ -7938,8 +8125,8 @@ async fn join_channel_internal(
     }
 
     if should_prompt {
-        if let Some(workspace) = requesting_window {
-            let answer = workspace
+        if let Some(multi_workspace) = requesting_window {
+            let answer = multi_workspace
                 .update(cx, |_, window, cx| {
                     window.prompt(
                         PromptLevel::Warning,
@@ -8008,9 +8195,9 @@ async fn join_channel_internal(
         // If you are the first to join a channel, see if you should share your project.
         if room.remote_participants().is_empty()
             && !room.local_participant_is_guest()
-            && let Some(workspace) = requesting_window
+            && let Some(workspace) = requesting_workspace.as_ref().and_then(|w| w.upgrade())
         {
-            let project = workspace.update(cx, |workspace, _, cx| {
+            let project = workspace.update(cx, |workspace, cx| {
                 let project = workspace.project.read(cx);
 
                 if !CallSettings::get_global(cx).share_on_join {
@@ -8029,7 +8216,7 @@ async fn join_channel_internal(
                     None
                 }
             });
-            if let Ok(Some(project)) = project {
+            if let Some(project) = project {
                 return Some(cx.spawn(async move |room, cx| {
                     room.update(cx, |room, cx| room.share_project(project, cx))?
                         .await?;
@@ -8050,14 +8237,21 @@ async fn join_channel_internal(
 pub fn join_channel(
     channel_id: ChannelId,
     app_state: Arc<AppState>,
-    requesting_window: Option<WindowHandle<Workspace>>,
+    requesting_window: Option<WindowHandle<MultiWorkspace>>,
+    requesting_workspace: Option<WeakEntity<Workspace>>,
     cx: &mut App,
 ) -> Task<Result<()>> {
     let active_call = ActiveCall::global(cx);
     cx.spawn(async move |cx| {
-        let result =
-            join_channel_internal(channel_id, &app_state, requesting_window, &active_call, cx)
-                .await;
+        let result = join_channel_internal(
+            channel_id,
+            &app_state,
+            requesting_window,
+            requesting_workspace,
+            &active_call,
+            cx,
+        )
+        .await;
 
         // join channel succeeded, and opened a window
         if matches!(result, Ok(true)) {
@@ -8081,6 +8275,12 @@ pub fn join_channel(
                 })
                 .await?;
 
+            window_handle
+                .update(cx, |_, window, _cx| {
+                    window.activate_window();
+                })
+                .ok();
+
             if result.is_ok() {
                 cx.update(|cx| {
                     cx.dispatch_action(&OpenChannelNotes);
@@ -8135,10 +8335,10 @@ pub fn join_channel(
     })
 }
 
-pub async fn get_any_active_workspace(
+pub async fn get_any_active_multi_workspace(
     app_state: Arc<AppState>,
     mut cx: AsyncApp,
-) -> anyhow::Result<WindowHandle<Workspace>> {
+) -> anyhow::Result<WindowHandle<MultiWorkspace>> {
     // find an existing workspace to focus and show call controls
     let active_window = activate_any_workspace_window(&mut cx);
     if active_window.is_none() {
@@ -8148,17 +8348,17 @@ pub async fn get_any_active_workspace(
     activate_any_workspace_window(&mut cx).context("could not open zed")
 }
 
-fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option<WindowHandle<Workspace>> {
+fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option<WindowHandle<MultiWorkspace>> {
     cx.update(|cx| {
         if let Some(workspace_window) = cx
             .active_window()
-            .and_then(|window| window.downcast::<Workspace>())
+            .and_then(|window| window.downcast::<MultiWorkspace>())
         {
             return Some(workspace_window);
         }
 
         for window in cx.windows() {
-            if let Some(workspace_window) = window.downcast::<Workspace>() {
+            if let Some(workspace_window) = window.downcast::<MultiWorkspace>() {
                 workspace_window
                     .update(cx, |_, window, _| window.activate_window())
                     .ok();
@@ -8169,14 +8369,17 @@ fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option<WindowHandle<Works
     })
 }
 
-pub fn local_workspace_windows(cx: &App) -> Vec<WindowHandle<Workspace>> {
+pub fn local_workspace_windows(cx: &App) -> Vec<WindowHandle<MultiWorkspace>> {
     cx.windows()
         .into_iter()
-        .filter_map(|window| window.downcast::<Workspace>())
-        .filter(|workspace| {
-            workspace
-                .read(cx)
-                .is_ok_and(|workspace| workspace.project.read(cx).is_local())
+        .filter_map(|window| window.downcast::<MultiWorkspace>())
+        .filter(|multi_workspace| {
+            multi_workspace.read(cx).is_ok_and(|multi_workspace| {
+                multi_workspace
+                    .workspaces()
+                    .iter()
+                    .any(|workspace| workspace.read(cx).project.read(cx).is_local())
+            })
         })
         .collect()
 }
@@ -8187,7 +8390,7 @@ pub struct OpenOptions {
     pub focus: Option<bool>,
     pub open_new_workspace: Option<bool>,
     pub prefer_focused_window: bool,
-    pub replace_window: Option<WindowHandle<Workspace>>,
+    pub replace_window: Option<WindowHandle<MultiWorkspace>>,
     pub env: Option<HashMap<String, String>>,
 }
 
@@ -8195,8 +8398,9 @@ pub struct OpenOptions {
 pub fn open_workspace_by_id(
     workspace_id: WorkspaceId,
     app_state: Arc<AppState>,
+    requesting_window: Option<WindowHandle<MultiWorkspace>>,
     cx: &mut App,
-) -> Task<anyhow::Result<WindowHandle<Workspace>>> {
+) -> Task<anyhow::Result<WindowHandle<MultiWorkspace>>> {
     let project_handle = Project::local(
         app_state.client.clone(),
         app_state.node_runtime.clone(),
@@ -8216,52 +8420,87 @@ pub fn open_workspace_by_id(
             .workspace_for_id(workspace_id)
             .with_context(|| format!("Workspace {workspace_id:?} not found"))?;
 
-        let window_bounds_override = window_bounds_env_override();
-
-        let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
-            (Some(WindowBounds::Windowed(bounds)), None)
-        } else if let Some(display) = serialized_workspace.display
-            && let Some(bounds) = serialized_workspace.window_bounds.as_ref()
-        {
-            (Some(bounds.0), Some(display))
-        } else if let Some((display, bounds)) = persistence::read_default_window_bounds() {
-            (Some(bounds), Some(display))
-        } else {
-            (None, None)
-        };
-
-        let options = cx.update(|cx| {
-            let mut options = (app_state.build_window_options)(display, cx);
-            options.window_bounds = window_bounds;
-            options
-        });
         let centered_layout = serialized_workspace.centered_layout;
 
-        let window = cx.open_window(options, {
-            let app_state = app_state.clone();
-            let project_handle = project_handle.clone();
-            move |window, cx| {
-                cx.new(|cx| {
-                    let mut workspace =
-                        Workspace::new(Some(workspace_id), project_handle, app_state, window, cx);
+        let (window, workspace) = if let Some(window) = requesting_window {
+            let workspace = window.update(cx, |multi_workspace, window, cx| {
+                let workspace = cx.new(|cx| {
+                    let mut workspace = Workspace::new(
+                        Some(workspace_id),
+                        project_handle.clone(),
+                        app_state.clone(),
+                        window,
+                        cx,
+                    );
                     workspace.centered_layout = centered_layout;
                     workspace
-                })
-            }
-        })?;
+                });
+                multi_workspace.add_workspace(workspace.clone(), cx);
+                workspace
+            })?;
+            (window, workspace)
+        } else {
+            let window_bounds_override = window_bounds_env_override();
+
+            let (window_bounds, display) = if let Some(bounds) = window_bounds_override {
+                (Some(WindowBounds::Windowed(bounds)), None)
+            } else if let Some(display) = serialized_workspace.display
+                && let Some(bounds) = serialized_workspace.window_bounds.as_ref()
+            {
+                (Some(bounds.0), Some(display))
+            } else if let Some((display, bounds)) = persistence::read_default_window_bounds() {
+                (Some(bounds), Some(display))
+            } else {
+                (None, None)
+            };
+
+            let options = cx.update(|cx| {
+                let mut options = (app_state.build_window_options)(display, cx);
+                options.window_bounds = window_bounds;
+                options
+            });
+
+            let window = cx.open_window(options, {
+                let app_state = app_state.clone();
+                let project_handle = project_handle.clone();
+                move |window, cx| {
+                    let workspace = cx.new(|cx| {
+                        let mut workspace = Workspace::new(
+                            Some(workspace_id),
+                            project_handle,
+                            app_state,
+                            window,
+                            cx,
+                        );
+                        workspace.centered_layout = centered_layout;
+                        workspace
+                    });
+                    cx.new(|cx| MultiWorkspace::new(workspace, cx))
+                }
+            })?;
+
+            let workspace = window.update(cx, |multi_workspace: &mut MultiWorkspace, _, _cx| {
+                multi_workspace.workspace().clone()
+            })?;
+
+            (window, workspace)
+        };
 
         notify_if_database_failed(window, cx);
 
         // Restore items from the serialized workspace
         window
-            .update(cx, |_workspace, window, cx| {
-                open_items(Some(serialized_workspace), vec![], window, cx)
+            .update(cx, |_, window, cx| {
+                workspace.update(cx, |_workspace, cx| {
+                    open_items(Some(serialized_workspace), vec![], window, cx)
+                })
             })?
             .await?;
 
-        window.update(cx, |workspace, window, cx| {
-            window.activate_window();
-            workspace.serialize_workspace(window, cx);
+        window.update(cx, |_, window, cx| {
+            workspace.update(cx, |workspace, cx| {
+                workspace.serialize_workspace(window, cx);
+            });
         })?;
 
         Ok(window)

crates/zed/Cargo.toml 🔗

@@ -49,6 +49,7 @@ visual-tests = [
     "language_model/test-support",
     "fs/test-support",
     "recent_projects/test-support",
+    "sidebar/test-support",
     "title_bar/test-support",
 ]
 
@@ -187,6 +188,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/main.rs 🔗

@@ -54,8 +54,8 @@ use theme::{ActiveTheme, GlobalTheme, ThemeRegistry};
 use util::{ResultExt, TryFutureExt, maybe};
 use uuid::Uuid;
 use workspace::{
-    AppState, PathList, SerializedWorkspaceLocation, Toast, Workspace, WorkspaceId,
-    WorkspaceSettings, WorkspaceStore, notifications::NotificationId,
+    AppState, MultiWorkspace, SerializedWorkspaceLocation, SessionWorkspace, Toast,
+    WorkspaceSettings, WorkspaceStore, notifications::NotificationId, restore_multiworkspace,
 };
 use zed::{
     OpenListener, OpenRequest, RawOpenRequest, app_menus, build_window_options,
@@ -511,15 +511,13 @@ fn main() {
                 let workspace_store = workspace_store.clone();
                 Arc::new(move |cx: &mut App| {
                     workspace_store.update(cx, |workspace_store, cx| {
-                        workspace_store
+                        Ok(workspace_store
                             .workspaces()
-                            .iter()
-                            .map(|workspace| {
-                                workspace.update(cx, |workspace, _, cx| {
-                                    workspace.project().read(cx).lsp_store()
-                                })
+                            .filter_map(|weak| weak.upgrade())
+                            .map(|workspace: gpui::Entity<workspace::Workspace>| {
+                                workspace.read(cx).project().read(cx).lsp_store()
                             })
-                            .collect()
+                            .collect())
                     })
                 })
             }),
@@ -849,7 +847,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
             OpenRequestKind::Extension { extension_id } => {
                 cx.spawn(async move |cx| {
                     let workspace =
-                        workspace::get_any_active_workspace(app_state, cx.clone()).await?;
+                        workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?;
                     workspace.update(cx, |_, window, cx| {
                         window.dispatch_action(
                             Box::new(zed_actions::Extensions {
@@ -864,31 +862,40 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
             }
             OpenRequestKind::AgentPanel { initial_prompt } => {
                 cx.spawn(async move |cx| {
-                    let workspace =
-                        workspace::get_any_active_workspace(app_state, cx.clone()).await?;
-                    workspace.update(cx, |workspace, window, cx| {
-                        if let Some(panel) = workspace.focus_panel::<AgentPanel>(window, cx) {
-                            panel.update(cx, |panel, cx| {
-                                panel.new_external_thread_with_text(initial_prompt, window, cx);
-                            });
-                        }
+                    let multi_workspace =
+                        workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?;
+
+                    multi_workspace.update(cx, |multi_workspace, window, cx| {
+                        multi_workspace.workspace().update(cx, |workspace, cx| {
+                            if let Some(panel) = workspace.focus_panel::<AgentPanel>(window, cx) {
+                                panel.update(cx, |panel, cx| {
+                                    panel.new_external_thread_with_text(initial_prompt, window, cx);
+                                });
+                            }
+                        });
                     })
                 })
                 .detach_and_log_err(cx);
             }
             OpenRequestKind::SharedAgentThread { session_id } => {
                 cx.spawn(async move |cx| {
+                    let multi_workspace =
+                        workspace::get_any_active_multi_workspace(app_state.clone(), cx.clone())
+                            .await?;
+
                     let workspace =
-                        workspace::get_any_active_workspace(app_state.clone(), cx.clone()).await?;
+                        multi_workspace.read_with(cx, |mw, _| mw.workspace().clone())?;
 
                     let (client, thread_store) =
-                        workspace.update(cx, |workspace, _window, cx| {
-                            let client = workspace.project().read(cx).client();
-                            let thread_store: Option<gpui::Entity<ThreadStore>> = workspace
-                                .panel::<AgentPanel>(cx)
-                                .map(|panel| panel.read(cx).thread_store().clone());
-                            (client, thread_store)
-                        })?;
+                        multi_workspace.update(cx, |_, _window, cx| {
+                            workspace.update(cx, |workspace, cx| {
+                                let client = workspace.project().read(cx).client();
+                                let thread_store: Option<gpui::Entity<ThreadStore>> = workspace
+                                    .panel::<AgentPanel>(cx)
+                                    .map(|panel| panel.read(cx).thread_store().clone());
+                                anyhow::Ok((client, thread_store))
+                            })
+                        })??;
 
                     let Some(thread_store): Option<gpui::Entity<ThreadStore>> = thread_store else {
                         anyhow::bail!("Agent panel not available");
@@ -921,25 +928,27 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                         meta: None,
                     };
 
-                    workspace.update(cx, |workspace, window, cx| {
-                        if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
-                            panel.update(cx, |panel, cx| {
-                                panel.open_thread(thread_metadata, window, cx);
-                            });
-                            panel.focus_handle(cx).focus(window, cx);
-                        }
-                    })?;
+                    let sharer_username = response.sharer_username.clone();
 
-                    workspace.update(cx, |workspace, _window, cx| {
-                        struct ImportedThreadToast;
-                        workspace.show_toast(
-                            Toast::new(
-                                NotificationId::unique::<ImportedThreadToast>(),
-                                format!("Imported shared thread from {}", response.sharer_username),
-                            )
-                            .autohide(),
-                            cx,
-                        );
+                    multi_workspace.update(cx, |_, window, cx| {
+                        workspace.update(cx, |workspace, cx| {
+                            if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
+                                panel.update(cx, |panel, cx| {
+                                    panel.open_thread(thread_metadata, window, cx);
+                                });
+                                panel.focus_handle(cx).focus(window, cx);
+                            }
+
+                            struct ImportedThreadToast;
+                            workspace.show_toast(
+                                Toast::new(
+                                    NotificationId::unique::<ImportedThreadToast>(),
+                                    format!("Imported shared thread from {}", sharer_username),
+                                )
+                                .autohide(),
+                                cx,
+                            );
+                        });
                     })?;
 
                     anyhow::Ok(())
@@ -1014,7 +1023,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                 // [ languages $(language) tab_size]
                 cx.spawn(async move |cx| {
                     let workspace =
-                        workspace::get_any_active_workspace(app_state, cx.clone()).await?;
+                        workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?;
 
                     workspace.update(cx, |_, window, cx| match setting_path {
                         None => window.dispatch_action(Box::new(zed_actions::OpenSettings), cx),
@@ -1076,23 +1085,29 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                     .await?;
 
                     workspace
-                        .update(cx, |workspace, window, cx| {
-                            let Some(repo) = workspace.project().read(cx).active_repository(cx)
-                            else {
-                                log::error!("no active repository found for commit view");
-                                return Err(anyhow::anyhow!("no active repository found"));
-                            };
-
-                            git_ui::commit_view::CommitView::open(
-                                sha,
-                                repo.downgrade(),
-                                workspace.weak_handle(),
-                                None,
-                                None,
-                                window,
-                                cx,
-                            );
-                            Ok(())
+                        .update(cx, |multi_workspace, window, cx| {
+                            multi_workspace
+                                .workspace()
+                                .clone()
+                                .update(cx, |workspace, cx| {
+                                    let Some(repo) =
+                                        workspace.project().read(cx).active_repository(cx)
+                                    else {
+                                        log::error!("no active repository found for commit view");
+                                        return Err(anyhow::anyhow!("no active repository found"));
+                                    };
+
+                                    git_ui::commit_view::CommitView::open(
+                                        sha,
+                                        repo.downgrade(),
+                                        workspace.weak_handle(),
+                                        None,
+                                        None,
+                                        window,
+                                        cx,
+                                    );
+                                    Ok(())
+                                })
                         })
                         .log_err();
 
@@ -1162,6 +1177,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                             client::ChannelId(channel_id),
                             app_state.clone(),
                             None,
+                            None,
                             cx,
                         )
                     })
@@ -1169,8 +1185,9 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
                 }
 
                 let workspace_window =
-                    workspace::get_any_active_workspace(app_state, cx.clone()).await?;
-                let workspace = workspace_window.entity(cx)?;
+                    workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?;
+
+                let workspace = workspace_window.read_with(cx, |mw, _| mw.workspace().clone())?;
 
                 let mut promises = Vec::new();
                 for (channel_id, heading) in request.open_channel_notes {
@@ -1260,78 +1277,53 @@ async fn installation_id() -> Result<IdType> {
     Ok(IdType::New(installation_id))
 }
 
-async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp) -> Result<()> {
-    if let Some(locations) = restorable_workspace_locations(cx, &app_state).await {
-        let use_system_window_tabs =
-            cx.update(|cx| WorkspaceSettings::get_global(cx).use_system_window_tabs);
+pub(crate) async fn restore_or_create_workspace(
+    app_state: Arc<AppState>,
+    cx: &mut AsyncApp,
+) -> Result<()> {
+    if let Some((multi_workspaces, remote_workspaces)) = restorable_workspaces(cx, &app_state).await
+    {
         let mut results: Vec<Result<(), Error>> = Vec::new();
         let mut tasks = Vec::new();
 
-        for (index, (workspace_id, location, paths)) in locations.into_iter().enumerate() {
-            match location {
-                SerializedWorkspaceLocation::Local if paths.is_empty() => {
-                    // Restore empty workspace by ID (has items like drafts but no folders)
-                    let app_state = app_state.clone();
-                    let task = cx.spawn(async move |cx| {
-                        let open_task = cx.update(|cx| {
-                            workspace::open_workspace_by_id(workspace_id, app_state, cx)
-                        });
-                        open_task.await.map(|_| ())
-                    });
+        let mut local_results = Vec::new();
+        for multi_workspace in multi_workspaces {
+            local_results
+                .push(restore_multiworkspace(multi_workspace, app_state.clone(), cx).await);
+        }
 
-                    if use_system_window_tabs && index == 0 {
-                        results.push(task.await);
-                    } else {
-                        tasks.push(task);
-                    }
-                }
-                SerializedWorkspaceLocation::Local => {
-                    let app_state = app_state.clone();
-                    let task = cx.spawn(async move |cx| {
-                        let open_task = cx.update(|cx| {
-                            workspace::open_paths(
-                                &paths.paths(),
-                                app_state,
-                                workspace::OpenOptions::default(),
-                                cx,
-                            )
-                        });
-                        open_task.await.map(|_| ())
-                    });
-
-                    // If we're using system window tabs and this is the first workspace,
-                    // wait for it to finish so that the other windows can be added as tabs.
-                    if use_system_window_tabs && index == 0 {
-                        results.push(task.await);
-                    } else {
-                        tasks.push(task);
-                    }
-                }
-                SerializedWorkspaceLocation::Remote(mut connection_options) => {
-                    let app_state = app_state.clone();
-                    if let RemoteConnectionOptions::Ssh(options) = &mut connection_options {
-                        cx.update(|cx| {
-                            RemoteSettings::get_global(cx)
-                                .fill_connection_options_from_settings(options)
-                        });
-                    }
-                    let task = cx.spawn(async move |cx| {
-                        recent_projects::open_remote_project(
-                            connection_options,
-                            paths.paths().into_iter().map(PathBuf::from).collect(),
-                            app_state,
-                            workspace::OpenOptions::default(),
-                            cx,
-                        )
-                        .await
-                        .map_err(|e| anyhow::anyhow!(e))
-                    });
-                    tasks.push(task);
-                }
+        for result in local_results {
+            results.push(result.map(|_| ()));
+        }
+
+        for session_workspace in remote_workspaces {
+            let app_state = app_state.clone();
+            let SerializedWorkspaceLocation::Remote(mut connection_options) =
+                session_workspace.location
+            else {
+                continue;
+            };
+            let paths = session_workspace.paths;
+            if let RemoteConnectionOptions::Ssh(options) = &mut connection_options {
+                cx.update(|cx| {
+                    RemoteSettings::get_global(cx).fill_connection_options_from_settings(options)
+                });
             }
+            let task = cx.spawn(async move |cx| {
+                recent_projects::open_remote_project(
+                    connection_options,
+                    paths.paths().iter().map(PathBuf::from).collect(),
+                    app_state,
+                    workspace::OpenOptions::default(),
+                    cx,
+                )
+                .await
+                .map_err(|e| anyhow::anyhow!(e))
+            });
+            tasks.push(task);
         }
 
-        // Wait for all workspaces to open concurrently
+        // Wait for all window groups and remote workspaces to open concurrently
         results.extend(future::join_all(tasks).await);
 
         // Show notifications for any errors that occurred
@@ -1356,12 +1348,16 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
             // Try to find an active workspace to show the toast
             let toast_shown = cx.update(|cx| {
                 if let Some(window) = cx.active_window()
-                    && let Some(workspace) = window.downcast::<Workspace>()
+                    && let Some(multi_workspace) = window.downcast::<MultiWorkspace>()
                 {
-                    workspace
-                        .update(cx, |workspace, _, cx| {
-                            workspace
-                                .show_toast(Toast::new(NotificationId::unique::<()>(), message), cx)
+                    multi_workspace
+                        .update(cx, |multi_workspace, _, cx| {
+                            multi_workspace.workspace().update(cx, |workspace, cx| {
+                                workspace.show_toast(
+                                    Toast::new(NotificationId::unique::<()>(), message),
+                                    cx,
+                                )
+                            });
                         })
                         .ok();
                     return true;
@@ -1402,10 +1398,25 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
     Ok(())
 }
 
+async fn restorable_workspaces(
+    cx: &mut AsyncApp,
+    app_state: &Arc<AppState>,
+) -> Option<(
+    Vec<workspace::SerializedMultiWorkspace>,
+    Vec<SessionWorkspace>,
+)> {
+    let locations = restorable_workspace_locations(cx, app_state).await?;
+    let (remote_workspaces, local_workspaces) = locations
+        .into_iter()
+        .partition(|sw| matches!(sw.location, SerializedWorkspaceLocation::Remote(_)));
+    let multi_workspaces = workspace::read_serialized_multi_workspaces(local_workspaces);
+    Some((multi_workspaces, remote_workspaces))
+}
+
 pub(crate) async fn restorable_workspace_locations(
     cx: &mut AsyncApp,
     app_state: &Arc<AppState>,
-) -> Option<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
+) -> Option<Vec<SessionWorkspace>> {
     let mut restore_behavior = cx.update(|cx| WorkspaceSettings::get(None, cx).restore_on_startup);
 
     let session_handle = app_state.session.clone();
@@ -1429,9 +1440,16 @@ pub(crate) async fn restorable_workspace_locations(
 
     match restore_behavior {
         workspace::RestoreOnStartupBehavior::LastWorkspace => {
-            workspace::last_opened_workspace_location()
+            workspace::last_opened_workspace_location(app_state.fs.as_ref())
                 .await
-                .map(|location| vec![location])
+                .map(|(workspace_id, location, paths)| {
+                    vec![SessionWorkspace {
+                        workspace_id,
+                        location,
+                        paths,
+                        window_id: None,
+                    }]
+                })
         }
         workspace::RestoreOnStartupBehavior::LastSession => {
             if let Some(last_session_id) = last_session_id {
@@ -1440,7 +1458,9 @@ pub(crate) async fn restorable_workspace_locations(
                 let mut locations = workspace::last_session_workspace_locations(
                     &last_session_id,
                     last_session_window_stack,
+                    app_state.fs.as_ref(),
                 )
+                .await
                 .filter(|locations| !locations.is_empty());
 
                 // Since last_session_window_order returns the windows ordered front-to-back

crates/zed/src/visual_test_runner.rs 🔗

@@ -59,6 +59,7 @@ use {
     },
     image::RgbaImage,
     project_panel::ProjectPanel,
+    recent_projects::RecentProjectEntry,
     settings::{NotifyWhenAgentWaiting, Settings as _},
     settings_ui::SettingsWindow,
     std::{
@@ -70,7 +71,7 @@ use {
     },
     util::ResultExt as _,
     watch,
-    workspace::{AppState, Workspace},
+    workspace::{AppState, MultiWorkspace, Workspace, WorkspaceId},
     zed_actions::OpenSettingsAt,
 };
 
@@ -435,7 +436,24 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
         }
     }
 
-    // Run Test 3: Agent Thread View tests
+    // Run Test 3: Multi-workspace sidebar visual tests
+    println!("\n--- Test 3: multi_workspace_sidebar ---");
+    match run_multi_workspace_sidebar_visual_tests(app_state.clone(), &mut cx, update_baseline) {
+        Ok(TestResult::Passed) => {
+            println!("✓ multi_workspace_sidebar: PASSED");
+            passed += 1;
+        }
+        Ok(TestResult::BaselineUpdated(_)) => {
+            println!("✓ multi_workspace_sidebar: Baselines updated");
+            updated += 1;
+        }
+        Err(e) => {
+            eprintln!("✗ multi_workspace_sidebar: FAILED - {}", e);
+            failed += 1;
+        }
+    }
+
+    // Run Test 4: Agent Thread View tests
     #[cfg(feature = "visual-tests")]
     {
         println!("\n--- Test 3: agent_thread_with_image (collapsed + expanded) ---");
@@ -2781,3 +2799,300 @@ fn run_tool_permissions_visual_tests(
     // Return success - we're just capturing screenshots, not comparing baselines
     Ok(TestResult::Passed)
 }
+
+#[cfg(target_os = "macos")]
+fn run_multi_workspace_sidebar_visual_tests(
+    app_state: Arc<AppState>,
+    cx: &mut VisualTestAppContext,
+    update_baseline: bool,
+) -> Result<TestResult> {
+    // Create temporary directories to act as worktrees for active workspaces
+    let temp_dir = tempfile::tempdir()?;
+    let temp_path = temp_dir.keep();
+    let canonical_temp = temp_path.canonicalize()?;
+
+    let workspace1_dir = canonical_temp.join("private-test-remote");
+    let workspace2_dir = canonical_temp.join("zed");
+    std::fs::create_dir_all(&workspace1_dir)?;
+    std::fs::create_dir_all(&workspace2_dir)?;
+
+    // Create directories for recent projects (they must exist on disk for display)
+    let recent1_dir = canonical_temp.join("tiny-project");
+    let recent2_dir = canonical_temp.join("font-kit");
+    let recent3_dir = canonical_temp.join("ideas");
+    let recent4_dir = canonical_temp.join("tmp");
+    std::fs::create_dir_all(&recent1_dir)?;
+    std::fs::create_dir_all(&recent2_dir)?;
+    std::fs::create_dir_all(&recent3_dir)?;
+    std::fs::create_dir_all(&recent4_dir)?;
+
+    // Enable the agent-v2 feature flag so multi-workspace is active
+    cx.update(|cx| {
+        cx.update_flags(true, vec!["agent-v2".to_string()]);
+    });
+
+    // Create both projects upfront so we can build both workspaces during
+    // window creation, before the MultiWorkspace entity exists.
+    // This avoids a re-entrant read panic that occurs when Workspace::new
+    // tries to access the window root (MultiWorkspace) while it's being updated.
+    let project1 = cx.update(|cx| {
+        project::Project::local(
+            app_state.client.clone(),
+            app_state.node_runtime.clone(),
+            app_state.user_store.clone(),
+            app_state.languages.clone(),
+            app_state.fs.clone(),
+            None,
+            project::LocalProjectFlags {
+                init_worktree_trust: false,
+                ..Default::default()
+            },
+            cx,
+        )
+    });
+
+    let project2 = cx.update(|cx| {
+        project::Project::local(
+            app_state.client.clone(),
+            app_state.node_runtime.clone(),
+            app_state.user_store.clone(),
+            app_state.languages.clone(),
+            app_state.fs.clone(),
+            None,
+            project::LocalProjectFlags {
+                init_worktree_trust: false,
+                ..Default::default()
+            },
+            cx,
+        )
+    });
+
+    let window_size = size(px(1280.0), px(800.0));
+    let bounds = Bounds {
+        origin: point(px(0.0), px(0.0)),
+        size: window_size,
+    };
+
+    // Open a MultiWorkspace window with both workspaces created at construction time
+    let multi_workspace_window: WindowHandle<MultiWorkspace> = cx
+        .update(|cx| {
+            cx.open_window(
+                WindowOptions {
+                    window_bounds: Some(WindowBounds::Windowed(bounds)),
+                    focus: false,
+                    show: false,
+                    ..Default::default()
+                },
+                |window, cx| {
+                    let workspace1 = cx.new(|cx| {
+                        Workspace::new(None, project1.clone(), app_state.clone(), window, cx)
+                    });
+                    let workspace2 = cx.new(|cx| {
+                        Workspace::new(None, project2.clone(), app_state.clone(), window, cx)
+                    });
+                    cx.new(|cx| {
+                        let mut multi_workspace = MultiWorkspace::new(workspace1, cx);
+                        multi_workspace.activate(workspace2, cx);
+                        multi_workspace
+                    })
+                },
+            )
+        })
+        .context("Failed to open MultiWorkspace window")?;
+
+    cx.run_until_parked();
+
+    // Add worktree to workspace 1 (index 0) so it shows as "private-test-remote"
+    let add_worktree1_task = multi_workspace_window
+        .update(cx, |multi_workspace, _window, cx| {
+            let workspace1 = &multi_workspace.workspaces()[0];
+            let project = workspace1.read(cx).project().clone();
+            project.update(cx, |project, cx| {
+                project.find_or_create_worktree(&workspace1_dir, true, cx)
+            })
+        })
+        .context("Failed to start adding worktree 1")?;
+
+    cx.background_executor.allow_parking();
+    cx.foreground_executor
+        .block_test(add_worktree1_task)
+        .context("Failed to add worktree 1")?;
+    cx.background_executor.forbid_parking();
+
+    cx.run_until_parked();
+
+    // Add worktree to workspace 2 (index 1) so it shows as "zed"
+    let add_worktree2_task = multi_workspace_window
+        .update(cx, |multi_workspace, _window, cx| {
+            let workspace2 = &multi_workspace.workspaces()[1];
+            let project = workspace2.read(cx).project().clone();
+            project.update(cx, |project, cx| {
+                project.find_or_create_worktree(&workspace2_dir, true, cx)
+            })
+        })
+        .context("Failed to start adding worktree 2")?;
+
+    cx.background_executor.allow_parking();
+    cx.foreground_executor
+        .block_test(add_worktree2_task)
+        .context("Failed to add worktree 2")?;
+    cx.background_executor.forbid_parking();
+
+    cx.run_until_parked();
+
+    // Switch to workspace 1 so it's highlighted as active (index 0)
+    multi_workspace_window
+        .update(cx, |multi_workspace, window, cx| {
+            multi_workspace.activate_index(0, window, cx);
+        })
+        .context("Failed to activate workspace 1")?;
+
+    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(), window, cx);
+        })
+        .context("Failed to register sidebar")?;
+
+    cx.run_until_parked();
+
+    // Inject recent project entries into the sidebar.
+    // We update the sidebar entity directly (not through the MultiWorkspace window update)
+    // to avoid a re-entrant read panic: rebuild_entries reads MultiWorkspace, so we can't
+    // be inside a MultiWorkspace update when that happens.
+    cx.update(|cx| {
+        sidebar.update(cx, |sidebar, cx| {
+            let recent_projects = vec![
+                RecentProjectEntry {
+                    name: "tiny-project".into(),
+                    full_path: recent1_dir.to_string_lossy().to_string().into(),
+                    paths: vec![recent1_dir.clone()],
+                    workspace_id: WorkspaceId::default(),
+                },
+                RecentProjectEntry {
+                    name: "font-kit".into(),
+                    full_path: recent2_dir.to_string_lossy().to_string().into(),
+                    paths: vec![recent2_dir.clone()],
+                    workspace_id: WorkspaceId::default(),
+                },
+                RecentProjectEntry {
+                    name: "ideas".into(),
+                    full_path: recent3_dir.to_string_lossy().to_string().into(),
+                    paths: vec![recent3_dir.clone()],
+                    workspace_id: WorkspaceId::default(),
+                },
+                RecentProjectEntry {
+                    name: "tmp".into(),
+                    full_path: recent4_dir.to_string_lossy().to_string().into(),
+                    paths: vec![recent4_dir.clone()],
+                    workspace_id: WorkspaceId::default(),
+                },
+            ];
+            sidebar.set_test_recent_projects(recent_projects, cx);
+        });
+    });
+
+    // Set thread info directly on the sidebar for visual testing
+    cx.update(|cx| {
+        sidebar.update(cx, |sidebar, _cx| {
+            sidebar.set_test_thread_info(
+                0,
+                "Refine thread view scrolling behavior".into(),
+                sidebar::AgentThreadStatus::Completed,
+            );
+            sidebar.set_test_thread_info(
+                1,
+                "Add line numbers option to FileEditBlock".into(),
+                sidebar::AgentThreadStatus::Running,
+            );
+        });
+    });
+
+    // Set last-worked-on thread titles on some recent projects for visual testing
+    cx.update(|cx| {
+        sidebar.update(cx, |sidebar, cx| {
+            sidebar.set_test_recent_project_thread_title(
+                recent1_dir.to_string_lossy().to_string().into(),
+                "Fix flaky test in CI pipeline".into(),
+                cx,
+            );
+            sidebar.set_test_recent_project_thread_title(
+                recent2_dir.to_string_lossy().to_string().into(),
+                "Upgrade font rendering engine".into(),
+                cx,
+            );
+        });
+    });
+
+    cx.run_until_parked();
+
+    // Open the sidebar
+    multi_workspace_window
+        .update(cx, |multi_workspace, window, cx| {
+            multi_workspace.toggle_sidebar(window, cx);
+        })
+        .context("Failed to toggle sidebar")?;
+
+    // Let rendering settle
+    for _ in 0..10 {
+        cx.advance_clock(Duration::from_millis(100));
+        cx.run_until_parked();
+    }
+
+    // Refresh the window
+    cx.update_window(multi_workspace_window.into(), |_, window, _cx| {
+        window.refresh();
+    })?;
+
+    cx.run_until_parked();
+
+    // Capture: sidebar open with active workspaces and recent projects
+    let test_result = run_visual_test(
+        "multi_workspace_sidebar_open",
+        multi_workspace_window.into(),
+        cx,
+        update_baseline,
+    )?;
+
+    // Clean up worktrees
+    multi_workspace_window
+        .update(cx, |multi_workspace, _window, cx| {
+            for workspace in multi_workspace.workspaces() {
+                let project = workspace.read(cx).project().clone();
+                project.update(cx, |project, cx| {
+                    let worktree_ids: Vec<_> =
+                        project.worktrees(cx).map(|wt| wt.read(cx).id()).collect();
+                    for id in worktree_ids {
+                        project.remove_worktree(id, cx);
+                    }
+                });
+            }
+        })
+        .log_err();
+
+    cx.run_until_parked();
+
+    // Close the window
+    cx.update_window(multi_workspace_window.into(), |_, window, _cx| {
+        window.remove_window();
+    })
+    .log_err();
+
+    cx.run_until_parked();
+
+    for _ in 0..15 {
+        cx.advance_clock(Duration::from_millis(100));
+        cx.run_until_parked();
+    }
+
+    Ok(test_result)
+}

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,
@@ -88,9 +89,9 @@ use workspace::notifications::{
 };
 use workspace::utility_pane::utility_slot_for_dock_position;
 use workspace::{
-    AppState, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, WorkspaceSettings,
-    create_and_open_local_file, notifications::simple_message_notification::MessageNotification,
-    open_new,
+    AppState, MultiWorkspace, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace,
+    WorkspaceSettings, create_and_open_local_file,
+    notifications::simple_message_notification::MessageNotification, open_new,
 };
 use workspace::{
     CloseIntent, CloseProject, CloseWindow, NotificationFrame, RestoreBanner,
@@ -370,6 +371,16 @@ pub fn initialize_workspace(
     })
     .detach();
 
+    cx.observe_new(|multi_workspace: &mut MultiWorkspace, window, cx| {
+        let Some(window) = window else {
+            return;
+        };
+        let multi_workspace_handle = cx.entity();
+        let sidebar = cx.new(|cx| Sidebar::new(multi_workspace_handle, window, cx));
+        multi_workspace.register_sidebar(sidebar, window, cx);
+    })
+    .detach();
+
     cx.observe_new(move |workspace: &mut Workspace, window, cx| {
         let Some(window) = window else {
             return;
@@ -1152,7 +1163,7 @@ fn register_actions(
         .register_action({
             let app_state = Arc::downgrade(&app_state);
             move |_, _: &CloseProject, window, cx| {
-                let Some(window_handle) = window.window_handle().downcast::<Workspace>() else {
+                let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
                     return;
                 };
                 if let Some(app_state) = app_state.upgrade() {
@@ -1248,6 +1259,7 @@ fn initialize_pane(
     window: &mut Window,
     cx: &mut Context<Workspace>,
 ) {
+    let workspace_handle = cx.weak_entity();
     pane.update(cx, |pane, cx| {
         pane.toolbar().update(cx, |toolbar, cx| {
             let multibuffer_hint = cx.new(|_| MultibufferHint::new());
@@ -1280,11 +1292,12 @@ fn initialize_pane(
             toolbar.add_item(telemetry_log_item, window, cx);
             let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new());
             toolbar.add_item(syntax_tree_item, window, cx);
+            let migration_banner =
+                cx.new(|inner_cx| MigrationBanner::new(workspace_handle.clone(), inner_cx));
+            toolbar.add_item(migration_banner, window, cx);
             let highlights_tree_item =
                 cx.new(|_| language_tools::HighlightsTreeToolbarItemView::new());
             toolbar.add_item(highlights_tree_item, window, cx);
-            let migration_banner = cx.new(|cx| MigrationBanner::new(workspace, cx));
-            toolbar.add_item(migration_banner, window, cx);
             let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx));
             toolbar.add_item(project_diff_toolbar, window, cx);
             let branch_diff_toolbar = cx.new(BranchDiffToolbar::new);
@@ -1359,10 +1372,10 @@ fn quit(_: &Quit, cx: &mut App) {
 
     let should_confirm = WorkspaceSettings::get_global(cx).confirm_quit;
     cx.spawn(async move |cx| {
-        let mut workspace_windows: Vec<WindowHandle<Workspace>> = cx.update(|cx| {
+        let mut workspace_windows: Vec<WindowHandle<MultiWorkspace>> = cx.update(|cx| {
             cx.windows()
                 .into_iter()
-                .filter_map(|window| window.downcast::<Workspace>())
+                .filter_map(|window| window.downcast::<MultiWorkspace>())
                 .collect::<Vec<_>>()
         });
 
@@ -1372,8 +1385,8 @@ fn quit(_: &Quit, cx: &mut App) {
             workspace_windows.sort_by_key(|window| window.is_active(cx) == Some(false));
         });
 
-        if should_confirm && let Some(workspace) = workspace_windows.first() {
-            let answer = workspace
+        if should_confirm && let Some(multi_workspace) = workspace_windows.first() {
+            let answer = multi_workspace
                 .update(cx, |_, window, cx| {
                     window.prompt(
                         PromptLevel::Info,
@@ -1397,14 +1410,30 @@ fn quit(_: &Quit, cx: &mut App) {
 
         // If the user cancels any save prompt, then keep the app open.
         for window in workspace_windows {
-            if let Some(should_close) = window
-                .update(cx, |workspace, window, cx| {
-                    workspace.prepare_to_close(CloseIntent::Quit, window, cx)
+            let workspaces = window
+                .update(cx, |multi_workspace, _, _| {
+                    multi_workspace.workspaces().to_vec()
                 })
-                .log_err()
-            {
-                if !should_close.await? {
-                    return Ok(());
+                .log_err();
+
+            let Some(workspaces) = workspaces else {
+                continue;
+            };
+
+            for workspace in workspaces {
+                if let Some(should_close) = window
+                    .update(cx, |multi_workspace, window, cx| {
+                        multi_workspace.activate(workspace.clone(), cx);
+                        window.activate_window();
+                        workspace.update(cx, |workspace, cx| {
+                            workspace.prepare_to_close(CloseIntent::Quit, window, cx)
+                        })
+                    })
+                    .log_err()
+                {
+                    if !should_close.await? {
+                        return Ok(());
+                    }
                 }
             }
         }
@@ -2356,6 +2385,7 @@ mod tests {
     use settings::{SaturatingBool, SettingsStore, watch_config_file};
     use std::{
         path::{Path, PathBuf},
+        sync::Arc,
         time::Duration,
     };
     use theme::ThemeRegistry;
@@ -2363,6 +2393,7 @@ mod tests {
         path,
         rel_path::{RelPath, rel_path},
     };
+    use workspace::MultiWorkspace;
     use workspace::{
         NewFile, OpenOptions, OpenVisible, SERIALIZATION_THROTTLE_TIME, SaveIntent, SplitDirection,
         WorkspaceHandle,
@@ -2398,10 +2429,12 @@ mod tests {
         .unwrap();
         assert_eq!(cx.read(|cx| cx.windows().len()), 1);
 
-        let workspace = cx.windows()[0].downcast::<Workspace>().unwrap();
-        workspace
-            .update(cx, |workspace, _, cx| {
-                assert!(workspace.active_item_as::<Editor>(cx).is_some())
+        let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+        multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    assert!(workspace.active_item_as::<Editor>(cx).is_some())
+                });
             })
             .unwrap();
     }
@@ -2409,6 +2442,10 @@ mod tests {
     #[gpui::test]
     async fn test_open_paths_action(cx: &mut TestAppContext) {
         let app_state = init_test(cx);
+        cx.update(|cx| {
+            use feature_flags::FeatureFlagAppExt as _;
+            cx.update_flags(false, vec!["agent-v2".to_string()]);
+        });
         app_state
             .fs
             .as_fake()
@@ -2462,21 +2499,23 @@ mod tests {
         .await
         .unwrap();
         assert_eq!(cx.read(|cx| cx.windows().len()), 1);
-        let workspace_1 = cx
-            .read(|cx| cx.windows()[0].downcast::<Workspace>())
+        let multi_workspace_1 = cx
+            .read(|cx| cx.windows()[0].downcast::<MultiWorkspace>())
             .unwrap();
         cx.run_until_parked();
-        workspace_1
-            .update(cx, |workspace, window, cx| {
-                assert_eq!(workspace.worktrees(cx).count(), 2);
-                assert!(workspace.left_dock().read(cx).is_open());
-                assert!(
-                    workspace
-                        .active_pane()
-                        .read(cx)
-                        .focus_handle(cx)
-                        .is_focused(window)
-                );
+        multi_workspace_1
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    assert_eq!(workspace.worktrees(cx).count(), 2);
+                    assert!(workspace.left_dock().read(cx).is_open());
+                    assert!(
+                        workspace
+                            .active_pane()
+                            .read(cx)
+                            .focus_handle(cx)
+                            .is_focused(window)
+                    );
+                });
             })
             .unwrap();
 
@@ -2494,7 +2533,7 @@ mod tests {
 
         // Replace existing windows
         let window = cx
-            .update(|cx| cx.windows()[0].downcast::<Workspace>())
+            .update(|cx| cx.windows()[0].downcast::<MultiWorkspace>())
             .unwrap();
         cx.update(|cx| {
             open_paths(
@@ -2511,11 +2550,12 @@ mod tests {
         .unwrap();
         cx.background_executor.run_until_parked();
         assert_eq!(cx.read(|cx| cx.windows().len()), 2);
-        let workspace_1 = cx
-            .update(|cx| cx.windows()[0].downcast::<Workspace>())
+        let multi_workspace_1 = cx
+            .update(|cx| cx.windows()[0].downcast::<MultiWorkspace>())
             .unwrap();
-        workspace_1
-            .update(cx, |workspace, window, cx| {
+        multi_workspace_1
+            .update(cx, |multi_workspace, window, cx| {
+                let workspace = multi_workspace.workspace().read(cx);
                 assert_eq!(
                     workspace
                         .worktrees(cx)
@@ -2687,17 +2727,21 @@ mod tests {
         assert_eq!(cx.update(|cx| cx.windows().len()), 1);
 
         // When opening the workspace, the window is not in a edited state.
-        let window = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
+        let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
 
-        let window_is_edited = |window: WindowHandle<Workspace>, cx: &mut TestAppContext| {
-            cx.update(|cx| window.read(cx).unwrap().is_edited())
+        let window_is_edited = |window: WindowHandle<MultiWorkspace>, cx: &mut TestAppContext| {
+            cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited())
         };
         let pane = window
-            .read_with(cx, |workspace, _| workspace.active_pane().clone())
+            .read_with(cx, |multi_workspace, cx| {
+                multi_workspace.workspace().read(cx).active_pane().clone()
+            })
             .unwrap();
         let editor = window
-            .read_with(cx, |workspace, cx| {
-                workspace
+            .read_with(cx, |multi_workspace, cx| {
+                multi_workspace
+                    .workspace()
+                    .read(cx)
                     .active_item(cx)
                     .unwrap()
                     .downcast::<Editor>()
@@ -2770,22 +2814,26 @@ mod tests {
         executor.run_until_parked();
 
         window
-            .update(cx, |workspace, _, cx| {
-                let editor = workspace
-                    .active_item(cx)
-                    .unwrap()
-                    .downcast::<Editor>()
-                    .unwrap();
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    let editor = workspace
+                        .active_item(cx)
+                        .unwrap()
+                        .downcast::<Editor>()
+                        .unwrap();
 
-                editor.update(cx, |editor, cx| {
-                    assert_eq!(editor.text(cx), "hey");
+                    editor.update(cx, |editor, cx| {
+                        assert_eq!(editor.text(cx), "hey");
+                    });
                 });
             })
             .unwrap();
 
         let editor = window
-            .read_with(cx, |workspace, cx| {
-                workspace
+            .read_with(cx, |multi_workspace, cx| {
+                multi_workspace
+                    .workspace()
+                    .read(cx)
                     .active_item(cx)
                     .unwrap()
                     .downcast::<Editor>()
@@ -2838,15 +2886,17 @@ mod tests {
         assert_eq!(cx.update(|cx| cx.windows().len()), 1);
 
         // When opening the workspace, the window is not in a edited state.
-        let window = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
+        let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
 
-        let window_is_edited = |window: WindowHandle<Workspace>, cx: &mut TestAppContext| {
-            cx.update(|cx| window.read(cx).unwrap().is_edited())
+        let window_is_edited = |window: WindowHandle<MultiWorkspace>, cx: &mut TestAppContext| {
+            cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited())
         };
 
         let editor = window
-            .read_with(cx, |workspace, cx| {
-                workspace
+            .read_with(cx, |multi_workspace, cx| {
+                multi_workspace
+                    .workspace()
+                    .read(cx)
                     .active_item(cx)
                     .unwrap()
                     .downcast::<Editor>()
@@ -2893,22 +2943,27 @@ mod tests {
         cx.run_until_parked();
 
         // When opening the workspace, the window is not in a edited state.
-        let window = cx.update(|cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
+        let window = cx.update(|cx| {
+            cx.active_window()
+                .unwrap()
+                .downcast::<MultiWorkspace>()
+                .unwrap()
+        });
         assert!(window_is_edited(window, cx));
 
         window
-            .update(cx, |workspace, _, cx| {
-                let editor = workspace
-                    .active_item(cx)
-                    .unwrap()
-                    .downcast::<editor::Editor>()
-                    .unwrap();
-                editor.update(cx, |editor, cx| {
-                    assert_eq!(editor.text(cx), "EDIThey");
-                    assert!(editor.is_dirty(cx));
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    let editor = workspace
+                        .active_item(cx)
+                        .unwrap()
+                        .downcast::<editor::Editor>()
+                        .unwrap();
+                    editor.update(cx, |editor, cx| {
+                        assert_eq!(editor.text(cx), "EDIThey");
+                        assert!(editor.is_dirty(cx));
+                    });
                 });
-
-                editor
             })
             .unwrap();
     }
@@ -2930,36 +2985,40 @@ mod tests {
         .unwrap();
         cx.run_until_parked();
 
-        let workspace = cx
-            .update(|cx| cx.windows().first().unwrap().downcast::<Workspace>())
+        let multi_workspace = cx
+            .update(|cx| cx.windows().first().unwrap().downcast::<MultiWorkspace>())
             .unwrap();
 
-        let editor = workspace
-            .update(cx, |workspace, _, cx| {
-                let editor = workspace
-                    .active_item(cx)
-                    .unwrap()
-                    .downcast::<editor::Editor>()
-                    .unwrap();
-                editor.update(cx, |editor, cx| {
-                    assert!(editor.text(cx).is_empty());
-                    assert!(!editor.is_dirty(cx));
-                });
+        let editor = multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    let editor = workspace
+                        .active_item(cx)
+                        .unwrap()
+                        .downcast::<editor::Editor>()
+                        .unwrap();
+                    editor.update(cx, |editor, cx| {
+                        assert!(editor.text(cx).is_empty());
+                        assert!(!editor.is_dirty(cx));
+                    });
 
-                editor
+                    editor
+                })
             })
             .unwrap();
 
-        let save_task = workspace
-            .update(cx, |workspace, window, cx| {
-                workspace.save_active_item(SaveIntent::Save, window, cx)
+        let save_task = multi_workspace
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    workspace.save_active_item(SaveIntent::Save, window, cx)
+                })
             })
             .unwrap();
         app_state.fs.create_dir(Path::new("/root")).await.unwrap();
         cx.background_executor.run_until_parked();
         cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name")));
         save_task.await.unwrap();
-        workspace
+        multi_workspace
             .update(cx, |_, _, cx| {
                 editor.update(cx, |editor, cx| {
                     assert!(!editor.is_dirty(cx));
@@ -3140,8 +3199,10 @@ mod tests {
         .unwrap();
         cx.run_until_parked();
         assert_eq!(cx.update(|cx| cx.windows().len()), 1);
-        let window = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
-        let workspace = window.root(cx).unwrap();
+        let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
+        let workspace = window
+            .read_with(cx, |mw, _| mw.workspace().clone())
+            .unwrap();
 
         #[track_caller]
         fn assert_project_panel_selection(
@@ -3176,17 +3237,19 @@ mod tests {
 
         // Open a file within an existing worktree.
         window
-            .update(cx, |workspace, window, cx| {
-                workspace.open_paths(
-                    vec![path!("/dir1/a.txt").into()],
-                    OpenOptions {
-                        visible: Some(OpenVisible::All),
-                        ..Default::default()
-                    },
-                    None,
-                    window,
-                    cx,
-                )
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    workspace.open_paths(
+                        vec![path!("/dir1/a.txt").into()],
+                        OpenOptions {
+                            visible: Some(OpenVisible::All),
+                            ..Default::default()
+                        },
+                        None,
+                        window,
+                        cx,
+                    )
+                })
             })
             .unwrap()
             .await;
@@ -3215,17 +3278,19 @@ mod tests {
 
         // Open a file outside of any existing worktree.
         window
-            .update(cx, |workspace, window, cx| {
-                workspace.open_paths(
-                    vec![path!("/dir2/b.txt").into()],
-                    OpenOptions {
-                        visible: Some(OpenVisible::All),
-                        ..Default::default()
-                    },
-                    None,
-                    window,
-                    cx,
-                )
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    workspace.open_paths(
+                        vec![path!("/dir2/b.txt").into()],
+                        OpenOptions {
+                            visible: Some(OpenVisible::All),
+                            ..Default::default()
+                        },
+                        None,
+                        window,
+                        cx,
+                    )
+                })
             })
             .unwrap()
             .await;
@@ -3265,17 +3330,19 @@ mod tests {
 
         // Ensure opening a directory and one of its children only adds one worktree.
         window
-            .update(cx, |workspace, window, cx| {
-                workspace.open_paths(
-                    vec![path!("/dir3").into(), path!("/dir3/c.txt").into()],
-                    OpenOptions {
-                        visible: Some(OpenVisible::All),
-                        ..Default::default()
-                    },
-                    None,
-                    window,
-                    cx,
-                )
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    workspace.open_paths(
+                        vec![path!("/dir3").into(), path!("/dir3/c.txt").into()],
+                        OpenOptions {
+                            visible: Some(OpenVisible::All),
+                            ..Default::default()
+                        },
+                        None,
+                        window,
+                        cx,
+                    )
+                })
             })
             .unwrap()
             .await;
@@ -3315,17 +3382,19 @@ mod tests {
 
         // Ensure opening invisibly a file outside an existing worktree adds a new, invisible worktree.
         window
-            .update(cx, |workspace, window, cx| {
-                workspace.open_paths(
-                    vec![path!("/d.txt").into()],
-                    OpenOptions {
-                        visible: Some(OpenVisible::None),
-                        ..Default::default()
-                    },
-                    None,
-                    window,
-                    cx,
-                )
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    workspace.open_paths(
+                        vec![path!("/d.txt").into()],
+                        OpenOptions {
+                            visible: Some(OpenVisible::None),
+                            ..Default::default()
+                        },
+                        None,
+                        window,
+                        cx,
+                    )
+                })
             })
             .unwrap()
             .await;
@@ -3419,8 +3488,13 @@ mod tests {
 
         let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
         project.update(cx, |project, _cx| project.languages().add(markdown_lang()));
-        let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
-        let workspace = window.root(cx).unwrap();
+        let window = cx.add_window({
+            let project = project.clone();
+            |window, cx| MultiWorkspace::test_new(project, window, cx)
+        });
+        let workspace = window
+            .read_with(cx, |mw, _| mw.workspace().clone())
+            .unwrap();
 
         let initial_entries = cx.read(|cx| workspace.file_project_paths(cx));
         let paths_to_open = [
@@ -3441,7 +3515,9 @@ mod tests {
             .unwrap();
 
         assert_eq!(
-            opened_workspace.root(cx).unwrap().entity_id(),
+            opened_workspace
+                .read_with(cx, |mw, _| mw.workspace().entity_id())
+                .unwrap(),
             workspace.entity_id(),
             "Excluded files in subfolders of a workspace root should be opened in the workspace"
         );
@@ -4864,6 +4940,7 @@ mod tests {
                 "lsp_tool",
                 "markdown",
                 "menu",
+                "multi_workspace",
                 "new_process_modal",
                 "notebook",
                 "notification_panel",
@@ -4951,7 +5028,7 @@ mod tests {
         cx.update(init);
 
         let project = Project::test(app_state.fs.clone(), [], cx).await;
-        let _window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx));
+        let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
 
         cx.update(|cx| {
             cx.dispatch_action(&OpenDefaultSettings);
@@ -4960,10 +5037,12 @@ mod tests {
 
         assert_eq!(cx.read(|cx| cx.windows().len()), 1);
 
-        let workspace = cx.windows()[0].downcast::<Workspace>().unwrap();
-        let active_editor = workspace
-            .update(cx, |workspace, _, cx| {
-                workspace.active_item_as::<Editor>(cx)
+        let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+        let active_editor = multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace
+                    .workspace()
+                    .update(cx, |workspace, cx| workspace.active_item_as::<Editor>(cx))
             })
             .unwrap();
         assert!(
@@ -5267,16 +5346,22 @@ mod tests {
             .await;
 
         let project_a = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
-        let window_a =
-            cx.add_window(|window, cx| Workspace::test_new(project_a.clone(), window, cx));
+        let window_a = cx.add_window({
+            let project = project_a.clone();
+            |window, cx| MultiWorkspace::test_new(project, window, cx)
+        });
 
         let project_b = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
-        let window_b =
-            cx.add_window(|window, cx| Workspace::test_new(project_b.clone(), window, cx));
+        let window_b = cx.add_window({
+            let project = project_b.clone();
+            |window, cx| MultiWorkspace::test_new(project, window, cx)
+        });
 
         let project_c = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
-        let window_c =
-            cx.add_window(|window, cx| Workspace::test_new(project_c.clone(), window, cx));
+        let window_c = cx.add_window({
+            let project = project_c.clone();
+            |window, cx| MultiWorkspace::test_new(project, window, cx)
+        });
 
         for window in [window_a, window_b, window_c] {
             let _ = cx.update_window(*window, |_, window, _| {
@@ -5297,8 +5382,8 @@ mod tests {
             cx.update_window(*window, |_, window, _| assert!(window.is_window_active()))
                 .unwrap();
 
-            let _ = window.read_with(cx, |workspace, cx| {
-                let pane = workspace.active_pane().read(cx);
+            let _ = window.read_with(cx, |multi_workspace, cx| {
+                let pane = multi_workspace.workspace().read(cx).active_pane().read(cx);
                 let project_path = pane.active_item().unwrap().project_path(cx).unwrap();
 
                 assert_eq!(
@@ -5308,4 +5393,707 @@ mod tests {
             });
         }
     }
+
+    #[gpui::test]
+    async fn test_open_paths_switches_to_best_workspace(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+        cx.update(|cx| {
+            use feature_flags::FeatureFlagAppExt as _;
+            cx.update_flags(false, vec!["agent-v2".to_string()]);
+        });
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                path!("/"),
+                json!({
+                    "dir1": {
+                        "a.txt": "content a"
+                    },
+                    "dir2": {
+                        "b.txt": "content b"
+                    },
+                    "dir3": {
+                        "c.txt": "content c"
+                    }
+                }),
+            )
+            .await;
+
+        // Create a window with workspace 0 containing /dir1
+        let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await;
+
+        let window = cx.add_window({
+            let project = project1.clone();
+            |window, cx| MultiWorkspace::test_new(project, window, cx)
+        });
+
+        cx.run_until_parked();
+        assert_eq!(cx.windows().len(), 1, "Should start with 1 window");
+
+        // Create workspace 2 with /dir2
+        let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await;
+        let workspace2 = window
+            .update(cx, |_, window, cx| {
+                cx.new(|cx| Workspace::test_new(project2.clone(), window, cx))
+            })
+            .unwrap();
+
+        // Create workspace 3 with /dir3
+        let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await;
+        let workspace3 = window
+            .update(cx, |_, window, cx| {
+                cx.new(|cx| Workspace::test_new(project3.clone(), window, cx))
+            })
+            .unwrap();
+
+        let workspace1 = window
+            .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+            .unwrap();
+
+        window
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.activate(workspace2.clone(), cx);
+                multi_workspace.activate(workspace3.clone(), cx);
+                // Switch back to workspace1 for test setup
+                multi_workspace.activate(workspace1, cx);
+                assert_eq!(multi_workspace.active_workspace_index(), 0);
+            })
+            .unwrap();
+
+        cx.run_until_parked();
+
+        // Verify setup: 3 workspaces, workspace 0 active, still 1 window
+        window
+            .read_with(cx, |multi_workspace, _| {
+                assert_eq!(multi_workspace.workspaces().len(), 3);
+                assert_eq!(multi_workspace.active_workspace_index(), 0);
+            })
+            .unwrap();
+        assert_eq!(cx.windows().len(), 1);
+
+        // Open a file in /dir3 - should switch to workspace 3 (not just "the other one")
+        cx.update(|cx| {
+            open_paths(
+                &[PathBuf::from(path!("/dir3/c.txt"))],
+                app_state.clone(),
+                OpenOptions::default(),
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+
+        cx.run_until_parked();
+
+        // Verify workspace 2 is active and file opened there
+        window
+            .read_with(cx, |multi_workspace, cx| {
+                assert_eq!(
+                    multi_workspace.active_workspace_index(),
+                    2,
+                    "Should have switched to workspace 3 which contains /dir3"
+                );
+                let active_item = multi_workspace
+                    .workspace()
+                    .read(cx)
+                    .active_pane()
+                    .read(cx)
+                    .active_item()
+                    .expect("Should have an active item");
+                assert_eq!(active_item.tab_content_text(0, cx), "c.txt");
+            })
+            .unwrap();
+        assert_eq!(cx.windows().len(), 1, "Should reuse existing window");
+
+        // Open a file in /dir2 - should switch to workspace 2
+        cx.update(|cx| {
+            open_paths(
+                &[PathBuf::from(path!("/dir2/b.txt"))],
+                app_state.clone(),
+                OpenOptions::default(),
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+
+        cx.run_until_parked();
+
+        // Verify workspace 1 is active and file opened there
+        window
+            .read_with(cx, |multi_workspace, cx| {
+                assert_eq!(
+                    multi_workspace.active_workspace_index(),
+                    1,
+                    "Should have switched to workspace 2 which contains /dir2"
+                );
+                let active_item = multi_workspace
+                    .workspace()
+                    .read(cx)
+                    .active_pane()
+                    .read(cx)
+                    .active_item()
+                    .expect("Should have an active item");
+                assert_eq!(active_item.tab_content_text(0, cx), "b.txt");
+            })
+            .unwrap();
+
+        // Verify c.txt is still in workspace 3 (file opened in correct workspace, not active one)
+        workspace3.read_with(cx, |workspace, cx| {
+            let active_item = workspace
+                .active_pane()
+                .read(cx)
+                .active_item()
+                .expect("Workspace 2 should have an active item");
+            assert_eq!(
+                active_item.tab_content_text(0, cx),
+                "c.txt",
+                "c.txt should have been opened in workspace 3, not the active workspace"
+            );
+        });
+
+        assert_eq!(cx.windows().len(), 1, "Should still have only 1 window");
+
+        // Open a file in /dir1 - should switch back to workspace 0
+        cx.update(|cx| {
+            open_paths(
+                &[PathBuf::from(path!("/dir1/a.txt"))],
+                app_state.clone(),
+                OpenOptions::default(),
+                cx,
+            )
+        })
+        .await
+        .unwrap();
+
+        cx.run_until_parked();
+
+        // Verify workspace 0 is active and file opened there
+        window
+            .read_with(cx, |multi_workspace, cx| {
+                assert_eq!(
+                    multi_workspace.active_workspace_index(),
+                    0,
+                    "Should have switched back to workspace 0 which contains /dir1"
+                );
+                let active_item = multi_workspace
+                    .workspace()
+                    .read(cx)
+                    .active_pane()
+                    .read(cx)
+                    .active_item()
+                    .expect("Should have an active item");
+                assert_eq!(active_item.tab_content_text(0, cx), "a.txt");
+            })
+            .unwrap();
+        assert_eq!(cx.windows().len(), 1, "Should still have only 1 window");
+    }
+
+    #[gpui::test]
+    async fn test_quit_checks_all_workspaces_for_dirty_items(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+        cx.update(init);
+        cx.update(|cx| {
+            use feature_flags::FeatureFlagAppExt as _;
+            cx.update_flags(false, vec!["agent-v2".to_string()]);
+        });
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                path!("/"),
+                json!({
+                    "dir1": {
+                        "a.txt": "content a"
+                    },
+                    "dir2": {
+                        "b.txt": "content b"
+                    },
+                    "dir3": {
+                        "c.txt": "content c"
+                    }
+                }),
+            )
+            .await;
+
+        // === Setup Window 1 with two workspaces ===
+        let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await;
+        let window1 = cx.add_window({
+            let project = project1.clone();
+            |window, cx| MultiWorkspace::test_new(project, window, cx)
+        });
+
+        cx.run_until_parked();
+
+        let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await;
+        let workspace1_1 = window1
+            .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+            .unwrap();
+        let workspace1_2 = window1
+            .update(cx, |_, window, cx| {
+                cx.new(|cx| Workspace::test_new(project2.clone(), window, cx))
+            })
+            .unwrap();
+
+        window1
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.activate(workspace1_2.clone(), cx);
+                multi_workspace.activate(workspace1_1.clone(), cx);
+            })
+            .unwrap();
+
+        // === Setup Window 2 with one workspace ===
+        let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await;
+        let window2 = cx.add_window({
+            let project = project3.clone();
+            |window, cx| MultiWorkspace::test_new(project, window, cx)
+        });
+
+        cx.run_until_parked();
+        assert_eq!(cx.windows().len(), 2);
+
+        // === Case 1: Active workspace has dirty item, quit can be cancelled ===
+        let worktree1_id = project1.update(cx, |project, cx| {
+            project.worktrees(cx).next().unwrap().read(cx).id()
+        });
+
+        let editor1 = window1
+            .update(cx, |_, window, cx| {
+                workspace1_1.update(cx, |workspace, cx| {
+                    workspace.open_path((worktree1_id, rel_path("a.txt")), None, true, window, cx)
+                })
+            })
+            .unwrap()
+            .await
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap();
+
+        window1
+            .update(cx, |_, window, cx| {
+                editor1.update(cx, |editor, cx| {
+                    editor.insert("dirty in active workspace", window, cx);
+                });
+            })
+            .unwrap();
+
+        cx.run_until_parked();
+
+        // Verify workspace1_1 is active
+        window1
+            .read_with(cx, |multi_workspace, _| {
+                assert_eq!(multi_workspace.active_workspace_index(), 0);
+            })
+            .unwrap();
+
+        cx.dispatch_action(*window1, Quit);
+        cx.run_until_parked();
+
+        assert!(
+            cx.has_pending_prompt(),
+            "Case 1: Should prompt to save dirty item in active workspace"
+        );
+
+        cx.simulate_prompt_answer("Cancel");
+        cx.run_until_parked();
+
+        assert_eq!(
+            cx.windows().len(),
+            2,
+            "Case 1: Windows should still exist after cancelling quit"
+        );
+
+        // Clean up Case 1: Close the dirty item without saving
+        let close_task = window1
+            .update(cx, |_, window, cx| {
+                workspace1_1.update(cx, |workspace, cx| {
+                    workspace.active_pane().update(cx, |pane, cx| {
+                        pane.close_active_item(&Default::default(), window, cx)
+                    })
+                })
+            })
+            .unwrap();
+        cx.run_until_parked();
+        cx.simulate_prompt_answer("Don't Save");
+        close_task.await.ok();
+        cx.run_until_parked();
+
+        // === Case 2: Non-active workspace (same window) has dirty item ===
+        let worktree2_id = project2.update(cx, |project, cx| {
+            project.worktrees(cx).next().unwrap().read(cx).id()
+        });
+
+        let editor2 = window1
+            .update(cx, |_, window, cx| {
+                workspace1_2.update(cx, |workspace, cx| {
+                    workspace.open_path((worktree2_id, rel_path("b.txt")), None, true, window, cx)
+                })
+            })
+            .unwrap()
+            .await
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap();
+
+        window1
+            .update(cx, |_, window, cx| {
+                editor2.update(cx, |editor, cx| {
+                    editor.insert("dirty in non-active workspace", window, cx);
+                });
+            })
+            .unwrap();
+
+        cx.run_until_parked();
+
+        // Verify workspace1_1 is still active (not workspace1_2 with dirty item)
+        window1
+            .read_with(cx, |multi_workspace, _| {
+                assert_eq!(multi_workspace.active_workspace_index(), 0);
+            })
+            .unwrap();
+
+        cx.dispatch_action(*window1, Quit);
+        cx.run_until_parked();
+
+        // Verify the non-active workspace got activated to show the dirty item
+        window1
+            .read_with(cx, |multi_workspace, _| {
+                assert_eq!(
+                    multi_workspace.active_workspace_index(),
+                    1,
+                    "Case 2: Non-active workspace should be activated when it has dirty item"
+                );
+            })
+            .unwrap();
+
+        assert!(
+            cx.has_pending_prompt(),
+            "Case 2: Should prompt to save dirty item in non-active workspace"
+        );
+
+        cx.simulate_prompt_answer("Cancel");
+        cx.run_until_parked();
+
+        assert_eq!(
+            cx.windows().len(),
+            2,
+            "Case 2: Windows should still exist after cancelling quit"
+        );
+
+        // Clean up Case 2: Close the dirty item without saving
+        let close_task = window1
+            .update(cx, |_, window, cx| {
+                workspace1_2.update(cx, |workspace, cx| {
+                    workspace.active_pane().update(cx, |pane, cx| {
+                        pane.close_active_item(&Default::default(), window, cx)
+                    })
+                })
+            })
+            .unwrap();
+        cx.run_until_parked();
+        cx.simulate_prompt_answer("Don't Save");
+        close_task.await.ok();
+        cx.run_until_parked();
+
+        // === Case 3: Non-active window has dirty item ===
+        let workspace3 = window2
+            .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
+            .unwrap();
+
+        let worktree3_id = project3.update(cx, |project, cx| {
+            project.worktrees(cx).next().unwrap().read(cx).id()
+        });
+
+        let editor3 = window2
+            .update(cx, |_, window, cx| {
+                workspace3.update(cx, |workspace, cx| {
+                    workspace.open_path((worktree3_id, rel_path("c.txt")), None, true, window, cx)
+                })
+            })
+            .unwrap()
+            .await
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap();
+
+        window2
+            .update(cx, |_, window, cx| {
+                editor3.update(cx, |editor, cx| {
+                    editor.insert("dirty in other window", window, cx);
+                });
+            })
+            .unwrap();
+
+        cx.run_until_parked();
+
+        // Activate window1 explicitly (editing in window2 may have activated it)
+        window1
+            .update(cx, |_, window, _| window.activate_window())
+            .unwrap();
+        cx.run_until_parked();
+
+        // Verify window2 is not active (window1 should still be active)
+        assert_eq!(
+            cx.update(|cx| window2.is_active(cx)),
+            Some(false),
+            "Case 3: window2 should not be active before quit"
+        );
+
+        // Dispatch quit from window1 (window2 has the dirty item)
+        cx.dispatch_action(*window1, Quit);
+        cx.run_until_parked();
+
+        // Verify window2 is now active (quit handler activated it to show dirty item)
+        assert_eq!(
+            cx.update(|cx| window2.is_active(cx)),
+            Some(true),
+            "Case 3: window2 should be activated when it has dirty item"
+        );
+
+        assert!(
+            cx.has_pending_prompt(),
+            "Case 3: Should prompt to save dirty item in non-active window"
+        );
+
+        cx.simulate_prompt_answer("Cancel");
+        cx.run_until_parked();
+
+        assert_eq!(
+            cx.windows().len(),
+            2,
+            "Case 3: Windows should still exist after cancelling quit"
+        );
+    }
+
+    #[gpui::test]
+    async fn test_multi_workspace_session_restore(cx: &mut TestAppContext) {
+        use collections::HashMap;
+        use session::Session;
+        use workspace::{Workspace, WorkspaceId};
+
+        let app_state = init_test(cx);
+
+        cx.update(|cx| {
+            use feature_flags::FeatureFlagAppExt as _;
+            cx.update_flags(false, vec!["agent-v2".to_string()]);
+        });
+
+        let dir1 = path!("/dir1");
+        let dir2 = path!("/dir2");
+        let dir3 = path!("/dir3");
+
+        let fs = app_state.fs.clone();
+        let fake_fs = fs.as_fake();
+        fake_fs.insert_tree(dir1, json!({})).await;
+        fake_fs.insert_tree(dir2, json!({})).await;
+        fake_fs.insert_tree(dir3, json!({})).await;
+
+        let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned());
+
+        // --- Create 3 workspaces in 2 windows ---
+        //
+        //   Window A: workspace for dir1, workspace for dir2
+        //   Window B: workspace for dir3
+        let (window_a, _) = cx
+            .update(|cx| {
+                Workspace::new_local(vec![dir1.into()], app_state.clone(), None, None, None, cx)
+            })
+            .await
+            .expect("failed to open first workspace");
+
+        window_a
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.open_project(vec![dir2.into()], window, cx)
+            })
+            .unwrap()
+            .await
+            .expect("failed to open second workspace into window A");
+        cx.run_until_parked();
+
+        let (window_b, _) = cx
+            .update(|cx| {
+                Workspace::new_local(vec![dir3.into()], app_state.clone(), None, None, None, cx)
+            })
+            .await
+            .expect("failed to open third workspace");
+
+        // Currently dir2 is active because it was added last.
+        // So, switch window_a's active workspace to dir1 (index 0).
+        // This sets up a non-trivial assertion: after restore, dir1 should
+        // still be active rather than whichever workspace happened to restore last.
+        window_a
+            .update(cx, |multi_workspace, window, cx| {
+                multi_workspace.activate_index(0, window, cx);
+            })
+            .unwrap();
+
+        // --- Flush serialization ---
+        cx.executor().advance_clock(SERIALIZATION_THROTTLE_TIME);
+        cx.run_until_parked();
+
+        // Verify all workspaces retained their session_ids.
+        let locations = workspace::last_session_workspace_locations(&session_id, None, fs.as_ref())
+            .await
+            .expect("expected session workspace locations");
+        assert_eq!(
+            locations.len(),
+            3,
+            "all 3 workspaces should have session_ids in the DB"
+        );
+
+        // Close the original windows.
+        window_a
+            .update(cx, |_, window, _| window.remove_window())
+            .unwrap();
+        window_b
+            .update(cx, |_, window, _| window.remove_window())
+            .unwrap();
+        cx.run_until_parked();
+
+        // Simulate a new session launch: replace the session so that
+        // `last_session_id()` returns the ID used during workspace creation.
+        // `restore_on_startup` defaults to `LastSession`, which is what we need.
+        cx.update(|cx| {
+            app_state.session.update(cx, |app_session, _cx| {
+                app_session
+                    .replace_session_for_test(Session::test_with_old_session(session_id.clone()));
+            });
+        });
+
+        // --- Read back from DB and verify grouping ---
+        let locations = workspace::last_session_workspace_locations(&session_id, None, fs.as_ref())
+            .await
+            .expect("expected session workspace locations");
+
+        assert_eq!(locations.len(), 3, "expected 3 session workspaces");
+
+        let mut groups_by_window: HashMap<gpui::WindowId, Vec<WorkspaceId>> = HashMap::default();
+        for session_workspace in &locations {
+            if let Some(window_id) = session_workspace.window_id {
+                groups_by_window
+                    .entry(window_id)
+                    .or_default()
+                    .push(session_workspace.workspace_id);
+            }
+        }
+        assert_eq!(
+            groups_by_window.len(),
+            2,
+            "expected 2 window groups, got {groups_by_window:?}"
+        );
+        assert!(
+            groups_by_window.values().any(|g| g.len() == 2),
+            "expected one group with 2 workspaces"
+        );
+        assert!(
+            groups_by_window.values().any(|g| g.len() == 1),
+            "expected one group with 1 workspace"
+        );
+
+        let mut async_cx = cx.to_async();
+        crate::restore_or_create_workspace(app_state.clone(), &mut async_cx)
+            .await
+            .expect("failed to restore workspaces");
+        cx.run_until_parked();
+
+        // --- Verify the restored windows ---
+        let restored_windows: Vec<WindowHandle<MultiWorkspace>> = cx.read(|cx| {
+            cx.windows()
+                .into_iter()
+                .filter_map(|window| window.downcast::<MultiWorkspace>())
+                .collect()
+        });
+
+        assert_eq!(
+            restored_windows.len(),
+            2,
+            "expected 2 restored windows, got {}",
+            restored_windows.len()
+        );
+
+        let workspace_counts: Vec<usize> = restored_windows
+            .iter()
+            .map(|window| {
+                window
+                    .read_with(cx, |multi_workspace, _| multi_workspace.workspaces().len())
+                    .unwrap()
+            })
+            .collect();
+        let mut sorted_counts = workspace_counts.clone();
+        sorted_counts.sort();
+        assert_eq!(
+            sorted_counts,
+            vec![1, 2],
+            "expected one window with 1 workspace and one with 2, got {workspace_counts:?}"
+        );
+
+        let dir1_path: Arc<Path> = Path::new(dir1).into();
+        let dir2_path: Arc<Path> = Path::new(dir2).into();
+        let dir3_path: Arc<Path> = Path::new(dir3).into();
+
+        let all_restored_paths: Vec<Vec<Vec<Arc<Path>>>> = restored_windows
+            .iter()
+            .map(|window| {
+                window
+                    .read_with(cx, |multi_workspace, cx| {
+                        multi_workspace
+                            .workspaces()
+                            .iter()
+                            .map(|ws| ws.read(cx).root_paths(cx))
+                            .collect()
+                    })
+                    .unwrap()
+            })
+            .collect();
+
+        let two_ws_window = all_restored_paths
+            .iter()
+            .find(|paths| paths.len() == 2)
+            .expect("expected a window with 2 workspaces");
+        assert!(
+            two_ws_window.iter().any(|p| p.contains(&dir1_path)),
+            "2-workspace window should contain dir1, got {two_ws_window:?}"
+        );
+        assert!(
+            two_ws_window.iter().any(|p| p.contains(&dir2_path)),
+            "2-workspace window should contain dir2, got {two_ws_window:?}"
+        );
+
+        let one_ws_window = all_restored_paths
+            .iter()
+            .find(|paths| paths.len() == 1)
+            .expect("expected a window with 1 workspace");
+        assert!(
+            one_ws_window[0].contains(&dir3_path),
+            "1-workspace window should contain dir3, got {one_ws_window:?}"
+        );
+
+        // --- Verify the active workspace is preserved ---
+        for window in &restored_windows {
+            let (active_paths, workspace_count) = window
+                .read_with(cx, |multi_workspace, cx| {
+                    let active = multi_workspace.workspace();
+                    (
+                        active.read(cx).root_paths(cx),
+                        multi_workspace.workspaces().len(),
+                    )
+                })
+                .unwrap();
+
+            if workspace_count == 2 {
+                assert!(
+                    active_paths.contains(&dir1_path),
+                    "2-workspace window should have dir1 active, got {active_paths:?}"
+                );
+            } else {
+                assert!(
+                    active_paths.contains(&dir3_path),
+                    "1-workspace window should have dir3 active, got {active_paths:?}"
+                );
+            }
+        }
+    }
 }

crates/zed/src/zed/migrate.rs 🔗

@@ -1,6 +1,7 @@
 use anyhow::{Context as _, Result};
 use editor::Editor;
 use fs::Fs;
+use gpui::WeakEntity;
 use migrator::{migrate_keymap, migrate_settings};
 use settings::{KeymapFile, Settings, SettingsStore};
 use util::ResultExt;
@@ -22,6 +23,7 @@ pub enum MigrationType {
 }
 
 pub struct MigrationBanner {
+    workspace: WeakEntity<Workspace>,
     migration_type: Option<MigrationType>,
     should_migrate_task: Option<Task<()>>,
     markdown: Option<Entity<Markdown>>,
@@ -54,7 +56,7 @@ struct GlobalMigrationNotification(Entity<MigrationNotification>);
 impl Global for GlobalMigrationNotification {}
 
 impl MigrationBanner {
-    pub fn new(_: &Workspace, cx: &mut Context<Self>) -> Self {
+    pub fn new(workspace: WeakEntity<Workspace>, cx: &mut Context<Self>) -> Self {
         if let Some(notifier) = MigrationNotification::try_global(cx) {
             cx.subscribe(
                 &notifier,
@@ -65,6 +67,7 @@ impl MigrationBanner {
             .detach();
         }
         Self {
+            workspace,
             migration_type: None,
             should_migrate_task: None,
             markdown: None,
@@ -235,22 +238,22 @@ impl Render for MigrationBanner {
                     ),
             )
             .child(
-                Button::new("backup-and-migrate", "Backup and Update").on_click(
+                Button::new("backup-and-migrate", "Backup and Update").on_click({
+                    let workspace = self.workspace.clone();
                     move |_, window, cx| {
                         let fs = <dyn Fs>::global(cx);
-                        match migration_type {
+                        let task = match migration_type {
                             Some(MigrationType::Keymap) => {
                                 cx.background_spawn(write_keymap_migration(fs.clone()))
-                                    .detach_and_notify_err(window, cx);
                             }
                             Some(MigrationType::Settings) => {
                                 cx.background_spawn(write_settings_migration(fs.clone()))
-                                    .detach_and_notify_err(window, cx);
                             }
                             None => unreachable!(),
-                        }
-                    },
-                ),
+                        };
+                        task.detach_and_notify_err(workspace.clone(), window, cx);
+                    }
+                }),
             )
             .into_any_element()
     }

crates/zed/src/zed/open_listener.rs 🔗

@@ -1,5 +1,5 @@
 use crate::handle_open_request;
-use crate::restorable_workspace_locations;
+use crate::restore_or_create_workspace;
 use anyhow::{Context as _, Result, anyhow};
 use cli::{CliRequest, CliResponse, ipc::IpcSender};
 use cli::{IpcHandshake, ipc};
@@ -30,7 +30,7 @@ use util::ResultExt;
 use util::paths::PathWithPosition;
 use workspace::PathList;
 use workspace::item::ItemHandle;
-use workspace::{AppState, OpenOptions, SerializedWorkspaceLocation, Workspace};
+use workspace::{AppState, MultiWorkspace, OpenOptions, SerializedWorkspaceLocation};
 
 #[derive(Default, Debug)]
 pub struct OpenRequest {
@@ -337,7 +337,7 @@ pub async fn open_paths_with_positions(
     open_options: workspace::OpenOptions,
     cx: &mut AsyncApp,
 ) -> Result<(
-    WindowHandle<Workspace>,
+    WindowHandle<MultiWorkspace>,
     Vec<Option<Result<Box<dyn ItemHandle>>>>,
 )> {
     let mut caret_positions = HashMap::default();
@@ -357,24 +357,29 @@ pub async fn open_paths_with_positions(
         })
         .collect::<Vec<_>>();
 
-    let (workspace, mut items) = cx
+    let (multi_workspace, mut items) = cx
         .update(|cx| workspace::open_paths(&paths, app_state, open_options, cx))
         .await?;
 
     if diff_all && !diff_paths.is_empty() {
-        if let Ok(diff_view) = workspace.update(cx, |workspace, window, cx| {
-            MultiDiffView::open(diff_paths.to_vec(), workspace, window, cx)
+        if let Ok(diff_view) = multi_workspace.update(cx, |multi_workspace, window, cx| {
+            multi_workspace.workspace().update(cx, |workspace, cx| {
+                MultiDiffView::open(diff_paths.to_vec(), workspace, window, cx)
+            })
         }) {
             if let Some(diff_view) = diff_view.await.log_err() {
                 items.push(Some(Ok(Box::new(diff_view))));
             }
         }
     } else {
+        let workspace_weak = multi_workspace.read_with(cx, |multi_workspace, _cx| {
+            multi_workspace.workspace().downgrade()
+        })?;
         for diff_pair in diff_paths {
             let old_path = Path::new(&diff_pair[0]).canonicalize()?;
             let new_path = Path::new(&diff_pair[1]).canonicalize()?;
-            if let Ok(diff_view) = workspace.update(cx, |workspace, window, cx| {
-                FileDiffView::open(old_path, new_path, workspace, window, cx)
+            if let Ok(diff_view) = multi_workspace.update(cx, |_multi_workspace, window, cx| {
+                FileDiffView::open(old_path, new_path, workspace_weak.clone(), window, cx)
             }) {
                 if let Some(diff_view) = diff_view.await.log_err() {
                     items.push(Some(Ok(Box::new(diff_view))))
@@ -395,7 +400,7 @@ pub async fn open_paths_with_positions(
             continue;
         };
         if let Some(active_editor) = item.downcast::<Editor>() {
-            workspace
+            multi_workspace
                 .update(cx, |_, window, cx| {
                     active_editor.update(cx, |editor, cx| {
                         editor.go_to_singleton_buffer_point(point, window, cx);
@@ -405,7 +410,7 @@ pub async fn open_paths_with_positions(
         }
     }
 
-    Ok((workspace, items))
+    Ok((multi_workspace, items))
 }
 
 pub async fn handle_cli_connection(
@@ -488,20 +493,13 @@ async fn open_workspaces(
     env: Option<collections::HashMap<String, String>>,
     cx: &mut AsyncApp,
 ) -> Result<()> {
+    if paths.is_empty() && diff_paths.is_empty() && open_new_workspace != Some(true) {
+        return restore_or_create_workspace(app_state, cx).await;
+    }
+
     let grouped_locations: Vec<(SerializedWorkspaceLocation, PathList)> =
         if paths.is_empty() && diff_paths.is_empty() {
-            if open_new_workspace == Some(true) {
-                Vec::new()
-            } else {
-                // The workspace_id from the database is not used;
-                // open_paths will assign a new WorkspaceId when opening the workspace.
-                restorable_workspace_locations(cx, &app_state)
-                    .await
-                    .unwrap_or_default()
-                    .into_iter()
-                    .map(|(_workspace_id, location, paths)| (location, paths))
-                    .collect()
-            }
+            Vec::new()
         } else {
             vec![(
                 SerializedWorkspaceLocation::Local,
@@ -755,7 +753,7 @@ mod tests {
     use serde_json::json;
     use std::{sync::Arc, task::Poll};
     use util::path;
-    use workspace::{AppState, Workspace};
+    use workspace::{AppState, MultiWorkspace};
 
     #[gpui::test]
     fn test_parse_ssh_url(cx: &mut TestAppContext) {
@@ -891,10 +889,12 @@ mod tests {
         open_workspace_file(path!("/root/dir1"), None, app_state.clone(), cx).await;
 
         assert_eq!(cx.windows().len(), 1);
-        let workspace = cx.windows()[0].downcast::<Workspace>().unwrap();
-        workspace
-            .update(cx, |workspace, _, cx| {
-                assert!(workspace.active_item_as::<Editor>(cx).is_none())
+        let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+        multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    assert!(workspace.active_item_as::<Editor>(cx).is_none())
+                });
             })
             .unwrap();
 
@@ -902,9 +902,11 @@ mod tests {
         open_workspace_file(path!("/root/dir1/file1.txt"), None, app_state.clone(), cx).await;
 
         assert_eq!(cx.windows().len(), 1);
-        workspace
-            .update(cx, |workspace, _, cx| {
-                assert!(workspace.active_item_as::<Editor>(cx).is_some());
+        multi_workspace
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    assert!(workspace.active_item_as::<Editor>(cx).is_some());
+                });
             })
             .unwrap();
 
@@ -919,12 +921,14 @@ mod tests {
 
         assert_eq!(cx.windows().len(), 2);
 
-        let workspace_2 = cx.windows()[1].downcast::<Workspace>().unwrap();
-        workspace_2
-            .update(cx, |workspace, _, cx| {
-                assert!(workspace.active_item_as::<Editor>(cx).is_some());
-                let items = workspace.items(cx).collect::<Vec<_>>();
-                assert_eq!(items.len(), 1, "Workspace should have two items");
+        let multi_workspace_2 = cx.windows()[1].downcast::<MultiWorkspace>().unwrap();
+        multi_workspace_2
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    assert!(workspace.active_item_as::<Editor>(cx).is_some());
+                    let items = workspace.items(cx).collect::<Vec<_>>();
+                    assert_eq!(items.len(), 1, "Workspace should have two items");
+                });
             })
             .unwrap();
     }
@@ -1000,10 +1004,12 @@ mod tests {
         open_workspace_file(path!("/root/file5.txt"), None, app_state.clone(), cx).await;
 
         assert_eq!(cx.windows().len(), 1);
-        let workspace_1 = cx.windows()[0].downcast::<Workspace>().unwrap();
-        workspace_1
-            .update(cx, |workspace, _, cx| {
-                assert!(workspace.active_item_as::<Editor>(cx).is_some())
+        let multi_workspace_1 = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+        multi_workspace_1
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    assert!(workspace.active_item_as::<Editor>(cx).is_some())
+                });
             })
             .unwrap();
 
@@ -1012,10 +1018,12 @@ mod tests {
         open_workspace_file(path!("/root/file6.txt"), Some(false), app_state.clone(), cx).await;
 
         assert_eq!(cx.windows().len(), 1);
-        workspace_1
-            .update(cx, |workspace, _, cx| {
-                let items = workspace.items(cx).collect::<Vec<_>>();
-                assert_eq!(items.len(), 2, "Workspace should have two items");
+        multi_workspace_1
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    let items = workspace.items(cx).collect::<Vec<_>>();
+                    assert_eq!(items.len(), 2, "Workspace should have two items");
+                });
             })
             .unwrap();
 
@@ -1024,11 +1032,13 @@ mod tests {
         open_workspace_file(path!("/root/file7.txt"), Some(true), app_state.clone(), cx).await;
 
         assert_eq!(cx.windows().len(), 2);
-        let workspace_2 = cx.windows()[1].downcast::<Workspace>().unwrap();
-        workspace_2
-            .update(cx, |workspace, _, cx| {
-                let items = workspace.items(cx).collect::<Vec<_>>();
-                assert_eq!(items.len(), 1, "Workspace should have two items");
+        let multi_workspace_2 = cx.windows()[1].downcast::<MultiWorkspace>().unwrap();
+        multi_workspace_2
+            .update(cx, |multi_workspace, _, cx| {
+                multi_workspace.workspace().update(cx, |workspace, cx| {
+                    let items = workspace.items(cx).collect::<Vec<_>>();
+                    assert_eq!(items.len(), 1, "Workspace should have two items");
+                });
             })
             .unwrap();
     }