diff --git a/Cargo.lock b/Cargo.lock
index 812f738b96f7e06a45e7a715a2bfcd61f37b0cd5..714eeb9099f96abe88c6d56a6315c755fab35e50 100644
--- a/Cargo.lock
+++ b/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",
diff --git a/Cargo.toml b/Cargo.toml
index c2f3912c4e61bb17910d1b10d6e6b682c6328fb0..7cb41534a7e331c4976cd4fe51b551b3a740fb77 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",
@@ -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 }
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 535774cbc92dc62e59642a689199589186d1639f..feb27bdcb017af2f8606624a863e8f6db483c41f 100644
--- a/assets/keymaps/default-linux.json
+++ b/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": {
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index 0a40df26168567c62d24dd9cc83ede764edd30f1..74a3d23c7d064b747a559eb6c0fa94430b54c504 100644
--- a/assets/keymaps/default-macos.json
+++ b/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,
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 7b4c4cd7de7d71859079311a2e8513a25ff2ec79..9ed8dc2e85f9c3b924ab6ad8d38723ea8185729d 100644
--- a/assets/keymaps/default-windows.json
+++ b/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,
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/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs
index 7c9966295483d5c0b0b5586b7d020c98db50f25f..faea22c45e32ba53f22367f89d8abe292f6e0753 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);
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index 8bc1cd1647acfb133164f6a2f72e89f62319e868..61c7301fb1e9d9b0fcc2b05144393ffea4248d9b 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;
@@ -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::()
+ .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::()
+ 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);
});
}
@@ -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);
+ ::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| {
+ 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::().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);
@@ -2839,18 +2991,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()),
diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs
index 850822679d2828b96ba6218c4d48e570764d6de6..bb00be46bad837a3b2242e885905709fbe38868c 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
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index bd9c31a983b723c222987544561cea82a97bad2b..ba3b9943713db2e0f26b464da21a26237dc13d6e 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/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 {
+ 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) {
@@ -428,6 +468,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,
@@ -445,18 +486,44 @@ pub struct AgentPanel {
impl AgentPanel {
fn serialize(&mut self, cx: &mut Context) {
+ 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::(&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,
prompt_store: Option>,
@@ -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, cx: &App) -> bool {
+ pub fn is_visible(workspace: &Entity, 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> {
@@ -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> {
+ pub fn active_agent_thread(&self, cx: &App) -> Option> {
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 for AgentPanel {}
+impl EventEmitter 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"
+ );
+ });
+ }
+}
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index e521cb33b120faaa4672a286e895e9e1ce92a5e4..3bfd58ff4bb2defb8e3d7c11a52fed4575f7b747 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};
@@ -418,6 +418,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/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs
index 48c597f0431c480ade5810db99c36a890ec65093..fd6b9bc5028c64f68dbed24dc44f3014376d1aff 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();
}
diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs
index 6dcd3b3c9558e85c0e688b8a1a21300ed10f9b1f..53c6c8f531aa66ee03412418958d91fa9ccd625b 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() {
@@ -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,
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()
@@ -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;
}))
}
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 1612e32833dd07dd5fa2294d5bb5a90442883f71..a973c9f17ec5488746a9ad6594a3e99fb711c203 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};
@@ -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 = 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.
@@ -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::(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 f28d247f67a149ef6d489b9bc6ab7b43eb77350f..0a2ec25cde361259344493d3532afeb2050aea71 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;
@@ -843,7 +843,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();
@@ -897,10 +897,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>(
@@ -908,19 +917,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)
@@ -931,8 +954,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 663d64d56d3e9832a6a92c2916fa62d22afd23e6..c0a68efdc7107800a0abfd5c522e5b0ed541a964 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);
}
@@ -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::() 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)
@@ -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()
})),
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