diff --git a/Cargo.lock b/Cargo.lock
index 6b2e66bff3b6c4306d80fbb0d5bc933323a764e7..1d1712b6f288945a9006a918e9b27052e4ff3fae 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4942,6 +4942,7 @@ dependencies = [
"serde_json",
"settings",
"smol",
+ "theme",
"ui",
"util",
"workspace",
@@ -8481,7 +8482,6 @@ dependencies = [
"fuzzy",
"gpui",
"language",
- "platform_title_bar",
"project",
"serde_json",
"serde_json_lenient",
@@ -12371,6 +12371,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
name = "platform_title_bar"
version = "0.1.0"
dependencies = [
+ "feature_flags",
"gpui",
"settings",
"smallvec",
@@ -15361,6 +15362,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"
@@ -17240,6 +17265,7 @@ dependencies = [
"cloud_api_types",
"collections",
"db",
+ "feature_flags",
"git_ui",
"gpui",
"http_client",
@@ -21127,6 +21153,7 @@ dependencies = [
"settings_profile_selector",
"settings_ui",
"shellexpand 2.1.2",
+ "sidebar",
"smol",
"snippet_provider",
"snippets_ui",
diff --git a/Cargo.toml b/Cargo.toml
index b5397ddf73470a28edfe8ec7867701345ee4449d..ecb469f6d83780db7192a2ac100f4d6993aaba4a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -155,6 +155,7 @@ members = [
"crates/schema_generator",
"crates/search",
"crates/session",
+ "crates/sidebar",
"crates/settings",
"crates/settings_content",
"crates/settings_json",
@@ -396,6 +397,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" }
@@ -855,6 +857,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 }
diff --git a/assets/icons/workspace_nav_closed.svg b/assets/icons/workspace_nav_closed.svg
new file mode 100644
index 0000000000000000000000000000000000000000..ed1fce52d6826a4d10299f331358ff84e4caa973
--- /dev/null
+++ b/assets/icons/workspace_nav_closed.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/workspace_nav_open.svg b/assets/icons/workspace_nav_open.svg
new file mode 100644
index 0000000000000000000000000000000000000000..464b6aac73c2aeaa9463a805aabc4559377bbfd3
--- /dev/null
+++ b/assets/icons/workspace_nav_open.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json
index 4462ac4429a9f24db7da981f4fc9b44c37605302..bf38edfeb85e07280d7ae817ad56067337c0f149 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -603,6 +603,8 @@
"ctrl-alt-b": "workspace::ToggleRightDock",
"ctrl-b": "workspace::ToggleLeftDock",
"ctrl-j": "workspace::ToggleBottomDock",
+ "ctrl-alt-j": "multi_workspace::ToggleWorkspaceSidebar",
+ "ctrl-alt-;": "multi_workspace::FocusWorkspaceSidebar",
"ctrl-alt-y": "workspace::ToggleAllDocks",
"ctrl-alt-0": "workspace::ResetActiveDockSize",
// For 0px parameter, uses UI font size value.
@@ -662,6 +664,13 @@
"ctrl-w": "workspace::CloseActiveDock",
},
},
+ {
+ "context": "WorkspaceSidebar",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-n": "multi_workspace::NewWorkspaceInWindow",
+ },
+ },
{
"context": "Workspace && debugger_running",
"bindings": {
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index 8ca82963523a65cfd483935edd33e1cb00f5cc55..bca1e42d9ceaf96a6da3f6ceaca77ef21cc40ef3 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -664,6 +664,8 @@
"cmd-alt-b": "workspace::ToggleRightDock",
"cmd-r": "workspace::ToggleRightDock",
"cmd-j": "workspace::ToggleBottomDock",
+ "cmd-alt-j": "multi_workspace::ToggleWorkspaceSidebar",
+ "cmd-alt-;": "multi_workspace::FocusWorkspaceSidebar",
"alt-cmd-y": "workspace::ToggleAllDocks",
// For 0px parameter, uses UI font size value.
"ctrl-alt-0": "workspace::ResetActiveDockSize",
@@ -723,6 +725,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,
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 41d4a976b0773ab3ae7b4cdb0b7ce271cf303432..0f117a75688e441de7b4dc98c80bf63a05238c7c 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -598,6 +598,8 @@
"ctrl-alt-b": "workspace::ToggleRightDock",
"ctrl-b": "workspace::ToggleLeftDock",
"ctrl-j": "workspace::ToggleBottomDock",
+ "ctrl-alt-j": "multi_workspace::ToggleWorkspaceSidebar",
+ "ctrl-alt-;": "multi_workspace::FocusWorkspaceSidebar",
"ctrl-shift-y": "workspace::ToggleAllDocks",
"alt-r": "workspace::ResetActiveDockSize",
// For 0px parameter, uses UI font size value.
@@ -666,6 +668,13 @@
"f5": "debugger::Continue",
},
},
+ {
+ "context": "WorkspaceSidebar",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-n": "multi_workspace::NewWorkspaceInWindow",
+ },
+ },
{
"context": "ApplicationMenu",
"use_key_equivalents": true,
diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs
index 904c9a6c7b7e383d09b54f58115be2303ef8754a..f76e64b557e7ee2ec6054bd0fab0afc36b201e2c 100644
--- a/crates/agent_ui/src/acp.rs
+++ b/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;
diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs
index 7db45461d0db7ec994b7a63810d25f79c2f98560..353e1168c8a685bd1822ebe83e7ea2d52733a728 100644
--- a/crates/agent_ui/src/acp/entry_view_state.rs
+++ b/crates/agent_ui/src/acp/entry_view_state.rs
@@ -419,7 +419,7 @@ mod tests {
use serde_json::json;
use settings::SettingsStore;
use util::path;
- use workspace::Workspace;
+ use workspace::MultiWorkspace;
#[gpui::test]
async fn test_diff_sync(cx: &mut TestAppContext) {
@@ -434,8 +434,9 @@ mod tests {
.await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let tool_call = acp::ToolCall::new("tool", "Tool call")
.status(acp::ToolCallStatus::InProgress)
diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs
index 7c9966295483d5c0b0b5586b7d020c98db50f25f..af636dfa74949fb4e8095a553607ae6741102294 100644
--- a/crates/agent_ui/src/acp/message_editor.rs
+++ b/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);
@@ -1450,7 +1463,7 @@ mod tests {
use text::Point;
use ui::{App, Context, IntoElement, Render, SharedString, Window};
use util::{path, paths::PathStyle, rel_path::rel_path};
- use workspace::{AppState, Item, Workspace};
+ use workspace::{AppState, Item, MultiWorkspace};
use crate::acp::{
message_editor::{Mention, MessageEditor, parse_mention_links},
@@ -1558,8 +1571,9 @@ mod tests {
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
let history = cx
@@ -1673,8 +1687,9 @@ mod tests {
// Start with no available commands - simulating Claude which doesn't support slash commands
let available_commands = Rc::new(RefCell::new(vec![]));
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let history = cx
.update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx)));
let workspace_handle = workspace.downgrade();
@@ -1822,10 +1837,13 @@ mod tests {
});
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
- let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
- let workspace = window.root(cx).unwrap();
+ let window =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
- let mut cx = VisualTestContext::from_window(*window, cx);
+ let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = None;
let history = cx
@@ -2014,8 +2032,11 @@ mod tests {
.await;
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
- let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
- let workspace = window.root(cx).unwrap();
+ let window =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
let worktree = project.update(cx, |project, cx| {
let mut worktrees = project.worktrees(cx).collect::>();
@@ -2024,7 +2045,7 @@ mod tests {
});
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
- let mut cx = VisualTestContext::from_window(*window, cx);
+ let mut cx = VisualTestContext::from_window(window.into(), cx);
let paths = vec![
rel_path("a/one.txt"),
@@ -2551,8 +2572,9 @@ mod tests {
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
let history = cx
@@ -2651,8 +2673,9 @@ mod tests {
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
let history = cx
@@ -2732,8 +2755,9 @@ mod tests {
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
let history = cx
@@ -2791,8 +2815,9 @@ mod tests {
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = None;
let history = cx
@@ -2845,8 +2870,9 @@ mod tests {
fs.insert_tree("/project", json!({"file": ""})).await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
let history = cx
@@ -2900,8 +2926,9 @@ mod tests {
.await;
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
let history = cx
@@ -2964,8 +2991,9 @@ mod tests {
let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = Some(cx.new(|cx| ThreadStore::new(cx)));
let history = cx
@@ -3085,8 +3113,11 @@ mod tests {
.await;
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
- let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
- let workspace = window.root(cx).unwrap();
+ let window =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
let worktree = project.update(cx, |project, cx| {
let mut worktrees = project.worktrees(cx).collect::>();
@@ -3095,7 +3126,7 @@ mod tests {
});
let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
- let mut cx = VisualTestContext::from_window(*window, cx);
+ let mut cx = VisualTestContext::from_window(window.into(), cx);
// Open a regular editor with the created file, and select a portion of
// the text that will be used for the selections that are meant to be
@@ -3237,10 +3268,13 @@ mod tests {
.await;
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
- let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
- let workspace = window.root(cx).unwrap();
+ let window =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
- let mut cx = VisualTestContext::from_window(*window, cx);
+ let mut cx = VisualTestContext::from_window(window.into(), cx);
let thread_store = cx.new(|cx| ThreadStore::new(cx));
let history = cx
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index 94fbff72f780ab5f4a1fa00d53a1b068c8505247..dd15ab75113835bc345c8c071382c22fa8d88ba4 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/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;
@@ -2161,9 +2163,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::()
+ .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);
}
}
@@ -2181,14 +2204,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;
@@ -2251,19 +2267,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::()
+ 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::(window, cx);
});
}
@@ -2288,12 +2307,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);
});
}
})
@@ -2545,6 +2564,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};
@@ -2556,7 +2576,9 @@ pub(crate) mod tests {
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::Arc;
- use workspace::Item;
+ use workspace::{Item, MultiWorkspace};
+
+ use crate::agent_panel;
use super::*;
@@ -2628,8 +2650,9 @@ pub(crate) mod tests {
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx)));
// Create history without an initial session list - it will be set after connection
@@ -2700,8 +2723,9 @@ pub(crate) mod tests {
let session = AgentSessionInfo::new(SessionId::new("resume-session"));
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
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)));
@@ -2747,8 +2771,9 @@ pub(crate) mod tests {
)
.await;
let project = Project::test(fs, [Path::new("/project")], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let connection = CwdCapturingConnection::new();
let captured_cwd = connection.captured_cwd.clone();
@@ -2798,8 +2823,9 @@ pub(crate) mod tests {
)
.await;
let project = Project::test(fs, [Path::new("/project")], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let connection = CwdCapturingConnection::new();
let captured_cwd = connection.captured_cwd.clone();
@@ -2849,8 +2875,9 @@ pub(crate) mod tests {
)
.await;
let project = Project::test(fs, [Path::new("/project")], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let connection = CwdCapturingConnection::new();
let captured_cwd = connection.captured_cwd.clone();
@@ -3011,6 +3038,137 @@ 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);
+ ::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::(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| {
+ mw.test_add_workspace(project2, window, 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::().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::())
+ .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);
@@ -3103,8 +3261,9 @@ pub(crate) mod tests {
) -> (Entity, &mut VisualTestContext) {
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
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)));
@@ -3173,18 +3332,18 @@ pub(crate) mod tests {
}
}
- struct StubAgentServer {
+ pub(crate) struct StubAgentServer {
connection: C,
}
impl StubAgentServer {
- fn new(connection: C) -> Self {
+ pub(crate) fn new(connection: C) -> Self {
Self { connection }
}
}
impl StubAgentServer {
- 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()),
@@ -3580,6 +3739,7 @@ pub(crate) mod tests {
cx.set_global(settings_store);
theme::init(theme::LoadThemes::JustBase, cx);
editor::init(cx);
+ agent_panel::init(cx);
release_channel::init(semver::Version::new(0, 0, 0), cx);
prompt_store::init(cx)
});
@@ -3614,8 +3774,9 @@ pub(crate) mod tests {
)
.await;
let project = Project::test(fs, [Path::new("/project")], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
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)));
diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs
index 09f993577ad6ec9ce27a664cfae5adaaa093c1ff..f3e2d9f8ee361b83ae379d9d1c55a98a0eaace78 100644
--- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs
+++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs
@@ -599,6 +599,7 @@ mod tests {
use project::Project;
use settings::SettingsStore;
use util::path;
+ use workspace::MultiWorkspace;
#[gpui::test]
async fn test_save_provider_invalid_inputs(cx: &mut TestAppContext) {
@@ -815,8 +816,9 @@ mod tests {
let fs = FakeFs::new(cx.executor());
cx.update(|cx| ::set_global(fs.clone(), cx));
let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
- let (_, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
cx
}
diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs
index efaa670720283054ee1d81f0691ce2e31cfc236c..841121cfa347c0e8b67bf378da76abe1fb47ac39 100644
--- a/crates/agent_ui/src/agent_diff.rs
+++ b/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
@@ -1734,6 +1734,7 @@ mod tests {
use settings::SettingsStore;
use std::{path::Path, rc::Rc};
use util::path;
+ use workspace::MultiWorkspace;
#[gpui::test]
async fn test_multibuffer_agent_diff(cx: &mut TestAppContext) {
@@ -1770,8 +1771,9 @@ mod tests {
let action_log = cx.read(|cx| thread.read(cx).action_log().clone());
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let agent_diff = cx.new_window_entity(|window, cx| {
AgentDiffPane::new(thread.clone(), workspace.downgrade(), window, cx)
});
@@ -1929,8 +1931,9 @@ mod tests {
})
.unwrap();
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
// Add the diff toolbar to the active pane
let diff_toolbar = cx.new_window_entity(|_, cx| AgentDiffToolbar::new(cx));
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index ccfc0cd7073b08249a9bdc07cf3525f92e689e9a..c57f156db693c5c24a4428994f7db7f32cb351e1 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -67,6 +67,7 @@ use ui::{
use util::ResultExt as _;
use workspace::{
CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace,
+ WorkspaceId,
dock::{DockPosition, Panel, PanelEvent},
};
use zed_actions::{
@@ -81,10 +82,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 {
+ 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::(&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 {
+ KEY_VALUE_STORE
+ .read_kvp(AGENT_PANEL_KEY)
+ .log_err()
+ .flatten()
+ .and_then(|json| serde_json::from_str::(&json).log_err())
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
struct SerializedAgentPanel {
width: Option,
selected_agent: Option,
+ #[serde(default)]
+ last_active_thread: Option,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+struct SerializedActiveThread {
+ session_id: String,
+ agent_type: AgentType,
+ title: Option,
+ cwd: Option,
}
pub fn init(cx: &mut App) {
@@ -128,7 +169,9 @@ pub fn init(cx: &mut App) {
.register_action(|workspace, _: &NewTextThread, window, cx| {
if let Some(panel) = workspace.panel::(cx) {
workspace.focus_panel::(window, cx);
- panel.update(cx, |panel, cx| panel.new_text_thread(window, cx));
+ panel.update(cx, |panel, cx| {
+ panel.new_text_thread(window, cx);
+ });
}
})
.register_action(|workspace, action: &NewExternalAgentThread, window, cx| {
@@ -413,6 +456,8 @@ impl ActiveView {
pub struct AgentPanel {
workspace: WeakEntity,
+ /// Workspace id is used as a database key
+ workspace_id: Option,
user_store: Entity,
project: Entity,
fs: Arc,
@@ -428,6 +473,7 @@ pub struct AgentPanel {
focus_handle: FocusHandle,
active_view: ActiveView,
previous_view: Option,
+ _active_view_observation: Option,
new_thread_menu_handle: PopoverMenuHandle,
agent_panel_menu_handle: PopoverMenuHandle,
agent_navigation_menu_handle: PopoverMenuHandle,
@@ -444,19 +490,39 @@ pub struct AgentPanel {
}
impl AgentPanel {
- fn serialize(&mut self, cx: &mut Context) {
+ fn serialize(&mut self, cx: &mut App) {
+ let Some(workspace_id) = self.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 +538,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::(&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 +568,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 +599,7 @@ impl AgentPanel {
})
}
- fn new(
+ pub(crate) fn new(
workspace: &Workspace,
text_thread_store: Entity,
prompt_store: Option>,
@@ -528,6 +611,7 @@ impl AgentPanel {
let project = workspace.project();
let language_registry = project.read(cx).languages().clone();
let client = workspace.client().clone();
+ let workspace_id = workspace.database_id();
let workspace = workspace.weak_handle();
let context_server_registry =
@@ -633,6 +717,7 @@ impl AgentPanel {
};
let mut panel = Self {
+ workspace_id,
active_view,
workspace,
user_store,
@@ -646,6 +731,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 +800,7 @@ impl AgentPanel {
&self.context_server_registry
}
- pub fn is_hidden(workspace: &Entity, cx: &App) -> bool {
+ pub fn is_visible(workspace: &Entity, cx: &App) -> bool {
let workspace_read = workspace.read(cx);
workspace_read
@@ -722,15 +808,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> {
@@ -1023,6 +1107,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 +1504,7 @@ impl AgentPanel {
}
}
- pub(crate) fn active_agent_thread(&self, cx: &App) -> Option> {
+ pub fn active_agent_thread(&self, cx: &App) -> Option> {
match &self.active_view {
ActiveView::AgentThread { thread_view, .. } => thread_view
.read(cx)
@@ -1475,9 +1560,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 +1847,12 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition {
AgentSettings::get_global(cx).dock.into()
}
+pub enum AgentPanelEvent {
+ ActiveViewChanged,
+}
+
impl EventEmitter for AgentPanel {}
+impl EventEmitter for AgentPanel {}
impl Panel for AgentPanel {
fn persistent_name() -> &'static str {
@@ -3251,7 +3353,8 @@ impl Dismissable for TrialEndUpsell {
const KEY: &'static str = "dismissed-trial-end-upsell";
}
-#[cfg(feature = "test-support")]
+/// Test-only helper methods
+#[cfg(any(test, feature = "test-support"))]
impl AgentPanel {
/// Opens an external thread using an arbitrary AgentServer.
///
@@ -3284,3 +3387,196 @@ 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;
+
+ #[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| {
+ multi_workspace.test_add_workspace(project_b.clone(), window, cx)
+ })
+ .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"
+ );
+ });
+ }
+
+ // Simple regression test
+ #[gpui::test]
+ async fn test_new_text_thread_action_handler(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ 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);
+ let slash_command_registry =
+ assistant_slash_command::SlashCommandRegistry::default_global(cx);
+ slash_command_registry
+ .register_command(assistant_slash_commands::DefaultSlashCommand, false);
+ ::set_global(fs.clone(), cx);
+ });
+
+ let project = Project::test(fs.clone(), [], cx).await;
+
+ let multi_workspace =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+
+ let workspace_a = multi_workspace
+ .read_with(cx, |multi_workspace, _cx| {
+ multi_workspace.workspace().clone()
+ })
+ .unwrap();
+
+ let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
+
+ workspace_a.update_in(cx, |workspace, window, cx| {
+ let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
+ let panel =
+ cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx));
+ workspace.add_panel(panel, window, cx);
+ });
+
+ cx.run_until_parked();
+
+ workspace_a.update_in(cx, |_, window, cx| {
+ window.dispatch_action(NewTextThread.boxed_clone(), cx);
+ });
+
+ cx.run_until_parked();
+ }
+}
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index aca99810b259107fd3be5bcfc05064ff6158a3c3..d7f003e95b7e3c286b45e3e5272463a32ac1a9b2 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/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};
@@ -422,6 +422,12 @@ fn update_command_palette_filter(cx: &mut App) {
filter.hide_action_types(&[TypeId::of::()]);
}
}
+
+ if agent_v2_enabled {
+ filter.show_namespace("multi_workspace");
+ } else {
+ filter.hide_namespace("multi_workspace");
+ }
});
}
diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs
index faa65768b04c75a89c2490b45e58a335fa993a21..b858db698cff07d0d488d92b09a604f65d63e58a 100644
--- a/crates/agent_ui/src/completion_provider.rs
+++ b/crates/agent_ui/src/completion_provider.rs
@@ -2354,7 +2354,7 @@ mod tests {
use project::Project;
use serde_json::json;
use util::{path, rel_path::rel_path};
- use workspace::AppState;
+ use workspace::{AppState, MultiWorkspace};
let app_state = cx.update(|cx| {
let state = AppState::test(cx);
@@ -2379,8 +2379,9 @@ mod tests {
.await;
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| workspace::Workspace::test_new(project, window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let worktree_id = cx.read(|cx| {
let worktrees = workspace.read(cx).worktrees(cx).collect::>();
diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs
index 48c597f0431c480ade5810db99c36a890ec65093..2066a7ad886614373b200f4e45dd3bb0034f72a2 100644
--- a/crates/agent_ui/src/inline_prompt_editor.rs
+++ b/crates/agent_ui/src/inline_prompt_editor.rs
@@ -417,8 +417,13 @@ impl PromptEditor {
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) {
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();
}
@@ -438,7 +443,7 @@ impl PromptEditor {
self.mention_set
.update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
- if let Some(workspace) = window.root::().flatten() {
+ if let Some(workspace) = Workspace::for_window(window, cx) {
workspace.update(cx, |workspace, cx| {
let is_via_ssh = workspace.project().read(cx).is_via_remote_server();
diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs
index ee796323e28c64fb4162bbb05f6f6f9555a12d38..707e7b45343363b9db440998190e319df1da5b80 100644
--- a/crates/agent_ui/src/mention_set.rs
+++ b/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,
editor: Entity,
mention_set: Entity,
+ workspace: WeakEntity,
cx: &mut gpui::AsyncWindowContext,
) {
if images.is_empty() {
@@ -718,7 +720,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);
});
@@ -732,11 +738,12 @@ pub(crate) async fn insert_images_as_context(
pub(crate) fn paste_images_as_context(
editor: Entity,
mention_set: Entity,
+ workspace: WeakEntity,
window: &mut Window,
cx: &mut App,
) -> Option> {
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()
@@ -783,7 +790,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;
}))
}
diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs
index 447449fe72fee89b0c6775bbbcf8836141efb2b9..2d4ada96e9fa6107b9f77c55b03948e4a00f1013 100644
--- a/crates/agent_ui/src/text_thread_editor.rs
+++ b/crates/agent_ui/src/text_thread_editor.rs
@@ -3168,6 +3168,7 @@ mod tests {
use text::OffsetRangeExt;
use unindent::Unindent;
use util::path;
+ use workspace::MultiWorkspace;
#[gpui::test]
async fn test_copy_paste_whole_message(cx: &mut TestAppContext) {
@@ -3337,25 +3338,27 @@ mod tests {
let text_thread = create_text_thread_with_messages(messages, cx);
let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
- let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
- let workspace = window.root(cx).unwrap();
- let mut cx = VisualTestContext::from_window(*window, cx);
-
- let text_thread_editor = window
- .update(&mut cx, |_, window, cx| {
- cx.new(|cx| {
- TextThreadEditor::for_text_thread(
- text_thread.clone(),
- fs,
- workspace.downgrade(),
- project,
- None,
- window,
- cx,
- )
- })
- })
+ let window_handle =
+ cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = window_handle
+ .read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
+ let mut cx = VisualTestContext::from_window(window_handle.into(), cx);
+
+ let weak_workspace = workspace.downgrade();
+ let text_thread_editor = workspace.update_in(&mut cx, |_, window, cx| {
+ cx.new(|cx| {
+ TextThreadEditor::for_text_thread(
+ text_thread.clone(),
+ fs,
+ weak_workspace,
+ project,
+ None,
+ window,
+ cx,
+ )
+ })
+ });
(text_thread, text_thread_editor, cx)
}
diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs
index 34ca0bb32a82aa23d1b954554ce2dfec436bfe1c..371523f129869786f13d1a220747f4d0d944d1e5 100644
--- a/crates/agent_ui/src/ui/agent_notification.rs
+++ b/crates/agent_ui/src/ui/agent_notification.rs
@@ -75,6 +75,16 @@ pub enum AgentNotificationEvent {
impl EventEmitter for AgentNotification {}
+impl AgentNotification {
+ pub fn accept(&mut self, cx: &mut Context) {
+ cx.emit(AgentNotificationEvent::Accepted);
+ }
+
+ pub fn dismiss(&mut self, cx: &mut Context) {
+ cx.emit(AgentNotificationEvent::Dismissed);
+ }
+}
+
impl Render for AgentNotification {
fn render(&mut self, window: &mut Window, cx: &mut Context) -> 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);
})
})),
)
diff --git a/crates/collab/tests/integration/channel_guest_tests.rs b/crates/collab/tests/integration/channel_guest_tests.rs
index 0d98af2a188ce18cfab5905e5b464c77101dfa00..85d69914a832c65260014f5f5792eb664879f715 100644
--- a/crates/collab/tests/integration/channel_guest_tests.rs
+++ b/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
diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs
index 34bed0086b9af8bc2ed39580f4ecda2c6c609338..596d857729da4c7d37881567f5e90013e403d5ca 100644
--- a/crates/collab/tests/integration/editor_tests.rs
+++ b/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};
@@ -52,7 +53,7 @@ use std::{
use text::Point;
use util::{path, rel_path::rel_path, uri};
use workspace::item::Item as _;
-use workspace::{CloseIntent, Workspace};
+use workspace::{CloseIntent, MultiWorkspace, Workspace};
#[gpui::test(iterations = 10)]
async fn test_host_disconnect(
@@ -96,34 +97,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 = 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::()
.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.
@@ -141,19 +154,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::(cx).is_some());
- assert!(!workspace.is_edited());
- })
- .unwrap();
+ workspace_b.update(cx_b, |workspace, cx| {
+ assert!(workspace.active_modal::(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);
diff --git a/crates/collab/tests/integration/following_tests.rs b/crates/collab/tests/integration/following_tests.rs
index 295105ecbd9f8663469276fe4d0d197708a4254e..6bdb06a6c5a0ffb95bc75a026a26c4797030f8ce 100644
--- a/crates/collab/tests/integration/following_tests.rs
+++ b/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::()
+ .downcast::()
.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::()
+ .downcast::()
.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
}
diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs
index 1378fcf95c63c883ee8dd424dc10ac67ccd774bd..63cee5886d5096cb0e3fbee3886b90f66c675bfa 100644
--- a/crates/collab/tests/integration/git_tests.rs
+++ b/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
diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
index 1f4dd0d353234f61675b5beefd2226c3d684c062..c6daedff803b6f5cada32750f90dd1adca5aeda6 100644
--- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
+++ b/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::()
+ .downcast::()
.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::()
+ .downcast::()
.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,
diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs
index a731a8ae1d50234f06806c8aba036abc455d223c..d822a087d96fdc119cc700f2f0e8f79d16b95acf 100644
--- a/crates/collab/tests/integration/test_server.rs
+++ b/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;
@@ -827,7 +827,7 @@ impl TestClient {
channel_id: ChannelId,
cx: &'a mut TestAppContext,
) -> (Entity, &'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();
@@ -881,10 +881,19 @@ impl TestClient {
project: &Entity,
cx: &'a mut TestAppContext,
) -> (Entity, &'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>(
@@ -892,19 +901,33 @@ impl TestClient {
cx: &'a mut TestAppContext,
) -> (Entity, &'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, &'a mut VisualTestContext) {
- let window = cx.update(|cx| cx.active_window().unwrap().downcast::().unwrap());
+ let window = cx.update(|cx| {
+ cx.active_window()
+ .unwrap()
+ .downcast::()
+ .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)
@@ -915,8 +938,15 @@ pub fn open_channel_notes(
channel_id: ChannelId,
cx: &mut VisualTestContext,
) -> Task>> {
- let window = cx.update(|_, cx| cx.active_window().unwrap().downcast::().unwrap());
- let entity = window.root(cx).unwrap();
+ let window = cx.update(|_, cx| {
+ cx.active_window()
+ .unwrap()
+ .downcast::()
+ .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))
}
diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs
index 60262951ef916183bdaf72df90ab39f2edd83f27..54bf5f3d22cf756db085b9ef81f30bc7465c1db5 100644
--- a/crates/collab_ui/src/collab_panel.rs
+++ b/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);
}
@@ -2189,12 +2191,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();
}
@@ -2223,12 +2226,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(())
})
@@ -2279,13 +2283,15 @@ impl CollabPanel {
let Some(workspace) = self.workspace.upgrade() else {
return;
};
- let Some(handle) = window.window_handle().downcast::() else {
+
+ let Some(handle) = window.window_handle().downcast::() 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)
@@ -2328,12 +2334,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()
})),
diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs
index dae7427f9f132cd8f1021ed9d99dd1b17a729a3b..a6fc0193a4b18407c2f4473a0fbea471d91eb9a9 100644
--- a/crates/command_palette/src/command_palette.rs
+++ b/crates/command_palette/src/command_palette.rs
@@ -723,7 +723,7 @@ mod tests {
use language::Point;
use project::Project;
use settings::KeymapFile;
- use workspace::{AppState, Workspace};
+ use workspace::{AppState, MultiWorkspace, Workspace};
#[test]
fn test_humanize_action_name() {
@@ -777,8 +777,9 @@ mod tests {
.unwrap();
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let editor = cx.new_window_entity(|window, cx| {
let mut editor = Editor::single_line(window, cx);
@@ -848,8 +849,9 @@ mod tests {
async fn test_normalized_matches(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let editor = cx.new_window_entity(|window, cx| {
let mut editor = Editor::single_line(window, cx);
@@ -884,8 +886,9 @@ mod tests {
async fn test_go_to_line(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
cx.simulate_keystrokes("cmd-n");
@@ -974,8 +977,9 @@ mod tests {
async fn test_history_navigation_basic(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let palette = open_palette_with_history(&workspace, &["backspace", "select all"], cx);
@@ -1017,8 +1021,9 @@ mod tests {
async fn test_history_mode_exit_on_typing(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let palette = open_palette_with_history(&workspace, &["backspace"], cx);
@@ -1041,8 +1046,9 @@ mod tests {
async fn test_history_navigation_with_suggestions(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let palette = open_palette_with_history(&workspace, &["editor: close", "editor: open"], cx);
@@ -1083,8 +1089,9 @@ mod tests {
async fn test_history_prefix_search(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let palette = open_palette_with_history(
&workspace,
@@ -1136,8 +1143,9 @@ mod tests {
async fn test_history_prefix_search_no_matches(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let palette =
open_palette_with_history(&workspace, &["open file", "backspace", "select all"], cx);
@@ -1158,8 +1166,9 @@ mod tests {
async fn test_history_empty_prefix_searches_all(cx: &mut TestAppContext) {
let app_state = init_test(cx);
let project = Project::test(app_state.fs.clone(), [], cx).await;
- let (workspace, cx) =
- cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let (multi_workspace, cx) =
+ cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let palette = open_palette_with_history(&workspace, &["alpha", "beta", "gamma"], cx);
diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs
index dd48f95e0af6daeaf2a0a15b7b9595cb4c08aba2..24b1218305474a29ac2d2e7c8e0a212d6d757522 100644
--- a/crates/copilot_ui/src/sign_in.rs
+++ b/crates/copilot_ui/src/sign_in.rs
@@ -35,7 +35,7 @@ pub fn initiate_sign_out(copilot: Entity, window: &mut Window, cx: &mut
cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx))
}
Err(err) => cx.update(|window, cx| {
- if let Some(workspace) = window.root::().flatten() {
+ if let Some(workspace) = Workspace::for_window(window, cx) {
workspace.update(cx, |workspace, cx| {
workspace.show_error(&err, cx);
})
@@ -82,7 +82,7 @@ fn open_copilot_code_verification_window(copilot: &Entity, window: &Win
fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) {
const NOTIFICATION_ID: NotificationId = NotificationId::unique::();
- let Some(workspace) = window.root::().flatten() else {
+ let Some(workspace) = Workspace::for_window(window, cx) else {
return;
};
diff --git a/crates/db/src/kvp.rs b/crates/db/src/kvp.rs
index 8ea877b35bfaf57bb258e7e179fa5b71f2b518ea..438adcdf44921aa1d2590694608c139e9174d788 100644
--- a/crates/db/src/kvp.rs
+++ b/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