diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5e57f92661e97f17b34bb57441670196f6feec88..01445c58b84c728e6a5d2efcb6679c1b70ada199 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -137,7 +137,7 @@ jobs:
run: cargo nextest run --workspace --no-fail-fast
shell: pwsh
- name: steps::show_sccache_stats
- run: sccache --show-stats; exit 0
+ run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0
shell: pwsh
- name: steps::cleanup_cargo_config
if: always()
@@ -234,7 +234,7 @@ jobs:
run: ./script/clippy.ps1
shell: pwsh
- name: steps::show_sccache_stats
- run: sccache --show-stats; exit 0
+ run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0
shell: pwsh
timeout-minutes: 60
check_scripts:
diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml
index 1cdc1d38b18fc1996e0c0339b545f4beee137b58..d6969a34c53c3b770fc0c60618469149f555cdb2 100644
--- a/.github/workflows/release_nightly.yml
+++ b/.github/workflows/release_nightly.yml
@@ -57,7 +57,7 @@ jobs:
run: cargo nextest run --workspace --no-fail-fast
shell: pwsh
- name: steps::show_sccache_stats
- run: sccache --show-stats; exit 0
+ run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0
shell: pwsh
- name: steps::cleanup_cargo_config
if: always()
@@ -90,7 +90,7 @@ jobs:
run: ./script/clippy.ps1
shell: pwsh
- name: steps::show_sccache_stats
- run: sccache --show-stats; exit 0
+ run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0
shell: pwsh
timeout-minutes: 60
bundle_linux_aarch64:
diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml
index 66bc7f4356a7093089c6a65a8c118f53ce1dd292..d4f2fd6fb044fdf4c71d65449fede615034fabeb 100644
--- a/.github/workflows/run_tests.yml
+++ b/.github/workflows/run_tests.yml
@@ -166,7 +166,7 @@ jobs:
run: ./script/clippy.ps1
shell: pwsh
- name: steps::show_sccache_stats
- run: sccache --show-stats; exit 0
+ run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0
shell: pwsh
timeout-minutes: 60
clippy_linux:
@@ -271,7 +271,7 @@ jobs:
run: cargo nextest run --workspace --no-fail-fast${{ needs.orchestrate.outputs.changed_packages && format(' -E "{0}"', needs.orchestrate.outputs.changed_packages) || '' }}
shell: pwsh
- name: steps::show_sccache_stats
- run: sccache --show-stats; exit 0
+ run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0
shell: pwsh
- name: steps::cleanup_cargo_config
if: always()
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 87ebd23f428ea66e96d0a3cdac2eb3b4db0ff0f5..740b33dd55790bd3cabfc75146d71854eca6375d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -63,6 +63,60 @@ If you need help deciding how to fix a bug, or finish implementing a feature
that we've agreed we want, please open a PR early so we can discuss how to make
the change with code in hand.
+### UI/UX checklist
+
+When your changes affect UI, consult this checklist:
+
+**Accessibility / Ergonomics**
+- Do all keyboard shortcuts work as intended?
+- Are shortcuts discoverable (tooltips, menus, docs)?
+- Do all mouse actions work (drag, context menus, resizing, scrolling)?
+- Does the feature look great in light mode and dark mode?
+- Are hover states, focus rings, and active states clear and consistent?
+- Is it usable without a mouse (keyboard-only navigation)?
+
+**Responsiveness**
+- Does the UI scale gracefully on:
+ - Narrow panes (e.g., side-by-side split views)?
+ - Short panes (e.g., laptops with 13" displays)?
+ - High-DPI / Retina displays?
+- Does resizing panes or windows keep the UI usable and attractive?
+- Do dialogs or modals stay centered and within viewport bounds?
+
+**Platform Consistency**
+- Is the feature fully usable on Windows, Linux, and Mac?
+- Does it respect system-level settings (fonts, scaling, input methods)?
+
+**Performance**
+- All user interactions must have instant feedback.
+ - If the user requests something slow (e.g. an LLM generation) there should be some indication of the work in progress.
+- Does it handle large files, big projects, or heavy workloads without degrading?
+- Frames must take no more than 8ms (120fps)
+
+**Consistency**
+- Does it match Zed’s design language (spacing, typography, icons)?
+- Are terminology, labels, and tone consistent with the rest of Zed?
+- Are interactions consistent (e.g., how tabs close, how modals dismiss, how errors show)?
+
+**Internationalization & Text**
+- Are strings concise, clear, and unambiguous?
+- Do we avoid internal Zed jargon that only insiders would know?
+
+**User Paths & Edge Cases**
+- What does the happy path look like?
+- What does the unhappy path look like? (errors, rejections, invalid states)
+- How does it work in offline vs. online states?
+- How does it work in unauthenticated vs. authenticated states?
+- How does it behave if data is missing, corrupted, or delayed?
+- Are error messages actionable and consistent with Zed’s voice?
+
+**Discoverability & Learning**
+- Can a first-time user figure it out without docs?
+- Is there an intuitive way to undo/redo actions?
+- Are power features discoverable but not intrusive?
+- Is there a path from beginner → expert usage (progressive disclosure)?
+
+
## Things we will (probably) not merge
Although there are few hard and fast rules, typically we don't merge:
diff --git a/Cargo.lock b/Cargo.lock
index 720dadc02255f01c6dd7691bb22843db9485dc03..0eda119e6bafe7017516c254d270d7a26d533f65 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -572,9 +572,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
-version = "0.9.1"
+version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
+checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
dependencies = [
"alsa-sys",
"bitflags 2.10.0",
@@ -1319,6 +1319,7 @@ dependencies = [
"anyhow",
"async-tar",
"collections",
+ "cpal",
"crossbeam",
"denoise",
"gpui",
@@ -3996,9 +3997,9 @@ dependencies = [
[[package]]
name = "cpal"
-version = "0.16.0"
+version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f"
+checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
dependencies = [
"alsa",
"coreaudio-rs 0.13.0",
@@ -4006,18 +4007,22 @@ dependencies = [
"jni",
"js-sys",
"libc",
- "mach2 0.4.3",
+ "mach2 0.5.0",
"ndk",
"ndk-context",
"num-derive",
"num-traits",
+ "objc2",
"objc2-audio-toolbox",
+ "objc2-avf-audio",
"objc2-core-audio",
"objc2-core-audio-types",
+ "objc2-core-foundation",
+ "objc2-foundation",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
- "windows 0.54.0",
+ "windows 0.61.3",
]
[[package]]
@@ -4942,6 +4947,7 @@ dependencies = [
"serde_json",
"settings",
"smol",
+ "theme",
"ui",
"util",
"workspace",
@@ -8481,7 +8487,6 @@ dependencies = [
"fuzzy",
"gpui",
"language",
- "platform_title_bar",
"project",
"serde_json",
"serde_json_lenient",
@@ -10938,16 +10943,27 @@ dependencies = [
"objc2-foundation",
]
+[[package]]
+name = "objc2-avf-audio"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfc1d11521c211a7ebe17739fc806719da41f56c6b3f949d9861b459188ce910"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
[[package]]
name = "objc2-core-audio"
-version = "0.3.2"
+version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
+checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82"
dependencies = [
"dispatch2",
"objc2",
"objc2-core-audio-types",
"objc2-core-foundation",
+ "objc2-foundation",
]
[[package]]
@@ -10967,7 +10983,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.10.0",
+ "block2",
"dispatch2",
+ "libc",
"objc2",
]
@@ -10984,6 +11002,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
dependencies = [
"bitflags 2.10.0",
+ "block2",
+ "libc",
"objc2",
"objc2-core-foundation",
]
@@ -11370,6 +11390,7 @@ dependencies = [
"gpui",
"indoc",
"language",
+ "lsp",
"menu",
"ordered-float 2.10.1",
"picker",
@@ -11401,6 +11422,7 @@ dependencies = [
"itertools 0.14.0",
"language",
"log",
+ "lsp",
"menu",
"outline",
"pretty_assertions",
@@ -12369,6 +12391,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
name = "platform_title_bar"
version = "0.1.0"
dependencies = [
+ "feature_flags",
"gpui",
"settings",
"smallvec",
@@ -14100,12 +14123,14 @@ dependencies = [
[[package]]
name = "rodio"
version = "0.21.1"
-source = "git+https://github.com/RustAudio/rodio?rev=e2074c6c2acf07b57cf717e076bdda7a9ac6e70b#e2074c6c2acf07b57cf717e076bdda7a9ac6e70b"
+source = "git+https://github.com/RustAudio/rodio?rev=e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a#e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a"
dependencies = [
"cpal",
"dasp_sample",
"hound",
"num-rational",
+ "rand 0.9.2",
+ "rand_distr",
"rtrb",
"symphonia",
"thiserror 2.0.17",
@@ -15230,12 +15255,14 @@ dependencies = [
"agent_settings",
"anyhow",
"assets",
+ "audio",
"bm25",
"client",
"codestral",
"component",
"copilot",
"copilot_ui",
+ "cpal",
"edit_prediction",
"edit_prediction_ui",
"editor",
@@ -15257,6 +15284,7 @@ dependencies = [
"recent_projects",
"regex",
"release_channel",
+ "rodio",
"schemars",
"search",
"serde",
@@ -15359,6 +15387,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"
@@ -17238,10 +17290,10 @@ dependencies = [
"cloud_api_types",
"collections",
"db",
+ "feature_flags",
"git_ui",
"gpui",
"http_client",
- "menu",
"notifications",
"platform_title_bar",
"pretty_assertions",
@@ -19621,16 +19673,6 @@ dependencies = [
"wasmtime-environ",
]
-[[package]]
-name = "windows"
-version = "0.54.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
-dependencies = [
- "windows-core 0.54.0",
- "windows-targets 0.52.6",
-]
-
[[package]]
name = "windows"
version = "0.57.0"
@@ -19687,16 +19729,6 @@ dependencies = [
"windows-core 0.61.2",
]
-[[package]]
-name = "windows-core"
-version = "0.54.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
-dependencies = [
- "windows-result 0.1.2",
- "windows-targets 0.52.6",
-]
-
[[package]]
name = "windows-core"
version = "0.57.0"
@@ -21002,7 +21034,7 @@ dependencies = [
[[package]]
name = "zed"
-version = "0.224.0"
+version = "0.225.0"
dependencies = [
"acp_thread",
"acp_tools",
@@ -21125,6 +21157,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..3ae1b149b3e0f26bf6ed91ae4cda8482ff1bea58 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",
@@ -389,13 +390,14 @@ remote_connection = { path = "crates/remote_connection" }
remote_server = { path = "crates/remote_server" }
repl = { path = "crates/repl" }
reqwest_client = { path = "crates/reqwest_client" }
-rodio = { git = "https://github.com/RustAudio/rodio", rev ="e2074c6c2acf07b57cf717e076bdda7a9ac6e70b", features = ["wav", "playback", "wav_output", "recording"] }
+rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] }
rope = { path = "crates/rope" }
rpc = { path = "crates/rpc" }
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" }
@@ -512,7 +514,7 @@ convert_case = "0.8.0"
core-foundation = "=0.10.0"
core-foundation-sys = "0.8.6"
core-video = { version = "0.4.3", features = ["metal"] }
-cpal = "0.16"
+cpal = "0.17"
crash-handler = "0.6"
criterion = { version = "0.5", features = ["html_reports"] }
ctor = "0.4.0"
@@ -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/thinking_mode_off.svg b/assets/icons/thinking_mode_off.svg
new file mode 100644
index 0000000000000000000000000000000000000000..e313950ce41303ad1bf799e5d65ab6d9ca0735ff
--- /dev/null
+++ b/assets/icons/thinking_mode_off.svg
@@ -0,0 +1,5 @@
+
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..1be1de0230a74c29e96f44d54a4405dfa4c0b29d 100644
--- a/assets/keymaps/default-linux.json
+++ b/assets/keymaps/default-linux.json
@@ -577,7 +577,6 @@
// "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]",
"alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
"alt-ctrl-shift-b": "branches::OpenRecent",
- "ctrl-alt-p": "workspace::SwitchProject",
"alt-shift-enter": "toast::RunAction",
"ctrl-~": "workspace::NewTerminal",
"save": "workspace::Save",
@@ -603,6 +602,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 +663,13 @@
"ctrl-w": "workspace::CloseActiveDock",
},
},
+ {
+ "context": "WorkspaceSidebar",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-n": "multi_workspace::NewWorkspaceInWindow",
+ },
+ },
{
"context": "Workspace && debugger_running",
"bindings": {
@@ -1085,6 +1093,13 @@
"ctrl-l": "pane::SplitRight",
},
},
+ {
+ "context": "RecentProjects || (RecentProjects > Picker > Editor)",
+ "bindings": {
+ "ctrl-k": "recent_projects::ToggleActionsMenu",
+ "ctrl-shift-a": "workspace::AddFolderToProject",
+ },
+ },
{
"context": "TabSwitcher",
"bindings": {
@@ -1416,10 +1431,4 @@
"alt-3": "git_picker::ActivateStashTab",
},
},
- {
- "context": "MultiProjectDropdown",
- "bindings": {
- "shift-backspace": "project_dropdown::RemoveSelectedFolder",
- },
- },
]
diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json
index 8ca82963523a65cfd483935edd33e1cb00f5cc55..2d121bce142c109af36480e6d11a455ce7fb848a 100644
--- a/assets/keymaps/default-macos.json
+++ b/assets/keymaps/default-macos.json
@@ -643,7 +643,6 @@
"ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
"ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }],
"cmd-ctrl-b": "branches::OpenRecent",
- "cmd-alt-p": "workspace::SwitchProject",
"ctrl-~": "workspace::NewTerminal",
"cmd-s": "workspace::Save",
"cmd-k s": "workspace::SaveWithoutFormat",
@@ -664,6 +663,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 +724,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,
@@ -1148,6 +1156,14 @@
"cmd-l": "pane::SplitRight",
},
},
+ {
+ "context": "RecentProjects || (RecentProjects > Picker > Editor)",
+ "use_key_equivalents": true,
+ "bindings": {
+ "cmd-k": "recent_projects::ToggleActionsMenu",
+ "cmd-shift-a": "workspace::AddFolderToProject",
+ },
+ },
{
"context": "TabSwitcher",
"use_key_equivalents": true,
@@ -1486,12 +1502,6 @@
"cmd-3": "git_picker::ActivateStashTab",
},
},
- {
- "context": "MultiProjectDropdown",
- "bindings": {
- "shift-backspace": "project_dropdown::RemoveSelectedFolder",
- },
- },
{
"context": "NotebookEditor",
"bindings": {
diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json
index 41d4a976b0773ab3ae7b4cdb0b7ce271cf303432..273e733b0cdef263ae5d2ee5d4004ac312f49f4b 100644
--- a/assets/keymaps/default-windows.json
+++ b/assets/keymaps/default-windows.json
@@ -576,7 +576,6 @@
// "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]",
"ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
"shift-alt-b": "branches::OpenRecent",
- "ctrl-alt-p": "workspace::SwitchProject",
"shift-alt-enter": "toast::RunAction",
"ctrl-shift-`": "workspace::NewTerminal",
"ctrl-s": "workspace::Save",
@@ -598,6 +597,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 +667,13 @@
"f5": "debugger::Continue",
},
},
+ {
+ "context": "WorkspaceSidebar",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-n": "multi_workspace::NewWorkspaceInWindow",
+ },
+ },
{
"context": "ApplicationMenu",
"use_key_equivalents": true,
@@ -1099,6 +1107,14 @@
"ctrl-l": "pane::SplitRight",
},
},
+ {
+ "context": "RecentProjects || (RecentProjects > Picker > Editor)",
+ "use_key_equivalents": true,
+ "bindings": {
+ "ctrl-k": "recent_projects::ToggleActionsMenu",
+ "ctrl-shift-a": "workspace::AddFolderToProject",
+ },
+ },
{
"context": "TabSwitcher",
"use_key_equivalents": true,
@@ -1408,12 +1424,6 @@
"alt-3": "git_picker::ActivateStashTab",
},
},
- {
- "context": "MultiProjectDropdown",
- "bindings": {
- "shift-backspace": "project_dropdown::RemoveSelectedFolder",
- },
- },
{
"context": "NotebookEditor",
"bindings": {
diff --git a/assets/settings/default.json b/assets/settings/default.json
index e1d38e08b72c3928698139887eea6346735dc29b..19a149a84fd9b5dfae7305c6527147b2561a8512 100644
--- a/assets/settings/default.json
+++ b/assets/settings/default.json
@@ -486,6 +486,18 @@
//
// You need to rejoin a call for this setting to apply
"experimental.legacy_audio_compatible": true,
+ // Requires 'rodio_audio: true'
+ //
+ // Select specific output audio device.
+ // `null` means use system default.
+ // Any unrecognized output device will fall back to system default.
+ "experimental.output_audio_device": null,
+ // Requires 'rodio_audio: true'
+ //
+ // Select specific input audio device.
+ // `null` means use system default.
+ // Any unrecognized input device will fall back to system default.
+ "experimental.input_audio_device": null,
},
// Scrollbar related settings
"scrollbar": {
@@ -1000,7 +1012,7 @@
},
},
// When enabled, agent edits will be displayed in single-file editors for review
- "single_file_review": true,
+ "single_file_review": false,
// When enabled, show voting thumbs for feedback on agent edits.
"enable_feedback": true,
"default_profile": "write",
@@ -1123,6 +1135,13 @@
// - "on": Use LSP folding wherever possible, falling back to tree-sitter and indent-based folding when no results were returned by the server.
"document_folding_ranges": "off",
+ // Controls the source of document symbols used for outlines and breadcrumbs.
+ //
+ // Options:
+ // - "off": Use tree-sitter queries to compute document symbols (default).
+ // - "on": Use the language server's `textDocument/documentSymbol` LSP response. When enabled, tree-sitter is not used for document symbols.
+ "document_symbols": "off",
+
// When to automatically save edited buffers. This setting can
// take four values.
//
diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs
index 1dae5c8206f49ba0d3d08912e6f5639800bbe0e5..5935824b18d0095448a902c763feed3448f9fb81 100644
--- a/crates/agent/src/tests/mod.rs
+++ b/crates/agent/src/tests/mod.rs
@@ -2956,12 +2956,12 @@ async fn test_agent_connection(cx: &mut TestAppContext) {
#[gpui::test]
async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await;
- thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool, None));
+ thread.update(cx, |thread, _cx| thread.add_tool(EchoTool, None));
let fake_model = model.as_fake();
let mut events = thread
.update(cx, |thread, cx| {
- thread.send(UserMessageId::new(), ["Think"], cx)
+ thread.send(UserMessageId::new(), ["Echo something"], cx)
})
.unwrap();
cx.run_until_parked();
@@ -2971,7 +2971,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: "1".into(),
- name: ThinkingTool::NAME.into(),
+ name: EchoTool::NAME.into(),
raw_input: input.to_string(),
input,
is_input_complete: false,
@@ -2980,11 +2980,11 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
));
// Input streaming completed
- let input = json!({ "content": "Thinking hard!" });
+ let input = json!({ "text": "Hello!" });
fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: "1".into(),
- name: "thinking".into(),
+ name: "echo".into(),
raw_input: input.to_string(),
input,
is_input_complete: true,
@@ -2997,13 +2997,9 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
let tool_call = expect_tool_call(&mut events).await;
assert_eq!(
tool_call,
- acp::ToolCall::new("1", "Thinking")
- .kind(acp::ToolKind::Think)
+ acp::ToolCall::new("1", "Echo")
.raw_input(json!({}))
- .meta(acp::Meta::from_iter([(
- "tool_name".into(),
- "thinking".into()
- )]))
+ .meta(acp::Meta::from_iter([("tool_name".into(), "echo".into())]))
);
let update = expect_tool_call_update_fields(&mut events).await;
assert_eq!(
@@ -3011,9 +3007,9 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
acp::ToolCallUpdate::new(
"1",
acp::ToolCallUpdateFields::new()
- .title("Thinking")
- .kind(acp::ToolKind::Think)
- .raw_input(json!({ "content": "Thinking hard!"}))
+ .title("Echo")
+ .kind(acp::ToolKind::Other)
+ .raw_input(json!({ "text": "Hello!"}))
)
);
let update = expect_tool_call_update_fields(&mut events).await;
@@ -3025,21 +3021,13 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
)
);
let update = expect_tool_call_update_fields(&mut events).await;
- assert_eq!(
- update,
- acp::ToolCallUpdate::new(
- "1",
- acp::ToolCallUpdateFields::new().content(vec!["Thinking hard!".into()])
- )
- );
- let update = expect_tool_call_update_fields(&mut events).await;
assert_eq!(
update,
acp::ToolCallUpdate::new(
"1",
acp::ToolCallUpdateFields::new()
.status(acp::ToolCallStatus::Completed)
- .raw_output("Finished thinking.")
+ .raw_output("Hello!")
)
);
}
@@ -3340,7 +3328,6 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest {
ToolRequiringPermission::NAME: true,
InfiniteTool::NAME: true,
CancellationAwareTool::NAME: true,
- ThinkingTool::NAME: true,
(TerminalTool::NAME): true,
}
}
diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs
index b062cfda357b809a074e65dd49f989a243af478c..d08bc1c9186d4578e759aefe58e0fe50f7982c7f 100644
--- a/crates/agent/src/thread.rs
+++ b/crates/agent/src/thread.rs
@@ -3,8 +3,8 @@ use crate::{
DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool,
RestoreFileFromDiskTool, SaveFileTool, StreamingEditFileTool, SubagentTool,
- SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, ToolPermissionDecision,
- WebSearchTool, decide_permission_from_settings,
+ SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool,
+ decide_permission_from_settings,
};
use acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog;
@@ -1343,7 +1343,6 @@ impl Thread {
TerminalTool::new(self.project.clone(), environment.clone()),
allowed_tool_names.as_ref(),
);
- self.add_tool(ThinkingTool, allowed_tool_names.as_ref());
self.add_tool(WebSearchTool, allowed_tool_names.as_ref());
if cx.has_flag::() && self.depth() < MAX_SUBAGENT_DEPTH {
diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs
index debfa39962a018de3baaf484db314a1c715897d7..000a394d910c6b6ae4b68b5377dbabb7c1da21f1 100644
--- a/crates/agent/src/tools.rs
+++ b/crates/agent/src/tools.rs
@@ -17,7 +17,6 @@ mod save_file_tool;
mod streaming_edit_file_tool;
mod subagent_tool;
mod terminal_tool;
-mod thinking_tool;
mod web_search_tool;
use crate::AgentTool;
@@ -42,7 +41,6 @@ pub use save_file_tool::*;
pub use streaming_edit_file_tool::*;
pub use subagent_tool::*;
pub use terminal_tool::*;
-pub use thinking_tool::*;
pub use web_search_tool::*;
macro_rules! tools {
@@ -130,6 +128,5 @@ tools! {
SaveFileTool,
SubagentTool,
TerminalTool,
- ThinkingTool,
WebSearchTool,
}
diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs
index 80df7b219fb265ea5f6172c9c21113dcfe95d689..ef3b3cc30a54fd6eb3c9e07c3bce4ea7b194ca47 100644
--- a/crates/agent/src/tools/edit_file_tool.rs
+++ b/crates/agent/src/tools/edit_file_tool.rs
@@ -31,7 +31,7 @@ use util::rel_path::RelPath;
const DEFAULT_UI_TEXT: &str = "Editing file";
-/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead.
+/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `move_path` tool instead.
///
/// Before using this tool:
///
diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs
index c2c577b58250413de0044a40f9de11c9c9623d96..b822191a7e78ba566f1551661e748b2027f2404d 100644
--- a/crates/agent/src/tools/streaming_edit_file_tool.rs
+++ b/crates/agent/src/tools/streaming_edit_file_tool.rs
@@ -28,7 +28,7 @@ use util::rel_path::RelPath;
const DEFAULT_UI_TEXT: &str = "Editing file";
-/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead.
+/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `move_path` tool instead.
///
/// Before using this tool:
///
diff --git a/crates/agent/src/tools/subagent_tool.rs b/crates/agent/src/tools/subagent_tool.rs
index 14edb0113724520dd5057e33f909cddb6182666c..ad63ee656d49d209481b69ce5b0dc28f31b3895e 100644
--- a/crates/agent/src/tools/subagent_tool.rs
+++ b/crates/agent/src/tools/subagent_tool.rs
@@ -238,7 +238,7 @@ mod tests {
cx,
);
thread.add_tool(crate::NowTool, None);
- thread.add_tool(crate::ThinkingTool, None);
+ thread.add_tool(crate::WebSearchTool, None);
thread
})
}
@@ -253,7 +253,7 @@ mod tests {
let valid_tools = Some(vec!["now".to_string()]);
assert!(SubagentTool::validate_allowed_tools(&valid_tools, &thread, cx).is_ok());
- let both_tools = Some(vec!["now".to_string(), "thinking".to_string()]);
+ let both_tools = Some(vec!["now".to_string(), "web_search".to_string()]);
assert!(SubagentTool::validate_allowed_tools(&both_tools, &thread, cx).is_ok());
});
}
diff --git a/crates/agent/src/tools/thinking_tool.rs b/crates/agent/src/tools/thinking_tool.rs
deleted file mode 100644
index 10e7b01bfbd88f3973e8618207170d6991ced579..0000000000000000000000000000000000000000
--- a/crates/agent/src/tools/thinking_tool.rs
+++ /dev/null
@@ -1,48 +0,0 @@
-use agent_client_protocol as acp;
-use anyhow::Result;
-use gpui::{App, SharedString, Task};
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use std::sync::Arc;
-
-use crate::{AgentTool, ToolCallEventStream};
-
-/// A tool for thinking through problems, brainstorming ideas, or planning without executing any actions.
-/// Use this tool when you need to work through complex problems, develop strategies, or outline approaches before taking action.
-#[derive(Debug, Serialize, Deserialize, JsonSchema)]
-pub struct ThinkingToolInput {
- /// Content to think about. This should be a description of what to think about or a problem to solve.
- content: String,
-}
-
-pub struct ThinkingTool;
-
-impl AgentTool for ThinkingTool {
- type Input = ThinkingToolInput;
- type Output = String;
-
- const NAME: &'static str = "thinking";
-
- fn kind() -> acp::ToolKind {
- acp::ToolKind::Think
- }
-
- fn initial_title(
- &self,
- _input: Result,
- _cx: &mut App,
- ) -> SharedString {
- "Thinking".into()
- }
-
- fn run(
- self: Arc,
- input: Self::Input,
- event_stream: ToolCallEventStream,
- _cx: &mut App,
- ) -> Task> {
- event_stream
- .update_fields(acp::ToolCallUpdateFields::new().content(vec![input.content.into()]));
- Task::ready(Ok("Finished thinking.".to_string()))
- }
-}
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..cffc90ea278e24fb81aba287c2668b2ac9a6655a 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/crates/agent_ui/src/acp/thread_view.rs
@@ -55,9 +55,11 @@ use ui::{
PopoverMenu, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, prelude::*,
right_click_menu,
};
-use util::defer;
use util::{ResultExt, size::format_file_size, time::duration_alt_display};
-use workspace::{CollaboratorId, NewTerminal, Toast, Workspace, notifications::NotificationId};
+use util::{debug_panic, defer};
+use workspace::{
+ CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId,
+};
use zed_actions::agent::{Chat, ToggleModelSelector};
use zed_actions::assistant::OpenRulesLibrary;
@@ -178,9 +180,9 @@ pub struct AcpServerView {
}
impl AcpServerView {
- pub fn active_thread(&self) -> Option> {
+ pub fn active_thread(&self) -> Option<&Entity> {
match &self.server_state {
- ServerState::Connected(connected) => Some(connected.current.clone()),
+ ServerState::Connected(connected) => connected.active_view(),
_ => None,
}
}
@@ -188,15 +190,15 @@ impl AcpServerView {
pub fn parent_thread(&self, cx: &App) -> Option> {
match &self.server_state {
ServerState::Connected(connected) => {
- let mut current = connected.current.clone();
+ let mut current = connected.active_view()?;
while let Some(parent_id) = current.read(cx).parent_id.clone() {
if let Some(parent) = connected.threads.get(&parent_id) {
- current = parent.clone();
+ current = parent;
} else {
break;
}
}
- Some(current)
+ Some(current.clone())
}
_ => None,
}
@@ -249,7 +251,7 @@ enum ServerState {
// hashmap of threads, current becomes session_id
pub struct ConnectedServerState {
auth_state: AuthState,
- current: Entity,
+ active_id: Option,
threads: HashMap>,
connection: Rc,
}
@@ -277,13 +279,18 @@ struct LoadingView {
}
impl ConnectedServerState {
+ pub fn active_view(&self) -> Option<&Entity> {
+ self.active_id.as_ref().and_then(|id| self.threads.get(id))
+ }
+
pub fn has_thread_error(&self, cx: &App) -> bool {
- self.current.read(cx).thread_error.is_some()
+ self.active_view()
+ .map_or(false, |view| view.read(cx).thread_error.is_some())
}
pub fn navigate_to_session(&mut self, session_id: acp::SessionId) {
- if let Some(session) = self.threads.get(&session_id) {
- self.current = session.clone();
+ if self.threads.contains_key(&session_id) {
+ self.active_id = Some(session_id);
}
}
@@ -386,8 +393,8 @@ impl AcpServerView {
);
self.set_server_state(state, cx);
- if let Some(connected) = self.as_connected() {
- connected.current.update(cx, |this, cx| {
+ if let Some(view) = self.active_thread() {
+ view.update(cx, |this, cx| {
this.message_editor.update(cx, |editor, cx| {
editor.set_command_state(
this.prompt_capabilities.clone(),
@@ -520,7 +527,14 @@ impl AcpServerView {
Err(e) => match e.downcast::() {
Ok(err) => {
cx.update(|window, cx| {
- Self::handle_auth_required(this, err, agent.name(), window, cx)
+ Self::handle_auth_required(
+ this,
+ err,
+ agent.name(),
+ connection,
+ window,
+ cx,
+ )
})
.log_err();
return;
@@ -551,15 +565,13 @@ impl AcpServerView {
.focus(window, cx);
}
+ let id = current.read(cx).thread.read(cx).session_id().clone();
this.set_server_state(
ServerState::Connected(ConnectedServerState {
connection,
auth_state: AuthState::Ok,
- current: current.clone(),
- threads: HashMap::from_iter([(
- current.read(cx).thread.read(cx).session_id().clone(),
- current,
- )]),
+ active_id: Some(id.clone()),
+ threads: HashMap::from_iter([(id, current)]),
}),
cx,
);
@@ -816,6 +828,7 @@ impl AcpServerView {
this: WeakEntity,
err: AuthRequired,
agent_name: SharedString,
+ connection: Rc,
window: &mut Window,
cx: &mut App,
) {
@@ -855,26 +868,36 @@ impl AcpServerView {
};
this.update(cx, |this, cx| {
+ let description = err
+ .description
+ .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx)));
+ let auth_state = AuthState::Unauthenticated {
+ pending_auth_method: None,
+ configuration_view,
+ description,
+ _subscription: subscription,
+ };
if let Some(connected) = this.as_connected_mut() {
- let description = err
- .description
- .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx)));
-
- connected.auth_state = AuthState::Unauthenticated {
- pending_auth_method: None,
- configuration_view,
- description,
- _subscription: subscription,
- };
- if connected
- .current
- .read(cx)
- .message_editor
- .focus_handle(cx)
- .is_focused(window)
+ connected.auth_state = auth_state;
+ if let Some(view) = connected.active_view()
+ && view
+ .read(cx)
+ .message_editor
+ .focus_handle(cx)
+ .is_focused(window)
{
this.focus_handle.focus(window, cx)
}
+ } else {
+ this.set_server_state(
+ ServerState::Connected(ConnectedServerState {
+ auth_state,
+ active_id: None,
+ threads: HashMap::default(),
+ connection,
+ }),
+ cx,
+ );
}
cx.notify();
})
@@ -887,19 +910,15 @@ impl AcpServerView {
window: &mut Window,
cx: &mut Context,
) {
- match &self.server_state {
- ServerState::Connected(connected) => {
- if connected
- .current
- .read(cx)
- .message_editor
- .focus_handle(cx)
- .is_focused(window)
- {
- self.focus_handle.focus(window, cx)
- }
+ if let Some(view) = self.active_thread() {
+ if view
+ .read(cx)
+ .message_editor
+ .focus_handle(cx)
+ .is_focused(window)
+ {
+ self.focus_handle.focus(window, cx)
}
- _ => {}
}
let load_error = if let Some(load_err) = err.downcast_ref::() {
load_err.clone()
@@ -1148,19 +1167,15 @@ impl AcpServerView {
}
}
AcpThreadEvent::LoadError(error) => {
- match &self.server_state {
- ServerState::Connected(connected) => {
- if connected
- .current
- .read(cx)
- .message_editor
- .focus_handle(cx)
- .is_focused(window)
- {
- self.focus_handle.focus(window, cx)
- }
+ if let Some(view) = self.active_thread() {
+ if view
+ .read(cx)
+ .message_editor
+ .focus_handle(cx)
+ .is_focused(window)
+ {
+ self.focus_handle.focus(window, cx)
}
- _ => {}
}
self.set_server_state(ServerState::LoadError(error.clone()), cx);
}
@@ -1389,6 +1404,7 @@ impl AcpServerView {
if !provider.is_authenticated(cx) {
let this = cx.weak_entity();
let agent_name = self.agent.name();
+ let connection = connection.clone();
window.defer(cx, |window, cx| {
Self::handle_auth_required(
this,
@@ -1397,6 +1413,7 @@ impl AcpServerView {
provider_id: Some(language_model::GOOGLE_PROVIDER_ID),
},
agent_name,
+ connection,
window,
cx,
);
@@ -1410,6 +1427,7 @@ impl AcpServerView {
{
let this = cx.weak_entity();
let agent_name = self.agent.name();
+ let connection = connection.clone();
window.defer(cx, |window, cx| {
Self::handle_auth_required(
@@ -1422,6 +1440,7 @@ impl AcpServerView {
provider_id: None,
},
agent_name,
+ connection,
window,
cx,
)
@@ -2161,9 +2180,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 +2221,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 +2284,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 +2324,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);
});
}
})
@@ -2397,8 +2433,19 @@ impl AcpServerView {
active.update(cx, |active, cx| active.clear_thread_error(cx));
}
let this = cx.weak_entity();
+ let Some(connection) = self.as_connected().map(|c| c.connection.clone()) else {
+ debug_panic!("This should not be possible");
+ return;
+ };
window.defer(cx, |window, cx| {
- Self::handle_auth_required(this, AuthRequired::new(), agent_name, window, cx);
+ Self::handle_auth_required(
+ this,
+ AuthRequired::new(),
+ agent_name,
+ connection,
+ window,
+ cx,
+ );
})
}
@@ -2508,7 +2555,14 @@ impl Render for AcpServerView {
cx,
))
.into_any_element(),
- ServerState::Connected(connected) => connected.current.clone().into_any_element(),
+ ServerState::Connected(connected) => {
+ if let Some(view) = connected.active_view() {
+ view.clone().into_any_element()
+ } else {
+ debug_panic!("This state should never be reached");
+ div().into_any_element()
+ }
+ }
})
}
}
@@ -2545,6 +2599,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 +2611,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 +2685,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 +2758,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 +2806,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 +2858,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 +2910,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();
@@ -2913,6 +2975,78 @@ pub(crate) mod tests {
});
}
+ #[gpui::test]
+ async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let connection = AuthGatedAgentConnection::new();
+ let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await;
+
+ // When new_session returns AuthRequired, the server should transition
+ // to Connected + Unauthenticated rather than getting stuck in Loading.
+ thread_view.read_with(cx, |view, _cx| {
+ let connected = view
+ .as_connected()
+ .expect("Should be in Connected state even though auth is required");
+ assert!(
+ !connected.auth_state.is_ok(),
+ "Auth state should be Unauthenticated"
+ );
+ assert!(
+ connected.active_id.is_none(),
+ "There should be no active thread since no session was created"
+ );
+ assert!(
+ connected.threads.is_empty(),
+ "There should be no threads since no session was created"
+ );
+ });
+
+ thread_view.read_with(cx, |view, _cx| {
+ assert!(
+ view.active_thread().is_none(),
+ "active_thread() should be None when unauthenticated without a session"
+ );
+ });
+
+ // Authenticate using the real authenticate flow on AcpServerView.
+ // This calls connection.authenticate(), which flips the internal flag,
+ // then on success triggers reset() -> new_session() which now succeeds.
+ thread_view.update_in(cx, |view, window, cx| {
+ view.authenticate(
+ acp::AuthMethodId::new(AuthGatedAgentConnection::AUTH_METHOD_ID),
+ window,
+ cx,
+ );
+ });
+ cx.run_until_parked();
+
+ // After auth, the server should have an active thread in the Ok state.
+ thread_view.read_with(cx, |view, cx| {
+ let connected = view
+ .as_connected()
+ .expect("Should still be in Connected state after auth");
+ assert!(connected.auth_state.is_ok(), "Auth state should be Ok");
+ assert!(
+ connected.active_id.is_some(),
+ "There should be an active thread after successful auth"
+ );
+ assert_eq!(
+ connected.threads.len(),
+ 1,
+ "There should be exactly one thread"
+ );
+
+ let active = view
+ .active_thread()
+ .expect("active_thread() should return the new thread");
+ assert!(
+ active.read(cx).thread_error.is_none(),
+ "The new thread should have no errors"
+ );
+ });
+ }
+
#[gpui::test]
async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) {
init_test(cx);
@@ -3011,6 +3145,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 +3368,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 +3439,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()),
@@ -3338,6 +3604,99 @@ pub(crate) mod tests {
}
}
+ /// Simulates an agent that requires authentication before a session can be
+ /// created. `new_session` returns `AuthRequired` until `authenticate` is
+ /// called with the correct method, after which sessions are created normally.
+ #[derive(Clone)]
+ struct AuthGatedAgentConnection {
+ authenticated: Arc>,
+ auth_method: acp::AuthMethod,
+ }
+
+ impl AuthGatedAgentConnection {
+ const AUTH_METHOD_ID: &str = "test-login";
+
+ fn new() -> Self {
+ Self {
+ authenticated: Arc::new(Mutex::new(false)),
+ auth_method: acp::AuthMethod::new(Self::AUTH_METHOD_ID, "Test Login"),
+ }
+ }
+ }
+
+ impl AgentConnection for AuthGatedAgentConnection {
+ fn telemetry_id(&self) -> SharedString {
+ "auth-gated".into()
+ }
+
+ fn new_session(
+ self: Rc,
+ project: Entity,
+ _cwd: &Path,
+ cx: &mut gpui::App,
+ ) -> Task>> {
+ if !*self.authenticated.lock() {
+ return Task::ready(Err(acp_thread::AuthRequired::new()
+ .with_description("Sign in to continue".to_string())
+ .into()));
+ }
+
+ let session_id = acp::SessionId::new("auth-gated-session");
+ let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ Task::ready(Ok(cx.new(|cx| {
+ AcpThread::new(
+ None,
+ "AuthGatedAgent",
+ self,
+ project,
+ action_log,
+ session_id,
+ watch::Receiver::constant(
+ acp::PromptCapabilities::new()
+ .image(true)
+ .audio(true)
+ .embedded_context(true),
+ ),
+ cx,
+ )
+ })))
+ }
+
+ fn auth_methods(&self) -> &[acp::AuthMethod] {
+ std::slice::from_ref(&self.auth_method)
+ }
+
+ fn authenticate(
+ &self,
+ method_id: acp::AuthMethodId,
+ _cx: &mut App,
+ ) -> Task> {
+ if method_id == self.auth_method.id {
+ *self.authenticated.lock() = true;
+ Task::ready(Ok(()))
+ } else {
+ Task::ready(Err(anyhow::anyhow!("Unknown auth method")))
+ }
+ }
+
+ fn prompt(
+ &self,
+ _id: Option,
+ _params: acp::PromptRequest,
+ _cx: &mut App,
+ ) -> Task> {
+ unimplemented!()
+ }
+
+ fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
+ unimplemented!()
+ }
+
+ fn into_any(self: Rc) -> Rc {
+ self
+ }
+ }
+
#[derive(Clone)]
struct SaboteurAgentConnection;
@@ -3580,6 +3939,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)
});
@@ -3589,7 +3949,13 @@ pub(crate) mod tests {
thread_view: &Entity,
cx: &TestAppContext,
) -> Entity {
- cx.read(|cx| thread_view.read(cx).as_connected().unwrap().current.clone())
+ cx.read(|cx| {
+ thread_view
+ .read(cx)
+ .active_thread()
+ .expect("No active thread")
+ .clone()
+ })
}
fn message_editor(
@@ -3614,8 +3980,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/acp/thread_view/active_thread.rs b/crates/agent_ui/src/acp/thread_view/active_thread.rs
index bde91fbec9a77ca9554ca31c3e3b4d97b7a21c04..49c95f21cf0949afc50853c80b65986d65a9e528 100644
--- a/crates/agent_ui/src/acp/thread_view/active_thread.rs
+++ b/crates/agent_ui/src/acp/thread_view/active_thread.rs
@@ -630,6 +630,7 @@ impl AcpThreadView {
if can_login && !logout_supported {
message_editor.update(cx, |editor, cx| editor.clear(window, cx));
+ let connection = self.thread.read(cx).connection().clone();
window.defer(cx, {
let agent_name = self.agent_name.clone();
let server_view = self.server_view.clone();
@@ -638,6 +639,7 @@ impl AcpThreadView {
server_view.clone(),
AuthRequired::new(),
agent_name,
+ connection,
window,
cx,
);
@@ -2767,18 +2769,25 @@ impl AcpThreadView {
let thinking = thread.thinking_enabled();
- let (tooltip_label, icon) = if thinking {
- ("Disable Thinking Mode", IconName::ThinkingMode)
+ let (tooltip_label, icon, color) = if thinking {
+ (
+ "Disable Thinking Mode",
+ IconName::ThinkingMode,
+ Color::Muted,
+ )
} else {
- ("Enable Thinking Mode", IconName::ToolThink)
+ (
+ "Enable Thinking Mode",
+ IconName::ThinkingModeOff,
+ Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)),
+ )
};
let focus_handle = self.message_editor.focus_handle(cx);
let thinking_toggle = IconButton::new("thinking-mode", icon)
.icon_size(IconSize::Small)
- .icon_color(Color::Muted)
- .toggle_state(thinking)
+ .icon_color(color)
.tooltip(move |_, cx| {
Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx)
})
@@ -6716,11 +6725,13 @@ impl AcpThreadView {
editor.set_message(message, window, cx);
});
}
+ let connection = this.thread.read(cx).connection().clone();
window.defer(cx, |window, cx| {
AcpServerView::handle_auth_required(
server_view,
AuthRequired::new(),
agent_name,
+ connection,
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..719ff77761562b972ef0ebd8ff6c0f2cf316d6e7 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
@@ -540,6 +540,7 @@ impl Render for AddLlmProviderModal {
.max_h(modal_max_height)
.pl_3()
.pr_4()
+ .pb_2()
.gap_2()
.overflow_y_scroll()
.track_scroll(&self.scroll_handle)
@@ -599,6 +600,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 +817,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 c5bdaaf91bc3cfc633e5ed9812ae9a1154b5e659..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
@@ -1726,6 +1726,7 @@ mod tests {
use super::*;
use crate::Keep;
use acp_thread::AgentConnection as _;
+ use agent_settings::AgentSettings;
use editor::EditorSettings;
use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
use project::{FakeFs, Project};
@@ -1733,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) {
@@ -1769,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)
});
@@ -1889,7 +1892,7 @@ mod tests {
}
#[gpui::test]
- async fn test_singleton_agent_diff(cx: &mut TestAppContext) {
+ async fn test_single_file_review_diff(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
@@ -1899,6 +1902,14 @@ mod tests {
workspace::register_project_item::(cx);
});
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, _cx| {
+ let mut agent_settings = store.get::(None).clone();
+ agent_settings.single_file_review = true;
+ store.override_global(agent_settings);
+ });
+ });
+
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/test"),
@@ -1920,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..9338cde0da066bea295ea7bb0e68fb5844288852 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> {
@@ -922,7 +1006,7 @@ impl AgentPanel {
return;
};
- let Some(active_thread) = thread_view.read(cx).active_thread() else {
+ let Some(active_thread) = thread_view.read(cx).active_thread().cloned() else {
return;
};
@@ -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 } => {
@@ -1195,7 +1280,7 @@ impl AgentPanel {
) {
if let Some(workspace) = self.workspace.upgrade()
&& let Some(thread_view) = self.active_thread_view()
- && let Some(active_thread) = thread_view.read(cx).active_thread()
+ && let Some(active_thread) = thread_view.read(cx).active_thread().cloned()
{
active_thread.update(cx, |thread, cx| {
thread
@@ -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..8cd512c0e4358ea46e5de9145c014b66d9ebf7ce 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};
@@ -377,13 +377,13 @@ fn update_command_palette_filter(cx: &mut App) {
if agent_enabled {
filter.show_namespace("agent");
filter.show_namespace("agents");
+ filter.show_namespace("assistant");
} else {
filter.hide_namespace("agent");
filter.hide_namespace("agents");
+ filter.hide_namespace("assistant");
}
- filter.show_namespace("assistant");
-
match edit_prediction_provider {
EditPredictionProvider::None => {
filter.hide_namespace("edit_prediction");
@@ -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");
+ }
});
}
@@ -582,6 +588,10 @@ mod tests {
!filter.is_hidden(&NewThread),
"NewThread should be visible by default"
);
+ assert!(
+ !filter.is_hidden(&text_thread_editor::CopyCode),
+ "CopyCode should be visible when agent is enabled"
+ );
});
// Disable agent
@@ -601,6 +611,10 @@ mod tests {
filter.is_hidden(&NewThread),
"NewThread should be hidden when agent is disabled"
);
+ assert!(
+ filter.is_hidden(&text_thread_editor::CopyCode),
+ "CopyCode should be hidden when agent is disabled"
+ );
});
// Test EditPredictionProvider
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/audio/Cargo.toml b/crates/audio/Cargo.toml
index 2aee764007a791176c6e41cb77f6efaf19aa3dc4..3139eb56c7e30555c48fe0be329c55d472b3f8eb 100644
--- a/crates/audio/Cargo.toml
+++ b/crates/audio/Cargo.toml
@@ -16,6 +16,7 @@ doctest = false
anyhow.workspace = true
async-tar.workspace = true
collections.workspace = true
+cpal.workspace = true
crossbeam.workspace = true
gpui.workspace = true
denoise = { path = "../denoise" }
diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs
index 49239320facdd71b47b709b67bab32b5f0aba9ac..d684b9c79e296e141a021a32c88c009e85504457 100644
--- a/crates/audio/src/audio.rs
+++ b/crates/audio/src/audio.rs
@@ -1,14 +1,16 @@
use anyhow::{Context as _, Result};
use collections::HashMap;
-use gpui::{App, BackgroundExecutor, BorrowAppContext, Global};
-use log::info;
+use cpal::{
+ DeviceDescription, DeviceId, default_host,
+ traits::{DeviceTrait, HostTrait},
+};
+use gpui::{App, AsyncApp, BackgroundExecutor, BorrowAppContext, Global};
#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
mod non_windows_and_freebsd_deps {
- pub(super) use gpui::AsyncApp;
+ pub(super) use cpal::Sample;
pub(super) use libwebrtc::native::apm;
pub(super) use parking_lot::Mutex;
- pub(super) use rodio::cpal::Sample;
pub(super) use rodio::source::LimitSettings;
pub(super) use std::sync::Arc;
}
@@ -17,7 +19,10 @@ mod non_windows_and_freebsd_deps {
use non_windows_and_freebsd_deps::*;
use rodio::{
- Decoder, OutputStream, OutputStreamBuilder, Source, mixer::Mixer, nz, source::Buffered,
+ Decoder, DeviceSinkBuilder, MixerDeviceSink, Source,
+ mixer::Mixer,
+ nz,
+ source::{AutomaticGainControlSettings, Buffered},
};
use settings::Settings;
use std::{io::Cursor, num::NonZero, path::PathBuf, sync::atomic::Ordering, time::Duration};
@@ -49,6 +54,15 @@ pub const REPLAY_DURATION: Duration = Duration::from_secs(30);
pub fn init(cx: &mut App) {
LIVE_SETTINGS.initialize(cx);
+ // TODO(jk): this is currently cached only once at startup - we should observe and react instead
+ let task = cx
+ .background_executor()
+ .spawn(async move { get_available_audio_devices() });
+ cx.spawn(async move |cx: &mut AsyncApp| {
+ let devices = task.await;
+ cx.update(|cx| cx.set_global(AvailableAudioDevices(devices)))
+ })
+ .detach();
}
#[derive(Debug, Copy, Clone, Eq, Hash, PartialEq)]
@@ -79,8 +93,7 @@ impl Sound {
}
pub struct Audio {
- output_handle: Option,
- output_mixer: Option,
+ output_handle: Option,
#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
pub echo_canceller: Arc>,
source_cache: HashMap>>>>,
@@ -91,7 +104,6 @@ impl Default for Audio {
fn default() -> Self {
Self {
output_handle: Default::default(),
- output_mixer: Default::default(),
#[cfg(not(any(
all(target_os = "windows", target_env = "gnu"),
target_os = "freebsd"
@@ -108,51 +120,58 @@ impl Default for Audio {
impl Global for Audio {}
impl Audio {
- fn ensure_output_exists(&mut self) -> Result<&Mixer> {
+ fn ensure_output_exists(&mut self, output_audio_device: Option) -> Result<&Mixer> {
#[cfg(debug_assertions)]
log::warn!(
"Audio does not sound correct without optimizations. Use a release build to debug audio issues"
);
if self.output_handle.is_none() {
- let output_handle = OutputStreamBuilder::open_default_stream()
- .context("Could not open default output stream")?;
- info!("Output stream: {:?}", output_handle);
- self.output_handle = Some(output_handle);
- if let Some(output_handle) = &self.output_handle {
- let (mixer, source) = rodio::mixer::mixer(CHANNEL_COUNT, SAMPLE_RATE);
- // or the mixer will end immediately as its empty.
- mixer.add(rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE));
- self.output_mixer = Some(mixer);
-
- // The webrtc apm is not yet compiling for windows & freebsd
- #[cfg(not(any(
- any(all(target_os = "windows", target_env = "gnu")),
- target_os = "freebsd"
- )))]
- let echo_canceller = Arc::clone(&self.echo_canceller);
- #[cfg(not(any(
- any(all(target_os = "windows", target_env = "gnu")),
- target_os = "freebsd"
- )))]
- let source = source.inspect_buffer::(move |buffer| {
- let mut buf: [i16; _] = buffer.map(|s| s.to_sample());
- echo_canceller
- .lock()
- .process_reverse_stream(
- &mut buf,
- SAMPLE_RATE.get() as i32,
- CHANNEL_COUNT.get().into(),
- )
- .expect("Audio input and output threads should not panic");
- });
+ let output_handle = open_output_stream(output_audio_device)?;
+
+ // The webrtc apm is not yet compiling for windows & freebsd
+ #[cfg(not(any(
+ any(all(target_os = "windows", target_env = "gnu")),
+ target_os = "freebsd"
+ )))]
+ let echo_canceller = Arc::clone(&self.echo_canceller);
+
+ #[cfg(not(any(
+ any(all(target_os = "windows", target_env = "gnu")),
+ target_os = "freebsd"
+ )))]
+ {
+ let source = rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE)
+ .inspect_buffer::(move |buffer| {
+ let mut buf: [i16; _] = buffer.map(|s| s.to_sample());
+ echo_canceller
+ .lock()
+ .process_reverse_stream(
+ &mut buf,
+ SAMPLE_RATE.get() as i32,
+ CHANNEL_COUNT.get().into(),
+ )
+ .expect("Audio input and output threads should not panic");
+ });
+ output_handle.mixer().add(source);
+ }
+
+ #[cfg(any(
+ any(all(target_os = "windows", target_env = "gnu")),
+ target_os = "freebsd"
+ ))]
+ {
+ let source = rodio::source::Zero::::new(CHANNEL_COUNT, SAMPLE_RATE);
output_handle.mixer().add(source);
}
+
+ self.output_handle = Some(output_handle);
}
Ok(self
- .output_mixer
+ .output_handle
.as_ref()
+ .map(|h| h.mixer())
.expect("we only get here if opening the outputstream succeeded"))
}
@@ -165,20 +184,7 @@ impl Audio {
#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
pub fn open_microphone(voip_parts: VoipParts) -> anyhow::Result {
- let stream = rodio::microphone::MicrophoneBuilder::new()
- .default_device()?
- .default_config()?
- .prefer_sample_rates([
- SAMPLE_RATE, // sample rates trivially resamplable to `SAMPLE_RATE`
- SAMPLE_RATE.saturating_mul(nz!(2)),
- SAMPLE_RATE.saturating_mul(nz!(3)),
- SAMPLE_RATE.saturating_mul(nz!(4)),
- ])
- .prefer_channel_counts([nz!(1), nz!(2), nz!(3), nz!(4)])
- .prefer_buffer_sizes(512..)
- .open_stream()?;
- info!("Opened microphone: {:?}", stream.config());
-
+ let stream = open_input_stream(voip_parts.input_audio_device)?;
let stream = stream
.possibly_disconnected_channels_to_mono()
.constant_samplerate(SAMPLE_RATE)
@@ -204,7 +210,12 @@ impl Audio {
})
.denoise()
.context("Could not set up denoiser")?
- .automatic_gain_control(0.90, 1.0, 0.0, 5.0)
+ .automatic_gain_control(AutomaticGainControlSettings {
+ target_level: 0.90,
+ attack_time: Duration::from_secs(1),
+ release_time: Duration::from_secs(0),
+ absolute_max_gain: 5.0,
+ })
.periodic_access(Duration::from_millis(100), move |agc_source| {
agc_source
.set_enabled(LIVE_SETTINGS.auto_microphone_volume.load(Ordering::Relaxed));
@@ -234,16 +245,22 @@ impl Audio {
) -> anyhow::Result<()> {
let (replay_source, source) = source
.constant_params(CHANNEL_COUNT, SAMPLE_RATE)
- .automatic_gain_control(0.90, 1.0, 0.0, 5.0)
+ .automatic_gain_control(AutomaticGainControlSettings {
+ target_level: 0.90,
+ attack_time: Duration::from_secs(1),
+ release_time: Duration::from_secs(0),
+ absolute_max_gain: 5.0,
+ })
.periodic_access(Duration::from_millis(100), move |agc_source| {
agc_source.set_enabled(LIVE_SETTINGS.auto_speaker_volume.load(Ordering::Relaxed));
})
.replayable(REPLAY_DURATION)
.expect("REPLAY_DURATION is longer than 100ms");
+ let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone();
cx.update_default_global(|this: &mut Self, _cx| {
let output_mixer = this
- .ensure_output_exists()
+ .ensure_output_exists(output_audio_device)
.context("Could not get output mixer")?;
output_mixer.add(source);
if is_staff {
@@ -254,10 +271,11 @@ impl Audio {
}
pub fn play_sound(sound: Sound, cx: &mut App) {
+ let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone();
cx.update_default_global(|this: &mut Self, cx| {
let source = this.sound_source(sound, cx).log_err()?;
let output_mixer = this
- .ensure_output_exists()
+ .ensure_output_exists(output_audio_device)
.context("Could not get output mixer")
.log_err()?;
@@ -298,6 +316,7 @@ pub struct VoipParts {
echo_canceller: Arc>,
replays: replays::Replays,
legacy_audio_compatible: bool,
+ input_audio_device: Option,
}
#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
@@ -309,11 +328,110 @@ impl VoipParts {
let legacy_audio_compatible =
AudioSettings::try_read_global(cx, |settings| settings.legacy_audio_compatible)
.unwrap_or(true);
+ let input_audio_device =
+ AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone())
+ .flatten();
Ok(Self {
legacy_audio_compatible,
echo_canceller: apm,
replays,
+ input_audio_device,
})
}
}
+
+pub fn open_input_stream(
+ device_id: Option,
+) -> anyhow::Result {
+ let builder = rodio::microphone::MicrophoneBuilder::new();
+ let builder = if let Some(id) = device_id {
+ // TODO(jk): upstream patch
+ // if let Some(input_device) = default_host().device_by_id(id) {
+ // builder.device(input_device);
+ // }
+ let mut found = None;
+ for input in rodio::microphone::available_inputs()? {
+ if input.clone().into_inner().id()? == id {
+ found = Some(builder.device(input));
+ break;
+ }
+ }
+ found.unwrap_or_else(|| builder.default_device())?
+ } else {
+ builder.default_device()?
+ };
+ let stream = builder
+ .default_config()?
+ .prefer_sample_rates([
+ SAMPLE_RATE,
+ SAMPLE_RATE.saturating_mul(rodio::nz!(2)),
+ SAMPLE_RATE.saturating_mul(rodio::nz!(3)),
+ SAMPLE_RATE.saturating_mul(rodio::nz!(4)),
+ ])
+ .prefer_channel_counts([rodio::nz!(1), rodio::nz!(2), rodio::nz!(3), rodio::nz!(4)])
+ .prefer_buffer_sizes(512..)
+ .open_stream()?;
+ log::info!("Opened microphone: {:?}", stream.config());
+ Ok(stream)
+}
+
+pub fn open_output_stream(device_id: Option) -> anyhow::Result {
+ let output_handle = if let Some(id) = device_id {
+ if let Some(device) = default_host().device_by_id(&id) {
+ DeviceSinkBuilder::from_device(device)?.open_stream()
+ } else {
+ DeviceSinkBuilder::open_default_sink()
+ }
+ } else {
+ DeviceSinkBuilder::open_default_sink()
+ };
+ let mut output_handle = output_handle.context("Could not open output stream")?;
+ output_handle.log_on_drop(false);
+ log::info!("Output stream: {:?}", output_handle);
+ Ok(output_handle)
+}
+
+#[derive(Clone, Debug)]
+pub struct AudioDeviceInfo {
+ pub id: DeviceId,
+ pub desc: DeviceDescription,
+}
+
+impl AudioDeviceInfo {
+ pub fn matches_input(&self, is_input: bool) -> bool {
+ if is_input {
+ self.desc.supports_input()
+ } else {
+ self.desc.supports_output()
+ }
+ }
+
+ pub fn matches(&self, id: &DeviceId, is_input: bool) -> bool {
+ &self.id == id && self.matches_input(is_input)
+ }
+}
+
+impl std::fmt::Display for AudioDeviceInfo {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{} ({})", self.desc.name(), self.id)
+ }
+}
+
+fn get_available_audio_devices() -> Vec {
+ let Some(devices) = default_host().devices().ok() else {
+ return Vec::new();
+ };
+ devices
+ .filter_map(|device| {
+ let id = device.id().ok()?;
+ let desc = device.description().ok()?;
+ Some(AudioDeviceInfo { id, desc })
+ })
+ .collect()
+}
+
+#[derive(Default, Clone, Debug)]
+pub struct AvailableAudioDevices(pub Vec);
+
+impl Global for AvailableAudioDevices {}
diff --git a/crates/audio/src/audio_settings.rs b/crates/audio/src/audio_settings.rs
index f86246292833bf285904cbc27f675f8ad1ebc856..4f60a6d63aef1d2c2d7fb4761a6fc2e2eaf3d8c7 100644
--- a/crates/audio/src/audio_settings.rs
+++ b/crates/audio/src/audio_settings.rs
@@ -1,5 +1,9 @@
-use std::sync::atomic::{AtomicBool, Ordering};
+use std::{
+ str::FromStr,
+ sync::atomic::{AtomicBool, Ordering},
+};
+use cpal::DeviceId;
use gpui::App;
use settings::{RegisterSetting, Settings, SettingsStore};
@@ -38,6 +42,14 @@ pub struct AudioSettings {
///
/// You need to rejoin a call for this setting to apply
pub legacy_audio_compatible: bool,
+ /// Requires 'rodio_audio: true'
+ ///
+ /// Select specific output audio device.
+ pub output_audio_device: Option,
+ /// Requires 'rodio_audio: true'
+ ///
+ /// Select specific input audio device.
+ pub input_audio_device: Option,
}
/// Configuration of audio in Zed
@@ -50,6 +62,14 @@ impl Settings for AudioSettings {
auto_speaker_volume: audio.auto_speaker_volume.unwrap(),
denoise: audio.denoise.unwrap(),
legacy_audio_compatible: audio.legacy_audio_compatible.unwrap(),
+ output_audio_device: audio
+ .output_audio_device
+ .as_ref()
+ .and_then(|x| x.0.as_ref().and_then(|id| DeviceId::from_str(&id).ok())),
+ input_audio_device: audio
+ .input_audio_device
+ .as_ref()
+ .and_then(|x| x.0.as_ref().and_then(|id| DeviceId::from_str(&id).ok())),
}
}
}
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..a48e43741641b92cedaf4e6d4d6bd80ad6f68c19 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};
@@ -35,8 +36,8 @@ use recent_projects::disconnected_overlay::DisconnectedOverlay;
use rpc::RECEIVE_TIMEOUT;
use serde_json::json;
use settings::{
- DocumentFoldingRanges, InlayHintSettingsContent, InlineBlameSettings, SemanticTokens,
- SettingsStore,
+ DocumentFoldingRanges, DocumentSymbols, InlayHintSettingsContent, InlineBlameSettings,
+ SemanticTokens, SettingsStore,
};
use std::{
collections::BTreeSet,
@@ -51,7 +52,8 @@ use std::{
};
use text::Point;
use util::{path, rel_path::rel_path, uri};
-use workspace::{CloseIntent, Workspace};
+use workspace::item::Item as _;
+use workspace::{CloseIntent, MultiWorkspace, Workspace};
#[gpui::test(iterations = 10)]
async fn test_host_disconnect(
@@ -95,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.
@@ -140,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);
@@ -5503,6 +5514,180 @@ async fn test_remote_project_worktree_trust(cx_a: &mut TestAppContext, cx_b: &mu
);
}
+#[gpui::test]
+async fn test_document_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
+ let mut server = TestServer::start(cx_a.executor()).await;
+ let executor = cx_a.executor();
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+ server
+ .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+ .await;
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
+
+ cx_a.update(editor::init);
+ cx_b.update(editor::init);
+
+ let capabilities = lsp::ServerCapabilities {
+ document_symbol_provider: Some(lsp::OneOf::Left(true)),
+ ..lsp::ServerCapabilities::default()
+ };
+ client_a.language_registry().add(rust_lang());
+ #[allow(deprecated)]
+ let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
+ "Rust",
+ FakeLspAdapter {
+ capabilities: capabilities.clone(),
+ initializer: Some(Box::new(|fake_language_server| {
+ #[allow(deprecated)]
+ fake_language_server
+ .set_request_handler::(
+ move |_, _| async move {
+ Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
+ lsp::DocumentSymbol {
+ name: "Foo".to_string(),
+ detail: None,
+ kind: lsp::SymbolKind::STRUCT,
+ tags: None,
+ deprecated: None,
+ range: lsp::Range::new(
+ lsp::Position::new(0, 0),
+ lsp::Position::new(2, 1),
+ ),
+ selection_range: lsp::Range::new(
+ lsp::Position::new(0, 7),
+ lsp::Position::new(0, 10),
+ ),
+ children: Some(vec![lsp::DocumentSymbol {
+ name: "bar".to_string(),
+ detail: None,
+ kind: lsp::SymbolKind::FIELD,
+ tags: None,
+ deprecated: None,
+ range: lsp::Range::new(
+ lsp::Position::new(1, 4),
+ lsp::Position::new(1, 13),
+ ),
+ selection_range: lsp::Range::new(
+ lsp::Position::new(1, 4),
+ lsp::Position::new(1, 7),
+ ),
+ children: None,
+ }]),
+ },
+ ])))
+ },
+ );
+ })),
+ ..FakeLspAdapter::default()
+ },
+ );
+ client_b.language_registry().add(rust_lang());
+ client_b.language_registry().register_fake_lsp_adapter(
+ "Rust",
+ FakeLspAdapter {
+ capabilities,
+ ..FakeLspAdapter::default()
+ },
+ );
+
+ client_a
+ .fs()
+ .insert_tree(
+ path!("/a"),
+ json!({
+ "main.rs": "struct Foo {\n bar: u32,\n}\n",
+ }),
+ )
+ .await;
+ let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
+ active_call_a
+ .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
+ .await
+ .unwrap();
+ let project_id = active_call_a
+ .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+ .await
+ .unwrap();
+
+ let project_b = client_b.join_remote_project(project_id, cx_b).await;
+ active_call_b
+ .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
+ .await
+ .unwrap();
+
+ let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
+
+ let editor_a = workspace_a
+ .update_in(cx_a, |workspace, window, cx| {
+ workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
+ })
+ .await
+ .unwrap()
+ .downcast::()
+ .unwrap();
+
+ let _fake_language_server = fake_language_servers.next().await.unwrap();
+ executor.run_until_parked();
+
+ cx_a.update(|_, cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.project.all_languages.defaults.document_symbols =
+ Some(DocumentSymbols::On);
+ });
+ });
+ });
+ executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
+ executor.run_until_parked();
+
+ editor_a.update(cx_a, |editor, cx| {
+ let breadcrumbs = editor
+ .breadcrumbs(cx)
+ .expect("Host should have breadcrumbs");
+ let texts: Vec<_> = breadcrumbs.iter().map(|b| b.text.as_str()).collect();
+ assert_eq!(
+ texts,
+ vec!["main.rs", "struct Foo"],
+ "Host should see file path and LSP symbol 'Foo' in breadcrumbs"
+ );
+ });
+
+ cx_b.update(|cx| {
+ SettingsStore::update_global(cx, |store, cx| {
+ store.update_user_settings(cx, |settings| {
+ settings.project.all_languages.defaults.document_symbols =
+ Some(DocumentSymbols::On);
+ });
+ });
+ });
+ let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
+ let editor_b = workspace_b
+ .update_in(cx_b, |workspace, window, cx| {
+ workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
+ })
+ .await
+ .unwrap()
+ .downcast::()
+ .unwrap();
+ executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
+ executor.run_until_parked();
+
+ editor_b.update(cx_b, |editor, cx| {
+ assert_eq!(
+ editor
+ .breadcrumbs(cx)
+ .expect("Client B should have breadcrumbs")
+ .iter()
+ .map(|b| b.text.as_str())
+ .collect::>(),
+ vec!["main.rs", "struct Foo"],
+ "Client B should see file path and LSP symbol 'Foo' via remote project"
+ );
+ });
+}
+
fn blame_entry(sha: &str, range: Range) -> git::blame::BlameEntry {
git::blame::BlameEntry {
sha: sha.parse().unwrap(),
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/src/copilot.rs b/crates/copilot/src/copilot.rs
index fb815e04a6eb9f3d713c593a3549a66c479cfb9c..9e27a6f871650f9978357031e91f2f897b361f93 100644
--- a/crates/copilot/src/copilot.rs
+++ b/crates/copilot/src/copilot.rs
@@ -393,11 +393,35 @@ impl Copilot {
};
this.start_copilot(true, false, cx);
cx.observe_global::(move |this, cx| {
- this.start_copilot(true, false, cx);
- if let Ok(server) = this.server.as_running() {
- notify_did_change_config_to_server(&server.lsp, cx)
- .context("copilot setting change: did change configuration")
- .log_err();
+ let ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
+
+ if ai_disabled {
+ // Stop the server if AI is disabled
+ if !matches!(this.server, CopilotServer::Disabled) {
+ let shutdown = match mem::replace(&mut this.server, CopilotServer::Disabled) {
+ CopilotServer::Running(server) => {
+ let shutdown_future = server.lsp.shutdown();
+ Some(cx.background_spawn(async move {
+ if let Some(fut) = shutdown_future {
+ fut.await;
+ }
+ }))
+ }
+ _ => None,
+ };
+ if let Some(task) = shutdown {
+ task.detach();
+ }
+ cx.notify();
+ }
+ } else {
+ // Only start if AI is enabled
+ this.start_copilot(true, false, cx);
+ if let Ok(server) = this.server.as_running() {
+ notify_did_change_config_to_server(&server.lsp, cx)
+ .context("copilot setting change: did change configuration")
+ .log_err();
+ }
}
this.update_action_visibilities(cx);
})
@@ -431,6 +455,9 @@ impl Copilot {
awaiting_sign_in_after_start: bool,
cx: &mut Context,
) {
+ if DisableAiSettings::get_global(cx).disable_ai {
+ return;
+ }
if !matches!(self.server, CopilotServer::Disabled) {
return;
}
@@ -1443,13 +1470,120 @@ async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow::
#[cfg(test)]
mod tests {
use super::*;
+ use fs::FakeFs;
use gpui::TestAppContext;
+ use language::language_settings::AllLanguageSettings;
+ use node_runtime::NodeRuntime;
+ use settings::{Settings, SettingsStore};
use util::{
path,
paths::PathStyle,
rel_path::{RelPath, rel_path},
};
+ #[gpui::test]
+ async fn test_copilot_does_not_start_when_ai_disabled(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let store = SettingsStore::test(cx);
+ cx.set_global(store);
+ DisableAiSettings::register(cx);
+ AllLanguageSettings::register(cx);
+
+ // Set disable_ai to true before creating Copilot
+ DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
+ });
+
+ let copilot = cx.new(|cx| Copilot {
+ server_id: LanguageServerId(0),
+ fs: FakeFs::new(cx.background_executor().clone()),
+ node_runtime: NodeRuntime::unavailable(),
+ server: CopilotServer::Disabled,
+ buffers: Default::default(),
+ _subscriptions: vec![],
+ });
+
+ // Try to start copilot - it should remain disabled
+ copilot.update(cx, |copilot, cx| {
+ copilot.start_copilot(false, false, cx);
+ });
+
+ // Verify the server is still disabled
+ copilot.read_with(cx, |copilot, _| {
+ assert!(
+ matches!(copilot.server, CopilotServer::Disabled),
+ "Copilot should not start when disable_ai is true"
+ );
+ });
+ }
+
+ #[gpui::test]
+ async fn test_copilot_stops_when_ai_becomes_disabled(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let store = SettingsStore::test(cx);
+ cx.set_global(store);
+ DisableAiSettings::register(cx);
+ AllLanguageSettings::register(cx);
+
+ // AI is initially enabled
+ DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
+ });
+
+ // Create a fake Copilot that's already running, with the settings observer
+ let (copilot, _lsp) = Copilot::fake(cx);
+
+ // Add the settings observer that handles disable_ai changes
+ copilot.update(cx, |_, cx| {
+ cx.observe_global::(move |this, cx| {
+ let ai_disabled = DisableAiSettings::get_global(cx).disable_ai;
+
+ if ai_disabled {
+ if !matches!(this.server, CopilotServer::Disabled) {
+ let shutdown = match mem::replace(&mut this.server, CopilotServer::Disabled)
+ {
+ CopilotServer::Running(server) => {
+ let shutdown_future = server.lsp.shutdown();
+ Some(cx.background_spawn(async move {
+ if let Some(fut) = shutdown_future {
+ fut.await;
+ }
+ }))
+ }
+ _ => None,
+ };
+ if let Some(task) = shutdown {
+ task.detach();
+ }
+ cx.notify();
+ }
+ }
+ })
+ .detach();
+ });
+
+ // Verify copilot is running
+ copilot.read_with(cx, |copilot, _| {
+ assert!(
+ matches!(copilot.server, CopilotServer::Running(_)),
+ "Copilot should be running initially"
+ );
+ });
+
+ // Now disable AI
+ cx.update(|cx| {
+ DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
+ });
+
+ // The settings observer should have stopped the server
+ cx.run_until_parked();
+
+ copilot.read_with(cx, |copilot, _| {
+ assert!(
+ matches!(copilot.server, CopilotServer::Disabled),
+ "Copilot should be disabled after disable_ai is set to true"
+ );
+ });
+ }
+
#[gpui::test(iterations = 10)]
async fn test_buffer_management(cx: &mut TestAppContext) {
init_test(cx);
@@ -1692,6 +1826,66 @@ mod tests {
}
}
+ #[gpui::test]
+ async fn test_copilot_starts_when_ai_becomes_enabled(cx: &mut TestAppContext) {
+ cx.update(|cx| {
+ let store = SettingsStore::test(cx);
+ cx.set_global(store);
+ DisableAiSettings::register(cx);
+ AllLanguageSettings::register(cx);
+
+ // AI is initially disabled
+ DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
+ });
+
+ let copilot = cx.new(|cx| Copilot {
+ server_id: LanguageServerId(0),
+ fs: FakeFs::new(cx.background_executor().clone()),
+ node_runtime: NodeRuntime::unavailable(),
+ server: CopilotServer::Disabled,
+ buffers: Default::default(),
+ _subscriptions: vec![],
+ });
+
+ // Verify copilot is disabled initially
+ copilot.read_with(cx, |copilot, _| {
+ assert!(
+ matches!(copilot.server, CopilotServer::Disabled),
+ "Copilot should be disabled initially"
+ );
+ });
+
+ // Try to start - should fail because AI is disabled
+ // Use check_edit_prediction_provider=false to skip provider check
+ copilot.update(cx, |copilot, cx| {
+ copilot.start_copilot(false, false, cx);
+ });
+
+ copilot.read_with(cx, |copilot, _| {
+ assert!(
+ matches!(copilot.server, CopilotServer::Disabled),
+ "Copilot should remain disabled when disable_ai is true"
+ );
+ });
+
+ // Now enable AI
+ cx.update(|cx| {
+ DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
+ });
+
+ // Try to start again - should work now
+ copilot.update(cx, |copilot, cx| {
+ copilot.start_copilot(false, false, cx);
+ });
+
+ copilot.read_with(cx, |copilot, _| {
+ assert!(
+ matches!(copilot.server, CopilotServer::Starting { .. }),
+ "Copilot should be starting after disable_ai is set to false"
+ );
+ });
+ }
+
fn init_test(cx: &mut TestAppContext) {
zlog::init_test();
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