Merge remote-tracking branch 'origin/main' into AI-119/fix-visual-test-panics

Richard Feldman created

Change summary

Cargo.lock                                              | 466 +++---
Cargo.toml                                              |   8 
assets/icons/maximize_alt.svg                           |   6 
assets/icons/thread_import.svg                          |   5 
assets/settings/default.json                            |   4 
crates/acp_tools/src/acp_tools.rs                       |   9 
crates/agent_ui/src/agent_panel.rs                      | 442 +++++-
crates/agent_ui/src/completion_provider.rs              |  73 
crates/agent_ui/src/config_options.rs                   |   2 
crates/agent_ui/src/conversation_view/thread_view.rs    |  33 
crates/agent_ui/src/thread_import.rs                    |  49 
crates/agent_ui/src/thread_metadata_store.rs            | 119 +
crates/agent_ui/src/threads_archive_view.rs             | 685 +++++++++-
crates/agent_ui/src/ui.rs                               |   2 
crates/agent_ui/src/ui/claude_agent_onboarding_modal.rs | 261 ----
crates/agent_ui/src/ui/model_selector_components.rs     |   2 
crates/csv_preview/src/csv_preview.rs                   |  46 
crates/csv_preview/src/parser.rs                        |   5 
crates/csv_preview/src/renderer/render_table.rs         |  43 
crates/csv_preview/src/renderer/row_identifiers.rs      |   1 
crates/csv_preview/src/renderer/table_cell.rs           |   1 
crates/diagnostics/src/diagnostic_renderer.rs           |   5 
crates/editor/src/code_context_menus.rs                 |   5 
crates/editor/src/editor_tests.rs                       |  85 +
crates/editor/src/element.rs                            |  12 
crates/editor/src/hover_popover.rs                      |   8 
crates/editor/src/signature_help.rs                     |   8 
crates/extension_host/src/wasm_host.rs                  |  47 
crates/extension_host/src/wasm_host/wit.rs              |   8 
crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs |  14 
crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs |  14 
crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs |  14 
crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs |  14 
crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs |  14 
crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs |  14 
crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs |  14 
crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs |  14 
crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs |  14 
crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs |  14 
crates/git_graph/src/git_graph.rs                       |  64 
crates/git_ui/src/branch_picker.rs                      |  18 
crates/git_ui/src/stash_picker.rs                       |  54 
crates/git_ui/src/worktree_picker.rs                    |  30 
crates/gpui/examples/input.rs                           |  14 
crates/gpui/src/platform.rs                             |   2 
crates/gpui/src/window.rs                               |   6 
crates/gpui_linux/src/linux/wayland/client.rs           |   4 
crates/gpui_linux/src/linux/wayland/window.rs           |  12 
crates/gpui_linux/src/linux/x11/window.rs               |   5 
crates/gpui_macos/Cargo.toml                            |   1 
crates/gpui_macos/src/window.rs                         |   5 
crates/gpui_windows/src/window.rs                       |   9 
crates/icons/src/icons.rs                               |   2 
crates/keymap_editor/src/keymap_editor.rs               |  55 
crates/languages/src/eslint.rs                          |   1 
crates/markdown/src/html/html_rendering.rs              |  11 
crates/markdown/src/markdown.rs                         |  71 
crates/markdown/src/mermaid.rs                          |  11 
crates/markdown_preview/src/markdown_preview_view.rs    |  92 +
crates/migrator/src/migrations.rs                       |   6 
crates/migrator/src/migrations/m_2026_03_31/settings.rs |  29 
crates/migrator/src/migrator.rs                         | 109 -
crates/recent_projects/src/recent_projects.rs           |  27 
crates/recent_projects/src/remote_servers.rs            |  40 
crates/rules_library/src/rules_library.rs               |   2 
crates/settings_content/src/agent.rs                    |   7 
crates/settings_ui/src/page_data.rs                     |   2 
crates/sidebar/src/sidebar.rs                           |  44 
crates/tab_switcher/src/tab_switcher.rs                 |   2 
crates/tasks_ui/src/modal.rs                            |   2 
crates/title_bar/src/onboarding_banner.rs               |  26 
crates/title_bar/src/title_bar.rs                       |  21 
crates/ui/src/components/data_table.rs                  | 672 +++++-----
crates/ui/src/components/data_table/tests.rs            |   4 
crates/ui/src/components/list/list_item.rs              |  75 
crates/vim/src/helix.rs                                 |   1 
crates/vim/src/normal/search.rs                         |  41 
crates/vim/src/state.rs                                 |   1 
crates/vim/src/vim.rs                                   |  10 
crates/workspace/src/multi_workspace.rs                 | 175 --
crates/workspace/src/multi_workspace_tests.rs           | 172 ++
crates/workspace/src/notifications.rs                   |   5 
crates/workspace/src/workspace.rs                       |   2 
crates/zed/src/zed/telemetry_log.rs                     |   9 
crates/zed_actions/src/lib.rs                           |   2 
85 files changed, 2,722 insertions(+), 1,811 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -111,22 +111,13 @@ dependencies = [
  "workspace",
 ]
 
-[[package]]
-name = "addr2line"
-version = "0.24.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
-dependencies = [
- "gimli 0.31.1",
-]
-
 [[package]]
 name = "addr2line"
 version = "0.25.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
 dependencies = [
- "gimli 0.32.3",
+ "gimli",
 ]
 
 [[package]]
@@ -674,7 +665,7 @@ version = "0.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b"
 dependencies = [
- "object 0.37.3",
+ "object",
 ]
 
 [[package]]
@@ -1821,11 +1812,11 @@ version = "0.3.76"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
 dependencies = [
- "addr2line 0.25.1",
+ "addr2line",
  "cfg-if",
  "libc",
  "miniz_oxide",
- "object 0.37.3",
+ "object",
  "rustc-demangle",
  "windows-link 0.2.1",
 ]
@@ -3818,36 +3809,36 @@ dependencies = [
 
 [[package]]
 name = "cranelift-assembler-x64"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5023e06632d8f351c2891793ccccfe4aef957954904392434038745fb6f1f68"
+checksum = "ba33ddc4e157cb1abe9da6c821e8824f99e56d057c2c22536850e0141f281d61"
 dependencies = [
  "cranelift-assembler-x64-meta",
 ]
 
 [[package]]
 name = "cranelift-assembler-x64-meta"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1c4012b4c8c1f6eb05c0a0a540e3e1ee992631af51aa2bbb3e712903ce4fd65"
+checksum = "69b23dd6ea360e6fb28a3f3b40b7f126509668f58076a4729b2cfd656f26a0ad"
 dependencies = [
  "cranelift-srcgen",
 ]
 
 [[package]]
 name = "cranelift-bforest"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4d6d883b4942ef3a7104096b8bc6f2d1a41393f159ac8de12aed27b25d67f895"
+checksum = "a9d81afcee8fe27ee2536987df3fadcb2e161af4edb7dbe3ef36838d0ce74382"
 dependencies = [
  "cranelift-entity",
 ]
 
 [[package]]
 name = "cranelift-bitset"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db7b2ee9eec6ca8a716d900d5264d678fb2c290c58c46c8da7f94ee268175d17"
+checksum = "fb33595f1279fe7af03b28245060e9085caf98b10ed3137461a85796eb83972a"
 dependencies = [
  "serde",
  "serde_derive",
@@ -3855,9 +3846,9 @@ dependencies = [
 
 [[package]]
 name = "cranelift-codegen"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aeda0892577afdce1ac2e9a983a55f8c5b87a59334e1f79d8f735a2d7ba4f4b4"
+checksum = "0230a6ac0660bfe31eb244cbb43dcd4f2b3c1c4e0addc3e0348c6053ea60272e"
 dependencies = [
  "bumpalo",
  "cranelift-assembler-x64",
@@ -3868,7 +3859,7 @@ dependencies = [
  "cranelift-control",
  "cranelift-entity",
  "cranelift-isle",
- "gimli 0.31.1",
+ "gimli",
  "hashbrown 0.15.5",
  "log",
  "postcard",
@@ -3880,40 +3871,42 @@ dependencies = [
  "sha2",
  "smallvec",
  "target-lexicon 0.13.3",
+ "wasmtime-internal-math",
 ]
 
 [[package]]
 name = "cranelift-codegen-meta"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e461480d87f920c2787422463313326f67664e68108c14788ba1676f5edfcd15"
+checksum = "96d6817fdc15cb8f236fc9d8e610767d3a03327ceca4abff7a14d8e2154c405e"
 dependencies = [
  "cranelift-assembler-x64-meta",
  "cranelift-codegen-shared",
  "cranelift-srcgen",
+ "heck 0.5.0",
  "pulley-interpreter",
 ]
 
 [[package]]
 name = "cranelift-codegen-shared"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "976584d09f200c6c84c4b9ff7af64fc9ad0cb64dffa5780991edd3fe143a30a1"
+checksum = "0403796328e9e2e7df2b80191cdbb473fd9ea3889eb45ef5632d0fef168ea032"
 
 [[package]]
 name = "cranelift-control"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46d43d70f4e17c545aa88dbf4c84d4200755d27c6e3272ebe4de65802fa6a955"
+checksum = "188f04092279a3814e0b6235c2f9c2e34028e4beb72da7bfed55cbd184702bcc"
 dependencies = [
  "arbitrary",
 ]
 
 [[package]]
 name = "cranelift-entity"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d75418674520cb400c8772bfd6e11a62736c78fc1b6e418195696841d1bf91f1"
+checksum = "43f5e7391167605d505fe66a337e1a69583b3f34b63d359ffa5a430313c555e8"
 dependencies = [
  "cranelift-bitset",
  "serde",
@@ -3922,9 +3915,9 @@ dependencies = [
 
 [[package]]
 name = "cranelift-frontend"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c8b1a91c86687a344f3c52dd6dfb6e50db0dfa7f2e9c7711b060b3623e1fdeb"
+checksum = "ea5440792eb2b5ba0a0976df371b9f94031bd853ae56f389de610bca7128a7cb"
 dependencies = [
  "cranelift-codegen",
  "log",
@@ -3934,15 +3927,15 @@ dependencies = [
 
 [[package]]
 name = "cranelift-isle"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "711baa4e3432d4129295b39ec2b4040cc1b558874ba0a37d08e832e857db7285"
+checksum = "1e5c05fab6fce38d729088f3fa1060eaa1ad54eefd473588887205ed2ab2f79e"
 
 [[package]]
 name = "cranelift-native"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41c83e8666e3bcc5ffeaf6f01f356f0e1f9dcd69ce5511a1efd7ca5722001a3f"
+checksum = "9c9a0607a028edf5ba5bba7e7cf5ca1b7f0a030e3ae84dcd401e8b9b05192280"
 dependencies = [
  "cranelift-codegen",
  "libc",
@@ -3951,9 +3944,9 @@ dependencies = [
 
 [[package]]
 name = "cranelift-srcgen"
-version = "0.120.2"
+version = "0.123.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02e3f4d783a55c64266d17dc67d2708852235732a100fc40dd9f1051adc64d7b"
+checksum = "cb0f2da72eb2472aaac6cfba4e785af42b1f2d82f5155f30c9c30e8cce351e17"
 
 [[package]]
 name = "crash-context"
@@ -7054,21 +7047,15 @@ dependencies = [
 
 [[package]]
 name = "gimli"
-version = "0.31.1"
+version = "0.32.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
+checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
 dependencies = [
  "fallible-iterator",
  "indexmap",
  "stable_deref_trait",
 ]
 
-[[package]]
-name = "gimli"
-version = "0.32.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
-
 [[package]]
 name = "gio-sys"
 version = "0.21.5"
@@ -7614,6 +7601,7 @@ dependencies = [
  "media",
  "metal",
  "objc",
+ "objc2-app-kit",
  "parking_lot",
  "pathfinder_geometry",
  "raw-window-handle",
@@ -11224,6 +11212,16 @@ dependencies = [
  "objc2-encode",
 ]
 
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
 [[package]]
 name = "objc2-audio-toolbox"
 version = "0.3.2"
@@ -11361,9 +11359,9 @@ dependencies = [
 
 [[package]]
 name = "object"
-version = "0.36.7"
+version = "0.37.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
+checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
 dependencies = [
  "crc32fast",
  "hashbrown 0.15.5",
@@ -11371,15 +11369,6 @@ dependencies = [
  "memchr",
 ]
 
-[[package]]
-name = "object"
-version = "0.37.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
-dependencies = [
- "memchr",
-]
-
 [[package]]
 name = "ollama"
 version = "0.1.0"
@@ -13518,13 +13507,25 @@ checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3"
 
 [[package]]
 name = "pulley-interpreter"
-version = "33.0.2"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "986beaef947a51d17b42b0ea18ceaa88450d35b6994737065ed505c39172db71"
+checksum = "499d922aa0f9faac8d92351416664f1b7acd914008a90fce2f0516d31efddf67"
 dependencies = [
  "cranelift-bitset",
  "log",
- "wasmtime-math",
+ "pulley-macros",
+ "wasmtime-internal-math",
+]
+
+[[package]]
+name = "pulley-macros"
+version = "36.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3848fb193d6dffca43a21f24ca9492f22aab88af1223d06bac7f8a0ef405b81"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
 ]
 
 [[package]]
@@ -16194,12 +16195,6 @@ dependencies = [
  "der 0.7.10",
 ]
 
-[[package]]
-name = "sptr"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
-
 [[package]]
 name = "sqlez"
 version = "0.1.0"
@@ -18242,17 +18237,6 @@ dependencies = [
  "windows-targets 0.52.6",
 ]
 
-[[package]]
-name = "trait-variant"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
 [[package]]
 name = "transpose"
 version = "0.2.3"
@@ -18265,9 +18249,9 @@ dependencies = [
 
 [[package]]
 name = "tree-sitter"
-version = "0.26.3"
+version = "0.26.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "974d205cc395652cfa8b37daa053fe56eebd429acf8dc055503fee648dae981e"
+checksum = "887bd495d0582c5e3e0d8ece2233666169fa56a9644d172fc22ad179ab2d0538"
 dependencies = [
  "cc",
  "regex",
@@ -19360,12 +19344,12 @@ dependencies = [
 
 [[package]]
 name = "wasm-encoder"
-version = "0.229.0"
+version = "0.236.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38ba1d491ecacb085a2552025c10a675a6fddcbd03b1fc9b36c536010ce265d2"
+checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7"
 dependencies = [
  "leb128fmt",
- "wasmparser 0.229.0",
+ "wasmparser 0.236.1",
 ]
 
 [[package]]
@@ -19488,9 +19472,9 @@ dependencies = [
 
 [[package]]
 name = "wasmparser"
-version = "0.229.0"
+version = "0.236.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0cc3b1f053f5d41aa55640a1fa9b6d1b8a9e4418d118ce308d20e24ff3575a8c"
+checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7"
 dependencies = [
  "bitflags 2.10.0",
  "hashbrown 0.15.5",
@@ -19513,22 +19497,22 @@ dependencies = [
 
 [[package]]
 name = "wasmprinter"
-version = "0.229.0"
+version = "0.236.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d25dac01892684a99b8fbfaf670eb6b56edea8a096438c75392daeb83156ae2e"
+checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1"
 dependencies = [
  "anyhow",
  "termcolor",
- "wasmparser 0.229.0",
+ "wasmparser 0.236.1",
 ]
 
 [[package]]
 name = "wasmtime"
-version = "33.0.2"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57373e1d8699662fb791270ac5dfac9da5c14f618ecf940cdb29dc3ad9472a3c"
+checksum = "6a2f8736ddc86e03a9d0e4c477a37939cfc53cd1b052ee38a3133679b87ef830"
 dependencies = [
- "addr2line 0.24.2",
+ "addr2line",
  "anyhow",
  "async-trait",
  "bitflags 2.10.0",
@@ -19542,10 +19526,9 @@ dependencies = [
  "log",
  "mach2 0.4.3",
  "memfd",
- "object 0.36.7",
+ "object",
  "once_cell",
  "postcard",
- "psm",
  "pulley-interpreter",
  "rayon",
  "rustix 1.1.2",
@@ -19553,82 +19536,109 @@ dependencies = [
  "serde",
  "serde_derive",
  "smallvec",
- "sptr",
  "target-lexicon 0.13.3",
- "trait-variant",
- "wasmparser 0.229.0",
- "wasmtime-asm-macros",
- "wasmtime-component-macro",
- "wasmtime-component-util",
- "wasmtime-cranelift",
+ "wasmparser 0.236.1",
  "wasmtime-environ",
- "wasmtime-fiber",
- "wasmtime-jit-icache-coherence",
- "wasmtime-math",
- "wasmtime-slab",
- "wasmtime-versioned-export-macros",
- "wasmtime-winch",
- "windows-sys 0.59.0",
+ "wasmtime-internal-asm-macros",
+ "wasmtime-internal-component-macro",
+ "wasmtime-internal-component-util",
+ "wasmtime-internal-cranelift",
+ "wasmtime-internal-fiber",
+ "wasmtime-internal-jit-debug",
+ "wasmtime-internal-jit-icache-coherence",
+ "wasmtime-internal-math",
+ "wasmtime-internal-slab",
+ "wasmtime-internal-unwinder",
+ "wasmtime-internal-versioned-export-macros",
+ "wasmtime-internal-winch",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
-name = "wasmtime-asm-macros"
-version = "33.0.2"
+name = "wasmtime-c-api-impl"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd0fc91372865167a695dc98d0d6771799a388a7541d3f34e939d0539d6583de"
+checksum = "f3c62ea3fa30e6b0cf61116b3035121b8f515c60ac118ebfdab2ee56d028ed1e"
 dependencies = [
- "cfg-if",
+ "anyhow",
+ "log",
+ "tracing",
+ "wasmtime",
+ "wasmtime-internal-c-api-macros",
 ]
 
 [[package]]
-name = "wasmtime-c-api-impl"
-version = "33.0.2"
+name = "wasmtime-environ"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46db556f1dccdd88e0672bd407162ab0036b72e5eccb0f4398d8251cba32dba1"
+checksum = "733682a327755c77153ac7455b1ba8f2db4d9946c1738f8002fe1fbda1d52e83"
 dependencies = [
  "anyhow",
+ "cpp_demangle",
+ "cranelift-bitset",
+ "cranelift-entity",
+ "gimli",
+ "indexmap",
  "log",
- "tracing",
- "wasmtime",
- "wasmtime-c-api-macros",
+ "object",
+ "postcard",
+ "rustc-demangle",
+ "semver",
+ "serde",
+ "serde_derive",
+ "smallvec",
+ "target-lexicon 0.13.3",
+ "wasm-encoder 0.236.1",
+ "wasmparser 0.236.1",
+ "wasmprinter",
+ "wasmtime-internal-component-util",
 ]
 
 [[package]]
-name = "wasmtime-c-api-macros"
-version = "33.0.2"
+name = "wasmtime-internal-asm-macros"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "315cc6bc8cdc66f296accb26d7625ae64c1c7b6da6f189e8a72ce6594bf7bd36"
+checksum = "68288980a2e02bcb368d436da32565897033ea21918007e3f2bae18843326cf9"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "wasmtime-internal-c-api-macros"
+version = "36.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c8c61294155a6d23c202f08cf7a2f9392a866edd50517508208818be626ce9f"
 dependencies = [
  "proc-macro2",
  "quote",
 ]
 
 [[package]]
-name = "wasmtime-component-macro"
-version = "33.0.2"
+name = "wasmtime-internal-component-macro"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25c9c7526675ff9a9794b115023c4af5128e3eb21389bfc3dc1fd344d549258f"
+checksum = "5dea846da68f8e776c8a43bde3386022d7bb74e713b9654f7c0196e5ff2e4684"
 dependencies = [
  "anyhow",
  "proc-macro2",
  "quote",
  "syn 2.0.117",
- "wasmtime-component-util",
- "wasmtime-wit-bindgen",
- "wit-parser 0.229.0",
+ "wasmtime-internal-component-util",
+ "wasmtime-internal-wit-bindgen",
+ "wit-parser 0.236.1",
 ]
 
 [[package]]
-name = "wasmtime-component-util"
-version = "33.0.2"
+name = "wasmtime-internal-component-util"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc42ec8b078875804908d797cb4950fec781d9add9684c9026487fd8eb3f6291"
+checksum = "fe1e5735b3c8251510d2a55311562772d6c6fca9438a3d0329eb6e38af4957d6"
 
 [[package]]
-name = "wasmtime-cranelift"
-version = "33.0.2"
+name = "wasmtime-internal-cranelift"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2bd72f0a6a0ffcc6a184ec86ac35c174e48ea0e97bbae277c8f15f8bf77a566"
+checksum = "e89bb9ef571288e2be6b8a3c4763acc56c348dcd517500b1679d3ffad9e4a757"
 dependencies = [
  "anyhow",
  "cfg-if",
@@ -19637,104 +19647,132 @@ dependencies = [
  "cranelift-entity",
  "cranelift-frontend",
  "cranelift-native",
- "gimli 0.31.1",
+ "gimli",
  "itertools 0.14.0",
  "log",
- "object 0.36.7",
+ "object",
  "pulley-interpreter",
  "smallvec",
  "target-lexicon 0.13.3",
  "thiserror 2.0.17",
- "wasmparser 0.229.0",
+ "wasmparser 0.236.1",
  "wasmtime-environ",
- "wasmtime-versioned-export-macros",
+ "wasmtime-internal-math",
+ "wasmtime-internal-versioned-export-macros",
 ]
 
 [[package]]
-name = "wasmtime-environ"
-version = "33.0.2"
+name = "wasmtime-internal-fiber"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6187bb108a23eb25d2a92aa65d6c89fb5ed53433a319038a2558567f3011ff2"
+checksum = "b698d004b15ea1f1ae2d06e5e8b80080cbd684fd245220ce2fac3cdd5ecf87f2"
 dependencies = [
  "anyhow",
- "cpp_demangle",
- "cranelift-bitset",
- "cranelift-entity",
- "gimli 0.31.1",
- "indexmap",
- "log",
- "object 0.36.7",
- "postcard",
- "rustc-demangle",
- "semver",
- "serde",
- "serde_derive",
- "smallvec",
- "target-lexicon 0.13.3",
- "wasm-encoder 0.229.0",
- "wasmparser 0.229.0",
- "wasmprinter",
- "wasmtime-component-util",
+ "cc",
+ "cfg-if",
+ "libc",
+ "rustix 1.1.2",
+ "wasmtime-internal-asm-macros",
+ "wasmtime-internal-versioned-export-macros",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
-name = "wasmtime-fiber"
-version = "33.0.2"
+name = "wasmtime-internal-jit-debug"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc8965d2128c012329f390e24b8b2758dd93d01bf67e1a1a0dd3d8fd72f56873"
+checksum = "c803a9fec05c3d7fa03474d4595079d546e77a3c71c1d09b21f74152e2165c17"
 dependencies = [
- "anyhow",
  "cc",
- "cfg-if",
- "rustix 1.1.2",
- "wasmtime-asm-macros",
- "wasmtime-versioned-export-macros",
- "windows-sys 0.59.0",
+ "wasmtime-internal-versioned-export-macros",
 ]
 
 [[package]]
-name = "wasmtime-jit-icache-coherence"
-version = "33.0.2"
+name = "wasmtime-internal-jit-icache-coherence"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7af0e940cb062a45c0b3f01a926f77da5947149e99beb4e3dd9846d5b8f11619"
+checksum = "d3866909d37f7929d902e6011847748147e8734e9d7e0353e78fb8b98f586aee"
 dependencies = [
  "anyhow",
  "cfg-if",
  "libc",
- "windows-sys 0.59.0",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
-name = "wasmtime-math"
-version = "33.0.2"
+name = "wasmtime-internal-math"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "acfca360e719dda9a27e26944f2754ff2fd5bad88e21919c42c5a5f38ddd93cb"
+checksum = "5a23b03fb14c64bd0dfcaa4653101f94ade76c34a3027ed2d6b373267536e45b"
 dependencies = [
  "libm",
 ]
 
 [[package]]
-name = "wasmtime-slab"
-version = "33.0.2"
+name = "wasmtime-internal-slab"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48e240559cada55c4b24af979d5f6c95e0029f5772f32027ec3c62b258aaff65"
+checksum = "fbff220b88cdb990d34a20b13344e5da2e7b99959a5b1666106bec94b58d6364"
 
 [[package]]
-name = "wasmtime-versioned-export-macros"
-version = "33.0.2"
+name = "wasmtime-internal-unwinder"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0963c1438357a3d8c0efe152b4ef5259846c1cf8b864340270744fe5b3bae5e"
+checksum = "13e1ad30e88988b20c0d1c56ea4b4fbc01a8c614653cbf12ca50c0dcc695e2f7"
+dependencies = [
+ "anyhow",
+ "cfg-if",
+ "cranelift-codegen",
+ "log",
+ "object",
+]
+
+[[package]]
+name = "wasmtime-internal-versioned-export-macros"
+version = "36.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "549aefdaa1398c2fcfbf69a7b882956bb5b6e8e5b600844ecb91a3b5bf658ca7"
 dependencies = [
  "proc-macro2",
  "quote",
  "syn 2.0.117",
 ]
 
+[[package]]
+name = "wasmtime-internal-winch"
+version = "36.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cc96a84c5700171aeecf96fa9a9ab234f333f5afb295dabf3f8a812b70fe832"
+dependencies = [
+ "anyhow",
+ "cranelift-codegen",
+ "gimli",
+ "object",
+ "target-lexicon 0.13.3",
+ "wasmparser 0.236.1",
+ "wasmtime-environ",
+ "wasmtime-internal-cranelift",
+ "winch-codegen",
+]
+
+[[package]]
+name = "wasmtime-internal-wit-bindgen"
+version = "36.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c28dc9efea511598c88564ac1974e0825c07d9c0de902dbf68f227431cd4ff8c"
+dependencies = [
+ "anyhow",
+ "bitflags 2.10.0",
+ "heck 0.5.0",
+ "indexmap",
+ "wit-parser 0.236.1",
+]
+
 [[package]]
 name = "wasmtime-wasi"
-version = "33.0.2"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ae951b72c7c6749a1c15dcdfb6d940a2614c932b4a54f474636e78e2c744b4c"
+checksum = "c3c2e99fbaa0c26b4680e0c9af07e3f7b25f5fbc1ad97dd34067980bd027d3e5"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -19758,14 +19796,14 @@ dependencies = [
  "wasmtime",
  "wasmtime-wasi-io",
  "wiggle",
- "windows-sys 0.59.0",
+ "windows-sys 0.60.2",
 ]
 
 [[package]]
 name = "wasmtime-wasi-io"
-version = "33.0.2"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a835790dcecc3d7051ec67da52ba9e04af25e1bc204275b9391e3f0042b10797"
+checksum = "de2dc367052562c228ce51ee4426330840433c29c0ea3349eca5ddeb475ecdb9"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -19774,35 +19812,6 @@ dependencies = [
  "wasmtime",
 ]
 
-[[package]]
-name = "wasmtime-winch"
-version = "33.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbc3b117d03d6eeabfa005a880c5c22c06503bb8820f3aa2e30f0e8d87b6752f"
-dependencies = [
- "anyhow",
- "cranelift-codegen",
- "gimli 0.31.1",
- "object 0.36.7",
- "target-lexicon 0.13.3",
- "wasmparser 0.229.0",
- "wasmtime-cranelift",
- "wasmtime-environ",
- "winch-codegen",
-]
-
-[[package]]
-name = "wasmtime-wit-bindgen"
-version = "33.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1382f4f09390eab0d75d4994d0c3b0f6279f86a571807ec67a8253c87cf6a145"
-dependencies = [
- "anyhow",
- "heck 0.5.0",
- "indexmap",
- "wit-parser 0.229.0",
-]
-
 [[package]]
 name = "wast"
 version = "35.0.2"
@@ -20257,9 +20266,9 @@ dependencies = [
 
 [[package]]
 name = "wiggle"
-version = "33.0.2"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "649c1aca13ef9e9dccf2d5efbbebf12025bc5521c3fb7754355ef60f5eb810be"
+checksum = "c13d1ae265bd6e5e608827d2535665453cae5cb64950de66e2d5767d3e32c43a"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -20272,9 +20281,9 @@ dependencies = [
 
 [[package]]
 name = "wiggle-generate"
-version = "33.0.2"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "164870fc34214ee42bd81b8ce9e7c179800fa1a7d4046d17a84e7f7bf422c8ad"
+checksum = "607c4966f6b30da20d24560220137cbd09df722f0558eac81c05624700af5e05"
 dependencies = [
  "anyhow",
  "heck 0.5.0",
@@ -20286,9 +20295,9 @@ dependencies = [
 
 [[package]]
 name = "wiggle-macro"
-version = "33.0.2"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d873bb5b59ca703b5e41562e96a4796d1af61bf4cf80bf8a7abda755a380ec1c"
+checksum = "fc36e39412fa35f7cc86b3705dbe154168721dd3e71f6dc4a726b266d5c60c55"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -20329,21 +20338,22 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
 
 [[package]]
 name = "winch-codegen"
-version = "33.0.2"
+version = "36.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7914c296fbcef59d1b89a15e82384d34dc9669bc09763f2ef068a28dd3a64ebf"
+checksum = "06c0ec09e8eb5e850e432da6271ed8c4a9d459a9db3850c38e98a3ee9d015e79"
 dependencies = [
  "anyhow",
  "cranelift-assembler-x64",
  "cranelift-codegen",
- "gimli 0.31.1",
+ "gimli",
  "regalloc2",
  "smallvec",
  "target-lexicon 0.13.3",
  "thiserror 2.0.17",
- "wasmparser 0.229.0",
- "wasmtime-cranelift",
+ "wasmparser 0.236.1",
  "wasmtime-environ",
+ "wasmtime-internal-cranelift",
+ "wasmtime-internal-math",
 ]
 
 [[package]]
@@ -21369,9 +21379,9 @@ dependencies = [
 
 [[package]]
 name = "wit-parser"
-version = "0.229.0"
+version = "0.236.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "459c6ba62bf511d6b5f2a845a2a736822e38059c1cfa0b644b467bbbfae4efa6"
+checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15"
 dependencies = [
  "anyhow",
  "id-arena",
@@ -21382,7 +21392,7 @@ dependencies = [
  "serde_derive",
  "serde_json",
  "unicode-xid",
- "wasmparser 0.229.0",
+ "wasmparser 0.236.1",
 ]
 
 [[package]]

Cargo.toml 🔗

@@ -604,6 +604,7 @@ nbformat = "1.2.0"
 nix = "0.29"
 num-format = "0.4.4"
 objc = "0.2"
+objc2-app-kit = { version = "0.3", default-features = false, features = [ "NSGraphics" ] }
 objc2-foundation = { version = "=0.3.2", default-features = false, features = [
     "NSArray",
     "NSAttributedString",
@@ -732,7 +733,7 @@ toml_edit = { version = "0.22", default-features = false, features = [
     "serde",
 ] }
 tower-http = "0.4.4"
-tree-sitter = { version = "0.26", features = ["wasm"] }
+tree-sitter = { version = "0.26.8", features = ["wasm"] }
 tree-sitter-bash = "0.25.1"
 tree-sitter-c = "0.24.1"
 tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" }
@@ -767,7 +768,7 @@ uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] }
 walkdir = "2.5"
 wasm-encoder = "0.221"
 wasmparser = "0.221"
-wasmtime = { version = "33", default-features = false, features = [
+wasmtime = { version = "36", default-features = false, features = [
     "async",
     "demangle",
     "runtime",
@@ -776,7 +777,7 @@ wasmtime = { version = "33", default-features = false, features = [
     "incremental-cache",
     "parallel-compilation",
 ] }
-wasmtime-wasi = "33"
+wasmtime-wasi = "36"
 wax = "0.7"
 which = "6.0.0"
 wasm-bindgen = "0.2.113"
@@ -821,6 +822,7 @@ features = [
     "Win32_System_Com",
     "Win32_System_Com_StructuredStorage",
     "Win32_System_Console",
+    "Win32_System_Diagnostics_Debug",
     "Win32_System_DataExchange",
     "Win32_System_IO",
     "Win32_System_LibraryLoader",

assets/icons/maximize_alt.svg 🔗

@@ -0,0 +1,6 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2.25 4.80555V3.52777C2.25 3.18889 2.38462 2.86388 2.62425 2.62425C2.86388 2.38462 3.18889 2.25 3.52777 2.25H4.80555" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M11.1945 2.25H12.4722C12.8111 2.25 13.1361 2.38462 13.3758 2.62425C13.6154 2.86388 13.75 3.18889 13.75 3.52777V4.80555" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M13.75 11.1945V12.4722C13.75 12.8111 13.6154 13.1361 13.3758 13.3758C13.1361 13.6154 12.8111 13.75 12.4722 13.75H11.1945" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.80555 13.75H3.52777C3.18889 13.75 2.86388 13.6154 2.62425 13.3758C2.38462 13.1361 2.25 12.8111 2.25 12.4722V11.1945" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/icons/thread_import.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.31947 5.03803L8.31947 9.28259" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6.19576 7.67419L8.31948 9.79792L10.4432 7.67419" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M5.64894 12.8952C6.89401 13.5339 8.32626 13.7069 9.68759 13.383C11.0489 13.0592 12.2499 12.2598 13.0739 11.1288C13.8979 9.99787 14.291 8.60973 14.1821 7.21464C14.0733 5.81955 13.4698 4.5092 12.4803 3.51972C11.4908 2.53024 10.1805 1.92671 8.78535 1.81787C7.39026 1.70904 6.00218 2.10207 4.87122 2.92612C3.74026 3.75018 2.94082 4.95106 2.61695 6.3124C2.29307 7.67374 2.46606 9.10598 3.10475 10.3511L1.80005 14.1999L5.64894 12.8952Z" stroke="#C6CAD0" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/settings/default.json 🔗

@@ -1117,8 +1117,8 @@
     "expand_terminal_card": true,
     // How thinking blocks should be displayed by default in the agent panel.
     //
-    // Default: automatic
-    "thinking_display": "automatic",
+    // Default: auto
+    "thinking_display": "auto",
     // Whether clicking the stop button on a running terminal tool should also cancel the agent's generation.
     // Note that this only applies to the stop button, not to ctrl+c inside the terminal.
     //

crates/acp_tools/src/acp_tools.rs 🔗

@@ -13,7 +13,7 @@ use gpui::{
     StyleRefinement, Subscription, Task, TextStyleRefinement, Window, actions, list, prelude::*,
 };
 use language::LanguageRegistry;
-use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle};
+use markdown::{CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle};
 use project::{AgentId, Project};
 use settings::Settings;
 use theme_settings::ThemeSettings;
@@ -384,8 +384,11 @@ impl AcpTools {
                             )
                             .code_block_renderer(
                                 CodeBlockRenderer::Default {
-                                    copy_button: false,
-                                    copy_button_on_hover: expanded,
+                                    copy_button_visibility: if expanded {
+                                        CopyButtonVisibility::VisibleOnHover
+                                    } else {
+                                        CopyButtonVisibility::Hidden
+                                    },
                                     border: false,
                                 },
                             ),

crates/agent_ui/src/agent_panel.rs 🔗

@@ -21,8 +21,8 @@ use settings::{LanguageModelProviderSetting, LanguageModelSelection};
 
 use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt as _};
 use zed_actions::agent::{
-    AddSelectionToThread, ConflictContent, OpenClaudeAgentOnboardingModal, ReauthenticateAgent,
-    ResolveConflictedFilesWithAgent, ResolveConflictsWithAgent, ReviewBranchDiff,
+    AddSelectionToThread, ConflictContent, ReauthenticateAgent, ResolveConflictedFilesWithAgent,
+    ResolveConflictsWithAgent, ReviewBranchDiff,
 };
 
 use crate::{
@@ -40,7 +40,7 @@ use crate::{
 };
 use crate::{
     DEFAULT_THREAD_TITLE,
-    ui::{AcpOnboardingModal, ClaudeCodeOnboardingModal, HoldForDefault},
+    ui::{AcpOnboardingModal, HoldForDefault},
 };
 use crate::{ExpandMessageEditor, ThreadHistoryView};
 use crate::{ManageProfiles, ThreadHistoryViewEvent};
@@ -66,7 +66,10 @@ use project::project_settings::ProjectSettings;
 use project::{Project, ProjectPath, Worktree};
 use prompt_store::{PromptStore, UserPromptId};
 use rules_library::{RulesLibrary, open_rules_library};
+use settings::TerminalDockPosition;
 use settings::{Settings, update_settings_file};
+use terminal::terminal_settings::TerminalSettings;
+use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
 use theme_settings::ThemeSettings;
 use ui::{
     Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide,
@@ -86,6 +89,30 @@ use zed_actions::{
 
 const AGENT_PANEL_KEY: &str = "agent_panel";
 const RECENTLY_UPDATED_MENU_LIMIT: usize = 6;
+const LAST_USED_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
+
+#[derive(Serialize, Deserialize)]
+struct LastUsedAgent {
+    agent: Agent,
+}
+
+/// Reads the most recently used agent across all workspaces. Used as a fallback
+/// when opening a workspace that has no per-workspace agent preference yet.
+fn read_global_last_used_agent(kvp: &KeyValueStore) -> Option<Agent> {
+    kvp.read_kvp(LAST_USED_AGENT_KEY)
+        .log_err()
+        .flatten()
+        .and_then(|json| serde_json::from_str::<LastUsedAgent>(&json).log_err())
+        .map(|entry| entry.agent)
+}
+
+async fn write_global_last_used_agent(kvp: KeyValueStore, agent: Agent) {
+    if let Some(json) = serde_json::to_string(&LastUsedAgent { agent }).log_err() {
+        kvp.write_kvp(LAST_USED_AGENT_KEY.to_string(), json)
+            .await
+            .log_err();
+    }
+}
 
 fn read_serialized_panel(
     workspace_id: workspace::WorkspaceId,
@@ -245,11 +272,6 @@ pub fn init(cx: &mut App) {
                 .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| {
                     AcpOnboardingModal::toggle(workspace, window, cx)
                 })
-                .register_action(
-                    |workspace, _: &OpenClaudeAgentOnboardingModal, window, cx| {
-                        ClaudeCodeOnboardingModal::toggle(workspace, window, cx)
-                    },
-                )
                 .register_action(|_workspace, _: &ResetOnboarding, window, cx| {
                     window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
                     window.refresh();
@@ -404,6 +426,48 @@ pub fn init(cx: &mut App) {
                 })
                 .register_action(
                     |workspace: &mut Workspace, _: &AddSelectionToThread, window, cx| {
+                        let active_editor = workspace
+                            .active_item(cx)
+                            .and_then(|item| item.act_as::<Editor>(cx));
+                        let has_editor_selection = active_editor.is_some_and(|editor| {
+                            editor.update(cx, |editor, cx| {
+                                editor.has_non_empty_selection(&editor.display_snapshot(cx))
+                            })
+                        });
+
+                        let has_terminal_selection = workspace
+                            .active_item(cx)
+                            .and_then(|item| item.act_as::<TerminalView>(cx))
+                            .is_some_and(|terminal_view| {
+                                terminal_view
+                                    .read(cx)
+                                    .terminal()
+                                    .read(cx)
+                                    .last_content
+                                    .selection_text
+                                    .as_ref()
+                                    .is_some_and(|text| !text.is_empty())
+                            });
+
+                        let has_terminal_panel_selection =
+                            workspace.panel::<TerminalPanel>(cx).is_some_and(|panel| {
+                                let position = match TerminalSettings::get_global(cx).dock {
+                                    TerminalDockPosition::Left => DockPosition::Left,
+                                    TerminalDockPosition::Bottom => DockPosition::Bottom,
+                                    TerminalDockPosition::Right => DockPosition::Right,
+                                };
+                                let dock_is_open =
+                                    workspace.dock_at_position(position).read(cx).is_open();
+                                dock_is_open && !panel.read(cx).terminal_selections(cx).is_empty()
+                            });
+
+                        if !has_editor_selection
+                            && !has_terminal_selection
+                            && !has_terminal_panel_selection
+                        {
+                            return;
+                        }
+
                         let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
                             return;
                         };
@@ -670,13 +734,18 @@ impl AgentPanel {
                 .ok()
                 .flatten();
 
-            let serialized_panel = cx
+            let (serialized_panel, global_last_used_agent) = cx
                 .background_spawn(async move {
-                    kvp.and_then(|kvp| {
-                        workspace_id
-                            .and_then(|id| read_serialized_panel(id, &kvp))
-                            .or_else(|| read_legacy_serialized_panel(&kvp))
-                    })
+                    match kvp {
+                        Some(kvp) => {
+                            let panel = workspace_id
+                                .and_then(|id| read_serialized_panel(id, &kvp))
+                                .or_else(|| read_legacy_serialized_panel(&kvp));
+                            let global_agent = read_global_last_used_agent(&kvp);
+                            (panel, global_agent)
+                        }
+                        None => (None, None),
+                    }
                 })
                 .await;
 
@@ -715,10 +784,21 @@ impl AgentPanel {
                 let panel =
                     cx.new(|cx| Self::new(workspace, prompt_store, window, cx));
 
-                if let Some(serialized_panel) = &serialized_panel {
-                    panel.update(cx, |panel, cx| {
+                panel.update(cx, |panel, cx| {
+                    let is_via_collab = panel.project.read(cx).is_via_collab();
+
+                    // Only apply a non-native global fallback to local projects.
+                    // Collab workspaces only support NativeAgent, so inheriting a
+                    // custom agent would cause set_active → new_agent_thread_inner
+                    // to bypass the collab guard in external_thread.
+                    let global_fallback = global_last_used_agent
+                        .filter(|agent| !is_via_collab || agent.is_native());
+
+                    if let Some(serialized_panel) = &serialized_panel {
                         if let Some(selected_agent) = serialized_panel.selected_agent.clone() {
                             panel.selected_agent = selected_agent;
+                        } else if let Some(agent) = global_fallback {
+                            panel.selected_agent = agent;
                         }
                         if let Some(start_thread_in) = serialized_panel.start_thread_in {
                             let is_worktree_flag_enabled =
@@ -739,9 +819,11 @@ impl AgentPanel {
                                 );
                             }
                         }
-                        cx.notify();
-                    });
-                }
+                    } else if let Some(agent) = global_fallback {
+                        panel.selected_agent = agent;
+                    }
+                    cx.notify();
+                });
 
                 if let Some(thread_info) = last_active_thread {
                     let agent = thread_info.agent_type.clone();
@@ -1074,85 +1156,30 @@ impl AgentPanel {
         let workspace = self.workspace.clone();
         let project = self.project.clone();
         let fs = self.fs.clone();
-        let is_via_collab = self.project.read(cx).is_via_collab();
-
-        const LAST_USED_EXTERNAL_AGENT_KEY: &str = "agent_panel__last_used_external_agent";
-
-        #[derive(Serialize, Deserialize)]
-        struct LastUsedExternalAgent {
-            agent: crate::Agent,
-        }
-
         let thread_store = self.thread_store.clone();
-        let kvp = KeyValueStore::global(cx);
-
-        if let Some(agent) = agent_choice {
-            cx.background_spawn({
-                let agent = agent.clone();
-                let kvp = kvp;
-                async move {
-                    if let Some(serialized) =
-                        serde_json::to_string(&LastUsedExternalAgent { agent }).log_err()
-                    {
-                        kvp.write_kvp(LAST_USED_EXTERNAL_AGENT_KEY.to_string(), serialized)
-                            .await
-                            .log_err();
-                    }
-                }
-            })
-            .detach();
-
-            let server = agent.server(fs, thread_store);
-            self.create_agent_thread(
-                server,
-                resume_session_id,
-                work_dirs,
-                title,
-                initial_content,
-                workspace,
-                project,
-                agent,
-                focus,
-                window,
-                cx,
-            );
-        } else {
-            cx.spawn_in(window, async move |this, cx| {
-                let ext_agent = if is_via_collab {
-                    Agent::NativeAgent
-                } else {
-                    cx.background_spawn(async move { kvp.read_kvp(LAST_USED_EXTERNAL_AGENT_KEY) })
-                        .await
-                        .log_err()
-                        .flatten()
-                        .and_then(|value| {
-                            serde_json::from_str::<LastUsedExternalAgent>(&value).log_err()
-                        })
-                        .map(|agent| agent.agent)
-                        .unwrap_or(Agent::NativeAgent)
-                };
 
-                let server = ext_agent.server(fs, thread_store);
-                this.update_in(cx, |agent_panel, window, cx| {
-                    agent_panel.create_agent_thread(
-                        server,
-                        resume_session_id,
-                        work_dirs,
-                        title,
-                        initial_content,
-                        workspace,
-                        project,
-                        ext_agent,
-                        focus,
-                        window,
-                        cx,
-                    );
-                })?;
+        let agent = agent_choice.unwrap_or_else(|| {
+            if self.project.read(cx).is_via_collab() {
+                Agent::NativeAgent
+            } else {
+                self.selected_agent.clone()
+            }
+        });
 
-                anyhow::Ok(())
-            })
-            .detach_and_log_err(cx);
-        }
+        let server = agent.server(fs, thread_store);
+        self.create_agent_thread(
+            server,
+            resume_session_id,
+            work_dirs,
+            title,
+            initial_content,
+            workspace,
+            project,
+            agent,
+            focus,
+            window,
+            cx,
+        );
     }
 
     fn deploy_rules_library(
@@ -2107,15 +2134,25 @@ impl AgentPanel {
         initial_content: Option<AgentInitialContent>,
         workspace: WeakEntity<Workspace>,
         project: Entity<Project>,
-        ext_agent: Agent,
+        agent: Agent,
         focus: bool,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if self.selected_agent != ext_agent {
-            self.selected_agent = ext_agent.clone();
+        if self.selected_agent != agent {
+            self.selected_agent = agent.clone();
             self.serialize(cx);
         }
+
+        cx.background_spawn({
+            let kvp = KeyValueStore::global(cx);
+            let agent = agent.clone();
+            async move {
+                write_global_last_used_agent(kvp, agent).await;
+            }
+        })
+        .detach();
+
         let thread_store = server
             .clone()
             .downcast::<agent::NativeAgentServer>()
@@ -2128,7 +2165,7 @@ impl AgentPanel {
             crate::ConversationView::new(
                 server,
                 connection_store,
-                ext_agent,
+                agent,
                 resume_session_id,
                 work_dirs,
                 title,
@@ -5616,4 +5653,211 @@ mod tests {
             "Thread A work_dirs should revert to only /project_a after removing /project_b"
         );
     }
+
+    #[gpui::test]
+    async fn test_new_workspace_inherits_global_last_used_agent(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);
+            // Use an isolated DB so parallel tests can't overwrite our global key.
+            cx.set_global(db::AppDatabase::test_new());
+        });
+
+        let custom_agent = Agent::Custom {
+            id: "my-preferred-agent".into(),
+        };
+
+        // Write a known agent to the global KVP to simulate a user who has
+        // previously used this agent in another workspace.
+        let kvp = cx.update(|cx| KeyValueStore::global(cx));
+        write_global_last_used_agent(kvp, custom_agent.clone()).await;
+
+        let fs = FakeFs::new(cx.executor());
+        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 = multi_workspace
+            .read_with(cx, |multi_workspace, _cx| {
+                multi_workspace.workspace().clone()
+            })
+            .unwrap();
+
+        workspace.update(cx, |workspace, _cx| {
+            workspace.set_random_database_id();
+        });
+
+        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
+
+        // Load the panel via `load()`, which reads the global fallback
+        // asynchronously when no per-workspace state exists.
+        let async_cx = cx.update(|window, cx| window.to_async(cx));
+        let panel = AgentPanel::load(workspace.downgrade(), async_cx)
+            .await
+            .expect("panel load should succeed");
+        cx.run_until_parked();
+
+        panel.read_with(cx, |panel, _cx| {
+            assert_eq!(
+                panel.selected_agent, custom_agent,
+                "new workspace should inherit the global last-used agent"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_workspaces_maintain_independent_agent_selection(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);
+        });
+
+        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);
+
+        let agent_a = Agent::Custom {
+            id: "agent-alpha".into(),
+        };
+        let agent_b = Agent::Custom {
+            id: "agent-beta".into(),
+        };
+
+        // Set up workspace A with agent_a
+        let panel_a = workspace_a.update_in(cx, |workspace, window, cx| {
+            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
+        });
+        panel_a.update(cx, |panel, _cx| {
+            panel.selected_agent = agent_a.clone();
+        });
+
+        // Set up workspace B with agent_b
+        let panel_b = workspace_b.update_in(cx, |workspace, window, cx| {
+            cx.new(|cx| AgentPanel::new(workspace, None, window, cx))
+        });
+        panel_b.update(cx, |panel, _cx| {
+            panel.selected_agent = agent_b.clone();
+        });
+
+        // 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 from serialized state and verify independence
+        let async_cx = cx.update(|window, cx| window.to_async(cx));
+        let loaded_a = AgentPanel::load(workspace_a.downgrade(), 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(), async_cx)
+            .await
+            .expect("panel B load should succeed");
+        cx.run_until_parked();
+
+        loaded_a.read_with(cx, |panel, _cx| {
+            assert_eq!(
+                panel.selected_agent, agent_a,
+                "workspace A should restore agent-alpha, not agent-beta"
+            );
+        });
+
+        loaded_b.read_with(cx, |panel, _cx| {
+            assert_eq!(
+                panel.selected_agent, agent_b,
+                "workspace B should restore agent-beta, not agent-alpha"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_new_thread_uses_workspace_selected_agent(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);
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        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 = multi_workspace
+            .read_with(cx, |multi_workspace, _cx| {
+                multi_workspace.workspace().clone()
+            })
+            .unwrap();
+
+        workspace.update(cx, |workspace, _cx| {
+            workspace.set_random_database_id();
+        });
+
+        let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx);
+
+        let custom_agent = Agent::Custom {
+            id: "my-custom-agent".into(),
+        };
+
+        let panel = workspace.update_in(cx, |workspace, window, cx| {
+            let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
+            workspace.add_panel(panel.clone(), window, cx);
+            panel
+        });
+
+        // Set selected_agent to a custom agent
+        panel.update(cx, |panel, _cx| {
+            panel.selected_agent = custom_agent.clone();
+        });
+
+        // Call new_thread, which internally calls external_thread(None, ...)
+        // This resolves the agent from self.selected_agent
+        panel.update_in(cx, |panel, window, cx| {
+            panel.new_thread(&NewThread, window, cx);
+        });
+
+        panel.read_with(cx, |panel, _cx| {
+            assert_eq!(
+                panel.selected_agent, custom_agent,
+                "selected_agent should remain the custom agent after new_thread"
+            );
+            assert!(
+                panel.active_conversation_view().is_some(),
+                "a thread should have been created"
+            );
+        });
+    }
 }

crates/agent_ui/src/completion_provider.rs 🔗

@@ -28,7 +28,7 @@ use prompt_store::{PromptStore, UserPromptId};
 use rope::Point;
 use settings::{Settings, TerminalDockPosition};
 use terminal::terminal_settings::TerminalSettings;
-use terminal_view::terminal_panel::TerminalPanel;
+use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
 use text::{Anchor, ToOffset as _, ToPoint as _};
 use ui::IconName;
 use ui::prelude::*;
@@ -562,8 +562,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
                     .collect();
 
                 // Collect terminal selections from all terminal views if the terminal panel is visible
-                let terminal_selections: Vec<String> =
-                    terminal_selections_if_panel_open(workspace, cx);
+                let terminal_selections: Vec<String> = terminal_selections(workspace, cx);
 
                 const EDITOR_PLACEHOLDER: &str = "selection ";
                 const TERMINAL_PLACEHOLDER: &str = "terminal ";
@@ -1198,7 +1197,7 @@ impl<T: PromptCompletionProviderDelegate> PromptCompletionProvider<T> {
                 })
             });
 
-        let has_terminal_selection = !terminal_selections_if_panel_open(workspace, cx).is_empty();
+        let has_terminal_selection = !terminal_selections(workspace, cx).is_empty();
 
         if has_editor_selection || has_terminal_selection {
             entries.push(PromptContextEntry::Action(
@@ -2169,28 +2168,45 @@ fn build_code_label_for_path(
     label.build()
 }
 
-/// Returns terminal selections from all terminal views if the terminal panel is open.
-fn terminal_selections_if_panel_open(workspace: &Entity<Workspace>, cx: &App) -> Vec<String> {
-    let Some(panel) = workspace.read(cx).panel::<TerminalPanel>(cx) else {
-        return Vec::new();
-    };
+fn terminal_selections(workspace: &Entity<Workspace>, cx: &App) -> Vec<String> {
+    let mut selections = Vec::new();
 
-    // Check if the dock containing this panel is open
-    let position = match TerminalSettings::get_global(cx).dock {
-        TerminalDockPosition::Left => DockPosition::Left,
-        TerminalDockPosition::Bottom => DockPosition::Bottom,
-        TerminalDockPosition::Right => DockPosition::Right,
-    };
-    let dock_is_open = workspace
+    // Check if the active item is a terminal (in a panel or not)
+    if let Some(terminal_view) = workspace
         .read(cx)
-        .dock_at_position(position)
-        .read(cx)
-        .is_open();
-    if !dock_is_open {
-        return Vec::new();
+        .active_item(cx)
+        .and_then(|item| item.act_as::<TerminalView>(cx))
+    {
+        if let Some(text) = terminal_view
+            .read(cx)
+            .terminal()
+            .read(cx)
+            .last_content
+            .selection_text
+            .clone()
+            .filter(|text| !text.is_empty())
+        {
+            selections.push(text);
+        }
     }
 
-    panel.read(cx).terminal_selections(cx)
+    if let Some(panel) = workspace.read(cx).panel::<TerminalPanel>(cx) {
+        let position = match TerminalSettings::get_global(cx).dock {
+            TerminalDockPosition::Left => DockPosition::Left,
+            TerminalDockPosition::Bottom => DockPosition::Bottom,
+            TerminalDockPosition::Right => DockPosition::Right,
+        };
+        let dock_is_open = workspace
+            .read(cx)
+            .dock_at_position(position)
+            .read(cx)
+            .is_open();
+        if dock_is_open {
+            selections.extend(panel.read(cx).terminal_selections(cx));
+        }
+    }
+
+    selections
 }
 
 fn selection_ranges(
@@ -2213,17 +2229,8 @@ fn selection_ranges(
 
         selections
             .into_iter()
-            .map(|s| {
-                let (start, end) = if s.is_empty() {
-                    let row = multi_buffer::MultiBufferRow(s.start.row);
-                    let line_start = text::Point::new(s.start.row, 0);
-                    let line_end = text::Point::new(s.start.row, snapshot.line_len(row));
-                    (line_start, line_end)
-                } else {
-                    (s.start, s.end)
-                };
-                snapshot.anchor_after(start)..snapshot.anchor_before(end)
-            })
+            .filter(|s| !s.is_empty())
+            .map(|s| snapshot.anchor_after(s.start)..snapshot.anchor_before(s.end))
             .flat_map(|range| {
                 let (start_buffer, start) = buffer.text_anchor_for_position(range.start, cx)?;
                 let (end_buffer, end) = buffer.text_anchor_for_position(range.end, cx)?;

crates/agent_ui/src/config_options.rs 🔗

@@ -650,7 +650,7 @@ impl PickerDelegate for ConfigOptionPickerDelegate {
                                 .end_slot(div().pr_2().when(is_selected, |this| {
                                     this.child(Icon::new(IconName::Check).color(Color::Accent))
                                 }))
-                                .end_hover_slot(div().pr_1p5().child({
+                                .end_slot_on_hover(div().pr_1p5().child({
                                     let (icon, color, tooltip) = if is_favorite {
                                         (IconName::StarFilled, Color::Accent, "Unfavorite")
                                     } else {

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

@@ -5152,9 +5152,12 @@ impl ThreadView {
     }
 
     pub(crate) fn auto_expand_streaming_thought(&mut self, cx: &mut Context<Self>) {
-        // Only auto-expand thinking blocks in Automatic mode.
-        // AlwaysExpanded shows them open by default; AlwaysCollapsed keeps them closed.
-        if AgentSettings::get_global(cx).thinking_display != ThinkingBlockDisplay::Automatic {
+        let thinking_display = AgentSettings::get_global(cx).thinking_display;
+
+        if !matches!(
+            thinking_display,
+            ThinkingBlockDisplay::Auto | ThinkingBlockDisplay::Preview
+        ) {
             return;
         }
 
@@ -5183,6 +5186,13 @@ impl ThreadView {
                 cx.notify();
             }
         } else if self.auto_expanded_thinking_block.is_some() {
+            if thinking_display == ThinkingBlockDisplay::Auto {
+                if let Some(key) = self.auto_expanded_thinking_block {
+                    if !self.user_toggled_thinking_blocks.contains(&key) {
+                        self.expanded_thinking_blocks.remove(&key);
+                    }
+                }
+            }
             self.auto_expanded_thinking_block = None;
             cx.notify();
         }
@@ -5196,7 +5206,16 @@ impl ThreadView {
         let thinking_display = AgentSettings::get_global(cx).thinking_display;
 
         match thinking_display {
-            ThinkingBlockDisplay::Automatic => {
+            ThinkingBlockDisplay::Auto => {
+                if self.expanded_thinking_blocks.contains(&key) {
+                    self.expanded_thinking_blocks.remove(&key);
+                    self.user_toggled_thinking_blocks.insert(key);
+                } else {
+                    self.expanded_thinking_blocks.insert(key);
+                    self.user_toggled_thinking_blocks.insert(key);
+                }
+            }
+            ThinkingBlockDisplay::Preview => {
                 let is_user_expanded = self.user_toggled_thinking_blocks.contains(&key);
                 let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key);
 
@@ -5249,7 +5268,11 @@ impl ThreadView {
         let is_in_expanded_set = self.expanded_thinking_blocks.contains(&key);
 
         let (is_open, is_constrained) = match thinking_display {
-            ThinkingBlockDisplay::Automatic => {
+            ThinkingBlockDisplay::Auto => {
+                let is_open = is_user_toggled || is_in_expanded_set;
+                (is_open, false)
+            }
+            ThinkingBlockDisplay::Preview => {
                 let is_open = is_user_toggled || is_in_expanded_set;
                 let is_constrained = is_in_expanded_set && !is_user_toggled;
                 (is_open, is_constrained)

crates/agent_ui/src/thread_import.rs 🔗

@@ -121,18 +121,6 @@ impl ThreadImportModal {
             .collect()
     }
 
-    fn set_agent_checked(&mut self, agent_id: AgentId, state: ToggleState, cx: &mut Context<Self>) {
-        match state {
-            ToggleState::Selected => {
-                self.unchecked_agents.remove(&agent_id);
-            }
-            ToggleState::Unselected | ToggleState::Indeterminate => {
-                self.unchecked_agents.insert(agent_id);
-            }
-        }
-        cx.notify();
-    }
-
     fn toggle_agent_checked(&mut self, agent_id: AgentId, cx: &mut Context<Self>) {
         if self.unchecked_agents.contains(&agent_id) {
             self.unchecked_agents.remove(&agent_id);
@@ -283,6 +271,11 @@ impl ModalView for ThreadImportModal {}
 
 impl Render for ThreadImportModal {
     fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let has_agents = !self.agent_entries.is_empty();
+        let disabled_import_thread = self.is_importing
+            || !has_agents
+            || self.unchecked_agents.len() == self.agent_entries.len();
+
         let agent_rows = self
             .agent_entries
             .iter()
@@ -295,6 +288,7 @@ impl Render for ThreadImportModal {
                     .rounded()
                     .spacing(ListItemSpacing::Sparse)
                     .focused(is_focused)
+                    .disabled(self.is_importing)
                     .child(
                         h_flex()
                             .w_full()
@@ -311,22 +305,14 @@ impl Render for ThreadImportModal {
                             })
                             .child(Label::new(entry.display_name.clone())),
                     )
-                    .end_slot(
-                        Checkbox::new(
-                            ("thread-import-agent-checkbox", ix),
-                            if is_checked {
-                                ToggleState::Selected
-                            } else {
-                                ToggleState::Unselected
-                            },
-                        )
-                        .on_click({
-                            let agent_id = entry.agent_id.clone();
-                            cx.listener(move |this, state: &ToggleState, _window, cx| {
-                                this.set_agent_checked(agent_id.clone(), *state, cx);
-                            })
-                        }),
-                    )
+                    .end_slot(Checkbox::new(
+                        ("thread-import-agent-checkbox", ix),
+                        if is_checked {
+                            ToggleState::Selected
+                        } else {
+                            ToggleState::Unselected
+                        },
+                    ))
                     .on_click({
                         let agent_id = entry.agent_id.clone();
                         cx.listener(move |this, _event, _window, cx| {
@@ -336,11 +322,6 @@ impl Render for ThreadImportModal {
             })
             .collect::<Vec<_>>();
 
-        let has_agents = !self.agent_entries.is_empty();
-        let disabled_import_thread = self.is_importing
-            || !has_agents
-            || self.unchecked_agents.len() == self.agent_entries.len();
-
         v_flex()
             .id("thread-import-modal")
             .key_context("ThreadImportModal")
@@ -373,7 +354,7 @@ impl Render for ThreadImportModal {
                             v_flex()
                                 .id("thread-import-agent-list")
                                 .max_h(rems_from_px(320.))
-                                .pb_2()
+                                .pb_1()
                                 .overflow_y_scroll()
                                 .when(has_agents, |this| this.children(agent_rows))
                                 .when(!has_agents, |this| {

crates/agent_ui/src/thread_metadata_store.rs 🔗

@@ -55,7 +55,7 @@ fn migrate_thread_metadata(cx: &mut App) {
                 .read(cx)
                 .entries()
                 .filter_map(|entry| {
-                    if existing_entries.contains(&entry.id.0) || entry.folder_paths.is_empty() {
+                    if existing_entries.contains(&entry.id.0) {
                         return None;
                     }
 
@@ -81,6 +81,9 @@ fn migrate_thread_metadata(cx: &mut App) {
         if is_first_migration {
             let mut per_project: HashMap<PathList, Vec<&mut ThreadMetadata>> = HashMap::default();
             for entry in &mut to_migrate {
+                if entry.folder_paths.is_empty() {
+                    continue;
+                }
                 per_project
                     .entry(entry.folder_paths.clone())
                     .or_default()
@@ -316,6 +319,25 @@ impl ThreadMetadataStore {
             .log_err();
     }
 
+    pub fn update_working_directories(
+        &mut self,
+        session_id: &acp::SessionId,
+        work_dirs: PathList,
+        cx: &mut Context<Self>,
+    ) {
+        if !cx.has_flag::<AgentV2FeatureFlag>() {
+            return;
+        }
+
+        if let Some(thread) = self.threads.get(session_id) {
+            self.save_internal(ThreadMetadata {
+                folder_paths: work_dirs,
+                ..thread.clone()
+            });
+            cx.notify();
+        }
+    }
+
     pub fn archive(&mut self, session_id: &acp::SessionId, cx: &mut Context<Self>) {
         self.update_archived(session_id, true, cx);
     }
@@ -495,7 +517,13 @@ impl ThreadMetadataStore {
                     PathList::new(&paths)
                 };
 
-                let archived = existing_thread.map(|t| t.archived).unwrap_or(false);
+                // Threads without a folder path (e.g. started in an empty
+                // window) are archived by default so they don't get lost,
+                // because they won't show up in the sidebar. Users can reload
+                // them from the archive.
+                let archived = existing_thread
+                    .map(|t| t.archived)
+                    .unwrap_or(folder_paths.is_empty());
 
                 let metadata = ThreadMetadata {
                     session_id,
@@ -994,7 +1022,7 @@ mod tests {
             store.read(cx).entries().cloned().collect::<Vec<_>>()
         });
 
-        assert_eq!(list.len(), 3);
+        assert_eq!(list.len(), 4);
         assert!(
             list.iter()
                 .all(|metadata| metadata.agent_id.as_ref() == agent::ZED_AGENT_ID.as_ref())
@@ -1013,17 +1041,12 @@ mod tests {
             .collect::<Vec<_>>();
         assert!(migrated_session_ids.contains(&"a-session-1"));
         assert!(migrated_session_ids.contains(&"b-session-0"));
-        assert!(!migrated_session_ids.contains(&"projectless"));
+        assert!(migrated_session_ids.contains(&"projectless"));
 
         let migrated_entries = list
             .iter()
             .filter(|metadata| metadata.session_id.0.as_ref() != "a-session-0")
             .collect::<Vec<_>>();
-        assert!(
-            migrated_entries
-                .iter()
-                .all(|metadata| !metadata.folder_paths.is_empty())
-        );
         assert!(migrated_entries.iter().all(|metadata| metadata.archived));
     }
 
@@ -1269,6 +1292,84 @@ mod tests {
         assert_eq!(metadata_ids, vec![session_id]);
     }
 
+    #[gpui::test]
+    async fn test_threads_without_project_association_are_archived_by_default(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        let project_without_worktree = Project::test(fs.clone(), None::<&Path>, cx).await;
+        let project_with_worktree = Project::test(fs, [Path::new("/project-a")], cx).await;
+        let connection = Rc::new(StubAgentConnection::new());
+
+        let thread_without_worktree = cx
+            .update(|cx| {
+                connection.clone().new_session(
+                    project_without_worktree.clone(),
+                    PathList::default(),
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        let session_without_worktree =
+            cx.read(|cx| thread_without_worktree.read(cx).session_id().clone());
+
+        cx.update(|cx| {
+            thread_without_worktree.update(cx, |thread, cx| {
+                thread.set_title("No Project Thread".into(), cx).detach();
+            });
+        });
+        cx.run_until_parked();
+
+        let thread_with_worktree = cx
+            .update(|cx| {
+                connection.clone().new_session(
+                    project_with_worktree.clone(),
+                    PathList::default(),
+                    cx,
+                )
+            })
+            .await
+            .unwrap();
+        let session_with_worktree =
+            cx.read(|cx| thread_with_worktree.read(cx).session_id().clone());
+
+        cx.update(|cx| {
+            thread_with_worktree.update(cx, |thread, cx| {
+                thread.set_title("Project Thread".into(), cx).detach();
+            });
+        });
+        cx.run_until_parked();
+
+        cx.update(|cx| {
+            let store = ThreadMetadataStore::global(cx);
+            let store = store.read(cx);
+
+            let without_worktree = store
+                .entry(&session_without_worktree)
+                .expect("missing metadata for thread without project association");
+            assert!(without_worktree.folder_paths.is_empty());
+            assert!(
+                without_worktree.archived,
+                "expected thread without project association to be archived"
+            );
+
+            let with_worktree = store
+                .entry(&session_with_worktree)
+                .expect("missing metadata for thread with project association");
+            assert_eq!(
+                with_worktree.folder_paths,
+                PathList::new(&[Path::new("/project-a")])
+            );
+            assert!(
+                !with_worktree.archived,
+                "expected thread with project association to remain unarchived"
+            );
+        });
+    }
+
     #[gpui::test]
     async fn test_subagent_threads_excluded_from_sidebar_metadata(cx: &mut TestAppContext) {
         init_test(cx);

crates/agent_ui/src/threads_archive_view.rs 🔗

@@ -1,5 +1,8 @@
+use std::collections::HashSet;
+use std::sync::Arc;
+
 use crate::agent_connection_store::AgentConnectionStore;
-use crate::thread_import::{AcpThreadImportOnboarding, ThreadImportModal};
+
 use crate::thread_metadata_store::{ThreadMetadata, ThreadMetadataStore};
 use crate::{Agent, RemoveSelectedThread};
 
@@ -9,21 +12,32 @@ use agent_settings::AgentSettings;
 use chrono::{DateTime, Datelike as _, Local, NaiveDate, TimeDelta, Utc};
 use editor::Editor;
 use fs::Fs;
+use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
-    AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, ListState, Render,
-    SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px,
+    AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    ListState, Render, SharedString, Subscription, Task, WeakEntity, Window, list, prelude::*, px,
 };
 use itertools::Itertools as _;
 use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrevious};
-use project::{AgentId, AgentRegistryStore, AgentServerStore};
+use picker::{
+    Picker, PickerDelegate,
+    highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
+};
+use project::{AgentId, AgentServerStore};
 use settings::Settings as _;
 use theme::ActiveTheme;
 use ui::ThreadItem;
 use ui::{
-    Divider, KeyBinding, Tooltip, WithScrollbar, prelude::*, utils::platform_title_bar_height,
+    Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, Tooltip, WithScrollbar,
+    prelude::*, utils::platform_title_bar_height,
 };
+use ui_input::ErasedEditor;
 use util::ResultExt;
-use workspace::{MultiWorkspace, Workspace};
+use util::paths::PathExt;
+use workspace::{
+    ModalView, PathList, SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId,
+    resolve_worktree_workspaces,
+};
 
 use zed_actions::agents_sidebar::FocusSidebarFilter;
 use zed_actions::editor::{MoveDown, MoveUp};
@@ -112,20 +126,16 @@ pub struct ThreadsArchiveView {
     filter_editor: Entity<Editor>,
     _subscriptions: Vec<gpui::Subscription>,
     _refresh_history_task: Task<()>,
+    workspace: WeakEntity<Workspace>,
     agent_connection_store: WeakEntity<AgentConnectionStore>,
     agent_server_store: WeakEntity<AgentServerStore>,
-    agent_registry_store: WeakEntity<AgentRegistryStore>,
-    workspace: WeakEntity<Workspace>,
-    multi_workspace: WeakEntity<MultiWorkspace>,
 }
 
 impl ThreadsArchiveView {
     pub fn new(
+        workspace: WeakEntity<Workspace>,
         agent_connection_store: WeakEntity<AgentConnectionStore>,
         agent_server_store: WeakEntity<AgentServerStore>,
-        agent_registry_store: WeakEntity<AgentRegistryStore>,
-        workspace: WeakEntity<Workspace>,
-        multi_workspace: WeakEntity<MultiWorkspace>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -184,11 +194,9 @@ impl ThreadsArchiveView {
                 thread_metadata_store_subscription,
             ],
             _refresh_history_task: Task::ready(()),
-            agent_registry_store,
+            workspace,
             agent_connection_store,
             agent_server_store,
-            workspace,
-            multi_workspace,
         };
 
         this.update_items(cx);
@@ -265,7 +273,14 @@ impl ThreadsArchiveView {
 
         self.list_state.reset(items.len());
         self.items = items;
-        self.hovered_index = None;
+
+        if !preserve {
+            self.hovered_index = None;
+        } else if let Some(ix) = self.hovered_index {
+            if ix >= self.items.len() || !self.is_selectable_item(ix) {
+                self.hovered_index = None;
+            }
+        }
 
         if let Some(scroll_top) = saved_scroll {
             self.list_state.scroll_to(scroll_top);
@@ -299,11 +314,57 @@ impl ThreadsArchiveView {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        if thread.folder_paths.is_empty() {
+            self.show_project_picker_for_thread(thread, window, cx);
+            return;
+        }
+
         self.selection = None;
         self.reset_filter_editor_text(window, cx);
         cx.emit(ThreadsArchiveViewEvent::Unarchive { thread });
     }
 
+    fn show_project_picker_for_thread(
+        &mut self,
+        thread: ThreadMetadata,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+
+        let archive_view = cx.weak_entity();
+        let fs = workspace.read(cx).app_state().fs.clone();
+        let current_workspace_id = workspace.read(cx).database_id();
+        let sibling_workspace_ids: HashSet<WorkspaceId> = workspace
+            .read(cx)
+            .multi_workspace()
+            .and_then(|mw| mw.upgrade())
+            .map(|mw| {
+                mw.read(cx)
+                    .workspaces()
+                    .iter()
+                    .filter_map(|ws| ws.read(cx).database_id())
+                    .collect()
+            })
+            .unwrap_or_default();
+
+        workspace.update(cx, |workspace, cx| {
+            workspace.toggle_modal(window, cx, |window, cx| {
+                ProjectPickerModal::new(
+                    thread,
+                    fs,
+                    archive_view,
+                    current_workspace_id,
+                    sibling_workspace_ids,
+                    window,
+                    cx,
+                )
+            });
+        });
+    }
+
     fn is_selectable_item(&self, ix: usize) -> bool {
         matches!(self.items.get(ix), Some(ArchiveListItem::Entry { .. }))
     }
@@ -391,10 +452,6 @@ impl ThreadsArchiveView {
             return;
         };
 
-        if thread.folder_paths.is_empty() {
-            return;
-        }
-
         self.unarchive_thread(thread.clone(), window, cx);
     }
 
@@ -482,6 +539,7 @@ impl ThreadsArchiveView {
                                 let agent = thread.agent_id.clone();
                                 let session_id = thread.session_id.clone();
                                 cx.listener(move |this, _, _, cx| {
+                                    this.preserve_selection_on_next_update = true;
                                     this.delete_thread(session_id.clone(), agent.clone(), cx);
                                     cx.stop_propagation();
                                 })
@@ -550,43 +608,6 @@ impl ThreadsArchiveView {
         .detach_and_log_err(cx);
     }
 
-    fn should_render_acp_import_onboarding(&self, cx: &App) -> bool {
-        let has_external_agents = self
-            .agent_server_store
-            .upgrade()
-            .map(|store| store.read(cx).has_external_agents())
-            .unwrap_or(false);
-
-        has_external_agents && !AcpThreadImportOnboarding::dismissed(cx)
-    }
-
-    fn show_thread_import_modal(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        let Some(agent_server_store) = self.agent_server_store.upgrade() else {
-            return;
-        };
-        let Some(agent_registry_store) = self.agent_registry_store.upgrade() else {
-            return;
-        };
-
-        let workspace_handle = self.workspace.clone();
-        let multi_workspace = self.multi_workspace.clone();
-
-        self.workspace
-            .update(cx, |workspace, cx| {
-                workspace.toggle_modal(window, cx, |window, cx| {
-                    ThreadImportModal::new(
-                        agent_server_store,
-                        agent_registry_store,
-                        workspace_handle.clone(),
-                        multi_workspace.clone(),
-                        window,
-                        cx,
-                    )
-                });
-            })
-            .log_err();
-    }
-
     fn render_header(&self, window: &Window, cx: &mut Context<Self>) -> impl IntoElement {
         let has_query = !self.filter_editor.read(cx).text(cx).is_empty();
         let sidebar_on_left = matches!(
@@ -729,28 +750,536 @@ impl Render for ThreadsArchiveView {
             .size_full()
             .child(self.render_header(window, cx))
             .child(content)
-            .when(!self.should_render_acp_import_onboarding(cx), |this| {
-                this.child(
-                    div()
-                        .w_full()
-                        .p_1p5()
-                        .border_t_1()
-                        .border_color(cx.theme().colors().border)
+    }
+}
+
+struct ProjectPickerModal {
+    picker: Entity<Picker<ProjectPickerDelegate>>,
+    _subscription: Subscription,
+}
+
+impl ProjectPickerModal {
+    fn new(
+        thread: ThreadMetadata,
+        fs: Arc<dyn Fs>,
+        archive_view: WeakEntity<ThreadsArchiveView>,
+        current_workspace_id: Option<WorkspaceId>,
+        sibling_workspace_ids: HashSet<WorkspaceId>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let delegate = ProjectPickerDelegate {
+            thread,
+            archive_view,
+            workspaces: Vec::new(),
+            filtered_entries: Vec::new(),
+            selected_index: 0,
+            current_workspace_id,
+            sibling_workspace_ids,
+            focus_handle: cx.focus_handle(),
+        };
+
+        let picker = cx.new(|cx| {
+            Picker::list(delegate, window, cx)
+                .list_measure_all()
+                .modal(false)
+        });
+
+        let picker_focus_handle = picker.focus_handle(cx);
+        picker.update(cx, |picker, _| {
+            picker.delegate.focus_handle = picker_focus_handle;
+        });
+
+        let _subscription =
+            cx.subscribe(&picker, |_this: &mut Self, _, _event: &DismissEvent, cx| {
+                cx.emit(DismissEvent);
+            });
+
+        let db = WorkspaceDb::global(cx);
+        cx.spawn_in(window, async move |this, cx| {
+            let workspaces = db
+                .recent_workspaces_on_disk(fs.as_ref())
+                .await
+                .log_err()
+                .unwrap_or_default();
+            let workspaces = resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
+            this.update_in(cx, move |this, window, cx| {
+                this.picker.update(cx, move |picker, cx| {
+                    picker.delegate.workspaces = workspaces;
+                    picker.update_matches(picker.query(cx), window, cx)
+                })
+            })
+            .ok();
+        })
+        .detach();
+
+        picker.focus_handle(cx).focus(window, cx);
+
+        Self {
+            picker,
+            _subscription,
+        }
+    }
+}
+
+impl EventEmitter<DismissEvent> for ProjectPickerModal {}
+
+impl Focusable for ProjectPickerModal {
+    fn focus_handle(&self, cx: &App) -> FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl ModalView for ProjectPickerModal {}
+
+impl Render for ProjectPickerModal {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
+            .key_context("ProjectPickerModal")
+            .elevation_3(cx)
+            .w(rems(34.))
+            .on_action(cx.listener(|this, _: &workspace::Open, window, cx| {
+                this.picker.update(cx, |picker, cx| {
+                    picker.delegate.open_local_folder(window, cx)
+                })
+            }))
+            .child(self.picker.clone())
+    }
+}
+
+enum ProjectPickerEntry {
+    Header(SharedString),
+    Workspace(StringMatch),
+}
+
+struct ProjectPickerDelegate {
+    thread: ThreadMetadata,
+    archive_view: WeakEntity<ThreadsArchiveView>,
+    current_workspace_id: Option<WorkspaceId>,
+    sibling_workspace_ids: HashSet<WorkspaceId>,
+    workspaces: Vec<(
+        WorkspaceId,
+        SerializedWorkspaceLocation,
+        PathList,
+        DateTime<Utc>,
+    )>,
+    filtered_entries: Vec<ProjectPickerEntry>,
+    selected_index: usize,
+    focus_handle: FocusHandle,
+}
+
+impl ProjectPickerDelegate {
+    fn update_working_directories_and_unarchive(
+        &mut self,
+        paths: PathList,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) {
+        self.thread.folder_paths = paths.clone();
+        ThreadMetadataStore::global(cx).update(cx, |store, cx| {
+            store.update_working_directories(&self.thread.session_id, paths, cx);
+        });
+
+        self.archive_view
+            .update(cx, |view, cx| {
+                view.selection = None;
+                view.reset_filter_editor_text(window, cx);
+                cx.emit(ThreadsArchiveViewEvent::Unarchive {
+                    thread: self.thread.clone(),
+                });
+            })
+            .log_err();
+    }
+
+    fn is_current_workspace(&self, workspace_id: WorkspaceId) -> bool {
+        self.current_workspace_id == Some(workspace_id)
+    }
+
+    fn is_sibling_workspace(&self, workspace_id: WorkspaceId) -> bool {
+        self.sibling_workspace_ids.contains(&workspace_id)
+            && !self.is_current_workspace(workspace_id)
+    }
+
+    fn selected_match(&self) -> Option<&StringMatch> {
+        match self.filtered_entries.get(self.selected_index)? {
+            ProjectPickerEntry::Workspace(hit) => Some(hit),
+            ProjectPickerEntry::Header(_) => None,
+        }
+    }
+
+    fn open_local_folder(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions {
+            files: false,
+            directories: true,
+            multiple: false,
+            prompt: None,
+        });
+        cx.spawn_in(window, async move |this, cx| {
+            let Ok(Ok(Some(paths))) = paths_receiver.await else {
+                return;
+            };
+            if paths.is_empty() {
+                return;
+            }
+
+            let work_dirs = PathList::new(&paths);
+
+            this.update_in(cx, |this, window, cx| {
+                this.delegate
+                    .update_working_directories_and_unarchive(work_dirs, window, cx);
+                cx.emit(DismissEvent);
+            })
+            .log_err();
+        })
+        .detach();
+    }
+}
+
+impl EventEmitter<DismissEvent> for ProjectPickerDelegate {}
+
+impl PickerDelegate for ProjectPickerDelegate {
+    type ListItem = AnyElement;
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        format!("Associate the \"{}\" thread with...", self.thread.title).into()
+    }
+
+    fn render_editor(
+        &self,
+        editor: &Arc<dyn ErasedEditor>,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Div {
+        h_flex()
+            .flex_none()
+            .h_9()
+            .px_2p5()
+            .justify_between()
+            .border_b_1()
+            .border_color(cx.theme().colors().border_variant)
+            .child(editor.render(window, cx))
+    }
+
+    fn match_count(&self) -> usize {
+        self.filtered_entries.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        _cx: &mut Context<Picker<Self>>,
+    ) {
+        self.selected_index = ix;
+    }
+
+    fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
+        matches!(
+            self.filtered_entries.get(ix),
+            Some(ProjectPickerEntry::Workspace(_))
+        )
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        _window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Task<()> {
+        let query = query.trim_start();
+        let smart_case = query.chars().any(|c| c.is_uppercase());
+        let is_empty_query = query.is_empty();
+
+        let sibling_candidates: Vec<_> = self
+            .workspaces
+            .iter()
+            .enumerate()
+            .filter(|(_, (id, _, _, _))| self.is_sibling_workspace(*id))
+            .map(|(id, (_, _, paths, _))| {
+                let combined_string = paths
+                    .ordered_paths()
+                    .map(|path| path.compact().to_string_lossy().into_owned())
+                    .collect::<Vec<_>>()
+                    .join("");
+                StringMatchCandidate::new(id, &combined_string)
+            })
+            .collect();
+
+        let mut sibling_matches = smol::block_on(fuzzy::match_strings(
+            &sibling_candidates,
+            query,
+            smart_case,
+            true,
+            100,
+            &Default::default(),
+            cx.background_executor().clone(),
+        ));
+
+        sibling_matches.sort_unstable_by(|a, b| {
+            b.score
+                .partial_cmp(&a.score)
+                .unwrap_or(std::cmp::Ordering::Equal)
+                .then_with(|| a.candidate_id.cmp(&b.candidate_id))
+        });
+
+        let recent_candidates: Vec<_> = self
+            .workspaces
+            .iter()
+            .enumerate()
+            .filter(|(_, (id, _, _, _))| {
+                !self.is_current_workspace(*id) && !self.is_sibling_workspace(*id)
+            })
+            .map(|(id, (_, _, paths, _))| {
+                let combined_string = paths
+                    .ordered_paths()
+                    .map(|path| path.compact().to_string_lossy().into_owned())
+                    .collect::<Vec<_>>()
+                    .join("");
+                StringMatchCandidate::new(id, &combined_string)
+            })
+            .collect();
+
+        let mut recent_matches = smol::block_on(fuzzy::match_strings(
+            &recent_candidates,
+            query,
+            smart_case,
+            true,
+            100,
+            &Default::default(),
+            cx.background_executor().clone(),
+        ));
+
+        recent_matches.sort_unstable_by(|a, b| {
+            b.score
+                .partial_cmp(&a.score)
+                .unwrap_or(std::cmp::Ordering::Equal)
+                .then_with(|| a.candidate_id.cmp(&b.candidate_id))
+        });
+
+        let mut entries = Vec::new();
+
+        let has_siblings_to_show = if is_empty_query {
+            !sibling_candidates.is_empty()
+        } else {
+            !sibling_matches.is_empty()
+        };
+
+        if has_siblings_to_show {
+            entries.push(ProjectPickerEntry::Header("This Window".into()));
+
+            if is_empty_query {
+                for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
+                    if self.is_sibling_workspace(*workspace_id) {
+                        entries.push(ProjectPickerEntry::Workspace(StringMatch {
+                            candidate_id: id,
+                            score: 0.0,
+                            positions: Vec::new(),
+                            string: String::new(),
+                        }));
+                    }
+                }
+            } else {
+                for m in sibling_matches {
+                    entries.push(ProjectPickerEntry::Workspace(m));
+                }
+            }
+        }
+
+        let has_recent_to_show = if is_empty_query {
+            !recent_candidates.is_empty()
+        } else {
+            !recent_matches.is_empty()
+        };
+
+        if has_recent_to_show {
+            entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
+
+            if is_empty_query {
+                for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
+                    if !self.is_current_workspace(*workspace_id)
+                        && !self.is_sibling_workspace(*workspace_id)
+                    {
+                        entries.push(ProjectPickerEntry::Workspace(StringMatch {
+                            candidate_id: id,
+                            score: 0.0,
+                            positions: Vec::new(),
+                            string: String::new(),
+                        }));
+                    }
+                }
+            } else {
+                for m in recent_matches {
+                    entries.push(ProjectPickerEntry::Workspace(m));
+                }
+            }
+        }
+
+        self.filtered_entries = entries;
+
+        self.selected_index = self
+            .filtered_entries
+            .iter()
+            .position(|e| matches!(e, ProjectPickerEntry::Workspace(_)))
+            .unwrap_or(0);
+
+        Task::ready(())
+    }
+
+    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        let candidate_id = match self.filtered_entries.get(self.selected_index) {
+            Some(ProjectPickerEntry::Workspace(hit)) => hit.candidate_id,
+            _ => return,
+        };
+        let Some((_workspace_id, _location, paths, _)) = self.workspaces.get(candidate_id) else {
+            return;
+        };
+
+        self.update_working_directories_and_unarchive(paths.clone(), window, cx);
+        cx.emit(DismissEvent);
+    }
+
+    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
+
+    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
+        let text = if self.workspaces.is_empty() {
+            "No recent projects found"
+        } else {
+            "No matches"
+        };
+        Some(text.into())
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        match self.filtered_entries.get(ix)? {
+            ProjectPickerEntry::Header(title) => Some(
+                v_flex()
+                    .w_full()
+                    .gap_1()
+                    .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
+                    .child(ListSubHeader::new(title.clone()).inset(true))
+                    .into_any_element(),
+            ),
+            ProjectPickerEntry::Workspace(hit) => {
+                let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
+
+                let ordered_paths: Vec<_> = paths
+                    .ordered_paths()
+                    .map(|p| p.compact().to_string_lossy().to_string())
+                    .collect();
+
+                let tooltip_path: SharedString = ordered_paths.join("\n").into();
+
+                let mut path_start_offset = 0;
+                let match_labels: Vec<_> = paths
+                    .ordered_paths()
+                    .map(|p| p.compact())
+                    .map(|path| {
+                        let path_string = path.to_string_lossy();
+                        let path_text = path_string.to_string();
+                        let path_byte_len = path_text.len();
+
+                        let path_positions: Vec<usize> = hit
+                            .positions
+                            .iter()
+                            .copied()
+                            .skip_while(|pos| *pos < path_start_offset)
+                            .take_while(|pos| *pos < path_start_offset + path_byte_len)
+                            .map(|pos| pos - path_start_offset)
+                            .collect();
+
+                        let file_name_match = path.file_name().map(|file_name| {
+                            let file_name_text = file_name.to_string_lossy().into_owned();
+                            let file_name_start = path_byte_len - file_name_text.len();
+                            let highlight_positions: Vec<usize> = path_positions
+                                .iter()
+                                .copied()
+                                .skip_while(|pos| *pos < file_name_start)
+                                .take_while(|pos| *pos < file_name_start + file_name_text.len())
+                                .map(|pos| pos - file_name_start)
+                                .collect();
+                            HighlightedMatch {
+                                text: file_name_text,
+                                highlight_positions,
+                                color: Color::Default,
+                            }
+                        });
+
+                        path_start_offset += path_byte_len;
+                        file_name_match
+                    })
+                    .collect();
+
+                let highlighted_match = HighlightedMatchWithPaths {
+                    prefix: match location {
+                        SerializedWorkspaceLocation::Remote(options) => {
+                            Some(SharedString::from(options.display_name()))
+                        }
+                        _ => None,
+                    },
+                    match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
+                    paths: Vec::new(),
+                };
+
+                Some(
+                    ListItem::new(ix)
+                        .toggle_state(selected)
+                        .inset(true)
+                        .spacing(ListItemSpacing::Sparse)
                         .child(
-                            Button::new("import-acp", "Import ACP Threads")
-                                .full_width()
-                                .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
-                                .label_size(LabelSize::Small)
-                                .start_icon(
-                                    Icon::new(IconName::ArrowDown)
-                                        .size(IconSize::XSmall)
-                                        .color(Color::Muted),
-                                )
-                                .on_click(cx.listener(|this, _, window, cx| {
-                                    this.show_thread_import_modal(window, cx);
-                                })),
-                        ),
+                            h_flex()
+                                .gap_3()
+                                .flex_grow()
+                                .child(highlighted_match.render(window, cx)),
+                        )
+                        .tooltip(Tooltip::text(tooltip_path))
+                        .into_any_element(),
                 )
-            })
+            }
+        }
+    }
+
+    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
+        let has_selection = self.selected_match().is_some();
+        let focus_handle = self.focus_handle.clone();
+
+        Some(
+            h_flex()
+                .flex_1()
+                .p_1p5()
+                .gap_1()
+                .justify_end()
+                .border_t_1()
+                .border_color(cx.theme().colors().border_variant)
+                .child(
+                    Button::new("open_local_folder", "Choose from Local Folders")
+                        .key_binding(KeyBinding::for_action_in(
+                            &workspace::Open::default(),
+                            &focus_handle,
+                            cx,
+                        ))
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.delegate.open_local_folder(window, cx);
+                        })),
+                )
+                .child(
+                    Button::new("select_project", "Select")
+                        .disabled(!has_selection)
+                        .key_binding(KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx))
+                        .on_click(cx.listener(move |picker, _, window, cx| {
+                            picker.delegate.confirm(false, window, cx);
+                        })),
+                )
+                .into_any(),
+        )
     }
 }

crates/agent_ui/src/ui.rs 🔗

@@ -1,6 +1,5 @@
 mod acp_onboarding_modal;
 mod agent_notification;
-mod claude_agent_onboarding_modal;
 mod end_trial_upsell;
 mod hold_for_default;
 mod mention_crease;
@@ -9,7 +8,6 @@ mod undo_reject_toast;
 
 pub use acp_onboarding_modal::*;
 pub use agent_notification::*;
-pub use claude_agent_onboarding_modal::*;
 pub use end_trial_upsell::*;
 pub use hold_for_default::*;
 pub use mention_crease::*;

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

@@ -1,261 +0,0 @@
-use agent_servers::CLAUDE_AGENT_ID;
-use gpui::{
-    ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render,
-    linear_color_stop, linear_gradient,
-};
-use ui::{TintColor, Vector, VectorName, prelude::*};
-use workspace::{ModalView, Workspace};
-
-use crate::{Agent, agent_panel::AgentPanel};
-
-macro_rules! claude_agent_onboarding_event {
-    ($name:expr) => {
-        telemetry::event!($name, source = "ACP Claude Code Onboarding");
-    };
-    ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => {
-        telemetry::event!($name, source = "ACP Claude Code Onboarding", $($key $(= $value)?),+);
-    };
-}
-
-pub struct ClaudeCodeOnboardingModal {
-    focus_handle: FocusHandle,
-    workspace: Entity<Workspace>,
-}
-
-impl ClaudeCodeOnboardingModal {
-    pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
-        let workspace_entity = cx.entity();
-        workspace.toggle_modal(window, cx, |_window, cx| Self {
-            workspace: workspace_entity,
-            focus_handle: cx.focus_handle(),
-        });
-    }
-
-    fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
-        self.workspace.update(cx, |workspace, cx| {
-            workspace.focus_panel::<AgentPanel>(window, cx);
-
-            if let Some(panel) = workspace.panel::<AgentPanel>(cx) {
-                panel.update(cx, |panel, cx| {
-                    panel.new_agent_thread(
-                        Agent::Custom {
-                            id: CLAUDE_AGENT_ID.into(),
-                        },
-                        window,
-                        cx,
-                    );
-                });
-            }
-        });
-
-        cx.emit(DismissEvent);
-
-        claude_agent_onboarding_event!("Open Panel Clicked");
-    }
-
-    fn view_docs(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
-        window.dispatch_action(Box::new(zed_actions::AcpRegistry), cx);
-        cx.notify();
-
-        claude_agent_onboarding_event!("Documentation Link Clicked");
-    }
-
-    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
-        cx.emit(DismissEvent);
-    }
-}
-
-impl EventEmitter<DismissEvent> for ClaudeCodeOnboardingModal {}
-
-impl Focusable for ClaudeCodeOnboardingModal {
-    fn focus_handle(&self, _cx: &App) -> FocusHandle {
-        self.focus_handle.clone()
-    }
-}
-
-impl ModalView for ClaudeCodeOnboardingModal {}
-
-impl Render for ClaudeCodeOnboardingModal {
-    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let illustration_element = |icon: IconName, label: Option<SharedString>, opacity: f32| {
-            h_flex()
-                .px_1()
-                .py_0p5()
-                .gap_1()
-                .rounded_sm()
-                .bg(cx.theme().colors().element_active.opacity(0.05))
-                .border_1()
-                .border_color(cx.theme().colors().border)
-                .border_dashed()
-                .child(
-                    Icon::new(icon)
-                        .size(IconSize::Small)
-                        .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))),
-                )
-                .map(|this| {
-                    if let Some(label_text) = label {
-                        this.child(
-                            Label::new(label_text)
-                                .size(LabelSize::Small)
-                                .color(Color::Muted),
-                        )
-                    } else {
-                        this.child(
-                            div().w_16().h_1().rounded_full().bg(cx
-                                .theme()
-                                .colors()
-                                .element_active
-                                .opacity(0.6)),
-                        )
-                    }
-                })
-                .opacity(opacity)
-        };
-
-        let illustration = h_flex()
-            .relative()
-            .h(rems_from_px(126.))
-            .bg(cx.theme().colors().editor_background)
-            .border_b_1()
-            .border_color(cx.theme().colors().border_variant)
-            .justify_center()
-            .gap_8()
-            .rounded_t_md()
-            .overflow_hidden()
-            .child(
-                div().absolute().inset_0().w(px(515.)).h(px(126.)).child(
-                    Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.))
-                        .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))),
-                ),
-            )
-            .child(div().absolute().inset_0().size_full().bg(linear_gradient(
-                0.,
-                linear_color_stop(
-                    cx.theme().colors().elevated_surface_background.opacity(0.1),
-                    0.9,
-                ),
-                linear_color_stop(
-                    cx.theme().colors().elevated_surface_background.opacity(0.),
-                    0.,
-                ),
-            )))
-            .child(
-                div()
-                    .absolute()
-                    .inset_0()
-                    .size_full()
-                    .bg(gpui::black().opacity(0.15)),
-            )
-            .child(
-                Vector::new(
-                    VectorName::AcpLogoSerif,
-                    rems_from_px(257.),
-                    rems_from_px(47.),
-                )
-                .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))),
-            )
-            .child(
-                v_flex()
-                    .gap_1p5()
-                    .child(illustration_element(IconName::Stop, None, 0.15))
-                    .child(illustration_element(
-                        IconName::AiGemini,
-                        Some("New Gemini CLI Thread".into()),
-                        0.3,
-                    ))
-                    .child(
-                        h_flex()
-                            .pl_1()
-                            .pr_2()
-                            .py_0p5()
-                            .gap_1()
-                            .rounded_sm()
-                            .bg(cx.theme().colors().element_active.opacity(0.2))
-                            .border_1()
-                            .border_color(cx.theme().colors().border)
-                            .child(
-                                Icon::new(IconName::AiClaude)
-                                    .size(IconSize::Small)
-                                    .color(Color::Muted),
-                            )
-                            .child(Label::new("New Claude Agent Thread").size(LabelSize::Small)),
-                    )
-                    .child(illustration_element(
-                        IconName::Stop,
-                        Some("Your Agent Here".into()),
-                        0.3,
-                    ))
-                    .child(illustration_element(IconName::Stop, None, 0.15)),
-            );
-
-        let heading = v_flex()
-            .w_full()
-            .gap_1()
-            .child(
-                Label::new("Beta Release")
-                    .size(LabelSize::Small)
-                    .color(Color::Muted),
-            )
-            .child(Headline::new("Claude Agent: Natively in Zed").size(HeadlineSize::Large));
-
-        let copy = "Powered by the Agent Client Protocol, you can now run Claude Agent as\na first-class citizen in Zed's agent panel.";
-
-        let open_panel_button = Button::new("open-panel", "Start with Claude Agent")
-            .style(ButtonStyle::Tinted(TintColor::Accent))
-            .full_width()
-            .on_click(cx.listener(Self::open_panel));
-
-        let docs_button = Button::new("add-other-agents", "Add Other Agents")
-            .end_icon(
-                Icon::new(IconName::ArrowUpRight)
-                    .size(IconSize::Indicator)
-                    .color(Color::Muted),
-            )
-            .full_width()
-            .on_click(cx.listener(Self::view_docs));
-
-        let close_button = h_flex().absolute().top_2().right_2().child(
-            IconButton::new("cancel", IconName::Close).on_click(cx.listener(
-                |_, _: &ClickEvent, _window, cx| {
-                    claude_agent_onboarding_event!("Canceled", trigger = "X click");
-                    cx.emit(DismissEvent);
-                },
-            )),
-        );
-
-        v_flex()
-            .id("acp-onboarding")
-            .key_context("AcpOnboardingModal")
-            .relative()
-            .w(rems(34.))
-            .h_full()
-            .elevation_3(cx)
-            .track_focus(&self.focus_handle(cx))
-            .overflow_hidden()
-            .on_action(cx.listener(Self::cancel))
-            .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| {
-                claude_agent_onboarding_event!("Canceled", trigger = "Action");
-                cx.emit(DismissEvent);
-            }))
-            .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, cx| {
-                this.focus_handle.focus(window, cx);
-            }))
-            .child(illustration)
-            .child(
-                v_flex()
-                    .p_4()
-                    .gap_2()
-                    .child(heading)
-                    .child(Label::new(copy).color(Color::Muted))
-                    .child(
-                        v_flex()
-                            .w_full()
-                            .mt_2()
-                            .gap_1()
-                            .child(open_panel_button)
-                            .child(docs_button),
-                    ),
-            )
-            .child(close_button)
-    }
-}

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

@@ -160,7 +160,7 @@ impl RenderOnce for ModelSelectorListItem {
             .end_slot(div().pr_2().when(self.is_selected, |this| {
                 this.child(Icon::new(IconName::Check).color(Color::Accent))
             }))
-            .end_hover_slot(div().pr_1p5().when_some(self.on_toggle_favorite, {
+            .end_slot_on_hover(div().pr_1p5().when_some(self.on_toggle_favorite, {
                 |this, handle_click| {
                     let (icon, color, tooltip) = if is_favorite {
                         (IconName::StarFilled, Color::Accent, "Unfavorite Model")

crates/csv_preview/src/csv_preview.rs 🔗

@@ -9,7 +9,10 @@ use std::{
 };
 
 use crate::table_data_engine::TableDataEngine;
-use ui::{SharedString, TableColumnWidths, TableInteractionState, prelude::*};
+use ui::{
+    AbsoluteLength, DefiniteLength, RedistributableColumnsState, SharedString,
+    TableInteractionState, TableResizeBehavior, prelude::*,
+};
 use workspace::{Item, SplitDirection, Workspace};
 
 use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent};
@@ -52,6 +55,32 @@ pub fn init(cx: &mut App) {
 }
 
 impl CsvPreviewView {
+    pub(crate) fn sync_column_widths(&self, cx: &mut Context<Self>) {
+        // plus 1 for the rows column
+        let cols = self.engine.contents.headers.cols() + 1;
+        let remaining_col_number = cols.saturating_sub(1);
+        let fraction = if remaining_col_number > 0 {
+            1. / remaining_col_number as f32
+        } else {
+            1.
+        };
+        let mut widths = vec![DefiniteLength::Fraction(fraction); cols];
+        let line_number_width = self.calculate_row_identifier_column_width();
+        widths[0] = DefiniteLength::Absolute(AbsoluteLength::Pixels(line_number_width.into()));
+
+        let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols];
+        resize_behaviors[0] = TableResizeBehavior::None;
+
+        self.column_widths.widths.update(cx, |state, _cx| {
+            if state.cols() != cols
+                || state.initial_widths().as_slice() != widths.as_slice()
+                || state.resize_behavior().as_slice() != resize_behaviors.as_slice()
+            {
+                *state = RedistributableColumnsState::new(cols, widths, resize_behaviors);
+            }
+        });
+    }
+
     pub fn register(workspace: &mut Workspace) {
         workspace.register_action_renderer(|div, _, _, cx| {
             div.when(cx.has_flag::<TabularDataPreviewFeatureFlag>(), |div| {
@@ -286,18 +315,19 @@ impl PerformanceMetrics {
 
 /// Holds state of column widths for a table component in CSV preview.
 pub(crate) struct ColumnWidths {
-    pub widths: Entity<TableColumnWidths>,
+    pub widths: Entity<RedistributableColumnsState>,
 }
 
 impl ColumnWidths {
     pub(crate) fn new(cx: &mut Context<CsvPreviewView>, cols: usize) -> Self {
         Self {
-            widths: cx.new(|cx| TableColumnWidths::new(cols, cx)),
+            widths: cx.new(|_cx| {
+                RedistributableColumnsState::new(
+                    cols,
+                    vec![ui::DefiniteLength::Fraction(1.0 / cols as f32); cols],
+                    vec![ui::TableResizeBehavior::Resizable; cols],
+                )
+            }),
         }
     }
-    /// Replace the current `TableColumnWidths` entity with a new one for the given column count.
-    pub(crate) fn replace(&self, cx: &mut Context<CsvPreviewView>, cols: usize) {
-        self.widths
-            .update(cx, |entity, cx| *entity = TableColumnWidths::new(cols, cx));
-    }
 }

crates/csv_preview/src/parser.rs 🔗

@@ -80,11 +80,8 @@ impl CsvPreviewView {
                     .insert("Parsing", (parse_duration, Instant::now()));
 
                 log::debug!("Parsed {} rows", parsed_csv.rows.len());
-                // Update table width so it can be rendered properly
-                let cols = parsed_csv.headers.cols();
-                view.column_widths.replace(cx, cols + 1); // Add 1 for the line number column
-
                 view.engine.contents = parsed_csv;
+                view.sync_column_widths(cx);
                 view.last_parse_end_time = Some(parse_end_time);
 
                 view.apply_filter_sort();

crates/csv_preview/src/renderer/render_table.rs 🔗

@@ -1,11 +1,9 @@
 use crate::types::TableCell;
 use gpui::{AnyElement, Entity};
 use std::ops::Range;
-use ui::Table;
-use ui::TableColumnWidths;
-use ui::TableResizeBehavior;
-use ui::UncheckedTableRow;
-use ui::{DefiniteLength, div, prelude::*};
+use ui::{
+    ColumnWidthConfig, RedistributableColumnsState, Table, UncheckedTableRow, div, prelude::*,
+};
 
 use crate::{
     CsvPreviewView,
@@ -15,44 +13,22 @@ use crate::{
 
 impl CsvPreviewView {
     /// Creates a new table.
-    /// Column number is derived from the `TableColumnWidths` entity.
+    /// Column number is derived from the `RedistributableColumnsState` entity.
     pub(crate) fn create_table(
         &self,
-        current_widths: &Entity<TableColumnWidths>,
+        current_widths: &Entity<RedistributableColumnsState>,
         cx: &mut Context<Self>,
     ) -> AnyElement {
-        let cols = current_widths.read(cx).cols();
-        let remaining_col_number = cols - 1;
-        let fraction = if remaining_col_number > 0 {
-            1. / remaining_col_number as f32
-        } else {
-            1. // only column with line numbers is present. Put 100%, but it will be overwritten anyways :D
-        };
-        let mut widths = vec![DefiniteLength::Fraction(fraction); cols];
-        let line_number_width = self.calculate_row_identifier_column_width();
-        widths[0] = DefiniteLength::Absolute(AbsoluteLength::Pixels(line_number_width.into()));
-
-        let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols];
-        resize_behaviors[0] = TableResizeBehavior::None;
-
-        self.create_table_inner(
-            self.engine.contents.rows.len(),
-            widths,
-            resize_behaviors,
-            current_widths,
-            cx,
-        )
+        self.create_table_inner(self.engine.contents.rows.len(), current_widths, cx)
     }
 
     fn create_table_inner(
         &self,
         row_count: usize,
-        widths: UncheckedTableRow<DefiniteLength>,
-        resize_behaviors: UncheckedTableRow<TableResizeBehavior>,
-        current_widths: &Entity<TableColumnWidths>,
+        current_widths: &Entity<RedistributableColumnsState>,
         cx: &mut Context<Self>,
     ) -> AnyElement {
-        let cols = widths.len();
+        let cols = current_widths.read(cx).cols();
         // Create headers array with interactive elements
         let mut headers = Vec::with_capacity(cols);
 
@@ -78,8 +54,7 @@ impl CsvPreviewView {
         Table::new(cols)
             .interactable(&self.table_interaction_state)
             .striped()
-            .column_widths(widths)
-            .resizable_columns(resize_behaviors, current_widths, cx)
+            .width_config(ColumnWidthConfig::redistributable(current_widths.clone()))
             .header(headers)
             .disable_base_style()
             .map(|table| {

crates/csv_preview/src/renderer/table_cell.rs 🔗

@@ -53,7 +53,6 @@ fn create_table_cell(
         .px_1()
         .bg(cx.theme().colors().editor_background)
         .border_b_1()
-        .border_r_1()
         .border_color(cx.theme().colors().border_variant)
         .map(|div| match vertical_alignment {
             VerticalAlignment::Top => div.items_start(),

crates/diagnostics/src/diagnostic_renderer.rs 🔗

@@ -8,7 +8,7 @@ use editor::{
 use gpui::{AppContext, Entity, Focusable, WeakEntity};
 use language::{BufferId, Diagnostic, DiagnosticEntryRef, LanguageRegistry};
 use lsp::DiagnosticSeverity;
-use markdown::{Markdown, MarkdownElement};
+use markdown::{CopyButtonVisibility, Markdown, MarkdownElement};
 use settings::Settings;
 use text::{AnchorRangeExt, Point};
 use theme_settings::ThemeSettings;
@@ -239,8 +239,7 @@ impl DiagnosticBlock {
                         diagnostics_markdown_style(bcx.window, cx),
                     )
                     .code_block_renderer(markdown::CodeBlockRenderer::Default {
-                        copy_button: false,
-                        copy_button_on_hover: false,
+                        copy_button_visibility: CopyButtonVisibility::Hidden,
                         border: false,
                     })
                     .on_url_click({

crates/editor/src/code_context_menus.rs 🔗

@@ -9,7 +9,7 @@ use itertools::Itertools;
 use language::CodeLabel;
 use language::{Buffer, LanguageName, LanguageRegistry};
 use lsp::CompletionItemTag;
-use markdown::{Markdown, MarkdownElement};
+use markdown::{CopyButtonVisibility, Markdown, MarkdownElement};
 use multi_buffer::{Anchor, ExcerptId};
 use ordered_float::OrderedFloat;
 use project::lsp_store::CompletionDocumentation;
@@ -1118,8 +1118,7 @@ impl CompletionsMenu {
         div().child(
             MarkdownElement::new(markdown, hover_markdown_style(window, cx))
                 .code_block_renderer(markdown::CodeBlockRenderer::Default {
-                    copy_button: false,
-                    copy_button_on_hover: false,
+                    copy_button_visibility: CopyButtonVisibility::Hidden,
                     border: false,
                 })
                 .on_url_click(open_markdown_url),

crates/editor/src/editor_tests.rs 🔗

@@ -32309,6 +32309,91 @@ async fn test_scroll_by_clicking_sticky_header(cx: &mut TestAppContext) {
     assert_eq!(selections, vec![empty_range(4, 5)]);
 }
 
+#[gpui::test]
+async fn test_clicking_sticky_header_sets_character_select_mode(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+    cx.update(|cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings.editor.sticky_scroll = Some(settings::StickyScrollContent {
+                    enabled: Some(true),
+                })
+            });
+        });
+    });
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let line_height = cx.update_editor(|editor, window, cx| {
+        editor
+            .style(cx)
+            .text
+            .line_height_in_pixels(window.rem_size())
+    });
+
+    let buffer = indoc! {"
+            fn foo() {
+                let abc = 123;
+            }
+            ˇstruct Bar;
+        "};
+    cx.set_state(&buffer);
+
+    cx.update_editor(|editor, _, cx| {
+        editor
+            .buffer()
+            .read(cx)
+            .as_singleton()
+            .unwrap()
+            .update(cx, |buffer, cx| {
+                buffer.set_language(Some(rust_lang()), cx);
+            })
+    });
+
+    let text_origin_x = cx.update_editor(|editor, _, _| {
+        editor
+            .last_position_map
+            .as_ref()
+            .unwrap()
+            .text_hitbox
+            .bounds
+            .origin
+            .x
+    });
+
+    cx.update_editor(|editor, window, cx| {
+        // Double click on `struct` to select it
+        editor.begin_selection(DisplayPoint::new(DisplayRow(3), 1), false, 2, window, cx);
+        editor.end_selection(window, cx);
+
+        // Scroll down one row to make `fn foo() {` a sticky header
+        editor.scroll(gpui::Point { x: 0., y: 1. }, None, window, cx);
+    });
+    cx.run_until_parked();
+
+    // Click at the start of the `fn foo() {` sticky header
+    cx.simulate_click(
+        gpui::Point {
+            x: text_origin_x,
+            y: 0.5 * line_height,
+        },
+        Modifiers::none(),
+    );
+    cx.run_until_parked();
+
+    // Shift-click at the end of `fn foo() {` to select the whole row
+    cx.update_editor(|editor, window, cx| {
+        editor.extend_selection(DisplayPoint::new(DisplayRow(0), 10), 1, window, cx);
+        editor.end_selection(window, cx);
+    });
+    cx.run_until_parked();
+
+    let selections = cx.update_editor(|editor, _, cx| display_ranges(editor, cx));
+    assert_eq!(
+        selections,
+        vec![DisplayPoint::new(DisplayRow(0), 0)..DisplayPoint::new(DisplayRow(0), 10)]
+    );
+}
+
 #[gpui::test]
 async fn test_next_prev_reference(cx: &mut TestAppContext) {
     const CYCLE_POSITIONS: &[&'static str] = &[

crates/editor/src/element.rs 🔗

@@ -1289,7 +1289,9 @@ impl EditorElement {
             cx.notify();
         }
 
-        if let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds {
+        if text_hovered
+            && let Some((bounds, buffer_id, blame_entry)) = &position_map.inline_blame_bounds
+        {
             let mouse_over_inline_blame = bounds.contains(&event.position);
             let mouse_over_popover = editor
                 .inline_blame_popover
@@ -6732,7 +6734,13 @@ impl EditorElement {
                             SelectionEffects::scroll(Autoscroll::top_relative(line_index)),
                             window,
                             cx,
-                            |selections| selections.select_ranges([anchor..anchor]),
+                            |selections| {
+                                selections.clear_disjoint();
+                                selections.set_pending_anchor_range(
+                                    anchor..anchor,
+                                    crate::SelectMode::Character,
+                                );
+                            },
                         );
                         cx.stop_propagation();
                     });

crates/editor/src/hover_popover.rs 🔗

@@ -17,7 +17,7 @@ use gpui::{
 use itertools::Itertools;
 use language::{DiagnosticEntry, Language, LanguageRegistry};
 use lsp::DiagnosticSeverity;
-use markdown::{Markdown, MarkdownElement, MarkdownStyle};
+use markdown::{CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle};
 use multi_buffer::{MultiBufferOffset, ToOffset, ToPoint};
 use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart};
 use settings::Settings;
@@ -1040,8 +1040,7 @@ impl InfoPopover {
                         .child(
                             MarkdownElement::new(markdown, hover_markdown_style(window, cx))
                                 .code_block_renderer(markdown::CodeBlockRenderer::Default {
-                                    copy_button: false,
-                                    copy_button_on_hover: false,
+                                    copy_button_visibility: CopyButtonVisibility::Hidden,
                                     border: false,
                                 })
                                 .on_url_click(open_markdown_url)
@@ -1155,8 +1154,7 @@ impl DiagnosticPopover {
                                     diagnostics_markdown_style(window, cx),
                                 )
                                 .code_block_renderer(markdown::CodeBlockRenderer::Default {
-                                    copy_button: false,
-                                    copy_button_on_hover: false,
+                                    copy_button_visibility: CopyButtonVisibility::Hidden,
                                     border: false,
                                 })
                                 .on_url_click(

crates/editor/src/signature_help.rs 🔗

@@ -7,7 +7,7 @@ use gpui::{
 };
 use language::BufferSnapshot;
 
-use markdown::{Markdown, MarkdownElement};
+use markdown::{CopyButtonVisibility, Markdown, MarkdownElement};
 use multi_buffer::{Anchor, MultiBufferOffset, ToOffset};
 use settings::Settings;
 use std::ops::Range;
@@ -408,9 +408,8 @@ impl SignatureHelpPopover {
                                         hover_markdown_style(window, cx),
                                     )
                                     .code_block_renderer(markdown::CodeBlockRenderer::Default {
-                                        copy_button: false,
+                                        copy_button_visibility: CopyButtonVisibility::Hidden,
                                         border: false,
-                                        copy_button_on_hover: false,
                                     })
                                     .on_url_click(open_markdown_url),
                                 )
@@ -421,9 +420,8 @@ impl SignatureHelpPopover {
                             .child(
                                 MarkdownElement::new(description, hover_markdown_style(window, cx))
                                     .code_block_renderer(markdown::CodeBlockRenderer::Default {
-                                        copy_button: false,
+                                        copy_button_visibility: CopyButtonVisibility::Hidden,
                                         border: false,
-                                        copy_button_on_hover: false,
                                     })
                                     .on_url_click(open_markdown_url),
                             )

crates/extension_host/src/wasm_host.rs 🔗

@@ -42,7 +42,7 @@ use wasmtime::{
     CacheStore, Engine, Store,
     component::{Component, ResourceTable},
 };
-use wasmtime_wasi::p2::{self as wasi, IoView as _};
+use wasmtime_wasi::{WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView};
 use wit::Extension;
 
 pub struct WasmHost {
@@ -93,7 +93,7 @@ impl extension::Extension for WasmExtension {
     ) -> Result<Command> {
         self.call(|extension, store| {
             async move {
-                let resource = store.data_mut().table().push(worktree)?;
+                let resource = store.data_mut().table.push(worktree)?;
                 let command = extension
                     .call_language_server_command(
                         store,
@@ -119,7 +119,7 @@ impl extension::Extension for WasmExtension {
     ) -> Result<Option<String>> {
         self.call(|extension, store| {
             async move {
-                let resource = store.data_mut().table().push(worktree)?;
+                let resource = store.data_mut().table.push(worktree)?;
                 let options = extension
                     .call_language_server_initialization_options(
                         store,
@@ -143,7 +143,7 @@ impl extension::Extension for WasmExtension {
     ) -> Result<Option<String>> {
         self.call(|extension, store| {
             async move {
-                let resource = store.data_mut().table().push(worktree)?;
+                let resource = store.data_mut().table.push(worktree)?;
                 let options = extension
                     .call_language_server_workspace_configuration(
                         store,
@@ -166,7 +166,7 @@ impl extension::Extension for WasmExtension {
     ) -> Result<Option<String>> {
         self.call(|extension, store| {
             async move {
-                let resource = store.data_mut().table().push(worktree)?;
+                let resource = store.data_mut().table.push(worktree)?;
                 extension
                     .call_language_server_initialization_options_schema(
                         store,
@@ -187,7 +187,7 @@ impl extension::Extension for WasmExtension {
     ) -> Result<Option<String>> {
         self.call(|extension, store| {
             async move {
-                let resource = store.data_mut().table().push(worktree)?;
+                let resource = store.data_mut().table.push(worktree)?;
                 extension
                     .call_language_server_workspace_configuration_schema(
                         store,
@@ -209,7 +209,7 @@ impl extension::Extension for WasmExtension {
     ) -> Result<Option<String>> {
         self.call(|extension, store| {
             async move {
-                let resource = store.data_mut().table().push(worktree)?;
+                let resource = store.data_mut().table.push(worktree)?;
                 let options = extension
                     .call_language_server_additional_initialization_options(
                         store,
@@ -234,7 +234,7 @@ impl extension::Extension for WasmExtension {
     ) -> Result<Option<String>> {
         self.call(|extension, store| {
             async move {
-                let resource = store.data_mut().table().push(worktree)?;
+                let resource = store.data_mut().table.push(worktree)?;
                 let options = extension
                     .call_language_server_additional_workspace_configuration(
                         store,
@@ -331,7 +331,7 @@ impl extension::Extension for WasmExtension {
         self.call(|extension, store| {
             async move {
                 let resource = if let Some(delegate) = delegate {
-                    Some(store.data_mut().table().push(delegate)?)
+                    Some(store.data_mut().table.push(delegate)?)
                 } else {
                     None
                 };
@@ -355,7 +355,7 @@ impl extension::Extension for WasmExtension {
     ) -> Result<Command> {
         self.call(|extension, store| {
             async move {
-                let project_resource = store.data_mut().table().push(project)?;
+                let project_resource = store.data_mut().table.push(project)?;
                 let command = extension
                     .call_context_server_command(store, context_server_id.clone(), project_resource)
                     .await?
@@ -374,7 +374,7 @@ impl extension::Extension for WasmExtension {
     ) -> Result<Option<ContextServerConfiguration>> {
         self.call(|extension, store| {
             async move {
-                let project_resource = store.data_mut().table().push(project)?;
+                let project_resource = store.data_mut().table.push(project)?;
                 let Some(configuration) = extension
                     .call_context_server_configuration(
                         store,
@@ -417,7 +417,7 @@ impl extension::Extension for WasmExtension {
     ) -> Result<()> {
         self.call(|extension, store| {
             async move {
-                let kv_store_resource = store.data_mut().table().push(kv_store)?;
+                let kv_store_resource = store.data_mut().table.push(kv_store)?;
                 extension
                     .call_index_docs(
                         store,
@@ -444,7 +444,7 @@ impl extension::Extension for WasmExtension {
     ) -> Result<DebugAdapterBinary> {
         self.call(|extension, store| {
             async move {
-                let resource = store.data_mut().table().push(worktree)?;
+                let resource = store.data_mut().table.push(worktree)?;
                 let dap_binary = extension
                     .call_get_dap_binary(store, dap_name, config, user_installed_path, resource)
                     .await?
@@ -532,7 +532,7 @@ impl extension::Extension for WasmExtension {
 pub struct WasmState {
     manifest: Arc<ExtensionManifest>,
     pub table: ResourceTable,
-    ctx: wasi::WasiCtx,
+    ctx: WasiCtx,
     pub host: Arc<WasmHost>,
     pub(crate) capability_granter: CapabilityGranter,
 }
@@ -726,7 +726,7 @@ impl WasmHost {
         })
     }
 
-    async fn build_wasi_ctx(&self, manifest: &Arc<ExtensionManifest>) -> Result<wasi::WasiCtx> {
+    async fn build_wasi_ctx(&self, manifest: &Arc<ExtensionManifest>) -> Result<WasiCtx> {
         let extension_work_dir = self.work_dir.join(manifest.id.as_ref());
         self.fs
             .create_dir(&extension_work_dir)
@@ -739,7 +739,7 @@ impl WasmHost {
         #[cfg(target_os = "windows")]
         let path = path.replace('\\', "/");
 
-        let mut ctx = wasi::WasiCtxBuilder::new();
+        let mut ctx = WasiCtxBuilder::new();
         ctx.inherit_stdio()
             .env("PWD", &path)
             .env("RUST_BACKTRACE", "full");
@@ -947,15 +947,16 @@ impl WasmState {
     }
 }
 
-impl wasi::IoView for WasmState {
-    fn table(&mut self) -> &mut ResourceTable {
-        &mut self.table
-    }
+impl wasmtime::component::HasData for WasmState {
+    type Data<'a> = &'a mut WasmState;
 }
 
-impl wasi::WasiView for WasmState {
-    fn ctx(&mut self) -> &mut wasi::WasiCtx {
-        &mut self.ctx
+impl WasiView for WasmState {
+    fn ctx(&mut self) -> WasiCtxView<'_> {
+        WasiCtxView {
+            ctx: &mut self.ctx,
+            table: &mut self.table,
+        }
     }
 }
 

crates/extension_host/src/wasm_host/wit.rs 🔗

@@ -42,18 +42,14 @@ pub use since_v0_0_4::LanguageServerConfig;
 
 pub fn new_linker(
     executor: &BackgroundExecutor,
-    f: impl Fn(&mut Linker<WasmState>, fn(&mut WasmState) -> &mut WasmState) -> Result<()>,
+    f: impl FnOnce(&mut Linker<WasmState>) -> Result<()>,
 ) -> Linker<WasmState> {
     let mut linker = Linker::new(&wasm_engine(executor));
     wasmtime_wasi::p2::add_to_linker_async(&mut linker).unwrap();
-    f(&mut linker, wasi_view).unwrap();
+    f(&mut linker).unwrap();
     linker
 }
 
-fn wasi_view(state: &mut WasmState) -> &mut WasmState {
-    state
-}
-
 /// Returns whether the given Wasm API version is supported by the Wasm host.
 pub fn is_supported_wasm_api_version(release_channel: ReleaseChannel, version: Version) -> bool {
     wasm_api_version_range(release_channel).contains(&version)

crates/extension_host/src/wasm_host/wit/since_v0_0_1.rs 🔗

@@ -12,8 +12,12 @@ use wasmtime::component::{Linker, Resource};
 pub const MIN_VERSION: Version = Version::new(0, 0, 1);
 
 wasmtime::component::bindgen!({
-    async: true,
-    trappable_imports: true,
+    imports: {
+        default: async | trappable,
+    },
+    exports: {
+        default: async,
+    },
     path: "../extension_api/wit/since_v0.0.1",
     with: {
          "worktree": ExtensionWorktree,
@@ -26,7 +30,11 @@ pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
 
 pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+    LINKER.get_or_init(|| {
+        super::new_linker(executor, |linker| {
+            Extension::add_to_linker::<_, WasmState>(linker, |s| s)
+        })
+    })
 }
 
 impl From<DownloadedFileType> for latest::DownloadedFileType {

crates/extension_host/src/wasm_host/wit/since_v0_0_4.rs 🔗

@@ -10,8 +10,12 @@ use wasmtime::component::{Linker, Resource};
 pub const MIN_VERSION: Version = Version::new(0, 0, 4);
 
 wasmtime::component::bindgen!({
-    async: true,
-    trappable_imports: true,
+    imports: {
+        default: async | trappable,
+    },
+    exports: {
+        default: async,
+    },
     path: "../extension_api/wit/since_v0.0.4",
     with: {
          "worktree": ExtensionWorktree,
@@ -24,7 +28,11 @@ pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
 
 pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+    LINKER.get_or_init(|| {
+        super::new_linker(executor, |linker| {
+            Extension::add_to_linker::<_, WasmState>(linker, |s| s)
+        })
+    })
 }
 
 impl From<DownloadedFileType> for latest::DownloadedFileType {

crates/extension_host/src/wasm_host/wit/since_v0_0_6.rs 🔗

@@ -10,8 +10,12 @@ use wasmtime::component::{Linker, Resource};
 pub const MIN_VERSION: Version = Version::new(0, 0, 6);
 
 wasmtime::component::bindgen!({
-    async: true,
-    trappable_imports: true,
+    imports: {
+        default: async | trappable,
+    },
+    exports: {
+        default: async,
+    },
     path: "../extension_api/wit/since_v0.0.6",
     with: {
          "worktree": ExtensionWorktree,
@@ -31,7 +35,11 @@ pub type ExtensionWorktree = Arc<dyn WorktreeDelegate>;
 
 pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+    LINKER.get_or_init(|| {
+        super::new_linker(executor, |linker| {
+            Extension::add_to_linker::<_, WasmState>(linker, |s| s)
+        })
+    })
 }
 
 impl From<Command> for latest::Command {

crates/extension_host/src/wasm_host/wit/since_v0_1_0.rs 🔗

@@ -26,8 +26,12 @@ use super::{latest, since_v0_6_0};
 pub const MIN_VERSION: Version = Version::new(0, 1, 0);
 
 wasmtime::component::bindgen!({
-    async: true,
-    trappable_imports: true,
+    imports: {
+        default: async | trappable,
+    },
+    exports: {
+        default: async,
+    },
     path: "../extension_api/wit/since_v0.1.0",
     with: {
          "worktree": ExtensionWorktree,
@@ -52,7 +56,11 @@ pub type ExtensionHttpResponseStream = Arc<Mutex<::http_client::Response<AsyncBo
 
 pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+    LINKER.get_or_init(|| {
+        super::new_linker(executor, |linker| {
+            Extension::add_to_linker::<_, WasmState>(linker, |s| s)
+        })
+    })
 }
 
 impl From<Command> for latest::Command {

crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs 🔗

@@ -11,8 +11,12 @@ use super::{latest, since_v0_6_0};
 pub const MIN_VERSION: Version = Version::new(0, 2, 0);
 
 wasmtime::component::bindgen!({
-    async: true,
-    trappable_imports: true,
+    imports: {
+        default: async | trappable,
+    },
+    exports: {
+        default: async,
+    },
     path: "../extension_api/wit/since_v0.2.0",
     with: {
          "worktree": ExtensionWorktree,
@@ -40,7 +44,11 @@ pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
 
 pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+    LINKER.get_or_init(|| {
+        super::new_linker(executor, |linker| {
+            Extension::add_to_linker::<_, WasmState>(linker, |s| s)
+        })
+    })
 }
 
 impl From<Command> for latest::Command {

crates/extension_host/src/wasm_host/wit/since_v0_3_0.rs 🔗

@@ -11,8 +11,12 @@ use super::{latest, since_v0_6_0};
 pub const MIN_VERSION: Version = Version::new(0, 3, 0);
 
 wasmtime::component::bindgen!({
-    async: true,
-    trappable_imports: true,
+    imports: {
+        default: async | trappable,
+    },
+    exports: {
+        default: async,
+    },
     path: "../extension_api/wit/since_v0.3.0",
     with: {
          "worktree": ExtensionWorktree,
@@ -40,7 +44,11 @@ pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
 
 pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+    LINKER.get_or_init(|| {
+        super::new_linker(executor, |linker| {
+            Extension::add_to_linker::<_, WasmState>(linker, |s| s)
+        })
+    })
 }
 
 impl From<CodeLabel> for latest::CodeLabel {

crates/extension_host/src/wasm_host/wit/since_v0_4_0.rs 🔗

@@ -11,8 +11,12 @@ use super::{latest, since_v0_6_0};
 pub const MIN_VERSION: Version = Version::new(0, 4, 0);
 
 wasmtime::component::bindgen!({
-    async: true,
-    trappable_imports: true,
+    imports: {
+        default: async | trappable,
+    },
+    exports: {
+        default: async,
+    },
     path: "../extension_api/wit/since_v0.4.0",
     with: {
         "worktree": ExtensionWorktree,
@@ -40,7 +44,11 @@ pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
 
 pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+    LINKER.get_or_init(|| {
+        super::new_linker(executor, |linker| {
+            Extension::add_to_linker::<_, WasmState>(linker, |s| s)
+        })
+    })
 }
 
 impl From<CodeLabel> for latest::CodeLabel {

crates/extension_host/src/wasm_host/wit/since_v0_5_0.rs 🔗

@@ -11,8 +11,12 @@ use super::{latest, since_v0_6_0};
 pub const MIN_VERSION: Version = Version::new(0, 5, 0);
 
 wasmtime::component::bindgen!({
-    async: true,
-    trappable_imports: true,
+    imports: {
+        default: async | trappable,
+    },
+    exports: {
+        default: async,
+    },
     path: "../extension_api/wit/since_v0.5.0",
     with: {
         "worktree": ExtensionWorktree,
@@ -41,7 +45,11 @@ pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
 
 pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+    LINKER.get_or_init(|| {
+        super::new_linker(executor, |linker| {
+            Extension::add_to_linker::<_, WasmState>(linker, |s| s)
+        })
+    })
 }
 
 impl From<CodeLabel> for latest::CodeLabel {

crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs 🔗

@@ -12,8 +12,12 @@ pub const MIN_VERSION: Version = Version::new(0, 6, 0);
 pub const MAX_VERSION: Version = Version::new(0, 7, 0);
 
 wasmtime::component::bindgen!({
-    async: true,
-    trappable_imports: true,
+    imports: {
+        default: async | trappable,
+    },
+    exports: {
+        default: async,
+    },
     path: "../extension_api/wit/since_v0.6.0",
     with: {
         "worktree": ExtensionWorktree,
@@ -43,7 +47,11 @@ pub type ExtensionKeyValueStore = Arc<dyn KeyValueStoreDelegate>;
 
 pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+    LINKER.get_or_init(|| {
+        super::new_linker(executor, |linker| {
+            Extension::add_to_linker::<_, WasmState>(linker, |s| s)
+        })
+    })
 }
 
 impl From<CodeLabel> for latest::CodeLabel {

crates/extension_host/src/wasm_host/wit/since_v0_8_0.rs 🔗

@@ -40,8 +40,12 @@ pub const MIN_VERSION: Version = Version::new(0, 8, 0);
 pub const MAX_VERSION: Version = Version::new(0, 8, 0);
 
 wasmtime::component::bindgen!({
-    async: true,
-    trappable_imports: true,
+    imports: {
+        default: async | trappable,
+    },
+    exports: {
+        default: async,
+    },
     path: "../extension_api/wit/since_v0.8.0",
     with: {
          "worktree": ExtensionWorktree,
@@ -65,7 +69,11 @@ pub type ExtensionHttpResponseStream = Arc<Mutex<::http_client::Response<AsyncBo
 
 pub fn linker(executor: &BackgroundExecutor) -> &'static Linker<WasmState> {
     static LINKER: OnceLock<Linker<WasmState>> = OnceLock::new();
-    LINKER.get_or_init(|| super::new_linker(executor, Extension::add_to_linker))
+    LINKER.get_or_init(|| {
+        super::new_linker(executor, |linker| {
+            Extension::add_to_linker::<_, WasmState>(linker, |s| s)
+        })
+    })
 }
 
 impl From<Range> for std::ops::Range<usize> {

crates/git_graph/src/git_graph.rs 🔗

@@ -41,9 +41,9 @@ use theme::AccentColors;
 use theme_settings::ThemeSettings;
 use time::{OffsetDateTime, UtcOffset, format_description::BorrowedFormatItem};
 use ui::{
-    ButtonLike, Chip, CommonAnimationExt as _, ContextMenu, DiffStat, Divider, HighlightedLabel,
-    ScrollableHandle, Table, TableColumnWidths, TableInteractionState, TableResizeBehavior,
-    Tooltip, WithScrollbar, prelude::*,
+    ButtonLike, Chip, ColumnWidthConfig, CommonAnimationExt as _, ContextMenu, DiffStat, Divider,
+    HighlightedLabel, RedistributableColumnsState, ScrollableHandle, Table, TableInteractionState,
+    TableResizeBehavior, Tooltip, WithScrollbar, prelude::*,
 };
 use workspace::{
     Workspace,
@@ -901,7 +901,7 @@ pub struct GitGraph {
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
     row_height: Pixels,
     table_interaction_state: Entity<TableInteractionState>,
-    table_column_widths: Entity<TableColumnWidths>,
+    table_column_widths: Entity<RedistributableColumnsState>,
     horizontal_scroll_offset: Pixels,
     graph_viewport_width: Pixels,
     selected_entry_idx: Option<usize>,
@@ -972,7 +972,23 @@ impl GitGraph {
         });
 
         let table_interaction_state = cx.new(|cx| TableInteractionState::new(cx));
-        let table_column_widths = cx.new(|cx| TableColumnWidths::new(4, cx));
+        let table_column_widths = cx.new(|_cx| {
+            RedistributableColumnsState::new(
+                4,
+                vec![
+                    DefiniteLength::Fraction(0.72),
+                    DefiniteLength::Fraction(0.12),
+                    DefiniteLength::Fraction(0.10),
+                    DefiniteLength::Fraction(0.06),
+                ],
+                vec![
+                    TableResizeBehavior::Resizable,
+                    TableResizeBehavior::Resizable,
+                    TableResizeBehavior::Resizable,
+                    TableResizeBehavior::Resizable,
+                ],
+            )
+        });
         let mut row_height = Self::row_height(cx);
 
         cx.observe_global_in::<settings::SettingsStore>(window, move |this, _window, cx| {
@@ -1324,6 +1340,12 @@ impl GitGraph {
             editor.set_text_style_refinement(Default::default());
         });
 
+        if query.as_str().is_empty() {
+            self.search_state.state = QueryState::Empty;
+            cx.notify();
+            return;
+        }
+
         let (request_tx, request_rx) = smol::channel::unbounded::<Oid>();
 
         repo.update(cx, |repo, cx| {
@@ -2453,11 +2475,6 @@ impl Render for GitGraph {
             self.search_state.state = QueryState::Empty;
             self.search(query, cx);
         }
-        let description_width_fraction = 0.72;
-        let date_width_fraction = 0.12;
-        let author_width_fraction = 0.10;
-        let commit_width_fraction = 0.06;
-
         let (commit_count, is_loading) = match self.graph_data.max_commit_count {
             AllCommitCount::Loaded(count) => (count, true),
             AllCommitCount::NotLoaded => {
@@ -2517,7 +2534,10 @@ impl Render for GitGraph {
                         .flex_col()
                         .child(
                             div()
-                                .p_2()
+                                .flex()
+                                .items_center()
+                                .px_1()
+                                .py_0p5()
                                 .border_b_1()
                                 .whitespace_nowrap()
                                 .border_color(cx.theme().colors().border)
@@ -2559,25 +2579,9 @@ impl Render for GitGraph {
                                 Label::new("Author").color(Color::Muted).into_any_element(),
                                 Label::new("Commit").color(Color::Muted).into_any_element(),
                             ])
-                            .column_widths(
-                                [
-                                    DefiniteLength::Fraction(description_width_fraction),
-                                    DefiniteLength::Fraction(date_width_fraction),
-                                    DefiniteLength::Fraction(author_width_fraction),
-                                    DefiniteLength::Fraction(commit_width_fraction),
-                                ]
-                                .to_vec(),
-                            )
-                            .resizable_columns(
-                                vec![
-                                    TableResizeBehavior::Resizable,
-                                    TableResizeBehavior::Resizable,
-                                    TableResizeBehavior::Resizable,
-                                    TableResizeBehavior::Resizable,
-                                ],
-                                &self.table_column_widths,
-                                cx,
-                            )
+                            .width_config(ColumnWidthConfig::redistributable(
+                                self.table_column_widths.clone(),
+                            ))
                             .map_row(move |(index, row), window, cx| {
                                 let is_selected = selected_entry_idx == Some(index);
                                 let is_hovered = hovered_entry_idx == Some(index);

crates/git_ui/src/branch_picker.rs 🔗

@@ -1087,13 +1087,8 @@ impl PickerDelegate for BranchListDelegate {
                         ),
                 )
                 .when(!is_new_items && !is_head_branch, |this| {
-                    this.map(|this| {
-                        if self.selected_index() == ix {
-                            this.end_slot(deleted_branch_icon(ix))
-                        } else {
-                            this.end_hover_slot(deleted_branch_icon(ix))
-                        }
-                    })
+                    this.end_slot(deleted_branch_icon(ix))
+                        .show_end_slot_on_hover()
                 })
                 .when_some(
                     if is_new_items {
@@ -1102,13 +1097,8 @@ impl PickerDelegate for BranchListDelegate {
                         None
                     },
                     |this, create_from_default_button| {
-                        this.map(|this| {
-                            if self.selected_index() == ix {
-                                this.end_slot(create_from_default_button)
-                            } else {
-                                this.end_hover_slot(create_from_default_button)
-                            }
-                        })
+                        this.end_slot(create_from_default_button)
+                            .show_end_slot_on_hover()
                     },
                 ),
         )

crates/git_ui/src/stash_picker.rs 🔗

@@ -501,16 +501,39 @@ impl PickerDelegate for StashListDelegate {
                     .size(LabelSize::Small),
             );
 
-        let focus_handle = self.focus_handle.clone();
+        let view_button = {
+            let focus_handle = self.focus_handle.clone();
+            IconButton::new(("view-stash", ix), IconName::Eye)
+                .icon_size(IconSize::Small)
+                .tooltip(move |_, cx| {
+                    Tooltip::for_action_in("View Stash", &ShowStashItem, &focus_handle, cx)
+                })
+                .on_click(cx.listener(move |this, _, window, cx| {
+                    this.delegate.show_stash_at(ix, window, cx);
+                }))
+        };
+
+        let pop_button = {
+            let focus_handle = self.focus_handle.clone();
+            IconButton::new(("pop-stash", ix), IconName::MaximizeAlt)
+                .icon_size(IconSize::Small)
+                .tooltip(move |_, cx| {
+                    Tooltip::for_action_in("Pop Stash", &menu::SecondaryConfirm, &focus_handle, cx)
+                })
+                .on_click(|_, window, cx| {
+                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
+                })
+        };
 
-        let drop_button = |entry_ix: usize| {
-            IconButton::new(("drop-stash", entry_ix), IconName::Trash)
+        let drop_button = {
+            let focus_handle = self.focus_handle.clone();
+            IconButton::new(("drop-stash", ix), IconName::Trash)
                 .icon_size(IconSize::Small)
                 .tooltip(move |_, cx| {
                     Tooltip::for_action_in("Drop Stash", &DropStashItem, &focus_handle, cx)
                 })
                 .on_click(cx.listener(move |this, _, window, cx| {
-                    this.delegate.drop_stash_at(entry_ix, window, cx);
+                    this.delegate.drop_stash_at(ix, window, cx);
                 }))
         };
 
@@ -530,17 +553,14 @@ impl PickerDelegate for StashListDelegate {
                         )
                         .child(div().w_full().child(stash_label).child(branch_info)),
                 )
-                .tooltip(Tooltip::text(format!(
-                    "stash@{{{}}}",
-                    entry_match.entry.index
-                )))
-                .map(|this| {
-                    if selected {
-                        this.end_slot(drop_button(ix))
-                    } else {
-                        this.end_hover_slot(drop_button(ix))
-                    }
-                }),
+                .end_slot(
+                    h_flex()
+                        .gap_0p5()
+                        .child(view_button)
+                        .child(pop_button)
+                        .child(drop_button),
+                )
+                .show_end_slot_on_hover(),
         )
     }
 
@@ -549,6 +569,10 @@ impl PickerDelegate for StashListDelegate {
     }
 
     fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
+        if self.matches.is_empty() {
+            return None;
+        }
+
         let focus_handle = self.focus_handle.clone();
 
         Some(

crates/git_ui/src/worktree_picker.rs 🔗

@@ -884,12 +884,30 @@ impl PickerDelegate for WorktreeListDelegate {
                             }
                         })),
                 )
-                .when(can_delete, |this| {
-                    if selected {
-                        this.end_slot(delete_button(ix))
-                    } else {
-                        this.end_hover_slot(delete_button(ix))
-                    }
+                .when(!entry.is_new, |this| {
+                    let focus_handle = self.focus_handle.clone();
+                    let open_in_new_window_button =
+                        IconButton::new(("open-new-window", ix), IconName::ArrowUpRight)
+                            .icon_size(IconSize::Small)
+                            .tooltip(move |_, cx| {
+                                Tooltip::for_action_in(
+                                    "Open in New Window",
+                                    &menu::SecondaryConfirm,
+                                    &focus_handle,
+                                    cx,
+                                )
+                            })
+                            .on_click(|_, window, cx| {
+                                window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
+                            });
+
+                    this.end_slot(
+                        h_flex()
+                            .gap_0p5()
+                            .child(open_in_new_window_button)
+                            .when(can_delete, |this| this.child(delete_button(ix))),
+                    )
+                    .show_end_slot_on_hover()
                 }),
         )
     }

crates/gpui/examples/input.rs 🔗

@@ -85,14 +85,24 @@ impl TextInput {
 
     fn backspace(&mut self, _: &Backspace, window: &mut Window, cx: &mut Context<Self>) {
         if self.selected_range.is_empty() {
-            self.select_to(self.previous_boundary(self.cursor_offset()), cx)
+            let prev = self.previous_boundary(self.cursor_offset());
+            if self.cursor_offset() == prev {
+                window.play_system_bell();
+                return;
+            }
+            self.select_to(prev, cx)
         }
         self.replace_text_in_range(None, "", window, cx)
     }
 
     fn delete(&mut self, _: &Delete, window: &mut Window, cx: &mut Context<Self>) {
         if self.selected_range.is_empty() {
-            self.select_to(self.next_boundary(self.cursor_offset()), cx)
+            let next = self.next_boundary(self.cursor_offset());
+            if self.cursor_offset() == next {
+                window.play_system_bell();
+                return;
+            }
+            self.select_to(next, cx)
         }
         self.replace_text_in_range(None, "", window, cx)
     }

crates/gpui/src/platform.rs 🔗

@@ -689,6 +689,8 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle {
 
     fn update_ime_position(&self, _bounds: Bounds<Pixels>);
 
+    fn play_system_bell(&self) {}
+
     #[cfg(any(test, feature = "test-support"))]
     fn as_test(&mut self) -> Option<&mut TestWindow> {
         None

crates/gpui/src/window.rs 🔗

@@ -5024,6 +5024,12 @@ impl Window {
             .set_tabbing_identifier(tabbing_identifier)
     }
 
+    /// Request the OS to play an alert sound. On some platforms this is associated
+    /// with the window, for others it's just a simple global function call.
+    pub fn play_system_bell(&self) {
+        self.platform_window.play_system_bell()
+    }
+
     /// Toggles the inspector mode on this window.
     #[cfg(any(feature = "inspector", debug_assertions))]
     pub fn toggle_inspector(&mut self, cx: &mut App) {

crates/gpui_linux/src/linux/wayland/client.rs 🔗

@@ -58,6 +58,7 @@ use wayland_protocols::xdg::decoration::zv1::client::{
     zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1,
 };
 use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
+use wayland_protocols::xdg::system_bell::v1::client::xdg_system_bell_v1;
 use wayland_protocols::{
     wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1},
     xdg::dialog::v1::client::xdg_wm_dialog_v1::{self, XdgWmDialogV1},
@@ -129,6 +130,7 @@ pub struct Globals {
     pub text_input_manager: Option<zwp_text_input_manager_v3::ZwpTextInputManagerV3>,
     pub gesture_manager: Option<zwp_pointer_gestures_v1::ZwpPointerGesturesV1>,
     pub dialog: Option<xdg_wm_dialog_v1::XdgWmDialogV1>,
+    pub system_bell: Option<xdg_system_bell_v1::XdgSystemBellV1>,
     pub executor: ForegroundExecutor,
 }
 
@@ -170,6 +172,7 @@ impl Globals {
             text_input_manager: globals.bind(&qh, 1..=1, ()).ok(),
             gesture_manager: globals.bind(&qh, 1..=3, ()).ok(),
             dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(),
+            system_bell: globals.bind(&qh, 1..=1, ()).ok(),
             executor,
             qh,
         }
@@ -1069,6 +1072,7 @@ impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for WaylandClientStat
 }
 
 delegate_noop!(WaylandClientStatePtr: ignore xdg_activation_v1::XdgActivationV1);
+delegate_noop!(WaylandClientStatePtr: ignore xdg_system_bell_v1::XdgSystemBellV1);
 delegate_noop!(WaylandClientStatePtr: ignore wl_compositor::WlCompositor);
 delegate_noop!(WaylandClientStatePtr: ignore wp_cursor_shape_device_v1::WpCursorShapeDeviceV1);
 delegate_noop!(WaylandClientStatePtr: ignore wp_cursor_shape_manager_v1::WpCursorShapeManagerV1);

crates/gpui_linux/src/linux/wayland/window.rs 🔗

@@ -1479,6 +1479,18 @@ impl PlatformWindow for WaylandWindow {
     fn gpu_specs(&self) -> Option<GpuSpecs> {
         self.borrow().renderer.gpu_specs().into()
     }
+
+    fn play_system_bell(&self) {
+        let state = self.borrow();
+        let surface = if state.surface_state.toplevel().is_some() {
+            Some(&state.surface)
+        } else {
+            None
+        };
+        if let Some(bell) = state.globals.system_bell.as_ref() {
+            bell.ring(surface);
+        }
+    }
 }
 
 fn update_window(mut state: RefMut<WaylandWindowState>) {

crates/gpui_linux/src/linux/x11/window.rs 🔗

@@ -1846,4 +1846,9 @@ impl PlatformWindow for X11Window {
     fn gpu_specs(&self) -> Option<GpuSpecs> {
         self.0.state.borrow().renderer.gpu_specs().into()
     }
+
+    fn play_system_bell(&self) {
+        // Volume 0% means don't increase or decrease from system volume
+        let _ = self.0.xcb.bell(0);
+    }
 }

crates/gpui_macos/Cargo.toml 🔗

@@ -48,6 +48,7 @@ mach2.workspace = true
 media.workspace = true
 metal.workspace = true
 objc.workspace = true
+objc2-app-kit.workspace = true
 parking_lot.workspace = true
 pathfinder_geometry = "0.5"
 raw-window-handle = "0.6"

crates/gpui_macos/src/window.rs 🔗

@@ -49,6 +49,7 @@ use objc::{
     runtime::{BOOL, Class, NO, Object, Protocol, Sel, YES},
     sel, sel_impl,
 };
+use objc2_app_kit::NSBeep;
 use parking_lot::Mutex;
 use raw_window_handle as rwh;
 use smallvec::SmallVec;
@@ -1676,6 +1677,10 @@ impl PlatformWindow for MacWindow {
         }
     }
 
+    fn play_system_bell(&self) {
+        unsafe { NSBeep() }
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     fn render_to_image(&self, scene: &gpui::Scene) -> Result<RgbaImage> {
         let mut this = self.0.lock();

crates/gpui_windows/src/window.rs 🔗

@@ -20,7 +20,9 @@ use windows::{
         Foundation::*,
         Graphics::Dwm::*,
         Graphics::Gdi::*,
-        System::{Com::*, LibraryLoader::*, Ole::*, SystemServices::*},
+        System::{
+            Com::*, Diagnostics::Debug::MessageBeep, LibraryLoader::*, Ole::*, SystemServices::*,
+        },
         UI::{Controls::*, HiDpi::*, Input::KeyboardAndMouse::*, Shell::*, WindowsAndMessaging::*},
     },
     core::*,
@@ -950,6 +952,11 @@ impl PlatformWindow for WindowsWindow {
 
         self.0.update_ime_position(self.0.hwnd, caret_position);
     }
+
+    fn play_system_bell(&self) {
+        // MB_OK: The sound specified as the Windows Default Beep sound.
+        let _ = unsafe { MessageBeep(MB_OK) };
+    }
 }
 
 #[implement(IDropTarget)]

crates/icons/src/icons.rs 🔗

@@ -174,6 +174,7 @@ pub enum IconName {
     LockOutlined,
     MagnifyingGlass,
     Maximize,
+    MaximizeAlt,
     Menu,
     MenuAltTemp,
     Mic,
@@ -240,6 +241,7 @@ pub enum IconName {
     ThinkingModeOff,
     Thread,
     ThreadFromSummary,
+    ThreadImport,
     ThreadsSidebarLeftClosed,
     ThreadsSidebarLeftOpen,
     ThreadsSidebarRightClosed,

crates/keymap_editor/src/keymap_editor.rs 🔗

@@ -31,10 +31,10 @@ use settings::{
     BaseKeymap, KeybindSource, KeymapFile, Settings as _, SettingsAssets, infer_json_indent_size,
 };
 use ui::{
-    ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, IconButtonShape, IconPosition,
-    Indicator, Modal, ModalFooter, ModalHeader, ParentElement as _, PopoverMenu, Render, Section,
-    SharedString, Styled as _, Table, TableColumnWidths, TableInteractionState,
-    TableResizeBehavior, Tooltip, Window, prelude::*,
+    ActiveTheme as _, App, Banner, BorrowAppContext, ColumnWidthConfig, ContextMenu,
+    IconButtonShape, IconPosition, Indicator, Modal, ModalFooter, ModalHeader, ParentElement as _,
+    PopoverMenu, RedistributableColumnsState, Render, Section, SharedString, Styled as _, Table,
+    TableInteractionState, TableResizeBehavior, Tooltip, Window, prelude::*,
 };
 use ui_input::InputField;
 use util::ResultExt;
@@ -450,7 +450,7 @@ struct KeymapEditor {
     context_menu: Option<(Entity<ContextMenu>, Point<Pixels>, Subscription)>,
     previous_edit: Option<PreviousEdit>,
     humanized_action_names: HumanizedActionNameCache,
-    current_widths: Entity<TableColumnWidths>,
+    current_widths: Entity<RedistributableColumnsState>,
     show_hover_menus: bool,
     actions_with_schemas: HashSet<&'static str>,
     /// In order for the JSON LSP to run in the actions arguments editor, we
@@ -623,7 +623,27 @@ impl KeymapEditor {
             actions_with_schemas: HashSet::default(),
             action_args_temp_dir: None,
             action_args_temp_dir_worktree: None,
-            current_widths: cx.new(|cx| TableColumnWidths::new(COLS, cx)),
+            current_widths: cx.new(|_cx| {
+                RedistributableColumnsState::new(
+                    COLS,
+                    vec![
+                        DefiniteLength::Absolute(AbsoluteLength::Pixels(px(36.))),
+                        DefiniteLength::Fraction(0.25),
+                        DefiniteLength::Fraction(0.20),
+                        DefiniteLength::Fraction(0.14),
+                        DefiniteLength::Fraction(0.45),
+                        DefiniteLength::Fraction(0.08),
+                    ],
+                    vec![
+                        TableResizeBehavior::None,
+                        TableResizeBehavior::Resizable,
+                        TableResizeBehavior::Resizable,
+                        TableResizeBehavior::Resizable,
+                        TableResizeBehavior::Resizable,
+                        TableResizeBehavior::Resizable,
+                    ],
+                )
+            }),
         };
 
         this.on_keymap_changed(window, cx);
@@ -2095,26 +2115,9 @@ impl Render for KeymapEditor {
                         let this = cx.entity();
                         move |window, cx| this.read(cx).render_no_matches_hint(window, cx)
                     })
-                    .column_widths(vec![
-                        DefiniteLength::Absolute(AbsoluteLength::Pixels(px(36.))),
-                        DefiniteLength::Fraction(0.25),
-                        DefiniteLength::Fraction(0.20),
-                        DefiniteLength::Fraction(0.14),
-                        DefiniteLength::Fraction(0.45),
-                        DefiniteLength::Fraction(0.08),
-                    ])
-                    .resizable_columns(
-                        vec![
-                            TableResizeBehavior::None,
-                            TableResizeBehavior::Resizable,
-                            TableResizeBehavior::Resizable,
-                            TableResizeBehavior::Resizable,
-                            TableResizeBehavior::Resizable,
-                            TableResizeBehavior::Resizable, // this column doesn't matter
-                        ],
-                        &self.current_widths,
-                        cx,
-                    )
+                    .width_config(ColumnWidthConfig::redistributable(
+                        self.current_widths.clone(),
+                    ))
                     .header(vec!["", "Action", "Arguments", "Keystrokes", "Context", "Source"])
                     .uniform_list(
                         "keymap-editor-table",

crates/languages/src/eslint.rs 🔗

@@ -148,6 +148,7 @@ impl LspInstaller for EsLintLspAdapter {
     ) -> Option<LanguageServerBinary> {
         let server_path =
             Self::build_destination_path(&container_dir).join(EsLintLspAdapter::SERVER_PATH);
+        fs::metadata(&server_path).await.ok()?;
         Some(LanguageServerBinary {
             path: self.node.binary_path().await.ok()?,
             env: None,

crates/markdown/src/html/html_rendering.rs 🔗

@@ -497,7 +497,10 @@ mod tests {
     use gpui::{TestAppContext, size};
     use ui::prelude::*;
 
-    use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle};
+    use crate::{
+        CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions,
+        MarkdownStyle,
+    };
 
     fn ensure_theme_initialized(cx: &mut TestAppContext) {
         cx.update(|cx| {
@@ -530,8 +533,7 @@ mod tests {
             |_window, _cx| {
                 MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
                     CodeBlockRenderer::Default {
-                        copy_button: false,
-                        copy_button_on_hover: false,
+                        copy_button_visibility: CopyButtonVisibility::Hidden,
                         border: false,
                     },
                 )
@@ -591,8 +593,7 @@ mod tests {
             |_window, _cx| {
                 MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
                     CodeBlockRenderer::Default {
-                        copy_button: false,
-                        copy_button_on_hover: false,
+                        copy_button_visibility: CopyButtonVisibility::Hidden,
                         border: false,
                     },
                 )

crates/markdown/src/markdown.rs 🔗

@@ -270,10 +270,16 @@ pub struct MarkdownOptions {
     pub render_mermaid_diagrams: bool,
 }
 
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum CopyButtonVisibility {
+    Hidden,
+    AlwaysVisible,
+    VisibleOnHover,
+}
+
 pub enum CodeBlockRenderer {
     Default {
-        copy_button: bool,
-        copy_button_on_hover: bool,
+        copy_button_visibility: CopyButtonVisibility,
         border: bool,
     },
     Custom {
@@ -826,8 +832,7 @@ impl MarkdownElement {
             markdown,
             style,
             code_block_renderer: CodeBlockRenderer::Default {
-                copy_button: true,
-                copy_button_on_hover: false,
+                copy_button_visibility: CopyButtonVisibility::VisibleOnHover,
                 border: false,
             },
             on_url_click: None,
@@ -1609,23 +1614,18 @@ impl Element for MarkdownElement {
                             builder.table.start(alignments.clone());
 
                             let column_count = alignments.len();
-                            builder.push_div(
-                                div().flex().flex_col().items_start(),
-                                range,
-                                markdown_end,
-                            );
                             builder.push_div(
                                 div()
                                     .id(("table", range.start))
-                                    .min_w_0()
                                     .grid()
                                     .grid_cols(column_count as u16)
                                     .when(self.style.table_columns_min_size, |this| {
                                         this.grid_cols_min_content(column_count as u16)
                                     })
                                     .when(!self.style.table_columns_min_size, |this| {
-                                        this.grid_cols_max_content(column_count as u16)
+                                        this.grid_cols(column_count as u16)
                                     })
+                                    .w_full()
                                     .mb_2()
                                     .border(px(1.5))
                                     .border_color(cx.theme().colors().border)
@@ -1691,38 +1691,10 @@ impl Element for MarkdownElement {
                         builder.pop_text_style();
 
                         if let CodeBlockRenderer::Default {
-                            copy_button: true, ..
-                        } = &self.code_block_renderer
-                        {
-                            builder.modify_current_div(|el| {
-                                let content_range = parser::extract_code_block_content_range(
-                                    &parsed_markdown.source()[range.clone()],
-                                );
-                                let content_range = content_range.start + range.start
-                                    ..content_range.end + range.start;
-
-                                let code = parsed_markdown.source()[content_range].to_string();
-                                let codeblock = render_copy_code_block_button(
-                                    range.end,
-                                    code,
-                                    self.markdown.clone(),
-                                );
-                                el.child(
-                                    h_flex()
-                                        .w_4()
-                                        .absolute()
-                                        .top_1p5()
-                                        .right_1p5()
-                                        .justify_end()
-                                        .child(codeblock),
-                                )
-                            });
-                        }
-
-                        if let CodeBlockRenderer::Default {
-                            copy_button_on_hover: true,
+                            copy_button_visibility,
                             ..
                         } = &self.code_block_renderer
+                            && *copy_button_visibility != CopyButtonVisibility::Hidden
                         {
                             builder.modify_current_div(|el| {
                                 let content_range = parser::extract_code_block_content_range(
@@ -1741,10 +1713,17 @@ impl Element for MarkdownElement {
                                     h_flex()
                                         .w_4()
                                         .absolute()
-                                        .top_0()
-                                        .right_0()
                                         .justify_end()
-                                        .visible_on_hover("code_block")
+                                        .when_else(
+                                            *copy_button_visibility
+                                                == CopyButtonVisibility::VisibleOnHover,
+                                            |this| {
+                                                this.top_0()
+                                                    .right_0()
+                                                    .visible_on_hover("code_block")
+                                            },
+                                            |this| this.top_1p5().right_1p5(),
+                                        )
                                         .child(codeblock),
                                 )
                             });
@@ -1770,7 +1749,6 @@ impl Element for MarkdownElement {
                         }
                     }
                     MarkdownTagEnd::Table => {
-                        builder.pop_div();
                         builder.pop_div();
                         builder.table.end();
                     }
@@ -2778,8 +2756,7 @@ mod tests {
             |_window, _cx| {
                 MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
                     CodeBlockRenderer::Default {
-                        copy_button: false,
-                        copy_button_on_hover: false,
+                        copy_button_visibility: CopyButtonVisibility::Hidden,
                         border: false,
                     },
                 )

crates/markdown/src/mermaid.rs 🔗

@@ -266,7 +266,10 @@ mod tests {
         CachedMermaidDiagram, MermaidDiagramCache, MermaidState,
         ParsedMarkdownMermaidDiagramContents, extract_mermaid_diagrams, parse_mermaid_info,
     };
-    use crate::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownOptions, MarkdownStyle};
+    use crate::{
+        CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownOptions,
+        MarkdownStyle,
+    };
     use collections::HashMap;
     use gpui::{Context, IntoElement, Render, RenderImage, TestAppContext, Window, size};
     use std::sync::Arc;
@@ -309,8 +312,7 @@ mod tests {
             |_window, _cx| {
                 MarkdownElement::new(markdown, MarkdownStyle::default()).code_block_renderer(
                     CodeBlockRenderer::Default {
-                        copy_button: false,
-                        copy_button_on_hover: false,
+                        copy_button_visibility: CopyButtonVisibility::Hidden,
                         border: false,
                     },
                 )
@@ -581,8 +583,7 @@ mod tests {
             |_window, _cx| {
                 MarkdownElement::new(markdown.clone(), MarkdownStyle::default())
                     .code_block_renderer(CodeBlockRenderer::Default {
-                        copy_button: false,
-                        copy_button_on_hover: false,
+                        copy_button_visibility: CopyButtonVisibility::Hidden,
                         border: false,
                     })
             },

crates/markdown_preview/src/markdown_preview_view.rs 🔗

@@ -13,7 +13,8 @@ use gpui::{
 };
 use language::LanguageRegistry;
 use markdown::{
-    CodeBlockRenderer, Markdown, MarkdownElement, MarkdownFont, MarkdownOptions, MarkdownStyle,
+    CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownFont,
+    MarkdownOptions, MarkdownStyle,
 };
 use settings::Settings;
 use theme_settings::ThemeSettings;
@@ -580,20 +581,33 @@ impl MarkdownPreviewView {
             .as_ref()
             .map(|state| state.editor.clone());
 
+        let mut workspace_directory = None;
+        if let Some(workspace_entity) = self.workspace.upgrade() {
+            let project = workspace_entity.read(cx).project();
+            if let Some(tree) = project.read(cx).worktrees(cx).next() {
+                workspace_directory = Some(tree.read(cx).abs_path().to_path_buf());
+            }
+        }
+
         let mut markdown_element = MarkdownElement::new(
             self.markdown.clone(),
             MarkdownStyle::themed(MarkdownFont::Editor, window, cx),
         )
         .code_block_renderer(CodeBlockRenderer::Default {
-            copy_button: false,
-            copy_button_on_hover: true,
+            copy_button_visibility: CopyButtonVisibility::VisibleOnHover,
             border: false,
         })
         .scroll_handle(self.scroll_handle.clone())
         .show_root_block_markers()
         .image_resolver({
             let base_directory = self.base_directory.clone();
-            move |dest_url| resolve_preview_image(dest_url, base_directory.as_deref())
+            move |dest_url| {
+                resolve_preview_image(
+                    dest_url,
+                    base_directory.as_deref(),
+                    workspace_directory.as_deref(),
+                )
+            }
         })
         .on_url_click(move |url, window, cx| {
             open_preview_url(url, base_directory.clone(), &workspace, window, cx);
@@ -687,7 +701,11 @@ fn resolve_preview_path(url: &str, base_directory: Option<&Path>) -> Option<Path
     }
 }
 
-fn resolve_preview_image(dest_url: &str, base_directory: Option<&Path>) -> Option<ImageSource> {
+fn resolve_preview_image(
+    dest_url: &str,
+    base_directory: Option<&Path>,
+    workspace_directory: Option<&Path>,
+) -> Option<ImageSource> {
     if dest_url.starts_with("data:") {
         return None;
     }
@@ -702,6 +720,19 @@ fn resolve_preview_image(dest_url: &str, base_directory: Option<&Path>) -> Optio
         .map(|decoded| decoded.into_owned())
         .unwrap_or_else(|_| dest_url.to_string());
 
+    let decoded_path = Path::new(&decoded);
+
+    if let Ok(relative_path) = decoded_path.strip_prefix("/") {
+        if let Some(root) = workspace_directory {
+            let absolute_path = root.join(relative_path);
+            if absolute_path.exists() {
+                return Some(ImageSource::Resource(Resource::Path(Arc::from(
+                    absolute_path.as_path(),
+                ))));
+            }
+        }
+    }
+
     let path = if Path::new(&decoded).is_absolute() {
         PathBuf::from(decoded)
     } else {
@@ -778,6 +809,9 @@ impl Render for MarkdownPreviewView {
 
 #[cfg(test)]
 mod tests {
+    use crate::markdown_preview_view::ImageSource;
+    use crate::markdown_preview_view::Resource;
+    use crate::markdown_preview_view::resolve_preview_image;
     use anyhow::Result;
     use std::fs;
     use tempfile::TempDir;
@@ -819,6 +853,54 @@ mod tests {
         Ok(())
     }
 
+    #[test]
+    fn resolves_workspace_absolute_preview_images() -> Result<()> {
+        let temp_dir = TempDir::new()?;
+        let workspace_directory = temp_dir.path();
+
+        let base_directory = workspace_directory.join("docs");
+        fs::create_dir_all(&base_directory)?;
+
+        let image_file = workspace_directory.join("test_image.png");
+        fs::write(&image_file, "mock data")?;
+
+        let resolved_success = resolve_preview_image(
+            "/test_image.png",
+            Some(&base_directory),
+            Some(workspace_directory),
+        );
+
+        match resolved_success {
+            Some(ImageSource::Resource(Resource::Path(p))) => {
+                assert_eq!(p.as_ref(), image_file.as_path());
+            }
+            _ => panic!("Expected successful resolution to be a Resource::Path"),
+        }
+
+        let resolved_missing = resolve_preview_image(
+            "/missing_image.png",
+            Some(&base_directory),
+            Some(workspace_directory),
+        );
+
+        let expected_missing_path = if std::path::Path::new("/missing_image.png").is_absolute() {
+            std::path::PathBuf::from("/missing_image.png")
+        } else {
+            // join is to retain windows path prefix C:/
+            #[expect(clippy::join_absolute_paths)]
+            base_directory.join("/missing_image.png")
+        };
+
+        match resolved_missing {
+            Some(ImageSource::Resource(Resource::Path(p))) => {
+                assert_eq!(p.as_ref(), expected_missing_path.as_path());
+            }
+            _ => panic!("Expected missing file to fallback to a Resource::Path"),
+        }
+
+        Ok(())
+    }
+
     #[test]
     fn does_not_treat_web_links_as_preview_paths() {
         assert_eq!(resolve_preview_path("https://zed.dev", None), None);

crates/migrator/src/migrations.rs 🔗

@@ -316,9 +316,3 @@ pub(crate) mod m_2026_03_23 {
 
     pub(crate) use keymap::KEYMAP_PATTERNS;
 }
-
-pub(crate) mod m_2026_03_31 {
-    mod settings;
-
-    pub(crate) use settings::remove_text_thread_settings;
-}

crates/migrator/src/migrations/m_2026_03_31/settings.rs 🔗

@@ -1,29 +0,0 @@
-use anyhow::Result;
-use serde_json::Value;
-
-use crate::migrations::migrate_settings;
-
-pub fn remove_text_thread_settings(value: &mut Value) -> Result<()> {
-    migrate_settings(value, &mut migrate_one)
-}
-
-fn migrate_one(obj: &mut serde_json::Map<String, Value>) -> Result<()> {
-    // Remove `agent.default_view`
-    if let Some(agent) = obj.get_mut("agent") {
-        if let Some(agent_obj) = agent.as_object_mut() {
-            agent_obj.remove("default_view");
-        }
-    }
-
-    // Remove `edit_predictions.enabled_in_text_threads`
-    if let Some(edit_predictions) = obj.get_mut("edit_predictions") {
-        if let Some(edit_predictions_obj) = edit_predictions.as_object_mut() {
-            edit_predictions_obj.remove("enabled_in_text_threads");
-        }
-    }
-
-    // Remove top-level `slash_commands`
-    obj.remove("slash_commands");
-
-    Ok(())
-}

crates/migrator/src/migrator.rs 🔗

@@ -247,7 +247,6 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
             migrations::m_2026_03_16::SETTINGS_PATTERNS,
             &SETTINGS_QUERY_2026_03_16,
         ),
-        MigrationType::Json(migrations::m_2026_03_31::remove_text_thread_settings),
     ];
     run_migrations(text, migrations)
 }
@@ -941,7 +940,8 @@ mod tests {
                     "foo": "bar"
                 },
                 "edit_predictions": {
-                    }
+                    "enabled_in_text_threads": false,
+                }
             }"#,
             ),
         );
@@ -4480,109 +4480,4 @@ mod tests {
             ),
         );
     }
-
-    #[test]
-    fn test_remove_text_thread_settings() {
-        assert_migrate_with_migrations(
-            &[MigrationType::Json(
-                migrations::m_2026_03_31::remove_text_thread_settings,
-            )],
-            r#"{
-    "agent": {
-        "default_model": {
-            "provider": "anthropic",
-            "model": "claude-sonnet"
-        },
-        "default_view": "text_thread"
-    },
-    "edit_predictions": {
-        "mode": "eager",
-        "enabled_in_text_threads": true
-    },
-    "slash_commands": {
-        "cargo_workspace": {
-            "enabled": true
-        }
-    }
-}"#,
-            Some(
-                r#"{
-    "agent": {
-        "default_model": {
-            "provider": "anthropic",
-            "model": "claude-sonnet"
-        }
-    },
-    "edit_predictions": {
-        "mode": "eager"
-    }
-}"#,
-            ),
-        );
-    }
-
-    #[test]
-    fn test_remove_text_thread_settings_only_default_view() {
-        assert_migrate_with_migrations(
-            &[MigrationType::Json(
-                migrations::m_2026_03_31::remove_text_thread_settings,
-            )],
-            r#"{
-    "agent": {
-        "default_model": "claude-sonnet",
-        "default_view": "thread"
-    }
-}"#,
-            Some(
-                r#"{
-    "agent": {
-        "default_model": "claude-sonnet"
-    }
-}"#,
-            ),
-        );
-    }
-
-    #[test]
-    fn test_remove_text_thread_settings_only_slash_commands() {
-        assert_migrate_with_migrations(
-            &[MigrationType::Json(
-                migrations::m_2026_03_31::remove_text_thread_settings,
-            )],
-            r#"{
-    "slash_commands": {
-        "cargo_workspace": {
-            "enabled": true
-        }
-    },
-    "vim_mode": true
-}"#,
-            Some(
-                r#"{
-    "vim_mode": true
-}"#,
-            ),
-        );
-    }
-
-    #[test]
-    fn test_remove_text_thread_settings_none_present() {
-        assert_migrate_with_migrations(
-            &[MigrationType::Json(
-                migrations::m_2026_03_31::remove_text_thread_settings,
-            )],
-            r#"{
-    "agent": {
-        "default_model": {
-            "provider": "anthropic",
-            "model": "claude-sonnet"
-        }
-    },
-    "edit_predictions": {
-        "mode": "eager"
-    }
-}"#,
-            None,
-        );
-    }
 }

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1264,13 +1264,8 @@ impl PickerDelegate for RecentProjectsDelegate {
                                     this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
                                 }),
                         )
-                        .map(|el| {
-                            if self.selected_index == ix {
-                                el.end_slot(secondary_actions)
-                            } else {
-                                el.end_hover_slot(secondary_actions)
-                            }
-                        })
+                        .end_slot(secondary_actions)
+                        .show_end_slot_on_hover()
                         .into_any_element(),
                 )
             }
@@ -1363,13 +1358,8 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 })
                                 .tooltip(Tooltip::text(tooltip_path)),
                         )
-                        .map(|el| {
-                            if self.selected_index == ix {
-                                el.end_slot(secondary_actions)
-                            } else {
-                                el.end_hover_slot(secondary_actions)
-                            }
-                        })
+                        .end_slot(secondary_actions)
+                        .show_end_slot_on_hover()
                         .into_any_element(),
                 )
             }
@@ -1503,13 +1493,8 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 })
                                 .tooltip(Tooltip::text(tooltip_path)),
                         )
-                        .map(|el| {
-                            if self.selected_index == ix {
-                                el.end_slot(secondary_actions)
-                            } else {
-                                el.end_hover_slot(secondary_actions)
-                            }
-                        })
+                        .end_slot(secondary_actions)
+                        .show_end_slot_on_hover()
                         .into_any_element(),
                 )
             }

crates/recent_projects/src/remote_servers.rs 🔗

@@ -1621,23 +1621,24 @@ impl RemoteServerProjects {
                     }))
                     .tooltip(Tooltip::text(project.paths.join("\n")))
                     .when(is_from_zed, |server_list_item| {
-                        server_list_item.end_hover_slot::<AnyElement>(Some(
-                            div()
-                                .mr_2()
-                                .child({
-                                    let project = project.clone();
-                                    // Right-margin to offset it from the Scrollbar
-                                    IconButton::new("remove-remote-project", IconName::Trash)
-                                        .icon_size(IconSize::Small)
-                                        .shape(IconButtonShape::Square)
-                                        .size(ButtonSize::Large)
-                                        .tooltip(Tooltip::text("Delete Remote Project"))
-                                        .on_click(cx.listener(move |this, _, _, cx| {
-                                            this.delete_remote_project(server_ix, &project, cx)
-                                        }))
-                                })
-                                .into_any_element(),
-                        ))
+                        server_list_item
+                            .end_slot(
+                                div()
+                                    .mr_2()
+                                    .child({
+                                        let project = project.clone();
+                                        IconButton::new("remove-remote-project", IconName::Trash)
+                                            .icon_size(IconSize::Small)
+                                            .shape(IconButtonShape::Square)
+                                            .size(ButtonSize::Large)
+                                            .tooltip(Tooltip::text("Delete Remote Project"))
+                                            .on_click(cx.listener(move |this, _, _, cx| {
+                                                this.delete_remote_project(server_ix, &project, cx)
+                                            }))
+                                    })
+                                    .into_any_element(),
+                            )
+                            .show_end_slot_on_hover()
                     }),
             )
     }
@@ -2413,9 +2414,8 @@ impl RemoteServerProjects {
                             .spacing(ui::ListItemSpacing::Sparse)
                             .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
                             .child(Label::new("Copy Server Address"))
-                            .end_hover_slot(
-                                Label::new(connection_string.clone()).color(Color::Muted),
-                            )
+                            .end_slot(Label::new(connection_string.clone()).color(Color::Muted))
+                            .show_end_slot_on_hover()
                             .on_click({
                                 let connection_string = connection_string.clone();
                                 move |_, _, cx| {

crates/rules_library/src/rules_library.rs 🔗

@@ -389,7 +389,7 @@ impl PickerDelegate for RulePickerDelegate {
                                 }))
                         }))
                         .when(!prompt_id.is_built_in(), |this| {
-                            this.end_hover_slot(
+                            this.end_slot_on_hover(
                                 h_flex()
                                     .child(
                                         IconButton::new("delete-rule", IconName::Trash)

crates/settings_content/src/agent.rs 🔗

@@ -81,11 +81,14 @@ pub enum SidebarSide {
 )]
 #[serde(rename_all = "snake_case")]
 pub enum ThinkingBlockDisplay {
+    /// Thinking blocks fully expand during streaming, then auto-collapse
+    /// when the model finishes thinking. Users can re-expand after collapse.
+    #[default]
+    Auto,
     /// Thinking blocks auto-expand with a height constraint during streaming,
     /// then remain in their constrained state when complete. Users can click
     /// to fully expand or collapse.
-    #[default]
-    Automatic,
+    Preview,
     /// Thinking blocks are always fully expanded by default (no height constraint).
     AlwaysExpanded,
     /// Thinking blocks are always collapsed by default.

crates/settings_ui/src/page_data.rs 🔗

@@ -7340,7 +7340,7 @@ fn ai_page(cx: &App) -> SettingsPage {
             }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Thinking Display",
-                description: "How thinking blocks should be displayed by default. 'Automatic' auto-expands with a height constraint during streaming. 'Always Expanded' shows full content. 'Always Collapsed' keeps them collapsed.",
+                description: "How thinking blocks should be displayed by default. 'Auto' fully expands during streaming, then auto-collapses when done. 'Preview' auto-expands with a height constraint during streaming. 'Always Expanded' shows full content. 'Always Collapsed' keeps them collapsed.",
                 field: Box::new(SettingField {
                     json_path: Some("agent.thinking_display"),
                     pick: |settings_content| {

crates/sidebar/src/sidebar.rs 🔗

@@ -3309,10 +3309,24 @@ impl Sidebar {
     }
 
     fn render_sidebar_bottom_bar(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
-        let on_right = self.side(cx) == SidebarSide::Right;
         let is_archive = matches!(self.view, SidebarView::Archive(..));
+        let show_import_button = is_archive && !self.should_render_acp_import_onboarding(cx);
+        let on_right = self.side(cx) == SidebarSide::Right;
+
         let action_buttons = h_flex()
             .gap_1()
+            .when(on_right, |this| this.flex_row_reverse())
+            .when(show_import_button, |this| {
+                this.child(
+                    IconButton::new("thread-import", IconName::ThreadImport)
+                        .icon_size(IconSize::Small)
+                        .tooltip(Tooltip::text("Import ACP Threads"))
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.show_archive(window, cx);
+                            this.show_thread_import_modal(window, cx);
+                        })),
+                )
+            })
             .child(
                 IconButton::new("archive", IconName::Archive)
                     .icon_size(IconSize::Small)
@@ -3325,21 +3339,16 @@ impl Sidebar {
                     })),
             )
             .child(self.render_recent_projects_button(cx));
-        let border_color = cx.theme().colors().border;
-        let toggle_button = self.render_sidebar_toggle_button(cx);
 
-        let bar = h_flex()
+        h_flex()
             .p_1()
             .gap_1()
+            .when(on_right, |this| this.flex_row_reverse())
             .justify_between()
             .border_t_1()
-            .border_color(border_color);
-
-        if on_right {
-            bar.child(action_buttons).child(toggle_button)
-        } else {
-            bar.child(toggle_button).child(action_buttons)
-        }
+            .border_color(cx.theme().colors().border)
+            .child(self.render_sidebar_toggle_button(cx))
+            .child(action_buttons)
     }
 
     fn active_workspace(&self, cx: &App) -> Option<Entity<Workspace>> {
@@ -3409,7 +3418,7 @@ impl Sidebar {
         v_flex()
             .min_w_0()
             .w_full()
-            .p_1p5()
+            .p_2()
             .border_t_1()
             .border_color(cx.theme().colors().border)
             .bg(linear_gradient(
@@ -3437,8 +3446,8 @@ impl Sidebar {
                     .style(ButtonStyle::OutlinedCustom(cx.theme().colors().border))
                     .label_size(LabelSize::Small)
                     .start_icon(
-                        Icon::new(IconName::ArrowDown)
-                            .size(IconSize::XSmall)
+                        Icon::new(IconName::ThreadImport)
+                            .size(IconSize::Small)
                             .color(Color::Muted),
                     )
                     .on_click(cx.listener(|this, _, window, cx| {
@@ -3467,9 +3476,6 @@ impl Sidebar {
         let Some(agent_panel) = active_workspace.read(cx).panel::<AgentPanel>(cx) else {
             return;
         };
-        let Some(agent_registry_store) = AgentRegistryStore::try_global(cx) else {
-            return;
-        };
 
         let agent_server_store = active_workspace
             .read(cx)
@@ -3482,11 +3488,9 @@ impl Sidebar {
 
         let archive_view = cx.new(|cx| {
             ThreadsArchiveView::new(
+                active_workspace.downgrade(),
                 agent_connection_store.clone(),
                 agent_server_store.clone(),
-                agent_registry_store.downgrade(),
-                active_workspace.downgrade(),
-                self.multi_workspace.clone(),
                 window,
                 cx,
             )

crates/tab_switcher/src/tab_switcher.rs 🔗

@@ -875,7 +875,7 @@ impl PickerDelegate for TabSwitcherDelegate {
                         el.end_slot::<AnyElement>(close_button)
                     } else {
                         el.end_slot::<AnyElement>(indicator)
-                            .end_hover_slot::<AnyElement>(close_button)
+                            .end_slot_on_hover::<AnyElement>(close_button)
                     }
                 }),
         )

crates/tasks_ui/src/modal.rs 🔗

@@ -570,7 +570,7 @@ impl PickerDelegate for TasksModalDelegate {
                                     Tooltip::simple("Delete Previously Scheduled Task", cx)
                                 }),
                         );
-                        item.end_hover_slot(delete_button)
+                        item.end_slot_on_hover(delete_button)
                     } else {
                         item
                     }

crates/title_bar/src/onboarding_banner.rs 🔗

@@ -1,3 +1,7 @@
+// This module provides infrastructure for showing onboarding banners in the title bar.
+// It's currently not in use but is kept for future feature announcements.
+#![allow(dead_code)]
+
 use gpui::{Action, Entity, Global, Render, SharedString};
 use ui::{ButtonLike, Tooltip, prelude::*};
 use util::ResultExt;
@@ -94,21 +98,21 @@ fn persist_dismissed(source: &str, cx: &mut App) {
 }
 
 pub fn restore_banner(cx: &mut App) {
-    cx.defer(|cx| {
-        cx.global::<BannerGlobal>()
-            .entity
-            .clone()
-            .update(cx, |this, cx| {
+    if let Some(banner_global) = cx.try_global::<BannerGlobal>() {
+        let entity = banner_global.entity.clone();
+        cx.defer(move |cx| {
+            entity.update(cx, |this, cx| {
                 this.dismissed = false;
                 cx.notify();
             });
-    });
+        });
 
-    let source = &cx.global::<BannerGlobal>().entity.read(cx).source;
-    let dismissed_at = dismissed_at_key(source);
-    let kvp = db::kvp::KeyValueStore::global(cx);
-    cx.spawn(async move |_| kvp.delete_kvp(dismissed_at).await)
-        .detach_and_log_err(cx);
+        let source = &cx.global::<BannerGlobal>().entity.read(cx).source;
+        let dismissed_at = dismissed_at_key(source);
+        let kvp = db::kvp::KeyValueStore::global(cx);
+        cx.spawn(async move |_| kvp.delete_kvp(dismissed_at).await)
+            .detach_and_log_err(cx);
+    }
 }
 
 impl Render for OnboardingBanner {

crates/title_bar/src/title_bar.rs 🔗

@@ -155,7 +155,7 @@ pub struct TitleBar {
     multi_workspace: Option<WeakEntity<MultiWorkspace>>,
     application_menu: Option<Entity<ApplicationMenu>>,
     _subscriptions: Vec<Subscription>,
-    banner: Entity<OnboardingBanner>,
+    banner: Option<Entity<OnboardingBanner>>,
     update_version: Entity<UpdateVersion>,
     screen_share_popover_handle: PopoverMenuHandle<ContextMenu>,
     _diagnostics_subscription: Option<gpui::Subscription>,
@@ -246,7 +246,9 @@ impl Render for TitleBar {
         children.push(self.render_collaborator_list(window, cx).into_any_element());
 
         if title_bar_settings.show_onboarding_banner {
-            children.push(self.banner.clone().into_any_element())
+            if let Some(banner) = &self.banner {
+                children.push(banner.clone().into_any_element())
+            }
         }
 
         let status = self.client.status();
@@ -385,19 +387,6 @@ impl TitleBar {
             }));
         }
 
-        let banner = cx.new(|cx| {
-            OnboardingBanner::new(
-                "ACP Claude Code Onboarding",
-                IconName::AiClaude,
-                "Claude Agent",
-                Some("Introducing:".into()),
-                zed_actions::agent::OpenClaudeAgentOnboardingModal.boxed_clone(),
-                cx,
-            )
-            // When updating this to a non-AI feature release, remove this line.
-            .visible_when(|cx| !project::DisableAiSettings::get_global(cx).disable_ai)
-        });
-
         let update_version = cx.new(|cx| UpdateVersion::new(cx));
         let platform_titlebar = cx.new(|cx| {
             let mut titlebar = PlatformTitleBar::new(id, cx);
@@ -416,7 +405,7 @@ impl TitleBar {
             user_store,
             client,
             _subscriptions: subscriptions,
-            banner,
+            banner: None,
             update_version,
             screen_share_popover_handle: PopoverMenuHandle::default(),
             _diagnostics_subscription: None,

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

@@ -1,14 +1,15 @@
 use std::{ops::Range, rc::Rc};
 
 use gpui::{
-    AbsoluteLength, AppContext, Context, DefiniteLength, DragMoveEvent, Entity, EntityId,
-    FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point,
-    Stateful, UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list,
+    AbsoluteLength, AppContext as _, DefiniteLength, DragMoveEvent, Entity, EntityId, FocusHandle,
+    Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point, Stateful,
+    UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list,
 };
+use itertools::intersperse_with;
 
 use crate::{
     ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
-    ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
+    ComponentScope, Context, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
     InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
     ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled,
     StyledExt as _, StyledTypography, Window, WithScrollbar, div, example_group_with_title, h_flex,
@@ -16,20 +17,20 @@ use crate::{
     table_row::{IntoTableRow as _, TableRow},
     v_flex,
 };
-use itertools::intersperse_with;
 
 pub mod table_row;
 #[cfg(test)]
 mod tests;
 
 const RESIZE_COLUMN_WIDTH: f32 = 8.0;
+const RESIZE_DIVIDER_WIDTH: f32 = 1.0;
 
 /// Represents an unchecked table row, which is a vector of elements.
 /// Will be converted into `TableRow<T>` internally
 pub type UncheckedTableRow<T> = Vec<T>;
 
 #[derive(Debug)]
-struct DraggedColumn(usize);
+pub(crate) struct DraggedColumn(pub(crate) usize);
 
 struct UniformListData {
     render_list_of_rows_fn:
@@ -110,106 +111,103 @@ impl TableInteractionState {
             view.update(cx, |view, cx| f(view, e, window, cx)).ok();
         }
     }
+}
 
-    /// Renders invisible resize handles overlaid on top of table content.
-    ///
-    /// - Spacer: invisible element that matches the width of table column content
-    /// - Divider: contains the actual resize handle that users can drag to resize columns
-    ///
-    /// Structure: [spacer] [divider] [spacer] [divider] [spacer]
-    ///
-    /// Business logic:
-    /// 1. Creates spacers matching each column width
-    /// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns)
-    /// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize
-    /// 4. Returns an absolute-positioned overlay that sits on top of table content
-    fn render_resize_handles(
-        &self,
-        column_widths: &TableRow<Length>,
-        resizable_columns: &TableRow<TableResizeBehavior>,
-        initial_sizes: &TableRow<DefiniteLength>,
-        columns: Option<Entity<TableColumnWidths>>,
-        window: &mut Window,
-        cx: &mut App,
-    ) -> AnyElement {
-        let spacers = column_widths
-            .as_slice()
-            .iter()
-            .map(|width| base_cell_style(Some(*width)).into_any_element());
-
-        let mut column_ix = 0;
-        let resizable_columns_shared = Rc::new(resizable_columns.clone());
-        let initial_sizes_shared = Rc::new(initial_sizes.clone());
-        let mut resizable_columns_iter = resizable_columns.as_slice().iter();
-
-        // Insert dividers between spacers (column content)
-        let dividers = intersperse_with(spacers, || {
-            let resizable_columns = Rc::clone(&resizable_columns_shared);
-            let initial_sizes = Rc::clone(&initial_sizes_shared);
-            window.with_id(column_ix, |window| {
-                let mut resize_divider = div()
-                    // This is required because this is evaluated at a different time than the use_state call above
-                    .id(column_ix)
-                    .relative()
-                    .top_0()
-                    .w_px()
-                    .h_full()
-                    .bg(cx.theme().colors().border.opacity(0.8));
-
-                let mut resize_handle = div()
-                    .id("column-resize-handle")
-                    .absolute()
-                    .left_neg_0p5()
-                    .w(px(RESIZE_COLUMN_WIDTH))
-                    .h_full();
-
-                if resizable_columns_iter
-                    .next()
-                    .is_some_and(TableResizeBehavior::is_resizable)
-                {
-                    let hovered = window.use_state(cx, |_window, _cx| false);
-
-                    resize_divider = resize_divider.when(*hovered.read(cx), |div| {
-                        div.bg(cx.theme().colors().border_focused)
-                    });
-
-                    resize_handle = resize_handle
-                        .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered))
-                        .cursor_col_resize()
-                        .when_some(columns.clone(), |this, columns| {
-                            this.on_click(move |event, window, cx| {
-                                if event.click_count() >= 2 {
-                                    columns.update(cx, |columns, _| {
-                                        columns.on_double_click(
-                                            column_ix,
-                                            &initial_sizes,
-                                            &resizable_columns,
-                                            window,
-                                        );
-                                    })
-                                }
+/// Renders invisible resize handles overlaid on top of table content.
+///
+/// - Spacer: invisible element that matches the width of table column content
+/// - Divider: contains the actual resize handle that users can drag to resize columns
+///
+/// Structure: [spacer] [divider] [spacer] [divider] [spacer]
+///
+/// Business logic:
+/// 1. Creates spacers matching each column width
+/// 2. Intersperses (inserts) resize handles between spacers (interactive only for resizable columns)
+/// 3. Each handle supports hover highlighting, double-click to reset, and drag to resize
+/// 4. Returns an absolute-positioned overlay that sits on top of table content
+fn render_resize_handles(
+    column_widths: &TableRow<Length>,
+    resizable_columns: &TableRow<TableResizeBehavior>,
+    initial_sizes: &TableRow<DefiniteLength>,
+    columns: Option<Entity<RedistributableColumnsState>>,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let spacers = column_widths
+        .as_slice()
+        .iter()
+        .map(|width| base_cell_style(Some(*width)).into_any_element());
+
+    let mut column_ix = 0;
+    let resizable_columns_shared = Rc::new(resizable_columns.clone());
+    let initial_sizes_shared = Rc::new(initial_sizes.clone());
+    let mut resizable_columns_iter = resizable_columns.as_slice().iter();
+
+    let dividers = intersperse_with(spacers, || {
+        let resizable_columns = Rc::clone(&resizable_columns_shared);
+        let initial_sizes = Rc::clone(&initial_sizes_shared);
+        window.with_id(column_ix, |window| {
+            let mut resize_divider = div()
+                .id(column_ix)
+                .relative()
+                .top_0()
+                .w(px(RESIZE_DIVIDER_WIDTH))
+                .h_full()
+                .bg(cx.theme().colors().border.opacity(0.8));
+
+            let mut resize_handle = div()
+                .id("column-resize-handle")
+                .absolute()
+                .left_neg_0p5()
+                .w(px(RESIZE_COLUMN_WIDTH))
+                .h_full();
+
+            if resizable_columns_iter
+                .next()
+                .is_some_and(TableResizeBehavior::is_resizable)
+            {
+                let hovered = window.use_state(cx, |_window, _cx| false);
+
+                resize_divider = resize_divider.when(*hovered.read(cx), |div| {
+                    div.bg(cx.theme().colors().border_focused)
+                });
+
+                resize_handle = resize_handle
+                    .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered))
+                    .cursor_col_resize()
+                    .when_some(columns.clone(), |this, columns| {
+                        this.on_click(move |event, window, cx| {
+                            if event.click_count() >= 2 {
+                                columns.update(cx, |columns, _| {
+                                    columns.on_double_click(
+                                        column_ix,
+                                        &initial_sizes,
+                                        &resizable_columns,
+                                        window,
+                                    );
+                                })
+                            }
 
-                                cx.stop_propagation();
-                            })
+                            cx.stop_propagation();
                         })
-                        .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| {
-                            cx.new(|_cx| gpui::Empty)
-                        })
-                }
+                    })
+                    .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| {
+                        cx.new(|_cx| gpui::Empty)
+                    })
+            }
 
-                column_ix += 1;
-                resize_divider.child(resize_handle).into_any_element()
-            })
-        });
+            column_ix += 1;
+            resize_divider.child(resize_handle).into_any_element()
+        })
+    });
 
-        h_flex()
-            .id("resize-handles")
-            .absolute()
-            .inset_0()
-            .w_full()
-            .children(dividers)
-            .into_any_element()
-    }
+    h_flex()
+        .id("resize-handles")
+        .absolute()
+        .inset_0()
+        .w_full()
+        .children(dividers)
+        .into_any_element()
 }
 
 #[derive(Debug, Copy, Clone, PartialEq)]
@@ -233,25 +231,181 @@ impl TableResizeBehavior {
     }
 }
 
-pub struct TableColumnWidths {
-    widths: TableRow<DefiniteLength>,
-    visible_widths: TableRow<DefiniteLength>,
-    cached_bounds_width: Pixels,
-    initialized: bool,
+pub enum ColumnWidthConfig {
+    /// Static column widths (no resize handles).
+    Static {
+        widths: StaticColumnWidths,
+        /// Controls widths of the whole table.
+        table_width: Option<DefiniteLength>,
+    },
+    /// Redistributable columns — dragging redistributes the fixed available space
+    /// among columns without changing the overall table width.
+    Redistributable {
+        columns_state: Entity<RedistributableColumnsState>,
+        table_width: Option<DefiniteLength>,
+    },
+}
+
+pub enum StaticColumnWidths {
+    /// All columns share space equally (flex-1 / Length::Auto).
+    Auto,
+    /// Each column has a specific width.
+    Explicit(TableRow<DefiniteLength>),
 }
 
-impl TableColumnWidths {
-    pub fn new(cols: usize, _: &mut App) -> Self {
+impl ColumnWidthConfig {
+    /// Auto-width columns, auto-size table.
+    pub fn auto() -> Self {
+        ColumnWidthConfig::Static {
+            widths: StaticColumnWidths::Auto,
+            table_width: None,
+        }
+    }
+
+    /// Redistributable columns with no fixed table width.
+    pub fn redistributable(columns_state: Entity<RedistributableColumnsState>) -> Self {
+        ColumnWidthConfig::Redistributable {
+            columns_state,
+            table_width: None,
+        }
+    }
+
+    /// Auto-width columns, fixed table width.
+    pub fn auto_with_table_width(width: impl Into<DefiniteLength>) -> Self {
+        ColumnWidthConfig::Static {
+            widths: StaticColumnWidths::Auto,
+            table_width: Some(width.into()),
+        }
+    }
+
+    /// Column widths for rendering.
+    pub fn widths_to_render(&self, cx: &App) -> Option<TableRow<Length>> {
+        match self {
+            ColumnWidthConfig::Static {
+                widths: StaticColumnWidths::Auto,
+                ..
+            } => None,
+            ColumnWidthConfig::Static {
+                widths: StaticColumnWidths::Explicit(widths),
+                ..
+            } => Some(widths.map_cloned(Length::Definite)),
+            ColumnWidthConfig::Redistributable {
+                columns_state: entity,
+                ..
+            } => {
+                let state = entity.read(cx);
+                Some(state.preview_widths.map_cloned(Length::Definite))
+            }
+        }
+    }
+
+    /// Table-level width.
+    pub fn table_width(&self) -> Option<Length> {
+        match self {
+            ColumnWidthConfig::Static { table_width, .. }
+            | ColumnWidthConfig::Redistributable { table_width, .. } => {
+                table_width.map(Length::Definite)
+            }
+        }
+    }
+
+    /// ListHorizontalSizingBehavior for uniform_list.
+    pub fn list_horizontal_sizing(&self) -> ListHorizontalSizingBehavior {
+        match self.table_width() {
+            Some(_) => ListHorizontalSizingBehavior::Unconstrained,
+            None => ListHorizontalSizingBehavior::FitList,
+        }
+    }
+
+    /// Render resize handles overlay if applicable.
+    pub fn render_resize_handles(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        match self {
+            ColumnWidthConfig::Redistributable {
+                columns_state: entity,
+                ..
+            } => {
+                let (column_widths, resize_behavior, initial_widths) = {
+                    let state = entity.read(cx);
+                    (
+                        state.preview_widths.map_cloned(Length::Definite),
+                        state.resize_behavior.clone(),
+                        state.initial_widths.clone(),
+                    )
+                };
+                Some(render_resize_handles(
+                    &column_widths,
+                    &resize_behavior,
+                    &initial_widths,
+                    Some(entity.clone()),
+                    window,
+                    cx,
+                ))
+            }
+            _ => None,
+        }
+    }
+
+    /// Returns info needed for header double-click-to-reset, if applicable.
+    pub fn header_resize_info(&self, cx: &App) -> Option<HeaderResizeInfo> {
+        match self {
+            ColumnWidthConfig::Redistributable { columns_state, .. } => {
+                let state = columns_state.read(cx);
+                Some(HeaderResizeInfo {
+                    columns_state: columns_state.downgrade(),
+                    resize_behavior: state.resize_behavior.clone(),
+                    initial_widths: state.initial_widths.clone(),
+                })
+            }
+            _ => None,
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct HeaderResizeInfo {
+    pub columns_state: WeakEntity<RedistributableColumnsState>,
+    pub resize_behavior: TableRow<TableResizeBehavior>,
+    pub initial_widths: TableRow<DefiniteLength>,
+}
+
+pub struct RedistributableColumnsState {
+    pub(crate) initial_widths: TableRow<DefiniteLength>,
+    pub(crate) committed_widths: TableRow<DefiniteLength>,
+    pub(crate) preview_widths: TableRow<DefiniteLength>,
+    pub(crate) resize_behavior: TableRow<TableResizeBehavior>,
+    pub(crate) cached_table_width: Pixels,
+}
+
+impl RedistributableColumnsState {
+    pub fn new(
+        cols: usize,
+        initial_widths: UncheckedTableRow<impl Into<DefiniteLength>>,
+        resize_behavior: UncheckedTableRow<TableResizeBehavior>,
+    ) -> Self {
+        let widths: TableRow<DefiniteLength> = initial_widths
+            .into_iter()
+            .map(Into::into)
+            .collect::<Vec<_>>()
+            .into_table_row(cols);
         Self {
-            widths: vec![DefiniteLength::default(); cols].into_table_row(cols),
-            visible_widths: vec![DefiniteLength::default(); cols].into_table_row(cols),
-            cached_bounds_width: Default::default(),
-            initialized: false,
+            initial_widths: widths.clone(),
+            committed_widths: widths.clone(),
+            preview_widths: widths,
+            resize_behavior: resize_behavior.into_table_row(cols),
+            cached_table_width: Default::default(),
         }
     }
 
     pub fn cols(&self) -> usize {
-        self.widths.cols()
+        self.committed_widths.cols()
+    }
+
+    pub fn initial_widths(&self) -> &TableRow<DefiniteLength> {
+        &self.initial_widths
+    }
+
+    pub fn resize_behavior(&self) -> &TableRow<TableResizeBehavior> {
+        &self.resize_behavior
     }
 
     fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 {
@@ -264,19 +418,19 @@ impl TableColumnWidths {
         }
     }
 
-    fn on_double_click(
+    pub(crate) fn on_double_click(
         &mut self,
         double_click_position: usize,
         initial_sizes: &TableRow<DefiniteLength>,
         resize_behavior: &TableRow<TableResizeBehavior>,
         window: &mut Window,
     ) {
-        let bounds_width = self.cached_bounds_width;
+        let bounds_width = self.cached_table_width;
         let rem_size = window.rem_size();
         let initial_sizes =
             initial_sizes.map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
         let widths = self
-            .widths
+            .committed_widths
             .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
 
         let updated_widths = Self::reset_to_initial_size(
@@ -285,53 +439,16 @@ impl TableColumnWidths {
             initial_sizes,
             resize_behavior,
         );
-        self.widths = updated_widths.map(DefiniteLength::Fraction);
-        self.visible_widths = self.widths.clone(); // previously was copy
+        self.committed_widths = updated_widths.map(DefiniteLength::Fraction);
+        self.preview_widths = self.committed_widths.clone();
     }
 
-    fn reset_to_initial_size(
+    pub(crate) fn reset_to_initial_size(
         col_idx: usize,
         mut widths: TableRow<f32>,
         initial_sizes: TableRow<f32>,
         resize_behavior: &TableRow<TableResizeBehavior>,
     ) -> TableRow<f32> {
-        // RESET:
-        // Part 1:
-        // Figure out if we should shrink/grow the selected column
-        // Get diff which represents the change in column we want to make initial size delta curr_size = diff
-        //
-        // Part 2: We need to decide which side column we should move and where
-        //
-        // If we want to grow our column we should check the left/right columns diff to see what side
-        // has a greater delta than their initial size. Likewise, if we shrink our column we should check
-        // the left/right column diffs to see what side has the smallest delta.
-        //
-        // Part 3: resize
-        //
-        // col_idx represents the column handle to the right of an active column
-        //
-        // If growing and right has the greater delta {
-        //    shift col_idx to the right
-        // } else if growing and left has the greater delta {
-        //  shift col_idx - 1 to the left
-        // } else if shrinking and the right has the greater delta {
-        //  shift
-        // } {
-        //
-        // }
-        // }
-        //
-        // if we need to shrink, then if the right
-        //
-
-        // DRAGGING
-        // we get diff which represents the change in the _drag handle_ position
-        // -diff => dragging left ->
-        //      grow the column to the right of the handle as much as we can shrink columns to the left of the handle
-        // +diff => dragging right -> growing handles column
-        //      grow the column to the left of the handle as much as we can shrink columns to the right of the handle
-        //
-
         let diff = initial_sizes[col_idx] - widths[col_idx];
 
         let left_diff =
@@ -376,10 +493,9 @@ impl TableColumnWidths {
         widths
     }
 
-    fn on_drag_move(
+    pub(crate) fn on_drag_move(
         &mut self,
         drag_event: &DragMoveEvent<DraggedColumn>,
-        resize_behavior: &TableRow<TableResizeBehavior>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -391,43 +507,42 @@ impl TableColumnWidths {
         let bounds_width = bounds.right() - bounds.left();
         let col_idx = drag_event.drag(cx).0;
 
-        let column_handle_width = Self::get_fraction(
-            &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_COLUMN_WIDTH))),
+        let divider_width = Self::get_fraction(
+            &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_DIVIDER_WIDTH))),
             bounds_width,
             rem_size,
         );
 
         let mut widths = self
-            .widths
+            .committed_widths
             .map_ref(|length| Self::get_fraction(length, bounds_width, rem_size));
 
         for length in widths[0..=col_idx].iter() {
-            col_position += length + column_handle_width;
+            col_position += length + divider_width;
         }
 
         let mut total_length_ratio = col_position;
         for length in widths[col_idx + 1..].iter() {
             total_length_ratio += length;
         }
-        let cols = resize_behavior.cols();
-        total_length_ratio += (cols - 1 - col_idx) as f32 * column_handle_width;
+        let cols = self.resize_behavior.cols();
+        total_length_ratio += (cols - 1 - col_idx) as f32 * divider_width;
 
         let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
         let drag_fraction = drag_fraction * total_length_ratio;
-        let diff = drag_fraction - col_position - column_handle_width / 2.0;
+        let diff = drag_fraction - col_position - divider_width / 2.0;
 
-        Self::drag_column_handle(diff, col_idx, &mut widths, resize_behavior);
+        Self::drag_column_handle(diff, col_idx, &mut widths, &self.resize_behavior);
 
-        self.visible_widths = widths.map(DefiniteLength::Fraction);
+        self.preview_widths = widths.map(DefiniteLength::Fraction);
     }
 
-    fn drag_column_handle(
+    pub(crate) fn drag_column_handle(
         diff: f32,
         col_idx: usize,
         widths: &mut TableRow<f32>,
         resize_behavior: &TableRow<TableResizeBehavior>,
     ) {
-        // if diff > 0.0 then go right
         if diff > 0.0 {
             Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1);
         } else {
@@ -435,7 +550,7 @@ impl TableColumnWidths {
         }
     }
 
-    fn propagate_resize_diff(
+    pub(crate) fn propagate_resize_diff(
         diff: f32,
         col_idx: usize,
         widths: &mut TableRow<f32>,
@@ -493,44 +608,16 @@ impl TableColumnWidths {
     }
 }
 
-pub struct TableWidths {
-    initial: TableRow<DefiniteLength>,
-    current: Option<Entity<TableColumnWidths>>,
-    resizable: TableRow<TableResizeBehavior>,
-}
-
-impl TableWidths {
-    pub fn new(widths: TableRow<impl Into<DefiniteLength>>) -> Self {
-        let widths = widths.map(Into::into);
-
-        let expected_length = widths.cols();
-        TableWidths {
-            initial: widths,
-            current: None,
-            resizable: vec![TableResizeBehavior::None; expected_length]
-                .into_table_row(expected_length),
-        }
-    }
-
-    fn lengths(&self, cx: &App) -> TableRow<Length> {
-        self.current
-            .as_ref()
-            .map(|entity| entity.read(cx).visible_widths.map_cloned(Length::Definite))
-            .unwrap_or_else(|| self.initial.map_cloned(Length::Definite))
-    }
-}
-
 /// A table component
 #[derive(RegisterComponent, IntoElement)]
 pub struct Table {
     striped: bool,
     show_row_borders: bool,
     show_row_hover: bool,
-    width: Option<Length>,
     headers: Option<TableRow<AnyElement>>,
     rows: TableContents,
     interaction_state: Option<WeakEntity<TableInteractionState>>,
-    col_widths: Option<TableWidths>,
+    column_width_config: ColumnWidthConfig,
     map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
     use_ui_font: bool,
     empty_table_callback: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
@@ -547,15 +634,14 @@ impl Table {
             striped: false,
             show_row_borders: true,
             show_row_hover: true,
-            width: None,
             headers: None,
             rows: TableContents::Vec(Vec::new()),
             interaction_state: None,
             map_row: None,
             use_ui_font: true,
             empty_table_callback: None,
-            col_widths: None,
             disable_base_cell_style: false,
+            column_width_config: ColumnWidthConfig::auto(),
         }
     }
 
@@ -626,10 +712,18 @@ impl Table {
         self
     }
 
-    /// Sets the width of the table.
-    /// Will enable horizontal scrolling if [`Self::interactable`] is also called.
-    pub fn width(mut self, width: impl Into<Length>) -> Self {
-        self.width = Some(width.into());
+    /// Sets a fixed table width with auto column widths.
+    ///
+    /// This is a shorthand for `.width_config(ColumnWidthConfig::auto_with_table_width(width))`.
+    /// For resizable columns or explicit column widths, use [`Table::width_config`] directly.
+    pub fn width(mut self, width: impl Into<DefiniteLength>) -> Self {
+        self.column_width_config = ColumnWidthConfig::auto_with_table_width(width);
+        self
+    }
+
+    /// Sets the column width configuration for the table.
+    pub fn width_config(mut self, config: ColumnWidthConfig) -> Self {
+        self.column_width_config = config;
         self
     }
 
@@ -637,10 +731,8 @@ impl Table {
     ///
     /// Vertical scrolling will be enabled by default if the table is taller than its container.
     ///
-    /// Horizontal scrolling will only be enabled if [`Self::width`] is also called, otherwise
-    /// the list will always shrink the table columns to fit their contents I.e. If [`Self::uniform_list`]
-    /// is used without a width and with [`Self::interactable`], the [`ListHorizontalSizingBehavior`] will
-    /// be set to [`ListHorizontalSizingBehavior::FitList`].
+    /// Horizontal scrolling will only be enabled if a table width is set via [`ColumnWidthConfig`],
+    /// otherwise the list will always shrink the table columns to fit their contents.
     pub fn interactable(mut self, interaction_state: &Entity<TableInteractionState>) -> Self {
         self.interaction_state = Some(interaction_state.downgrade());
         self
@@ -666,36 +758,6 @@ impl Table {
         self
     }
 
-    pub fn column_widths(mut self, widths: UncheckedTableRow<impl Into<DefiniteLength>>) -> Self {
-        if self.col_widths.is_none() {
-            self.col_widths = Some(TableWidths::new(widths.into_table_row(self.cols)));
-        }
-        self
-    }
-
-    pub fn resizable_columns(
-        mut self,
-        resizable: UncheckedTableRow<TableResizeBehavior>,
-        column_widths: &Entity<TableColumnWidths>,
-        cx: &mut App,
-    ) -> Self {
-        if let Some(table_widths) = self.col_widths.as_mut() {
-            table_widths.resizable = resizable.into_table_row(self.cols);
-            let column_widths = table_widths
-                .current
-                .get_or_insert_with(|| column_widths.clone());
-
-            column_widths.update(cx, |widths, _| {
-                if !widths.initialized {
-                    widths.initialized = true;
-                    widths.widths = table_widths.initial.clone();
-                    widths.visible_widths = widths.widths.clone();
-                }
-            })
-        }
-        self
-    }
-
     pub fn no_ui_font(mut self) -> Self {
         self.use_ui_font = false;
         self
@@ -812,11 +874,7 @@ pub fn render_table_row(
 pub fn render_table_header(
     headers: TableRow<impl IntoElement>,
     table_context: TableRenderContext,
-    columns_widths: Option<(
-        WeakEntity<TableColumnWidths>,
-        TableRow<TableResizeBehavior>,
-        TableRow<DefiniteLength>,
-    )>,
+    resize_info: Option<HeaderResizeInfo>,
     entity_id: Option<EntityId>,
     cx: &mut App,
 ) -> impl IntoElement {
@@ -837,9 +895,7 @@ pub fn render_table_header(
         .flex()
         .flex_row()
         .items_center()
-        .justify_between()
         .w_full()
-        .p_2()
         .border_b_1()
         .border_color(cx.theme().colors().border)
         .children(
@@ -850,34 +906,33 @@ pub fn render_table_header(
                 .zip(column_widths.into_vec())
                 .map(|((header_idx, h), width)| {
                     base_cell_style_text(width, table_context.use_ui_font, cx)
+                        .px_1()
+                        .py_0p5()
                         .child(h)
                         .id(ElementId::NamedInteger(
                             shared_element_id.clone(),
                             header_idx as u64,
                         ))
-                        .when_some(
-                            columns_widths.as_ref().cloned(),
-                            |this, (column_widths, resizables, initial_sizes)| {
-                                if resizables[header_idx].is_resizable() {
-                                    this.on_click(move |event, window, cx| {
-                                        if event.click_count() > 1 {
-                                            column_widths
-                                                .update(cx, |column, _| {
-                                                    column.on_double_click(
-                                                        header_idx,
-                                                        &initial_sizes,
-                                                        &resizables,
-                                                        window,
-                                                    );
-                                                })
-                                                .ok();
-                                        }
-                                    })
-                                } else {
-                                    this
-                                }
-                            },
-                        )
+                        .when_some(resize_info.as_ref().cloned(), |this, info| {
+                            if info.resize_behavior[header_idx].is_resizable() {
+                                this.on_click(move |event, window, cx| {
+                                    if event.click_count() > 1 {
+                                        info.columns_state
+                                            .update(cx, |column, _| {
+                                                column.on_double_click(
+                                                    header_idx,
+                                                    &info.initial_widths,
+                                                    &info.resize_behavior,
+                                                    window,
+                                                );
+                                            })
+                                            .ok();
+                                    }
+                                })
+                            } else {
+                                this
+                            }
+                        })
                 }),
         )
 }
@@ -901,7 +956,7 @@ impl TableRenderContext {
             show_row_borders: table.show_row_borders,
             show_row_hover: table.show_row_hover,
             total_row_count: table.rows.len(),
-            column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)),
+            column_widths: table.column_width_config.widths_to_render(cx),
             map_row: table.map_row.clone(),
             use_ui_font: table.use_ui_font,
             disable_base_cell_style: table.disable_base_cell_style,
@@ -913,48 +968,52 @@ impl RenderOnce for Table {
     fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
         let table_context = TableRenderContext::new(&self, cx);
         let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
-        let current_widths = self
-            .col_widths
-            .as_ref()
-            .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable.clone())))
-            .map(|(curr, resize_behavior)| (curr.downgrade(), resize_behavior));
 
-        let current_widths_with_initial_sizes = self
-            .col_widths
+        let header_resize_info = interaction_state
             .as_ref()
-            .and_then(|widths| {
-                Some((
-                    widths.current.as_ref()?,
-                    widths.resizable.clone(),
-                    widths.initial.clone(),
-                ))
-            })
-            .map(|(curr, resize_behavior, initial)| (curr.downgrade(), resize_behavior, initial));
+            .and_then(|_| self.column_width_config.header_resize_info(cx));
 
-        let width = self.width;
+        let table_width = self.column_width_config.table_width();
+        let horizontal_sizing = self.column_width_config.list_horizontal_sizing();
         let no_rows_rendered = self.rows.is_empty();
 
+        // Extract redistributable entity for drag/drop/prepaint handlers
+        let redistributable_entity =
+            interaction_state
+                .as_ref()
+                .and_then(|_| match &self.column_width_config {
+                    ColumnWidthConfig::Redistributable {
+                        columns_state: entity,
+                        ..
+                    } => Some(entity.downgrade()),
+                    _ => None,
+                });
+
+        let resize_handles = interaction_state
+            .as_ref()
+            .and_then(|_| self.column_width_config.render_resize_handles(window, cx));
+
         let table = div()
-            .when_some(width, |this, width| this.w(width))
+            .when_some(table_width, |this, width| this.w(width))
             .h_full()
             .v_flex()
             .when_some(self.headers.take(), |this, headers| {
                 this.child(render_table_header(
                     headers,
                     table_context.clone(),
-                    current_widths_with_initial_sizes,
+                    header_resize_info,
                     interaction_state.as_ref().map(Entity::entity_id),
                     cx,
                 ))
             })
-            .when_some(current_widths, {
-                |this, (widths, resize_behavior)| {
+            .when_some(redistributable_entity, {
+                |this, widths| {
                     this.on_drag_move::<DraggedColumn>({
                         let widths = widths.clone();
                         move |e, window, cx| {
                             widths
                                 .update(cx, |widths, cx| {
-                                    widths.on_drag_move(e, &resize_behavior, window, cx);
+                                    widths.on_drag_move(e, window, cx);
                                 })
                                 .ok();
                         }
@@ -965,7 +1024,7 @@ impl RenderOnce for Table {
                             widths
                                 .update(cx, |widths, _| {
                                     // This works because all children x axis bounds are the same
-                                    widths.cached_bounds_width =
+                                    widths.cached_table_width =
                                         bounds[0].right() - bounds[0].left();
                                 })
                                 .ok();
@@ -974,10 +1033,9 @@ impl RenderOnce for Table {
                     .on_drop::<DraggedColumn>(move |_, _, cx| {
                         widths
                             .update(cx, |widths, _| {
-                                widths.widths = widths.visible_widths.clone();
+                                widths.committed_widths = widths.preview_widths.clone();
                             })
                             .ok();
-                        // Finish the resize operation
                     })
                 }
             })
@@ -1029,11 +1087,7 @@ impl RenderOnce for Table {
                             .size_full()
                             .flex_grow()
                             .with_sizing_behavior(ListSizingBehavior::Auto)
-                            .with_horizontal_sizing_behavior(if width.is_some() {
-                                ListHorizontalSizingBehavior::Unconstrained
-                            } else {
-                                ListHorizontalSizingBehavior::FitList
-                            })
+                            .with_horizontal_sizing_behavior(horizontal_sizing)
                             .when_some(
                                 interaction_state.as_ref(),
                                 |this, state| {
@@ -1063,25 +1117,7 @@ impl RenderOnce for Table {
                             .with_sizing_behavior(ListSizingBehavior::Auto),
                         ),
                     })
-                    .when_some(
-                        self.col_widths.as_ref().zip(interaction_state.as_ref()),
-                        |parent, (table_widths, state)| {
-                            parent.child(state.update(cx, |state, cx| {
-                                let resizable_columns = &table_widths.resizable;
-                                let column_widths = table_widths.lengths(cx);
-                                let columns = table_widths.current.clone();
-                                let initial_sizes = &table_widths.initial;
-                                state.render_resize_handles(
-                                    &column_widths,
-                                    resizable_columns,
-                                    initial_sizes,
-                                    columns,
-                                    window,
-                                    cx,
-                                )
-                            }))
-                        },
-                    );
+                    .when_some(resize_handles, |parent, handles| parent.child(handles));
 
                 if let Some(state) = interaction_state.as_ref() {
                     let scrollbars = state

crates/ui/src/components/data_table/tests.rs 🔗

@@ -82,7 +82,7 @@ mod reset_column_size {
         let cols = initial_sizes.len();
         let resize_behavior_vec = parse_resize_behavior(resize_behavior, total_1, cols);
         let resize_behavior = TableRow::from_vec(resize_behavior_vec, cols);
-        let result = TableColumnWidths::reset_to_initial_size(
+        let result = RedistributableColumnsState::reset_to_initial_size(
             column_index,
             TableRow::from_vec(widths, cols),
             TableRow::from_vec(initial_sizes, cols),
@@ -259,7 +259,7 @@ mod drag_handle {
         let distance = distance as f32 / total_1;
 
         let mut widths_table_row = TableRow::from_vec(widths, cols);
-        TableColumnWidths::drag_column_handle(
+        RedistributableColumnsState::drag_column_handle(
             distance,
             column_index,
             &mut widths_table_row,

crates/ui/src/components/list/list_item.rs 🔗

@@ -14,6 +14,14 @@ pub enum ListItemSpacing {
     Sparse,
 }
 
+#[derive(Default)]
+enum EndSlotVisibility {
+    #[default]
+    Always,
+    OnHover,
+    SwapOnHover(AnyElement),
+}
+
 #[derive(IntoElement, RegisterComponent)]
 pub struct ListItem {
     id: ElementId,
@@ -28,9 +36,7 @@ pub struct ListItem {
     /// A slot for content that appears after the children, usually on the other side of the header.
     /// This might be a button, a disclosure arrow, a face pile, etc.
     end_slot: Option<AnyElement>,
-    /// A slot for content that appears on hover after the children
-    /// It will obscure the `end_slot` when visible.
-    end_hover_slot: Option<AnyElement>,
+    end_slot_visibility: EndSlotVisibility,
     toggle: Option<bool>,
     inset: bool,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
@@ -61,7 +67,7 @@ impl ListItem {
             indent_step_size: px(12.),
             start_slot: None,
             end_slot: None,
-            end_hover_slot: None,
+            end_slot_visibility: EndSlotVisibility::default(),
             toggle: None,
             inset: false,
             on_click: None,
@@ -165,8 +171,14 @@ impl ListItem {
         self
     }
 
-    pub fn end_hover_slot<E: IntoElement>(mut self, end_hover_slot: impl Into<Option<E>>) -> Self {
-        self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element);
+    pub fn end_slot_on_hover<E: IntoElement>(mut self, end_slot_on_hover: E) -> Self {
+        self.end_slot_visibility =
+            EndSlotVisibility::SwapOnHover(end_slot_on_hover.into_any_element());
+        self
+    }
+
+    pub fn show_end_slot_on_hover(mut self) -> Self {
+        self.end_slot_visibility = EndSlotVisibility::OnHover;
         self
     }
 
@@ -234,9 +246,9 @@ impl RenderOnce for ListItem {
                 this.ml(self.indent_level as f32 * self.indent_step_size)
                     .px(DynamicSpacing::Base04.rems(cx))
             })
-            .when(!self.inset && !self.disabled, |this| {
+            .when(!self.inset, |this| {
                 this.when_some(self.focused, |this, focused| {
-                    if focused {
+                    if focused && !self.disabled {
                         this.border_1()
                             .when(self.docked_right, |this| this.border_r_2())
                             .border_color(cx.theme().colors().border_focused)
@@ -244,7 +256,7 @@ impl RenderOnce for ListItem {
                         this.border_1()
                     }
                 })
-                .when(self.selectable, |this| {
+                .when(self.selectable && !self.disabled, |this| {
                     this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
                         .active(|style| style.bg(cx.theme().colors().ghost_element_active))
                         .when(self.outlined, |this| this.rounded_sm())
@@ -268,16 +280,16 @@ impl RenderOnce for ListItem {
                         ListItemSpacing::ExtraDense => this.py_neg_px(),
                         ListItemSpacing::Sparse => this.py_1(),
                     })
-                    .when(self.inset && !self.disabled, |this| {
+                    .when(self.inset, |this| {
                         this.when_some(self.focused, |this, focused| {
-                            if focused {
+                            if focused && !self.disabled {
                                 this.border_1()
                                     .border_color(cx.theme().colors().border_focused)
                             } else {
                                 this.border_1()
                             }
                         })
-                        .when(self.selectable, |this| {
+                        .when(self.selectable && !self.disabled, |this| {
                             this.hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
                                 .active(|style| style.bg(cx.theme().colors().ghost_element_active))
                                 .when(self.selected, |this| {
@@ -338,28 +350,31 @@ impl RenderOnce for ListItem {
                             .children(self.start_slot)
                             .children(self.children),
                     )
+                    .when(self.end_slot.is_some(), |this| this.justify_between())
                     .when_some(self.end_slot, |this, end_slot| {
-                        this.justify_between().child(
-                            h_flex()
+                        this.child(match self.end_slot_visibility {
+                            EndSlotVisibility::Always => {
+                                h_flex().flex_shrink().overflow_hidden().child(end_slot)
+                            }
+                            EndSlotVisibility::OnHover => h_flex()
                                 .flex_shrink()
                                 .overflow_hidden()
-                                .when(self.end_hover_slot.is_some(), |this| {
-                                    this.visible()
-                                        .group_hover("list_item", |this| this.invisible())
-                                })
-                                .child(end_slot),
-                        )
-                    })
-                    .when_some(self.end_hover_slot, |this, end_hover_slot| {
-                        this.child(
-                            h_flex()
-                                .h_full()
-                                .absolute()
-                                .right(DynamicSpacing::Base06.rems(cx))
-                                .top_0()
                                 .visible_on_hover("list_item")
-                                .child(end_hover_slot),
-                        )
+                                .child(end_slot),
+                            EndSlotVisibility::SwapOnHover(hover_slot) => h_flex()
+                                .relative()
+                                .flex_shrink()
+                                .child(h_flex().visible_on_hover("list_item").child(hover_slot))
+                                .child(
+                                    h_flex()
+                                        .absolute()
+                                        .inset_0()
+                                        .justify_end()
+                                        .overflow_hidden()
+                                        .group_hover("list_item", |this| this.invisible())
+                                        .child(end_slot),
+                                ),
+                        })
                     }),
             )
     }

crates/vim/src/helix.rs 🔗

@@ -648,6 +648,7 @@ impl Vim {
                     self.search = SearchState {
                         direction: searchable::Direction::Next,
                         count: 1,
+                        cmd_f_search: false,
                         prior_selections,
                         prior_operator: self.operator_stack.last().cloned(),
                         prior_mode: self.mode,

crates/vim/src/normal/search.rs 🔗

@@ -284,6 +284,7 @@ impl Vim {
         self.search = SearchState {
             direction,
             count,
+            cmd_f_search: false,
             prior_selections,
             prior_operator: self.operator_stack.last().cloned(),
             prior_mode,
@@ -298,6 +299,7 @@ impl Vim {
         let current_mode = self.mode;
         self.search = Default::default();
         self.search.prior_mode = current_mode;
+        self.search.cmd_f_search = true;
         cx.propagate();
     }
 
@@ -957,6 +959,45 @@ mod test {
         cx.assert_editor_state("«oneˇ» one one one");
     }
 
+    #[gpui::test]
+    async fn test_non_vim_search_in_vim_mode(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.cx.set_state("ˇone one one one");
+        cx.run_until_parked();
+        cx.simulate_keystrokes("cmd-f");
+        cx.run_until_parked();
+
+        cx.assert_state("«oneˇ» one one one", Mode::Visual);
+        cx.simulate_keystrokes("enter");
+        cx.run_until_parked();
+        cx.assert_state("one «oneˇ» one one", Mode::Visual);
+        cx.simulate_keystrokes("shift-enter");
+        cx.run_until_parked();
+        cx.assert_state("«oneˇ» one one one", Mode::Visual);
+
+        cx.simulate_keystrokes("escape");
+        cx.run_until_parked();
+        cx.assert_state("«oneˇ» one one one", Mode::Visual);
+    }
+
+    #[gpui::test]
+    async fn test_non_vim_search_in_vim_insert_mode(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.set_state("ˇone one one one", Mode::Insert);
+        cx.run_until_parked();
+        cx.simulate_keystrokes("cmd-f");
+        cx.run_until_parked();
+
+        cx.assert_state("«oneˇ» one one one", Mode::Insert);
+        cx.simulate_keystrokes("enter");
+        cx.run_until_parked();
+        cx.assert_state("one «oneˇ» one one", Mode::Insert);
+
+        cx.simulate_keystrokes("escape");
+        cx.run_until_parked();
+        cx.assert_state("one «oneˇ» one one", Mode::Insert);
+    }
+
     #[gpui::test]
     async fn test_visual_star_hash(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;

crates/vim/src/state.rs 🔗

@@ -1022,6 +1022,7 @@ impl Clone for ReplayableAction {
 pub struct SearchState {
     pub direction: Direction,
     pub count: usize,
+    pub cmd_f_search: bool,
 
     pub prior_selections: Vec<Range<Anchor>>,
     pub prior_operator: Option<Operator>,

crates/vim/src/vim.rs 🔗

@@ -432,8 +432,12 @@ pub fn init(cx: &mut App) {
                 .and_then(|item| item.act_as::<Editor>(cx))
                 .and_then(|editor| editor.read(cx).addon::<VimAddon>().cloned());
             let Some(vim) = vim else { return };
-            vim.entity.update(cx, |_, cx| {
-                cx.defer_in(window, |vim, window, cx| vim.search_submit(window, cx))
+            vim.entity.update(cx, |vim, cx| {
+                if !vim.search.cmd_f_search {
+                    cx.defer_in(window, |vim, window, cx| vim.search_submit(window, cx))
+                } else {
+                    cx.propagate()
+                }
             })
         });
         workspace.register_action(|_, _: &GoToTab, window, cx| {
@@ -2086,7 +2090,7 @@ impl Vim {
         VimEditorSettingsState {
             cursor_shape: self.cursor_shape(cx),
             clip_at_line_ends: self.clip_at_line_ends(),
-            collapse_matches: !HelixModeSetting::get_global(cx).0,
+            collapse_matches: !HelixModeSetting::get_global(cx).0 && !self.search.cmd_f_search,
             input_enabled: self.editor_input_enabled(),
             expects_character_input: self.expects_character_input(),
             autoindent: self.should_autoindent(),

crates/workspace/src/multi_workspace.rs 🔗

@@ -1113,178 +1113,3 @@ impl Render for MultiWorkspace {
         )
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use fs::FakeFs;
-    use gpui::TestAppContext;
-    use settings::SettingsStore;
-
-    fn init_test(cx: &mut TestAppContext) {
-        cx.update(|cx| {
-            let settings_store = SettingsStore::test(cx);
-            cx.set_global(settings_store);
-            theme_settings::init(theme::LoadThemes::JustBase, cx);
-            DisableAiSettings::register(cx);
-            cx.update_flags(false, vec!["agent-v2".into()]);
-        });
-    }
-
-    #[gpui::test]
-    async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
-        init_test(cx);
-        let fs = FakeFs::new(cx.executor());
-        let project = Project::test(fs, [], cx).await;
-
-        let (multi_workspace, cx) =
-            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
-
-        multi_workspace.read_with(cx, |mw, cx| {
-            assert!(mw.multi_workspace_enabled(cx));
-        });
-
-        multi_workspace.update_in(cx, |mw, _window, cx| {
-            mw.open_sidebar(cx);
-            assert!(mw.sidebar_open());
-        });
-
-        cx.update(|_window, cx| {
-            DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
-        });
-        cx.run_until_parked();
-
-        multi_workspace.read_with(cx, |mw, cx| {
-            assert!(
-                !mw.sidebar_open(),
-                "Sidebar should be closed when disable_ai is true"
-            );
-            assert!(
-                !mw.multi_workspace_enabled(cx),
-                "Multi-workspace should be disabled when disable_ai is true"
-            );
-        });
-
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.toggle_sidebar(window, cx);
-        });
-        multi_workspace.read_with(cx, |mw, _cx| {
-            assert!(
-                !mw.sidebar_open(),
-                "Sidebar should remain closed when toggled with disable_ai true"
-            );
-        });
-
-        cx.update(|_window, cx| {
-            DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
-        });
-        cx.run_until_parked();
-
-        multi_workspace.read_with(cx, |mw, cx| {
-            assert!(
-                mw.multi_workspace_enabled(cx),
-                "Multi-workspace should be enabled after re-enabling AI"
-            );
-            assert!(
-                !mw.sidebar_open(),
-                "Sidebar should still be closed after re-enabling AI (not auto-opened)"
-            );
-        });
-
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.toggle_sidebar(window, cx);
-        });
-        multi_workspace.read_with(cx, |mw, _cx| {
-            assert!(
-                mw.sidebar_open(),
-                "Sidebar should open when toggled after re-enabling AI"
-            );
-        });
-    }
-
-    #[gpui::test]
-    async fn test_replace(cx: &mut TestAppContext) {
-        init_test(cx);
-        let fs = FakeFs::new(cx.executor());
-        let project_a = Project::test(fs.clone(), [], cx).await;
-        let project_b = Project::test(fs.clone(), [], cx).await;
-        let project_c = Project::test(fs.clone(), [], cx).await;
-        let project_d = Project::test(fs.clone(), [], cx).await;
-
-        let (multi_workspace, cx) = cx
-            .add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
-
-        let workspace_a_id =
-            multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id());
-
-        // Replace the only workspace (single-workspace case).
-        let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
-            let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx));
-            mw.replace(workspace.clone(), &*window, cx);
-            workspace
-        });
-
-        multi_workspace.read_with(cx, |mw, _cx| {
-            assert_eq!(mw.workspaces().len(), 1);
-            assert_eq!(
-                mw.workspaces()[0].entity_id(),
-                workspace_b.entity_id(),
-                "slot should now be project_b"
-            );
-            assert_ne!(
-                mw.workspaces()[0].entity_id(),
-                workspace_a_id,
-                "project_a should be gone"
-            );
-        });
-
-        // Add project_c as a second workspace, then replace it with project_d.
-        let workspace_c = multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.test_add_workspace(project_c.clone(), window, cx)
-        });
-
-        multi_workspace.read_with(cx, |mw, _cx| {
-            assert_eq!(mw.workspaces().len(), 2);
-            assert_eq!(mw.active_workspace_index(), 1);
-        });
-
-        let workspace_d = multi_workspace.update_in(cx, |mw, window, cx| {
-            let workspace = cx.new(|cx| Workspace::test_new(project_d.clone(), window, cx));
-            mw.replace(workspace.clone(), &*window, cx);
-            workspace
-        });
-
-        multi_workspace.read_with(cx, |mw, _cx| {
-            assert_eq!(mw.workspaces().len(), 2, "should still have 2 workspaces");
-            assert_eq!(mw.active_workspace_index(), 1);
-            assert_eq!(
-                mw.workspaces()[1].entity_id(),
-                workspace_d.entity_id(),
-                "active slot should now be project_d"
-            );
-            assert_ne!(
-                mw.workspaces()[1].entity_id(),
-                workspace_c.entity_id(),
-                "project_c should be gone"
-            );
-        });
-
-        // Replace with workspace_b which is already in the list — should just switch.
-        multi_workspace.update_in(cx, |mw, window, cx| {
-            mw.replace(workspace_b.clone(), &*window, cx);
-        });
-
-        multi_workspace.read_with(cx, |mw, _cx| {
-            assert_eq!(
-                mw.workspaces().len(),
-                2,
-                "no workspace should be added or removed"
-            );
-            assert_eq!(
-                mw.active_workspace_index(),
-                0,
-                "should have switched to workspace_b"
-            );
-        });
-    }
-}

crates/workspace/src/multi_workspace_tests.rs 🔗

@@ -0,0 +1,172 @@
+use super::*;
+use feature_flags::FeatureFlagAppExt;
+use fs::FakeFs;
+use gpui::TestAppContext;
+use project::DisableAiSettings;
+use settings::SettingsStore;
+
+fn init_test(cx: &mut TestAppContext) {
+    cx.update(|cx| {
+        let settings_store = SettingsStore::test(cx);
+        cx.set_global(settings_store);
+        theme_settings::init(theme::LoadThemes::JustBase, cx);
+        DisableAiSettings::register(cx);
+        cx.update_flags(false, vec!["agent-v2".into()]);
+    });
+}
+
+#[gpui::test]
+async fn test_sidebar_disabled_when_disable_ai_is_enabled(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    let project = Project::test(fs, [], cx).await;
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx));
+
+    multi_workspace.read_with(cx, |mw, cx| {
+        assert!(mw.multi_workspace_enabled(cx));
+    });
+
+    multi_workspace.update_in(cx, |mw, _window, cx| {
+        mw.open_sidebar(cx);
+        assert!(mw.sidebar_open());
+    });
+
+    cx.update(|_window, cx| {
+        DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx);
+    });
+    cx.run_until_parked();
+
+    multi_workspace.read_with(cx, |mw, cx| {
+        assert!(
+            !mw.sidebar_open(),
+            "Sidebar should be closed when disable_ai is true"
+        );
+        assert!(
+            !mw.multi_workspace_enabled(cx),
+            "Multi-workspace should be disabled when disable_ai is true"
+        );
+    });
+
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.toggle_sidebar(window, cx);
+    });
+    multi_workspace.read_with(cx, |mw, _cx| {
+        assert!(
+            !mw.sidebar_open(),
+            "Sidebar should remain closed when toggled with disable_ai true"
+        );
+    });
+
+    cx.update(|_window, cx| {
+        DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx);
+    });
+    cx.run_until_parked();
+
+    multi_workspace.read_with(cx, |mw, cx| {
+        assert!(
+            mw.multi_workspace_enabled(cx),
+            "Multi-workspace should be enabled after re-enabling AI"
+        );
+        assert!(
+            !mw.sidebar_open(),
+            "Sidebar should still be closed after re-enabling AI (not auto-opened)"
+        );
+    });
+
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.toggle_sidebar(window, cx);
+    });
+    multi_workspace.read_with(cx, |mw, _cx| {
+        assert!(
+            mw.sidebar_open(),
+            "Sidebar should open when toggled after re-enabling AI"
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_replace(cx: &mut TestAppContext) {
+    init_test(cx);
+    let fs = FakeFs::new(cx.executor());
+    let project_a = Project::test(fs.clone(), [], cx).await;
+    let project_b = Project::test(fs.clone(), [], cx).await;
+    let project_c = Project::test(fs.clone(), [], cx).await;
+    let project_d = Project::test(fs.clone(), [], cx).await;
+
+    let (multi_workspace, cx) =
+        cx.add_window_view(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx));
+
+    let workspace_a_id = multi_workspace.read_with(cx, |mw, _cx| mw.workspaces()[0].entity_id());
+
+    // Replace the only workspace (single-workspace case).
+    let workspace_b = multi_workspace.update_in(cx, |mw, window, cx| {
+        let workspace = cx.new(|cx| Workspace::test_new(project_b.clone(), window, cx));
+        mw.replace(workspace.clone(), &*window, cx);
+        workspace
+    });
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        assert_eq!(mw.workspaces().len(), 1);
+        assert_eq!(
+            mw.workspaces()[0].entity_id(),
+            workspace_b.entity_id(),
+            "slot should now be project_b"
+        );
+        assert_ne!(
+            mw.workspaces()[0].entity_id(),
+            workspace_a_id,
+            "project_a should be gone"
+        );
+    });
+
+    // Add project_c as a second workspace, then replace it with project_d.
+    let workspace_c = multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.test_add_workspace(project_c.clone(), window, cx)
+    });
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        assert_eq!(mw.workspaces().len(), 2);
+        assert_eq!(mw.active_workspace_index(), 1);
+    });
+
+    let workspace_d = multi_workspace.update_in(cx, |mw, window, cx| {
+        let workspace = cx.new(|cx| Workspace::test_new(project_d.clone(), window, cx));
+        mw.replace(workspace.clone(), &*window, cx);
+        workspace
+    });
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        assert_eq!(mw.workspaces().len(), 2, "should still have 2 workspaces");
+        assert_eq!(mw.active_workspace_index(), 1);
+        assert_eq!(
+            mw.workspaces()[1].entity_id(),
+            workspace_d.entity_id(),
+            "active slot should now be project_d"
+        );
+        assert_ne!(
+            mw.workspaces()[1].entity_id(),
+            workspace_c.entity_id(),
+            "project_c should be gone"
+        );
+    });
+
+    // Replace with workspace_b which is already in the list — should just switch.
+    multi_workspace.update_in(cx, |mw, window, cx| {
+        mw.replace(workspace_b.clone(), &*window, cx);
+    });
+
+    multi_workspace.read_with(cx, |mw, _cx| {
+        assert_eq!(
+            mw.workspaces().len(),
+            2,
+            "no workspace should be added or removed"
+        );
+        assert_eq!(
+            mw.active_workspace_index(),
+            0,
+            "should have switched to workspace_b"
+        );
+    });
+}

crates/workspace/src/notifications.rs 🔗

@@ -5,7 +5,7 @@ use gpui::{
     DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle,
     Task, TextStyleRefinement, UnderlineStyle, WeakEntity, svg,
 };
-use markdown::{Markdown, MarkdownElement, MarkdownStyle};
+use markdown::{CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle};
 use parking_lot::Mutex;
 use project::project_settings::ProjectSettings;
 use settings::Settings;
@@ -401,8 +401,7 @@ impl Render for LanguageServerPrompt {
                         MarkdownElement::new(self.markdown.clone(), markdown_style(window, cx))
                             .text_size(TextSize::Small.rems(cx))
                             .code_block_renderer(markdown::CodeBlockRenderer::Default {
-                                copy_button: false,
-                                copy_button_on_hover: false,
+                                copy_button_visibility: CopyButtonVisibility::Hidden,
                                 border: false,
                             })
                             .on_url_click(|link, _, cx| cx.open_url(&link)),

crates/workspace/src/workspace.rs 🔗

@@ -5,6 +5,8 @@ pub mod invalid_item_view;
 pub mod item;
 mod modal_layer;
 mod multi_workspace;
+#[cfg(test)]
+mod multi_workspace_tests;
 pub mod notifications;
 pub mod pane;
 pub mod pane_group;

crates/zed/src/zed/telemetry_log.rs 🔗

@@ -12,7 +12,7 @@ use gpui::{
     StyleRefinement, Task, TextStyleRefinement, Window, list, prelude::*,
 };
 use language::LanguageRegistry;
-use markdown::{CodeBlockRenderer, Markdown, MarkdownElement, MarkdownStyle};
+use markdown::{CodeBlockRenderer, CopyButtonVisibility, Markdown, MarkdownElement, MarkdownStyle};
 use project::Project;
 use settings::Settings;
 use telemetry_events::{Event, EventWrapper};
@@ -424,8 +424,11 @@ impl TelemetryLogView {
                             },
                         )
                         .code_block_renderer(CodeBlockRenderer::Default {
-                            copy_button: false,
-                            copy_button_on_hover: expanded,
+                            copy_button_visibility: if expanded {
+                                CopyButtonVisibility::VisibleOnHover
+                            } else {
+                                CopyButtonVisibility::Hidden
+                            },
                             border: false,
                         }),
                     ),

crates/zed_actions/src/lib.rs 🔗

@@ -450,8 +450,6 @@ pub mod agent {
             OpenOnboardingModal,
             /// Opens the ACP onboarding modal.
             OpenAcpOnboardingModal,
-            /// Opens the Claude Agent onboarding modal.
-            OpenClaudeAgentOnboardingModal,
             /// Resets the agent onboarding state.
             ResetOnboarding,
             /// Starts a chat conversation with the agent.